Skip to content

memoryview.tobytes() can copy released exporter storage #157114

Description

@Nievesjyl

Description

memoryview.tobytes() checks that a view is not released and then copies through its private buffer snapshot without acquiring an export lease. Another thread can release the view and resize its bytearray exporter while PyBuffer_ToContiguous() still reads the captured address.

Observed Behavior

On a free-threaded ASan build, the first round returns a 512 MiB mixed-generation result with only 4,096 bytes retaining the original value. The GIL-enabled ASan control completes five rounds with complete original-generation copies.

Affected Version

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

Reproduction

Run:

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

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

PoC Source Code

poc/reproduce.py:

#!/usr/bin/env python3
"""Race memoryview.tobytes() 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=512 * 1024 * 1024)
    args = parser.parse_args()

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

        def copy_view() -> None:
            start.wait()
            try:
                data = view.tobytes()
                if len(data) != args.size:
                    errors.append(("short-copy", len(data)))
                else:
                    unchanged = data.count(0x11)
                    if unchanged != args.size:
                        errors.append(("corrupt-copy", unchanged))
                outcomes["copied"] += 1
            except ValueError:
                outcomes["released"] += 1

        def release_and_resize() -> None:
            start.wait()
            # Widen the chance that the reader has passed CHECK_RELEASED and is
            # copying through VIEW_ADDR(self) before this thread releases it.
            time.sleep(0.0001)
            view.release()
            try:
                exporter.clear()
                exporter.extend(b"\xee" * args.size)
            except BufferError:
                outcomes["resize_blocked"] += 1

        reader = threading.Thread(target=copy_view, name="copy-view")
        releaser = threading.Thread(target=release_and_resize, name="release-view")
        reader.start()
        releaser.start()
        reader.join()
        releaser.join()
        if errors:
            raise AssertionError((round_no, errors))

    print(outcomes)


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