Skip to content

Unpickler.find_class() can use freed compatibility-mapping values #157122

Description

@Nievesjyl

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())

Activity

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions