news 2026/10/15 5:44:37

从栈溢出到堆利用:Pwn进阶实战笔记与UAF漏洞解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从栈溢出到堆利用:Pwn进阶实战笔记与UAF漏洞解析

pwn练习笔记做到第19到20篇,正好是我个人学习路径上的一道分水岭。前面十几篇基本都在栈溢出和格式化字符串里打转,这两篇开始把重心往堆利用上挪,同时又回头把ret2libc的完整链路彻底捋了一遍。这个阶段踩的坑特别多,尤其是堆那边的UAF和double free,一度让我卡了好几天。所以这篇笔记我不想只贴exp,想把题目背景、分析思路、为什么这么构造payload、以及调试时怎么一步步定位问题,都完整记录下来。如果你也是刚学完栈溢出、准备往堆利用走的新手,这篇应该能帮你少走不少弯路。

1. 本阶段练习的整体思路与靶题选择

1.1 为什么把栈溢出和堆利用放在同一阶段

说实话,刚开始我给自己定的计划是“先把栈溢出刷明白,再碰堆”。但实际操作下来发现,这两个方向根本不是割裂的。栈溢出里学的ROP链构造、参数传递、地址泄露,到了堆利用里全都要用,而且用得更频繁。第19篇练的主要是一个经典的栈溢出进阶题,第20篇则是一个UAF题目,两者放在一起做,反而有种“前面学的终于派上用场”的感觉。

栈溢出是pwn的地基,这个谁都得认。但地基打得再牢,不会盖楼也不行。堆利用就是那栋楼。从ctf赛题的方向看,近年来的pwn题里栈题比例在下降,堆题越来越多,而且难度水涨船高。早点开始碰堆,给自己留出犯错和消化的时间,比临时抱佛脚强太多。

我当时的时间分配是这样的:大约七成时间用来巩固栈溢出,重点是把ret2libc的各种变体玩熟;剩下三成时间用来接触堆,先从最简单的UAF入手,暂时不碰需要复杂堆风水的高难度题目。这么安排的好处是,遇到堆题卡壳时,我还能退回栈题找找信心,不会因为连续挫败而彻底自闭。

1.2 靶题难度阶梯与判定方法

选题也是门学问。很多新手容易犯的毛病是看到题目就上,结果被一道高难度的堆题虐得体无完肤,然后开始怀疑自己。我自己的经验是,拿到一个程序先别急着看反编译,先做一些基础侦查工作。

第一步是看保护。这部分我习惯用某个check工具,一条命令就能把程序的保护机制列出来。重点关注这几个:栈上是否有canary、PIE是否开启、NX是否开启、RELRO是partial还是full。通常栈溢出题如果开了canary,就要考虑泄露canary值;如果开了PIE,就得多一步先泄露程序基址。新手选题时,尽量选保护措施简单的,比如只有NX开启的题,这类题思路清晰,适合练基本功。

第二步是看程序逻辑。我会先用反编译工具把伪代码过一遍,搞清楚程序到底做了什么、有哪些输入点、缓冲区大小是多少、有没有现成的后门函数。这里有个小技巧:看伪代码时重点关注函数名,如果有system、execve这类危险函数,题目难度往往会降一个档次。

第三步是判断难度阶梯。我的经验是,按从易到难的顺序排列大致是:无保护的栈溢出 < NX开启的ret2libc < 开启canary的栈题 < 简单的堆UAF < 需要堆布局的fastbin攻击 < 复杂堆利用。第19篇的栈题大概处在第二档,第20篇的UAF题处在第四档,正好是一个合理的进阶梯度。

2. 栈溢出进阶:从ret2text到ret2libc的完整链路

2.1 ret2text的局限性到底在哪里

如果你刷过基础栈溢出题,一定对ret2text不陌生。所谓ret2text,就是程序里本来就有后门函数,比如某个可以直接getshell的函数,我们只需要把返回地址覆盖成这个函数的地址就行。

但这种题有个致命前提:程序里必须有现成的后门函数。现实中的程序哪有这么好心,真题里也基本不会给你一个明晃晃的system("/bin/sh")。所以ret2text只是入门玩具,真正实用的是ret2libc。

ret2libc的核心思路是:程序动态链接了libc,而libc里有system函数和"/bin/sh"字符串。如果我们能知道libc的加载基址,就能算出system的实际地址,然后把返回地址覆盖成system,同时布置好参数,让它去执行system("/bin/sh")。

第19篇这个题就是这样。程序里没有后门,NX开启,别想着往栈上塞shellcode了,老老实实走ret2libc。但问题又来了:题目是开启了地址随机化的系统,libc基址每次加载都不同,怎么知道system在哪?

答案就是泄露。程序虽然没有后门,但它有输出函数。我们可以先调用一次puts,让它把某个函数的真实地址打印出来,然后用这个真实地址减去该函数在libc里的偏移量,就能得到libc基址。有了基址,system的地址就呼之欲出了。

2.2 地址泄露与libc版本判定的实操

泄露函数选择上,我习惯用puts而不是printf。原因很简单:puts会自动在字符串末尾补一个换行符,接收端好处理;printf如果遇到格式化字符串漏洞另说,单纯泄露地址的话,puts输出的字节更规整。另外,泄露的目标函数优先选puts本身,或者程序里确实用到的某个libc函数。因为泄露的对象必须真的被程序调用过,否则GOT表里没有它的真实地址。

拿第19篇的题目举例。程序有个read函数可以读入数据,有个puts函数可以输出数据。我构造的第一段payload是:用read读入足够的填充字节,把返回地址覆盖成puts@plt的地址,但参数是puts@got。这样程序返回后就会执行puts(puts@got),把GOT表中puts的真实地址打印出来。

这里有个细节容易翻车:栈对齐。x86-64下system执行时要求栈16字节对齐,如果你直接在printf调用后面接system,可能会因为对齐问题直接段错误。解决办法是先在payload里加一段ret指令的gadget,用它来做一次栈对齐,然后再真正跳转到system。这个坑我踩过很多次,后来养成习惯了,构造ROP链时第一件事就是检查对齐。

拿到puts的真实地址后,下一步是算偏移。你需要一份和目标程序相同版本的libc文件,然后用命令行工具查一下puts在libc里的偏移量。这一步如果libc版本对不上,算出来的system地址就是错的。判定版本的方法是:把泄露出来的puts后12位和libc-database里记录的做比对,后12位匹配基本就能锁定版本。我一般把常见libc版本都下载下来放到一个目录里,随时用脚本比对,效率高很多。

3. 堆利用初探:UAF漏洞的识别与利用原理

3.1 所谓UAF,到底漏洞在哪里

堆和栈的最大区别,是堆上分配的内存生命周期完全由程序员自己管理。栈上变量在函数返回时自动销毁,堆上则要靠free或delete手动释放。UAF的全称是Use After Free,中文叫释放后使用,意思是内存已经被释放了,但程序里还保留着指向这块内存的指针,之后又用这个指针去读写数据。

这听起来好像只是逻辑漏洞,但为什么它能getshell?关键在于glibc的分配器。当你free一块内存后,这块内存并不会真正还给操作系统,而是被glibc放进自己的空闲链表里缓存起来,等下次malloc同样大小时会优先复用。于是攻击思路就出来了:我free掉一个对象,然后马上malloc取回这块内存,写入我伪造的数据,再用原来的悬空指针去访问这个对象。如果这个对象内部有函数指针或者某种可以被劫持的成员,我就能控制程序流程。

第20篇的题目就是个经典UAF。程序定义了一个结构体,里面有函数指针和字符串指针。创建对象时malloc一块内存,函数指针指向某个正常的功能函数;释放时只调用了free,但结构体指针还留在全局变量里没清空。这就给了我们利用空间。

要识别这类漏洞,看反编译代码时有个规律:程序如果同时存在add、delete、edit、show四个功能,大概率是堆题;如果delete里只free没置空指针,那UAF实锤了。另外看malloc的时候,申请的大小是否和结构体大小一致也很关键,如果edit功能写入数据的大小比结构体大,那就可能是堆溢出,是另一种攻击路径了。

3.2 从fastbin到double free的利用链

直接伪造函数指针就能getshell,UAF就太简单了。第20篇里的实际利用路径更典型,涉及fastbin和double free的组合拳。

glibc的分配器把空闲块按大小分成几个bins,其中fastbin专门处理小于某个阈值的小块。fastbin的特点是LIFO,后释放的块先被重新分配,而且它的链表结构是单向的,free块里第一个字段就存着下一个空闲块的地址。因为fastbin不检查双重释放,我们可以free同一个chunk两次,这样链表里就会出现两个指向同一地址的块,形成环。

double free的下一步是伪造。正常malloc两次,第一次会拿到那块被重复释放的chunk,第二次又会拿到同一块,于是有了两个指向同一块内存的指针。这时候向第一个指针写入内容,第二个指针会看到相同的数据,这就实现了对同一块内存的写和读分离控制。利用这个特性,可以把结构体的函数指针改写成system的地址,同时想办法让参数指向"/bin/sh"字符串。

不过这里有一个新手必踩的坑:从glibc 2.26版本开始,分配器新增了tcache机制,它有自己的缓存和双重释放检测。很多旧教程里的double free利用链在新版本下直接失效。所以做题前一定先用工具查看程序的libc版本,再决定利用方案,别拿着老exp硬套,白费功夫。

4. 实操过程记录:从分析到真正拿到权限

4.1 静态分析和动态调试的配合

第20篇拿到程序后,我照例先用反编译工具看伪代码。程序功能很清晰:1是创建对象,2是删除对象,3是编辑对象内容,4是展示对象内容。全局变量里存了两个指针,一个指向对象结构体,一个指向对象内部的内容缓冲区。

创建对象的函数里,malloc了一个固定大小的结构体,结构体偏移0处是函数指针,偏移8处是内容指针。编辑功能允许我们向内容缓冲区写入最多指定长度的数据。删除功能则直接把内容指针free掉了,然后又把结构体free掉——但坏就坏在它没有把全局指针置空,也没有在free内容指针后把结构体里的内容指针字段清掉。

静态分析到这里,利用思路已经比较清晰了:释放对象后,再创建另一个对象,让分配器把刚释放的内容缓冲区重新分配给我们可控的对象,这样旧指针和新数据就重叠了。但我没有急着写exp,而是先用调试器跑一遍程序,下断点观察free之后堆块的状态,确认fastbin或tcache里的链表结构,再决定下一步怎么打。

动态调试的另一个作用是确认偏移量。栈题要算返回地址距离缓冲区开头的偏移,堆题则要确认结构体成员的确切偏移,以及可控数据离函数指针的距离。这些都不适合靠肉眼目测,实际用调试器看一眼内存布局,比对着代码胡思乱想踏实得多。我调试时习惯在关键函数调用前后下断点,用堆查看命令确认空闲块链表,再单步几次看清楚分配器到底返回了哪个地址。

4.2 关键payload构造的细节和坑点

整个利用过程分两步。第一步是泄露libc地址。先把一个对象释放掉,再用show功能查看已被释放但内容指针还残留的对象,这样就能读出原本存在内容缓冲区里的数据。如果这个缓冲区里曾经存放过某个libc函数指针或堆地址,就能顺势泄露出来。我当时构造的exp是先创建一个对象,往里面填入一组地址数据,删除它,再show,从输出中整理出需要的地址。

第二步才是真正的getshell。利用double free或UAF把伪造数据写进结构体,替换函数指针。这里最难的是地址对齐。释放后的堆块如果被重新分配,分配器返回的地址可能带随机偏移,直接指向结构体里的函数指针字段并不容易。我踩过的一个坑是:伪造数据时少算了一个成员的偏移,导致函树指针没被覆盖,程序运行到调用函数指针时直接崩溃,回显显示非法地址。

解决的办法是:先把要覆盖的地址计算清楚,然后反复调试打印堆地址。不要嫌麻烦,pwn调试就是耐心活。payload没反应、程序崩了、地址段错误,这些在新手期都是家常便饭,我后来都是用脚本把地址自动算好,再在注释里写好每一步的逻辑,这样即使出错也能快速定位是哪一步的问题。

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

5.1 本地调试和远程打不通:glibc版本不匹配

这个问题我在第20篇上浪费了整整一个晚上。本地exp跑得好好的,一连远程就段错误。后来一查,远程环境的libc版本和我本地的不一样,导致我在本地算好的system地址和"/bin/sh"地址全部失效。

解决方法是:先把远程泄露出来的地址,匹配到正确的libc版本,然后在payload里动态用这个libc版本的偏移去计算地址,而不是把系统地址写死在exp里。建议在exp代码里封装一个计算地址的函数,每次先泄露,再算基址,最后动态拼payload,这样换环境只需要换libc路径。

5.2 libc版本判定靠后三位不可靠

很多教程说拿泄露地址的后三位就能确认libc版本,但实际操作中我发现,不同版本可能存在后三位相同的巧合,只靠三位比对不够稳妥。更可靠的办法是用泄露地址减去某个候选libc里对应函数的偏移,看算出来的libc基址是否符合内存页对齐的规律,只有页内偏移匹配,才说明这个候选libc是对的。

还有一种情况是开启了地址随机化的系统上,泄露出来的地址本身带有随机量,但后12位的页内偏移是固定的,不受随机化影响。所以正确做法是:用后12位先做粗过滤,再用基址对齐做精确认。

5.3 输入缓冲和交互时机的坑

pwn题的交互和普通程序不同,输入输出都是流式的,经常会出现输出还没完全显示、脚本就发数据过去的尴尬情况。我习惯在exp里用接收完成函数等关键字或者调用接收一行函数把当前输出读完,再发下一步数据。

另外一个常被忽略的是换行符。puts输出会带换行,read读取时又会把换行符当输入的一部分,这会让你的payload长度和你以为的长度不一样。处理方法是接收端先去除多余字符,发送端尽量用字节类型的数据精确控制长度。

5.4 踩过最深的坑:盲目相信工具的输出

初次用工具看伪代码时很兴奋,觉得程序逻辑一目了然,结果后来发现工具的优化选项会改变代码结构,直接解出来的伪代码和程序真实行为有偏差。比如它可能把某些函数内联了,或者把某些循环展开了,导致我推算的偏移量不准。

从那以后我养成了一个习惯:先用工具看一遍大概逻辑,再对照汇编确认关键路径,特别是指针赋值和函数调用相关的指令。虽然更费时间,但在堆题里基本能避免方向性错误。

6. 写在最后的一点个人体会

做完这两篇练习,我自己最大的感受是:pwn这东西真的没有捷径,但也没有想象中那么玄乎。栈溢出是地基,堆利用是房子,所有的技法说到底都是对底层内存分配和程序执行流程的理解。遇到卡壳时,与其硬耗时间,不如退回一步想想分配器的行为,或者翻翻glibc的源码,很多时候答案就在那里。

如果只能给一条建议,我会说:一定把调试器用熟。很多人觉得看伪代码就能写exp,但真正到了堆题阶段,不观察内存、不验证假设,几乎寸步难行。我见过不少朋友折在堆利用入门这关,多数不是思路不对,而是调试工具不熟导致验证太慢。

后面我计划把tcache dup和house of系列也整理出来,那些题目比现在这两篇要绕得多,但核心思路和我上面说的还是一脉相承:理解分配器,掌控布局,劫持流程。先把基础题吃透,再上复杂题,这条路是走得通的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 5:44:37

AI辅助逆向工程实战:31.2K Star项目拆解与工作流指南

1. 从31.2K Star说起&#xff1a;逆向工程为什么突然被AI盯上了第一次在代码托管平台上刷到这个项目的时候&#xff0c;我盯着Star数看了好几秒——31.2K。这个量级在开源圈里意味着什么&#xff1f;意味着它已经跨过了"小众工具"的门槛&#xff0c;进入了"行业…

作者头像 李华
网站建设 2026/10/15 5:44:18

AD9513三路精密延时校准实战指南

简介&#xff1a;本资源为ADI公司AD9513高性能时钟分配芯片的官方中文数据手册PDF版&#xff0c;面向嵌入式硬件工程师、高速ADC/DAC系统设计人员及射频与精密仪器开发从业者&#xff0c;解决低抖动时钟信号分配、多路相位对齐与可编程分频等关键设计难题。手册全面覆盖器件架构…

作者头像 李华
网站建设 2026/10/15 5:43:37

AI回答自己长出界面:生成式交互界面实测与提问技巧

1. 这次升级到底改了什么&#xff1a;从“问答工具”到“生成式交互界面”先把结论摆在前面&#xff1a;这次更新的核心不是“模型又聪明了一点”&#xff0c;而是交互形态发生了质变。过去我们用对话式AI&#xff0c;本质上是在一个固定的聊天框里打字、等回复、再打字。界面是…

作者头像 李华
网站建设 2026/10/15 5:42:37

Java后端动态生成Word,Poi-tl模板引擎从入门到实战

做后端开发的同学&#xff0c;十有八九都写过“导出Word”这种需求。尤其是企业级系统里动不动就要导合同、导验收报告、导培训记录&#xff0c;你要是用原生POI一行行去画画样式&#xff0c;能把人画崩溃&#xff1b;你要是用Freemarker拼Word的XML结构&#xff0c;调试起来也…

作者头像 李华
网站建设 2026/10/15 5:42:35

Cherry Studio:本地离线多模型AI客户端,支持LLaMA-3/Phi-4跨平台部署

简介&#xff1a;本资源是一份关于AI桌面客户端Cherry Studio的深度介绍文档&#xff0c;面向希望提升日常效率的开发者、内容创作者、教育工作者及企业协作团队&#xff0c;解决多模型切换难、本地与云端AI工具割裂、文档与代码协同低效等实际问题。文档为单个16KB的Word&…

作者头像 李华
网站建设 2026/10/15 5:41:38

JDK 18新特性深度解析:从实用升级到迁移避坑指南

各位做Java开发的朋友&#xff0c;最近几年JDK的更新节奏确实快&#xff0c;从17到21&#xff0c;基本上每年都在折腾新东西。JDK 18是2022年3月发布的短期版本&#xff0c;虽然不像17或者21那样有长期支持光环&#xff0c;但这并不代表它没有看头。实际用过之后&#xff0c;我…

作者头像 李华