CVE-2026-12328
Overview
Background
- ANGLE
- Firefox’s translation layer that compiles WebGL/GLSL shaders down to the host platform’s native graphics API.
- `EmulateGLFragColorBroadcast`
- an ANGLE tree-transformation pass that rewrites the deprecated
gl_FragColoroutput into writes across thegl_FragDataarray for backends lacking native broadcast. - `TIntermGlobalQualifierDeclaration`
- an AST node representing a bare global qualifier statement such as
invariant gl_FragColor;that decorates a built-in without assigning it. - `ReferenceBuiltInVariable`
- an ANGLE helper that manufactures a fresh, correctly typed symbol node for a named built-in from the symbol table.
Root Cause Analysis
The GLFragColorBroadcastTraverser pass rewrote every ordinary reference to gl_FragColor (and gl_SecondaryFragColorEXT) into gl_FragData array accesses via visitSymbol, but it never inspected TIntermGlobalQualifierDeclaration nodes, so a global qualifier statement like invariant gl_FragColor; left an un-rewritten gl_FragColor symbol in the AST. The invariant the pass is supposed to guarantee — that no gl_FragColor symbol survives into later stages once the built-in has been emulated away — was therefore violated for the qualifier-declaration code path. Downstream compiler passes and code generation then operate on a symbol whose backing built-in has been eliminated/reshaped, producing an inconsistent, corrupted intermediate state that manifests as memory corruption.
The fix adds an explicit visitGlobalQualifierDeclaration override that detects the BuiltIn symbols gl_FragColor and gl_SecondaryFragColorEXT inside these declarations and rewrites them to gl_FragData / gl_SecondaryFragDataEXT with queueReplacementWithParent. By covering the previously unhandled node type, the pass now upholds its total-rewrite invariant so no stale built-in reference reaches later stages.
gl_FragColor in expression positions but ignored the same built-in appearing inside a global qualifier declaration, leaving a stale reference. The fix closes the gap by overriding visitGlobalQualifierDeclaration so the built-in is rewritten in that node type too.Attack Path
- Craft a malicious shader
An attacker authors a WebGL fragment shader that both uses
gl_FragColoroutput emulation and declares it with a bare global qualifier, e.g.invariant gl_FragColor;. - Submit to WebGL The shader is delivered to a victim via any page with WebGL and compiled through ANGLE’s translator.
- Bypass rewrite
The
EmulateGLFragColorBroadcastpass rewrites the assignment uses but leaves thegl_FragColorsymbol inside theTIntermGlobalQualifierDeclarationuntouched. - Trigger corruption Later translator passes and code generation dereference the stale built-in whose emulation has already removed its backing, corrupting compiler memory state.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
GLFragColorBroadcastTraversergfx/angle/checkout/src/compiler/translator/tree_ops/EmulateGLFragColorBroadcast.cpp |
modified |
Files Changed
gfx/angle/checkout/src/compiler/translator/tree_ops/EmulateGLFragColorBroadcast.cpp
Audit Directions
- Incomplete visitor coverageWhen a tree-transform pass must eliminate a symbol entirely, audit every AST node type that can hold that symbol (
visitSymbol,visitGlobalQualifierDeclaration, and other overrides), not just the common expression cases. - Built-in emulation invariantsFor any pass that rewrites or removes a built-in, verify no residual reference to the original built-in can survive into later stages, including declaration-only and qualifier contexts.
- Secondary/extension built-insCheck that companion built-ins like
gl_SecondaryFragColorEXTandgl_SecondaryFragDataEXTreceive the same rewriting treatment as their primary counterparts across all node types.
Patch
diff --git a/gfx/angle/checkout/src/compiler/translator/tree_ops/EmulateGLFragColorBroadcast.cpp b/gfx/angle/checkout/src/compiler/translator/tree_ops/EmulateGLFragColorBroadcast.cpp
index 25e1fde29a2..606a94ff9b9 100644
--- a/gfx/angle/checkout/src/compiler/translator/tree_ops/EmulateGLFragColorBroadcast.cpp
+++ b/gfx/angle/checkout/src/compiler/translator/tree_ops/EmulateGLFragColorBroadcast.cpp
@@ -26,6 +26,7 @@ namespace
{
constexpr const ImmutableString kGlFragDataString("gl_FragData");
+constexpr const ImmutableString kGlSecondaryFragDataString("gl_SecondaryFragDataEXT");
class GLFragColorBroadcastTraverser : public TIntermTraverser
{
@@ -42,6 +43,8 @@ class GLFragColorBroadcastTraverser : public TIntermTraverser
bool isGLFragColorUsed() const { return mGLFragColorUsed; }
protected:
+ bool visitGlobalQualifierDeclaration(Visit visit,
+ TIntermGlobalQualifierDeclaration *node) override;
void visitSymbol(TIntermSymbol *node) override;
TIntermBinary *constructGLFragDataNode(int index) const;
@@ -71,6 +74,31 @@ TIntermBinary *GLFragColorBroadcastTraverser::constructGLFragDataAssignNode(int
return new TIntermBinary(EOpAssign, fragDataIndex, fragDataZero);
}
+bool GLFragColorBroadcastTraverser::visitGlobalQualifierDeclaration(
+ Visit visit,
+ TIntermGlobalQualifierDeclaration *node)
+{
+ TIntermSymbol *symbol = node->getSymbol();
+ if (symbol->variable().symbolType() == SymbolType::BuiltIn)
+ {
+ if (symbol->getName() == "gl_FragColor")
+ {
+ queueReplacementWithParent(
+ node, node->getSymbol(),
+ ReferenceBuiltInVariable(kGlFragDataString, *mSymbolTable, mShaderVersion),
+ OriginalNode::IS_DROPPED);
+ }
+ else if (symbol->getName() == "gl_SecondaryFragColorEXT")
+ {
+ queueReplacementWithParent(
+ node, node->getSymbol(),
+ ReferenceBuiltInVariable(kGlSecondaryFragDataString, *mSymbolTable, mShaderVersion),
+ OriginalNode::IS_DROPPED);
+ }
+ }
+ return false;
+}
+
void GLFragColorBroadcastTraverser::visitSymbol(TIntermSymbol *node)
{
if (node->variable().symbolType() == SymbolType::BuiltIn && node->getName() == "gl_FragColor")