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