yt-dlp: Arbitrary code execution via manifest downloads with aria2c
Description
yt-dlp使用aria2c作为外部下载器处理HLS/DASH流时未充分净化输入,导致任意文件写入,可进而实现代码执行。
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
yt-dlp使用aria2c作为外部下载器处理HLS/DASH流时未充分净化输入,导致任意文件写入,可进而实现代码执行。
Vulnerability
CVE-2026-50574存在于yt-dlp中,当用户通过--downloader aria2c选项使用aria2c作为外部下载器下载HLS或DASH分段流时,yt-dlp会从清单中提取片段URL并构建一个aria2c输入文件(通过-i选项传递)。该输入文件支持每URI后的配置行(以空白开头),yt-dlp使用out=选项指定输出文件名。然而,yt-dlp未能充分净化片段URL中的特殊字符,使得攻击者可以注入任意aria2c选项。此漏洞影响所有使用aria2c下载HLS/DASH格式的yt-dlp版本,直至2026年6月9日发布的修复版本[1][2][3]。
Exploitation
攻击者需要诱导用户使用aria2c下载一个恶意的DASH或HLS清单。有两种已知攻击向量:1) 在DASH清单的片段URL中嵌入HTML转义序列 (代表换行符),yt-dlp将其解释为实际换行符,从而注入额外aria2c选项;2) 通过out=选项指定包含路径遍历(如../)的输出文件名,实现任意路径写入[4]。攻击者无需认证,仅需控制清单内容即可。
Impact
成功利用该漏洞可实现任意文件写入。在Windows平台上,写入的文件(如.exe、.dll或脚本)可能被立即执行,导致任意代码执行。在非Windows平台上,攻击者可通过写入yt-dlp配置文件或启动脚本,在下次调用yt-dlp时触发代码执行[4]。漏洞影响文件的机密性、完整性和可用性。
Mitigation
yt-dlp在2026年6月9日发布的稳定版(2026.06.09)和同日发布的nightly构建(2026.06.09.230517)中修复了此漏洞,具体措施是移除了对HLS和DASH格式的aria2c外部下载器支持[2][3]。修复提交为25056f0[1]。建议所有用户立即升级到上述版本,并将下载方式切换为yt-dlp内置的并发片段下载器(使用-N选项)[2][3]。
AI Insight generated on Jun 16, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
125056f0d2d47[fd/external] `aria2c`: Remove support for m3u8/dash protocols
2 files changed · +12 −134
README.md+1 −3 modified@@ -2272,8 +2272,6 @@ with yt_dlp.YoutubeDL(ydl_opts) as ydl: * **Multi-threaded fragment downloads**: Download multiple fragments of m3u8/mpd videos in parallel. Use `--concurrent-fragments` (`-N`) option to set the number of threads used -* **Aria2c with HLS/DASH**: You can use `aria2c` as the external downloader for DASH(mpd) and HLS(m3u8) formats - * **New and fixed extractors**: Many new extractors have been added and a lot of existing ones have been fixed. See the [changelog](Changelog.md) or the [list of supported sites](supportedsites.md) * **New MSOs**: Philo, Spectrum, SlingTV, Cablevision, RCN etc. @@ -2328,7 +2326,7 @@ Some of yt-dlp's default options are different from that of youtube-dl and youtu * When `--embed-subs` and `--write-subs` are used together, the subtitles are written to disk and also embedded in the media file. You can use just `--embed-subs` to embed the subs and automatically delete the separate file. See [#630 (comment)](https://github.com/yt-dlp/yt-dlp/issues/630#issuecomment-893659460) for more info. `--compat-options no-keep-subs` can be used to revert this * `certifi` will be used for SSL root certificates, if installed. If you want to use system certificates (e.g. self-signed), use `--compat-options no-certifi` * yt-dlp's sanitization of invalid characters in filenames is different/smarter than in youtube-dl. You can use `--compat-options filename-sanitization` to revert to youtube-dl's behavior -* ~~yt-dlp tries to parse the external downloader outputs into the standard progress output if possible (Currently implemented: [aria2c](https://github.com/yt-dlp/yt-dlp/issues/5931)). You can use `--compat-options no-external-downloader-progress` to get the downloader output as-is~~ +* (Not currently implemented) ~~yt-dlp tries to parse the external downloader outputs into the standard progress output if possible. You can use `--compat-options no-external-downloader-progress` to get the downloader output as-is~~ * yt-dlp versions from 2021.09.01 to 2022.11.11 (inclusive) applied `--match-filters` to nested playlists. This was an unintentional side-effect of [8f18ac](https://github.com/yt-dlp/yt-dlp/commit/8f18aca8717bb0dd49054555af8d386e5eda3a88) and is fixed in [d7b460](https://github.com/yt-dlp/yt-dlp/commit/d7b460d0e5fc710950582baed2e3fc616ed98a80). Use `--compat-options playlist-match-filter` to revert this * yt-dlp versions from 2021.11.10 to 2023.06.21 (inclusive) estimated `filesize_approx` values for fragmented/manifest formats. This was added for convenience in [f2fe69](https://github.com/yt-dlp/yt-dlp/commit/f2fe69c7b0d208bdb1f6292b4ae92bc1e1a7444a), but was reverted in [0dff8e](https://github.com/yt-dlp/yt-dlp/commit/0dff8e4d1e6e9fb938f4256ea9af7d81f42fd54f) due to the potentially extreme inaccuracy of the estimated values. Use `--compat-options manifest-filesize-approx` to keep extracting the estimated values * yt-dlp uses modern http client backends such as `requests`. Use `--compat-options prefer-legacy-http-handler` to prefer the legacy http handler (`urllib`) to be used for standard http requests.
yt_dlp/downloader/external.py+11 −131 modified@@ -1,17 +1,14 @@ import enum import functools import io -import json import os import re import subprocess import sys import tempfile import time -import uuid from .fragment import FragmentFD -from ..networking import Request from ..postprocessor.ffmpeg import EXT_TO_OUT_FORMATS, FFmpegPostProcessor from ..utils import ( Popen, @@ -25,7 +22,6 @@ cli_valueless_option, determine_ext, encodeArgument, - find_available_port, remove_end, traverse_obj, version_tuple, @@ -309,38 +305,17 @@ def _make_cmd(self, tmpfilename, info_dict): class Aria2cFD(ExternalFD): AVAILABLE_OPT = '-v' - SUPPORTED_PROTOCOLS = ('http', 'https', 'ftp', 'ftps', 'dash_frag_urls', 'm3u8_frag_urls') - - @staticmethod - def supports_manifest(manifest): - UNSUPPORTED_FEATURES = [ - r'#EXT-X-BYTERANGE', # playlists composed of byte ranges of media files [1] - # 1. https://tools.ietf.org/html/draft-pantos-http-live-streaming-17#section-4.3.2.2 - ] - check_results = (not re.search(feature, manifest) for feature in UNSUPPORTED_FEATURES) - return all(check_results) + SUPPORTED_PROTOCOLS = ('http', 'https', 'ftp', 'ftps') @staticmethod def _aria2c_filename(fn): return fn if os.path.isabs(fn) else f'.{os.path.sep}{fn}' - def _call_downloader(self, tmpfilename, info_dict): - # FIXME: Disabled due to https://github.com/yt-dlp/yt-dlp/issues/5931 - if False and 'no-external-downloader-progress' not in self.params.get('compat_opts', []): - info_dict['__rpc'] = { - 'port': find_available_port() or 19190, - 'secret': str(uuid.uuid4()), - } - return super()._call_downloader(tmpfilename, info_dict) - def _make_cmd(self, tmpfilename, info_dict): cmd = [self.exe, '-c', '--no-conf', '--console-log-level=warn', '--summary-interval=0', '--download-result=hide', - '--http-accept-gzip=true', '--file-allocation=none', '-x16', '-j16', '-s16'] - if 'fragments' in info_dict: - cmd += ['--allow-overwrite=true', '--allow-piece-length-change=true'] - else: - cmd += ['--min-split-size', '1M'] + '--http-accept-gzip=true', '--file-allocation=none', '-x16', '-j16', '-s16', + '--min-split-size', '1M'] cmd += [f'--load-cookies={self._write_cookies()}'] if info_dict.get('http_headers') is not None: @@ -354,12 +329,6 @@ def _make_cmd(self, tmpfilename, info_dict): cmd += self._bool_option('--show-console-readout', 'noprogress', 'false', 'true', '=') cmd += self._configuration_args() - if '__rpc' in info_dict: - cmd += [ - '--enable-rpc', - f'--rpc-listen-port={info_dict["__rpc"]["port"]}', - f'--rpc-secret={info_dict["__rpc"]["secret"]}'] - # aria2c strips out spaces from the beginning/end of filenames and paths. # We work around this issue by adding a "./" to the beginning of the # filename and relative path, and adding a "/" at the end of the path. @@ -369,105 +338,16 @@ def _make_cmd(self, tmpfilename, info_dict): dn = os.path.dirname(tmpfilename) if dn: cmd += ['--dir', self._aria2c_filename(dn) + os.path.sep] - if 'fragments' not in info_dict: - cmd += ['--out', self._aria2c_filename(os.path.basename(tmpfilename))] - cmd += ['--auto-file-renaming=false'] - - if 'fragments' in info_dict: - cmd += ['--uri-selector=inorder'] - url_list_file = f'{tmpfilename}.frag.urls' - url_list = [] - for frag_index, fragment in enumerate(info_dict['fragments']): - fragment_filename = f'{os.path.basename(tmpfilename)}-Frag{frag_index}' - url_list.append('{}\n\tout={}'.format(fragment['url'], self._aria2c_filename(fragment_filename))) - stream, _ = self.sanitize_open(url_list_file, 'wb') - stream.write('\n'.join(url_list).encode()) - stream.close() - cmd += ['-i', self._aria2c_filename(url_list_file)] - else: - cmd += ['--', info_dict['url']] - return cmd - - def aria2c_rpc(self, rpc_port, rpc_secret, method, params=()): - # Does not actually need to be UUID, just unique - sanitycheck = str(uuid.uuid4()) - d = json.dumps({ - 'jsonrpc': '2.0', - 'id': sanitycheck, - 'method': method, - 'params': [f'token:{rpc_secret}', *params], - }).encode() - request = Request( - f'http://localhost:{rpc_port}/jsonrpc', - data=d, headers={ - 'Content-Type': 'application/json', - 'Content-Length': f'{len(d)}', - }, proxies={'all': None}) - with self.ydl.urlopen(request) as r: - resp = json.load(r) - assert resp.get('id') == sanitycheck, 'Something went wrong with RPC server' - return resp['result'] - - def _call_process(self, cmd, info_dict): - if '__rpc' not in info_dict: - return super()._call_process(cmd, info_dict) - - send_rpc = functools.partial(self.aria2c_rpc, info_dict['__rpc']['port'], info_dict['__rpc']['secret']) - started = time.time() - - fragmented = 'fragments' in info_dict - frag_count = len(info_dict['fragments']) if fragmented else 1 - status = { - 'filename': info_dict.get('_filename'), - 'status': 'downloading', - 'elapsed': 0, - 'downloaded_bytes': 0, - 'fragment_count': frag_count if fragmented else None, - 'fragment_index': 0 if fragmented else None, - } - self._hook_progress(status, info_dict) - - def get_stat(key, *obj, average=False): - val = tuple(filter(None, map(float, traverse_obj(obj, (..., ..., key))))) or [0] - return sum(val) / (len(val) if average else 1) - - with Popen(cmd, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) as p: - # Add a small sleep so that RPC client can receive response, - # or the connection stalls infinitely - time.sleep(0.2) - retval = p.poll() - while retval is None: - # We don't use tellStatus as we won't know the GID without reading stdout - # Ref: https://aria2.github.io/manual/en/html/aria2c.html#aria2.tellActive - active = send_rpc('aria2.tellActive') - completed = send_rpc('aria2.tellStopped', [0, frag_count]) - - downloaded = get_stat('totalLength', completed) + get_stat('completedLength', active) - speed = get_stat('downloadSpeed', active) - total = frag_count * get_stat('totalLength', active, completed, average=True) - if total < downloaded: - total = None - - status.update({ - 'downloaded_bytes': int(downloaded), - 'speed': speed, - 'total_bytes': None if fragmented else total, - 'total_bytes_estimate': total, - 'eta': (total - downloaded) / (speed or 1), - 'fragment_index': min(frag_count, len(completed) + 1) if fragmented else None, - 'elapsed': time.time() - started, - }) - self._hook_progress(status, info_dict) - - if not active and len(completed) >= frag_count: - send_rpc('aria2.shutdown') - retval = p.wait() - break - time.sleep(0.1) - retval = p.poll() + cmd += [ + '--out', + self._aria2c_filename(os.path.basename(tmpfilename)), + '--auto-file-renaming=false', + '--', + info_dict['url'], + ] - return '', p.stderr.read(), retval + return cmd class HttpieFD(ExternalFD):
Vulnerability mechanics
Root cause
"yt-dlp writes fragment URLs from DASH/HLS manifests into an aria2c input file without sanitizing newline characters, allowing injection of arbitrary aria2c options."
Attack vector
An attacker can craft a malicious DASH manifest containing fragment URLs with ` ` (HTML newline escape). When yt-dlp parses the XML manifest, it unescapes these sequences into actual newlines, which are then written verbatim into the aria2c input file. This allows the attacker to inject arbitrary aria2c options (such as `out=` paths) and achieve arbitrary file writes. On Windows, writing a malicious `ffmpeg.exe` to the working directory can lead to immediate code execution during postprocessing. On all platforms, writing a `yt-dlp.conf` file with a malicious `--exec` argument can lead to code execution on the next yt-dlp invocation.
Affected code
The vulnerability resides in `yt_dlp/downloader/external.py` in the `Aria2cFD._make_cmd` method. When downloading fragmented manifests (HLS/DASH), yt-dlp constructed an aria2c input file by joining fragment URLs with newlines and `out=` options, without sanitizing the fragment URLs for embedded newline characters (` `). The patch removes all fragment-related code from `Aria2cFD`, including the `SUPPORTED_PROTOCOLS` entries for `dash_frag_urls` and `m3u8_frag_urls`, and the entire input-file generation logic.
What the fix does
The patch removes all support for downloading fragmented manifest formats (HLS/DASH) with aria2c. Specifically, it deletes the `dash_frag_urls` and `m3u8_frag_urls` entries from `SUPPORTED_PROTOCOLS`, removes the `supports_manifest` static method, and eliminates the entire code path that constructed an aria2c input file from fragment URLs. Instead, aria2c now only receives a single URL via `info_dict['url']` and a fixed output filename. This closes both injection vectors because the attacker-controlled fragment URLs are no longer written to an aria2c input file at all.
Preconditions
- configUser must have selected aria2c as the external downloader for fragmented formats (e.g., via `--downloader aria2c`).
- inputFor the DASH vector, the attacker must control a DASH manifest that yt-dlp processes (e.g., by hosting a malicious stream).
- inputFor the metadata vector, the attacker must control metadata fields (e.g., `title`) that contain newlines, and the user must have passed `--no-windows-filename`.
Generated on Jun 16, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-vx4q-3cr2-7cg2ghsaADVISORY
- github.com/yt-dlp/yt-dlp-nightly-builds/releases/tag/2026.06.09.230517ghsa
- github.com/yt-dlp/yt-dlp/commit/25056f0d2d47adbd235a8d422fa62d68d0be2bc2ghsa
- github.com/yt-dlp/yt-dlp/releases/tag/2026.06.09ghsa
- github.com/yt-dlp/yt-dlp/security/advisories/GHSA-vx4q-3cr2-7cg2ghsa
News mentions
0No linked articles in our index yet.