VYPR
High severityNVD Advisory· Published Sep 25, 2023· Updated Sep 24, 2024

yt-dlp command injection when using `%q` in `--exec` on Windows

CVE-2023-40581

Description

yt-dlp on Windows is vulnerable to remote code execution via shell command injection in the --exec flag's output template expansion with the %q conversion.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

yt-dlp on Windows is vulnerable to remote code execution via shell command injection in the --exec flag's output template expansion with the %q conversion.

Overview

CVE-2023-40581 is a command injection vulnerability in yt-dlp on Windows that allows remote code execution. The flaw exists in the --exec flag's output template expansion, specifically when the %q conversion is used to quote/escape metadata values. For the cmd shell used by Python's subprocess on Windows, the escaping does not properly handle special characters, enabling an attacker to inject arbitrary shell commands through maliciously crafted remote data [1][2].

Exploitation

An attacker can exploit this vulnerability by providing a video or metadata field that contains special characters such as double quotes, pipes, or ampersands, which bypass the insufficient escaping. If a user runs yt-dlp with --exec containing %q expansion on such content, the injected commands execute in the context of the user's shell. The attack requires no authentication beyond normal user access and works regardless of whether yt-dlp is invoked from cmd or PowerShell [2].

Impact

Successful exploitation grants the attacker arbitrary code execution on the Windows system. This can lead to data exfiltration, installation of malware, or full compromise of the affected machine. The vulnerability was introduced in yt-dlp version 2021.04.11 and affects all Windows users who use --exec with output template expansion [2].

Mitigation

The issue is fixed in yt-dlp version 2023.09.24, which replaces the shell escape function to use "" instead of \" and properly quotes commands [3][4]. Users are urged to update immediately. For those unable to upgrade, the advisory recommends avoiding output template expansion other than {} (filepath), verifying that fields used in --exec do not contain dangerous characters, or using JSON output and loading fields instead of using --exec [2].

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
yt-dlpPyPI
>= 2021.04.11, < 2023.09.242023.09.24

Affected products

2
  • ghsa-coords
    Range: >= 2021.04.11, < 2023.09.24
  • yt-dlp/yt-dlpv5
    Range: >= 2021.04.11, < 2023.09.24

Patches

1
de015e930747

[core] Prevent RCE when using `--exec` with `%q` (CVE-2023-40581)

https://github.com/yt-dlp/yt-dlpSimon SawickiSep 24, 2023via ghsa
6 files changed · +46 13
  • devscripts/changelog_override.json+5 0 modified
    @@ -93,5 +93,10 @@
             "action": "add",
             "when": "c1d71d0d9f41db5e4306c86af232f5f6220a130b",
             "short": "[priority] **The minimum *recommended* Python version has been raised to 3.8**\nSince Python 3.7 has reached end-of-life, support for it will be dropped soon. [Read more](https://github.com/yt-dlp/yt-dlp/issues/7803)"
    +    },
    +    {
    +        "action": "add",
    +        "when": "61bdf15fc7400601c3da1aa7a43917310a5bf391",
    +        "short": "[priority] Security: [[CVE-2023-40581](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-40581)] [Prevent RCE when using `--exec` with `%q` on Windows](https://github.com/yt-dlp/yt-dlp/security/advisories/GHSA-42h4-v29r-42qg)\n    - The shell escape function is now using `\"\"` instead of `\\\"`.\n    - `utils.Popen` has been patched to properly quote commands."
         }
     ]
    
  • test/test_utils.py+16 0 modified
    @@ -14,6 +14,7 @@
     import io
     import itertools
     import json
    +import subprocess
     import xml.etree.ElementTree
     
     from yt_dlp.compat import (
    @@ -28,6 +29,7 @@
         InAdvancePagedList,
         LazyList,
         OnDemandPagedList,
    +    Popen,
         age_restricted,
         args_to_str,
         base_url,
    @@ -2388,6 +2390,20 @@ def test_extract_basic_auth(self):
             assert extract_basic_auth('http://user:@foo.bar') == ('http://foo.bar', 'Basic dXNlcjo=')
             assert extract_basic_auth('http://user:pass@foo.bar') == ('http://foo.bar', 'Basic dXNlcjpwYXNz')
     
    +    @unittest.skipUnless(compat_os_name == 'nt', 'Only relevant on Windows')
    +    def test_Popen_windows_escaping(self):
    +        def run_shell(args):
    +            stdout, stderr, error = Popen.run(
    +                args, text=True, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    +            assert not stderr
    +            assert not error
    +            return stdout
    +
    +        # Test escaping
    +        assert run_shell(['echo', 'test"&']) == '"test""&"\n'
    +        # Test if delayed expansion is disabled
    +        assert run_shell(['echo', '^!']) == '"^!"\n'
    +        assert run_shell('echo "^!"') == '"^!"\n'
     
     if __name__ == '__main__':
         unittest.main()
    
  • test/test_YoutubeDL.py+3 3 modified
    @@ -784,9 +784,9 @@ def expect_same_infodict(out):
             test('%(title4)#S', 'foo_bar_test')
             test('%(title4).10S', ('foo "bar" ', 'foo "bar"' + ('#' if compat_os_name == 'nt' else ' ')))
             if compat_os_name == 'nt':
    -            test('%(title4)q', ('"foo \\"bar\\" test"', ""foo ⧹"bar⧹" test""))
    -            test('%(formats.:.id)#q', ('"id 1" "id 2" "id 3"', '"id 1" "id 2" "id 3"'))
    -            test('%(formats.0.id)#q', ('"id 1"', '"id 1"'))
    +            test('%(title4)q', ('"foo ""bar"" test"', None))
    +            test('%(formats.:.id)#q', ('"id 1" "id 2" "id 3"', None))
    +            test('%(formats.0.id)#q', ('"id 1"', None))
             else:
                 test('%(title4)q', ('\'foo "bar" test\'', '\'foo "bar" test\''))
                 test('%(formats.:.id)#q', "'id 1' 'id 2' 'id 3'")
    
  • yt_dlp/compat/__init__.py+1 1 modified
    @@ -30,7 +30,7 @@ def compat_etree_fromstring(text):
     if compat_os_name == 'nt':
         def compat_shlex_quote(s):
             import re
    -        return s if re.match(r'^[-_\w./]+$', s) else '"%s"' % s.replace('"', '\\"')
    +        return s if re.match(r'^[-_\w./]+$', s) else s.replace('"', '""').join('""')
     else:
         from shlex import quote as compat_shlex_quote  # noqa: F401
     
    
  • yt_dlp/postprocessor/exec.py+5 7 modified
    @@ -1,8 +1,6 @@
    -import subprocess
    -
     from .common import PostProcessor
     from ..compat import compat_shlex_quote
    -from ..utils import PostProcessingError, encodeArgument, variadic
    +from ..utils import Popen, PostProcessingError, variadic
     
     
     class ExecPP(PostProcessor):
    @@ -27,10 +25,10 @@ def parse_cmd(self, cmd, info):
         def run(self, info):
             for tmpl in self.exec_cmd:
                 cmd = self.parse_cmd(tmpl, info)
    -            self.to_screen('Executing command: %s' % cmd)
    -            retCode = subprocess.call(encodeArgument(cmd), shell=True)
    -            if retCode != 0:
    -                raise PostProcessingError('Command returned error code %d' % retCode)
    +            self.to_screen(f'Executing command: {cmd}')
    +            _, _, return_code = Popen.run(cmd, shell=True)
    +            if return_code != 0:
    +                raise PostProcessingError(f'Command returned error code {return_code}')
             return [], info
     
     
    
  • yt_dlp/utils/_utils.py+16 2 modified
    @@ -825,7 +825,7 @@ def _fix(key):
             _fix('LD_LIBRARY_PATH')  # Linux
             _fix('DYLD_LIBRARY_PATH')  # macOS
     
    -    def __init__(self, *args, env=None, text=False, **kwargs):
    +    def __init__(self, args, *remaining, env=None, text=False, shell=False, **kwargs):
             if env is None:
                 env = os.environ.copy()
             self._fix_pyinstaller_ld_path(env)
    @@ -835,7 +835,21 @@ def __init__(self, *args, env=None, text=False, **kwargs):
                 kwargs['universal_newlines'] = True  # For 3.6 compatibility
                 kwargs.setdefault('encoding', 'utf-8')
                 kwargs.setdefault('errors', 'replace')
    -        super().__init__(*args, env=env, **kwargs, startupinfo=self._startupinfo)
    +
    +        if shell and compat_os_name == 'nt' and kwargs.get('executable') is None:
    +            if not isinstance(args, str):
    +                args = ' '.join(compat_shlex_quote(a) for a in args)
    +            shell = False
    +            args = f'{self.__comspec()} /Q /S /D /V:OFF /C "{args}"'
    +
    +        super().__init__(args, *remaining, env=env, shell=shell, **kwargs, startupinfo=self._startupinfo)
    +
    +    def __comspec(self):
    +        comspec = os.environ.get('ComSpec') or os.path.join(
    +            os.environ.get('SystemRoot', ''), 'System32', 'cmd.exe')
    +        if os.path.isabs(comspec):
    +            return comspec
    +        raise FileNotFoundError('shell not found: neither %ComSpec% nor %SystemRoot% is set')
     
         def communicate_or_kill(self, *args, **kwargs):
             try:
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.