In this guest blog from Pwn2Own winner Cody Gallagher, he details CVE-2024-21115 – an Out-of-Bounds (OOB) Write that occurs in Oracle VirtualBox that can be leveraged for privilege escalation. This bug was recently patched by Oracle in April. Cody has graciously provided this detailed write-up of the vulnerability and how he exploited it at the contest.
在 Pwn2Own 获奖者 Cody Gallagher 的这篇客座博客中,他详细介绍了 CVE-2024-21115 – Oracle VirtualBox 中发生的越界 (OOB) 写入,可用于权限提升。Oracle 最近在 4 月份修补了这个错误。Cody 慷慨地提供了这篇关于该漏洞的详细文章,以及他如何在比赛中利用它。

The core bug used for this escape is a relative bit clear on the heap from the VGA device. The bug is in function vgaR3DrawBlank, which is called from vgaR3UpdateDisplay. The bug can be triggered with a single core and 32MB of VRAM, and possibly less. All testing was done using the default graphics controller for Linux (VMSVGA). It should work on others as well.
用于此转义的核心错误在 VGA 设备的堆上相对清晰。该错误位于函数中,该函数 vgaR3DrawBlank 是从 调用 vgaR3UpdateDisplay 的。该错误可以由单个内核和 32MB 的 VRAM 触发,甚至可能更少。所有测试均使用适用于 Linux 的默认图形控制器 (VMSVGA) 完成。它也应该适用于其他人。

As for the exploit, I could not get it to work with those constraints. For my exploit, I require at least 65 MB of VRAM but am using 128 MB to be safe. It requires 4 cores because of the race condition I use.
至于漏洞利用,我无法让它在这些限制下工作。对于我的漏洞,我需要至少 65 MB 的 VRAM,但为了安全起见,我使用 128 MB。由于我使用的竞争条件,它需要 4 个内核。

The Vulnerability 漏洞

Inside the VGAState struct there is a bitmap used for tracking dirty pages in the vram buffer so that it knows whether it needs to redraw that part of the frame buffer.
VGAState 在结构体内部有一个位图,用于跟踪 vram 缓冲区中的脏页面,以便它知道是否需要重新绘制帧缓冲区的那部分。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

This bitmap is large enough to hold the total number of pages even when using the max vram allowable by vbox, which is 256MB. However, inside vgaR3DrawBlank, when it attempts to clear the dirty page bits it incorrectly multiplies start_addr by 4 before doing so:
此位图足够大,即使在使用 vbox 允许的最大 vram (256MB) 时,也可以容纳总页数。但是,在内部 vgaR3DrawBlank ,当它尝试清除脏页位时,它会错误地乘 start_addr 以 4 然后再这样做:

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

We can see here that if we are able to set start_addr to a value greater than 64MB, it will clear bits outside the bounds of the bitmap. Alternatively, even if start_addr is below 64MB, so that it starts clearing within the bitmap, the bit clear operation can continue past the bitmap’s end.
我们可以在这里看到,如果我们能够设置为 start_addr 大于 64MB 的值,它将清除位图边界之外的位。或者,即使 start_addr 低于 64MB,以便它开始在位图中清除,位清除操作也可以继续到位图的末尾。

Examining how start_addr is set, we can see that it allows any value up to vram_size:
检查如何 start_addr 设置,我们可以看到它允许任何值 vram_size :

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

Later in the code, vbe_start_addr is stored into start_addrand and vbe_line_offset is stored into line_offset. This happens when vgaR3UpdateBasicParams calls vgaR3GetOffsets. This update occurs whenever a new graphic or text is being drawn.
在后面的代码中, vbe_start_addr 存储到 start_addrand vbe_line_offset 和 存储到 line_offset 中。当 vgaR3UpdateBasicParams 调用 vgaR3GetOffsets .每当绘制新的图形或文本时,都会发生此更新。

As long as our vram_size is greater than 64MB we will able to clear bits in heap memory following the bitmap.
只要我们的 vram_size 大于 64MB,我们就可以按照位图清除堆内存中的位。

The following are the values I set up to trigger the bug. All of these are settable via ioport communication.

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

These values are chosen to zero out a specific bit, but if the VBE_DISPI_INDEX_VIRT_WIDTH is increased it will most likely overwrite enough data to cause a segfault. For the exact ioport comms used, please reference the exploit code.
选择这些值是为了将特定位归零,但如果增加, VBE_DISPI_INDEX_VIRT_WIDTH 则很可能会覆盖足够的数据以导致段错误。有关使用的确切 ioport 通信,请参考漏洞利用代码。

The Exploit 漏洞利用

I explored several paths to find something we can zero out that would be usable to gain reliable code execution. I ended up looking at CritSect inside of VGAState. This critical section is used so that only 1 thread at a time can process in and out instructions for each device, as well as any loads or stores to the mmio region. There are several things we are concerned with in the critical section. The relevant structures are as follows:
我探索了几条路径,以找到一些我们可以归零的东西,这些东西可以用来获得可靠的代码执行。我最终查看 CritSect 了 VGAState .使用此关键部分,以便一次只能处理每个设备的 1 个线程 in 和 out 指令,以及任何加载或存储到 mmio 区域。在关键部分,我们关注了几件事。相关结构如下:

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

When a thread locks the critical section, it adds 1 to cLockers, updates NativeThreadOwner to the current thread, and adds 1 to cNestings.
当线程锁定关键部分时,它会将 1 添加到 cLockers , NativeThreadOwner 更新到当前线程,并将 1 添加到 cNestings 。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

If a different thread then attempts to lock this same section it will see that cLockers is set and will attempt to wait its turn to lock. There is first an optimized wait, in which it will attempt to spin for some microseconds to see if it can quickly acquire the lock.
如果另一个线程随后尝试锁定同一部分,它将看到已设置, cLockers 并尝试等待轮到它锁定。首先是优化的等待,在此期间,它将尝试旋转几微秒,看看它是否可以快速获取锁。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

If that fails it will the block on the EventSem semaphore.
如果失败,它将阻塞 EventSem 信号量。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

This hEvent value is just an int. Each time a critical section is created, a new hEvent value will be allocated in sequential fashion. When we look at the critical section of VGAState we can see the value of hEvent is 0x23.
此 hEvent 值只是一个 int。每次创建关键部分时,都会按顺序分配一个新 hEvent 值。当我们查看 的 VGAState 临界部分时,我们可以看到 hEvent 的值是 0x23 。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

The first 4 bytes are u32Magic, and the hEvent value can be seen at offset 0x18. With this information in hand, I realized that if we can find another critical section with an hEvent, we can modify the hEvent of VGState to match that of the other critical section. Then we can use that confusion to produce a race condition in any VGA ioport or mmio read/write. After looking around I found that VMMDev was using the hEvent value of 0x21.
前 4 个字节是 u32Magic ,该 hEvent 值可以在偏移量 0x18 处看到。有了这些信息,我意识到如果我们能找到另一个带有 hEvent 的关键部分,我们可以修改 hEvent of VGState 以匹配另一个关键部分的 。然后,我们可以利用这种混淆在任何 VGA ioport 或 mmio 读/写中生成争用条件。环顾四周后, VMMDev 我发现使用的 hEvent 是 0x21 .

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

After some testing, I found that the hEvent values are consistent between runs because they are assigned sequentially on startup. The critical sections for VMMDev and VGA are created directly after the processor-related critical sections. So long as the processor chipset doesn’t change, these should remain constant.
经过一些测试,我发现这些 hEvent 值在运行之间是一致的,因为它们是在启动时按顺序分配的。 VMMDev 和 VGA 的关键部分直接在处理器相关的关键部分之后创建。只要处理器芯片组不改变,这些就应该保持不变。

I will note here that there are other critical sections that could potentially be used, but I chose to write my exploit using the VMMDev critical section.
我在这里要指出的是,还有其他可能使用的关键部分,但我选择使用 VMMDev 关键部分来编写我的漏洞利用。

First, we use our bit clearing bug to turn 0x23 into 0x21. Subsequently, whenever there are two threads, one holding the critical section for VMMDev and one holding the critical section for VGA, when either thread releases its critical section it can wake up a thread waiting for either device. Our plan is to use this race condition to wake a thread waiting for VGA prematurely, which is to say, while some other thread is still using VGA.
首先,我们使用我们的位清除错误来变成 0x23 0x21 .随后,每当有两个线程时,一个保存 for VMMDev 的关键部分,一个保存 VGA 的临界部分,当任一线程释放其关键部分时,它可以唤醒等待任一设备的线程。我们的计划是使用此争用条件来过早唤醒等待的 VGA 线程,也就是说,当其他一些线程仍在使用 VGA 时。

This is not good enough yet, though. Even if we hit the race, VirtualBox throws a SigTrap shortly thereafter. This is because when the racing thread locks the critical section, it changes NativeThreadOwner. When the first thread tries to unlock the critical section, the NativeThreadOwner does not match, causing the error.
不过,这还不够好。即使我们参加了比赛,VirtualBox 也会在不久之后抛出一个 SigTrap 。这是因为当赛车线程锁定关键部分时,它会发生变化 NativeThreadOwner 。当第一个线程尝试解锁关键部分时,不 NativeThreadOwner 匹配,从而导致错误。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

Upon discovering this we also see that there is a way to completely turn off an individual critical section. There is a bit in fFlags called RTCRITSECT_FLAGS_NOP. If this bit is set then it will ignore all locking and unlocking operations for that particular critical section.
发现这一点后,我们还发现有一种方法可以完全关闭单个关键部分。里面有 fFlags 一点叫做 RTCRITSECT_FLAGS_NOP .如果设置了此位,则它将忽略该特定关键部分的所有锁定和解锁操作。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

This poses a challenge for us, though. The only bug we have is a bit clear, so we have no way to set this flag. Instead, we must find a way to set the flag from our racing VGA thread before the first VGA thread exits and crashes the process.
不过,这对我们来说是一个挑战。我们唯一的错误有点清楚,所以我们没有办法设置这个标志。相反,我们必须找到一种方法,在第一个 VGA 线程退出并导致进程崩溃之前,从我们的赛车 VGA 线程中设置标志。

When looking for a way to accomplish this, I found an ioport for writing data to vbe_regs in VGAState:
在寻找实现此目的的方法时,我发现了一个用于将数据写入 vbe_regs 的 VGAState ioport:

       uint16_t vbe_regs[VBE_DISPI_INDEX_NB];

This ioport allows us to specify vbe_index as an arbitrary short, and then it will write an arbitrary short to vbe_regs[vbe_index] in vbe_ioport_write_data. The write is protected by a bounds check on the index, but we can circumvent the check by using the race condition we manufactured.
这个 ioport 允许我们指定 vbe_index 为任意的 short ,然后它会 vbe_regs[vbe_index] 写入任意的 short 。 vbe_ioport_write_data 写入受索引边界检查的保护,但我们可以使用我们制造的争用条件来规避检查。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

To exploit, we start a VGA request on one thread (the “worker”) specifying a valid vbe_index, and a second VGA request on a second thread (the “racer”) specifying a bad vbe_index. Normally the racer request would need to wait for the worker to finish, but by racing two VMMDev requests (on two other threads) we can wake the racerVGA thread prematurely, modifying the vbe_index after the workerthread has finished validating it but before using it.
为了利用这个漏洞,我们在一个线程(“worker”)上启动一个 VGA 请求,指定一个有效的 vbe_index ,并在第二个线程(“racer”)上启动第二个 VGA 请求,指定一个 bad vbe_index 。通常,racer 请求需要等待 worker 完成,但是通过竞速两个 VMMDev 请求(在另外两个线程上),我们可以过早唤醒 racer VGA 线程,在 workerthread 完成验证后但在使用它之前修改它 vbe_index 。

Note that, for this to succeed, the racer thread must be woken at a critical moment during execution of the worker. To make this race easier to win, we can take advantage of a memset in vbe_ioport_write_data where we control the length. For the worker request, we make this a large number so we have a longer window in which to win the race. In testing, I found we can easily get this to over 1 millisecond which is a massive amount of time during which we can win the race.
请注意,要成功,必须在执行工作器的关键时刻唤醒赛车线程。为了使这场比赛更容易获胜,我们可以利用 memset vbe_ioport_write_data 我们控制长度的优势。对于工人的要求,我们将其设置为一个很大的数字,以便我们有更长的窗口来赢得比赛。在测试中,我发现我们可以轻松地将其提高到 1 毫秒以上,这是我们可以赢得比赛的大量时间。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

After winning the race, we can see the desired effect.

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

By means of the vgaR3DrawBlank bug we have changed hEvent from 0x23 to 0x21, and by means of the vbe_ioport_write race we have changed the fFlags member at offset 0x14 to 0xf, disabling the critical section. Now that the critical section is fully disabled, we can easily race VGA threads against each other. The next step is to find a read and a better write with our new and improved race condition.
通过 vgaR3DrawBlank 错误,我们从 hEvent 0x23 更改为 0x21 ,通过 vbe_ioport_write race 将 off fFlags 0x14 的成员更改为 0xf ,禁用了关键部分。现在关键部分已完全禁用,我们可以轻松地将线程相互竞争 VGA 。下一步是找到我们新的和改进的竞争条件的读取和更好的写入。

Both the write and the read can be achieved by corrupting the same value. In VGAState there is a field of struct type VMSVGASTATE, and that struct contains a field named cScratchRegion.
写入和读取都可以通过损坏相同的值来实现。中 VGAState 有一个 struct 类型的 VMSVGASTATE 字段,该 struct 包含一个名为 cScratchRegion 的字段。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

cScratchRegion is used to track the size of the buffer au32ScratchRegion, which stores data during VMSVGA IO port communication. In functions vmsvgaIORead and vmsvgaIOWrite we can read and write this buffer based on the value of cScratchRegion.
cScratchRegion 用于跟踪缓冲区的大小 au32ScratchRegion ,缓冲区在VMSVGA IO端口通信期间存储数据。在函数 vmsvgaIORead 中, vmsvgaIOWrite 我们可以根据 的 cScratchRegion 值来读取和写入此缓冲区。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

Using the vbe_ioport_write race one more time, we can corrupt cScratchRegion. This gives us a fully controlled buffer overread and buffer overflow of a buffer within VGAState.
再使用 vbe_ioport_write 一次比赛,我们可以腐败 cScratchRegion .这为我们提供了完全受控的缓冲区溢读和缓冲区溢 VGAState 出。

From here we need to find a way to get arbitrary execution. Conveniently, each device in VirtualBox has a PDMPCIDEV allocated directly after it in memory. Since it is part of the initial allocation for the device, we can be assured it will always be there.
从这里开始,我们需要找到一种方法来获得任意执行。方便的是,VirtualBox 中的每个设备都直接在内存中分配了一个 PDMPCIDEV 。由于它是设备初始分配的一部分,因此我们可以放心,它将始终存在。

At the beginning of the structure there is a pointer to the static string vga located in VBoxDD.dll. We can use our buffer overread to read this pointer and infer the base address of VBoxDD.dll. The structure also has a nested PDMPCIDEVINT structure, which contains several easily accessible function pointers:
在结构的开头,有一个指向位于 中的 VBoxDD.dll 静态字符串 vga 的指针。我们可以使用缓冲区超读来读取此指针并推断 的 VBoxDD.dll 基址。该结构还具有嵌 PDMPCIDEVINT 套结构,其中包含几个易于访问的函数指针:

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

The function pointers pfnConfigRead and pfnConfigWrite can be overwritten by our buffer overflow. Afterwards, we can trigger calls to these function pointers using PCI ioports.
函数指针 pfnConfigRead , pfnConfigWrite 可以被我们的缓冲区溢出覆盖。之后,我们可以使用 PCI ioport 触发对这些函数指针的调用。

To prepare for calling these function pointers, we first call pciIOPortAddressWrite to set uConfigRegto to specify the PCI device we want to read from or write to. In our case, that value can be found in the uDevFn value at the beginning of the PDMPCIDEV struct.
为了准备调用这些函数指针,我们首先调用 pciIOPortAddressWrite set uConfigRegto 来指定要读取或写入的 PCI 设备。在我们的例子中,该值可以在 PDMPCIDEV 结构开头的 uDevFn 值中找到。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

After we set uConfigReg, we can then call pciIOPortDataWrite, which will call pci_data_write. This function will call our function pointer with some controlled arguments.
设置 uConfigReg 后,我们可以调用 pciIOPortDataWrite ,它将调用 pci_data_write 。此函数将使用一些受控参数调用我们的函数指针。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

When the function pointer is called, arg1 ends up being the value of pDevInsR3 which is fully user-controlled by means of our buffer overflow. arg2 points to the PDMPCIDEV struct after our VGAState, which means we can control data at that location. With a fully controlled arg1 and arg2, we can start to write our final execution chain.
当调用函数指针时,arg1 最终成为其值,该值 pDevInsR3 通过我们的缓冲区溢出完全由用户控制。arg2 指向 后面 VGAState 的 PDMPCIDEV 结构体,这意味着我们可以控制该位置的数据。有了完全受控的 arg1 和 arg2,我们就可以开始编写最终的执行链了。

These libraries use Windows Control Flow Guard so we are not able to make indirect calls to arbitrary code. Fortunately for us, CFG allows calls to arbitrary functions in other libraries, so it doesn’t prevent us from calling WinExec("calc").
这些库使用 Windows 控制流防护,因此我们无法对任意代码进行间接调用。幸运的是,CFG 允许调用其他库中的任意函数,因此它不会阻止我们调用 WinExec("calc") .

First, we need to use our buffer read/write primitives to construct an arbitrary read so we can get the address of kernel32.dll. We currently have the base address for VBoxDD.dll only, so we will have to find something to use in that library. When looking through functions in VBoxDD.dll I found one that will work perfectly for what we want to do.
首先,我们需要使用缓冲区读/写原语来构造任意读取,以便我们可以获取 的 kernel32.dll 地址。我们目前只有基 VBoxDD.dll 址,因此我们必须在该库中找到要使用的东西。在查看函数时 VBoxDD.dll ,我发现一个可以完美地满足我们想要做的事情。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

Our arg1 is fully controlled, so this read routine will allow us to take memory from arg1+0x2d8 and store it into the memory pointed to by arg2. arg2 points directly after VGAState in memory, so we can read it afterwards with our buffer overread. This effectively gives us an arbitrary read primitive. With this, we can leak pointers to functions in other libraries through VBoxDD.dlls IAT.
我们的 arg1 是完全受控的,因此此读取例程将允许我们从 arg2 指向的内存中获取内存 arg1+0x2d8 并将其存储到内存中。arg2 直接在内存中指向, VGAState 因此我们可以在缓冲区过度读取的情况下读取它。这有效地为我们提供了任意读取原语。有了这个,我们可以通过 VBoxDD.dll s IAT 泄漏指向其他库中函数的指针。

VBoxDD.dll imports several functions from kernel32.dll, so we can read any one of those import table entries to get a pointer into kernel32.dll. From there we can scan backward using our read until we encounter the PE magic at the beginning of kernel32.dll, which gives us the base.
VBoxDD.dll 从 kernel32.dll 导入多个函数,因此我们可以读取其中任何一个导入表条目以获取指向 kernel32.dll 的指针。从那里,我们可以使用读取向后扫描,直到我们在 kernel32.dll 开头遇到 PE 魔法,这为我们提供了基础。

Next, we scan for the export table of kernel32.dll. We start by reading out all the table addresses.
接下来,我们扫描 的 kernel32.dll 导出表。我们首先读出所有的表地址。

“}” data-block-type=”22″ data-immersive-translate-walked=”de26fb74-b1e3-488f-b13c-66ba7a33a8a8″>

We then scan through the names table until we find the name WinExec. Having obtained the index, we can use the ordinal and address tables to get the function address. Finally we write calc into heap memory we control and call WinExec("calc").
然后,我们浏览名称表,直到找到名称 WinExec 。获得索引后,我们可以使用序号表和地址表来获取函数地址。最后,我们将我们控制并调用 WinExec("calc") 的堆内存写入 calc 。

Impact 冲击

This bug can be triggered on a large percentage of virtual machines because it is an easily accessible path in VGA. I believe this can probably be turned into at least a DOS on any VM with at least 32MB of VRAM.
此 bug 可以在很大比例的虚拟机上触发,因为它是 VGA 中易于访问的路径。我相信这可能可以在任何具有至少 32MB VRAM 的 VM 上至少变成一个 DOS。

The way I exploited it has significantly more constraints, which restricts the number of machines affected by the full escape. It still may be possible to turn this bug into a full escape under a wider range of conditions, but that was not part of my research.

Thanks again to Cody for providing this thorough write-up. This was his first Pwn2Own event, and we certainly hope to see more submissions from him in the future. Until then, follow the team on Twitter, Mastodon, LinkedIn, or Instagram for the latest in exploit techniques and security patches.
再次感谢 Cody 提供这篇详尽的文章。这是他第一次参加Pwn2Own活动,我们当然希望将来能看到他提交的更多作品。在此之前,请在Twitter,Mastodon,LinkedIn或Instagram上关注团队,以获取最新的漏洞利用技术和安全补丁。


版权声明:admin 发表于 2024年5月13日 上午9:06。