High patch-now ✔︎ verified CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in GPU
DescriptionUse after free in GPU
ComponentGPU
Sub-componentD3D11
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — factors set by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: none. Citations verified: getSOBuffers 103-121 writes mBuffers[i] only when binding non-null (109-116), no else; bindIndexedBuffer 73-82 never clears mBuffers; getNumSOBuffers returns mBuffers.size() (100); fill via storage->getBuffer (113). Diff adds both nulling paths; commit msg + StaleBufferBinding test confirm stale-deleted-buffer-to-SOSetTargets UAF. Final deref correctly inferred.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Additionally cross-checked: a second, different model independently derived the same bug class, vulnerable function and fix from the commit.
Tracker489791425
Fix commit06e6c6b59454 (angle/angle) +42/-0
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-03-05 · bounty $11000 (per vendor advisory)
Fix landed2026-03-25 (public git)
Disclosed2026-05-19
Patch gap55 days — fix public in source before the advisory

Background

ANGLE D3D11 backend
ANGLE’s translation of OpenGL ES to Direct3D 11, used by Chrome’s GPU process on Windows.
`TransformFeedback11`
the D3D11 implementation of a GL transform-feedback object; its mBuffers vector caches one raw ID3D11Buffer* per indexed buffer slot for stream output.
`getSOBuffers`
builds the buffer array handed to ID3D11DeviceContext::SOSetTargets.
`bindIndexedBuffer`
called when a GL buffer is bound to (or detached from) a transform-feedback slot.

Root Cause Analysis

In the pre-fix TransformFeedback11::getSOBuffers (src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp:103-121), the loop over slots wrote into mBuffers[bindingIdx] only when binding.get() != nullptr (lines 109-116); there was no else branch, so a slot whose GL binding had become null kept whatever ID3D11Buffer* was cached from an earlier draw. Separately, TransformFeedback11::bindIndexedBuffer (lines 73-82) updated mBufferOffsets[index] and marked the object dirty (lines 78-80) but never cleared mBuffers[index]. The invariant is “a cached raw ID3D11Buffer* must not outlive the GL buffer it came from”; mBuffers holds non-owning pointers (filled via storage->getBuffer(...) at line 113), so when a buffer is detached and destroyed, the cached entry dangles. Because getNumSOBuffers returns mBuffers.size() (all slots, line 100) while program validation only checks the slots the program actually uses, a dangling entry in an unused slot would still be passed to SOSetTargets, dereferencing a freed COM object.

The fix clears the stale pointer on both paths: bindIndexedBuffer now sets mBuffers[index] = nullptr; on every rebind, and getSOBuffers adds else { mBuffers[bindingIdx] = nullptr; } so a null GL binding also nulls its cached D3D pointer. This TIGHTENS state tracking so a detached/deleted buffer can no longer leave a live-looking cache entry.

Key insight
mBuffers cached non-owning ID3D11Buffer* entries that were only ever overwritten for non-null bindings, so detaching and deleting a buffer left a dangling pointer that SOSetTargets later used; the fix nulls the entry both when a slot is rebound and when its GL binding is null during synchronization.

Attack Path

  1. Bind a buffer to an unused slot A WebGL2 program binds a transform-feedback buffer to a slot the linked program does not require. (inferred)
  2. Populate the cache A transform-feedback draw runs getSOBuffers, caching that buffer’s ID3D11Buffer* into mBuffers (TransformFeedback11.cpp:113-114).
  3. Delete the buffer The buffer is unbound and deleted; pre-fix bindIndexedBuffer leaves mBuffers[index] pointing at the now-freed COM object (:73-82).
  4. Re-draw with transform feedback A second transform-feedback pass passes all mBuffers.size() entries (getNumSOBuffers, :100) to SOSetTargets, dereferencing the stale pointer. (inferred)

Impact Assessment

On Windows with the D3D11 backend, content using the WebGL2 transform-feedback API could drive a use-after-free of a freed ID3D11Buffer COM object in the GPU process; the reporter describes it as deterministic. The advisory rates this high severity and marks it not exploited in the wild. Chrome-side reachability from WebGL2 is described by the reporter but the GL-to-ANGLE call path is not included in this material, so that linkage is (inferred).

Files Changed

  • src/libANGLE/renderer/d3d/d3d11/TransformFeedback11.cpp
  • src/tests/gl_tests/TransformFeedbackTest.cpp

Audit Directions

  • Non-owning cached handles
    Flag caches of raw/.get() pointers (here ID3D11Buffer*) that mirror an external binding and must be invalidated whenever that binding changes or is destroyed.
  • Conditional cache updates
    Audit loops that update a cache only on the “present” branch; the missing else that fails to clear removed entries is the classic stale-pointer bug.
  • Count vs. validated range
    Where the number of elements submitted to an API (getNumSOBuffers -> all slots) exceeds what validation checks (program-used slots only), confirm the unvalidated extra slots cannot carry dangling state.
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),
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 [email protected]

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