Closed Bug 2037323 (CVE-2026-12306) Opened 6 months ago Closed 5 months ago

`copyBufferSubData on ELEMENT_ARRAY_BUFFER does not invalidate WebGLBuffer::mIndexCache, allowing index-validation bypass and uncramped gl_VertexID delivery to vertex shaders`

Categories

(Core :: Graphics: CanvasWebGL, defect)

Firefox 150
defect

Tracking

()

RESOLVED FIXED
153 Branch
Tracking Status
firefox-esr115 --- wontfix
firefox-esr140 152+ fixed
firefox151 --- wontfix
firefox152 + fixed
firefox153 + fixed

People

(Reporter: mihalis.haatainen, Assigned: nical)

References

Details

(Keywords: csectype-bounds, reporter-external, sec-moderate, Whiteboard: [adv-main152+][adv-esr140.12+])

Attachments

(9 files, 3 external files)

User Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0

Steps to reproduce:

Actual results:

Filing fields

Field Value
Product Core
Component Graphics: CanvasWebGL
Type defect
Group Core Security (yes, mark as security bug)
Version Firefox 150.0 (also reproducible on 150.0.1)
Platform / OS All; tested on macOS

| See Also | bug 569431, bug 732660, bug 1836705 (CVE-2023-5724) |

Attach: firefox-webgl-desync-variants-v2.html (the multi-variant PoC).



WebGL2RenderingContext.copyBufferSubData writes to a destination element-array-buffer's GPU storage but does not update or invalidate the corresponding WebGLBuffer::mIndexCache or mIndexRanges state. When a subsequent drawElements* call validates indices against the stale cache, the validator passes for indices that, on the GPU side, exceed the bound vertex attribute capacity. The GPU then runs the vertex shader with gl_VertexID values that violate the WebGL safety contract, and on drivers without KHR_robust_buffer_access_behavior the resulting OOB attribute fetches may return GPU memory contents.

The bug is reachable in the default configuration of Firefox on macOS native GL (which does not advertise robust_buffer_access_behavior), and on any other build where webgl.force-index-validation=1 is set or where the GL backend lacks the robust extension.

Affected file

dom/canvas/WebGL2ContextBuffers.cpp, function WebGL2Context::CopyBufferSubData (currently around line 16-77 of the file at tip).

The bug

Firefox keeps a private mirror of element-array-buffer contents in WebGLBuffer::mIndexCache plus a memoization map mIndexRanges that caches max-vertex-index for (type, byteOffset, indexCount) ranges. This cache is populated by WebGLBuffer::BufferData and updated by WebGLBuffer::BufferSubData (which calls InvalidateCacheRange after the memcpy at WebGLBuffer.cpp:199).

WebGL2Context::CopyBufferSubData performs the GL-side copy via gl->fCopyBufferSubData(...) and then calls only writeBuffer->ResetLastUpdateFenceId(). It does NOT:

  • update mIndexCache to reflect the new bytes that the GL VBO now contains,
  • clear mIndexRanges, which still holds stale max-vertex results computed from the pre-copy mIndexCache,
  • call mFetchInvalidator.InvalidateCaches().

This means the cache and the GL VBO can diverge, and validation will use the stale cache.

The check at WebGL2ContextBuffers.cpp:63-69 (if (writeType != readType)) makes EAB-to-EAB the only allowed copy direction that bypasses the cache update. EAB-to-OtherData and OtherData-to-EAB are rejected with INVALID_OPERATION, so the desync window is specifically EAB-to-EAB.

Reproduction

The attached firefox-webgl-desync-variants-v2.html runs a sweep of variants and prints both a sanity baseline and per-variant results.

Concrete attack chain (variant G in the PoC):

  1. Create a vertex attribute buffer with 3 float slots (maxVerts = 3).
  2. Create EAB A with bufferData(EAB, [0, 1, 2]). mIndexCache(A) = [0,1,2], GL VBO(A) = [0,1,2].
  3. Create EAB B with bufferData(EAB, [42, 0, 0]). mIndexCache(B) = [42,0,0], GL VBO(B) = [42,0,0].
  4. Issue a warm draw drawElements(POINTS, 1, UNSIGNED_BYTE, 0) against A. Validation populates mIndexRanges(A)[(UBYTE, 0, 1)] = Some(0).
  5. copyBufferSubData(COPY_READ_BUFFER=B, COPY_WRITE_BUFFER=A, 0, 0, 3). GL VBO(A) becomes [42, 0, 0]. mIndexCache(A) is unchanged. mIndexRanges(A)[(UBYTE, 0, 1)] = Some(0) is unchanged.
  6. Issue the same drawElements(POINTS, 1, UNSIGNED_BYTE, 0) against A. WebGLContext::DrawElements_check calls GetIndexedFetchMaxVert(UBYTE, 0, 1) which returns the cached Some(0). Validation passes (0 < maxVerts(3)). The driver fetches index 42 from the post-copy VBO and runs the vertex shader with gl_VertexID = 42.

The PoC vertex shader for variant G writes float(gl_VertexID) to a varying that is read back from a 1x1 RGBA32F framebuffer. Observed result on Apple M1, Firefox 150.0:

G: gl_VertexID echo (EAB[0]=42)   warm=0   bad=42   err=0x0000   desync, NONZERO bad=42

bad = 42 confirms the post-copy GPU index value reached the vertex shader despite the WebGL validator approving the draw based on the stale cache.

Test environment confirmed

  • macOS, Apple Silicon
  • Firefox 150.0 (rv:150.0)
  • gl.getSupportedExtensions() does not advertise any robust_* extension
  • mNeedsIndexValidation is true by default (no override needed)

The PoC includes a self-check that reports whether mNeedsIndexValidation is engaged. On builds where the GL backend supports robust_buffer_access_behavior, the PoC instructs the user to set webgl.force-index-validation=1. The bug is then reproducible there as well; the difference is only whether the on-driver robust path mitigates OOB attribute fetches.

Impact

Confirmed:

  • Index validation bypass: drawElements calls that should be rejected with INVALID_OPERATION are instead issued to the driver.
  • Unclamped gl_VertexID reaches the vertex shader. Variant G demonstrates this end to end on Apple M1.
  • Cache state corruption: Firefox internal invariant "mIndexCache mirrors VBO contents for EABs with mNeedsIndexValidation=true" is violated.

Plausible but not yet demonstrated on tested hardware:

  • Information disclosure: on drivers without KHR_robust_buffer_access_behavior, OOB vertex attribute fetches may return adjacent GPU memory rather than zero. Apple's path on M1 returned 0 for fetches in variants A-F. Mesa, older Linux drivers, and some Android GLES drivers are documented as not implementing robust clamping; testing on those is the next step.

The bug parallels CVE-2023-5724 (bug 1836705) and CVE-2014-1556 (bug 1028891), both of which involved index/draw validation gaps in WebGLContextDraw.cpp and were rated sec-high.

Suggested fix

The cleanest fix is to add a method on WebGLBuffer that invalidates the index cache, and call it from CopyBufferSubData when the destination buffer is an EAB.

In WebGLBuffer.h:

public:
  void OnBytesReplacedExternally() {
    mFetchInvalidator.InvalidateCaches();
    mIndexCache.reset();
    mIndexRanges.clear();
  }

In WebGL2Context::CopyBufferSubData, after the GL call:

gl->fCopyBufferSubData(readTarget, writeTarget, readOffset, writeOffset, size);

// The destination buffer's GPU bytes have changed but our private
// mIndexCache and mIndexRanges still reflect the pre-copy state.
// Invalidate so the next index-validating draw recomputes from the
// fresh GL state. This mirrors what BufferSubData does via
// InvalidateCacheRange (called from WebGLBuffer::BufferSubData) and
// what the destructor does on teardown.
if (writeBuffer->Content() == WebGLBuffer::Kind::ElementArray) {
  writeBuffer->OnBytesReplacedExternally();
}

writeBuffer->ResetLastUpdateFenceId();

The if (Content() == ElementArray) guard avoids an unnecessary mFetchInvalidator.InvalidateCaches() for non-EAB destinations, where there is no index cache to invalidate. (mFetchInvalidator is also used for vertex attribute fetch caches but those are a different concern.)

Alternative: read back the modified bytes from the GL VBO into mIndexCache instead of dropping it. Adds a GPU round trip and is only worth it if profiling shows cache rebuild on the next draw is a hot path. Recommend the invalidate approach unless that's the case.

Notes for the security team

  • The PoC HTML is self-contained, reads back pixel data only, and does not attempt any privilege boundary crossing. It demonstrates the desync and the leaked gl_VertexID value.
  • Variant H in the PoC also confirms the cache-miss path in GetIndexedFetchMaxVert is vulnerable, not just the cache-hit path.
  • Variant D (TRIANGLES, multi-index draw) shows warm=1.75, bad=-9999 on Apple M1. The warm draw rasterizes a covering triangle; the bad draw does not, but this is not Firefox catching the bug. After the desync, gl_VertexID for the three triangle vertices is 4, 5, 6 (leaked from the post-copy EAB), the variant's VS computes pos from gl_VertexID == 1 and gl_VertexID == 2, both of which are false for {4,5,6}, so all three vertices collapse to (-1,-1). The triangle is degenerate and rasterizes nothing. This is a quirk of the variant's VS, not a sign that validation rejected the draw. Variant D therefore confirms the desync (the leaked gl_VertexID values reached the shader) by the same mechanism as variant G.
  • I have not tested non-Apple drivers. If the security team tests on Linux/Mesa or older Intel/NVIDIA macOS, please share results so the impact section can be updated.

Reporter info

  • Mihalis Haatainen (Bountyy Oy)

Expected results:

Group: firefox-core-security → gfx-core-security
Component: Untriaged → Graphics: CanvasWebGL
Product: Firefox → Core

Follow-up: confirmed information disclosure component on Linux Firefox 147, Ubuntu 24.04 ARM64 (virtio-gpu/virgl backend, no robust extensions advertised). Across multiple runs of variant C (index 4 with adjacent allocations) and variant E (index 100, UNSIGNED_SHORT), bad values vary run-to-run: C produced 214, 239, 0 across three runs; E produced 1109, 0, 512. Variance across runs (vs constant zero or constant garbage) is consistent with OOB attribute fetches reading uninitialized adjacent GPU memory, which is the disclosure path the original report flagged as "plausible but not yet demonstrated." Variant G remains deterministic (bad=42) as expected, confirming the validator bypass independently. Both halves of the impact section now have positive evidence on a non-Apple driver stack.
webgl.force-index-validation=true. mNeedsIndexValidation engaged confirmed by the PoC pre-check.

adding screenshots as a attachments...

One additional run showing recognizable float data in the leak. Variant A (index 100, POINTS, UBYTE): bad bits 0xbf51999a (-0.8187500...), variant C (index 4 + adjacent allocs): bad bits 0xbf72d88a (-0.9486166...). Both decode as well-formed negative single-precision floats with unit-range magnitudes, consistent with attribute fetches reading memory that was previously stored as float data (e.g., another GL buffer or a recently-freed TypedArray) rather than uniform random heap entropy. Same environment as comments 1-4 (Linux, Firefox 147, virtio-gpu, no robust extensions advertised, mNeedsIndexValidation engaged).

Severity: -- → S2
Flags: needinfo?(ahale)

Filed bug 2038302 as a sibling. Different trigger path (bufferData(COPY_WRITE_BUFFER, ...) on an already-Kind::ElementArray buffer) and different cache failure mode (mIndexCache dropped to null vs. left stale here). The OnBytesReplacedExternally fix suggested in this report does not cover that path - the new bug is in WebGLBuffer::BufferData's target check at line 120, not in CopyBufferSubData. Worth coordinating fixes across both so the cache invariant holds end-to-end.

See Also: → CVE-2026-12308

Two more runs on UTM Ubuntu / Firefox 150 (virtio-gpu, no robust extensions advertised, mNeedsIndexValidation engaged via webgl.force-index-validation=1).
Run A: variant A bad=0.447265625 (bits 0x3ee50000), variant G deterministic at 42.
Run B: variant A bad=0.001953125 (bits 0x3b000000, exact 2^-9), variant G deterministic at 42.
Run C: variant A clamped to 0, variant H bad=-3.4028234663852886e+38 (bits 0xff7fffff, -FLT_MAX) on the cache-miss path with no warm draw.

The variant H result is new evidence for the cache-miss code path I called out as "also vulnerable" in the original report. Previous runs on Apple M1 and the earlier Linux runs in comment 1 had variant H clamping to 0; this run shows the path can deliver a non-zero post-copy value to the shader without any prior warm draw populating mIndexRanges. -FLT_MAX (0xff7fffff) is a well-known sentinel/init value, consistent with leaked previously-stored data rather than random entropy.

Combined with the run-to-run variance on variants A/C/E (different concrete floats each run, all decoding as well-formed values rather than uniform garbage), both the validator-bypass impact (variant G, deterministic across all runs) and the information-disclosure impact (variants A/C/E/H, variable across runs with recognizable float patterns) now have positive evidence on a non-Apple driver stack across multiple runs.

Attached file (secure) —
Assignee: nobody → nical.bugzilla
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Keywords: sec-moderate

Comment on attachment 9587267 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: It's fairly easy to guess what the issue is from the fix if you know what you are looking for, but it's somewhat hard to exploit.
    The bug this fixes allows the attacker to read out of bounds in GPU memory. On a lot of hardware the out of bound reads will produce only zeroes (so not exploitable), but on some hardware it may read the memory directly. This can potentially let them extract sensitive information (the content of a texture that was rendered by the browser for example).
  • Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: No
  • Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: All branches
  • If not all supported branches, which bug introduced the flaw?: None
  • Do you have backports for the affected branches?: No
  • If not, how different, hard to create, and risky will they be?: This should apply cleanly to older branches.
  • How likely is this patch to cause regressions; how much testing does it need?: Low.
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: Yes

Edit: rewrote the first paragraph about the exploit. I originally got mixed up with another webgl sec fix and confused OOB read vs write.

Attachment #9587267 - Flags: sec-approval?
Attachment #9587267 - Flags: sec-approval? → sec-approval+
Group: gfx-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 5 months ago
Resolution: --- → FIXED
Target Milestone: --- → 153 Branch

The patch landed in nightly and beta is affected.
:nical, is this bug important enough to require an uplift?

For more information, please visit BugBot documentation.

Flags: needinfo?(nical.bugzilla)

Please also request ESR140 uplift.

Flags: needinfo?(ahale)
Attached file (secure) —
Attachment #9590942 - Flags: approval-mozilla-beta?
Attached file (secure) —
Attachment #9590945 - Flags: approval-mozilla-esr140?
Attachment #9590945 - Flags: approval-mozilla-esr140? → approval-mozilla-esr140+
Attachment #9590942 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
Flags: needinfo?(nical.bugzilla)
QA Whiteboard: [sec] [uplift] [qa-triage-done-c153/b152]
Whiteboard: [adv-main152+]
Whiteboard: [adv-main152+] → [adv-main152+][adv-esr140.12+]
Alias: CVE-2026-12306
Flags: sec-bounty?
Flags: sec-bounty? → sec-bounty+
See Also: → CVE-2026-74946
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: