gh-151295: Fix use-after-free in bytes.join()/bytearray.join() via re-entrant __buffer__#151296
Conversation
…via re-entrant __buffer__
| seq = make_seq(replace) | ||
| self.assertEqual(sep.join(seq), sep.join([b'a', b'x', b'c'])) | ||
|
|
||
| # A benign __buffer__() that does not mutate joins normally. |
There was a problem hiding this comment.
Is this related to the issue?
There was a problem hiding this comment.
Agreed, removed it.
| with self.assertRaises(TypeError): | ||
| dot_join([memoryview(b"ab"), "cd", b"ef"]) | ||
|
|
||
| def test_join_reentrant_buffer_mutation(self): |
There was a problem hiding this comment.
The issue is not reentrant mutation, but concurrent mutation. The GIL can be released due to execution of __buffer__, and the sequence can be concurrently mutated in other thread. This is more probably to happen in real world than intentional reentrant mutation. Reentrant mutation is just a simple way to imitate concurrent mutation. I suggest to focus on concurrent mutation in comments and the test name.
There was a problem hiding this comment.
Good point — renamed to test_join_concurrent_buffer_mutation and reworded the comment accordingly. Thanks.
|
Thanks @tonghuaroot for the PR, and @serhiy-storchaka for merging it 🌮🎉.. I'm working now to backport this PR to: 3.13, 3.14, 3.15. |
|
GH-151304 is a backport of this pull request to the 3.15 branch. |
|
GH-151305 is a backport of this pull request to the 3.14 branch. |
|
GH-151306 is a backport of this pull request to the 3.13 branch. |
What
bytes.join()andbytearray.join()could crash with a use-after-free when anon-
bytesitem's__buffer__()dropped the last reference to that item bymutating the sequence being joined.
STRINGLIB(bytes_join)(Objects/stringlib/join.h) borrows each item from thePySequence_Fastview. The exact-bytesfast path keeps the item alive withPy_NewRef(item), but the general buffer path calledPyObject_GetBuffer(item, ...)on the borrowed item without owning areference.
PyObject_GetBuffer()runsitem.__buffer__()(PEP 688), which canexecute arbitrary Python; if that callback drops the item's last reference
(e.g.
L.clear()), the item is freed while the buffer machinery is stilldereferencing it.
Why
This is the same class of re-entrant
__buffer__mutation bug as gh-143988(
sendmsg/recvmsg_into). The end-of-loop "sequence changed size" recheckdoes not catch it because the use-after-free happens inside the current
iteration's
PyObject_GetBuffer()call.Unlike the
sendmsgfix, this does not snapshot the whole sequence up front.bytes_joinalready has a "sequence changed size during iteration" recheckafter each item, so the minimal fix is just to keep the current
itemaliveacross its own
PyObject_GetBuffer()call (Py_INCREF/Py_DECREF); theexisting recheck then reports the mutation as a
RuntimeError. Snapshottingthe whole sequence would be a larger change and would alter that error
behaviour, so the targeted reference fix is preferred.
This is a crash-robustness fix, not a security vulnerability: the caller has to
pass an object with a malicious
__buffer__into its ownjoin()call, so itcrashes the caller's own process with no trust boundary crossed.
Fix
Hold a reference to
itemacrossPyObject_GetBuffer()in the buffer path(
Py_INCREFbefore the call,Py_DECREFon both the success and the failurepath), mirroring the
Py_NewRef(item)already used by the exact-bytesfastpath.
Testing
BaseBytesTest.test_join_reentrant_buffer_mutation(runs for bothbytesandbytearray) exercises a__buffer__that mutates the joined list, for emptyand non-empty separators. One case clears the list (changing its length) and
asserts a
RuntimeErroris raised instead of crashing. A second case replacesthe item in place with an object: the list length is unchanged, so the
"sequence changed size" recheck cannot fire and only keeping the item alive
across
__buffer__()avoids the use-after-free; it asserts the join stillreturns the expected result. A benign
__buffer__negative control (also runfor both types and separators) verifies normal joins still produce the correct
result.
Verified locally on macOS arm64 against a fresh build (both
--with-pydebugand a release build): without the patch the new test fatally crashes the
interpreter (on a debug build the fault is in
bufferwrapper_releasebuf,confirming a use-after-free); with the patch
python -m test test_bytespasses (312 tests) with no reference leaks under
-R 3:3.This affects 3.12+, since PEP 688 made
__buffer__overridable in purePython, so the fix should be backported to the maintained 3.13 and 3.14
branches.
AI usage disclosure
I used Claude to help draft this change (the C fix, the regression test, and
this description). I reviewed every line, ran the reproducer and the full
test_bytessuite locally (debug and release builds), and confirmed the fixand the test behave as described.