Skip to content

memoryview.__hash__ can read released storage after the exporter hash returns #157112

Description

@Nievesjyl

Description

memoryview.__hash__ temporarily increments the view's export count only around hashing the exporter. It drops that pin before Py_HashBuffer() reads the previously captured buffer address, allowing another thread to release the view and replace the exporter's storage during the final hash.

Observed Behavior

On a free-threaded ASan/debug build, a synchronized run hashes 128 MiB of original 0x31 bytes to 8533043431672768313, while an immutable copy hashes to -6352974130124169244. The GIL-enabled control returns equal hashes.

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 --size 134217728

The reproducer also verifies that release is initially blocked while the exporter-hash pin is active.

PoC Source Code

poc/reproduce.py:

#!/usr/bin/env python3
"""Coordinate memoryview hashing with release after the exporter-hash pin."""

import argparse
import array
import threading


entered_hash = threading.Event()
release_attempted = threading.Event()


class Exporter(array.array):
    def __hash__(self) -> int:
        entered_hash.set()
        if not release_attempted.wait(10):
            raise RuntimeError("releaser did not reach the pinned view")
        return 123


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

    data = Exporter("B", b"\x31" * args.size)
    view = memoryview(data).toreadonly()
    expected = hash(bytes(data))
    errors: list[tuple[object, ...]] = []

    def reader() -> None:
        try:
            result = hash(view)
            if result != expected:
                errors.append(("wrong-hash", result, expected))
            print("hash", result, "expected", expected, flush=True)
        except BaseException as exc:
            errors.append(("reader", repr(exc)))

    def releaser() -> None:
        if not entered_hash.wait(10):
            errors.append(("releaser", "hash callback not entered"))
            return
        try:
            view.release()
        except BufferError:
            release_attempted.set()
        else:
            errors.append(("releaser", "release bypassed active pin"))
            release_attempted.set()
            return

        while True:
            try:
                view.release()
                break
            except BufferError:
                pass
        data.clear()
        data.frombytes(b"\xce" * args.size)

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

    if errors:
        raise AssertionError(errors)


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