CVE-2026-113210
Overview
Fix not yet public
Original Bug Report
Uninitialized GPU memory disclosure in ANGLE TextureD3D_2DArray::redefineImage
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: TextureD3D_2DArray::redefineImage in ANGLE’s D3D11 backend is the only one of the four redefineImage overloads that mutates the texture’s CPU-side state (deletes the level’s ImageD3Ds, rewrites mLayerCounts[level]) before calling releaseTexStorage(). When web content redefines the base level of a TEXTURE_2D_ARRAY with a changed layer count, the copy-back pass inside releaseTexStorage probes the already-mutated live state, matches no surviving subresource, and destroys the D3D texture holding the only copy of the other levels’ data — while ANGLE’s front-end keeps those levels stamped InitState::Initialized. The storage is later recreated via CreateTexture2D(pInitialData=nullptr), robust resource initialization is skipped, and WebGL2 readPixels returns recycled GPU-process VRAM to JavaScript. Demonstrated end-to-end on default Windows Chrome (plain WebGL2, no flags, no user interaction): a page read back 512 KB of a deleted victim texture’s contents.
Affected files:
third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp:3962-4026— 2DArrayredefineImage: layer realloc mutates live state beforereleaseTexStoragethird_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp:927-983—releaseTexStoragecopy-back probes live (now-poisoned) base-level statethird_party/angle/src/libANGLE/renderer/d3d/d3d11/Image11.cpp:127-144—isDirty()false-negative blocks re-upload of the lost levels
Estimated timestamp from git blame: 2015-07-14 (mis-ordered layer realloc, ANGLE commit 2453dbcb2d; the copy-back preservation it defeats landed 2022-01-21, 459c367ed5)
Summary
On Windows Chrome’s default WebGL2 stack (ANGLE D3D11 + passthrough decoder, robust_resource_initialization forced on at gpu/command_buffer/service/service_utils.cc:152), a plain web page can destroy texture content that exists only in the D3D storage texture, then read back the raw contents of a freshly allocated, never-initialized D3D11 texture. The trigger is a texImage3D call that redefines the base level of a 2D-array texture with a different layer count.
Two independent preservation/initialization mechanisms both fail, in sequence:
- Backend content preservation —
releaseTexStorage’s copy-back is neutralized becauseredefineImagemutates the base-level image state before calling it. - Front-end robust resource init — the surviving level’s
ImageDescremainsInitState::Initialized, so the zero-fill hook never runs on the recreated (uninitialized) storage.
The leaked bytes are recycled allocations of the GPU-process D3D11 device, which is shared by all origins and the compositor. The attached POC proves the cross-allocation read (a deleted texture’s contents returned to JS); nothing restricts the recycled block to allocations made by the attacking origin.
Root Cause Analysis
- Mutation before release:
third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp:3968-4011— when the redefined level’s layer count changes, everyImageD3Dof that level is deleted andmLayerCounts[level]is rewritten; only afterwards is the storage released. A freshImageD3Dis 0x0,GL_NONE, clean (ImageD3D.cpp:16-24).// TextureD3D.cpp:3969-4010 (condensed) const int storageDepth = getBaseLevelStorageDepth(); // snapshot used ONLY for the release DECISION if (size.depth != mLayerCounts[level]) { for (...) SafeDelete(mImageArray[level][layer]); // <-- old images deleted mLayerCounts[level] = size.depth; // <-- live state rewritten mImageArray[level][layer] = mRenderer->createImage(); // <-- fresh 0x0 / GL_NONE placeholders } if (mTexStorage) { if (... || size.depth != storageDepth || ...) { // decision fires correctly (snapshot) ... ANGLE_TRY(releaseTexStorage(context, copyImageMask)); // <-- release AFTER mutation } } - Copy-back probes live state and preserves nothing:
TextureD3D.cpp:946-975— the preservation pass resolvesgetBaseLevelInternalFormat(),getLevelZeroWidth()/Height(),getBaseLevelStorageDepth()from the current base-level image. When the redefined level is the base level (default 0), those now returnGL_NONE, 0, 0, and the new depth, soisImageSubresourceMatchingStorage(TextureD3D.cpp:330-361) fails for every surviving subresource andcopyFromTexStorageis never called beforeSafeDelete(mTexStorage)destroys the texture (:980-981). - The destroyed storage held the only copy: full-coverage
texSubImage3Duploads take thesetDatafast path (TextureD3D.cpp:293-328, 426-429→UpdateSubresource,TextureStorage11.cpp:889-895) that writes the D3D texture directly — no staging image, no storage association.TextureStorage11_2DArray::onDestroy(TextureStorage11.cpp:2821-2838) recovers only associated images, i.e. nothing here. FBO-rendered layers are storage-only as well. - The backend never re-uploads:
markAllImagesDirty()setsmDirty=true, butImage11::isDirty()(Image11.cpp:127-144) returns false for a dirty image with no staging texture whose format has nodataInitializerFunction(RGBA8,texture_format_table_autogen.cpp:3131-3146).updateStorageLevel(TextureD3D.cpp:3916-3931) therefore skips the lost levels. The function’s own comment says robust-init should factor in; the code never checks it. - Front-end robust-init is bypassed: the earlier full-coverage
texSubImage3Dstamped the surviving levelInitState::Initialized(Texture.cpp:1552-1577), and redefining level 0 rewrites only level 0’s desc (Texture.cpp:1543). All robust-init hooks key off the per-level desc (Framebuffer.cpp:2228, 2730-2737;Texture.cpp:2729-2747), so no zero-fill runs. - Recreated storage is uninitialized in production:
TextureStorage11_2DArray::getResource(TextureStorage11.cpp:2917-2947) →Renderer11::allocateTexturewith defaultinitData=nullptr(Renderer11.h:447-453) →CreateTexture2D(desc, nullptr)(ResourceManager11.cpp:475-502); D3D11 defines the contents as undefined.ResourceManager11’s zero-fill runs only with a D3D debug device (Renderer11.cpp:505, 1010), never in production Chrome.
The three sibling overloads release first, so the live probes still see the old geometry and the copy-back works: TextureD3D_2D::redefineImage (TextureD3D.cpp:1838-1847), TextureD3D_Cube (:2555-2566), TextureD3D_3D (:3218-3228). Only the 2DArray layer-realloc block — a 2DArray-only concept — sits on the wrong side of the release. Related loss paths were recently fixed while this one was not: TextureD3D::setBaseLevel now pre-copies storage-only content before mutating (TextureD3D.cpp:836-900, landed 2026-09-09), and its comment describes exactly this fast-path-upload loss mechanism.
Reproduction
WebGL2 on Windows Chrome, default configuration (D3D11 ANGLE, passthrough decoder, no flags, no user interaction):
- Create a
TEXTURE_2D_ARRAY;texImage3Dlevel 0 = RGBA8 8x8x4, level 1 = RGBA8 4x4x4 (null data). - Attach level 0 layer 0 via
framebufferTextureLayerandclear— forces creation of an RT-capableTextureStorage11_2DArray(4 mips). texSubImage3Dlevel 1, full coverage (0,0,0, 4x4x4) with known data — front-end stamps level 1Initialized; backend writes storage directly via thesetDatafast path (only copy of the data).texImage3Dlevel 0 = RGBA8 8x8x2 (null) — layer-count change triggers the bug: images deleted andmLayerCounts[0]=2beforereleaseTexStorage; copy-back matches nothing; storage destroyed with level 1’s data.texImage3Dlevel 0 = RGBA8 8x8x4 (null) to restore the layer count (storage is null; no release), thentexParameteri(TEXTURE_BASE_LEVEL, 1)so the level-1 attachment is complete.- For each layer k:
framebufferTextureLayer(..., tex, 1, k)andreadPixels— the first attach recreates storage withCreateTexture2D(pInitialData=nullptr); robust-init and re-upload are both skipped. - Expected:
readPixelsreturns the data written in step 3 (correct GL behavior, and what the non-D3D backends produce).
Observed (executed on stock Windows Chrome, NVIDIA L4, D3D11): readPixels returned raw recycled VRAM — in the first round, all 131,072 pixels (512 KB) of the level-1 readback contained the distinctive marker pattern of a different, already-deleted texture, reproduced in 2/2 independent sessions. In-page controls isolate the defect: a robust-init baseline read of a never-written level returns all zeros, and an identical sequence with a same-depth redefine preserves 100% of the content. Scaling to 2048x2048xN arrays and repeating create/leak/delete cycles sweeps megabytes of recycled GPU-process device memory per iteration into JS-readable pixels.
Severity
high — uninitialized-memory info leak in the GPU process, reachable directly from arbitrary web content on default Windows Chrome, with no flags, no user interaction, and no compromised renderer. The GPU-process D3D11 device is shared across all origins and the compositor, so recycled allocations can contain cross-origin pixel data (the executed POC demonstrated the cross-allocation read; the cross-origin reach follows from the shared device). This defeats the robust_resource_initialization guarantee Chrome relies on for WebGL on Windows. Not memory corruption, so critical does not apply. Prior art: the “Uninitialized Use in ANGLE” class (e.g. CVE-2026-11109/-11110/-11123/-11138, fixed in 149.0.7827.53) confirms this bug class is accepted and practically exploitable; this is a new instance with a distinct root cause, not covered by the recent setBaseLevel or cube-redefinition fixes.
Suggested Fix Direction
Reorder TextureD3D_2DArray::redefineImage so releaseTexStorage(context, copyImageMask) runs before the layer-realloc mutation, matching the 2D/Cube/3D overloads. This reordering was built and A/B-tested against the same trigger: the leak disappears and 100% of the level-1 content is preserved.
POC: ANGLE D3D11 uninitialized-VRAM disclosure — TextureD3D_2DArray::redefineImage mutates layer state before releaseTexStorage, neutralizing content preservation and desyncing robust-resource-init
Strategy: web-content
Bug Type: uninitialized GPU memory disclosure to web content (CWE-908); cross-texture / cross-context VRAM read via WebGL2 readPixels
A plain web page (WebGL2 only, no flags, no user interaction) makes Chrome’s GPU
process hand back the contents of a different, already-deleted texture’s GPU
memory — raw recycled VRAM of the shared D3D11 device — through
gl.readPixels. Demonstrated on Windows, default configuration (ANGLE D3D11
backend, passthrough command decoder, robust resource initialization on),
against a real NVIDIA GPU.
Root cause (one paragraph)
TextureD3D_2DArray::redefineImage
(third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp) is the only one
of the four redefineImage overloads that mutates the texture’s CPU-side state
before calling releaseTexStorage(): when the redefined level’s layer
count changes it deletes every ImageD3D of that level and rewrites
mLayerCounts[level], and only then releases the D3D storage.
releaseTexStorage’s copy-back pass — the mechanism that preserves every other
level’s storage-only content before the D3D texture is destroyed — probes
live state (getLevelZeroWidth/Height, getBaseLevelInternalFormat,
getBaseLevelStorageDepth). When the redefined level is the base level, those
probes now see a fresh 0x0/GL_NONE placeholder image and the new layer count,
so isImageSubresourceMatchingStorage fails for every surviving subresource
and the copy-back preserves nothing. Content whose only copy lived in the
storage (full-coverage texSubImage3D uploads take the setData fast path
directly into the D3D texture; FBO-rendered layers likewise) is silently
destroyed — while the ANGLE front-end’s per-level InitState::Initialized
stamp survives. When the storage is later recreated
(CreateTexture2D(pInitialData = nullptr) — contents undefined), the
robust-resource-init hook is skipped (level still stamped Initialized) and
the backend never re-uploads (Image11::isDirty() returns false for
dirty-without-staging RGBA8 images). readPixels then returns whatever the
driver recycled into the new allocation.
What the POC does (poc.html)
Per round, all in plain WebGL2:
- Dropper:
TEXTURE_2D_ARRAY, L0 = 256×256×8, L1 = 128×128×8; FBO-clear of L0 layer 0 forces an RT-capableTextureStorage11_2DArray(full mip chain). One full-coveragetexSubImage3Dwrites pattern P1 into all 8 layers of L1 — the front-end stamps L1Initialized, the backend writes the storage directly; no other copy of the data exists. - Victims: 6 textures with the byte-identical D3D11 desc the dropper
will later recreate (256×256×8, 9 mips, RTV|SRV), every mip filled with the
marker
C3 7E 5A D1, verified viatextureLodsampling. - Trigger:
texImage3D(L0, RGBA8, 256, 256, 6, null)— the layer-count change fires the buggy path: base-level images deleted +mLayerCounts[0]rewritten beforereleaseTexStorage→ copy-back dead → storage destroyed with the only copy of L1’s content. - An occupier texture pins the dropper’s own freed block (so a P1 readback cannot masquerade as a leak), then the victims are deleted — their marker-filled VRAM enters the driver’s free pool.
- Leak: restore L0 depth (storage is null, nothing released),
TEXTURE_BASE_LEVEL = 1, attach L1 layer k,readPixels. The first attach recreates the storage uninitialized; robust init and re-upload are both skipped (see root cause). Each pixel read is classified as P1 (correct GL answer) / zero (robust-init answer) / MARKER (cross-texture leak) / other (unknown leaked memory).
Controls run first in the same page:
- Control A — a never-written level read through the same attach path returns all zeros (robust init works normally; the bug specifically bypasses it).
- Control B — the identical sequence with a same-depth redefine keeps the storage and reads back 100% P1 (the layer-count change is exactly what destroys the data).
Results (stock Chrome, no flags, NVIDIA L4, Windows Server 2022)
ANGLE (NVIDIA, NVIDIA L4 (0x000027B8) Direct3D11 vs_5_0 ps_5_0, D3D11)
- Round 0: all 131,072 pixels (512 KB) of the L1 readback returned the
deleted victim texture’s marker
C3 7E 5A D1— memory of another (freed) texture, returned to JavaScript. Reproduced in 2/2 independent browser sessions (results-stock-rounds3.json,results-stock-rounds5.json; raw leaked bytes inleak_samples, base64). - Later rounds read back stale P1 — the recreation recycled the dropper’s own freed block; equally uninitialized memory, just self-owned data, so the classifier conservatively does not count it as a leak.
- Both controls pass (zeros baseline / 100% preservation), all victim uploads verified, framebuffers complete (0x8CD5), no GL errors.
The severity driver: the D3D11 device in Chrome’s GPU process is shared by all origins and the compositor, so recycled allocations can contain cross-origin pixel data. The POC proves the cross-allocation read with its own planted texture; nothing restricts the recycled block to allocations made by the attacking origin.
Bandaid fix (bandaid-fix.patch, verified)
Reorder TextureD3D_2DArray::redefineImage to release the storage (with
copy-back) before the layer-realloc mutation — the ordering the 2D, Cube
and 3D overloads already use. A/B on the same machine, same POC:
| Build | Verdict |
|---|---|
| stock | LEAK-CROSS-TEXTURE (round 0: 131,072/131,072 marker px) |
| + patch | NOT-REPRODUCED — 655,360/655,360 px P1, 5/5 rounds (results-fixed.json) |
Prerequisites
- Windows with a D3D11-capable GPU (default Chrome config; ANGLE D3D11
backend). On GPU-less VMs the Microsoft Basic Render Driver is blocklisted
(software_rendering_list entry 74) and WebGL2 is unavailable without
--ignore-gpu-blocklist. - A Chromium build (
chrome.exe+ runtime files) and matchingchromedriver.exe(only used to automate the browser; the leak is triggered entirely by the page).
Reproduction
Manual: serve the page and open it in Chrome on Windows (default flags):
python3 server.py 8228 # from this directory
chrome.exe http://127.0.0.1:8228/poc.html?rounds=3&victims=6
The page prints per-layer classification and a final verdict; it also exposes
window.__POC_RESULT (JSON). Expect VERDICT: CROSS-TEXTURE LEAK (or
UNINITIALIZED MEMORY LEAK / CONTENT-DESTROYED-ZERO-MEMORY depending on
what the driver recycles into the allocation — any verdict other than
NOT-REPRODUCED means L1’s content was destroyed by the redefine; correct GL
behavior is a 100% P1 readback, as Chrome on non-D3D backends and the patched
build produce).
Automated (what produced the attached results):
python3 run_poc.py --chrome <path>\chrome.exe --chromedriver <path>\chromedriver.exe \
--outdir out --rounds 3 --victims 6
See run-on-windows.md for the exact remote-runner procedure used here
(payload list, ACL grant needed for sandboxed child processes when running
out of a scratch directory, screenshots).
Expected Result
Stock build: [+] VERDICT : LEAK-CROSS-TEXTURE with marker pixel counts in
round summaries; leaked buffers contain c3 7e 5a d1 .... Patched build /
non-D3D backends: NOT-REPRODUCED, 100% P1.
Output
Stock run (verbatim driver output, NVIDIA L4 runner):
[+] navigated to http://127.0.0.1:55696/poc.html?rounds=3&victims=6
[+] title: POC-DONE
[+] RENDERER : ANGLE (NVIDIA, NVIDIA L4 (0x000027B8) Direct3D11 vs_5_0 ps_5_0, D3D11)
[+] VERDICT : LEAK-CROSS-TEXTURE
[+] TOTALS : {"pixels": 393216, "p1": 262144, "zero": 0, "marker": 131072, "other": 0, "victims_verified": 18, "victims_total": 18}
[+] CONTROLS : {"robust_init_baseline": {"fb_status": 36053, "nonzero_bytes": 0, "pass": true}, "same_depth_redefine": {"pass": true}}
round0: victims_ok=6 summary={"pixels": 131072, "p1": 0, "zero": 0, "marker": 131072, "other": 0}
round1: victims_ok=6 summary={"pixels": 131072, "p1": 131072, "zero": 0, "marker": 0, "other": 0}
round2: victims_ok=6 summary={"pixels": 131072, "p1": 131072, "zero": 0, "marker": 0, "other": 0}
First 32 bytes of the leaked 64 KB layer-0 buffer (leak_samples.round0_layer0):
c3 7e 5a d1 c3 7e 5a d1 c3 7e 5a d1 c3 7e 5a d1 c3 7e 5a d1 c3 7e 5a d1 c3 7e 5a d1 c3 7e 5a d1
Fixed-build run:
[+] RENDERER : ANGLE (NVIDIA, NVIDIA L4 (0x000027B8) Direct3D11 vs_5_0 ps_5_0, D3D11)
[+] VERDICT : NOT-REPRODUCED
[+] TOTALS : {"pixels": 655360, "p1": 655360, "zero": 0, "marker": 0, "other": 0, "victims_verified": 30, "victims_total": 30}
Files
| file | contents |
|---|---|
poc.html |
self-contained WebGL2 POC (trigger + controls + classification) |
server.py |
loopback HTTP server for the page |
run_poc.py |
chromedriver automation (navigate, collect JSON, screenshot) |
run.bat |
runner-side entry point used on the Windows machine |
results-stock-rounds3.json / results-stock-rounds5.json |
full results incl. base64 leaked buffers, two independent sessions |
results-fixed.json |
same POC against the patched build |
poc_screenshot-stock.png / poc_screenshot-fixed.png |
on-page verdicts |
bandaid-fix.patch |
verified fix (applies in third_party/angle) |
run-on-windows.md |
exact remote-runner reproduction procedure |
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:55696/poc.html?rounds=3&victims=6
- http://127.0.0.1:8228/poc.html?rounds=3&victims=6
- https://source.chromium.org/chromium/chromium/src/+/main:gpu/command_buffer/service/service_utils.cc;l=152
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp;l=3962
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp;l=3968
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp;l=927
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/d3d/d3d11/Image11.cpp;l=127