CVE-2026-13875
Overview
Background
- `D3DImageBacking`
- the Windows GPU-service shared-image backing built on a D3D11 texture, shared across D3D11, D3D11on12/D3D12, and Dawn (WebGPU) devices.
- `D3DSharedFence`
- a cross-device GPU synchronization fence; a backing waits on pending fences before granting access so producers’ writes are visible and ordered.
- `GetPendingWaitFences`
- the method that gathers the set of fences an access must wait on, lazily creating and signaling a per-texture-device fence when the backing is used on another device.
- External fence injection
UpdateExternalFencelets a caller supply a fence handle for the backing; an invalid handle can make internal wait/signal operations fail.
Root Cause Analysis
GetPendingWaitFences returned a std::vector of fences, and on internal failure paths (failing to create the D3D11 signal fence, failing WaitD3D11 on a write_fence, or failing IncrementAndSignalD3D11) it returned an empty vector {}. Callers (BeginAccess paths for Dawn, D3D11on12/D3D12, and D3D11) could not distinguish “no synchronization required” from “synchronization failed,” so on failure they iterated an empty list and proceeded to grant access as if no waits were needed, skipping required GPU synchronization. This is a value-vs-error conflation: the empty-list sentinel overloaded a legitimate “nothing to wait on” result with a hard failure.
The fix changes the return type to std::optional<std::vector<...>>, returning std::nullopt on genuine failures while returning an empty vector for the legitimate no-fence case; every caller now checks if (!wait_fences) and fails the access (return nullptr / return false). Additionally, the lazy per-device fence is inserted into d3d11_signaled_fence_map_ only after all create/wait/signal steps succeed (via insert_or_assign at the end) instead of eagerly binding a map slot with operator[], avoiding leaving a partial/invalid fence cached in the map.
std::optional/std::nullopt and makes callers reject access on nullopt.Attack Path
- Create a shared-image backing
A renderer creates a
D3DImageBackingfrom a shared handle (as in theInvalidExternalFencetest). - Inject an invalid fence
The attacker calls
UpdateExternalFencewith a bogus fence handle (e.g. a dummy event handle), so later internal wait/signal operations on it will fail. - Begin access
The attacker begins access on a representation, driving a
BeginAccesspath that callsGetPendingWaitFences. - Failure returns empty list (pre-patch)
An internal wait/signal step fails; pre-patch
GetPendingWaitFencesreturns{}, which the caller reads as “no fences to wait on” and grants access without synchronizing. - Race / stale data Access proceeds without the required GPU synchronization, producing data races and reads of unsynchronized/stale GPU memory, an information leak (specific leaked contents inferred).
Impact Assessment
D3DImageBacking and begin access can cause the GPU process to skip required cross-device synchronization, leading to data races and information leaks of unsynchronized GPU memory contents, as the patch description states. Preconditions are Windows with D3D shared images and D3DSharedFence support plus the ability to call UpdateExternalFence. The advisory classifies this as insufficient validation of untrusted input in GPU; it reports no exploited-in-the-wild status, and any escalation beyond the described races/leaks is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifgpu/command_buffer/service/shared_image/d3d_image_backing.cc |
modified | |
forgpu/command_buffer/service/shared_image/d3d_image_backing.cc |
modified | |
TEST_Pgpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc |
modified |
Files Changed
gpu/command_buffer/service/shared_image/d3d_image_backing.ccgpu/command_buffer/service/shared_image/d3d_image_backing.hgpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc
Audit Directions
- Sentinel-value error conflationAudit APIs that report failure by returning an empty container, zero, or a default value indistinguishable from a valid “nothing here” result; prefer
std::optional/status codes so callers can reject failures. - Skipped synchronization on errorReview GPU access/
BeginAccesspaths where a failed fence create/wait/signal could let access proceed unsynchronized, especially with externally injected fences. - Partial state mutation on failureLook for lazy-initialization patterns using
map[key]that insert a slot before the value is fully built, leaving invalid entries cached when a later step fails.
Patch
From a0683f849d4f6ea24ddfa848d03a9796c6cb5580 Mon Sep 17 00:00:00 2001 From: Sunny Sachanandani <[email protected]> Date: Fri, 15 May 2026 13:36:29 -0700 Subject: [PATCH] [gpu] Fix GPU synchronization bypass in D3DImageBacking GetPendingWaitFences returned an empty list of fences if one of the internal waits failed (e.g., due to an invalid injected fence). Callers interpreted this as no synchronization being required, leading to potential data races and information leaks. This CL changes GetPendingWaitFences to return std::optional and returns std::nullopt on failure. Callers are updated to fail the access if GetPendingWaitFences returns std::nullopt. It also refactors the lazy initialization of the texture device fence to only insert it into the map when everything succeeds, avoiding partial state mutation. Bug: 498721671 Test: D3DImageBackingFactoryTest.InvalidExternalFence Link: https://chromium-review.googlesource.com/id/I2d3ff59eaa59d7a29e484056be70b26b6a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7823350 Reviewed-by: Vasiliy Telezhnikov <[email protected]> Auto-Submit: Sunny Sachanandani <[email protected]> Commit-Queue: Sunny Sachanandani <[email protected]> Cr-Commit-Position: refs/heads/main@{#1631525} --- diff --git a/gpu/command_buffer/service/shared_image/d3d_image_backing.cc b/gpu/command_buffer/service/shared_image/d3d_image_backing.cc index c9746faba..cf9c985 100644 --- a/gpu/command_buffer/service/shared_image/d3d_image_backing.cc +++ b/gpu/command_buffer/service/shared_image/d3d_image_backing.cc @@ -1138,7 +1138,7 @@ device, src_texture); } -std::vector<scoped_refptr<gfx::D3DSharedFence>> +std::optional<std::vector<scoped_refptr<gfx::D3DSharedFence>>> D3DImageBacking::GetPendingWaitFences( const Microsoft::WRL::ComPtr<ID3D11Device>& wait_d3d11_device, const wgpu::Device& wait_dawn_device, @@ -1148,33 +1148,40 @@ // (i.e. scanout cases) means we always need to check for the presence of the // availability fence. if (!use_cross_device_fence_synchronization() && !dcomp_texture_) { - return {}; + return std::vector<scoped_refptr<gfx::D3DSharedFence>>{}; } // Lazily create and signal the D3D11 fence on the texture's original device // if not present and we're using the backing on another device. - auto& texture_device_fence = d3d11_signaled_fence_map_[texture_d3d11_device_]; + auto it = d3d11_signaled_fence_map_.find(texture_d3d11_device_); + scoped_refptr<gfx::D3DSharedFence> texture_device_fence = + it != d3d11_signaled_fence_map_.end() ? it->second : nullptr; + if (wait_d3d11_device != texture_d3d11_device_ && !texture_device_fence) { texture_device_fence = gfx::D3DSharedFence::CreateForD3D11(texture_d3d11_device_); if (!texture_device_fence) { LOG(ERROR) << "Failed to retrieve D3D11 signal fence"; - return {}; + return std::nullopt; } // Make D3D11 device wait for |write_fences_| since we'll replace it below. for (auto& fence : write_fences_) { if (!fence->WaitD3D11(texture_d3d11_device_)) { LOG(ERROR) << "Failed to wait for write fence"; - return {}; + return std::nullopt; } } if (!texture_device_fence->IncrementAndSignalD3D11()) { LOG(ERROR) << "Failed to signal D3D11 signal fence"; - return {}; + return std::nullopt; } // Store it in |write_fences_| so it's waited on for all subsequent access. write_fences_.clear(); write_fences_.insert(texture_device_fence); + + // Insert in the map only when everything succeeds. + d3d11_signaled_fence_map_.insert_or_assign(texture_d3d11_device_, + std::move(texture_device_fence)); } // TODO(crbug.com/335003893): Investigate how to avoid passing any fences back @@ -1299,11 +1306,15 @@ CHECK(shared_texture_memory); // Defer clearing fences until later to handle Dawn failure to import texture. - std::vector<scoped_refptr<gfx::D3DSharedFence>> wait_fences = + auto wait_fences = GetPendingWaitFences(dawn_d3d11_device, device, write_access); + if (!wait_fences) { + LOG(ERROR) << "Failed to get pending wait fences"; + return nullptr; + } std::vector<wgpu::SharedFence> shared_fences; std::vector<uint64_t> signaled_values; - for (auto& wait_fence : wait_fences) { + for (auto& wait_fence : *wait_fences) { // TODO(crbug.com/335003893): Look into caching the wgpu::SharedFence object // in gfx::D3DSharedFence. shared_fences.push_back(CreateDawnSharedFence(device, wait_fence)); @@ -1548,10 +1559,13 @@ auto unwrapped_d3d12_resource = EnsureD3D12Resource(); // Defer clearing fences until later to handle failure to synchronize. - std::vector<scoped_refptr<gfx::D3DSharedFence>> wait_fences = - GetPendingWaitFences(d3d11on12_device, /*dawn_device=*/nullptr, - write_access); - for (auto& wait_fence : wait_fences) { + auto wait_fences = GetPendingWaitFences( + d3d11on12_device, /*dawn_device=*/nullptr, write_access); + if (!wait_fences) { + LOG(ERROR) << "Failed to get pending wait fences"; + return false; + } + for (auto& wait_fence : *wait_fences) { if (!wait_fence->WaitD3D11(d3d11on12_device)) { LOG(ERROR) << "Failed to wait for fence"; return false; @@ -1605,9 +1619,13 @@ } // Defer clearing fences until later to handle D3D11 failure to synchronize. - std::vector<scoped_refptr<gfx::D3DSharedFence>> wait_fences = + auto wait_fences = GetPendingWaitFences(d3d11_device, /*dawn_device=*/nullptr, write_access); - for (auto& wait_fence : wait_fences) { + if (!wait_fences) { + LOG(ERROR) << "Failed to get pending wait fences"; + return false; + } + for (auto& wait_fence : *wait_fences) { if (!wait_fence->WaitD3D11(d3d11_device)) { LOG(ERROR) << "Failed to wait for fence"; return false; diff --git a/gpu/command_buffer/service/shared_image/d3d_image_backing.h b/gpu/command_buffer/service/shared_image/d3d_image_backing.h index 58f5987..4d69281a 100644 --- a/gpu/command_buffer/service/shared_image/d3d_image_backing.h +++ b/gpu/command_buffer/service/shared_image/d3d_image_backing.h @@ -343,7 +343,8 @@ // no-op. Similarly, |wait_dawn_device| can be provided to skip over waits on // fences previously signaled on the same Dawn device which are cached in // |dawn_signaled_fence_map_|. - std::vector<scoped_refptr<gfx::D3DSharedFence>> GetPendingWaitFences( + std::optional<std::vector<scoped_refptr<gfx::D3DSharedFence>>> + GetPendingWaitFences( const Microsoft::WRL::ComPtr<ID3D11Device>& wait_d3d11_device, const wgpu::Device& wait_dawn_device, bool write_access) EXCLUSIVE_LOCKS_REQUIRED(lock_); diff --git a/gpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc b/gpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc index de85bff..235b4e45 100644 --- a/gpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc +++ b/gpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc @@ -1044,6 +1044,87 @@ RunCreateSharedImageFromHandleTest(DXGI_FORMAT_R8G8B8A8_TYPELESS); } +TEST_P(D3DImageBackingFactoryTest, InvalidExternalFence) { + Microsoft::WRL::ComPtr<ID3D11Device> d3d11_device = + shared_image_factory_->GetDeviceForTesting(); + if (!gfx::D3DSharedFence::IsSupported(d3d11_device.Get())) { + GTEST_SKIP(); + } + + const auto format = viz::SinglePlaneFormat::kRGBA_8888; + const gfx::Size size(1, 1); + const auto color_space = gfx::ColorSpace::CreateSRGB(); + const gpu::SharedImageUsageSet usage = + SHARED_IMAGE_USAGE_GLES2_READ | SHARED_IMAGE_USAGE_DISPLAY_READ; + + D3D11_TEXTURE2D_DESC desc; + desc.Width = size.width(); + desc.Height = size.height(); + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.SampleDesc.Quality = 0; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; + desc.CPUAccessFlags = 0; + desc.MiscFlags = + D3D11_RESOURCE_MISC_SHARED_NTHANDLE | D3D11_RESOURCE_MISC_SHARED; + Microsoft::WRL::ComPtr<ID3D11Texture2D> d3d11_texture; + HRESULT hr = d3d11_device->CreateTexture2D(&desc, nullptr, &d3d11_texture); + ASSERT_EQ(hr, S_OK); + + Microsoft::WRL::ComPtr<IDXGIResource1> dxgi_resource; + hr = d3d11_texture.As(&dxgi_resource); + ASSERT_EQ(hr, S_OK); + + HANDLE shared_handle; + hr = dxgi_resource->CreateSharedHandle( + nullptr, DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE, nullptr, + &shared_handle); + ASSERT_EQ(hr, S_OK);
Regression Test / PoC
diff --git a/gpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc b/gpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc
index de85bff..235b4e45 100644
--- a/gpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc
+++ b/gpu/command_buffer/service/shared_image/d3d_image_backing_factory_unittest.cc
@@ -1044,6 +1044,87 @@
RunCreateSharedImageFromHandleTest(DXGI_FORMAT_R8G8B8A8_TYPELESS);
}
+TEST_P(D3DImageBackingFactoryTest, InvalidExternalFence) {
+ Microsoft::WRL::ComPtr<ID3D11Device> d3d11_device =
+ shared_image_factory_->GetDeviceForTesting();
+ if (!gfx::D3DSharedFence::IsSupported(d3d11_device.Get())) {
+ GTEST_SKIP();
+ }
+
+ const auto format = viz::SinglePlaneFormat::kRGBA_8888;
+ const gfx::Size size(1, 1);
+ const auto color_space = gfx::ColorSpace::CreateSRGB();
+ const gpu::SharedImageUsageSet usage =
+ SHARED_IMAGE_USAGE_GLES2_READ | SHARED_IMAGE_USAGE_DISPLAY_READ;
+
+ D3D11_TEXTURE2D_DESC desc;
+ desc.Width = size.width();
+ desc.Height = size.height();
+ desc.MipLevels = 1;
+ desc.ArraySize = 1;
+ desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
+ desc.SampleDesc.Count = 1;
+ desc.SampleDesc.Quality = 0;
+ desc.Usage = D3D11_USAGE_DEFAULT;
+ desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET;
+ desc.CPUAccessFlags = 0;
+ desc.MiscFlags =
+ D3D11_RESOURCE_MISC_SHARED_NTHANDLE | D3D11_RESOURCE_MISC_SHARED;
+ Microsoft::WRL::ComPtr<ID3D11Texture2D> d3d11_texture;
+ HRESULT hr = d3d11_device->CreateTexture2D(&desc, nullptr, &d3d11_texture);
+ ASSERT_EQ(hr, S_OK);
+
+ Microsoft::WRL::ComPtr<IDXGIResource1> dxgi_resource;
+ hr = d3d11_texture.As(&dxgi_resource);
+ ASSERT_EQ(hr, S_OK);
+
+ HANDLE shared_handle;
+ hr = dxgi_resource->CreateSharedHandle(
+ nullptr, DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE, nullptr,
+ &shared_handle);
+ ASSERT_EQ(hr, S_OK);
+
+ gfx::GpuMemoryBufferHandle gmb_handle{
+ gfx::DXGIHandle(base::win::ScopedHandle(shared_handle))};
+
+ auto mailbox = Mailbox::Generate();
+ auto backing = shared_image_factory_->CreateSharedImage(
+ mailbox,
+ SharedImageInfo(format, size, color_space, kTopLeft_GrSurfaceOrigin,
+ kPremul_SkAlphaType, usage, "TestLabel"),
+ /*is_thread_safe=*/false, std::move(gmb_handle));
+ ASSERT_NE(backing, nullptr);
+
+ D3DImageBacking* d3d_backing = static_cast<D3DImageBacking*>(backing.get());
+
+ // Create a dummy event handle to use as an invalid fence handle.
+ HANDLE dummy_handle = ::CreateEvent(nullptr, FALSE, FALSE, nullptr);
+ ASSERT_NE(dummy_handle, nullptr);
+ auto invalid_fence = gfx::D3DSharedFence::CreateFromScopedHandle(
+ base::win::ScopedHandle(dummy_handle), gfx::DXGIHandleToken());
+
+ // Inject the invalid fence.
+ d3d_backing->UpdateExternalFence(invalid_fence);
+
+ std::unique_ptr<SharedImageRepresentationFactoryRef> factory_ref =
+ shared_image_manager_.Register(std::move(backing),
+ memory_type_tracker_.get());
+
+ // Produce a representation and try to BeginAccess.
+ auto gl_representation =
+ shared_image_representation_factory_->ProduceGLTexturePassthrough(
+ mailbox);
+ ASSERT_TRUE(gl_representation);
+
+ // BeginAccess should fail because GetPendingWaitFences will fail on the
+ // invalid fence.
+ std::unique_ptr<GLTexturePassthroughImageRepresentation::ScopedAccess>
+ scoped_access = gl_representation->BeginScopedAccess(
+ GL_SHARED_IMAGE_ACCESS_MODE_READ_CHROMIUM,
+ SharedImageRepresentation::AllowUnclearedAccess::kYes);
+ EXPECT_FALSE(scoped_access);
+}
+
// Tests that writing to a Skia representation of a D3DImageBacking created
// from a shared handle is reflected in a second backing created from the
// same handle.
Original Bug Report
Potential GPU sync bypass in D3DImageBacking leading to info leak
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 security team.
Overview: A compromised renderer can inject an invalid Windows NT handle as a DXGI fence, causing GetPendingWaitFences to fail and return an empty wait list. This bypasses GPU synchronization during cross-device access, allowing WebGPU commands to execute concurrently with initialization. This potential race condition can expose uninitialized, pooled GPU memory.
Affected files:
gpu/command_buffer/service/shared_image/d3d_image_backing.cc
Estimated timestamp from git blame: 2025-05-12
Vulnerability Details
In gpu/command_buffer/service/shared_image/d3d_image_backing.cc, the GetPendingWaitFences method is responsible for returning a list of fences that must be waited on before a SharedImage can be safely accessed on a different device (e.g., transitioning from D3D11 to Dawn/D3D12).
During a cross-device access, GetPendingWaitFences attempts a lazy initialization of a D3D11 fence (lines 1027-1048). As part of this, it iterates over all current write_fences_ and calls WaitD3D11 on each. If WaitD3D11 fails (for instance, because the fence wraps an invalid or non-D3D Windows NT handle), the function logs an error and returns an empty std::vector ({}) at line 1038.
The callers of this function, such as BeginAccessDawn, interpret an empty vector to mean that no synchronization is required. BeginAccessDawn passes fenceCount = 0 to Dawn, executing the GPU commands immediately. Furthermore, BeginAccessDawn subsequently calls BeginAccessCommon(write_access), which unconditionally clears both write_fences_ and read_fences_ (lines 1735-1736). This permanently discards legitimate synchronization primitives.
Potential Attack Steps
Note: These are suggested steps to trigger the vulnerability, as our automated tooling does not currently run or verify Proof of Concept code.
- Compromise Renderer: An attacker gains arbitrary code execution within the sandboxed renderer process.
- Create SharedImage: The renderer requests the creation of a
SharedImagewith usages that require cross-device synchronization (e.g.,SHARED_IMAGE_USAGE_WEBGPU_READ). This texture is backed by newly allocated or pooled D3D memory, which may contain sensitive cross-origin data. - Inject Invalid Fence: The renderer sends a
mojom::DeferredSharedImageRequest::Tag::kRegisterDxgiFenceIPC message to the GPU process. The attacker supplies an arbitrary, non-D3D Windows NT handle (e.g., a standard Event handle) wrapped as aGpuFenceHandle. - Update Fence:
SharedImageStub::OnRegisterDxgiFencewraps this handle without validating it’s a D3D fence. The renderer then sends akUpdateDxgiFenceIPC, causing the invalid fence to be inserted into the backing’swrite_fences_set. - Trigger Cross-Device Access: The renderer initiates a WebGPU read access to the SharedImage (Dawn D3D12 backend). This calls
BeginAccessDawnin the GPU process. - Bypass Synchronization:
BeginAccessDawncallsGetPendingWaitFences. The function attempts to wait on the attacker’s invalid fence viaWaitD3D11.OpenSharedFencefails on the invalid handle, causingGetPendingWaitFencesto return an empty vector. - Data Race and Info Leak: Dawn is instructed to proceed with 0 wait fences. The WebGPU read command executes on the GPU concurrently with, or before, the D3D11 clear command that was meant to zero-initialize the texture. The attacker reads the uninitialized, pooled GPU memory into an attacker-controlled buffer, resulting in a cross-origin information leak.
Suggested Fix
- Safe Failure Handling: Modify
GetPendingWaitFencesand its callers (likeBeginAccessDawnandBeginAccessD3D11) to treat synchronization failures as fatal for the access. IfGetPendingWaitFencesencounters an error, it should explicitly indicate a failure (e.g., returningstd::nulloptinstead of an empty vector), and the caller should abort the access (returningnullptrorfalse) instead of proceeding with no wait fences. - Input Validation: Add validation to
SharedImageStub::OnRegisterDxgiFenceorD3DSharedFence::CreateFromScopedHandleto ensure that handles passed over IPC from the renderer are actually valid D3D shared fences before they are accepted and stored.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
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.