[CISCN 2022]华东北赛区 blue NSS

通过 pwninit 我们了解到这个文件是 libc 2.31 版本的
程序分析:
main
简单逆向修改函数过后大概是这个样子
1 | void __fastcall __noreturn main(const char *p_Invalid_choice_n, char **a2, char **a3) |
init_0
首位各一个 prctl,很明显的沙箱
1 | unsigned int init_0() |

add
1 | int add() |
Del
1 | int Del() |
show
1 | int show() |
free_uaf
1 | int free_uaf() |
很贴心地留了一个 UAF
EXP 思路:。
整个利用过程可以划分为四个阶段:Libc 泄露 -> 栈地址泄露 (FSOP) -> 劫持程序控制流 -> 执行 ORW ROP链
以下是详细的思路拆解:
封装函数
1 | def add(size, content): |
泄露 Libc_base(house of botcake)
1 | for i in range(9): |

- 申请 9 个大小为 0x80 的堆块后再申请一个 0x10 的防合并块,防止前面的堆块释放后与 Top Chunk 合并
- 释放前 7 个堆块,填满 Tcache 链表。
- 调用
uaf(8),即前面分析的带有 UAF 的free_all函数释放第 8 个堆块。由于 Tcache 已满,这个堆块会被放入 Unsorted Bin 中。
- 放入 Unsorted Bin 的堆块,其
fd和bk指针会指向 Libc 内部的main_arena结构。由于uaf(8)存在 UAF 漏洞,此时调用show(8)就可以打印出fd指针的内容,从而精准计算出libc_base。
Tcache Poisoning 1
1 | #poisoning |
- 一开始释放了 chunk 0~6 填满了 Tcache,。然后利用 UAF 将 chunk 8 释放进了 Unsorted Bin
delete(7):释放 chunk 7。由于 Tcache 已满,chunk 7 也进入 Unsorted Bin。由于物理内存上 chunk 7 紧挨在 chunk 8 前面,且 chunk 8 处于释放状态,Glibc 会将它们 Consolidate。add(0x80,b'cccc') #0:申请一个 0x80 的块。系统会从 Tcache 中弹出一个块,此时 Tcache 余量为 6
触发:Double Free
delete(8)虽然 chunk 8 的内存已经被合并到了 0x120 的大堆块中,但程序依然允许我们释放 chunk 8- 因为刚才 Tcache 腾出了一个位置,这次释放会把 chunk 8 放进 Tcache (0x90) 中。
- 此时 chunk 8 这块内存,既作为一条链表节点存在于 Tcache(0x90) 中,又同时包含在 Unsorted Bin 那个0x120 的大堆块内部
Tcache Poisoning 2
题目开启了沙箱,必须将执行流劫持到栈上执行 ROP 链。为了在栈上伪造数据,我们需要先知道栈的地址。这里利用了 _IO_2_1_stdout_ 和 environ。
1 | stdout = libc_base + libc.sym["_IO_2_1_stdout_"] |
add(0x70, b"aaaa") # idx=1:请求 0x70,实际分配0x80大小。- Tcache(0x80) 是空的,系统去 Unsorted Bin 找,找到了那个
0x120的大块。切下 0x80 字节给 idx=1,这部分正好是原 chunk 7 的区域剩余的 Unsorted Bin 块大小变为:0x120 - 0x80 = 0xA0。这个新块的起始地址向后移动了 0x80 字节。 add(0x70, payload) # idx=2:再次请求 0x70,实际分配0x80大小。系统继续切割那个0xA0的 Unsorted Bin 块。idx=2 的 chunk 头部起始地址是原地址 + 0x80。由于 chunk 头部占 0x10 字节,所以 idx=2 的 User_Data 起始于原地址 + 0x80 + 0x10 = 原地址 + 0x90。原 chunk 8 的起始地址刚好就是原地址 + 0x90,这意味着,idx 2 的用户可写区域正好覆盖了 chunk 8 的头部。- 此时写入 payload
payload = p64(0) + p64(0x91) + p64(stdout)由于 idx 2 的数据区就是 chunk 8 的头部,这段写入操作实际上在做:p64(0): 伪造 chunk 8 的 prev_sizep64(0x91): 伪造 chunk 8 的 sizep64(stdout): 覆盖 chunk 8 的 fd 指针进行 Tcache Poisoning。

add(0x80, b"bbbbb") # idx=3请求 0x80,实际分配0x90大小,系统检查 Tcache(0x90),发现里面有 chunk 8(前面被 Double Free 进去的)。系统把 chunk 8 分配给 idx=3(完成重叠,idx 2 的数据区和 idx 3 的头部重叠)。同时,系统读取了 chunk 8 的fd指针(已经被我们改成了stdout),将其更新为 Tcache 的链表头。
下次调用 add(0x80),系统就会把 _IO_2_1_stdout_ 的内存地址当作正常堆块分配,从而实现任意地址写
IO_2_1_stdout 利用全局变量 environ泄露栈地址
1 | payload2 = p64(0xfbad1800) # flag |
- Tcache Poisoning : 在 chunk 2 中写入伪造的 size (0x91) 和 fd 指针指向
_IO_2_1_stdout_ - 随后再申请同样大小的堆块时,堆管理器会把
_IO_2_1_stdout_所在的内存当成堆块分配给用户(即 chunk 4) - IO_2_1_stdout: 向分配到的
stdout结构体写入payload2。核心是把_IO_write_base设置为environ,_IO_write_ptr设置为environ+8。
- 当调用输出函数时,Glibc 会把从
_IO_write_base开始,到_IO_write_ptr结束的这段内存里的数据,当成字符串打印出来。 - 我们把
_IO_write_base指向了environ的地址。 - 把
_IO_write_ptr指向了environ + 8。 - 当程序下次调用
puts或printf时,会根据被篡改的stdout结构,将environ内存放的栈地址直接打印出来。
劫持控制流到栈上 (二次 Tcache Poisoning)
1 | offset2 = 0x128 |

- 既然知道了栈地址,只要把堆块分配到当前函数的返回地址上,再写入我们的恶意代码,当函数执行
ret指令时,就会跳转到我们的代码去执行。 - 操作:
- 根据泄露出的
environ栈底地址,减去固定偏移0x128,精准算出当前栈帧的 rbp - 再次释放堆块 2 和 3,利用同样的堆重叠/投毒手法,将 chunk 3 的
fd指针覆盖为stack_addr。 - 连续两次
add,此时堆管理器就会把包含函数返回地址所在的栈空间当作堆块分配给我们。
- 根据泄露出的
布置 ORW ROP 链读取 Flag
1 | # open('./flag', 0) |
总结: 利用 UAF 泄露 libc -> Tcache 投毒伪造 stdout 泄露栈地址 -> 二次 Tcache 投毒将分配指针改到栈上 -> 写入 ORW ROP 链绕过沙箱。
总 EXP:
Local:
1 | from pwn import * |
Remote:
1 | from pwn import * |
更新: 2026-05-07 16:49:15
原文: https://www.yuque.com/idcm/wnemg9/gvr9dhnayk94sxfw