Medium patch-now ~ plausible CVSS 9.6 chrome OOB 🔧 Commit mapped

Overview

Medium
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Sub-componentMetal
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIPlausible
The mapped commit may not contain the whole fix.
Reviewer notes: Diff only replaces UB-prone integer/float-to-int ops in the Metal translator ('Further' emulation); no indexing or bounds code changes, so the UB-to-OOB-write path is inferred yet stated as fact. Also omits unsigned div/shift, negInt and increment/decrement changes; limited to Metal backend.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker500127519
Fix commit35c006a36fe9 (angle/angle) +2224/-36
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-06 (per vendor advisory)
Fix landed2026-08-19 (public git)
Disclosed2026-09-22
Patch gap34 days — fix public in source before the advisory

Background

ANGLE
Google’s cross-platform graphics abstraction that translates WebGL/GLES calls into native backends; its Metal backend translates GLSL into Metal Shading Language (MSL).
`EmitMetal.cpp`
the MSL code generator in the ANGLE Metal translator that walks the shader AST and prints the target MSL source, including the operator strings used for each TOperator.
UB in `MSL`
signed integer overflow and out-of-range float-to-int conversion are undefined behavior in Metal Shading Language, so unguarded emission produces implementation-defined (and attacker-influenceable) results.
`TType::isSignedInt()`
a new type predicate added in Types.h that tells the emitter whether an operation’s result type is a signed integer, gating which operator string is emitted.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Diff only replaces UB-prone integer/float-to-int ops in the Metal translator ('Further' emulation); no indexing or bounds code changes, so the UB-to-OOB-write path is inferred yet stated as fact. Also omits unsigned div/shift, negInt and increment/decrement changes; limited to Metal backend.

The ANGLE Metal translator previously emitted signed-integer arithmetic, shifts, and float-to-int constructor conversions using raw C operators (+, -, *, <<, casts) directly into the generated MSL. Because signed integer overflow and out-of-range float→int conversion are undefined behavior in Metal Shading Language, those unguarded operations could produce undefined results at shader runtime rather than the well-defined wrapping/clamped semantics GLSL requires. An attacker authoring a WebGL shader can craft expressions that overflow a signed int or convert an out-of-range float via int(f)/ivec3(v), and the undefined result can then be used as an array index or write target inside the shader. Since the translated shader runs on the GPU with results that are not bounded, this can drive an out-of-bounds index or write.

The fix routes signed-int operators through guarded helper calls (ANGLE_addInt, ANGLE_subInt, ANGLE_imul, ANGLE_div, ANGLE_imod, ANGLE_ilshift, ANGLE_ulshift, ANGLE_ftoi) that impose defined, wrapping/clamped semantics. IsFloatToIntegerConstructor() in AstHelpers.cpp detects the float→int constructor cases so they emit ANGLE_ftoi instead of a raw cast.

Key insight
GLSL guarantees defined wrapping/clamping for integer overflow and out-of-range float-to-int, but Metal treats these as undefined behavior — so a faithful, safe translator must interpose helper functions rather than pass the operations through as native MSL operators.

Attack Path

  1. Craft a malicious shader An attacker supplies a WebGL/GLES shader containing signed-integer arithmetic or an int(f)/ivec3(v) conversion designed to overflow or fall out of range.
  2. Unguarded translation Pre-patch EmitMetal.cpp emits the operation using a raw MSL operator or cast, carrying the undefined-behavior semantics into the generated shader.
  3. Undefined result at runtime On the GPU, the overflowing operation or out-of-range float→int cast yields an undefined value not constrained to the expected range.
  4. Out-of-bounds index/write The undefined value is used to index or write into a shader array or buffer, producing an out-of-bounds access.
  5. Memory corruption The out-of-bounds write corrupts GPU-accessible memory, the security impact ANGLE flags for this component.

Impact Assessment

This is an out-of-bounds write in ANGLE reachable from attacker-controlled WebGL shaders, giving a remote web page a path to GPU memory corruption. Because the undefined value derives from attacker-chosen operands, the resulting index or write offset is influenceable, raising the corruption toward controlled out-of-bounds writes. Exploitation requires only that a victim load a page that submits the crafted shader to the Metal backend.

Files Changed

  • src/compiler/translator/Types.h
  • src/compiler/translator/msl/EmitMetal.cpp
  • src/compiler/translator/tree_ops/msl/RewriteUnaddressableReferences.cpp
  • src/tests/angle_end2end_tests.gni
  • src/tests/gl_tests/GLSLUBTest.cpp
  • src/compiler/translator/msl/AstHelpers.h
  • src/compiler/translator/msl/ProgramPrelude.cpp
  • src/tests/angle_end2end_tests_expectations.txt
  • src/compiler/translator/msl/AstHelpers.cpp

Audit Directions

  • UB-carrying operators
    Search every translator backend for raw C operators or casts emitted for integer arithmetic, shifts, and float→int conversions; each is a candidate for undefined-behavior leakage where the target language does not match GLSL’s defined semantics.
  • Result-type gating
    Audit code that branches on isSignedInt()/IsInteger() to ensure EOpMul, EOpDiv, EOpIMod, and both shift-assign variants are all covered and that vector/matrix component types are classified correctly.
  • Overflow/out-of-range sinks
    Trace whether translated integer or float→int results reach array indices, buffer offsets, or write targets, and confirm the guarding helpers are applied before any such sink rather than only on standalone expressions.
From 35c006a36fe974c31816f3f6102e4e5df0da9ed9 Mon Sep 17 00:00:00 2001
From: Kimmo Kinnunen <kkinnunen@apple.com>
Date: Fri, 14 Aug 2026 14:37:45 +0300
Subject: [PATCH] Metal: Further signed int emulation implementation

Implement div, imod, imul, addInt, subInt, ilshift, ulshift, rshift,
ftoi, preIncrementInt, postIncrementInt, preDecrementInt,
postDecrementInt.

Bug: angleproject:500127519
Change-Id: I5148d7a3ef746873d8c29d497f79a2a9d1d6af93
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8250860
Commit-Queue: Kimmo Kinnunen <kkinnunen@apple.com>
Auto-Submit: Kimmo Kinnunen <kkinnunen@apple.com>
Reviewed-by: Alexey Knyazev <lexa.knyazev@gmail.com>
Reviewed-by: Kimmo Kinnunen <kkinnunen@apple.com>
---
diff --git a/src/compiler/translator/Types.h b/src/compiler/translator/Types.h
index d47613a..a777fe7 100644
--- a/src/compiler/translator/Types.h
+++ b/src/compiler/translator/Types.h
@@ -256,6 +256,7 @@
     bool isScalarBool() const { return isScalar() && type == EbtBool; }
     bool isScalarFloat() const { return isScalar() && type == EbtFloat; }
     bool isScalarInt() const { return isScalar() && (type == EbtInt || type == EbtUInt); }
+    bool isSignedInt() const { return type == EbtInt; }
 
     bool canBeConstructed() const;
 
diff --git a/src/compiler/translator/msl/AstHelpers.cpp b/src/compiler/translator/msl/AstHelpers.cpp
index a2ad182..8a5db24 100644
--- a/src/compiler/translator/msl/AstHelpers.cpp
+++ b/src/compiler/translator/msl/AstHelpers.cpp
@@ -248,6 +248,21 @@
     return HasScalarBasicType(type.getBasicType());
 }
 
+bool sh::IsFloatToIntegerConstructor(const TIntermAggregate &node)
+{
+    if (!node.isConstructor() || node.getChildCount() != 1)
+    {
+        return false;
+    }
+    const TType &retType = node.getType();
+    if (!IsInteger(retType.getBasicType()) || (!retType.isScalar() && !retType.isVector()))
+    {
+        return false;
+    }
+    const TType &argType = node.getChildNode(0)->getAsTyped()->getType();
+    return argType.getBasicType() == EbtFloat && (argType.isScalar() || argType.isVector());
+}
+
 TType &sh::CloneType(const TType &type)
 {
     TType &clone = *new TType(type);
diff --git a/src/compiler/translator/msl/AstHelpers.h b/src/compiler/translator/msl/AstHelpers.h
index 905004f..e69104b 100644
--- a/src/compiler/translator/msl/AstHelpers.h
+++ b/src/compiler/translator/msl/AstHelpers.h
@@ -89,6 +89,11 @@
 // Matches bool, int, uint32_t, float.
 bool HasScalarBasicType(TBasicType type);
 
+// Matches a scalar or vector constructor that converts a floating point value to an integer, e.g.
+// int(f) or ivec3(v). Such conversions have undefined behavior in Metal when the value is out of
+// the range of the destination type, so they are emitted as a call to ANGLE_ftoi.
+bool IsFloatToIntegerConstructor(const TIntermAggregate &node);
+
 // Clones a type.
 TType &CloneType(const TType &type);
 
diff --git a/src/compiler/translator/msl/EmitMetal.cpp b/src/compiler/translator/msl/EmitMetal.cpp
index 014df1f..65298cc 100644
--- a/src/compiler/translator/msl/EmitMetal.cpp
+++ b/src/compiler/translator/msl/EmitMetal.cpp
@@ -281,19 +281,19 @@
         case TOperator::EOpInitialize:
             return "=";
         case TOperator::EOpAddAssign:
-            return "+=";
+            return resultType.isSignedInt() ? "ANGLE_addInt" : "+=";
         case TOperator::EOpSubAssign:
-            return "-=";
+            return resultType.isSignedInt() ? "ANGLE_subInt" : "-=";
         case TOperator::EOpMulAssign:
-            return "*=";
+            return resultType.isSignedInt() ? "ANGLE_imul" : "*=";
         case TOperator::EOpDivAssign:
-            return "/=";
+            return IsInteger(resultType.getBasicType()) ? "ANGLE_div" : "/=";
         case TOperator::EOpIModAssign:
-            return "%=";
+            return "ANGLE_imod";
         case TOperator::EOpBitShiftLeftAssign:
-            return "<<=";  // TODO: Check logical vs arithmetic shifting.
+            return resultType.isSignedInt() ? "ANGLE_ilshift" : "ANGLE_ulshift";
         case TOperator::EOpBitShiftRightAssign:
-            return ">>=";  // TODO: Check logical vs arithmetic shifting.
+            return "ANGLE_rshift";
         case TOperator::EOpBitwiseAndAssign:
             return "&=";
         case TOperator::EOpBitwiseXorAssign:
@@ -301,19 +301,19 @@
         case TOperator::EOpBitwiseOrAssign:
             return "|=";
         case TOperator::EOpAdd:
-            return "+";
+            return resultType.isSignedInt() ? "ANGLE_addInt" : "+";
         case TOperator::EOpSub:
-            return "-";
+            return resultType.isSignedInt() ? "ANGLE_subInt" : "-";
         case TOperator::EOpMul:
-            return "*";
+            return resultType.isSignedInt() ? "ANGLE_imul" : "*";
         case TOperator::EOpDiv:
-            return "/";
+            return IsInteger(resultType.getBasicType()) ? "ANGLE_div" : "/";
         case TOperator::EOpIMod:
-            return "%";
+            return "ANGLE_imod";
         case TOperator::EOpBitShiftLeft:
-            return "<<";  // TODO: Check logical vs arithmetic shifting.
+            return resultType.isSignedInt() ? "ANGLE_ilshift" : "ANGLE_ulshift";
         case TOperator::EOpBitShiftRight:
-            return ">>";  // TODO: Check logical vs arithmetic shifting.
+            return "ANGLE_rshift";
         case TOperator::EOpBitwiseAnd:
             return "&";
         case TOperator::EOpBitwiseXor:
@@ -344,7 +344,12 @@
         case TOperator::EOpLogicalAnd:
             return "&&";
         case TOperator::EOpNegative:
-            return "-";
+            // Matrices have an operator- overload in the program prelude.
+            if (argType0->isMatrix())
+            {
+                return "-";
+            }
+            return resultType.isSignedInt() ? "ANGLE_negInt" : "-";
         case TOperator::EOpPositive:
             if (argType0->isMatrix())
             {
@@ -358,15 +363,15 @@
         case TOperator::EOpBitwiseNot:
             return "~";
         case TOperator::EOpPostIncrement:
-            return "++";
+            return resultType.isSignedInt() ? "ANGLE_postIncrementInt" : "++";
         case TOperator::EOpPostDecrement:
-            return "--";
+            return resultType.isSignedInt() ? "ANGLE_postDecrementInt" : "--";
         case TOperator::EOpPreIncrement:
-            return "++";
+            return resultType.isSignedInt() ? "ANGLE_preIncrementInt" : "++";
         case TOperator::EOpPreDecrement:
-            return "--";
+            return resultType.isSignedInt() ? "ANGLE_preDecrementInt" : "--";
         case TOperator::EOpVectorTimesScalarAssign:
-            return "*=";
+            return resultType.isSignedInt() ? "ANGLE_imul" : "*=";
         case TOperator::EOpVectorTimesMatrixAssign:
             return "*=";
         case TOperator::EOpMatrixTimesScalarAssign:
@@ -374,7 +379,7 @@
         case TOperator::EOpMatrixTimesMatrixAssign:
             return "*=";
         case TOperator::EOpVectorTimesScalar:
-            return "*";
+            return resultType.isSignedInt() ? "ANGLE_imul" : "*";
         case TOperator::EOpVectorTimesMatrix:
             return "*";
         case TOperator::EOpMatrixTimesVector:
@@ -784,6 +789,20 @@
             case TOperator::EOpInitialize:
                 return AsSpecificBinaryNode(*binaryNode->getRight(), TOperator::EOpComma);
 
+            case TOperator::EOpAddAssign:
+            case TOperator::EOpSubAssign:
+            case TOperator::EOpMulAssign:
+            case TOperator::EOpDivAssign:
+            case TOperator::EOpIModAssign:
+            case TOperator::EOpBitShiftLeftAssign:
+            case TOperator::EOpBitShiftRightAssign:
+            case TOperator::EOpVectorTimesScalarAssign:
+                // A compound assignment binds less tightly than any operator that can enclose it,
+                // both in its symbolic form `a += b` and when it is emulated and emitted as an
+                // assignment `a = ANGLE_addInt(a, b)`. The latter form has an alphanumeric operator
+                // string, so it cannot be detected with IsSymbolicOperator().
+                return true;
+
             default:
             {
                 const TType &resultType = binaryNode->getType();
@@ -1831,6 +1850,26 @@
         }
         break;
 
+        case TOperator::EOpAddAssign:
+        case TOperator::EOpSubAssign:
+        case TOperator::EOpMulAssign:
+        case TOperator::EOpDivAssign:
+        case TOperator::EOpIModAssign:
+        case TOperator::EOpBitShiftLeftAssign:
+        case TOperator::EOpBitShiftRightAssign:
+        case TOperator::EOpVectorTimesScalarAssign:
+            // Operators that are emulated with a function do not assign to the left hand side, so
+            // decompose e.g. `a %= b` into `a = ANGLE_imod(a, b)`. The left hand side can be
+            // emitted twice because SeparateCompoundExpressions has already hoisted out any side
+            // effects.
+            if (!IsSymbolicOperator(op, binaryNode->getType(), &leftNode.getType(),
+                                    &rightNode.getType()))
+            {
+                groupedTraverse(leftNode);
+                mOut << " = ";
+            }
+            [[fallthrough]];
+
         default:
         {
             const TType &resultType = binaryNode->getType();
@@ -2265,6 +2304,13 @@
             emitType(retType, etConfig);
             emitArgList("{", "}");
         }
+        else if (IsFloatToIntegerConstructor(*aggregateNode))
+        {
+            mOut << "ANGLE_ftoi<";
+            emitType(retType, etConfig);
+            mOut << ">";
+            emitArgList("(", ")");
+        }
         else
         {
             emitType(retType, etConfig);
diff --git a/src/compiler/translator/msl/ProgramPrelude.cpp b/src/compiler/translator/msl/ProgramPrelude.cpp
index 652acfb..8afcfc2 100644
--- a/src/compiler/translator/msl/ProgramPrelude.cpp
+++ b/src/compiler/translator/msl/ProgramPrelude.cpp
@@ -117,6 +117,21 @@
     void degrees();
     void radians();
     void mod();
+    void safeDivisor();
+    void div();
+    void imod();
+    void imul();
+    void ilshift();
+    void ulshift();
+    void rshift();
+    void ftoi();
+    void addInt();
+    void subInt();
+    void negInt();
+    void postIncrementInt();
+    void preIncrementInt();
+    void postDecrementInt();
+    void preDecrementInt();
     void postIncrementMatrix();
     void preIncrementMatrix();
     void postDecrementMatrix();
@@ -435,6 +450,192 @@
 }
 )")
 
+// Integer division and remainder are undefined behavior in Metal when the divisor is 0 and when
+// the division overflows, i.e. when dividing the smallest negative value by -1. Substitute a
+// divisor of 1 in those cases: it keeps the divisor non-zero, and dividing the smallest negative
+// value by 1 yields that same value, which GLSL ES 3.00 section 4.1.3 permits as one of the two
+// allowed results for the overflow case. Dividing by 1 or -1 also leaves a remainder of 0, which is
+// the mathematically correct remainder for the overflow case.
+PROGRAM_PRELUDE_DECLARE(safeDivisor,
+                        R"(
+template <typename Z>
+ANGLE_ALWAYS_INLINE Z ANGLE_safeDivisor(Z x, Z y)
+{
+    return metal::select(y, Z(1), (y == Z(0)) | ((x == metal::numeric_limits<Z>::min()) & (y == Z(-1))));
+}
+)")
+
+// GLSL ES 3.00 section 5.9 makes division by zero produce an unspecified value rather than
+// undefined behavior, and section 4.1.3 requires the smallest negative value divided by -1 to
+// return either the smallest or the largest representable value. This returns the dividend for a
+// zero divisor and the smallest representable value for the overflow case, which satisfies both.
+PROGRAM_PRELUDE_DECLARE(div,
+                        R"(
+template <typename X, typename Y, typename Z = metal::conditional_t<metal::is_scalar_v<Y>, X, Y>>
+ANGLE_ALWAYS_INLINE Z ANGLE_div(X x, Y y)
+{
+    Z zx = Z(x);
+    Z zy = Z(y);
+    return zx / ANGLE_safeDivisor(zx, zy);
+}
+)",
+                        safeDivisor())
+
+// In addition to the divisor restrictions, Metal's remainder operator is undefined behavior when
+// either operand is negative. Compute the remainder with wraparound unsigned arithmetic instead,
+// which matches C++ semantics. GLSL ES 3.00 section 5.9 does leave the result undefined both for a
+// zero divisor, per component, and when either operand is negative, so any value will do as long as
+// it is produced without undefined behavior.
+PROGRAM_PRELUDE_DECLARE(imod,
+                        R"(
+template <typename X, typename Y, typename Z = metal::conditional_t<metal::is_scalar_v<Y>, X, Y>>
+ANGLE_ALWAYS_INLINE Z ANGLE_imod(X x, Y y)
+{
+    using U = metal::make_unsigned_t<Z>;
+    Z zx = Z(x);
+    Z zy = Z(y);
+    Z safe = ANGLE_safeDivisor(zx, zy);
+    return as_type<Z>(U(zx) - U(zx / safe) * U(safe));
+}
+)",
+                        safeDivisor())
+
+// Signed integer overflow is undefined in Metal, while GLSL ES requires the result to wrap
+// around. Do the arithmetic on unsigned values, where wraparound is well defined.
+PROGRAM_PRELUDE_DECLARE(addInt,
+                        R"(
+template <typename X, typename Y, typename Z = metal::conditional_t<metal::is_scalar_v<Y>, X, Y>>
+ANGLE_ALWAYS_INLINE Z ANGLE_addInt(X x, Y y)
+{
+    using U = metal::make_unsigned_t<Z>;
+    return as_type<Z>(U(Z(x)) + U(Z(y)));
+}
+)")
+
+PROGRAM_PRELUDE_DECLARE(subInt,
+                        R"(
+template <typename X, typename Y, typename Z = metal::conditional_t<metal::is_scalar_v<Y>, X, Y>>
+ANGLE_ALWAYS_INLINE Z ANGLE_subInt(X x, Y y)
+{
+    using U = metal::make_unsigned_t<Z>;
+    return as_type<Z>(U(Z(x)) - U(Z(y)));
+}
+)")
+
+// Negating the smallest negative value overflows, which is undefined in Metal, while GLSL ES
+// requires the result to wrap around, i.e. the value is unchanged.
+PROGRAM_PRELUDE_DECLARE(negInt,
+                        R"(
+template <typename X>
+ANGLE_ALWAYS_INLINE X ANGLE_negInt(X x)
+{
+    return ANGLE_subInt(X(0), x);
+}
+)",
+                        subInt())
+
+PROGRAM_PRELUDE_DECLARE(imul,
+                        R"(
+template <typename X, typename Y, typename Z = metal::conditional_t<metal::is_scalar_v<Y>, X, Y>>
+ANGLE_ALWAYS_INLINE Z ANGLE_imul(X x, Y y)
+{
+    using U = metal::make_unsigned_t<Z>;
+    return as_type<Z>(U(Z(x)) * U(Z(y)));
+}
+)")
+
+// Shifting by a negative amount or by more than the bit width of the type is undefined in Metal,
+// and GLSL ES leaves the result undefined as well. Mask the shift amount so that it is always in
+// range, which matches what GPUs do natively and what the other ANGLE backends end up with. Note
+// that masking also handles negative amounts, e.g. -1 becomes 31.
+PROGRAM_PRELUDE_DECLARE(rshift,
+                        R"(
+template <typename X, typename Y>
+ANGLE_ALWAYS_INLINE X ANGLE_rshift(X x, Y y)
+{
+    return x >> (y & Y(31));
+}
+)")
+
+PROGRAM_PRELUDE_DECLARE(ulshift,
+                        R"(
+template <typename X, typename Y>
+ANGLE_ALWAYS_INLINE X ANGLE_ulshift(X x, Y y)
+{
+    return x << (y & Y(31));
+}
+)")
+
+// GLSL ES defines a left shift of a negative value as shifting the sign bit out, whereas in Metal
+// it is undefined. Do the shift on an unsigned value.
+PROGRAM_PRELUDE_DECLARE(ilshift,
+                        R"(
+template <typename X, typename Y>
+ANGLE_ALWAYS_INLINE X ANGLE_ilshift(X x, Y y)
+{
+    using UX = metal::make_unsigned_t<X>;
+    return as_type<X>(UX(x) << (y & Y(31)));
+}
+)")
+
+// Converting a floating point value that is out of the range of the destination integer type is
+// undefined behavior in Metal. GLSL ES 3.00 section 5.4.1 only specifies that the fractional part
+// is dropped, which presumes the value is representable, and separately makes conversion of a
+// negative value to uint undefined, so no particular result is required here. Clamp to the range
+// that is always convertible.
+// Note that numeric_limits<X>::max() is not representable as a float: it rounds up to a value that
+// is out of range. Scaling it by (1 - 2^-24) gives the largest float that is in range, without
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/angle_end2end_tests.gni b/src/tests/angle_end2end_tests.gni
index 5dc468e..44ad854 100644
--- a/src/tests/angle_end2end_tests.gni
+++ b/src/tests/angle_end2end_tests.gni
@@ -95,6 +95,7 @@
   "gl_tests/GLSLConstantFoldingTest.cpp",
   "gl_tests/GLSLOutputTest.cpp",
   "gl_tests/GLSLTest.cpp",
+  "gl_tests/GLSLUBTest.cpp",
   "gl_tests/GLSLValidationTest.cpp",
   "gl_tests/GeometryShaderTest.cpp",
   "gl_tests/GetImageTest.cpp",
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 9a2da89..2bc0976 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -2881,6 +2881,8 @@
 42266581 LINUX INTEL OPENGL : EGLDisplayTest.TerminateMultipleTimesInDifferentThreads/* = SKIP
 42266581 LINUX INTEL OPENGL : EGLNoConfigContextTest.RenderCheck/* = SKIP
 
+500127519 MAC METAL INTEL : GLSLUBTest.AbsIntOverflow/* = SKIP
+
 // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
 // Slow tests, should appear last in this file
 // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
diff --git a/src/tests/gl_tests/GLSLUBTest.cpp b/src/tests/gl_tests/GLSLUBTest.cpp
new file mode 100644
index 0000000..7d4ecbd
--- /dev/null
+++ b/src/tests/gl_tests/GLSLUBTest.cpp
@@ -0,0 +1,1816 @@
+//
+// Copyright 2025 The ANGLE Project Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+
+#include "test_utils/ANGLETest.h"
+
+#include "test_utils/angle_test_configs.h"
+#include "test_utils/gl_raii.h"
+#include "util/shader_utils.h"
+
+namespace
+{
+using namespace angle;
+
+class GLSLUBTest : public ANGLETest<>
+{
+  protected:
+    GLSLUBTest()
+    {
+        setWindowWidth(128);
+        setWindowHeight(128);
+        setConfigRedBits(8);
+        setConfigGreenBits(8);
+        setConfigBlueBits(8);
+        setConfigAlphaBits(8);
+    }
+};
+
+// grep TEST_P src/tests/gl_tests/GLSLUBTest.cpp
+// For prefixes Add, Sub find:
+//   IntInt
+//   IntIvec
+//   IvecInt
+//   IvecIvec
+//   AssignIvecInt
+//   AssignIvecIvec
+
+// Test int + int with overflow. Expect wraparound.
+TEST_P(GLSLUBTest, AddIntIntOverflow)
+{
+    constexpr char kFS[] = R"(
+precision highp int;
+precision highp float;
+uniform int u;
+void main() {
+    int r0 = u + -1;
+    int r1 = u + 0;
+    int r2 = 2147483646 + u;
+    int r3 = u + 2147483647;
+
+    gl_FragColor.r = r0 == 1 ? 1.0 : 0.0;
+    gl_FragColor.g = r1 == 2 ? 1.0 : 0.0;
+    gl_FragColor.b = r2 == -2147483648 ? 1.0 : 0.0;
+    gl_FragColor.a = r3 == -2147483647 ? 1.0 : 0.0;
+}
+)";
+    ANGLE_GL_PROGRAM(testProgram, essl1_shaders::vs::Simple(), kFS);
+    ASSERT_GL_NO_ERROR();
+    glUseProgram(testProgram);
+    GLint u = glGetUniformLocation(testProgram, "u");
+    EXPECT_NE(-1, u);
+    glUniform1i(u, 2);
+    drawQuad(testProgram, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(255, 255, 255, 255));
+    ASSERT_GL_NO_ERROR();
+}
+
+// Test int + ivec overflow. Expect wraparound.
+TEST_P(GLSLUBTest, AddIntIvecOverflow)
+{
+    constexpr char kFS[] = R"(
+precision highp int;
+precision highp float;
+uniform int u;
+void main() {
+    ivec4 r = u + ivec4(0, -1, 1, 2147483647);
+    gl_FragColor.r = r.x == 2 ? 1.0 : 0.0;
+    gl_FragColor.g = r.y == 1 ? 1.0 : 0.0;
+    gl_FragColor.b = r.z == 3 ? 1.0 : 0.0;
+    gl_FragColor.a = r.w == -2147483647 ? 1.0 : 0.0;
+}
+)";
+    ANGLE_GL_PROGRAM(testProgram, essl1_shaders::vs::Simple(), kFS);
+    ASSERT_GL_NO_ERROR();
+    glUseProgram(testProgram);
+    GLint u = glGetUniformLocation(testProgram, "u");
+    EXPECT_NE(-1, u);
+    glUniform1i(u, 2);
+    drawQuad(testProgram, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(255, 255, 255, 255));
+    ASSERT_GL_NO_ERROR();
+}
+
+// Test int + ivec overflow. Expect wraparound.
+TEST_P(GLSLUBTest, AddIvecIntOverflow)
+{
+    constexpr char kFS[] = R"(
+precision highp int;
+precision highp float;
+uniform int u;
+void main() {
+    ivec4 r = ivec4(0, -1, 1, 2147483647) + u;
+    gl_FragColor.r = r.x == 2 ? 1.0 : 0.0;
+    gl_FragColor.g = r.y == 1 ? 1.0 : 0.0;
+    gl_FragColor.b = r.z == 3 ? 1.0 : 0.0;
+    gl_FragColor.a = r.w == -2147483647 ? 1.0 : 0.0;
+}
+)";
+    ANGLE_GL_PROGRAM(testProgram, essl1_shaders::vs::Simple(), kFS);
+    ASSERT_GL_NO_ERROR();
+    glUseProgram(testProgram);
+    GLint u = glGetUniformLocation(testProgram, "u");
+    EXPECT_NE(-1, u);
+    glUniform1i(u, 2);
+    drawQuad(testProgram, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(255, 255, 255, 255));
+    ASSERT_GL_NO_ERROR();
+}
+
+// Test ivec + ivec, ivec + int overflow. Expect wraparound.
+TEST_P(GLSLUBTest, AddIvecIvecOverflow)
+{
+    constexpr char kFS[] = R"(
+precision highp int;
+precision highp float;
+uniform ivec4 u;
+void main() {
+    ivec4 r0 = u + ivec4(0, -1, 1, 2147483647);
+    ivec4 r1 = ivec4(0, -1, 1, 2147483647) + u;
+    gl_FragColor.r = r0 == r1 ? 1.0 : 0.0;
+    gl_FragColor.g = r0.y == 1 ? 1.0 : 0.0;
+    gl_FragColor.b = r0.z == 3 ? 1.0 : 0.0;
+    gl_FragColor.a = r0.w == -2147483647 ? 1.0 : 0.0;
+}
+)";
+    ANGLE_GL_PROGRAM(testProgram, essl1_shaders::vs::Simple(), kFS);
+    ASSERT_GL_NO_ERROR();
+    glUseProgram(testProgram);
+    GLint u = glGetUniformLocation(testProgram, "u");
+    EXPECT_NE(-1, u);
+    glUniform4i(u, 2, 2, 2, 2);
+    drawQuad(testProgram, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(255, 255, 255, 255));
+    ASSERT_GL_NO_ERROR();
+}
+
+// Test highp ivec += int overflow. Expect wraparound.
+TEST_P(GLSLUBTest, AddAssignIvecIntOverflow)
+{
+    constexpr char kFS[] = R"(
+precision highp int;
+precision highp float;
+uniform int u;
+void main() {
+    ivec4 r = ivec4(0, -1, 1, 2147483647);
+    r += u;
+    gl_FragColor.r = r.x == 2 ? 1.0 : 0.0;
+    gl_FragColor.g = r.y == 1 ? 1.0 : 0.0;
+    gl_FragColor.b = r.z == 3 ? 1.0 : 0.0;
+    gl_FragColor.a = r.w == -2147483647 ? 1.0 : 0.0;
+}
+)";
+    ANGLE_GL_PROGRAM(testProgram, essl1_shaders::vs::Simple(), kFS);
+    ASSERT_GL_NO_ERROR();
+    glUseProgram(testProgram);
+    GLint u = glGetUniformLocation(testProgram, "u");
+    EXPECT_NE(-1, u);
+    glUniform1i(u, 2);
+    drawQuad(testProgram, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(255, 255, 255, 255));
+    ASSERT_GL_NO_ERROR();
+}
+
+// Test highp ivec += ivec overflow. Expect wraparound.
+TEST_P(GLSLUBTest, AddAssignIvecIvecOverflow)
+{
+    constexpr char kFS[] = R"(
+precision highp int;
+precision highp float;
+uniform ivec4 u;
+void main() {
+    ivec4 r = ivec4(0, -1, 1, 2147483647);
+    r += u;
+    gl_FragColor.r = r.x == 2 ? 1.0 : 0.0;
+    gl_FragColor.g = r.y == 1 ? 1.0 : 0.0;
+    gl_FragColor.b = r.z == 3 ? 1.0 : 0.0;
+    gl_FragColor.a = r.w == -2147483647 ? 1.0 : 0.0;
+}
+)";
+    ANGLE_GL_PROGRAM(testProgram, essl1_shaders::vs::Simple(), kFS);
+    ASSERT_GL_NO_ERROR();
+    glUseProgram(testProgram);
+    GLint u = glGetUniformLocation(testProgram, "u");
+    EXPECT_NE(-1, u);
+    glUniform4i(u, 2, 2, 2, 2);
+    drawQuad(testProgram, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(255, 255, 255, 255));
+    ASSERT_GL_NO_ERROR();
+}
+
+// Test int - int with overflow. Expect wraparound.
+TEST_P(GLSLUBTest, SubIntIntOverflow)
+{
+    constexpr char kFS[] = R"(
+precision highp int;
+precision highp float;
+uniform int u;
+void main() {
+    int r0 = u - (-1);
+    int r1 = u - 0;
+    int r2 = -2147483646 - u;
+    int r3 = u - (-2147483647);
+
+    gl_FragColor.r = r0 == 3 ? 1.0 : 0.0;
+    gl_FragColor.g = r1 == 2 ? 1.0 : 0.0;
+    gl_FragColor.b = r2 == 2147483648 ? 1.0 : 0.0;
+    gl_FragColor.a = r3 == -2147483647 ? 1.0 : 0.0;
+}
+)";
+    ANGLE_GL_PROGRAM(testProgram, essl1_shaders::vs::Simple(), kFS);
+    ASSERT_GL_NO_ERROR();
+    glUseProgram(testProgram);
+    GLint u = glGetUniformLocation(testProgram, "u");
+    EXPECT_NE(-1, u);
+    glUniform1i(u, 2);
+    drawQuad(testProgram, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(255, 255, 255, 255));
+    ASSERT_GL_NO_ERROR();
+}
+
+// Test highp int - ivec underflow. Expect wraparound.
+TEST_P(GLSLUBTest, SubIntIvecUnderflow)
+{
+    constexpr char kFS[] = R"(
+precision highp int;
+precision highp float;
+uniform int u;
+void main() {
+    ivec4 r = u - ivec4(0, -1, 1, 2147483647);
+    gl_FragColor.r = r.x == 2 ? 1.0 : 0.0;
+    gl_FragColor.g = r.y == 3 ? 1.0 : 0.0;
+    gl_FragColor.b = r.z == 1 ? 1.0 : 0.0;
+    gl_FragColor.a = r.w == -2147483645 ? 1.0 : 0.0;
+}
+)";
+    ANGLE_GL_PROGRAM(testProgram, essl1_shaders::vs::Simple(), kFS);
+    ASSERT_GL_NO_ERROR();
+    glUseProgram(testProgram);
+    GLint u = glGetUniformLocation(testProgram, "u");
+    EXPECT_NE(-1, u);
+    glUniform1i(u, 2);
+    drawQuad(testProgram, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor(255, 255, 255, 255));
+    ASSERT_GL_NO_ERROR();
+}
+
+// Test highp ivec - int underflow. Expect wraparound.
+TEST_P(GLSLUBTest, SubIvecIntUnderflow)
+{
+    constexpr char kFS[] = R"(
+precision highp int;
+precision highp float;
+uniform int u;
+void main() {
+    ivec4 r = ivec4(0, -1, 1, -2147483647) - u;
+    gl_FragColor.r = r.x == -2 ? 1.0 : 0.0;
+    gl_FragColor.g = r.y == -3 ? 1.0 : 0.0;
... (truncated)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.