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
Custom Command set as default download mode (default: off)
"Configure before download" dialog disabled (default: on, i.e. safe by default)
- 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.
- Fresh install, enable Settings → General → Custom Command as default mode, disable "Configure before download".
- 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.)
- 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.
Summary
QuickDownloadActivityis exported (android:exported="true") and handlesACTION_VIEWintents from any other app on the device (seeAndroidManifest.xml, lines 53–77). When it receives anACTION_VIEWintent, it storesintent.dataStringverbatim intourl(QuickDownloadActivity.kt:44-46), with no validation — unlike theACTION_SENDpath, which runs the shared text throughmatchUrlFromSharedText()(a stricthttp(s)://-anchored regex, seeTextUtil.kt:79-103).If the user has configured:
then
QuickDownloadActivity.onCreate()callsonDownloadStarted(customCommand = true)immediately, with no user interaction (QuickDownloadActivity.kt:99-108), which callsDownloader.executeCommandWithUrl(url)→DownloadUtil.executeCommandInBackground(url).In
executeCommandInBackground()(DownloadUtil.kt:710-743), line 718:urlis split on spaces/newlines into separate tokens, which are passed toYoutubeDLRequest(urls: List<String>). I checked the underlyingyoutubedl-androidlibrary (io.github.junkfood02.youtubedl-android0.17.1,YoutubeDLRequest.kt:12-14andYoutubeDL.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 viaProcessBuilder(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_VIEWintent to an exported activity) can supply adataStringsuch ashttps://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.--execis 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/--outputfor writing files to arbitrary paths within reach of the app's storage permissions).Preconditions
Custom Commandset as default download mode (default: off)"Configure before download"dialog disabled (default: on, i.e. safe by default)--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
QuickDownloadActivityis 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 usesYoutubeDLRequest(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
intent.dataStringinQuickDownloadActivity'sACTION_VIEWhandler the same way theACTION_SENDpath already does viamatchUrlFromSharedText()(reject/strip anything that isn't a well-formedhttp(s)://URL) before it ever reachesexecuteCommandWithUrl().executeCommandInBackground(), reject tokens that start with-before they are added tourlList, or better: only ever pass a single validated URL string as a positional arg (mirroring the safe general-mode behavior).Reproduction
Tag
v1.13.1, commita2d7e5d5819d0aa8b4f5dbb906731da283a28ed1.adb shell(simulating a third-party app, no special permissions needed):Intent.setData.)--execargument 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/
ProcessBuilderbehavior were both verified by reading the source at the pinned commit/version above (Sealv1.13.1,youtubedl-android0.17.1). This is related to #2520 (missingandroid:permissionon the exported activities) but adds the concrete argument-injection chain and a fix specific to the custom-command path.