
【pwn】 IERAE CTF 2025 公式 Writeup
本記事では2025年6月21日~22日に開催された、IERAE CTF 2025のpwn問題の解法を解説します。
- [warmup] Length Calculator
- [easy] Stdio Studio
- [medium] Gotcha-Go
- [medium] Copy & Paste 2
- [hard] vibexec
- [extreme] The World
他のジャンルの解説は以下の記事をご覧ください:
[warmup] Length Calculator
作問者: hugeh0ge
正解チーム数: 313
問題文: This is my first C code. Why is this language so bothering when handling strings???
概要
以下のように、入力したサイズのメモリを確保した上で、そこに文字列を保存し、文字列の長さを計測してくれるプログラムです。
$ ./chal
Enter size: 10
Input: aaaa
Your string length: 4
Enter size:
コメントでも書かれている通り、SEGV(プログラムのクラッシュ)を引き起こせば、フラグが出力されます。
void win(int sig) {
puts("Well done!");
system("cat ./flag*");
exit(0);
}
int main() {
// If you cause SEGV, then you will get flag
signal(SIGSEGV, win);
setbuf(stdout, NULL);
バグ
結論からいうと、文字列を処理している箇所は、適切に実装されており、SEGVを引き起こすようなバグはありません。
printf("Input: ");
fgets(buf, size, stdin);
buf[strcspn(buf, "\n")] = '\0';
printf("Your string length: %d\n", strlen(buf));
したがって、メモリを確保している箇所を見てみます。
unsigned int size = 100;
printf("Enter size: ");
scanf("%u%*c", &size);
char *buf = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
if (!buf) {
puts("Too large!");
exit(1);
}
普段C言語を書いている人にとっては、動的メモリの確保をmallocではなくmmapで行っていることが違和感を感じる点でしょう。
ただ、mmapに渡している引数自体は問題がなく(※1)、メモリ確保が失敗していないかも、mmapの戻り値がNULLであるかを調べることで確認しており、一見問題ないように見えます。
※1: sizeがalignされていないことは仕様上は問題ですが、実際には問題なく動作します。
しかし、実はこの部分にSEGVを引き起こすバグが隠されています。
mmapのmanを読んでみると、mmapの戻り値は以下のように定義されています。
On success, mmap() returns a pointer to the mapped area. On error, the value MAP_FAILED (that is, (void *) -1) is returned, and errno is set to indicate the error.
つまり、メモリ確保に失敗したとき、mallocのようにNULL(通常は (void *) 0
)が返ってくるわけではなく、(void *) -1
が返ってきます。そのため、!buf
によってメモリ確保が失敗しているかを確認するのは誤りです。
結果としてこのプログラムは、メモリ確保が失敗している状態でも、文字列を入力しようと試みるため、そのタイミングでSEGVを引き起こすことが可能です。
解法
どのようにしてメモリ確保を失敗させればいいかを考えます。
単純に考えると、大きなサイズを入力すれば失敗するように思えますが、実はその方針はうまくいきません。
デフォルトのmmapの挙動においては、mmapを呼び出してメモリ領域を作成した際に、物理メモリが確保されるわけではありません。4096バイトのページ単位で、そのメモリ領域のページに初めてアクセスした際、対応する物理メモリが確保されます。
このため、実際に使用できる物理メモリが、指定されているサイズよりも少なかったとしても、mmapは成功してしまいます。また、そのような状況で、すべてのページにアクセスしていき、実際に物理メモリを確保させていったとしても、最終的にはOut of Memoryによりプロセスがkillされるだけであり、SEGVは引き起こされません。
ではどのようなサイズであればmmapは失敗するのでしょうか。manを読んでみると、mmapでは以下のような取り決めがなされています。
ERRORS
…
EINVAL (since Linux 2.6.12) length was 0.
つまり、サイズとして0を入力すれば、mmapが失敗することが保証されています。よって、プログラムに0を入力するだけで、フラグを得ることができます。
$ ./chal
Enter size: 0
Input: Well done!
IERAE{dummy flag for test}
[easy] Stdio Studio
作問者: hugeh0ge
正解チーム数: 57
問題文: Get familiar with stdio first to tackle with pwn challenges!
概要
2つのコマンドが提供されているプログラムです。
1. Load flag
2. Echo
Enter command: 1
Enter command: 2
Size: 20
Input: hello
Output: hello
Enter command:
1番のコマンドでは、フラグがスタック上にロードされますが、以下のコードのとおり、特に出力されることはない上に、関数の最後にはmemsetによって内容をクリアされてしまいます。
void load_flag() {
char flag[128] = "";
FILE *fp = fopen("flag.txt", "rb");
if (!fp) {
puts("Something went wrong. Call admin.");
exit(1);
}
fread(flag, sizeof(char), 128, fp);
fclose(fp);
// puts(flag); // Sorry! No flag for you!
memset(flag, 0, 128); // The secret should be cleared up
}
2番のコマンドでは、入力した文字列がそのまま出力されます。
void echo(void) {
unsigned int size;
char *buf;
printf("Size: ");
scanf("%u%*c", &size);
buf = alloca(size);
if (!buf) {
puts("Too large!");
exit(1);
}
printf("Input: ");
fgets(buf, size, stdin);
sleep(1);
printf("Output: %s\n", buf);
}
文字列を入力するバッファが、allocaによりスタック上に存在することは、特筆すべき点です。
脆弱性
このプログラムには2つの脆弱性があります。
まず、メモリの内容の消去に、memsetを利用すべきではないことが知られています。
コンパイラの最適化の影響により、memsetの呼び出しが削除される可能性があるためです。
今回のプログラムにおいても、memsetでクリアされるはずだった配列 flag
は、それ以降利用されていません。そのため、最適化によりmemsetも不要と判断され、実際のバイナリの中ではmemsetは呼び出されません。
すなわち、1番のコマンドの呼び出し後、フラグはメモリに残り続けることが分かります。
もう1つの脆弱性は、2番のコマンドにおいて、fgetsが成功したかを確認していないことです。もし、fgetsで文字列が正常に入力されなかった場合、後続のprintfによって、未初期化のbuf
が出力されてしまい、メモリ内容の漏洩 (リーク) につながる可能性があります。
解法
これらの脆弱性を組み合わせて、2番のコマンドによりフラグを漏洩させることを考えます。
まず前述したように、1番のコマンドを呼び出せば、無条件でフラグをメモリに残置することができます。
次にこれを2番のコマンドで取得します。文字列を入力するバッファはallocaにより確保されるため、バッファのサイズをうまく調整することにより、フラグが存在している位置に重ね合わせることが可能です。デバッガで確認するなどの方法を用いれば、サイズは80に設定すべきことが分かります。
最後に、fgetsを失敗させ、バッファがフラグをそのまま含んでいる状態にします。
fgetsは、1文字でも入力してしまうと、入力の最後がヌル文字で終端されることが保証されているため、フラグを漏洩させることが不可能になってしまいます。しかし、通常は、1文字以上を入力しないとfgetsは終了しません。
ではどのようにすればよいかというと、1文字も入力しない状態でEOF (End of File) を発生させます。プログラムをターミナルで実行している場合は、Ctrl+Dを入力することによって、EOFを発生させられることが知られています。
$ ./chal
1. Load flag
2. Echo
Enter command: 1
Enter command: 2
Size: 80
Input: [Ctrl+D]Output: IERAE{dummy flag for test}
実際にはTCP経由で接続しているリモートサーバーに対してEOFを発生させる必要があります。Linuxにおいては、shutdownと呼ばれるシステムコールを利用することで、これが可能になります。各言語の標準的なライブラリでも、この機能は提供されています。一例として、Pythonで実装する場合には、以下のようになります。
from socket import *
p = socket(AF_INET, SOCK_STREAM)
p.connect(('35.187.219.36', 33335))
p.send(b'1\n')
p.send(b'2\n')
p.send(b'80\n')
p.shutdown(SHUT_WR)
print(p.recv(1024))
print(p.recv(1024))
print(p.recv(1024))
[medium] Gotcha-Go
作問者: tsuneki
正解チーム数: 25
問題文:
Isn’t Go a safe language?
概要
Go言語で書かれたノートアプリです。imfo,init,editの3つのメソッドを持つインターフェース型が用意されており、MyStr, MyList構造体でノートを管理しています。
$./ctf
Option (1.Init, 2.Info, 3.Edit):
1
Idx):
0
Hi everyone!!
Option (1.Init, 2.Info, 3.Edit):
2
Idx):
0
Hi everyone!!
脆弱性
プログラムの脆弱性はMyList構造体のメソッドにある範囲外参照(Out-of-Bound)です。
func (m *MyList) info(idx int) {
m.list[idx].info(idx)
}
func (m *MyList) init(ptr *MyStr) {
m.list[ptr.idx] = ptr
}
func (m *MyList) edit(idx int) {
m.list[idx].edit(idx)
}
なお、Go言語ではコンパイルオプションを特に指定しなければ配列へのアクセス時にインデックスのvalidationがあります。
解法
oobが発生するMyList構造体の変数l
と同じく.data領域にあるitab vtableを破壊することでripを取得します。
pwndbg> x/gx 0x0000000005669C0
0x5669c0 <main.l>: 0x00000000004e51a8
pwndbg> tel 0x00000000004e51a8
00:0000│ 0x4e51a8 (go:itab.*main.MyList,main.Data) —▸ 0x4ac020 (type:*+61472) ◂— 0x10
01:0008│ 0x4e51b0 (go:itab.*main.MyList,main.Data+8) —▸ 0x4ac120 (type:*+61728) ◂— 8
02:0010│ 0x4e51b8 (go:itab.*main.MyList,main.Data+16) ◂— 0x4a3ba2dc
03:0018│ 0x4e51c0 (go:itab.*main.MyList,main.Data+24) —▸ 0x49c100 (main.(*MyList).edit) ◂— cmp rsp, qword ptr [r14 + 0x10]
04:0020│ 0x4e51c8 (go:itab.*main.MyList,main.Data+32) —▸ 0x49c060 (main.(*MyList).info) ◂— cmp rsp, qword ptr [r14 + 0x10]
05:0028│ 0x4e51d0 (go:itab.*main.MyList,main.Data+40) —▸ 0x49c0a0 (main.(*MyList).init) ◂— cmp rsp, qword ptr [r14 + 0x10]
06:0030│ 0x4e51d8 (go:itab.*fmt.pp,fmt[State]) —▸ 0x4ad420 (type:*+66592) ◂— 0x10
07:0038│ 0x4e51e0 (go:itab.*fmt.pp,fmt[State]+8) —▸ 0x4b94a0 (type:*+115872) ◂— 8
go言語の特性として、ビルドされたバイナリはPIEではなく、なおかつ静的リンクであるため、ripを破壊することで多様な関数を呼び出すことが可能です。
作問者はMyStrのinit
メソッドをMyStrのinit
メソッドへ書き換え、ノートを保持するポインタ配列に書き込むことで、AAR/AAWプリミティブ(任意のメモリアドレスに対して任意に読み書きを実現する機能)を作成しました(※2)
※2: goのABIではraxに第一引数が渡されることに注意が必要です。
その後、goルーチンのスタックに対して書き込みを行い、ROPによりシェルを起動しました。goルーチンのスタックはホストにより固定のため、ripを適当な値に書き換え発火させることでクラッシュメッセージからリークが可能です。
[*] Switching to interactive mode
unexpected fault address 0xdeadbeaf
fatal error: fault
[signal SIGSEGV: segmentation violation code=0x1 addr=0xdeadbeaf pc=0xdeadbeaf]
goroutine 1 gp=0xc0000061c0 m=0 mp=0x56cde0 [running]:
runtime.throw({0x4bd817?, 0x0?})
/usr/local/go/src/runtime/panic.go:1067 +0x48 fp=0xc0000a4e38 sp=0xc0000a4e08 pc=0x465388
なお、前述したようにこの問題では多様な関数を呼び出せるため、他にも様々なアプローチが考えられ、実際、レビュアーは別の解法によりこの問題を解いています。具体的には、mov rsp, rbx; pop rbp; ret;
というROPガジェットを、MyList構造体のinit
にします。これにより、ノートを作成するたびにROPが発火します。この解法では、リモートのrspの値を必要とせず、安定してシェルを取ることができます。
[medium] Copy & Paste 2
作問者: hugeh0ge & kam1tsur3
正解チーム数: 3
問題文: Show me your growth over the past year!
概要
IERAECTF 2024で出題されたCopy & Pasteの改題です。
原題であるCopy & Pasteについては、以下の記事を参照ください。
3つのコマンドが提供されているプログラムで、ファイルの中身をバッファーにロード、バッファ同士でのコピー、バッファの出力ができます。
1. Create new buffer and load file content
2. Copy some buffer to another
3. Print a buffer
4. Exit
Enter command:
ファイルのバッファには以下の構造体が利用されます。
struct buffer {
long long int buf_size;
char *buf_ptr;
};
脆弱性
昨年同様、デバイスファイルなどの特殊なファイルを用いることでftell
の戻り値が-1となり、異常なサイズのバッファを確保することが可能です。
またバッファの終端にヌル文字を挿入する処理がありますが、サイズが-1の場合、メモリ上に残っている値を壊さずにバッファを確保することができるため、
libcのアドレスやheapのアドレスリークに利用することができます。
long long int size = ftell(fp);
char *ptr = malloc(sizeof(char)*(size+1)); // plus 1 for '\0'
if (!ptr) {
puts("malloc failed");
exit(1);
}
// Read file content
fseek(fp, 0, SEEK_SET);
fread(ptr, sizeof(char), size, fp);
ptr[size] = '\0';
fclose(fp);
加えて、バッファコピーのコマンドではmemcpy
の第3引数にftellで取得したファイルサイズを渡しています。すなわち、サイズが-1のバッファを利用した場合には、memcpyのコピーサイズとして、-1が利用されます。
memcpy
では第3引数をsize_t型として扱うため、-1を渡した場合、x86-64においては、2 ** 64 – 1(すなわち、size_tの最大値)として解釈されます。単純なmemcpyの実装を想像すると、無制限にバッファがコピーされ、クラッシュしてしまうような気がしますが、実はglibcにおいては、SIMDを利用して工夫のある実装がなされている影響により、クラッシュは発生しません。今回の問題の環境では、その代わりに、バッファの開始位置より低位のアドレスからメモリのコピーを行うような挙動が発生します。
例えば以下のようなメモリ状態があったと仮定します。
0x55a3204c25c0: 0x4242424242424242 0x4242424242424242
0x55a3204c25d0: 0x0000000000000000 0x0000000000000021
0x55a3204c25e0: 0x0000000000000100 0x000055a3204c2600
0x55a3204c25f0: 0x0000000000000000 0x0000000000000111
0x55a3204c2600: 0x4343434343434343 0x4343434343434343
...(中略)...
0x55a3204c26f0: 0x4343434343434343 0x4343434343434343
0x55a3204c2700: 0x0000000000000000 0x0000000000000021
0x55a3204c2710: 0xffffffffffffffff 0x000055a3204c2730
0x55a3204c2720: 0x0000000000000000 0x0000000000000021
0x55a3204c2730: 0x0000000000000000 0x0000000000000000
0x55a3204c2740: 0x0000000000000000 0x0000000000000d71
0x55a3204c2750: 0x0000000000000000 0x0000000000000000
上記ではbuffer構造体が2つ0x55a3204c25e0, 0x55a3204c2710に位置しています。
前者のバッファには”CCC…”が続くファイルを読み込んでおり、後者のbufferはsizeが-1に設定された状態です。
この状態で前者から後者のバッファにコピーを行った場合、バッファの開始番地より低位のアドレスからコピーが実行されます。
0x55a3204c2690: 0x4343434343434343 0x4343434343434343
0x55a3204c26a0: 0x4242424242424242 0x4242424242424242
0x55a3204c26b0: 0x4242424242424242 0x4242424242424242
0x55a3204c26c0: 0x4242424242424242 0x4242424242424242
0x55a3204c26d0: 0x4242424242424242 0x4242424242424242
0x55a3204c26e0: 0x4242424242424242 0x4242424242424242
0x55a3204c26f0: 0x4242424242424242 0x4242424242424242
0x55a3204c2700: 0x0000000000000000 0x0000000000000021
0x55a3204c2710: 0x0000000000000100 0x000055a3204c2600
0x55a3204c2720: 0x0000000000000000 0x0000000000000111
0x55a3204c2730: 0x4343434343434343 0x4343434343434343
0x55a3204c2740: 0x4343434343434343 0x4343434343434343
0x55a3204c2750: 0x4343434343434343 0x4343434343434343
0x55a3204c2760: 0x4343434343434343 0x4343434343434343
0x55a3204c2770: 0x4343434343434343 0x4343434343434343
0x55a3204c2780: 0x4343434343434343 0x4343434343434343
0x55a3204c2790: 0x4343434343434343 0x4343434343434343
0x55a3204c27a0: 0x4343434343434343 0x4343434343434343
0x55a3204c27b0: 0x0000000000000000 0x0000000000000000
解法
今回の問題では標準入力のバッファリングをheap領域で行っています。
そのため、コピー元のバッファを標準入力のバッファに近接させた状態で、前述のようなコピーを行うことで、ユーザの入力値を標準入力バッファ以外のheap領域のメモリに書き込むことができます。
buffer構造体自体もheap領域上に確保されるためbuf_ptr
メンバを上書きすることでAAR/AAWのプリミティブを作成することができます。
AAWが達成出来たらFSOP (File Stream Oriented Programming) でシェルを起動します。
[hard] vibexec
作問者: rona
正解チーム数: 5
問題文: LLM says so
概要
メモアプリとして動作するカーネルモジュール、vibexec.ko
が動いています。
メモはそれぞれ、struct list_item
という構造体で表されます。(以下、itemと呼称)
struct list_item {
long key;
struct list_head list;
char memo[0x28];
};
vibexec
はioctl
経由で以下の機能を提供しています。
- IOCTL_ADD_ITEM: userからkeyを受け取る。dedicated cacheからitemをallocateして、受け取ったkeyを設定した上で、双方向リストにつなげる。
- IOCTL_REMOVE_ITEM: userからkeyを受け取る。全itemを走査して、受け取ったkeyと一致するitemをリストから削除してfreeする。
- IOCTL_SKIP: userからkeyを受け取る。全itemを走査して、受け取ったkeyと一致するitemをunlink(自前実装)してfreeする。
- IOCTL_EDIT_MEMO: userからkeyとmemoを受け取る。受け取ったkeyと一致するitemのmemoに受け取ったmemoをコピーする。
脆弱性
IOCTL_SKIP
は以下のように実装されています。
list_for_each_safe(pos, temp, &g_head) {
if (pos->prev == &g_head)
continue;
if (pos->next == &g_head)
break;
cur = list_entry(pos, struct list_item, list);
prev = list_entry(pos->prev, struct list_item, list);
next = list_entry(pos->next, struct list_item, list);
if (cur->key == key) {
prev->list.next = cur->list.next;
next->list.next = prev->list.next;
next->list.prev = cur->list.prev;
prev->list.prev = cur->list.prev;
kmem_cache_free(item_cache, pos);
return 0;
}
}
リストの操作を観察すると、明らかに操作が誤っていることがわかります。
例えば、A <-> B <-> Cとitemが繋がっている時にBをSKIPすると以下のようになります。

解法
上記の脆弱性でループを作った後に、ループしているitem(図ではC)removeすると、それぞれのポインタは何も変わらず、itemだけfreeされるのでUse-After-Free(UAF)が発生します。
しかし、itemは専用cacheからallocされるため、そのままではUAFを利用することができません。そのため、cross-cache attackをすることで、UAFされたアドレスを別のobjectに再割当てします。
ここでは、pagetableに再割り当てすることを考えます(Dirty pagetable)。
以下のような順番で操作を行うことで、UAFされたitemがあるページをbuddy allocatorに返却することができます。
なお、cpu_partial
は/sys/kernel/slab/vibexec_cache/cpu_partial
にて、objs_per_slab
は/sys/kernel/slab/vibexec_cache/objs_per_slab
にて確認することができます。
cpu_partial + 1 pages
分のitemをallocobjs_per_slab - 1
個のitemをalloc- itemを1つalloc (UAFを起こすためのitem)
objs_per_slab - 1
個のitemをalloc- 2,4でallocしたitemをfreeし、UAF用item以外存在しないpageを作る (buddy allocator に返却されるpage)
- 上記脆弱性で3でallocしたitemがUAFされるように
- 1でallocされたitemを
objs_per_slab
個につき1つfree
その後、大量にPTEをallocすることでUAFされたアドレスとPTEのアドレスが重複するため、IOCTL_EDIT_MEMO
を通じてPTEを任意の値に書き換えることができます。PTEが書き換えられるということは、userlandからRWな仮想アドレスと任意の物理アドレスを結びつけられることを意味します。このプリミティブを用いて、userlandからRWな仮想アドレスと、kernelのtext領域に対応する物理アドレスを結びつけて、shellcodeで書き換えることでroot権限を奪取することができます。
作者は__sys_setuid
を書き換えました。なお、この問題ではCONFIG_STATIC_USERMODEHELPER
が設定されているため、core_pattern
やmodprobe_path
を書き換えてもroot権限の奪取はできません。
[extreme] The World
作問者: hugeh0ge
正解チーム数: 0
問題文: THE WORLD! STOP TIME!
概要
仮想通貨のマイニングのようなイメージで、ユーザーの入力に応じて、特定の条件を満たすハッシュ値を計算するプログラムです。ハッシュ値の計算は、独立したスレッドで、並列的に行われます。
1. Start new mining
2. Show results
3. Exit
Enter command: 1
Miner idx: 0
Length of hash: 2
Hash: 0
Length of prefix: 6
Prefix: hello
1. Start new mining
2. Show results
3. Exit
Enter command: 2
#0 - BUFFER READY
#1 - UNUSED
#2 - UNUSED
1. Start new mining
2. Show results
3. Exit
Enter command: 2
#0 - FINISHED
Result: 68656C6C6F0AB963000000000000
#1 - UNUSED
#2 - UNUSED
脆弱性
このプログラムには2つの脆弱性があります。
まず、このプログラムでは、全体的にscanfの戻り値を確認していません。
そのため、不正な入力を行えば、scanfは失敗し、未初期化変数が利用される恐れがあります。
例えば、以下のhash
の入力などが悪用しやすいでしょう。
printf("Length of hash: ");
scanf("%d", &hash_len);
if (hash_len < 0 || 8 < hash_len) {
puts("Invalid length");
return;
}
long long int hash;
printf("Hash: ");
scanf("%lld", &hash);
printf("Length of prefix: ");
scanf("%d", &prefix_len);
if (prefix_len < 0 || 0x1000000 < prefix_len) {
puts("Too large");
return;
}
もう一つの脆弱性は、以下の部分で発生するRace Conditionおよびその結果として生じるType Confusionです。
pthread_mutex_lock(&mutex);
atomic_store(&status[idx], RESULT_READY);
pthread_mutex_unlock(&mutex);
read(notify_fd[idx][0], alloced, 7);
assert(memcmp(alloced, "ALLOCED", 7) == 0);
sleep(1);
pthread_mutex_lock(&mutex);
atomic_store(&status[idx], FINISHED);
memcpy(miners[idx].data.result, candidate, result_len);
pthread_mutex_unlock(&mutex);
この部分では、計算結果を格納するバッファresult
が別スレッドにより準備されるのを待ち、準備ができたら結果を書き込んでいます。
data
は以下のようなunionになっており、result
はhash
と共用されています。
union {
unsigned char *result;
long long int hash;
} data;
そのため、もし、result
が準備できていない(すなわち、hash
として使用されている)場合、結果の書き込みにおいて、hash
がresult
として誤って利用されてしまいます (Type Confusion)。
しかし、上記処理では通常、パイプからのreadを行うことで、result
が準備できるまでの待機が行われます。そして、別スレッドは、バッファを用意するまでパイプへのwriteを行わないため、処理としては正しいものになっており、問題はありません。
実は、プログラムの終了時だけ、この前提が崩れます。
void handler(int) {
for (int i=0; i<NUM_MINERS; i++) {
close(notify_fd[i][0]);
close(notify_fd[i][1]);
}
puts("Timed out");
exit(0);
}
...
int main() {
...
for (int i = 0; i < NUM_MINERS; i++) {
if (pipe(notify_fd[i]) == -1) {
puts("End");
return 0;
}
pthread_create(&miner_threads[i], NULL, miner_thread, (void*)i);
}
signal(SIGALRM, handler);
alarm(60);
このプログラムでは、main関数の冒頭でalarmを呼び出し、60秒の実行時間制限を設けています。プログラムが開始して60秒をすぎると、handler
が呼び出され、プログラムは終了します。
ここで注目すべき点は、handler
でパイプが閉じられていることです。パイプが閉じられた際に、前述したスレッドが read(notify_fd[idx][0], alloced, 7);
を実行していたとすると、readはEOFにより終了し、result
が準備できていないにもかかわらず、結果の書き込みを開始してしまいます (Race Condition) したがって、待機しているスレッドがある状態で、60秒待てば自動的に脆弱性が発火します。
ちなみに、readがSIGALRMによりリターンすると考えた人もいるかもしれませんが、これはSIGALRMでリターンしているわけではありません。シグナルはメインスレッド以外には通知されないので、readを実行しているスレッドには届いていないためです。
解法
1つめの脆弱性により、libcのアドレスをリークすることができます。
ただし、不正な入力であれば何を入力してもいいというわけではありません。
例えば、scanfで数値を要求している場所に対して、x
を入力してしまうと、stdinのバッファにはx
が残り続けてしまうため、後続のscanfのすべてでx
が利用されつづけ、以降の入力が不可能になってしまいます(コマンド番号の入力が数値のscanfであるため、以降何もできなくなってしまいます)。
これを解決するためには、+
や-
のような符号が利用できます。例えば、--0
を入力すると、1度目のscanfは先頭の-
を読み取り、消費した上で失敗し、2度目のscanfは残りの-0
を読み取り、正常に終了します。
未初期化なhash
(ハッシュの目標値)は、libcのアドレスを含んでおり、目標値はハッシュの計算が終了するまでは2番のコマンドで表示できるため、計算が現実的な時間で終了しないマイニングを開始すれば、libcのアドレスが判明します。
2つめの脆弱性ではAAWが実現できるわけですが、実はこれの達成には、最後にして最大の障壁を乗り越える必要があります。前述したRace Conditionの発生箇所では、書き込みが行われる前に、sleep(1)
が実行されています。
read(notify_fd[idx][0], alloced, 7);
assert(memcmp(alloced, "ALLOCED", 7) == 0);
sleep(1);
pthread_mutex_lock(&mutex);
atomic_store(&status[idx], FINISHED);
memcpy(miners[idx].data.result, candidate, result_len);
前述のとおり、脆弱性が発火するのは、シグナルハンドラが実行された直後であり、シグナルハンドラはすぐにexitによりプロセスを終了します。
void handler(int) {
for (int i=0; i<NUM_MINERS; i++) {
close(notify_fd[i][0]);
close(notify_fd[i][1]);
}
puts("Timed out");
exit(0);
}
当然、普通はexitの実行まで1秒もかからないため、AAWを起こす前にプロセスが終了し、任意のコード実行まではたどり着けません。
これを解決するために注目すべき点は、exitの前にputsが実行されていることです。
Linuxのプロセスには、I/O waitと呼ばれる減少があります。入出力を実行する必要があるが、それをすぐに実行できない場合に、プロセスが待ち状態になることです。
実は、このputsを利用することで、意図的にI/O waitを引き起こし、exitを実行させないことが可能です。
サーバーの出力がクライアントによってrecvされず、消費されなかった場合、送信されるべき出力内容は、最終的にサーバーのカーネルのバッファにたまっていきます。
このバッファすらフルになってしまった場合、それ以降の出力はI/O waitを引き起こし、バッファに格納可能になるまで、プロセスは停止した状態になります。
すなわち、シグナルハンドラが呼び出される前に、プログラムに大量の出力をさせ、それをあえてクライアントから読み取らないようにすることで、I/O waitが起き、AAWまでつなげることができます。