2022年7月8日 星期五

開發 debugger

寧可十年不將軍, 不可一日不拱卒。
對於編譯器全端的訓練, 剩下 debugger (好啦, linker 嚴格來說還沒完全練過一遍), debugger 是我覺得最難的, 我有想過目標設定在 dos debug 那種即可 (無 symbol debugger), 但還是不知道怎麼開始, 甚至去看 msdos 的 source code, 裡頭有 debug, 不過是用組合語言寫的, 決定先撤退。

和我之前學習的主題一樣, 曾經挑戰多次, 但都無功而返。突然這幾天又被我想到 debugger 這個主題, 再次找了一些資料, 終於找到有關 linux debugger 開發的資料, 幸運的是還有簡體中文翻譯 - 开发一个 Linux 调试器, 原文是: Writing a Linux Debugger

是不是在 linux 我倒是沒那麼在意, debugger 都好, 但如果是 linux debugger 那更好。

這個門檻就低一點, 站在巨人的肩膀, 使用 linux/ptrace 來寫 debugger, 不用和硬體 debug 暫存器搏鬥。不過 ptrace 也不是那麼好學就是, 我曾經挑戰多次, 一樣無功而返。

ptrace 和 debugger 一起學習很適合, 之前學習 ptrace 就只想做一件事情, 改變程式的變數值印出來, 這不就是 debugger 做的事情嗎!

這篇算是這個教學文的學習指引, 也就是學習這系列文的學習文, 有點繞舌, 如果 Writing a Linux Debugger 有難倒你, 希望我這篇文章能幫助你學習這個主題。

先把程式編譯起來, 需要額外 2 個 library, libelfin, linenoise。

descent@debian-vm:minidbg$ ls ext/
libelfin  linenoise

需要自己 git clone 出來:

libelfin 需要切到 fbreg branch
origin	https://github.com/TartanLlama/libelfin.git (fetch)
origin	https://github.com/TartanLlama/libelfin.git (push)
descent@debian-vm:libelfin$ git branch
* fbreg

linenoise
origin	https://github.com/antirez/linenoise.git (fetch)
origin	https://github.com/antirez/linenoise.git (push)

編譯 minidbg
cmake .
make
make  VERBOSE=1 # 可以看到編譯指令

編譯好 minidbg 之後會再需要一個 debug 程式, 寫個 hello world 吧! 編譯之後, 然後執行 ./minidbg hello 會發現:

list 1 error message
Hello worlddescent@debian-vm:minidbg$ ./minidbg h 
terminate called after throwing an instance of 'dwarf::format_error'
  what():  unknown compilation unit version 5
Aborted

這是因為該程式使用的 libelfin 只支援 DWARFv4, gcc 11 是使用 DWARFv5, 改用 gcc -gdwarf-4 h.c -o h 來編譯要除錯的程式即可, 這樣就會使用 DWARFv4 的版本; 不過 source code 是用 -gdwarf-2 來編譯測試程式。而為了搭配設定中斷點, 最後我使用 gcc h.c -no-pie -gdwarf-2 -o h 來編譯要除錯的程式。

minidbg 使用範例請參考 list 2。

list 2. minidbg
0 minidbg h
1 minidbg> break main
2 Set breakpoint at address 0x1148
3 minidbg> cont
4 hello, 10
5 Got signal Unknown signal -911728400
6 minidbg> cont

作者 Sy Brand 有說明 -no-pie 的影響, 並介紹了 personality()。大概原因是這樣, 用 objdump 看到的 main 位址很有可能不是被載入執行的位址, 如果是這樣, 設置的中斷點就可能不是預期的位置, 而 execl 不是自己寫的, 我們不知道 execl 是不是真的把 main load 到 objdump 看到的位址 (這衍生另外一個問題, execl 把程式 load 到那個位址, 有辦法查得到嗎?), 所以才有 -no-pie 或是 personality(), 我自己嘗試過在 linux 把 elf 執行檔 load 起來並執行, 但沒有成功, 卡在一些地方, 我的目標是想把 elf 執行檔 load 任何位址都可以執行, 但其中有部份我還沒克服, 所以止步到某個步驟, 在 linux 要克服蠻多問題, 在 bare-metal 環境我是有成功, uefi loader 載入 os kernel 就是這麼做的。

Sy Brand 有提到可以看 /proc/[pid]/maps 的第一行位址來當做載入位址。使用 gdb 測試, gdb 不管有沒有 -no-pie, 都可以正確設定中斷點, gdb 找得到執行檔被真正載入的位址, 我用 strace (strace -o g.txt gdb abcdef) 觀察 gdb, 可惜沒找出什麼方向, gdb 在執行 run 指令之後才會呼叫 ptrace。

    //child
    personality(ADDR_NO_RANDOMIZE);
    execute_debugee(prog);

如果不用 -no-pie 編譯, elf 執行檔會被載入到某個未知位址, 我想了一個很特別的方式, 找出 elf 被載入到的 main 位址, list 22 會印出 main addree, 就可以得知被載入的真正位址, 和 objdump (list 23) 果然不同。可以看到, 被載入到 0x555555555163, 而不是 0x1163。

流程是這樣: 執行到 list 22 L12 會卡在 while(1), 使用 kill -s SIGSTOP 240757 stop list 22 的執行檔, 這時候 minidbg 又可以繼續執行, list 25 L6 就可以看到 main 開始的 8 byte data。

我本來一開始是沒有設計 list 22 L12 while(1), cont 讓程式跑完再使用 memory read 指令, 但是只讀到 0xffffffffffffffff, 可能為了資安問題, 這個 process 被整個清掉了。所以才用了這麼迂迴的手法。

list 22 h.c
 1 #include <stdio.h>
 2 int abc123 = 5;
 3
 4 void func123()
 5 {
 6   int mn789 = 8;
 7   printf("hello c, abc123: %d, mn789: %d\n", abc123, mn789);
 8 }
 9 int main(int argc, char *argv[])
10 {
11   printf("main: %p\n", main);
12   while(1);
13   func123();
14   return abc123;
15 }

list 23 objdump -d h
1 0000000000001163 <main>:
2 int main(int argc, char *argv[])
3 {
4     1163:       55                      push   %rbp
5     1164:       48 89 e5                mov    %rsp,%rbp
6     1167:       48 83 ec 10             sub    $0x10,%rsp

list 25 md
1 descent@debian64:minidbg$ ./minidbg h
2 h child pid: 240757
3 minidbg> c
4 regs.rip: 7ffff7fd5090
5 main: 0x555555555163
6 minidbg> m r 0x555555555163
7 0x55 0x48 0x89 0xe5 0x48 0x83 0xec 0x10
8 minidbg>

分析 elf 有個 readelf 工具, 類似地, 觀察 dwarf 也有一個 dwarfdump 工具, readelf 算熟悉, dwarfdump 就很陌生了, 我今天 (20220630) 才第一次安裝這個工具, 和 elf 不同的是, dwarf 的資訊是壓縮過的, 所以無法用 hexdump 來直接觀察 dwarf, 還是借助 dwarfdump 會比較容易。對於 elf 我會使用 readelf, hexdump 來交叉比對, dwarf 看起來就難纏多了。

fig 3 的圖應該是我看過最具象化 elf 的圖了。初步比較, 我覺得 dwarf 比較複雜。

fig 3. from https://github.com/corkami/pics/raw/master/binary/elf101/elf101-64.pdf

dwarf 比我想的難很多, 之前的基礎派不太上用場, 大概是重新學習的難度。

在快速掃過 10 篇文章之後, 我慢慢有了怎麼開始學習的計畫:

在初步階段, 需要用 objdump 來觀察到設定的位址, 然後使用 break 指令來設定中斷點, 先不要把 dwarf 搞進來, 光使用 ptrace 就夠複雜了。對於 dwarf 安排了另外的學習方式, 我已經很擅長拆開整個複雜的東西來學習, 畢竟主要還是要學習 debugger, 沒有 dwarf 還是可以作到的, 只要會用 ptrace 即可。

這部份就是 Writing a Linux Debugger Part 3: Registers and memory 之前的部份, 之後就會開始講到 dwarf, 會變得很複雜, 先放一邊。

測試了「設定中斷點」和「繼續執行」和「讀暫存器」以及「讀寫記憶體位址」這些指令。和 list 2 不同的是, 我增加了縮寫, break 打 b 就可以, cont 打 c 就可以, 類似 gdb 的指令。

測試時需要 list 3 的資訊, 才能做這個驗證, main 位址在 0x401122, 所以 list 5 L3 就是把中斷點設定在 main。另外我把 h 的 pid 印出來, ps 可以看到 h 的狀態是 t+, 處於停止或是被追蹤的狀態。

再來打 c 讓 h 停在 main, 程式起來是停在 main, 要怎麼驗證? 我想到的方式是看 cs:rip, 所以來看一下 rip, list 5 L8 的指令, 顯示的是 0x401123, 差了 1, 不知道是怎麼回事?

可能是這樣, main 現在被換成 0xcc (int 3), 所以 rip 指向 0x401123 是下一個要執行的位址。如果我說錯, 請打我臉 (不是真的打我臉)。

好, 看起來真的停在中斷點上, 再來看記憶體的內容, 要看哪裡, 看 main 好了, list 5 L6 的指令下了之後可以看到 20ec8348e58948cc, 有沒發現和 list 3 L5 ~ L6, L7 很像, 除了 0x55 變成 0xcc, 也說明之前下的中斷點真的把 0x55 改成 0xcc 了。

list 3. objdump -Sd h
 1 0000000000401122 <main>:
 2 #include <stdio.h>
 3 int main(int argc, char *argv[])
 4 {
 5   401122:     55                      push   %rbp
 6   401123:     48 89 e5                mov    %rsp,%rbp
 7   401126:     48 83 ec 20             sub    $0x20,%rsp
 8   40112a:     89 7d ec                mov    %edi,-0x14(%rbp)
 9   40112d:     48 89 75 e0             mov    %rsi,-0x20(%rbp)
10   int a;
11   a = 5;
12   401131:     c7 45 fc 05 00 00 00    movl   $0x5,-0x4(%rbp)
13   printf("hello c\n");
14   401138:     48 8d 3d c5 0e 00 00    lea    0xec5(%rip),%rdi        # 402004 <_IO_stdin_used+0x4>
15   40113f:     e8 ec fe ff ff          callq  401030 <puts@plt>
16   return a;
17   401144:     8b 45 fc                mov    -0x4(%rbp),%eax
18 }
19   401147:     c9                      leaveq
20   401148:     c3                      retq
21   401149:     0f 1f 80 00 00 00 00    nopl   0x0(%rax)


list 5 minidbg 測試過程
1 descent@debian64:minidbg$ ./minidbg h
2 h child pid: 231719
3 minidbg> b 0x401122
4 Set breakpoint at address 0x401122
5 minidbg> c
6 minidbg> m r 0x401122
7 20ec8348e58948cc
8 minidbg> r r rip
9 401123


有了使用上的理解, 之後看相關的程式碼, 就可以知道這是怎麼辦到的, 揭開除錯器神秘的面紗。Writing a Linux Debugger 系列文一次解除我 debugger/ptrace 2 個疑惑。

最後在我寫下這篇文章時, 我對 dwarfdump 的內容以達「略懂」, 沒有一開始覺得那麼難了, 也覺得 dwarf 很了不起, 為了 debug, 紀錄了相當多的資訊。

ref:

沒有留言:

張貼留言

使用 google 的 reCAPTCHA 驗證碼, 總算可以輕鬆留言了。

我實在受不了 spam 了, 又不想讓大家的眼睛花掉, 只好放棄匿名留言。這是沒辦法中的辦法了。留言的朋友需要有 google 帳號。