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)
Tracking
()
| 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)
|
771 bytes,
text/html
|
Details | |
|
1.05 KB,
patch
|
Details | Diff | Splinter Review | |
|
7.14 KB,
text/plain
|
Details |
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
- Branch: main
- Revision: ddd655c0aee6b279703c727d2469f9665cd0d6f3
- Timestamp: 2026-06-05T19:37:47+00:00
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
- 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.
- 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).
- 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().
- 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.
- 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 */, ...).
- 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.
- 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
- 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)
- 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)
- 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)
| Reporter | ||
Comment 1•5 months ago
|
||
| Reporter | ||
Comment 2•5 months ago
|
||
| Reporter | ||
Comment 3•5 months ago
|
||
Updated•5 months ago
|
Comment 5•5 months ago
|
||
vhilla, sorry, something about initial about:blank (even though I think this is old).
Perhaps a simple nsCOMPtr would work here?
Updated•5 months ago
|
| Assignee | ||
Comment 6•5 months ago
•
|
||
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.
Comment 7•5 months ago
|
||
The bug has a release status flag that shows some version of Firefox is affected, thus it will be considered confirmed.
| Assignee | ||
Comment 8•5 months ago
|
||
| Assignee | ||
Comment 9•5 months ago
|
||
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:///xto 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
| Assignee | ||
Updated•5 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
Comment 10•5 months ago
|
||
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
| Assignee | ||
Comment 11•5 months ago
|
||
Comment 12•5 months ago
|
||
Updated•5 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
Comment 13•5 months ago
|
||
| uplift | ||
Comment 14•5 months ago
|
||
Updated•5 months ago
|
Updated•5 months ago
|
Updated•1 month ago
|
Description
•