ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从栈溢出到堆利用:二进制安全学习路线与PWN实战调试笔记

从栈溢出到堆利用:二进制安全学习路线与PWN实战调试笔记

1. 从栈到堆:为什么我坚持把这条学习路线走完

刚接触二进制安全那会儿,我和很多人一样,觉得栈溢出就是全部。一个gets、一个strcpy,配上pattern_create和pattern_offset,算出偏移量,控制EIP,跳转到shellcode或者system("/bin/sh"),一套流程下来行云流水,感觉自己已经摸到了门槛。但真正到了实际场景里,你会发现栈溢出只是冰山浮在水面上的那一角。现代程序里,栈上的保护机制越来越密——Canary、NX、ASLR、PIE,一层层叠上来,光靠栈溢出能打通的场景越来越少。而堆,这个看起来“没那么直观”的区域,反而成了很多真实漏洞的温床。

我整理这条学习路线,不是为了写一份教科书式的笔记,而是想把自己从栈溢出一路踩到堆利用的过程中,那些真正卡住我的点、那些让我反复调试到凌晨的细节,完整地摊开来讲。栈溢出是理解控制流劫持的起点,堆利用则是理解内存管理漏洞的进阶。两者之间不是割裂的,栈上的很多思路——比如覆盖返回地址、构造ROP链——在堆里会以另一种形式重现,只不过操作的对象从栈帧变成了chunk,从返回地址变成了fd/bk指针或者tcache链表。

这篇文章适合谁?如果你已经能独立完成一个基础的栈溢出题目,知道什么是偏移量、什么是ROPgadget,但一看到堆相关的题目就不知道从哪下手,那这篇整理就是为你准备的。我会从栈溢出的核心机制重新梳理一遍,然后自然过渡到堆的结构、分配器行为、常见利用手法,每个环节都配上我实际调试过的例子。关键词里的“栈溢出”“堆利用”“PWN”“二进制安全”“漏洞利用”会贯穿全文,但我不打算堆砌术语,而是尽量用调试器里的真实反馈来说话。

有一点需要提前说明:这篇文章里的所有例子都是我自己构造的模拟场景,不涉及任何真实系统或真实漏洞。我用的环境是Linux下的64位程序,工具链是gdb配合pwndbg插件,编译器是gcc,调试过程中会用到pwntools写exp。如果你用的是其他环境,思路是一样的,只是具体地址和偏移量需要自己重新算。

2. 栈溢出的本质:不只是覆盖返回地址那么简单

2.1 栈帧布局与覆盖点的精确定位

很多人学栈溢出,第一个卡住的地方不是原理,而是怎么精确算出偏移量。我见过不少新手,拿着一个pattern生成的字符串丢进去,程序崩溃了,然后对着EIP的值一脸茫然。其实定位偏移量的方法很直接:用cyclic生成一段有规律的字符串,长度要足够覆盖整个栈帧,然后看崩溃时EIP或RIP里装的是哪四个或八个字节,再用cyclic -l反查位置。

但这里有个细节容易被忽略:32位和64位的栈帧结构不一样。32位程序里,函数参数是直接压在栈上的,返回地址在EBP上方;64位程序里,前六个参数走寄存器,返回地址在RBP上方,而且栈对齐要求是16字节。这意味着你在64位程序里算偏移量时,不能简单照搬32位的经验。我自己的习惯是,先在gdb里disassemble目标函数,看清楚sub rsp, 0xXX这条指令,0xXX就是局部变量占用的栈空间大小,再加上保存的RBP(8字节),就是到返回地址的偏移。

举个例子,一个64位程序里,vuln函数的反汇编是这样的:

push rbp mov rbp, rsp sub rsp, 0x40 lea rax, [rbp-0x40] mov rsi, rax ...

那么从rbp-0x40这个缓冲区起始位置到返回地址的偏移就是0x40 + 8 = 0x48,也就是72字节。这个计算过程看起来简单,但实际调试时,编译器可能会插入额外的栈空间用于对齐,所以最稳妥的办法还是用cyclic实测一遍,和理论值对照。

2.2 从ret2text到ret2libc:保护机制逼出来的进化

最早的栈溢出利用就是ret2text——程序里自带一个system("/bin/sh")或者类似的函数,你只要把返回地址覆盖成那个函数的地址就行。这种场景在CTF入门题里很常见,但现实中几乎不可能这么幸运。于是有了ret2shellcode,把shellcode写到栈上然后跳过去执行。但NX保护一开,栈不可执行,这条路就断了。

接下来就是ret2libc。思路是:不直接执行shellcode,而是调用libc里现成的函数,比如system,参数是/bin/sh字符串的地址。但这里有个问题:system的地址在libc里,而libc的加载基址受ASLR影响,每次运行都不一样。所以你需要先泄露一个已经执行过的函数的实际地址,比如puts的GOT表项,然后根据libc版本算出system和/bin/sh的偏移,最后再构造第二次溢出跳过去。

这个过程中,泄露地址和再次溢出往往需要在同一个程序运行周期内完成,因为ASLR每次运行都会重新随机化。所以通常的做法是:第一次溢出控制程序返回到puts@plt,参数设为puts@got,把puts的真实地址打印出来;然后让程序返回到main函数重新开始,第二次溢出时用算好的system地址和/bin/sh地址构造ROP链。

我踩过的一个坑是:64位程序的参数传递需要用到pop rdi; ret这样的gadget,而32位程序是直接把参数压在栈上。如果你在64位程序里按32位的方式写exp,参数根本传不进去。找gadget可以用ROPgadget或者ropper,命令很简单:

ROPgadget --binary ./vuln --only "pop|ret"

输出里找pop rdi; ret,记下地址,这就是你构造ROP链的关键零件。

2.3 栈溢出调试中的三个高频翻车点

第一个翻车点是栈对齐。64位程序里,system函数内部会用到movaps指令,这条指令要求栈地址是16字节对齐的。如果你构造的ROP链在调用system时栈没有对齐,程序会在movaps那里崩掉,报SIGSEGV。解决办法是在ROP链里加一个retgadget,相当于多弹一次栈,调整对齐。这个坑我卡了整整一个晚上,后来在gdb里单步跟到movaps才恍然大悟。

第二个翻车点是libc版本不匹配。你本地调试用的libc和远程环境用的libc可能不是同一个版本,算出来的偏移量自然对不上。解决办法是用libc-database或者libcsearcher根据泄露的地址低12位去查,或者直接用题目提供的libc文件。我现在的习惯是,拿到题目先看有没有附libc,有的话就用patchelf把程序的libc换掉,保证本地和远程一致。

第三个翻车点是输入函数的行为差异。gets会一直读到换行符,read需要指定长度,scanf遇到空格会截断。如果你用read读入payload,但payload里包含了\x0a(换行符),read是不会截断的,但如果你用gets,payload里的\x0a就会提前结束输入。这个细节在构造包含地址的payload时特别重要,因为地址里很可能出现\x0a。

3. 堆的脾气:从chunk结构到分配器的真实行为

3.1 堆chunk的物理布局与size字段的玄机

栈是自动管理的,函数进来分配,出去回收,规规矩矩。堆不一样,堆是程序员手动要、手动还的,分配器(glibc里是ptmalloc)在中间做调度。你每次malloc,分配器给你一块内存,这块内存的头部藏着两个关键字段:prev_size和size。size字段的低三位有特殊含义:最低位是PREV_INUSE,表示前一个chunk是否在使用;倒数第二位是IS_MMAPPED,表示这个chunk是不是通过mmap分配的;倒数第三位是NON_MAIN_ARENA,表示这个chunk属不属于主分配区。

这三个位里,PREV_INUSE是最常被利用的。当你free一个chunk时,如果它前面的chunk是空闲的,分配器会把它们合并。合并的时候,会用当前chunk的prev_size字段去定位前一个chunk的起始位置。如果你能控制prev_size,就能让分配器合并到一个你伪造的地址上,这就是unlink和overlapping chunk的基础。

我刚开始学堆的时候,最困惑的是chunk的大小是怎么算的。比如你malloc(0x18),实际得到的chunk大小是0x20,因为size字段本身占8字节,而且chunk大小要按16字节对齐。所以malloc的参数和实际chunk大小之间有一个换算关系:

malloc请求大小实际chunk大小(64位)用户可用区域
0x00 - 0x180x200x18
0x19 - 0x280x300x28
0x29 - 0x380x400x38
0x39 - 0x480x500x48

这个表我建议你记在心里,因为后面算偏移、构造overlap的时候,全靠这个换算。

3.2 tcache:最快但也最容易被玩坏的缓存

glibc 2.26之后引入了tcache,每个线程默认有64个tcache bin,每个bin最多存7个chunk,大小从0x20到0x410。tcache的设计初衷是加速小内存的分配和释放,但它有个致命的特点:几乎没有安全检查。在早期的glibc版本里,tcache的fd指针直接指向下一个空闲chunk,你只要覆盖这个fd,下一次malloc就会返回你指定的地址。

tcache的利用手法里,最经典的是tcache poisoning。假设你有一个UAF(Use After Free)漏洞,能往已经free的chunk里写数据,那你就可以覆盖它的fd指针,把它改成你想分配的目标地址。然后连续malloc两次同样大小的chunk,第一次返回原来的chunk,第二次就返回你指定的地址。这个手法在glibc 2.27到2.29上非常稳定,2.31之后加了key字段做校验,但依然有绕过方法。

我实测下来,tcache的调试有个小技巧:在gdb里用p *(struct tcache_perthread_struct*)tcache可以看每个bin里存了几个chunk,用p tcache->entries[i]可以看第i个bin的链表头。pwndbg插件里直接有tcachebins命令,输出更直观。当你看到某个bin的计数是7的时候,说明这个大小的tcache已经满了,再free就会进fastbin或者unsorted bin。

3.3 fastbin与unsorted bin:不同大小走不同路径

fastbin管理的是0x20到0x80大小的chunk(64位下默认),它的特点是先进后出,而且free的时候不会清除PREV_INUSE位,也不会合并相邻chunk。fastbin的fd指针指向下一个空闲chunk,但只用了4字节(32位)或8字节(64位)的低位,高位是0。这个特性导致了一个经典的利用手法:fastbin attack,通过覆盖fd指针的低位,让分配器返回一个几乎任意地址。

unsorted bin是另一个重要的区域。当你free一个大于fastbin范围的chunk,或者tcache满了之后free的chunk,会先进入unsorted bin。unsorted bin是个双向链表,fd和bk指针分别指向前后chunk。当你再次malloc时,分配器会先遍历unsorted bin,找到合适大小的chunk就返回。如果找不到,就把这些chunk按大小归入small bin或large bin。

unsorted bin的利用价值在于:它的fd和bk指针在free之后会指向main_arena中的某个地址。如果你能泄露这个地址,就能算出libc的基址。这就是unsorted bin leak,是堆题里泄露libc最常用的手段之一。具体来说,当你free一个chunk进入unsorted bin,它的fd和bk都会被写成main_arena+0x60左右的地址。如果你能读这个chunk的内容,就能拿到这个地址,减去已知偏移就是libc基址。

4. 堆利用的实战手法:从UAF到House of系列

4.1 UAF:堆漏洞的万恶之源

UAF全称Use After Free,意思是释放之后继续使用。程序free了一个指针,但没有把指针置空,后续又通过这个指针读写数据。这个漏洞本身不是内存破坏,但它是一系列堆利用手法的入口。有了UAF,你可以往已经释放的chunk里写数据,而这块chunk可能已经被分配器放进了tcache或fastbin,你的写入会直接修改它的fd指针。

我构造过一个最简单的UAF例子:程序有两个功能,add和free,add的时候malloc一个chunk并读入内容,free的时候释放但不置空。我先add一个0x20的chunk,然后free它,再用add一个同样大小的chunk,这时候分配器会把刚才释放的chunk重新分配给我。如果我在free之后、再次add之前,通过某种方式(比如另一个指针)往那个chunk里写数据,就能控制新chunk的内容。

实际调试时,我会在gdb里用heap命令看chunk的布局,用bins命令看各个bin的状态。UAF的利用往往需要配合tcache poisoning或者fastbin attack,单独一个UAF很难直接拿到shell,但它提供了写fd指针的能力。

4.2 tcache poisoning的完整利用链

假设我们有一个UAF,能往free后的chunk里写8个字节。目标是在__malloc_hook或__free_hook附近分配一个chunk,然后写入system的地址,最后触发free或malloc拿到shell。

步骤是这样的:

  1. 分配两个0x20的chunk,记为A和B。
  2. 释放A,再释放B。此时tcache的0x20 bin里,B在链表头,B->fd = A。
  3. 通过UAF往B里写数据,覆盖B->fd,把它改成__free_hook的地址。
  4. 连续malloc两次0x20的chunk。第一次返回B,第二次返回__free_hook。
  5. 往第二次分配的chunk里写入system的地址。
  6. 释放一个内容为/bin/sh的chunk,触发__free_hook,拿到shell。

这个链条里,最关键的是第3步的地址计算。__free_hook的地址在libc里,你需要先泄露libc基址。泄露的方法可以用前面说的unsorted bin leak,也可以用tcache里残留的指针。我通常会在free一个chunk之前,先往里面写一个已知的libc地址(比如通过格式化字符串泄露),然后free之后读出来。

4.3 unsorted bin attack与House of Orange

unsorted bin attack的目标是往任意地址写一个main_arena相关的值。它的原理是:当你free一个chunk进入unsorted bin,然后再次malloc一个更大的chunk时,分配器会把unsorted bin里的chunk取出来,同时把它的bk指针写到unsorted_chunks(av)->bk->fd的位置。如果你能控制bk指针,就能往任意地址写一个堆地址。

这个手法单独用很难直接拿shell,但配合其他技术就很强。比如House of Orange,它的核心是在没有free功能的情况下,通过malloc一个比当前top chunk还大的chunk,触发sysmalloc,把旧的top chunk放进unsorted bin,然后利用unsorted bin attack修改_IO_list_all,最终劫持_IO_FILE结构体拿到shell。

House of Orange的利用链比较长,我建议先理解_IO_FILE的结构和虚表劫持的原理,再回头看这个手法。实际调试时,_IO_list_all的地址和system的地址都需要精确计算,一个字节的偏差就会导致崩溃。

4.4 堆利用中的信息泄露:没有leak就没有一切

堆题和栈题最大的区别在于:栈题往往可以直接跳转到某个函数,堆题几乎必须依赖信息泄露。因为堆的地址、libc的地址都是随机的,你不知道这些地址就没法构造有效的利用链。

泄露的手段主要有几种:

  • unsorted bin leak:free一个chunk进unsorted bin,读它的fd/bk,得到main_arena相关地址,算出libc基址。
  • tcache leak:在某些glibc版本里,tcache的fd指针会指向堆地址,可以用来泄露堆基址。
  • 格式化字符串:如果程序有格式化字符串漏洞,可以直接泄露栈上或任意地址的内容。
  • stdout leak:通过修改_IO_2_1_stdout_结构体,把libc地址打印出来。

我自己的习惯是,拿到堆题先找有没有show功能,有的话优先考虑unsorted bin leak。如果没有show,那就得找其他泄露途径,比如UAF配合printf,或者利用malloc返回的指针做文章。

5. 调试堆的笨办法与巧办法

5.1 用gdb+ pwndbg看清每一个chunk

堆调试最痛苦的地方在于,你没法像栈那样直接x/20gx $rsp就看清楚布局。堆的chunk散落在内存各处,靠肉眼找效率极低。我刚开始学的时候,用x/100gx一块一块翻,经常翻到眼花。

后来用了pwndbg,效率提升了一个量级。pwndbg的heap命令会列出所有chunk的地址、大小、状态(是否在使用),bins命令会显示tcache、fastbin、unsorted bin、small bin、large bin里各有哪些chunk。vis_heap_chunks命令更是直接把堆内存可视化出来,每个chunk的头部和用户区域都标得清清楚楚。

我常用的几个命令:

heap # 查看所有chunk bins # 查看所有bin的状态 tcachebins # 只看tcache vis_heap_chunks # 可视化堆布局 p *(struct malloc_chunk*)0x555555757000 # 查看指定地址的chunk结构

还有一个技巧是在malloc和free处下断点,每次调用都打印参数和返回值。这样你能清楚地看到每次分配和释放对堆状态的影响。比如:

b malloc commands silent printf "malloc(%d) = ", $rdi finish printf "%p\n", $rax continue end

这个断点脚本会打印每次malloc的大小和返回地址,对于跟踪chunk的分配顺序非常有用。

5.2 写exp时的常见错误与排查思路

写堆利用的exp,最常遇到的错误是地址算错。比如__free_hook的偏移,不同libc版本可能差几十个字节。我的做法是,先用readelf -s libc.so.6 | grep __free_hook确认偏移,然后在gdb里p &__free_hook验证。如果本地和远程libc不一致,那就用patchelf换掉,或者用libcsearcher查。

第二个常见错误是chunk大小算错。前面说过,malloc(0x18)实际得到0x20的chunk,但如果你malloc(0x20),实际得到的是0x30的chunk。这个换算关系在构造tcache链表时特别重要,因为不同大小的chunk进不同的bin,搞错了就串不起来。

第三个错误是忽略了tcache的计数。tcache每个bin最多7个chunk,第8个free会进fastbin或unsorted bin。如果你以为所有free的chunk都在tcache里,构造利用链时就会发现链表对不上。我通常会在exp里加一句print(tcache_count),或者直接在gdb里看tcachebins的输出。

5.3 从本地打通到远程打通:环境差异的坑

本地打通了,远程打不通,这是堆题最让人抓狂的情况。原因通常有三个:

第一,libc版本不同。远程的libc可能比你本地的旧或新,__free_hook、system、/bin/sh的偏移都不一样。解决办法是尽量用题目提供的libc,或者用libc-database根据泄露的地址反查。

第二,ASLR的随机性。有时候本地打通是因为运气好,地址恰好落在可用的范围里。远程多跑几次就崩了。解决办法是确保你的利用链不依赖任何随机地址,所有地址都从泄露的值算出来。

第三,网络延迟和输入输出缓冲。远程交互时,recvuntil的时机很重要。如果程序先输出提示再读输入,你就要先recv到提示再send。我习惯在exp里加context.log_level = 'debug',把所有的收发都打印出来,方便定位卡在哪一步。

6. 从栈到堆的学习路径建议

如果你现在还在栈溢出的阶段,我的建议是先把栈溢出打扎实。不要急着跳到堆,因为栈溢出里的ROP链构造、GOT/PLT机制、libc基址计算,这些在堆利用里都会反复用到。栈溢出是理解控制流劫持的基础,堆利用是理解内存管理漏洞的进阶,两者是递进关系,不是替代关系。

具体来说,我建议按这个顺序走:

  1. 栈溢出基础:ret2text、ret2shellcode、ret2libc,理解偏移量计算、gadget查找、ROP链构造。
  2. 栈溢出进阶:绕过Canary、绕过NX、绕过ASLR,理解__libc_csu_init、ret2dlresolve。
  3. 堆基础:理解chunk结构、tcache、fastbin、unsorted bin,能在gdb里看懂堆布局。
  4. 堆利用入门:UAF、tcache poisoning、fastbin attack,能独立写出利用exp。
  5. 堆利用进阶:unsorted bin attack、House of Orange、_IO_FILE劫持,理解更复杂的利用链。

每个阶段都要动手调试,不要只看文章。我见过很多人把文章看了一遍,觉得自己懂了,一上手就卡住。堆利用尤其如此,gdb里的实际内存布局和文章里的示意图往往有出入,只有自己调过一遍,才能记住那些细节。

最后分享一个我自己的习惯:每做完一道题,我都会把exp和调试笔记整理成一个markdown文件,记录下偏移量、关键地址、踩过的坑。下次遇到类似的题目,直接翻笔记,效率高很多。堆利用的手法虽然多,但核心思路就那么几个——控制fd指针、伪造chunk大小、泄露关键地址。把这些核心思路吃透,剩下的就是组合和变通。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进