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()
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 whilePyBuffer_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:
The oracle accepts a release-before-check
ValueErroror a complete old-generation copy and rejects short or mixed successful output.PoC Source Code
poc/reproduce.py: