续之前的Windows
,最近应该会学习一波Windows
的Pwn
题。但是,之前还有几个比赛的题还没做完,tcl,做题太慢了。
基础知识
库介绍
ntdll.dll:描述了 windows 本地
NTAPI
的接口,是重要的Windows NT
内核级文件。位于Kernel32
和user32.dll
中的所有win32 API
最终都是调用Ntdll.dll
中的函数实现的。kernel32.dll:是32位动态链接库文件,属于内核级文件,控制系统的内存管理、数据的输入输出操作和中断处理;
User32.dll:是
Windows
用户界面相关应用的程序接口,用于包括Windows
处理,基本用户界面等特征,如创建窗口和发送消息。Gdi32.dll:是存放在
Windows
系统文件夹中的一个动态链接库,是Windows
下图形用户界面的应用拓展;ucrtbased.dll:是Runtime Library组件,即 VC 运行库中的相关动态链接库文件,例如 vs中的各种集成开发环境都会需要该组件才能够运行。
Windows下函数调用
Windows
的 x64
下只有一种函数调用约定,即 __fastcall
,其他调用约定的关键字会被忽略,也即 ABI
只有 __fastcall
一个函数在调用时,前四个参数是从左至右依次存放于 RCX、RDX、R8、R9
寄存器里面,剩下的参数通过栈传递,从右至左顺序入栈。注意这里的参数寄存器与Linux
不同。
如果是 int f(double a, double b, double c, double d, double e, double f)
这样的函数,前四个浮点类型的参数从左到右由 XMM0,XMM1,XMM2,XMM3
依次传递,剩下的参数通过栈传递,从右至左顺序入栈。
被调用函数的返回值64位以内,仍然会被放入 RAX
; 如果是 浮点值,则返回值存放入 XMM0
.
注意:更大的返回值(比如结构体),由调用方在栈上分配空间,并由 RCX
持有该空间的指针并传递给被调用函数,因此整型参数使用的寄存器依次右移一格,实际只可利用 RDX,R8, R9
3个寄存器,其余参数通过栈传递。函数调用结束后,RAX
返回该空间指针。
2020 QWB Overflow
程序分析
程序功能很简单,就三次溢出。
利用分析
- 泄露 GS_Cookie
由于栈上存在 GS_Cookie
,所以当我们覆盖返回地址实现 ROP
时,会由于覆盖了 GS_Cookie
而导致程序出错,所以我们需要先泄露 GS_Cookie
,然后ROP
将该位置填上正确的 GS_Cookie
;如下图所示,三个箭头的位置从上至下分别是 GS_Cookie
,rbp
和返回地址。
- 泄露程序基址和
ntdll.dll
地址
windows
下的 ASLR
机制与linux
不同,PIE_base
和 dll_base
其低 2 bytes
均为0,而且在短时间是不会变化的,经过我测试发现程序基址只要不重启应该都不会改变。
泄露程序基址,可以通过直接泄露返回地址,即可得到当前程序的基址;
泄露 ntdll.dll
基址,我们可以看一下执行到 main
函数时 程序的函数调用栈,如下所示,可以看到在main
之前分别调用了 Kernel32
和ntdll
。也就是说这几个库的函数栈一定在当前 main
函数的调用栈下面,我们可以从上面的那张图也看到,在main
函数的返回地址之后出现了一个 0x7fff20c67c24
地址,这个地址就是 Kernel32
库里的地址,证明了我们函数调用栈的结果。
我们从 main
函数继续向下查找,可以看到 能找到 ntdll
库函数的地址,那么我们可以成功泄露 Ntdll
的地址。
泄露 ntdll
的地址是为了方便我们使用 gadget
:
- 泄露
ucrtbased.dll
基址和security_cookie
接着就要泄露 ucrtbased.dll
的基址,这个库是 VC
程序执行必须要有的库,里面放入了很多 VC
程序的API
调用接口,类似于 Glibc.so
。
我们可以通过他找到 system
地址和 cmd.exe
字符串。
这个库的地址,不能在 Main
函数下方找不到,不能够直接泄露。其泄露思路和 glibc
下的常见泄露思路是一致的,Linux
是使用puts
函数输出 got
表的地址,即可泄露 Libc
地址;Windows
下之前提过没有 got
表,但是有导入导出表,我们也可以通过 Puts
函数输出导入导出表里的地址,来泄露 各个库的基址。
如下所示,我们在导入表里可以找到 read
函数,其由 ucrtbased.dll
导入。
我们动态调试,可以发现程序中该导入表的地址已经存储了 ucrtbased.dll
中 read
函数的真正位置。
注意,还有一点是需要注意的地方,即windows
下也没有了 plt
表,如果我们想调用一个函数需要直接使用含有call
该函数指令的 Gadget
来调用。
例如 Linux
下,我们要泄露 read_got
地址,rop
如下:
1 | p64(p_rdi_ret)+p64(read_got)+p64(puts_plt) |
但是,在windows
下,我们需要首先找到 含有 call puts
指令的 gadget
,如下所示:
1 | p64(p_rcx_ret)+p64(read_import)+p64(call_puts) |
然后,我们还需要泄露 security_cookie
这个值是存储在 程序地址空间中的一个随机值,作用就是每次新生成栈时用来和rsp
异或生成 新的 GS_Cookie
的值。由于每次执行main
,rsp
都会抬高 0x130
。拿到这个值了,我们才能够在再次执行 Main
函数时绕过 GS_Cookie
验证:
getshell
最后getshell
的方法,就是执行 system('cmd.exe')
函数了,ROP
如下:
1 | p64(p_rcx_ret)+p64(cmd_exe_addr)+p64(p_rdi_rsi_ret)+p64(0)+p64(0)p64(system_addr) |
EXP
这题目,我环境有点怪,我执行程序时,直接一下三次输出输入执行完,程序退出了。程序就没有停下来让我输入的时候。不知道是啥原因,所以也没测自己的EXP
有没有问题。
1 | from pwn import * |
Windows 堆基础
之前分析的几题,都是针对 windows
下的 栈溢出漏洞。对于 堆漏洞还未有涉及。我们首先来补充一点 windows
下的堆基础知识。
Windows NT 堆
Windows
下的 NT
堆已经被研究的比较透彻,下面主要是参考 0day安全
总结的。
堆数据结构
堆块:和 linux
下chunk
类似,包含堆头和数据区。堆头的数据结构如下(以32位为例):
1 | 0 1 2 3 4 5 6 7 8(byte) |
其中Flags
是标识堆块状态:01
表示使用。Flink
和 Blink
相当于 Linux
中的 fd
和 bk
指针,如果堆块处于使用状态会被数据区占用。
堆表:堆表一般位于堆区的起始位置,用于索引堆区中的所有堆块的重要信息,包括堆块的位置、堆块的大小、空闲还是占用等。堆表分为两类:
- 空表
堆区一开始的堆表区中有一个128项的指针数组,被称为空表索引(Freelist array)。该数组的每一项包括两个指针,用于标识一条空表。也就是每一个 size
的空表是一个双链表指针,而且都是从后往前取,遵循 LIFO
原则,这和 Linux
下的 smallbin
和 Largebin
类似。其中 free[0]
类似 unsortedbin
链表,存储的空闲堆块是按照size
从小到大 从前往后排列。其余的 空表链表,都是类似 smallbin
,每一个链表表示一个 size
,存储的堆块都是相同size
。
- 快表
快表是 Windows
用来加速堆块分配而采用的一种堆表,这是一种单向链表。快表也有128
条,每个快表最多有4个结点。每个快表的节点的flag
都被设为使用态,其和 Linux
下的 fastbin
机制十分类似。
堆管理策略
堆块分配
NT
堆分配可分为三类:快表分配、普通空表分配和 零号空表分配:
快表分配:找到大小匹配的空闲堆块,将其状态修改位占用态,从链表中 Unlink
,最后返回一个指向堆块块身的指针给程序使用;
普通空表分配:优先寻找大小最合适的空闲块分配,若失败则依次寻找次优的空闲块分配,即最小的能够满足要求的空闲块;
零号空表分配:其按照大小升序链着不同大小的堆块,分配时从后向前查找,找到最后一个最合适大小的chunk分配出去。
堆块释放
将堆块状态改为空闲,链入相应的堆表。所有的释放块都链入堆表的末尾,分配的时候也从堆表末尾拿。
堆块合并
堆块合并是将两个相邻的空闲堆块从链表中卸下,合并堆块,调整合并后大块的块首信息,再将新块重新链入空闲链表。
注意:针对NT
堆的最常见的攻击还是对 unlink
攻击。
Windows10 堆
Windows10下的堆机制更加复杂,其分为两种:NT堆和 Segment Heap。下面主要讲解NT堆,主要参考 Angel Boy 的这个 Slides。如果要了解 Segment Heap
机制,可以参考K0Shi
的这篇文章。
NT堆又可以分为:后端堆(Back-end)和前端堆(Front-End)
堆函数
HeapCreate
创建一个只有调用进程才能访问的私有堆。进程从虚拟地址空间里保留出一个连续的块,并且为这个块特定的初始部分分配物理空间。
HANDLE HEAPCreate(DWORD flOptions, DWORD dwInitialSize, DWORD dwMaxmunSize)
参数:
flOptions:堆的可选属性,会影响堆的函数操作,指定标记有:
- HEAP_NO_SERIALIAZE:指定当函数从堆里分配和释放空间时不互斥(不适用互斥锁)。当不指定该标记时默认为使用互斥。序列化允许多个线程操作同一个堆而不会错误。这个标记是可忽略的;
- HEAP_SHARED_READONLY:这个标记指定这个堆只能由创建它的进程进行写操作,对其他进程是只读的。如果调用者不是可靠的,调用将会失败,错误代码
ERROR_ACCESS_DENIDE
,为了使用该标记,运行在 Kernel mode(核心状态)是必须的
dwInitialsize:堆的初始大小,单位为 Bytes,其大小会向上舍入直到下一个
page boundary
。若需得到主机的页大小,使用 GetSystemInfo函数。dwMaxmumSize:非零时指定这个堆的最大大小,也是向上舍入到一个
Page boundary
,然后为这个堆再进程的虚拟地址里保留舍入后大小的块。如果函数HeapAlloc
和HeapReAlloc
分配的空间超过参数dwInitialSize
指定的大小,系统会分配额外的空间给该堆直到这个堆的最大大小;
返回值:
- 成功:返回新创建的对指针
- 失败:Null指针
- 调用函数
GetLaseError
获得更多的错误信息
注意:
- 生成的是私有堆,只有调用进程才能够访问,会在虚拟空间内创建一个块,实现对增长
HeapAlloc
请求空间超过当前页大小,物理空间足够,则会从保留的空间里附加- 一个 DLL 创建了一个私有堆,这个私有堆是在调用该DLL的进程的地址空间内,且仅该进程可访问
- 系统会使用私有堆的一部分空间去储存堆的结构信息
HeapAlloc
1 | LPVOID HeapAlloc( |
hHeap:分配堆的句柄,可以通过
HeapCreate()
函数或GetProcessHeap()
函数获得dwFlags:堆分配时的可选参数:
- HEAP_GENERATE_EXCEPTIONS:分配错误将会抛出异常
- HEAP_NO_SERIALIZE:不使用连续存取
- HEAP_ZERO_MEMORY:将分配的内存全部清零
dwBytes:要分配堆的字节数
HeapFree
1 | BOOL HeapFree( |
Heap 数据结构
_HEAP
每个堆段都有一个 _HEAP
结构,其是管理该堆段的核心数据结构,位于该堆段的头部。
每个 Heap
有一个 HEAP
结构,一个 heap
结构有多个 heap_segment
。
1 | heap结构{ |
首先使用 .process
查看进程的 PEB
地址,随后使用 td _PEB peb_addr
查看进程的 PEB
信息,如下所示:
进程的 PEB
信息十分全面,我们重点关注如下结构信息:
1 | 0x30 ProcessHeap: 默认的堆地址 |
根据ProcessHeaps
可以查看当前进程所申请堆地址,如下图所示,程序总共申请了三个堆地址。
通过该方法的查看结果与我们直接使用 !heap -h
命令查看的结果一致:
还有一个注意事项是:实现 !heap
命令查看堆布局,可以发现进程中的堆都是 NT HEAP
,未开启 win10
下的新机制 Segment Heap
,这对于研究win堆是需要区分的。
上述查询的地址,每一个都是一个 Heap
结果,我们对该地址可以直接使用 dt _HEAP heap_addr
查看,如下图所示。也就是 HEAP
结构是在每一个 通过 Heap_Create
创建的堆起始地址。同时每个 HEAP
结构是由每个堆的 0号堆段和一个特殊结构拼接而成,特殊结构中的 Heap
结构是用来保存该堆段的资产及必要信息。
在这个结构体重点关注:
1 | 0x40 FirstEntry 第一个堆头地址 |
后端堆管理
_Heap_ENTRY(Chunk)
如果没有接触过 windbg
调试堆的,可以参考一下这篇教程。
_HEAP_ENTRY
可以分为 Allocated chunk
、Freed chunk
和 VirtualAlloc chunk
三类。下面这图只表现了 Freed chunk
,但是 Allocated chunk
除了 Flink和Blink
其他也类似。
其堆头数据结构如下:
1 | PreviousBlockPrivateData 基本上可为前一块chunk的data,因为chunk必须对齐0x10 |
其中flag
的值对应的结果如下
01-HEAP_ENTRY_BUSY
堆块处于占用状态02-HEAP_ENTRY_EXTRA_PRESENT
该块存在额外的描述03-HEAP_ENTRY_FILE_PATTERN
使用固定模式填充堆块08-HEAP_ENTRY_VIRTUAL_ALLOC
虚拟分配的堆块virtual allocation
10-HEAP_ENTRY_LAST_ENTRY
表示是该段的最后一个堆块
类似于 Linux
下的 chunk。前 8 字节保存结构信息,类似 chunk 头,但是 windows 为了安全性,堆前 8 字节进行了加密。加密方式:与 Heap 结构 0x80偏移处 16(64位) 个字节异或,以此防止堆溢出。
可以看到堆头的数据和 Encoder
异或后的结果,低位 两字节 0x0013
是当前堆块的大小(64位单位是 0x10对齐,所以就是0x130
字节),flags
是7,Perv_size
是 0x0d
。我们分析的结果和图中最后直接显示的一致。
FreeLists
当我们对一个释放一个堆块后,其结构如下。可以看到
其Flink
指向了 下一个空闲堆块,Blink
指向了 Freelist
,注意:此处的 Flink 和 Blink 都是指向了freed chunk的 数据区,并非堆头,这里和 tcache 有点类似 。当释放一个堆块,其会按照size插入 Freelist
中,Freelist
中的堆块是按照从小到大排列。
Windows
下通过堆头部的BlocksIndex
的成员变量(单向链表)起到Linux
中smallbin/largebin
的效果,快速找到相应大小的释放的堆块,其结构体如下,
1 | 0:000> dt _HEAP_LIST_LOOKUP |
分配机制
基本按照size大小,分为三种,0day安全里描述为 小块、大块和巨块。
Size <= 0x4000:
分配都会在
RtlpAllocateHeap
,然后会在chunk size中的
FrontEndHeapStatusBitmap
是否启用LFH
如果没有,会对对应的
FrontEndHeapUsageData
加上 0x21,并且检查值是否超过0xff00
或者 & 0x1f 后超过 0x10 通过条件就会启用 LFH
然后回判断对应的
ListHind
是否有值,会以ListHint
中的chunk
为优先:- 如果有适合的
chunk
在ListHint
上则移除ListHint
,并且判断该chunk
的 Flink 大小是否也为同样size
- 如果是,则将
ListHint
填上Flink
,不是则清空;最后则unlink
该chunk
把这块chunk
从Linked list
中移除返还给使用者,并将header xor
回去。 - 如果没有合适的,从比较大的
ListHint
中找,有找到比较大的后将该chunk
从ListHint
中移除,然后重复步骤5的处理ListHint
。然后切割该chunk
,剩下的重新加入Freelist
,尝试将其大小放入ListHint
。最后将切好的chunk传给使用者,并对header
加密。
- 如果有适合的
如果
FreeList
中都没有:- 尝试
ExtengdHeap
加大heap
空间 - 再从
extend
出来的chunk
拿 - 接着后面一样切割,放回
ListHint
,xor header
- 尝试
0x4000 < size <= 0xff000
除了没有对 LFH 相关的操作,其余和 0x4000
一样
Size > 0xff000 (VitualMemoryThreshod << 4)
- 直接使用
ZwAllocateVirtualMemory
- 类似
mmap
直接给一大块,并且回插入_HEAP->VirtualAllocdBlocks
这个Linkd list
中,这个linked list
是连接该Heap VirtualAllocated
出来的区块用的
- 类似
释放机制
size<= 0xff0000
检查
alignment
、利用Unused byte
判断该chunk
状态- 如果是 非 LFH下,会对对应的
FrontEndHeapUsageData
减一,接着会判断前后的chunk是否为 freed,是的话就合并; - 合并前后堆块,就是 unlink操作,并从 ListHint 移除,移除方式与前面相同,看下一块是不是同样大小,是的话就补上
ListHInt
- 如果是 非 LFH下,会对对应的
合并完后,
update size & prevsize
,然后会看看是不是最前跟最后,是就插入;不是就会从ListHint
中插入,并且update ListHint
,插入时也会对 linked list 做检查
size > 0xff000
- 检查该
chunk的 linked list
并从_HEAP->VirtualAllocdBlocks
移除 - 接着使用
RtlpSecMemFreeVirtualMemory
将 chunk 整个munmap
掉
Unlink攻击
知道了上面的释放堆块是用双链表指针前后相连,并且也会存在相邻空闲堆块合并的操作。那么也就和 Linux
一样 存在 unlink
攻击。我以 Angel Boy Slides
中的 Unlink 攻击,合并讲解 Unlink 时中会做的步骤。
初始堆布局如上图所示,申请了 5个堆块,我们的chunk_list 依次为 P Q R S T。依次释放 Q 和 S 堆块,此时 Freelist 的链表为:
1 | Freelist->Flink = S |
然后修改 Q 的 Flink 为 &Q -0x8
,Blink 为 &Q
,这里的 &Q 是值得 Q 在 chunk_list 里的地址,并非是 Q的 chunk 地址。
- Free(P)
然后我们 释放 chunk P,此时会检查P前后堆块时否空闲,会找到 后一个 堆块 Q 处于空闲状态
- 检查Q
然后会先对Q的header 进行解密,检查 header是否正确;然后会对 Q的 Flink 和 Blink 执行如下检查:
1 | Flink = chunk->flink |
此时,我们修改的值是能够成功满足的:
1 | *((&Q-8)->Blink) == *(&Q-8+8) = Q |
我们也就能成功过掉check。
- Find BlockIndex
然后就会对 ListHint进行处理,由于此时 ListHint 中存储的是后释放 S堆块,并不是堆块 Q,所以也就不会对 ListHint 进行处理。可以以此绕过对 ListHint 的检测。
- Unlink
执行经典的 Unlink函数,成功改了chunk_list里 &Q地址 的值为 &Q。
1 | Q->Blink->Flink = Q->Flink |
- 更新 P size
随后就是对 P的size 进行更新,然后根据新的 size 判断是不是在freelist中的最前或最后,是就插入;不是就还需要在 LinkHint 中去寻找合适的位置插入。
- 自此,我们实现了 Q 的值为 &Q,我们就可以成功控制 chunk_list。
TSCTF hellowin
这道题和 Angel Boy 当时分享的一道题的思路是十分相似的。
程序分析
首先程序存在一个 格式化字符串漏洞,可以输入 10字节的数据,可以泄露数据。
然后有一个堆溢出漏洞,可以直接修改下一个chunk的值。
利用分析
- 格式化字符串泄露地址
首先利用格式化字符串直接泄露程序的 plt 基址 和栈地址 以及 ucrtbased.dll 的基址。虽然泄露完后程序会退出,但是这个点已提到过,再次运行程序基址不会改变。
- Unlink实现任意地址写数据
然后,我们就需要利用 Unlink
漏洞,来实现任意地址写。程序中存在一个 chunk_list
,且delete
堆块后其地址仍然不变。如果我们能够劫持 chunk_list
里的值,就能够实现任意地址写。
方法和 上面介绍相同:
初始堆布局:申请 5 个0x58堆块,先释放 chunk2。
修改chunk2:通过chunk1的堆溢出修改chunk2 的Flink 和 Blink 分别为
&chunk_list+0x8
和&chunk_list+0x10
,此时的&chunk_list+0x10
地址的值正好为 chunk2 的地址;释放 chunk4:释放chunk4的原因是 为了将 chunk4 放入 ListHint中,但是不释放 chunk3 的原因是为了 防止chunk3和chunk2堆合并,不释放chunk5也是为了防止 和 freed chunk合并;
释放chunk1:触发 chunk2的 unlink。经过 unlink 函数后, 此时
&chunk_list+0x10
的值为&chunk_list+0x10
此时我们就可以通过 edit chunk2 实现对 chunk_list 的值的修改,实现 任意地址写。
- 泄露地址信息
然后,为了实现ROP执行ORW,我们需要泄露 ntdll.dll
和 Kernel32.dll
的地址信息。这道题有一个容易点就是可以直接通过 将 chunk3 改为我们想知道的 地址,再通过 Show
chunk3函数将我们想知道的地址的值输出。这样就可以结合每个dll里的 IAT表,不断泄露每个dll
的基址。
注意:这里有一个巨坑,就是在将 Kernel32.dll
里的 有关 ntdll
的 iat
地址写入chunk3
,其地址千万不要有 0a
出现,否则就会写不进去。这导致我后面一直出错。
其次,我们需要获取函数的返回堆栈地址。我们可以采用爆破的方式,不断爆破栈数据。知道输出的栈数据是 main
函数的返回地址,这样我们即可确定该 栈地址存储的是一个 返回地址。我们后续写 ROP,即可在此栈地址处开始写。
- ROW
如果我们能够直接获得 ucrtbased.dll
,就可以直接调用该库里面的 ROW
函数来得到 flag。
但是,如果不知道 ucrtbased.dll
的信息,我们可以选择更通用的方法。这个方法我在之前进行 Adobe
漏洞分析时其实也看到过真实漏洞利用也是有这样利用的。
EXP
1 | from pwn import * |
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2020/10/30/TSCTF-helloWin/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!