Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Dawn
DescriptionUse after free in Dawn
ComponentDawn
Bug ClassUAF
Tracker516649133
Fix commitbd3e9649dc17 (dawn) +7/-15
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

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
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