Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in WebGL
DescriptionOut of bounds write in WebGL
ComponentWebGL
Sub-componentGPU
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker559320837
Fix commit594bffac95f7 (chromium/src) +20/-31
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-09-09 (per vendor advisory)
Fix landed2026-09-14 (public git)
Disclosed2026-09-22
Patch gap8 days — fix public in source before the advisory

Background

NPOT (non-power-of-two) texture
a texture whose width or height is not a power of two, detected here via !has_single_bit(width) || !has_single_bit(height).
Host-twiddled layout
an internal, driver-specific memory reordering (“twiddling”) of client pixel data that some GPU drivers apply during a single-call glTexImage2D upload.
`use_tex_sub_image_for_client_data_npot_uploads`
the renamed GPU driver-bug workaround (formerly use_tex_sub_image_for_host_twiddled_npot_uploads) that splits an NPOT client-data upload into a TexImage2D allocation followed by a TexSubImage2D data copy.
Client-data upload
a texture upload sourced from caller-supplied pixel data (args.pixels), as opposed to a null-data allocation or a GPU-to-GPU copy.

Root Cause Analysis

On affected GPUs, certain drivers mishandle a single-call glTexImage2D upload of NPOT client pixel data: while twiddling the data into their internal layout they compute an incorrect destination extent and write out of bounds. Chrome shipped the use_tex_sub_image_for_host_twiddled_npot_uploads workaround to avoid this by allocating with TexImage2D and then copying data with TexSubImage2D, but it gated the workaround behind IsHostTwiddledFormat(internal_format, args.format, args.type), restricting it to two texture formats. That predicate was too narrow: the driver’s faulty twiddling path is triggered by any NPOT client-data upload, not only the two enumerated formats.

As a result, NPOT uploads in every other format still went through the single glTexImage2D call and continued to trigger the driver OOB write.

The fix removes the IsHostTwiddledFormat check so the workaround now applies whenever width/height is NPOT, regardless of internal_format, format, or type. The workaround is correspondingly renamed to use_tex_sub_image_for_client_data_npot_uploads to reflect that it covers all client-data uploads.

Key insight
The vulnerability was a scoping error, not a missing mitigation: the correct TexSubImage2D workaround existed but was gated by a format predicate (IsHostTwiddledFormat) that failed to cover every format the buggy driver twiddling path affects.

Attack Path

  1. Target an affected GPU the victim runs Chrome on a GPU/driver combination that gpu_driver_bug_list.json flags for this workaround.
  2. Choose an unguarded format pick a WebGL texture internal_format/format/type that is NOT one of the two formats IsHostTwiddledFormat matched, so the old workaround stays off.
  3. Issue an NPOT client-data upload call texImage2D with caller-supplied pixels and NPOT width/height (!has_single_bit(width) || !has_single_bit(height)), on a non-immutable texture, driving the single-call glTexImage2D path.
  4. Trigger the driver OOB write the driver’s host-twiddling logic miscomputes the destination extent and writes client-controlled bytes past the allocation.

Impact Assessment

An out-of-bounds write in the GPU process, driven by attacker-controlled WebGL texture dimensions and pixel data, corrupts memory beyond the intended texture allocation. Because the write occurs in the driver during upload with attacker-influenced size and content, it can corrupt adjacent GPU-process state and is a candidate for GPU-process memory corruption leading to sandboxed code execution. Exposure is limited to the specific GPUs/drivers listed in gpu_driver_bug_list.json.

Changed Functions

FunctionChangeNotes
if
gpu/command_buffer/service/texture_manager.cc
modified
GLNPOTClientDataTextureTest
gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc
modified
if
gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc
modified
TEST_P
gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc
modified
for
gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc
modified

Files Changed

  • gpu/BUILD.gn
  • gpu/command_buffer/service/texture_manager.h
  • gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc
  • gpu/config/gpu_driver_bug_list.json
  • gpu/command_buffer/service/texture_manager.cc
  • gpu/config/gpu_workaround_list.txt

Audit Directions

  • Format-gated driver workarounds
    audit other GPU driver-bug mitigations that condition on a format/type predicate (like IsHostTwiddledFormat); if the underlying driver defect is triggered by geometry (e.g. NPOT) rather than format, the predicate under-scopes the fix and leaves formats exposed.
  • Single-call vs. split uploads
    review remaining single-glTexImage2D client-data upload paths in texture_manager.cc for cases where driver-side layout conversion of caller-controlled dimensions can write out of bounds.
  • Workaround coverage in config
    cross-check gpu_driver_bug_list.json and gpu_workaround_list.txt entries against the code guards they enable to confirm the code applies each workaround for the full set of conditions the driver bug requires.
From 594bffac95f78e6e73914b494359a3bbb7561826 Mon Sep 17 00:00:00 2001
From: Ken Russell <[email protected]>
Date: Mon, 14 Sep 2026 12:27:22 -0700
Subject: [PATCH] GPU: apply host-twiddled TexSubImage2D workaround to all formats.

The previous use_tex_sub_image_for_host_twiddled_npot_uploads
workaround is needed for more texture formats, so simply apply it to
all formats on affected GPUs, and rename the workaround to
use_tex_sub_image_for_client_data_npot_uploads .

Bug: 559320837
Change-Id: I179c3479bad4bac06cbd1b6465b6f9229178c13d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8391507
Reviewed-by: Geoff Lang <[email protected]>
Reviewed-by: Zhenyao Mo <[email protected]>
Auto-Submit: Kenneth Russell <[email protected]>
Commit-Queue: Kenneth Russell <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1697432}
---
diff --git a/gpu/command_buffer/service/texture_manager.cc b/gpu/command_buffer/service/texture_manager.cc
index 7032ad4..e8c8adac 100644
--- a/gpu/command_buffer/service/texture_manager.cc
+++ b/gpu/command_buffer/service/texture_manager.cc
@@ -432,13 +432,6 @@
   uint32_t previous_size_;
 };
 
-bool IsHostTwiddledFormat(GLenum internal_format, GLenum format, GLenum type) {
-  return (internal_format == GL_RGB10_A2 && format == GL_RGBA &&
-          type == GL_UNSIGNED_INT_2_10_10_10_REV) ||
-         (internal_format == GL_SRGB8_ALPHA8 && format == GL_RGBA &&
-          type == GL_UNSIGNED_BYTE);
-}
-
 }  // namespace anonymous
 
 DecoderTextureState::DecoderTextureState(
@@ -457,8 +450,8 @@
           workarounds.split_level_0_pbo_full_sub_image_2d),
       upload_oversized_mip_levels_via_unpack_buffer(
           workarounds.upload_oversized_mip_levels_via_unpack_buffer),
-      use_tex_sub_image_for_host_twiddled_npot_uploads(
-          workarounds.use_tex_sub_image_for_host_twiddled_npot_uploads) {}
+      use_tex_sub_image_for_client_data_npot_uploads(
+          workarounds.use_tex_sub_image_for_client_data_npot_uploads) {}
 
 TextureManager::DestructionObserver::DestructionObserver() = default;
 
@@ -3000,21 +2993,18 @@
     uploaded = true;
   }
 
-  GLenum internal_format = 0;
-  GLenum tex_type = 0;
-  texture->GetLevelType(args.target, args.level, &tex_type, &internal_format);
-
   if (uploaded) {
     // Upload was performed by one of the workarounds above.
   } else if (full_image && !texture->IsImmutable() &&
-             !(texture_state
-                   ->use_tex_sub_image_for_host_twiddled_npot_uploads &&
+             !(texture_state->use_tex_sub_image_for_client_data_npot_uploads &&
                args.command_type ==
                    DoTexSubImageArguments::CommandType::kTexSubImage2D &&
                (!std::has_single_bit(static_cast<uint32_t>(args.width)) ||
-                !std::has_single_bit(static_cast<uint32_t>(args.height))) &&
-               IsHostTwiddledFormat(internal_format, args.format, args.type))) {
+                !std::has_single_bit(static_cast<uint32_t>(args.height))))) {
     TRACE_EVENT0("gpu", "FullImage");
+    GLenum internal_format = 0;
+    GLenum tex_type = 0;
+    texture->GetLevelType(args.target, args.level, &tex_type, &internal_format);
     // NOTE: In OpenGL ES 2/3 border is always zero. If that changes we'll need
     // to look it up.
     if (args.command_type ==
@@ -3468,11 +3458,10 @@
                    AdjustTexFormat(feature_info_.get(), args.format), args.type,
                    args.pixels);
     } else {
-      if (texture_state->use_tex_sub_image_for_host_twiddled_npot_uploads &&
+      if (texture_state->use_tex_sub_image_for_client_data_npot_uploads &&
           (args.pixels != nullptr || unpack_buffer_bound) &&
           (!std::has_single_bit(static_cast<uint32_t>(args.width)) ||
-           !std::has_single_bit(static_cast<uint32_t>(args.height))) &&
-          IsHostTwiddledFormat(args.internal_format, args.format, args.type)) {
+           !std::has_single_bit(static_cast<uint32_t>(args.height)))) {
         glTexImage2D(args.target, args.level,
                      AdjustTexInternalFormat(feature_info_.get(),
                                              args.internal_format, args.type),
diff --git a/gpu/command_buffer/service/texture_manager.h b/gpu/command_buffer/service/texture_manager.h
index dd336939..c4be683 100644
--- a/gpu/command_buffer/service/texture_manager.h
+++ b/gpu/command_buffer/service/texture_manager.h
@@ -712,7 +712,7 @@
   bool unpack_overlapping_rows_separately_unpack_buffer;
   bool split_level_0_pbo_full_sub_image_2d;
   bool upload_oversized_mip_levels_via_unpack_buffer;
-  bool use_tex_sub_image_for_host_twiddled_npot_uploads;
+  bool use_tex_sub_image_for_client_data_npot_uploads;
 };
 
 // This class keeps track of the textures and their sizes so we can do NPOT and
diff --git a/gpu/config/gpu_driver_bug_list.json b/gpu/config/gpu_driver_bug_list.json
index b142833..13d82259 100644
--- a/gpu/config/gpu_driver_bug_list.json
+++ b/gpu/config/gpu_driver_bug_list.json
@@ -3893,10 +3893,10 @@
     {
       "id": 494,
       "cr_bugs": [553172761],
-      "description": "For texture formats which would be twiddled on the host CPU, upload via TexSubImage2D instead of TexImage2D.",
+      "description": "Upload non-power-of-two textures via TexSubImage2D instead of TexImage2D when uploading client-side data.",
       "gl_vendor": "Imagination.*",
       "features": [
-        "use_tex_sub_image_for_host_twiddled_npot_uploads"
+        "use_tex_sub_image_for_client_data_npot_uploads"
       ]
     },
     {
diff --git a/gpu/config/gpu_workaround_list.txt b/gpu/config/gpu_workaround_list.txt
index 68ffd77..e57a0c2 100644
--- a/gpu/config/gpu_workaround_list.txt
+++ b/gpu/config/gpu_workaround_list.txt
@@ -142,7 +142,7 @@
 use_g22_topleft_color_space_for_yuv_vp
 use_gpu_driver_workaround_for_testing
 use_non_zero_size_for_client_side_stream_buffers
-use_tex_sub_image_for_host_twiddled_npot_uploads
+use_tex_sub_image_for_client_data_npot_uploads
 use_virtualized_gl_contexts
 validate_max_per_stage_uniform_blocks_at_compile_time
 wake_up_gpu_before_drawing
diff --git a/gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc b/gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc
new file mode 100644
index 0000000..7e22791
--- /dev/null
+++ b/gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc
@@ -0,0 +1,263 @@
+// 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 <GLES3/gl3.h>
+#include <stdint.h>
+
+#include <vector>
+
+#include "gpu/command_buffer/tests/gl_manager.h"
+#include "gpu/command_buffer/tests/gl_test_utils.h"
+#include "gpu/config/gpu_driver_bug_workarounds.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace gpu {
+
+namespace {
+
+void BlitTexture(GLuint program) {
+  glUseProgram(program);
+  const GLuint kVertexPositionAttrib = 0;
+  const GLfloat kQuadVertices[] = {-1.0f, -1.0f, 1.0f,  -1.0f,
+                                   1.0f,  1.0f,  -1.0f, 1.0f};
+  GLuint buffer_id = 0;
+  glGenBuffers(1, &buffer_id);
+  glBindBuffer(GL_ARRAY_BUFFER, buffer_id);
+  glBufferData(GL_ARRAY_BUFFER, sizeof(kQuadVertices), kQuadVertices,
+               GL_STATIC_DRAW);
+
+  glEnableVertexAttribArray(kVertexPositionAttrib);
+  glVertexAttribPointer(kVertexPositionAttrib, 2, GL_FLOAT, GL_FALSE, 0, 0);
+
+  GLuint sampler_handle = glGetUniformLocation(program, "u_sampler");
+  glUniform1i(sampler_handle, 0);
+
+  glDrawArrays(GL_TRIANGLE_FAN, 0, 4);
+  EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+  glDisableVertexAttribArray(kVertexPositionAttrib);
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+  glDeleteBuffers(1, &buffer_id);
+}
+
+}  // namespace
+
+class GLNPOTClientDataTextureTest : public testing::TestWithParam<bool> {
+ protected:
+  void SetUp() override {
+    GLManager::Options options;
+    options.context_type = CONTEXT_TYPE_OPENGLES3;
+    options.size = gfx::Size(256, 256);
+
+    GpuDriverBugWorkarounds workarounds;
+    workarounds.use_tex_sub_image_for_client_data_npot_uploads = GetParam();
+    gl_.InitializeWithWorkarounds(options, workarounds);
+
+    if (!IsApplicable()) {
+      return;
+    }
+
+    gl_.MakeCurrent();
+
+    static const char* kVertexShader =
+        "attribute vec2 a_position;\n"
+        "varying mediump vec2 v_uv;\n"
+        "void main(void) {\n"
+        "  gl_Position = vec4(a_position, 0, 1);\n"
+        "  v_uv = 0.5 * (a_position + vec2(1, 1));\n"
+        "}\n";
+    static const char* kFragmentShader =
+        "precision mediump float;\n"
+        "uniform sampler2D u_sampler;\n"
+        "varying mediump vec2 v_uv;\n"
+        "void main(void) {\n"
+        "  gl_FragColor = texture2D(u_sampler, v_uv);\n"
+        "}\n";
+
+    program_ = GLTestHelper::LoadProgram(kVertexShader, kFragmentShader);
+    ASSERT_NE(0u, program_);
+    glBindAttribLocation(program_, 0, "a_position");
+    glLinkProgram(program_);
+
+    glGenFramebuffers(1, &fbo_);
+  }
+
+  void TearDown() override {
+    if (IsApplicable()) {
+      if (program_) {
+        glDeleteProgram(program_);
+      }
+      if (fbo_) {
+        glDeleteFramebuffers(1, &fbo_);
+      }
+    }
+    gl_.Destroy();
+  }
+
+  bool IsApplicable() const { return gl_.IsInitialized(); }
+
+  GLManager gl_;
+  GLuint program_ = 0;
+  GLuint fbo_ = 0;
+};
+
+INSTANTIATE_TEST_SUITE_P(GLNPOTClientDataTextureTests,
+                         GLNPOTClientDataTextureTest,
+                         ::testing::Bool());
+
+// Test that non-power-of-two uploads of RGB10_A2 with
+// UNSIGNED_INT_2_10_10_10_REV data succeed and verify texture contents.
+TEST_P(GLNPOTClientDataTextureTest, RGB10A2) {
+  if (!IsApplicable()) {
+    return;
+  }
+
+  constexpr GLsizei kWidth = 65;
+  constexpr GLsizei kHeight = 64;
+
+  for (int iter = 0; iter < 50; ++iter) {
+    std::vector<uint32_t> data(kWidth * kHeight);
+    for (GLsizei y = 0; y < kHeight; ++y) {
+      for (GLsizei x = 0; x < kWidth; ++x) {
+        uint32_t r = ((x + iter) % 2 == 0) ? 0x3FF : 0;
+        uint32_t g = ((y + iter) % 2 == 0) ? 0x3FF : 0;
+        uint32_t b = ((x + y + iter) % 2 == 0) ? 0x3FF : 0;
+        uint32_t a = 3;  // 1.0 in 2-bit alpha
+        data[y * kWidth + x] = (r & 0x3FF) | ((g & 0x3FF) << 10) |
+                               ((b & 0x3FF) << 20) | ((a & 0x3) << 30);
+      }
+    }
+
+    GLuint tex = 0;
+    glGenTextures(1, &tex);
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB10_A2, kWidth, kHeight, 0, GL_RGBA,
+                 GL_UNSIGNED_INT_2_10_10_10_REV, data.data());
+    EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+    // Verify texture contents via FBO readback
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+                           tex, 0);
+    EXPECT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE),
+              glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+    std::vector<uint8_t> readback(kWidth * kHeight * 4);
+    glReadPixels(0, 0, kWidth, kHeight, GL_RGBA, GL_UNSIGNED_BYTE,
+                 readback.data());
+    EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+    for (GLsizei y = 0; y < kHeight; ++y) {
+      for (GLsizei x = 0; x < kWidth; ++x) {
+        uint8_t expected_r = ((x + iter) % 2 == 0) ? 255 : 0;
+        uint8_t expected_g = ((y + iter) % 2 == 0) ? 255 : 0;
+        uint8_t expected_b = ((x + y + iter) % 2 == 0) ? 255 : 0;
+        uint8_t expected_a = 255;
+        size_t idx = (y * kWidth + x) * 4;
+        EXPECT_EQ(readback[idx], expected_r)
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[idx + 1], expected_g)
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[idx + 2], expected_b)
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[idx + 3], expected_a)
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+      }
+    }
+
+    // Also verify sampling via BlitTexture
+    glBindFramebuffer(GL_FRAMEBUFFER, 0);
+    glViewport(0, 0, 256, 256);
+    BlitTexture(program_);
+
+    glDeleteTextures(1, &tex);
+  }
+}
+
+// Test that non-power-of-two uploads of SRGB8_ALPHA8 with UNSIGNED_BYTE data
+// succeed and verify texture contents, including non-default unpack alignment
+// and skip pixels.
+TEST_P(GLNPOTClientDataTextureTest, SRGB8Alpha8) {
+  if (!IsApplicable()) {
+    return;
+  }
+
+  constexpr GLsizei kWidth = 255;
+  constexpr GLsizei kHeight = 256;
+  constexpr GLint kSkipPixels = 1;
+  constexpr GLsizei kRowLength = kWidth + kSkipPixels;
+
+  for (int iter = 0; iter < 50; ++iter) {
+    std::vector<uint8_t> data(kHeight * kRowLength * 4);
+    for (size_t i = 0; i < data.size() / 4; ++i) {
+      data[i * 4] = static_cast<uint8_t>((i * 17 + iter * 3) % 256);
+      data[i * 4 + 1] = static_cast<uint8_t>((i * 31 + iter * 5 + 7) % 256);
+      data[i * 4 + 2] = static_cast<uint8_t>((i * 53 + iter * 11 + 13) % 256);
+      data[i * 4 + 3] = 255;
+    }
+
+    GLuint tex = 0;
+    glGenTextures(1, &tex);
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+    glPixelStorei(GL_UNPACK_ALIGNMENT, 2);
+    glPixelStorei(GL_UNPACK_ROW_LENGTH, kRowLength);
+    glPixelStorei(GL_UNPACK_SKIP_PIXELS, kSkipPixels);
+
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, kWidth, kHeight, 0, GL_RGBA,
+                 GL_UNSIGNED_BYTE, data.data());
+    EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+    glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
+    glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
+    glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0);
+
+    // Verify texture contents via FBO readback
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+                           tex, 0);
+    EXPECT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE),
+              glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+    std::vector<uint8_t> readback(kWidth * kHeight * 4);
+    glReadPixels(0, 0, kWidth, kHeight, GL_RGBA, GL_UNSIGNED_BYTE,
+                 readback.data());
+    EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+    for (GLsizei y = 0; y < kHeight; ++y) {
+      for (GLsizei x = 0; x < kWidth; ++x) {
+        size_t src_idx = (y * kRowLength + x + kSkipPixels) * 4;
+        size_t dst_idx = (y * kWidth + x) * 4;
+        EXPECT_EQ(readback[dst_idx], data[src_idx])
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[dst_idx + 1], data[src_idx + 1])
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[dst_idx + 2], data[src_idx + 2])
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[dst_idx + 3], data[src_idx + 3])
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+      }
+    }
+
+    // Also verify sampling via BlitTexture
+    glBindFramebuffer(GL_FRAMEBUFFER, 0);
+    glViewport(0, 0, 256, 256);
+    BlitTexture(program_);
+
+    glDeleteTextures(1, &tex);
+  }
+}
+
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc b/gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc
new file mode 100644
index 0000000..7e22791
--- /dev/null
+++ b/gpu/command_buffer/tests/gl_npot_client_data_texture_unittest.cc
@@ -0,0 +1,263 @@
+// 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 <GLES3/gl3.h>
+#include <stdint.h>
+
+#include <vector>
+
+#include "gpu/command_buffer/tests/gl_manager.h"
+#include "gpu/command_buffer/tests/gl_test_utils.h"
+#include "gpu/config/gpu_driver_bug_workarounds.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace gpu {
+
+namespace {
+
+void BlitTexture(GLuint program) {
+  glUseProgram(program);
+  const GLuint kVertexPositionAttrib = 0;
+  const GLfloat kQuadVertices[] = {-1.0f, -1.0f, 1.0f,  -1.0f,
+                                   1.0f,  1.0f,  -1.0f, 1.0f};
+  GLuint buffer_id = 0;
+  glGenBuffers(1, &buffer_id);
+  glBindBuffer(GL_ARRAY_BUFFER, buffer_id);
+  glBufferData(GL_ARRAY_BUFFER, sizeof(kQuadVertices), kQuadVertices,
+               GL_STATIC_DRAW);
+
+  glEnableVertexAttribArray(kVertexPositionAttrib);
+  glVertexAttribPointer(kVertexPositionAttrib, 2, GL_FLOAT, GL_FALSE, 0, 0);
+
+  GLuint sampler_handle = glGetUniformLocation(program, "u_sampler");
+  glUniform1i(sampler_handle, 0);
+
+  glDrawArrays(GL_TRIANGLE_FAN, 0, 4);
+  EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+  glDisableVertexAttribArray(kVertexPositionAttrib);
+  glBindBuffer(GL_ARRAY_BUFFER, 0);
+  glDeleteBuffers(1, &buffer_id);
+}
+
+}  // namespace
+
+class GLNPOTClientDataTextureTest : public testing::TestWithParam<bool> {
+ protected:
+  void SetUp() override {
+    GLManager::Options options;
+    options.context_type = CONTEXT_TYPE_OPENGLES3;
+    options.size = gfx::Size(256, 256);
+
+    GpuDriverBugWorkarounds workarounds;
+    workarounds.use_tex_sub_image_for_client_data_npot_uploads = GetParam();
+    gl_.InitializeWithWorkarounds(options, workarounds);
+
+    if (!IsApplicable()) {
+      return;
+    }
+
+    gl_.MakeCurrent();
+
+    static const char* kVertexShader =
+        "attribute vec2 a_position;\n"
+        "varying mediump vec2 v_uv;\n"
+        "void main(void) {\n"
+        "  gl_Position = vec4(a_position, 0, 1);\n"
+        "  v_uv = 0.5 * (a_position + vec2(1, 1));\n"
+        "}\n";
+    static const char* kFragmentShader =
+        "precision mediump float;\n"
+        "uniform sampler2D u_sampler;\n"
+        "varying mediump vec2 v_uv;\n"
+        "void main(void) {\n"
+        "  gl_FragColor = texture2D(u_sampler, v_uv);\n"
+        "}\n";
+
+    program_ = GLTestHelper::LoadProgram(kVertexShader, kFragmentShader);
+    ASSERT_NE(0u, program_);
+    glBindAttribLocation(program_, 0, "a_position");
+    glLinkProgram(program_);
+
+    glGenFramebuffers(1, &fbo_);
+  }
+
+  void TearDown() override {
+    if (IsApplicable()) {
+      if (program_) {
+        glDeleteProgram(program_);
+      }
+      if (fbo_) {
+        glDeleteFramebuffers(1, &fbo_);
+      }
+    }
+    gl_.Destroy();
+  }
+
+  bool IsApplicable() const { return gl_.IsInitialized(); }
+
+  GLManager gl_;
+  GLuint program_ = 0;
+  GLuint fbo_ = 0;
+};
+
+INSTANTIATE_TEST_SUITE_P(GLNPOTClientDataTextureTests,
+                         GLNPOTClientDataTextureTest,
+                         ::testing::Bool());
+
+// Test that non-power-of-two uploads of RGB10_A2 with
+// UNSIGNED_INT_2_10_10_10_REV data succeed and verify texture contents.
+TEST_P(GLNPOTClientDataTextureTest, RGB10A2) {
+  if (!IsApplicable()) {
+    return;
+  }
+
+  constexpr GLsizei kWidth = 65;
+  constexpr GLsizei kHeight = 64;
+
+  for (int iter = 0; iter < 50; ++iter) {
+    std::vector<uint32_t> data(kWidth * kHeight);
+    for (GLsizei y = 0; y < kHeight; ++y) {
+      for (GLsizei x = 0; x < kWidth; ++x) {
+        uint32_t r = ((x + iter) % 2 == 0) ? 0x3FF : 0;
+        uint32_t g = ((y + iter) % 2 == 0) ? 0x3FF : 0;
+        uint32_t b = ((x + y + iter) % 2 == 0) ? 0x3FF : 0;
+        uint32_t a = 3;  // 1.0 in 2-bit alpha
+        data[y * kWidth + x] = (r & 0x3FF) | ((g & 0x3FF) << 10) |
+                               ((b & 0x3FF) << 20) | ((a & 0x3) << 30);
+      }
+    }
+
+    GLuint tex = 0;
+    glGenTextures(1, &tex);
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB10_A2, kWidth, kHeight, 0, GL_RGBA,
+                 GL_UNSIGNED_INT_2_10_10_10_REV, data.data());
+    EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+    // Verify texture contents via FBO readback
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+                           tex, 0);
+    EXPECT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE),
+              glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+    std::vector<uint8_t> readback(kWidth * kHeight * 4);
+    glReadPixels(0, 0, kWidth, kHeight, GL_RGBA, GL_UNSIGNED_BYTE,
+                 readback.data());
+    EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+    for (GLsizei y = 0; y < kHeight; ++y) {
+      for (GLsizei x = 0; x < kWidth; ++x) {
+        uint8_t expected_r = ((x + iter) % 2 == 0) ? 255 : 0;
+        uint8_t expected_g = ((y + iter) % 2 == 0) ? 255 : 0;
+        uint8_t expected_b = ((x + y + iter) % 2 == 0) ? 255 : 0;
+        uint8_t expected_a = 255;
+        size_t idx = (y * kWidth + x) * 4;
+        EXPECT_EQ(readback[idx], expected_r)
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[idx + 1], expected_g)
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[idx + 2], expected_b)
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[idx + 3], expected_a)
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+      }
+    }
+
+    // Also verify sampling via BlitTexture
+    glBindFramebuffer(GL_FRAMEBUFFER, 0);
+    glViewport(0, 0, 256, 256);
+    BlitTexture(program_);
+
+    glDeleteTextures(1, &tex);
+  }
+}
+
+// Test that non-power-of-two uploads of SRGB8_ALPHA8 with UNSIGNED_BYTE data
+// succeed and verify texture contents, including non-default unpack alignment
+// and skip pixels.
+TEST_P(GLNPOTClientDataTextureTest, SRGB8Alpha8) {
+  if (!IsApplicable()) {
+    return;
+  }
+
+  constexpr GLsizei kWidth = 255;
+  constexpr GLsizei kHeight = 256;
+  constexpr GLint kSkipPixels = 1;
+  constexpr GLsizei kRowLength = kWidth + kSkipPixels;
+
+  for (int iter = 0; iter < 50; ++iter) {
+    std::vector<uint8_t> data(kHeight * kRowLength * 4);
+    for (size_t i = 0; i < data.size() / 4; ++i) {
+      data[i * 4] = static_cast<uint8_t>((i * 17 + iter * 3) % 256);
+      data[i * 4 + 1] = static_cast<uint8_t>((i * 31 + iter * 5 + 7) % 256);
+      data[i * 4 + 2] = static_cast<uint8_t>((i * 53 + iter * 11 + 13) % 256);
+      data[i * 4 + 3] = 255;
+    }
+
+    GLuint tex = 0;
+    glGenTextures(1, &tex);
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+    glPixelStorei(GL_UNPACK_ALIGNMENT, 2);
+    glPixelStorei(GL_UNPACK_ROW_LENGTH, kRowLength);
+    glPixelStorei(GL_UNPACK_SKIP_PIXELS, kSkipPixels);
+
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, kWidth, kHeight, 0, GL_RGBA,
+                 GL_UNSIGNED_BYTE, data.data());
+    EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+    glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
+    glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
+    glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0);
+
+    // Verify texture contents via FBO readback
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+                           tex, 0);
+    EXPECT_EQ(static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE),
+              glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+    std::vector<uint8_t> readback(kWidth * kHeight * 4);
+    glReadPixels(0, 0, kWidth, kHeight, GL_RGBA, GL_UNSIGNED_BYTE,
+                 readback.data());
+    EXPECT_EQ(static_cast<GLenum>(GL_NO_ERROR), glGetError());
+
+    for (GLsizei y = 0; y < kHeight; ++y) {
+      for (GLsizei x = 0; x < kWidth; ++x) {
+        size_t src_idx = (y * kRowLength + x + kSkipPixels) * 4;
+        size_t dst_idx = (y * kWidth + x) * 4;
+        EXPECT_EQ(readback[dst_idx], data[src_idx])
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[dst_idx + 1], data[src_idx + 1])
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[dst_idx + 2], data[src_idx + 2])
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+        EXPECT_EQ(readback[dst_idx + 3], data[src_idx + 3])
+            << "Mismatch at (" << x << ", " << y << ") on iter " << iter;
+      }
+    }
+
+    // Also verify sampling via BlitTexture
+    glBindFramebuffer(GL_FRAMEBUFFER, 0);
+    glViewport(0, 0, 256, 256);
+    BlitTexture(program_);
+
+    glDeleteTextures(1, &tex);
+  }
+}
+
+}  // namespace gpu
diff --git a/gpu/command_buffer/tests/gl_npot_host_twiddled_texture_unittest.cc b/gpu/command_buffer/tests/gl_npot_host_twiddled_texture_unittest.cc
deleted file mode 100644
index ea0e632..0000000
--- a/gpu/command_buffer/tests/gl_npot_host_twiddled_texture_unittest.cc
+++ /dev/null
@@ -1,263 +0,0 @@
-// 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 <GLES3/gl3.h>
-#include <stdint.h>
-
-#include <vector>
-
-#include "gpu/command_buffer/tests/gl_manager.h"
-#include "gpu/command_buffer/tests/gl_test_utils.h"
-#include "gpu/config/gpu_driver_bug_workarounds.h"
-#include "testing/gtest/include/gtest/gtest.h"
-
-namespace gpu {
-
-namespace {
-
-void BlitTexture(GLuint program) {
-  glUseProgram(program);
-  const GLuint kVertexPositionAttrib = 0;
-  const GLfloat kQuadVertices[] = {-1.0f, -1.0f, 1.0f,  -1.0f,
-                                   1.0f,  1.0f,  -1.0f, 1.0f};
... (truncated)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.