CVE-2026-9932
Overview
Background
- ANGLE D3D11 `Buffer11`
- the D3D11 backend representation of a GL buffer (
src/libANGLE/renderer/d3d/d3d11/Buffer11.cpp). A single GL buffer is backed by severalBufferStoragerepresentations held inmBufferStorages[usage](e.g.SYSTEM_MEMORY= index 0,STAGING= index 1,UNIFORM, …). - `mMappedStorage`
- a raw
BufferStorage*pointing at whichever storage currently backs an active CPU mapping.glUnmapBufferlater calls the virtualmMappedStorage->unmap(). - `garbageCollection` / `checkForDeallocation`
- periodic GC that deletes storages whose per-usage
mIdleness[usage]exceedsmDeallocThresholds[usage](default 8), unless that storage is themLatestBufferStorage. - Priority inversion
- mapping a
SYSTEM_MEMORYbuffer for read creates aSTAGINGstorage formMappedStorage, butmLatestBufferStoragestays onSYSTEM_MEMORY(STAGING’s priority index is not strictly higher), so GC considers the STAGING storage deletable while it is still the mapped one. - MiraclePtr exclusion
mMappedStorageis a raw C++ pointer outside Chromium’s BackupRefPtr, so a dangling value is directly exploitable.
Root Cause Analysis
The invariant is that the storage a buffer is currently mapped through (mMappedStorage) must never be freed while the mapping is live. Per the report, two flaws combined to break it: (1) mapping a read-only SYSTEM_MEMORY buffer creates a STAGING storage and points mMappedStorage at it, but leaves mLatestBufferStorage on SYSTEM_MEMORY; and (2) draw-time validation (ValidateDrawStates) checks that vertex/index buffers are not mapped but does not check UBOs. So a draw issued while a buffer is mapped and bound to many uniform blocks reaches StateManager11::syncUniformBuffersForShader, which calls Buffer11::getConstantBufferRange per binding; each call runs garbageCollection → checkForDeallocation(BUFFER_USAGE_STAGING), incrementing mIdleness[STAGING]. Once the program uses more than the threshold (8) of uniform-block bindings, mIdleness[STAGING] exceeds mDeallocThresholds[STAGING], and because mLatestBufferStorage (SYSTEM_MEMORY) != storage (STAGING), checkForDeallocation SafeDeletes the STAGING storage — leaving mMappedStorage dangling. The later glUnmapBuffer dereferences it via the virtual unmap(), a use-after-free in the GPU process.
The patch adds a defensive guard inside Buffer11::checkForDeallocation (Buffer11.cpp): right after fetching BufferStorage *&storage = mBufferStorages[usage];, it checks if (storage != nullptr && storage == mMappedStorage) and, if so, fails the operation with ANGLE_TRY_HR(..., E_FAIL, "Error deallocating mapped storage") instead of proceeding to delete it. This converts the UAF into a surfaced GL_INVALID_OPERATION at draw time. The commit message and an inline TODO(http://anglebug.com/505771894) are explicit that the real fix is to validate “buffer is mapped during draw” in the API layer (as vertex/index buffers already are) and later turn this check into an assert — so this change is an interim safety net, not the structural fix. The regression test SimpleUniformBufferTest.MappedUBOStagingGarbageCollection builds a 10-uniform-block program, maps a GL_DYNAMIC_DRAW buffer for read (forcing STAGING), draws, and asserts GL_INVALID_OPERATION where the old code would UAF on unmap. The advisory’s “Use-after-free” (CWE-416) matches the diff.
STAGING storage was the mMappedStorage but not the mLatestBufferStorage, and UBO mappings were never validated at draw time — so a draw with enough uniform-block bindings drove the STAGING storage’s idleness past the GC threshold and freed the still-mapped storage, leaving mMappedStorage dangling for glUnmapBuffer.Attack Path
- Renderer (attacker) open a WebGL2/GLES3 context on Windows using the passthrough decoder (ANGLE D3D11, default).
- Force SYSTEM_MEMORY storage
create a buffer with
GL_DYNAMIC_DRAW/GL_STREAM_DRAWand data, somLatestBufferStorageisSYSTEM_MEMORY. - Bind to many UBO slots
link a program using ≥9 uniform blocks and
glBindBufferBase(GL_UNIFORM_BUFFER, 0..8, buffer). - Map for read
glMapBufferRange(..., GL_MAP_READ_BIT)allocates theSTAGINGstorage and pointsmMappedStorageat it (whilemLatestBufferStoragestaysSYSTEM_MEMORY). - Draw
glDrawArrays;syncUniformBuffersForShaderruns GC per binding; on the 9th the STAGING idleness exceeds the threshold andSafeDeletefrees the mapped STAGING storage, danglingmMappedStorage. - Groom (inferred)
spray the GPU heap to reclaim the freed
NativeStorageand forge its vtable. - Primitive (inferred)
glUnmapBufferinvokes the virtualmMappedStorage->unmap()on the freed/forged object — GPU-process code execution.
Impact Assessment
GL_INVALID_OPERATION (confirmed by the regression test). The heap reclamation and virtual-call-on-freed-object RCE are (inferred) from the report, not shown by this diff. Because mMappedStorage is a raw pointer excluded from MiraclePtr, the UAF is not downgraded to a safe crash. Gating: requires the D3D11 backend, a SYSTEM_MEMORY-backed read-mapped buffer, and a program with more uniform-block bindings than the dealloc threshold (default 8).Changed Functions
| Function | Change | Notes |
|---|---|---|
SimpleUniformBufferTestsrc/tests/gl_tests/UniformBufferTest.cpp |
modified |
Files Changed
src/libANGLE/renderer/d3d/d3d11/Buffer11.cppsrc/tests/gl_tests/UniformBufferTest.cpp
Audit Directions
- UBO mapped-state validationthe structural gap is that
ValidateDrawStates/ValidateProgramDrawStatesvalidate vertex/index buffers for being mapped but not UBOs (or SSBOs/atomic-counter buffers); audit all buffer-binding kinds consumed by draws for the same missing “is mapped” check. - `mLatestBufferStorage` priority orderingreview the priority indices of all
BufferStorageusages; any mapping whose storage is not the latest can be GC’d out from undermMappedStorage. Verify STAGING-vs-SYSTEM_MEMORY is the only such inversion. - GC during per-binding sync loops
syncUniformBuffersForShaderruns GC once per binding; audit other sync loops that callgarbageCollectionrepeatedly within a single draw for threshold-crossing side effects on live objects. - Raw backend pointers
mMappedStorageand peer raw pointers in the D3D11 backend are MiraclePtr-excluded; flag forraw_ptrmigration to blunt exploitability of residual lifetime bugs.
Patch
From ba4afac4c523d50f5639014cc50f35052f125913 Mon Sep 17 00:00:00 2001
From: Antonio Maiorano <amaiorano@google.com>
Date: Thu, 23 Apr 2026 12:56:06 -0400
Subject: [PATCH] [d3d11] Fail on potential delete of mapped buffer during draw
Before this change, it was possible on D3D11 to map a buffer, then draw
in such a way that garbage collection attempts to delete the mapped
buffer, which would then result in a UAF on unmapping that buffer.
For now we detect this situation and return an invalid operation;
however, we should instead catch this in validation as per
http://anglebug.com/505771894.
Bug: chromium:501563323
Change-Id: I26f0a10564cc04ffd1eba31bcb542f0c5529a0ae
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7790252
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Antonio Maiorano <amaiorano@google.com>
---
diff --git a/src/libANGLE/renderer/d3d/d3d11/Buffer11.cpp b/src/libANGLE/renderer/d3d/d3d11/Buffer11.cpp
index d3ff511..1cbfd32 100644
--- a/src/libANGLE/renderer/d3d/d3d11/Buffer11.cpp
+++ b/src/libANGLE/renderer/d3d/d3d11/Buffer11.cpp
@@ -622,6 +622,15 @@
mIdleness[usage]++;
BufferStorage *&storage = mBufferStorages[usage];
+
+ // TODO(http://anglebug.com/505771894): Add validation that a buffer is not mapped in calls that
+ // use it (draw, etc.). Once fixed, turn this into an assert.
+ if (storage != nullptr && storage == mMappedStorage)
+ {
+ ANGLE_TRY_HR(SafeGetImplAs<Context11>(context), E_FAIL,
+ "Error deallocating mapped storage");
+ }
+
if (storage != nullptr && mIdleness[usage] > mDeallocThresholds[usage])
{
BufferStorage *latestStorage = nullptr;
diff --git a/src/tests/gl_tests/UniformBufferTest.cpp b/src/tests/gl_tests/UniformBufferTest.cpp
index 36d5d79..45de0dd 100644
--- a/src/tests/gl_tests/UniformBufferTest.cpp
+++ b/src/tests/gl_tests/UniformBufferTest.cpp
@@ -5007,4 +5007,107 @@
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(WebGL2UniformBufferTest);
ANGLE_INSTANTIATE_TEST_ES3(WebGL2UniformBufferTest);
+class SimpleUniformBufferTest : public ANGLETest<>
+{
+ protected:
+ SimpleUniformBufferTest()
+ {
+ setWindowWidth(128);
+ setWindowHeight(128);
+ setConfigRedBits(8);
+ setConfigGreenBits(8);
+ setConfigBlueBits(8);
+ setConfigAlphaBits(8);
+ }
+};
+
+// Test that maps a buffer as staging, then performs a draw inducing a garbage collect of the
+// staging buffer. This would formerly UAF, but now returns an invalid operation.
+// TODO(http://anglebug.com/505771894): Update test once we validate that drawing with a mapped
+// buffer is invalid.
+TEST_P(SimpleUniformBufferTest, MappedUBOStagingGarbageCollection)
+{
+ // Only applies to D3D11 backend.
+ ANGLE_SKIP_TEST_IF(!IsD3D11());
+
+ // Create a program with 10 uniform blocks.
+ constexpr char kVS[] = R"(#version 300 es
+layout(std140) uniform block0 { vec4 data0; };
+layout(std140) uniform block1 { vec4 data1; };
+layout(std140) uniform block2 { vec4 data2; };
+layout(std140) uniform block3 { vec4 data3; };
+layout(std140) uniform block4 { vec4 data4; };
+layout(std140) uniform block5 { vec4 data5; };
+layout(std140) uniform block6 { vec4 data6; };
+layout(std140) uniform block7 { vec4 data7; };
+layout(std140) uniform block8 { vec4 data8; };
+layout(std140) uniform block9 { vec4 data9; };
+
+void main() {
+ vec4 total = vec4(0.0);
+ total += data0;
+ total += data1;
+ total += data2;
+ total += data3;
+ total += data4;
+ total += data5;
+ total += data6;
+ total += data7;
+ total += data8;
+ total += data9;
+ gl_Position = vec4(total.xyz, 1.0);
+}
+ )";
+
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+out vec4 color;
+void main() {
+ color = vec4(1.0);
+}
+)";
+
+ ANGLE_GL_PROGRAM(program, kVS, kFS);
+ glUseProgram(program);
+
+ // Create a buffer and bind it as a COPY_READ_BUFFER.
+ GLBuffer buffer;
+ glBindBuffer(GL_COPY_READ_BUFFER, buffer);
+
+ // Use GL_DYNAMIC_DRAW and provide data to ensure it uses SYSTEM_MEMORY storage
+ // and sets it as mLatestBufferStorage.
+ std::vector<uint8_t> data(1024, 0);
+ glBufferData(GL_COPY_READ_BUFFER, 1024, data.data(), GL_DYNAMIC_DRAW);
+
+ // Bind the buffer as a UBO to all 10 slots.
+ for (int i = 0; i < 10; ++i)
+ {
+ glBindBufferBase(GL_UNIFORM_BUFFER, i, buffer);
+ }
+
+ // Map the buffer for reading. Since it's SYSTEM_MEMORY and we are mapping for read,
+ // it should allocate a STAGING buffer.
+ void *ptr = glMapBufferRange(GL_COPY_READ_BUFFER, 0, 1024, GL_MAP_READ_BIT);
+ ASSERT_NE(ptr, nullptr);
+
+ // Now perform a draw call.
+ // This will trigger StateManager11::syncUniformBuffersForShader.
+ // It will iterate through the 10 slots.
+ // For each slot, it calls Buffer11::getConstantBufferRange -> getBufferStorage(UNIFORM) ->
+ // garbageCollection. garbageCollection(UNIFORM) calls checkForDeallocation(STAGING). On the 9th
+ // slot, STAGING's idleness will be 9, exceeding the threshold of 8. checkForDeallocation would
+ // then attempt to delete the STAGING storage because latestStorage (SYSTEM_MEMORY) != storage
+ // (STAGING), however, it currently detects this and returns failure.
+ glDrawArrays(GL_POINTS, 0, 1);
+
+ // Now unmap. This calls Buffer11::unmap() which uses mMappedStorage and would formerly UAF on
+ // accessing mMappedBuffer.
+ glUnmapBuffer(GL_COPY_READ_BUFFER);
+
+ ASSERT_GL_ERROR(GL_INVALID_OPERATION);
+}
+
+GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(SimpleUniformBufferTest);
+ANGLE_INSTANTIATE_TEST_ES3(SimpleUniformBufferTest);
+
} // namespace
Regression Test / PoC
diff --git a/src/tests/gl_tests/UniformBufferTest.cpp b/src/tests/gl_tests/UniformBufferTest.cpp
index 36d5d79..45de0dd 100644
--- a/src/tests/gl_tests/UniformBufferTest.cpp
+++ b/src/tests/gl_tests/UniformBufferTest.cpp
@@ -5007,4 +5007,107 @@
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(WebGL2UniformBufferTest);
ANGLE_INSTANTIATE_TEST_ES3(WebGL2UniformBufferTest);
+class SimpleUniformBufferTest : public ANGLETest<>
+{
+ protected:
+ SimpleUniformBufferTest()
+ {
+ setWindowWidth(128);
+ setWindowHeight(128);
+ setConfigRedBits(8);
+ setConfigGreenBits(8);
+ setConfigBlueBits(8);
+ setConfigAlphaBits(8);
+ }
+};
+
+// Test that maps a buffer as staging, then performs a draw inducing a garbage collect of the
+// staging buffer. This would formerly UAF, but now returns an invalid operation.
+// TODO(http://anglebug.com/505771894): Update test once we validate that drawing with a mapped
+// buffer is invalid.
+TEST_P(SimpleUniformBufferTest, MappedUBOStagingGarbageCollection)
+{
+ // Only applies to D3D11 backend.
+ ANGLE_SKIP_TEST_IF(!IsD3D11());
+
+ // Create a program with 10 uniform blocks.
+ constexpr char kVS[] = R"(#version 300 es
+layout(std140) uniform block0 { vec4 data0; };
+layout(std140) uniform block1 { vec4 data1; };
+layout(std140) uniform block2 { vec4 data2; };
+layout(std140) uniform block3 { vec4 data3; };
+layout(std140) uniform block4 { vec4 data4; };
+layout(std140) uniform block5 { vec4 data5; };
+layout(std140) uniform block6 { vec4 data6; };
+layout(std140) uniform block7 { vec4 data7; };
+layout(std140) uniform block8 { vec4 data8; };
+layout(std140) uniform block9 { vec4 data9; };
+
+void main() {
+ vec4 total = vec4(0.0);
+ total += data0;
+ total += data1;
+ total += data2;
+ total += data3;
+ total += data4;
+ total += data5;
+ total += data6;
+ total += data7;
+ total += data8;
+ total += data9;
+ gl_Position = vec4(total.xyz, 1.0);
+}
+ )";
+
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+out vec4 color;
+void main() {
+ color = vec4(1.0);
+}
+)";
+
+ ANGLE_GL_PROGRAM(program, kVS, kFS);
+ glUseProgram(program);
+
+ // Create a buffer and bind it as a COPY_READ_BUFFER.
+ GLBuffer buffer;
+ glBindBuffer(GL_COPY_READ_BUFFER, buffer);
+
+ // Use GL_DYNAMIC_DRAW and provide data to ensure it uses SYSTEM_MEMORY storage
+ // and sets it as mLatestBufferStorage.
+ std::vector<uint8_t> data(1024, 0);
+ glBufferData(GL_COPY_READ_BUFFER, 1024, data.data(), GL_DYNAMIC_DRAW);
+
+ // Bind the buffer as a UBO to all 10 slots.
+ for (int i = 0; i < 10; ++i)
+ {
+ glBindBufferBase(GL_UNIFORM_BUFFER, i, buffer);
+ }
+
+ // Map the buffer for reading. Since it's SYSTEM_MEMORY and we are mapping for read,
+ // it should allocate a STAGING buffer.
+ void *ptr = glMapBufferRange(GL_COPY_READ_BUFFER, 0, 1024, GL_MAP_READ_BIT);
+ ASSERT_NE(ptr, nullptr);
+
+ // Now perform a draw call.
+ // This will trigger StateManager11::syncUniformBuffersForShader.
+ // It will iterate through the 10 slots.
+ // For each slot, it calls Buffer11::getConstantBufferRange -> getBufferStorage(UNIFORM) ->
+ // garbageCollection. garbageCollection(UNIFORM) calls checkForDeallocation(STAGING). On the 9th
+ // slot, STAGING's idleness will be 9, exceeding the threshold of 8. checkForDeallocation would
+ // then attempt to delete the STAGING storage because latestStorage (SYSTEM_MEMORY) != storage
+ // (STAGING), however, it currently detects this and returns failure.
+ glDrawArrays(GL_POINTS, 0, 1);
+
+ // Now unmap. This calls Buffer11::unmap() which uses mMappedStorage and would formerly UAF on
+ // accessing mMappedBuffer.
+ glUnmapBuffer(GL_COPY_READ_BUFFER);
+
+ ASSERT_GL_ERROR(GL_INVALID_OPERATION);
+}
+
+GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(SimpleUniformBufferTest);
+ANGLE_INSTANTIATE_TEST_ES3(SimpleUniformBufferTest);
+
} // namespace
Original Bug Report
Potential Use-After-Free in ANGLE D3D11 Buffer11 via STAGING storage deallocation
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.
Overview: A Use-After-Free vulnerability exists in ANGLE’s D3D11 backend when a mapped buffer’s staging storage is prematurely garbage-collected. This occurs if a read-only mapped buffer is bound to multiple uniform blocks and drawn, causing its idleness counter to exceed the deallocation threshold. An attacker could potentially exploit this from a compromised renderer to execute arbitrary code in the GPU process.
Affected files:
third_party/angle/src/libANGLE/renderer/d3d/d3d11/Buffer11.cppthird_party/angle/src/libANGLE/renderer/d3d/d3d11/Buffer11.hthird_party/angle/src/libANGLE/renderer/d3d/d3d11/StateManager11.cpp
Estimated timestamp from git blame: 2025-05-14
Vulnerability Details
In ANGLE’s D3D11 backend, Buffer11 manages multiple storage representations for a single GL buffer (e.g., SYSTEM_MEMORY, STAGING, UNIFORM). To optimize memory, Buffer11::garbageCollection periodically deallocates unused storages.
A Use-After-Free (UAF) condition can occur because the raw pointer Buffer11::mMappedStorage can be left dangling if its underlying storage object is reclaimed by garbage collection while the buffer is mapped. This relies on two distinct logic flaws:
- Priority Inversion in Mapping: When a buffer is mapped with
GL_MAP_READ_BITand itsmLatestBufferStorageisSYSTEM_MEMORY,Buffer11::mapRangecreates aSTAGINGstorage to provide CPU access. However, becauseBUFFER_USAGE_STAGING(index 1) does not have a strictly lower priority index thanBUFFER_USAGE_SYSTEM_MEMORY(index 0),mLatestBufferStorageremains pointing toSYSTEM_MEMORY.mMappedStorageis set to the newSTAGINGstorage. - Lack of UBO Mapping Validation: ANGLE’s draw-time validation (
ValidateDrawStates) correctly prevents drawing if vertex or index buffers are mapped, but fails to validate the mapped state of Uniform Buffers (UBOs).
If a draw call is issued while the buffer is mapped and bound to multiple uniform blocks, StateManager11::syncUniformBuffersForShader iterates through the uniform bindings. For each binding, it calls Buffer11::getConstantBufferRange, which ultimately triggers garbageCollection and checkForDeallocation(BUFFER_USAGE_STAGING).
Each iteration increments mIdleness[BUFFER_USAGE_STAGING]. If the shader uses more than 8 uniform block bindings (the default deallocation threshold), the idleness exceeds the threshold. Because mLatestBufferStorage is SYSTEM_MEMORY, latestStorage != storage evaluates to true, and SafeDelete(storage) destroys the STAGING object.
mMappedStorage is completely unaware of this deletion. A subsequent call to glUnmapBuffer will trigger the virtual function mMappedStorage->unmap(), resulting in a virtual call on a freed object.
Potential Reproduction Steps
Note: These are suggested steps based on static analysis. Our tooling does not yet execute code to provide a working proof-of-concept.
- From a compromised renderer process, establish a WebGL2/GLES3 context that uses the passthrough command decoder (default on Windows for ANGLE D3D11).
- Create a GL buffer and initialize it with
GL_DYNAMIC_DRAWorGL_STREAM_DRAW. This sets the internal D3D usage to dynamic, forcing ANGLE to useSYSTEM_MEMORYas themLatestBufferStorage. - Compile a shader program that actively uses at least 9 uniform block bindings.
- Bind the buffer to all 9 uniform block binding points (
glBindBufferBase(GL_UNIFORM_BUFFER, 0 through 8)). - Map the buffer for reading via
MapBufferRangewithGL_MAP_READ_BIT. ANGLE allocatesSTAGINGstorage and pointsmMappedStorageto it. - Issue a
DrawArrayscommand. ANGLE fails to validate that the UBO is mapped, allowing the draw to proceed. - The loop in
syncUniformBuffersForShaderfetches the storage for each of the 9 bindings, callinggarbageCollection9 times. - On the 9th iteration,
mIdleness[STAGING]reaches 9, exceeding the threshold of 8. TheSTAGINGstorage isSafeDeleted. - (Optional) Spray the GPU process heap to reclaim the freed
NativeStorageobject and counterfeit its vtable. - Unmap the buffer (
glUnmapBuffer). The GPU process executesmMappedStorage->unmap(), dereferencing the attacker’s payload and achieving Remote Code Execution in the GPU process.
Suggested Fix
There are multiple ways to address this issue:
- Prevent Deallocation of Mapped Storage: Update
Buffer11::checkForDeallocationto explicitly prevent deleting the storage if it is currently mapped:if (storage != nullptr && storage != mMappedStorage && mIdleness[usage] > mDeallocThresholds[usage]) { // ... existing deletion logic ... } - Validate UBO Mapped State: Update ANGLE’s top-level API validation (e.g.,
ValidateProgramDrawStates) to return an error (likekBufferMapped) if any active Uniform Buffer Object is currently mapped, bringing UBOs in line with vertex and index buffer validation. - Modernize Pointers: ANGLE relies extensively on raw pointers. Migrating ANGLE’s pointers (like
mMappedStorage) tobase::raw_ptrwould mitigate the exploitability of this and similar UAFs by transforming them into safe, non-exploitable crashes via MiraclePtr/BackupRefPtr.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.