はじめに

(Full Weak Engineer CTF 2025) Pwn Me Baby を学びながら解く

Pwn Me Baby を解くために

  • C 言語自体がどう実行されるかの知識
  • C 言語の関数がどう呼ばれるかの知識

が必要です。

それについて調べながら学びます。

前提

このノートにおいて Mac M4 (arm64) で検証しています。

そのため、

  • gcc コマンドの実体が clang
  • 実行可能ファイルの形式が Mach-O など Linux と異なるためご注意ください。

参考サイト

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 は、コンパイラがソースコードをコンパイルする前に、プリプロセッサがソースコードを処理するステップです。

ylb.jp さんから参照

-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
main.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
add.s
	.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 しか入らないので
    • 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

Imgur

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-endianbig-endian の違いがでてくるのか…。

main 関数を追ってみる

最後に main 関数のアセンブリも追ってみます。

main.asm
_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 によってリンクするときにどのアドレスにどのセクションを配置するかを決めます。

Imgur

Imgur

(補足) ここまでアセンブリコードを読んできて思った疑問をまとめる

スタックフレームは関数ごとにどう積まれるのか?アドレスはどう振られるのか?

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 番地方面へ向かって使用するケースで考えます。

Imgur

func1 から func2 を呼び出すときに、 func2 用のスタックフレームが「より 0 番地へ近づく」方面へ積まれています。 コンパイラ作業域 と記されている領域においてローカル変数の確保などがおこなわれています。 戻り番地などが x29, x30 に該当するとおもいます。

上位アドレス とは 0xFFFFFFFF を指し、下位アドレスは 0 番地を指します。 よって、スタックは上位アドレス (0xFFFFFFFF) から下位アドレス (0) へ向かっていきます。

スタックの多くが上位アドレスから下位アドレスへ向かう設計が多いのは、メモリ管理を効率化するためらしいです (gemini)。 ヒープ領域はメモリの下位アドレス側から確保し、スタック領域はメモリの上位アドレス側から確保するようにすることで、メモリ空間を最大限利用できます。

Imgur 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 などのラベルが確認できます。

Imgur Imgur

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)
main.s
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 について otoolshared-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 にまとめながら、移していく、を次にやっていきます。