CVE-2026-17772
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/test/data/gpu/webgl_y16_uploads.html |
modified |
Files Changed
content/test/content_test_bundle_data.filelistcontent/test/data/gpu/webgl_y16_uploads.htmlcontent/test/gpu/gpu_tests/gpu_process_integration_test.pycontent/test/gpu/gpu_tests/test_expectations/gpu_process_expectations.txtthird_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
Patch
From 6fc6707ea327ffb0706d2782ca45b3e67ff21004 Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Mon, 08 Jun 2026 20:50:54 -0700
Subject: [PATCH] Reset unpack parameters during Y16 texture uploads.
Unconditionally reset unpack parameters during Y16 texture
uploads. Integrate test case from the bug report as a GpuProcess
integration test. Skip the test on Android and Fuchsia as it requires
SwiftShader.
Co-authored with jetski-cli.
Fixed: 513197846
Change-Id: I5ec095c08d1c6a2b3dfd7eb4ffdd1ddb143fe04d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7904715
Auto-Submit: Kenneth Russell <kbr@chromium.org>
Reviewed-by: Kai Ninomiya <kainino@chromium.org>
Commit-Queue: Kai Ninomiya <kainino@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1643666}
---
diff --git a/content/test/content_test_bundle_data.filelist b/content/test/content_test_bundle_data.filelist
index 1754822..328a35e 100644
--- a/content/test/content_test_bundle_data.filelist
+++ b/content/test/content_test_bundle_data.filelist
@@ -8044,6 +8044,7 @@
data/gpu/webgl_extension_test.html
data/gpu/webgl_test_page.html
data/gpu/webgl_with_select_element.html
+data/gpu/webgl_y16_uploads.html
data/gpu/webgpu-caching.html
data/gpu/webgpu-context-lost.html
data/gpu/webgpu-domain-blocking-page1.html
diff --git a/content/test/data/gpu/webgl_y16_uploads.html b/content/test/data/gpu/webgl_y16_uploads.html
new file mode 100644
index 0000000..66a947b6
--- /dev/null
+++ b/content/test/data/gpu/webgl_y16_uploads.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html>
+<head><title>Y16 texSubImage2D UNPACK_ROW_LENGTH OOB Test</title></head>
+<body>
+<video id="v" autoplay playsinline muted></video>
+<canvas id="c" width="8" height="8"></canvas>
+<pre id="log"></pre>
+<script>
+const log = m => { document.getElementById('log').textContent += m + '\n'; console.log(m); };
+
+async function run() {
+ // 1. Open the Y16 depth-style camera. With Chrome's fake-device factory and
+ // device-count=2, device index 1 (label "fake_device_1") emits
+ // PIXEL_FORMAT_Y16 frames -- the same pipeline a RealSense camera uses.
+ await navigator.mediaDevices.getUserMedia({video: true}); // prime labels
+ const devs = await navigator.mediaDevices.enumerateDevices();
+ for (const d of devs) log(`dev: kind=${d.kind} label="${d.label}" id=${d.deviceId}`);
+ const y16 = devs.find(d => d.kind === 'videoinput' && /fake_device_1/.test(d.label));
+ if (!y16) {
+ log('FAIL: no Y16 fake device found (need device-count>=2)');
+ if (window.domAutomationController) {
+ window.domAutomationController.send('failed: no Y16 fake device found');
+ }
+ return;
+ }
+
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { deviceId: { exact: y16.deviceId }, width: 96, height: 96 }
+ });
+ const v = document.getElementById('v');
+ v.srcObject = stream;
+ await new Promise(r => v.onloadeddata = r);
+ await v.play().catch(()=>{});
+ // wait for an actual frame to land in the compositor
+ await new Promise(r => {
+ if ('requestVideoFrameCallback' in v) v.requestVideoFrameCallback(()=>r());
+ else setTimeout(r, 500);
+ });
+ const W = v.videoWidth, H = v.videoHeight;
+ log(`video ${W}x${H}`);
+
+ // 2. WebGL2 context + destination texture sized to the video.
+ const gl = document.getElementById('c').getContext('webgl2');
+ if (!gl) {
+ log('FAIL: no WebGL2');
+ if (window.domAutomationController) {
+ window.domAutomationController.send('failed: no WebGL2');
+ }
+ return;
+ }
+ const tex = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, tex);
+ // Allocate destination storage (R32F so RED/FLOAT sub-upload is legal).
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, W, H, 0, gl.RED, gl.FLOAT, null);
+ log('texStorage err=0x' + gl.getError().toString(16));
+
+ // 3. Set UNPACK_ROW_LENGTH > frame width. Leave UNPACK_FLIP_Y_WEBGL and
+ // UNPACK_PREMULTIPLY_ALPHA_WEBGL at their defaults (false) so that
+ // ScopedUnpackParametersResetRestore in the Y16 fast path is a no-op
+ // and ROW_LENGTH stays active inside the GLES2 client.
+ const ROW_LENGTH = W + 256;
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, ROW_LENGTH);
+ log(`UNPACK_ROW_LENGTH=${ROW_LENGTH}`);
+
+ // 4. Upload the Y16 video frame for 5 seconds.
+ const startTime = performance.now();
+ let frameCount = 0;
+ while (performance.now() - startTime < 5000) {
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RED, gl.FLOAT, v);
+ const err = gl.getError();
+ if (err !== gl.NO_ERROR) {
+ log(`texSubImage2D err=0x` + err.toString(16));
+ }
+ frameCount++;
+ await new Promise(r => requestAnimationFrame(r));
+ }
+
+ log(`Done ${frameCount} uploads. Reporting completion.`);
+ if (window.domAutomationController) {
+ window.domAutomationController.send('SUCCESS');
+ } else {
+ log('FAIL: domAutomationController not found');
+ }
+}
+run().catch(e => {
+ log('EXC: ' + e);
+ if (window.domAutomationController) {
+ window.domAutomationController.send('exception: ' + e);
+ }
+});
+</script>
+</body>
+</html>
diff --git a/content/test/gpu/gpu_tests/gpu_process_integration_test.py b/content/test/gpu/gpu_tests/gpu_process_integration_test.py
index 2b1f34c..3ea5421e 100644
--- a/content/test/gpu/gpu_tests/gpu_process_integration_test.py
+++ b/content/test/gpu/gpu_tests/gpu_process_integration_test.py
@@ -128,6 +128,7 @@
('GpuProcess_webgpu_iframe_removed', 'gpu/webgpu-iframe-removed.html'),
('GpuProcess_vulkan_vma_race', 'gpu/vulkan_vma_race.html'),
('GpuProcess_visibility', 'about:blank'),
+ ('GpuProcess_webgl_y16_uploads', 'gpu/webgl_y16_uploads.html'),
)
for t in tests:
@@ -474,6 +475,15 @@
self.fail(
f'Visibility callback call count expected 3, got {callback_count}')
+ def _GpuProcess_webgl_y16_uploads(self, test_path: str) -> None:
+ self.RestartBrowserIfNecessaryWithArgs([
+ '--use-fake-device-for-media-stream=device-count=2',
+ '--use-fake-ui-for-media-stream',
+ '--autoplay-policy=no-user-gesture-required',
+ '--use-angle=swiftshader',
+ ])
+ self._NavigateAndWait(test_path)
+
def _GpuProcess_disable_gpu_and_swiftshader(self, test_path: str) -> None:
# Disable SwiftShader, GPU process should launch for display compositing.
self.RestartBrowserIfNecessaryWithArgs(
diff --git a/content/test/gpu/gpu_tests/test_expectations/gpu_process_expectations.txt b/content/test/gpu/gpu_tests/test_expectations/gpu_process_expectations.txt
index 025e5c3..ef5776d 100644
--- a/content/test/gpu/gpu_tests/test_expectations/gpu_process_expectations.txt
+++ b/content/test/gpu/gpu_tests/test_expectations/gpu_process_expectations.txt
@@ -137,6 +137,10 @@
# Fails on Fuchsia cast receiver - not important to fix
crbug.com/500083376 [ fuchsia ] GpuProcess_vulkan_vma_race [ Skip ]
+# Test requires SwiftShader which isn't available on some platforms
+crbug.com/513197846 [ android ] GpuProcess_webgl_y16_uploads [ Skip ]
+crbug.com/513197846 [ fuchsia ] GpuProcess_webgl_y16_uploads [ Skip ]
+
###############################
# Temporary Skip Expectations #
###############################
diff --git a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
index ceef9fac1..3b7e67d 100644
--- a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
+++ b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
@@ -6582,8 +6582,10 @@
unpack_color_space_is_srgb) {
// Try using optimized CPU-GPU path for some formats: e.g. Y16 and Y8. It
// leaves early for other formats or if frame is stored on GPU.
- ScopedUnpackParametersResetRestore unpack_params(
- this, unpack_flip_y_ || unpack_premultiply_alpha_);
+ // Unconditionally reset unpack state: PaintCanvasVideoRenderer hands a
+ // tightly-sized buffer to gl->Tex(Sub)Image2D, so any active
+ // UNPACK_ROW_LENGTH/SKIP_* would make the GLES2 client over-read it.
+ ScopedUnpackParametersResetRestore unpack_params(this);
// TODO(crbug.com/378688985): UNPACK_COLORSPACE_CONVERSION_WEBGL shouldn't
// affect alpha type (and the format doesn't even have alpha channel).
Regression Test / PoC
diff --git a/content/test/content_test_bundle_data.filelist b/content/test/content_test_bundle_data.filelist
index 1754822..328a35e 100644
--- a/content/test/content_test_bundle_data.filelist
+++ b/content/test/content_test_bundle_data.filelist
@@ -8044,6 +8044,7 @@
data/gpu/webgl_extension_test.html
data/gpu/webgl_test_page.html
data/gpu/webgl_with_select_element.html
+data/gpu/webgl_y16_uploads.html
data/gpu/webgpu-caching.html
data/gpu/webgpu-context-lost.html
data/gpu/webgpu-domain-blocking-page1.html
diff --git a/content/test/data/gpu/webgl_y16_uploads.html b/content/test/data/gpu/webgl_y16_uploads.html
new file mode 100644
index 0000000..66a947b6
--- /dev/null
+++ b/content/test/data/gpu/webgl_y16_uploads.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html>
+<head><title>Y16 texSubImage2D UNPACK_ROW_LENGTH OOB Test</title></head>
+<body>
+<video id="v" autoplay playsinline muted></video>
+<canvas id="c" width="8" height="8"></canvas>
+<pre id="log"></pre>
+<script>
+const log = m => { document.getElementById('log').textContent += m + '\n'; console.log(m); };
+
+async function run() {
+ // 1. Open the Y16 depth-style camera. With Chrome's fake-device factory and
+ // device-count=2, device index 1 (label "fake_device_1") emits
+ // PIXEL_FORMAT_Y16 frames -- the same pipeline a RealSense camera uses.
+ await navigator.mediaDevices.getUserMedia({video: true}); // prime labels
+ const devs = await navigator.mediaDevices.enumerateDevices();
+ for (const d of devs) log(`dev: kind=${d.kind} label="${d.label}" id=${d.deviceId}`);
+ const y16 = devs.find(d => d.kind === 'videoinput' && /fake_device_1/.test(d.label));
+ if (!y16) {
+ log('FAIL: no Y16 fake device found (need device-count>=2)');
+ if (window.domAutomationController) {
+ window.domAutomationController.send('failed: no Y16 fake device found');
+ }
+ return;
+ }
+
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { deviceId: { exact: y16.deviceId }, width: 96, height: 96 }
+ });
+ const v = document.getElementById('v');
+ v.srcObject = stream;
+ await new Promise(r => v.onloadeddata = r);
+ await v.play().catch(()=>{});
+ // wait for an actual frame to land in the compositor
+ await new Promise(r => {
+ if ('requestVideoFrameCallback' in v) v.requestVideoFrameCallback(()=>r());
+ else setTimeout(r, 500);
+ });
+ const W = v.videoWidth, H = v.videoHeight;
+ log(`video ${W}x${H}`);
+
+ // 2. WebGL2 context + destination texture sized to the video.
+ const gl = document.getElementById('c').getContext('webgl2');
+ if (!gl) {
+ log('FAIL: no WebGL2');
+ if (window.domAutomationController) {
+ window.domAutomationController.send('failed: no WebGL2');
+ }
+ return;
+ }
+ const tex = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, tex);
+ // Allocate destination storage (R32F so RED/FLOAT sub-upload is legal).
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, W, H, 0, gl.RED, gl.FLOAT, null);
+ log('texStorage err=0x' + gl.getError().toString(16));
+
+ // 3. Set UNPACK_ROW_LENGTH > frame width. Leave UNPACK_FLIP_Y_WEBGL and
+ // UNPACK_PREMULTIPLY_ALPHA_WEBGL at their defaults (false) so that
+ // ScopedUnpackParametersResetRestore in the Y16 fast path is a no-op
+ // and ROW_LENGTH stays active inside the GLES2 client.
+ const ROW_LENGTH = W + 256;
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, ROW_LENGTH);
+ log(`UNPACK_ROW_LENGTH=${ROW_LENGTH}`);
+
+ // 4. Upload the Y16 video frame for 5 seconds.
+ const startTime = performance.now();
+ let frameCount = 0;
+ while (performance.now() - startTime < 5000) {
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RED, gl.FLOAT, v);
+ const err = gl.getError();
+ if (err !== gl.NO_ERROR) {
+ log(`texSubImage2D err=0x` + err.toString(16));
+ }
+ frameCount++;
+ await new Promise(r => requestAnimationFrame(r));
+ }
+
+ log(`Done ${frameCount} uploads. Reporting completion.`);
+ if (window.domAutomationController) {
+ window.domAutomationController.send('SUCCESS');
+ } else {
+ log('FAIL: domAutomationController not found');
+ }
+}
+run().catch(e => {
+ log('EXC: ' + e);
+ if (window.domAutomationController) {
+ window.domAutomationController.send('exception: ' + e);
+ }
+});
+</script>
+</body>
+</html>
diff --git a/content/test/gpu/gpu_tests/gpu_process_integration_test.py b/content/test/gpu/gpu_tests/gpu_process_integration_test.py
index 2b1f34c..3ea5421e 100644
--- a/content/test/gpu/gpu_tests/gpu_process_integration_test.py
+++ b/content/test/gpu/gpu_tests/gpu_process_integration_test.py
@@ -128,6 +128,7 @@
('GpuProcess_webgpu_iframe_removed', 'gpu/webgpu-iframe-removed.html'),
('GpuProcess_vulkan_vma_race', 'gpu/vulkan_vma_race.html'),
('GpuProcess_visibility', 'about:blank'),
+ ('GpuProcess_webgl_y16_uploads', 'gpu/webgl_y16_uploads.html'),
)
for t in tests:
@@ -474,6 +475,15 @@
self.fail(
f'Visibility callback call count expected 3, got {callback_count}')
+ def _GpuProcess_webgl_y16_uploads(self, test_path: str) -> None:
+ self.RestartBrowserIfNecessaryWithArgs([
+ '--use-fake-device-for-media-stream=device-count=2',
+ '--use-fake-ui-for-media-stream',
+ '--autoplay-policy=no-user-gesture-required',
+ '--use-angle=swiftshader',
+ ])
+ self._NavigateAndWait(test_path)
+
def _GpuProcess_disable_gpu_and_swiftshader(self, test_path: str) -> None:
# Disable SwiftShader, GPU process should launch for display compositing.
self.RestartBrowserIfNecessaryWithArgs(
diff --git a/content/test/gpu/gpu_tests/test_expectations/gpu_process_expectations.txt b/content/test/gpu/gpu_tests/test_expectations/gpu_process_expectations.txt
index 025e5c3..ef5776d 100644
--- a/content/test/gpu/gpu_tests/test_expectations/gpu_process_expectations.txt
+++ b/content/test/gpu/gpu_tests/test_expectations/gpu_process_expectations.txt
@@ -137,6 +137,10 @@
# Fails on Fuchsia cast receiver - not important to fix
crbug.com/500083376 [ fuchsia ] GpuProcess_vulkan_vma_race [ Skip ]
+# Test requires SwiftShader which isn't available on some platforms
+crbug.com/513197846 [ android ] GpuProcess_webgl_y16_uploads [ Skip ]
+crbug.com/513197846 [ fuchsia ] GpuProcess_webgl_y16_uploads [ Skip ]
+
###############################
# Temporary Skip Expectations #
###############################
Original Bug Report
Renderer OOB Heap Read via WebGL2 Y16 Video Upload and Stale UNPACK_ROW_LENGTH
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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic error in WebGL2’s optimized Y16 video upload path allows an out-of-bounds (OOB) heap read in the renderer process. The implementation fails to reset the UNPACK_ROW_LENGTH parameter when performing a fast-path upload, causing the GLES2 client to over-read from a tightly-allocated temporary buffer. This can result in leaking sensitive renderer memory into a texture that can be read by JavaScript.
Affected files:
third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.ccmedia/renderers/paint_canvas_video_renderer.ccgpu/command_buffer/client/gles2_implementation.ccthird_party/blink/renderer/modules/webgl/webgl2_rendering_context_base.cc
Estimated timestamp from git blame: 2016-12-04
Summary
A potential out-of-bounds (OOB) heap read exists in the WebGL2 implementation within Blink. When uploading a media::PIXEL_FORMAT_Y16 video frame using an optimized fast path, the implementation incorrectly gates the reset of GL unpack parameters. If certain WebGL-specific parameters like UNPACK_FLIP_Y_WEBGL are at their default values, the UNPACK_ROW_LENGTH parameter is not reset. Because the fast path uses a buffer tightly allocated for the image dimensions, a non-zero UNPACK_ROW_LENGTH causes the GPU client to read past the end of the buffer, leaking heap data into the resulting texture.
Root Cause Analysis
In WebGLRenderingContextBase::TexImageHelperMediaVideoFrame (located in third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc), an optimized path exists for PIXEL_FORMAT_Y16 frames. This path relies on source_image_rect_is_default to determine if a full-frame upload is occurring:
// lines 6455-6460
const bool source_image_rect_is_default =
params.unpack_skip_pixels == 0 && params.unpack_skip_rows == 0 &&
(!params.width || *params.width == media_video_frame->natural_size().width()) &&
(!params.height || *params.height == media_video_frame->natural_size().height());
This check is insufficient for WebGL2 because it does not account for UNPACK_ROW_LENGTH (or UNPACK_IMAGE_HEIGHT). Consequently, the fast path is entered even when a custom stride is set.
Furthermore, the path uses a ScopedUnpackParametersResetRestore object to manage GL state:
// line 6497
ScopedUnpackParametersResetRestore unpack_params(
this, unpack_flip_y_ || unpack_premultiply_alpha_); // Gated reset
The RAII object only calls ResetUnpackParameters() if its second argument is true. If both unpack_flip_y_ and unpack_premultiply_alpha_ are false, no reset occurs, leaving any previously set UNPACK_ROW_LENGTH active in the GL context.
PaintCanvasVideoRenderer then performs a tight allocation based only on the visible width and height:
// media/renderers/paint_canvas_video_renderer.cc:1167
*temp_buffer = base::MakeRefCounted<DataBuffer>(
output_row_bytes * frame->visible_rect().height());
Finally, when gl->TexSubImage2D is called (via media::PaintCanvasVideoRenderer::TexSubImage2D), the GLES2 client implementation (gpu/command_buffer/client/gles2_implementation.cc) computes the required data size based on the active (stale) UNPACK_ROW_LENGTH. If this value is greater than the frame’s width, the implementation reads past the end of the allocated temp_buffer while copying data to the GPU transfer buffer.
Potential Impact
This is a highly reliable information leak that allows an attacker to read arbitrary renderer heap data adjacent to the temporary allocation. By reading the resulting texture back via gl.readPixels(), an attacker can recover leaked pointers or other sensitive data, aiding in ASLR bypass. This vulnerability requires a Y16 video source, which typically requires a depth camera and user permission via getUserMedia.
Suggested Steps to Reproduce (Potential)
- Initialize a WebGL2 context.
- Obtain a Y16 video stream (e.g., from a depth camera or virtual driver) via
navigator.mediaDevices.getUserMedia. - Set
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, width + offset)whereoffset > 0andwidthis the video width. - Ensure
UNPACK_FLIP_Y_WEBGLandUNPACK_PREMULTIPLY_ALPHA_WEBGLarefalse(default). - Call
gl.texSubImage2Dpassing the video element as the source. - Use
gl.readPixelsto extract the texture data; the extra bytes per row will contain leaked heap data.
Recommended Fix
Update source_image_rect_is_default in WebGLRenderingContextBase::TexImageHelperMediaVideoFrame to also verify that unpack_row_length_ and unpack_image_height_ are zero. Alternatively, ensure that ScopedUnpackParametersResetRestore is always enabled or correctly accounts for WebGL2-specific pixel storage parameters in its activation logic.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
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.