関数実行の流れを紐解く(弊研究室の某課題について考える2日目)

はじめに

この記事は弊研究室の某課題について考える二日目の記事です。

昨日はこちら

今日説明する部分は昨日の記事で説明しなかったmain関数のpush命令の部分です。

データ構造としてのスタックを理解していれば読めると信じます。突っ込みは大歓迎です。

ここを前回話さなかったのはスタックや関数実行の仕組みにまで踏み込むからです。では行きます。

お品書き

  • スタックとスタックフレーム(予備知識)
  • 今回のテストプログラムとアセンブラ
  • 関数実行の流れ(主要処理に入るまで)
  • 関数実行の流れ(主要処理が終わった後)

スタックとスタックフレーム

はじめに

関数の実行を説明するためにスタックとスタックフレームというのを知らなければなりません。

ここで説明するのはデータ構造としてのスタックではなく、実行するプログラムのメモリセグメント中のスタックセグメントのことです。

メモリセグメント?スタックセグメント?なんじゃそりゃとなると思うので下の図を見てください。

f:id:kataware8136:20171202204359p:plain

プログラムは実行するとメモリ上にバイナリを展開します。そのメモリでの配置図が上の図になります。このそれぞれのセグメント(領域)は用途によって分かれています。各セグメントの役割は以下の通り

テキストセグメントにはプログラムの命令列が含まれています。 安全性のため、この領域はRead Onlyです。

データセグメントには初期化済みの大域変数が含まれています。

bssセグメントには初期化されていない大域変数が含まれています。

ヒープセグメントはプログラム中で動的にメモリを確保するときに使われます。これはpwnable編のHeap overflowで紹介するかな?(まだ書いてないのでわからないです)

スタックセグメントは関数の作業領域、関数の引数渡しに使われます。

簡単に説明すると上のとおりなんですが、Pwnableとかでは関数は重要なファクターになるので実行の仕組みについてしっかりやります。

スタック

はじめにで書いたようにスタックは関数の引数渡しや作業領域として使われます。

そのスタックはスタックフレームを積み重ねた構造をしています。スタックフレームは後で詳しく解説するのでここでは簡単な説明です

スタックフレームは1つの関数が使用している領域です。下の図のように一つの関数が一つのスタックフレームを持ちます。

f:id:kataware8136:20171202210335p:plain

そしてスタックを管理するために2つのレジスタが使用されます。それがebp(rbp)esp(rsp)です

ebpはスタックフレームの開始場所を示し、espは現在使用しているスタックの一番上を指します。

関数は実行される際にその関数のスタックフレームがスタックに積まれるのでespはその関数のスタックフレームの最上部を指すといっても問題ないです。

スタックフレーム

では積まれるスタックフレームには何が入っているのかということをここで説明します。

f:id:kataware8136:20171202213200p:plain

スタックフレームには局所(ローカル)変数、前関数の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関数のアセンブラに移ります。今のスタックの状況を下の図に示します。

f:id:kataware8136:20171202220114p:plain

さて最初の一行目のpush rbpです。

スタックフレームを管理するレジスタは2つしかないので関数が切り替わるときにrbprspの値も変わります。ですが関数が終わったときには呼び出し元の関数に戻るためrbprspを保存しておく必要があります。これはrbpを保存するための命令です。

rspはスタックのトップを指し示すので、push命令でスタックに値を積むとrspの指す値が変わります!

次の行のmov rbp,rspでは今のrsprbpにコピーしています。スタックの説明のところの図を見てもらえばいいのですが新しい関数のスタックフレームのrbpは前の関数のrspになります。

さてここまでのスタックの状況が下の図です。

f:id:kataware8136:20171202221505p:plain

次のsub rsp,0x40が関数の主要処理に入るまでの最後の行です。この命令で局所変数の領域を確保します。

最後のスタックの状況です。

f:id:kataware8136:20171202221930p:plain

この後の処理ですが、今回は引数がレジスタに積まれているのでレジスタから引数を取ってこれます。スタックに引数が積まれている場合はrbpの値から計算してとることができます。局所変数の領域も確保されているので後は普通に読めますね!!

内容は簡単なのでカットします。ぜひ自分で読んでみてください。わからなくてもC言語のソースと見比べることによってここらへんはこういうことをやっているんだとつかむことができると思います。

関数の実行の流れ(主要処理が終わった後)

  4005d5:       c9                      leave
  4005d6:       c3                      ret

アセンブラのコードが上に行ってしまっていると思うので終わりの部分だけ引っ張ってきました。

命令は2つだけです。ですがleave命令はいろんなことをやります。

というよりfunc関数の始めの3行のアセンブラの逆を行います。

leave命令がやってることをアセンブラで書いてみます。

mov rsp,rbp
pop rbp

leave命令はまずrbprspにコピーすることで局所領域を破棄します。それによってスタックの状況は下の図のようになります

f:id:kataware8136:20171202221505p:plain

次に最初にスタックに積んだmain関数のrbpをpopしてrbpに格納することでスタックは下の図のようになります。

pushした時と同じようにpopしたらrspの値は変わります。

f:id:kataware8136:20171202220114p:plain

leave命令はこれで終わりで最後にret命令です。

ret命令の実行直前はスタックの状況は上の図のように呼び出し直後に戻っています。ret命令はスタックをpopした値にjmpします。つまり最後にリターンアドレス(400605)を呼び出してmain関数の処理に戻ります。

これで関数の実行の流れはすべておしまいです。

終わりに

さてスタックフレームのところを見るとこう考えられますよね。

開始前のebpの下にリターンアドレスあるならそこを書き換えればいろんな処理ができそうだな

これがスタックバッファオーバーフローになります。

私はこの話をしたくてこんな地味な話をしています。でもこの地味な部分が理解できてないとどうやって攻撃しているのかがわからないんですよね~

さて次回はアセンブラを読んだりするときのツールの話なんですが12/3は諸事情あって更新できないと思います。失踪ではないのでのんびり待っててください。