はじめに
(Full Weak Engineer CTF 2025) Pwn Me Baby を学びながら解く
Pwn Me Baby を解くために
- C 言語自体がどう実行されるかの知識
- C 言語の関数がどう呼ばれるかの知識
が必要です。
それについて調べながら学びます。
前提
このノートにおいて Mac M4 (arm64) で検証しています。
そのため、
参考サイト
url: https://code-bug.net/entry/2018/12/07/143444/
title: "C言語のコンパイルにおけるアセンブラ→実行ファイルまでの流れをまとめてみた"
host: code-bug.net
コンパイラ・アセンブラ・リンクがわかりやすく拡張子・作り方も含めて説明されています。
url: https://nenya.cis.ibaraki.ac.jp/TIPS/compiler.html
title: "what is compiler doing"
host: nenya.cis.ibaraki.ac.jp
url: https://calmand.hatenablog.com/entry/2022/09/19/183321
title: "コンパイルからプログラム実行ファイル生成までの流れ(C言語) - 計画と改善"
description: "データベース使用するライブラリを動作させるためにC言語のコンパイラをインストールしなければならないことがあり、これまでよりも低レイヤーの学習しておこうという気になった。 今回はC言語本格入門のコンパイルからプログラム実行ファイル生成までの流れをまとめておく。 環境 Debian GNU/Linux 11 (bullseye) GCC C言語のコンパイラ。gccは単にコンパイルをするのではなく、中で色々な処理を行なっている。 その処理とはプリプロセス、コンパイル、アセンブル、リンクという流れで行っている。 プリプロセスはその名の通り、コンパイルの前処理。 コンパイルとはソースコードをアセンブリ言…"
host: calmand.hatenablog.com
favicon: https://calmand.hatenablog.com/icon/link
image: https://ogimage.blog.st-hatena.com/4207112889912453446/4207112889919572277/1663584712
header file と source file の違いが説明されています。 また、 header & source file の違いを踏まえたうえでの C コンパイルの流れも説明されています。
url: https://c-lang.sevendays-study.com/day7.html
title: "一週間で学べるシリーズ - おさえておきたいプログラミングの基本"
description: "プログラミングの初心者でも1週間でC言語プログラミングが出来るように、基礎からきちんと学べるC言語入門サイトです。基本的なプログラミングの方法から、オブジェクト指向を使ってプログラムを作る方法まで解説します。"
host: c-lang.sevendays-study.com
favicon: assets/favicon.ico
非常にわかりやすいです。 メモリマップ・スタック・アセンブリコードが丁寧に解説されており、こちらの記事は絶対に参照するのがよいです。
url: https://www.gaio.co.jp/gaioclub/compiler_blog09/
title: "【第9回】メモリマップ | ガイオ・テクノロジー ソフト検証ツール/モデルベース/エンジニアリングサービス プロバイダー"
description: "ガイオ・テクノロジー株式会社は、自動車開発の新しいトレンドに対応するソフトウェア開発・検証を徹底支援します。"
host: www.gaio.co.jp
favicon: https://www.gaio.co.jp/favicon.ico
ソースコードから実行ファイルができるまでの全体の流れ
サンプルファイル
url: https://github.com/ganyariya/playground/tree/a67f2cca57016ab43829cc601fa6b3d598b3bf2d/c-compile-assemble
title: "playground/c-compile-assemble at a67f2cca57016ab43829cc601fa6b3d598b3bf2d · ganyariya/playground"
description: "Contribute to ganyariya/playground development by creating an account on GitHub."
host: github.com
favicon: https://github.githubassets.com/favicons/favicon.svg
image: https://opengraph.githubassets.com/5f9bfd162023dc030c282a02d8e45772fde68ecc05c503c492a5c8e585ae15f7/ganyariya/playground
ソースコードから実行ファイルができるまでのステップ
大前提
- プリプロセッサ
- コンパイラ
- アセンブラ
これらはそれぞれ各 .c ファイルごとに実行されます。 そして、 .c ファイルをコンパイル・アセンブルすることによってオブジェクトファイルが生成され、それらをリンクすることで実行ファイルが生成されます。
すべての c ファイルを同時にコンパイルする必要はありません。 好きな c ファイルからコンパイルできます。 これは declaration 宣言のみが必要であり、具体的な定義はコンパイル時点ではいらないためです。
url: https://so-zou.jp/software/tech/programming/cpp/grammar/data-type/declaration/definition.htm
title: "宣言と定義の違い | C++ プログラミング解説"
host: so-zou.jp
プリプロセスでヘッダファイルとマクロを処理する
url: https://ylb.jp/2006b/proc/cpp/
title: "�ץ�ץ����å���Ư���Ȥ��λȤ���"
host: ylb.jp
pre-process は、コンパイラがソースコードをコンパイルする前に、プリプロセッサがソースコードを処理するステップです。
-E
オプションでプリプロセッサ機能だけを行えます。
gcc -E {hoge}.c
gcc -E main.c
を実行すると以下のようになります。
- コメント文はすべて破棄されている
- builtin ヘッダならびに include したヘッダが挿入されている
__vsprintf_chk
など stdio.h の宣言がそのまま展開されている
// main.c の `1` 行目を処理していることを表しています
# 1 "main.c"
// gcc コンパイラが自動的に組み込む builtin ヘッダ
# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 465 "<built-in>" 3
# 1 "<command line>" 1
# 1 "<built-in>" 2
# 1 "main.c" 2
// ここから stdio.h の読み込み
# 1 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/stdio.h" 1 3 4
# 61 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/stdio.h" 3 4
...
...
...
extern int __vsnprintf_chk (char * restrict , size_t __maxlen, int, size_t,
const char * restrict, va_list);
# 504 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/_stdio.h" 2 3 4
# 62 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/stdio.h" 2 3 4
// main.c の 2 行目に戻って来る
# 2 "main.c" 2
# 1 "./add.h" 1
# 20 "./add.h"
extern int globalW;
int add(int x, int y);
# 3 "main.c" 2
int globalW = 100;
int main(int argc, char **argv)
{
int sum = add(5, 8);
printf("Sum = %d\n", sum);
return 0;
}
プリプロセスしたコードは -o でファイルとして出力できます。
*.i
という拡張子が一般的です。
gcc -E main.c -o main.i
プリプロセスしたファイルさえあれば add.h といったヘッダファイルは必要なくなります。 というのもヘッダファイルはプリプロセス時に .i ファイルに埋め込まれるためです。 よって、コンパイル・アセンブル・リンク時にヘッダファイルがなくても動作するといえます。
コンパイルによってアセンブリコードを生成する
プリプロセッサによって前処理されたコードをコンパイラによってアセンブリコードに変換します。 assembly
gcc の -S
オプションでアセンブリコードの生成のみ行えます。
gcc -S {hoge}.c
下記コマンドによって、 main.s, add.s が生成されます。
この .s
がアセンブリコードです。
gcc -S main.c add.c
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 15, 0 sdk_version 15, 5
.globl _main ; -- Begin function main
.p2align 2
_main: ; @main
.cfi_startproc
; %bb.0:
sub sp, sp, #48
stp x29, x30, [sp, #32] ; 16-byte Folded Spill
add x29, sp, #32
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
mov w8, #0 ; =0x0
str w8, [sp, #8] ; 4-byte Folded Spill
stur wzr, [x29, #-4]
stur w0, [x29, #-8]
str x1, [sp, #16]
mov w0, #5 ; =0x5
mov w1, #8 ; =0x8
bl _add
str w0, [sp, #12]
ldr w8, [sp, #12]
; kill: def $x8 killed $w8
mov x9, sp
str x8, [x9]
adrp x0, l_.str@PAGE
add x0, x0, l_.str@PAGEOFF
bl _printf
ldr w0, [sp, #8] ; 4-byte Folded Reload
ldp x29, x30, [sp, #32] ; 16-byte Folded Reload
add sp, sp, #48
ret
.cfi_endproc
; -- End function
.section __DATA,__data
.globl _globalW ; @globalW
.p2align 2, 0x0
_globalW:
.long 100 ; 0x64
.section __TEXT,__cstring,cstring_literals
l_.str: ; @.str
.asciz "Sum = %d\n"
.subsections_via_symbols
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 15, 0 sdk_version 15, 5
.globl _doubled ; -- Begin function doubled
.p2align 2
_doubled: ; @doubled
.cfi_startproc
; %bb.0:
sub sp, sp, #16
.cfi_def_cfa_offset 16
str w0, [sp, #12]
ldr w8, [sp, #12]
lsl w0, w8, #1
add sp, sp, #16
ret
.cfi_endproc
; -- End function
.globl _add ; -- Begin function add
.p2align 2
_add: ; @add
.cfi_startproc
; %bb.0:
sub sp, sp, #32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
stur w0, [x29, #-4]
str w1, [sp, #8]
adrp x8, _globalW@GOTPAGE
ldr x8, [x8, _globalW@GOTPAGEOFF]
ldr w8, [x8]
str w8, [sp, #4] ; 4-byte Folded Spill
ldur w0, [x29, #-4]
bl _doubled
ldr w8, [sp, #4] ; 4-byte Folded Reload
add w8, w8, w0
ldr w9, [sp, #8]
add w0, w8, w9
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32
ret
.cfi_endproc
; -- End function
.subsections_via_symbols
url: https://blog.foresta.me/posts/view_assembly_code/
title: "アセンブリコードを眺めてみる"
description: "こんにちは、 @kz_morita です。前回の記事で「プログラムはなぜ動くのか?」C 言語から生成されたアセンブリを眺めていたらいろいろ知見があったので今日はそのことについてまとめていこうと思います。アセンブリコードをみてみる 今回対象とするのは、以下のような C 言語のソースコードです。 c という変数に、100 と 123 を足した結果を保持するだけのものになります。sample.c // 2つの引数の加算結果を返す関数 int add(int a, int b) { return a + b; } int main() { int c; c = add(100, 123); return 0; } これをアセンブリコードに変換するためには以下のコマンドを用います。$ gcc -S sample.c -o sample.s これを実行すると以下のようなアセンブリファイルが生成されます。.section __TEXT,__text,regular,pure_instructions .build_version macos, 10, 15 sdk_version 10, 15 .globl _add ## -- Begin function add .p2align 4, 0x90 _add: ## @add ."
host: blog.foresta.me
image: https://blog.foresta.me/images/eyecatch.png
ニーモニック
url: https://ja.wikipedia.org/wiki/%E3%82%A2%E3%82%BB%E3%83%B3%E3%83%96%E3%83%AA%E8%A8%80%E8%AA%9E#%E3%83%8B%E3%83%BC%E3%83%A2%E3%83%8B%E3%83%83%E3%82%AF
title: "アセンブリ言語 - Wikipedia"
host: ja.wikipedia.org
favicon: https://ja.wikipedia.org/static/favicon/wikipedia.ico
image: https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/Motorola_6800_Assembly_Language.png/960px-Motorola_6800_Assembly_Language.png
assembly において、機械語のビット列
に対応する文字列命令を ニーモニック とよびます。
これは、機械語における opcode であり、処理内容に応じて特定の文字列が与えられています。
たとえば、ADD
は加算のニーモニックです (特定の処理系において ADD
と表現しているだけであることに注意してください)。
mov
というなにか操作をおこなうことを表すニーモニックを opcode とよびます。
そして、オペコードが対象とする操作対象を operand と呼びます。
今回の GCC では gnu-assembler (GAS) というシンタックスを利用しており、
opcode {src} {dest}
という順序です。
mov x9, sp
move {src} {dest}
アセンブリコードにでてくる記号と用語の説明
- アドレス
[register, #num]
などで表される- register に記録されているアドレス + num
- ARM64 では、 アドレスに紐づくメモリには 1 byte のみ保存できるみたい
- そのため、 w0 = 8byte を保存するために
[sp, #8] [sp, #16]
のように 8 ごとに話すみたい - 各メモリには 1byte しか入らないので
- そのため、 w0 = 8byte を保存するために
- Syntax
[register, #num]
のように[]
で囲うとアドレスが指すメモリの中を表す- register,
#num
のように囲わなければ該当のアドレス自体を指す
- メモリページ
仮想メモリシステムがメモリを管理するために利用する固定サイズの連続したメモリブロック
- ARM64 では 4KB 単位のページサイズ
- メモリページの仮想アドレスを物理メモリの物理アドレスに OS が紐づける
- ページのなかにアドレスがある
- register
- CPU 内の記憶領域
- (ARM64 の場合)
w+%d
- 32bit = 4 byte らしい
- 例: w0, w1
x+%d
- 64bit = 8 byte らしい
- 例: x0, x1
- ただし、 w レジスタの場合、対応する番号の x レジスタの下位 32bit を読み取るらしい
- 実体としては x レジスタであり、8byte のうちの下位 4 byte を取る
- stack-pointer-register
sp
- メモリにおけるスタック領域の現在のポインタを指す、特別なレジスタ
- 引数やローカル変数、戻り値などを記録するレジスタ
- 即値
#16
など、#
をつけることでリテラルを定義できる
- アドレスの移動
[sp, #12]
で sp アドレスから#12
バイトだけ進めたメモリの内容を表す
- stack-frame
- 関数が実行されるたびに、その関数内で必要になるスタック領域のこと
add
関数であれば、 add 関数が実行されるときに add 関数用に新たに領域が確保される
- 関数が実行されるたびに、その関数内で必要になるスタック領域のこと
x29
frame-pointer- 現在実行している関数のスタックフレームの基準点 を保存しておくレジスタ
- add 関数において、 add 関数のローカル変数や引数に簡単にアクセスするためのアドレスを記録しておく
sp
レジスタは実行中に絶えず変化するため、記録用に x29 を使う
- x30 link-register
- 関数が終了したときに戻るべき命令のアドレスを記録する
- main 関数から add 関数を呼び出したときに、
add 関数において main 関数のどの部分へ戻ればよいか
を記録するため- main 関数のどこへ戻るか?がわからないと、 main 関数の最初から実行することになってしまう
double 関数から追ってみる
int doubled(int x)
{
return x * 2;
}
; -- 関数名 doubled
_doubled: ; @doubled
.cfi_startproc
; %bb.0:
; sp ← sp - 16 (16 バイトスタック領域を確保する)
; 16 バイトをこの関数で使うため
sub sp, sp, #16
.cfi_def_cfa_offset 16
; int double(int x) の引数 x は、関数実行時に自動的に w0 レジスタに格納されている
; str (store) で w0 = x の値を sp + 12, 13, 14, 15 というアドレスのメモリにコピーする
; sp + 12 が w0 の 1 byte 目
; sp + 13 が w0 の 2 byte 目...
str w0, [sp, #12]
; sp + 12, 13, 14, 15 というアドレスのメモリの中身を w8 レジスタにコピーする
; w0 は返り値でも使うため、 w0 を空けるために w8 をわざわざ経由してコピーしている
ldr w8, [sp, #12]
; w8 レジスタ (=x) を 1 だけ LogicalShiftLeft して *2 し、 w0 に代入する
lsl w0, w8, #1
; sp をもとに戻す
add sp, sp, #16
ret
.cfi_endproc
; -- End function
.globl _add ; -- Begin function add
.p2align 2
doubled 関数では sub sp, sp, #16
で sp を 16 バイト後ろにずらしています。
これは、スタックではメモリアドレスが大きい方から小さい方へ確保するのが一般的なためです。
下記のコードにおいて
- 大前提として x は w0 レジスタに自動的に格納されている
- w0 レジスタの値を
[sp, #12]
にコピーしたうえで、 w8 レジスタにコピー- w0 レジスタは返り値にも使うため
- lsl で二倍する
add sp, sp, #16
でもとに戻す
を行っています。
str w0, [sp, #12]
ldr w8, [sp, #12]
lsl w0, w8, #1
add sp, sp, #16
ret
add 関数を追ってみる
_add: ; @add
.cfi_startproc
; %bb.0:
; 32 byte 確保する
sub sp, sp, #32
; store pair で x29, x30 を同時にメモリに保存する
; x29 (フレームポインタ) レジスタの値を [sp, #16] ~ [sp, #23] へ
; x29 は `add 関数を呼び出した元の関数 (main)` のスタックフレームのアドレスが入っている
; x30 (リングレジスタ) レジスタの値を [sp, #24] ~ [sp, #31] へ
; x30 は `add 関数`が終わった後に戻るべき、`main 関数の add 関数呼び出し直後のアドレス`が入っている
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
; 読み出し元 = main 関数の x29, x30 は退避済み → x29 をこの add 関数のフレームポインタにして OK!
; x29 に (sp + #16) のアドレスを登録し、フレームポインタとする
; この add 関数の基準点を用意できる
add x29, sp, #16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
; w0 = x の値を ([x29, #-4] = [sp, #12]) ~ ([x29, #-1] = [sp, #15]) に保存する
stur w0, [x29, #-4]
; w1 = y の値を [sp, #8] ~ [sp, #11] に保存する
str w1, [sp, #8]
; adrp = Address-page-relative
; _globalWが含まれるメモリ`ページ`の先頭アドレスを実行時に取得して x8 レジスタに格納する_
; @GOTPAGE は Global Offset Table (グローバル変数・関数アドレステーブル) を使うよう指示している
; メモリページという大まかな単位で探す
adrp x8, _globalW@GOTPAGE
; x8 = globalW のメモリページアドレスに、 GOT 内での OFFSET を足し合わせて実際のメモリアドレスを求める
; → つまり、 x8 に globalW にアクセスするアドレスが入っている
ldr x8, [x8, _globalW@GOTPAGEOFF]
ldr w8, [x8]
; w8 (x8 の 32 bit) を [sp, #4] ~ [sp, #7] にいれる
str w8, [sp, #4] ; 4-byte Folded Spill
; [x29, -4] ~ [x29, -1] のメモリの値を読み出して、 w0 に代入する
; add(x, y) の x の値がすてに (x29, -4 ~ -1) に入っており、それを double 関数の x として渡すため w0 に load している
ldur w0, [x29, #-4]
; **`branch with link` で double 関数を呼び出す**
; w0 の値を渡し、2 倍された w0 が返ってくる
bl _doubled
; globalW ([sp, #4 ~ #7]) を w8 レジスタに格納する
ldr w8, [sp, #4] ; 4-byte Folded Reload
; w8 = w8 (globalW) + w0 (double(x))
add w8, w8, w0
; w9 に [sp, #8 ~ #11] = y の値を代入
ldr w9, [sp, #8]
; w0 = w8 (globalW + double(x)) + w9 (y)
add w0, w8, w9
; load pair
; [sp, #16 ~ #31] の値を読み取って x29, x30 = フレームポインタとリンクレジスタに設定する
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
; sp を元に戻す
add sp, sp, #32
; x30 のアドレスに戻る
ret
.cfi_endproc
main 関数が add 関数を呼び出したとします。 このとき、 add 関数内部において以下の処理がおこなわれています。
main 関数のフレームポインタとリターンアドレス
をadd 関数のスタックフレーム
に退避させる- 退避が完了したら、 add 関数の x29 = フレームポインタを設定する
- グローバル変数の場合は GlobalW@GOTPAGE によって動的にアドレスを取得する
- add 関数から double 関数を呼び出す
- このとき、 w0 レジスタに add(x, y) の x を値を代入しておく
- その後 Branch With Link = bl で double を呼び出す
- double(x) として w0 レジスタ経由で渡す
- double 関数は2倍した値を w0 レジスタに入れて返してくる
- main 関数に戻るために、
add 関数のスタックフレーム
に退避させておいたmain 関数のフレームポインタとリターンアドレス
を x29, x30 レジスタに戻す - sp スタックポインタを戻す
- ret する
add 関数内において、 main 関数のフレームポインタとリターンアドレス
を退避させるのですね。main 関数側でやると思っていました。
また、各メモリには 1 byte しか入らないのなるほどなぁと思いました。 int = 4byte を入れるために連続したメモリアドレスを 4 つ確保して 4 byte を入れるのですね。 だからこと little-endian と big-endian の違いがでてくるのか…。
main 関数を追ってみる
最後に main 関数のアセンブリも追ってみます。
_main: ; @main
.cfi_startproc
; %bb.0:
; sp を 48 byte 分移動させて main 用のスタックを確保する
sub sp, sp, #48
; `main 関数を呼び出した関数` の フレームポインタ・リターンアドレスを
; (sp, #32 ~ sp 39) と (sp, #40 ~ sp 47) にバックアップする
stp x29, x30, [sp, #32] ; 16-byte Folded Spill
; main 関数のフレームポインタを sp + #32 の位置にして基準点とする
add x29, sp, #32
; -- ret0 と argc, argv をメモリに格納する --
; [sp, #8] に 0 を値を代入しておく
; main 関数の返り値 0 の準備をしている
mov w8, #0 ; =0x0
str w8, [sp, #8] ; 4-byte Folded Spill
; wzr = zero レジスタ; 常に 0 を保持する特別なレジスタ
stur wzr, [x29, #-4]
; argc を [x29, -8] ~ [x29, -5] へ
stur w0, [x29, #-8]
; argv を [sp, #16] ~ [sp, #23] へ
str x1, [sp, #16]
; -- add 関数を実行する --
; w0 = 5, w1 = 8 を入れて bl で実行する
mov w0, #5 ; =0x5
mov w1, #8 ; =0x8
bl _add
; 結果を [sp, #12 ~ #19] に入れたうえで、 w8 レジスタに格納する
str w0, [sp, #12]
ldr w8, [sp, #12]
; kill: def $x8 killed $w8
; x9 レジスタに sp アドレスを入れる
mov x9, sp
; x8 レジスタの値 (add の結果) を sp アドレスのメモリに入れている
str x8, [x9]
; l_.str のメモリアドレスを特定し x0 に格納する
adrp x0, l_.str@PAGE
add x0, x0, l_.str@PAGEOFF
; 可変長引数である printf は sp をつかって、第 2 引数以降を受け取る
; そのために x8 レジスタの値を [x9=sp] に格納している
bl _printf
; [sp, #8] = wzr の 値 0 を取り出して返り値にいれる
ldr w0, [sp, #8] ; 4-byte Folded Reload
; main 関数を呼び出した関数のフレームポインタとリターンアドレスを復旧する
ldp x29, x30, [sp, #32] ; 16-byte Folded Reload
add sp, sp, #48
; w0 = 0 を返して終わり
ret
.cfi_endproc
; -- End function
.section __DATA,__data
.globl _globalW ; @globalW
.p2align 2, 0x0
_globalW:
.long 100 ; 0x64
.section __TEXT,__cstring,cstring_literals
l_.str: ; @.str
.asciz "Sum = %d\n"
add 関数と同様の処理がおこなわれています。 異なる点として
_globalW
の値が定義されているl_.str
として定数リテラルが定義されている
とい違いがあります。
アセンブラディレクティブを追ってみる
url: https://ja.wikipedia.org/wiki/%E3%82%A2%E3%82%BB%E3%83%B3%E3%83%96%E3%83%AA%E8%A8%80%E8%AA%9E#%E3%82%A2%E3%82%BB%E3%83%B3%E3%83%96%E3%83%AA%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83%86%E3%82%A3%E3%83%96
title: "アセンブリ言語 - Wikipedia"
host: ja.wikipedia.org
favicon: https://ja.wikipedia.org/static/favicon/wikipedia.ico
image: https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/Motorola_6800_Assembly_Language.png/960px-Motorola_6800_Assembly_Language.png
url: https://www.swlab.cs.okayama-u.ac.jp/~nom/lect/p3/what-is-directive.html
title: "アセンブラ指令 - 2024年度 システムプログラミング"
host: www.swlab.cs.okayama-u.ac.jp
url: https://www.gaio.co.jp/gaioclub/compiler_blog09/
title: "【第9回】メモリマップ | ガイオ・テクノロジー ソフト検証ツール/モデルベース/エンジニアリングサービス プロバイダー"
description: "ガイオ・テクノロジー株式会社は、自動車開発の新しいトレンドに対応するソフトウェア開発・検証を徹底支援します。"
host: www.gaio.co.jp
favicon: https://www.gaio.co.jp/favicon.ico
アセンブラがアセンブリ実行中に読み出して実行する疑似命令のことを assembler-directive とよびます。 命令と異なり、アセンブラに指示を与えるためにあります。
有名なものとして .text
と .data
があります。
.text
は immutable であるため、プログラム命令自体が .text になることが多く、 ROM に直接載せられます。異なるプロセスと共有できます。
一方、 .data
は変数など、異なるプロセスと共有できないものになります。
; .section セクションを定義することで OS がプログラムを読み込むときに権限を正しく設定できる
; __TEXT__ のため、実行される命令であり ReadOnly メモリに配置される
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 15, 0 sdk_version 15, 5
; 指定されたシンボルがほかファイルから参照ファイルであることを伝える
; _main はプログラムのエントリポイント_
.globl _main ; -- Begin function main
.p2align 2
; __DATA であることを定義する
; 読み書き可能なメモリに配置される
.section __DATA,__data
.globl _globalW ; @globalW
.p2align 2, 0x0
; globalW というラベル(変数)を定義して、 100 を格納する
_globalW:
.long 100 ; 0x64
; __TEXT であり Readonly メモリに配置される
.section __TEXT,__cstring,cstring_literals
l_.str: ; @.str
.asciz "Sum = %d\n"
.subsections_via_symbols
C ソースコードは .text + .text (ROM) と .data (RAM) に分けられます。
このとき、section
という塊ごとにまとめられます。
そして、アセンブリコードのセクションを linker によってリンクするときにどのアドレスにどのセクションを配置するかを決めます。
(補足) ここまでアセンブリコードを読んできて思った疑問をまとめる
スタックフレームは関数ごとにどう積まれるのか?アドレスはどう振られるのか?
url: https://www.gaio.co.jp/gaioclub/compiler_blog10/
title: "【第10回】スタックフレーム | ガイオ・テクノロジー ソフト検証ツール/モデルベース/エンジニアリングサービス プロバイダー"
description: "ガイオ・テクノロジー株式会社は、自動車開発の新しいトレンドに対応するソフトウェア開発・検証を徹底支援します。"
host: www.gaio.co.jp
favicon: https://www.gaio.co.jp/favicon.ico
通常、StartUp で指定されたアドレスから 0 番地方向に向かって使用します。スタックフレームの量は関数呼出しで増え、リターンで減ります。今現在どこまでスタックを使ったかを管理するポインタをスタックポインタと言います。スタックポインタは CPU が持つレジスタです。レジスタはメモリとは別の記憶域で、高速にアクセスができ、主に機械語の演算命令で使用されます。レジスタは CPU 毎に大きさ(ビット長)や数が決まっています。スタックポインタは通称 SP と呼びますが、SP という名称のレジスタがある場合と無い場合があります。SP レジスタが無くても、他のレジスタが SP の代わりをしています。
上記サイトにあるように、 0xFFFFFFFF (32bit) アドレスから 0 番地方面へ向かって使用するケースで考えます。
func1 から func2 を呼び出すときに、 func2 用のスタックフレームが「より 0 番地へ近づく」方面へ積まれています。
コンパイラ作業域
と記されている領域においてローカル変数の確保などがおこなわれています。
戻り番地などが x29, x30 に該当するとおもいます。
上位アドレス
とは 0xFFFFFFFF を指し、下位アドレスは 0 番地を指します。
よって、スタックは上位アドレス (0xFFFFFFFF) から下位アドレス (0) へ向かっていきます。
スタックの多くが上位アドレスから下位アドレスへ向かう設計が多いのは、メモリ管理を効率化するためらしいです (gemini)。 ヒープ領域はメモリの下位アドレス側から確保し、スタック領域はメモリの上位アドレス側から確保するようにすることで、メモリ空間を最大限利用できます。
https://www.gaio.co.jp/gaioclub/compiler_blog11/
今回見てきたアセンブリコードにおいても sub sp, sp, #48
のように、下位アドレスへ向かってスタックフレームを確保していることがわかります。
アセンブリコードからオブジェクトファイルを生成する
gcc -c オプションによって preprocess, compile, assemble を実行し、 object-file を生成できます。 今回はアセンブリコードを渡してオブジェクトファイルを作ってもらいます。
gcc -c ${hoge}
-c Only run preprocess, compile, and assemble steps
オブジェクトファイルを生成する時点においては linker によるリンク処理はおこなわれません。 そのため、他ファイルにあるグローバル変数や関数については declaration 宣言のみを参照しており未解決のシンボルのままとしておきます。 そのため、1 ファイルごとにオブジェクトファイルを生成できます。
make においてソースコードを変更していない場合ビルドをスキップしますが、ソースコードとオブジェクトファイルのタイムスタンプを比較してソースコードのほうが新しければ該当ファイルのみビルドする、ということで実現しています。
playground/c-compile-assemble on feature/learn-c-compile-assemble [!?] via C v17.0.0-clang [☁️ ]
❯ gcc -c main.s
# main.o が作成される
playground/c-compile-assemble on feature/learn-c-compile-assemble [!?] via C v17.0.0-clang [☁️ ]
❯ gcc -c add.s
# add.s が作成される
*.o
オブジェクトファイルはバイナリであり人間はそのままでは解読できません。
オブジェクトファイルをコマンドによって中身を確認する
url: https://ja.wikipedia.org/wiki/%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB
title: "オブジェクトファイル - Wikipedia"
host: ja.wikipedia.org
favicon: https://ja.wikipedia.org/static/favicon/wikipedia.ico
hexdump を使ってみる で main.o を確認すると、 .text
や .data
などの assembler-directive , _main
などのラベルが確認できます。
objdump を使ってみる では objdump によって逆アセンブルした assembly コードを確認できます。
nm ではオブジェクトファイルのシンボルテーブルを確認できます。
_main
が 0x0 番地にあり、 .text
であることがわかります。
また、 _printf
が U = Undefined 未定義であることもわかります。
l_.str
は 0x5c 番地にあり local symbol であることがわかります。
❯ nm main.o
U _add
0000000000000058 D _globalW
0000000000000000 T _main
U _printf
000000000000005c s l_.str
0000000000000000 t ltmp0
0000000000000058 d ltmp1
000000000000005c s ltmp2
0000000000000068 s ltmp3
オブジェクトファイルをリンクして実行可能プログラムを生成する
オブジェクトファイルを linker でリンクして実行可能プログラム作成します。
main.o だけを引き渡してリンクしようとするとエラーが発生します。
main.o の _main
ラベルから呼び出そうとしている _add
が未解決とのことです。
add ラベルを main.o 内で bl
で呼び出しており、確かにこのラベルは未解決の状態です。
❯ gcc main.o
Undefined symbols for architecture arm64:
"_add", referenced from:
_main in main.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
bl _add
main.o にくわえて、 add.o も同時にリンクしてみます。
すると、無事正常にリンクでき a.out
が作成されます。
playground/c-compile-assemble on feature/learn-c-compile-assemble via C v17.0.0-clang [☁️ ]
❯ gcc main.o add.o
playground/c-compile-assemble on feature/learn-c-compile-assemble via C v17.0.0-clang [☁️ ]
❯ ./a.out
Sum = 118
生成された実行可能プログラムは Mach-O という形式フォーマットです。
playground/c-compile-assemble on feature/learn-c-compile-assemble via C v17.0.0-clang [☁️ ]
❯ file a.out
a.out: Mach-O 64-bit executable arm64
Mach-O フォーマットについて調べる で a.out の中身を詳しくみています。
リンクするときになにがおこなわれるか
url: https://zenn.dev/watahaya/articles/570239cf5b7672
title: "C言語で学ぶリンカーとシンボル解決のプロセス"
host: zenn.dev
image: https://res.cloudinary.com/zenn/image/upload/s--j8OsANPQ--/c_fit%2Cg_north_west%2Cl_text:notosansjp-medium.otf_55:C%25E8%25A8%2580%25E8%25AA%259E%25E3%2581%25A7%25E5%25AD%25A6%25E3%2581%25B6%25E3%2583%25AA%25E3%2583%25B3%25E3%2582%25AB%25E3%2583%25BC%25E3%2581%25A8%25E3%2582%25B7%25E3%2583%25B3%25E3%2583%259C%25E3%2583%25AB%25E8%25A7%25A3%25E6%25B1%25BA%25E3%2581%25AE%25E3%2583%2597%25E3%2583%25AD%25E3%2582%25BB%25E3%2582%25B9%2Cw_1010%2Cx_90%2Cy_100/g_south_west%2Cl_text:notosansjp-medium.otf_37:Isco%2Cx_203%2Cy_121/g_south_west%2Ch_90%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYXZhdGFyL2Y2MDllZGQ4M2QuanBlZw==%2Cr_max%2Cw_90%2Cx_87%2Cy_95/v1627283836/default/og-base-w1200-v2.png
url: https://tanakamura.github.io/pllp/docs/linker.html
title: "リンカ"
host: tanakamura.github.io
https://shop.cqpub.co.jp/hanbai/books/38/38071/38071.pdf
playground/c-compile-assemble on feature/learn-c-compile-assemble via C v17.0.0-clang [☁️ ]
❯ nm add.o main.o
add.o:
0000000000000018 T _add
0000000000000000 T _doubled
U _globalW
0000000000000000 t ltmp0
0000000000000060 s ltmp1
main.o:
U _add
0000000000000058 D _globalW
0000000000000000 T _main
U _printf
000000000000005c s l_.str
0000000000000000 t ltmp0
0000000000000058 d ltmp1
000000000000005c s ltmp2
0000000000000068 s ltmp3
オブジェクトファイル (*.o
) には symbol-table が用意されています。
これは「 symbol = 関数・変数の識別ラベル」とその相対アドレスをまとめたテーブルです。
未解決のシンボルも存在し、リンクするときに解決することを期待しています。
リンク時において、未解決のシンボルがあればそのシンボルを含む他オブジェクトファイルを参照するようにします。 そして、すべてのシンボルについて解決できたら、最終的な仮想アドレスを決定します。
アセンブリコードにおいては、 _add
という識別子の関数を呼び出してね、という識別子の参照でした。
しかし、リンク後の実行可能プログラムにおいては _add
という識別子は消されて 相対アドレス がそのまま記載されます。
_add
関数を呼び出している箇所から add 関数自体への距離がリンク時に計算され、そのアドレス差が入力されます。
mov w0, #5 ; =0x5
mov w1, #8 ; =0x8
bl _add
動的リンクと静的リンク
playground/c-compile-assemble on feature/learn-c-compile-assemble via C v17.0.0-clang [☁️ ]
❯ otool -L a.out
a.out:
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1351.0.0)
playground/c-compile-assemble on feature/learn-c-compile-assemble via C v17.0.0-clang [☁️ ]
❯ nm a.out
0000000100000000 T __mh_execute_header
0000000100000568 T _add
0000000100000550 T _doubled
0000000100008000 D _globalW
00000001000004f8 T _main
U _printf
生成された a.out について otool で shared-library の情報を見るとわかりますが、 libSystem.B.dylib というものに依存しています。また、 printf 関数は未解決のままです。
未解決シンボルを解決するために、OS のローダーがプログラムをメモリへロードするときに、共有ライブラリを動的にリンクします。 そして、リンクしたうえでファイルを実行します。
動的リンクは実行時に共有ライブラリをリンクするものであり、動的ロードの場合は実行時にプログラム側から明示的に dlopen() という関数を呼び出すことでロードします。この違いに注意します。
url: https://qiita.com/argama147/items/2f636a2f4fd76f6ce130
title: "ライブラリのリンク方法をきっちり区別しよう - Qiita"
description: "初めに ライブラリのリンク方法は3種類に分けられます。 「動的ライブラリ」や「動的リンク」といったキーワードでネット検索すると、3種類全てを説明しているサイトがかなり少ない印象を受けます。 そこで「ライブラリのリンク方法」、という切り口で整理してみます。 OSの対象は、W..."
host: qiita.com
favicon: https://cdn.qiita.com/assets/favicons/public/production-c620d3e403342b1022967ba5e3db1aaa.ico
image: https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-user-contents.imgix.net%2Fhttps%253A%252F%252Fcdn.qiita.com%252Fassets%252Fpublic%252Farticle-ogp-background-afbab5eb44e0b055cce1258705637a91.png%3Fixlib%3Drb-4.0.0%26w%3D1200%26blend64%3DaHR0cHM6Ly9xaWl0YS11c2VyLXByb2ZpbGUtaW1hZ2VzLmltZ2l4Lm5ldC9odHRwcyUzQSUyRiUyRnFpaXRhLWltYWdlLXN0b3JlLnMzLmFwLW5vcnRoZWFzdC0xLmFtYXpvbmF3cy5jb20lMkYwJTJGNjk2NzglMkZwcm9maWxlLWltYWdlcyUyRjE2MzI0MTk3MjY_aXhsaWI9cmItNC4wLjAmYXI9MSUzQTEmZml0PWNyb3AmbWFzaz1lbGxpcHNlJmJnPUZGRkZGRiZmbT1wbmczMiZzPTFkZWZhOThhMzRmOGZkNGNhY2M4MGU4OTQ5NWQ5ZTEw%26blend-x%3D120%26blend-y%3D467%26blend-w%3D82%26blend-h%3D82%26blend-mode%3Dnormal%26s%3D7e5ec15ebb665d4f902b49e505a391a5?ixlib=rb-4.0.0&w=1200&fm=jpg&mark64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZ3PTk2MCZoPTMyNCZ0eHQ9JUUzJTgzJUE5JUUzJTgyJUE0JUUzJTgzJTk2JUUzJTgzJUE5JUUzJTgzJUFBJUUzJTgxJUFFJUUzJTgzJUFBJUUzJTgzJUIzJUUzJTgyJUFGJUU2JTk2JUI5JUU2JUIzJTk1JUUzJTgyJTkyJUUzJTgxJThEJUUzJTgxJUEzJUUzJTgxJUExJUUzJTgyJThBJUU1JThDJUJBJUU1JTg4JUE1JUUzJTgxJTk3JUUzJTgyJTg4JUUzJTgxJTg2JnR4dC1hbGlnbj1sZWZ0JTJDdG9wJnR4dC1jb2xvcj0lMjMxRTIxMjEmdHh0LWZvbnQ9SGlyYWdpbm8lMjBTYW5zJTIwVzYmdHh0LXNpemU9NTYmdHh0LXBhZD0wJnM9MzQyMTc0OTFmZDBmMTRhNmM2NzkyODc5YmVlNTQwMTY&mark-x=120&mark-y=112&blend64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZ3PTgzOCZoPTU4JnR4dD0lNDBhcmdhbWExNDcmdHh0LWNvbG9yPSUyMzFFMjEyMSZ0eHQtZm9udD1IaXJhZ2lubyUyMFNhbnMlMjBXNiZ0eHQtc2l6ZT0zNiZ0eHQtcGFkPTAmcz1jODc0YWI2ZjM3MGE3M2Q0YTUxOWJhY2JhNTU5ZWRkMQ&blend-x=242&blend-y=480&blend-w=838&blend-h=46&blend-fit=crop&blend-crop=left%2Cbottom&blend-mode=normal&s=2d958cf02ca9e95164798ef4398d9ce6
これらのことから、 今回生成した a.out は動的リンクをつかった実行可能ファイルであることがわかります。
静的リンクをしたい場合は -static をつけてリンクを行います。 ただし、 Mac (clang) の場合は crt0.o という静的リンクに必要なスタートアップファイルを提供していないため、静的リンクが行えないようです。
実行可能ファイルの中身を見る
ソースコードから実行ファイルが生成されるまでを見てきました。 続いて実行ファイルの中身を見てみます。
objdump で中身を見る
objdump を使ってみる objdump で逆アセンブルしてみます。
main 関数において、100000520: 94000012 bl 0x100000568 <_add>
のように add 関数を呼び出しています。
ここで bl 0x100000568 と絶対アドレスで表示されていますが、これは objdump が気を利かせて相対アドレスを絶対アドレスへ変換しているようです。
下記の記事でもアドレスが正規化されている旨が触れられてします。
url: https://yuma.ohgami.jp/Introduction-to-x86_64-Machine-Language/01_setup.html
title: "環境構築とはじめてのプログラム | 作って分かる!x86_64機械語入門"
host: yuma.ohgami.jp
1 つの実行可能ファイルにリンクされたことによって、 main, doubled, add 関数が連続したアドレス空間に並べられたことがわかります。 そして、これらプログラム命令にくわえて .data データも連続したアドレスに割り振られています。 #ノイマン型コンピュータ の設計をあらためて再認識できました。
url: https://ja.wikipedia.org/wiki/%E3%83%8E%E3%82%A4%E3%83%9E%E3%83%B3%E5%9E%8B
title: "ノイマン型 - Wikipedia"
host: ja.wikipedia.org
favicon: https://ja.wikipedia.org/static/favicon/wikipedia.ico
image: https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/Von_Neumann_Architecture.svg/1200px-Von_Neumann_Architecture.svg.png
playground/c-compile-assemble on main via C v17.0.0-clang [☁️ ]
❮ objdump -d a.out
a.out: file format mach-o arm64
Disassembly of section __TEXT,__text:
00000001000004f8 <_main>:
1000004f8: d100c3ff sub sp, sp, #0x30
1000004fc: a9027bfd stp x29, x30, [sp, #0x20]
100000500: 910083fd add x29, sp, #0x20
100000504: 52800008 mov w8, #0x0 ; =0
100000508: b9000be8 str w8, [sp, #0x8]
10000050c: b81fc3bf stur wzr, [x29, #-0x4]
100000510: b81f83a0 stur w0, [x29, #-0x8]
100000514: f9000be1 str x1, [sp, #0x10]
100000518: 528000a0 mov w0, #0x5 ; =5
10000051c: 52800101 mov w1, #0x8 ; =8
100000520: 94000012 bl 0x100000568 <_add>
100000524: b9000fe0 str w0, [sp, #0xc]
100000528: b9400fe8 ldr w8, [sp, #0xc]
10000052c: 910003e9 mov x9, sp
100000530: f9000128 str x8, [x9]
100000534: 90000000 adrp x0, 0x100000000 <_printf+0x100000000>
100000538: 9116f000 add x0, x0, #0x5bc
10000053c: 9400001d bl 0x1000005b0 <_printf+0x1000005b0>
100000540: b9400be0 ldr w0, [sp, #0x8]
100000544: a9427bfd ldp x29, x30, [sp, #0x20]
100000548: 9100c3ff add sp, sp, #0x30
10000054c: d65f03c0 ret
0000000100000550 <_doubled>:
100000550: d10043ff sub sp, sp, #0x10
100000554: b9000fe0 str w0, [sp, #0xc]
100000558: b9400fe8 ldr w8, [sp, #0xc]
10000055c: 531f7900 lsl w0, w8, #1
100000560: 910043ff add sp, sp, #0x10
100000564: d65f03c0 ret
0000000100000568 <_add>:
100000568: d10083ff sub sp, sp, #0x20
10000056c: a9017bfd stp x29, x30, [sp, #0x10]
-D option
Disassembly of section __TEXT,__cstring:
00000001000005bc <__cstring>:
1000005bc: 206d7553 <unknown>
1000005c0: 6425203d fmul z29.h, z1.h, z5.h[0]
1000005c4: 0a 00 <unknown>
Disassembly of section __DATA,__data:
0000000100008000 <_globalW>:
100008000: 00000064 udf #0x64
otool で中身を見る
Mach-O フォーマットについて調べる で otool を使いました。
lldb で実行可能ファイルをデバッグする
gdb & lldb のつかいかた で実行可能ファイルをデバッグしました。
lldb を使うことで実行可能ファイルをインタラクティブにデバッグできます。
まとめ
- C 言語ソースコードがどういうフローで実行可能ファイルになるのか
- スタックフレームがどう扱われているのか
を少しずつ理解しながら追うことができました。
まだまだ知識不足であるため、CTF の問題を解きながら実践的に補っていこう・理解して行こうとおもいます。
この Note を Zenn にまとめながら、移していく、を次にやっていきます。