実行環境

  • x86-64
  • Linux (Debian Proxmox)

で行っています。 C 言語プログラムを実行するまでになにがおこなわれるかARM で行っており、このノートでは x86-64 を対象にしていることに注意してください。

root@pve:~/ctf/fullweakctf/pwnmebaby/dist/test-x86# cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
 
root@pve:~/ctf/fullweakctf/pwnmebaby/dist/test-x86# lscpu
Architecture:             x86_64
  CPU op-mode(s):         32-bit, 64-bit
  Address sizes:          39 bits physical, 48 bits virtual
  Byte Order:             Little Endian
CPU(s):                   4
  On-line CPU(s) list:    0-3
 
root@pve:~/ctf/fullweakctf/pwnmebaby/dist/test-x86# gcc --version
gcc (Debian 12.2.0-14+deb12u1) 12.2.0
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

参考にさせていただいたサイト

gondow さんによるアセンブリ言語の授業の資料が非常にわかりやすく、丁寧に読ませていただきました。 図・説明ともに簡潔かつわかりやすく、gondow さんのサイト + gemini などの AI ツール壁打ちですべて賄えるかと思いました。 よって、このノートは「gondow さんのサイトを読みながら、自分で手を動かしてスタックフレームの挙動を確かめる」ものになります。

url: https://gondow.github.io/linux-x86-64-programming/2-asm-intro.html
title: "アセンブリ言語の概要 - Linuxで学ぶx86-64アセンブリ言語"
description: "この本は書きかけですが,ご意見は歓迎します"
host: gondow.github.io
favicon: favicon.svg

kataware さんによるアセンブリ言語のブログも図が多くわかりやすいです。 一部図と地の文があっていない箇所がありそうであり、そちらについては補いつつ参考にするのがよいのかなと思いました。

url: https://kataware.hatenablog.jp/entry/2017/12/01/185923
title: "アセンブラ入門(弊研究室の某課題について考える一日目) - ごちうさ民の覚え書き"
description: "はじめに 弊研究室の某課題について考えるの一日目の記事です。 新しく入ってくる方たちになにとぞ覚えていただきたいのがBoFとかPwnable系の知識だと思うのでそのための入門編のアセンブラ入門です。 なのでx86, x64系の説明になります。 お品書き アセンブラについて 簡単なアセンブラ命令を覚える アセンブラを読んでみる アセンブラについて 計算機は機械語(0と1のコード)を実行するがそれを人間にわかりやすくした言語です。大学3年生ならそこらへんはわかってるよねってことで割愛 (注) アセンブラを人に説明する際に アセンブラを進捗報告で取り扱う際は何のアセンブラなのかを明確にしましょう。ア…"
host: kataware.hatenablog.jp
favicon: https://kataware.hatenablog.jp/icon/link
image: https://cdn.image.st-hatena.com/image/scale/41fe4409aba2d246530bc2cec84e1a846c1e538c/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fk%2Fkataware8136%2F20171201%2F20171201173826.png
url: https://kataware.hatenablog.jp/entry/2017/12/02/224444
title: "関数実行の流れを紐解く(弊研究室の某課題について考える2日目) - ごちうさ民の覚え書き"
description: "はじめに この記事は弊研究室の某課題について考える二日目の記事です。 昨日はこちら 今日説明する部分は昨日の記事で説明しなかったmain関数のpush命令の部分です。 データ構造としてのスタックを理解していれば読めると信じます。突っ込みは大歓迎です。 ここを前回話さなかったのはスタックや関数実行の仕組みにまで踏み込むからです。では行きます。 お品書き スタックとスタックフレーム(予備知識) 今回のテストプログラムとアセンブラ 関数実行の流れ(主要処理に入るまで) 関数実行の流れ(主要処理が終わった後) スタックとスタックフレーム はじめに 関数の実行を説明するためにスタックとスタックフレームと…"
host: kataware.hatenablog.jp
favicon: https://kataware.hatenablog.jp/icon/link
image: https://cdn.image.st-hatena.com/image/scale/f1995adfe40cccc4f7a12c133484b29ab281a657/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fk%2Fkataware8136%2F20171202%2F20171202204359.png

x86-64 アセンブリコードを読み進める前に ganyariya のためにまとめておきたい前知識

x86-64 の事前知識をまとめておきます。

  • ARMx86-64 はアセンブリコードならびにスタックフレームの扱いが大きくことなる
    • それぞれの特性を理解する
    • シンプルなのは x86-64
  • x86-64 では メモリアドレス空間は (8byte) になっている
    • 1 つのアドレスに 1byte 書き込める
    • 4byte のデータを書き込みたい場合は 4 つの連続したアドレスを利用する
  • 汎用レジスタ
    • rsp: スタックポインタ
      • x86-64 ではスタックの最上部を常に指す
    • rbp: ベースポインタ
      • スタックフレームの基準となる
      • 関数 から関数 を呼び出したとき、関数 自身が明示的に命令を呼び出して rbp を以下の手順で更新する
          1. 関数 の rbp をスタックの最上部に積んで退避させておく
          1. 関数 のベースポインタの位置が rsp に入っているので、 rbp に rsp を代入する
    • rip: プログラムカウンタ
      • 今実行している命令アドレスが入っている
      • 元の関数に戻るとき ret、スタックフレームに積まれていた元の関数へのリターンアドレスが rip にコピーされる
        • その後 rip レジスタから値が読み出されて、元の関数へ戻って来る
    • rax: 返り値レジスタ
      • rax にいれて返り値を返す
    • 引数 (1, 2, 3, 4, 5, 6) = (rdi, rsi, rdx, rcx, r8, r9)
  • 関数 から を call 関数で呼ぶとき、プロセッサが自動的に関数 への戻りアドレスをスタックの上部に積む

スタックフレームの構成図

main → func1 → func2 のように呼び出すことを考えます。 func1 から 2 回 func2 を呼び出していますが、同じアドレスに func2 のスタックフレームを詰み直すため無視して構いません。

#include <stdio.h>
 
int globalVal;
 
int func2(int x)
{
    globalVal++;
    return x + globalVal;
}
 
int func1(int x, int y)
{
    int k1 = x - y;
    int k2 = func2(k1);
    int k3 = func2(k2);
    return k3;
}
 
int main(int argc, char **argv)
{
    globalVal = 0;
 
    char text[100];
    scanf("%s", text);
    printf("Input: %s\n", text);
 
    int result = func1(20, 10);
    printf("Result: %d\n", result);
 
    return 0;
}

このノートでは、図の上部を低アドレス、図の下部を高アドレスとします。

0x0000000000000000 (低アドレス)
++--------++

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

メモリ全体のレイアウトとして、下図(gondow さんのサイトより引用)のように配置されています。

命令 (.text) が最も低アドレスに配置され、 .data, .bss, .rodata といったデータ領域が続きます。

  • ヒープ領域は 0 番地から 0xFFFFFFFFFFFFFFFF 番地向きへ
  • スタック領域は 0xFFFFFFFFFFFFFFFF 番地から 0 番地向きへ

成長していきます。

Imgur

ここからはスタックのみに注目して図を書きます。 図にかかれていないテキスト・データ・ヒープは別途、スタックよりも遥か低アドレスに配置されているということに注意してください。

main 関数の実行を始めると以下のようなスタックフレーム構成になります。 main のスタックフレーム領域として 128byte = 0x80 の領域が確保されています。

0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe980 ← rsp
(main のスタックフレーム領域)
------------ 0x7fffffffea00 ← rbp

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

また、 main のスタックフレーム領域をより細かく見ると以下の構成になっています。 padding はアラインメントのために追加されるものです。これは、 16byte 境界にスタックポインタを配置したいためにコンパイラが追加します。

0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe980 ← rsp
- 引数
  - argv (第 2 引数のほうが低アドレス側)
  - argc
- ローカル変数 
- padding
- 返り値 
------------ 0x7fffffffea00 ← rbp

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

関数を呼び出す

それでは main 関数から func1 関数を呼び出すときを考えてみます。 func1(20, 10) を call で呼び出します。

   0x00000000004011de <+79>:    mov    $0xa,%esi
   0x00000000004011e3 <+84>:    mov    $0x14,%edi
=> 0x00000000004011e8 <+89>:    call   0x401159 <func1>
   0x00000000004011ed <+94>:    mov    %eax,-0x4(%rbp)

call を呼び出すと、 call は自動的に 呼び出し元関数へのリターンアドレス をスタックに詰みます。

よって、 call 関数が呼び出されたときに

  • rsp が 0x7fffffffe980 から 0x7fffffffe978 になり、スタックポインタがより低アドレス側に進む
  • main 関数へのリターンアドレスである 0x00000000004011ed を 0x7fffffffe978 に書き込む

という処理が行われます。

0x0000000000000000 (低アドレス)
++--------++


------------ 0x7fffffffe978 ← rsp
main 関数へのリターンアドレス: 0x00000000004011ed
------------ 0x7fffffffe980
(main のスタックフレーム領域)
------------ 0x7fffffffea00 ← rbp

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

call が終わると、 func1 側の処理がはじまります。 func1 において、はじめに push %rbp がおこなわれ、 main 関数のときのベースポインタ をスタックに詰みます。 これによって、 main 関数が利用していたベースポインタをバックアップできます。

このときはまだ %rbp は main 関数時のベースポインタを指したままです。

(gdb) disas
Dump of assembler code for function func1:
=> 0x0000000000401159 <+0>:     push   %rbp
   0x000000000040115a <+1>:     mov    %rsp,%rbp
   0x000000000040115d <+4>:     sub    $0x18,%rsp
   0x0000000000401161 <+8>:     mov    %edi,-0x14(%rbp)
   0x0000000000401164 <+11>:    mov    %esi,-0x18(%rbp)
   0x0000000000401167 <+14>:    mov    -0x14(%rbp),%eax
   0x000000000040116a <+17>:    sub    -0x18(%rbp),%eax
   0x000000000040116d <+20>:    mov    %eax,-0x4(%rbp)
0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe970 ← rsp
main 関数が利用していたベースポインタ: 0x00007fffffffea00
------------ 0x7fffffffe978
main 関数へのリターンアドレス: 0x00000000004011ed
------------ 0x7fffffffe980
(main のスタックフレーム領域)
------------ 0x7fffffffea00 ← rbp

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

続いて、 mov %rsp,%rbp を実行し、func1 用のベースポインタを用意します。 これは、この命令実行時の rsp が func1 スタックフレーム としての最も下部を指しているためです。

0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe970 ← rsp, rbp (func1 用のベースポインタ)
main 関数が利用していたベースポインタ: 0x00007fffffffea00
------------ 0x7fffffffe978
main 関数へのリターンアドレス: 0x00000000004011ed
------------ 0x7fffffffe980
(main のスタックフレーム領域)
------------ 0x7fffffffea00

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

そして、sub $0x18,%rsp で func1 用にスタックフレーム 0x18=24 が確保されます。 具体的には rsp が 0x18 だけ小さくなります。

0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe958 ← rsp
func1 のスタックフレーム領域
------------ 0x7fffffffe970 ← rbp
main 関数が利用していたベースポインタ: 0x00007fffffffea00
------------ 0x7fffffffe978
main 関数へのリターンアドレス: 0x00000000004011ed
------------ 0x7fffffffe980
(main のスタックフレーム領域)
------------ 0x7fffffffea00

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

よって、下記の 3 命令によって

  1. main (呼び出し元関数) のベースポインタをスタック上部にバックアップ
  2. ベースポインタを func1 用に更新する
  3. rsp を sub して func1 用のスタックフレーム領域を用意する

が実現され、これは 関数プロローグ と呼ばれるようです。

   0x0000000000401159 <+0>:     push   %rbp
   0x000000000040115a <+1>:     mov    %rsp,%rbp
   0x000000000040115d <+4>:     sub    $0x18,%rsp

func1 が func2 を call するときも同様の処理がおこなわれます。 下記の図のように

  • func1 のリターンアドレスとベースポインタをスタックへバックアップ
  • func2 用の rsp, rbp を用意する ようになっています。

ただし、 func1 と異なり、 func2 用の作業領域が明示的に確保されていません。 というのも、 func1 では sub $0x4,%rsp という演算でスタックフレーム領域を作るために rsp を移動させていますが、 func2 では動かしていません。 かわりに rdp = 0x7fffffffe948 より 4 アドレス小さい 0x7fffffffe944 を mov %edi,-0x4(%rbp) という直接的な演算で利用しています。

これについては gondow さんのサイトで解説されていました。

Imgur

func1
   0x0000000000401173 <+26>:    mov    %eax,%edi
=> 0x0000000000401175 <+28>:    call   0x401136 <func2>
   0x000000000040117a <+33>:    mov    %eax,-0x8(%rbp)
 
Dump of assembler code for function func2:
   0x0000000000401136 <+0>:     push   %rbp
=> 0x0000000000401137 <+1>:     mov    %rsp,%rbp
   0x000000000040113a <+4>:     mov    %edi,-0x4(%rbp)
0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe944 (-0x4(%rbp) で rbp の外側で強引に作業する)
func2 関数の作業領域
------------ 0x7fffffffe948 ← rsp, rbp
func1 関数が利用していたベースポインタ:  0x00007fffffffe970
------------ 0x7fffffffe950
func1 関数へのリターンアドレス: 0x000000000040117a
------------ 0x7fffffffe958
func1 のスタックフレーム領域
------------ 0x7fffffffe970
main 関数が利用していたベースポインタ: 0x00007fffffffea00
------------ 0x7fffffffe978
main 関数へのリターンアドレス: 0x00000000004011ed
------------ 0x7fffffffe980
(main のスタックフレーム領域)
------------ 0x7fffffffea00

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

関数が終了する

次は func2, func1, main 関数 という順番で関数が終わっていきます。

はじめに func2 から見ていきます。

pop %rbp を実行すると、スタックから func1 が利用していたときのベースポインタ を取り出し、 rbp に代入します。 これによって、 func1 のときのベースポインタを思い出せます。

このとき、 スタックが縮小するため rsp も変化して、 func1 関数へのリターンアドレスを指すようになっていることに注意してください。

   0x0000000000401155 <+31>:    add    %edx,%eax
=> 0x0000000000401157 <+33>:    pop    %rbp
   0x0000000000401158 <+34>:    ret
0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe950 ← rsp (func1 関数へのリターンアドレスを指すようになる)
func1 関数へのリターンアドレス: 0x000000000040117a
------------ 0x7fffffffe958
func1 のスタックフレーム領域
------------ 0x7fffffffe970 ← rbp (func1 のベースポインタを指すようになる)
main 関数が利用していたベースポインタ: 0x00007fffffffea00
------------ 0x7fffffffe978
main 関数へのリターンアドレス: 0x00000000004011ed
------------ 0x7fffffffe980
(main のスタックフレーム領域)
------------ 0x7fffffffea00

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

func2 関数として最後に ret が実行されます。 ret を実行すると

  • rsp が指している func1 関数へのリターンアドレスをスタックから取り出して、 rip プログラムカウンタレジスタに move する
  • rip レジスタをもとに、func1 関数の実行途中の命令アドレスに戻る

がおこなわれます。

このようにスタックへうまく積む・取り外すを行うことで、func2 から元々の func1 へうまく戻ってこれました。 rip レジスタも func1 の実行途中だったアドレス 0x40117a へ戻ってこれています。

0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe958 ← rsp
func1 のスタックフレーム領域
------------ 0x7fffffffe970 ← rbp
main 関数が利用していたベースポインタ: 0x00007fffffffea00
------------ 0x7fffffffe978
main 関数へのリターンアドレス: 0x00000000004011ed
------------ 0x7fffffffe980
(main のスタックフレーム領域)
------------ 0x7fffffffea00

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

また、func1 から main 関数に戻っていく箇所も見てみます。

   0x000000000040118a <+49>:    mov    -0xc(%rbp),%eax
=> 0x000000000040118d <+52>:    leave
   0x000000000040118e <+53>:    ret

leave 命令が呼ばれます。 この leave 命令は今のスタックフレームを破棄する命令です。 つまり、 func1 のスタックフレーム領域を破棄します。

0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe958
🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥 破棄される領域
func1 のスタックフレーム領域
main 関数が利用していたベースポインタ: 0x00007fffffffea00
🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
------------ 0x7fffffffe978 ← rsp (main 関数へのリターンアドレスを指すようになる)
main 関数へのリターンアドレス: 0x00000000004011ed
------------ 0x7fffffffe980
(main のスタックフレーム領域)
------------ 0x7fffffffea00 ← rbp (main 関数のベースポインタを指すようになる)

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

最後に ret が呼ばれて main 関数へ戻ります。

0x0000000000000000 (低アドレス)
++--------++

------------ 0x7fffffffe980 ← rsp
(main のスタックフレーム領域)
------------ 0x7fffffffea00 ← rbp

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

まとめ

main → func1 を呼び出すと

  1. call が main 関数の実行途中の命令へのリターンアドレスをスタックに積む
  2. func1 関数が以下の関数プロローグを実行する
    1. main 関数のベースポインタをバックアップする
    2. func1 関数のスタックフレーム領域の基準をさす rbp を設定する
    3. func1 関数のスタックフレーム領域を確保する = rsp を sub する

がおこなわれます。

0x0000000000000000 (低アドレス)
++--------++

------------ ← rsp
func1 のスタックフレーム領域
- 引数
- ローカル変数
- padding
- return
------------ ← rbp
main 関数のベースポインタバックアップ
------------
main 関数へのリターンアドレス
------------
(main のスタックフレーム領域)
------------

++--------++
0xFFFFFFFFFFFFFFFF (高アドレス)

まとめ

url: https://gondow.github.io/linux-x86-64-programming/2-asm-intro.html
title: "アセンブリ言語の概要 - Linuxで学ぶx86-64アセンブリ言語"
description: "この本は書きかけですが,ご意見は歓迎します"
host: gondow.github.io
favicon: favicon.svg

gondow さんによる解説が非常にわかりやすく、理解に繋がりました。 あとは CTF 問題を解きながら x86 スタックフレームへの「経験」をつけようとおもいます。