Type confusion in HTMLSelectElement::GetSelectedContentText via Sanitizer mutation of UA shadow tree → attacker-controlled wild pointer in Servo_Element_ClearData
Categories
(Core :: DOM: Security, defect)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | unaffected |
| firefox152 | + | fixed |
| firefox153 | + | fixed |
| firefox154 | + | fixed |
People
(Reporter: bugmon, Assigned: tschuster)
References
(Blocks 1 open bug)
Details
(5 keywords, Whiteboard: [adv-main152.0.4+r])
Attachments
(9 files)
|
425 bytes,
text/html
|
Details | |
|
964 bytes,
patch
|
Details | Diff | Splinter Review | |
|
51 bytes,
text/plain
|
Details | |
|
843 bytes,
patch
|
Details | Diff | Splinter Review | |
|
12.77 KB,
text/plain
|
Details | |
|
4.21 KB,
text/plain
|
Details | |
|
48 bytes,
text/x-phabricator-request
|
Details | Review | |
|
48 bytes,
text/x-phabricator-request
|
phab-bot
:
approval-mozilla-beta+
|
Details | Review |
|
48 bytes,
text/x-phabricator-request
|
phab-bot
:
approval-mozilla-release+
|
Details | Review |
Sanitizer::SanitizeChildren recurses into shadow roots via Element::GetShadowRoot(), which returns user-agent shadow roots as well as author roots. When Document.parseHTML() parses a <select>, HTMLSelectElement::SetupShadowTree builds a UA shadow tree (<slot/><label>{text}</label><div popover><slot/></div>) whose structure is assumed invariant. A Sanitizer config that allows {slot, div} but not {label} causes the Sanitizer to delete the <label> from the UA shadow root, leaving <slot/><div><slot/></div>.
When the Sanitizer subsequently strips the selected attribute from the light-DOM <option>, HTMLSelectElement::SelectedContentTextMightHaveChanged runs and calls GetSelectedContentText(), which blindly walks the shadow tree: sr->GetFirstChild()->GetNextSibling()->GetFirstChild()->AsText(). With the corrupted tree this returns the picker <slot> static_cast to Text*. CharacterData::SetText then writes CharacterDataBuffer::{m1b/m2b, mAllBits} onto the same offsets in Element, overwriting Element::mState with a heap pointer and the low 32 bits of Element::mServoData with (label_length << 3) | mInHeap. Moving the <select> out of the data document triggers Element::ClearServoData → Servo_Element_ClearData → Box::from_raw(attacker_value) → drop_glue, performing Arc-refcount decrement and dealloc on an attacker-chosen address.
In Nightly/early-beta the path is masked by MOZ_DIAGNOSTIC_ASSERT(label->IsHTMLElement(label)); in late-Beta/Release those asserts are compiled out and the type confusion proceeds. dom.security.sanitizer.enabled defaults to true, so this is reachable from untrusted web content with no non-default prefs.
Build Info
- Branch: main
- Revision: 3b0ec2e6ef5bd362c37420922deaddf9d5d5e175
- Timestamp: 2026-06-20T22:36:09+00:00
Affected Code
File: dom/security/sanitizer/Sanitizer.cpp, line 1971-1975
// Step 1.5.6. If child is a shadow host, then call sanitize core on child’s
// shadow root with configuration and handleJavascriptNavigationUrls.
if (RefPtr<ShadowRoot> shadow = child->GetShadowRoot()) { // <-- returns UA shadow roots too
SanitizeChildren<IsDefaultConfig>(shadow, aSafe); // <-- mutates <select>'s internal anon tree
}
File: dom/html/HTMLSelectElement.cpp, line 220-235
Text* HTMLSelectElement::GetSelectedContentText() const {
auto* sr = GetShadowRoot();
if (!sr) {
MOZ_ASSERT(OwnerDoc()->IsStaticDocument() || !IsInComposedDoc());
return nullptr;
}
auto* slot = sr->GetFirstChild();
MOZ_DIAGNOSTIC_ASSERT(slot);
MOZ_DIAGNOSTIC_ASSERT(slot->IsHTMLElement(nsGkAtoms::slot));
auto* label = slot->GetNextSibling(); // <div> after Sanitizer removed <label>
MOZ_DIAGNOSTIC_ASSERT(label);
MOZ_DIAGNOSTIC_ASSERT(label->IsHTMLElement(nsGkAtoms::label)); // no-op in release
MOZ_DIAGNOSTIC_ASSERT(label->GetFirstChild());
MOZ_DIAGNOSTIC_ASSERT(label->GetFirstChild()->IsText()); // no-op in release
return label->GetFirstChild()->AsText(); // <-- HTMLSlotElement* static_cast to Text*
}
File: dom/html/HTMLSelectElement.cpp, line 1442-1455
void HTMLSelectElement::SelectedContentTextMightHaveChanged(
bool aNotify, IgnoredOptionList aIgnored) {
RefPtr textNode = GetSelectedContentText(); // really an HTMLSlotElement
if (!textNode) {
return;
}
nsAutoString newText;
...
textNode->SetText(newText, aNotify); // <-- writes CharacterDataBuffer fields onto Element members
File: dom/base/CharacterDataBuffer.h, line 311-323
struct FragmentBits {
uint32_t mInHeap : 1;
uint32_t mIs2b : 1;
uint32_t mIsBidi : 1;
uint32_t mLength : 29; // attacker-controlled via <option> label length
};
// union { m1b/m2b (8B); } -> overlaps Element::mState
// union { FragmentBits mState; uint32_t mAllBits; } -> overlaps low 4 bytes of Element::mServoData
File: dom/base/Element.h, line 2642-2652
// Data members
ElementState mState; // overwritten with heap buffer ptr
// Per-node data managed by Servo.
mozilla::RustCell<ServoNodeData*> mServoData; // low 32 bits overwritten with (len<<3)|flags
protected:
// Array containing all attributes for this element
AttrArray mAttrs;
File: dom/base/Element.cpp, line 5883
Servo_Element_ClearData(this); // Box::from_raw(mServoData) -> drop_glue on attacker-controlled pointer
The Sanitizer recurses into UA shadow roots and removes elements from them; HTMLSelectElement then trusts the corrupted shadow-tree shape and performs an unchecked static_cast to Text*, after which SetText overwrites Element::mServoData with an attacker-controlled value.
Exploit Chain
- Web content calls Document.parseHTML('<select><option selected>AAAA…</option></select>', {sanitizer: new Sanitizer({elements:[…,'select','option','slot','div'], attributes:['name','popover']})}). dom.security.sanitizer.enabled defaults to true, so Sanitizer is exposed to content.
- Parsing creates a connected data document; HTMLSelectElement::BindToTree → SetupShadowTree builds the UA shadow tree <slot name='internal-select-button'/><label></label><div popover name='select'><slot/></div>.
- Sanitizer::SanitizeChildren reaches the <select>, calls child->GetShadowRoot() (Sanitizer.cpp:1973) which returns the UA shadow root, and recurses. <label> is not in the element allow-list → it (and its text node) are removed. Shadow tree is now <slot/><div popover name='select'><slot/></div>.
- Sanitizer continues to the light DOM, strips the disallowed
selectedattribute from <option>. Element::UnsetAttr → HTMLOptionElement::BeforeSetAttr → HTMLSelectElement::SetOptionsSelectedByIndex → OnSelectionChanged → SelectedContentTextMightHaveChanged. - GetSelectedContentText walks the corrupted shadow tree: slot=<slot>, label=slot->GetNextSibling()=<div>, return label->GetFirstChild()->AsText() = picker <slot> static_cast to Text*. MOZ_DIAGNOSTIC_ASSERTs are compiled out in release.
- textNode->SetText(optionLabel, true) runs CharacterData::SetTextInternal on what is actually an HTMLSlotElement. CharacterDataBuffer::SetTo writes m1b=malloc(len) over Element::mState and mAllBits=(len<<3)|mInHeap over the low 32 bits of Element::mServoData. With len=0x2000000, mServoData becomes 0x10000001.
- Content calls document.body.appendChild(sel). ReplaceOrInsertBefore removes the <select> from the data document → UnbindFromTree recurses into the shadow root → HTMLSlotElement::UnbindFromTree → Element::ClearServoData reads mServoData=0x10000001 and calls Servo_Element_ClearData.
- Servo_Element_ClearData performs Box::from_raw(0x10000001) and runs drop_glue<ElementDataWrapper>: dereferences the wild pointer, decrements Arc<ComputedValues> refcounts found there, and deallocs the box. With heap spray at the chosen low-32-bit address this yields arbitrary refcount-decrement / arbitrary-free → renderer code execution.
Steps to Reproduce
- Apply source_patch.diff (downgrades the seven MOZ_DIAGNOSTIC_ASSERTs in HTMLSelectElement::GetSelectedContentText to MOZ_ASSERT, matching late-Beta/Release behaviour where MOZ_DIAGNOSTIC_ASSERT_ENABLED is undefined).
- Build with mozconfig.linux.asan.fuzzing (--enable-address-sanitizer --disable-debug).
- Set pref dom.security.sanitizer.enabled=true (already the default).
- Load testcase.html.
- Observe ASAN SEGV at address 0x10000001 in core::ptr::drop_glue / Servo_Element_ClearData. The fault address equals (option_label_length << 3) | 1 and is fully content-controlled.
- Without the patch, an unmodified Nightly build instead hits MOZ_DIAGNOSTIC_ASSERT(label->IsHTMLElement(nsGkAtoms::label)) at HTMLSelectElement.cpp:231, confirming the same corrupted-shadow-tree state.
Security Impact
- Severity: High
- Attacker capability: Web content can corrupt Element::mServoData on a UA-shadow-root <slot> to an arbitrary low-32-bit value of its choosing (high bits 0), then force Servo_Element_ClearData to Box::from_raw that pointer and run drop_glue on it — yielding a wild dereference followed by Arc-refcount decrement (write) and dealloc on attacker-chosen memory. Element::mState is simultaneously overwritten with a heap pointer. With heap spraying this provides arbitrary-free / refcount-underflow primitives sufficient for content-process code execution. The same Sanitizer-mutates-UA-shadow root cause also breaks structural invariants for other UA-widget hosts (e.g.
).
- Preconditions: None beyond loading attacker HTML in a release / late-Beta build (where MOZ_DIAGNOSTIC_ASSERT is compiled out). The Sanitizer API is enabled by default (dom.security.sanitizer.enabled = true). No user interaction, no non-default prefs, no compromised process required.
ASAN Report
=================================================================
==54706==ERROR: AddressSanitizer: SEGV on unknown address 0x000010000001 (pc 0x770c268397d7 bp 0x7fffd11d20b0 sp 0x7fffd11d2080 T0)
==54706==The signal is caused by a READ memory access.
#0 0x770c268397d7 in core::ptr::drop_glue::<core::option::Option<servo_arc::Arc<style::properties::generated::gecko::ComputedValues>>> /rustc/.../core/src/ptr/mod.rs:825:1
#1 0x770c268397d7 in core::ptr::drop_glue::<style::data::ElementStyles> /rustc/.../core/src/ptr/mod.rs:825:1
#2 0x770c268397d7 in core::ptr::drop_glue::<style::data::ElementData> /rustc/.../core/src/ptr/mod.rs:825:1
#3 0x770c268397d7 in core::ptr::drop_glue::<core::cell::UnsafeCell<style::data::ElementData>> /rustc/.../core/src/ptr/mod.rs:825:1
#4 0x770c268397d7 in core::ptr::drop_glue::<style::data::ElementDataWrapper> /rustc/.../core/src/ptr/mod.rs:825:1
#5 0x770c268397d7 in core::ptr::drop_glue::<alloc::boxed::Box<style::data::ElementDataWrapper>> /rustc/.../core/src/ptr/mod.rs:825:1
#6 0x770c268397d7 in <style::gecko::wrapper::GeckoElement as style::dom::TElement>::clear_data /firefox/servo/components/style/gecko/wrapper.rs:1447:9
#7 0x770c258ab139 in Servo_Element_ClearData /firefox/servo/ports/geckolib/glue.rs:1463:36
#8 0x770c0f3bc179 in mozilla::dom::Element::ClearServoData(mozilla::dom::Document*) /firefox/dom/base/Element.cpp:5883:5
#9 0x770c0f3bc179 in mozilla::dom::Element::UnbindFromTree(mozilla::dom::UnbindContext&) /firefox/dom/base/Element.cpp:3126:5
#10 0x770c12e03077 in nsGenericHTMLElement::UnbindFromTree(mozilla::dom::UnbindContext&) /firefox/dom/html/nsGenericHTMLElement.cpp:576:20
#11 0x770c12da3e2d in mozilla::dom::HTMLSlotElement::UnbindFromTree(mozilla::dom::UnbindContext&) /firefox/dom/html/HTMLSlotElement.cpp:70:25
#12 0x770c0f3bcb52 in mozilla::dom::Element::UnbindFromTree(mozilla::dom::UnbindContext&) /firefox/dom/base/Element.cpp:3226:12
#13 0x770c12e03077 in nsGenericHTMLElement::UnbindFromTree(mozilla::dom::UnbindContext&) /firefox/dom/html/nsGenericHTMLElement.cpp:576:20
#14 0x770c0f5b8f01 in mozilla::dom::ShadowRoot::Unbind() /firefox/dom/base/ShadowRoot.cpp:217:12
#15 0x770c0f3bce0f in mozilla::dom::Element::UnbindFromTree(mozilla::dom::UnbindContext&) /firefox/dom/base/Element.cpp:3233:17
#16 0x770c12e03077 in nsGenericHTMLElement::UnbindFromTree(mozilla::dom::UnbindContext&) /firefox/dom/html/nsGenericHTMLElement.cpp:576:20
#17 0x770c12e0f6f4 in nsGenericHTMLFormElement::UnbindFromTree(mozilla::dom::UnbindContext&) /firefox/dom/html/nsGenericHTMLElement.cpp:2117:25
#18 0x770c12d9271d in mozilla::dom::HTMLSelectElement::UnbindFromTree(mozilla::dom::UnbindContext&) /firefox/dom/html/HTMLSelectElement.cpp:885:45
#19 0x770c0f29fc43 in nsIContent::UnbindFromTree(nsINode*, BatchRemovalState const*) /firefox/dom/base/FragmentOrElement.cpp:156:3
#20 0x770c0f7b1f35 in nsINode::RemoveChildNode(nsIContent*, bool, BatchRemovalState const*, nsINode*, MutationEffectOnScript) /firefox/dom/base/nsINode.cpp:2823:9
#21 0x770c0f7b47bc in nsINode::ReplaceOrInsertBefore(bool, nsINode*, nsINode*, MutationEffectOnScript, mozilla::ErrorResult&) /firefox/dom/base/nsINode.cpp:3192:18
#22 0x770c1012b60b in nsINode::AppendChild(nsINode&, mozilla::ErrorResult&) /firefox/dom/base/nsINode.h:2467:12
#25 0x770c1012b60b in mozilla::dom::Node_Binding::appendChild(JSContext*, JS::Handle<JSObject*>, void*, JSJitMethodCallArgs const&) /firefox/obj-firefox-asan/dom/bindings/NodeBinding.cpp:951:60
...
#68 0x770c19c2dc41 in XRE_InitChildProcess(int, char**, XREChildData const*) /firefox/toolkit/xre/nsEmbedFunctions.cpp:594:34
==54706==Register values:
rax = 0x0000000000000000 rbx = 0x0000000010000001 rcx = 0x0000000000000000 rdx = 0x0000000000000001
rdi = 0x0000000000000001 rsi = 0x0000000000001ce8 rbp = 0x00007fffd11d20b0 rsp = 0x00007fffd11d2080
r8 = 0x0000562e7e424000 r9 = 0x0000000000000001 r10 = 0x00007fffffffff01 r11 = 0x0000770c3655ce01
r12 = 0x0000000002000000 r13 = 0x000077fc37601f88 r14 = 0x0000000000000000 r15 = 0x0000000000000000
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV (/firefox/obj-firefox-asan/dist/bin/libxul.so+0x2f8357d7)
==54706==ABORTING
Note: rbx = 0x10000001 = (0x2000000 << 3) | mInHeap. Fault address = (option_label_length << 3) | 1, fully content-controlled.
| Reporter | ||
Comment 1•3 months ago
|
||
| Reporter | ||
Comment 2•3 months ago
|
||
| Reporter | ||
Comment 3•3 months ago
|
||
| Reporter | ||
Comment 4•3 months ago
|
||
| Reporter | ||
Comment 5•3 months ago
|
||
| Reporter | ||
Comment 6•3 months ago
|
||
| Reporter | ||
Comment 7•3 months ago
|
||
Updated•3 months ago
|
| Assignee | ||
Updated•3 months ago
|
| Assignee | ||
Comment 9•3 months ago
|
||
I found this code in nsINode::GetShadowRootForSelection that also skips <use> elements: https://searchfox.org/firefox-main/source/dom/base/nsINode.cpp#4450-4453. Should we be also skipping those? Do they even have a shadow dom just after parsing? Or maybe for some other element?
- if (RefPtr<ShadowRoot> shadow = child->GetShadowRoot()) {
+ if (RefPtr<ShadowRoot> shadow = child->GetShadowRoot(); shadow && !shadow->IsUAWidget()) {
SanitizeChildren<IsDefaultConfig>(shadow, aSafe);
}
Otherwise something like above might be enough.
| Assignee | ||
Comment 11•3 months ago
|
||
| Assignee | ||
Updated•3 months ago
|
| Assignee | ||
Updated•3 months ago
|
Comment 12•3 months ago
|
||
Comment 13•3 months ago
|
||
https://hg-edge.mozilla.org/mozilla-central/rev/0c4c417a20e2
Please nominate this for Beta and Release uplift.
| Assignee | ||
Comment 14•3 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D308227
Updated•3 months ago
|
Comment 15•3 months ago
|
||
firefox-beta Uplift Approval Request
- User impact if declined/Reason for urgency: bad pointer leading to crashes
- Code covered by automated testing?: no
- Fix verified in Nightly?: yes
- Needs manual QE testing?: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: Simple obvious fix.
- String changes made/needed?: no
- Is Android affected?: yes
Comment 16•3 months ago
|
||
firefox-release Uplift Approval Request
- User impact if declined/Reason for urgency: bad pointer leading to crashes
- Code covered by automated testing?: no
- Fix verified in Nightly?: yes
- Needs manual QE testing?: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: Simple obvious fix.
- String changes made/needed?: no
- Is Android affected?: yes
| Assignee | ||
Comment 17•3 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D308227
| Assignee | ||
Updated•3 months ago
|
Updated•3 months ago
|
Updated•3 months ago
|
Comment 18•3 months ago
|
||
| uplift | ||
Updated•3 months ago
|
Updated•3 months ago
|
Updated•3 months ago
|
Comment 19•3 months ago
|
||
| uplift | ||
Updated•3 months ago
|
Updated•18 days ago
|
Description
•