From d9788607df7c33dd743d7f093500f816e21fff74 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Tue, 9 Jun 2026 03:31:44 -0700 Subject: [PATCH 1/3] gh-143008: Fix Null pointer dereferences in TextIOWrapper underlying stream access (#145957) TextIOWrapper keeps its underlying stream in a member called `self->buffer`. That stream can be detached by user code, such as custom `.flush` implementations resulting in `self->buffer` being set to NULL. The implementation often checked at the start of functions if `self->buffer` is in a good state, but did not always recheck after other Python code was called which could modify `self->buffer`. The cases which need to be re-checked are hard to spot so rather than rely on reviewer effort create better safety by making all self->buffer access go through helper functions. Thank you yihong0618 for the test, NEWS and initial implementation in gh-143041. Co-authored-by: yihong0618 Co-authored-by: Victor Stinner (cherry picked from commit db4b1948bc4ad5e7230fe1f2814da409ca1d669b) --- Lib/test/test_io/test_textio.py | 50 ++++++ ...-12-21-17-56-37.gh-issue-143008.aakErJ.rst | 2 + ...-06-04-18-22-56.gh-issue-143008.z5tw-J.rst | 1 + Modules/_io/textio.c | 154 ++++++++++++++---- 4 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst create mode 100644 Misc/NEWS.d/next/Library/2026-06-04-18-22-56.gh-issue-143008.z5tw-J.rst diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index f07302660161aab..e7f118f1bc67656 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1626,6 +1626,56 @@ def closed(self): wrapper = self.TextIOWrapper(raw) wrapper.close() # should not crash + def test_reentrant_detach_during_flush(self): + # gh-143008: Reentrant detach() during flush should not crash. + + class DetachOnce(self.BufferedRandom): + wrapper = None + + def detach_once(self): + original = self.wrapper + self.wrapper = None + if original is not None: + original.detach() + original.flush() + + class DetachOnFlush(DetachOnce): + def flush(self): + self.detach_once() + + class DetachOnWrite(DetachOnce): + def write(self, b): + self.detach_once() + return len(b) + + # Separate reference for after detach_once. + wrapper = None + + def make_text(buffer): + nonlocal wrapper + buffer.wrapper = self.TextIOWrapper(buffer, encoding='utf-8') + wrapper = buffer.wrapper + + # Many calls could result in the same null self->buffer crash. + tests = [ + ('truncate', lambda: wrapper.truncate(0)), + ('close', lambda: wrapper.close()), + ('detach', lambda: wrapper.detach()), + ('seek', lambda: wrapper.seek(0)), + ('tell', lambda: wrapper.tell()), + ('reconfigure', lambda: wrapper.reconfigure(line_buffering=True)), + ] + for name, method in tests: + with self.subTest(name): + make_text(DetachOnFlush(self.MockRawIO())) + self.assertRaisesRegex(ValueError, "detached", method) + + # Should not crash. + with self.subTest('read via writeflush'): + make_text(DetachOnWrite(self.MockRawIO())) + wrapper.write('x') + self.assertRaisesRegex(ValueError, "detached", wrapper.read) + class PyTextIOWrapperTest(TextIOWrapperTest, PyTestCase): shutdown_error = "LookupError: unknown encoding: ascii" diff --git a/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst b/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst new file mode 100644 index 000000000000000..907cdb770ab65f9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst @@ -0,0 +1,2 @@ +Fix crash in :class:`io.TextIOWrapper` when reentrant +:meth:`io.TextIOBase.detach` is called reentrantly from the underlying buffer. diff --git a/Misc/NEWS.d/next/Library/2026-06-04-18-22-56.gh-issue-143008.z5tw-J.rst b/Misc/NEWS.d/next/Library/2026-06-04-18-22-56.gh-issue-143008.z5tw-J.rst new file mode 100644 index 000000000000000..e99bc39c45f9b8f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-04-18-22-56.gh-issue-143008.z5tw-J.rst @@ -0,0 +1 @@ +Fix race conditions when re-initializing a :class:`io.TextIOWrapper` object. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 1d39fd859162579..ed4ccd7e3286c0b 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -672,6 +672,8 @@ struct textio int ok; /* initialized? */ int detached; Py_ssize_t chunk_size; + /* Use helpers buffer_*() functions to access buffer; many operations can set it to + NULL (see gh-143008, gh-142594). */ PyObject *buffer; PyObject *encoding; PyObject *encoder; @@ -729,6 +731,67 @@ struct textio #define textio_CAST(op) ((textio *)(op)) +/* Helpers to safely operate on self->buffer. + + self->buffer can be detached (set to NULL) by any user code that is called + leading to NULL pointer dereferences (see gh-143008, gh-142594). Protect + against that by using helpers to check self->buffer validity at callsites. */ +static PyObject * +buffer_access_safe(textio *self) +{ + /* Check self->buffer directly but match errors of CHECK_ATTACHED since this + is called during construction and finalization where self->ok == 0. */ + if (self->buffer == NULL) { + if (self->ok <= 0) { + PyErr_SetString(PyExc_ValueError, + "I/O operation on uninitialized object"); + } + else { + PyErr_SetString(PyExc_ValueError, + "underlying buffer has been detached"); + } + return NULL; + } + + /* Returning a borrowed reference is safe since TextIOWrapper methods are + protected by critical sections. */ + _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(self); + return self->buffer; +} + +static PyObject * +buffer_getattr(textio *self, PyObject *attr_name) +{ + PyObject *buffer = buffer_access_safe(self); + if (buffer == NULL) { + return NULL; + } + + return PyObject_GetAttr(buffer, attr_name); +} + +static PyObject * +buffer_callmethod_noargs(textio *self, PyObject *name) +{ + PyObject *buffer = buffer_access_safe(self); + if (buffer == NULL) { + return NULL; + } + + return PyObject_CallMethodNoArgs(buffer, name); +} + +static PyObject * +buffer_callmethod_onearg(textio *self, PyObject *name, PyObject *arg) +{ + PyObject *buffer = buffer_access_safe(self); + if (buffer == NULL) { + return NULL; + } + + return PyObject_CallMethodOneArg(buffer, name, arg); +} + static void textiowrapper_set_decoded_chars(textio *self, PyObject *chars); @@ -898,7 +961,7 @@ _textiowrapper_set_decoder(textio *self, PyObject *codec_info, PyObject *res; int r; - res = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(readable)); + res = buffer_callmethod_noargs(self, &_Py_ID(readable)); if (res == NULL) return -1; @@ -954,7 +1017,7 @@ _textiowrapper_set_encoder(textio *self, PyObject *codec_info, PyObject *res; int r; - res = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(writable)); + res = buffer_callmethod_noargs(self, &_Py_ID(writable)); if (res == NULL) return -1; @@ -1000,8 +1063,7 @@ _textiowrapper_fix_encoder_state(textio *self) self->encoding_start_of_stream = 1; - PyObject *cookieObj = PyObject_CallMethodNoArgs( - self->buffer, &_Py_ID(tell)); + PyObject *cookieObj = buffer_callmethod_noargs(self, &_Py_ID(tell)); if (cookieObj == NULL) { return -1; } @@ -1569,11 +1631,14 @@ _io_TextIOWrapper_detach_impl(textio *self) /*[clinic end generated code: output=7ba3715cd032d5f2 input=c908a3b4ef203b0f]*/ { PyObject *buffer; - CHECK_ATTACHED(self); if (_PyFile_Flush((PyObject *)self) < 0) { return NULL; } - buffer = self->buffer; + /* _PyFile_Flush could detach before returning; raise an exception. */ + buffer = buffer_access_safe(self); + if (buffer == NULL) { + return NULL; + } self->buffer = NULL; self->detached = 1; return buffer; @@ -1642,7 +1707,7 @@ _textiowrapper_writeflush(textio *self) PyObject *ret; do { - ret = PyObject_CallMethodOneArg(self->buffer, &_Py_ID(write), b); + ret = buffer_callmethod_onearg(self, &_Py_ID(write), b); } while (ret == NULL && _PyIO_trap_eintr()); Py_DECREF(b); // NOTE: We cleared buffer but we don't know how many bytes are actually written @@ -1794,7 +1859,8 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text) } if (needflush) { - if (_PyFile_Flush(self->buffer) < 0) { + PyObject *buffer = buffer_access_safe(self); + if (buffer == NULL || _PyFile_Flush(buffer) < 0) { return NULL; } } @@ -1924,9 +1990,10 @@ textiowrapper_read_chunk(textio *self, Py_ssize_t size_hint) if (chunk_size == NULL) goto fail; - input_chunk = PyObject_CallMethodOneArg(self->buffer, - (self->has_read1 ? &_Py_ID(read1): &_Py_ID(read)), - chunk_size); + input_chunk = buffer_callmethod_onearg(self, + (self->has_read1 ? &_Py_ID(read1) : + &_Py_ID(read)), + chunk_size); Py_DECREF(chunk_size); if (input_chunk == NULL) goto fail; @@ -2010,7 +2077,7 @@ _io_TextIOWrapper_read_impl(textio *self, Py_ssize_t n) if (n < 0) { /* Read everything */ - PyObject *bytes = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(read)); + PyObject *bytes = buffer_callmethod_noargs(self, &_Py_ID(read)); PyObject *decoded; if (bytes == NULL) goto fail; @@ -2607,7 +2674,11 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) Py_DECREF(res); } - res = _PyObject_CallMethod(self->buffer, &_Py_ID(seek), "ii", 0, 2); + PyObject *buf = buffer_access_safe(self); + if (buf == NULL) { + goto fail; + } + res = _PyObject_CallMethod(buf, &_Py_ID(seek), "ii", 0, 2); Py_CLEAR(cookieObj); if (res == NULL) goto fail; @@ -2655,7 +2726,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) posobj = PyLong_FromOff_t(cookie.start_pos); if (posobj == NULL) goto fail; - res = PyObject_CallMethodOneArg(self->buffer, &_Py_ID(seek), posobj); + res = buffer_callmethod_onearg(self, &_Py_ID(seek), posobj); Py_DECREF(posobj); if (res == NULL) goto fail; @@ -2672,8 +2743,15 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) if (cookie.chars_to_skip) { /* Just like _read_chunk, feed the decoder and save a snapshot. */ - PyObject *input_chunk = _PyObject_CallMethod(self->buffer, &_Py_ID(read), - "i", cookie.bytes_to_feed); + PyObject *bytes_to_feed = PyLong_FromLong(cookie.bytes_to_feed); + if (bytes_to_feed == NULL) { + goto fail; + } + PyObject *input_chunk = buffer_callmethod_onearg(self, + &_Py_ID(read), + bytes_to_feed); + Py_DECREF(bytes_to_feed); + PyObject *decoded; if (input_chunk == NULL) @@ -2772,7 +2850,7 @@ _io_TextIOWrapper_tell_impl(textio *self) goto fail; } - posobj = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(tell)); + posobj = buffer_callmethod_noargs(self, &_Py_ID(tell)); if (posobj == NULL) goto fail; @@ -2988,7 +3066,7 @@ _io_TextIOWrapper_truncate_impl(textio *self, PyObject *pos) return NULL; } - return PyObject_CallMethodOneArg(self->buffer, &_Py_ID(truncate), pos); + return buffer_callmethod_onearg(self, &_Py_ID(truncate), pos); } static PyObject * @@ -3070,8 +3148,7 @@ static PyObject * _io_TextIOWrapper_fileno_impl(textio *self) /*[clinic end generated code: output=21490a4c3da13e6c input=515e1196aceb97ab]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(fileno)); + return buffer_callmethod_noargs(self, &_Py_ID(fileno)); } /*[clinic input] @@ -3083,8 +3160,7 @@ static PyObject * _io_TextIOWrapper_seekable_impl(textio *self) /*[clinic end generated code: output=ab223dbbcffc0f00 input=71c4c092736c549b]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(seekable)); + return buffer_callmethod_noargs(self, &_Py_ID(seekable)); } /*[clinic input] @@ -3096,8 +3172,7 @@ static PyObject * _io_TextIOWrapper_readable_impl(textio *self) /*[clinic end generated code: output=72ff7ba289a8a91b input=80438d1f01b0a89b]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(readable)); + return buffer_callmethod_noargs(self, &_Py_ID(readable)); } /*[clinic input] @@ -3109,8 +3184,7 @@ static PyObject * _io_TextIOWrapper_writable_impl(textio *self) /*[clinic end generated code: output=a728c71790d03200 input=9d6c22befb0c340a]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(writable)); + return buffer_callmethod_noargs(self, &_Py_ID(writable)); } /*[clinic input] @@ -3122,8 +3196,7 @@ static PyObject * _io_TextIOWrapper_isatty_impl(textio *self) /*[clinic end generated code: output=12be1a35bace882e input=7f83ff04d4d1733d]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(isatty)); + return buffer_callmethod_noargs(self, &_Py_ID(isatty)); } /*[clinic input] @@ -3140,7 +3213,7 @@ _io_TextIOWrapper_flush_impl(textio *self) self->telling = self->seekable; if (_textiowrapper_writeflush(self) < 0) return NULL; - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(flush)); + return buffer_callmethod_noargs(self, &_Py_ID(flush)); } /*[clinic input] @@ -3173,8 +3246,9 @@ _io_TextIOWrapper_close_impl(textio *self) else { PyObject *exc = NULL; if (self->finalizing) { - res = PyObject_CallMethodOneArg(self->buffer, &_Py_ID(_dealloc_warn), - (PyObject *)self); + res = buffer_callmethod_onearg(self, + &_Py_ID(_dealloc_warn), + (PyObject *)self); if (res) { Py_DECREF(res); } @@ -3186,7 +3260,7 @@ _io_TextIOWrapper_close_impl(textio *self) exc = PyErr_GetRaisedException(); } - res = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(close)); + res = buffer_callmethod_noargs(self, &_Py_ID(close)); if (exc != NULL) { _PyErr_ChainExceptions1(exc); Py_CLEAR(res); @@ -3254,8 +3328,7 @@ static PyObject * _io_TextIOWrapper_name_get_impl(textio *self) /*[clinic end generated code: output=8c2f1d6d8756af40 input=26ecec9b39e30e07]*/ { - CHECK_ATTACHED(self); - return PyObject_GetAttr(self->buffer, &_Py_ID(name)); + return buffer_getattr(self, &_Py_ID(name)); } /*[clinic input] @@ -3268,8 +3341,17 @@ static PyObject * _io_TextIOWrapper_closed_get_impl(textio *self) /*[clinic end generated code: output=b49b68f443a85e3c input=7dfcf43f63c7003d]*/ { - CHECK_ATTACHED(self); - return PyObject_GetAttr(self->buffer, &_Py_ID(closed)); + /* If partially constructed or deconstructed, return that the underlying + buffer is closed. + + The code managing the transition is responsible for closing. The closed + attribute is often called in re-initalization, as part of repr in error + cases, and when the I/O stack is garbage collected. */ + if (self->ok <= 0) { + Py_RETURN_TRUE; + } + + return buffer_getattr(self, &_Py_ID(closed)); } /*[clinic input] From e1ff17bba16d03e9776a883b2e1c749caac42e1c Mon Sep 17 00:00:00 2001 From: Brij Kapadia <97006829+brijkapadia@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:29:52 -0400 Subject: [PATCH 2/3] gh-154523: Fix data-race in `TextIOWrapper.detach()` (#154565) (cherry picked from commit 8440a6a4651f93ecd1c7768ffb2c735c5b9fb984) --- Lib/test/test_free_threading/test_io.py | 20 ++++++++++++++ ...-07-23-18-53-48.gh-issue-154523.XUSRBO.rst | 2 ++ Modules/_io/clinic/textio.c.h | 27 ++++++++++++++++++- Modules/_io/textio.c | 15 ++++++++++- 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-23-18-53-48.gh-issue-154523.XUSRBO.rst diff --git a/Lib/test/test_free_threading/test_io.py b/Lib/test/test_free_threading/test_io.py index fad8339af2edd6b..1c2c1e941e25a7e 100644 --- a/Lib/test/test_free_threading/test_io.py +++ b/Lib/test/test_free_threading/test_io.py @@ -198,3 +198,23 @@ def reset_worker(): decoder.reset() run_concurrently([decode_worker] * 2 + [reset_worker] * 2) + + +class TextIOWrapperTest(TestCase): + def test_buffer_detach_race(self): + make = lambda: io.TextIOWrapper(io.BytesIO()) + slot = [make()] + + def reader(): + for _ in range(1000): + try: + slot[0].buffer + except ValueError: + pass + + def detacher(): + for _ in range(1000): + slot[0] = make() + slot[0].detach() + + run_concurrently([reader, detacher]) diff --git a/Misc/NEWS.d/next/Library/2026-07-23-18-53-48.gh-issue-154523.XUSRBO.rst b/Misc/NEWS.d/next/Library/2026-07-23-18-53-48.gh-issue-154523.XUSRBO.rst new file mode 100644 index 000000000000000..23b25a26effb642 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-23-18-53-48.gh-issue-154523.XUSRBO.rst @@ -0,0 +1,2 @@ +Fixed data-race when calling :meth:`io.TextIOBase.detach` in +:term:`free-threaded build`. diff --git a/Modules/_io/clinic/textio.c.h b/Modules/_io/clinic/textio.c.h index 99a3eb9f2607076..10d0f1390cccbb4 100644 --- a/Modules/_io/clinic/textio.c.h +++ b/Modules/_io/clinic/textio.c.h @@ -1337,4 +1337,29 @@ _io_TextIOWrapper__CHUNK_SIZE_set(PyObject *self, PyObject *value, void *Py_UNUS return return_value; } -/*[clinic end generated code: output=cec74a7476964ae4 input=a9049054013a1b77]*/ + +#if !defined(_io_TextIOWrapper_buffer_DOCSTR) +# define _io_TextIOWrapper_buffer_DOCSTR NULL +#endif +#if defined(_IO_TEXTIOWRAPPER_BUFFER_GETSETDEF) +# undef _IO_TEXTIOWRAPPER_BUFFER_GETSETDEF +# define _IO_TEXTIOWRAPPER_BUFFER_GETSETDEF {"buffer", (getter)_io_TextIOWrapper_buffer_get, (setter)_io_TextIOWrapper_buffer_set, _io_TextIOWrapper_buffer_DOCSTR}, +#else +# define _IO_TEXTIOWRAPPER_BUFFER_GETSETDEF {"buffer", (getter)_io_TextIOWrapper_buffer_get, NULL, _io_TextIOWrapper_buffer_DOCSTR}, +#endif + +static PyObject * +_io_TextIOWrapper_buffer_get_impl(textio *self); + +static PyObject * +_io_TextIOWrapper_buffer_get(PyObject *self, void *Py_UNUSED(context)) +{ + PyObject *return_value = NULL; + + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _io_TextIOWrapper_buffer_get_impl((textio *)self); + Py_END_CRITICAL_SECTION(); + + return return_value; +} +/*[clinic end generated code: output=e93032a0691ff0e4 input=a9049054013a1b77]*/ diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index ed4ccd7e3286c0b..5630f0309d98cff 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -3426,6 +3426,19 @@ _io_TextIOWrapper__CHUNK_SIZE_set_impl(textio *self, PyObject *value) return 0; } +/*[clinic input] +@critical_section +@getter +_io.TextIOWrapper.buffer +[clinic start generated code]*/ + +static PyObject * +_io_TextIOWrapper_buffer_get_impl(textio *self) +/*[clinic end generated code: output=d265a34555aa5d4b input=5951cfa148f7350a]*/ +{ + return Py_XNewRef(buffer_access_safe(self)); +} + static PyMethodDef incrementalnewlinedecoder_methods[] = { _IO_INCREMENTALNEWLINEDECODER_DECODE_METHODDEF _IO_INCREMENTALNEWLINEDECODER_GETSTATE_METHODDEF @@ -3484,7 +3497,6 @@ static PyMethodDef textiowrapper_methods[] = { static PyMemberDef textiowrapper_members[] = { {"encoding", _Py_T_OBJECT, offsetof(textio, encoding), Py_READONLY}, - {"buffer", _Py_T_OBJECT, offsetof(textio, buffer), Py_READONLY}, {"line_buffering", Py_T_BOOL, offsetof(textio, line_buffering), Py_READONLY}, {"write_through", Py_T_BOOL, offsetof(textio, write_through), Py_READONLY}, {"_finalizing", Py_T_BOOL, offsetof(textio, finalizing), 0}, @@ -3499,6 +3511,7 @@ static PyGetSetDef textiowrapper_getset[] = { _IO_TEXTIOWRAPPER_NEWLINES_GETSETDEF _IO_TEXTIOWRAPPER_ERRORS_GETSETDEF _IO_TEXTIOWRAPPER__CHUNK_SIZE_GETSETDEF + _IO_TEXTIOWRAPPER_BUFFER_GETSETDEF {NULL} }; From 5dd00da19a445e9749f0ce67bbb0968a6722af34 Mon Sep 17 00:00:00 2001 From: Shamil Date: Wed, 7 Oct 2026 21:08:54 +0300 Subject: [PATCH 3/3] gh-157364: Fix use-after-free in io.TextIOWrapper during reentrant detach (#157370) Co-authored-by: Victor Stinner (cherry picked from commit e19dc470f4c2afd7ac944896232a3a6ca0204399) --- Lib/test/test_io/test_textio.py | 30 +++++++++++++++++++ ...-09-12-18-03-15.gh-issue-157364.Qd7mKs.rst | 2 ++ Modules/_io/textio.c | 28 +++++++++++------ 3 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-12-18-03-15.gh-issue-157364.Qd7mKs.rst diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index e7f118f1bc67656..72dc3ffc0ab4b82 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1676,6 +1676,36 @@ def make_text(buffer): wrapper.write('x') self.assertRaisesRegex(ValueError, "detached", wrapper.read) + def test_reentrant_detach_during_read(self): + # gh-157363, gh-157364: The buffer must stay alive until its active + # read operation returns. + wrapper = None + + class DetachOnRead(self.RawIOBase): + detached = False + + def readable(self): + return True + + def readinto(self, b): + if self.detached: + return 0 + self.detached = True + wrapper.detach() + b[:3] = b"ab\n" + return 3 + + for method_name in ("read", "readline"): + with self.subTest(method_name): + raw = DetachOnRead() + wrapper = self.TextIOWrapper( + self.BufferedReader(raw), encoding="utf-8") + method = getattr(wrapper, method_name) + self.assertEqual(method(), "ab\n") + with self.assertRaisesRegex(ValueError, + "underlying buffer has been detached"): + getattr(wrapper, 'buffer') + class PyTextIOWrapperTest(TextIOWrapperTest, PyTestCase): shutdown_error = "LookupError: unknown encoding: ascii" diff --git a/Misc/NEWS.d/next/Library/2026-09-12-18-03-15.gh-issue-157364.Qd7mKs.rst b/Misc/NEWS.d/next/Library/2026-09-12-18-03-15.gh-issue-157364.Qd7mKs.rst new file mode 100644 index 000000000000000..3ca44d25b579109 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-12-18-03-15.gh-issue-157364.Qd7mKs.rst @@ -0,0 +1,2 @@ +Fix a use-after-free in :class:`io.TextIOWrapper` when a call to the +underlying buffer reentrantly detaches it. Patched by Shamil Abdulaev. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 5630f0309d98cff..785c10cef878922 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -753,10 +753,8 @@ buffer_access_safe(textio *self) return NULL; } - /* Returning a borrowed reference is safe since TextIOWrapper methods are - protected by critical sections. */ _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(self); - return self->buffer; + return Py_NewRef(self->buffer); } static PyObject * @@ -767,7 +765,9 @@ buffer_getattr(textio *self, PyObject *attr_name) return NULL; } - return PyObject_GetAttr(buffer, attr_name); + PyObject *res = PyObject_GetAttr(buffer, attr_name); + Py_DECREF(buffer); + return res; } static PyObject * @@ -778,7 +778,9 @@ buffer_callmethod_noargs(textio *self, PyObject *name) return NULL; } - return PyObject_CallMethodNoArgs(buffer, name); + PyObject *res = PyObject_CallMethodNoArgs(buffer, name); + Py_DECREF(buffer); + return res; } static PyObject * @@ -789,7 +791,9 @@ buffer_callmethod_onearg(textio *self, PyObject *name, PyObject *arg) return NULL; } - return PyObject_CallMethodOneArg(buffer, name, arg); + PyObject *res = PyObject_CallMethodOneArg(buffer, name, arg); + Py_DECREF(buffer); + return res; } static void @@ -1639,7 +1643,7 @@ _io_TextIOWrapper_detach_impl(textio *self) if (buffer == NULL) { return NULL; } - self->buffer = NULL; + Py_CLEAR(self->buffer); self->detached = 1; return buffer; } @@ -1860,7 +1864,12 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text) if (needflush) { PyObject *buffer = buffer_access_safe(self); - if (buffer == NULL || _PyFile_Flush(buffer) < 0) { + if (buffer == NULL) { + return NULL; + } + int res = _PyFile_Flush(buffer); + Py_DECREF(buffer); + if (res < 0) { return NULL; } } @@ -2679,6 +2688,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) goto fail; } res = _PyObject_CallMethod(buf, &_Py_ID(seek), "ii", 0, 2); + Py_DECREF(buf); Py_CLEAR(cookieObj); if (res == NULL) goto fail; @@ -3436,7 +3446,7 @@ static PyObject * _io_TextIOWrapper_buffer_get_impl(textio *self) /*[clinic end generated code: output=d265a34555aa5d4b input=5951cfa148f7350a]*/ { - return Py_XNewRef(buffer_access_safe(self)); + return buffer_access_safe(self); } static PyMethodDef incrementalnewlinedecoder_methods[] = {