Hey VM Wizards, DiegoAltF4 here! Time to unleash some real virtualization magic!
In 2023, the outstanding security researcher Andy Nguyen discovered multiple vulnerabilities in Virtio-net for VirtualBox. All kudos to him.
In this post, I’ll take you through an in-depth analysis of CVE-2023-22098. We’ll begin by exploring the vulnerability and diving into some Virtio-net internals. Next, I’ll guide you through setting up a debugging environment, and we’ll wrap things up by developing a fully reliable PoC that escapes VirtualBox (it includes an ASLR bypass).
If you run into any issues or have questions, feel free to reach out to me on X!
Virtio-net
As mentioned in 1: “In a nutshell, virtio is an abstraction layer over devices in a paravirtualized hypervisor. virtio was developed by RustyRussell in support of his own virtualization solution called lguest”.
Virtio was developed as a standardized, open interface to simplify the way virtual machines (VMs) access devices like block devices and network adapters. Virtio-net, a virtual ethernet card, is one of the most complex devices currently supported by virtio. As mentioned in 2 “the communication between the driver in the guest OS and the device in the hypervisor is done through shared memory (that’s what makes virtio devices so efficient) using specialized data structures called virtqueues, which are actually ring buffers of buffer descriptors”.
The most common queues in Virtio-net are:
- TX Queue (Transmit Queue): Used to send packets from the guest to the host.
- RX Queue (Receive Queue): Used to receive packets from the host to the guest.
Additionally, Virtio-net includes a Control Virtqueue (CtrlQ), which is used to send control commands from the guest to the host. These commands allow the guest to modify or query the configuration of the Virtio-net device, for example, querying status in real-time.
As mentioned in 3: “To each guest we can associate a number of virtual CPUs (vCPUs) and the RX/TX queues are created per CPU so a more elaborated example with 4 vCPUs would look like this (removing the control plane for simplicity)”:
For example, when the guest wants to send a network packet to the host:
- The guest places the packet in a shared memory buffer.
- The guest creates a descriptor in the Descriptor Ring, pointing to the buffer holding the packet.
- The guest places the index of the descriptor in the Available Ring, indicating that the buffer is ready for processing.
- The guest notifies the host by writing to a notification register that a packet is ready for transmission.
- The host reads the buffer, transmits the packet, and places the descriptor index in the Used Ring to indicate that processing is complete. Optionally, the host generates an interrupt to notify the guest that the buffer can be reused.
Vulnerability Analysis
Let’s start by taking a look at the Oracle Critical Patch Update Advisory - October 2023:
As you can see, the versions affected are prior to 7.0.12. Therefore, according to the Virtualbox Download Page, the latest vulnerable version is 7.0.10.
I like to start doing a simple diff to obtain a big picture of the situation. In this case, the principal file
related to Virtio-net is src/VBox/Devices/Network/DevVirtioNet.cpp
. Mainly two functions include significant changes:
The changes in AssertMsgReturn
quickly caught my attention. Here’s how it’s implemented:
Let’s break it down. The macro evaluates the expression expr
:
- If
expr
evaluates to true, nothing happens (the if block is empty). - If
expr
evaluates to false, the else block executes, returning the valuerc
.
Now, here’s an interesting bug: we can set cVirtqPairs > VIRTIONET_MAX_QPAIRS
or uVlanId > VIRTIONET_MAX_VLAN_ID
.
After considering the possibility of abusing cVirtqPairs > VIRTIONET_MAX_QPAIRS
, I turned my focus to the second case
(uVlanId > VIRTIONET_MAX_VLAN_ID
) where it can be seen that the value of uVlanId
is passed to the functions
ASMBitSet
and ASMBitClear
. These functions do the following:
These functions rely on bitwise operations to manipulate the bits in the pThis->aVlanFilter
array. Specifically:
ASMBitSet
sets the bit in the bitmap.ASMBitClear
clears the bit.
This seemed more promising for exploitation, as it involves an out-of-bounds write
in the VIRTIONET
structure:
Setting up the lab
We’ll focus on developing the exploit for the debug version of VirtualBox, as it provides access to all symbols, making it easier to analyze and understand the exploitation process.
The source code for the latest vulnerable version can be obtained here. Both my host operating system and my guest operating system are Ubuntu 20.04.6 LTS.
This guide outlines the steps for building VirtualBox with debug symbols.
Once the required packages have been installed, the following commands need to be executed:
And finally:
ASAN can be enabled with:
The virtual machine can be launched with the command:
To configure the VM, we need to modify the network adapter type and select virtio-net:
Once configured, we can verify that everything is working correctly by running the following command:
If successful, the device should appear as shown below:
As mentioned in 1:
When using PCI as a transport method, the device will present itself on the PCI bus with vendor 0x1af4 (Red Hat, Inc.) and device id 0x1003 (virtio console), as defined in the spec, so the kernel will detect it as it would do with any other PCI device.
Writing the exploit
Now that we know where the bug is located and have a functional debugging environment, we can start attempting to exploit the bug, with the goal of escaping from the virtual machine.
Triggering the bug
To trigger the bug, we need to reach the virtioNetR3CtrlVlan
function and specify a value of
uVlanId
> VIRTIONET_MAX_VLAN_ID
. The function responsible for calling virtioNetR3CtrlVlan
is virtioNetR3Ctrl
,
which handles processing control commands from the guest. It’s invoked by worker for virtio-net control queue to
process a queued control command buffer:
The value of uClass
from the CtrlPktHdr
structure is evaluated in a switch statement. This uClass
represents the
type of control command being processed. Depending on its value, different functions are called to handle the specific
control command.
Therefore, in our exploit, we can do the following:
Note: I added the +4
to satisfy the check cbRemaining > sizeof(cVirtqPairs)
. Something strange is happening with
VBox, as it doesn’t seem to handle the size correctly.
If we set a breakpoint at src/VBox/Devices/Network/DevVirtioNet.cpp:2536
, we can verify that the uClass
has been
correctly set. Additionally, we can set another breakpoint at src/VBox/Devices/Network/DevVirtioNet.cpp:2467
to check
that the value of uVlanId
is as expected:
Identifying structures in memory
Alright, knowing that we have an out-of-bounds write, what can we modify? To figure this out, we are going to try to identify the structures in memory, which will give us a much clearer understanding of our situation.
As we’ve already seen, the function virtioNetR3CtrlVlan
attempts to modify pThis->aVlanFilter
, which is of the
type PVIRTIONET
:
After this structure, we can observe that the PPDMCRITSECT structure is located following 16 bytes of padding.
Later, we come across the PDMPCIDEV
structure:
If we examine the definition of this structure, we find the most important and key member PDMPCIDEVINT s
, which
represents the internal data of the PDM PCI device:
Let’s verify it:
In fact, by inspecting PPDMDEVINSR3 pDevInsR3
, we can confirm that the apPciDevs[0]
member points to the PDMPCIDEV
structure we had identified earlier:
Exploitation Strategy
The members pfnConfigRead
and pfnConfigWrite
in the PDMPCIDEVINT
struct caught my attention. These are actually
callbacks. Therefore, we can control the execution flow by modifying these callbacks. However, we have a problem: we
don’t have any memory leaks.
To obtain the leaks, I remembered that with the lspci
command, we can query the device’s data. Therefore, it’s
retrieving the information somehow. Specifically, the function responsible for this is virtioR3PciConfigRead
:
The first two lines are quite important:
The definition of the macros is as follows:
Therefore, pVirtio
will point to pvInstanceDataR3
. However, we cannot directly modify the contents of
pvInstanceDataR3
.
But there is a trick we can use: we can modify the pDevInsR3
pointer in the PDMPCIDEVINT
structure to point to pDevInsR3 + 0x10
. This way, pvInstanceDataR3
will contain the pointer that was originally in
pCritSectRoR3
. We do this because we know that the critical section is located at pThis + 7536 + 16
(as we observed earlier). By pointing it there, we will be able to modify the contents:
However, there are a series of checks we need to bypass:
Therefore, it is necessary to modify these “fields” in order to successfully bypass the checks and trigger the call to:
For example, uBar
is located at a distance of 7536 + 16 + 0x300 + 4
from pThis
:
We are interested in the call to the virtioMmioRead
function because this is where the data copy will actually
be handled.
However, we have a couple of small issues. If we enter the first if statement, when pVirtioCC->pfnDevCapRead
is called,
it is not pointing to the correct function (virtioNetR3DevCapRead
) because we previously modified the pointer
to pDevInsR3
.
I considered entering the second if statement, where virtioCommonCfgAccessed
is called. To save you part of the
analysis process, this turned out to be the correct option:
To access that if statement, it is necessary to meet the check:
This is a macro, which is defined as:
To do this, we can, for example, write a 0 at 7536 + 16 + 1828 + 0
(offMmio
) and 0xff at
7536 + 16 + 1828 + 2
(cbMmio
).
From the virtioCommonCfgAccessed
function, the part we are most interested in is when the calls to
VIRTIO_DEV_CONFIG_ACCESS
are made, which is again a macro, defined as follows:
It copies data to or from the specified member field of the config structure, depending on whether fWrite
indicates a
write or a read operation.
We are particularly interested in the accesses made to the member fields of pVirtio->aVirtqueues
.
Additionally, we can control the value of uVirtq
(pVirtio->uVirtqSelect
).
By doing this, we can successfully leak the address of pDevInsR3
, which is “fragmented” across the member fields:
uNotifyOffset
, uEnable
, and uMsixVector
.
This also applies to virtioR3PciConfigRead
if we read the same fields, but set uVirtq
to 4.
Additionally, after obtaining the address of virtioR3PciConfigRead
, we can calculate the base address of VBoxDD.so
:
Now that we have the leaks, we can try to gain control of the execution flow by leveraging the initial idea of
corrupting pfnConfigRead
.
The call to virtioR3PciConfigRead
is made via:
In fact, if you want to debug the ROP chain, I recommend setting a breakpoint at this location: src/VBox/Devices/Bus/DevPCI.cpp:216
.
RAX contains the address of pDevInsR3
, with an additional offset of + 0x10 because we modified it earlier:
We have some powerful gadgets at our disposal, such as:
This will allow us to pivot the stack:
Additionally, since we can modify the pointer to pDevInsR3, we can make RSP
point to a controlled and “safe” area,
ensuring that we don’t overwrite important content that could cause crashes.
What are our objectives with the ROP chain? Well, the simplest way to execute our code is through a shellcode, but to do
that, we need to give the memory region where it’s stored execution permissions. This is why we could attempt to make a
call to mprotect
. However, mprotect
is located in the libc.so
, and we don’t have a leak for libc. An alternative
approach would be to use RTMemProtect
, but we don’t know its address either, as it is located in VBoxRT.so
.
However, we can try to dynamically resolve the address of RTMemProtect
. If we take a look at the entries in the GOT,
for example, we have the address of RTErrInfoSet
, which is located in VBoxRT.so
:
And we have gadgets like:
Of course, we also have:
At this point, we have the address of RTMemProtect in RAX:
Now we can write that address to the memory region where we’re constructing the ROP chain, allowing us to call
RTMemProtect
later. After that, the only task left is to set up the arguments for the call:
After all this, you can see that the permissions have changed and are now rwx.
The only thing remaining is to trigger the pfnConfigRead
callback.
Demo
Full exploit
You can find the full exploit on my GitHub repository.
I hope you enjoyed it!