Description
With fix_imports enabled for older protocols, Unpickler.find_class() retrieves borrowed values from the public _compat_pickle.NAME_MAPPING and IMPORT_MAPPING dictionaries and uses them without locking or ownership promotion. Concurrent replacement can destroy the tuple, string, or tuple fields before import and attribute lookup.
Observed Behavior
On a free-threaded build, replacing entries with fresh but equal values makes both the name-remapping and import-remapping modes terminate with SIGSEGV in under 0.10 seconds. Both compatibility-GIL controls complete normally with the expected globals.
Affected Version
CPython 3.14.7 at commit 823f0323ee6ec1402088b73bce1a38473cac36dc, using the free-threaded build with PYTHON_GIL=0.
Reproduction
Run both modes:
PYTHON_GIL=0 python3.14t poc/reproduce.py 10 name
PYTHON_GIL=0 python3.14t poc/reproduce.py 10 import
PoC Source Code
poc/reproduce.py:
#!/usr/bin/env python3
"""Race Python-2 global remapping with public compatibility-table updates."""
import _compat_pickle
import pickle
import sys
import threading
import time
MODE = sys.argv[2] if len(sys.argv) > 2 else "name"
if MODE == "name":
TABLE = _compat_pickle.NAME_MAPPING
KEY = ("__builtin__", "xrange")
PAYLOAD = b"\x80\x02c__builtin__\nxrange\n."
EXPECTED = range
elif MODE == "import":
TABLE = _compat_pickle.IMPORT_MAPPING
KEY = "__builtin__"
PAYLOAD = b"\x80\x02c__builtin__\nlen\n."
EXPECTED = len
else:
raise ValueError("mode must be 'name' or 'import'")
ORIGINAL = TABLE[KEY]
STOP = threading.Event()
FAILURES = []
def mutate():
while not STOP.is_set():
if MODE == "name":
TABLE[KEY] = ("".join(("built", "ins")), "".join(("ra", "nge")))
else:
TABLE[KEY] = "".join(("built", "ins"))
def load_worker(deadline):
count = 0
try:
while time.monotonic() < deadline and not STOP.is_set():
result = pickle.loads(PAYLOAD)
if result is not EXPECTED:
raise AssertionError(result)
count += 1
except BaseException as exc:
FAILURES.append((type(exc).__name__, repr(exc), count))
STOP.set()
def main():
seconds = float(sys.argv[1]) if len(sys.argv) > 1 else 10.0
deadline = time.monotonic() + seconds
threads = [threading.Thread(target=mutate)]
threads += [threading.Thread(target=load_worker, args=(deadline,)) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads[1:]:
thread.join()
STOP.set()
threads[0].join()
TABLE[KEY] = ORIGINAL
print("mode=", MODE, "gil=", sys._is_gil_enabled(), "failures=", FAILURES)
return bool(FAILURES)
raise SystemExit(main())
Description
With
fix_importsenabled for older protocols,Unpickler.find_class()retrieves borrowed values from the public_compat_pickle.NAME_MAPPINGandIMPORT_MAPPINGdictionaries and uses them without locking or ownership promotion. Concurrent replacement can destroy the tuple, string, or tuple fields before import and attribute lookup.Observed Behavior
On a free-threaded build, replacing entries with fresh but equal values makes both the name-remapping and import-remapping modes terminate with SIGSEGV in under 0.10 seconds. Both compatibility-GIL controls complete normally with the expected globals.
Affected Version
CPython 3.14.7 at commit
823f0323ee6ec1402088b73bce1a38473cac36dc, using the free-threaded build withPYTHON_GIL=0.Reproduction
Run both modes:
PoC Source Code
poc/reproduce.py: