CVE-2026-113340
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
VertexAttributeTestES31_Basicsrc/tests/gl_tests/VertexAttributeTest.cpp |
modified |
Files Changed
src/libANGLE/renderer/d3d/VertexDataManager.hsrc/libANGLE/renderer/d3d/VertexDataManager.cppsrc/tests/gl_tests/VertexAttributeTest.cpp
Patch
From 97efe786edd6c722fd6e173aad84421a58dbd12e Mon Sep 17 00:00:00 2001
From: Ran Wang <wangra@google.com>
Date: Fri, 25 Sep 2026 11:08:24 -0400
Subject: [PATCH] D3D: Use optional instead of NaN sentinel for current-value cache
Replace the quiet_NaN sentinel in CurrentValueState with std::optional
to prevent initial NaN vertex attribute values from matching
uninitialized cache state and skipping vertex buffer uploads.
Test: angle_end2end_tests --gtest_filter="*NaNCurrentValueInitialDraw*"
Bug: b/564213722
Change-Id: Ic518e7a5cf1973af3cadce1514613a3f5c76faf3
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8468927
Commit-Queue: Ran Wang <wangra@google.com>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
---
diff --git a/src/libANGLE/renderer/d3d/VertexDataManager.cpp b/src/libANGLE/renderer/d3d/VertexDataManager.cpp
index 3a3340a..53d8432 100644
--- a/src/libANGLE/renderer/d3d/VertexDataManager.cpp
+++ b/src/libANGLE/renderer/d3d/VertexDataManager.cpp
@@ -203,13 +203,7 @@
VertexDataManager::CurrentValueState::CurrentValueState(BufferFactoryD3D *factory)
: buffer(new StreamingVertexBufferInterface(factory)), offset(0)
-{
- data.Values.FloatValues[0] = std::numeric_limits<float>::quiet_NaN();
- data.Values.FloatValues[1] = std::numeric_limits<float>::quiet_NaN();
- data.Values.FloatValues[2] = std::numeric_limits<float>::quiet_NaN();
- data.Values.FloatValues[3] = std::numeric_limits<float>::quiet_NaN();
- data.Type = gl::VertexAttribType::Float;
-}
+{}
VertexDataManager::CurrentValueState::CurrentValueState(CurrentValueState &&other)
{
@@ -616,7 +610,7 @@
ANGLE_TRY(buffer.initialize(context, CONSTANT_VERTEX_BUFFER_SIZE));
}
- if (cachedState->data != currentValue)
+ if (!cachedState->data.has_value() || *cachedState->data != currentValue)
{
ASSERT(translated->attribute && translated->binding);
const auto &attrib = *translated->attribute;
diff --git a/src/libANGLE/renderer/d3d/VertexDataManager.h b/src/libANGLE/renderer/d3d/VertexDataManager.h
index e6d8cf0..2ca2947 100644
--- a/src/libANGLE/renderer/d3d/VertexDataManager.h
+++ b/src/libANGLE/renderer/d3d/VertexDataManager.h
@@ -10,6 +10,8 @@
#ifndef LIBANGLE_RENDERER_D3D_VERTEXDATAMANAGER_H_
#define LIBANGLE_RENDERER_D3D_VERTEXDATAMANAGER_H_
+#include <optional>
+
#include "common/angleutils.h"
#include "libANGLE/Constants.h"
#include "libANGLE/VertexAttribute.h"
@@ -135,7 +137,7 @@
~CurrentValueState();
std::unique_ptr<StreamingVertexBufferInterface> buffer;
- gl::VertexAttribCurrentValueData data;
+ std::optional<gl::VertexAttribCurrentValueData> data;
size_t offset;
};
diff --git a/src/tests/gl_tests/VertexAttributeTest.cpp b/src/tests/gl_tests/VertexAttributeTest.cpp
index 35e57ea..bbd3457 100644
--- a/src/tests/gl_tests/VertexAttributeTest.cpp
+++ b/src/tests/gl_tests/VertexAttributeTest.cpp
@@ -6996,6 +6996,73 @@
swapBuffers();
}
+// Test that setting a disabled vertex attribute's current value to NaN before any draw
+// correctly writes the NaN values to the vertex buffer and does not read uninitialized memory
+// (b/564213722).
+TEST_P(VertexAttributeTestES3, NaNCurrentValueInitialDraw)
+{
+ constexpr char kVS[] =
+ R"(#version 300 es
+in vec4 a_position;
+in vec4 a_test;
+out vec4 v_color;
+void main()
+{
+ gl_Position = a_position;
+ if (all(isnan(a_test)))
+ {
+ v_color = vec4(0.0, 1.0, 0.0, 1.0);
+ }
+ else
+ {
+ v_color = vec4(1.0, 0.0, 0.0, 1.0);
+ }
+})";
+
+ constexpr char kFS[] =
+ R"(#version 300 es
+precision mediump float;
+in vec4 v_color;
+out vec4 fragColor;
+void main()
+{
+ fragColor = v_color;
+})";
+
+ ANGLE_GL_PROGRAM(program, kVS, kFS);
+ glUseProgram(program);
+
+ GLint testLocation = glGetAttribLocation(program, "a_test");
+ ASSERT_NE(-1, testLocation);
+
+ glDisableVertexAttribArray(testLocation);
+
+ const float kNaN = std::numeric_limits<float>::quiet_NaN();
+ glVertexAttrib4f(testLocation, kNaN, kNaN, kNaN, kNaN);
+
+ drawQuad(program, "a_position", 0.5f);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+
+ // Verify that subsequent changes also work properly.
+ glVertexAttrib4f(testLocation, 1.0f, 2.0f, 3.0f, 4.0f);
+ glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(program, "a_position", 0.5f);
+ EXPECT_GL_NO_ERROR();
+ // Now it should be red because it is no longer NaN.
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+
+ // Transition back to NaN.
+ glVertexAttrib4f(testLocation, kNaN, kNaN, kNaN, kNaN);
+ glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(program, "a_position", 0.5f);
+ EXPECT_GL_NO_ERROR();
+ // Must be green again.
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+}
+
class VertexAttributeTestES31_Basic : public ANGLETest<>
{
protected:
Regression Test / PoC
diff --git a/src/tests/gl_tests/VertexAttributeTest.cpp b/src/tests/gl_tests/VertexAttributeTest.cpp
index 35e57ea..bbd3457 100644
--- a/src/tests/gl_tests/VertexAttributeTest.cpp
+++ b/src/tests/gl_tests/VertexAttributeTest.cpp
@@ -6996,6 +6996,73 @@
swapBuffers();
}
+// Test that setting a disabled vertex attribute's current value to NaN before any draw
+// correctly writes the NaN values to the vertex buffer and does not read uninitialized memory
+// (b/564213722).
+TEST_P(VertexAttributeTestES3, NaNCurrentValueInitialDraw)
+{
+ constexpr char kVS[] =
+ R"(#version 300 es
+in vec4 a_position;
+in vec4 a_test;
+out vec4 v_color;
+void main()
+{
+ gl_Position = a_position;
+ if (all(isnan(a_test)))
+ {
+ v_color = vec4(0.0, 1.0, 0.0, 1.0);
+ }
+ else
+ {
+ v_color = vec4(1.0, 0.0, 0.0, 1.0);
+ }
+})";
+
+ constexpr char kFS[] =
+ R"(#version 300 es
+precision mediump float;
+in vec4 v_color;
+out vec4 fragColor;
+void main()
+{
+ fragColor = v_color;
+})";
+
+ ANGLE_GL_PROGRAM(program, kVS, kFS);
+ glUseProgram(program);
+
+ GLint testLocation = glGetAttribLocation(program, "a_test");
+ ASSERT_NE(-1, testLocation);
+
+ glDisableVertexAttribArray(testLocation);
+
+ const float kNaN = std::numeric_limits<float>::quiet_NaN();
+ glVertexAttrib4f(testLocation, kNaN, kNaN, kNaN, kNaN);
+
+ drawQuad(program, "a_position", 0.5f);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+
+ // Verify that subsequent changes also work properly.
+ glVertexAttrib4f(testLocation, 1.0f, 2.0f, 3.0f, 4.0f);
+ glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(program, "a_position", 0.5f);
+ EXPECT_GL_NO_ERROR();
+ // Now it should be red because it is no longer NaN.
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+
+ // Transition back to NaN.
+ glVertexAttrib4f(testLocation, kNaN, kNaN, kNaN, kNaN);
+ glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ drawQuad(program, "a_position", 0.5f);
+ EXPECT_GL_NO_ERROR();
+ // Must be green again.
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+}
+
class VertexAttributeTestES31_Basic : public ANGLETest<>
{
protected:
Original Bug Report
ANGLE D3D11 NaN current-value sentinel leaks victim GPU data across Site Isolation boundaries
Report description
ANGLE D3D11 NaN current-value sentinel leaks victim GPU data across Site Isolation boundaries
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 bind newly allocated 4096-byte current-value vertex buffers without ever writing their first logical values. The page supplies four exact 0x7fc00000 float words, which compare equal to ANGLE’s four-quiet_NaN() cache sentinel. ANGLE allocates each native buffer, treats the attacker value as already cached, skips the initial store, and binds offset zero. Transform feedback returns existing bytes from reused D3D11 allocations to JavaScript.
Released Chrome 153.0.8010.52 Stable and Chrome 156.0.8063.3 Dev reproduce the issue on Windows 11 with the default ANGLE D3D11 backend and the sandboxed, out-of-process GPU process. The attached PoC recovered eight independently generated victim values totaling 128 bytes from a different-site OOPIF after the victim target was destroyed. The attacker received HTTP 403 for the private endpoint, received no victim data message, and every recovered value matched a victim-only SHA-256 commitment.
The final Stable PoC recovered all eight values in 5/5 fresh-profile runs. Browser-level evidence records the victim as a separate iframe target whose parentId is the attacker target, records the victim target’s destruction before recovery, and records the same sandboxed GPU process before and after the disclosure. A separate top-level variant placed the victim in a non-default Incognito browser context, closed the victim target and disposed that context before creating the regular-context attacker, and recovered all 128 bytes. Exact non-colliding controls on both paths recovered zero victim bytes.
Root cause
The behavior is present in Chrome Stable’s ANGLE revision ed04c8113c6538f3e1264042da8cb4afd100369b, Chrome Dev’s revision 94e010815a2c80b96d2a80d252b2138f6729847f, and reviewed ANGLE main revision 2bb28bbcef760ede0c084f4cf3341f495887f1f5.
VertexDataManager::CurrentValueState in src/libANGLE/renderer/d3d/VertexDataManager.cpp initializes its cached value to four quiet NaNs and type Float:
VertexDataManager::CurrentValueState::CurrentValueState(BufferFactoryD3D *factory)
: buffer(new StreamingVertexBufferInterface(factory)), offset(0)
{
data.Values.FloatValues[0] = std::numeric_limits<float>::quiet_NaN();
data.Values.FloatValues[1] = std::numeric_limits<float>::quiet_NaN();
data.Values.FloatValues[2] = std::numeric_limits<float>::quiet_NaN();
data.Values.FloatValues[3] = std::numeric_limits<float>::quiet_NaN();
data.Type = gl::VertexAttribType::Float;
}
VertexAttribCurrentValueData::operator== in src/libANGLE/VertexAttribute.inc compares all value bytes:
inline bool operator==(const VertexAttribCurrentValueData &a,
const VertexAttribCurrentValueData &b)
{
return (a.Type == b.Type &&
memcmp(&a.Values, &b.Values,
sizeof(VertexAttribCurrentValueData::Values)) == 0);
}
WebGL vertexAttrib4fv accepts four canonical quiet NaNs. On the tested Windows builds, the exact representation is four 0x7fc00000 words, so an attacker-controlled current value can equal the internal sentinel.
storeCurrentValue() allocates the 4096-byte native buffer before checking whether the current value differs from that sentinel:
if (buffer.getBufferSize() == 0)
{
ANGLE_TRY(buffer.initialize(context, CONSTANT_VERTEX_BUFFER_SIZE));
}
if (cachedState->data != currentValue)
{
ANGLE_TRY(buffer.reserveVertexSpace(context, attrib, binding, 1, 0, 0));
const uint8_t *sourceData =
reinterpret_cast<const uint8_t *>(currentValue.Values.FloatValues);
unsigned int streamOffset;
ANGLE_TRY(buffer.storeDynamicAttribute(context, attrib, binding, currentValue.Type,
0, 1, 0, 0, &streamOffset, sourceData));
cachedState->data = currentValue;
cachedState->offset = streamOffset;
}
translated->vertexBuffer.set(buffer.getVertexBuffer());
translated->baseOffset = static_cast<unsigned int>(cachedState->offset);
For the colliding value, the allocation occurs but the complete store block is skipped. The default offset remains zero and the new native buffer is bound.
VertexBuffer11::initialize() creates the buffer without 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;
bufferDesc.MiscFlags = 0;
bufferDesc.StructureByteStride = 0;
ANGLE_TRY(mRenderer->allocateResource(GetImplAs<Context11>(context),
bufferDesc, &mBuffer));
The allocation path calls ID3D11Device::CreateBuffer(desc, nullptr, buffer). There is no validity bit in CurrentValueState to distinguish the sentinel from a native value that was actually stored.
The victim uses disabled active attributes 8 through 15 for eight distinct 16-byte private values. It alternates one bit in each value to force a current-value update, draws, and repeats 16,384 times. Each attribute uses a 4096-byte streaming buffer. After 256 stores, StreamingVertexBufferInterface::reserveSpace() calls discard(), and VertexBuffer11::discard() maps the buffer with D3D11_MAP_WRITE_DISCARD. This populates the D3D11 rename pool with 4096-byte backings containing the eight victim values.
Attributes 0 through 7 remain untouched. After the victim target is destroyed, the attacker supplies the four-NaN sentinel to those eight attributes. ANGLE creates eight current-value buffers, skips every first write, and receives victim-populated backings. A one-point draw forwards the first 16 bytes of each allocation through transform feedback, and getBufferSubData makes all 128 bytes available to JavaScript.
Renderer11 owns StateManager11, which owns one VertexDataManager; Context11 draws use mRenderer->getStateManager(). This backend state and the D3D11 allocation pool are shared across the tested WebGL contexts. The victim and attacker have different renderer targets under Site Isolation but use the same sandboxed GPU process, which is where the disclosure occurs.
Reproduction environment
Google Chrome for Testing 153.0.8010.52 Stable, 64-bit Installed Google Chrome 153.0.8010.50 Stable, 64-bit Google Chrome for Testing 156.0.8063.3 Dev, 64-bit Windows 11 x64, Windows NT 10.0.26200.9457 Intel Iris Xe, PCI device 0x46A6, 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 GPU process: sandboxed: true, inProcessGpu: false
The tests used Chrome’s default ANGLE D3D11 backend. No --use-angle, --use-gl, unsafe, experimental, software-renderer, single-process, no-sandbox, or in-process-GPU flag was used. Each primary reproduction directly loaded the attached poc.html in a fresh Chrome profile. No automation, debugging client, or non-default graphics configuration is required.
PoC
Place the attached poc.html, victim.html, and server.py in one directory and run:
python3 server.py
Open the following URL in Chrome with a fresh profile:
http://127.0.0.1:8000/poc.html
The attacker runs at http://127.0.0.1:8000 and creates a victim iframe at http://localhost:8000, a different site and origin. The server creates eight distinct random 16-byte finite-float values, totaling 128 bytes. /private-data returns the raw values only to the victim. The attacker receives HTTP 403 and sees only eight SHA-256 commitments.
The victim writes the values into eight current-value buffers, submits the draws, sends only victim-done, and is removed. The attacker triggers the NaN sentinel collision on eight untouched attributes, reads 128 bytes through transform feedback, normalizes the one bit used to force victim cache updates, and accepts the result only when all eight SHA-256 values match the commitments.
A successful result contains:
status: CROSS_SITE_BULK_GPU_DATA_RECOVERED
privateDirectFetchStatus: 403
victimIframeRemovedBeforeRecovery: true
victimCompletionMessages: 1
victimDataMessages: 0
disclosedByteCount: 128
uniqueVictimValuesRecovered: 8
commitmentMatched: true
glError: 0
contextLost: false
The final Stable test recovered all 128 bytes in 5/5 fresh-profile runs. The eight commitments for those runs were:
d071734cedf7fec91674131796473d497349b9a0084a5854709b1e2bd91660cb
6b688de0f74896f85e9ddc200ab6cc9917aa1a32aa7dc0c5808647aa6d5b8967
1cb3ec3d7ab11c675d131cef670fd7c65ec263c1a83898564eeb230b8fc298e8
8ec1a1ba3db06e0273457c25ad1f43c43a1f05f6e5dac7285d9d6963359d2a05
5ea6f138dd1be2534a9780caa3fc3c640a44bed42ba2d55b1a47554c27af9de8
8bfedfd1bceeb2563766ae4a5255d80d5a74768c5e14a4fd844d06e8d1e2fbcb
20516ecc2d40a85b786e71539613b5479a5834788c0131a7bd65eb8449487455
caace9a30289756ffcf3fc050bc13f8fd70a293abd0aded8e5ee7564cc15d987
Chrome 156.0.8063.3 Dev recovered all eight values with the same final files.
For the exact negative control, use a separate fresh profile and open:
http://127.0.0.1:8000/poc.html?control=1
The control changes only the first attacker word in each attribute from 0x7fc00000 to finite 1.0f (0x3f800000). This prevents the sentinel collision and forces all eight first writes. It recovered zero victim values, did not match any commitment, and had GL error zero with no context loss.
The browser-level OOPIF evidence records:
attackerTarget.type: page
victimTarget.type: iframe
victimTarget.parentId: attackerTarget.targetId
attackerAndVictimAreDifferentSites: true
victimIsOutOfProcessIframeTarget: true
victimTargetDestroyedBeforeRecovery: true
sameGpuProcessBeforeAndAfter: true
disclosedByteCount: 128
uniqueVictimValuesRecovered: 8
commitmentMatched: true
The separate-top-level variant creates the victim in a non-default Incognito browser context, closes its target, disposes that context, and only then creates the attacker in the regular default context with poc.html?externalVictim=1. It records different browser-context IDs, target destruction and context disposal before attacker creation, the same GPU process before and after, and complete recovery of all 128 bytes. The corresponding externalVictim=1&control=1 run recovered zero victim bytes.
Expected behavior: every newly allocated current-value vertex buffer must receive its first logical value before ANGLE binds it, regardless of whether that value’s bytes equal an internal cache sentinel. GPU allocation contents belonging to a different-site renderer or a disposed browser context must not become observable to attacker JavaScript.
Actual behavior: an exact WebGL-supplied NaN value compares equal to the sentinel, suppresses eight first native writes, and exposes 128 victim-only bytes from reused D3D11 storage to a different-site attacker after the victim target is destroyed.
Impact analysis
A remote attacker can read victim-origin WebGL input data through ordinary WebGL2 content on a Windows system using Chrome’s default ANGLE D3D11 backend. The path requires no browser permission, victim cooperation, renderer compromise, unsafe flag, browser modification, crash, GPU sandbox disablement, or user interaction beyond loading web content.
The PoC establishes attribution with eight independently generated victim-only values rather than a repeated public marker. The victim alone can fetch the raw bytes. The attacker receives HTTP 403, sees only SHA-256 commitments, receives no victim data message, destroys the victim iframe, and then reconstructs all 128 bytes from reused GPU allocation state. The exact non-colliding control changes one word in each attribute, forces the missing first stores, and recovers zero victim data.
The browser-level evidence establishes the Site Isolation boundary directly. The victim is a different-site OOPIF target under the attacker page. Its target is destroyed before recovery, while the GPU process remains the same. The attacker then obtains the victim-only payload in JavaScript. The stronger separate-top-level variant disposes the victim’s Incognito browser context before creating the regular-context attacker and still recovers the complete payload.
The disclosed state is application-controlled WebGL input, not a framebuffer artifact. Generic vertex attributes can carry private geometry, visualization inputs, rendering parameters, decoded content, and application state. The primitive transfers eight distinct values from victim-controlled D3D11 storage into an attacker-owned transform-feedback buffer across renderer and browser-context isolation boundaries.
This is a non-crashing state disclosure, so ASAN does not report it: the shader reads within valid D3D11 vertex-buffer allocations whose logical initialization state is wrong. The concrete security evidence is attacker-observable randomized victim data, HTTP access control, exact commitment matches, OOPIF identity and destruction, separate browser-context disposal, release-version reproduction, and matched negative controls.
The cause
What version of Chrome have you found the security issue in?
153.0.8010.50 - Stable / 153.0.8010.52 - Stable / 156.0.8063.3 - Dev
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