# ezheap 详细题解(Writeup)
# 一、题目信息
| 项目 | 内容 |
|---|---|
| 题目名称 | ezheap |
| 考点 | 堆溢出 + 函数指针劫持(无需 __free_hook ) |
| 运行环境 | glibc 2.23(Ubuntu 16.04,无 tcache) |
| 靶机地址 | node5.anna.nssctf.cn:27448 |
| 附件 | ezheap (ELF 64-bit)、 libc-2.23.so |
# 二、保护机制
1 | Arch: amd64-64-little |
程序开启了 PIE、Full RELRO、Canary、NX,无法直接改写 GOT 表,栈不可执行。由于堆上存在一个函数指针字段,本题思路是通过堆溢出劫持该函数指针为 system ,实现任意函数调用。
# 三、逆向分析
程序是一个菜单程序(banner 为 Easy Note. ),主菜单:
1 | 1.Add. |
# 3.1 全局数组
| 符号 | 地址 | 作用 |
|---|---|---|
sizelist |
0x4060 |
记录每个 note 的 content 大小 |
heaplist |
0x40e0 |
记录每个 note 结构体指针 |
# 3.2 结构体定义
add 中先 malloc(0x20) 作为结构体(实际 chunk 大小为 0x30 ),再 malloc(size) 作为内容。结构体布局如下:
1 | struct note { |
反汇编关键片段( add ):
1 | ; malloc(0x20) 结构体 -> heaplist[idx] |
# 3.3 add(idx, size, name, content) —— 0x12e9
1 | void add() { |
关键点: name 固定读入 0x10 (16)字节,不会自动补 \0 。当 name 恰好 16 字节时,紧邻其后的 content 指针会与 name 连在一起被 puts 打印,产生信息泄露。
# 3.4 show(idx) —— 0x153b
1 | void show() { |
关键点: show 会连续两次通过函数指针调用:
func_ptr(struct)—— 参数为结构体指针(name位于+0x00);func_ptr(struct->content)—— 参数为内容指针。
默认情况下 func_ptr = puts ,因此第一次调用相当于 puts(name) ,第二次相当于 puts(content) 。
反汇编中注意:两次调用都从
[struct+0x20]取函数指针,参数分别取struct和[struct+0x10](content)。
# 3.5 delete(idx) —— 0x162a
1 | void delete() { |
delete 释放 content 与结构体后会将指针置空、flag 置 0,逻辑较严谨。
# 3.6 edit(idx, size) —— 0x1756 (漏洞点)
1 | void edit() { |
漏洞:
edit没有检查flag(即被 delete 后仍可 edit,但本题用不到 UAF);edit读入size字节(最大0x100),不校验size是否超过原 chunk 的大小,导致 堆溢出;edit没有"Content:"提示,直接read。
溢出起点是
content的起始地址,通过控制size可以覆盖到相邻 chunk 的内容。
# 四、漏洞利用思路
由于结构体中自带一个函数指针 +0x20 ,我们只需要:
- 泄露堆地址 → 得到
content0指针,从而推算出结构体地址; - 泄露 libc 地址 → 把某个 note 的 content 指针重定向到「存放 puts 函数指针的地址」,用
show打印出来; - 劫持函数指针 → 覆盖
struct1:name = "/bin/sh"、func_ptr = system,再show(1)触发system("/bin/sh")。
利用链路: edit 堆溢出 → 篡改相邻结构体字段 → show 的两次函数指针调用完成泄露与命令执行。
# 五、详细利用步骤
# 5.1 堆布局
先分配两个 0x20 的 note:
1 | add(0, 0x20, b'A'*16, b'\x00'*0x20) |
堆布局(每个结构体 / 内容 chunk 都是 0x30 ):
1 | 低地址 |
即 struct0 = C0 - 0x30 , struct1 = C0 + 0x30 。
# 5.2 第一步:泄露堆地址(content0)
show(0) 会调用 puts(struct0) 。由于 name 是 16 字节无 \0 , puts 会继续打印紧随其后的 content 指针( +0x10 ),直到遇到 \0 :
1 | 输出: "A"*16 + content0指针(6字节) + "\n" |
1 | show(0) |
# 5.3 第二步:泄露 libc 地址(puts)
利用 edit(0, 0x50, ...) 从 content0 溢出到 struct1 ,把 struct1->content 改成 &struct0->puts (即 struct0+0x20 = C0-0x10 ),再 show(1) :
1 | payload = b'A'*0x30 + b'B'*0x10 + p64(C0 - 0x10) + p32(1) + b'C'*4 |
溢出后 struct1 各字段:
1 | struct1.name = "B"*16 (C0+0x30) |
show(1) 的两次调用:
puts(struct1)→ 打印"B"*16+&struct0->puts指针(6 字节)+\n;puts(struct1->content)=puts(&struct0->puts)→ 打印真正的puts函数地址(6 字节)。
因此 puts 地址位于 "B"*16 之后偏移 16(名字) + 6(重定向指针) + 1(换行) = 23 处:
1 | data = p.recvuntil(b'4.Edit.\n') |
计算基址:
1 | libc_base = puts - 0x6f6a0 # libc 2.23 的 puts 偏移 |
libc 版本判定:
puts低 12 位为0x6a0,glibc 2.18 与 2.23 都满足,仅凭一次 puts 泄露无法区分。可通过 unsorted bin 泄露main_arena,计算main_arena - puts的差值:2.23 为0x3554d8,2.18 为0x350108。本题由附件libc-2.23.so确认使用 2.23,偏移如下:
符号 偏移 puts0x6f6a0system0x453a0"/bin/sh"0x18ce57
# 5.4 第三步:劫持函数指针执行 system
再次 edit(0, 0x58, ...) 覆盖 struct1 :
1 | payload2 = b'J'*0x30 + b'/bin/sh\x00' + b'X'*8 + p64(0) + p32(1) + b'Y'*4 + p64(system) |
覆盖后 struct1 :
1 | struct1.name = "/bin/sh\0XXXXXXX" (C0+0x30) 16 字节 |
此时 show(1) 的第一次调用:
1 | n->puts(n); // => system(struct1) => system("/bin/sh") |
struct1 的起始地址( +0x00 )正好是 "/bin/sh\0" ,于是 system 收到参数 "/bin/sh" ,拿到 shell。
# 六、完整 EXP
1 | #!/usr/bin/env python3 |
# 七、踩坑记录
-
name 无
\0才能泄露:add读 name 用read(0, n->name, 0x10),必须填满 16 字节,让puts越过 name 继续打印 content 指针。若填\x00会截断泄露。 -
ASLR 低字节为
0x00导致泄露截断:堆地址若其第 2 字节恰为0x00(概率约 1/16),puts会提前截断,固定偏移解析出垃圾值。解决:对泄露结果做合法性校验(堆地址0x55~0x57、puts 地址0x7e~0x80且低 12 位为0x6a0),非法则重连重试。 -
puts 地址范围:64 位 Linux 下 libc 通常在
0x7f00...附近,但 ASLR 偶尔会映射到0x7efe...,校验区间下界应放宽到0x7e0000000000,否则会误丢弃合法泄露。 -
edit无"Content:"提示:与add不同,edit是read(0, content, size)直接读,交互时用send而非sendafter。 -
沙箱 /chroot 环境:拿到 shell 后
id不存在、/dev/null重定向报Permission denied。读 flag 需用绝对路径/bin/cat /flag,且不要加2>/dev/null。 -
触发点:
show第一次调用参数是结构体指针(+0x00为 name),因此把name写成"/bin/sh"、函数指针写成system,即可system("/bin/sh"),无需另外布置参数。
# 八、Flag
1 | nssctf{dsbuyh81e2781ey7ed8u8718278t1321} |
# hectf_2024_Arcaea_Sorting 详细 Writeup
题目:Arcaea Sorting 计分系统 (32 位 ELF,PIE + Partial RELRO + NX, 无 canary)
漏洞:格式化字符串 (主)+ 栈溢出 (次)
利用:格式化字符串泄漏 libc →%hn覆写printf@GOT为system→ 触发system("/bin/sh")
# 一、题目信息与保护机制
1 | $ file pwn |
关键点:
- 32 位 PIE: 所有代码 / 数据地址都要先泄漏基址。
- Partial RELRO:
.got.plt可写,可以做 GOT 覆写。 - NX 开启:不能直接跑 shellcode, 走 ret2libc / GOT hijack。
- RUNPATH 为
.: 题目给的libc.so.6会被本地自动加载,偏移可以直接算。
# 二、程序逻辑
程序是一个 "Arcaea 查分器", main 里是一个菜单循环:
1 | 1. If the score is qualified, it will be stored in B30 -> setB30 |
进入菜单前会先用 scanf("%f") 读一次你的 PTT, compare() 按分数段打印鼓励语 (输入 12.0 这种正常值即可,别触发 exit(0) )。
与漏洞相关的是两个函数:
# 2.1 vote() —— 写入全局 name
1 | char name[0x100]; // 全局变量,位于 PIE 基址 + 0x5080 |
name 完全可控,每次投票写入 0x60 字节,最多 3 次。
# 2.2 output_vote() —— 格式化字符串 + 栈溢出
1 | void output_vote() { |
对应的关键汇编:
1 | 1ccc: lea eax,[ebp-0x48] ; buf |
两个漏洞:
- 栈溢出:
buf在ebp-0x48, 返回地址在ebp+4, 距离 76 字节;name最长 96 字节,可溢出 20 字节 (本文解法没有用它)。 - 格式化字符串:
printf(buf), 而buf内容来自我们控制的name。这才是本题的核心。
# 三、格式化字符串定位
先把 name 设成探测串,看 %p 能打到哪:
1 | name = b"AAAA" + b".%p"*30 |
output_vote 输出:
1 | AAAA.0x565b9080.0xff97b068.0x565b5ca0.0x41414141.0x2e70252e... |
④ = 0x41414141="AAAA", 说明栈缓冲的第 0 个 dword 是第 4 个参数 (%4$)。
于是把地址放在 name 开头,即可用 %4$s / %4$hn 去解引用 / 写入它。结构:
1 | name = p32(目标地址) + 格式化串 |
# 四、泄漏地址 (第 1 次投票)
一次格式化字符串同时拿到两个基址:
1 | name = b'%1$p.%31$p' |
%1$p= 全局name的地址 =PIE基址 + 0x5080→ 算出 PIE 基址。%31$p= 栈上 main 的返回地址 (落回 libc 的__libc_start_main调用链) =libc基址 + 0x21519→ 算出 libc 基址。
0x21519 这个偏移是通过 " 泄漏 %31$p 同时泄漏 printf@GOT 交叉验证 " 得到的,对该 libc 恒定:
1 | %31$p = 0xf7d3c519 |
得到:
1 | pie_base = name_addr - 0x5080 |
# 五、覆写 printf@GOT → system (第 2 次投票)
目标:把 printf@GOT (4 字节) 写成 system 的地址。用 %hn 分两次写 2 字节:
1 | printf_got[0:2] = system & 0xffff (低 2 字节) |
构造 (两个目标地址放在最前,分别落到 %4$ / %5$ ):
1 | low = system & 0xffff |
原理:
- 先原样打印 8 字节 (两个地址)→ 已输出 8 个字符;
%{pad1}c再补pad1个字符 → 当前已输出8 + pad1 = low, 用%4$hn把low写到printf_got;%{pad2}c再补pad2个字符 → 当前输出low + pad2 = high, 用%5$hn把high写到printf_got+2。
此时 printf@GOT 已指向 system 。
整个
fmt长度约 35 字节,远小于buf的 68 字节,不会触发栈溢出;且printf_got(形如0x565xxx1c) 字节里没有\x00,strcpy能完整复制。
# 六、触发 system("/bin/sh") (第 3 次投票)
现在 printf 已经被劫持成 system 。把 name 设成 /bin/sh , 再调一次 output_vote :
1 | strcpy(buf, name); // buf = "/bin/sh" |
注意:第 3 次投票时, vote() 内部也会调用 printf("Your vote was for %s...") , 它同样会被劫持成 system("Your vote was for %s...") —— 只是一条无害的命令报错,不影响流程。因此第 3 步定位输入时改用 puts 的文案 ( cast your valuable vote ) 而不是 printf 的文案来同步。
# 七、完整 exp
1 | #!/usr/bin/env python3 |
运行结果:
1 | $ python3 exploit.py |
# 八、补充
-
evaluate()隐藏函数 (未被菜单调用): 有strcmp(input, "g01den")的鉴权 + 一个 "读 count 字节" 的逻辑,属于干扰项;它本身没有明显溢出 (count 上限 0x100 正好等于缓冲区大小), 本题未用到。 - 栈溢出:
output_vote的strcpy可溢出 20 字节,理论上也能做 ret2libc / ROP, 但格式化字符串更直接、且一次能完成 "泄漏 + 覆写", 因此选择格式化字符串路线。 - 只要换掉
HOST/PORT, 本地与远程逻辑一致 (ASLR 每次都变,但偏移恒定,脚本自动适配)。
# hectf_2024_Arcaea_Sorting_Revenge Writeup
# 一、题目信息
| 项目 | 内容 |
|---|---|
| 题目 | hectf_2024_Arcaea_Sorting_Revenge |
| 类型 | 堆溢出(heap overflow) |
| 环境 | glibc 2.23( ld-2.23.so ),64 位 |
| 保护 | Full RELRO + Stack Canary + NX + PIE 全开 |
| 附件 | pwn (主程序)、 libc.so.6 、 ld-2.23.so |
程序是一个 "音乐管理系统",通过菜单提供增删改查等功能。
1 | =======Please select the following options to use the relevant functions======== |
# 二、程序分析
# 2.1 数据结构
每次 add 都会 malloc(0x48) 分配一个结构体(chunk 大小 0x50 ),再 malloc(max_size) 分配 description。结构体布局如下(偏移相对 malloc 返回指针):
1 | struct music { |
全局 music_array 位于 bss(PIE 基址 + 0x202080 ),共 80 个指针。
# 2.2 关键函数
- add(
0xaee):malloc(0x48)结构体 +malloc(max_size)description,依次read/scanf写入各字段。 - delete(
0xd29):free(desc)、free(struct),并清空指针(无 UAF)。 - edit(
0xe0b):漏洞点,见下。 - show(
0xf26):printf("%s", desc)打印 description。
# 2.3 漏洞点
edit 函数的逻辑:
1 | scanf("%d", &index); |
关键问题:① 允许把 max_size 改成任意值,但 ② 的 read 直接写入原来的 description 指针,而这个缓冲区在 add 时只 malloc 了最初的 max_size 大小,并未重新分配。
因此只要把 max_size 改大,再编辑 description,就能造成堆溢出,向相邻 chunk 越界写任意数据。
补充:程序用
setbuf(stdin, NULL)关闭了标准 IO 缓冲,scanf的 1 字节 pushback 分隔符不会被read()读到,因此read()总是从管道读新数据,交互上比较干净。
# 三、利用思路
由于全保护(Full RELRO 不能改 GOT,PIE 需要先泄露基址),采用经典的堆利用路线:
1 | 泄露 libc 基址 → 堆溢出劫持 desc 指针 → 任意写 __free_hook → 触发 system("/bin/sh") |
# 3.1 泄露 libc 基址(unsorted bin 残留指针)
glibc 2.23 无 tcache, free 一个大于 fastbin 的 chunk 会进 unsorted bin,其 fd / bk 指向 main_arena 。
add(0):description 大小设为0x100(chunk0x110)。add(1):占位,description 大小0x20。delete(0):0x110chunk 进 unsorted bin,fd = bk = main_arena + 0x58。add(2):description 大小设为0。此时malloc(0)从 unsorted bin 的0x110chunk 上切下0x20一块返回,剩余部分留在 unsorted bin。切下的这一块其fd/bk仍保留着main_arena指针,而read(0, desc, 0)不写入任何数据。show():printf("%s", desc)把残留的fd指针当字符串打出来(直到遇到\x00,共 6 字节)。
实测得到:
1 | leak = libc_base + 0x3c4c78 |
# 3.2 堆溢出劫持 desc 指针
堆布局上,依次 add 两个 music A、B,其内存顺序为 [S_A][D_A][S_B][D_B] ,即 A 的 description(D_A)紧邻 B 的结构体(S_B)。
- A、B 的 description 大小都设为
0x20(chunk0x30)。 - 溢出 D_A,覆写到 S_B,重点把
S_B->desc(偏移+0x40)改成__free_hook地址。
从 D_A 用户区到 S_B->desc 的偏移计算(D_A chunk 大小 0x30 ):
| 区间 | 大小 | 说明 |
|---|---|---|
| D_A 用户区 | 0x20 |
填充 |
| S_B prev_size | 0x08 |
填充 |
| S_B size | 0x08 |
必须保留 0x51 |
| S_B name | 0x30 |
填充 |
| index/rating/score/max_size | 0x10 |
填充(max_size 置 8) |
| S_B desc | 0x08 |
覆写为 __free_hook |
溢出 payload:
1 | payload = b'A' * 0x20 # D_A 用户区 |
# 3.3 任意写 __free_hook
此时 S_B->desc 已指向 __free_hook ,再调用 edit(B) :
1 | scanf("%d", &music[B]->max_size); // 写 8 |
于是 __free_hook = system 。
# 3.4 触发 shell
add 一个 description 为 /bin/sh\x00 的 music,再 delete 它:
1 | free(desc); // desc = "/bin/sh" |
glibc 2.23 中 free 开头检查 __free_hook ,非空则调用 __free_hook(mem) ,即 system("/bin/sh") ,拿到 shell。
# 四、关键偏移
| 符号 | 偏移 |
|---|---|
leak - libc_base |
0x3c4c78 |
system |
0x453a0 |
__free_hook |
0x3c67a8 |
/bin/sh |
0x18ce57 |
# 五、完整 Exploit
1 | #!/usr/bin/env python3 |
# 六、结果
1 | [+] libc_base = 0x7f9dc666c000 |
# 七、小结
本题的核心考点是 edit 中 "只改 size 不重新分配" 导致的堆溢出。利用路径为:
- unsorted bin 残留指针泄露 libc(利用
read长度为 0 不覆盖数据 +%s打印残留fd)。 - 堆溢出劫持相邻结构体的
desc指针,获得任意地址读写。 - 覆写
__free_hook为system,最后free("/bin/sh")触发 RCE。
由于 glibc 2.23 无 tcache 检查,整个利用过程干净稳定。