《Debug Hacks》和除錯技巧
Debug Hacks
作者為吉岡弘隆、大和一洋、大巖尚巨集、安部東洋、吉田俊輔,有中文版《Debug Hacks中文版—深入除錯的技術和工具》。這本書涉及了很多除錯技巧,對偵錯程式使用、核心除錯方法、常見錯誤的原因,還介紹了systemtap
、strace
、ltrace
等一大堆工具,非常值得一讀。
話說我聽說過的各程式設計課程似乎都沒有強調過除錯的重要性,把除錯當作單獨一節課來上(就算有估計也上不好),很多人都只會printf
除錯法,breakpoint都很少用,就不提conditional breakpoint、watchpoint、reverse execution之類的了。也看到過很多同學在除錯上浪費了很長很長的時間。
下面是篇review,也包含了一些我自己整理的一些除錯技巧。
折騰工具
繼續牢騷幾句,我接觸過的人當中感覺最執著與折騰工具的人只有兩個,ppwwyyxx和xiaq,他們是少有的能把折騰工具當作正經工作來做的人。
很久以前我還會到處在網上搜索好的實用工具,尤其是那些CLI程式,比如renameutils
、xsel
、recode
、the_silver_searcher
,查閱文件定製自己的配置檔案。但這麼做花費的時間太多。後來就想我可以搜尋一些善於折騰的人的配置檔案,關注他們修改了哪些地方,我的配置只要取眾家之所長就可以了。
先厚顏自薦一下我的配置。下面的使用者列表就是我找到的在GitHub上把dotfiles
1 | alejandrogomez bhj craigbarnes dotvim hamaco joedicastro laurentb ok100 pyx roylez sjl trapd00r vodik w0ng |
有了上述的dotfiles
,其他人的dotfiles
大多都不願看了。但是五嶽歸來不看山,黃山歸來不看嶽,ppwwyyxx
的dotfiles感覺與之前諸位相比更勝一籌。
無關的話到此結束,下面是正文:
gdb
記錄歷史
把下面幾行新增到~/.gdbinit
中吧,gdb
啟動時會自動讀取裡面的命令並執行:
123 | set history save onset history size 10000set history filename ~/.history/gdb |
我習慣在~/.history
堆放各個歷史檔案。有了歷史,使用readline
的reverse-search-history (C-r)
就能輕鬆喚起之前輸入過的命令。
修改任意記憶體地址的值
1 | set {int}0x83040 = 4 |
顯示intel風格的彙編指令
1 | set disassembly-flavor intel |
斷點在function prologue前
先說一下function prologue吧,每個函式最前面一般有三四行指令用來儲存舊的幀指標(rbp),並騰出一部分棧空間(通常用於儲存區域性變數、為當前函式呼叫其他函式騰出空間存放參數,有時候還會儲存字面字串,當有nested function時也會用於儲存當前的棧指標)。
在x86-64環境下典型的funcition prologue長成這樣:
123 | push rbpmov rbp, rspsub rsp, 0x10 |
可能還會有and
指令用於對齊rsp
。如果編譯時加上-fomit-frame-pointer
(Visual Studio中文版似乎譯作“省略框架指標”),那麼生成的指令就會避免使用rbp
,function prologue就會簡化成下面一行:
1 | sub rsp, 0x10 |
設定斷點時如果使用了b *func
的格式,也就是說在函式名前加上*
,gdb
就會在執行function prologue前停下,而b func
則是在執行function prologue後停下。參考下面的會話:
1234567891011121314151617181920212223242526 | % gdb a.outReading symbols from /tmp/a.out...done.(gdb) b *mainBreakpoint 1 at 0x4005cc: file a.c, line 4.(gdb) rStarting program: /tmp/a.out warning: Could not load shared library symbols for linux-vdso.so.1.Do you need "set solib-search-path" or "set sysroot"?Breakpoint 1, main () at a.c:44 {(gdb) disasDump of assembler code for function main:=> 0x00000000004005cc <+0>: push rbp 0x00000000004005cd <+1>: mov rbp,rsp 0x00000000004005d0 <+4>: sub rsp,0x10 0x00000000004005d4 <+8>: mov DWORD PTR [rbp-0x4],0x0 0x00000000004005db <+15>: mov eax,DWORD PTR [rbp-0x4] 0x00000000004005de <+18>: mov esi,eax 0x00000000004005e0 <+20>: mov edi,0x4006ec 0x00000000004005e5 <+25>: mov eax,0x0 0x00000000004005ea <+30>: call 0x400454 <[email protected]> 0x00000000004005ef <+35>: leave 0x00000000004005f0 <+36>: ret End of assembler dump.(gdb) |
Checkpoint
gdb
可以為被除錯的程式建立一個快照,即儲存程式執行時的狀態,等待以後恢復。這個是非常方便的一個功能,特別適合需要探測接下來會發生什麼但又不想離開當前狀態時使用。
ch
是建立快照,d c ID
是刪除指定編號的快照,i ch
是檢視所有快照,restart ID
是切換到指定編號的快照,詳細說明可以在shell裡鍵入info '(gdb) Checkpoint/Restart'
檢視。
12345678910111213141516171819202122232425262728 | % gdb ./a.outReading symbols from /tmp/a.out...done.(gdb) b 6Breakpoint 1 at 0x4005db: file a.c, line 6.(gdb) rStarting program: /tmp/a.out warning: Could not load shared library symbols for linux-vdso.so.1.Do you need "set solib-search-path" or "set sysroot"?Breakpoint 1, main () at a.c:66 printf("%d\n", a);(gdb) chcheckpoint: fork returned pid 6420.(gdb) p a=3$1 = 3(gdb) i ch 1 process 6420 at 0x4005db, file a.c, line 6* 0 process 6416 (main process) at 0x4005db, file a.c, line 6(gdb) restart 1Switching to process 6420#0 main () at a.c:66 printf("%d\n", a);(gdb) cContinuing.0[Inferior 1 (process 6420) exited with code 02][Switching to process 6416](gdb) |
上面的會話中先用ch
建立了一個快照,緊接著a
被修改為了3,隨後用restart 1
恢復到編號為1的快照,繼續執行程式可以發現a
仍然為原來的值0。
逆向技術
gcc
Mudflap
使用了compile-time instrumentation(CTI)的工具。編譯時加上-fmudflap -lmudflap
選項即可,會在很多不安全程式碼生成的指令前加上判斷合法性的指令。
12345678910111213 | % echo 'int main() { int z[1]; z[1] = 2; }' | cc -xc - -fmudflap -lmudflap% ./a.out*******mudflap violation 1 (check/write): time=1376473424.792953 ptr=0x7fff2cde3150 size=8pc=0x7fa2bacf86f1 location=`<stdin>:1:29 (main)' /usr/lib/gcc/x86_64-pc-linux-gnu/4.7.3/libmudflap.so.0(__mf_check+0x41) [0x7fa2bacf86f1] ./a.out(main+0x8f) [0x400b6b] /lib64/libc.so.6(__libc_start_main+0xf5) [0x7fa2ba968c35]Nearby object 1: checked region begins 0B into and ends 4B aftermudflap object 0x7070e0: name=`<stdin>:1:18 (main) z'bounds=[0x7fff2cde3150,0x7fff2cde3153] size=4 area=stack check=0r/3w liveness=3alloc time=1376473424.792946 pc=0x7fa2bacf7de1number of nearby objects: 1 |
第一行用-xc -
讓cc
從標準輸入讀原始碼,並當作C來編譯。接來下執行./a.out
,可以看到執行時程式報錯了。
使用MUDFLAP_OPTIONS
環境變數可以控制Mudflap的執行期行為,具體參見Mudflap Pointer Debugging。
AddressSanitizer
和Mudflap類似的工具,clang
和gcc
可以加上選項-fsanitize=address
使用,比如:
1 | clang -fsanitize=address a.c |
如果想在出錯的地方斷點停下來,可以用gdb
開啟,輸入b __asan_report_store1
回車,再輸入r
回車執行程式。
-ftrapv
這個選項是除錯有符號整型溢位問題的利器。在i386環境下,gcc會把int32_t
運算編譯成call __addvsi3
,__addvsi3
函式會在執行時檢查32位有符號加法運算是否產生溢位,如果是則呼叫abort
函式中止程式。減法、乘法和取反運算也有類似的執行時函式檢查溢位,另外也有64位版本的__addvdi3
等函式。但不存在對無符號整型的溢位檢測函式。比如下面這些程式碼均會觸發trap:
1234 | int a = INT_MAX; a++;int b = INT_MIN; b--;int c = INT_MAX; c *= 2;int d = INT_MIN; d = -d; |
這段程式碼來自gcc
專案目錄的libgcc/libgcc2.c
:
1234567891011 | #ifdef L_subvsi3Wtype__subvSI3 (Wtype a, Wtype b){ const Wtype w = (UWtype) a - (UWtype) b; if (b >= 0 ? w > a : w < a) abort (); return w;} |
但注意在x86-64環境下-ftrapv
只檢查64位溢位。考慮下面這段程式碼:
12345678910111213 | #include <limits.h>#include <stdio.h>int main(){ int a = INT_MAX; a++; puts("barrier"); long b = LONG_MAX; b++;} |
在x86-64下用gcc
編譯執行,輸出barrier
後才會執行abort
使程式中止,因為int32_t
的溢位不會觸發trap。
clang
也有-ftrapv
,在x86-64環境下對於int32_t
的溢位也能觸發trap。
_FORTIFY_SOURCE
gets
、strcpy
這類函式容易造成stack mashing。gcc
編譯時如果指定了-D_FORTIFY_SOURCE=1
,生成的彙編程式中這些不安全的函式呼叫會被替代為libc.so
中名字類似__gets_chk
的一類安全函式,會在執行期檢查是否產生了緩衝區溢位。比如,下面的程式碼會在執行時報錯:
1234567 | #include <string.h>int main(){ char a[2]; strcpy(a, "meow");} |
Gentoo Portage從gcc-4.3.3-r1
開始預設開啟_FORTIFY_SOURCE
標誌了,好多發行版都開啟了,測試發現Arch Linux的gcc
似乎沒有。shell裡執行下面程式碼就可以看到Gentoo裡是怎麼定義_FORTIFY_SOURCE
的了:
1 | echo -e '#undef __OPTIMIZE__\nmain() { printf("%d\\n", _FORTIFY_SOURCE); }' | cpp |
也就是當優化等級在-O1
或以上時_FORTIFY_SOURCE
會生效,名字為__$func_chk
模式的函式會被使用。這種做法造成了一些麻煩,比如suricata
git tree裡的src/suricata.c
使用了#ifdef _FORTIFY_SOURCE
,會造成編譯無法通過。
-fstack-protector
-fstack-protector -fstack-protector-all gcc 4.8.1 -fstack-protector-strong
https://securityblog.redhat.com/2013/10/23/debugging-stack-protector-failures/
開啟Stack-Smashing Protector (SSP)。我的理解是在儲存的幀指標(rbp)前寫入一個magic number,函式返回的時候檢查下這個magic number是否被改動,如果是就可能產生stack smashing了。這個方法的footprint最小,但是保護力度也比較弱。
IA32
function prologue 80484c0: 65 a1 14 00 00 00 mov eax,gs:0x14 80484c6: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
function epilogue 80484d7: 8b 45 f4 mov eax,DWORD PTR [ebp-0xc] 80484da: 65 33 05 14 00 00 00 xor eax,DWORD PTR gs:0x14 80484e1: 74 05 je 80484e8 80484e3: e8 68 fe ff ff call 8048350 [email protected] 80484e8: c9 leave 80484e9: c3 ret
x86-64
function prologue:
4005c9: 64 48 8b 04 25 28 00 mov rax,QWORD PTR fs:0x28 4005d0: 00 00 4005d2: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
function epilogue
400618: 64 48 33 04 25 28 00 xor rax,QWORD PTR fs:0x28 40061f: 00 00 400621: 74 05 je 400628 400623: e8 88 fe ff ff call 4004b0 [email protected] 400628: 48 83 c4 78 add rsp,0x78 40062c: c3 ret
execinfo.h
提供了int backtrace (void **buffer, int size)
、char ** backtrace_symbols (void *const *buffer, int size)
在程式執行時檢視函式呼叫棧。參見http://www.gnu.org/software/libc/manual/html_node/Backtraces.html。
Misc
Valgrind
一系列除錯和profiling工具的套件,其中的Memcheck是一個使用了dynamic binary instrumentation(DBI)的工具, 在程式指令間插入自己的指令檢查validity和addressablity。另外Memcheck替換了標準的malloc
,這樣就可以檢測出off-by-one error、double free、記憶體洩漏等許多問題。
Memcheck引入的footprint極小,無需重編譯程式,也沒有繁瑣的配置。比如原來是用./a.out
執行程式,需要Memcheck時就換成valgrind ./a.out
。
在程式訪問某一記憶體地址時Memcheck會檢查是否有越界之類的錯誤,Memcheck能診斷出大量但不是全部的訪問錯誤,比如下面這樣有問題的程式碼就沒法檢查出來:
12345 | int main(){ int a[1]; a[1992] = 12;} |
因為a[1992]
的地址在棧上,允許訪問。
Valgrind啟動時會讀取~/.valgrindrc
,對於memcheck
我配置了下面這幾行:
12345678 | --memcheck:leak-check=yes--memcheck:show-possibly-lost=yes--memcheck:show-reachable=yes--memcheck:track-origins=yes--memcheck:dsymutil=yes--memcheck:track-fds=yes--memcheck:track-origins=yes--memcheck:gen-suppressions=all |
valgrind --vgdb-error=0 --vgdb=yes
很強大,可以在程序遇到錯誤時讓gdb
除錯。
strace
記錄程式執行的系統呼叫和收到的訊號,和valgrind
類似,使用非常簡單:
1 | strace ./a.out |
有一些選項可以attach到現有程序上去(-p)、記錄時刻(-t)、統計系統呼叫使用次數(-c)、過濾特定的系統呼叫(-e)等。
帶上-c
選項可以統計系統呼叫的使用次數:
12345678910111213141516171819202122232425262728 | % strace -c lschap04 chap05 chap06 chap07 chap08 chap09 chap10 chap11 chap12 chap13 chap14 chap15 chap16 chap17% time seconds usecs/call calls errors syscall------ ----------- ----------- --------- --------- ---------------- 0.00 0.000000 0 5 read 0.00 0.000000 0 1 write 0.00 0.000000 0 7 open 0.00 0.000000 0 10 close 0.00 0.000000 0 8 fstat 0.00 0.000000 0 20 mmap 0.00 0.000000 0 12 mprotect 0.00 0.000000 0 2 munmap 0.00 0.000000 0 3 brk 0.00 0.000000 0 2 rt_sigaction 0.00 0.000000 0 1 rt_sigprocmask 0.00 0.000000 0 2 ioctl 0.00 0.000000 0 1 1 access 0.00 0.000000 0 1 execve 0.00 0.000000 0 1 fcntl 0.00 0.000000 0 2 getdents 0.00 0.000000 0 1 getrlimit 0.00 0.000000 0 1 arch_prctl 0.00 0.000000 0 2 1 futex 0.00 0.000000 0 1 set_tid_address 0.00 0.000000 0 1 openat 0.00 0.000000 0 1 set_robust_list------ ----------- ----------- --------- --------- ----------------100.00 0.000000 85 2 total |
-e
選項只跟蹤指定系統呼叫:
123456789101112131415 | % strace -e read,open lsopen("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3open("/lib64/librt.so.1", O_RDONLY|O_CLOEXEC) = 3read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220(\0\0\0\0\0\0"..., 832) = 832open("/lib64/libacl.so.1", O_RDONLY|O_CLOEXEC) = 3read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\320#\0\0\0\0\0\0"..., 832) = 832open("/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\[email protected]\2\0\0\0\0\0"..., 832) = 832open("/lib64/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\[email protected]}\0\0\0\0\0\0"..., 832) = 832open("/lib64/libattr.so.1", O_RDONLY|O_CLOEXEC) = 3read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0`\25\0\0\0\0\0\0"..., 832) = 832open("/usr/lib64/locale/locale-archive", O_RDONLY|O_CLOEXEC) = 3chap04 chap05 chap06 chap07 chap08 chap09 chap10 chap11 chap12 chap13 chap14 chap15 chap16 chap17+++ exited with 0 +++ |
使用strace
還可以做一些很可怕的事,比如有root
許可權的情況下嗅探sshd
以得到其他嘗試SSH登入的使用者的密碼:SSHD password sniffing。
-p
很有用,比如除錯CGI wrapperfcgiwrap
,觀察它的輸出:
1 | strace -s200 -p$(pidof -s fcgiwrap) -e write |
ltrace
記錄程式呼叫的動態庫中的函式。名字和strace
很像,使用方式和很多命令列選項也如出一轍。
檢視echo test
1234567891011121314151617181920212223242526272829 | % ltrace echo test__libc_start_main(0x401590, 2, 0x7fff2bb3d4d8, 0x403ef0 <unfinished ...>getenv("POSIXLY_CORRECT") = nilstrrchr("echo", '/') = nilsetlocale(LC_ALL, "") = "en_US.UTF-8"bindtextdomain("coreutils", "/usr/share/locale") = "/usr/share/locale"textdomain("coreutils") = "coreutils"__cxa_atexit(0x401cf8, 0, 0, 0x736c6974756572) = 0strcmp("test", "--help") = 71strcmp("test", "--version") = 71fputs_unlocked(0x7fff2bb3f1d3, 0x7f50af982160, 0, 45) = 1putchar_unlocked(10, 116, 0x7f50afba6004, 0xfbad2a84test) = 10exit(0 <unfinished ...>__fpending(0x7f50af982160, 0, 4, 0x7f50af982cf0) = 0ferror_unlocked(0x7f50af982160, 0, 4, 0x7f50af982cf0) = 0fileno(0x7f50af982160) = 1__freading(0x7f50af982160, 0, 4, 0x7f50af982cf0) = 0__freading(0x7f50af982160, 0, 2052, 0x7f50af982cf0) = 0fflush(0x7f50af982160) = 0fclose(0x7f50af982160) = 0__fpending(0x7f50af982080, 0, 0, 0) = 0ferror_unlocked(0x7f50af982080, 0, 0, 0) = 0fileno(0x7f50af982080) = 2__freading(0x7f50af982080, 0, 0, 0) = 0__freading(0x7f50af982080, 0, 4, 0) = 0fflush(0x7f50af982080) = 0fclose(0x7f50af982080) = 0+++ exited (status 0) +++ |
SystemTap
SystemTap提供了一套底層工具用於trace/probe。使用者編寫SystemTap script語言的程式,SystemTap將其翻譯為C程式碼,再編譯成臨時的核心模組。核心模組載入時SystemTap script腳本里的hook就會在特定event發生時執行。當SystemTap指令碼停止執行時,相應的hook就被刪除,移除臨時的核心模組。這一整套流程都是通過一個簡單的CLI程式stap
驅動的。
SystemTap使用前的配置過程比較複雜,需要特製的核心,開啟CONFIG_KPROBES=y
、CONFIG_DEBUG_INFO=y
等諸多核心編譯選項。
比如如下的簡單指令碼就能顯示各程序呼叫net/socket.c
內函式的情況:
123456 | probe kernel.function("*@net/socket.c").call { printf ("%s -> %s\n", thread_indent(1), ppfunc())}probe kernel.function("*@net/socket.c").return { printf ("%s <- %s\n", thread_indent(-1), ppfunc())} |
perf
1234 | perf record -e probe_a:main -e probe_a:main_1 /home/ray/tmp/aperf annotatesudo perf probe -x ~/tmp/a 'main%return %ip %sp'sudo perf record -e probe_a:main -e probe_a:main_1 /home/ray/tmp/a && sudo perf script |
可執行檔案不能在tmpfs分割槽。
1 | A=~/tmp; cc -xc <(echo 'main(){}') -Wl,-rpath,$A -o a && sudo perf probe -d '*' || :; sudo perf probe -x $A/libc.so.6 malloc && sudo perf record -e probe_libc:malloc -aR ./a && sudo perf report -n |
其他
書裡還介紹了很多神奇的玩意兒,比如kaho
,用於讀取被編譯器優化掉的變數;livepatch
,執行時動態修改變數、替換函式等。這兩個工具我在網上檢索了下,感覺是個proof of concept的東西,也沒有更新了。不夠這些思路很奇特,想到了並試圖去解決除錯時常受困擾的問題,很棒。
CFLAGS
使用-g3
對於重度使用macro的程式很有用,可以在gdb裡使用info macro NAME
、macro expand EXPR
等命令了,print
引數裡的macro也可以展開。
rr
參見http://rr-project.org/,除錯時最痛苦的莫過於難於重現,rr可以把不確定的外部影響固定下來。它的初衷是用來調Firefox的,由此可見它的可用性……幻燈片http://rr-project.org/rr.html介紹了很多內部機理,值得一看。
gdb -p
不可用: ptrace: Operation not permitted.
gdb無法attach到使用者相同的另一個程序上。Arch Linux、Ubuntu等很多發行版的核心預設設定了kernel.yama.ptrace_scope
,參見https://lwn.net/Articles/393012/,即不具有CAP_SYS_PTRACE
capability的程序只能ptrace它的後裔程序(子、孫、玄孫、來孫、晜孫、仍孫、雲孫、耳孫等)。不特別在乎安全性的話,可以執行sudo sysctl kernel.yama.ptrace_scope=0
。
收到SIGINT(或其他訊號)後立刻用gdb除錯自己
設想是fork產生一個新程序並停下來,原程序exec成gdb
並attach除錯新程序。注意:新程序應設定以建立新的程序組,不然gdb按數次continue
後自身也會被stop,gdb所在終端將丟失前臺程序組。這裡我不太清楚gdb被stop的具體原因,但程序組經常作為一個整體和訊號、終端等概念相互關聯,可能是這方面的原因。
這裡SIGINT
可以考慮換成SIGFPE
、SIGSEGV
等,以防止程序死亡,用gdb互動式檢視各個變數的值等以便於差錯。
12345678910111213141516171819202122232425262728 | #include <signal.h>#include <stdio.h>#include <stdlib.h>#include <sys/types.h>#include <unistd.h>void sigint(int){ pid_t pid = fork(); if (pid == -1) abort(); else if (pid) { char s[13]; sprintf(s, "%d", pid); execlp("gdb", "gdb", "-p", s, NULL); } else { setpgid(0, getpid()); kill(getpid(), SIGSTOP); }}int main(){ signal(SIGINT, sigint); sleep(1337); puts("seen after gdb"); sleep(1337);} |
除錯使用終端特性的程式
對於ncurses這類使用終端特性的程式,在gdb下除錯時,gdb互動的終端也會被程式使用,程式可能執行螢幕擦除、移動游標等操作,和gdb互動的輸出混雜在一起,產生干擾。解決方案是使用gdb的tty
命令(文件見info '(gdb) Input/Output'
)。下面以rlwrap rev
為例說明除錯方法。
使用coreutils中的tty
命令(並非gdb的tty
命令)獲得當前終端的名稱,如/dev/pts/13
,然後建立新shell會話,假設終端名是/dev/pts/14
,將用作被除錯程式的標準輸入、輸出、出錯。在這個新終端裡執行sleep 9999
(如果不執行這條命令的話,/dev/pts/14
的前臺程序組是shell,會搶奪終端輸入,而sleep
不會讀取終端輸入,因此不會和被除錯程式競爭)。
然後回到原來的shell會話(/dev/pts/13
),用gdb除錯程式:
123 | % gdb -tty /dev/pts/14 --args rlwrap revReading symbols from rlwrap...(no debugging symbols found)...done.(gdb) r |
之後即可在/dev/pts/14
和被除錯程式互動了。或者用命令tty /dev/pts/14
替代命令列選項-tty
。
注意,此時被除錯程式的標準輸入、輸出、出錯均為/dev/pts/14
,但沒有控制終端(controlling terminal),並且能在/dev/pts/14
看到gdb的警報:warning: GDB: Failed to set controlling terminal: Operation not permitted
。用strac