Closed Bug 2038302 (CVE-2026-12308) Opened 4 months ago Closed 3 months ago

bufferData(COPY_WRITE_BUFFER, ...) on a Kind::ElementArray buffer drops mIndexCache without rebuilding it, 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: reporter-external, sec-moderate, Whiteboard: [adv-main152+][adv-esr140.12+])

Attachments

(4 files)

Attached file sibling.html โ€”

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:

bufferData(COPY_WRITE_BUFFER, ...) on a Kind::ElementArray buffer drops mIndexCache without rebuilding it, allowing index-validation bypass and uncramped gl_VertexID delivery to vertex shaders

Filing fields

Field Value
Product Core
Component Graphics: CanvasWebGL
Type defect
Group Core Security (mark as security bug)
Version Firefox 150.0
Platform / OS All; tested on macOS
See Also bug 2037323, bug 1836705 (CVE-2023-5724), bug 1028891 (CVE-2014-1556)

Attach: sibling.html (the PoC).

Summary

This is a sibling to bug 2037323 (copyBufferSubData on EAB). Different code path, different fix surface, same impact class (validator bypass, uncramped gl_VertexID, OOB attribute fetches on non-robust drivers).

WebGLBuffer::BufferData allocates the index-validation cache only when its target argument is ELEMENT_ARRAY_BUFFER (WebGLBuffer.cpp line 120). However, a buffer with Kind::ElementArray is bindable to COPY_WRITE_BUFFER per WebGL2 spec section 5.1, and bufferData(COPY_WRITE_BUFFER, ...) is a valid call. When invoked with the COPY_WRITE_BUFFER target, the function:

  1. Skips the newIndexCache allocation (line 120 condition is false).
  2. Updates the GL VBO contents via gl->fBufferData(target, ...) at line 142 / 156.
  3. At line 164, executes mIndexCache = std::move(newIndexCache). Since newIndexCache was never assigned, mIndexCache becomes null.
  4. Skips the mIndexRanges.clear() at line 170 because the guard at line 166 (if (mIndexCache)) is false after the move; stale memoized ranges remain (not directly exploitable since the consumer short-circuits earlier, but worth noting).

The downstream consumer at WebGLContextDraw.cpp line 1006-1008:

const auto globalMaxVertId =
    indexBuffer->GetIndexedFetchMaxVert(type, 0, indexCapacity);
if (!globalMaxVertId) return true;

When mIndexCache is null, GetIndexedFetchMaxVert returns Nothing() at line 333. The lambda short-circuits to "fetch valid". drawElements proceeds to the driver with no clamping, and the post-bufferData GPU index value reaches the vertex shader.

Reproduction

Attached PoC firefox-eab-cache-drop-via-copy-write.html. Self-contained, R32UI integer FBO for clean readback (no extension required).

Steps:

  1. Create vertex attribute buffer with 3 vec2 slots (maxVerts = 3).
  2. Create EAB X. bindBuffer(ELEMENT_ARRAY_BUFFER, X). mContent = Kind::ElementArray.
  3. bufferData(ELEMENT_ARRAY_BUFFER, [0, 1, 2]). mIndexCache(X) = [0, 1, 2]. GL VBO(X) = [0, 1, 2].
  4. WARM draw: drawElements(POINTS, 1, UNSIGNED_BYTE, 0). Validation populates mIndexRanges(X)[(UBYTE, 0, 1)] = Some(0). PoC reads back gl_VertexID = 0 from the integer FBO. No GL error.
  5. TRIGGER: bindBuffer(COPY_WRITE_BUFFER, X). Allowed because COPY_* doesn't restrict kind (ValidateCanBindToTarget lines 408-410).
  6. bufferData(COPY_WRITE_BUFFER, [42, 99, 99]). Inside WebGLBuffer::BufferData, target != ELEMENT_ARRAY_BUFFER, so the cache is dropped to null. GL VBO(X) becomes [42, 99, 99]. No GL error.
  7. BAD draw: bindBuffer(ELEMENT_ARRAY_BUFFER, X) then identical drawElements(POINTS, 1, UNSIGNED_BYTE, 0). PoC reads back gl_VertexID = 42. No GL error. The validator did not reject the draw despite the index 42 being out of bounds for the vertex attribute capacity of 3.

Test environment confirmed

  • macOS, Apple Silicon (Mac15,12, M3 MacBook Air)
  • Firefox 150.0
  • gl.getSupportedExtensions() does not advertise any robust_* extension
  • mNeedsIndexValidation engaged (default on macOS native GL path)

PoC output:

robust extensions advertised: (none)
warm draw: gl_VertexID echo = 0 (expect 0), gl error = 0x0
trigger bufferData(COPY_WRITE_BUFFER, [42,99,99]) gl error = 0x0
bad  draw: gl_VertexID echo = 42 (42 == bypass, 0 == clamp), gl error = 0x0
CONFIRMED: validator bypass via bufferData(COPY_WRITE_BUFFER, ...).

Impact

Confirmed:

  • Index validation bypass: drawElements with index 42 against a 3-vertex attribute completes without INVALID_OPERATION. The cache state corruption invariant mIndexCache mirrors VBO contents for EABs with mNeedsIndexValidation=true is violated.
  • Unclamped gl_VertexID = 42 reaches the vertex shader.

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 M3 returned 0 for OOB attribute fetches in adjacent variants tested for bug 2037323. Linux/Mesa testing for bug 2037323 (Comment 1 and Comment 5 of that bug) did demonstrate non-zero leaked values run-to-run including recognizable float bit patterns. The same disclosure path should apply here since the post-bypass driver behavior is determined by the driver, not the trigger.

This is the same architectural pattern as bug 2037323 and the same impact ceiling. The CVE precedents (CVE-2023-5724, CVE-2014-1556) for index/draw validation gaps in the same area were rated sec-high.

Why this is not a duplicate of bug 2037323

Aspect bug 2037323 this filing
Trigger copyBufferSubData(COPY_READ, EAB) bufferData(COPY_WRITE_BUFFER, ...) on EAB-kind buffer
Cache failure mode mIndexCache retains pre-copy bytes (stale) mIndexCache is dropped to null entirely
Cache mirror after trigger stale (still readable, contents diverge from VBO) absent (!mIndexCache)
Validator path cache-hit returns memoized stale Some(maxVert) GetIndexedFetchMaxVert returns Nothing()
Consumer behavior validation passes against stale value consumer short-circuits to "fetch valid"
Fix location WebGL2Context::CopyBufferSubData (post-fCopyBufferSubData) WebGLBuffer::BufferData (target-check at line 120)
Suggested fix shape (in 2037323) OnBytesReplacedExternally() after the copy does NOT cover this path; see below

The fix suggested in bug 2037323 (if (writeBuffer->Content() == Kind::ElementArray) writeBuffer->OnBytesReplacedExternally() inside CopyBufferSubData) does not address this filing. The trigger here is bufferData, not copyBufferSubData, and the bug is the missing allocation of newIndexCache rather than a missing invalidation of an existing one. Both bugs need to be fixed for the cache invariant to hold.

Suggested fix

The check at WebGLBuffer.cpp line 120 conditions cache allocation on the call's target argument:

if (target == LOCAL_GL_ELEMENT_ARRAY_BUFFER && needsIndexCache) {
  newIndexCache = UniqueBuffer::Take(malloc(...));
  ...
}

The intent is "only build the index cache for EABs". The bug is that the buffer's kind (which determines whether the buffer will be used as an EAB later) is decoupled from the target of the current bufferData call. The buffer can be Kind::ElementArray and the call's target can be COPY_WRITE_BUFFER simultaneously.

The fix is to gate on the buffer's kind, not on the call's target:

const bool needsIndexCache = mContext->mNeedsIndexValidation ||
                             mContext->mMaybeNeedsLegacyVertexAttrib0Handling;
if (mContent == WebGLBuffer::Kind::ElementArray && needsIndexCache) {
  newIndexCache = UniqueBuffer::Take(malloc(AssertedCast<size_t>(size)));
  if (!newIndexCache) {
    mContext->ErrorOutOfMemory("Failed to alloc index cache.");
    return;
  }
  memcpy(newIndexCache.get(), uploadData, size);
  uploadData = newIndexCache.get();
}

Note mContent is the buffer's WebGL kind. By the time BufferData is called, the buffer has been bound through some target via ValidateBufferSelection, and the path that reaches here for a Kind::Undefined buffer first goes through BindBuffer โ†’ SetContentAfterBind, so mContent is set when the call reaches BufferData. (Caveat: see the secondary spec note below.)

Alternative minimal patch: keep the target check but add the kind check disjunctively:

if ((target == LOCAL_GL_ELEMENT_ARRAY_BUFFER ||
     mContent == WebGLBuffer::Kind::ElementArray) && needsIndexCache) {

This preserves existing behavior for newly-created Undefined buffers while closing the COPY_WRITE_BUFFER path on already-Kind::ElementArray buffers.

Secondary note (separate, low-priority spec compliance)

While auditing, I also noticed WebGLBuffer::SetContentAfterBind (line 27-48) sets mContent = Kind::OtherData for COPY_READ_BUFFER and COPY_WRITE_BUFFER targets. Per WebGL 2.0 spec section 5.1, calling bindBuffer "with the target argument set to any buffer binding point except COPY_READ_BUFFER or COPY_WRITE_BUFFER" sets the WebGL buffer type. The current implementation deviates from spec by setting Kind for COPY_* targets. This is over-restrictive (closes a path the spec leaves open) rather than permissive, so it's not a security issue, but it's a real spec deviation. Filing separately if desired; mentioning here only because it touches the same kind-classification code path.

Notes for the security team

  • The PoC HTML is self-contained, reads back integer pixel data only, and does not attempt any privilege boundary crossing. It demonstrates the desync and the leaked gl_VertexID value.
  • I did not test on Linux/Mesa or other non-Apple drivers for this filing. The same driver-side OOB-fetch behavior documented in bug 2037323 Comment 1 and Comment 5 (run-to-run variance, recognizable float bit patterns) should apply here on those drivers; the bypass mechanism differs but the post-bypass shader execution is identical.
  • The existing bug 2037323 fix surface (OnBytesReplacedExternally after fCopyBufferSubData) does not cover this path. Both bugs need a fix.

Reporter info

Mihalis Haatainen (Bountyy Oy)

Expected results:

Group: firefox-core-security → gfx-core-security
Component: Untriaged → Graphics: CanvasWebGL
Product: Firefox → Core
See Also: → CVE-2026-12306

@gw, you set S2 on the sibling bug 2037323 last week. This bug is the same architectural pattern (host-side cache desync, validator trusts stale state) but a different cache and trigger path. Would you have time to triage severity here as well? Repro and source-level root cause in the description.

I'll have a look on Monday.

Flags: needinfo?(nical.bugzilla)
Severity: -- → S2

While auditing, I also noticed WebGLBuffer::SetContentAfterBind (line 27-48) sets mContent = Kind::OtherData for COPY_READ_BUFFER and COPY_WRITE_BUFFER targets. Per WebGL 2.0 spec section 5.1, calling bindBuffer "with the target argument set to any buffer binding point except COPY_READ_BUFFER or COPY_WRITE_BUFFER" sets the WebGL buffer type. The current implementation deviates from spec by setting Kind for COPY_* targets. This is over-restrictive (closes a path the spec leaves open) rather than permissive, so it's not a security issue, but it's a real spec deviation. Filing separately if desired; mentioning here only because it touches the same kind-classification code path.

Looks like chromium is similarly over-restrictive: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/webgl/webgl2_rendering_context_base.cc;l=5128;drc=a0f96f45d931f3fc57126fad5d8050ea63bd2d25

Attached file (secure) โ€”
Assignee: nobody → nical.bugzilla
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true

I took the suggested fix.

Flags: needinfo?(nical.bugzilla)
Keywords: sec-moderate

Comment on attachment 9587208 [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?: Yes
  • If not, how different, hard to create, and risky will they be?: I expect that this will apply cleanly to all 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 paragraph about the exploit, I got mixed up with another webgl sec bug and confused OOB reads vs writes.

Attachment #9587208 - Flags: sec-approval?
Attachment #9587208 - Flags: sec-approval? → sec-approval+
Group: gfx-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 3 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.

Attached file (secure) โ€”
Attachment #9590941 - Flags: approval-mozilla-beta?
Attached file (secure) โ€”
Attachment #9590944 - Flags: approval-mozilla-esr140?
Attachment #9590944 - Flags: approval-mozilla-esr140? → approval-mozilla-esr140+
Attachment #9590941 - 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-12308
Flags: sec-bounty?
Flags: sec-bounty? → sec-bounty+
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: