この記事について
この記事は「Hacking: The Art Of Exploitation」を読んだ私の個人的なメモシリーズ#1となります。
今回はGDBやC言語、低レイヤーなどの知識を必要とするかとは思われますが、
こんなメモを読むのは大体そういう方(失礼)だと思うのであしからず。
言葉だけでなく、実際のコードを見て手を動かしたほうが理解も捗るかと思い掲載しました。
関数のプロローグについて
今回は関数のプロローグについてのメモです。
関数のプロローグとは、関数を呼び出して実行し、呼び出し元に戻る際に必要となる情報(局所変数のメモリ、rbp、戻りアドレスなど)をスタックに積み上げる処理のことを指します。
関数のプロローグを実際に確認するため、以下のようなプログラムを考えてみましょう。
あくまで、関数のプロローグを見るための適当なプログラムです。
#include <stdio.h>
void func(void)
{
int x = 100;
char str[] = "Hello, World!";
}
int main(void)
{
func();
return 0;
}
これをコンパイルしつつデバッグ情報を付与した後に、GDBデバッガを起動させます。
gcc -o test test.c -g
gdb -q ./test
GDBデバッガに入ったら、まずは個人的に分かりやすいと思っているintel記法へと設定を変更します。
(gdb) set disassembly-flavor intel
いよいよ本題です。
ソースコードをアセンブリとして表示してみます。
(gdb) disass main
Dump of assembler code for function main:
0x000055555555519d <+0>: endbr64
0x00005555555551a1 <+4>: push rbp
0x00005555555551a2 <+5>: mov rbp,rsp
0x00005555555551a5 <+8>: call 0x555555555149 <func>
0x00005555555551aa <+13>: mov eax,0x0
0x00005555555551af <+18>: pop rbp
0x00005555555551b0 <+19>: ret
End of assembler dump.
(gdb) disass func
0x0000555555555149 <+0>: endbr64
0x000055555555514d <+4>: push rbp
0x000055555555514e <+5>: mov rbp,rsp
0x0000555555555151 <+8>: sub rsp,0x20
0x0000555555555155 <+12>: mov rax,QWORD PTR fs:0x28
0x000055555555515e <+21>: mov QWORD PTR [rbp-0x8],rax
0x0000555555555162 <+25>: xor eax,eax
0x0000555555555164 <+27>: mov DWORD PTR [rbp-0x1c],0x64
0x000055555555516b <+34>: movabs rax,0x57202c6f6c6c6548
0x0000555555555175 <+44>: mov QWORD PTR [rbp-0x16],rax
0x0000555555555179 <+48>: mov DWORD PTR [rbp-0xe],0x646c726f
0x0000555555555180 <+55>: mov WORD PTR [rbp-0xa],0x21
0x0000555555555186 <+61>: nop
0x0000555555555187 <+62>: mov rax,QWORD PTR [rbp-0x8]
0x000055555555518b <+66>: sub rax,QWORD PTR fs:0x28
0x0000555555555194 <+75>: je 0x55555555519b <func+82>
0x0000555555555196 <+77>: call 0x555555555050 <__stack_chk_fail@plt>
0x000055555555519b <+82>: leave
0x000055555555519c <+83>: ret
End of assembler dump.
特に、func関数のアセンブリの中にある
0x000055555555514d <+4>: push rbp
0x000055555555514e <+5>: mov rbp,rsp
0x0000555555555151 <+8>: sub rsp,0x20
この部分に注目してください。
この部分こそが関数のプロローグと呼ばれる処理の実態なのです。
なにをしているの?
最初に述べたように、関数のプロローグは、関数の実行と呼び出し元に戻るための情報をスタックに積み上げます。
関数のプロローグを理解するためには、rspレジスタとrbpレジスタの理解から始めましょう。
rspとrbpは適当な話、スタックに積まれた一つのフレーム(関数実行時に必要となる情報の集まり)の上下のアドレスを保持しています。
つまりrspとrbpが保持しているアドレスの間に局所変数などか格納されているということです。
func関数を呼び出す前と、呼び出されてfuncの処理が行われているときのrspとrbpが持つアドレスを見てみましょう。
まずはbreakコマンドにより、7行目(func関数内)と11行目(func関数呼び出し前)にブレイクポイントを設定し、runコマンドで最初のブレイクポイント、func関数呼び出し前の11行目で処理を停止させます。
(gdb) break 7
(gdb) break 11
(gdb) run
次にfunc関数を呼び出す前のrspとrbpが保持するアドレスについて見ていきましょう。
以下のコマンドで、レジスタが保持している値を確認できます。
(gdb) i r $rsp $rbp
rsp 0x7fffffffe060 0x7fffffffe060
rbp 0x7fffffffe060 0x7fffffffe060
先程、rspとrbpが保持しているアドレスの間に局所変数などか格納されていると書きましが、main関数内では局所変数を宣言していないため、スタック上には領域が確保されていないのです。つまりrspとrbpの値は同じアドレスを指します。
funcを呼び出す前のスタックの状態が確認ができたら、次はfunc実行中のの状態を確認してみましょう。
contコマンドで、次のブレイクポイントである7行目(func関数内)へと処理を進め、先ほどと同じinfo registerコマンドを使用して、レジスタを確認します。
(gdb) cont
(gdb) i r $rsp $rbp
rsp 0x7fffffffe030 0x7fffffffe030
rbp 0x7fffffffe050 0x7fffffffe050
アドレスが先程よりも小さくなっていることが分かると思いますが、これはmain関数のスタックフレームが存在している0x7fffffffe060の上にfunc関数のスタックフレーム(関数実行に必要な情報)が積み上げられたためです。
またrspとrbpには0x20の差がありますが、この差の中に局所変数などが格納されています。
もう少し詳しく
今度は命令を追って、具体的に値が変更される流れを見ていきましょう。
今のスタックのイメージは先程のrsp・rbpのアドレスを参考に、このような状態からスタートします。
まずはmain関数内にあるcall命令からです。
0x00005555555551a5 <+8>: call 0x555555555149 <func>
call命令は戻りアドレスをスタックへpush(積み上げ)し、ripを呼び出し先の関数の先頭アドレスに変更します。
また、戻りアドレスとはmain関数内のcall命令の次の命令のアドレスを意味します。
つまり以下の命令のアドレスが、戻りアドレスとして0x7fffffffe060の上にpushされるのです。
0x00005555555551aa <+13>: mov eax,0x0
そしてpushを行った場合、rspは減算されます。
つまりrspは、保持している0x7fffffffe060から0x8が減算されて0x7fffffffe058という値を格納するわけです。
また、ripに格納される呼び出し先の関数(func)の先頭アドレスはこれになります。
0x0000555555555149 <+0>: endbr64
ripとは次に実行する命令のアドレスを保持するレジスタのことです。プロセッサはこれを使って次の命令の場所を把握し、実行します。call命令を使用することで、呼び出し先の関数の命令が格納されているアドレスへとripを書き換え、関数の実行が実現します。
では次の命令、つまり関数のプロローグ最初の命令を見ていきましょう。
0x000055555555514d <+4>: push rbp
これは、rbpをスタックへとpushする命令です。main関数へ制御を返す際にrbpの値を復元するために後々用いられます。
0x7fffffffe058の上にrbpの現在の値がpushされるため、rspのアドレスは先ほどと同じように0x8だけ小さくなるので0x7fffffffe050となるわけです。
次の命令はrbpへrspのアドレスをmov命令によってコピーします。これによって作成するfunc関数のスタックフレーム、そのボトムをrbpが表すようになります。
0x000055555555514e <+5>: mov rbp,rsp
これで、関数から呼び出し元へと帰る際の準備が完了しました。
あとは局所変数などを格納するためにrspの値を減算して、領域を確保するだけです。
0x0000555555555151 <+8>: sub rsp,0x20
sub命令はrspから0x20を引き算するよう命令しています。
これにて関数プロローグを全て追うことができました。
確認
本当に一連の動作が行われているのかを確認してみましょう。
examineコマンドを使用してrspのアドレスからmain関数のフレームである0x7fffffffe060のあたりまでを確認します。
ちなみに本書は32ビットコンピュータを使用しているようで、gではなくwなのですが、64ビットコンピュータを使用する場合はgをつけたほうが見やすいと思います。
(gdb) x/8xg $rsp
0x7fffffffe030: 0x0000006400000002 0x2c6f6c6c6548fbff
0x7fffffffe040: 0x0021646c726f5720 0xfea8e158512f7600
0x7fffffffe050: 0x00007fffffffe060 0x00005555555551aa
0x7fffffffe060: 0x0000000000000001 0x00007ffff7db8d90
下から2行目には右に戻りアドレス、左にrbpが格納されていることが確認できます。
また、上から1行目の4バイトまでを数えると0x00000064が格納されており、これは10進数の100であることもわかります。
("Hello, World!"だけ見つからないのですが、多分この中に先頭アドレスがあるはずです。てへ)
(追記) : 先頭アドレスの格納だと思い込んでいたのですが、実際には実体そのものが格納されていました。
上から2行目の右側と、3行目の左側の値をASCIIコード表と比べてみると
0x 2c 6f 6c 6c 65 48 fb ff
, o l l e H
0x 00 21 64 6c 72 6f 57 20
\0 ! d l r o W Space
上記のように、Hello, World! が確認できます。
最後に
おそらく間違えているところが多々あると思います。その際にはぜひともご指摘ください。
普段何気なくプログラムを書いていれば関数にはよくお世話になりますが、理屈を知るとプログラムの深い部分を感じられていいですね。