Medium chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized Use in ANGLE
DescriptionUninitialized Use in ANGLE
ComponentANGLE
Bug ClassUninitialized Memory
Tracker501505198
Fix commit727b2427ce46 (angle/angle) +143/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • src/libANGLE/Texture.cpp
  • src/tests/gl_tests/RobustResourceInitTest.cpp
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)
 {
Loading diff…

Regression Test / PoC

shipped with the fix
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)
 {
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cpp
  • third_party/angle/src/libANGLE/State.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cpp
  • third_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.

  1. Allocate uninitialized level: Create a WebGL texture and call gl.texImage2D for level 0 with pixels=null. This sets level 0’s state to MayNeedInit.
  2. Clobber aggregate state: Call gl.texImage2D for level 1 with a valid Uint8Array. This triggers signalDirtyStorage(Initialized), incorrectly promoting the texture’s overall state to Initialized.
  3. Set completeness: Set gl.TEXTURE_MIN_FILTER and gl.TEXTURE_MAG_FILTER to gl.NEAREST so the texture is “complete” and samplable using only the base level (level 0).
  4. Sample and Leak: Create an FBO, bind a fragment shader that samples from level 0, and draw. The shader reads uninitialized GPU heap memory.
  5. Exfiltration: Call gl.readPixels on 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.

View on issue tracker