Closed Bug 2049409 Opened 3 months ago Closed 3 months ago

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)

defect

Tracking

()

RESOLVED FIXED
154 Branch
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)

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

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

  1. 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.
  2. 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>.
  3. 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>.
  4. Sanitizer continues to the light DOM, strips the disallowed selected attribute from <option>. Element::UnsetAttr → HTMLOptionElement::BeforeSetAttr → HTMLSelectElement::SetOptionsSelectedByIndex → OnSelectionChanged → SelectedContentTextMightHaveChanged.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. 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).
  2. Build with mozconfig.linux.asan.fuzzing (--enable-address-sanitizer --disable-debug).
  3. Set pref dom.security.sanitizer.enabled=true (already the default).
  4. Load testcase.html.
  5. 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.
  6. 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.
Attached file trace.jsonl —
Attached file testcase.html —
Attached file prefs.js —
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Attached file README.txt —
Group: core-security → dom-core-security
Assignee: nobody → tschuster
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true

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.

Flags: needinfo?(emilio)

No, <use> elements should be fine.

Flags: needinfo?(emilio)
Attached file (secure) —

https://hg-edge.mozilla.org/mozilla-central/rev/0c4c417a20e2

Please nominate this for Beta and Release uplift.

Group: dom-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 3 months ago
Flags: needinfo?(tschuster)
Resolution: --- → FIXED
Target Milestone: --- → 154 Branch
Attached file (secure) —
Attachment #9601169 - Flags: approval-mozilla-beta?

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

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
Attachment #9601178 - Flags: approval-mozilla-release?
Attached file (secure) —
Flags: needinfo?(tschuster)
Attachment #9601169 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c154/b153]
Attachment #9601178 - Flags: approval-mozilla-release? → approval-mozilla-release+
Whiteboard: [adv-main152.0.4+r]
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: