Closed Bug 2043001 Opened 4 months ago Closed 4 months ago

ClaimParentLoad lacks ContentParent check; cross-process load theft

Categories

(Core :: DOM: Navigation, defect)

defect

Tracking

()

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

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)

Attached file BUILD.md (obsolete) —

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 commit a49121972a61, 26 May 2026)
  • 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 AttemptSpeculativeLoadInParent landed (the relevant function bodies are byte-identical between 151 release and central tip).

Root cause

  1. DocumentLoadListener::SpeculativeLoadInParent registers the listener in the process-wide RedirectChannelRegistrar keyed only by loadIdentifier (a uint64_t).
  2. The loadIdentifier is reachable by any compromised content process in the same BrowsingContextGroup: CurrentLoadIdentifier is a synced BrowsingContext field (declared in BrowsingContext.h ~line 227; updated synchronously inside SpeculativeLoadInParent via StartDocumentLoad), so a same-BCG process can read it via bc->GetCurrentLoadIdentifier(). The attached harness takes a shortcut and reuses the identifier off the nsDocShellLoadState it just constructed for its own LoadURI call, but the synced-field path is the realistic one for a content-process RCE attacker.
  3. The compromised content process (the attacker) forges a PDocumentChannelConstructor with that loadIdentifier, channelInitialized = true, and mTriggeringRemoteType set to its own remote type. nsDocShellLoadState's deserialization guard only enforces a check when ContentParent::mPendingLoadStates has an entry for the identifier — a parent-initiated speculative load never populates that map, so the check falls through.
  4. DocumentLoadListener::ClaimParentLoad returns the listener to whichever actor claims first, with no check that the claimant's ContentParent (or BrowsingContext) matches the registration.
  5. DocumentChannelParent::RedirectToRealChannel sends SendRedirectToRealChannel over Manager()->Manager() (the claimant), and HttpChannelParent::ConnectChannel → NS_LinkRedirectChannels attaches the live nsHttpChannel to whichever HttpChannelParent presents the matching integer registrarId. The principal check in TriggerRedirectToRealChannel validates against the legitimate target's mContentParent, 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.

  1. Build Firefox from FIREFOX_151_0_RELEASE (or mozilla-central tip) with the attached harness patch. The patch is env-gated on MOZ_DOCCHANNEL_STEAL_POC=1 plus the docsteal-secret URL substring; it adds an instrumentation hook in BrowsingContext::LoadURI (~line 2342) that fires a forged PDocumentChannelConstructor on the source process's PNeckoChild with the parent-registered loadIdentifier, channelInitialized = true, and TriggeringRemoteType set to the source process's own remote type. No production code is removed or weakened.
  2. Add a loopback alias: sudo ifconfig lo0 alias 127.0.0.2 up.
  3. Start the test server. attacker.html (on 127.0.0.1) opens a popup to 127.0.0.2/initial.html, then sets popup.location.href to 127.0.0.2/docsteal-secret/page, and repeats 20×. The /docsteal-secret/page endpoint returns a fresh random token per request and logs each hit.
  4. Launch:
    MOZ_DOCCHANNEL_STEAL_POC=1 firefox \
      --profile /tmp/docsteal-profile --no-remote --new-instance \
      --url http://127.0.0.1:8080/attacker.html
    
    Profile sets fission.autostart=true, dom.ipc.processCount.webIsolated=4, and disables HTTPS-only mode. Capture stderr.
  5. Click "launch" (20 attempts). Each iteration races the legitimate target's PDocumentChannelConstructor against 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's ContentParent and BrowsingContext match the listener's mContentParent / GetLoadingBrowsingContext().
  • RedirectToRealChannel: refuse to send over a ContentParent different from the listener's recorded one.
  • HttpChannelParent::ConnectChannel / NS_LinkRedirectChannels: refuse to link a registered channel to an HttpChannelParent whose ContentParent does not match the channel's LoadInfo-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) and nightly-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.
Flags: sec-bounty?
Attached patch patch — — Splinter Review
Attached file firefox.run1.log —
Summary: Cross-ContentParent speculative DocumentChannel claim bypass → ClaimParentLoad lacks ContentParent check; cross-process load theft
Attached file BUILD.md —
Attachment #9590672 - Attachment is obsolete: true
Group: firefox-core-security → network-core-security
Component: Security → Networking
Product: Firefox → Core

Nika, do you know which component this should live in? I'm not sure if this is more like DOM: Navigation or Networking. Thanks.

Flags: needinfo?(nika)

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.

Assignee: nobody → nika
Flags: needinfo?(nika)
Group: network-core-security → dom-core-security
Component: Networking → DOM: Navigation
Attached file (secure) —
Severity: -- → S2

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
Attachment #9591536 - Flags: sec-approval?

Can we give this bug a security severity rating?

Blocks: fission-ipc

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

(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.

Flags: needinfo?(nika)
Keywords: sec-want
Attachment #9591536 - Flags: sec-approval?

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.

Group: dom-core-security → core-security-release
Status: NEW → RESOLVED
Closed: 4 months ago
Resolution: --- → FIXED
Target Milestone: --- → 153 Branch

The patch landed in nightly and beta is affected.
:nika, is this bug important enough to require an uplift?

For more information, please visit BugBot documentation.

Flags: needinfo?(nika)
Whiteboard: [client-bounty-form] → [client-bounty-form][adv-esr140.12+]

Too late for 152. We can revisit what if anything we want to do for ESR140 next cycle.

Whiteboard: [client-bounty-form][adv-esr140.12+] → [client-bounty-form]
Flags: sec-bounty? → sec-bounty-
QA Whiteboard: [sec] [qa-triage-done-c154/b153]
Whiteboard: [client-bounty-form] → [client-bounty-form][adv-main153-]
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Creator:
Created:
Updated:
Size: