Skip to content

Non-contiguous memoryview.hex() can copy released exporter storage #157113

Description

@Nievesjyl

Description

The non-contiguous path in memoryview.hex() copies through the view's buffer, shape, and strides without acquiring an export lease. Another thread can release the view and resize its bytearray exporter while PyBuffer_ToContiguous() still uses the captured buffer descriptor.

Observed Behavior

On a free-threaded ASan/debug build, the first round successfully returns mixed-generation output: only 2,048 of 67,108,864 expected bytes retain the original value. The GIL-enabled control completes normally with complete original-generation output.

Affected Version

CPython 3.14.7 at commit 823f0323ee6ec1402088b73bce1a38473cac36dc, built with --disable-gil --with-pydebug --with-address-sanitizer.

Reproduction

Run:

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

The oracle accepts a release-before-check ValueError or a complete old-generation result, and rejects short or mixed successful output.

PoC Source Code

poc/reproduce.py:

#!/usr/bin/env python3
"""Race non-contiguous memoryview.hex() against release and exporter reuse."""

import argparse
import threading
import time


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--rounds", type=int, default=20)
    parser.add_argument("--size", type=int, default=128 * 1024 * 1024)
    args = parser.parse_args()

    outcomes = {"hexed": 0, "released": 0, "resize_blocked": 0}
    for round_no in range(args.rounds):
        exporter = bytearray(b"\x11") * args.size
        view = memoryview(exporter)[::2]
        expected = args.size // 2
        start = threading.Barrier(2)
        errors: list[tuple[object, ...]] = []

        def reader() -> None:
            start.wait()
            try:
                value = view.hex()
                if len(value) != expected * 2:
                    errors.append(("short", len(value)))
                else:
                    original = value.count("11")
                    if original != expected:
                        errors.append(("mixed", original, expected))
                outcomes["hexed"] += 1
            except ValueError:
                outcomes["released"] += 1

        def releaser() -> None:
            start.wait()
            time.sleep(0.0001)
            view.release()
            try:
                exporter.clear()
                exporter.extend(b"\xee" * args.size)
            except BufferError:
                outcomes["resize_blocked"] += 1

        copy_thread = threading.Thread(target=reader, name="hex-view")
        release_thread = threading.Thread(target=releaser, name="release-view")
        copy_thread.start()
        release_thread.start()
        copy_thread.join()
        release_thread.join()

        if errors:
            raise AssertionError((round_no, errors))
        print(round_no, outcomes, flush=True)


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