CVE-2026-9112
Overview
Files Changed
src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cppsrc/tests/gl_tests/TransformFeedbackTest.cpp
Patch
From 06e6c6b59454d0a122fb274b2e1dd0ab09ffb638 Mon Sep 17 00:00:00 2001 From: Geoff Lang <[email protected]> Date: Mon, 23 Mar 2026 12:30:56 -0400 Subject: [PATCH] D3D11: Fix buffer state tracking in TransformFeedback11. TransformFeedback11::getSOBuffers would only update the elements of mBuffers if the GL buffer binding was non-null. This could lead to setting a previously-deleted buffer on the DeviceContext later. Update the state tracking in TransformFeedback11 to null out entries in mBuffers every time a new buffer is bound and add a null check when synchronizing mBuffers. Bug: angleproject:489791425 Change-Id: Ic80e36c1511d5e14d41a13c56f5055c55f36bc20 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7689826 Reviewed-by: Shahbaz Youssefi <[email protected]> Commit-Queue: Geoff Lang <[email protected]> --- diff --git a/src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp b/src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp index 4620f67..3560fee 100644 --- a/src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp +++ b/src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp @@ -77,6 +77,7 @@ { mIsDirty = true; mBufferOffsets[index] = static_cast<UINT>(binding.getOffset()); + mBuffers[index] = nullptr; mRenderer->getStateManager()->invalidateTransformFeedback(); return angle::Result::Continue; } @@ -114,6 +115,10 @@ &mBuffers[bindingIdx], &feedback)); binding.get()->applyImplFeedback(context, feedback); } + else + { + mBuffers[bindingIdx] = nullptr; + } } *buffersOut = &mBuffers; diff --git a/src/tests/gl_tests/TransformFeedbackTest.cpp b/src/tests/gl_tests/TransformFeedbackTest.cpp index ed019b1..d52d3b3 100644 --- a/src/tests/gl_tests/TransformFeedbackTest.cpp +++ b/src/tests/gl_tests/TransformFeedbackTest.cpp @@ -4736,6 +4736,43 @@ glEndTransformFeedback(); } +// Test that deleting a buffer bound to a transform feedback slot that is not used by the current +// program. +TEST_P(TransformFeedbackTest, StaleBufferBinding) +{ + std::vector<std::string> tfVaryings = {"gl_Position"}; + mProgram = CompileProgramWithTransformFeedback( + essl3_shaders::vs::Simple(), essl3_shaders::fs::Red(), tfVaryings, GL_INTERLEAVED_ATTRIBS); + ASSERT_NE(0u, mProgram); + glUseProgram(mProgram); + + GLBuffer buf0, buf1; + glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, buf0); + glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 1024, nullptr, GL_DYNAMIC_COPY); + glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, buf1); + glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 1024, nullptr, GL_DYNAMIC_COPY); + + glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buf0); + glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 1, buf1); + + // Draw once with the buffers, syncs initial state. + glBeginTransformFeedback(GL_POINTS); + glDrawArrays(GL_POINTS, 0, 1); + glEndTransformFeedback(); + + // Regular draw while TF inactive, syncs null transform feedback buffers. + glDrawArrays(GL_POINTS, 0, 1); + + buf1.reset(); + + // Draw with TF after the buffer has been deleted. It should not be referenced. + glBeginTransformFeedback(GL_POINTS); + glDrawArrays(GL_POINTS, 0, 1); + glEndTransformFeedback(); + + ASSERT_GL_NO_ERROR(); +} + GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(TransformFeedbackTest); ANGLE_INSTANTIATE_TEST_ES3_AND(TransformFeedbackTest, ES3_VULKAN().disable(Feature::SupportsTransformFeedbackExtension),
Regression Test / PoC
diff --git a/src/tests/gl_tests/TransformFeedbackTest.cpp b/src/tests/gl_tests/TransformFeedbackTest.cpp
index ed019b1..d52d3b3 100644
--- a/src/tests/gl_tests/TransformFeedbackTest.cpp
+++ b/src/tests/gl_tests/TransformFeedbackTest.cpp
@@ -4736,6 +4736,43 @@
glEndTransformFeedback();
}
+// Test that deleting a buffer bound to a transform feedback slot that is not used by the current
+// program.
+TEST_P(TransformFeedbackTest, StaleBufferBinding)
+{
+ std::vector<std::string> tfVaryings = {"gl_Position"};
+ mProgram = CompileProgramWithTransformFeedback(
+ essl3_shaders::vs::Simple(), essl3_shaders::fs::Red(), tfVaryings, GL_INTERLEAVED_ATTRIBS);
+ ASSERT_NE(0u, mProgram);
+ glUseProgram(mProgram);
+
+ GLBuffer buf0, buf1;
+ glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, buf0);
+ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 1024, nullptr, GL_DYNAMIC_COPY);
+ glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, buf1);
+ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 1024, nullptr, GL_DYNAMIC_COPY);
+
+ glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buf0);
+ glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 1, buf1);
+
+ // Draw once with the buffers, syncs initial state.
+ glBeginTransformFeedback(GL_POINTS);
+ glDrawArrays(GL_POINTS, 0, 1);
+ glEndTransformFeedback();
+
+ // Regular draw while TF inactive, syncs null transform feedback buffers.
+ glDrawArrays(GL_POINTS, 0, 1);
+
+ buf1.reset();
+
+ // Draw with TF after the buffer has been deleted. It should not be referenced.
+ glBeginTransformFeedback(GL_POINTS);
+ glDrawArrays(GL_POINTS, 0, 1);
+ glEndTransformFeedback();
+
+ ASSERT_GL_NO_ERROR();
+}
+
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(TransformFeedbackTest);
ANGLE_INSTANTIATE_TEST_ES3_AND(TransformFeedbackTest,
ES3_VULKAN().disable(Feature::SupportsTransformFeedbackExtension),
Original Bug Report
ANGLE D3D11: stale ID3D11Buffer pointer in TransformFeedback11 mBuffers leads to use-after-free in GPU process via SOSetTargets
ANGLE D3D11: stale ID3D11Buffer pointer in TransformFeedback11 mBuffers leads to use-after-free in GPU process via SOSetTargets
Summary
The ANGLE D3D11 backend caches raw ID3D11Buffer* pointers in TransformFeedback11::mBuffers but never clears entries whose corresponding GL buffer binding has become null. When a WebGL2 program binds a transform feedback buffer to an unused slot, performs a TF draw to populate the cache, then deletes the buffer and initiates a second TF draw, the stale pointer is passed to ID3D11DeviceContext::SOSetTargets, which dereferences the freed COM object. This is a use-after-free in the GPU process on Windows systems using the D3D11 rendering backend. The vulnerability is deterministic and triggers on every attempt.
Platform: Windows only (D3D11 backend). Requires a GPU with D3D11 support.
Bisect
Introducing Commit: 73bd218e12d26a626e0b21625606593ad2a5fd1a
- Date: 2016-07-15
- Author: Geoff Lang <[email protected]>
- Review: https://chromium-review.googlesource.com/360910
Root Cause
TransformFeedback11 maintains a vector of raw ID3D11Buffer* pointers that mirror the GL-level indexed buffer bindings for stream output. When getSOBuffers prepares the buffer array for SOSetTargets, it iterates over all slots but only writes into mBuffers[i] when the corresponding binding is non-null.
// third_party/angle/src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp
for (size_t bindingIdx = 0; bindingIdx < mBuffers.size(); bindingIdx++)
{
const auto &binding = mState.getIndexedBuffer(bindingIdx);
if (binding.get() != nullptr)
{
Buffer11 *storage = GetImplAs<Buffer11>(binding.get());
BufferFeedback feedback;
ANGLE_TRY(storage->getBuffer(context, BUFFER_USAGE_VERTEX_OR_TRANSFORM_FEEDBACK,
&mBuffers[bindingIdx], &feedback));
binding.get()->applyImplFeedback(context, feedback);
}
// Missing: else { mBuffers[bindingIdx] = nullptr; }
}
When a binding transitions from non-null to null, as happens when deleteBuffer detaches the buffer from the transform feedback object, the corresponding mBuffers entry retains the old ID3D11Buffer*. The pointer returned by Buffer11::getBuffer is a non-owning raw pointer obtained via .get() on the internal Resource11<ID3D11Buffer> wrapper, with no AddRef performed. Once the Buffer11 is destroyed by deleteBuffer, its destructor releases the underlying ID3D11Buffer through TypedData::~TypedData, which calls Release() and drops the COM refcount to zero.
Meanwhile, TransformFeedback11::bindIndexedBuffer, called during the detach path, marks the object as dirty and updates the offset but does not clear mBuffers[index].
// third_party/angle/src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp
angle::Result TransformFeedback11::bindIndexedBuffer(
const gl::Context *context,
size_t index,
const gl::OffsetBindingPointer<gl::Buffer> &binding)
{
mIsDirty = true;
mBufferOffsets[index] = static_cast<UINT>(binding.getOffset());
mRenderer->getStateManager()->invalidateTransformFeedback();
return angle::Result::Continue;
}
The number of buffers passed to SOSetTargets is determined by getNumSOBuffers, which returns mBuffers.size(), the total number of indexed buffer slots (typically 4 on D3D11), regardless of how many the linked program actually requires.
// third_party/angle/src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp
UINT TransformFeedback11::getNumSOBuffers() const
{
return static_cast<UINT>(mBuffers.size());
}
The validation performed by ValidateProgramExecutableXFBBuffersPresent only checks slots up to programExecutable->getTransformFeedbackBufferCount(). For a program linked with INTERLEAVED_ATTRIBS and a single varying, this count is 1, so only slot 0 is validated. Slot 1 can be null without causing a validation failure, yet mBuffers[1] still holds the dangling pointer and is passed to SOSetTargets.
// third_party/angle/src/libANGLE/validationES.cpp
bool ValidateProgramExecutableXFBBuffersPresent(const Context *context,
const ProgramExecutable *programExecutable)
{
size_t programXfbCount = programExecutable->getTransformFeedbackBufferCount();
const TransformFeedback *transformFeedback = context->getState().getCurrentTransformFeedback();
for (size_t programXfbIndex = 0; programXfbIndex < programXfbCount; ++programXfbIndex)
{
const OffsetBindingPointer<Buffer> &buffer =
transformFeedback->getIndexedBuffer(programXfbIndex);
if (!buffer.get())
{
return false;
}
}
return true;
}
The trigger sequence exploits this gap between the number of slots the program needs and the number getSOBuffers passes to D3D. The attacker binds a buffer to a slot unused by the program, performs a TF draw to cache its D3D pointer, unbinds and deletes it, then begins a new TF pass. Because validation only checks program-required slots, the second beginTransformFeedback succeeds, and the subsequent draw call feeds the stale pointer to SOSetTargets. The D3D11 runtime attempts to access the freed COM object, resulting in a use-after-free.
Reproduce
This bug affects the ANGLE D3D11 backend and can only be reproduced on Windows with a GPU that uses the D3D11 rendering path. It was tested on Chromium commit cdd1f63c02a65c37ccdb85e85b25dbec456c9914.
No source code modifications are required. The PoC is a self-contained HTML file that triggers the vulnerability through the WebGL2 Transform Feedback API.
To build Chromium, use a release configuration. Create out/release/args.gn with the following content, then run gn gen out/release and autoninja -C out/release chrome.
is_debug = false
dcheck_always_on = false
target_cpu = "x64"
Because the use-after-free occurs on a D3D11 COM object allocated by the Windows system heap rather than by an ASAN-instrumented allocator, ASAN cannot detect this bug. Windows Page Heap is the appropriate detection tool. Enable it by running the following command in an elevated (Administrator) command prompt, where the path to gflags.exe may vary depending on the Windows SDK installation.
"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\gflags.exe" /p /enable chrome.exe /full
Serve the PoC over HTTP. From the directory containing poc.html, start a local server with python -m http.server 8080.
Launch Chrome with the following command.
out\release\chrome.exe --no-sandbox --user-data-dir=%TEMP%\angl107_test --no-first-run --disable-default-apps --disable-extensions http://localhost:8080/poc.html
The PoC runs 50 iterations of the trigger sequence automatically. Within several seconds, the GPU process will crash with an access violation inside d3d11!CContext::TID3D11DeviceContext_SOSetTargets_<2>, and Chrome will report “The GPU process has crashed” in its stderr output. A Crashpad dump is written to the user data directory under Crashpad/reports/. Analyzing the dump with WinDbg confirms the crash occurs when SOSetTargets dereferences a dangling ID3D11Buffer* pointer at a page marked PAGE_NOACCESS by the page heap.
After testing, disable page heap by running the following command in an elevated prompt.
"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\gflags.exe" /p /disable chrome.exe
Crash log
Chrome stderr:
[12552:19208:WARNING:content\browser\gpu\gpu_process_host.cc:1441] The GPU process has crashed 1 time(s)
[12552:19208:INFO:CONSOLE:0] "WebGL: CONTEXT_LOST_WEBGL: loseContext: context lost"
[12552:19208:WARNING:content\browser\gpu\gpu_process_host.cc:1021] Reinitialized the GPU process after a crash. The reported initialization time was 214 ms
WinDbg crash dump analysis (GPU process):
EXCEPTION_RECORD:
ExceptionAddress: 00007ff94483640a (d3d11!CContext::TID3D11DeviceContext_SOSetTargets_<2>+0xda)
ExceptionCode: c0000005 (Access violation)
ExceptionFlags: 00000000
NumberParameters: 2
Parameter[0]: 0000000000000000
Parameter[1]: 000001b1b6e68fd0
Attempt to read from address 000001b1b6e68fd0
CONTEXT:
rax=0000000000000000 rbx=000001b1a02052a0 rcx=0000000000000001
rdx=000001b1b6e68e78 rsi=0000000000000000 rdi=0000000000000004
rip=00007ff94483640a rsp=000000abd43fd690 rbp=0000000000000000
r8=0000000000000000 r9=0000204800173d28 r10=000000abd43fd6c1
r11=0000000000000000 r12=0000204800038f80 r13=0000000000014000
r14=0000000000000001 r15=0000204800173d20
Faulting instruction:
d3d11!CContext::TID3D11DeviceContext_SOSetTargets_<2>+0xda:
00007ff94483640a cmp dword ptr [rdx+158h],eax ds:000001b1b6e68fd0=????????
NTGLOBALFLAG: 2000000
APPLICATION_VERIFIER_LOADED: 1
FAILURE_BUCKET_ID: INVALID_POINTER_READ_AVRF_c0000005_d3d11.dll!CContext::TID3D11DeviceContext_SOSetTargets__2_
STACK_TEXT:
d3d11!CContext::TID3D11DeviceContext_SOSetTargets_<2>+0xda
libglesv2!glStartTilingQCOM+0x32bece (StateManager11::syncTransformFeedbackBuffers)
libglesv2!glStartTilingQCOM+0x32b1f6 (StateManager11::updateState)
libglesv2!glStartTilingQCOM+0x2ff2fe (Context11::drawArrays)
libglesv2!GL_DrawArrays+0x31f (gl::Context::drawArrays)
chrome!... (GPU command buffer dispatch)
The register rdx holds the value 000001b1b6e68e78, which is the dangling ID3D11Buffer* from mBuffers[1]. The D3D11 runtime reads at rdx+0x158 (000001b1b6e68fd0), which falls on a PAGE_NOACCESS guard page placed by the page heap around the freed allocation. This confirms a use-after-free on the released COM object.
Credit
Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.