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()
Description
memoryview.__hash__temporarily increments the view's export count only around hashing the exporter. It drops that pin beforePy_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
0x31bytes to8533043431672768313, 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:
The reproducer also verifies that release is initially blocked while the exporter-hash pin is active.
PoC Source Code
poc/reproduce.py: