結構前に買っていたセキュリティコンテストチャレンジブックという本を最近になって読んで、脆弱性のあるコードを動かしたりして遊んでいました。
スタックオーバーフローとかSEGVの結果が本で書いていた通りにはならなかったのでとりあえずgdbを使ってアセンブラとかスタックの状態を見て386の動作を思い出すための復習をしてみました。
復習がてらメモを取りたかったので、どうせならと思いブログに書いておきたいと思います。
だらだらとながくなりそうですので TL;DR;
※ちゃんとまとめれていないと思うので後から整理したいと思います。
gdbinit
アセンブラの解析目的でgdbを使う場合は.gdbinitを整備しておいた方が色々と便利です。
特に長くなるコマンドやよく使うコマンドに省略系(alias)をつけておくと余計なタイプをしなくて済むのでコードの解析に集中できます。
普段デバッグにはgdbを使いません(printfデバッグが多い)のでカスタム度合いは低いですが、私の.gdbinitはこんな感じです。
ネットで調べた内容からよさげなものを流用させてもらってる感じです。
echo ******** ~/.gdbinit loaded ....\n ********
set disassembly-flavor intel
set disassemble-next-line on# ブレークポイントの一覧
alias ib = info breakpoints# レジスタ一覧
alias ir = info registers# アセンブラ表示
define as
x/11i $pc
end# スタックダンプ(10byte)
define st
x/10xw $sp
end# 現在のPCを表示
define pc
info registers $pc
end# For C++
set print demangle on
アセンブラの勉強用サンプル
とりあえずスタックの状態とか、関数の呼び出しと復帰を思い出したかったので簡単な関数を解析してみます。
// main.c #include <stdio.h> int add(int a, int b) { return a + b; } int main(int argc, char **argv) { int ret; ret = add(3, 4); printf("%d\n", ret); return 0; }
32bitのバイナリを出力させる
64bit環境で普通にGCCとかでビルドすると、当然ですが64bitのバイナリが出力されます。
要するに x86_64 アセンブラなのですが、セキュリティ関係の書籍ではx86(32bit)のアセンブラを前提として解説が書かれていることが多いので、勉強のために32bitのバイナリを出力できるようにします。
ライブラリのインストール
gccで64bit環境で32bitのバイナリを出力させるには
-m32 というオプションが必要です。
このオプションを動かすためにはいくつか32bit用のライブラリ等をインストールしておく必要があるようです。※インストールしていないと必要なヘッダファイルが見つからなかったりリンクエラーが出たりします。
$ sudo apt-get update
$ sudo apt-get install libc6-dev-i386 gcc-multilib g++-multilib
必要なライブラリをインストールした状態であれば -m32 を指定してもエラーにはなりません。
$ gcc -m32 -g main.c
$
gdbによるデバッグ・解析開始
$ gdb ./a.out
まずはメインでブレークを貼って実行します。
(gdb) b main
(gdb) r
Breakpoint 1, main (argc=1, argv=0xffffd664) at main.c:11
11 ret = add(3, 4);
=> 0x08048429: 6a 04 push 0x4
0x0804842b: 6a 03 push 0x3
0x0804842d: e8 d9 ff ff ff call 0x804840b
0x08048432: 83 c4 08 add esp,0x8
0x08048435: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
上記のアセンブラを見ると、
0x08048429
0x0804842b
の行が見えます。
4と3というのはソースコード上のaddの引数に対応しているというのはすぐにわかると思います。
引数は後ろから前へとスタックに積まれているのが面白いですね。
アセンブラを解析するときは常に、
- スタックの状態
- 各レジスタの状態(特にスタックポインタとプログラムカウンタ)
を意識しておくと何が起こっているのかが理解しやすくなります。
push命令はスタックに値を積む命令なので
push 0x4
push 0x3
が実行された時点でスタックの状態は、
のような状態になっています。
si(stepi)でpush 0x3の後に進み、スタックの状態を出力してみます。
stは私の.gdbinitでは x/10xw $sp を意味します。
(gdb) si
0x0804842d 11 ret = add(3, 4);
0x08048429: 6a 04 push 0x4
0x0804842b: 6a 03 push 0x3
=> 0x0804842d: e8 d9 ff ff ff call 0x804840b
0x08048432: 83 c4 08 add esp,0x8
0x08048435: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
(gdb) st
0xffffd598: 0x00000003 0x00000004 0x00000001 0xffffd664
0xffffd5a8: 0xffffd66c 0x08048481 0xf7fb33dc 0xffffd5d0
0xffffd5b8: 0x00000000 0xf7e1b637
確かに 0x00000003, 0x00000004 がスタックに載っています。
gdbのダンプは直感的ではないため、頭の弱い私は(真面目に解析をするときは)紙にスタックの状態を書きながらデバッガを動かしています。
さて、ここまでで引数をスタックに積むところまではアセンブラレベルでイメージできました。
いよいよ関数の呼び出しに入ります。
関数呼び出しは call 命令ですね。
call命令の実行により何が起こるかというと、 callの次の命令のアドレスが自動的にスタックに積まれます。そして、プログラムカウンタがcallのジャンプ先のアドレスに変わります。
(gdb) si
add (a=3, b=4) at main.c:4
4 {
=> 0x0804840b: 55 push ebp
0x0804840c: 89 e5 mov ebp,esp
(gdb) st
0xffffd594: 0x08048432 0x00000003 0x00000004 0x00000001
0xffffd5a4: 0xffffd664 0xffffd66c 0x08048481 0xf7fb33dc
0xffffd5b4: 0xffffd5d0 0x00000000
(gdb)
確かにスタックに 0x08048432 という値が格納されています。
0x08048432 は、
0x08048432
の命令行のアドレスであることがアセンブラのコードを見ると分かります。
要するにC言語レベルだと関数を呼び出した後の行の場所をスタックに積んで覚えておくという形ですね。
スタックオーバーフロー
スタックオーバーフローの脆弱性は、このスタックに積まれた「関数を呼び出した後の行の場所」を呼び出し先の関数を実行中に書き換えることで、関数から戻ってくるときに、思いもよらぬ場所から処理が始まるということです。
32bit(i386)にはMMUを使った仮想メモリの機構があるので、悪意のあるコードの場所へジャンプさせようとしても、そのコードが同一セグメント内になければセグメンテーションフォルト(通称SEGV・セグフォ・セグ落ち)でプログラムが停止するのが普通です。
じゃあどうやってスタックオーバーフローの脆弱性をついて悪意のあるコードを実行させるのかというとそれは自分もよく知らないのでこれからもう少し勉強してみます。
セグメント機構がある以上、バイナリを改ざんするしかないと思うのですが、具体的にどうやってやるのかわかっていません。。。
脆弱性の話になってしまいましたが、アセンブラの解析に話を戻すと、
callした先(add関数内)では、
しています。
=> 0x0804840b: 55 push ebp
ebp レジスタの値をスタックに積んでいます。
ebpはextended base pointer 。要するにベースポインタで現在のスタックの底となるアドレスを保持するレジスタです。ローカル変数にアクセスする時とかはこのアドレスからのオフセットを使って値を場所を指定します。
はスタックポインタの値でベースポインタを更新しています。
0x0804840c: 89 e5 mov ebp,esp
関数呼び出しの直後に、ここからが新しいフレームだよということでスタックフレームを切りなおすというイメージですね。
この後ですが、
という命令が続いています。
=> 0x804840e: mov edx,DWORD PTR [ebp+0x8]
0x8048411: mov eax,DWORD PTR [ebp+0xc]
edxに[ebp+0x8]と[ebp+0xc]にある値をコピーしています。
ebp +0x8, +0xcは要するに、スタックの中身です。
何が入っているかというと、3と4になります。
つまりここでやっていることは
edx = 第1引数(値は3)
eax = 第2引数(値は4)
この次が、
です。
0x8048414: add eax,edx
addは文字通り加算命令です。
eax に edxの値を足すという命令で結果はeaxに格納されます。
つまり、今この時点で、
edx = 3 , eax = 4
ですので、
eax += 3
になり、
eax の値は7になります。
(gdb) ir
eax 0x7 7
ecx 0xffffd5d0 -10800
edx 0x3 3
ebx 0x0 0
esp 0xffffd590 0xffffd590
ebp 0xffffd590 0xffffd590
esi 0xf7fb3000 -134533120
edi 0xf7fb3000 -134533120
eip 0x8048416 0x8048416
eflags 0x202 [ IF ]
cs 0x23 35
ss 0x2b 43
ds 0x2b 43
es 0x2b 43
fs 0x0 0
gs 0x63 99
(gdb)
i386の戻り値はEAXレジスタに格納されますので、このadd関数の結果は7になります(3+4=7 なのであっていますね)
(gdb) as
=> 0x8048416: pop ebp
0x8048417: ret
0x8048418: lea ecx,[esp+0x4]
0x804841c: and esp,0xfffffff0
0x804841f: push DWORD PTR [ecx-0x4]
0x8048422: push ebp
0x8048423: mov ebp,esp
0x8048425: push ecx
0x8048426: sub esp,0x14
0x8048429: push 0x4
0x804842b: push 0x3
(gdb)
最後に pop ebp していますが、スタック上に退避されていたebpの値をレジスタに復帰させています。
※今回解析したadd関数内では、ローカル変数を使っていないので、ebpを切りなおしたもののスタックにはそれ以上何も積まれることはありませんでした。
最後に、ret 命令で呼び出しもとに戻ります。
呼び出し元に戻るといっても、ret命令はスタックトップの値を取り出し、プログラムカウンタに設定するという動作をするだけです。
例えばret実行時にスタックトップに 0x0とか積まれてたら0x0番地にアクセスしようとしてSEGVが発生します。
まとめ
だらだらと長くなってしまいましたが386の関数呼び出し・復帰の基本的な流れを思い出すために書いてみました。
これだけだとスタックオーバーフローからの攻撃の原理までは分からないので、攻撃については続けて勉強が必要。
スタックの状態に関しては「今何が入っているのか」がグラフィカルに表示できるツールが欲しい。
ptrace系のAPIとかelectronとかと連携させればカッチョよさげなのが作れると思いますが、時間と根性と体力がなくて作れていません。
electronアプリ作る能力が注射打ったり薬飲んだり、あるいは自動販売機にお金払ったりしたら身につくというようなことができればと思います。
現実はそうは行かないので、地道な勉強とか、少しでも手を動かすというのが大切なんですよね・・・楽しくもあり、つらくもあり。