High CVSS 4.3 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ANGLE
DescriptionInappropriate implementation in ANGLE
ComponentANGLE
Sub-componentMSL
Bug ClassLogic Error
Tracker500046096
Fix commit64b421a9a1ef (angle/angle) +32/-13
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-06 (per vendor advisory)
Fix landed2026-05-07 (public git)
Disclosed2026-05-27
Patch gap20 days — fix public in source before the advisory

Background

Metal/MSL backend forward-progress mitigation
On macOS the Apple Metal (LLVM-based) compiler treats a side-effect-free infinite loop as UB and may delete code after it or elide ANGLE’s inserted bounds checks. ANGLE’s EnsureLoopForwardProgress pass injects a volatile ANGLE_loopForwardProgress() side effect into loops it cannot prove finite, keeping the loop “real.”
AST loop node (`TIntermLoop`)
Has four child subtrees: init, condition, expression (the i++ step), and body.
`EnsureLoopForwardProgressTraverser::traverseLoop`
ANGLE’s custom traversal that decides whether a loop is finite; it tracks the induction variable and marks a loop non-finite if the variable is modified unexpectedly (detected in visitSymbol).
`LValueTrackingTraverser` / `visitSymbol`
Base machinery that observes assignments to symbols; it only fires for subtrees actually traversed.
`computeFiniteLoopVariable`
Helper that classifies a loop as a simple finite loop from its cond/expr.

Root Cause Analysis

The custom EnsureLoopForwardProgressTraverser::traverseLoop in src/compiler/translator/tree_ops/msl/EnsureLoopForwardProgress.cpp traversed only node->getBody()->traverse(this) — it never descended into the loop’s init, condition, or expression subtrees. Because visitSymbol fires only on traversed nodes, any assignment to an enclosing loop’s induction variable that is hidden inside a nested loop’s init/cond/expr (e.g. for (int j = (i = 0); ...)) was never observed. The outer loop was still classified finite (its own cond/expr look well-formed), so no ANGLE_loopForwardProgress() side effect was injected — yielding a side-effect-free loop that is actually infinite at runtime, which is exactly the UB the mitigation exists to prevent.

The patch makes traverseLoop descend into all three previously-skipped subtrees before the body: after installing ScopedNodeInTraversalPath addToPath(this, node) (moved up), it now calls node->getInit()->traverse(this), node->getCondition()->traverse(this), and node->getExpression()->traverse(this) (each guarded by a null check), then pushes the LoopInfoStack and traverses the body as before. With init/cond/expr visited, a write to an enclosing induction variable anywhere in a nested loop’s header is now seen by visitSymbol, the enclosing loop is correctly marked non-finite, and the forward-progress side effect is injected. The added GLSLOutputTest cases cover exactly these shapes (writes to i inside an inner loop’s init, cond, and expr). The advisory labels this a logic error whose downstream consequence is OOB; the diff substantiates the logic error (incomplete AST traversal in a mitigation) — the actual OOB arises from the Metal compiler’s optimization of the resulting infinite loop and is not present in this diff.

Key insight
ANGLE’s infinite-loop mitigation only inspected loop bodies, so an induction-variable write smuggled into a nested loop’s init/cond/expr escaped detection; both loops were wrongly deemed finite and the forward-progress side effect was omitted, leaving a side-effect-free infinite loop for Metal to miscompile. The fix traverses init/cond/expr as well.

Attack Path

  1. Craft shader Attacker writes a WebGL shader with an outer loop and a nested inner loop whose header modifies the outer induction variable, e.g. for (int i=0;i!=10;i++){ for (int j=(i=0); j!=5; j++){} }.
  2. Evade detection Pre-patch, traverseLoop skips the inner init, so the i = 0 write is never seen; both loops are marked finite.
  3. No mitigation injected ANGLE_loopForwardProgress() is not added to either loop, so the emitted MSL contains a side-effect-free infinite loop.
  4. Metal miscompiles The Apple Metal/LLVM compiler treats the infinite loop as UB and may delete following code or elide ANGLE’s bounds checks (inferred; driver/compiler-side, not in diff).
  5. OOB access With bounds checks elided, subsequent indexed accesses run out of bounds in the GPU process — the achieved primitive is an OOB read/write in the GPU process (inferred), historically seen (cf. crbug.com/41486305).

Impact Assessment

Reachable from an unprivileged renderer via a WebGL shader on the macOS/Metal backend. The diff substantiates a logic error — an incomplete AST traversal that defeats a UB-avoidance mitigation — but the memory-safety sink (bounds-check elision and subsequent out-of-bounds access) occurs inside the Apple Metal compiler’s output and is therefore (inferred), not demonstrated by the diff. Realistic impact is a GPU-process OOB/crash (DoS) with potential for stronger corruption depending on what the Metal optimizer elides; a crash is not RCE, and the escalation to a controlled OOB is (inferred). Gated on the macOS/Metal backend.

Files Changed

  • src/compiler/translator/tree_ops/msl/EnsureLoopForwardProgress.cpp
  • src/tests/gl_tests/GLSLOutputTest.cpp

Audit Directions

  • Custom `traverseLoop` overrides
    Audit other ANGLE AST passes that override traverseLoop/visitation and may similarly skip init/cond/expr, missing side effects.
  • `computeFiniteLoopVariable`
    The advisory notes this helper ignores the init node; review it for finiteness decisions that disregard initialization writes.
  • Mitigation completeness on all backends
    Compare the Metal forward-progress pass with analogous loop-safety handling in other backends (Vulkan/D3D) for the same incomplete-traversal gap.
From 64b421a9a1ef9a726e9a8bcca11c2b17c4bbfb29 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <[email protected]>
Date: Thu, 07 May 2026 10:56:33 -0400
Subject: [PATCH] MSL: Inspect loop init/cond/expr in loop-forward-progress

The loop variable in an enclosing loop might be modified inside the
init, condition and expression of the for loop too, not just the body.

Bug: chromium:500046096
Change-Id: I880be859bb57122a0549f60401763cc8122a7aff
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7828698
Reviewed-by: Geoff Lang <[email protected]>
Commit-Queue: Shahbaz Youssefi <[email protected]>
---

diff --git a/src/compiler/translator/tree_ops/msl/EnsureLoopForwardProgress.cpp b/src/compiler/translator/tree_ops/msl/EnsureLoopForwardProgress.cpp
index 98a4c8c..3d81eed 100644
--- a/src/compiler/translator/tree_ops/msl/EnsureLoopForwardProgress.cpp
+++ b/src/compiler/translator/tree_ops/msl/EnsureLoopForwardProgress.cpp
@@ -209,10 +209,23 @@
 
 void EnsureLoopForwardProgressTraverser::traverseLoop(TIntermLoop *node)
 {
+    ScopedNodeInTraversalPath addToPath(this, node);
+    if (node->getInit())
+    {
+        node->getInit()->traverse(this);
+    }
+    if (node->getCondition())
+    {
+        node->getCondition()->traverse(this);
+    }
+    if (node->getExpression())
+    {
+        node->getExpression()->traverse(this);
+    }
+
     LoopInfoStack loopInfo{node, mLoopInfoStack};
     mLoopInfoStack = &loopInfo;
 
-    ScopedNodeInTraversalPath addToPath(this, node);
     node->getBody()->traverse(this);
 
     if (!loopInfo.isFinite())
diff --git a/src/tests/gl_tests/GLSLOutputTest.cpp b/src/tests/gl_tests/GLSLOutputTest.cpp
index 0539f10..4df385b 100644
--- a/src/tests/gl_tests/GLSLOutputTest.cpp
+++ b/src/tests/gl_tests/GLSLOutputTest.cpp
@@ -580,18 +580,24 @@
 
 )";
     const char kShaderSuffix[] = "}\n";
-    const char *kTests[]{"for (;;) { }",
-                         "for (bool b = true; b; b = false) { }",
-                         "for (int i = 0; i < 10;) { }",
-                         "int i = 101; for (; i < 10; i+=2) { }",
-                         "int i = 101; for (; i < 10; i-=2) { }",
-                         "int z = 7; for (int i = 0; i < z; i++) { }",
-                         "for (int i = 0; i < 10; i++) { i++; }",
-                         "for (int i = 0; i < 10;) { i++; }",
-                         "for (int i = 0; i < a/2; i++) { }",
-                         "for (int i = 0; float(i) < 10e10; ++i) { }",
-                         "for (int i = 0; i < 10; i++) { for (int j = 0; j < 1000; ++i) { }}",
-                         "for (int i = 0; i != 1; i+=2) { }"};
+    const char *kTests[]{
+        "for (;;) { }",
+        "for (bool b = true; b; b = false) { }",
+        "for (int i = 0; i < 10;) { }",
+        "int i = 101; for (; i < 10; i+=2) { }",
+        "int i = 101; for (; i < 10; i-=2) { }",
+        "int z = 7; for (int i = 0; i < z; i++) { }",
+        "for (int i = 0; i < 10; i++) { i++; }",
+        "for (int i = 0; i < 10;) { i++; }",
+        "for (int i = 0; i < a/2; i++) { }",
+        "for (int i = 0; float(i) < 10e10; ++i) { }",
+        "for (int i = 0; i < 10; i++) { for (int j = 0; j < 1000; ++i) { }}",
+        "for (int i = 0; i != 1; i+=2) { }",
+        "for (int i = 0; i < 10; i++) { int j; for (j = 0, i = 0; j < 10; j++) { } }",
+        "for (int i = 0; i < 10; i++) { for (int j = 0; i = 0, j < 10; j++) { } }",
+        "for (int i = 0; i < 10; i++) { for (int j = 0; j < 10; i = 0, j++) { } }",
+        "for (int i = 0; i < 10; i++) { for (int j = 0; j < 10; i--, j++) { } }",
+    };
 
     for (const char *test : kTests)
     {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/GLSLOutputTest.cpp b/src/tests/gl_tests/GLSLOutputTest.cpp
index 0539f10..4df385b 100644
--- a/src/tests/gl_tests/GLSLOutputTest.cpp
+++ b/src/tests/gl_tests/GLSLOutputTest.cpp
@@ -580,18 +580,24 @@
 
 )";
     const char kShaderSuffix[] = "}\n";
-    const char *kTests[]{"for (;;) { }",
-                         "for (bool b = true; b; b = false) { }",
-                         "for (int i = 0; i < 10;) { }",
-                         "int i = 101; for (; i < 10; i+=2) { }",
-                         "int i = 101; for (; i < 10; i-=2) { }",
-                         "int z = 7; for (int i = 0; i < z; i++) { }",
-                         "for (int i = 0; i < 10; i++) { i++; }",
-                         "for (int i = 0; i < 10;) { i++; }",
-                         "for (int i = 0; i < a/2; i++) { }",
-                         "for (int i = 0; float(i) < 10e10; ++i) { }",
-                         "for (int i = 0; i < 10; i++) { for (int j = 0; j < 1000; ++i) { }}",
-                         "for (int i = 0; i != 1; i+=2) { }"};
+    const char *kTests[]{
+        "for (;;) { }",
+        "for (bool b = true; b; b = false) { }",
+        "for (int i = 0; i < 10;) { }",
+        "int i = 101; for (; i < 10; i+=2) { }",
+        "int i = 101; for (; i < 10; i-=2) { }",
+        "int z = 7; for (int i = 0; i < z; i++) { }",
+        "for (int i = 0; i < 10; i++) { i++; }",
+        "for (int i = 0; i < 10;) { i++; }",
+        "for (int i = 0; i < a/2; i++) { }",
+        "for (int i = 0; float(i) < 10e10; ++i) { }",
+        "for (int i = 0; i < 10; i++) { for (int j = 0; j < 1000; ++i) { }}",
+        "for (int i = 0; i != 1; i+=2) { }",
+        "for (int i = 0; i < 10; i++) { int j; for (j = 0, i = 0; j < 10; j++) { } }",
+        "for (int i = 0; i < 10; i++) { for (int j = 0; i = 0, j < 10; j++) { } }",
+        "for (int i = 0; i < 10; i++) { for (int j = 0; j < 10; i = 0, j++) { } }",
+        "for (int i = 0; i < 10; i++) { for (int j = 0; j < 10; i--, j++) { } }",
+    };
 
     for (const char *test : kTests)
     {
Loading diff…

Original Bug Report

reported by [email protected]

Potential bypass of ANGLE Metal forward-progress mitigation via incomplete AST traversal

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: The EnsureLoopForwardProgress pass in ANGLE fails to traverse loop initialization, condition, and expression subtrees. This allows WebGL shaders to hide induction variable writes inside nested loops, bypassing infinite loop detection and potentially leading to out-of-bounds memory access in the GPU process.

Affected files:

  • third_party/angle/src/compiler/translator/tree_ops/msl/EnsureLoopForwardProgress.cpp

Estimated timestamp from git blame: 2025-06-16

Background

ANGLE includes a security mitigation (EnsureLoopForwardProgress) for the macOS/Metal backend to prevent the Apple Metal compiler (which is LLVM-based) from aggressively optimizing away side-effect-free infinite loops. Under LLVM semantics, an infinite loop without side effects is undefined behavior. If a shader contains such a loop, the compiler may assume the code following the loop is unreachable or elide bounds checks that ANGLE inserts for safety. This optimization bug has historically led to out-of-bounds (OOB) memory access and crashes in the GPU process (e.g., crbug.com/41486305). To prevent this, ANGLE injects a volatile variable access (ANGLE_loopForwardProgress()) into loops it cannot prove are finite.

Description of the Vulnerability

The vulnerability lies in how EnsureLoopForwardProgressTraverser::traverseLoop traverses the Abstract Syntax Tree (AST). The pass is designed to monitor loop induction variables to ensure they are not modified outside of the expected increment/decrement step. If a modification is detected via visitSymbol, the loop is marked as non-finite.

However, the custom traverseLoop implementation in third_party/angle/src/compiler/translator/tree_ops/msl/EnsureLoopForwardProgress.cpp is flawed:

void EnsureLoopForwardProgressTraverser::traverseLoop(TIntermLoop *node)
{
    // ... setup ...
    node->getBody()->traverse(this); // <-- ONLY traverses the body
    // ... mitigation injection ...
}

Unlike the default TIntermTraverser::traverseLoop, which traverses all children of a loop (init, cond, expr, and body), this custom implementation explicitly skips traversing the initialization (init), condition (cond), and expression (expr) subtrees.

Additionally, the helper function computeFiniteLoopVariable (used to initially identify simple finite loops) only inspects the cond and expr nodes, completely ignoring the init node.

Potential Attack Steps

An attacker can construct a WebGL shader to hide a write to an outer loop’s induction variable inside the initialization of a nested inner loop:

for (int i = 0; i != 10; i++) {           // Outer loop
    for (int j = (i = 0); j != 5; j++) {  // Inner loop resets 'i'
        // Inner loop body
    }
}
  1. The traverser evaluates the outer loop and marks it as finite based on i != 10 and i++.
  2. The traverser traverses the outer loop’s body and encounters the inner loop.
  3. The traverser evaluates the inner loop and marks it as finite based on j != 5 and j++. It ignores the j = (i = 0) initialization.
  4. The traverser traverses the inner loop’s body. Crucially, it skips traversing the inner loop’s initialization.
  5. Because the init block is never traversed, the TLValueTrackingTraverser never sees the i = 0 assignment. visitSymbol is bypassed for this modification.
  6. Both loops are incorrectly finalized as finite, and the ANGLE_loopForwardProgress() mitigation is not injected into either loop.
  7. The resulting Metal Shading Language (MSL) code contains a side-effect-free infinite loop. The Apple Metal compiler optimizes this loop, potentially eliding subsequent bounds checks and leading to out-of-bounds memory access in the GPU process.

Note: These are suggested steps based on static analysis. Our tooling agent does not currently have the ability to run code to produce a working proof of concept.

Proposed Fix

Modify EnsureLoopForwardProgressTraverser::traverseLoop to ensure that all subtrees of the loop are traversed, not just the body. It should call traverse(node) (which relies on the base class’s generic traversal logic that visits all children) instead of node->getBody()->traverse(this).

Care must be taken to ensure that the traversal of the loop’s own expr block (e.g., i++) does not incorrectly flag the loop as non-finite. The traverser might need to temporarily suppress setNotFinite for the current loop’s expected induction variable when traversing its own expr node.

Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad


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
Links in the report