CVE-2026-113293
Overview
Files Changed
src/compiler/translator/hlsl/UtilsHLSL.cpp
Patch
From 3adcc064145b9a06658988724db784cecea9c744 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Sat, 19 Sep 2026 23:07:38 -0400
Subject: [PATCH] HLSL: Fix name collision due to lack of delimiter
The HLSL names were generated as `nameID`, which could collide with a
different name ending with a prefix of `ID` and an ID that's the suffix
of `ID`, for example `a` with ID `13579` and `a1` with id `3579`.
This change removes the possibility of collisions by producing names
like `name_ID`, which aligns with how the IR produces names.
There is no regression test in this fix because causing such a collision
reliably (across changes to ANGLE, which frequently changes the IDs)
requires too many try and errors in the test to be practical. Note that
the IDs between the two variables must be at least 10000 apart, given
the translator starts user IDs at 3000.
Bug: chromium:562040486
Change-Id: I8eb54fa55d982882c47d66e84bf0757c527f62a5
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8427126
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
---
diff --git a/src/compiler/translator/hlsl/UtilsHLSL.cpp b/src/compiler/translator/hlsl/UtilsHLSL.cpp
index 807df13..bee7e85 100644
--- a/src/compiler/translator/hlsl/UtilsHLSL.cpp
+++ b/src/compiler/translator/hlsl/UtilsHLSL.cpp
@@ -858,7 +858,7 @@
qualifier == EvqParamIn || qualifier == EvqParamOut || qualifier == EvqParamInOut ||
qualifier == EvqParamConst))
{
- return Decorate(variable.name()) + str(variable.uniqueId().get());
+ return Decorate(variable.name()) + '_' + str(variable.uniqueId().get());
}
else
{
Original Bug Report
GPU shader OOB write via HLSL identifier collision in ANGLE DecorateVariableIfNeeded
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: ANGLE’s D3D/HLSL translator names user shader variables by concatenating the GLSL name directly with the decimal symbol id — no delimiter — so distinct GLSL symbols can emit the identical HLSL identifier (g1 with id 3001 and g with id 13001 both become _g13001). A web page abuses this from plain WebGL on Windows (default D3D11 backend, no flags, no user interaction): a 4-element inout parameter shadows a same-named 16384-element global in the generated HLSL, while ANGLE’s WebGL robustness clamp (ClampIndirectIndices) is sized from the global. One draw call then executes a GPU-shader out-of-bounds write/read with attacker-controlled index (up to 16383) and attacker-controlled 32-bit value. Demonstrated end-to-end on stock builds: OOB write round-trips at offsets up to 16383 past the 4-element array (WARP), and the mirrored OOB read leaks stale GPU-process memory (NVIDIA L4).
Affected files:
third_party/angle/src/compiler/translator/hlsl/UtilsHLSL.cpp:861— delimiter-lessDecorate(name) + str(uniqueId); the (name, id) → identifier mapping is not injectivethird_party/angle/src/compiler/translator/tree_ops/ClampIndirectIndices.cpp:94-107— robustness clamp bound taken from the AST type of the indexed symbol, not the object the emitted identifier resolves tothird_party/angle/src/compiler/translator/SymbolTable.cpp:418-433— id counter reset to 3000 per compile, incremented once per symbol in parse order → ids are attacker-predictablethird_party/angle/src/compiler/translator/hlsl/OutputHLSL.cpp:1272-1275— global declarations, function parameters, and body references all emitted through the same non-injective naming
Estimated timestamp from git blame: 2019-02-04 (the delimiter-less concatenation at UtilsHLSL.cpp:861; the qualifier list on that branch was last reshaped 2026-07-20)
Summary
DecorateVariableIfNeeded emits every user-defined temporary, global, const, and parameter as "_" + name + decimal(uniqueId). Because ESSL identifiers may end in digits, digits can shift between name and id: ("g1", 3001) and ("g", 13001) collide as _g13001. The code comment says the id suffix exists “so variables of the same name in different scopes do not get overwritten” — the suffix is applied, but without a delimiter it does not achieve that goal.
Symbol ids are fully attacker-steerable: the counter resets to 3000 at the start of every compile and each declared symbol consumes exactly one id in source parse order, so padding declarations place any chosen symbol at any chosen id. WEBGL_debug_shaders (registered for WebGL1/2 and supported on the D3D11 backend) returns the translated HLSL, which literally contains the ids — the page can verify the collision and re-calibrate in one recompile if a future ANGLE revision shifts the id base.
The collision recipe: declare float g1[16384] as the first user symbol (id 3001), pad with scalar declarators, then declare void f(inout float g[4]) so the parameter lands on id 13001. Both emit as _g13001; inside f, HLSL C-style scoping binds the name to the 4-element parameter, but ClampIndirectIndices clamps the index of the body’s g1[idx] store to 16383 (the AST global’s size − 1). All sizes are within WebGL hardened-context limits (float[16384] = exactly the 64KB per-variable cap, which is compared with >).
Result: a dynamically-indexed store/load on a 4-element dcl_indexableTemp with index range [0, 16383] — a repeatable, 4-byte-granular OOB write of an attacker-controlled 32-bit value across a 64KB window, plus the mirrored OOB read. This bypasses ANGLE’s designated enforcement of the WebGL requirement that shaders must not read or write array elements outside the application’s own data (WebGL 1.0 §Out-of-Range Array Accesses). Per the D3D11.3 Functional Spec (§Temporary Storage), out-of-bounds x#[] access is undefined, confined only to the GPU-process device context — which Chrome shares across all origins.
Root Cause Analysis
-
Non-injective naming:
third_party/angle/src/compiler/translator/hlsl/UtilsHLSL.cpp:854-862— the id is appended with no separator:// UtilsHLSL.cpp:854-861 // For user defined variables, combine variable name with unique id // so variables of the same name in different scopes do not get overwritten. else if (variable.symbolType() == SymbolType::UserDefined && (qualifier == EvqTemporary || qualifier == EvqGlobal || qualifier == EvqConst || qualifier == EvqParamIn || qualifier == EvqParamOut || qualifier == EvqParamInOut || qualifier == EvqParamConst)) { return Decorate(variable.name()) + str(variable.uniqueId().get()); // <-- no delimiter }Decorateonly prepends_for non-gl_names (UtilsHLSL.cpp:831-839), soDecorate("g1")+"3001"==Decorate("g")+"13001"==_g13001. -
Deterministic, attacker-steerable ids:
SymbolTable.cpp:418-433resetsmUniqueIdCountertokFirstUserDefinedSymbolId(3000,SymbolTable.h:325) per compile and hands out ids by pre-increment, one per symbol in parse order (Symbol.cpp:34-65). The first user symbol of every compile gets id 3001; a function’sTFunctionid precedes its parameters’ ids; padding declarators consume ids even if later pruned as unreferenced. -
Both symbols emit the same identifier: global declaration, parameter list, and body references all go through
DecorateVariableIfNeeded(OutputHLSL.cpp:1272-1275for symbols,OutputHLSL.cpp:2654-2673for parameters). The generated HLSL containsstatic float _g13001[16384]at file scope andinout float _g13001[4]as the parameter of the function whose body writes_g13001[...]— captured live from the GPU process via the translated-shader-source path. -
Clamp sized from the shadowed global:
ClampIndirectIndices.cpp:68,94,107computes the bound from the AST type of the indexed expression —leftType.getOutermostArraySize() - 1= 16383 forg1[idx]. The pass is always active for WebGL: every WebGL context is hardened (libANGLE/Context.cpp:729), and hardened D3D contexts setclampIndirectArrayBounds(renderer/d3d/ShaderD3D.cpp:291-294). -
OOB access executes on the GPU: inside the function, HLSL scoping resolves
_g13001to the 4-element parameter; fxc inlines the call, so the clamped-to-16383 index is applied to the caller’s 4-elementdcl_indexableTemp. The D3D11.3 Functional Spec caps a shader’s entire temp space at 4096 elements, so index 16383 is outside the shader’s whole declared storage, with behavior undefined within the GPU-process context.
Reproduction
- On Windows Chrome (default D3D11 ANGLE backend), create a WebGL2 context from any web page — no flags, no user interaction. (WebGL1 also works.)
- Compile a vertex shader declaring, in order:
float g1[16384];(first user symbol → id 3001); ~10⁴ scalar padding declarators;uniform int idx; uniform float val;; thenvoid f(inout float g[4]) { g1[idx] = val; }so the parameter receives id 13001. Frommain, callf(a)on a localfloat a[4]and consumea[0]so the array stays live. - Optionally calibrate:
WEBGL_debug_shaders.getTranslatedShaderSource()returns the HLSL; adjust the padding count until the file-scope static and the parameter print the same identifier (converges in one iteration). - Link with a trivial fragment shader and draw once with
idx/valset; varyidxper draw. For the read primitive, returng1[idx]fromfinto a varying and read back viareadPixels. - Expected: the store targets the 16384-element global; the caller’s
a[4]is untouched, and no access ever leaves the bounds of the object actually accessed (WebGL out-of-range protection).
Observed (executed on two stock Windows configurations — D3D11-on-WARP and a flagless NVIDIA L4): the translated HLSL shows static float _g13001[16384], inout float _g13001[4], and the body store clamped at 16383.0. Writes with idx 0–3 land in the caller’s 4-element array and reads return its contents bit-exactly, proving the binding to the parameter. On WARP, the attacker value written at offsets 100/1000/5000/16383 past the 4-element array reads back from those same OOB offsets — a 64KB-reach, 4-byte-granular controlled write, repeatable per draw. On NVIDIA L4, 376/382 sampled OOB reads returned non-zero stale GPU memory (244 distinct 32-bit values); where the OOB store’s side effect surfaces is driver-defined per the D3D11.3 spec, and targeted recovery of another context’s chosen data was attempted but not achieved.
Severity
high — memory corruption (controlled OOB write plus mirrored OOB read) executing in the GPU process, which is sandboxed on Windows, reachable directly from web content with no unusual interaction. The read primitive additionally discloses stale GPU-process memory, and the GPU process serves all origins on a shared D3D11 device, so the disclosure domain is cross-origin. Not critical: this path is specific to the Windows D3D11/HLSL backend, where the GPU process is sandboxed, and the corruption target is GPU-domain scratch memory rather than CPU-side browser memory.
Suggested Fix Direction
Make the name mapping injective: emit a reserved delimiter between the decorated name and the id (e.g. Decorate(name) + "_" + str(id)), or put the id first as the struct-naming scheme in the same file already does ("ss" + str(id) + "_" + name, UtilsHLSL.cpp:986). A one-line delimiter insertion of this shape was verified to make the collision unconstructible with no other behavior change.
POC: ANGLE HLSL name collision — DecorateVariableIfNeeded delimiter-less name+id concatenation lets a 4-element parameter shadow a 16384-element global; robustness clamp is sized from the global → GPU-shader OOB write/read from WebGL (Windows/D3D11)
Strategy: web-content Bug Type: out-of-bounds write / out-of-bounds read (CWE-787) in GPU-shader execution, GPU process, Windows/D3D11 (default backend)
Summary
DecorateVariableIfNeeded (third_party/angle/src/compiler/translator/hlsl/UtilsHLSL.cpp:861)
emits user variables as "_" + name + decimal(uniqueId) with no delimiter.
The mapping is not injective: a file-scope global named g1 with uniqueId
3001 and a function parameter named g with uniqueId 13001 both become the
HLSL identifier _g13001. Symbol ids are fully attacker-controlled: the
counter resets to 3000 at every compile and increments once per symbol in
parse order, so 9995 padding declarations steer the parameter to exactly
id 13001 = “1”‖“3001”.
In the generated HLSL, the 4-element parameter shadows the 16384-element
file-scope static inside the function body (C-style scoping). ANGLE’s WebGL
robustness transform ClampIndirectIndices sizes its clamp from the AST type
— the 16384-element global — emitting clamp(idx, 0.0, 16383.0). The
store/load therefore executes on the 4-element array with an
attacker-controlled index of up to 16383: a 64KB out-of-bounds window at
4-byte granularity, with an attacker-controlled 32-bit value, repeatable per
draw, from plain web content.
Captured live from Chrome’s GPU process via WEBGL_debug_shaders
(translated_W.hlsl):
static float _g13001[16384] = {_ANGLE_ZEROS_}; // global "g1", id 3001
void f_f_float(inout float _g13001[4]) // param "g", id 13001 — SAME NAME
{
(_g13001[int_ctor(clamp(float_ctor_int(_idx), 0.0, 16383.0))] = _val);
} // clamp bound: from the 16384 global
// store target: the 4-element param
What the POC does (poc.html)
Auto-calibrates the collision (compiles, reads back the translated HLSL, adjusts padding-declaration count; converges in one iteration on this tree), then runs four experiments and POSTs results + translated HLSL to the server:
| Experiment | Shader body | Correct GLSL semantics | Observed with collision |
|---|---|---|---|
| W (write binding) | void f(inout float g[4]) { g1[idx] = val; } — caller array a[4] zeroed, then f(a), output a |
a stays {0,0,0,0} (store goes to global g1) |
a[idx] == val for idx 0..3 — the store lands in the 4-element parameter |
| RW (OOB write roundtrip) | f stores g1[idx] = val then returns g1[idx2] |
returns val via the 16384 global, in bounds |
value 1234.5678 written at offsets 100 / 1000 / 5000 / 16383 of the 4-element array and read back from the same OOB offsets (WARP) |
| R (read binding + leak) | f returns g1[idx]; caller array is {10.5, 20.5, 30.5, 40.5} |
always 0.0 (zero-initialized global) |
idx 0..3 → the caller array values (bit-exact); idx 4..16383 → stale GPU memory |
| C (control) | same as W with global renamed zz1 (no collision possible) |
a stays zero |
a stays zero — harness observes correct semantics absent the collision |
Prerequisites
- Windows, Chrome with the default D3D11 ANGLE backend (release build of
this tree used:
out/win). - No special flags on real GPUs. On GPU-less VMs (WARP adapter) WebGL2 is
blocklisted;
--ignore-gpu-blocklistcompensates for that environment only and does not affect the bug path.
Reproduction
python3 server.py
# open http://127.0.0.1:8228/poc.html in Chrome on Windows (D3D11 backend)
# read results.json / progress.log; the page also displays its verdicts
Exact runner-fleet recipe used for the attached logs: run-on-windows.md.
Expected Result
results.json verdicts report CONFIRMED for paramWriteBinding,
paramReadBinding, oobRead (and oobWrite on WARP); the translated
sources show the identical _g13001 identifier on the 16384-element static
and the 4-element parameter, with the store clamp-bounded at 16383.
Output
Run 1 — GPU-less runner, ANGLE D3D11-on-WARP (Microsoft Basic Render Driver)
results-vulnerable-warp.json, progress-vulnerable-warp.log:
renderer: ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)
W gsize=16384 collided=true padding=9995 clampBound=16383
W idx=0 px=[255,0,0,0] ... idx=3 px=[0,0,0,255] # stores land in the 4-elem param
R idx=0..3 -> 10.5 / 20.5 / 30.5 / 40.5 (bit-exact) # reads come from the 4-elem param
R leak sweep: 49/382 OOB reads returned non-zero data
RW idx=100 idx2=100 -> 1234.5677 roundtrip=true # OOB write lands, read back
RW idx=1000 idx2=1000 -> 1234.5677 roundtrip=true
RW idx=5000 idx2=5000 -> 1234.5677 roundtrip=true
RW idx=16383 idx2=16383 -> 1234.5677 roundtrip=true # full 64KB reach at clamp bound
verdicts:
paramWriteBinding: CONFIRMED paramReadBinding: CONFIRMED
oobRead: CONFIRMED (49 non-zero) oobWrite: CONFIRMED (offsets up to 16383)
control: SANE
Run 2 — real-GPU runner (NVIDIA L4), completely stock flags
results-vulnerable-nvidia-l4.json, progress-vulnerable-nvidia-l4.log,
screenshot-nvidia-l4.png:
renderer: ANGLE (NVIDIA, NVIDIA L4 (0x000027B8) Direct3D11 vs_5_0 ps_5_0, D3D11)
W gsize=16384 collided=true padding=9995 clampBound=16383
W idx=0..3 -> channel writes land in the 4-elem param # binding CONFIRMED
R idx=0..3 -> 10.5 / 20.5 / 30.5 / 40.5 (bit-exact) # binding CONFIRMED
R leak sweep: 376/382 OOB reads returned non-zero data # 244 distinct stale
# 32-bit values, incl.
# 1.0f, -123.0f, ...
RW in-bounds roundtrip @2: true; OOB roundtrips read back 0 on this driver
Where the out-of-bounds store’s side effect becomes visible is
driver-defined (D3D11.3 Functional Spec, “Temporary Storage”: out-of-bounds
x#[] access is undefined, confined only to the GPU process context). On
WARP — Microsoft’s reference D3D11 rasterizer — the written value is read
back from the same OOB offset, proving the store lands at the
attacker-chosen out-of-bounds location. On the NVIDIA driver the same DXBC
executes without error and the OOB reads leak substantial stale GPU
memory, while the store’s landing spot was not observable through this
shader’s own reads.
Fix verification
bandaid-fix.patch inserts a delimiter making the mapping injective
(Decorate(name) + "_" + str(id) — same shape as the already-injective
struct naming "ss" + id + "_" + name at UtilsHLSL.cpp:986):
- return Decorate(variable.name()) + str(variable.uniqueId().get());
+ return Decorate(variable.name()) + "_" + str(variable.uniqueId().get());
With patched translator DLLs on the same runner
(results-bandaid-fixed.json, progress-bandaid-fixed.log):
W/R/RW all gsizes: delimited decorated names (injective-fix present):
global=_g1_3001 param=_g_13001 - collision impossible
C control: still correct semantics (px=[0,0,0,0])
Every collision attempt becomes unconstructible; nothing else regresses.
Additional probe (honest negative)
poc2-crosscontext.html / results-crosscontext-probe.json: attempted to
recover a chosen magic pattern written by a separate “victim” WebGL
context through the OOB read (three orderings, NVIDIA L4). No exact-pattern
hits — targeted cross-context data steering needs driver-pool layout work
beyond this POC. The OOB reads demonstrably return non-zero stale GPU
memory regardless (376/382 above); the D3D11.3 spec bounds the disclosure
domain at the whole GPU-process device context, which Chrome shares across
all origins.
Files
poc.html— the POC page (all four experiments, self-calibrating)poc2-crosscontext.html— bonus cross-context probeserver.py— result-collecting HTTP server for manual reproductionrun_poc.py— Windows runner driver (serves page, launches Chrome, collects)run-on-windows.md— exact fleet recipe usedtranslated_{W,R,RW,C}.hlsl— HLSL captured from the live GPU processresults-*.json,progress-*.log— full run outputs (vulnerable WARP, vulnerable NVIDIA L4, bandaid-fixed, cross-context probe)screenshot-nvidia-l4.png— POC page running on the NVIDIA L4 runnerbandaid-fix.patch— one-line injective-naming fix (verified above)
No Chromium/ANGLE source changes are needed to trigger the bug — the POC is pure web content against stock builds.
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.
- http://127.0.0.1:8228/poc.html
- https://source.chromium.org/chromium/chromium/src/+/main:libANGLE/Context.cpp;l=729
- https://source.chromium.org/chromium/chromium/src/+/main:renderer/d3d/ShaderD3D.cpp;l=291
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/compiler/translator/SymbolTable.cpp;l=418
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/compiler/translator/hlsl/OutputHLSL.cpp;l=1272
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/compiler/translator/hlsl/UtilsHLSL.cpp;l=854
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/compiler/translator/hlsl/UtilsHLSL.cpp;l=861
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/compiler/translator/tree_ops/ClampIndirectIndices.cpp;l=94