Skip to content

Argument injection into yt-dlp via exported QuickDownloadActivity when Custom Command mode is enabled (relates to #2520) #2585

Description

@MiMoHo

Summary

QuickDownloadActivity is exported (android:exported="true") and handles ACTION_VIEW intents from any other app on the device (see AndroidManifest.xml, lines 53–77). When it receives an ACTION_VIEW intent, it stores intent.dataString verbatim into url (QuickDownloadActivity.kt:44-46), with no validation — unlike the ACTION_SEND path, which runs the shared text through matchUrlFromSharedText() (a strict http(s)://-anchored regex, see TextUtil.kt:79-103).

If the user has configured:

  • Settings → General → default download mode = Custom Command, and
  • Settings → General → "Configure before download" dialog = disabled,

then QuickDownloadActivity.onCreate() calls onDownloadStarted(customCommand = true) immediately, with no user interaction (QuickDownloadActivity.kt:99-108), which calls Downloader.executeCommandWithUrl(url)DownloadUtil.executeCommandInBackground(url).

In executeCommandInBackground() (DownloadUtil.kt:710-743), line 718:

val urlList = url.split(Regex("[\n ]")).filter { it.isNotBlank() }
...
val request = YoutubeDLRequest(urlList).apply { ... }

url is split on spaces/newlines into separate tokens, which are passed to YoutubeDLRequest(urls: List<String>). I checked the underlying youtubedl-android library (io.github.junkfood02.youtubedl-android 0.17.1, YoutubeDLRequest.kt:12-14 and YoutubeDL.kt:169-174): each element of this list becomes a separate argv entry, appended at the end of the final command, and the command is executed via ProcessBuilder(command) — a real subprocess without a shell. So classic shell metacharacter injection (;, `, $()) is not possible, but any token starting with - is parsed by yt-dlp's own argparse as a CLI option, regardless of its position in argv.

Impact

A third-party app installed on the same device (no special permission required — any app can send an ACTION_VIEW intent to an exported activity) can supply a dataString such as https://example.com/x --exec <cmd>. With the settings above enabled, this string is split into ["https://example.com/x", "--exec", "<cmd>"] and appended to the yt-dlp invocation. --exec is a documented yt-dlp option that runs a shell command after the download completes — meaning command execution in the app's process/sandbox context is possible, without any confirmation dialog, triggered purely by an external Intent. Other flags could equally be injected this way (e.g. -o/--output for writing files to arbitrary paths within reach of the app's storage permissions).

Preconditions

  1. Custom Command set as default download mode (default: off)
  2. "Configure before download" dialog disabled (default: on, i.e. safe by default)
  3. At least one command template exists — this is always true in practice, since Seal auto-creates a sample template (--no-mtime -S "ext") on first launch if none exists (DatabaseUtil.kt:33, PreferenceUtil.kt:311-321, PreferenceUtil.kt:161).

So while the out-of-the-box defaults are safe, this is a realistic configuration for the exact "power user" audience the Custom Command feature targets, and QuickDownloadActivity is itself a documented, advertised entry point (share-target / "Quick Download" from other apps/links) — not an accidental unused export.

Note: the equivalent general-mode path (Downloader.quickDownload()DownloadUtil.fetchVideoInfoFromUrl()) is not affected, because it uses YoutubeDLRequest(url: String) (single-string constructor), which keeps the whole string as one argv element. The issue is specific to the .split(Regex("[\n ]")) call in the custom-command path.

Suggested fix directions

  • Validate intent.dataString in QuickDownloadActivity's ACTION_VIEW handler the same way the ACTION_SEND path already does via matchUrlFromSharedText() (reject/strip anything that isn't a well-formed http(s):// URL) before it ever reaches executeCommandWithUrl().
  • In executeCommandInBackground(), reject tokens that start with - before they are added to urlList, or better: only ever pass a single validated URL string as a positional arg (mirroring the safe general-mode behavior).
  • Consider requiring the confirmation dialog (or at minimum a one-time warning) specifically when Custom Command + no-confirm are combined with an externally-triggered (non-in-app) URL source.

Reproduction

Tag v1.13.1, commit a2d7e5d5819d0aa8b4f5dbb906731da283a28ed1.

  1. Fresh install, enable Settings → General → Custom Command as default mode, disable "Configure before download".
  2. From adb shell (simulating a third-party app, no special permissions needed):
    adb shell am start -a android.intent.action.VIEW \
      -d "https://example.com/x --exec touch%20/sdcard/Download/seal_poc" \
      -n com.junkfood.seal/.QuickDownloadActivity
    
    (URL-encode the space, or send via a real app using Intent.setData.)
  3. Observe: Seal starts a custom-command download with no confirmation dialog; the injected --exec argument runs after the yt-dlp process completes.

This was found via static analysis (no live build executed); the code path and the underlying library's argv/ProcessBuilder behavior were both verified by reading the source at the pinned commit/version above (Seal v1.13.1, youtubedl-android 0.17.1). This is related to #2520 (missing android:permission on the exported activities) but adds the concrete argument-injection chain and a fix specific to the custom-command path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions