Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in ANGLE
DescriptionInsufficient validation of untrusted input in ANGLE
ComponentANGLE
Bug ClassLogic Error
Tracker498820206
Fix commite8706defef30 (angle/angle) +147/-14
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Files Changed

  • src/libANGLE/validationES.cpp
  • src/tests/gl_tests/WebGLCompatibilityTest.cpp
From e8706defef30b77a8a06d60fb8cb35a82f21b76c Mon Sep 17 00:00:00 2001
From: Geoff Lang <geofflang@chromium.org>
Date: Wed, 13 May 2026 12:53:39 -0400
Subject: [PATCH] Generate errors for feedback loops on hardened contexts.

This is undefined behaviour but the backends typically gracefully handle
feedback loops with extra copies or errors. Handle it more decisively in
the frontend.

Fix some cases where the webgl compat extension was being checked
instead of Context::isWebGL.

Bug: chromium:498820206
Fixed: chromium:512405081
Change-Id: I080aeb992b1c6f9cb9f2f02424866c92f145e42a
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7843562
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
---

diff --git a/src/libANGLE/validationES.cpp b/src/libANGLE/validationES.cpp
index c830bbe..8002fce 100644
--- a/src/libANGLE/validationES.cpp
+++ b/src/libANGLE/validationES.cpp
@@ -3955,8 +3955,8 @@
         *textureFormatOut = texture->getFormat(target, level);
     }
 
-    // Detect texture copying feedback loops for WebGL.
-    if (context->isWebGL())
+    // Detect texture copying feedback loops for WebGL or hardedend contexts.
+    if (context->isWebGL() || context->isHardenedContext())
     {
         if (readFramebuffer->formsCopyingFeedbackLoopWith(texture->id(), level, zoffset))
         {
@@ -4056,7 +4056,7 @@
     ASSERT(framebuffer);
 
     if (ANGLE_UNLIKELY(context->getLimitations().noSeparateStencilRefsAndMasks ||
-                       extensions.webglCompatibilityANGLE))
+                       context->isWebGL()))
     {
         ASSERT(framebuffer);
         const FramebufferAttachment *dsAttachment =
@@ -4079,7 +4079,7 @@
 
             if (differentRefs || differentWritemasks || differentMasks)
             {
-                if (!extensions.webglCompatibilityANGLE)
+                if (!context->isWebGL())
                 {
                     WARN() << "This ANGLE implementation does not support separate front/back "
                               "stencil writemasks, reference values, or stencil mask values.";
@@ -4109,11 +4109,11 @@
     }
 
     if (ANGLE_UNLIKELY(context->getLimitations().noSimultaneousConstantColorAndAlphaBlendFunc ||
-                       extensions.webglCompatibilityANGLE))
+                       context->isWebGL()))
     {
         if (state.hasSimultaneousConstantColorAndAlphaBlendFunc())
         {
-            if (extensions.webglCompatibilityANGLE)
+            if (context->isWebGL())
             {
                 return kInvalidConstantColor;
             }
@@ -4201,7 +4201,7 @@
 
     if (ANGLE_UNLIKELY(context->hasAnyEnabledClientAttrib()))
     {
-        if (extensions.webglCompatibilityANGLE || !state.areClientArraysEnabled())
+        if (context->isWebGL() || !state.areClientArraysEnabled())
         {
             // [WebGL 1.0] Section 6.5 Enabled Vertex Attributes and Range Checking
             // If a vertex attribute is enabled as an array via enableVertexAttribArray but no
@@ -4301,7 +4301,7 @@
         }
 
         // Do some additional WebGL-specific validation
-        if (ANGLE_UNLIKELY(extensions.webglCompatibilityANGLE))
+        if (ANGLE_UNLIKELY(context->isWebGL()))
         {
             const TransformFeedback *transformFeedbackObject = state.getCurrentTransformFeedback();
             if (state.isTransformFeedbackActive() &&
@@ -4310,12 +4310,6 @@
                 return kTransformFeedbackBufferDoubleBound;
             }
 
-            // Detect rendering feedback loops for WebGL.
-            if (framebuffer->formsRenderingFeedbackLoopWith(context))
-            {
-                return kFeedbackLoop;
-            }
-
             // Detect that the vertex shader input types match the attribute types
             if (!ValidateVertexShaderAttributeTypeMatch(context))
             {
@@ -4351,6 +4345,15 @@
             }
         }
 
+        if (ANGLE_UNLIKELY(context->isWebGL() || context->isHardenedContext()))
+        {
+            // UB: Detect rendering feedback loops for WebGL or hardened context.
+            if (framebuffer->formsRenderingFeedbackLoopWith(context))
+            {
+                return kFeedbackLoop;
+            }
+        }
+
         // The QCOM_framebuffer_foveated spec:
         if (ANGLE_UNLIKELY(framebuffer->isFoveationEnabled()))
         {
diff --git a/src/tests/gl_tests/WebGLCompatibilityTest.cpp b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
index 5b02305..772d5b0 100644
--- a/src/tests/gl_tests/WebGLCompatibilityTest.cpp
+++ b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
@@ -2678,6 +2678,72 @@
     EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
 }
 
+// Tests that a rendering feedback loop triggers a GL error for a hardened context.
+// Based on WebGL test conformance/renderbuffers/feedback-loop.html.
+TEST_P(HardenedContextTest, RenderingFeedbackLoop)
+{
+    constexpr char kVS[] =
+        R"(attribute vec4 a_position;
+varying vec2 v_texCoord;
+void main() {
+    gl_Position = a_position;
+    v_texCoord = (a_position.xy * 0.5) + 0.5;
+})";
+
+    constexpr char kFS[] =
+        R"(precision mediump float;
+varying vec2 v_texCoord;
+uniform sampler2D u_texture;
+void main() {
+    // Shader swizzles color channels so we can tell if the draw succeeded.
+    gl_FragColor = texture2D(u_texture, v_texCoord).gbra;
+})";
+
+    GLTexture texture;
+    FillTexture2D(texture, 1, 1, GLColor::red, 0, GL_RGBA, GL_RGBA, GL_UNSIGNED_BYTE);
+
+    ASSERT_GL_NO_ERROR();
+
+    GLFramebuffer framebuffer;
+    glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
+
+    ASSERT_GLENUM_EQ(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+    ANGLE_GL_PROGRAM(program, kVS, kFS);
+
+    GLint uniformLoc = glGetUniformLocation(program, "u_texture");
+    ASSERT_NE(-1, uniformLoc);
+
+    glUseProgram(program);
+    glUniform1i(uniformLoc, 0);
+    glDisable(GL_BLEND);
+    glDisable(GL_DEPTH_TEST);
+    ASSERT_GL_NO_ERROR();
+
+    // Drawing with a texture that is also bound to the current framebuffer should fail
+    glBindTexture(GL_TEXTURE_2D, texture);
+    drawQuad(program, "a_position", 0.5f, 1.0f, true);
+    EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+    // Ensure that the texture contents did not change after the previous render
+    glBindFramebuffer(GL_FRAMEBUFFER, 0);
+    drawQuad(program, "a_position", 0.5f, 1.0f, true);
+    ASSERT_GL_NO_ERROR();
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::blue);
+
+    // Drawing when texture is bound to an inactive uniform should succeed
+    GLTexture texture2;
+    FillTexture2D(texture2, 1, 1, GLColor::green, 0, GL_RGBA, GL_RGBA, GL_UNSIGNED_BYTE);
+
+    glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
+    glActiveTexture(GL_TEXTURE1);
+    glBindTexture(GL_TEXTURE_2D, texture);
+    drawQuad(program, "a_position", 0.5f, 1.0f, true);
+    ASSERT_GL_NO_ERROR();
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+}
+
 // Multi-context uses of textures should not cause rendering feedback loops.
 TEST_P(WebGLCompatibilityTest, MultiContextNoRenderingFeedbackLoops)
 {
@@ -3019,6 +3085,70 @@
     EXPECT_GL_NO_ERROR();
 }
 
+// Test tests that texture copying feedback loops are properly rejected in a hardened context.
+// Based on the WebGL test conformance/textures/misc/texture-copying-feedback-loops.html
+TEST_P(HardenedContextTest, TextureCopyingFeedbackLoops)
+{
+    // TODO(anglebug.com/40096747): Failing on ARM-based Apple DTKs.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/WebGLCompatibilityTest.cpp b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
index 5b02305..772d5b0 100644
--- a/src/tests/gl_tests/WebGLCompatibilityTest.cpp
+++ b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
@@ -2678,6 +2678,72 @@
     EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
 }
 
+// Tests that a rendering feedback loop triggers a GL error for a hardened context.
+// Based on WebGL test conformance/renderbuffers/feedback-loop.html.
+TEST_P(HardenedContextTest, RenderingFeedbackLoop)
+{
+    constexpr char kVS[] =
+        R"(attribute vec4 a_position;
+varying vec2 v_texCoord;
+void main() {
+    gl_Position = a_position;
+    v_texCoord = (a_position.xy * 0.5) + 0.5;
+})";
+
+    constexpr char kFS[] =
+        R"(precision mediump float;
+varying vec2 v_texCoord;
+uniform sampler2D u_texture;
+void main() {
+    // Shader swizzles color channels so we can tell if the draw succeeded.
+    gl_FragColor = texture2D(u_texture, v_texCoord).gbra;
+})";
+
+    GLTexture texture;
+    FillTexture2D(texture, 1, 1, GLColor::red, 0, GL_RGBA, GL_RGBA, GL_UNSIGNED_BYTE);
+
+    ASSERT_GL_NO_ERROR();
+
+    GLFramebuffer framebuffer;
+    glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
+
+    ASSERT_GLENUM_EQ(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER));
+
+    ANGLE_GL_PROGRAM(program, kVS, kFS);
+
+    GLint uniformLoc = glGetUniformLocation(program, "u_texture");
+    ASSERT_NE(-1, uniformLoc);
+
+    glUseProgram(program);
+    glUniform1i(uniformLoc, 0);
+    glDisable(GL_BLEND);
+    glDisable(GL_DEPTH_TEST);
+    ASSERT_GL_NO_ERROR();
+
+    // Drawing with a texture that is also bound to the current framebuffer should fail
+    glBindTexture(GL_TEXTURE_2D, texture);
+    drawQuad(program, "a_position", 0.5f, 1.0f, true);
+    EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+    // Ensure that the texture contents did not change after the previous render
+    glBindFramebuffer(GL_FRAMEBUFFER, 0);
+    drawQuad(program, "a_position", 0.5f, 1.0f, true);
+    ASSERT_GL_NO_ERROR();
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::blue);
+
+    // Drawing when texture is bound to an inactive uniform should succeed
+    GLTexture texture2;
+    FillTexture2D(texture2, 1, 1, GLColor::green, 0, GL_RGBA, GL_RGBA, GL_UNSIGNED_BYTE);
+
+    glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
+    glActiveTexture(GL_TEXTURE1);
+    glBindTexture(GL_TEXTURE_2D, texture);
+    drawQuad(program, "a_position", 0.5f, 1.0f, true);
+    ASSERT_GL_NO_ERROR();
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+}
+
 // Multi-context uses of textures should not cause rendering feedback loops.
 TEST_P(WebGLCompatibilityTest, MultiContextNoRenderingFeedbackLoops)
 {
@@ -3019,6 +3085,70 @@
     EXPECT_GL_NO_ERROR();
 }
 
+// Test tests that texture copying feedback loops are properly rejected in a hardened context.
+// Based on the WebGL test conformance/textures/misc/texture-copying-feedback-loops.html
+TEST_P(HardenedContextTest, TextureCopyingFeedbackLoops)
+{
+    // TODO(anglebug.com/40096747): Failing on ARM-based Apple DTKs.
+    ANGLE_SKIP_TEST_IF(IsMac() && IsARM64() && IsDesktopOpenGL());
+
+    GLTexture texture;
+    glBindTexture(GL_TEXTURE_2D, texture);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+    GLTexture texture2;
+    glBindTexture(GL_TEXTURE_2D, texture2);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+    GLFramebuffer framebuffer;
+    glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
+
+    // framebuffer should be FRAMEBUFFER_COMPLETE.
+    ASSERT_GLENUM_EQ(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER));
+    ASSERT_GL_NO_ERROR();
+
+    // testing copyTexImage2D
+
+    // copyTexImage2D to same texture but different level
+    glBindTexture(GL_TEXTURE_2D, texture);
+    glCopyTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA, 0, 0, 2, 2, 0);
+    EXPECT_GL_NO_ERROR();
+
+    // copyTexImage2D to same texture same level, invalid feedback loop
+    glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 0, 0, 2, 2, 0);
+    EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+    // copyTexImage2D to different texture
+    glBindTexture(GL_TEXTURE_2D, texture2);
+    glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 0, 0, 2, 2, 0);
+    EXPECT_GL_NO_ERROR();
+
+    // testing copyTexSubImage2D
+
+    // copyTexSubImage2D to same texture but different level
+    glBindTexture(GL_TEXTURE_2D, texture);
+    glCopyTexSubImage2D(GL_TEXTURE_2D, 1, 0, 0, 0, 0, 1, 1);
+    EXPECT_GL_NO_ERROR();
+
+    // copyTexSubImage2D to same texture same level, invalid feedback loop
+    glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 1, 1);
+    EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+    // copyTexSubImage2D to different texture
+    glBindTexture(GL_TEXTURE_2D, texture2);
+    glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, 1, 1);
+    EXPECT_GL_NO_ERROR();
+}
+
 // Test that copying from mip 1 of a texture to mip 0 works.  When the framebuffer is attached to
 // mip 1 of a mip-complete texture, an image with both mips are created.  When copying from the
 // framebuffer to mip 0, it is being redefined.
Loading diff…

Original Bug Report

reported by vm...@google.com

Bypass of ANGLE WebGL validation via GpuChannel IPC leading to GPU info leak

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 security team.

Overview: A compromised renderer can request a non-WebGL context (e.g., kOpenGLES2) via the GpuChannel Mojo IPC, bypassing ANGLE’s isWebGL() validation checks. This allows an attacker to bypass texture feedback loop protections and leak uninitialized GPU memory on the Metal backend.

Affected files:

  • gpu/ipc/service/gpu_channel.cc
  • gpu/ipc/common/gpu_channel.mojom
  • gpu/ipc/common/context_type_mojom_traits.h
  • gpu/ipc/service/gles2_command_buffer_stub.cc
  • gpu/command_buffer/service/service_utils.cc
  • ui/gl/gl_context_egl.cc
  • third_party/angle/src/libANGLE/renderer/metal/TextureMtl.mm
  • third_party/angle/src/libANGLE/validationES.cpp

Estimated timestamp from git blame: 2026-01-04

Background

ANGLE performs extensive validation when executing WebGL contexts, relying on the isWebGL() flag to enforce security boundaries, such as preventing texture feedback loops and ensuring robust resource initialization.

Potential Vulnerability

A compromised renderer process can potentially bypass these WebGL-specific validation checks by requesting a command buffer with CONTEXT_TYPE_OPENGLES2 via the GpuChannel Mojo interface.

In gpu/ipc/service/gpu_channel.cc, the GpuChannel::CreateCommandBuffer method receives mojom::CreateCommandBufferParams containing the requested context_type. The GPU process does not validate that a renderer-provided context_type is restricted to WebGL types. It fails to check the is_gpu_host_ flag to ensure untrusted renderers cannot request native GLES contexts. Because the Mojo default for context_type in GLESCreationAttribs is kOpenGLES2, explicitly setting it (or omitting the field) triggers the creation of an ANGLE context where isWebGL() evaluates to false.

Impact

By establishing a context where isWebGL() is false, an attacker bypasses approximately 79 validation sites across the ANGLE frontend and backends.

A severe consequence of this bypass is a potential uninitialized GPU memory leak on the Metal backend. An attacker can set up a texture copying feedback loop (which bypasses validation in validationES.cpp) and trigger an out-of-bounds copy via glCopyTexImage2D. Because isWebGL() is false, TextureMtl::copyImage skips the initializeContents call when allocating the new, larger texture backing:

// third_party/angle/src/libANGLE/renderer/metal/TextureMtl.mm
if (context->isWebGL() && !fbRect.encloses(sourceArea))
{
    ANGLE_TRY(initializeContents(context, GL_NONE, index));
}

This leaves the out-of-bounds region of the new texture uninitialized. The attacker can then read this texture to access uninitialized GPU memory, leading to a reliable cross-process/cross-origin information leak.

Potential Steps to Trigger

The following are suggested potential steps an attacker would follow to trigger the vulnerability:

  1. From a compromised renderer, obtain the GpuChannel Mojo remote.
  2. Construct mojom::CreateCommandBufferParams with attribs.context_type set to kOpenGLES2.
  3. Call CreateCommandBuffer over the IPC. The GPU process creates a context where isWebGL() is false.
  4. Create a texture, initialize it fully, and attach it to the current read framebuffer.
  5. Issue a glCopyTexImage2D operation where the sourceArea extends beyond the source framebuffer bounds, copying into the same texture (a feedback loop). This bypasses frontend initialization checks since it copies to itself.
  6. The Metal backend redefines the texture size but skips initialization. Read from the texture to access uninitialized GPU memory contents.

Suggested Fix

In gpu/ipc/service/gpu_channel.cc within GpuChannel::CreateCommandBuffer (or earlier in the message filter), add validation to verify the requested context_type. If the requester is an untrusted renderer (!is_gpu_host_), the context_type must be restricted to WebGL/WebGPU types (e.g., CONTEXT_TYPE_WEBGL1 or CONTEXT_TYPE_WEBGL2). If an unauthorized type is requested, the command buffer creation should fail and potentially trigger a mojo::ReportBadMessage.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


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