CVE-2026-14417
Overview
Background
- `CommandAllocator`
- A Dawn bump-pointer allocator that hands out contiguous command records from a chain of heap blocks tracked by
mCurrentPtr,mEndPtr, andmBlocks, appending a new block when the current one runs out of space. - `new (std::nothrow)` / `AllocNoThrow`
- Non-throwing allocation forms that return
nullptron failure instead of raisingstd::bad_alloc, so every caller must explicitly detect and fatally handle thenullptr. - `ChunkedCommandSerializer`
- The Dawn wire component that serializes client-side WebGPU commands into wire buffers for transmission to the GPU service.
- `AdditionalData`
- A per-command trailer written via
Allocate(detail::kAdditionalData, ...), whose size can legitimately be0, meaning a zero-byte write into the returned command pointer.
Root Cause Analysis
In the pre-patch Allocate, the slow path called GetNewBlock, which computed mLastAllocationSize, attempted new (std::nothrow) char[mLastAllocationSize], and — on failure — executed return false before touching mCurrentPtr, mEndPtr, or mBlocks, so allocator state was left fully intact and no cursor was ever stale or partially updated. The actual defect is that Allocate then propagated this failure as a nullptr command pointer, and the size-0 AdditionalData write path consumed that nullptr without an immediate fatal (DAWN_ASSERT-style) check: writing 0 bytes through a null/invalid pointer does not fault, so the invalid pointer was silently embedded into the recorded command stream and only faulted much later when that command was read back or replayed, far from the originating allocation failure. The violated invariant is that a command pointer returned by Allocate must always be a valid, block-backed address before it enters the command stream — a nullptr on out-of-memory broke that invariant without any enforcement. Independently, ChunkedCommandSerializer used AllocNoThrow<char> and simply returned on nullptr, silently dropping a command mid-stream and desynchronizing the wire protocol.
The fix replaces both non-throwing allocations with ordinary throwing new char[] — renaming GetNewBlock to the infallible AppendNewBlock (returning void) — so an out-of-memory condition aborts deterministically via std::bad_alloc rather than producing a null command pointer or a dropped wire command.
nullptr result reached callers that could not enforce it as fatal — specifically the size-0 AdditionalData write, which accepts a null pointer without crashing and buries it in the command stream to fault later. The fix removes the fallible allocation so OOM raises std::bad_alloc and aborts at the point of failure, and no invalid pointer ever enters the stream.Attack Path
- Drive allocator to OOM
A WebGPU client submits command sequences under memory pressure so that
Allocate’s slow path reachesGetNewBlockwhen the process is out of memory. - Non-throwing allocation returns null
new (std::nothrow) char[]yieldsnullptr;GetNewBlockreturnsfalse(leavingmCurrentPtr/mEndPtr/mBlocksunchanged), andAllocatepropagates anullptrcommand pointer. - Zero-byte write hides the failure
A size-
0AdditionalDatawrite through thenullptrperforms no memory access and does not crash, so the invalid pointer is silently recorded into the command stream instead of aborting. - Deferred fault on replay
When the command stream is later read back or replayed, the embedded invalid/null pointer is dereferenced, producing a fault or invalid access far from the original allocation site
(inferred — the diff shows the recording defect, not the downstream dereference). - Escalation
An attacker who can shape heap and memory-pressure conditions turns the invalid-pointer access into a controlled read/write primitive or sandbox escape
(inferred — the trigger is an OOM path and the diff demonstrates no primitive).
Impact Assessment
CommandAllocator or ChunkedCommandSerializer to an allocation-failure path can cause deferred invalid-pointer use in the GPU/Dawn context or desynchronize the wire stream. Any escalation to a read/write primitive, code execution, or sandbox escape is (inferred), since the demonstrated trigger is an out-of-memory condition and the patch shows only deterministic-abort hardening.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/dawn/wire/ChunkedCommandSerializer.h |
modified |
Files Changed
src/dawn/native/CommandAllocator.cppsrc/dawn/native/CommandAllocator.hsrc/dawn/wire/ChunkedCommandSerializer.h
Audit Directions
- Non-throwing allocation with unchecked nullGrep for
new (std::nothrow),AllocNoThrow, and similar forms whose returnednullptrflows to a caller that does not immediately treat it as fatal, especially where a subsequent size-0or length-0operation would not fault on the null pointer. - Zero-length writes as failure maskersReview write/copy helpers that accept a size of
0; a0-byte operation through an invalid pointer succeeds silently and can embed that pointer into persistent state that faults later. - Silent drops on allocation failureAudit serializers and command-recording paths that
returnearly on allocation failure, since dropping one record can desynchronize a length-prefixed or streamed protocol and corrupt all following commands.
Patch
From bd3e9649dc17a7e0cd904ce0ca37fbb625d637ca Mon Sep 17 00:00:00 2001 From: Corentin Wallez <[email protected]> Date: Wed, 27 May 2026 04:00:25 -0700 Subject: [PATCH] [dawn] Remove AllocNoThrow calls without proper error handling. - CommandAllocator had an AllocNoThrow that caused errors not handled all cases: nullptr would be returned, but when writing AdditionalData of size 0, it wouldn't cause an immediate crash and lead to issue much later. - ChunkedCommandSerializer had an AllocNoThrow but just drops the command if it failed, which seems like it could cause issues downstream. Turn it into a regular allocation. Fixed: 516649133 Change-Id: I90d1b19c0838d32b0b47bd1a2d5779967fdc3f25 Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/311135 Commit-Queue: Corentin Wallez <[email protected]> Reviewed-by: Kai Ninomiya <[email protected]> --- diff --git a/src/dawn/native/CommandAllocator.cpp b/src/dawn/native/CommandAllocator.cpp index 0910b04..28d6b6b 100644 --- a/src/dawn/native/CommandAllocator.cpp +++ b/src/dawn/native/CommandAllocator.cpp @@ -214,25 +214,19 @@ return nullptr; } - if (!GetNewBlock(requestedBlockSize)) [[unlikely]] { - return nullptr; - } + AppendNewBlock(requestedBlockSize); return Allocate(commandId, commandSize, commandAlignment); } -bool CommandAllocator::GetNewBlock(size_t minimumSize) { +void CommandAllocator::AppendNewBlock(size_t minimumSize) { // Allocate blocks doubling sizes each time, to a maximum of 16k (or at least minimumSize). mLastAllocationSize = std::max(minimumSize, std::min(mLastAllocationSize * 2, size_t(16384))); - auto block = std::unique_ptr<char[]>(new (std::nothrow) char[mLastAllocationSize]); - if (block == nullptr) [[unlikely]] { - return false; - } + auto block = std::unique_ptr<char[]>(new char[mLastAllocationSize]); mCurrentPtr = AlignPtr(block.get(), alignof(uint32_t)); mEndPtr = DAWN_UNSAFE_TODO(block.get() + mLastAllocationSize); mBlocks.push_back({mLastAllocationSize, std::move(block)}); - return true; } void CommandAllocator::ResetPointers() { diff --git a/src/dawn/native/CommandAllocator.h b/src/dawn/native/CommandAllocator.h index 8229987..53f99d5 100644 --- a/src/dawn/native/CommandAllocator.h +++ b/src/dawn/native/CommandAllocator.h @@ -274,7 +274,7 @@ return Allocate(detail::kAdditionalData, commandSize, commandAlignment); } - bool GetNewBlock(size_t minimumSize); + void AppendNewBlock(size_t minimumSize); void ResetPointers(); @@ -282,7 +282,7 @@ size_t mLastAllocationSize = kDefaultBaseAllocationSize; // Data used for the block range at initialization so that the first call to Allocate sees - // there is not enough space and calls GetNewBlock. This avoids having to special case the + // there is not enough space and calls AppendNewBlock. This avoids having to special case the // initialization in Allocate. uint32_t mPlaceholderSpace[1] = {0}; diff --git a/src/dawn/wire/ChunkedCommandSerializer.h b/src/dawn/wire/ChunkedCommandSerializer.h index bc15d50..1c744a4 100644 --- a/src/dawn/wire/ChunkedCommandSerializer.h +++ b/src/dawn/wire/ChunkedCommandSerializer.h @@ -135,10 +135,8 @@ return; } - auto cmdSpace = std::unique_ptr<char[]>(AllocNoThrow<char>(requiredSize)); - if (!cmdSpace) { - return; - } + auto cmdSpace = std::unique_ptr<char[]>(new char[requiredSize]); + SerializeBuffer serializeBuffer(cmdSpace.get(), requiredSize); WireResult rCmd = SerializeCmd(cmd, requiredSize, &serializeBuffer); WireResult rExts = detail::SerializeCommandExtension(&serializeBuffer, extensions...);
Original Bug Report
Potential command stream desynchronization in CommandEncoder::APIWriteBuffer under transient OOM
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential command stream desynchronization vulnerability has been identified in Dawn’s CommandEncoder::APIWriteBuffer under transient Out-of-Memory (OOM) conditions. When allocating zero-sized inline data fails during block expansion, the kAdditionalData marker is omitted while the encoder reports success. This can lead to desynchronization of the command consumer, causing potential type confusion and controlled virtual calls in the GPU process.
Affected files:
third_party/dawn/src/dawn/native/CommandEncoder.cppthird_party/dawn/src/dawn/native/CommandAllocator.cppthird_party/dawn/src/dawn/native/Commands.cppthird_party/dawn/src/dawn/native/CommandAllocator.h
Estimated timestamp from git blame: 2021-09-21
Summary
A potential security vulnerability exists in Dawn’s command buffer serialization mechanism where command stream desynchronization and subsequent type confusion can occur under transient Out-of-Memory (OOM) conditions. When CommandEncoder::APIWriteBuffer is called with size == 0, it attempts to allocate inline data. If this allocation triggers the allocation of a new block and fails due to OOM, the kAdditionalData marker is omitted, but the encoder fails to propagate the error. During command cleanup, the command iterator desynchronizes, leading to potential type confusion.
Potential Root Cause Analysis
1. Command Encoding (Producer Path)
In CommandEncoder::APIWriteBuffer (third_party/dawn/src/dawn/native/CommandEncoder.cpp):
WriteBufferCmd* cmd = allocator->Allocate<WriteBufferCmd>(Command::WriteBuffer);
cmd->buffer = buffer;
cmd->offset = bufferOffset;
cmd->size = size;
uint8_t* inlinedData = allocator->AllocateData<uint8_t>(size);
DAWN_UNSAFE_TODO(memcpy(inlinedData, data, size));
When size == 0, AllocateData requests 0 bytes, which maps to Allocate(detail::kAdditionalData, 0, 1). If the current block’s remaining size is less than kWorstCaseAdditionalSize (20 bytes), the allocator falls back to AllocateInNewBlock.
Inside AllocateInNewBlock (third_party/dawn/src/dawn/native/CommandAllocator.cpp):
uint32_t* idAlloc = reinterpret_cast<uint32_t*>(mCurrentPtr);
*idAlloc = detail::kEndOfBlock;
...
if (!GetNewBlock(requestedBlockSize)) [[unlikely]] {
return nullptr;
}
If GetNewBlock fails because new(std::nothrow) returns nullptr, AllocateInNewBlock returns nullptr. The kAdditionalData marker is never written. However, APIWriteBuffer does not check if inlinedData is nullptr and simply performs memcpy(nullptr, data, 0) (a safe no-op when size is 0) and returns success.
2. Command Iteration (Consumer Path)
During destruction/cleanup, FreeCommands iterates over the commands. For Command::WriteBuffer, it executes:
case Command::WriteBuffer: {
WriteBufferCmd* write = commands->NextCommand<WriteBufferCmd>();
commands->NextData<uint8_t>(write->size);
write->~WriteBufferCmd();
break;
}
The helper NextData retrieves the marker:
DAWN_FORCE_INLINE void* NextData(size_t dataSize, size_t dataAlignment) {
uint32_t id = 0;
bool hasId = NextCommandId(&id);
DAWN_ASSERT(hasId);
DAWN_ASSERT(id == detail::kAdditionalData);
return NextCommand(dataSize, dataAlignment);
}
In release builds of Chrome, DAWN_ASSERT is compile-time only and has no runtime effect. Because kAdditionalData was never written, NextCommandId reads the first 4 bytes of the next block’s command ID as id, returning true. This advances mCurrentPtr past the next command’s ID. Since write->size is 0, NextCommand(0, 1) does not advance the cursor further, leaving mCurrentPtr pointing 4 bytes into the subsequent command’s structure (desynchronized).
Potential Security Impact
An attacker could potentially exploit this desynchronization to hijack control flow:
- By shaping the command allocator’s memory layout and triggering a transient OOM condition, the attacker can cause the command stream to desynchronize.
- The iterator will then misinterpret subsequent bytes (such as attacker-controlled inline data in a subsequent successful command) as command IDs.
- If the iterator interprets a forged command ID such as
Command::ClearBuffer, it will invoke its destructor, executing~ClearBufferCmd(), which resolves to~Ref<BufferBase>(). - This calls
Release()on an arbitrary 64-bit pointer. Decrementing a forged reference counter to zero triggersDeleteThis(), causing a controlled virtual call via a fake vtable in the GPU process.
Suggested Steps to Trigger the Potential Vulnerability
- Groom the command allocator block to leave less than 20 bytes of space remaining, but enough for a
WriteBufferCmdstructure. - Induce transient Out-of-Memory pressure in the GPU process to trigger a failure of a 16 KiB host memory allocation.
- Call
CommandEncoder.writeBufferwith a size of 0. The fast-path allocation for the command structure succeeds, but the zero-sized inline data allocation fails. The encoder ignores the null return and succeeds. - Release memory pressure and encode subsequent valid commands containing crafted payload bytes representing forged command structures.
- Trigger cleanup of the command encoder (or discard the context), causing the iterator to desynchronize and execute a destructor on the attacker’s forged structure.
Note: Our tooling agent is currently purely analytical and lacks the runtime environment to execute code or produce a working proof-of-concept. These findings and exploitation steps are based entirely on static code analysis.
Suggested Fix
To resolve this issue, we recommend adding an explicit check to skip inline data allocation when size == 0, or validating that inlinedData is not null:
void CommandEncoder::APIWriteBuffer(BufferBase* buffer,
uint64_t bufferOffset,
const uint8_t* data,
uint64_t size) {
mEncodingContext.TryEncode(
this,
[&](CommandAllocator* allocator) -> MaybeError {
if (GetDevice()->IsValidationEnabled()) {
DAWN_TRY(ValidateWriteBuffer(GetDevice(), buffer, bufferOffset, size));
}
WriteBufferCmd* cmd = allocator->Allocate<WriteBufferCmd>(Command::WriteBuffer);
cmd->buffer = buffer;
cmd->offset = bufferOffset;
cmd->size = size;
if (size > 0) {
uint8_t* inlinedData = allocator->AllocateData<uint8_t>(size);
DAWN_INVALID_IF(inlinedData == nullptr, "Failed to allocate inline data for WriteBuffer.");
DAWN_UNSAFE_TODO(memcpy(inlinedData, data, size));
}
mTopLevelBuffers.insert(buffer);
return {};
},
"encoding %s.WriteBuffer(%s, %u, ..., %u).", this, buffer, bufferOffset, size);
}
Evaluated with Chrome root at commit: a2bea94528f4bd6cc57739c43fa3bb890b8367d3
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.