Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in GPU
DescriptionMemory corruption in GPU
ComponentGPU
Sub-componentGLES2
Bug ClassMemory Corruption
Tracker559720179
Fix commitf7e8670b9ce9 (chromium/src) +78/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
TEST_P
gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
modified

Files Changed

  • gpu/command_buffer/service/copy_texture_chromium_mock.h
  • gpu/command_buffer/service/gles2_cmd_decoder.cc
  • gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
  • gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
From f7e8670b9ce9a721207451187e70d25a87ddd630 Mon Sep 17 00:00:00 2001
From: Victor Miura <[email protected]>
Date: Mon, 21 Sep 2026 11:28:19 -0700
Subject: [PATCH] [GLES2] Restrict prefer_draw_to_copy workaround to dest_level 0 on ES2

In the validating command decoder, the prefer_draw_to_copy workaround
upgraded CopySubTextureCHROMIUM from DIRECT_COPY to DIRECT_DRAW without
verifying that dest_level == 0. On WebGL1 / ES2 contexts, DIRECT_DRAW
sets driver-side GL_TEXTURE_BASE_LEVEL on the destination texture, but
RestoreTextureState only restores GL_TEXTURE_BASE_LEVEL on WebGL2 / ES3
contexts. This caused a driver/decoder state desync where mipmap
generation marked unwritten levels as defined and cleared, resulting in
potential uninitialized GPU memory disclosure.

This CL ensures prefer_draw_to_copy only upgrades to DIRECT_DRAW when
dest_level == 0 or when running on a WebGL2/ES3 context.

TAG=agy
CONV=bb0e64a6-6b39-433c-8b05-6d3c2fb0a71f

Bug: 559720179
Change-Id: Ic44b22e6205ffc51280a9bc140f3c1785926c241
Fixed: 559720179
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8433582
Auto-Submit: Victor Miura <[email protected]>
Reviewed-by: Zhenyao Mo <[email protected]>
Commit-Queue: Zhenyao Mo <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1701909}
---
diff --git a/gpu/command_buffer/service/copy_texture_chromium_mock.h b/gpu/command_buffer/service/copy_texture_chromium_mock.h
index 5756ed47..faf4428 100644
--- a/gpu/command_buffer/service/copy_texture_chromium_mock.h
+++ b/gpu/command_buffer/service/copy_texture_chromium_mock.h
@@ -5,9 +5,10 @@
 #ifndef GPU_COMMAND_BUFFER_SERVICE_COPY_TEXTURE_CHROMIUM_MOCK_H_
 #define GPU_COMMAND_BUFFER_SERVICE_COPY_TEXTURE_CHROMIUM_MOCK_H_
 
+#include <optional>
+
 #include "gpu/command_buffer/service/gles2_cmd_copy_tex_image.h"
 #include "gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.h"
-
 #include "testing/gmock/include/gmock/gmock.h"
 
 namespace gpu {
@@ -123,7 +124,16 @@
       bool premultiply_alpha,
       bool unpremultiply_alpha,
       CopyTextureMethod method,
-      CopyTexImageResourceManager* luma_emulation_blitter) override {}
+      CopyTexImageResourceManager* luma_emulation_blitter) override {
+    last_copy_sub_texture_method_ = method;
+  }
+
+  std::optional<CopyTextureMethod> last_copy_sub_texture_method() const {
+    return last_copy_sub_texture_method_;
+  }
+
+ private:
+  std::optional<CopyTextureMethod> last_copy_sub_texture_method_;
 };
 
 }  // namespace gles2
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder.cc b/gpu/command_buffer/service/gles2_cmd_decoder.cc
index cc02dea9..a24707e 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder.cc
@@ -16462,7 +16462,8 @@
 
   // Use DRAW instead of COPY if the workaround is enabled.
   if (method == CopyTextureMethod::DIRECT_COPY &&
-      workarounds().prefer_draw_to_copy) {
+      workarounds().prefer_draw_to_copy &&
+      (dest_level == 0 || feature_info_->IsWebGL2OrES3Context())) {
     method = CopyTextureMethod::DIRECT_DRAW;
   }
 
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
index 541d34d..dd35e761 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
@@ -534,6 +534,10 @@
 
   GLvoid* BufferOffset(unsigned i) { return reinterpret_cast<GLvoid*>(i); }
 
+  MockCopyTextureResourceManager* copy_texture_manager() const {
+    return copy_texture_manager_;
+  }
+
   template <typename Command, typename Result>
   bool IsObjectHelper(GLuint client_id) {
     Result* result = static_cast<Result*>(shared_memory_address_);
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
index 678e5ac..1abfc15 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
@@ -19,6 +19,7 @@
 #include "gpu/command_buffer/common/shared_image_usage.h"
 #include "gpu/command_buffer/service/context_group.h"
 #include "gpu/command_buffer/service/context_state.h"
+#include "gpu/command_buffer/service/copy_texture_chromium_mock.h"
 #include "gpu/command_buffer/service/gl_surface_mock.h"
 #include "gpu/command_buffer/service/gles2_cmd_decoder.h"
 #include "gpu/command_buffer/service/gles2_cmd_decoder_unittest.h"
@@ -4490,6 +4491,65 @@
   EXPECT_TRUE(texture->SafeToRenderFrom());
 }
 
+TEST_P(GLES2DecoderManualInitTest,
+       CopySubTextureCHROMIUMPreferDrawToCopyDestLevel) {
+  InitState init;
+  init.gl_version = "OpenGL ES 2.0";
+  init.has_alpha = true;
+  init.has_depth = true;
+  init.request_alpha = true;
+  init.request_depth = true;
+  gpu::GpuDriverBugWorkarounds workarounds;
+  workarounds.prefer_draw_to_copy = true;
+  InitDecoderWithWorkarounds(init, workarounds);
+  SetupDefaultProgram();
+
+  // Create dest texture with level 0 (4x4) and level 1 (2x2).
+  DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
+  DoTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0,
+               0);
+  DoTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0,
+               0);
+
+  // Create source texture with level 0 (2x2).
+  EXPECT_CALL(*gl_, GenTextures(1, _))
+      .WillOnce(SetArgPointee<1>(kNewServiceId))
+      .RetiresOnSaturation();
+  GenHelper<cmds::GenTexturesImmediate>(kNewClientId);
+  DoBindTexture(GL_TEXTURE_2D, kNewClientId, kNewServiceId);
+  DoTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0,
+               0);
+
+  // Set destination texture level 1 as cleared so ClearTextureLevel is not
+  // needed.
+  TextureRef* dest_texture_ref =
+      group().texture_manager()->GetTexture(client_texture_id_);
+  ASSERT_TRUE(dest_texture_ref != nullptr);
+  group().texture_manager()->SetLevelCleared(dest_texture_ref, GL_TEXTURE_2D, 1,
+                                             true);
+
+  // Set source texture level 0 as cleared.
+  TextureRef* src_texture_ref =
+      group().texture_manager()->GetTexture(kNewClientId);
+  ASSERT_TRUE(src_texture_ref != nullptr);
+  group().texture_manager()->SetLevelCleared(src_texture_ref, GL_TEXTURE_2D, 0,
+                                             true);
+
+  EXPECT_CALL(*gl_, GetError()).WillRepeatedly(Return(GL_NO_ERROR));
+
+  cmds::CopySubTextureCHROMIUM cmd;
+  cmd.Init(kNewClientId /* source_id */, 0 /* source_level */,
+           GL_TEXTURE_2D /* dest_target */, client_texture_id_ /* dest_id */,
+           1 /* dest_level */, 0 /* xoffset */, 0 /* yoffset */, 0 /* x */,
+           0 /* y */, 2 /* width */, 2 /* height */, false /* unpack_flip_y */,
+           false /* unpack_premultiply_alpha */,
+           false /* unpack_unmultiply_alpha */);
+  EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
+  EXPECT_EQ(GL_NO_ERROR, GetGLError());
+  EXPECT_EQ(CopyTextureMethod::DIRECT_COPY,
+            copy_texture_manager()->last_copy_sub_texture_method());
+}
+
 TEST_P(GLES3DecoderTest, ImmutableTextureBaseLevelMaxLevelClamping) {
   GLenum kTarget = GL_TEXTURE_3D;
   GLint kBaseLevel = 1416354905;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
index 541d34d..dd35e761 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.h
@@ -534,6 +534,10 @@
 
   GLvoid* BufferOffset(unsigned i) { return reinterpret_cast<GLvoid*>(i); }
 
+  MockCopyTextureResourceManager* copy_texture_manager() const {
+    return copy_texture_manager_;
+  }
+
   template <typename Command, typename Result>
   bool IsObjectHelper(GLuint client_id) {
     Result* result = static_cast<Result*>(shared_memory_address_);
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
index 678e5ac..1abfc15 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_textures.cc
@@ -19,6 +19,7 @@
 #include "gpu/command_buffer/common/shared_image_usage.h"
 #include "gpu/command_buffer/service/context_group.h"
 #include "gpu/command_buffer/service/context_state.h"
+#include "gpu/command_buffer/service/copy_texture_chromium_mock.h"
 #include "gpu/command_buffer/service/gl_surface_mock.h"
 #include "gpu/command_buffer/service/gles2_cmd_decoder.h"
 #include "gpu/command_buffer/service/gles2_cmd_decoder_unittest.h"
@@ -4490,6 +4491,65 @@
   EXPECT_TRUE(texture->SafeToRenderFrom());
 }
 
+TEST_P(GLES2DecoderManualInitTest,
+       CopySubTextureCHROMIUMPreferDrawToCopyDestLevel) {
+  InitState init;
+  init.gl_version = "OpenGL ES 2.0";
+  init.has_alpha = true;
+  init.has_depth = true;
+  init.request_alpha = true;
+  init.request_depth = true;
+  gpu::GpuDriverBugWorkarounds workarounds;
+  workarounds.prefer_draw_to_copy = true;
+  InitDecoderWithWorkarounds(init, workarounds);
+  SetupDefaultProgram();
+
+  // Create dest texture with level 0 (4x4) and level 1 (2x2).
+  DoBindTexture(GL_TEXTURE_2D, client_texture_id_, kServiceTextureId);
+  DoTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0,
+               0);
+  DoTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0,
+               0);
+
+  // Create source texture with level 0 (2x2).
+  EXPECT_CALL(*gl_, GenTextures(1, _))
+      .WillOnce(SetArgPointee<1>(kNewServiceId))
+      .RetiresOnSaturation();
+  GenHelper<cmds::GenTexturesImmediate>(kNewClientId);
+  DoBindTexture(GL_TEXTURE_2D, kNewClientId, kNewServiceId);
+  DoTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0,
+               0);
+
+  // Set destination texture level 1 as cleared so ClearTextureLevel is not
+  // needed.
+  TextureRef* dest_texture_ref =
+      group().texture_manager()->GetTexture(client_texture_id_);
+  ASSERT_TRUE(dest_texture_ref != nullptr);
+  group().texture_manager()->SetLevelCleared(dest_texture_ref, GL_TEXTURE_2D, 1,
+                                             true);
+
+  // Set source texture level 0 as cleared.
+  TextureRef* src_texture_ref =
+      group().texture_manager()->GetTexture(kNewClientId);
+  ASSERT_TRUE(src_texture_ref != nullptr);
+  group().texture_manager()->SetLevelCleared(src_texture_ref, GL_TEXTURE_2D, 0,
+                                             true);
+
+  EXPECT_CALL(*gl_, GetError()).WillRepeatedly(Return(GL_NO_ERROR));
+
+  cmds::CopySubTextureCHROMIUM cmd;
+  cmd.Init(kNewClientId /* source_id */, 0 /* source_level */,
+           GL_TEXTURE_2D /* dest_target */, client_texture_id_ /* dest_id */,
+           1 /* dest_level */, 0 /* xoffset */, 0 /* yoffset */, 0 /* x */,
+           0 /* y */, 2 /* width */, 2 /* height */, false /* unpack_flip_y */,
+           false /* unpack_premultiply_alpha */,
+           false /* unpack_unmultiply_alpha */);
+  EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
+  EXPECT_EQ(GL_NO_ERROR, GetGLError());
+  EXPECT_EQ(CopyTextureMethod::DIRECT_COPY,
+            copy_texture_manager()->last_copy_sub_texture_method());
+}
+
 TEST_P(GLES3DecoderTest, ImmutableTextureBaseLevelMaxLevelClamping) {
   GLenum kTarget = GL_TEXTURE_3D;
   GLint kBaseLevel = 1416354905;
Loading diff…

Original Bug Report

reported by [email protected]

Uninitialized GPU memory leak via prefer_draw_to_copy BASE_LEVEL desync in GLES2 decoder

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: In the validating GLES2 command decoder, the shipped prefer_draw_to_copy workaround (Android Mali-T8x GPUs, gpu/config/gpu_driver_bug_list.json entry 312) upgrades CopySubTextureCHROMIUM from DIRECT_COPY to DIRECT_DRAW without re-checking the dest_level == 0 invariant that method selection enforces for DIRECT_DRAW. With dest_level > 0, the copy helper sets the destination texture’s driver-side GL_TEXTURE_BASE_LEVEL, and RestoreTextureState never restores it on WebGL1/ES2 client contexts — a permanent driver/decoder state desync. A subsequent generateMipmap then marks a level the driver never wrote as defined and fully cleared, so a draw plus readPixels returns recycled GPU-process memory. Demonstrated end-to-end from pure JavaScript/WebGL1 on an Android device, recovering another WebGL context’s freed texture contents; on Android the GPU process is unsandboxed and shared across all origins.

Affected files:

  • gpu/command_buffer/service/gles2_cmd_decoder.cc:16281-16284 — upgrades DIRECT_COPY→DIRECT_DRAW with no dest_level recheck
  • gpu/command_buffer/service/gles2_cmd_decoder.cc:5281-5284 — RestoreTextureState restores BASE_LEVEL only for WebGL2/ES3 client contexts
  • gpu/command_buffer/service/gles2_cmd_copy_texture_chromium.cc:512-513 — sets driver-side BASE_LEVEL of the client’s texture when dest_level > 0
  • gpu/command_buffer/service/gl_utils.cc:1152-1172 — DIRECT_COPY permitted for any dest level; DIRECT_DRAW requires dest_level == 0
  • gpu/command_buffer/service/gles2_cmd_decoder.cc:5938-5976 — DoGenerateMipmap clears and bookkeeps against the tracked base level
  • gpu/command_buffer/service/texture_manager.cc:776-797 — MarkMipmapsGenerated marks tracked levels base+1..N defined + fully cleared
  • gpu/config/gpu_driver_bug_list.json:1726-1737 — entry 312 ships prefer_draw_to_copy for .*Mali-T8.* on Android

Estimated timestamp from git blame: 2019-09-18

Summary

On Android devices whose GL_RENDERER matches Mali-T8x, shipped workaround entry 312 enables prefer_draw_to_copy. The validating command decoder is the shipped default for Android WebView (android_webview/browser/aw_field_trials.cc:92-94 force-disables DefaultPassthroughCommandDecoder) and serves Chrome-on-Android populations routed off passthrough by the Finch device blocklist and killswitch (ui/gl/gl_features.cc:193-229).

On such a configuration, one CopySubTextureCHROMIUM with dest_level > 0 on a WebGL1 context leaves the destination texture’s driver-side GL_TEXTURE_BASE_LEVEL permanently at dest_level while the decoder tracks base level 0. The client cannot repair it: the ES2 parameter validator rejects GL_TEXTURE_BASE_LEVEL (gpu/command_buffer/service/gles2_cmd_validation_implementation_autogen.h:1079-1092; ES3 validators are enabled only for WebGL2/ES3 contexts, gles2_cmd_decoder.cc:3162-3165), and no other decoder path rewrites it in this sequence.

The desync defeats the decoder’s uninitialized-memory protections: generateMipmap bookkeeping marks the driver’s (never-written) base level as cleared, the lazy pre-draw clear is skipped, and NEAREST sampling + readPixels reads recycled GPU-process allocations. Because the Android GPU process is unsandboxed and shared across all origins, recycled allocations can contain other origins’ texture/canvas/video and compositor memory.

The bug is reachable from plain web content: Blink’s TexImageViaGPU fast path forwards the JS-supplied mip level as the CopySubTextureCHROMIUM dest level with no level restriction (third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc:6213-6253; third_party/blink/renderer/platform/graphics/accelerated_static_bitmap_image.cc:190-198), so texImage2D(TEXTURE_2D, /*level=*/1, ...) with an accelerated source performs the desync step. A compromised renderer can issue CopySubTextureCHROMIUM(dest_level=1) directly.


Root Cause Analysis

  1. Method selection enforces dest_level == 0 for DIRECT_DRAW but not DIRECT_COPY: gpu/command_buffer/service/gl_utils.cc:1152-1172 — the in-code comment states why: on WebGL1/ES2, “DIRECT_DRAW path isn’t available for dest_level > 0”.
    // gl_utils.cc:1152-1172 (abridged)
    if (source_target == GL_TEXTURE_2D && ... source_level == 0 && !flip_y && !premultiply_alpha_change) {
      ...
        return CopyTextureMethod::DIRECT_COPY;   // <-- no dest_level bound
    }
    if (dest_format_color_renderable && dest_level == 0 && dest_target != GL_TEXTURE_CUBE_MAP)
      return CopyTextureMethod::DIRECT_DRAW;     // <-- dest_level == 0 only
    
  2. The workaround upgrade skips the invariant: gpu/command_buffer/service/gles2_cmd_decoder.cc:16281-16284 converts the method after selection with no recheck; CopySubTextureHelper’s WebGL1 validation rejects only source_level > 0 (gles2_cmd_decoder.cc:16153-16158), never dest_level > 0.
    // gles2_cmd_decoder.cc:16280-16284
    // Use DRAW instead of COPY if the workaround is enabled.
    if (method == CopyTextureMethod::DIRECT_COPY &&
        workarounds().prefer_draw_to_copy) {
      method = CopyTextureMethod::DIRECT_DRAW;   // <-- no dest_level == 0 recheck
    }
    
  3. DIRECT_DRAW mutates the client texture’s driver state: the copy helper attaches the client’s dest_id/dest_level to its FBO, and BindFramebufferTexture2D pokes BASE_LEVEL. This succeeds because validating-decoder contexts always run atop a native ES3 context (gpu/command_buffer/service/service_utils.cc:168-171), guarded only by a DCHECK (gles2_cmd_copy_texture_chromium.cc:1414).
    // gles2_cmd_copy_texture_chromium.cc:510-513
    if (level > 0)
      glTexParameteri(binding_target, GL_TEXTURE_BASE_LEVEL, level);  // driver BASE_LEVEL := dest_level
    
  4. The restore is gated on client context type, not driver capability: the only destination restore is RestoreTextureState (gles2_cmd_copy_texture_chromium.cc:1471), which skips BASE_LEVEL for WebGL1/ES2 clients. The mutation is gated on driver IsES3Capable() while the restore is gated on client IsWebGL2OrES3Context() — on WebGL1 the desync is permanent (driver base = dest_level, tracked base = 0).
    // gles2_cmd_decoder.cc:5281-5284
    if (feature_info_->IsWebGL2OrES3Context()) {   // false for WebGL1: BASE_LEVEL never restored
      api()->glTexParameteriFn(target, GL_TEXTURE_BASE_LEVEL,
                               texture->base_level());
    }
    
  5. GenerateMipmap bookkeeping trusts the tracked base level: DoGenerateMipmap (gles2_cmd_decoder.cc:5938-5976) pre-clears tracked level 0 (a no-op when already cleared) and, on GL_NO_ERROR, MarkMipmapsGenerated (texture_manager.cc:776-797) marks tracked levels 1..N defined + fully cleared. Per GLES 3.2 §8.14.4 (/opt/docs/gpu/gles/es_spec_3.2.txt:9722-9726), the driver generates only levels levelbase+1..q and leaves levelbase — its level 1 — unchanged, so a level the driver never wrote is now tracked as initialized.
  6. Draw-time protections consume only tracked state: ClearUnclearedTextures skips the lazy clear because SafeToRenderFrom() is true (gles2_cmd_decoder.cc:9426-9456), and CanRenderWithSampler accepts POT RGBA with NEAREST/NEAREST (texture_manager.cc:677-747), so there is no black-texture substitution. The driver samples from its base level 1 — never-initialized recycled memory — and ReadPixels returns it to the client.

Reproduction

Preconditions: validating command decoder + prefer_draw_to_copy — automatic on Mali-T8x devices (entry 312), where the validating decoder is WebView’s shipped default and serves Finch-blocklisted/killswitched Chrome-on-Android populations. All steps are ordinary WebGL1 API calls from a web page:

  1. Create texD on a WebGL1 canvas: level 0 = 64x64 RGBA red (CPU upload), MIN/MAG filter NEAREST.
  2. Desync: gl.texImage2D(gl.TEXTURE_2D, /*level=*/1, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, otherWebGLCanvas) with UNPACK state chosen so no flip/alpha conversion is needed at the GL level (flipY=true + premultiplyAlpha=true works for a canvas source). Blink TexImageViaGPU issues CopySubTextureCHROMIUM(dest_level=1); the workaround upgrades DIRECT_COPY→DIRECT_DRAW; driver BASE_LEVEL(texD) := 1; the restore is skipped on WebGL1. (A compromised renderer can instead issue CopySubTextureCHROMIUM(..., dest_level=1, flip_y=false, ...) directly.)
  3. Reallocate: texImage2D(TEXTURE_2D, 1, RGBA, S, S, 0, RGBA, UNSIGNED_BYTE, null) with S different from the current level-1 size, forcing a real glTexImage2D(NULL) (avoids the same-size fast path, texture_manager.cc:3435-3443); the driver’s level 1 now holds unspecified recycled contents and the decoder tracks it uncleared.
  4. generateMipmap(TEXTURE_2D) — the decoder marks tracked levels 1..N cleared while the driver, generating from its base level 1, leaves level 1 untouched.
  5. Draw a quad sampling texD (NEAREST/NEAREST) and readPixels.
  6. Repeat 3-5, cycling level-1 sizes, to harvest a fresh recycled allocation each iteration.

Expected: with NEAREST filtering a correct stack must sample base level 0 (red), and any never-written level must read back as zeros after lazy clearing.

Observed (executed on an Android arm64 device, in both Chrome running the validating decoder and WebView running its shipped-default decoder): the step-2 oracle confirms the driver samples level 1 (returns level-1 content where correct GL must return level-0 red), and the leak loop returned 8,704 (Chrome run) and 20,416 (WebView run) pixels of 0xDEADBEEF poison recovered from freed textures of a different WebGL context, through a level the decoder tracks as defined and fully cleared. Negative controls are immune: the validating decoder without the workaround, and the passthrough decoder with it, never establish the desync. The test device’s GL stack is ANGLE/SwiftShader with the Mali-T8x workaround bit set via the production command-line workarounds path (GpuDriverBugList::AppendWorkaroundsFromCommandLine); the desync and cleared-bookkeeping bypass are decoder-side and spec-mandated, so they apply equally on real Mali-T8x hardware where entry 312 sets the bit automatically — only the content of the recycled allocations is driver-specific.

Severity

high — GPU-process info leak of uninitialized/recycled memory, triggered deterministically (no races) from plain web content with no user interaction; on Android the GPU process is unsandboxed and shared across all origins, so leaked allocations are cross-origin (the executed run demonstrably recovered another context’s freed texture data). Exposure is gated to the Mali-T8x population running the validating decoder (all Android WebView on such devices by shipped default; Chrome-on-Android under the Finch passthrough blocklist/killswitch) — a population constraint, not trigger complexity. Not critical: the primitive is a read, not memory corruption.

Suggested Fix Direction

Gate the prefer_draw_to_copy upgrade at gles2_cmd_decoder.cc:16281-16284 on the same invariant method selection enforces, i.e. allow it only when dest_level == 0 or the client is a WebGL2/ES3 context (where RestoreTextureState does restore BASE_LEVEL); a fix of this shape was built and verified on-device to prevent the desync under the identical vulnerable configuration. Alternatively or additionally, make RestoreTextureState restore GL_TEXTURE_BASE_LEVEL whenever the driver is ES3-capable (feature_info_->IsES3Capable()), mirroring the gate under which the copy helper performs the mutation.


POC: Validating decoder: prefer_draw_to_copy upgrades CopySubTextureCHROMIUM DIRECT_COPY→DIRECT_DRAW without dest_level==0 recheck; BASE_LEVEL poke never restored on WebGL1/ES2 contexts → GenerateMipmap cleared-bookkeeping desync → uninitialized GPU memory disclosure

Strategy: web-content Bug Type: uninitialized GPU memory disclosure (CWE-908, logic/state-desync — no ASAN signal by nature)

Pure JavaScript/WebGL1 page — no Chromium source modification, no privileged APIs, no MojoJS. Reproduced end-to-end on a real Android 17 arm64 device (Cuttlefish farm) in two delivery vehicles:

  1. Chrome for Android (ChromePublic.apk) with --use-cmd-decoder=validating (the production decoder for Finch blocklisted/killswitched populations) + --prefer_draw_to_copy.
  2. Android WebView (our SystemWebView64.apk installed as the system WebView provider, driven by the preinstalled org.chromium.webview_shell) with no decoder flag at all — the validating decoder is WebView’s shipped default (aw_field_trials.cc force-disables DefaultPassthroughCommandDecoder)
    • --prefer_draw_to_copy.

--prefer_draw_to_copy enables exactly the production workaround that gpu/config/gpu_driver_bug_list.json entry 312 applies automatically on devices whose GL_RENDERER matches .*Mali-T8.* (Mali-T820/T830/T860/T880). The farm device is a Cuttlefish VM (ANGLE-on-Vulkan/SwiftShader GL), so the flag substitutes for the Mali GPU identity; it goes through the production GpuDriverBugList::AppendWorkaroundsFromCommandLine path and sets the same workarounds().prefer_draw_to_copy bit the JSON entry sets. Nothing about the vulnerable logic is modified.

What the POC does

All steps are ordinary WebGL1 API calls:

  1. texD: texImage2D(L0, 64x64 RGBA, red) — NEAREST/NEAREST filters.
  2. Desync: gl.texImage2D(TEXTURE_2D, /*level=*/1, RGBA, RGBA, UNSIGNED_BYTE, &lt;another WebGL canvas&gt;) — Blink’s TexImageViaGPU fast path issues CopySubTextureCHROMIUM(..., dest_level=1, ...). GetCopyTextureCHROMIUMMethod returns DIRECT_COPY (no dest_level limit); CopySubTextureHelper upgrades it to DIRECT_DRAW because of prefer_draw_to_copy without rechecking dest_level == 0 (gles2_cmd_decoder.cc:16280); the copy helper’s BindFramebufferTexture2D sets driver-side GL_TEXTURE_BASE_LEVEL(texD) = 1; RestoreTextureState skips the BASE_LEVEL restore because the client context is WebGL1 (gles2_cmd_decoder.cc:5281). Driver base level = 1, decoder-tracked base level = 0, and a WebGL1 client can never repair it (GL_TEXTURE_BASE_LEVEL is rejected by the ES2 validator). Oracle: redefine L1 = 32x32 green (CPU data) and draw with NEAREST — a correct stack samples L0 (red); sampling green proves the driver’s base level is 1.
  3. Leak loop: free pattern-filled (DE AD BE EF) textures from a separate WebGL context (poison) → texImage2D(texD, L1, SxS, null) (real driver reallocation; decoder tracks L1 uncleared) → generateMipmap (decoder pre-clears only tracked base L0 — a no-op — and MarkMipmapsGenerated marks tracked L1..L6 defined + fully cleared, while the driver generates from its base level 1 and leaves level 1 untouched) → draw + readPixels (no lazy clear: tracked state says all-cleared; the driver samples its base level 1 = the never-written allocation).

Level-1 realloc sizes cycle {16,8} (and {64,16,32} on a 256x256 texture) — each differs from the size MarkMipmapsGenerated rewrites into tracked L1, so every texImage2D(null) is a genuine glTexImage2D(NULL) reallocation (avoids the same-size fast path, texture_manager.cc:3435), and stays within the mip-slot bound (avoids the oversized-mip workaround path).

Prerequisites

  • out/android-arm64 build: ChromePublic.apk (and optionally SystemWebView64.apk for the WebView variant).
  • Android device (arm64). On a real Mali-T8x device no --prefer_draw_to_copy flag is needed — gpu_driver_bug_list entry 312 applies it automatically.

Reproduction

SERIAL=<device>

# --- Chrome variant ---------------------------------------------------------
adb -s "$SERIAL" install -r -t out/android-arm64/apks/ChromePublic.apk
adb -s "$SERIAL" push poc.html /data/local/tmp/poc.html
adb -s "$SERIAL" shell chmod 644 /data/local/tmp/poc.html
adb -s "$SERIAL" shell 'echo "_ --use-cmd-decoder=validating --prefer_draw_to_copy --ignore-gpu-blocklist --no-first-run --disable-fre --enable-features=LogJsConsoleMessages" > /data/local/tmp/chrome-command-line'
adb -s "$SERIAL" logcat -c
adb -s "$SERIAL" shell am start --activity-clear-task \
    -n org.chromium.chrome/com.google.android.apps.chrome.Main \
    -a android.intent.action.VIEW -d "file:///data/local/tmp/poc.html"
sleep 25
adb -s "$SERIAL" logcat -d | grep POCLOG

# --- WebView variant (validating decoder is the SHIPPED DEFAULT) ------------
# install our WebView as provider (remove preinstalled first: adb root;
# adb remount; rm -rf /product/app/webview; reboot), then:
out/android-arm64/bin/system_webview_apk install
out/android-arm64/bin/system_webview_apk set-webview-provider
adb -s "$SERIAL" shell 'echo "_ --disable-field-trial-config --prefer_draw_to_copy --ignore-gpu-blocklist" > /data/local/tmp/webview-command-line'
adb -s "$SERIAL" logcat -c
adb -s "$SERIAL" shell am start --activity-clear-task \
    -n org.chromium.webview_shell/.WebViewBrowserActivity \
    -a android.intent.action.VIEW -d "file:///data/local/tmp/poc.html"
sleep 25
adb -s "$SERIAL" logcat -d | grep POCLOG

Flag notes (production realism):

  • --prefer_draw_to_copy — substitutes the Mali-T8x GPU identity (shipped gpu_driver_bug_list entry 312) on non-Mali test hardware.
  • --use-cmd-decoder=validating (Chrome variant only) — selects the production validating decoder, which is the default for Android WebView and for Finch-blocklisted/killswitched Chrome-on-Android populations.
  • --disable-field-trial-config (WebView variant) — disables the test-only fieldtrial_testing_config.json (which force-enables the not-yet-launched passthrough-on-WebView experiment on local builds); with it disabled the WebView run uses shipped defaults, and the validating decoder is chosen with no decoder flag.
  • --ignore-gpu-blocklist — environment flag for the farm VM’s software-GL stack; not needed on real Mali hardware.
  • --enable-features=LogJsConsoleMessages — observation only (routes page console.log to logcat; WebView enables this by default).
  • server.py serves the page over HTTP for network-attached setups (python3 server.py 8000 + browse to http://<host>:8000/poc.html); the farm device had no route to the sandbox, so runs used file:// delivery — same renderer/GPU code paths, and the page auto-detects and POSTs results to /report when served over HTTP.

Expected Result

On a vulnerable build, logcat (POCLOG lines) shows:

  1. [control] CPU-upload path oracle: RED (expected) — sanity control.
  2. [web-path] flipY=true premul=true ... oracle=GREEN → DESYNC ESTABLISHED — driver-side base level of the victim texture is 1 while the decoder tracks 0 (in a correct stack this oracle can never be green: with NEAREST filtering, sampling must come from base level 0).
  3. [leak iter N] ... poison-hits=... first-bytes: deadbeef ... — readPixels returns the content of freed allocations of a different GL context through a texture level the decoder has marked defined + fully cleared and that this context never wrote.

On a fixed build (or without the workaround, or on passthrough): every oracle stays RED and the page prints desync NOT established.

Output (actual runs, Android 17 arm64 device)

Chrome + validating decoder + workaround (run-chrome-validating.log):

[env] UNMASKED_RENDERER=ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 16.0.0) (0x0000C0DE)), SwiftShader driver-5.0.0)
[control] CPU-upload path oracle: RED (expected)
[web-path] flipY=true premul=true glError=0x0 oracle=GREEN
DESYNC ESTABLISHED via: web-content texImage2D(level=1, canvas) flipY=true premul=true
driver-side GL_TEXTURE_BASE_LEVEL(texD) == 1, decoder-tracked base_level == 0
[leak iter 3] size=8x8 nonzero=64/64 poison-hits=64 green(old L1 data)=0 first-bytes: deadbeef deadbeef deadbeef deadbeef ...
...
SUMMARY: iterations=52 total-nonzero-uninit-pixels=8889 poison-pattern-hits=8704
RESULT: SUCCESS - recovered content of freed, unrelated GPU allocations through a texture level the decoder believes is cleared

WebView, shipped-default decoder (no --use-cmd-decoder flag), run-webview-default-decoder.log:

[env] UNMASKED_RENDERER=ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device ...) ...)   <- single-ANGLE = validating decoder
DESYNC ESTABLISHED via: web-content texImage2D(level=1, canvas) flipY=true premul=true
SUMMARY: iterations=52 total-nonzero-uninit-pixels=21696 poison-pattern-hits=20416
RESULT: SUCCESS - recovered content of freed, unrelated GPU allocations through a texture level the decoder believes is cleared

Negative controls (bug specificity):

  • run-chrome-validating-no-workaround-control.log — validating decoder without --prefer_draw_to_copy: all oracles RED, no desync.
  • run-chrome-passthrough-control.log — default passthrough decoder with the workaround flag: all oracles RED, no desync (note the ANGLE-on-ANGLE renderer string fingerprinting the passthrough decoder).

Bandaid fix (bandaid-fix.patch) — verified on device

Gate the prefer_draw_to_copy upgrade on the same invariant GetCopyTextureCHROMIUMMethod() enforces for DIRECT_DRAW:

  if (method == CopyTextureMethod::DIRECT_COPY &&
      workarounds().prefer_draw_to_copy &&
      (dest_level == 0 || feature_info_->IsWebGL2OrES3Context())) {
    method = CopyTextureMethod::DIRECT_DRAW;
  }

(dest_level > 0 upgrades stay allowed on WebGL2/ES3 client contexts, where RestoreTextureState does restore GL_TEXTURE_BASE_LEVEL, so the workaround’s crash avoidance is preserved to the maximum safe extent.)

Rebuilt chrome_public_apk with the patch and re-ran the identical vulnerable configuration (run-chrome-fixed.log): all four unpack combos give oracle=RED, desync NOT established — the leak is closed.

Defense-in-depth alternative (not required by the POC): restore GL_TEXTURE_BASE_LEVEL in RestoreTextureState() whenever feature_info_->IsES3Capable() (mirroring the gate under which the copy helper performs the mutation) instead of only for WebGL2/ES3 client contexts.

Files

  • poc.html — the complete POC (pure WebGL1 JavaScript).
  • server.py — optional HTTP server (serves POC, collects /report POSTs).
  • bandaid-fix.patch — minimal fix, verified on device.
  • run-chrome-validating.log — vulnerable run, Chrome + validating + workaround.
  • run-webview-default-decoder.log — vulnerable run, WebView shipped-default decoder + workaround.
  • run-chrome-validating-no-workaround-control.log — negative control (no workaround).
  • run-chrome-passthrough-control.log — negative control (passthrough decoder). (The two control logs were captured with a slightly earlier POC revision that additionally probed a [hook-path] fallback — a polygonOffset no-op on stock builds, oracle=RED; the shipped poc.html omits that probe since the pure web path suffices everywhere.)
  • run-chrome-fixed.log — fix verification run (desync no longer possible).

Notes on fidelity

  • The GLES behavior underlying the leak (mipmap generation replaces only levels base+1..q and “all other mipmap levels, including levelbase, are left unchanged”; glTexImage2D(NULL) leaves “unspecified image contents”; NEAREST sampling reads the driver’s base level) is spec-mandated (GLES 3.2 §8.14.4, §8.5, §8.17), so the desync and cleared-bookkeeping bypass reproduce identically on real Mali-T8x hardware; only the content of the recycled allocations is driver-specific. On this device’s driver stack the recycled memory demonstrably contained another context’s freed texture data.
  • On a real Mali-T8x device the GPU process is unsandboxed and shared by all origins, so the recycled allocations can contain other origins' texture/canvas/video and compositor memory.

Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker