howdays

JIN's Lab

Hacking, Research, Write-up

Virtualbox 6.1.18 0-day(였던)

개요


결과부터 말씀 드리자면 올해 Pwn2Own Vancouver 2021에 못갔습니다 ㅠㅠ

올해 4월 초에 열린 Pwn2Own 2021에 VirtualBox 부문에 참가를 위해 3월부터 약 한달 간 준비를 하였는데요, 오디팅 및 취약점을 찾는데 대략 2주의 시간과 익스플로잇을 하는데 약 3주가 안되는 시간이 걸렸습니다.

하지만 Pwn2Own 특성상 익스플로잇이 Universal 하게 작동해야하기 때문에 익스플로잇하는데 시간을 많이 쏟아 아깝게 마감 기한을 놓쳐 못가게 되었습니다 ㅠㅠ

기존의 VirtualBox 익스플로잇 하는 과정들이 모두 패치(?)가 되버려 사용할 수 없게 되었고 새로운 익스플로잇 방법을 찾아야 했었습니다.

어차피 Pwn2Own 진출을 실패했고 현재는 패치가 되었기 때문에(당시 .18, 현재 .30) 이 참에 블로그에 유용한 게시글을 올리는게 부가가치가 더 클 것이라고 생각하여 취약점 찾는 과정부터 익스플로잇 과정까지 작성하려 합니다.

모든 내용은 취약점을 발견한 시점은 Virtualbox 6.1.18 버전에 기초하여 작성되었습니다.

취약점


static VBOXSTRICTRC vmsvgaWritePort(PPDMDEVINS pDevIns, PVGASTATE pThis, PVGASTATECC pThisCC, uint32_t u32)
    ...
    switch(idxReg) {
        ...
        case SVGA_REG_HEIGHT://DevVGA-SVGA.cpp-1724~1733
            STAM_REL_COUNTER_INC(&pThis->svga.StatRegHeightWr);
            if (pThis->svga.uHeight != u32)
            {
                pThis->svga.uHeight = u32;
                if (pThis->svga.fEnabled)
                    ASMAtomicOrU32(&pThis->svga.u32ActionFlags, VMSVGA_ACTION_CHANGEMODE);
            }
            /* else: nop */
        break;
            ...

취약점의 시초는 위의 코드인 vmsvga 라는 그래픽관련 인터페이스의 PCI Communication에서 시작되었습니다.

Guest는 PCI 통신을 통해 위의 코드에서 idxReg과 u32 변수를 셋팅할 수 있으며 이를 통해 pThis->svga.uHeight 변수를 아무 제한 없이 셋팅을 할 수 있는 것을 볼 수 있습니다.

해당 변수를 아무 제한 없이 컨트롤을 할 수 있다는 것이 이 취약점의 핵심 부분입니다.

pThis->svga.uHeight 를 사용하는 코드를 살펴보겠습니다.

static int vmsvgaR3ChangeMode(PVGASTATE pThis, PVGASTATECC pThisCC)
{
...
VMSVGASCREENOBJECT *pScreen = &pSVGAState->aScreens[0];
...
pScreen->cHeight   = pThis->svga.uHeight; //DevVGA-SVGA.cpp-1448~

pScreen->cHeight 라는 변수에 pThis->svga.uHeight 를 대입합니다.

해당 변수는 현재 Guest OS 창 크기의 높이를 뜻하는데요 이를 통해 화면 사이즈를 측정하고 버퍼를 할당하여 그래픽에 쓰는 과정도 진행하게 됩니다.

해당 변수도 유저가 아무 제한 없이 조작한 값을 그대로 갖게 됩니다.

pScreen->cHeight 변수는 다른 방식을 통해서도 조작이 가능한데요.

static DECLCALLBACK(int) vmsvgaR3FifoLoop(PPDMDEVINS pDevIns, PPDMTHREAD pThread)
{
    ...
    case SVGA_CMD_DEFINE_SCREEN:
        uint32_t const uHeight = pCmd->screen.size.height;
        AssertBreak(uHeight <= pThis->svga.u32MaxHeight);
        ...
        if (!fBlank)
        {
            AssertBreak(uWidth > 0 && uHeight > 0);
            ...
            pScreen->cHeight = uHeight;
            ...
        }

위의 코드는 MMIO Communucation을 통해 해당 변수를 컨트롤하는 코드입니다.
pScreen->cHeight 변수가 pThis->svga.u32MaxHeight 보다 작은지 검사를 하는데,
pThis->svga.u32MaxHeight 는 GuestOS가 실행 될 때 고정값이 들어있는 변수로 유저가 따로 컨트롤할 수 없습니다.

즉, 종합적으로 확인하였을 때 pScreen->cHeight 는 분명히 Boundary가 있어야 하는 변수임에는 틀림없습니다.
하지만, 아까 위에서 언급한 pScreen->cHeight = pThis->svga.uHeight 에서는 제한이 없었던 것을 볼 수 있었고 이 부분으로 인해서 Side-Effect가 발생하지 않을까? 라는 궁금증을 충분히 던질 수 있었습니다.

그럼 pScreen->cHeight 이 변수가 어디에 쓰이는 지 자세히 알아보겠습니다.

static int vmsvgaR3ChangeMode(PVGASTATE pThis, PVGASTATECC pThisCC)
{
    ...
    pThis->last_scr_height = pSVGAState->aScreens[0].cHeight; //DevVGA-SVGA.cpp-1478
    ...
}

pScreen->cHeight 는 그대로 pThis->last_scr_height 에 그대로 대입을 합니다.

static int vmsvgaR3DrawGraphic(PVGASTATE pThis, PVGASTATER3 pThisCC, bool fFullUpdate,
                               bool fFailOnResize, bool reset_dirty, PDMIDISPLAYCONNECTOR *pDrv)
{
    uint32_t const cx        = pThis->last_scr_width;
    uint32_t const cxDisplay = cx;
    uint32_t const cy        = pThis->last_scr_height; // [1]
    uint32_t       cBits     = pThis->last_bpp;
    ...
    uint8_t    *pbDst          = pDrv->pbData; // [2]


    uint32_t    cbDstScanline  = pDrv->cbScanline;//0
    uint32_t    offSrcStart    = 0;  /* always start at the beginning of the framebuffer */
    uint32_t    cbScanline     = (cx * cBits + 7) / 8;   /* The visible width of a scanline. */
    uint32_t    yUpdateRectTop = UINT32_MAX;
    uint32_t    offPageMin     = UINT32_MAX;
    int32_t     offPageMax     = -1;
    uint32_t    y;
    for (y = 0; y < cy; y++) //[3]
    {
        uint32_t offSrcLine = offSrcStart + y * cbScanline;
        uint32_t offPage0   = offSrcLine & ~PAGE_OFFSET_MASK;
        uint32_t offPage1   = (offSrcLine + cbScanline - 1) & ~PAGE_OFFSET_MASK;
        ...
        fUpdate |= (pThis->invalidated_y_table[y >> 5] >> (y & 0x1f)) & 1;
        if (fUpdate)
        {
            ...
            if (pThis->fRenderVRAM)
                pfnVgaDrawLine(pThis, pThisCC, pbDst, pThisCC->pbVRam + offSrcLine, cx); //[4]
        }
        ...
        pbDst += cbDstScanline;
    }
    ...
}

[1]: pThis->last_scr_height 변수를 cy 변수에 담습니다.
[2]: pDrv->pbData(pbDst) 그래픽 버퍼 주소를 뜻합니다.
[3]: cy 만큼 loop를 돌면서 [4]에서 pbDst에 데이터를 씁니다.

대충 어떤 취약점인지 느끼셨을 거라고 생각합니다.
cy 값은 아무런 제한 없이, 극단적으로 0xffffffff 여도 작동하는 변수이고, 이를 통해 for loop를 돌면서 heap에 데이터를 작성한다면 heap-overflow 취약점이 터지는 것을 볼 수 있습니다.

*참고 지금까지 pThis->last_scr_height 를 기준으로 설명했지만 비슷하게 pThis->last_scr_weight 도 아무 제한없이 설정이 가능합니다.

그러면 여기서 힙 버퍼 역할을 하는 pDrv->pbData 의 역할도 중요한데요, 이를 설정하는 코드의 depth가 깊어 자세한 설명은 생략하지만 해당 변수인 pDrv->pbData 의 사이즈는 유저가 컨트롤할 수 있으며 이에 맞춰 할당할 수 있습니다.

*TMI: 위의 Loop를 보시면 각 분기마다 Pbdst += Cbdstscanline; 를 하여 Cbdstscanline 씩 뛰어 넘으면서 데이터를 입력할 수 있습니다. 이는 이번 익스플로잇할 때 굉장히 까다로운 요소였습니다.

익스플로잇


Guest OS가 힙 버퍼인 pbData 를 직접적으로 읽을 방법은 존재하지 않아 현재 취약점만 본다면 단순히 heap-overflow 만 있는 상태입니다.

Virtual Machine이기 때문에 알 수 없는 힙이 할당된다는 같은 점을 감안하면 heap-overflow 만으로는 Escape 까지 도달하기는 매우 어려워 보였습니다.

이미 힙에 할당된 다른 구조체를 덮어서 Index 를 조작한다던지 같은 여러 공개된 Exploit 방법을 살펴봤지만 대부분 불가능했을 뿐더러 Universal하게 쓸 수가 없었습니다.

그래서 새로운 익스플로잇 방법을 찾아야만 했었고 취약점 찾는 시간보다 더 많은 시간이 소요되었습니다.

할당과 관련된 모든 부분을 보다가 눈에 들어온 것은 USB를 관리하는 인터페이스인 Urb 부분이었습니다.

DECLHIDDEN(PVUSBURB) vusbUrbPoolAlloc(PVUSBURBPOOL pUrbPool, VUSBXFERTYPE enmType,
                                      VUSBDIRECTION enmDir, size_t cbData, size_t cbHci,
                                      size_t cbHciTd, unsigned cTds)
{
    ...
    RTCritSectEnter(&pUrbPool->CritSectPool);
    PVUSBURBHDR pHdr = NULL; //[1]
    PVUSBURBHDR pIt, pItNext;
    RTListForEachSafe(&pUrbPool->aLstFreeUrbs[enmType], pIt, pItNext, VUSBURBHDR, NdFree) //[2]
    {
        if (pIt->cbAllocated >= cbMem)
        {
            RTListNodeRemove(&pIt->NdFree);
            Assert(pIt->Urb.u32Magic == VUSBURB_MAGIC);
            Assert(pIt->Urb.enmState == VUSBURBSTATE_FREE);
            /*
             * If the allocation is far too big we increase the age counter too
             * so we don't waste memory for a lot of small transfers
             */
            if (pIt->cbAllocated >= 2 * cbMem) //[3]
                pIt->cAge++;
            else
                pIt->cAge = 0;
            pHdr = pIt; //[4]
            break;
        }
        else
        {
            /* Increase age and free if it reached a threshold. */
            pIt->cAge++;
            if (pIt->cAge == VUSBURB_AGE_MAX)
            {
                RTListNodeRemove(&pIt->NdFree);
                ASMAtomicDecU32(&pUrbPool->cUrbsInPool);
                RTMemFree(pIt);
            }
        }
    }

    if (!pHdr) //[5]
    {
        /* allocate a new one. */
        size_t cbDataAllocated = cbMem <= _4K  ? RT_ALIGN_32(cbMem, _1K)
                               : cbMem <= _32K ? RT_ALIGN_32(cbMem, _4K)
                                               : RT_ALIGN_32(cbMem, 16*_1K);

        pHdr = (PVUSBURBHDR)RTMemAllocZ(RT_UOFFSETOF_DYN(VUSBURBHDR, Urb.abData[cbDataAllocated]));
        if (RT_UNLIKELY(!pHdr))
        {
            RTCritSectLeave(&pUrbPool->CritSectPool);
            AssertLogRelFailedReturn(NULL);
        }

        pHdr->cbAllocated = cbDataAllocated;
        pHdr->cAge        = 0;
        ASMAtomicIncU32(&pUrbPool->cUrbsInPool);
    }
    RTCritSectLeave(&pUrbPool->CritSectPool);

    Assert(pHdr->cbAllocated >= cbMem);
}

[1]: pHdr 변수는 Urb의 구조체를 뜻합니다.

[2]: pUrbPool->aLstFreeUrbs 의 변수명에서 유추할 수 있듯이 Free(Remove)된 Urb들이 해당 배열에 들어가게 됩니다.

[3], [4]: [for-each 문을 통해 얻은 Free된 Urb의 사이즈] > [현재 요청하려는 사이즈] 를 만족할 경우 Free된 Urb를 재사용하게 됩니다.

[5] 만약 [3],[4] 를 통해 얻은 Urb가 없을 경우 새로 생성해줍니다.

pHdr의 타입이자 Urb의 구조체인 PVUSBURBHDR 의 중요한 부분을 표시하자면 이렇게 생겼습니다.



그림에 나와 있는 구조체에서 중요한 부분 3가지를 설명드리겠습니다.

1 – pHDR 안에 URB 구조체가 포함되어있습니다.

2 – 해당 URB 구조체안에는 cbData만큼 User가 URB Data를 읽고 쓸 수 있는 공간(여러 이유로 익스플로잇에는 사용하기 어렵습니다.)과 pVUsb 구조체가 담겨있습니다.

3 – pVUsb에는 URB information 을 담는 변수들이 들어있습니다. 그림으로부터 유추할 수 있듯이 대충 pVUsb = &URB[cbData] 이렇게 생겼습니다.

4 – pVUsb 안에는 Free(remove)할 때 hook 역할을 하는 function pointer도 있습니다.

*참고 pHdr를 할당 할 때 RTMemAllocZ 함수를 통해 할당하는 데 내부적으로 null-mapping을 하므로 uninitialize 취약점은 발생하지 않습니다.

위처럼 새로운 URB를 생성하는 과정을 비추어 봤을 때, 유저는 URB를 마음대로 생성 및 제거를 할 수 있고 해당 URB를 제거하였을 때 바로 Free 되는게 아닌 Custom하게 만들어놓은 FreeList 배열에 들어간다는 것을 알 수 있습니다.

그러면 지금까지 획득한 Urb 정보와 우리가 가진 heap-overflow 취약점을 엮는 방법에 대하여 설명하겠습니다.

Leak


heap-overflow 취약점과 URB의 정보를 간단히 정리해보겠습니다.

Heap-overflow (vmsvga)

– pbData를 원하는 사이즈로 할당가능하다.
– 원하는 인덱스 거리, 덮을 크기 조절이 가능하다.

URB

– 여러번 생성(할당) 가능하다.
– 할당 사이즈 조절이 가능하다.
– Free 되었을 때 cbAllocated 라는 URB의 총 사이즈를 담고 있는 변수가 있다.

위에서 잠깐 언급했듯이, URB 자체에서의 읽고 쓰는 기능으로는 leak vector가 되기 어렵습니다.

그러므로 원하는 사이즈로 할당이 가능하면서 마음대로 읽고 쓸 수 있는 기능이 필요한데 이는 VBoxGuestPropSvc 라는 Virtualbox의 hgcm 기능을 이용할 수 있습니다.
참고 1, 참고 2

Host-OS가 윈도우이므로 Low Fragmentation Heap(이하 LFH)기능을 이용하여 익스플로잇을 진행하였습니다.
참고

시나리오는 이러합니다.

1. VBoxGuestPropSvc를 이용하여 사이즈 N을 여러번 할당해 LFH를 활성화시킨다.

2. 사이즈 N인 URB를 생성하고 해제하여 FreeList에 들어가도록 한다. => 실제론 Free 안됨.

3. Heap-overflow 취약점으로 FreeList에 들어간 URB의 cbAllocated 변수를 아주 크게 조작합니다.

4. 생성한 URB보다 뒤에 할당되도록 사이즈 N인 VBoxGuestPropSvc를 스프레이 합니다.

5. 사이즈 N보다 약 3배 큰 URB를 할당합니다.

6. [3]번을 통해 Free된 URB의 cbAllocated가 아주 크게 조작되었기 때문에 기존의 사이즈보다 3배 크더라도 FreeList에서 URB를 가져옵니다.

7. URB의 pVUsb 구조체는 &URB[cbData=N*3]에 적힙니다.

8. &URB[N*3]는 Guest가 읽고 쓸 수 있는 VBoxGuestPropSvc의 영역과 맞물리기 때문에 유저는 pVUsb의 구조체를 읽을 수 있게 되며 PIE, Heap Leak에 성공합니다.

[2] ~ [8] 의 과정을 반복하면 마침내 릭에 성공할 수 있습니다.

ROP


ROP 까지 가는 과정은 Leak에 비해 매우 쉽고 이미 공개된 POC를 참고하였기 때문에 자세한 과정은 생략하겠습니다.

위에서는 Heap-overflow 취약점을 통해 URB 구조체를 조작해 Leak을 진행하였다면 이번에는 VBoxGuestPropSvc 구조체를 조작하여 ROP를 진행하였습니다.

 * @{
 */
/** Get a guest property */
#define GUEST_PROP_FN_GET_PROP              1
/** Set a guest property */
#define GUEST_PROP_FN_SET_PROP              2
/** Set just the value of a guest property */
#define GUEST_PROP_FN_SET_PROP_VALUE        3
/** Delete a guest property */
#define GUEST_PROP_FN_DEL_PROP              4
/** Enumerate guest properties */
#define GUEST_PROP_FN_ENUM_PROPS            5
/** Poll for guest notifications */
#define GUEST_PROP_FN_GET_NOTIFICATION      6
/** @} */

HGCM 기능 중 하나인 VBoxGuestPropSvc는 위처럼 6개의 옵션에 대해 HGCM CALL를 할 수 있습니다.

int Service::getNotification(uint32_t u32ClientId, VBOXHGCMCALLHANDLE callHandle,
                             uint32_t cParms, VBOXHGCMSVCPARM paParms[])
{
    int rc = VINF_SUCCESS;
    char *pszPatterns = NULL;           /* shut up gcc */
    char *pchBuf;
    uint32_t cchPatterns = 0;
    uint32_t cbBuf = 0;
    uint64_t nsTimestamp;

    /*
     * Get the HGCM function arguments and perform basic verification.
     */
    ...
        while (it != mGuestWaiters.end())
        {
            if (u32ClientId == it->u32ClientId)
            {
                const char *pszPatternsExisting;
                uint32_t    cchPatternsExisting;
                int rc3 = HGCMSvcGetCStr(&it->mParms[0], &pszPatternsExisting, &cchPatternsExisting);
                if (   RT_SUCCESS(rc3)
                    && RTStrCmp(pszPatterns, pszPatternsExisting) == 0)
                {
                    /* Complete the old request. */
                    mpHelpers->pfnCallComplete(it->mHandle, VERR_INTERRUPTED); //[1]
                    it = mGuestWaiters.erase(it);
                }
                ...
int HGCMThread::MsgComplete(HGCMMsgCore *pMsg, int32_t result)
{
    LogFlow(("HGCMThread::MsgComplete: thread = %p, pMsg = %p, result = %Rrc (%d)\n", this, pMsg, result, result));

    AssertRelease(pMsg->m_pThread == this);
    AssertReleaseMsg((pMsg->m_fu32Flags & HGCM_MSG_F_IN_PROCESS) != 0, ("%p %x\n", pMsg, pMsg->m_fu32Flags));

    int rcRet = VINF_SUCCESS;
    if (pMsg->m_pfnCallback)
    {
        /** @todo call callback with error code in MsgPost in case of errors */

        rcRet = pMsg->m_pfnCallback(result, pMsg); //[2]

        LogFlow(("HGCMThread::MsgComplete: callback executed. pMsg = %p, thread = %p, rcRet = %Rrc\n", pMsg, this, rcRet));
    }

[1]: HGCM Call를 통해 위 코드에서 보이는 Guest의 데이터 주소인 it->mHandle를 인자로 pfnCallComplete 를 호출합니다.

[2]: [1]에서 타고 들어가면 HGCMThread::MsgComplete 함수에 도달할 수 있습니다. 해당 함수의 pMsgit->mHandle과 동일하며 해당 구조체로부터 함수 포인터인 m_pfnCallback를 가져와 호출합니다.

Heap-overflow를 통해 it->mHandle를 조작할 수 있으며 m_pfnCallback의 두 번째 인자가 pMsg(it->mHandle) 이므로 성공적으로 ROP를 이끌어 나갈 수 있습니다.

Exploit Video


익스플로잇하는데 약 50초 정도 걸립니다. 감안해서 시청해주세요

궁금한 사항은 댓글(느립니다) 혹은 http://howdays.kr/about.html 에 나와있는 연락처로 연락주세요.

References

https://github.com/hongphipham95/Vulnerabilities/blob/master/VirtualBox/Pwn2Own%202020/Pwn2Own%202020%20-%20Oracle%20VirtualBox%20Escape.md

https://starlabs.sg/blog/2020/04/adventures-in-hypervisor-oracle-virtualbox-research/

Leave a Reply