Skip to content

json.dumps() can use freed custom-mapping items during concurrent mutation #157106

Description

@Nievesjyl

Description

For a non-exact dictionary, _json iterates the list returned by items() with unchecked list access and increments each borrowed tuple without holding the list's critical section. Another thread can replace or remove that tuple between acquisition and ownership promotion.

Observed Behavior

On a free-threaded ASan build, racing JSON encoding with mutation of a custom mapping's shared items list produces a null SEGV after encoder_listencode_dict() passes stale key or value data to nested encoding.

Affected Version

CPython 3.14.7 at commit 823f0323ee6ec1402088b73bce1a38473cac36dc, built with --disable-gil --with-address-sanitizer and run with PYTHON_GIL=0.

Reproduction

Run the stress reproducer; the schedule is probabilistic:

ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 PYTHON_GIL=0 python3.14 poc/reproduce.py --seconds 10

PoC Source Code

poc/reproduce.py:

#!/usr/bin/env python3
"""Race JSON encoding against a shared custom-mapping items list."""

import argparse
import json
import threading
import time


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--seconds", type=float, default=10.0)
    args = parser.parse_args()

    shared_items = [(str(index), [index]) for index in range(512)]

    class SharedMapping(dict):
        def items(self):
            return shared_items

    mapping = SharedMapping(seed=1)
    stop = threading.Event()
    counts = [0, 0]

    def encode() -> None:
        while not stop.is_set():
            try:
                json.dumps(mapping, check_circular=False)
            except (RuntimeError, ValueError, IndexError):
                pass
            counts[0] += 1

    def mutate() -> None:
        generation = 0
        while not stop.is_set():
            index = generation % 512
            shared_items[index] = (str(index), [generation])
            if generation % 4 == 0:
                shared_items.pop()
                shared_items.append(("tail", [generation + 1]))
            generation += 1
            counts[1] += 1

    threads = [threading.Thread(target=encode), threading.Thread(target=mutate)]
    for thread in threads:
        thread.start()
    time.sleep(args.seconds)
    stop.set()
    for thread in threads:
        thread.join()
    print({"encode": counts[0], "mutate": counts[1]})


if __name__ == "__main__":
    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