Oct 24, 2026·6 min read·2 visits
virtualenv < 21.14.9 fails to validate path boundaries when parsing seed metadata during environment re-creation, allowing local malicious metadata to trigger arbitrary file deletion.
A path traversal vulnerability in PyPA virtualenv prior to version 21.14.9 allows local attackers to cause arbitrary file and directory deletion when a virtual environment target directory containing untrusted distribution metadata is reused or reseeded.
virtualenv is the standard library and command-line package used across the Python ecosystem to construct isolated Python environments. During environment instantiation, virtualenv utilizes seed mechanisms—predominantly the embed app-data seeder—to populate target environments with base utilities such as pip, setuptools, and wheel.
Prior to version 21.14.9, when virtualenv is directed to create or reseed an environment at a target destination directory (dest) that already exists, it performs a cleanup routine to remove pre-existing seed distributions before copying new ones. This uninstallation logic inspects existing .dist-info metadata folders inside site-packages to identify which files and package subdirectories must be removed.
When processing package metadata files such as top_level.txt and RECORD, virtualenv failed to verify that paths derived from these files remained within the boundary of the virtual environment destination. If a target directory contains metadata crafted with directory traversal elements (..), absolute paths, or symbolic links, virtualenv processes those paths and executes recursive file deletion routines outside the environment boundary under the execution rights of the invoking user.
The root cause of CVE-2026-113719 lies in the static method PipInstall._uninstall_dist() within virtualenv.seed.embed.via_app_data.pip_install.base. When uninstallation of a pre-existing package distribution is required, _uninstall_dist resolves the parent directory of the distribution metadata (dist.parent, which corresponds to site-packages) and reads metadata files to build a list of target paths to delete.
The routine reads top_level.txt line-by-line and appends each line directly to dist.parent. It then parses the RECORD CSV file, concatenates each relative entry with dist_base, and aggregates all derived target Path objects into a set. At no point during path collection or iteration did the code verify that the target paths were contained within the environment root (self._creator.dest).
Additionally, edge cases in metadata formatting aggravated the issue. A blank line within top_level.txt resolved to dist.parent (site-packages), causing virtualenv to attempt deletion of the entire package directory. Relative path entries beginning with sequence constructs like ../../ allowed deletion of files outside the virtual environment tree, including system files or user data files to which the executing user had write permissions.
In patched version 21.14.9, _uninstall_previous_version() passes self._creator.dest into _uninstall_dist(dist, env_dir) to enforce target directory containment. The fixed code canonicalizes the environment target directory as root = env_dir.resolve() and explicitly validates every candidate path before executing unlinking operations.
# Vulnerable Implementation (virtualenv < 21.14.9)
@staticmethod
def _uninstall_dist(dist: Path) -> None:
dist_base = dist.parent
LOGGER.debug("uninstall existing distribution %s from %s", dist.stem, dist_base)
top_txt = dist / "top_level.txt"
paths = (
{dist.parent / i.strip() for i in top_txt.read_text(encoding="utf-8").splitlines()}
if top_txt.exists()
else set()
)
paths.add(dist)
base_dirs, record = paths.copy(), dist / "RECORD"
for name in (
(i.split(",")[0] for i in record.read_text(encoding="utf-8").splitlines()) if record.exists() else ()
):
path = dist_base / name
if not any(p in base_dirs for p in path.parents):
paths.add(path)
for path in sorted(paths):
if path.exists():
if path.is_dir() and not path.is_symlink():
safe_delete(path)
else:
path.unlink()The fix introduces concrete boundary checks using Path.resolve() and checks parent relationships against root:
# Fixed Implementation (virtualenv 21.14.9)
@staticmethod
def _uninstall_dist(dist: Path, env_dir: Path) -> None:
dist_base: Final = dist.parent
# ... path collection logic ...
root: Final = env_dir.resolve()
metadata_parents: Final = set(dist.resolve().parents)
for entry in sorted(paths):
if entry.exists() or entry.is_symlink():
linked: Final = entry.is_symlink()
# Do not follow symlinks for boundary calculation
resolved: Final = entry.parent.resolve() / entry.name if linked else entry.resolve()
if root not in resolved.parents or resolved in metadata_parents:
LOGGER.warning(
"skip removal of %s listed by %s: not inside environment %s or a parent of the metadata",
entry,
dist.name,
root,
)
elif entry.is_dir() and not linked:
safe_delete(entry)
else:
entry.unlink()The fix completely mitigates the flaw by checking that root in resolved.parents and verifying that candidate entries are not parents of the .dist-info metadata folder itself. Symlink handling ensures that unlinking a symlink removes only the link without traversing into arbitrary target destinations.
Exploitation relies on local pre-conditions where a target directory is shared, reused, or pre-populated by a lower-privileged local attacker. Consider a multi-user environment or shared build runner where directory /tmp/shared_env is reused across pipeline jobs or user sessions without issuing the --clear flag.
An attacker with write permissions to /tmp/shared_env creates a fake distribution metadata path such as /tmp/shared_env/lib/python3.10/site-packages/pip-23.0.dist-info/RECORD. Inside this RECORD file, the attacker embeds entries containing traversal relative paths, such as ../../../../home/victim/.ssh/authorized_keys,sha256=...,100.
When a victim executes virtualenv /tmp/shared_env, the via_app_data seeder invokes _uninstall_previous_version(). The seeder parses the injected RECORD file, constructs the path /tmp/shared_env/lib/python3.10/site-packages/../../../../home/victim/.ssh/authorized_keys, and invokes entry.unlink(). The operation succeeds using the victim's privileges, resulting in target file deletion.
The severity of CVE-2026-113719 is rated Medium with a CVSS v3.1 base score of 4.5 (CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L). The primary security impact is local arbitrary file and directory deletion. While the vulnerability does not directly grant remote code execution or privilege escalation, arbitrary file deletion can lead to denial of service or indirect system compromise if critical configuration files or authentication credentials are destroyed.
Exploitation requires specific conditions: local system access, write access to a target directory prior to virtualenv execution, and reuse of that environment path without the --clear parameter. Execution in clean, non-existent directories or invocations with --clear are unaffected because pre-existing metadata cleanup is bypassed.
Because deletion occurs with the identity and privileges of the user running virtualenv, running virtualenv as a privileged user (such as root or inside CI/CD containers with elevated rights) increases the blast radius of potential file destruction.
To resolve CVE-2026-113719, update virtualenv to version 21.14.9 or higher. The fixed version enforces canonical boundary checks on all seed package metadata entries.
In environments where upgrading virtualenv immediately is not feasible, implement operational controls:
--clear flag when re-creating or reusing existing virtual environment paths (virtualenv --clear /path/to/env).virtualenv against world-writable or shared directories such as /tmp or shared group workspaces where untrusted local accounts can manipulate directory structures.0700) owned exclusively by the target executing user.CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L| Product | Affected Versions | Fixed Version |
|---|---|---|
virtualenv PyPA | < 21.14.9 | 21.14.9 |
| Attribute | Detail |
|---|---|
| Vulnerability ID | CVE-2026-113719 |
| CWE ID | CWE-22 |
| CVSS v3.1 Score | 4.5 (Medium) |
| CVSS Vector | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:L |
| EPSS Score | 0.00117 (1.57th percentile) |
| Impact | Arbitrary File Deletion |
| Exploit Status | Proof of Concept / Local Pre-condition |
| CISA KEV Status | Not Listed |
The software uses external input to construct a pathname that should be restricted to a directory within a restricted parent directory, but does not properly sanitize path elements that resolve outside the target path.
In virtualenv versions prior to 21.14.9, creating isolated Python virtual environments using the standard library creator (`--creator venv`) allows configuration key injection into `pyvenv.cfg` if an attacker can control or influence the destination directory path containing Unicode line break characters.
A command injection vulnerability in PyPA virtualenv prior to version 21.14.9 allows local attackers to execute arbitrary commands when a user sources a generated shell activation script containing a crafted prompt value.
virtualenv prior to version 21.14.9 contains a local permission inheritance vulnerability in its app-data copy seeder. Due to the reliance on Python's standard shutil.copy() function, permissions from cached packages are replicated directly onto newly created virtual environment files, ignoring the active process umask and allowing unprivileged local attackers to inject code.
A code injection vulnerability in pypa/virtualenv prior to version 21.14.9 allows arbitrary command execution in xonsh shells using legacy multibyte encodings when sourcing activate.xsh.
Prior to version 21.14.9, virtualenv downloads wheel files directly into its reusable persistent cache directory prior to verifying PyPI digests or compatibility. Combined with unsanitized stdout parsing from pip, a controlled package index can cause virtualenv to calculate digests against pre-existing local files while leaving unverified archives in the shared persistent cache.
A verification bypass vulnerability in virtualenv allows unverified Python wheel installation when using pip seeding with the download flag. Affected versions fail to perform SHA-256 digest validation against PyPI metadata before invoking pip.