CVE-2026-9894
Overview
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
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