Skip to content

Fix crashes found hunting the last open fuzzing record - #8524

Draft
youknowone wants to merge 10 commits into
RustPython:mainfrom
youknowone:fuzzer-issues
Draft

Fix crashes found hunting the last open fuzzing record#8524
youknowone wants to merge 10 commits into
RustPython:mainfrom
youknowone:fuzzer-issues

Conversation

@youknowone

@youknowone youknowone commented Aug 14, 2026

Copy link
Copy Markdown
Member

Follow-up to #8514 and #8518. Those closed every record in the fuzzing + static-review catalogs except one: RUSTPY-0007 face 7c, the object-core segfault reported in the selectors and asyncio_queues vehicles, which has no reproducer and whose crash dirs are not public. Hunting it turned up crashes that were not in the catalog at all, and hunting those turned up more. Every one is reachable from ordinary pure Python, and CPython 3.14 answers all of them with a normal exception or a result.

reproducer before after (and what CPython does)
Narrow.x = Big.__dict__["a7"]; Narrow().x in a loop panic, object/core.rs: index out of bounds: the len is 1 but the index is 7 TypeError
socket.socket().recv(2**62) SIGABRT: memory allocation of 4611686018427387904 bytes failed MemoryError
c = C(); C.__call__ = c; c() SIGSEGV RecursionError
d = D(); D.__get__ = d; D.x = d; d.x SIGSEGV RecursionError
30k-deep typing.ParamSpecArgs chain, then repr() SIGSEGV RecursionError
_asyncio.future_add_to_awaited_by() with a hostile __hash__, select.select() with a hostile fileno(), poll() under a signal handler hang finish
memoryview(b"abcd").cast("0s") panic: attempt to divide by zero ValueError
memoryview(b"abcd")[::-1] == memoryview(b"dcba") panic: range end index 4 out of range for slice of length 1 True
"x".center(2**62) SIGABRT MemoryError
l = []; l.append(l); marshal.dumps(l, allow_code=False) SIGSEGV round-trips
marshal.loads(b"\xdb\xff\xff\xff\xff") SIGABRT, 32 GiB reserved ValueError: bad marshal data (list size out of range)
b = BytesIO(b"abcdef"); b.readinto(b.getbuffer()) hang 6
a[0] = x where x.__index__ appends to the same array hang [1, 1]

One commit per defect.

The slot-offset specialization did not check the descriptor's type

LOAD_ATTR/STORE_ATTR specialize member-descriptor access by caching the descriptor's slot offset and guarding the specialized instruction on the owner's type version. descr_get/descr_set check on every access that the instance belongs to the type the descriptor was defined for; the specializer skipped that check, so a descriptor lifted from a wider class and bound to a narrower one indexed past the instance's slot array once the cache warmed up:

class Big:
    __slots__ = ("a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7")
class Narrow:
    __slots__ = ("z",)

Narrow.x = Big.__dict__["a7"]
o = Narrow()
for _ in range(1000):
    try: o.x            # TypeError until specialized, then a panic
    except TypeError: pass

A class with __slots__ = () reached the ext_ref().unwrap() on the same line instead. Both halves are covered in builtin_type.py, for the load, the store and the delete.

This one is the reason for the hunt: object::core::PyInner as the top frame, in the object core, independent of the already-guarded recursion paths — the signature reported for face 7c. Without the crash dirs that stays a match, not a diagnosis.

socket.recv() reserved its buffer infallibly

recv() and recvfrom() passed the caller's bufsize to Vec::with_capacity, so an unreachable size went through handle_alloc_error and aborted the process before any syscall. try_reserve_exact reports MemoryError.

__call__ and __get__ slot dispatches were not counted as recursion

Both slot wrappers re-enter Python without pushing a frame, so when the special method names the object it was looked up on, nothing bounded the nesting and the native stack ran out:

class C: pass
c = C(); C.__call__ = c
c()                                     # SIGSEGV

class D: pass
d = D(); D.__get__ = d; D.x = d
d.x                                     # SIGSEGV

vm.with_recursion around the two dispatches raises RecursionError, the way Py_EnterRecursiveCall bounds a tp_call dispatch. Measured against a build without the guards, it costs about 5% on a __call__ dispatch and 3% on a __get__ dispatch through these wrappers; both only run for types whose special method is defined in Python.

CPython answers the second one with TypeError: 'D' object is not callable, because slot_tp_descr_get looks __get__ up with a plain _PyType_Lookup and calls it directly, while call_special_method binds it through the descriptor protocol and so goes round again. The crash is gone either way; the remaining difference is which exception comes out, and the snippet accepts both.

with_recursion was charging the wrong budget

Putting a guard on a native dispatch made test_tomllib's two recursion-limit tests fail, and the guard was right to be there — with_recursion was spending the wrong thing. It checked the limit sys.setrecursionlimit() sets and incremented the same counter pushing a frame does, so bounding a native dispatch took frames away from the Python code underneath it, and took them where sys._getframe() cannot see them: test.support.get_recursion_available() counted frames that were no longer available. Py_EnterRecursiveCall bounds the native stack, a separate budget, and the C stack check with_recursion already performs is exactly that bound; the limit check and the counter are gone.

ParamSpecArgs formatted its origin with {:?}

ParamSpecArgs/ParamSpecKwargs fell back to a Rust {:?} of __origin__ when it had no __name__. That walks the object graph natively, through Debug for PyInner, where no recursion guard sits — the same shape as the PyAtomicRef Debug type confusion fixed in #8514, and reachable the same way, through a formatting fallback:

a = object()
for _ in range(30000):
    a = typing.ParamSpecArgs(a)
repr(a)                                 # SIGSEGV

The origin is now shown by its repr, which is guarded, and a ParamSpec origin is recognized by its type rather than by carrying a __name__ — matching paramspecargs_repr.

A lock was held across a call back into Python

Two commits, one class of defect: a lock or a borrow taken and then held while running code that can reach the same object, so a callback that touches it waits on a lock its own caller holds. The process wedges — no exception, no timeout, no way out.

_asyncio.future_add_to_awaited_by(fut, waiter)  # waiter.__hash__ adds again
select.select(elements, [], [], 0)              # fileno() clears `elements`
select.poll().poll(1000)                        # a SIGALRM handler registers

memoryview(b)[0:4] = memoryview(b)[::-1]        # source overlaps destination
BytesIO(b"abcdef").readinto(its own getbuffer())
array("i", [0])[0] = x                          # x.__index__ appends
mmap_obj.write(memoryview(mmap_obj))
bytearray(b"-").join(hostile_iterable)
bytearray(b"%s") % hostile_tuple

Which fix applies depends on how the lock is reached. Where the value is only needed after the call, the call happens first: array.__setitem__ and memoryview.__setitem__ convert the value before taking the write borrow, and array/bytearray answer "is this resizable" before taking the write lock rather than after — an export is exactly a borrow someone else is already holding, so asking under a lock asks too late. Where the source aliases the destination, the source is copied first: mmap.write, BytesIO.readinto and memoryview slice assignment resolve a memoryview argument to the object it views and compare identities. Where an iterable drives the loop, the container is read again on each step rather than borrowed for the duration: bytearray.join, bytearray.__mod__, and select.select's list extraction, which now re-reads the list the way map_iterable_object() does. poll() waits on a copy of its descriptors, and a future's awaited-by set is built outside the future's lock.

A TextIOWrapper cookie is validated in this commit too: it has to name a position inside what was decoded in characters as well as in bytes, and only the byte offset was checked — the character count is what read() and tell() index with, so a forged cookie panicked.

A size taken from Python went into an infallible allocation

Eight places passed a caller-supplied size straight to Vec::with_capacity or equivalent, so the process aborted through handle_alloc_error before any exception could be raised: center(), ljust(), rjust() and zfill() on str/bytes/bytearray; expandtabs(), which builds its runs of spaces from tabsize; Buffered{Reader,Writer,Random}(buffer_size=); read(), read1() and FileIO.read(); bytes(n) and bytearray(n); and pbkdf2_hmac()'s derived key length. Each reports MemoryError now, or OverflowError where the argument does not fit the C type it is declared with (expandtabs, pbkdf2_hmac).

bytes(n) and the read paths allocate with alloc_zeroed rather than reserving and then memsetting, so FileIO.read(2**40) costs the pages that are written to rather than all of them, as PyBytes_FromStringAndSize + calloc does.

marshal answered allow_code by walking the result again

allow_code=False was enforced by traversing the finished value a second time, looking for a code object, with no depth counter and no record of what it had already visited. A value referring back to itself never terminated, and a value nested deeply enough ran off the native stack:

l = []; l.append(l)
marshal.dumps(l, allow_code=False)      # SIGSEGV

w_object() and r_object() answer it where the code object actually is, inside the walk that already bounds its depth and resolves FLAG_REF back-references — which is where CPython answers it. The 12 differential cases (dumps/loads × code in a tuple, list, dict, set, frozenset, nested code) now produce the same exception with the same message.

Two more in the same file: a container length is read the way r_long() reads one — signed, so a length with the top bit set is out of range rather than four billion items to reserve room for — and load() no longer holds a borrow of the buffer read() returned across the seek() it makes afterwards.

Tests

Regression cases go where the feature is tested: builtin_type.py, builtin_memoryview.py, builtin_str.py, builtin_bytes.py, builtin_hash.py, recursion.py, stdlib_socket.py, stdlib_typing.py, stdlib_select.py, stdlib_asyncio.py, stdlib_io.py, stdlib_io_bytesio.py, stdlib_array.py, stdlib_marshal.py, stdlib_hashlib.py, stdlib_types.py. The snippet suite runs each of them under host CPython as well, so every case is checked against 3.14 by construction.

The full snippet suite passes (422 tests). Of the CPython suite, test_descr, test_typing, test_socket, test_types, test_dynamic, test_richcmp, test_class, test_property, test_super, test_array, test_mmap, test_bytes, test_memoryview, test_io, test_re, test_buffer, test_memoryio, test_bufio, test_fileio, test_str, test_struct, test_marshal, test_tomllib pass, as does a wider batch of 43 modules including test_asyncio, test_collections, test_enum, test_dataclasses, test_functools, test_gc, test_weakref and test_generators. The CI clippy line is clean.

Still open

Face 7c itself remains unconfirmed: nothing here can be tied to the reported crash dirs without their backtraces, and the vehicles' surfaces (selectors with hostile fileno(), asyncio queues plus the _asyncio task registry, both from several threads) still produce no crash. Details in #8325.

Three thread defects found alongside these are not addressed here, because all three need the stop-the-world machinery reworked rather than a call site moved: a _thread._local whose __del__ re-registers during teardown aborts the process out of the TLS destructor, and sys._current_frames() can deadlock or segfault when it lazily initializes a suspended thread's frame data while that thread is stopped.

🤖 Generated with Claude Code

…t offset

The LOAD_ATTR/STORE_ATTR specializations cached the slot offset of any member
descriptor found on the owner's type and then guarded the specialized
instruction on the type version alone, while descr_get()/descr_set() check on
every access that the instance belongs to the type the descriptor was defined
for. A descriptor taken from a wider class and bound to a narrower one read
past the instance's slot array once the cache warmed up:

    class Big:
        __slots__ = ("a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7")
    class Narrow:
        __slots__ = ("z",)
    Narrow.x = Big.__dict__["a7"]
    o = Narrow()
    for _ in range(1000):
        try: o.x
        except TypeError: pass
    # index out of bounds: the len is 1 but the index is 7 (object/core.rs)

A class with no slots at all reached the ext_ref().unwrap() on the same line.

Assisted-by: Claude
recv() and recvfrom() handed the caller's bufsize straight to
Vec::with_capacity, so an unreachable size aborted the process through
handle_alloc_error before any syscall was made:

    socket.socket().recv(2**62)
    # memory allocation of 4611686018427387904 bytes failed -> SIGABRT

try_reserve_exact reports MemoryError instead, which is what CPython raises.

Assisted-by: Claude
Both wrappers re-enter Python without pushing a frame, so nothing counted the
nesting when the special method named the object it was looked up on:

    class C: pass
    c = C(); C.__call__ = c
    c()                                    # native stack overflow, SIGSEGV

    class D: pass
    d = D(); D.__get__ = d; D.x = d
    d.x                                    # the same, through descr_get

with_recursion around the two dispatches raises RecursionError instead, the
way Py_EnterRecursiveCall bounds a tp_call dispatch. It costs about 5% on a
__call__ dispatch and 3% on a __get__ dispatch through these wrappers.

Assisted-by: Claude
ParamSpecArgs and ParamSpecKwargs fell back to a Rust `{:?}` of __origin__ when
it had no __name__. That walks the object graph natively through Debug for
PyInner, where no recursion guard sits, so a single repr() of a deeply nested
chain overflowed the native stack:

    a = object()
    for _ in range(30000):
        a = typing.ParamSpecArgs(a)
    repr(a)                                # SIGSEGV

The origin is formatted with its repr now, which is guarded, and a ParamSpec
origin is recognized by its type rather than by carrying a __name__.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: de0ebe67-f2d2-4ea5-a601-6bd493c93d58

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Three places kept a lock while running code that can reach the same object, so
a callback that touched it wedged the process:

    _asyncio.future_add_to_awaited_by(fut, waiter)   # waiter.__hash__ adds again
    select.select(elements, [], [], 0)               # fileno() clears `elements`
    select.poll().poll(1000)                         # SIGALRM handler registers

The future's awaited-by field is read and written under its lock but the set is
built outside it, the list extraction re-reads the list on each step the way
map_iterable_object() does, and poll() waits on a copy of its descriptors. All
three ran forever before and now finish the way they do on CPython.

Assisted-by: Claude
…ectly

cast() accepted any struct format and any shape element. A zero-size
format ('0s') and a 0 in the shape both reached a division by zero;
cast() now takes only a native single character format, optionally
'@'-prefixed, and shape elements that are ints greater than zero.

A view with a negative stride starts at its last item, so the bytes it
exported began there and its own offsets walked off the front of them.
Such a view now exports the whole underlying buffer with `start` folded
into the descriptor's offsets, and zip_eq() hands over a whole run only
when both sides are contiguous in the last dimension.

Assisted-by: Claude
with_recursion() checked the limit sys.setrecursionlimit() sets and
incremented the same counter that pushing a frame does, so a guard on a
native dispatch spent what Python code had left to call with, and did so
where sys._getframe() cannot see it: test.support.get_recursion_available()
reported frames that were no longer there. Py_EnterRecursiveCall bounds
the native stack instead, which is a separate budget, and the C stack
check with_recursion already performs is that bound.

The snippets pinning the guarded paths nest deep enough to reach the
stack rather than the frame limit.

Assisted-by: Claude
A size taken from Python went straight into an infallible allocation in
several places, so the process aborted through handle_alloc_error before
any exception could be raised:

- str/bytes/bytearray center(), ljust(), rjust() and zfill() reserved
  the padded result for the caller's width
- expandtabs() built its runs of spaces from a tabsize of any width;
  the argument is a C int, and a wider one does not fit
- Buffered{Reader,Writer,Random} allocated buffer_size, and read(),
  read1() and FileIO.read() their read size
- bytes(n) and bytearray(n) allocated n
- pbkdf2_hmac() allocated the derived key length, which is a C int

Each of these now reports MemoryError, or OverflowError where the
argument does not fit the type it is declared with.

new_zeroed_bytes() leaves the zeroing to the allocator, so a large
request costs the pages that are written to rather than all of them.

Assisted-by: Claude
allow_code was answered by walking the whole result a second time, with
no depth counter and no record of what it had already seen, so a value
that referred back to itself or nested deeply enough ran off the native
stack. w_object() and r_object() answer it where the code object is,
inside the walk that already bounds its depth and resolves references.

A container length is read the way r_long() reads one: it is signed, so
a length with the top bit set is out of range rather than four billion
items to reserve room for.

load() no longer holds a borrow of the buffer read() returned across the
seek() it makes afterwards.

Assisted-by: Claude
Several places held a lock or a borrow of an object across a call back
into Python, so a callback that touched the same object waited on a lock
its own caller was holding:

- memoryview slice assignment read a source overlapping the destination,
  and __setitem__ converted the value while holding the write borrow
- BytesIO.readinto() read into a buffer viewing the same BytesIO
- array.__setitem__ converted the value under the array's write lock,
  and mmap.write() read a source viewing the same map
- bytearray.join() and bytearray.__mod__ drove Python with the
  bytearray borrowed
- array and bytearray answered "is this resizable" after taking the
  write lock, though an export is exactly a borrow someone else holds

A TextIOWrapper cookie now has to name a position inside what was
decoded in characters as well as in bytes; only the byte offset was
checked, and the character count is what read() and tell() index with.

Assisted-by: Claude
@youknowone youknowone changed the title Fix five crashes found hunting the last open fuzzing record Fix crashes found hunting the last open fuzzing record Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant