hogetrace - 関数コールトレーサ
でかいソフトウェアの、大量のソースコードを短時間で読む必要が生じたので、その補助ツールとしてptrace(2)ベースのLinux用関数トレーサを自作しました。こういうツール上でまずソフトウェアを実行してみて、どのファイルのどの関数がどういう順で呼ばれるか把握おけば、いきなりソースコードの山と格闘を始めるより楽かなーと思いまして。せっかく作ったので公開します。
http://binary.nahi.to/hogetrace/
straceはシステムコールだけ、ltraceは共有ライブラリ(DSO)の関数呼び出しだけ*1をトレースしますが、このツールは、実行バイナリ中の自作関数の呼び出しもトレースします。例えば再帰で1から10まで足し算するソースコードを用意して
% cat recursion.c #include <stdio.h> int sum(int n) { return n == 0 ? 0 : n + sum(n - 1); } int main() { int s = sum(10); printf("sum(10) = %d\n", s); return s; }
最適化なしで普通にコンパイルしてトレーサにかけてみるとこんな感じの出力になります。-g をつけると、ファイル名/行番号を出せるので、そうしてあります。
% gcc -g -o recursion recursion.c % hogetrace --plt -lATv ./recursion [pid 17700] +++ process 17700 attached (ppid 17699) +++ [pid 17700] === symbols loaded: './recursion' === [pid 17700] ==> _start() at 0x08048300 [pid 17700] ==> __libc_start_main@plt() at 0x080482cc [pid 17700] ==> __libc_csu_init() at 0x08048460 [pid 17700] ==> _init() at 0x08048294 [pid 17700] ==> call_gmon_start() at 0x08048324 [pid 17700] <== call_gmon_start() [eax = 0x0] [pid 17700] ==> frame_dummy() at 0x080483b0 [pid 17700] <== frame_dummy() [eax = 0x0] [pid 17700] ==> __do_global_ctors_aux() at 0x080484d0 [pid 17700] <== __do_global_ctors_aux() [eax = 0xffffffff] [pid 17700] <== _init() [eax = 0xffffffff] [pid 17700] <== __libc_csu_init() [eax = 0x8049534] [pid 17700] ==> main() at 0x08048404 [/home/sato/ht-trunk/sample/recursion.c:9] [pid 17700] ==> sum(int n <10>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <9>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <8>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <7>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <6>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <5>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <4>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <3>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <2>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <1>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] ==> sum(int n <0>) at 0x080483d4 [/home/sato/ht-trunk/sample/recursion.c:4] [pid 17700] <== sum() [eax = 0x0] [pid 17700] <== sum() [eax = 0x1] [pid 17700] <== sum() [eax = 0x3] [pid 17700] <== sum() [eax = 0x6] [pid 17700] <== sum() [eax = 0xa] [pid 17700] <== sum() [eax = 0xf] [pid 17700] <== sum() [eax = 0x15] [pid 17700] <== sum() [eax = 0x1c] [pid 17700] <== sum() [eax = 0x24] [pid 17700] <== sum() [eax = 0x2d] [pid 17700] <== sum() [eax = 0x37] [pid 17700] ==> printf@plt() at 0x080482ec sum(10) = 55 [pid 17700] <== printf@plt() [eax = 0xd] [pid 17700] <== main() [eax = 0x37] [pid 17700] ==> _fini() at 0x080484f8 [pid 17700] ==> __do_global_dtors_aux() at 0x08048350 [pid 17700] <== __do_global_dtors_aux() [eax = 0x0] [pid 17700] <== _fini() [eax = 0x0] [pid 17700] +++ process 17700 detached (ppid 17699) +++ hogetrace: done
結果の55が計算され、表示されるまでの関数の呼び出し状況がわかると思います。
当初は、KLabのftraceなど、gcc -finstrument-functions系のトレーサを使わせてもらおうと思っていたのですが、(私の解析したいソフトウェアの場合)ソフトウェアの再コンパイルやMakefileの書き換えに大変手間がかかることがわかったので、「stripされていなければ再コンパイル不要」なツールを自作してみたという感じです*2。連休もあることだし、勉強がてら。仕組みはstrace/ltrace/gdb と同じくptrace(2)ベースで、fork/exec/clone対応、DSOの関数呼び出しのトレースも対応、です。なお関数の引数表示のところは、ftraceのソースコードをそのまま流用させていただきました。ありがとうございます。
以下仕組みのメモ + 遊んでいておもしろかったところ (あとでちゃんと書く):
- libbfdを使って解析対象のプログラムの.symtabを読んで、全関数の先頭アドレスを得ている。ついでに.plt上の合成(synthetic)シンボル(printf@pltとか)も得ている
- libopcodesで、さっき得た各関数の先頭アドレスから、バイナリを逆アセンブル、ret命令を探すことで関数からのリターンアドレスを静的に解析 /特定している。スタック見て動的にやる方法もありますが、今回は静的にやった。ltraceとは違う方法にしてみたかったというのもあるし、マルチスレッド環境で動的にセットしたBPを別スレッドが踏むとめんどくさいというのもあったり。
- ret/retq命令探しの際、_start() はretじゃなくてhltだとか、末尾再帰関数で-O2だとretがないとか、throwで抜けてるとやっぱりretがないとか、構造体を値で戻すような関数だと0xC3ではなく0xC2だったりとか、__attribute__((noreturn))されてるとretしないとか、そういう関数を呼ぶ側も callではなくjmpにしちゃうとか、探すときに細かい注意点がある。
- ひとつの関数内に複数のretが存在することが(もちろん?)ある。switch-case使ったときとか。
- .debug_info 見て、関数の定義されているファイル名、行番号の情報を取得している。同じく.debug_info 見て、関数の引数の情報を取得している。
- 解析対象をforkしてptrace(TRACEME);してsetenv(LD_BIND_NOW=1);してexec。setenvしておかないと、 PLT経由の関数呼びの初回(_dl_runtime_resolve時)がすぐにreturnするように表示されてしまう。子プロセスの起動時間を延ばしてしまう悪いhackかも。
- 解析対象がメモリにロードされたら、関数の先頭アドレス、RET命令アドレスにブレークポイント設定。ptrace(POKE)でそのアドレスに0xCCを書くだけ
- 解析対象が0xCCを踏むと、tracefにシグナルで通知がくる。ので、適当に情報を表示。0xCCで止まった解析対象の再開方法は、gdbとかと同じ(説明になっていない)。EIPを戻して元の命令書き戻してシングルステップして云々。詳しくは書籍「デバッガの理論と実装 (ASCII SOFTWARE SCIENCE Language)」あたりを。
- PLT経由のジャンプのフックは、まずPLT先頭の jmp *0x80... のとこに0xCCを仕掛け、その次の命令 push 0x.. にも0xCCを仕掛けておく。jmp*を踏んで解析対象が止まったら、解析対象のスタック上のリターンアドレスをPEEKして、tracef側に記憶する。そのリターンアドレスをpush 0x..のアドレスにすりかえる(POKE)。push 0xにリターンしてくるとまた0xCCを踏んで止まるので、tracef側に記憶しておいた本物のリターンアドレスを、今度はEIPに書き込んで continue。以上。これはひどい。
- この方法だと、DSO内で例外がthrowされて、それが解析対象のバイナリまで到達すると、abortする。ごめんなさいごめんなさいごめんなさい。まぁ滅多にそんなことしないよね。
- 最低限の対策として、__cxa_throw@plt の先頭でブレークした時は、このリターンアドレスのtweakを行わないようにしておいた。これで、exe内でthrowして、exe内でcatchする場合は問題なくなった。dso内でthrowして、exe内でcatchするプログラムをtraceする場合は、--pltか-Tのどちらかをはずしてもらわないとダメだ...。やはりltrace風の(よくあるデバッガの)、スタック見てリターンアドレスに地雷置くやりかたのほうがよかったか。
- GOT/PLT回りは、arch毎にコードがいる。x86_64はx86とほとんど同じだけど、jmp* がPC相対なのでやっぱり#ifdefすることになった。
- 例外のthrowで表示(コールツリーのインデント具合)が乱れないよう、各プロセス/スレッドの関数呼び出し状況を、tracef側の std::stack
にも記憶しておく。 - ptraceはぐぐってもあまり文献がなくて、結局頼りになるのはstraceとltraceのソースコードだけだった。straceは多OS対応で魔窟なので、ltraceを中心に見た。でもltraceはスレッドというかcloneというか PTRACE_O_TRACECLONE というかに対応していない。結局そこは試行錯誤。あと、ltraceの .dynほげセクションの処理部分は面白かったのだけど、今回は使わない...
- straceをstraceしたり、ltraceをstraceしたりするといろいろなことがわかった。
- プロセスのptraceを終了する(detach)と、そのプロセスが即死する現象に悩んだ。単に、プロセスの0xCCを元に戻さないままデタッチしていたからシグナルで死んでいるだけだった。ありがとうございました。forkすると0xCCなまま.textがコピーされることも忘れずに。当然ですが。
- ptrace(GETREGS)に渡す構造体の初期化を省いたら、それが原因でvalgrindが死ぬほどの量の警告を出してくれた。ptraceの引数の初期化省略が原因だと気づくのにすこし時間がかかった。
- ptrace(2)は、以前straceもどきを作ってみてそれでおわりにしていたけど、いろいろ試してみたら想像以上におもしろかった。早いとこ遊んでおけばよかった。
デバッガもどきを作るのは初めてだったので、とてーも楽しかったです。ソースコードやrpmはリンク先にあります。