High chrome UAF 📄 Reporter bug report

Overview

High
Severity
—
CVSS
No
Exploited ITW
Embargoed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Sub-componentInternals>GPU>ANGLE
Bug ClassUAF
Tracker563354824
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Fix not yet public

No public source fix for this bug has been identified on the main branch yet — it is embargoed or not yet disclosed. Root-cause analysis is withheld until the fix commit is available.

Original Bug Report

reported by [email protected]

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:

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 because redefineImage mutates the base-level image state before calling it.
  • Front-end robust resource init — the surviving level’s ImageDesc remains InitState::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

  1. Mutation before release: third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp:3968-4011 — when the redefined level’s layer count changes, every ImageD3D of that level is deleted and mLayerCounts[level] is rewritten; only afterwards is the storage released. A fresh ImageD3D is 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
        }
    }
    
  2. Copy-back probes live state and preserves nothing: TextureD3D.cpp:946-975 — the preservation pass resolves getBaseLevelInternalFormat(), getLevelZeroWidth()/Height(), getBaseLevelStorageDepth() from the current base-level image. When the redefined level is the base level (default 0), those now return GL_NONE, 0, 0, and the new depth, so isImageSubresourceMatchingStorage (TextureD3D.cpp:330-361) fails for every surviving subresource and copyFromTexStorage is never called before SafeDelete(mTexStorage) destroys the texture (:980-981).
  3. The destroyed storage held the only copy: full-coverage texSubImage3D uploads take the setData fast 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.
  4. The backend never re-uploads: markAllImagesDirty() sets mDirty=true, but Image11::isDirty() (Image11.cpp:127-144) returns false for a dirty image with no staging texture whose format has no dataInitializerFunction (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.
  5. Front-end robust-init is bypassed: the earlier full-coverage texSubImage3D stamped the surviving level InitState::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.
  6. Recreated storage is uninitialized in production: TextureStorage11_2DArray::getResource (TextureStorage11.cpp:2917-2947) → Renderer11::allocateTexture with default initData=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):

  1. Create a TEXTURE_2D_ARRAY; texImage3D level 0 = RGBA8 8x8x4, level 1 = RGBA8 4x4x4 (null data).
  2. Attach level 0 layer 0 via framebufferTextureLayer and clear — forces creation of an RT-capable TextureStorage11_2DArray (4 mips).
  3. texSubImage3D level 1, full coverage (0,0,0, 4x4x4) with known data — front-end stamps level 1 Initialized; backend writes storage directly via the setData fast path (only copy of the data).
  4. texImage3D level 0 = RGBA8 8x8x2 (null) — layer-count change triggers the bug: images deleted and mLayerCounts[0]=2 before releaseTexStorage; copy-back matches nothing; storage destroyed with level 1’s data.
  5. texImage3D level 0 = RGBA8 8x8x4 (null) to restore the layer count (storage is null; no release), then texParameteri(TEXTURE_BASE_LEVEL, 1) so the level-1 attachment is complete.
  6. For each layer k: framebufferTextureLayer(..., tex, 1, k) and readPixels — the first attach recreates storage with CreateTexture2D(pInitialData=nullptr); robust-init and re-upload are both skipped.
  7. Expected: readPixels returns 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:

  1. Dropper: TEXTURE_2D_ARRAY, L0 = 256×256×8, L1 = 128×128×8; FBO-clear of L0 layer 0 forces an RT-capable TextureStorage11_2DArray (full mip chain). One full-coverage texSubImage3D writes pattern P1 into all 8 layers of L1 — the front-end stamps L1 Initialized, the backend writes the storage directly; no other copy of the data exists.
  2. 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 via textureLod sampling.
  3. Trigger: texImage3D(L0, RGBA8, 256, 256, 6, null) — the layer-count change fires the buggy path: base-level images deleted + mLayerCounts[0] rewritten before releaseTexStorage → copy-back dead → storage destroyed with the only copy of L1’s content.
  4. 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.
  5. 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 in leak_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 matching chromedriver.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.

View on issue tracker