Closed Bug 2017512 (CVE-2026-4691) Opened 5 months ago Closed 5 months ago

Use-After-Free in StyleSheet::mAdopters with document or shadow root

Categories

(Core :: CSS Parsing and Computation, defect)

defect

Tracking

()

VERIFIED FIXED
150 Branch
Tracking Status
firefox-esr115 149+ verified
firefox-esr140 149+ verified
firefox147 --- wontfix
firefox148 --- wontfix
firefox149 + verified
firefox150 + verified

People

(Reporter: fabius.watson, Assigned: emilio)

References

Details

(4 keywords, Whiteboard: [client-bounty-form][adv-main149+][adv-ESR140.9+][adv-ESR115.34+])

Crash Data

Attachments

(6 files)

A use-after-free exists in Firefox's adoptedStyleSheets implementation. The ObservableArray backing ShadowRoot.adoptedStyleSheets uses a plain JS Array whose indices are subject to prototype setter interception. An attacker can exploit this to desynchronize the backing array from the C++ StyleSheet::mAdopters list, leaving a dangling raw pointer after the ShadowRoot is freed. Subsequent stylesheet mutations dereference this pointer into freed heap memory.

The vulnerability is triggered entirely from content JavaScript with no user interaction. The freed memory can be reclaimed with attacker-controlled data, enabling controlled memory writes within the content process.

Attached deliverables:

  1. adoptedstylesheets-shadowroot-stale-adopter-uaf-min3.xhtml -- Minimal PoC (ASAN crash)
  2. forcedirty-addr.xhtml -- Write primitive to attacker-specified address (Requires Infoleak)
  3. asan-report.txt -- Minimal PoC ASAN stack trace
  4. uaf-spray-write.txt -- Forcedirty PoC GDB session showing register control

Affected Versions: Firefox Nightly, Release (tested on build f8c6433acfd27bcf33dd362940cb6b13d68e32fb)
Affected Component: layout/style/StyleSheet.cpp, dom/base/Element.cpp

Flags: sec-bounty?
Attached file asan-report.txt
Attached file forcedirty-addr.xhtml

Write PoC

Attached file uaf-spray-write.txt

Write Debug Log

Two parallel data structures track which ShadowRoots have adopted a given stylesheet:

(a) The ObservableArray's JS backing array -- the ShadowRoot's source of truth. Its destructor walks this array and calls RemoveAdopter for each sheet listed.

(b) StyleSheet::mAdopters -- a C++ nsTArray of raw DocumentOrShadowRoot* pointers. The NOTIFY macro iterates this list on any stylesheet mutation (insertRule, deleteRule, replaceSync, disabled toggle) and calls methods through each pointer.

These are kept in sync by the ObservableArray's proxy callbacks: set-element calls AddAdopter (pushing a raw DOSR* into mAdopters), delete-element calls RemoveAdopter. The critical assumption is that all mutations to the backing array go through these callbacks. A prototype setter on Array.prototype breaks this assumption, because the ObservableArray's backing store is a plain JS Array that inherits from Array.prototype.

The desynchronization works as follows:

  1. Define a setter on Array.prototype at index 1:

    Object.defineProperty(Array.prototype, "1", {
    configurable: true,
    set(v) { backing = this; }
    });

  2. Assign sr.adoptedStyleSheets = [victim, other]. The ObservableArray processes this element by element. For each element, the set-element callback fires first (calling AddAdopter, which pushes a raw DOSR* into the sheet's mAdopters), then the element is stored into the backing array.

Both sheets get AddAdopter called -- both now have a DOSR* in their mAdopters. But when the ObservableArray stores index 1 into the backing array, the prototype setter intercepts the write. The setter captures this (the backing array itself) into the attacker's variable. The value (other) is never actually stored at index 1. The backing array ends up containing only victim at index 0, while mAdopters for both sheets has been updated.

  1. Delete the prototype setter. The attacker now holds a direct reference to the backing array that bypasses the ObservableArray proxy. Mutations through this reference do NOT trigger callbacks, so mAdopters is NOT updated.

  2. Overwrite backing[0] = other. This replaces victim with other in the backing array silently. No callback fires. mAdopters is unchanged.

  3. Delete sr.adoptedStyleSheets[0]. This goes through the proxy, so the delete-element callback fires and calls RemoveAdopter for whatever is at backing[0] -- which is now other (from step 4). Other's mAdopters entry is removed. But victim's mAdopters entry is never removed, because victim is no longer in the backing array.

Final state:

  • The backing array is empty. The ShadowRoot believes it has no adopted stylesheets.
  • victim.mAdopters still contains a raw DOSR* to this ShadowRoot. Nothing will ever remove it -- the ShadowRoot destructor only walks its own backing array, which no longer lists victim.

When the host element is removed and GC/CC runs, the ShadowRoot is freed without cleaning up victim's mAdopters entry. The raw DOSR* becomes dangling. Any subsequent victim stylesheet mutation dereferences it:

layout/style/StyleSheet.cpp, line 521-544 (NOTIFY macro):

for (auto* adopter : mAdopters) {
  if (auto* shadow = ShadowRoot::FromNode(adopter->AsNode())) {
    shadow->function_ args_;
  } else {
    adopter->AsNode().AsDocument()->function_ args_;
  }
}

adopter->AsNode() reads the mAsNode field from the freed DOSR, returning a pointer into freed (and potentially reclaimed) memory.

Exploitation:

On release Firefox, the freed ShadowRoot's data persists. The stale DOSR reads mAsNode, which still points to the original ShadowRoot nsINode. IsShadowRoot() passes (original flag bits and NULL mParent are intact), so ShadowRoot::RuleAdded is called on the stale ShadowRoot. This calls Servo_AuthorStyles_ForceDirty on the stale mServoStyles pointer, performing unconditional writes:

(uint16_t)(mServoStyles + 0x18) = 0x0201
(uint8_t)(mServoStyles + 0xA0) = 0x01

The 192-byte StyleAuthorStyles allocation (Rust Box, default heap) is freed alongside the ShadowRoot. By spraying namespace elements with controlled string buffers into the 128-byte jemalloc bin, the attacker can reclaim this slot with data that places an arbitrary address at the mServoStyles offset. If valid, ForceDirty may then proceed to write to that address.

The GDB session (uaf-spray-write.txt) confirms this with rdi = 0x4242424242424262 at the crash point inside ForceDirty. The 0x20 offset from the input address is a fixed constant from the ShadowRoot field layout.

PROOF OF CONCEPT

File 1: adoptedstylesheets-shadowroot-stale-adopter-uaf-min3.xhtml

Minimal reproduction. Creates a ShadowRoot, uses the prototype setter trick to desynchronize mAdopters, frees the ShadowRoot, then toggles victim.disabled.

Expected: ASAN heap-use-after-free in DocumentOrShadowRoot::AsNode.

File 2: asan-report.txt

ASAN Report for the Minimal PoC

File 3: forcedirty-addr.xhtml

Forcedirty primitive PoC. Accepts ?addr=HEX. Performs the desynchronization, fills and frees jemalloc pages, sprays controlled strings, then triggers insertRule to fire ForceDirty at the specified address.

File 4: uaf-spray-write.txt

GDB log showing rdi = 0x4242424242424262, crash at mov r15, QWORD PTR [rdi+0x18] inside ForceDirty.

Note: This report was written with the assistance of AI

Attachment #9546011 - Attachment mime type: application/octet-stream → text/xhtml
Attachment #9546013 - Attachment mime type: application/octet-stream → text/xhtml

I confirmed the ASan UAF with the test case.

I'm not sure if this is more of a CSS issue or some kind of DOM:Core thing from the implementation of Shadowroot, but I'll leave it in CSS for now.

Group: firefox-core-security → layout-core-security
Status: UNCONFIRMED → NEW
Component: Security → CSS Parsing and Computation
Ever confirmed: true
Product: Firefox → Core
Summary: Use-After-Free in StyleSheet::mAdopters → Use-After-Free in StyleSheet::mAdopters with ShadowRoot
Flags: needinfo?(emilio)

Ok, so here's the minimal thing:

{
  let backing = null;
  Object.defineProperty(Array.prototype, "1", {
    configurable: true,
    set(v) { backing = this; }
  });
  document.adoptedStyleSheets = [new CSSStyleSheet(), new CSSStyleSheet()];
  delete Array.prototype["1"];
  console.log(`Backing object:`, backing, Array.isArray(backing), backing == document.adoptedStyleSheets);
}

That allows you to extract the backing array out of the adoptedStyleSheets proxy, which is what then allows the rest of the code to confuse us.

That seems like a bug in the DOM bindings code, you're not supposed to be able to do this I don't think.

Then the issue is that this assumption breaks, and we remove a stylesheet from the array without calling RemoveAdopter(*this).

Andrew, we can definitely at least paper over this on the CSSOM code, but the root is in the DOM bindings. Would you like me to fix the UAF in this bug and file another one for the DOM bindings, or vice versa?

Flags: needinfo?(emilio) → needinfo?(continuation)

FYI Edgar, see above. Maybe it's an easy bug to fix in the ObservableArray implementation? Maybe it should do something similar to X-Rays.

Flags: needinfo?(echen)

It seems ObservableArray is just using JS_GetElement / JS_SetElement... It seems we might want to just go through NativeSetElement or so?

Arai, Jan, do you know if there's an already exposed way of calling SetElement etc ignoring the prototype chain?

Flags: needinfo?(jdemooij)
Flags: needinfo?(arai.unmht)

JS_DefineElement skips the prototype chain and setter, and just defines an element.

Flags: needinfo?(arai.unmht)
Attached file (secure)
Assignee: nobody → emilio
Status: NEW → ASSIGNED

(In reply to Emilio Cobos Álvarez [:emilio] from comment #7)

Andrew, we can definitely at least paper over this on the CSSOM code, but the root is in the DOM bindings. Would you like me to fix the UAF in this bug and file another one for the DOM bindings, or vice versa?

Thanks for jumping on this! I'm fine either way, as long as they are both sec bugs, of course. I guess the other one can be more like sec-want? Assuming the bindings issue isn't a broader problem.

Flags: needinfo?(continuation)

(In reply to Tooru Fujisawa [:arai] from comment #10)

JS_DefineElement skips the prototype chain and setter, and just defines an element.

Thanks! Do you know if there is a Get equivalent for that? I don't see one off-hand.

(In reply to Andrew McCreight [:mccr8] from comment #12)

Thanks for jumping on this! I'm fine either way, as long as they are both sec bugs, of course. I guess the other one can be more like sec-want? Assuming the bindings issue isn't a broader problem.

Ok, I'll file a separate bug for the bindings. I think we should prioritize it, the CSSOM code is pretty reasonable and I'd rather fix this than having to audit all ObservableArray implementations and having to keep it in mind when reviewing code.

Flags: needinfo?(arai.unmht)

Hello,

I've noticed that the summary has been changed to "Use-After-Free in StyleSheet::mAdopters with ShadowRoot". As this issue is reachable via both Document and ShadowRoot, is each source being regarded as a separate issue? Thanks I appreciate the clarification!

Blocks: 2017942

(In reply to Emilio Cobos Álvarez [:emilio] from comment #9)

Arai, Jan, do you know if there's an already exposed way of calling SetElement etc ignoring the prototype chain?

Is this an internal array object? In that case one option is to create it with a null proto. (I don't think we have a JS::NewArrayObject flavor for this but it could be added.. Or we could use JS_SetPrototype.)

Flags: needinfo?(jdemooij)

(In reply to Emilio Cobos Álvarez [:emilio] from comment #13)

(In reply to Tooru Fujisawa [:arai] from comment #10)

JS_DefineElement skips the prototype chain and setter, and just defines an element.

Thanks! Do you know if there is a Get equivalent for that? I don't see one off-hand.

JS_GetOwnPropertyDescriptorById is the closest one I think.
It's the equivalent of Object.getOwnPropertyDescriptor(obj, "name"), and you can extract the raw data from the descriptor by JS::PropertyDescriptor::value if it's a data property.

But just to make sure, both of them just skips prototype chain and the accessor.
Proxy handler can still trigger side effect, so if the intent is to avoid side-effect, please verify that the target is not a user-provided proxy.

Flags: needinfo?(arai.unmht)

(In reply to Jan de Mooij [:jandem] from comment #15)

Is this an internal array object? In that case one option is to create it with a null proto. (I don't think we have a JS::NewArrayObject flavor for this but it could be added.. Or we could use JS_SetPrototype.)

Yeah, this is the internal backing object that ObservableArray uses.

I sent a patch to bug 2017942 which does seem to work, but not an expert in that code so eyes welcome.

(In reply to Fabius Artrel from comment #14)

I've noticed that the summary has been changed to "Use-After-Free in StyleSheet::mAdopters with ShadowRoot". As this issue is reachable via both Document and ShadowRoot, is each source being regarded as a separate issue? Thanks I appreciate the clarification!

I was just guessing. From the patch, it looks like the code involved is in a class DocumentOrShadowRoot so both are included.

Summary: Use-After-Free in StyleSheet::mAdopters with ShadowRoot → Use-After-Free in StyleSheet::mAdopters with document or shadow root

Thanks for filing bug 2017942!

Flags: needinfo?(echen)

Comment on attachment 9546473 [details]
(secure)

Beta/Release Uplift Approval Request

  • User impact if declined/Reason for urgency: UAF
  • Is this code covered by automated tests?: No
  • Has the fix been verified in Nightly?: No
  • Needs manual test from QE?: Yes
  • If yes, steps to reproduce: comment 0
  • List of other uplifts needed: none
  • Risk to taking this patch: Low
  • Why is the change risky/not risky? (and alternatives if risky): Sanity-check
  • String changes made/needed: none
  • Is Android affected?: Yes

ESR Uplift Approval Request

  • If this is not a sec:{high,crit} bug, please state case for ESR consideration:
  • User impact if declined:
  • Fix Landed on Version: none
  • Risk to taking this patch: Low
  • Why is the change risky/not risky? (and alternatives if risky): One liner sanity-check against the wrong state.

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: not trivially, it doesn't quite point out at the root cause, but it hints.
  • Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: Yes
  • 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
  • If not all supported branches, which bug introduced the flaw?: all
  • Do you have backports for the affected branches?: No
  • If not, how different, hard to create, and risky will they be?: Should be trivial or apply cleanly
  • How likely is this patch to cause regressions; how much testing does it need?: very little
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: Yes
Attachment #9546473 - Flags: sec-approval?
Attachment #9546473 - Flags: approval-mozilla-release?
Attachment #9546473 - Flags: approval-mozilla-esr140?
Attachment #9546473 - Flags: approval-mozilla-beta?
Flags: qe-verify+

FWIW bug 2017942 fixes the root issue as well, also has review, and it's similarly low-risk. We might want to land that first, or later? I don't have strong opinions. Bug 2017942 kinda points at the problem. This patch also hints at it but it's harder to find. Any strong opinion?

Flags: needinfo?(dveditz)

We're in RC week for 148 so I can't grant sec-approval until next week. Because this also affects ESR I don't know if we'll be able to get it into a 148 point release. I'll set the tracking flag so it doesn't get lost but we're most likely looking at 149.

Attachment #9546011 - Attachment mime type: text/xhtml → application/xhtml+xml
Attachment #9546013 - Attachment mime type: text/xhtml → application/xhtml+xml

Using a nightly build, the first testcase crashes on the UAF poison address with the signature [@ nsWrapperCache::HasFlag ] (bp-79d3009e-5c6e-45d5-8bc9-08a960260226) and forcedirty-addr.xhtml crashes on the address 0x4141414141414179 with the signature [@ hashbrown::raw::RawTable<T>::len ]

Crash Signature: [@ nsWrapperCache::HasFlag ] [@ hashbrown::raw::RawTable<T>::len ]

Comment on attachment 9546473 [details]
(secure)

sec-approval+, a=dveditz to land

Attachment #9546473 - Flags: sec-approval? → sec-approval+

Comment on attachment 9546473 [details]
(secure)

Rejecting release uplift request.
Since ESR140 is affected, this is not suitable for a release and will ride the train with Fx149.

Attachment #9546473 - Flags: approval-mozilla-release? → approval-mozilla-release-
Group: layout-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 5 months ago
Resolution: --- → FIXED
Target Milestone: --- → 150 Branch
QA Whiteboard: [uplift][qa-ver-needed-c150/b149]
Attachment #9546473 - Flags: approval-mozilla-beta? → approval-mozilla-beta+

I reproduced this crash using the minimal test case from comment 1 on an affected ASAN Nightly build (2026-02-17) with Ubuntu 24.

The issue is verified as fixed on latest Nightly asan build 150.0a1 and Beta 149.0b3 with Ubuntu 24.

QA Whiteboard: [uplift][qa-ver-needed-c150/b149] → [uplift][qa-ver-done-c150/b149]
Attachment #9546473 - Flags: approval-mozilla-esr140? → approval-mozilla-esr140+

We will need a separate rebased patch and an uplift request for esr115

(In reply to Pulsebot from comment #33)

https://hg.mozilla.org/releases/mozilla-esr140/rev/c71248812286

Revert "Bug 2017512 - Sanity check adopted stylesheet setters / deleters. r=edgar,#style,#layout a=pascalc" for causing build failures a=backout

We will also need an updated patch for esr140 as we reverted this one for causing build bustage.

Flags: needinfo?(emilio)
Attachment #9549382 - Flags: approval-mozilla-esr140?

firefox-esr140 Uplift Approval Request

  • User impact if declined: sec-high
  • Code covered by automated testing: yes
  • Fix verified in Nightly: yes
  • Needs manual QE test: yes
  • Steps to reproduce for manual QE testing: comment 0
  • Risk associated with taking this patch: low
  • Explanation of risk level: Trivial check
  • String changes made/needed: none
  • Is Android affected?: yes

Changed [[unlikely]] which the base-toolchains build of 140esr doesn't understand to the old MOZ_UNLIKELY.

Flags: needinfo?(emilio)
Flags: sec-bounty? → sec-bounty+
Attachment #9549382 - Flags: approval-mozilla-esr140? → approval-mozilla-esr140+

Hi Emilio, can we get an ESR115 uplift request for this also? Thanks!

Flags: needinfo?(emilio)
Attachment #9546473 - Flags: approval-mozilla-esr140+
Flags: needinfo?(emilio)
Attachment #9549382 - Flags: approval-mozilla-esr115+
Whiteboard: [client-bounty-form] → [client-bounty-form][adv-main149+][adv-ESR140.9+][adv-ESR115.34]
Whiteboard: [client-bounty-form][adv-main149+][adv-ESR140.9+][adv-ESR115.34] → [client-bounty-form][adv-main149+][adv-ESR140.9+][adv-ESR115.34+]

This is also verified as fixed on asan build, 115.34.0esr and 140.9.0 esr with Ubuntu 24.04.

Status: RESOLVED → VERIFIED
Flags: qe-verify+
QA Contact: cgeorgiu
Alias: CVE-2026-4691
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Creator:
Created:
Updated:
Size: