OSED egg-hunter and shellcode staging - deep

OSED egg-hunter and shellcode staging - deep

TL;DR: Egg-hunters solve the “tiny primary buffer, huge secondary buffer” problem common in OSED-style stack overflows: you can land EIP but only have ~30-60 bytes of contiguous reliable shellcode space, while your real payload lives elsewhere in memory (HTTP body, second recv, registry value). A small searcher stub (Skape’s 32-byte NtAccessCheckAndAuditAlarm hunter is the canonical one) walks the virtual address space looking for an 8-byte tag (the “egg”, typically w00tw00t) and jumps to whatever follows. This note goes deeper than egghunters: SEH-driven hunters, IsBadReadPtr variant, x64 considerations, egg-byte selection under bad-character-handling constraints, staging patterns (write-stage vs in-place), and the reliability tax of page-fault scanning. Companion to osed-custom-exploit-walkthrough, stack-buffer-overflow, and custom-windows-shellcode-writing.

Why it matters

Real Windows targets rarely give you a single 600-byte stack buffer with EIP at offset 504 and clean space behind it. Far more common:

  • HTTP server with a 60-byte vulnerable header but a 64 KB body you control.
  • Service that reads filename into a fixed MAX_PATH buffer but spools your file contents elsewhere.
  • Format-string-in-log primitive where the corruption point is small but the log line itself is huge.
  • Two-stage protocols where the first message smashes the stack and the second message arrives at a different heap address.

In all of these, the corruption gadget (EIP control + small stub) and the payload (full reverse shell, Meterpreter, custom custom-windows-shellcode-writing blob) live in different memory regions, and at exploit time you may not know the payload’s exact address. An egg-hunter bridges them: small enough to fit in the cramped primary buffer, smart enough to scan memory safely (no access-violation crashes on unmapped pages), fast enough to finish before the user notices.

OSED expects you to write an egg-hunter, not just paste Skape’s. The exam typically restricts characters, sometimes requires unicode-safe variants, and grades whether you understand why each instruction is there.

Patterns and classes

When egg-hunter is the right tool

Decision tree:

  1. Do you have one contiguous buffer >= shellcode length, reachable from EIP? Use direct shellcode. No hunter needed.
  2. Do you have a register or stack slot pointing into a large attacker-controlled region? Use a short jump or jmp esp / call reg. No hunter needed (see stack-bof-walkthrough-end-to-end).
  3. Is your large buffer at a known address (e.g., a static heap allocation, a hard-coded module data section)? Use a direct absolute jump. No hunter needed.
  4. Is your large buffer at an unknown but mapped address, and you have ~30+ bytes of stub space? Egg-hunter.
  5. Less than ~30 bytes? Use a staged loader: a tiny stub that calls recv again into a known location (see staging section below).

Skape’s NtAccessCheckAndAuditAlarm egg-hunter (32 bytes)

The classic Windows x86 egg-hunter, published by Skape in 2004. Idea: use a syscall that takes a pointer argument and returns STATUS_ACCESS_VIOLATION (instead of crashing the process) when handed an invalid pointer. NtAccessCheckAndAuditAlarm is one such; NtDisplayString is another (shorter, but kernel-noisy). Loop pseudocode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
loop_page:
  or  dx, 0xfff        ; page-align ECX/EDX scan pointer to end of page
loop_inc:
  inc edx              ; next byte
  push edx             ; arg for syscall
  push 0x2             ; syscall number for NtAccessCheckAndAuditAlarm
  pop eax
  int 0x2e             ; or syscall on newer; legacy stub
  cmp al, 0x05         ; STATUS_ACCESS_VIOLATION low byte
  je  loop_page        ; bad page, skip to next
  mov eax, 0x77303074  ; "w00t" (egg, little-endian)
  mov edi, edx
  scasd                ; compare egg first half
  jnz loop_inc
  scasd                ; compare egg second half
  jnz loop_inc
  jmp edi              ; land just past the double-tagged egg

Key invariants:

  • The egg is written twice in the payload (w00tw00t = w00t + w00t) so the hunter itself, which also contains a single w00t constant in its body, cannot match itself.
  • Scanning is page-aligned: on access violation, jump to next page rather than retry the same address byte by byte. This collapses scan time on huge unmapped gaps.
  • int 0x2e was the syscall gate on XP/2003. Modern Windows (Vista+) deprecated it for user code; egg-hunters using it can still work because the kernel preserves a compatibility path for some syscalls, but the more portable variant uses WaitForSingleObject or IsBadReadPtr instead.

IsBadReadPtr variant

Slightly larger but avoids the int 0x2e legacy concern. Resolve IsBadReadPtr from kernel32 (or pass its address in via the staged loader), then in the loop call it on the candidate pointer; if it returns nonzero, advance a page. Trades size for portability. Useful when the target is Windows 10/11 user-mode and you’ve already done module base resolution for other reasons.

SEH-based egg-hunter

Instead of a syscall acting as the page-fault filter, register a custom SEH handler that simply increments the scan pointer and resumes. The hunter walks memory directly with mov al, [edi]; on access violation the handler kicks in. Smaller in instruction count but requires SEH to be reachable. On targets with safesh-bypass not satisfied (SafeSEH disabled, no SEHOP) this is sometimes the smallest reliable option. Note that an SEH-based hunter inside an exploit that also used seh-overwrite for control needs careful handler chain management.

x64 considerations

OSED is x86-centric, but real OSED-adjacent work covers x64. Differences:

  • Syscall numbers are larger, instruction encoding changes; the Skape stub does not translate one-to-one.
  • int 0x2e is gone; use syscall instruction or call into ntdll’s stub directly.
  • Addresses are 64-bit, so inc edx is too small a step granularity practically; usually you work with rdi and add rdi, 1 plus page-align via or di, 0xfff.
  • The egg is still typically 8 bytes (two 4-byte halves), and scasq would match all 8 at once if you pad the egg to 8 bytes that don’t collide with the hunter’s own constants.

Egg byte selection

The egg must:

  • Not appear in the hunter’s own code or in any reachable stub before payload landing.
  • Survive bad-character-handling filtering: if \x00, \x0a, \x0d, \x20 are bad, your egg can’t contain them. w00t (\x77\x30\x30\x74) is bad-char-clean for most filters but collides with the literal word “w00t” if a logging/AV signature looks for it.
  • Be unique enough that no incidental memory region matches. Random four-letter ASCII often works; pick something not present in loaded modules’ .rdata.
  • Be writable in whatever protocol carries it: HTTP-safe, SMTP-safe, unicode-safe as needed.

If the target unicode-expands input (every byte becomes XX 00), pick an egg whose alternating bytes still match, or use a unicode-aware hunter (Corelan’s venetian-style stubs).

Staging variants

When you don’t have room for a hunter, or when you want to avoid page-fault scan cost, stage the payload:

Write-stage to known location

Primary stub:

  1. Resolves VirtualAlloc (PEB walk) or uses a known RWX region.
  2. Calls recv (socket FD often discoverable by iterating handles 0..0xffff) into the allocation.
  3. Jumps into the allocation.

Trade-off: requires socket recovery, which means finding the connected socket. Techniques: brute-force getpeername over FDs, parse the SSDP/HTTP context, or read it from a known struct. Larger stub (~100-200 bytes) but deterministic, no scan time.

In-place stage

If the large buffer arrives in a known register or known stack offset (e.g., second-stage protocol places it at [esp+0x200]), just compute the offset and jump. This is the cheapest stage but only applies when layout is fully predictable.

Hybrid: hunter + stager

Tiny hunter finds an 8-byte tag, the bytes after the tag are not the final payload but a second stager that does VirtualAlloc + recv. Useful when the first payload buffer is itself small but a third channel (a separate connection) carries the real implant. Niche, but appears on some OSED-style multi-stage challenges.

Reliability tradeoffs

  • Scan time. On a process with sparse address space, a hunter may scan gigabytes before hitting the egg. Each unmapped page is one syscall round-trip. Worst case observed: 5-15 seconds. Solution: place the egg low in the address space if possible (heap allocations early in process startup), or use multiple eggs to bias toward a hit.
  • Crash risk on guard pages. Stack guard pages, when read by the hunter, raise STATUS_GUARD_PAGE_VIOLATION which the int 0x2e variant does not handle gracefully. SEH variant handles it. Targets with PageHeap enabled (Application Verifier) trip more guard pages.
  • AV / EDR signatures. Skape’s stub byte pattern is in every AV database. Re-encode (xor stub, alphanumeric, custom register choice) before use on production. See edr-bypass-at-exploitation-time and edr-hooks-and-unhooking for context, though against userland hooks the hunter typically runs before injection so hooks are not the issue; static signature on the stub is.
  • Egg collisions. If the same egg appears in .rdata of a loaded DLL, the hunter lands there and executes garbage. Verify with !for_each_module in WinDbg before fixing the egg.

Workflow to study

  1. Read Skape’s original “Safely Searching Process Virtual Address Space” paper (2004) end to end. Trace each instruction; explain why or dx, 0xfff not or edx, 0xfff (size optimization, byte vs dword).
  2. In a Windows 10 VM with a vulnerable service that has a small primary buffer and large body (any OSED practice target, or a hand-rolled one), develop without a hunter first to confirm EIP control and payload landing zone (see stack-bof-walkthrough-end-to-end).
  3. Drop in Skape’s 32-byte hunter, prefix payload with double egg, verify it runs. Measure scan time with WinDbg !time.
  4. Reimplement the hunter from scratch in NASM. Assemble, extract bytes, compare to Skape’s. Diff and explain every difference.
  5. Replace with IsBadReadPtr variant; resolve the API by PEB walk in your stub.
  6. Replace with SEH-based variant. Confirm it still works under DEP (it should, payload region must be RX).
  7. Add bad-character-handling constraints: pick an egg under a restrictive filter (e.g., printable ASCII only). Re-encode the hunter so it passes the same filter; this is the alphanumeric egg-hunter problem.
  8. Write a staged-recv loader; compare reliability and stub size against the hunter for the same target.
  9. Practice the unicode-expanded variant against an MS04-007-style target or hand-rolled equivalent.

References

  • Skape, “Safely Searching Process Virtual Address Space” (Hick.org / nologin) - https://web.archive.org/web/20100530043053/https://www.hick.org/code/skape/papers/egghunt-shellcode.pdf
  • Corelan, “Exploit writing tutorial part 8: Win32 egg hunting” - https://www.corelan.be/index.php/2010/01/09/exploit-writing-tutorial-part-8-win32-egg-hunting/
  • Offensive Security, OSED syllabus (Windows User Mode Exploit Development) - https://www.offsec.com/courses/exp-301/
  • FuzzySecurity, “Egg Hunters” tutorial - https://www.fuzzysecurity.com/tutorials/expDev/7.html
  • Exploit-DB, custom egg-hunter shellcode entries - https://www.exploit-db.com/shellcodes
  • Microsoft Docs, IsBadReadPtr reference (and discouragement) - https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-isbadreadptr