CVE-2026-9894
Overview
Background
- `GLES2DecoderPassthroughImpl`
- the GPU-process command decoder that forwards GLES2 commands straight to the driver via
api()function pointers. - `mapped_buffer_map`
- a map in
PassthroughResourcesrecording currently mapped buffers so a later unmap can copy renderer shared memory into the mapped region. - `CheckErrorCallbackState()`
- returns true when the KHR_debug callback recorded that the preceding GL call produced an error.
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: none; the UAF-write (DoUnmapBuffer memcpy) and GL_OUT_OF_MEMORY driver-free behavior are not in the shown source (analysis hedges as inferred) but are corroborated by the commit message and the added regression test confirming the stale mapped_buffer_map entry.
GLES2DecoderPassthroughImpl::DoBufferData() (gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc:612-630) clears the GL error state, calls api()->glBufferDataFn(target, size, data, usage) at :617, then checks if (CheckErrorCallbackState()) at :618 and returns early at :619 when the call errored. Only on the no-error path does it reach resources_->mapped_buffer_map.erase(bound_buffers_[target]) at :627, which removes the mapping entry because a glBufferData on a mapped buffer implicitly unmaps it (comment at :626). Because the early-return at :619 skips that erase, a glBufferData that fails (per the reporter, most impactfully GL_OUT_OF_MEMORY, where the driver frees the old backing store) leaves a stale mapped_buffer_map entry whose stored pointer now references freed driver memory. A later unmap then writes renderer-controlled data into that freed region (the unmap memcpy/DoUnmapBuffer path is described by the reporter and the commit message but is not in the shown source — (inferred)).
The fix adds resources_->mapped_buffer_map.erase(bound_buffers_[target]); inside the if (CheckErrorCallbackState()) branch (diff at pre-fix :618), so the entry is removed whether or not the call errored, restoring the invariant that a mapping entry never outlives its backing store.
glBufferData succeeded; moving the mapped_buffer_map.erase() into the error branch too ensures no stale mapping survives a failed, buffer-freeing call.Attack Path
- Map a buffer
A compromised renderer binds a buffer and maps it with write access, adding an entry to
mapped_buffer_map. (inferred: map/unmap entry points are not in the shown source.) - Trigger a failing `glBufferData`
It issues
glBufferDatawith a size that makes the driver error (e.g. an out-of-memory size), which under the KHR_debug callback makesCheckErrorCallbackState()return true andDoBufferDatatake the early return at:619without erasing the entry. - Unmap into freed memory A subsequent unmap uses the stale entry’s pointer to copy renderer data into the now-freed driver allocation, a heap use-after-free write. (inferred: the unmap write path is not shown.)
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fgpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc |
modified |
Files Changed
gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.ccgpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc
Audit Directions
- Error-path bookkeepingAudit decoder doers that release or re-point driver resources on success; the identical cleanup must run on the GL-error early-return branch.
- Cross-decoder parityWhen a security fix lands in one decoder (validating vs passthrough), check that the sibling decoder performs the same state teardown.
- Mapped-resource lifetimeReview every operation that can implicitly unmap or free a buffer to confirm the corresponding host-side mapping record is cleared.
Patch
From 13532776ca8af93b5254efd1ac3cd4db62bddd6c Mon Sep 17 00:00:00 2001
From: Shrek Shao <shrekshao@google.com>
Date: Mon, 04 May 2026 13:58:01 -0700
Subject: [PATCH] gpu: Fix UAF in passthrough decoder DoBufferData
GLES2DecoderPassthroughImpl::DoBufferData does not clean up the
mapped_buffer_map entry when glBufferData returns a GL error.
The result is that DoUnmapBuffer later finds a stale entry with
map_ptr pointing to freed driver memory and writes renderer-controlled
data into it.
This CL fixes the issue by erasing the entry from mapped_buffer_map
even if glBufferData returns an error, preventing the potential
heap-use-after-free write.
Bug: 507707838
Change-Id: I51d7eb959a8f11077e05b73e86d16d9fae850047
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7808748
Reviewed-by: Zhenyao Mo <zmo@chromium.org>
Commit-Queue: Shrek Shao <shrekshao@google.com>
Cr-Commit-Position: refs/heads/main@{#1624914}
---
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc
index ceed2b38..ef823b7 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc
@@ -616,6 +616,10 @@
CheckErrorCallbackState();
api()->glBufferDataFn(target, size, data, usage);
if (CheckErrorCallbackState()) {
+ // Calling buffer data on a mapped buffer will implicitly unmap it
+ // (https://registry.khronos.org/OpenGL-Refpages/es3.1/html/glMapBufferRange.xhtml)
+ // Even if it returns an error.
+ resources_->mapped_buffer_map.erase(bound_buffers_[target]);
return error::kNoError;
}
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc
index cf812e2a..39005d9 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc
@@ -691,5 +691,42 @@
EXPECT_EQ(GL_NO_ERROR, GetGLError());
}
+TEST_F(GLES3DecoderPassthroughTest, BufferDataGLErrorLeavesStaleMapEntry) {
+ const GLenum kTarget = GL_ARRAY_BUFFER;
+ const GLsizeiptr kSize = 64;
+ const GLbitfield kAccess = GL_MAP_WRITE_BIT;
+
+ uint32_t result_shm_id = shared_memory_id_;
+ uint32_t result_shm_offset = kSharedMemoryOffset;
+ uint32_t data_shm_id = shared_memory_id_;
+ uint32_t data_shm_offset = kSharedMemoryOffset + sizeof(uint32_t);
+
+ GenHelper<cmds::GenBuffersImmediate>(kClientBufferId);
+ DoBindBuffer(kTarget, kClientBufferId);
+ DoBufferData(kTarget, kSize, nullptr, GL_STREAM_DRAW);
+
+ // Map the buffer
+ auto* result = GetSharedMemoryAs<cmds::MapBufferRange::Result*>();
+ cmds::MapBufferRange map_cmd;
+ map_cmd.Init(kTarget, 0, kSize, kAccess, data_shm_id, data_shm_offset,
+ result_shm_id, result_shm_offset);
+ *result = 0;
+ EXPECT_EQ(error::kNoError, ExecuteCmd(map_cmd));
+ EXPECT_EQ(1u, *result);
+
+ PassthroughResources* passthrough_resources = GetPassthroughResources();
+ EXPECT_NE(passthrough_resources->mapped_buffer_map.find(kClientBufferId),
+ passthrough_resources->mapped_buffer_map.end());
+
+ // Call BufferData with invalid size to trigger GL error
+ cmds::BufferData buffer_data_cmd;
+ buffer_data_cmd.Init(kTarget, -1, 0, 0, GL_STREAM_DRAW);
+ EXPECT_EQ(error::kNoError, ExecuteCmd(buffer_data_cmd));
+
+ // The entry should be erased even after a GL error.
+ EXPECT_EQ(passthrough_resources->mapped_buffer_map.find(kClientBufferId),
+ passthrough_resources->mapped_buffer_map.end());
+}
+
} // namespace gles2
} // namespace gpu
Regression Test / PoC
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc
index cf812e2a..39005d9 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc
@@ -691,5 +691,42 @@
EXPECT_EQ(GL_NO_ERROR, GetGLError());
}
+TEST_F(GLES3DecoderPassthroughTest, BufferDataGLErrorLeavesStaleMapEntry) {
+ const GLenum kTarget = GL_ARRAY_BUFFER;
+ const GLsizeiptr kSize = 64;
+ const GLbitfield kAccess = GL_MAP_WRITE_BIT;
+
+ uint32_t result_shm_id = shared_memory_id_;
+ uint32_t result_shm_offset = kSharedMemoryOffset;
+ uint32_t data_shm_id = shared_memory_id_;
+ uint32_t data_shm_offset = kSharedMemoryOffset + sizeof(uint32_t);
+
+ GenHelper<cmds::GenBuffersImmediate>(kClientBufferId);
+ DoBindBuffer(kTarget, kClientBufferId);
+ DoBufferData(kTarget, kSize, nullptr, GL_STREAM_DRAW);
+
+ // Map the buffer
+ auto* result = GetSharedMemoryAs<cmds::MapBufferRange::Result*>();
+ cmds::MapBufferRange map_cmd;
+ map_cmd.Init(kTarget, 0, kSize, kAccess, data_shm_id, data_shm_offset,
+ result_shm_id, result_shm_offset);
+ *result = 0;
+ EXPECT_EQ(error::kNoError, ExecuteCmd(map_cmd));
+ EXPECT_EQ(1u, *result);
+
+ PassthroughResources* passthrough_resources = GetPassthroughResources();
+ EXPECT_NE(passthrough_resources->mapped_buffer_map.find(kClientBufferId),
+ passthrough_resources->mapped_buffer_map.end());
+
+ // Call BufferData with invalid size to trigger GL error
+ cmds::BufferData buffer_data_cmd;
+ buffer_data_cmd.Init(kTarget, -1, 0, 0, GL_STREAM_DRAW);
+ EXPECT_EQ(error::kNoError, ExecuteCmd(buffer_data_cmd));
+
+ // The entry should be erased even after a GL error.
+ EXPECT_EQ(passthrough_resources->mapped_buffer_map.find(kClientBufferId),
+ passthrough_resources->mapped_buffer_map.end());
+}
+
} // namespace gles2
} // namespace gpu
Original Bug Report
GPU passthrough decoder UAF: DoBufferData GL error path skips mapped_buffer_map.erase()
Report description
GPU passthrough decoder UAF: DoBufferData GL error path skips mapped_buffer_map.erase()
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
The problem
Please describe the technical details of the vulnerability
Vulnerability Description
GLES2DecoderPassthroughImpl::DoBufferData in the GPU passthrough decoder does not clean up the mapped_buffer_map entry when glBufferData returns a GL error. The result is that DoUnmapBuffer later finds a stale entry with map_ptr pointing to freed driver memory and writes renderer-controlled data into it.
The relevant code in gles2_cmd_decoder_passthrough_doers.cc (current HEAD, ~line 618):
CheckErrorCallbackState();
api()->glBufferDataFn(target, size, data, usage);
if (CheckErrorCallbackState()) {
return error::kNoError; // early return, no erase
}
resources_->mapped_buffer_map.erase(bound_buffers_[target]); // not reached on error
CheckErrorCallbackState() works through the KHR_debug callback (PassthroughGLDebugMessageCallback -> OnDebugMessage -> had_error_callback_ = true). Any GL error from glBufferDataFn causes the early return. The most impactful case is GL_OUT_OF_MEMORY, where the driver frees the old backing store and the map_ptr in the remaining mapped_buffer_map entry now points to freed memory.
The same vulnerability was fixed in the validating decoder in commit daeb9ba8d7ce (Bug 498715368) with this comment in buffer_manager.cc:
// SECURITY: ... Clear it here so that UnmapBufferHelper does not memcpy
// renderer-controlled SHM into freed driver memory.
buffer->ClearMapping();
The passthrough decoder was not included in that fix.
Attack Preconditions
Attacker has a compromised renderer process (standard precondition for GPU process bugs). The GPU passthrough decoder must be active, which is the case by default on Windows, macOS, and Linux: ui/gl/features.gni sets enable_validating_command_decoder = is_android, so on desktop builds UsePassthroughCommandDecoder() in gl_utils.cc returns true unconditionally.
Reproduction Steps / POC
I added a regression test to the existing passthrough decoder test suite: gpu/command_buffer/service/gles2_cmd_decoder_passthrough_unittest_buffers.cc
Test name: GLES3DecoderPassthroughTest.BufferDataGLErrorLeavesStaleMapEntry
Steps the test performs:
- Create and bind a buffer, map it with GL_MAP_WRITE_BIT. Confirms the entry is added to mapped_buffer_map.
- Submit a BufferData command with size=-1. This triggers GL_INVALID_VALUE, which fires the KHR_debug callback and causes DoBufferData to take the early-return path. Functionally the same as GL_OUT_OF_MEMORY for this code path.
- Assert that the mapped_buffer_map entry is still present (bug confirmed).
Output from the ASAN build (out/asan/gpu_unittests, macOS arm64, ANGLE null backend):
[ RUN ] GLES3DecoderPassthroughTest.BufferDataGLErrorLeavesStaleMapEntry
[ERROR] GL_INVALID_VALUE: glBufferData: Negative size.
[ OK ] GLES3DecoderPassthroughTest.BufferDataGLErrorLeavesStaleMapEntry (3584 ms)
The EXPECT_NE assertion passes, confirming the stale entry is present after the GL error. On real hardware with GL_OUT_OF_MEMORY the map_ptr would point to freed driver memory, and a subsequent glUnmapBuffer call from the renderer would trigger:
memcpy(map_info.map_ptr, renderer_shm, map_info.size)
That memcpy is a heap-use-after-free write in the GPU process with renderer-controlled content and size.
Fix: add the erase before the early return in DoBufferData:
if (CheckErrorCallbackState()) {
resources_->mapped_buffer_map.erase(bound_buffers_[target]);
return error::kNoError;
}
Impact analysis
A compromised renderer can trigger this by mapping a GPU buffer, then calling glBufferData with a large enough size to cause GL_OUT_OF_MEMORY. The GPU driver frees the old backing store. DoBufferData returns early without erasing the mapped_buffer_map entry. The renderer then calls glUnmapBuffer, which causes the GPU process to write attacker-controlled data into freed driver memory.
This gives an attacker a heap-use-after-free write primitive in the GPU process, with control over the write destination (via timing/heap grooming), the content (shared memory), and the size. The GPU process is a sandboxed process separate from the renderer. Exploiting this primitive to achieve GPU process code execution would constitute a sandbox escape from a compromised renderer.
The bug affects all desktop Chrome users on Windows, macOS, and Linux, since the passthrough decoder is enabled by default on those platforms.
The cause
What version of Chrome have you found the security issue in?
149.0.7815.0 (dev/canary) - confirmed on HEAD commit fc933fae76 (2026-04-28) 147.0.7727.138 (stable) - affected, bug predates current HEAD
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a sandboxed process)
How would you like to be publicly acknowledged for your report?
tohafrit