CVE-2026-113134
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
HardenedTransformFeedbackTestsrc/tests/gl_tests/TransformFeedbackTest.cpp |
modified |
Files Changed
src/libANGLE/renderer/d3d/d3d11/InputLayoutCache.cppsrc/tests/gl_tests/TransformFeedbackTest.cppsrc/tests/gl_tests/MultiviewDrawTest.cpp
Patch
From 48a16bd63d2d5ac42b133ff4da511928af02eb97 Mon Sep 17 00:00:00 2001 From: Le Hoang Quyen <[email protected]> Date: Thu, 17 Sep 2026 17:18:13 +0800 Subject: [PATCH] D3D11: Fix InputLayoutCache key for disabled and multiview attributes In InputLayoutCache::getInputLayout, the divisor recorded in the cache key did not account for whether the attribute is enabled, nor did it use the overflow-safe multiview divisor helper: 1. When an attribute is disabled, D3D11 uses current-value data which is always stored and fetched with divisor 0 (per-vertex). Recording the binding's non-zero divisor in the cache key caused collisions with enabled attributes. 2. In multiview rendering, calculating the cache key divisor via wrapping 32-bit arithmetic could wrap to 0 (e.g. 0x80000000 * 2), while VertexArray11 clamped to UINT_MAX via GetMultiviewAdjustedDivisor. This change uses GetMultiviewAdjustedDivisor and sets the divisor to 0 for disabled attributes in the cache key. Bug: chromium:560423758 Bug: chromium:556505644 Change-Id: I0e256bb80d8e9588236be20e001271d9d0698b62 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8425179 Reviewed-by: Geoff Lang <[email protected]> Reviewed-by: Shahbaz Youssefi <[email protected]> Commit-Queue: Quyen Le <[email protected]> Auto-Submit: Quyen Le <[email protected]> --- diff --git a/src/libANGLE/renderer/d3d/d3d11/InputLayoutCache.cpp b/src/libANGLE/renderer/d3d/d3d11/InputLayoutCache.cpp index 3795eb4..a3607da 100644 --- a/src/libANGLE/renderer/d3d/d3d11/InputLayoutCache.cpp +++ b/src/libANGLE/renderer/d3d/d3d11/InputLayoutCache.cpp @@ -24,6 +24,7 @@ #include "libANGLE/renderer/d3d/d3d11/ShaderExecutable11.h" #include "libANGLE/renderer/d3d/d3d11/VertexArray11.h" #include "libANGLE/renderer/d3d/d3d11/formatutils11.h" +#include "libANGLE/renderer/renderer_utils.h" namespace rx { @@ -127,7 +128,7 @@ const auto &attribs = state.getVertexArray()->getVertexAttributes(); const auto &bindings = state.getVertexArray()->getVertexBindings(); const auto &locationToSemantic = executableD3D->getAttribLocationToD3DSemantics(); - int divisorMultiplier = executable->usesMultiview() ? executable->getNumViews() : 1; + int numViews = executable->usesMultiview() ? executable->getNumViews() : 1; for (size_t attribIndex : executable->getActiveAttribLocationsMask()) { @@ -143,8 +144,13 @@ state.getVertexAttribCurrentValue(static_cast<unsigned int>(attribIndex)); angle::FormatID vertexFormatID = gl::GetVertexFormatID(attrib, currentValue.Type); - layout.addAttributeData(glslElementType, d3dSemantic, vertexFormatID, - binding.getDivisor() * divisorMultiplier); + // Disabled attributes use current-value data, which is stored and fetched with divisor 0 + // (per-vertex). Using divisor 0 here prevents cache key collisions with enabled attributes + // whose binding has a non-zero divisor. + GLuint divisor = + attrib.enabled ? GetMultiviewAdjustedDivisor(numViews, binding.getDivisor()) : 0; + + layout.addAttributeData(glslElementType, d3dSemantic, vertexFormatID, divisor); } if (layout.numAttributes > 0) diff --git a/src/tests/gl_tests/MultiviewDrawTest.cpp b/src/tests/gl_tests/MultiviewDrawTest.cpp index df34655..cafb935 100644 --- a/src/tests/gl_tests/MultiviewDrawTest.cpp +++ b/src/tests/gl_tests/MultiviewDrawTest.cpp @@ -1622,6 +1622,76 @@ EXPECT_EQ(GLColor::green, GetViewColor(0, 0, 1)); } +// The test verifies that using a large attribute divisor does not cause an internal integer +// overflow when drawing with multiview after a prior draw with divisor 0. +TEST_P(MultiviewRenderTest, LargeAttribDivisorWithPriorDraw) +{ + ANGLE_SKIP_TEST_IF(!requestMultiviewExtension(isMultisampled())); + + updateFBOs(1, 1, 2); + + const std::string nonMultiviewVS = R"(#version 300 es +layout(location = 0) in float val; +out vec4 color; +void main() { + gl_Position = vec4(0.0, 0.0, 0.0, 1.0); + gl_PointSize = 1.0; + color = (val == 0.0) ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0); +})"; + + const std::string multiviewVS = R"(#version 300 es +#extension )" + extensionName() + + R"( : require +layout(num_views = 2) in; +layout(location = 0) in float val; +out vec4 color; +void main() { + gl_Position = vec4(0.0, 0.0, 0.0, 1.0); + gl_PointSize = 1.0; + color = (val == 0.0) ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0); +})"; + + constexpr char kFS[] = R"(#version 300 es +precision mediump float; +in vec4 color; +out vec4 outColor; +void main() { outColor = color; })"; + + ANGLE_GL_PROGRAM(nonMultiviewProgram, nonMultiviewVS.c_str(), kFS); + ANGLE_GL_PROGRAM(multiviewProgram, multiviewVS.c_str(), kFS); + + // Buffer with 2 floats: index 0 is 0.0f (green); index 1 is 1.0f (red). + const float kData[2] = {0.0f, 1.0f}; + GLBuffer buffer; + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glBufferData(GL_ARRAY_BUFFER, sizeof(kData), kData, GL_STATIC_DRAW); + + glVertexAttribPointer(0, 1, GL_FLOAT, GL_FALSE, 0, nullptr); + glEnableVertexAttribArray(0); + + // Initial draw with single-view program and divisor 0. + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + glVertexAttribDivisor(0, 0); + glUseProgram(nonMultiviewProgram); + glDrawArrays(GL_POINTS, 0, 1); + ASSERT_GL_NO_ERROR(); + + // Subsequent draw with 2-view multiview program and large divisor. + bindMemberDrawFramebuffer(); + glViewport(0, 0, 1, 1); + glClearColor(0, 0, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + + glVertexAttribDivisor(0, 0x80000000u); + glUseProgram(multiviewProgram); + glDrawArraysInstanced(GL_POINTS, 0, 2, 1); + ASSERT_GL_NO_ERROR(); + + resolveMultisampledFBO(); + EXPECT_EQ(GLColor::green, GetViewColor(0, 0, 0)); + EXPECT_EQ(GLColor::green, GetViewColor(0, 0, 1)); +} + // Test that different sequences of vertexAttribDivisor, useProgram and bindVertexArray in a // multi-view context propagate the correct divisor to the driver. TEST_P(MultiviewRenderTest, DivisorOrderOfOperation) diff --git a/src/tests/gl_tests/TransformFeedbackTest.cpp b/src/tests/gl_tests/TransformFeedbackTest.cpp index 3a74bc3..e71f68b 100644 --- a/src/tests/gl_tests/TransformFeedbackTest.cpp +++ b/src/tests/gl_tests/TransformFeedbackTest.cpp @@ -5473,6 +5473,72 @@ EXPECT_EQ(6u, primitivesWritten); } +// Test that an instanced attribute with a non-zero divisor produces the expected value across +// all vertices of an instance, even after an intervening draw where the attribute was disabled. +TEST_P(TransformFeedbackTest, InstancedAttributeAfterDisabledAttribDraw) +{ + constexpr char kVS[] = R"(#version 300 es +layout(location=0) in vec4 value; +out vec4 captured; +void main() { + captured = value; + gl_Position = vec4(0.0, 0.0, 0.0, 1.0); + gl_PointSize = 1.0; +})"; + + constexpr char kFS[] = R"(#version 300 es +precision highp float; +out vec4 color; +void main() { + color = vec4(0.0); +})"; + + std::vector<std::string> tfVaryings = {"captured"}; + ANGLE_GL_PROGRAM_TRANSFORM_FEEDBACK(program, kVS, kFS, tfVaryings, GL_INTERLEAVED_ATTRIBS); + glUseProgram(program); + + constexpr std::array<float, 4> kMarker = {11.25f, 22.5f, 33.75f, 44.125f}; + GLBuffer source; + glBindBuffer(GL_ARRAY_BUFFER, source); + glBufferData(GL_ARRAY_BUFFER, sizeof(kMarker), kMarker.data(), GL_DYNAMIC_DRAW); + glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 16, nullptr); + glVertexAttribDivisor(0, 0xffffffffu); + + // Initial draw with attribute 0 disabled. + glDisableVertexAttribArray(0); + glVertexAttrib4fv(0, kMarker.data()); + glDrawArraysInstanced(GL_POINTS, 0, 1, 1); + glFinish(); + + // Re-enable attribute 0 and capture vertices via transform feedback in an instanced draw. + glEnableVertexAttribArray(0); + constexpr GLsizei kCount = 64; + GLBuffer destination; + glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, destination); + glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kCount * sizeof(kMarker), nullptr, GL_DYNAMIC_READ); + glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, destination); + + glEnable(GL_RASTERIZER_DISCARD); + glBeginTransformFeedback(GL_POINTS); + glDrawArraysInstanced(GL_POINTS, 0, kCount, 1); + glEndTransformFeedback(); + glDisable(GL_RASTERIZER_DISCARD); + glFinish(); + ASSERT_GL_NO_ERROR(); + + const float *captured = static_cast<const float *>(glMapBufferRange( + GL_TRANSFORM_FEEDBACK_BUFFER, 0, kCount * sizeof(kMarker), GL_MAP_READ_BIT)); + ASSERT_NE(nullptr, captured); + for (GLsizei vertex = 0; vertex < kCount; ++vertex) + { + EXPECT_EQ(kMarker[0], captured[vertex * 4 + 0]) << "vertex " << vertex; + EXPECT_EQ(kMarker[1], captured[vertex * 4 + 1]) << "vertex " << vertex; + EXPECT_EQ(kMarker[2], captured[vertex * 4 + 2]) << "vertex " << vertex; + EXPECT_EQ(kMarker[3], captured[vertex * 4 + 3]) << "vertex " << vertex; + } + glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER); +} + class HardenedTransformFeedbackTest : public TransformFeedbackTest { public:
Regression Test / PoC
diff --git a/src/tests/gl_tests/MultiviewDrawTest.cpp b/src/tests/gl_tests/MultiviewDrawTest.cpp
index df34655..cafb935 100644
--- a/src/tests/gl_tests/MultiviewDrawTest.cpp
+++ b/src/tests/gl_tests/MultiviewDrawTest.cpp
@@ -1622,6 +1622,76 @@
EXPECT_EQ(GLColor::green, GetViewColor(0, 0, 1));
}
+// The test verifies that using a large attribute divisor does not cause an internal integer
+// overflow when drawing with multiview after a prior draw with divisor 0.
+TEST_P(MultiviewRenderTest, LargeAttribDivisorWithPriorDraw)
+{
+ ANGLE_SKIP_TEST_IF(!requestMultiviewExtension(isMultisampled()));
+
+ updateFBOs(1, 1, 2);
+
+ const std::string nonMultiviewVS = R"(#version 300 es
+layout(location = 0) in float val;
+out vec4 color;
+void main() {
+ gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
+ gl_PointSize = 1.0;
+ color = (val == 0.0) ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
+})";
+
+ const std::string multiviewVS = R"(#version 300 es
+#extension )" + extensionName() +
+ R"( : require
+layout(num_views = 2) in;
+layout(location = 0) in float val;
+out vec4 color;
+void main() {
+ gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
+ gl_PointSize = 1.0;
+ color = (val == 0.0) ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
+})";
+
+ constexpr char kFS[] = R"(#version 300 es
+precision mediump float;
+in vec4 color;
+out vec4 outColor;
+void main() { outColor = color; })";
+
+ ANGLE_GL_PROGRAM(nonMultiviewProgram, nonMultiviewVS.c_str(), kFS);
+ ANGLE_GL_PROGRAM(multiviewProgram, multiviewVS.c_str(), kFS);
+
+ // Buffer with 2 floats: index 0 is 0.0f (green); index 1 is 1.0f (red).
+ const float kData[2] = {0.0f, 1.0f};
+ GLBuffer buffer;
+ glBindBuffer(GL_ARRAY_BUFFER, buffer);
+ glBufferData(GL_ARRAY_BUFFER, sizeof(kData), kData, GL_STATIC_DRAW);
+
+ glVertexAttribPointer(0, 1, GL_FLOAT, GL_FALSE, 0, nullptr);
+ glEnableVertexAttribArray(0);
+
+ // Initial draw with single-view program and divisor 0.
+ glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
+ glVertexAttribDivisor(0, 0);
+ glUseProgram(nonMultiviewProgram);
+ glDrawArrays(GL_POINTS, 0, 1);
+ ASSERT_GL_NO_ERROR();
+
+ // Subsequent draw with 2-view multiview program and large divisor.
+ bindMemberDrawFramebuffer();
+ glViewport(0, 0, 1, 1);
+ glClearColor(0, 0, 0, 1);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ glVertexAttribDivisor(0, 0x80000000u);
+ glUseProgram(multiviewProgram);
+ glDrawArraysInstanced(GL_POINTS, 0, 2, 1);
+ ASSERT_GL_NO_ERROR();
+
+ resolveMultisampledFBO();
+ EXPECT_EQ(GLColor::green, GetViewColor(0, 0, 0));
+ EXPECT_EQ(GLColor::green, GetViewColor(0, 0, 1));
+}
+
// Test that different sequences of vertexAttribDivisor, useProgram and bindVertexArray in a
// multi-view context propagate the correct divisor to the driver.
TEST_P(MultiviewRenderTest, DivisorOrderOfOperation)
diff --git a/src/tests/gl_tests/TransformFeedbackTest.cpp b/src/tests/gl_tests/TransformFeedbackTest.cpp
index 3a74bc3..e71f68b 100644
--- a/src/tests/gl_tests/TransformFeedbackTest.cpp
+++ b/src/tests/gl_tests/TransformFeedbackTest.cpp
@@ -5473,6 +5473,72 @@
EXPECT_EQ(6u, primitivesWritten);
}
+// Test that an instanced attribute with a non-zero divisor produces the expected value across
+// all vertices of an instance, even after an intervening draw where the attribute was disabled.
+TEST_P(TransformFeedbackTest, InstancedAttributeAfterDisabledAttribDraw)
+{
+ constexpr char kVS[] = R"(#version 300 es
+layout(location=0) in vec4 value;
+out vec4 captured;
+void main() {
+ captured = value;
+ gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
+ gl_PointSize = 1.0;
+})";
+
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+out vec4 color;
+void main() {
+ color = vec4(0.0);
+})";
+
+ std::vector<std::string> tfVaryings = {"captured"};
+ ANGLE_GL_PROGRAM_TRANSFORM_FEEDBACK(program, kVS, kFS, tfVaryings, GL_INTERLEAVED_ATTRIBS);
+ glUseProgram(program);
+
+ constexpr std::array<float, 4> kMarker = {11.25f, 22.5f, 33.75f, 44.125f};
+ GLBuffer source;
+ glBindBuffer(GL_ARRAY_BUFFER, source);
+ glBufferData(GL_ARRAY_BUFFER, sizeof(kMarker), kMarker.data(), GL_DYNAMIC_DRAW);
+ glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 16, nullptr);
+ glVertexAttribDivisor(0, 0xffffffffu);
+
+ // Initial draw with attribute 0 disabled.
+ glDisableVertexAttribArray(0);
+ glVertexAttrib4fv(0, kMarker.data());
+ glDrawArraysInstanced(GL_POINTS, 0, 1, 1);
+ glFinish();
+
+ // Re-enable attribute 0 and capture vertices via transform feedback in an instanced draw.
+ glEnableVertexAttribArray(0);
+ constexpr GLsizei kCount = 64;
+ GLBuffer destination;
+ glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, destination);
+ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kCount * sizeof(kMarker), nullptr, GL_DYNAMIC_READ);
+ glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, destination);
+
+ glEnable(GL_RASTERIZER_DISCARD);
+ glBeginTransformFeedback(GL_POINTS);
+ glDrawArraysInstanced(GL_POINTS, 0, kCount, 1);
+ glEndTransformFeedback();
+ glDisable(GL_RASTERIZER_DISCARD);
+ glFinish();
+ ASSERT_GL_NO_ERROR();
+
+ const float *captured = static_cast<const float *>(glMapBufferRange(
+ GL_TRANSFORM_FEEDBACK_BUFFER, 0, kCount * sizeof(kMarker), GL_MAP_READ_BIT));
+ ASSERT_NE(nullptr, captured);
+ for (GLsizei vertex = 0; vertex < kCount; ++vertex)
+ {
+ EXPECT_EQ(kMarker[0], captured[vertex * 4 + 0]) << "vertex " << vertex;
+ EXPECT_EQ(kMarker[1], captured[vertex * 4 + 1]) << "vertex " << vertex;
+ EXPECT_EQ(kMarker[2], captured[vertex * 4 + 2]) << "vertex " << vertex;
+ EXPECT_EQ(kMarker[3], captured[vertex * 4 + 3]) << "vertex " << vertex;
+ }
+ glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
+}
+
class HardenedTransformFeedbackTest : public TransformFeedbackTest
{
public:
Original Bug Report
WebGL2 ANGLE D3D11 multiview input-layout cache collision leaks cross-site GPU data
Report description
WebGL2 ANGLE D3D11 multiview input-layout cache collision leaks cross-site GPU data
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
The problem
Please describe the technical details of the vulnerability
Summary
A WebGL2 page can make ANGLE’s D3D11 backend reuse a cached per-vertex input layout for an attribute whose current multiview-adjusted divisor is UINT_MAX. The cache key computes the adjusted divisor with wrapping 32-bit multiplication, while the translated attribute uses a hardened 64-bit calculation and clamps the same value. This mismatch causes D3D11 to fetch subsequent vertices from bytes beyond the one element that ANGLE validated and copied into its internal dynamic vertex buffer.
On the current official Stable 153.0.8010.36 and Dev 155.0.8048.0 builds using their default ANGLE D3D11 backend, the attached PoC recovered a victim-only 16-byte value across different-site WebGL contexts. A separate Chrome 155 OOPIF inventory run confirmed that Chrome placed the victims in different-site iframe renderer targets. The attacker’s direct request for the value received HTTP 403, and the victim sent no messages or data to the attacker.
Root cause
Current Dev Chrome 155.0.8048.0 reports ANGLE revision 59229fef1fb2a80e8f514f16aa27d14af739e17d. That revision contains the multiview divisor overflow hardening from ANGLE commit 5cbd3b1344b5b89f7e6334971ce26671433cbfd1.
VertexArray11 obtains the divisor used by the native D3D11 input layout through the hardened helper:
translatedAttrib->divisor = GetMultiviewAdjustedDivisor(
mAppliedNumViewsToDivisor, translatedAttrib->binding->getDivisor());
The helper performs the multiplication in 64 bits and clamps the result:
inline GLuint GetMultiviewAdjustedDivisor(int numViews, GLuint divisor)
{
uint64_t adjusted = static_cast<uint64_t>(numViews) * static_cast<uint64_t>(divisor);
return static_cast<GLuint>(
std::min<uint64_t>(adjusted, std::numeric_limits<GLuint>::max()));
}
For a two-view shader and an original divisor of 0x80000000, the translated divisor is therefore UINT_MAX.
The cache key in src/libANGLE/renderer/d3d/d3d11/InputLayoutCache.cpp independently calculates the adjusted divisor:
int divisorMultiplier = executable->usesMultiview() ? executable->getNumViews() : 1;
layout.addAttributeData(glslElementType, d3dSemantic, vertexFormatID,
binding.getDivisor() * divisorMultiplier);
binding.getDivisor() is a 32-bit GLuint. Multiplying 0x80000000 by two wraps to zero. The resulting PackedAttributeLayout key is identical to the key for the same attribute with divisor zero.
The PoC first draws with a non-multiview program and divisor zero. This inserts an input layout whose native classification is per-vertex:
D3D11_INPUT_CLASSIFICATION inputClass =
attrib.divisor > 0 ? D3D11_INPUT_PER_INSTANCE_DATA
: D3D11_INPUT_PER_VERTEX_DATA;
inputElement->InputSlotClass = inputClass;
inputElement->InstanceDataStepRate = attrib.divisor;
The PoC then switches to a two-view program and sets divisor 0x80000000. The wrapping key finds the divisor-zero entry, so createInputLayout() is not called for the translated UINT_MAX divisor. ANGLE reuses D3D11_INPUT_PER_VERTEX_DATA instead of creating D3D11_INPUT_PER_INSTANCE_DATA with step rate UINT_MAX.
The frontend and streaming path use the original nonzero WebGL divisor to calculate how many elements are required:
size_t totalCount = gl::ComputeVertexBindingElementCount(
binding.getDivisor(), count, clampedInstances, 0);
For one instance and divisor 0x80000000, one attribute element is sufficient and one converted element is copied. The cached per-vertex D3D layout nevertheless advances through the internal vertex buffer for all 64 vertices. The first vertex returns the attacker’s element, while later vertices return bytes after the intended one-element region.
Renderer11 owns a StateManager11, and that state manager owns the VertexDataManager used while the D3D11 renderer services WebGL contexts. The manager creates a 1 MiB internal streaming vertex buffer:
INITIAL_STREAM_BUFFER_SIZE = 1024 * 1024
VertexBuffer11::initialize() allocates the native dynamic D3D11 buffer with no initial data:
D3D11_BUFFER_DESC bufferDesc;
bufferDesc.ByteWidth = size;
bufferDesc.Usage = D3D11_USAGE_DYNAMIC;
bufferDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
bufferDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
ANGLE_TRY(mRenderer->allocateResource(
GetImplAs<Context11>(context), bufferDesc, &mBuffer));
The victim draws place their converted vertex data in this internal streaming state. Destroying the victim WebGL contexts does not clear every byte in the backing streaming allocation before later contexts use it. The attacker’s normal streaming write updates its intended region, while the wrongly reused per-vertex input layout fetches from the stale tail containing the prior victim data and returns it through an ordinary framebuffer readPixels() operation.
The current audited ANGLE revision f792931409058cea1049574dfb59ec4da89ff612 retains the same discrepancy: VertexArray11 uses GetMultiviewAdjustedDivisor(), while InputLayoutCache still uses the wrapping multiplication. Chrome 155.0.8048.0’s ANGLE revision is a descendant of the divisor-clamping commit and retains both code paths, so the latest Dev reproduction occurs after the earlier arithmetic hardening rather than relying only on a pre-hardening build.
The regression test added by that hardening commit creates the multiview program and draws directly with the large divisor. On a cache miss, createInputLayout() sees the clamped translated divisor and creates the correct per-instance layout, so the test passes. It does not first populate the cache with the colliding divisor-zero layout. The PoC exercises this missing cache-history sequence.
Reproduction environment
- Official Chrome for Testing 153.0.8010.36 Stable and 155.0.8048.0 Dev, x64
- Windows 11 x64
- Intel Iris Xe, driver 30.0.101.1747
- Renderer:
ANGLE (Intel, Intel(R) Iris(R) Xe Graphics (0x000046A6) Direct3D11 vs_5_0 ps_5_0, D3D11) - GL implementation:
(gl=egl-angle,angle=d3d11) - Display type:
ANGLE_D3D11 - Stable GL version:
OpenGL ES 3.0 (ANGLE 2.1.28650 git hash: fca5efdfeff5) - Dev GL version:
OpenGL ES 3.0 (ANGLE 2.1.28786 git hash: 59229fef1fb2) - GPU process:
sandboxed: true,inProcessGpu: false
The disclosure uses Chrome’s default backend on this system. No --use-angle, --use-gl, unsafe, experimental, software-renderer, single-process, or in-process-GPU flag was used. The evidence runs used a fresh profile and remote debugging only to collect the page result and process inventory. Neither is required for manual reproduction.
The exact current Stable and Dev builds each reproduced the complete disclosure in fresh profiles. The finalized PoC was also run three times on Chrome 155.0.8040.2 with independently restarted servers and three distinct secrets, succeeding in 3/3 runs. An additional fresh-profile OOPIF inventory run with a longer victim hold also reproduced the complete disclosure. The exact current Dev control returned only the attacker marker in all 320 sampled words and recovered no secret.
PoC
Place attacker.html, victim.html, and server.py in one directory and run:
python3 server.py --port 8047
Open the following URL in Chrome:
http://localhost:8047/attacker.html
The top-level attacker is http://localhost:8047. It embeds victims from http://127.0.0.1:8047, a different site and origin. The server creates a random 16-byte value at startup. /private-gpu-data.bin returns the value only to same-origin victim requests; the attacker’s direct cross-site request receives HTTP 403. The public /commitment endpoint contains only the value’s SHA-256 digest.
Each victim independently fetches the private value and submits it as WebGL2 vertex data. The victims do not call postMessage, transfer framebuffer contents, or acknowledge draw completion. After the victim contexts are destroyed, the attacker creates WebGL2 contexts and triggers the multiview cache-key collision while scanning the later contents of ANGLE’s persistent internal streaming state. Candidate bytes are accepted only when their SHA-256 equals the public commitment.
A successful result contains:
status: CROSS_SITE_SECRET_RECOVERED
commitmentMatched: true
directReadBlocked: true
victimToAttackerMessages: 0
glError: 0
contextLost: false
The three finalized default-timing runs recovered:
DQR0UHHAVQVV7XKM
HM2P2G2WDN7D4BQB
KBM4GZICQVO0IFDH
Each value matched the separately printed server secret and its SHA-256 commitment. The additional OOPIF inventory run recovered RTIJNHCP7HNSH9TA and matched commitment 61f72c7d2540dea0bdeffc1f7fdbc6a47e51e6bf95eee5f39d1fb659a17adafb.
The exact current Stable 153.0.8010.36 and Dev 155.0.8048.0 builds both recovered LQKEG6T4CTRGX7OF in separate fresh profiles and matched commitment ca7e3d73e370aeac066c7d8a172339f2a8289a981b967b61384a97b783163d48.
For the control, use a fresh profile and open:
http://localhost:8047/attacker.html?control=1
The control changes only the divisor to 0x40000000. With two views, its cache key is 0x80000000 and does not collide with the primed divisor-zero entry. ANGLE creates the correct per-instance D3D11 layout. All 64 returned values in every scan equal the attacker’s marker [213,109,47,239], and the result is NO_SECRET_RECOVERED.
Expected behavior: the cache key must use the same overflow-safe adjusted divisor as VertexArray11, and WebGL content must never read bytes beyond the vertex elements validated and copied for its draw.
Actual behavior: the wrapping key reuses a per-vertex input layout for a per-instance attribute and returns stale vertex data from destroyed, different-site WebGL contexts to attacker JavaScript.
Impact analysis
A remote attacker can exploit this vulnerability from ordinary WebGL2 content on a Windows system using Chrome’s default ANGLE D3D11 backend. No permission or user interaction beyond loading the page is required, and the attack does not rely on an unsafe flag, software renderer, modified browser, renderer compromise, or GPU-process crash.
The input-layout cache collision gives attacker JavaScript a readback path for stale bytes in ANGLE’s internal D3D11 vertex streaming state. The D3D11 renderer uses this state to translate WebGL vertex data while servicing contexts in the shared GPU process. A different site’s WebGL data can remain in later bytes of the backing streaming allocation after its WebGL context is destroyed; the attacker’s incorrectly classified vertex input reads those bytes and copies them into an attacker-readable framebuffer.
The PoC demonstrates the confidentiality-boundary impact with a top-level attacker at http://localhost:8047 and victims at http://127.0.0.1:8047. Chrome exposes the victims as separate OOPIF renderer targets. The server generates a random 16-byte value and permits only the victim origin to fetch the raw bytes. The attacker origin receives HTTP 403. The victims load the bytes into WebGL and do not send the attacker messages, framebuffer data, or completion acknowledgements. The attacker nevertheless reconstructs all 16 bytes from stale shared GPU state, and the recovered value matches the public SHA-256 commitment.
The finalized PoC recovered three independently generated values in 3/3 fresh-profile runs on official Chrome 155.0.8040.2 with default ANGLE D3D11. A fourth run used to capture the OOPIF process inventory also recovered its independently generated value. Follow-up fresh-profile tests reproduced the complete disclosure on the exact current Stable 153.0.8010.36 and current Dev 155.0.8048.0 builds. The non-colliding control on current Dev returned only attacker-owned marker bytes in all 320 samples and recovered no victim value.
This provides a JavaScript-readable disclosure primitive across Chrome’s different-site renderer isolation boundary. WebGL vertex data can contain values derived from private page state, 3D assets, visualization data, decoded media, and application content. The vulnerability allows an attacker origin to recover such victim-origin GPU input data through shared GPU-process execution state.
The cause
What version of Chrome have you found the security issue in?
153.0.8010.36 - Stable (official Chrome for Testing build, Windows 11 x64, default ANGLE D3D11) / 155.0.8048.0 - Dev (official Chrome for Testing build, Windows 11 x64, default ANGLE D3D11)
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Information Leak
How would you like to be publicly acknowledged for your report?
Jinpyo Lee