スタックフレームを図解する——関数呼び出しの裏側をアセンブリで追う
はじめに
前回は gcc -S でコンパイラの出力を読みました。関数の最初と最後に必ず現れる「プロローグ・エピローグ」を飛ばして読もうと書きましたが、今回はそこに踏み込みます。
関数を呼ぶたびにメモリ上で何が起きているのかを追ってみると、スタックオーバーフローやバッファオーバーフローの仕組みも見えてきました。
スタックの基本
スタックはメモリの一領域で、高いアドレスから低いアドレスに向かって伸びます。
「スタックフレーム」は1回の関数呼び出しに対応するスタック上の領域です。ローカル変数、退避したレジスタ、戻りアドレスなどが入ります。
x86_64 のスタックフレーム
サンプルコード
int callee(int a, int b, int c) {
int x = a + 1;
int y = b + 2;
return x + y + c;
}
int caller() {
return callee(10, 20, 30);
}
caller のアセンブリ (-O0)
caller:
push rbp # (1) 呼び出し元のベースポインタを退避
mov rbp, rsp # (2) 現在のSPをベースポインタに
mov edx, 30 # 第3引数
mov esi, 20 # 第2引数
mov edi, 10 # 第1引数
call callee # (3) 戻りアドレスをpushしてジャンプ
pop rbp # ベースポインタを復帰
ret
callee のアセンブリ (-O0)
callee:
push rbp # (1)
mov rbp, rsp # (2)
mov DWORD PTR [rbp-20], edi # 引数aをスタックに保存
mov DWORD PTR [rbp-24], esi # 引数b
mov DWORD PTR [rbp-28], edx # 引数c
mov eax, DWORD PTR [rbp-20]
add eax, 1
mov DWORD PTR [rbp-4], eax # ローカル変数 x
mov eax, DWORD PTR [rbp-24]
add eax, 2
mov DWORD PTR [rbp-8], eax # ローカル変数 y
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add edx, eax
mov eax, DWORD PTR [rbp-28]
add eax, edx # x + y + c
pop rbp
ret
スタックの中身(callee 実行中)
ポイント:
- rbp は固定の基準点。ローカル変数はすべて rbp からの負のオフセットでアクセスします
- 戻りアドレスは rbp の直上(rbp+8)にあります。これがバッファオーバーフロー攻撃のターゲットになります
- 引数は最初からレジスタに入っているのに、スタックにコピーしている(-O0 だから)
AArch64 のスタックフレーム
callee のアセンブリ (-O0)
callee:
sub sp, sp, #32 // スタックフレームを確保
stp x29, x30, [sp, #16] // フレームポインタとリンクレジスタを退避
add x29, sp, #16 // フレームポインタを設定
str w0, [sp, #12] // 引数a
str w1, [sp, #8] // 引数b
str w2, [sp, #4] // 引数c
ldr w8, [sp, #12]
add w8, w8, #1
str w8, [sp] // ローカル変数 x
...
ldp x29, x30, [sp, #16] // フレームポインタとリンクレジスタを復帰
add sp, sp, #32 // スタックフレームを解放
ret
x86_64 との違い
| x86_64 | AArch64 | |
|---|---|---|
| 戻りアドレス |
callがスタックに自動push |
blがx30レジスタに保存 → 明示的にスタックに退避 |
| フレームポインタ | rbp | x29 |
| レジスタ退避 | push/pop で1つずつ | stp/ldp でペアで保存(16バイトアライン必須) |
| スタック確保 | push rbpが暗黙に行う + sub rsp | sub sp で明示的に確保 |
AArch64 で重要なのは、スタックポインタが常に16バイトアラインされていなければならないという制約です。stp(Store Pair)で2つのレジスタをまとめて保存するのはこの制約に対応するためでもあります。
z/Architecture のスタックフレーム
callee のアセンブリ (-O0)
callee:
stmg %r6,%r15,48(%r15) # レジスタ R6-R15 をまとめて退避
lay %r15,-168(%r15) # スタックフレーム確保(168バイト)
lgr %r11,%r15 # フレームポインタ設定
st %r2,164(%r11) # 引数a
st %r3,168(%r11) # 引数b
st %r4,172(%r11) # 引数c
...
lmg %r6,%r15,216(%r11) # レジスタ R6-R15 を復帰
br %r14 # リターン(R14 = リターンアドレス)
z/Architecture の特徴
z/Architecture のスタックフレームには「セーブエリア」という独特の構造があります。
x86_64 やAArch64 では関数が自分のレジスタを自分のフレームに退避しますが、z/Architecture では呼び出し元が用意したセーブエリアに退避するという規約になっています。stmg %r6,%r15,48(%r15) の 48(%r15) が呼び出し元のセーブエリアを指しています。
リーフ関数の最適化
他の関数を呼ばない「リーフ関数」では、スタックフレームがどう変わるか見てみます。
int square(int x) {
return x * x;
}
x86_64 (-O2)
square:
mov eax, edi
imul eax, edi
ret
プロローグもエピローグもありません。レジスタだけで完結しています。
AArch64 (-O2)
square:
mul w0, w0, w0
ret
同様にスタック操作なし。リンクレジスタ(x30)を退避する必要がないので、stp/ldp も不要です。
s390x (-O2)
square:
msr %r2,%r2
lgfr %r2,%r2
br %r14
リンクレジスタ方式(AArch64, z/Architecture)ではリーフ関数のオーバーヘッドがほぼゼロなのに対し、x86_64 は call/ret でスタックへの暗黙のpush/popが残るという違いが見えました。
3アーキテクチャのスタックフレーム比較
ここまで見てきた3つのアーキテクチャのスタックフレーム構造を並べてみました。
戻りアドレスの扱い(赤=スタック自動push、緑=リンクレジスタ明示退避、橙=セーブエリア方式)が一番の違いです。
バッファオーバーフローとスタック
スタックフレームの構造がわかると、なぜバッファオーバーフローが危険なのかも見えてきます。
void vulnerable(char *input) {
char buf[16];
strcpy(buf, input); // 長さチェックなし
}
buf に16バイトを超えるデータを書き込むと、退避した rbp を越えて戻りアドレスまで到達します。攻撃者はここに任意のアドレスを書くことで、ret 時に好きなコードにジャンプさせることができます。
現代の防御機構
| 防御 | 仕組み |
|---|---|
| Stack Canary | 戻りアドレスの手前にランダムな値を置き、関数終了時に壊されていないか確認する |
| ASLR | スタックのアドレスをランタイムでランダム化し、攻撃先の推測を困難にする |
| NX bit (DEP) | スタック領域を実行不可にし、スタック上に置いたコードの実行を防ぐ |
| Shadow Stack | 戻りアドレスを別の保護された領域にも記録し、改ざんを検知する(CET) |
gcc -fstack-protector を有効にすると、アセンブリ出力に Stack Canary のチェックコードが追加されるのが見えます。
# Stack Canary の例(x86_64)
mov rax, QWORD PTR fs:40 # カナリア値をロード
mov QWORD PTR [rbp-8], rax # スタックに配置
...
mov rax, QWORD PTR [rbp-8] # 関数終了前に確認
xor rax, QWORD PTR fs:40
jne .L_stack_fail # 壊されていたら異常終了
まとめ
スタックフレームは3つのアーキテクチャで構造が異なりますが、担っている役割は共通していました。
- ローカル変数の置き場所
- 呼び出し元に戻るためのアドレス保存
- レジスタの退避・復帰
違いを整理すると:
| x86_64 | AArch64 | z/Architecture | |
|---|---|---|---|
| 戻りアドレス | スタックに自動push | リンクレジスタ(x30) | リンクレジスタ(R14) |
| レジスタ退避先 | 自分のフレーム | 自分のフレーム | 呼び出し元のセーブエリア |
| アライン制約 | 16バイト | 16バイト | 8バイト |
| 複数レジスタ退避 | push を連発 | stp でペア保存 | stmg で一括保存 |
次回はシステムコールと割り込みについて掘り下げます。ユーザー空間からカーネルへの遷移がアセンブリレベルでどう起きるのかを見ていきます。
Discussion