CVE-2026-113242
Overview
Files Changed
src/tests/gl_tests/UniformBufferTest.cppsrc/libANGLE/renderer/d3d/d3d11/Buffer11.cppsrc/tests/angle_end2end_tests_expectations.txt
Patch
From 67691470653ae44f88fe9c0da1215f0a1106c05f Mon Sep 17 00:00:00 2001
From: Ran Wang <wangra@google.com>
Date: Mon, 28 Sep 2026 17:28:16 -0400
Subject: [PATCH] D3D11: Fix uniform and structured buffer partial update and zeroing
Prevent range-cache storages from becoming mLatestBufferStorage, route
partial copySubData on uniform buffers through staging, clamp
copyFromStorage ranges, and zero-fill uninitialized tail bytes in
dynamic uniform and structured buffers.
Test:
angle_end2end_tests --gtest_filter="*CopySubDataPartialOverwritePreservesRemainingUniformData*:*UninitializedStructuredBufferRangeCacheAndUpdate*"
Bug: b/550626693
Change-Id: I6b5382f0788843faaa88e9046c66ea93d6552384
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8480731
Commit-Queue: Ran Wang <wangra@google.com>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Quyen Le <lehoangquyen@google.com>
---
diff --git a/src/libANGLE/renderer/d3d/d3d11/Buffer11.cpp b/src/libANGLE/renderer/d3d/d3d11/Buffer11.cpp
index 95b64c8..483e354 100644
--- a/src/libANGLE/renderer/d3d/d3d11/Buffer11.cpp
+++ b/src/libANGLE/renderer/d3d/d3d11/Buffer11.cpp
@@ -415,18 +415,17 @@
size <= static_cast<UINT>(mRenderer->getNativeCaps().maxUniformBlockSize) &&
!mRenderer->getFeatures().useSystemMemoryForConstantBuffers.enabled)
{
- BufferStorage *latestStorage = nullptr;
- ANGLE_TRY(getLatestBufferStorage(context, &latestStorage, feedback));
- if (latestStorage && (latestStorage->getUsage() == BUFFER_USAGE_STRUCTURED))
- {
- ANGLE_TRY(
- getBufferStorage(context, BUFFER_USAGE_STRUCTURED, &writeBuffer, feedback));
- }
- else
- {
- ANGLE_TRY(
- getBufferStorage(context, BUFFER_USAGE_UNIFORM, &writeBuffer, feedback));
- }
+ // Always use BUFFER_USAGE_UNIFORM here. StructuredBufferStorage requires a
+ // shader-dependent structureByteStride at draw time, lives exclusively in
+ // mStructuredBufferRangeStoragesCache, and is always synced downstream from
+ // mLatestBufferStorage (SYSTEM_MEMORY, STAGING, or UNIFORM) in
+ // getStructuredBufferRangeSRV. Because onCopyStorage only updates
+ // mLatestBufferStorage to lower BufferUsage enum values and onStorageUpdate is
+ // never called on range-cache entries, mLatestBufferStorage can never be
+ // BUFFER_USAGE_STRUCTURED.
+ ASSERT(!mLatestBufferStorage ||
+ mLatestBufferStorage->getUsage() != BUFFER_USAGE_STRUCTURED);
+ ANGLE_TRY(getBufferStorage(context, BUFFER_USAGE_UNIFORM, &writeBuffer, feedback));
}
else
{
@@ -476,8 +475,15 @@
BufferStorage *copyDest = nullptr;
ANGLE_TRY(getLatestBufferStorage(context, ©Dest, feedback));
+ // copyDest comes from mLatestBufferStorage, which only tracks canonical storages in
+ // mBufferStorages and can never be a BUFFER_USAGE_STRUCTURED range-cache entry.
+ ASSERT(!copyDest || copyDest->getUsage() != BUFFER_USAGE_STRUCTURED);
- if (!copyDest)
+ // BUFFER_USAGE_UNIFORM uses D3D11_USAGE_DYNAMIC + D3D11_BIND_CONSTANT_BUFFER, which maps with
+ // D3D11_MAP_WRITE_DISCARD (discarding the entire buffer on partial CPU writes) and cannot
+ // preserve existing data when copyFromStorage uses the CPU setData path. Route through
+ // BUFFER_USAGE_STAGING so existing bytes outside [destOffset, destOffset + size) are preserved.
+ if (!copyDest || copyDest->getUsage() == BUFFER_USAGE_UNIFORM)
{
ANGLE_TRY(getStagingStorage(context, ©Dest));
}
@@ -816,6 +822,9 @@
BufferFeedback *feedback)
{
ASSERT(0 <= usage && usage < BUFFER_USAGE_COUNT);
+ // Structured buffers require a shader-dependent structureByteStride (via
+ // resizeStructuredBuffer) and are managed exclusively in mStructuredBufferRangeStoragesCache.
+ ASSERT(usage != BUFFER_USAGE_STRUCTURED);
BufferStorage *&newStorage = mBufferStorages[usage];
if (!newStorage)
@@ -1019,7 +1028,27 @@
if (!latestBuffer)
{
- onStorageUpdate(storage);
+ // updateBufferStorage is called by getBufferStorage (for canonical full-buffer storages in
+ // mBufferStorages) as well as getConstantBufferRangeStorage and
+ // getStructuredBufferRangeSRV (for sub-range cache entries stored in
+ // mConstantBufferRangeStoragesCache and mStructuredBufferRangeStoragesCache).
+ //
+ // When mLatestBufferStorage is nullptr (e.g., after glBufferData with data == nullptr), we
+ // must only promote |storage| to mLatestBufferStorage if:
+ // 1. mBufferStorages[storage->getUsage()] == storage: |storage| is a canonical storage in
+ // mBufferStorages and not a range-cache entry (range-cache entries only cover a
+ // sub-range, can be freed by LRU eviction without checking mLatestBufferStorage, and
+ // StructuredBufferStorage cannot be resized via NativeStorage::resize).
+ // 2. storage->getSize() >= mSize: |storage| covers the full buffer size. For
+ // BUFFER_USAGE_UNIFORM on D3D11.0 devices (!supportsConstantBufferOffsets),
+ // FillBufferDesc clamps ByteWidth to maxUniformBlockSize. If
+ // mSize > maxUniformBlockSize, a clamped uniform storage has getSize() < mSize and would
+ // cause getLatestBufferStorage to repeatedly reallocate the buffer on every call while
+ // truncating data above maxUniformBlockSize.
+ if (mBufferStorages[storage->getUsage()] == storage && storage->getSize() >= mSize)
+ {
+ onStorageUpdate(storage);
+ }
return angle::Result::Continue;
}
@@ -1123,6 +1152,12 @@
void Buffer11::onStorageUpdate(BufferStorage *updatedStorage)
{
+ // StructuredBufferStorage instances live exclusively in mStructuredBufferRangeStoragesCache as
+ // downstream range caches (which may only cover a sub-range of the buffer and can be evicted by
+ // LRU). They cannot be read back or copied from (copyFromStorage only supports
+ // SystemMemoryStorage and NativeStorage sources), so they must never become
+ // mLatestBufferStorage.
+ ASSERT(updatedStorage->getUsage() != BUFFER_USAGE_STRUCTURED);
updatedStorage->setDataRevision(updatedStorage->getDataRevision() + 1);
mLatestBufferStorage = updatedStorage;
}
@@ -1141,12 +1176,37 @@
// Uniform storage can have a different internal size than the buffer size. Ensure we don't
// overflow.
- size_t mapSize = std::min(size, mBufferSize - offset);
+ const size_t copySize = std::min(size, mBufferSize - offset);
+
+ // Uniform and structured buffers are D3D11_USAGE_DYNAMIC and mapped with
+ // D3D11_MAP_WRITE_DISCARD, which discards the entire buffer and returns fresh uninitialized
+ // memory. Therefore, when copySize < mBufferSize (e.g., due to constant buffer alignment
+ // padding or a range binding extending past the source buffer), we must map up to the
+ // end of the buffer and zero-fill the remaining tail bytes. Non-discard storages
+ // (SYSTEM_MEMORY, STAGING, PIXEL_PACK) preserve untouched bytes and only map copySize bytes.
+ const bool zeroTail = (mUsage == BUFFER_USAGE_UNIFORM || mUsage == BUFFER_USAGE_STRUCTURED);
+ const size_t mapSize = zeroTail ? (mBufferSize - offset) : copySize;
+ if (mapSize == 0)
+ {
+ return angle::Result::Continue;
+ }
uint8_t *writePointer = nullptr;
ANGLE_TRY(map(context, offset, mapSize, GL_MAP_WRITE_BIT, &writePointer));
- memcpy(writePointer, data, mapSize);
+ if (copySize > 0)
+ {
+ memcpy(writePointer, data, copySize);
+ }
+
+ if (zeroTail)
+ {
+ ASSERT(offset == 0);
+ if (copySize < mapSize)
+ {
+ memset(writePointer + copySize, 0, mapSize - copySize);
+ }
+ }
unmap();
@@ -1200,24 +1260,43 @@
*resultOut = CopyResult::NOT_RECREATED;
}
- size_t clampedSize = size;
- if (mUsage == BUFFER_USAGE_UNIFORM)
- {
- clampedSize = std::min(clampedSize, mBufferSize - destOffset);
- }
+ // Clamp the copy size to both the available source bytes and destination capacity:
+ // - sourceOffset + size can exceed source->getSize() when a shader uniform/structured block or
+ // glBindBufferRange window is larger than the source storage.
+ // - destOffset + size can exceed mBufferSize when BUFFER_USAGE_UNIFORM is capped at
+ // maxUniformBlockSize in FillBufferDesc.
+ size_t sourceAvailable =
+ (sourceOffset < source->getSize()) ? (source->getSize() - sourceOffset) : 0;
+ size_t destAvailable = (destOffset < mBufferSize) ? (mBufferSize - destOffset) : 0;
+ size_t clampedSize = std::min({size, sourceAvailable, destAvailable});
if (clampedSize == 0)
{
+ // If no source bytes are available (e.g., sourceOffset >= source->getSize()), uniform and
+ // structured buffers must still be zero-filled via setData so the shader does not read
+ // uninitialized D3D11 memory.
+ if (mUsage == BUFFER_USAGE_UNIFORM || mUsage == BUFFER_USAGE_STRUCTURED)
+ {
+ ANGLE_TRY(setData(context, nullptr, destOffset, 0));
+ }
return angle::Result::Continue;
}
+ // Route uniform and structured buffers with clampedSize < mBufferSize through CPU map + setData
+ // instead of GPU CopySubresourceRegion. CopySubresourceRegion can only copy clampedSize bytes
+ // from the source buffer (since srcBox must fit within source), which would leave the remaining
+ // [clampedSize, mBufferSize) tail bytes uninitialized in a newly allocated or previously
+ // discarded buffer. setData maps with D3D11_MAP_WRITE_DISCARD and zero-fills the tail.
if (source->getUsage() == BUFFER_USAGE_PIXEL_PACK ||
- source->getUsage() == BUFFER_USAGE_SYSTEM_MEMORY)
+ source->getUsage() == BUFFER_USAGE_SYSTEM_MEMORY ||
+ ((mUsage == BUFFER_USAGE_UNIFORM || mUsage == BUFFER_USAGE_STRUCTURED) &&
+ clampedSize < mBufferSize))
{
ASSERT(source->isCPUAccessible(GL_MAP_READ_BIT) && isCPUAccessible(GL_MAP_WRITE_BIT));
- // Uniform buffers must be mapped with write/discard.
- ASSERT(!(preserveData && mUsage == BUFFER_USAGE_UNIFORM));
+ // Uniform and structured buffers must be mapped with write/discard.
+ ASSERT(!(preserveData &&
+ (mUsage == BUFFER_USAGE_UNIFORM || mUsage == BUFFER_USAGE_STRUCTURED)));
uint8_t *sourcePointer = nullptr;
ANGLE_TRY(source->map(context, sourceOffset, clampedSize, GL_MAP_READ_BIT, &sourcePointer));
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index e94f9a8..3a5566f 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -1395,6 +1395,7 @@
523128541 PIXEL10 GLES : UniformBlockWithOneLargeArrayMemberTest.TwoUniformBlocksInSameProgram/* = SKIP
523128541 PIXEL10 GLES : UniformBufferTest.SameBufferDifferentMetalSize/* = SKIP
562821012 PIXEL10 GLES : UniformBufferTest.DifferentRangeSizesSameOffsetAndStride/* = SKIP
+562821012 PIXEL10 GLES : UniformBufferTest.CopySubDataPartialOverwritePreservesRemainingUniformData/* = SKIP
526503288 PIXEL10 GLES : BaseInstanceOverflowTest.BaseInstanceOverflow2/ES3_OpenGLES_AlwaysEnableEmulatedMultidrawExtensions = SKIP
516824665 PIXEL10 GLES : Texture2DTest.InvalidFormatTypeCombosShouldFail/* = SKIP
516824665 PIXEL10 GLES : Texture2DTest.FormatRGB10A2REVToRGB565/* = SKIP
diff --git a/src/tests/gl_tests/UniformBufferTest.cpp b/src/tests/gl_tests/UniformBufferTest.cpp
index 9fc6ca2..316fd62 100644
--- a/src/tests/gl_tests/UniformBufferTest.cpp
+++ b/src/tests/gl_tests/UniformBufferTest.cpp
@@ -4669,6 +4669,106 @@
EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
}
+// Test that partial updates to a uniform buffer via glCopyBufferSubData preserve the remaining
+// untouched buffer data.
+TEST_P(UniformBufferTest, CopySubDataPartialOverwritePreservesRemainingUniformData)
+{
+ constexpr size_t kElementCount = 64;
+ constexpr size_t kComponentsPerElement = 4;
+ constexpr GLsizeiptr kElementStride = kComponentsPerElement * sizeof(GLuint);
+
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+out vec4 my_FragColor;
+layout(std140) uniform BlockLarge {
+ uvec4 data[64];
+};
+void main() {
+ bool ok = (data[0] == uvec4(100u)) && (data[1] == uvec4(200u)) &&
+ (data[63] == uvec4(64u));
+ my_FragColor = ok ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
+})";
+
+ ANGLE_GL_PROGRAM(prog, essl3_shaders::vs::Simple(), kFS);
+ glUniformBlockBinding(prog, glGetUniformBlockIndex(prog, "BlockLarge"), 0);
+
+ std::vector<GLuint> initialData(kElementCount * kComponentsPerElement);
+ for (size_t i = 0; i < kElementCount; ++i)
+ {
+ for (size_t c = 0; c < kComponentsPerElement; ++c)
+ {
+ initialData[i * kComponentsPerElement + c] = static_cast<GLuint>(i + 1);
+ }
+ }
+
+ GLBuffer ubo;
+ glBindBuffer(GL_UNIFORM_BUFFER, ubo);
+ glBufferData(GL_UNIFORM_BUFFER, initialData.size() * sizeof(GLuint), initialData.data(),
+ GL_DYNAMIC_DRAW);
+ glBindBufferBase(GL_UNIFORM_BUFFER, 0, ubo);
+
+ // Use a non-uniform target with GL_DYNAMIC_DRAW so the D3D11 backend keeps the source buffer in
+ // system memory and takes the CPU copy path into the uniform buffer.
+ GLBuffer copySrc;
+ glBindBuffer(GL_COPY_READ_BUFFER, copySrc);
+ std::vector<GLuint> patchData = {100u, 100u, 100u, 100u, 200u, 200u, 200u, 200u};
+ glBufferData(GL_COPY_READ_BUFFER, patchData.size() * sizeof(GLuint), patchData.data(),
+ GL_DYNAMIC_DRAW);
+
+ // Partially overwrite element 0 and element 1 of ubo.
+ glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_UNIFORM_BUFFER, 0, 0, kElementStride);
+ glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_UNIFORM_BUFFER, kElementStride, kElementStride,
+ kElementStride);
+ EXPECT_GL_NO_ERROR();
+
+ // Verify that elements 0 and 1 have the copied data and element 63 still retains its value.
+ drawQuad(prog, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+}
+
+// Test that drawing with an uninitialized uniform buffer followed by a full-buffer glBufferSubData
+// update works properly when the shader uses the D3D11 structured buffer path.
+TEST_P(UniformBufferTest, UninitializedStructuredBufferRangeCacheAndUpdate)
+{
+ constexpr size_t kElementCount = 64;
+ constexpr size_t kComponentsPerElement = 4;
+ constexpr GLsizeiptr kBlockSize = kElementCount * kComponentsPerElement * sizeof(GLuint);
+ constexpr GLsizeiptr kTotalSize = kBlockSize * 2;
+
+ // It is important to use a std140 uniform block with a sufficiently large array so the
+ // D3D11 backend translates the uniform block into a StructuredBuffer instead of a cbuffer.
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+out vec4 my_FragColor;
+layout(std140) uniform BlockLarge {
+ uvec4 data[64];
+};
+void main() {
+ my_FragColor = (data[0] == uvec4(42u)) ? vec4(0.0, 1.0, 0.0, 1.0)
+ : vec4(1.0, 0.0, 0.0, 1.0);
+})";
+
+ ANGLE_GL_PROGRAM(prog, essl3_shaders::vs::Simple(), kFS);
+ glUniformBlockBinding(prog, glGetUniformBlockIndex(prog, "BlockLarge"), 0);
+
+ // Allocate an uninitialized buffer (data = nullptr) larger than the shader block size.
+ GLBuffer ubo;
+ glBindBuffer(GL_UNIFORM_BUFFER, ubo);
+ glBufferData(GL_UNIFORM_BUFFER, kTotalSize, nullptr, GL_DYNAMIC_DRAW);
+ glBindBufferBase(GL_UNIFORM_BUFFER, 0, ubo);
+
+ // Draw once before populating the buffer to trigger structured buffer range cache creation on
+ // the D3D11 backend while the buffer has no canonical storage yet.
+ drawQuad(prog, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_GL_NO_ERROR();
+
+ // Populate the full buffer (kTotalSize > kBlockSize) via glBufferSubData and draw again.
+ std::vector<GLuint> fullData(kTotalSize / sizeof(GLuint), 42u);
+ glBufferSubData(GL_UNIFORM_BUFFER, 0, kTotalSize, fullData.data());
+ drawQuad(prog, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+}
+
// Tests rendering with a bound, unreferenced UBO that has no data. Covers a paticular back-end bug.
TEST_P(UniformBufferTest, EmptyUnusedUniformBuffer)
{
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index e94f9a8..3a5566f 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -1395,6 +1395,7 @@
523128541 PIXEL10 GLES : UniformBlockWithOneLargeArrayMemberTest.TwoUniformBlocksInSameProgram/* = SKIP
523128541 PIXEL10 GLES : UniformBufferTest.SameBufferDifferentMetalSize/* = SKIP
562821012 PIXEL10 GLES : UniformBufferTest.DifferentRangeSizesSameOffsetAndStride/* = SKIP
+562821012 PIXEL10 GLES : UniformBufferTest.CopySubDataPartialOverwritePreservesRemainingUniformData/* = SKIP
526503288 PIXEL10 GLES : BaseInstanceOverflowTest.BaseInstanceOverflow2/ES3_OpenGLES_AlwaysEnableEmulatedMultidrawExtensions = SKIP
516824665 PIXEL10 GLES : Texture2DTest.InvalidFormatTypeCombosShouldFail/* = SKIP
516824665 PIXEL10 GLES : Texture2DTest.FormatRGB10A2REVToRGB565/* = SKIP
diff --git a/src/tests/gl_tests/UniformBufferTest.cpp b/src/tests/gl_tests/UniformBufferTest.cpp
index 9fc6ca2..316fd62 100644
--- a/src/tests/gl_tests/UniformBufferTest.cpp
+++ b/src/tests/gl_tests/UniformBufferTest.cpp
@@ -4669,6 +4669,106 @@
EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
}
+// Test that partial updates to a uniform buffer via glCopyBufferSubData preserve the remaining
+// untouched buffer data.
+TEST_P(UniformBufferTest, CopySubDataPartialOverwritePreservesRemainingUniformData)
+{
+ constexpr size_t kElementCount = 64;
+ constexpr size_t kComponentsPerElement = 4;
+ constexpr GLsizeiptr kElementStride = kComponentsPerElement * sizeof(GLuint);
+
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+out vec4 my_FragColor;
+layout(std140) uniform BlockLarge {
+ uvec4 data[64];
+};
+void main() {
+ bool ok = (data[0] == uvec4(100u)) && (data[1] == uvec4(200u)) &&
+ (data[63] == uvec4(64u));
+ my_FragColor = ok ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
+})";
+
+ ANGLE_GL_PROGRAM(prog, essl3_shaders::vs::Simple(), kFS);
+ glUniformBlockBinding(prog, glGetUniformBlockIndex(prog, "BlockLarge"), 0);
+
+ std::vector<GLuint> initialData(kElementCount * kComponentsPerElement);
+ for (size_t i = 0; i < kElementCount; ++i)
+ {
+ for (size_t c = 0; c < kComponentsPerElement; ++c)
+ {
+ initialData[i * kComponentsPerElement + c] = static_cast<GLuint>(i + 1);
+ }
+ }
+
+ GLBuffer ubo;
+ glBindBuffer(GL_UNIFORM_BUFFER, ubo);
+ glBufferData(GL_UNIFORM_BUFFER, initialData.size() * sizeof(GLuint), initialData.data(),
+ GL_DYNAMIC_DRAW);
+ glBindBufferBase(GL_UNIFORM_BUFFER, 0, ubo);
+
+ // Use a non-uniform target with GL_DYNAMIC_DRAW so the D3D11 backend keeps the source buffer in
+ // system memory and takes the CPU copy path into the uniform buffer.
+ GLBuffer copySrc;
+ glBindBuffer(GL_COPY_READ_BUFFER, copySrc);
+ std::vector<GLuint> patchData = {100u, 100u, 100u, 100u, 200u, 200u, 200u, 200u};
+ glBufferData(GL_COPY_READ_BUFFER, patchData.size() * sizeof(GLuint), patchData.data(),
+ GL_DYNAMIC_DRAW);
+
+ // Partially overwrite element 0 and element 1 of ubo.
+ glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_UNIFORM_BUFFER, 0, 0, kElementStride);
+ glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_UNIFORM_BUFFER, kElementStride, kElementStride,
+ kElementStride);
+ EXPECT_GL_NO_ERROR();
+
+ // Verify that elements 0 and 1 have the copied data and element 63 still retains its value.
+ drawQuad(prog, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+}
+
+// Test that drawing with an uninitialized uniform buffer followed by a full-buffer glBufferSubData
+// update works properly when the shader uses the D3D11 structured buffer path.
+TEST_P(UniformBufferTest, UninitializedStructuredBufferRangeCacheAndUpdate)
+{
+ constexpr size_t kElementCount = 64;
+ constexpr size_t kComponentsPerElement = 4;
+ constexpr GLsizeiptr kBlockSize = kElementCount * kComponentsPerElement * sizeof(GLuint);
+ constexpr GLsizeiptr kTotalSize = kBlockSize * 2;
+
+ // It is important to use a std140 uniform block with a sufficiently large array so the
+ // D3D11 backend translates the uniform block into a StructuredBuffer instead of a cbuffer.
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+out vec4 my_FragColor;
+layout(std140) uniform BlockLarge {
+ uvec4 data[64];
+};
+void main() {
+ my_FragColor = (data[0] == uvec4(42u)) ? vec4(0.0, 1.0, 0.0, 1.0)
+ : vec4(1.0, 0.0, 0.0, 1.0);
+})";
+
+ ANGLE_GL_PROGRAM(prog, essl3_shaders::vs::Simple(), kFS);
+ glUniformBlockBinding(prog, glGetUniformBlockIndex(prog, "BlockLarge"), 0);
+
+ // Allocate an uninitialized buffer (data = nullptr) larger than the shader block size.
+ GLBuffer ubo;
+ glBindBuffer(GL_UNIFORM_BUFFER, ubo);
+ glBufferData(GL_UNIFORM_BUFFER, kTotalSize, nullptr, GL_DYNAMIC_DRAW);
+ glBindBufferBase(GL_UNIFORM_BUFFER, 0, ubo);
+
+ // Draw once before populating the buffer to trigger structured buffer range cache creation on
+ // the D3D11 backend while the buffer has no canonical storage yet.
+ drawQuad(prog, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_GL_NO_ERROR();
+
+ // Populate the full buffer (kTotalSize > kBlockSize) via glBufferSubData and draw again.
+ std::vector<GLuint> fullData(kTotalSize / sizeof(GLuint), 42u);
+ glBufferSubData(GL_UNIFORM_BUFFER, 0, kTotalSize, fullData.data());
+ drawQuad(prog, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+}
+
// Tests rendering with a bound, unreferenced UBO that has no data. Covers a paticular back-end bug.
TEST_P(UniformBufferTest, EmptyUnusedUniformBuffer)
{
Original Bug Report
ANGLE/Direct3D11: Cross-origin GPU-memory image disclosure - one web origin reconstructs another origin's picture from the discarded WRITE_DISCARD StructuredBuffer tail (default backend, ordinary WebGL2)
Security Bug
Important: Please do not change the component of this bug manually.
Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md
Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs
Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp
NOTE: Security bugs are normally made public once a fix has been widely deployed.
VULNERABILITY DETAILS
On the default ANGLE Direct3D11 backend, a web page on one origin can read pixels that belong to a different web origin out of a recycled GPU dynamic buffer. ANGLE emulates std140 uniform blocks as HLSL StructuredBuffers backed by D3D11_USAGE_DYNAMIC buffers updated with Map(WRITE_DISCARD) (buffer renaming). The discarded/renamed buffer the D3D11 driver hands back is a physical buffer previously used and freed by another origin’s WebGL context in the shared GPU-process D3D device, and ANGLE never zero-initializes the portion the page did not write. The attacker reads that untouched tail and recovers the other origin’s bytes.
To make the impact unmistakable, the victim origin holds an image (a picture drawn into a WebGL structured-buffer uniform block). The attacker origin — a different site, sharing nothing but the GPU process — reconstructs that image at ~95% per read, and can repeat the steal (one fresh page-load per picture) to grab every new picture the victim shows.
This crosses two qualifying boundaries at once: cross-origin data disclosure and uninitialized/stale GPU-memory disclosure. The two origins share no JavaScript, pixels, digests, or WebGL objects, so any victim byte the attacker reads can only have crossed the boundary through the shared GPU-process D3D device. No GL error is raised (gl.getError() is NO_ERROR throughout).
Mechanism:
- ANGLE translates a
std140uniform block whose single member is a 1-D array of length ≥ 50 into an HLSLStructuredBuffer(featureallowTranslateUniformBlockToStructuredBuffer, default-on for Windows 10+). These are backed byD3D11_USAGE_DYNAMICbuffers updated viaMap(WRITE_DISCARD). - A victim origin repeatedly creates such UBOs filled with its data (the pixels of an image) and frees them, returning those dynamic buffers to the driver’s rename pool. All WebGL contexts across all origins share one GPU-process D3D11 device, so the pool is shared.
- An attacker origin creates its own large StructuredBuffer UBO, fills it with a sentinel (
K = 0x1234ABCD), overwrites only the prefix (elements 0–49) withG = 0x0BADF00DviabufferSubData(this triggers theWRITE_DISCARDrename), and reads back the untouched tail with a shader +readPixels. - Because the renamed buffer still holds the victim origin’s freed bytes and the tail is never zeroed, the attacker reads the victim’s bytes — data it never wrote.
How the image PoC works:
- Victim (
victim.html, originhttp://127.0.0.2) draws a picture (e.g. 🐎) into a 64×64 canvas, then packs its RGBA pixels into a structured-buffer uniform blockuvec4 data[1024]— oneuint32per pixel, 16,384 bytes (N = 64*64/4 = 1024uvec4). It floods the shared rename pool by uploading and freeing that buffer repeatedly, so the picture’s bytes populate freed dynamic-buffer slots. - Attacker (
attacker-batch.html, originhttp://127.0.0.1— a different site) creates its ownuvec4 data[1024]UBO,K-fills it, overwrites elements 0–49 withG(the rename trigger), then does a single wide render reading the whole untouched tail in one draw (color = data[50 + gl_FragCoord.x]) andreadPixelsback the values. Any tail word that is notK,G, or0is foreign data; the attacker writes each recovereduint32back out as an RGBA pixel, rebuilding the victim’s image.
A single successful wide-read recovers the whole current image at once (~95% coverage), so the attacker needs only one successful read per picture. The leak fires while the shared GPU-process pool is in its cold state (right after a fresh GPU process starts servicing the origins); once warm it does not re-arm within that process. This maps directly to the real world: every fresh load of the attacker’s page re-arms the cold state and exfiltrates whatever picture the victim origin currently holds. The steal also succeeds from a cross-site <iframe> (out-of-process iframe) with no top-level navigation (verified 95.1% from an embedded frame on a distinct site), so loading attacker content in a sub-frame (e.g. a third-party widget) is sufficient.
Preconditions (stated honestly)
- Direct3D11 backend — the default on Windows (renderer
ANGLE (… Direct3D11)), no flag needed. Confirmed not to occur on WARP, Vulkan, or OpenGL (see controls). - Windows 10+ (
allowTranslateUniformBlockToStructuredBufferdefault-on). - Real GPU driver — reproduced on NVIDIA and Intel.
- The victim origin must have recently used StructuredBuffer UBOs in the same GPU process (ordinary for WebGL2 content); attacker and victim share the GPU process (any two origins do).
VERSION
- Chrome Version: Stable 151.0.7922.174 (64-bit), default D3D11 backend (CDP-observed renderer
ANGLE (NVIDIA GeForce RTX 5060 Laptop GPU … Direct3D11)). - Operating System: Windows 11 Home, build 26200. GPUs confirmed: NVIDIA GeForce RTX 5060 Laptop (driver 32.0.15.9144) and Intel UHD Graphics.
REPRODUCTION CASE
Attached (evidence.zip, 6 files):
| File | Role |
|---|---|
victim.html |
Victim origin: draws a picture, packs it into a StructuredBuffer UBO, floods the shared pool. |
attacker-batch.html |
Attacker origin: sentinel-fill → prefix-overwrite (rename) → wide tail read → reconstruct the victim’s picture. Writes no victim values. |
batch-leak.mjs |
Driver: serves victim on http://127.0.0.2 and attacker on http://127.0.0.1 (distinct sites → distinct renderer processes → shared GPU process); leaks a sequence of pictures, one fresh cold process each. |
poc.mp4 |
Proof of concept. |
wall-snapshot.html |
Live capture of many different pictures each stolen cross-origin (self-contained). |
Steps:
node batch-leak.mjs 0 13 4 8 1(default D3D11; no boundary-disabling flags).- For each requested picture the driver launches a fresh Chrome, floods the victim origin, has the attacker origin reconstruct it, and saves
leak-<i>-original.png/leak-<i>-stolen.png.
Actual result (NVIDIA RTX 5060, default D3D11): every requested picture reconstructed at 95.1% (horse, penguin 🐧, lion 🦁, dolphin 🐬, unicorn 🦄); the no-victim control reconstructs nothing (blank). horse-original.png vs horse-recovered.png shows a single steal; wall-snapshot.html shows the attacker grabbing a stream of different victim pictures.
Differential (controls that rule out a harness artifact)
The reliable signal is that recovery appears only on hardware D3D11 with a victim origin present.
| Configuration | Cross-origin recovery |
|---|---|
| D3D11 NVIDIA (default) + victim | YES — image reconstructed ~95%; markers 43/100, up to 197/200 |
D3D11 Intel UHD (--gpu low) + victim |
YES — 62/100, 88/200 |
| D3D11 no-victim control (same attacker, no victim) | NO — 0 / 350, blank reconstruction |
| D3D11 WARP (software rasterizer) + victim | NO — 0 |
| Vulkan backend + victim | NO — 0 (attacker always reads its own sentinel) |
| OpenGL backend + victim | NO — 0 |
The no-victim control proves the bytes originate from the victim origin; the WARP/Vulkan/OpenGL controls localize it to the D3D11 StructuredBuffer WRITE_DISCARD renaming path (the other backends don’t use the HLSL StructuredBuffer emulation and are clean).
IMPACT — breadth and scale (not a single byte)
A single successful read leaks the entire discarded tail. Escalated to the maximum structured buffer (N = MAX_UNIFORM_BLOCK_SIZE / 16 = 4096 elements = 65,536 bytes) with a position-unique tag (each word encodes its own slot index) read via one wide render, on NVIDIA D3D11 default backend:
| Metric | Value |
|---|---|
| Structured buffer size | 65,536 bytes (64 KB) |
| Max victim bytes recovered in ONE read | 64,736 / 64,736 (the entire tail) |
| Distinct victim positions recovered | 16,184 / 16,384 (98.8%) — each individually tagged |
| No-victim control | 0 bytes |
| Pointer / heap-metadata bytes | 0 |
Because the tag is unique per position, the recovered slots are distinct positions (not a repeated canary): a single read recovers up to the full ~64 KB of another origin’s contiguous dynamic-buffer content, scaling with MAX_UNIFORM_BLOCK_SIZE. Any image, canvas, or WebGL data another origin places in a StructuredBuffer UBO can be reconstructed cross-origin, ~64 KB per read, one fresh page-load per capture. The recovered bytes are buffer content only (0 pointer-like values), so this is a high-volume content-disclosure primitive, not an ASLR/heap-leak primitive.
FOR CRASHES
Not applicable — this is an information-disclosure bug, not a crash. No GL error is raised; gl.getError() is NO_ERROR throughout. The attacker simply reads the wrong (victim) origin’s bytes.
WHAT IS AND IS NOT CLAIMED
- Claimed: on the default D3D11 backend of released Chrome, a web origin reads another web origin’s StructuredBuffer/uniform-block bytes from a recycled
WRITE_DISCARDdynamic buffer that was not zero-initialized — demonstrated by reconstructing the victim origin’s picture and by unique-tag byte recovery (98.8% of a 64 KB buffer). Reproduced on NVIDIA and Intel with a clean no-victim control and clean WARP/Vulkan/OpenGL controls. Also reachable from a cross-site iframe. - NOT claimed: arbitrary victim-process memory read. The leaked bytes are whatever previously occupied the recycled dynamic StructuredBuffer pool slot — another origin’s freed WebGL uniform/structured-buffer contents. Demonstrated with synthetic pictures/markers; a real attack recovers another origin’s actual buffer data.
- NOT claimed: 100% determinism per read. The per-read hit rate is driver/timing-dependent; the leak re-arms per fresh GPU-process cold state (one fresh page-load per picture); the no-victim control is deterministically blank.
- NOT claimed on WARP / OpenGL / Vulkan (all clean).
CREDIT INFORMATION
JonathanBouman