第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寄存器传递。我们需要:

  1. 把"cmd.exe"字符串的地址放进rdi
  2. 调用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都不难,难的是同时绕过。实际场景中,这两个防护通常是同时开启的。那怎么办?

思路是这样的:

  1. 先通过信息泄露拿到某个模块的基址
  2. 用这个基址计算所有gadget的地址
  3. 构造ROP链,调用VirtualProtectmprotect修改栈的权限
  4. 把shellcode复制到栈上
  5. 跳转到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绕过的核心逻辑串起来:

ROP链构造与ASLR/DEP绕过知识体系 目标:获取代码执行权限 DEP:数据不可执行 ASLR:地址随机化 ROP:拼接已有代码片段 信息泄露:获取模块基址 寻找gadget ROPgadget / objdump 构造ROP链 栈布局设计 格式化字符串 泄露栈/堆地址 未初始化变量 残留指针泄露 最终:绕过防护,执行shellcode

92.7 实战中的坑与技巧

最后分享几个我在实战中踩过的坑:

  • gadget地址的稳定性:不同版本的系统DLL,gadget的偏移可能不同。我建议在目标系统上现场提取gadget,不要用本地系统的。
  • 栈对齐问题:64位系统下,很多函数要求栈16字节对齐。如果ROP链执行到目标函数时栈没对齐,加一个ret gadget垫一下。
  • 空字节问题:如果漏洞是通过字符串拷贝触发的,ROP链里不能有空字节(0x00)。这时候需要找地址中不含空字节的gadget。
  • 多次利用:有时候一次ROP搞不定所有事,需要分阶段。比如先泄露地址,再第二次触发漏洞执行完整的ROP链。

重要提醒:ROP链构造是个精细活,一个字节的偏差就可能导致崩溃。我建议在调试器里单步跟踪ROP链的执行,确保每个gadget都按预期工作。别嫌麻烦,这一步省不了。

好了,这一章的内容就到这儿。ROP链构造和ASLR/DEP绕过是二进制漏洞利用的核心技能,需要多练、多调试。下一章咱们会聊更高级的利用技术,敬请期待。


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