ClaimParentLoad lacks ContentParent check; cross-process load theft
Categories
(Core :: DOM: Navigation, defect)
Tracking
()
People
(Reporter: andrew, Assigned: nika)
References
(Blocks 1 open bug)
Details
(Keywords: reporter-external, sec-want, Whiteboard: [client-bounty-form][adv-main153-])
Attachments
(5 files, 1 obsolete file)
Summary
A compromised content process can claim the live DocumentLoadListener of a top-level HTTP navigation that the parent is speculatively performing on behalf of another content process in the same BrowsingContextGroup. The redirect-to-real-channel and lower-level HTTP attach paths then route the live nsHttpChannel to the attacker's HttpChannelParent, delivering the response stream (body bytes demonstrated; response headers, Set-Cookie, and redirect chain expected to flow over the same channel handoff but not separately instrumented in this PoC) to the attacker's HttpChannelChild. The request itself is the legitimate target's navigation and carries the cookies the browser would send on that navigation, so the attacker reads an authenticated response. Fission's process-isolation invariant — that a ContentParent only receives data destined for its own principals — is bypassed at the IPC / channel-handoff layer, before any document-level enforcement runs.
Scope
Affects top-level navigations within the same BrowsingContextGroup as the compromised process — typically attacker-opened popups, window.open targets, or named-target navigations into related windows. Does not steal arbitrary unrelated tabs in other BCGs.
Affected
- Component: Core :: DOM: Navigation (also touches Core :: Networking; Core :: Security: Process Sandboxing)
- Builds confirmed:
- Firefox 151.0 release, BuildID
20260526002750 - Firefox Nightly 153.0a1, BuildID
20260526140118(mozilla-central commita49121972a61, 26 May 2026)
- Firefox 151.0 release, BuildID
- OS tested: macOS aarch64. Code paths are platform-independent; same behaviour expected on Windows/Linux.
- Not tested on Beta. Likely affects earlier versions back to when
AttemptSpeculativeLoadInParentlanded (the relevant function bodies are byte-identical between 151 release and central tip).
Root cause
DocumentLoadListener::SpeculativeLoadInParentregisters the listener in the process-wideRedirectChannelRegistrarkeyed only byloadIdentifier(auint64_t).- The
loadIdentifieris reachable by any compromised content process in the sameBrowsingContextGroup:CurrentLoadIdentifieris a syncedBrowsingContextfield (declared inBrowsingContext.h~line 227; updated synchronously insideSpeculativeLoadInParentviaStartDocumentLoad), so a same-BCG process can read it viabc->GetCurrentLoadIdentifier(). The attached harness takes a shortcut and reuses the identifier off thensDocShellLoadStateit just constructed for its ownLoadURIcall, but the synced-field path is the realistic one for a content-process RCE attacker. - The compromised content process (the attacker) forges a
PDocumentChannelConstructorwith thatloadIdentifier,channelInitialized = true, andmTriggeringRemoteTypeset to its own remote type.nsDocShellLoadState's deserialization guard only enforces a check whenContentParent::mPendingLoadStateshas an entry for the identifier — a parent-initiated speculative load never populates that map, so the check falls through. DocumentLoadListener::ClaimParentLoadreturns the listener to whichever actor claims first, with no check that the claimant'sContentParent(orBrowsingContext) matches the registration.DocumentChannelParent::RedirectToRealChannelsendsSendRedirectToRealChanneloverManager()->Manager()(the claimant), andHttpChannelParent::ConnectChannel→NS_LinkRedirectChannelsattaches the livensHttpChannelto whicheverHttpChannelParentpresents the matching integerregistrarId. The principal check inTriggerRedirectToRealChannelvalidates against the legitimate target'smContentParent, so it passes.
Net effect: response bytes destined for the legitimate target content process (the one the parent opened the speculative load for) flow to the attacker's content process via HttpChannelChild::OnTransportAndData.
Impact
A compromised content process can exfiltrate authenticated top-level navigation responses for same-BCG target windows/popups, bypassing Fission process isolation at the IPC / channel-handoff layer. The attacker triggers it by opening a cross-site popup and setting popup.location.href (writable cross-origin). Because the request is the legitimate target's top-level navigation, the browser attaches the cookies it would send for that navigation: SameSite=Lax (the modern default) and SameSite=None cookies are exposed; SameSite=Strict cookies are not. Webmail, banking, social, and SaaS sessions typically rely on Lax-default cookies, so those sessions are exposed along with any embedded CSRF tokens.
Threat model: the attacker is assumed to have arbitrary native code execution in a content process and can issue any IPC the parent accepts from a content process. The harness uses docshell->LoadURI(stealState) for convenience, but a real attacker would construct the PDocumentChannelConstructor IPC directly (actor in netwerk/ipc/PDocumentChannel.ipdl; args are DocumentChannelCreationArgs in NeckoChannelParams.ipdlh) and read the bytes in HttpChannelChild::OnTransportAndData — no docshell-level processing is required.
Steps to reproduce
Full mechanical recipe is in BUILD.md inside the attached worktree. The PoC needs two distinct sites under Fission so attacker and target popup live in distinct ContentParents; it uses loopback aliases 127.0.0.1 (attacker) and 127.0.0.2 (target) with a small Python server on 0.0.0.0:8080.
- Build Firefox from
FIREFOX_151_0_RELEASE(or mozilla-central tip) with the attached harness patch. The patch is env-gated onMOZ_DOCCHANNEL_STEAL_POC=1plus thedocsteal-secretURL substring; it adds an instrumentation hook inBrowsingContext::LoadURI(~line 2342) that fires a forgedPDocumentChannelConstructoron the source process'sPNeckoChildwith the parent-registeredloadIdentifier,channelInitialized = true, andTriggeringRemoteTypeset to the source process's own remote type. No production code is removed or weakened. - Add a loopback alias:
sudo ifconfig lo0 alias 127.0.0.2 up. - Start the test server.
attacker.html(on 127.0.0.1) opens a popup to127.0.0.2/initial.html, then setspopup.location.hrefto127.0.0.2/docsteal-secret/page, and repeats 20×. The/docsteal-secret/pageendpoint returns a fresh random token per request and logs each hit. - Launch:
Profile setsMOZ_DOCCHANNEL_STEAL_POC=1 firefox \ --profile /tmp/docsteal-profile --no-remote --new-instance \ --url http://127.0.0.1:8080/attacker.htmlfission.autostart=true,dom.ipc.processCount.webIsolated=4, and disables HTTPS-only mode. Capture stderr. - Click "launch" (20 attempts). Each iteration races the legitimate target's
PDocumentChannelConstructoragainst the attacker's forged one. On a quiet machine the attacker wins a meaningful fraction.
Actual results
On 151.0, one click produced this stderr (loadIdentifier 6442450946; attacker ChildID 3, popup ChildID 11):
[DOCSTEAL:content] child=3 stealing loadIdentifier=6442450946 uri=http://127.0.0.2:8080/docsteal-secret/page?...
[DOCSTEAL:parent] SpeculativeLoadInParent targetCP=11 targetBC=6442450945 loadIdentifier=6442450946
[DOCSTEAL:parent] DocumentChannelParent claim req claimingCP=3 claimingBC=4 loadIdentifier=6442450946
[DOCSTEAL:parent] ClaimParentLoad hit loadIdentifier=6442450946 originalCP=11 originalBC=6442450945
[DOCSTEAL:parent] DocumentChannelParent claim req claimingCP=11 claimingBC=6442450945 loadIdentifier=6442450946
[DOCSTEAL:parent] ClaimParentLoad miss loadIdentifier=6442450946
[DOCSTEAL:parent] RedirectToRealChannel sendingToCP=3 listenerOriginalCP=11
[DOCSTEAL:parent] HttpChannelParent::ConnectChannel hit registrarId=31 channel=0x163db6140
[DOCSTEAL:content] child=3 HttpChannelChild bytes len=116 data=<!doctype html>…<p>DOCSTEAL_SECRET=de3036407134417d800fe4c533e4ac23</p>…
The two log lines ClaimParentLoad hit … originalCP=11 immediately after claimingCP=3, and RedirectToRealChannel sendingToCP=3 listenerOriginalCP=11, are the cross-ContentParent boundary: the listener registered for process 11 was handed to process 3. The token de303640… was delivered to the attacker's content process; the popup eventually rendered a different token from its own fallback fetch after the parent's speculative channel had been stolen.
On 153.0a1 Nightly the same harness applied cleanly and produced two distinct steals with identical signatures on a single click. On that build the attacker content process exits a few ms after the steal because the harness uses LoadURI to trigger the IPC and Fission's runtime principal check then tears down the docshell — but the tear-down happens after the IPC-layer steal has already routed the live nsHttpChannel to the attacker's HttpChannelParent (visible in the RedirectToRealChannel and HttpChannelParent::ConnectChannel lines). A real attacker would not go through LoadURI, so this tear-down does not fire in practice.
Expected results
The parent should hand the speculative listener only to the ContentParent it was opened on behalf of. Any one of the following checks would close the primitive; the defense in depth would be to add all of them:
ClaimParentLoad: verify claimant'sContentParentandBrowsingContextmatch the listener'smContentParent/GetLoadingBrowsingContext().RedirectToRealChannel: refuse to send over aContentParentdifferent from the listener's recorded one.HttpChannelParent::ConnectChannel/NS_LinkRedirectChannels: refuse to link a registered channel to anHttpChannelParentwhoseContentParentdoes not match the channel'sLoadInfo-derived expected destination.
Suggested fix
Bind the speculative listener to its originating (ContentParent, CanonicalBrowsingContext) at registration and verify both in ClaimParentLoad — the listener carries mContentParent and GetLoadingBrowsingContext() already, and the claimant's ContentParent is reachable via the Init aContext path. A registry-side alternative: key RedirectChannelRegistrar on (loadIdentifier, ContentParent ChildID), or record the expected ContentParent in the registrar entry and check it inside LinkChannels. Tightening nsDocShellLoadState's deserialization guard to require a pending-load entry whenever mChannelInitialized = true would also close the steal vector at the deserializer.
Attachments / artifacts
I will attach:
harness.patch— ~90 lines across 5 files, env-gated.firefox.run1.log(151) andnightly-poc2.saved.log(153.0a1) — stderr captures from the runs above.server.py— ~80 lines of stdlib Python, the test server.BUILD.md— full build/run instructions.
Updated•4 months ago
|
Comment 5•4 months ago
|
||
Nika, do you know which component this should live in? I'm not sure if this is more like DOM: Navigation or Networking. Thanks.
| Assignee | ||
Comment 6•4 months ago
|
||
While the code is technically in netwerk/, I think it's safe to say that DocumentLoadListener is part of docshell/dom navigation at this point.
Another option here is around the fact that this channel initialization piece is tied to the nsDocShellLoadState being bounced through the content process. We never should have a pre-initialized channel like this unless the load state was initially created in the parent process. Since bug 1538028, we actually keep around the original nsDocShellLoadState instance which we sent over IPC, so it might actually be possible for us to bypass the need to use the registry entirely, while still keeping the value bound to a single process.
Updated•4 months ago
|
| Assignee | ||
Comment 7•4 months ago
|
||
Updated•4 months ago
|
| Assignee | ||
Comment 8•4 months ago
|
||
Comment on attachment 9591536 [details]
(secure)
Security Approval Request
- How easily could an exploit be constructed based on the patch?: I think the patch obscures the bug fairly well, though I expect someone motivated could figure it out from the hints.
- 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?: all
- If not all supported branches, which bug introduced the flaw?: None
- Do you have backports for the affected branches?: No
- If not, how different, hard to create, and risky will they be?: I expect there will be some merge conflicts especially for older branches, as this code has changed over time, but they shouldn't be too bad to resolve if we decide to uplift.
- How likely is this patch to cause regressions; how much testing does it need?: This is a more fundamental change to the way that claiming parent-initiated speculative loads works, so is inherently more risky than adding an extra validation call or similar.
- Is the patch ready to land after security approval is given?: Yes
- Is Android affected?: Yes
Comment 9•4 months ago
|
||
Can we give this bug a security severity rating?
Updated•4 months ago
|
Comment 10•4 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 11•4 months ago
|
||
(In reply to Ryan VanderMeulen [:RyanVM] from comment #9)
Can we give this bug a security severity rating?
I think this is probably sec-want, but I'm not 100% sure. The primary thing this allows is reading a network response you shouldn't have access to from another origin, which IIRC is a known gap in our content process sandboxing model (that a compromised content process can perform largely unrestricted credentialed network requests).
The bigger risk would be if this allows bypassing document loading restrictions, but I don't think that is the case. The document is loaded throughout DocumentLoadListener as if it was targeting the original process, and can still process-switch. The load only ends up directed into the attacker process if we decide not to process switch, and all special permissions given to the target process are sent to the intended process not the attacker process.
I would really like to close that known gap in our sandboxing model at some point, but given that it exists, this exploit appears to not be giving the process notably more power than it already has ottomh.
| Assignee | ||
Updated•4 months ago
|
Comment 12•4 months ago
|
||
Comment 13•4 months ago
|
||
https://hg-edge.mozilla.org/mozilla-central/rev/73ba40ab09bf
Sounds like we probably want this on Beta and maybe ESR140 also, but ESR115 probably isn't worth the likely backport pain unless you feel strongly otherwise.
Comment 14•4 months ago
|
||
The patch landed in nightly and beta is affected.
:nika, is this bug important enough to require an uplift?
- If yes, please nominate the patch for beta approval.
- See https://wiki.mozilla.org/Release_Management/Requesting_an_Uplift for documentation on how to request an uplift.
- If no, please set
status-firefox152towontfix.
For more information, please visit BugBot documentation.
Updated•4 months ago
|
Comment 15•3 months ago
|
||
Too late for 152. We can revisit what if anything we want to do for ESR140 next cycle.
Updated•3 months ago
|
Updated•3 months ago
|
Updated•3 months ago
|
Updated•2 months ago
|
Updated•18 days ago
|
Description
•