Chromium Bug Hunting Adventures 03 - Reproducing CVE-2024-5839
What is CVE-2024-5839?
CVE-2024-5839 was the first publicly acknowledged bug report to bypass Chrome’s MiraclePtr
defense via a use-after-free (UAF) vulnerability. It is worth noting that this vulnerability was patched in Chromium version 126.0.6478.54.
Since 2022, the Chromium team has invested heavily in UAF mitigations via MiraclePtr
. Many historical UAFs involved raw pointers, prompting Chromium to introduce MiraclePtr
in their custom memory allocator PartitionAlloc
. Among multiple MiraclePtr
implementations, Google selected BackupRefPtr
to mitigate the exploitability of UAF vulnerabilities, which is enabled in non-ASAN release builds.
Although the vulnerability was limited in practical damage, it still allowed a brief window for instruction pointer (RIP) hijacking. The exploit relied on overflowing the reference counter on a separate thread to trigger a UAF condition.
The original researcher, Micky, demonstrated exploitability by:
- Implementing custom C++ PoC code
- Triggering it via
mojo IPC
interfaces (since mostMiraclePtr
usage is in the browser process) - Temporarily patching
MiraclePtr
internals to reduce time and RAM requirements during testing
The public disclosure of this vulnerability can be found here.
Core Differences Between Build Types
When reproducing this class of bug, build types matter significantly due to how PartitionAlloc
behaves. I tested:
BUILD TYPE | IS_ASAN |
---|---|
Release | Yes |
Release | No |
Debug | Yes |
Debug | No |
Key Points:
- ASAN (Address Sanitizer) helps detect memory corruption.
- Non-ASAN release builds are the closest to stable Chromium and are required for demonstrating RIP control.
- ASAN builds make it easier to confirm
MiraclePtr
status and detect UAFs.
Tip: For this bug class, using release builds with debug symbols (instead of pure debug builds) is more reliable, as debug builds may alter
PartitionAlloc
behavior and make the bug non-reproducible.
Core Challenges in Reproducing the CVE
- Tracing execution through
PartitionAlloc
was tricky due to differences across build types. - Release builds are heavily optimized, making GDB tracing more challenging.
- Build time on an 8-core CPU with 32GB RAM was ~10 hours per build.
Patching the Build
Following the original PoC instructions, I patched and built Chromium 124.0.6367.258 to mimic Micky’s environment. The steps included:
- Implementing
UAFManager
to encapsulate the UAF PoC logic. - Creating
mojo
bindings in the PermissionManager to expose the PoC to JavaScript. - Developing an HTML PoC to trigger the exploit flow.
- Patching
MiraclePtr
to reduce time and memory requirements. - Updating the build files to integrate all changes into the
chrome
binary.
HTML Exploit PoC
I used the following HTML exploit when running chromium so that you can understand the order of operation in the UAF.
In the next examples below, Chromium will process this HTML code, highlighting the RIP hijack, bypassing MiraclePtr
checks.
<script src="/mojo_bindings.js"></script>
<script src="/gen/third_party/blink/public/mojom/permissions/permission.mojom.js"></script>
<script>
var PermissionService0;
function bindClientPermissionService() {
var PermissionService_ptr = new blink.mojom.PermissionServicePtr();
Mojo.bindInterface(blink.mojom.PermissionService.name, mojo.makeRequest(PermissionService_ptr).handle, "context");
return PermissionService_ptr
}
</script>
<html>
<body>
<script>
PermissionService0 = bindClientPermissionService();
PermissionService0.testBackupRefPtr("NewObject");
for (var i = 0; i < 4095; i++) {
PermissionService0.testBackupRefPtr("Addref");
}
PermissionService0.testBackupRefPtr("AddRefInThreadpool");
PermissionService0.testBackupRefPtr("FreeObject");
PermissionService0.testBackupRefPtr("HeapSpray");
PermissionService0.testBackupRefPtr("TriggerUaf");
</script>
</body>
</html>
Running the Exploit - Release Build
The point of running the release build is to highlight that RIP can be hijacked with an arbitrary memory address.
The key points to notice here are the following:
- In the screenshot above, the pointer originally used at
0x81c00548800
was free’d and reused during a heap spray. - A MiraclePtr defense check in
in_slot_metadata.h
is made at line 187. - Chromium crashes and a core file is created.
Dissecting the Core File
When dissecting the core file in gdb, and navigating to the appropriate thread, we can see that the program crashed at frame 14 - UafManager::TriggerUAF
The screenshot below highlights that in 1
, the RAX
register is the value of our controlled memory address from the heap spray. We can also see that RIP is executing code at UafManager::TriggerUaf()+16
. This is because in 2
, the instruction pointer jumps to the memory address pointed to rax+0x10
which is effectively the memory address we that we wrote with our heap spray with a value of 0x4141414141414141
. This is a common pattern in vtables
or virtual functions in C++ as the disassembly is pretty consistent when invoking a virtual function belonging to an object that was written to after it was free’d.
Running the Exploit - ASAN Release Build
For reporting the bug and getting the bounty, the Chromium team requires proof of the message MiraclePtr Status: PROTECTED
to prove that RIP was hijacked even though MiraclePtr
was claiming to protect the user.
Notice that owned ptr
is different from spray ptr
in this build because ASAN builds use raw_ptr_asan_hooks
rather than the BackupRefPtr
implementation for MiraclePtr.
Notice how the stack trace below suggests that the crash originated from UafManager::TriggerUaf
.
STACK TRACE SNIPPED
STACK TRACE SNIPPED
STACK TRACE SNIPPED
As seen in the screenshot above, we can confirm that MiraclePtr defenses were enabled as we can see the message MiraclePtr Status: PROTECTED
as well as a use-after-free with the corresponding stack traces pointing to UafManager::TriggerUaf
.
Dissecting the Bug
The bug resides in in_slot_metadata.h
. However, we must first consider how this file looks like after the patch is made by the original researcher.
in_slot_metadata.h
// Special-purpose atomic bit field class mainly used by RawPtrBackupRefImpl.
// Formerly known as `PartitionRefCount`, but renamed to support usage that is
// unrelated to BRP.
class PA_COMPONENT_EXPORT(PARTITION_ALLOC) InSlotMetadata {
public:
// This class holds an atomic 32 bits field: `count_`. It holds 4 values:
//
// bits name description
// ----- --------------------- ----------------------------------------
// 0 is_allocated Whether or not the memory is held by the
// allocator.
// - 1 at construction time.
// - Decreased in ReleaseFromAllocator();
// - We check whether this bit is set in
// `ReleaseFromAllocator()`, and if not we
// have a double-free.
//
// 1-29 ptr_count Number of raw_ptr<T>.
// - Increased in Acquire()
// - Decreased in Release()
//
// 30 request_quarantine When set, PA will quarantine the memory in
// Scheduler-Loop quarantine.
// It also extends quarantine duration when
// set after being quarantined.
// 31 needs_mac11_malloc_ Whether malloc_size() return value needs to
// size_hack be adjusted for this allocation.
[... CONTENT SNIPPED ...]
using CountType = uint32_t;
static constexpr CountType kMemoryHeldByAllocatorBit =
BitField<CountType>::Bit(0);
static constexpr CountType kPtrCountMask = BitField<CountType>::Mask(1, 12);
static constexpr CountType kRequestQuarantineBit =
BitField<CountType>::Bit(13);
static constexpr CountType kNeedsMac11MallocSizeHackBit =
BitField<CountType>::Bit(14);
static constexpr CountType kDanglingRawPtrDetectedBit =
BitField<CountType>::None();
static constexpr CountType kUnprotectedPtrCountMask =
BitField<CountType>::None();
[... CONTENT SNIPPED ...]
Each raw_ptr
has their own reference count denoted by count_
. In release builds, it is 4 bytes, but data is packed in bits. In practice, bits 1-29 would represent uint32_t
reference count. However, the researcher patched this code to make only bits 1-12 represent the reference count such that less time and RAM are used during testing.
If we recall the original crash that happened in the release build, the error message suggested [FATAL:in_slot_metadata.h(187)] Check failed: (old_count & kPtrCountMask) != kPtrCountMask.
just before the spray ptr
was allocated on a separate thread. This check was made in partition_alloc::internal::Acquire
via a failed PA_CHECK
.
in_slot_metadata.h
PA_ALWAYS_INLINE void Acquire() {
CheckCookieIfSupported();
CountType old_count = count_.fetch_add(kPtrInc, std::memory_order_relaxed);
// Check overflow.
PA_CHECK((old_count & kPtrCountMask) != kPtrCountMask);
}
The Chromium authors wrote that function with bitwise overflow in mind in which, the check does indeed work. However, there is a small time window in between fetch_add
and PA_CHECK
as the bits in count_
will be overflowed, allowing the next raw_ptr
(let’s call it spray_ptr
) to be allocated in the same memory region that the original unique_ptr
owned by UafManager
(let’s call it owned_ptr
) was originally allocated at. An attacker could write an arbitrary value to spray_ptr
, allowing a UAF to occur when invoking a virtual function call using a raw_ptr
in an std::vector
referencing the old owned_ptr
(which is now spray_ptr
).
After the Patch
You can find the full patch here.
in_slot_metadata.h
[... CONTENT SNIPPED ...]
using CountType = uint32_t;
static constexpr CountType kMemoryHeldByAllocatorBit =
BitField<CountType>::Bit(0);
static constexpr CountType kPtrCountMask = BitField<CountType>::Mask(1, 29);
// The most significant bit of the refcount is reserved to prevent races with
// overflow detection.
static constexpr CountType kMaxPtrCount = BitField<CountType>::Mask(1, 28);
static constexpr CountType kRequestQuarantineBit =
BitField<CountType>::Bit(30);
static constexpr CountType kNeedsMac11MallocSizeHackBit =
BitField<CountType>::Bit(31);
static constexpr CountType kDanglingRawPtrDetectedBit =
BitField<CountType>::None();
static constexpr CountType kUnprotectedPtrCountMask =
BitField<CountType>::None();
[... CONTENT SNIPPED ...]
PA_ALWAYS_INLINE void Acquire() {
CheckCookieIfSupported();
CountType old_count = count_.fetch_add(kPtrInc, std::memory_order_relaxed);
// Check overflow.
PA_CHECK((old_count & kPtrCountMask) != kMaxPtrCount);
}
[... CONTENT SNIPPED ...]
After applying the patch, I can confirm that it was no longer possible to achieve a controlled write on RIP since spray ptr
was being allocated in the next memory slot adjacent to owned ptr
.
I believe this is because there is not enough time to overflow the kPtrCountMask
on the separate thread before the PA_CHECK
kicks in, mitigating any attempts to overflow kPtrCountMask
.
Takeaways
- Chromium release builds use
BackupRefPtr
to implement theMiraclePtr
defense. Release ASAN builds don’t actually use theBackupRefPtr
implementation, but they make it easier to check and validate heap use-after-free attempts. - You don’t necessarily need to find a code path that directly leads to exploitability when reporting
MiraclePtr
bypass vulnerabilities. As long as you can reproduce an isolated scenario that will proveMiraclePtr
defenses can be bypassed, that should be sufficient to highlight the bug!
Next Steps
After spending a considerable amount of time understanding this CVE and the intricacies of Chromium’s PartitionAlloc
, I am hoping to leverage my own novel techniques and methodologies find a unique MiraclePtr
bypass in the most stable release of Chromium. Whether those efforts result in reporting a valid CVE - who knows. If I fail, I’ll be happy to post some of the techniques I used which you as the reader can hopefully use in other kinds of research in open-source projects. If I succeed, then be prepared for a full-writeup once the bug has been publicly disclosed, patched, and recognized! 😉
Credits
- Props to Micky for finding and reporting the original bug
- The Chromium team for applying the patch fixing the bug