📚

スタックフレームを図解する——関数呼び出しの裏側をアセンブリで追う

に公開

はじめに

前回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