Closed Bug 2045516 Opened 5 months ago Closed 5 months ago

Heap use-after-free in [@ mozilla::dom::Document::SetBaseURI] - <base> removal in pagehide handler frees raw base URI used by nsDocShell::CreateAboutBlankDocumentViewer

Categories

(Core :: DOM: Core & HTML, defect)

defect

Tracking

()

RESOLVED FIXED
153 Branch
Tracking Status
firefox-esr115 --- unaffected
firefox-esr140 --- unaffected
firefox151 --- wontfix
firefox152 + fixed
firefox153 + fixed

People

(Reporter: bugmon, Assigned: vhilla)

References

(Regression)

Details

(6 keywords, Whiteboard: [adv-main152+r])

Attachments

(3 files, 2 external files)

The document base URI (Document::mDocumentBaseURI) is created and exclusively owned through dom/html/HTMLSharedElement.cpp: SetBaseURIUsingFirstBaseWithHref() builds a fresh nsStandardURL with NS_NewURI (HTMLSharedElement.cpp:83) whenever a <base href> element is set/inserted, and Document::mDocumentBaseURI becomes the only strong reference. The same helper releases that reference (HTMLSharedElement.cpp:118, via Document::SetBaseURI) when the <base> element is removed (HTMLSharedElement::UnbindFromTree, line 201) or its href changes (AfterSetAttr, line 156), immediately freeing the URI object.

nsDocShell::CompleteInitialAboutBlankLoad (docshell/base/nsDocShell.cpp:10074) fetches this URI as a RAW nsIURI* via doc->GetDocBaseURI() and passes it to nsDocShell::CreateAboutBlankDocumentViewer(). Inside that function, FirePageHideNotification() (nsDocShell.cpp:6565) synchronously dispatches the pagehide event into content script of the old document BEFORE the raw pointer is consumed at blankDoc->SetBaseURI(aBaseURI) (nsDocShell.cpp:6646). A pagehide handler that removes the <base> element frees the URI through HTMLSharedElement::UnbindFromTree -> SetBaseURIUsingFirstBaseWithHref -> Document::SetBaseURI(nullptr); the subsequent AddRef in blankDoc->SetBaseURI touches freed memory, and the dangling pointer is installed as the new document's long-lived base URI.

The vulnerable code path is fully web-reachable and deterministic: an iframe whose src load is security-denied (e.g. src="file:///x" from web content) keeps its eagerly-created UNCOMMITTED initial about:blank document; a same-principal frame (a data: URL iframe sharing the null principal) plants the <base> and pagehide handler in it; a cross-principal about:blank navigation targeted at that frame (a link click from the top page, which sets PrincipalToInherit explicitly in OnLinkClickSync) takes the synchronous initial-about:blank path with a principal mismatch, entering the 'clobber' branch that performs the raw-pointer fetch/script/use sequence.

Build Info

Affected Code

File: dom/html/HTMLSharedElement.cpp, line 79-118

static void SetBaseURIUsingFirstBaseWithHref(Document* aDocument,
                                             nsIContent* aMustMatch) {
  ...
      nsCOMPtr<nsIURI> newBaseURI;
      nsContentUtils::NewURIWithDocumentCharset(   // line 83: allocates the nsStandardURL;
          getter_AddRefs(newBaseURI), href, aDocument,   // Document::mDocumentBaseURI becomes
          aDocument->GetFallbackBaseURI());              // the ONLY strong reference
  ...
      aDocument->SetBaseURI(newBaseURI);
      aDocument->SetChromeXHRDocBaseURI(nullptr);
      return;
    }
  }

  aDocument->SetBaseURI(nullptr);   // line 118: releases the last reference -> frees the URI

File: dom/html/HTMLSharedElement.cpp, line 192-207

void HTMLSharedElement::UnbindFromTree(UnbindContext& aContext) {
  Document* doc = GetUncomposedDoc();
  nsGenericHTMLElement::UnbindFromTree(aContext);
  // If we're removing a <base> from a document, we may need to update the
  // document's base URI and base target
  if (doc && mNodeInfo->Equals(nsGkAtoms::base)) {
    if (HasAttr(nsGkAtoms::href)) {
      SetBaseURIUsingFirstBaseWithHref(doc, nullptr);   // line 201: frees mDocumentBaseURI
    }
    ...

File: docshell/base/nsDocShell.cpp, line 10070-10077

// Clobber document before completing the synchronous load if it doesn't have
// the right principal (bug 1979032)
if (principalMismatch || shouldBeSandboxed) {
  // This will sandbox the principals as needed
  rv = CreateAboutBlankDocumentViewer(
      expectedPrincipal, expectedPartitionedPrincipal,
      aLoadState->PolicyContainer(), doc->GetDocBaseURI(),  // RAW nsIURI*, only ref is
      /* aIsInitialDocument */ true);                       // doc->mDocumentBaseURI

File: docshell/base/nsDocShell.cpp, line 6557-6646

nsresult nsDocShell::CreateAboutBlankDocumentViewer(
    nsIPrincipal* aPrincipal, nsIPrincipal* aPartitionedPrincipal,
    nsIPolicyContainer* aPolicyContainer, nsIURI* aBaseURI, ...) {
  ...
    Stop();
    // Notify the current document that it is about to be unloaded!!
    (void)FirePageHideNotification();   // line 6565: fires 'pagehide' SYNCHRONOUSLY into
  ...                                   // content script -> handler removes <base> -> URI freed
      // Hack: set the base URI manually, since this document never
      // got Reset() with a channel.
      blankDoc->SetBaseURI(aBaseURI);   // line 6646: AddRef on the freed nsStandardURL -> UAF

File: dom/base/Document.h, line 940-945

nsIURI* GetDocBaseURI() const {     // returns a RAW, non-addrefed pointer
  if (mDocumentBaseURI) {
    return mDocumentBaseURI;
  }
  return GetFallbackBaseURI();
}

The URI is allocated and solely owned via HTMLSharedElement.cpp (alloc at line 83, ownership in Document::mDocumentBaseURI), freed via the same file's UnbindFromTree path (lines 201 -> 118) during the pagehide event, and used after free by nsDocShell::CreateAboutBlankDocumentViewer, which received it as a raw nsIURI* before running script.

Exploit Chain

  1. Attacker page T (http origin A) embeds an iframe M with a data: URL; M gets a null (opaque) principal but stays in T's content process.
  2. M creates a child iframe V with name='victimframe' and src='file:///x'. The file: load is denied by CheckLoadURIWithPrincipal inside nsFrameLoader::LoadURI, so no load ever starts on V's docshell: V keeps its eagerly-created UNCOMMITTED initial about:blank document, whose principal is M's null principal (and mHasStartedLoadingOtherThanInitialBlankURI remains false).
  3. M, being same-principal with V's initial document, inserts a <base> element and sets href='http://example.org/uaf/' - HTMLSharedElement::AfterSetAttr -> SetBaseURIUsingFirstBaseWithHref allocates the victim nsStandardURL (HTMLSharedElement.cpp:83); Document::mDocumentBaseURI holds the only reference. M also adds a pagehide listener on V's window that calls base.remove().
  4. T programmatically clicks <a href='about:blank' target='victimframe'>. nsDocShell::OnLinkClickSync explicitly sets PrincipalToInherit = T's principal (A). The load is retargeted to V's docshell.
  5. In V's docshell, ShouldDoInitialAboutBlankSyncLoad passes (about:blank load onto an uncommitted initial document) and CompleteInitialAboutBlankLoad runs. expectedPrincipal (A) != V's doc principal (M's null principal) => principalMismatch => the clobber branch calls CreateAboutBlankDocumentViewer(..., doc->GetDocBaseURI() /* raw */, ...).
  6. Inside CreateAboutBlankDocumentViewer, FirePageHideNotification() synchronously fires pagehide on V's window. M's handler executes base.remove() -> HTMLSharedElement::UnbindFromTree (line 201) -> SetBaseURIUsingFirstBaseWithHref(doc, nullptr) (line 118) -> Document::SetBaseURI(nullptr) releases the last reference; the 312-byte nsStandardURL is freed. Attacker script can now groom the freed allocation.
  7. Execution returns to CreateAboutBlankDocumentViewer, which calls blankDoc->SetBaseURI(aBaseURI) with the dangling pointer: nsCOMPtr assignment performs a virtual AddRef on freed memory (ASAN: heap-use-after-free READ of size 8 = vtable load, followed by a refcount write). The dangling pointer is then stored as the new document's mDocumentBaseURI, a long-lived dangling reference exercised by every later relative-URL resolution and released again at document teardown (controllable double-use), giving a strong primitive for content-process code execution via heap grooming.

Steps to Reproduce

  1. Build Firefox with the ASAN fuzzing config: mcp__clauditor__build_firefox with mozconfig /app/mozconfigs/mozconfig.linux.asan.fuzzing (tree pristine at commit ddd655c0aee6b279703c727d2469f9665cd0d6f3; no source modifications)
  2. Load testcase.html in the resulting build (e.g. via mcp__clauditor__browser_evaluator with firefox_binary <build_dir>/dist/bin/firefox, timeout 30; NO custom prefs are required)
  3. The content process crashes deterministically within ~2 seconds with an AddressSanitizer heap-use-after-free in mozilla::dom::Document::SetBaseURI called from nsDocShell::CreateAboutBlankDocumentViewer, freed via mozilla::dom::HTMLSharedElement::UnbindFromTree -> SetBaseURIUsingFirstBaseWithHref

Security Impact

  • Severity: High
  • Attacker capability: Deterministic use-after-free of a 312-byte nsStandardURL object in the content process, triggered entirely by unprivileged web content with no user interaction. The attacker controls the exact moment of the free from a synchronous pagehide handler (allowing heap grooming to reclaim the allocation before the use), the first use is a virtual AddRef on attacker-reclaimable memory (vtable-controlled call primitive), and the dangling pointer is additionally stored as the new document's base URI, providing repeated later uses (relative URL resolution) and a final Release at document teardown. This is a strong candidate for arbitrary code execution in the content process.
  • Preconditions: None beyond loading an attacker-controlled web page in a default-configuration Firefox (no special prefs, no user interaction, no races; single self-contained HTML file).

ASAN Report

==202589==ERROR: AddressSanitizer: heap-use-after-free on address 0x79f4818f47c0 at pc 0x78d459bb7b59 bp 0x7ffef6c9e630 sp 0x7ffef6c9e628
READ of size 8 at 0x79f4818f47c0 thread T0 (WebCOOP+COEP Co)
    #0 mozilla::RefPtrTraits<nsIURI>::AddRef(nsIURI*) dist/include/mozilla/RefPtr.h:46:39
    #1 nsCOMPtr<nsIURI>::assign_with_AddRef(nsIURI*) xpcom/base/nsCOMPtr.h:808:5
    #2 nsCOMPtr<nsIURI>::operator=(nsIURI*) xpcom/base/nsCOMPtr.h:588:5
    #3 mozilla::dom::Document::SetBaseURI(nsIURI*) dom/base/Document.cpp:7311:20
    #4 nsDocShell::CreateAboutBlankDocumentViewer(nsIPrincipal*, nsIPrincipal*, nsIPolicyContainer*, nsIURI*, bool, mozilla::Maybe<nsILoadInfo::CrossOriginEmbedderPolicy> const&, bool, bool, mozilla::dom::WindowGlobalChild*) docshell/base/nsDocShell.cpp:6646:17
    #5 nsDocShell::CompleteInitialAboutBlankLoad(nsDocShellLoadState*, nsILoadInfo*) docshell/base/nsDocShell.cpp:10074:10
    #6 nsDocShell::DoURILoad(nsDocShellLoadState*, mozilla::Maybe<unsigned int>, nsIRequest**) docshell/base/nsDocShell.cpp:9877:12
    #7 nsDocShell::InternalLoad(nsDocShellLoadState*, mozilla::Maybe<unsigned int>) docshell/base/nsDocShell.cpp:8856:8
    #8 mozilla::dom::BrowsingContext::InternalLoad(nsDocShellLoadState*) docshell/base/BrowsingContext.cpp:2424:22
    #9 nsDocShell::PerformRetargeting(nsDocShellLoadState*) docshell/base/nsDocShell.cpp:7671:25
    #10 nsDocShell::InternalLoad(nsDocShellLoadState*, mozilla::Maybe<unsigned int>) docshell/base/nsDocShell.cpp:8457:12
    #11 nsDocShell::OnLinkClickSync(nsIContent*, nsDocShellLoadState*, bool, nsIPrincipal*) docshell/base/nsDocShell.cpp:12334:17
    #12 OnLinkClickEvent::Run() docshell/base/nsDocShell.cpp:11983:17
    [...]

0x79f4818f47c0 is located 0 bytes inside of 312-byte region [0x79f4818f47c0,0x79f4818f48f8)
freed by thread T0 (WebCOOP+COEP Co) here:
    #0 __interceptor_free
    #1 mozilla::net::nsStandardURL::Release() netwerk/base/nsStandardURL.cpp:1137:1
    #2-#5 nsCOMPtr<nsIURI>::operator=(nsIURI*) [release of old value]
    #6 mozilla::dom::Document::SetBaseURI(nsIURI*) dom/base/Document.cpp:7311:20
    #7 mozilla::dom::SetBaseURIUsingFirstBaseWithHref(mozilla::dom::Document*, nsIContent*) dom/html/HTMLSharedElement.cpp:118:14
    #8 mozilla::dom::HTMLSharedElement::UnbindFromTree(mozilla::dom::UnbindContext&) dom/html/HTMLSharedElement.cpp:201:7
    #9 nsIContent::UnbindFromTree(nsINode*, BatchRemovalState const*) dom/base/FragmentOrElement.cpp:156:3
    #10 nsINode::RemoveChildNode(...) dom/base/nsINode.cpp:2674:9
    #11 nsINode::RemoveChildInternal(...) dom/base/nsINode.cpp:1207:3
    #12-#13 nsINode::Remove() dom/base/nsINode.cpp:2354:11
    #14 mozilla::dom::Element_Binding::remove(...)  [JS: b.remove() in the pagehide handler]
    [... EventListener::HandleEvent / EventDispatcher::Dispatch - synchronous pagehide dispatched from nsDocShell::FirePageHideNotification ...]

previously allocated by thread T0 (WebCOOP+COEP Co) here:
    #0 __interceptor_malloc
    #1 moz_xmalloc
    #2-#3 mozilla::net::nsStandardURL::TemplatedMutator<mozilla::net::nsStandardURL>::Create() netwerk/base/nsStandardURL.h:503:35
    #6-#10 NS_NewURI(...) netwerk/base/nsNetUtil.cpp
    #11 mozilla::dom::SetBaseURIUsingFirstBaseWithHref(mozilla::dom::Document*, nsIContent*) dom/html/HTMLSharedElement.cpp:83:7
    #12 mozilla::dom::HTMLSharedElement::AfterSetAttr(...) dom/html/HTMLSharedElement.cpp:156:9
    #13 mozilla::dom::Element::SetAttrAndNotify(...) dom/base/Element.cpp:4004:5
    [... JS: b.setAttribute('href', ...) ...]

SUMMARY: AddressSanitizer: heap-use-after-free (libxul.so+0x18165b58) (BuildId: 95c40c7d569f2aa7f4004e7df008c125)
Attached file testcase.html —
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Group: core-security → dom-core-security

vhilla, sorry, something about initial about:blank (even though I think this is old).
Perhaps a simple nsCOMPtr would work here?

Severity: -- → S2
Flags: needinfo?(vhilla)
Assignee: nobody → vhilla

This CreateAboutBlankDocumentViewer(...doc->GetDocBaseURI()...) was added by bug 543435, the PoC should not work in versions before 148.

Though the general issue in that FirePageHideNotification is MOZ_CAN_RUN_SCRIPT_BOUNDARY, CreateAboutBlankDocumentViewer not being MOZ_CAN_RUN_SCRIPT, and GetDocBaseURI() being a raw ptr did pre-exist. We should make sure there aren't similar cases.


an iframe whose src load is security-denied (e.g. src="file:///x" from web content) keeps its eagerly-created UNCOMMITTED initial about:blank document [...] about:blank navigation targeted at that frame [...] takes the synchronous initial-about:blank

We might want to change the second part. Those arguably are edge cases, but we usually set mHasStartedLoadingOtherThanInitialBlankURI and prevent future synchronous navigations. That's also more in line with spec firing load during iframe attribute parsing, because we aren't in initial attribute parsing anymore when setting src later. Then again, Chrome also does a sync load here and we already stop in nsFrameLoader::LoadSrc, so it might be a bit awkward to set mHasStartedLoadingOtherThanInitialBlankURI from there.

I'm not aware of other cases where web pages can modify the document (in this case, touch the <base> object) before the synchronous load. Though web extensions can do this.

The bug has a release status flag that shows some version of Firefox is affected, thus it will be considered confirmed.

Status: UNCONFIRMED → NEW
Ever confirmed: true
Attached file (secure) —

Comment on attachment 9595059 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: Uplifting a strong reference does hint at an UAF. I don't think using file:///x to delay the synchronous load is obvious, but clauditor found it.
  • 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?: beta, release
  • If not all supported branches, which bug introduced the flaw?: Bug 543435
  • Do you have backports for the affected branches?: No
  • If not, how different, hard to create, and risky will they be?: Patch applies cleanly
  • How likely is this patch to cause regressions; how much testing does it need?: Unlikely, it adds MOZ_CAN_RUN_SCRIPT and strong references
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: Yes
Flags: needinfo?(vhilla)
Attachment #9595059 - Flags: sec-approval?
Attachment #9595059 - Flags: sec-approval? → sec-approval+

firefox-beta Uplift Approval Request

  • User impact if declined/Reason for urgency: UAF
  • Code covered by automated testing?: no
  • Fix verified in Nightly?: no
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing: Open the attached testcase.html, wait a few seconds, it shouldn't crash.
  • Risk associated with taking this patch: low
  • Explanation of risk level: Adds strong references
  • String changes made/needed?: No
  • Is Android affected?: yes
Attachment #9595641 - Flags: approval-mozilla-beta?
Attached file (secure) —
Pushed by vhilla@mozilla.com: https://github.com/mozilla-firefox/firefox/commit/206f82125faa https://hg.mozilla.org/integration/autoland/rev/8ac1f9388b99 Mark CreateAboutBlankDocumentViewer MOZ_CAN_RUN_SCRIPT. r=dom-core-reviewers,smaug
Attachment #9595641 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
Group: dom-core-security → core-security-release
Status: NEW → RESOLVED
Closed: 5 months ago
Resolution: --- → FIXED
Target Milestone: --- → 153 Branch
QA Whiteboard: [sec] [uplift] [qa-triage-done-c153/b152]
Whiteboard: [adv-main152+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: