Use-After-Free in StyleSheet::mAdopters with document or shadow root
Categories
(Core :: CSS Parsing and Computation, defect)
Tracking
()
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)
|
2.55 KB,
application/xhtml+xml
|
Details | |
|
23.90 KB,
text/plain
|
Details | |
|
6.97 KB,
application/xhtml+xml
|
Details | |
|
6.18 KB,
text/plain
|
Details | |
|
48 bytes,
text/x-phabricator-request
|
pascalc|PTO
:
approval-mozilla-beta+
dmeehan
:
approval-mozilla-release-
dveditz
:
sec-approval+
|
Details | Review |
|
48 bytes,
text/x-phabricator-request
|
RyanVM
:
approval-mozilla-esr115+
phab-bot
:
approval-mozilla-esr140+
|
Details | Review |
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:
- adoptedstylesheets-shadowroot-stale-adopter-uaf-min3.xhtml -- Minimal PoC (ASAN crash)
- forcedirty-addr.xhtml -- Write primitive to attacker-specified address (Requires Infoleak)
- asan-report.txt -- Minimal PoC ASAN stack trace
- 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
| Reporter | ||
Comment 1•5 months ago
|
||
Minimal PoC
| Reporter | ||
Comment 2•5 months ago
|
||
| Reporter | ||
Comment 3•5 months ago
|
||
Write PoC
| Reporter | ||
Comment 4•5 months ago
|
||
Write Debug Log
| Reporter | ||
Comment 5•5 months ago
|
||
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:
-
Define a setter on Array.prototype at index 1:
Object.defineProperty(Array.prototype, "1", {
configurable: true,
set(v) { backing = this; }
}); -
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.
-
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.
-
Overwrite backing[0] = other. This replaces victim with other in the backing array silently. No callback fires. mAdopters is unchanged.
-
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
Updated•5 months ago
|
Updated•5 months ago
|
Comment 6•5 months ago
|
||
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.
| Assignee | ||
Updated•5 months ago
|
Updated•5 months ago
|
| Assignee | ||
Comment 7•5 months ago
|
||
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?
| Assignee | ||
Comment 8•5 months ago
|
||
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.
| Assignee | ||
Comment 9•5 months ago
|
||
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?
Comment 10•5 months ago
|
||
JS_DefineElement skips the prototype chain and setter, and just defines an element.
| Assignee | ||
Comment 11•5 months ago
|
||
Updated•5 months ago
|
Comment 12•5 months ago
|
||
(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.
| Assignee | ||
Comment 13•5 months ago
|
||
(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.
| Reporter | ||
Comment 14•5 months ago
|
||
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!
Comment 15•5 months ago
|
||
(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.)
Comment 16•5 months ago
|
||
(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.
| Assignee | ||
Comment 17•5 months ago
|
||
(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::NewArrayObjectflavor for this but it could be added.. Or we could useJS_SetPrototype.)
Yeah, this is the internal backing object that ObservableArray uses.
| Assignee | ||
Comment 18•5 months ago
|
||
I sent a patch to bug 2017942 which does seem to work, but not an expert in that code so eyes welcome.
Comment 19•5 months ago
•
|
||
(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.
| Assignee | ||
Comment 21•5 months ago
|
||
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
| Assignee | ||
Updated•5 months ago
|
| Assignee | ||
Comment 22•5 months ago
|
||
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?
Comment 23•5 months ago
|
||
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.
Updated•5 months ago
|
Updated•5 months ago
|
Comment 24•5 months ago
|
||
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 ]
Comment 25•5 months ago
|
||
Comment on attachment 9546473 [details]
(secure)
sec-approval+, a=dveditz to land
Comment 26•5 months ago
|
||
Updated•5 months ago
|
Updated•5 months ago
|
Comment 27•5 months ago
|
||
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.
Comment 28•5 months ago
|
||
Updated•5 months ago
|
Updated•5 months ago
|
Comment 29•5 months ago
|
||
| uplift | ||
Updated•5 months ago
|
Comment 30•5 months ago
|
||
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.
Updated•5 months ago
|
Comment 31•5 months ago
|
||
| uplift | ||
Updated•5 months ago
|
We will need a separate rebased patch and an uplift request for esr115
Comment 33•5 months ago
|
||
| uplift | ||
(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.
| Assignee | ||
Comment 35•5 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D284097
Updated•5 months ago
|
Comment 36•5 months ago
|
||
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
| Assignee | ||
Comment 37•5 months ago
|
||
Changed [[unlikely]] which the base-toolchains build of 140esr doesn't understand to the old MOZ_UNLIKELY.
Updated•5 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
Comment 38•5 months ago
|
||
| uplift | ||
Comment 39•5 months ago
|
||
Hi Emilio, can we get an ESR115 uplift request for this also? Thanks!
Updated•5 months ago
|
Comment 40•5 months ago
|
||
| uplift | ||
Updated•5 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
Comment 41•4 months ago
|
||
This is also verified as fixed on asan build, 115.34.0esr and 140.9.0 esr with Ubuntu 24.04.
Updated•4 months ago
|
Updated•19 days ago
|
Description
•