Fix crashes found hunting the last open fuzzing record - #8524
Draft
youknowone wants to merge 10 commits into
Draft
Fix crashes found hunting the last open fuzzing record#8524youknowone wants to merge 10 commits into
youknowone wants to merge 10 commits into
Conversation
…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
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #8514 and #8518. Those closed every record in the fuzzing + static-review catalogs except one:
RUSTPY-0007face 7c, the object-core segfault reported in theselectorsandasyncio_queuesvehicles, 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.Narrow.x = Big.__dict__["a7"]; Narrow().xin a loopobject/core.rs:index out of bounds: the len is 1 but the index is 7TypeErrorsocket.socket().recv(2**62)memory allocation of 4611686018427387904 bytes failedMemoryErrorc = C(); C.__call__ = c; c()RecursionErrord = D(); D.__get__ = d; D.x = d; d.xRecursionErrortyping.ParamSpecArgschain, thenrepr()RecursionError_asyncio.future_add_to_awaited_by()with a hostile__hash__,select.select()with a hostilefileno(),poll()under a signal handlermemoryview(b"abcd").cast("0s")attempt to divide by zeroValueErrormemoryview(b"abcd")[::-1] == memoryview(b"dcba")range end index 4 out of range for slice of length 1True"x".center(2**62)MemoryErrorl = []; l.append(l); marshal.dumps(l, allow_code=False)marshal.loads(b"\xdb\xff\xff\xff\xff")ValueError: bad marshal data (list size out of range)b = BytesIO(b"abcdef"); b.readinto(b.getbuffer())6a[0] = xwherex.__index__appends to the samearray[1, 1]One commit per defect.
The slot-offset specialization did not check the descriptor's type
LOAD_ATTR/STORE_ATTRspecialize member-descriptor access by caching the descriptor's slot offset and guarding the specialized instruction on the owner's type version.descr_get/descr_setcheck 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:A class with
__slots__ = ()reached theext_ref().unwrap()on the same line instead. Both halves are covered inbuiltin_type.py, for the load, the store and the delete.This one is the reason for the hunt:
object::core::PyInneras 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 infalliblyrecv()andrecvfrom()passed the caller'sbufsizetoVec::with_capacity, so an unreachable size went throughhandle_alloc_errorand aborted the process before any syscall.try_reserve_exactreportsMemoryError.__call__and__get__slot dispatches were not counted as recursionBoth 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:
vm.with_recursionaround the two dispatches raisesRecursionError, the wayPy_EnterRecursiveCallbounds atp_calldispatch. 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, becauseslot_tp_descr_getlooks__get__up with a plain_PyType_Lookupand calls it directly, whilecall_special_methodbinds 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_recursionwas charging the wrong budgetPutting a guard on a native dispatch made
test_tomllib's two recursion-limit tests fail, and the guard was right to be there —with_recursionwas spending the wrong thing. It checked the limitsys.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 wheresys._getframe()cannot see them:test.support.get_recursion_available()counted frames that were no longer available.Py_EnterRecursiveCallbounds the native stack, a separate budget, and the C stack checkwith_recursionalready performs is exactly that bound; the limit check and the counter are gone.ParamSpecArgsformatted its origin with{:?}ParamSpecArgs/ParamSpecKwargsfell back to a Rust{:?}of__origin__when it had no__name__. That walks the object graph natively, throughDebug for PyInner, where no recursion guard sits — the same shape as thePyAtomicRefDebugtype confusion fixed in #8514, and reachable the same way, through a formatting fallback:The origin is now shown by its repr, which is guarded, and a
ParamSpecorigin is recognized by its type rather than by carrying a__name__— matchingparamspecargs_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.
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__andmemoryview.__setitem__convert the value before taking the write borrow, andarray/bytearrayanswer "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.readintoand memoryview slice assignment resolve amemoryviewargument 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__, andselect.select's list extraction, which now re-reads the list the waymap_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
TextIOWrappercookie 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 whatread()andtell()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_capacityor equivalent, so the process aborted throughhandle_alloc_errorbefore any exception could be raised:center(),ljust(),rjust()andzfill()onstr/bytes/bytearray;expandtabs(), which builds its runs of spaces fromtabsize;Buffered{Reader,Writer,Random}(buffer_size=);read(),read1()andFileIO.read();bytes(n)andbytearray(n); andpbkdf2_hmac()'s derived key length. Each reportsMemoryErrornow, orOverflowErrorwhere the argument does not fit the C type it is declared with (expandtabs,pbkdf2_hmac).bytes(n)and the read paths allocate withalloc_zeroedrather than reserving and then memsetting, soFileIO.read(2**40)costs the pages that are written to rather than all of them, asPyBytes_FromStringAndSize+callocdoes.marshal answered
allow_codeby walking the result againallow_code=Falsewas 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:w_object()andr_object()answer it where the code object actually is, inside the walk that already bounds its depth and resolvesFLAG_REFback-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 — andload()no longer holds a borrow of the bufferread()returned across theseek()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_tomllibpass, as does a wider batch of 43 modules includingtest_asyncio,test_collections,test_enum,test_dataclasses,test_functools,test_gc,test_weakrefandtest_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_asynciotask 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._localwhose__del__re-registers during teardown aborts the process out of the TLS destructor, andsys._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