第92章:漏洞利用开发(二):ROP链构造、ASLR/DEP绕过
各位好,欢迎来到第92章。上一章我们聊了漏洞利用的基础,这一章咱们直接上硬菜——ROP链构造和ASLR/DEP绕过。说实话,这两个东西是二进制漏洞利用里最核心的坎儿。我当年刚接触的时候,也被绕得晕头转向。但别怕,今天咱们把它拆开揉碎了讲。
92.1 为什么需要ROP?
先问个问题:你写了一个栈溢出漏洞的利用代码,把返回地址改成了shellcode的地址。然后呢?程序崩了?大概率是的。因为现代操作系统都开启了DEP(数据执行保护),栈上的数据不能执行。你放再好的shellcode,也只能当数据躺着。
那怎么办?思路很简单:既然不能执行新代码,那就利用已有的代码。已有的代码在哪里?在程序本身、在加载的DLL里。我们把已有的代码片段(gadget)串起来,让程序执行我们想要的操作。这就是ROP(Return-Oriented Programming)的核心思想。
核心要点:ROP不是注入新代码,而是拼接已有代码片段。每个gadget以ret指令结尾,通过控制栈上的返回地址链,让程序在gadget之间跳转。
92.2 寻找Gadget
找gadget是ROP的第一步。说白了,就是在二进制文件里搜符合要求的指令序列。我个人习惯用ROPgadget这个工具,简单粗暴。
# 查找所有gadget
ROPgadget --binary ./vuln_program
# 查找特定类型的gadget(比如pop rdi; ret)
ROPgadget --binary ./vuln_program --only "pop|ret"
# 过滤出带ret的gadget
ROPgadget --binary ./vuln_program --re "ret$"
嗯,这里要注意:不是所有gadget都靠谱。我在项目中遇到过,有些gadget中间夹了别的指令,执行起来会破坏寄存器状态。所以找到gadget后,最好用反汇编器看一眼上下文。
小技巧:找gadget时,优先在程序自身的代码段和系统DLL(如ntdll.dll、kernel32.dll)里找。系统DLL的地址虽然被ASLR随机化,但一旦泄露一个地址,整个DLL的基址就能算出来。
92.3 构造ROP链
ROP链说白了就是一组精心排列的地址。每个地址指向一个gadget,gadget执行完自己的活儿后,通过ret指令从栈上取下个地址继续执行。你想想看,这不就是用手动的方式模拟了函数调用栈吗?
咱们来看个实际例子。假设我们要调用system("cmd.exe"),在64位系统上,参数通过rdi寄存器传递。我们需要:
- 把"cmd.exe"字符串的地址放进rdi
- 调用system函数
对应的ROP链大概长这样:
# 栈布局(从低地址到高地址)
[pop rdi; ret] # gadget地址
["cmd.exe"地址] # 参数值
[system地址] # 函数地址
执行流程是这样的:
- 第一个ret跳转到
pop rdi; ret - pop rdi把栈上的"cmd.exe"地址弹进rdi
- 第二个ret跳转到system函数
- system执行时,rdi已经是"cmd.exe"了
是不是挺巧妙的?但实际利用中,往往需要更长的链。比如要调用多个函数,或者需要调整栈对齐。我记得有一次调试一个64位的漏洞,因为栈没对齐,system函数死活调不起来。后来发现是栈需要16字节对齐,加了个额外的ret gadget就解决了。
避坑指南:我曾经在Windows 10 x64上栽过跟头——system函数内部用了movaps指令,要求栈16字节对齐。如果ROP链执行到system时栈没对齐,直接崩。解决办法是在链里加一个ret gadget,把栈指针调整8字节。
92.4 绕过ASLR
ASLR(地址空间布局随机化)把代码、堆、栈的基址随机化。没有地址,ROP链就无从谈起。怎么破?
常用的思路是信息泄露。比如程序里有个格式化字符串漏洞,或者能输出某个指针的值。一旦泄露了某个模块的地址,整个模块的基址就能算出来。
举个例子:
# 假设泄露了ntdll.dll中的一个函数地址
leaked_addr = 0x7ffe_a0b12345
# 已知该函数在ntdll.dll中的偏移
func_offset = 0x12345
# 计算ntdll.dll基址
ntdll_base = leaked_addr - func_offset
# 现在可以计算ntdll.dll中任意gadget的地址
gadget_addr = ntdll_base + gadget_offset
嗯,这里有个关键点:泄露的地址必须是模块内的地址,不能是堆或栈上的地址。因为堆和栈的随机化是独立的,泄露了也没法推算模块基址。
实战经验:我常用的信息泄露手法有两种:一是利用格式化字符串漏洞直接读栈上的返回地址;二是利用未初始化的栈变量,里面可能残留着之前的函数调用地址。这两种方法我都用过,成功率还不错。
92.5 组合拳:ASLR + DEP 绕过
单独绕过ASLR或DEP都不难,难的是同时绕过。实际场景中,这两个防护通常是同时开启的。那怎么办?
思路是这样的:
- 先通过信息泄露拿到某个模块的基址
- 用这个基址计算所有gadget的地址
- 构造ROP链,调用
VirtualProtect或mprotect修改栈的权限 - 把shellcode复制到栈上
- 跳转到shellcode执行
说白了,就是先用ROP把DEP关掉,再执行shellcode。这招叫"ret2libc"的升级版。
下面是个简化的ROP链示例,调用VirtualProtect修改栈权限:
# 栈布局(64位)
[pop rcx; ret] # 第一个参数:栈地址
[栈地址]
[pop rdx; ret] # 第二个参数:大小
[0x1000]
[pop r8; ret] # 第三个参数:新权限(PAGE_EXECUTE_READWRITE = 0x40)
[0x40]
[pop r9; ret] # 第四个参数:旧权限输出地址
[可写地址]
[VirtualProtect地址]
[shellcode地址] # 修改成功后跳转到shellcode
提示:如果找不到pop r9; ret这样的gadget,可以试试用mov [rsp+offset], r9; ret之类的间接方式。我在实际利用中经常需要组合多个gadget来完成一个参数传递。
92.6 知识体系总览
说了这么多,咱们用一张图把ROP链构造和ASLR/DEP绕过的核心逻辑串起来:
92.7 实战中的坑与技巧
最后分享几个我在实战中踩过的坑:
- gadget地址的稳定性:不同版本的系统DLL,gadget的偏移可能不同。我建议在目标系统上现场提取gadget,不要用本地系统的。
- 栈对齐问题:64位系统下,很多函数要求栈16字节对齐。如果ROP链执行到目标函数时栈没对齐,加一个ret gadget垫一下。
- 空字节问题:如果漏洞是通过字符串拷贝触发的,ROP链里不能有空字节(0x00)。这时候需要找地址中不含空字节的gadget。
- 多次利用:有时候一次ROP搞不定所有事,需要分阶段。比如先泄露地址,再第二次触发漏洞执行完整的ROP链。
重要提醒:ROP链构造是个精细活,一个字节的偏差就可能导致崩溃。我建议在调试器里单步跟踪ROP链的执行,确保每个gadget都按预期工作。别嫌麻烦,这一步省不了。
好了,这一章的内容就到这儿。ROP链构造和ASLR/DEP绕过是二进制漏洞利用的核心技能,需要多练、多调试。下一章咱们会聊更高级的利用技术,敬请期待。
公众号:蓝海资料掘金营,微信deep3321