CVE-2026-119264
Overview
Background
- Dawn
- Chromium’s WebGPU implementation.
src/dawn/native/is the browser/GPU-process-side native backend that translates WebGPU API calls into backend (e.g. Vulkan) objects. - `CreatePipelineAsyncEvent`
- The event object backing
createRenderPipelineAsync/createComputePipelineAsync. It holds aRef<>to the pipeline being built in itsmPipelinemember while initialization runs asynchronously, then fires a user callback on completion. - `Ref<T>` / teardown
- Dawn’s intrusively ref-counted smart pointer. When the last
Refto a backend pipeline drops, the pipeline is destroyed, which releases the underlying backend (Vulkan) GPU object. Destruction must happen while the device lock is held and while the device/backend objects are still valid. - `InitializeImpl` error case
- Pipeline initialization can fail (validation error or unrecoverable internal error). In that case no usable pipeline exists, but a
Refto the partially-constructed pipeline object still lives inmPipeline. - Device lock
- The mutex that serializes native-side access to device/backend objects. The relevant code releases
mPipelineinside a locked scope; the bug is about when that release happens relative to the lock and the callback.
Root Cause Analysis
The invariant is “the event’s reference to the pipeline (mPipeline) must be dropped deterministically inside the device-locked scope, so the backend (Vulkan) object is torn down while the device is still valid.” In CreatePipelineAsyncEvent::Complete (src/dawn/native/CreatePipelineAsyncEvent.cpp), the old code moved mPipeline into a local pipeline only on the success paths — pipeline = std::move(mPipeline); appeared inside the “ready” branch and inside the cached-pipeline else branch — but the error branch (else if (mError != nullptr)) did not move or clear mPipeline. As the commit message states, “the mPipeline reference may live if an error occurs as part of initializing the pipeline. Because we still hold a reference to the resulting pipeline in the error case, we can run into a teardown race where we reference the Vulkan object when it is no longer valid.” The surviving Ref in mPipeline outlives the locked completion scope, so the pipeline’s final release (and the backend GPU-object teardown it triggers) happens later and off the device lock — racing device teardown and touching a Vulkan object after it is no longer valid: a use-after-free.
The patch makes the member release unconditional and correctly ordered. It removes the two branch-local pipeline = std::move(mPipeline); statements and instead, in the error branch, sets mPipeline = nullptr;, then performs a single pipeline = std::move(mPipeline); after the whole if/else, still inside the locked scope. The result is that in every outcome (success, cache hit, and error) mPipeline is emptied deterministically while the device lock is held, so the last reference drops at a well-defined point and the Vulkan object is destroyed while still valid. The test changes confirm the intent: they capture the raw pointer, pass the Ref<PipelineMock> by std::move so the last reference behaves as in production, and add InSequence + EXPECT_CALL(..., DestroyImpl).Times(1) before ProcessEvents() to assert the pipeline is destroyed at the right time. The advisory labels this generic “Memory corruption”; the diff substantiates specifically a use-after-free / teardown race on the backend pipeline object, so this RCA classifies it as UAF.
Ref to the pipeline only on success paths, so on an initialization error the reference outlived the locked completion scope and the backend (Vulkan) object was torn down later, off-lock, after it was no longer valid — a use-after-free.Attack Path
- Web entry
Attacker-controlled JavaScript on a page with WebGPU access calls
device.createRenderPipelineAsync()/createComputePipelineAsync()(inferred web reachability; the diff is backend code and tests). - Force init failure
Supply a pipeline descriptor that makes
InitializeImplreturn an error (a validation error or an unrecoverable internal error), so the completion takes theelse if (mError != nullptr)branch. - Leak the Ref
Because that branch never cleared
mPipeline, aRefto the failed pipeline survives past the locked completion scope. - Race teardown Drive device/pipeline teardown (e.g. drop the device, or let process-event ordering drop the surviving reference) so the pipeline’s final release and its Vulkan-object destruction occur after the device/backend has been invalidated.
- Use-after-free The late destruction dereferences the no-longer-valid Vulkan object. Primitive: use-after-free of a GPU backend object in the GPU/browser-side process, the basis for memory corruption (the diff proves the dangling-reference lifetime bug; full exploit strength is inferred).
Impact Assessment
Ref released off the device lock during teardown — a memory-corruption primitive consistent with the advisory’s High rating. The “race” framing means the window depends on event-processing and device-teardown ordering (a reliability gate), and the concrete exploit beyond the dangling reference is not shown by the diff (inferred). This is a genuine memory-safety bug, not a crash-only DoS.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/dawn/native/CreatePipelineAsyncEvent.cpp |
modified |
Files Changed
src/dawn/native/CreatePipelineAsyncEvent.cppsrc/dawn/tests/unittests/native/CreatePipelineAsyncEventTests.cpp
Audit Directions
- Other async/event completion paths in DawnReview every
...AsyncEvent/ callback-completion site that holds aRefto a backend object across an async boundary; confirm the member is released on all branches (success, cache, and error) while the device lock is held, not just the success path. - Error-branch resource releaseGrep
src/dawn/native/for members moved/cleared only inside success branches ofif/else if (mError ...)completion logic; the same “error path forgets to release” pattern may recur. - Lock-scoped destructionAudit backend-object destructors (Vulkan
DestroyImpland peers) for the assumption that they run under the device lock while the device is valid; anyRefthat can outlive that scope is a candidate for the same teardown race. - Device-drop orderingExamine the interaction between
ProcessEvents, device drop, and pending creation events to find other objects whose final release can be deferred past device invalidation.
Patch
From 2afd6c8a512fed7956c8d2579e10c2209b23960d Mon Sep 17 00:00:00 2001 From: Lokbondo Kung <[email protected]> Date: Wed, 21 Oct 2026 00:17:20 -0700 Subject: [PATCH] [security] Make sure that mPipeline in CreatePipelineEvent is cleared. - As per the bug below, the mPipeline reference may live if an error occurs as a part of initializing the pipeline. Because we still hold a reference to the resulting pipeline in the error case, we can run into a teardown race where we reference the Vulkan object when it is no longer valid. - This change just ensures that we always move the member variable out after the Complete call while holding the device lock. - Update existing tests to verify the fix. Note that the crucial change to the test is to pass Ref<PipelineMock> with std::move so that the last reference is dropped the way it would be in actual production. Bug: 575150692 Change-Id: Id1497f9fde96595bdba3b675415d2f07bc709a73 Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/352215 Commit-Queue: Corentin Wallez <[email protected]> Auto-Submit: Loko Kung <[email protected]> Reviewed-by: Corentin Wallez <[email protected]> --- diff --git a/src/dawn/native/CreatePipelineAsyncEvent.cpp b/src/dawn/native/CreatePipelineAsyncEvent.cpp index 8722e2e..2628fa5 100644 --- a/src/dawn/native/CreatePipelineAsyncEvent.cpp +++ b/src/dawn/native/CreatePipelineAsyncEvent.cpp @@ -201,8 +201,8 @@ const std::string& label = mPipeline->GetLabel(); mPipeline = PipelineType::MakeError(device.Get(), label.c_str()); } - pipeline = std::move(mPipeline); } else if (mError != nullptr) { + mPipeline = nullptr; switch (mError->GetType()) { case InternalErrorType::Validation: status = WGPUCreatePipelineAsyncStatus_ValidationError; @@ -214,8 +214,8 @@ message = mError->GetFormattedMessage(); } else { AddOrGetCachedPipeline(); - pipeline = std::move(mPipeline); } + pipeline = std::move(mPipeline); } if (mCallback) { diff --git a/src/dawn/tests/unittests/native/CreatePipelineAsyncEventTests.cpp b/src/dawn/tests/unittests/native/CreatePipelineAsyncEventTests.cpp index b229537..0f21f69 100644 --- a/src/dawn/tests/unittests/native/CreatePipelineAsyncEventTests.cpp +++ b/src/dawn/tests/unittests/native/CreatePipelineAsyncEventTests.cpp @@ -48,9 +48,12 @@ namespace { using ::testing::_; +using ::testing::ByMove; using ::testing::HasSubstr; +using ::testing::InSequence; using ::testing::IsNull; using ::testing::MockCppCallback; +using ::testing::Return; using MockComputePipelineAsyncCallback = MockCppCallback< void (*)(wgpu::CreatePipelineAsyncStatus, wgpu::ComputePipeline, wgpu::StringView)>; @@ -182,13 +185,15 @@ desc.depthStencil = &ds; Ref<RenderPipelineMock> renderPipelineMock = RenderPipelineMock::Create(mDeviceMock, FromCppAPI(&desc)); + RenderPipelineMock* renderPipelineMockPtr = renderPipelineMock.Get(); std::unique_ptr<UnknownError> err = DAWN_VALIDATION_ERROR(kErrorMessage); - ON_CALL(*renderPipelineMock.Get(), InitializeImpl) - .WillByDefault(testing::Return(testing::ByMove(std::move(err)))); + ON_CALL(*renderPipelineMockPtr, InitializeImpl).WillByDefault(Return(ByMove(std::move(err)))); ON_CALL(*mDeviceMock.get(), CreateUninitializedRenderPipelineImpl) - .WillByDefault(testing::Return(testing::ByMove(renderPipelineMock))); + .WillByDefault(Return(ByMove(std::move(renderPipelineMock)))); + InSequence s; + EXPECT_CALL(*renderPipelineMockPtr, DestroyImpl).Times(1); EXPECT_CALL(mockRenderPipelineCb, Call(wgpu::CreatePipelineAsyncStatus::ValidationError, IsNull(), HasSubstr(kErrorMessage))) .Times(1); @@ -196,8 +201,6 @@ device.CreateRenderPipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents, mockRenderPipelineCb.Callback()); ProcessEvents(); - - EXPECT_CALL(*renderPipelineMock.Get(), DestroyImpl).Times(1); } // Verify CreateComputePipelineAsync and the internal CreateComputePipelineAsyncEvent behavior @@ -213,13 +216,15 @@ desc.depthStencil = &ds; Ref<RenderPipelineMock> renderPipelineMock = RenderPipelineMock::Create(mDeviceMock, FromCppAPI(&desc)); + RenderPipelineMock* renderPipelineMockPtr = renderPipelineMock.Get(); std::unique_ptr<UnknownError> err = DAWN_MAKE_UNRECOVERABLE_ERROR(std::string(kErrorMessage)); - ON_CALL(*renderPipelineMock.Get(), InitializeImpl) - .WillByDefault(testing::Return(testing::ByMove(std::move(err)))); + ON_CALL(*renderPipelineMockPtr, InitializeImpl).WillByDefault(Return(ByMove(std::move(err)))); ON_CALL(*mDeviceMock.get(), CreateUninitializedRenderPipelineImpl) - .WillByDefault(testing::Return(testing::ByMove(renderPipelineMock))); + .WillByDefault(Return(ByMove(std::move(renderPipelineMock)))); + InSequence s; + EXPECT_CALL(*renderPipelineMockPtr, DestroyImpl).Times(1); EXPECT_CALL(mockRenderPipelineCb, Call(wgpu::CreatePipelineAsyncStatus::InternalError, IsNull(), HasSubstr(kErrorMessage))) .Times(1); @@ -227,8 +232,6 @@ device.CreateRenderPipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents, mockRenderPipelineCb.Callback()); ProcessEvents(); - - EXPECT_CALL(*renderPipelineMock.Get(), DestroyImpl).Times(1); } // Test that a long async task's execution won't extend to after the device is dropped. @@ -269,13 +272,15 @@ desc.compute.module = utils::CreateShaderModule(device, kComputeShader.data()); Ref<ComputePipelineMock> computePipelineMock = ComputePipelineMock::Create(mDeviceMock, FromCppAPI(&desc)); + ComputePipelineMock* computePipelineMockPtr = computePipelineMock.Get(); std::unique_ptr<UnknownError> err = DAWN_VALIDATION_ERROR(kErrorMessage); - ON_CALL(*computePipelineMock.Get(), InitializeImpl) - .WillByDefault(testing::Return(testing::ByMove(std::move(err)))); + ON_CALL(*computePipelineMockPtr, InitializeImpl).WillByDefault(Return(ByMove(std::move(err)))); ON_CALL(*mDeviceMock.get(), CreateUninitializedComputePipelineImpl) - .WillByDefault(testing::Return(testing::ByMove(computePipelineMock))); + .WillByDefault(Return(ByMove(std::move(computePipelineMock)))); + InSequence s; + EXPECT_CALL(*computePipelineMockPtr, DestroyImpl).Times(1); EXPECT_CALL(mockComputePipelineCb, Call(wgpu::CreatePipelineAsyncStatus::ValidationError, IsNull(), HasSubstr(kErrorMessage))) .Times(1); @@ -283,8 +288,6 @@ device.CreateComputePipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents, mockComputePipelineCb.Callback()); ProcessEvents(); - - EXPECT_CALL(*computePipelineMock.Get(), DestroyImpl).Times(1); } // Verify CreateComputePipelineAsync and the internal CreateComputePipelineAsyncEvent behavior @@ -295,13 +298,15 @@ desc.compute.module = utils::CreateShaderModule(device, kComputeShader.data()); Ref<ComputePipelineMock> computePipelineMock = ComputePipelineMock::Create(mDeviceMock, FromCppAPI(&desc)); + ComputePipelineMock* computePipelineMockPtr = computePipelineMock.Get(); std::unique_ptr<UnknownError> err = DAWN_MAKE_UNRECOVERABLE_ERROR(std::string(kErrorMessage)); - ON_CALL(*computePipelineMock.Get(), InitializeImpl) - .WillByDefault(testing::Return(testing::ByMove(std::move(err)))); + ON_CALL(*computePipelineMockPtr, InitializeImpl).WillByDefault(Return(ByMove(std::move(err)))); ON_CALL(*mDeviceMock.get(), CreateUninitializedComputePipelineImpl) - .WillByDefault(testing::Return(testing::ByMove(computePipelineMock))); + .WillByDefault(Return(ByMove(std::move(computePipelineMock)))); + InSequence s; + EXPECT_CALL(*computePipelineMockPtr, DestroyImpl).Times(1); EXPECT_CALL(mockComputePipelineCb, Call(wgpu::CreatePipelineAsyncStatus::InternalError, IsNull(), HasSubstr(kErrorMessage))) .Times(1); @@ -309,8 +314,6 @@ device.CreateComputePipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents, mockComputePipelineCb.Callback()); ProcessEvents(); - - EXPECT_CALL(*computePipelineMock.Get(), DestroyImpl).Times(1); } // Verify that noexcept callbacks work with CreateComputePipelineAsync.
Regression Test / PoC
diff --git a/src/dawn/tests/unittests/native/CreatePipelineAsyncEventTests.cpp b/src/dawn/tests/unittests/native/CreatePipelineAsyncEventTests.cpp
index b229537..0f21f69 100644
--- a/src/dawn/tests/unittests/native/CreatePipelineAsyncEventTests.cpp
+++ b/src/dawn/tests/unittests/native/CreatePipelineAsyncEventTests.cpp
@@ -48,9 +48,12 @@
namespace {
using ::testing::_;
+using ::testing::ByMove;
using ::testing::HasSubstr;
+using ::testing::InSequence;
using ::testing::IsNull;
using ::testing::MockCppCallback;
+using ::testing::Return;
using MockComputePipelineAsyncCallback = MockCppCallback<
void (*)(wgpu::CreatePipelineAsyncStatus, wgpu::ComputePipeline, wgpu::StringView)>;
@@ -182,13 +185,15 @@
desc.depthStencil = &ds;
Ref<RenderPipelineMock> renderPipelineMock =
RenderPipelineMock::Create(mDeviceMock, FromCppAPI(&desc));
+ RenderPipelineMock* renderPipelineMockPtr = renderPipelineMock.Get();
std::unique_ptr<UnknownError> err = DAWN_VALIDATION_ERROR(kErrorMessage);
- ON_CALL(*renderPipelineMock.Get(), InitializeImpl)
- .WillByDefault(testing::Return(testing::ByMove(std::move(err))));
+ ON_CALL(*renderPipelineMockPtr, InitializeImpl).WillByDefault(Return(ByMove(std::move(err))));
ON_CALL(*mDeviceMock.get(), CreateUninitializedRenderPipelineImpl)
- .WillByDefault(testing::Return(testing::ByMove(renderPipelineMock)));
+ .WillByDefault(Return(ByMove(std::move(renderPipelineMock))));
+ InSequence s;
+ EXPECT_CALL(*renderPipelineMockPtr, DestroyImpl).Times(1);
EXPECT_CALL(mockRenderPipelineCb, Call(wgpu::CreatePipelineAsyncStatus::ValidationError,
IsNull(), HasSubstr(kErrorMessage)))
.Times(1);
@@ -196,8 +201,6 @@
device.CreateRenderPipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents,
mockRenderPipelineCb.Callback());
ProcessEvents();
-
- EXPECT_CALL(*renderPipelineMock.Get(), DestroyImpl).Times(1);
}
// Verify CreateComputePipelineAsync and the internal CreateComputePipelineAsyncEvent behavior
@@ -213,13 +216,15 @@
desc.depthStencil = &ds;
Ref<RenderPipelineMock> renderPipelineMock =
RenderPipelineMock::Create(mDeviceMock, FromCppAPI(&desc));
+ RenderPipelineMock* renderPipelineMockPtr = renderPipelineMock.Get();
std::unique_ptr<UnknownError> err = DAWN_MAKE_UNRECOVERABLE_ERROR(std::string(kErrorMessage));
- ON_CALL(*renderPipelineMock.Get(), InitializeImpl)
- .WillByDefault(testing::Return(testing::ByMove(std::move(err))));
+ ON_CALL(*renderPipelineMockPtr, InitializeImpl).WillByDefault(Return(ByMove(std::move(err))));
ON_CALL(*mDeviceMock.get(), CreateUninitializedRenderPipelineImpl)
- .WillByDefault(testing::Return(testing::ByMove(renderPipelineMock)));
+ .WillByDefault(Return(ByMove(std::move(renderPipelineMock))));
+ InSequence s;
+ EXPECT_CALL(*renderPipelineMockPtr, DestroyImpl).Times(1);
EXPECT_CALL(mockRenderPipelineCb, Call(wgpu::CreatePipelineAsyncStatus::InternalError, IsNull(),
HasSubstr(kErrorMessage)))
.Times(1);
@@ -227,8 +232,6 @@
device.CreateRenderPipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents,
mockRenderPipelineCb.Callback());
ProcessEvents();
-
- EXPECT_CALL(*renderPipelineMock.Get(), DestroyImpl).Times(1);
}
// Test that a long async task's execution won't extend to after the device is dropped.
@@ -269,13 +272,15 @@
desc.compute.module = utils::CreateShaderModule(device, kComputeShader.data());
Ref<ComputePipelineMock> computePipelineMock =
ComputePipelineMock::Create(mDeviceMock, FromCppAPI(&desc));
+ ComputePipelineMock* computePipelineMockPtr = computePipelineMock.Get();
std::unique_ptr<UnknownError> err = DAWN_VALIDATION_ERROR(kErrorMessage);
- ON_CALL(*computePipelineMock.Get(), InitializeImpl)
- .WillByDefault(testing::Return(testing::ByMove(std::move(err))));
+ ON_CALL(*computePipelineMockPtr, InitializeImpl).WillByDefault(Return(ByMove(std::move(err))));
ON_CALL(*mDeviceMock.get(), CreateUninitializedComputePipelineImpl)
- .WillByDefault(testing::Return(testing::ByMove(computePipelineMock)));
+ .WillByDefault(Return(ByMove(std::move(computePipelineMock))));
+ InSequence s;
+ EXPECT_CALL(*computePipelineMockPtr, DestroyImpl).Times(1);
EXPECT_CALL(mockComputePipelineCb, Call(wgpu::CreatePipelineAsyncStatus::ValidationError,
IsNull(), HasSubstr(kErrorMessage)))
.Times(1);
@@ -283,8 +288,6 @@
device.CreateComputePipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents,
mockComputePipelineCb.Callback());
ProcessEvents();
-
- EXPECT_CALL(*computePipelineMock.Get(), DestroyImpl).Times(1);
}
// Verify CreateComputePipelineAsync and the internal CreateComputePipelineAsyncEvent behavior
@@ -295,13 +298,15 @@
desc.compute.module = utils::CreateShaderModule(device, kComputeShader.data());
Ref<ComputePipelineMock> computePipelineMock =
ComputePipelineMock::Create(mDeviceMock, FromCppAPI(&desc));
+ ComputePipelineMock* computePipelineMockPtr = computePipelineMock.Get();
std::unique_ptr<UnknownError> err = DAWN_MAKE_UNRECOVERABLE_ERROR(std::string(kErrorMessage));
- ON_CALL(*computePipelineMock.Get(), InitializeImpl)
- .WillByDefault(testing::Return(testing::ByMove(std::move(err))));
+ ON_CALL(*computePipelineMockPtr, InitializeImpl).WillByDefault(Return(ByMove(std::move(err))));
ON_CALL(*mDeviceMock.get(), CreateUninitializedComputePipelineImpl)
- .WillByDefault(testing::Return(testing::ByMove(computePipelineMock)));
+ .WillByDefault(Return(ByMove(std::move(computePipelineMock))));
+ InSequence s;
+ EXPECT_CALL(*computePipelineMockPtr, DestroyImpl).Times(1);
EXPECT_CALL(mockComputePipelineCb, Call(wgpu::CreatePipelineAsyncStatus::InternalError,
IsNull(), HasSubstr(kErrorMessage)))
.Times(1);
@@ -309,8 +314,6 @@
device.CreateComputePipelineAsync(&desc, wgpu::CallbackMode::AllowProcessEvents,
mockComputePipelineCb.Callback());
ProcessEvents();
-
- EXPECT_CALL(*computePipelineMock.Get(), DestroyImpl).Times(1);
}
// Verify that noexcept callbacks work with CreateComputePipelineAsync.