Overview

Critical
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in ANGLE
DescriptionInteger overflow in ANGLE
ComponentANGLE
Sub-componentVulkan
Bug ClassInteger Overflow
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable — revised by independent review by AI
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.
Independent review by AIConfirmed (tier revised from patch-now)
Reviewer notes: The fix is Vulkan-backend only (TextureVk.cpp), which goes unmentioned. WebGPU does not reach this ANGLE path. 'Readback' is unsupported: the test shows a copy/conversion path. The test notes that 4GB texture allocations fail on most configurations, so 'default' overstates it.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker485622239
Fix commita08731cf6d70 (angle/angle) +99/-32
CWECWE-472, CWE-190 (Integer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedcinzinga
Reported2026-02-18 · bounty $33,000 (per vendor advisory)
Fix landed2026-02-20 (public git)
Disclosed2026-03-03
Patch gap11 days — fix public in source before the advisory

Background

ANGLE
Chrome’s translation layer that implements OpenGL ES on top of native backends such as Vulkan; the affected code lives in its Vulkan renderer.
`rx::TextureVk`
the Vulkan-backend texture object that manages GPU image storage and the CPU-side staging buffers used for copies and readbacks.
`gl::Box`
a rectangular region descriptor whose width, height, and depth members are signed 32-bit ints (GLint), used here as the source extent of a copy.
Staging buffer
an intermediate host-visible allocation sized to hold the pixel bytes transferred between a texture and CPU/GPU memory during a copy or readback.

Root Cause Analysis

In the copy/readback path, the destination staging-buffer size was computed as sourceBox.width * sourceBox.height * sourceBox.depth * dstFormat.pixelBytes * layerCount, where the leading operands are signed 32-bit gl::Box ints and dstFormat.pixelBytes/layerCount are also narrow types. Because C++ integer promotion keeps this arithmetic in 32-bit (int) until the final assignment, the product of all factors is truncated modulo 2^32 before it is widened to size_t. For a large 2D-array texture — for example ~1GB per layer across 4 layers, ~4GB total — the true byte count exceeds INT32_MAX, so the 32-bit multiplication overflows and yields a dstBufferSize far smaller than the real transfer size. The subsequent allocation reserves that undersized buffer, while the actual copy engine writes the full (untruncated) amount of pixel data, producing a heap buffer overflow in the GPU process.

The fix builds the product stepwise in size_t (first width * height, then multiplying in the remaining factors) and adds static_asserts bounding the intermediate products by ANGLE’s IMPLEMENTATION_MAX_* texture-size constants so no intermediate can overflow before promotion.

Key insight
The overflow is a promotion-timing bug: every operand was individually within range, but their 32-bit product wrapped before the compiler widened the result to size_t, so simply assigning to a size_t was too late to be safe.

Attack Path

  1. Allocate an oversized texture Content creates a 2D-array (or otherwise large) texture whose total byte size exceeds INT32_MAX via WebGL/WebGPU-exposed GL ES calls.
  2. Trigger the copy/readback A copySubImage/CPU-readback operation drives rx::TextureVk::copySubImageImplWithDraw, computing dstBufferSize over the full source gl::Box and layer count.
  3. Integer overflow truncates the size The 32-bit multiplication wraps, so dstBufferSize is far smaller than the true transfer size.
  4. Undersized allocation A staging buffer of the truncated size is allocated for the destination.
  5. Heap buffer overflow The copy writes the full pixel payload into the undersized buffer, corrupting adjacent GPU-process heap memory.

Impact Assessment

This is a heap buffer overflow in the GPU process reachable from web content that can create large textures through WebGL/WebGPU. Controlled out-of-bounds writes in the GPU process can corrupt adjacent allocations and are a strong primitive toward memory corruption and potential remote code execution within the sandboxed GPU process. Exploitation requires allocating multi-gigabyte textures, so success depends on available GPU/host memory, but the write itself is content-triggered.

Files Changed

  • src/libANGLE/renderer/vulkan/TextureVk.cpp
  • src/tests/gl_tests/FramebufferTest.cpp
  • src/tests/gl_tests/VulkanImageTest.cpp
  • util/shader_utils.cpp

Audit Directions

  • Pre-promotion integer overflow
    Flag size/byte computations that multiply narrow signed operands (e.g. gl::Box members, pixelBytes, layerCount) and only widen at assignment; force the first operand or a cast into size_t so the whole chain is 64-bit.
  • Buffer size vs. transfer size mismatch
    Audit allocation paths where a computed buffer size is used for alloc while the actual copy length is derived separately, ensuring both use the same overflow-safe arithmetic.
  • Bounded-input assumptions
    Where IMPLEMENTATION_MAX_* constants are relied on to keep products in range, require explicit static_asserts (or runtime checks) so future limit increases cannot silently reintroduce overflow.
From a08731cf6d70c60fd74b1d75f2e8b94c52e18140 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <[email protected]>
Date: Thu, 19 Feb 2026 14:42:08 -0500
Subject: [PATCH] Vulkan: Avoid overflow in texture size calculation

Bug: chromium:485622239
Change-Id: Idf9847afa0aa2e72b6433ac8348ae2820c1ad8c5
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7595734
Reviewed-by: Amirali Abdolrashidi <[email protected]>
Commit-Queue: Shahbaz Youssefi <[email protected]>
---

diff --git a/src/libANGLE/renderer/vulkan/TextureVk.cpp b/src/libANGLE/renderer/vulkan/TextureVk.cpp
index 9e208f9..e2185a4 100644
--- a/src/libANGLE/renderer/vulkan/TextureVk.cpp
+++ b/src/libANGLE/renderer/vulkan/TextureVk.cpp
@@ -3164,8 +3164,17 @@
         // invalidate must be called after wait for finish.
         ANGLE_TRY(srcBuffer->invalidate(renderer));
 
-        size_t dstBufferSize = sourceBox.width * sourceBox.height * sourceBox.depth *
-                               dstFormat.pixelBytes * layerCount;
+        // Use size_t calculations to avoid 32-bit overflows.  Note that the dimensions are bound by
+        // the maximums specified in Constants.h, and that gl::Box members are signed 32-bit
+        // integers.
+        static_assert(gl::IMPLEMENTATION_MAX_2D_TEXTURE_SIZE *
+                          gl::IMPLEMENTATION_MAX_2D_TEXTURE_SIZE <
+                      std::numeric_limits<int32_t>::max());
+        size_t dstBufferSize = sourceBox.width * sourceBox.height;
+        static_assert(gl::IMPLEMENTATION_MAX_3D_TEXTURE_SIZE *
+                          gl::IMPLEMENTATION_MAX_2D_ARRAY_TEXTURE_LAYERS * 16 <
+                      std::numeric_limits<int32_t>::max());
+        dstBufferSize *= sourceBox.depth * dstFormat.pixelBytes * layerCount;
 
         // Allocate memory in the destination texture for the copy/conversion.
         uint8_t *dstData = nullptr;
diff --git a/src/tests/gl_tests/FramebufferTest.cpp b/src/tests/gl_tests/FramebufferTest.cpp
index 020a041..f72f1a7 100644
--- a/src/tests/gl_tests/FramebufferTest.cpp
+++ b/src/tests/gl_tests/FramebufferTest.cpp
@@ -8894,6 +8894,62 @@
     ASSERT_GL_NO_ERROR();
 }
 
+// Test that 2D array texture size calculation doesn't overflow internally when rendering to it.  An
+// RGB format is used which is often emualted with RGBA.
+//
+// Practically we cannot run this test.  On most configurations, allocating a 4GB texture fails due
+// to internal driver limitations.  On the few configs that the test actually runs, allocating such
+// large memory leads to instability.
+TEST_P(FramebufferTest_ES3, DISABLED_MaxSize2DArrayNoOverflow)
+{
+    GLint maxTexture2DSize;
+    glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxTexture2DSize);
+
+    maxTexture2DSize = std::min(maxTexture2DSize, 16384);
+
+    // Create a 2D array texture with RGB format.  Every layer is going to take 1GB of memory (if
+    // emulated with RGBA), so only create 4 layers of it (for a total of 4GB of memory).  If 32-bit
+    // math is involved when calculating sizes related to this texture, they will overflow.
+    constexpr uint32_t kLayers = 4;
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D_ARRAY, tex);
+    glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGB8, maxTexture2DSize, maxTexture2DSize, kLayers);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Initialize the texture so its content is considered valid and worth preserving.
+    constexpr int kValidSubsectionWidth  = 16;
+    constexpr int kValidSubsectionHeight = 20;
+    std::vector<GLColorRGB> data(kValidSubsectionWidth * kValidSubsectionHeight,
+                                 GLColorRGB(0, 255, 0));
+    for (uint32_t layer = 0; layer < kLayers; ++layer)
+    {
+        glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, layer, kValidSubsectionWidth,
+                        kValidSubsectionHeight, 1, GL_RGB, GL_UNSIGNED_BYTE, data.data());
+    }
+
+    // Draw with the texture, making sure it's initialized and data is flushed.
+    ANGLE_GL_PROGRAM(drawTex2DArray, essl3_shaders::vs::Texture2DArray(),
+                     essl3_shaders::fs::Texture2DArray());
+    drawQuad(drawTex2DArray, essl3_shaders::PositionAttrib(), 0.5f);
+
+    // Bind a framebuffer to the texture and render into it.  In some backends, the texture is
+    // recreated to RGBA to be renderable.
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, 0, 1);
+
+    ANGLE_GL_PROGRAM(drawRed, essl1_shaders::vs::Simple(), essl1_shaders::fs::Red());
+    glViewport(0, 0, kValidSubsectionWidth / 2, kValidSubsectionHeight);
+    drawQuad(drawRed, essl1_shaders::PositionAttrib(), 0.5f);
+
+    EXPECT_PIXEL_RECT_EQ(0, 0, kValidSubsectionWidth / 2, kValidSubsectionHeight, GLColor::red);
+    EXPECT_PIXEL_RECT_EQ(kValidSubsectionWidth / 2, 0,
+                         kValidSubsectionWidth - kValidSubsectionWidth / 2, kValidSubsectionHeight,
+                         GLColor::green);
+    ASSERT_GL_NO_ERROR();
+}
+
 ANGLE_INSTANTIATE_TEST_ES2_AND(AddMockTextureNoRenderTargetTest,
                                ES2_D3D9().enable(Feature::AddMockTextureNoRenderTarget),
                                ES2_D3D11().enable(Feature::AddMockTextureNoRenderTarget));
diff --git a/src/tests/gl_tests/VulkanImageTest.cpp b/src/tests/gl_tests/VulkanImageTest.cpp
index 2f06e2d..87e7482 100644
--- a/src/tests/gl_tests/VulkanImageTest.cpp
+++ b/src/tests/gl_tests/VulkanImageTest.cpp
@@ -677,8 +677,8 @@
                         kTextureHeight, 1, GL_RGBA, GL_UNSIGNED_BYTE, textureColor.data());
     }
 
-    ANGLE_GL_PROGRAM(drawTex2DArray, essl1_shaders::vs::Texture2DArray(),
-                     essl1_shaders::fs::Texture2DArray());
+    ANGLE_GL_PROGRAM(drawTex2DArray, essl3_shaders::vs::Texture2DArray(),
+                     essl3_shaders::fs::Texture2DArray());
     drawQuad(drawTex2DArray, essl1_shaders::PositionAttrib(), 0.5f);
 
     // Fill up the device memory until we start allocating on the system memory.
diff --git a/util/shader_utils.cpp b/util/shader_utils.cpp
index 275e261..8994612 100644
--- a/util/shader_utils.cpp
+++ b/util/shader_utils.cpp
@@ -580,18 +580,6 @@
 })";
 }
 
-const char *Texture2DArray()
-{
-    return R"(#version 300 es
-out vec2 v_texCoord;
-in vec4 a_position;
-void main()
-{
-    gl_Position = vec4(a_position.xy, 0.0, 1.0);
-    v_texCoord = (a_position.xy * 0.5) + 0.5;
-})";
-}
-
 }  // namespace vs
 
 namespace fs
@@ -689,20 +677,6 @@
 })";
 }
 
-const char *Texture2DArray()
-{
-    return R"(#version 300 es
-precision highp float;
-uniform highp sampler2DArray tex2DArray;
-uniform int slice;
-in vec2 v_texCoord;
-out vec4 fragColor;
-void main()
-{
-    fragColor = texture(tex2DArray, vec3(v_texCoord, float(slice)));
-})";
-}
-
 }  // namespace fs
 }  // namespace essl1_shaders
 
@@ -787,6 +761,18 @@
 })";
 }
 
+const char *Texture2DArray()
+{
+    return R"(#version 300 es
+out vec2 v_texCoord;
+in vec4 a_position;
+void main()
+{
+    gl_Position = vec4(a_position.xy, 0.0, 1.0);
+    v_texCoord = (a_position.xy * 0.5) + 0.5;
+})";
+}
+
 }  // namespace vs
 
 namespace fs
@@ -844,6 +830,20 @@
 })";
 }
 
+const char *Texture2DArray()
+{
+    return R"(#version 300 es
+precision highp float;
+uniform highp sampler2DArray tex2DArray;
+uniform int slice;
+in vec2 v_texCoord;
+out vec4 fragColor;
+void main()
+{
+    fragColor = texture(tex2DArray, vec3(v_texCoord, float(slice)));
+})";
+}
+
 }  // namespace fs
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/FramebufferTest.cpp b/src/tests/gl_tests/FramebufferTest.cpp
index 020a041..f72f1a7 100644
--- a/src/tests/gl_tests/FramebufferTest.cpp
+++ b/src/tests/gl_tests/FramebufferTest.cpp
@@ -8894,6 +8894,62 @@
     ASSERT_GL_NO_ERROR();
 }
 
+// Test that 2D array texture size calculation doesn't overflow internally when rendering to it.  An
+// RGB format is used which is often emualted with RGBA.
+//
+// Practically we cannot run this test.  On most configurations, allocating a 4GB texture fails due
+// to internal driver limitations.  On the few configs that the test actually runs, allocating such
+// large memory leads to instability.
+TEST_P(FramebufferTest_ES3, DISABLED_MaxSize2DArrayNoOverflow)
+{
+    GLint maxTexture2DSize;
+    glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxTexture2DSize);
+
+    maxTexture2DSize = std::min(maxTexture2DSize, 16384);
+
+    // Create a 2D array texture with RGB format.  Every layer is going to take 1GB of memory (if
+    // emulated with RGBA), so only create 4 layers of it (for a total of 4GB of memory).  If 32-bit
+    // math is involved when calculating sizes related to this texture, they will overflow.
+    constexpr uint32_t kLayers = 4;
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D_ARRAY, tex);
+    glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RGB8, maxTexture2DSize, maxTexture2DSize, kLayers);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    // Initialize the texture so its content is considered valid and worth preserving.
+    constexpr int kValidSubsectionWidth  = 16;
+    constexpr int kValidSubsectionHeight = 20;
+    std::vector<GLColorRGB> data(kValidSubsectionWidth * kValidSubsectionHeight,
+                                 GLColorRGB(0, 255, 0));
+    for (uint32_t layer = 0; layer < kLayers; ++layer)
+    {
+        glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, layer, kValidSubsectionWidth,
+                        kValidSubsectionHeight, 1, GL_RGB, GL_UNSIGNED_BYTE, data.data());
+    }
+
+    // Draw with the texture, making sure it's initialized and data is flushed.
+    ANGLE_GL_PROGRAM(drawTex2DArray, essl3_shaders::vs::Texture2DArray(),
+                     essl3_shaders::fs::Texture2DArray());
+    drawQuad(drawTex2DArray, essl3_shaders::PositionAttrib(), 0.5f);
+
+    // Bind a framebuffer to the texture and render into it.  In some backends, the texture is
+    // recreated to RGBA to be renderable.
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, 0, 1);
+
+    ANGLE_GL_PROGRAM(drawRed, essl1_shaders::vs::Simple(), essl1_shaders::fs::Red());
+    glViewport(0, 0, kValidSubsectionWidth / 2, kValidSubsectionHeight);
+    drawQuad(drawRed, essl1_shaders::PositionAttrib(), 0.5f);
+
+    EXPECT_PIXEL_RECT_EQ(0, 0, kValidSubsectionWidth / 2, kValidSubsectionHeight, GLColor::red);
+    EXPECT_PIXEL_RECT_EQ(kValidSubsectionWidth / 2, 0,
+                         kValidSubsectionWidth - kValidSubsectionWidth / 2, kValidSubsectionHeight,
+                         GLColor::green);
+    ASSERT_GL_NO_ERROR();
+}
+
 ANGLE_INSTANTIATE_TEST_ES2_AND(AddMockTextureNoRenderTargetTest,
                                ES2_D3D9().enable(Feature::AddMockTextureNoRenderTarget),
                                ES2_D3D11().enable(Feature::AddMockTextureNoRenderTarget));
diff --git a/src/tests/gl_tests/VulkanImageTest.cpp b/src/tests/gl_tests/VulkanImageTest.cpp
index 2f06e2d..87e7482 100644
--- a/src/tests/gl_tests/VulkanImageTest.cpp
+++ b/src/tests/gl_tests/VulkanImageTest.cpp
@@ -677,8 +677,8 @@
                         kTextureHeight, 1, GL_RGBA, GL_UNSIGNED_BYTE, textureColor.data());
     }
 
-    ANGLE_GL_PROGRAM(drawTex2DArray, essl1_shaders::vs::Texture2DArray(),
-                     essl1_shaders::fs::Texture2DArray());
+    ANGLE_GL_PROGRAM(drawTex2DArray, essl3_shaders::vs::Texture2DArray(),
+                     essl3_shaders::fs::Texture2DArray());
     drawQuad(drawTex2DArray, essl1_shaders::PositionAttrib(), 0.5f);
 
     // Fill up the device memory until we start allocating on the system memory.
Loading diff…

Original Bug Report

reported by [email protected]

ANGLE Vulkan reinitImageAsRenderable uint32 Overflow causes GPU OOB Write


Report description

ANGLE Vulkan reinitImageAsRenderable uint32 Overflow causes GPU OOB Write


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/angle/angle/+/aca6a0fe0850/src/libANGLE/renderer/vulkan/TextureVk.cpp#3167


The problem

Please describe the technical details of the vulnerability

TextureVk::reinitImageAsRenderable in the ANGLE Vulkan backend computes a staging buffer size using five operands that multiply in 32-bit arithmetic, overflowing before widening to size_t. When a WebGL2 page creates a non-renderable RGB8 TEXTURE_2D_ARRAY and calls copyTexSubImage3D, ANGLE detects that RGB8 is not natively renderable in Vulkan and converts the texture to RGBA8. The size calculation at TextureVk.cpp:3167 multiplies width, height, depth, pixelBytes, and layerCount in 32-bit arithmetic. For a 16384x16384x4-layer texture, the product is exactly 2^32 and wraps to 0. The staging buffer allocation receives the overflowed size (0). CopyImageCHROMIUM then writes 4GB of converted pixel data into it.

The attacker controls the source RGB8 pixel data. After conversion to RGBA8, the R, G, B channels are derived from the attacker’s data and the alpha channel is always 0xFF. The attacker controls 3 of every 4 bytes written during the overflow.

No compromised renderer is required. The overflow is triggered entirely from JavaScript via the WebGL2 API.

Affects Stable, Beta, and Dev. The vulnerable code has been present since September 2021. Tested on:

  • 144.0.7559.133 (Stable, Windows)
  • 146.0.7678.0 (ASan, Windows)

Affected Code

TextureVk.cpp, lines 3167-3168 in reinitImageAsRenderable:

size_t dstBufferSize = sourceBox.width * sourceBox.height * sourceBox.depth *
                       dstFormat.pixelBytes * layerCount;

All five operands (width, height, depth as int; pixelBytes, layerCount as uint32_t) multiply in 32-bit arithmetic. The result overflows before being stored into the 64-bit size_t. This value is passed to stageSubresourceUpdateAndGetData (line 3172), which calls initBufferForImageCopy to allocate a VMA staging buffer of the overflowed size. The CopyImageCHROMIUM loop at lines 3190-3197 writes the correct (non-overflowed) amount of data per layer, using pitch values from lines 3177-3184.

The pitch calculations at lines 3177-3184 use GLuint (uint32_t) and have the same overflow class, but they do not overflow at the dimensions required to trigger the size overflow.

Steps to Reproduce

  1. Launch Chrome with: –use-angle=vulkan
  2. Navigate to the attached poc.html
  3. The PoC auto-fires on page load

For ASan builds, also pass –in-process-gpu –disable-features=SkiaGraphite –disable-gpu-compositing. The latter two flags prevent an unrelated Dawn/D3D11 initialization failure in ASan builds when ANGLE is set to Vulkan; they are not related to the vulnerability, but were required in my testing.

ASan output (Chrome 146.0.7678.0, Windows, Intel HD 620):

==17556==ERROR: AddressSanitizer: access-violation on unknown address 0x120973c50000
==17556==The signal is caused by a WRITE memory access.
    #0 angle::R8G8B8A8SRGB::writeColor               imageformats.cpp:392
    #1 rx::CopyImageCHROMIUM                           renderer_utils.cpp:795
    #2 rx::TextureVk::reinitImageAsRenderable          TextureVk.cpp:3192
    #3 rx::TextureVk::respecifyImageStorage            TextureVk.cpp:3260
    #4 rx::TextureVk::ensureRenderableWithFormat       TextureVk.cpp:4907
    #5 rx::TextureVk::ensureRenderableIfCopyTexImage.. TextureVk.cpp:4968
    #6 rx::TextureVk::copySubImage                     TextureVk.cpp:1479
    #7 gl::Texture::copySubImage                       Texture.cpp:1613
    #8 gl::Context::copyTexSubImage3D                  Context.cpp:5139

rax = 41  rcx = 120973c50000

RAX holds the attacker’s marker byte (0x41) at the point of the crash. RCX is the destination pointer. The faulting instruction is writeColor writing the attacker’s R channel byte to the overflow destination.

Chrome 144 stable (no –in-process-gpu) crashes the GPU process with STATUS_ACCESS_VIOLATION (0xC0000005), WRITE. The crashpad minidump confirms:

  • RAX = 0x41 (attacker’s marker byte)
  • The 128 bytes immediately before the crash address contain 32 consecutive 41 41 41 FF RGBA pixels
  • The overflow wrote through a 4MB PAGE_READWRITE heap region and crashed at the adjacent PAGE_NOACCESS boundary

The PoC works as follows:

  1. Creates a 16384x16384x4-layer RGB8 TEXTURE_2D_ARRAY (non-renderable in Vulkan)
  2. Fills the first 512 rows with a controlled marker byte (0x41)
  3. Creates a 1x1 RGBA8 renderbuffer FBO as the copy source
  4. Calls copyTexSubImage3D from the RGBA8 FBO into the RGB8 texture
  5. ANGLE calls reinitImageAsRenderable, which computes dstBufferSize = 0 (uint32 overflow) and writes 4GB of RGBA8 converted data into the undersized staging buffer

The trigger path is:

gl.copyTexSubImage3D(TEXTURE_2D_ARRAY, ...)
  -> TextureVk::copySubImage
  -> ensureRenderableIfCopyTexImageCannotTransfer
  -> ensureRenderableWithFormat (RGB8 not renderable, convert to RGBA8)
  -> respecifyImageStorage
  -> reinitImageAsRenderable (layerCount > 1, takes slow path)
  -> dstBufferSize = width * height * depth * pixelBytes * layerCount overflows to 0
  -> stageSubresourceUpdateAndGetData allocates staging buffer with overflowed size
  -> CopyImageCHROMIUM writes 4GB into undersized buffer

Fix

Use CheckedNumeric for the size calculation, consistent with how block-compressed formats are already handled via ANGLE_VK_CHECK_MATH in vk_helpers.cpp stageSubresourceUpdateImpl (lines 8375-8390):

angle::CheckedNumeric<size_t> checkedSize = sourceBox.width;
checkedSize *= sourceBox.height;
checkedSize *= sourceBox.depth;
checkedSize *= dstFormat.pixelBytes;
checkedSize *= layerCount;
ANGLE_VK_CHECK_MATH(contextVk, checkedSize.IsValid());
size_t dstBufferSize = checkedSize.ValueOrDie();

The GLuint pitch calculations at lines 3177-3184 should also use CheckedNumeric.

Bisect

The vulnerable code was introduced in ANGLE commit 8ea87a6767 (“Vulkan: Avoid texture format fallback when possible”), committed 2021-09-08, CL https://chromium-review.googlesource.com/c/angle/angle/+/3104514. The entire reinitImageAsRenderable function, including the unchecked multiplication, was added as new code in this commit.

Impact analysis

Heap buffer overflow in the GPU process, reachable from JavaScript via WebGL2. The attacker controls 3 of every 4 bytes written during the 4GB overflow (the 4th byte is always 0xFF). The staging buffer is allocated in the VMA (Vulkan Memory Allocator) HOST_VISIBLE staging pool. The overflow corrupts adjacent heap allocations. In release Chrome on Windows, approximately 4MB of heap data is overwritten with attacker-controlled content before the write reaches an unmapped page.

On Android, ANGLE Vulkan is the default backend and no command-line flags are required. The Android GPU process has no sandbox (kAndroidGpuSandbox is FEATURE_DISABLED_BY_DEFAULT) and shares the same UID as the browser process, so this overflow corrupts memory in an unsandboxed process reachable from JavaScript with no user interaction. On Linux and ChromeOS, ANGLE Vulkan is the default on many configurations.


The cause

What version of Chrome have you found the security issue in?

144.0.7559.133 Stable

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption (in a sandboxed process)

How would you like to be publicly acknowledged for your report?

cinzinga

View on issue tracker