関数実行の流れを紐解く(弊研究室の某課題について考える2日目)
はじめに
この記事は弊研究室の某課題について考える二日目の記事です。
昨日はこちら
今日説明する部分は昨日の記事で説明しなかったmain関数のpush
命令の部分です。
データ構造としてのスタックを理解していれば読めると信じます。突っ込みは大歓迎です。
ここを前回話さなかったのはスタックや関数実行の仕組みにまで踏み込むからです。では行きます。
お品書き
- スタックとスタックフレーム(予備知識)
- 今回のテストプログラムとアセンブラ
- 関数実行の流れ(主要処理に入るまで)
- 関数実行の流れ(主要処理が終わった後)
スタックとスタックフレーム
はじめに
関数の実行を説明するためにスタックとスタックフレームというのを知らなければなりません。
ここで説明するのはデータ構造としてのスタックではなく、実行するプログラムのメモリセグメント中のスタックセグメントのことです。
メモリセグメント?スタックセグメント?なんじゃそりゃとなると思うので下の図を見てください。
プログラムは実行するとメモリ上にバイナリを展開します。そのメモリでの配置図が上の図になります。このそれぞれのセグメント(領域)は用途によって分かれています。各セグメントの役割は以下の通り
テキストセグメントにはプログラムの命令列が含まれています。 安全性のため、この領域はRead Onlyです。
データセグメントには初期化済みの大域変数が含まれています。
bssセグメントには初期化されていない大域変数が含まれています。
ヒープセグメントはプログラム中で動的にメモリを確保するときに使われます。これはpwnable編のHeap overflowで紹介するかな?(まだ書いてないのでわからないです)
スタックセグメントは関数の作業領域、関数の引数渡しに使われます。
簡単に説明すると上のとおりなんですが、Pwnableとかでは関数は重要なファクターになるので実行の仕組みについてしっかりやります。
スタック
はじめにで書いたようにスタックは関数の引数渡しや作業領域として使われます。
そのスタックはスタックフレームを積み重ねた構造をしています。スタックフレームは後で詳しく解説するのでここでは簡単な説明です
スタックフレームは1つの関数が使用している領域です。下の図のように一つの関数が一つのスタックフレームを持ちます。
そしてスタックを管理するために2つのレジスタが使用されます。それがebp(rbp)
とesp(rsp)
です
ebp
はスタックフレームの開始場所を示し、esp
は現在使用しているスタックの一番上を指します。
関数は実行される際にその関数のスタックフレームがスタックに積まれるのでesp
はその関数のスタックフレームの最上部を指すといっても問題ないです。
スタックフレーム
では積まれるスタックフレームには何が入っているのかということをここで説明します。
スタックフレームには局所(ローカル)変数、前関数のebp、リターンアドレス、引数などが入っています。以上です。
今回のテストプログラムとアセンブラ
やってることは前回の足し算を関数に分けてるだけですww
#include<stdio.h> int func(int a, int b){ char tmp[16]; int c = a+b; return c; } int main(void){ int tmp = func(1,2); printf("%d\n", tmp); return 0; }
0000000000400596 <func>: 400596: 55 push rbp 400597: 48 89 e5 mov rbp,rsp 40059a: 48 83 ec 40 sub rsp,0x40 40059e: 89 7d cc mov DWORD PTR [rbp-0x34],edi 4005a1: 89 75 c8 mov DWORD PTR [rbp-0x38],esi 4005a4: 64 48 8b 04 25 28 00 mov rax,QWORD PTR fs:0x28 4005ab: 00 00 4005ad: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax 4005b1: 31 c0 xor eax,eax 4005b3: 8b 55 cc mov edx,DWORD PTR [rbp-0x34] 4005b6: 8b 45 c8 mov eax,DWORD PTR [rbp-0x38] 4005b9: 01 d0 add eax,edx 4005bb: 89 45 dc mov DWORD PTR [rbp-0x24],eax 4005be: 8b 45 dc mov eax,DWORD PTR [rbp-0x24] 4005c1: 48 8b 4d f8 mov rcx,QWORD PTR [rbp-0x8] 4005c5: 64 48 33 0c 25 28 00 xor rcx,QWORD PTR fs:0x28 4005cc: 00 00 4005ce: 74 05 je 4005d5 <func+0x3f> 4005d0: e8 8b fe ff ff call 400460 <__stack_chk_fail@plt> 4005d5: c9 leave 4005d6: c3 ret 00000000004005d7 <main>: 4005d7: 55 push rbp 4005d8: 48 89 e5 mov rbp,rsp 4005db: 48 83 ec 10 sub rsp,0x10 4005df: be 02 00 00 00 mov esi,0x2 4005e4: bf 01 00 00 00 mov edi,0x1 4005e9: e8 a8 ff ff ff call 400596 <func> 4005ee: 89 45 fc mov DWORD PTR [rbp-0x4],eax 4005f1: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 4005f4: 89 c6 mov esi,eax 4005f6: bf 94 06 40 00 mov edi,0x400694 4005fb: b8 00 00 00 00 mov eax,0x0 400600: e8 6b fe ff ff call 400470 <printf@plt> 400605: b8 00 00 00 00 mov eax,0x0 40060a: c9 leave 40060b: c3 ret 40060c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
関数の実行の流れ(主要処理に入るまで)
さて予備知識の説明が終わったので主要処理に入るまでの関数の実行の流れを説明します。
まぁ察しのよい人はスタックフレームに積まれているものを見て気づくのではないでしょうか?
その通りです。引数と関数が終わった際の戻りアドレスをスタックに積んで、関数を呼び出せばいいんです。
(注)関数の引数は7個まではレジスタに格納できます。今回は引数2つなのでレジスタに格納して関数を呼び出します。
では順を追ってみていきます。アセンブラのmain関数の4005df
のところから2行でレジスタに引数を格納しているのを見て取れます。
その次に関数をcall
命令で呼び出しています。これは前回の記事で紹介していない命令ですね。call
命令はアドレスを呼び出す命令なのですが、次命令のアドレスをスタックに積みます。なので400605
がスタックに積まれます。
call
命令でfunc関数を呼び出すので、処理がfunc関数のアセンブラに移ります。今のスタックの状況を下の図に示します。
さて最初の一行目のpush rbp
です。
スタックフレームを管理するレジスタは2つしかないので関数が切り替わるときにrbp
とrsp
の値も変わります。ですが関数が終わったときには呼び出し元の関数に戻るためrbp
とrsp
を保存しておく必要があります。これはrbp
を保存するための命令です。
rsp
はスタックのトップを指し示すので、push命令でスタックに値を積むとrsp
の指す値が変わります!
次の行のmov rbp,rsp
では今のrsp
をrbp
にコピーしています。スタックの説明のところの図を見てもらえばいいのですが新しい関数のスタックフレームのrbp
は前の関数のrsp
になります。
さてここまでのスタックの状況が下の図です。
次のsub rsp,0x40
が関数の主要処理に入るまでの最後の行です。この命令で局所変数の領域を確保します。
最後のスタックの状況です。
この後の処理ですが、今回は引数がレジスタに積まれているのでレジスタから引数を取ってこれます。スタックに引数が積まれている場合はrbp
の値から計算してとることができます。局所変数の領域も確保されているので後は普通に読めますね!!
内容は簡単なのでカットします。ぜひ自分で読んでみてください。わからなくてもC言語のソースと見比べることによってここらへんはこういうことをやっているんだとつかむことができると思います。
関数の実行の流れ(主要処理が終わった後)
4005d5: c9 leave 4005d6: c3 ret
アセンブラのコードが上に行ってしまっていると思うので終わりの部分だけ引っ張ってきました。
命令は2つだけです。ですがleave
命令はいろんなことをやります。
というよりfunc関数の始めの3行のアセンブラの逆を行います。
leave
命令がやってることをアセンブラで書いてみます。
mov rsp,rbp pop rbp
leave
命令はまずrbp
をrsp
にコピーすることで局所領域を破棄します。それによってスタックの状況は下の図のようになります
次に最初にスタックに積んだmain関数のrbp
をpopしてrbp
に格納することでスタックは下の図のようになります。
push
した時と同じようにpop
したらrsp
の値は変わります。
leave
命令はこれで終わりで最後にret
命令です。
ret
命令の実行直前はスタックの状況は上の図のように呼び出し直後に戻っています。ret
命令はスタックをpop
した値にjmp
します。つまり最後にリターンアドレス(400605)を呼び出してmain関数の処理に戻ります。
これで関数の実行の流れはすべておしまいです。
終わりに
さてスタックフレームのところを見るとこう考えられますよね。
開始前のebpの下にリターンアドレスあるならそこを書き換えればいろんな処理ができそうだな
これがスタックバッファオーバーフローになります。
私はこの話をしたくてこんな地味な話をしています。でもこの地味な部分が理解できてないとどうやって攻撃しているのかがわからないんですよね~
さて次回はアセンブラを読んだりするときのツールの話なんですが12/3は諸事情あって更新できないと思います。失踪ではないのでのんびり待っててください。