はじめに
大学生だった頃に読んだBinary Hacksという本がとても面白くて、夢中で読んだ覚えがあります。(2006年の発売だそうで、もう15年になるのですね…)
この中に、確かプログラム中へのブレークポイントの埋め込み方について書かれていて、以前、自分でIntel Mac向けにバイナリのトレーサを作ったことがあります。ふとそれを思い出したので、この仕組みについて復習もかねて書いておこうと思います。
ブレークポイントを仕掛ける方法
x86/x86_64プロセッサでは、ブレークポイントを仕込む方法の一つに、プログラムのテキストセクションにint3
(マシン後では0xcc
)を書き込んでおくというテクニックがあります。
こうすると、このプロセスにアタッチしているプロセスに制御が自動的に戻り、その時にスタックやレジスタの値を調べることで変数の中身をみたり変更することができるようになります。これをC言語で実験してみようというのが本記事の主旨です。
実験
まずは以下のようなC言語のコードを考えてみましょう。インラインアセンブラでint3
をつっこむだけの簡単なプログラムです。
#include <stdio.h>
int main(void)
{
printf("before break\n");
asm volatile ("int3");
printf("after break\n");
return 0;
}
これを、下記のようにコンパイルして実行してみます。
$ gcc breakpt.c -o breakpt.x # コンパイル
$ ./breakpt.x # 実行
before break
zsh: trace trap (core dumped) ./breakpt.x
int3
よりも前に書かれている、printf("before break")
だけ実行して落ちました。
今度は、これをgdbの中で実行して、ブレークポイントの後をc
コマンドで継続実行出来るかどうか試してみます。
$ gdb breakpt.x
(省略)
Reading symbols from /home/swakamoto/code/break_test/breakpt.x...(no debugging symbols found)...done.
(gdb) run # 実行開始
Starting program: /home/swakamoto/code/break_test/breakpt.x
before break # printfの表示
Program received signal SIGTRAP, Trace/breakpoint trap.
0x000000000040053c in main ()
Missing separate debuginfos, use: debuginfo-install glibc-2.17-157.el7.x86_64
(gdb) c # 継続実行
Continuing.
after break # 表示された!
[Inferior 1 (process 116833) exited normally]
(gdb) quit
無事、printf("after break")
も実行して正常終了することができました。
ソースコードの表示
gdbでは、list
コマンドで前後のコードをみることができます。やってみましょう。
(省略)
before break
Program received signal SIGTRAP, Trace/breakpoint trap.
0x000000000040053c in main ()
Missing separate debuginfos, use: debuginfo-install glibc-2.17-157.el7.x86_64
(gdb) list
No symbol table is loaded. Use the "file" command.
(gdb)
何も出てこないですね。今度は、コンパイル時に、gccに-g
オプションを渡して、デバッグ情報をつけてから、同様に実行してみます。
$ gcc breakpt.c -o breakpt.x -g
$ gdb breakpt.x
(省略)
Reading symbols from /home/swakamoto/code/break_test/breakpt.x...done.
(gdb) run
Starting program: /home/swakamoto/code/break_test/breakpt.x
before break
Program received signal SIGTRAP, Trace/breakpoint trap.
main () at breakpt.c:9
9 printf("after break\n"); # 次の命令が見えるようになっている!
Missing separate debuginfos, use: debuginfo-install glibc-2.17-157.el7.x86_64
(gdb) list # listで前後のソースコードがみられる!
4 {
5 printf("before break\n");
6
7 asm volatile ("int3");
8
9 printf("after break\n");
10 return 0;
11 }
(gdb) c
Continuing.
after break
[Inferior 1 (process 120815) exited normally]
(gdb) quit
見ることができました。
変数を覗いてみる
ここで変数も覗いてみることができるかやってみましょう。先ほどのコードを少しだけ改造します。
#include <stdio.h>
int main(void)
{
int a = 10;
printf("before break\n");
a = 15;
asm volatile ("int3");
printf("after break\n");
return 0;
}
$ gcc breakpt.c -o breakpt.x -g
$ gdb breakpt.x
(省略)
(gdb) r # 実行
Starting program: /home/swakamoto/code/break_test/breakpt.x
before break
Program received signal SIGTRAP, Trace/breakpoint trap.
main () at breakpt.c:11
11 printf("after break\n");
Missing separate debuginfos, use: debuginfo-install glibc-2.17-157.el7.x86_64
(gdb) print(a) # 変数aを表示する
$1 = 15 # きちんと、15が入っている!
(gdb) c
Continuing.
after break
[Inferior 1 (process 133992) exited normally]
(gdb) quit
きちんとみることができました。なお、デバッグ情報がないと、当然変数aのメモリ上での位置がわからないので、確認することはできません。
まとめ
ここまで、プログラムにブレークポイントを入れる方法をみてきました。これを応用すれば、
- プログラムを子プロセスにロードする時に逆アセンブルなどして、ブレークポイントを入れたい箇所を決める。
- テキストセクション中の該当する場所に
int3
(マシン後では0xcc
)を書き込んでおき、実行する。 - ブレークポイントに来たら、
EIP
レジスタ(次に実行する命令のアドレスを保持するレジスタ)を1バイト分だけ巻き戻して int3 のところを本来の命令に書き換えてそれを実行させる
という手順でデバッガ作れそうな感じがします。
実際にはそこまで簡単ではなく、他のプロセスのメモリやレジスタ(上記のEIP
など)を読み書きに用いるプロセス間通信や、そのための権限の昇格が必要になります。
その他、バイナリに予め書きこまれるメモリアドレスと実際のメモリ上でマップされる展開されるアドレスはASLR(Address Space Layout Randomization)機能によってページ単位でシャッフルされており、これをロード時に無効化したりする作業も必要となります。
こういう様々な要素を考慮しながらも、安定してきちんと動作するデバッガが作られているのだと思うと、つくづくすごいなぁと思う次第です。