CVE-2026-11123
Overview
Files Changed
src/libANGLE/Texture.cppsrc/tests/gl_tests/RobustResourceInitTest.cpp
Patch
From 727b2427ce4610c80195da9ee0d89ef41dbbd5ed Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Wed, 22 Apr 2026 17:05:22 -0400
Subject: [PATCH] Fix robust texture init if last op initializes subresource
Bug: chromium:501505198
Change-Id: I733b87a8f912f4937313709774cd059f993cd811
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7786003
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
---
diff --git a/src/libANGLE/Texture.cpp b/src/libANGLE/Texture.cpp
index c50ce7c..102cffc 100644
--- a/src/libANGLE/Texture.cpp
+++ b/src/libANGLE/Texture.cpp
@@ -1387,7 +1387,15 @@
void Texture::signalDirtyStorage(InitState initState)
{
- mState.mInitState = initState;
+ // If initState is InitState::Initialized, some subresource is initialized. Instead of
+ // checking all the subresources to update mState.mInitState appropriately, leave it be until
+ // ensureInitialized() syncs it if needed.
+ //
+ // If initState is InitState::MayNeedInit, then the texture definitely needs initialization.
+ if (initState == InitState::MayNeedInit)
+ {
+ mState.mInitState = InitState::MayNeedInit;
+ }
invalidateCompletenessCache();
mState.mCachedSamplerFormatValid = false;
onStateChange(angle::SubjectMessage::SubjectChanged);
@@ -1656,6 +1664,9 @@
dstLevel, dstX, dstY, dstZ, srcWidth, srcHeight,
srcDepth));
+ // Incorrect: must set initialized only if the entire subresource is covered, and only for the
+ // corresponding ImageDesc. Image must be initialized before copy if not writing to entire
+ // subresource. http://anglebug.com/505317123
signalDirtyStorage(InitState::Initialized);
return angle::Result::Continue;
@@ -1679,6 +1690,9 @@
dstLevel, dstX, dstY, dstZ, srcWidth, srcHeight,
srcDepth));
+ // Incorrect: must set initialized only if the entire subresource is covered, and only for the
+ // corresponding ImageDesc. Image must be initialized before copy if not writing to entire
+ // subresource. http://anglebug.com/505317123
signalDirtyStorage(InitState::Initialized);
return angle::Result::Continue;
diff --git a/src/tests/gl_tests/RobustResourceInitTest.cpp b/src/tests/gl_tests/RobustResourceInitTest.cpp
index 763ba82..a62a229 100644
--- a/src/tests/gl_tests/RobustResourceInitTest.cpp
+++ b/src/tests/gl_tests/RobustResourceInitTest.cpp
@@ -1251,6 +1251,134 @@
checkFramebufferNonZeroPixels(0, 0, 0, 0, GLColor::black);
}
+// Tests that drawing with an uninitialized mipped texture works as expected if the last call
+// initializes the mip it creates. Using glTexImage2D data to initialize the mip.
+TEST_P(RobustResourceInitTestES3, DrawWithMippedTextureLastLevelInitWithTexImage2D)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ constexpr uint32_t kMipCount = 4;
+ const std::vector<GLColor> kLastMipData(
+ (kWidth >> (kMipCount - 1)) * (kHeight >> (kMipCount - 1)), GLColor::red);
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D, texture);
+ for (uint32_t mip = 0; mip < kMipCount; ++mip)
+ {
+ glTexImage2D(GL_TEXTURE_2D, mip, GL_RGBA, kWidth >> mip, kHeight >> mip, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, mip + 1 == kMipCount ? kLastMipData.data() : nullptr);
+ }
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, kMipCount - 1);
+
+ EXPECT_GL_NO_ERROR();
+
+ ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Texture2DLod(), essl3_shaders::fs::Texture2DLod());
+ glUseProgram(program);
+ GLint lodLoc = glGetUniformLocation(program, essl3_shaders::LodUniform());
+ ASSERT_NE(-1, lodLoc);
+
+ for (uint32_t mip = 0; mip < kMipCount; ++mip)
+ {
+ glUniform1f(lodLoc, mip);
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0,
+ (mip + 1 == kMipCount ? kLastMipData[0] : GLColor::transparentBlack))
+ << mip;
+ }
+}
+
+// Tests that drawing with an uninitialized mipped texture works as expected if the last call
+// initializes the mip it creates. Using glCopyTexImage2D to initialize the mip.
+TEST_P(RobustResourceInitTestES3, DrawWithMippedTextureLastLevelInitWithCopyTexImage2D)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ glClearColor(1, 0, 0, 1);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ constexpr uint32_t kMipCount = 4;
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D, texture);
+ for (uint32_t mip = 0; mip < kMipCount - 1; ++mip)
+ {
+ glTexImage2D(GL_TEXTURE_2D, mip, GL_RGBA, kWidth >> mip, kHeight >> mip, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ }
+ glCopyTexImage2D(GL_TEXTURE_2D, kMipCount - 1, GL_RGBA, 0, 0, kWidth >> (kMipCount - 1),
+ kHeight >> (kMipCount - 1), 0);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, kMipCount - 1);
+
+ EXPECT_GL_NO_ERROR();
+
+ ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Texture2DLod(), essl3_shaders::fs::Texture2DLod());
+ glUseProgram(program);
+ GLint lodLoc = glGetUniformLocation(program, essl3_shaders::LodUniform());
+ ASSERT_NE(-1, lodLoc);
+
+ for (uint32_t mip = 0; mip < kMipCount; ++mip)
+ {
+ glUniform1f(lodLoc, mip);
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0,
+ (mip + 1 == kMipCount ? GLColor::red : GLColor::transparentBlack))
+ << mip;
+ }
+}
+
+// Tests that drawing with an uninitialized mipped texture works as expected if the last call
+// initializes the mip it creates. Using glCopyTextureCHROMIUM to initialize the mip.
+TEST_P(RobustResourceInitTestES3, DrawWithMippedTextureLastLevelInitWithCopyTexture)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_CHROMIUM_copy_texture"));
+
+ constexpr uint32_t kMipCount = 4;
+ const std::vector<GLColor> kLastMipData(
+ (kWidth >> (kMipCount - 1)) * (kHeight >> (kMipCount - 1)), GLColor::red);
+
+ GLTexture copySrc;
+ glBindTexture(GL_TEXTURE_2D, copySrc);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kWidth >> (kMipCount - 1), kHeight >> (kMipCount - 1),
+ 0, GL_RGBA, GL_UNSIGNED_BYTE, kLastMipData.data());
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D, texture);
+ for (uint32_t mip = 0; mip < kMipCount - 1; ++mip)
+ {
+ glTexImage2D(GL_TEXTURE_2D, mip, GL_RGBA, kWidth >> mip, kHeight >> mip, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, mip + 1 == kMipCount ? kLastMipData.data() : nullptr);
+ }
+ glCopyTextureCHROMIUM(copySrc, 0, GL_TEXTURE_2D, texture, kMipCount - 1, GL_RGBA,
+ GL_UNSIGNED_BYTE, GL_FALSE, GL_FALSE, GL_FALSE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, kMipCount - 1);
+
+ EXPECT_GL_NO_ERROR();
+
+ ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Texture2DLod(), essl3_shaders::fs::Texture2DLod());
+ glUseProgram(program);
+ GLint lodLoc = glGetUniformLocation(program, essl3_shaders::LodUniform());
+ ASSERT_NE(-1, lodLoc);
+
+ for (uint32_t mip = 0; mip < kMipCount; ++mip)
+ {
+ glUniform1f(lodLoc, mip);
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0,
+ (mip + 1 == kMipCount ? kLastMipData[0] : GLColor::transparentBlack))
+ << mip;
+ }
+}
+
// Test that readback of uninitialized mipped texture works as expected.
TEST_P(RobustResourceInitTestES3, ReadbackWithMippedTexture)
{
Regression Test / PoC
diff --git a/src/tests/gl_tests/RobustResourceInitTest.cpp b/src/tests/gl_tests/RobustResourceInitTest.cpp
index 763ba82..a62a229 100644
--- a/src/tests/gl_tests/RobustResourceInitTest.cpp
+++ b/src/tests/gl_tests/RobustResourceInitTest.cpp
@@ -1251,6 +1251,134 @@
checkFramebufferNonZeroPixels(0, 0, 0, 0, GLColor::black);
}
+// Tests that drawing with an uninitialized mipped texture works as expected if the last call
+// initializes the mip it creates. Using glTexImage2D data to initialize the mip.
+TEST_P(RobustResourceInitTestES3, DrawWithMippedTextureLastLevelInitWithTexImage2D)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ constexpr uint32_t kMipCount = 4;
+ const std::vector<GLColor> kLastMipData(
+ (kWidth >> (kMipCount - 1)) * (kHeight >> (kMipCount - 1)), GLColor::red);
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D, texture);
+ for (uint32_t mip = 0; mip < kMipCount; ++mip)
+ {
+ glTexImage2D(GL_TEXTURE_2D, mip, GL_RGBA, kWidth >> mip, kHeight >> mip, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, mip + 1 == kMipCount ? kLastMipData.data() : nullptr);
+ }
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, kMipCount - 1);
+
+ EXPECT_GL_NO_ERROR();
+
+ ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Texture2DLod(), essl3_shaders::fs::Texture2DLod());
+ glUseProgram(program);
+ GLint lodLoc = glGetUniformLocation(program, essl3_shaders::LodUniform());
+ ASSERT_NE(-1, lodLoc);
+
+ for (uint32_t mip = 0; mip < kMipCount; ++mip)
+ {
+ glUniform1f(lodLoc, mip);
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0,
+ (mip + 1 == kMipCount ? kLastMipData[0] : GLColor::transparentBlack))
+ << mip;
+ }
+}
+
+// Tests that drawing with an uninitialized mipped texture works as expected if the last call
+// initializes the mip it creates. Using glCopyTexImage2D to initialize the mip.
+TEST_P(RobustResourceInitTestES3, DrawWithMippedTextureLastLevelInitWithCopyTexImage2D)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+
+ glClearColor(1, 0, 0, 1);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ constexpr uint32_t kMipCount = 4;
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D, texture);
+ for (uint32_t mip = 0; mip < kMipCount - 1; ++mip)
+ {
+ glTexImage2D(GL_TEXTURE_2D, mip, GL_RGBA, kWidth >> mip, kHeight >> mip, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ }
+ glCopyTexImage2D(GL_TEXTURE_2D, kMipCount - 1, GL_RGBA, 0, 0, kWidth >> (kMipCount - 1),
+ kHeight >> (kMipCount - 1), 0);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, kMipCount - 1);
+
+ EXPECT_GL_NO_ERROR();
+
+ ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Texture2DLod(), essl3_shaders::fs::Texture2DLod());
+ glUseProgram(program);
+ GLint lodLoc = glGetUniformLocation(program, essl3_shaders::LodUniform());
+ ASSERT_NE(-1, lodLoc);
+
+ for (uint32_t mip = 0; mip < kMipCount; ++mip)
+ {
+ glUniform1f(lodLoc, mip);
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0,
+ (mip + 1 == kMipCount ? GLColor::red : GLColor::transparentBlack))
+ << mip;
+ }
+}
+
+// Tests that drawing with an uninitialized mipped texture works as expected if the last call
+// initializes the mip it creates. Using glCopyTextureCHROMIUM to initialize the mip.
+TEST_P(RobustResourceInitTestES3, DrawWithMippedTextureLastLevelInitWithCopyTexture)
+{
+ ANGLE_SKIP_TEST_IF(!hasGLExtension());
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_CHROMIUM_copy_texture"));
+
+ constexpr uint32_t kMipCount = 4;
+ const std::vector<GLColor> kLastMipData(
+ (kWidth >> (kMipCount - 1)) * (kHeight >> (kMipCount - 1)), GLColor::red);
+
+ GLTexture copySrc;
+ glBindTexture(GL_TEXTURE_2D, copySrc);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kWidth >> (kMipCount - 1), kHeight >> (kMipCount - 1),
+ 0, GL_RGBA, GL_UNSIGNED_BYTE, kLastMipData.data());
+
+ GLTexture texture;
+ glBindTexture(GL_TEXTURE_2D, texture);
+ for (uint32_t mip = 0; mip < kMipCount - 1; ++mip)
+ {
+ glTexImage2D(GL_TEXTURE_2D, mip, GL_RGBA, kWidth >> mip, kHeight >> mip, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, mip + 1 == kMipCount ? kLastMipData.data() : nullptr);
+ }
+ glCopyTextureCHROMIUM(copySrc, 0, GL_TEXTURE_2D, texture, kMipCount - 1, GL_RGBA,
+ GL_UNSIGNED_BYTE, GL_FALSE, GL_FALSE, GL_FALSE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, kMipCount - 1);
+
+ EXPECT_GL_NO_ERROR();
+
+ ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Texture2DLod(), essl3_shaders::fs::Texture2DLod());
+ glUseProgram(program);
+ GLint lodLoc = glGetUniformLocation(program, essl3_shaders::LodUniform());
+ ASSERT_NE(-1, lodLoc);
+
+ for (uint32_t mip = 0; mip < kMipCount; ++mip)
+ {
+ glUniform1f(lodLoc, mip);
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0,
+ (mip + 1 == kMipCount ? kLastMipData[0] : GLColor::transparentBlack))
+ << mip;
+ }
+}
+
// Test that readback of uninitialized mipped texture works as expected.
TEST_P(RobustResourceInitTestES3, ReadbackWithMippedTexture)
{
Original Bug Report
ANGLE robust resource init bypass via state clobber in signalDirtyStorage
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.
Overview: A logic error in ANGLE’s Texture::signalDirtyStorage incorrectly clobbers a texture’s aggregate initialization state when a single mip level is updated. This bypasses robust resource initialization checks, potentially allowing standard WebGL applications to sample uninitialized GPU memory.
Affected files:
third_party/angle/src/libANGLE/Texture.cppthird_party/angle/src/libANGLE/State.cppthird_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cppthird_party/angle/src/libANGLE/Texture.h
Estimated timestamp from git blame: 2021-10-25
Summary
There is a potential vulnerability in ANGLE’s texture state management that allows for the bypass of robust resource initialization. By updating a single mip level of a texture with data, the texture’s aggregate initialization state (mState.mInitState) is unconditionally set to Initialized, even if other mip levels remain uninitialized (MayNeedInit). This leads to uninitialized GPU memory being accessible via shader sampling, as the frontend’s initialization checks are bypassed and backends (like Vulkan) rely on the frontend for clearing.
Root Cause Analysis
When setting image data via functions like Texture::setImage, ANGLE calculates whether the level needs initialization via DetermineInitState.
InitState initState = DetermineInitState(context, unpackBuffer, pixels);
mState.setImageDesc(target, level, ImageDesc(size, Format(internalFormat, type), initState));
TextureState::setImageDesc updates the specific level’s state. Crucially, it only promotes the aggregate mInitState to Initialized if all levels are initialized:
bool allImagesInitialized = true;
for (const ImageDesc &initDesc : mImageDescs) {
if (initDesc.initState == InitState::MayNeedInit) {
allImagesInitialized = false;
break;
}
}
if (allImagesInitialized) mInitState = InitState::Initialized;
However, immediately after setImageDesc, Texture::setImage calls signalDirtyStorage:
signalDirtyStorage(initState);
Inside Texture::signalDirtyStorage, the aggregate state is unconditionally clobbered by the state of the single level just updated:
void Texture::signalDirtyStorage(InitState initState)
{
mState.mInitState = initState; // <-- The Flaw
// ...
}
If level 0 is allocated without data (MayNeedInit), and then level 1 is allocated with data (Initialized), signalDirtyStorage forcibly sets the entire texture’s state to Initialized.
When a draw call occurs, State::syncTexturesInit invokes Texture::ensureInitialized(const Context *context). Because mState.mInitState was incorrectly clobbered, this function early-returns:
if (!context->isRobustResourceInitEnabled() || mState.mInitState == InitState::Initialized)
{
return angle::Result::Continue;
}
The uninitialized base level is never zero-cleared. Since backends (e.g., ImageHelper::stageClearIfEmulatedFormat in Vulkan) trust the frontend when robust initialization is enabled, the memory remains uninitialized.
Proposed Attack Scenario
Note: These are suggested steps to trigger the vulnerability; a working proof-of-concept has not been verified via execution.
- Allocate uninitialized level: Create a WebGL texture and call
gl.texImage2Dfor level 0 withpixels=null. This sets level 0’s state toMayNeedInit. - Clobber aggregate state: Call
gl.texImage2Dfor level 1 with a validUint8Array. This triggerssignalDirtyStorage(Initialized), incorrectly promoting the texture’s overall state toInitialized. - Set completeness: Set
gl.TEXTURE_MIN_FILTERandgl.TEXTURE_MAG_FILTERtogl.NEARESTso the texture is “complete” and samplable using only the base level (level 0). - Sample and Leak: Create an FBO, bind a fragment shader that samples from level 0, and draw. The shader reads uninitialized GPU heap memory.
- Exfiltration: Call
gl.readPixelson the FBO to retrieve the stale GPU memory into JavaScript, resulting in a cross-origin/cross-process information leak.
Suggested Fix
Remove the mState.mInitState = initState; assignment from Texture::signalDirtyStorage. The aggregate initialization state should be strictly managed by TextureState::setImageDesc (or a dedicated state update function that evaluates the whole mip chain), ensuring that mInitState only becomes Initialized when no levels remain as MayNeedInit.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.