CVE-2026-9913
Overview
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
EnsureLoopForwardProgresspass injects a volatileANGLE_loopForwardProgress()side effect into loops it cannot prove finite, keeping the loop “real.” - AST loop node (`TIntermLoop`)
- Has four child subtrees:
init,condition,expression(thei++step), andbody. - `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.
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
- 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++){} }. - Evade detection
Pre-patch,
traverseLoopskips the innerinit, so thei = 0write is never seen; both loops are marked finite. - No mitigation injected
ANGLE_loopForwardProgress()is not added to either loop, so the emitted MSL contains a side-effect-free infinite loop. - 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).
- 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
Files Changed
src/compiler/translator/tree_ops/msl/EnsureLoopForwardProgress.cppsrc/tests/gl_tests/GLSLOutputTest.cpp
Audit Directions
- Custom `traverseLoop` overridesAudit other ANGLE AST passes that override
traverseLoop/visitation and may similarly skipinit/cond/expr, missing side effects. - `computeFiniteLoopVariable`The advisory notes this helper ignores the
initnode; review it for finiteness decisions that disregard initialization writes. - Mitigation completeness on all backendsCompare the Metal forward-progress pass with analogous loop-safety handling in other backends (Vulkan/D3D) for the same incomplete-traversal gap.
Patch
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) {
Regression Test / PoC
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)
{
Original Bug Report
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
}
}
- The traverser evaluates the outer loop and marks it as finite based on
i != 10andi++. - The traverser traverses the outer loop’s body and encounters the inner loop.
- The traverser evaluates the inner loop and marks it as finite based on
j != 5andj++. It ignores thej = (i = 0)initialization. - The traverser traverses the inner loop’s body. Crucially, it skips traversing the inner loop’s initialization.
- Because the
initblock is never traversed, theTLValueTrackingTraversernever sees thei = 0assignment.visitSymbolis bypassed for this modification. - Both loops are incorrectly finalized as finite, and the
ANGLE_loopForwardProgress()mitigation is not injected into either loop. - 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.