Critical watch CVSS 9.6 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Dawn
DescriptionUse after free in Dawn
ComponentDawn
Sub-componentDawn
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker516649133
Fix commitbd3e9649dc17 (dawn) +7/-15
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-26 (per vendor advisory)
Fix landed2026-05-27 (public git)
Disclosed2026-06-30
Patch gap34 days — fix public in source before the advisory

Background

`CommandAllocator`
A Dawn bump-pointer allocator that hands out contiguous command records from a chain of heap blocks tracked by mCurrentPtr, mEndPtr, and mBlocks, appending a new block when the current one runs out of space.
`new (std::nothrow)` / `AllocNoThrow`
Non-throwing allocation forms that return nullptr on failure instead of raising std::bad_alloc, so every caller must explicitly detect and fatally handle the nullptr.
`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 be 0, 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.

Key insight
The single core mistake was using non-throwing allocation whose 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

  1. Drive allocator to OOM A WebGPU client submits command sequences under memory pressure so that Allocate’s slow path reaches GetNewBlock when the process is out of memory.
  2. Non-throwing allocation returns null new (std::nothrow) char[] yields nullptr; GetNewBlock returns false (leaving mCurrentPtr/mEndPtr/mBlocks unchanged), and Allocate propagates a nullptr command pointer.
  3. Zero-byte write hides the failure A size-0 AdditionalData write through the nullptr performs no memory access and does not crash, so the invalid pointer is silently recorded into the command stream instead of aborting.
  4. 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).
  5. 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

The advisory classifies this as a critical use-after-free in Dawn (not observed in the wild, not KEV), though the patched mechanism is an unhandled allocation failure that embeds an invalid/null command pointer rather than a freed-object reuse. A client able to drive 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

FunctionChangeNotes
if
src/dawn/wire/ChunkedCommandSerializer.h
modified

Files Changed

  • src/dawn/native/CommandAllocator.cpp
  • src/dawn/native/CommandAllocator.h
  • src/dawn/wire/ChunkedCommandSerializer.h

Audit Directions

  • Non-throwing allocation with unchecked null
    Grep for new (std::nothrow), AllocNoThrow, and similar forms whose returned nullptr flows to a caller that does not immediately treat it as fatal, especially where a subsequent size-0 or length-0 operation would not fault on the null pointer.
  • Zero-length writes as failure maskers
    Review write/copy helpers that accept a size of 0; a 0-byte operation through an invalid pointer succeeds silently and can embed that pointer into persistent state that faults later.
  • Silent drops on allocation failure
    Audit serializers and command-recording paths that return early on allocation failure, since dropping one record can desynchronize a length-prefixed or streamed protocol and corrupt all following commands.
From bd3e9649dc17a7e0cd904ce0ca37fbb625d637ca Mon Sep 17 00:00:00 2001
From: Corentin Wallez <cwallez@chromium.org>
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 <cwallez@chromium.org>
Reviewed-by: Kai Ninomiya <kainino@chromium.org>
---

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

Original Bug Report

reported by vm...@google.com

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.cpp
  • third_party/dawn/src/dawn/native/CommandAllocator.cpp
  • third_party/dawn/src/dawn/native/Commands.cpp
  • third_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:

  1. By shaping the command allocator’s memory layout and triggering a transient OOM condition, the attacker can cause the command stream to desynchronize.
  2. The iterator will then misinterpret subsequent bytes (such as attacker-controlled inline data in a subsequent successful command) as command IDs.
  3. If the iterator interprets a forged command ID such as Command::ClearBuffer, it will invoke its destructor, executing ~ClearBufferCmd(), which resolves to ~Ref<BufferBase>().
  4. This calls Release() on an arbitrary 64-bit pointer. Decrementing a forged reference counter to zero triggers DeleteThis(), causing a controlled virtual call via a fake vtable in the GPU process.

Suggested Steps to Trigger the Potential Vulnerability

  1. Groom the command allocator block to leave less than 20 bytes of space remaining, but enough for a WriteBufferCmd structure.
  2. Induce transient Out-of-Memory pressure in the GPU process to trigger a failure of a 16 KiB host memory allocation.
  3. Call CommandEncoder.writeBuffer with 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.
  4. Release memory pressure and encode subsequent valid commands containing crafted payload bytes representing forged command structures.
  5. 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.

View on issue tracker