CVE-2026-91746
Overview
Background
- Transfer cache
- a GPU-service-side cache that holds serialized resources (such as decoded images) uploaded by a renderer/client, keyed by a numeric entry ID.
- `ClientImageTransferCacheEntry`
- the client-side object representing an image entry to be uploaded; each instance takes an ID at construction that the GPU service later uses to look the entry up.
- `base::AtomicSequenceNumber` / `base::AtomicSequenceNumberT<T>`
- a thread-safe monotonically increasing counter;
GetNext()returns the current value and increments, and the templated form lets the counter be auint32_tinstead of a signedint. - ID wrap around
- when a fixed-width counter exceeds its maximum representable value it silently rolls back to zero, so subsequent allocations reuse IDs that are already live.
Root Cause Analysis
Every ClientImageTransferCacheEntry obtained its ID from the static ClientImageTransferCacheEntry::GetNextId(), which simply returned s_next_id_.GetNext() on a shared base::AtomicSequenceNumber with no upper-bound check. Because the GPU service transfer cache reuses one ID namespace for all clients in the same process, a client that forces enough entry allocations can drive the counter past its maximum, at which point GetNext() wraps and begins handing out IDs that are still in use — violating the invariant that each live cache entry has a unique key.
The fix introduces GetNextCacheEntryId(), which draws from an explicit base::AtomicSequenceNumberT<uint32_t> and executes CHECK_NE(id, std::numeric_limits<uint32_t>::max()) before returning, so the process deterministically crashes at the boundary instead of silently reissuing a duplicate ID. Both ClientImageTransferCacheEntry constructors were repointed from GetNextId() to GetNextCacheEntryId(), and switching the counter to uint32_t also makes the full 32-bit range usable rather than the signed int range. The change is a TIGHTENING: it adds a guard that previously did not exist and converts a silent ID-collision condition into a controlled abort.
CHECK that aborts before wrap around can occur, keeping every issued ID unique.Attack Path
- Drive entry allocation
A compromised or malicious renderer repeatedly creates image transfer cache entries, each of which calls
GetNextId()and consumes one ID from the shared sequence. - Exhaust the ID space
By allocating enough entries the client advances the counter through its entire range so that
GetNext()wraps back toward previously issued values (the CL notes this is not reachable through normal Chrome usage). - Force ID collision The wrapped counter hands a new entry an ID that is already bound to a live entry in the process-wide transfer cache namespace, breaking the one-ID-per-entry invariant.
- Confuse cache lookups (inferred) With two live entries sharing an ID, GPU-service lookups may resolve to the wrong entry, potentially causing aliasing or type/size confusion between cache entries. (inferred)
- Escalate to memory corruption (inferred) Such a mismatch in the GPU process could be leveraged toward out-of-bounds access or corruption, though the diff itself only demonstrates the duplicate-ID condition. (inferred)
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
image_cc/paint/image_transfer_cache_entry.cc |
modified | |
gainmap_info_cc/paint/image_transfer_cache_entry.cc |
modified |
Files Changed
gpu/command_buffer/service/shared_context_state.cccc/paint/image_transfer_cache_entry.hcc/paint/image_transfer_cache_entry.cc
Audit Directions
- Unbounded ID/handle countersLook for
base::AtomicSequenceNumber/GetNext()(or similar monotonic counters) used to mint IDs or handles without a wrap-around or exhaustion check, especially where IDs key a shared lookup structure. - Cross-client shared namespacesAudit resource pools in the GPU service and other privileged processes where one ID/key space is shared across multiple clients, since a single client can then affect keys other clients rely on.
- Signed-to-full-range mismatchesReview counters typed as signed
intbut consumed asuint32_t(or vice versa), where the assumed range and the wrap point diverge and collisions or overflow can go unnoticed.
Patch
From 863daaa953d823531569d571cd77da02be50e265 Mon Sep 17 00:00:00 2001
From: kylechar <kylechar@chromium.org>
Date: Tue, 08 Sep 2026 11:01:10 -0700
Subject: [PATCH] Check transfer cache entries for wrap around
The GPU service transfer cache uses the same namespace of ids for every
client in the same process, The ID generation had no protection against
wrap around so we could see duplicate IDs if enough are allocated
maliciously. To prevent ID collisions add a CHECK that ensures wrap
around doesn't happen. We don't expect wrap around to happen through
normal usage of Chrome.
This CL moves the ID generation implementation to source file since it's
only used there. It also generates uint32_t instead of int values so the
full uint32_t ID range is used.
Bug: 556260782
Change-Id: I876c5fdd55185a3a2d290abfdb59cb257f8e1196
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8349499
Commit-Queue: Kyle Charbonneau <kylechar@chromium.org>
Reviewed-by: Vladimir Levin <vmpstr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1693922}
---
diff --git a/cc/paint/image_transfer_cache_entry.cc b/cc/paint/image_transfer_cache_entry.cc
index 729563a..648e624 100644
--- a/cc/paint/image_transfer_cache_entry.cc
+++ b/cc/paint/image_transfer_cache_entry.cc
@@ -6,9 +6,11 @@
#include <algorithm>
#include <array>
+#include <limits>
#include <type_traits>
#include <utility>
+#include "base/atomic_sequence_num.h"
#include "base/compiler_specific.h"
#include "base/containers/span.h"
#include "base/functional/callback_helpers.h"
@@ -434,6 +436,13 @@
}
}
+uint32_t GetNextCacheEntryId() {
+ static base::AtomicSequenceNumberT<uint32_t> id_sequence;
+ uint32_t id = id_sequence.GetNext();
+ CHECK_NE(id, std::numeric_limits<uint32_t>::max());
+ return id;
+}
+
} // namespace
size_t NumberOfPlanesForYUVDecodeFormat(YUVDecodeFormat format) {
@@ -496,7 +505,7 @@
sk_sp<SkColorSpace> target_color_space)
: needs_mips_(needs_mips),
target_color_space_(target_color_space),
- id_(GetNextId()),
+ id_(GetNextCacheEntryId()),
image_(image) {
ComputeSize();
}
@@ -507,7 +516,7 @@
const SkGainmapInfo& gainmap_info,
bool needs_mips)
: needs_mips_(needs_mips),
- id_(GetNextId()),
+ id_(GetNextCacheEntryId()),
image_(image),
gainmap_image_(gainmap_image),
gainmap_info_(gainmap_info) {
@@ -516,9 +525,6 @@
ClientImageTransferCacheEntry::~ClientImageTransferCacheEntry() = default;
-// static
-base::AtomicSequenceNumber ClientImageTransferCacheEntry::s_next_id_;
-
uint32_t ClientImageTransferCacheEntry::SerializedSize() const {
return size_;
}
diff --git a/cc/paint/image_transfer_cache_entry.h b/cc/paint/image_transfer_cache_entry.h
index 3e00775..14a8efd 100644
--- a/cc/paint/image_transfer_cache_entry.h
+++ b/cc/paint/image_transfer_cache_entry.h
@@ -12,7 +12,6 @@
#include <optional>
#include <vector>
-#include "base/atomic_sequence_num.h"
#include "base/containers/span.h"
#include "base/memory/raw_ptr.h"
#include "cc/paint/tone_map_util.h"
@@ -96,7 +95,6 @@
uint32_t SerializedSize() const final;
bool Serialize(base::span<uint8_t> data) const final;
- static uint32_t GetNextId() { return s_next_id_.GetNext(); }
bool IsYuv() const {
return image_.yuv_plane_config != SkYUVAInfo::PlaneConfig::kUnknown;
}
@@ -109,7 +107,6 @@
sk_sp<SkColorSpace> target_color_space_;
const uint32_t id_;
uint32_t size_ = 0;
- static base::AtomicSequenceNumber s_next_id_;
Image image_;
diff --git a/gpu/command_buffer/service/shared_context_state.cc b/gpu/command_buffer/service/shared_context_state.cc
index d644576..c723443 100644
--- a/gpu/command_buffer/service/shared_context_state.cc
+++ b/gpu/command_buffer/service/shared_context_state.cc
@@ -4,6 +4,7 @@
#include "gpu/command_buffer/service/shared_context_state.h"
+#include "base/atomic_sequence_num.h"
#include "base/compiler_specific.h"
#include "base/debug/dump_without_crashing.h"
#include "base/immediate_crash.h"