From 4752da614c59fec046f0887b991f9665f4e60e1f Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Sat, 11 Jul 2026 12:43:46 +0800 Subject: [PATCH] gh-153539: Fix use-after-free in TextIOWrapper.tell() with a reentrant decoder TextIOWrapper.tell() used a borrowed next_input from the snapshot across the decoder's getstate/decode/setstate calls, so a decoder that reenters seek() from getstate could free it and leave tell() reading freed memory. Own the reference across those calls, matching the sibling textiowrapper_read_chunk. --- Lib/test/test_io/test_textio.py | 43 +++++++++++++++++++ ...-07-11-12-21-12.gh-issue-153539.gBs2T5.rst | 2 + Modules/_io/textio.c | 8 +++- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index 82096ab0987395..07f6b1415d0dbd 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1610,6 +1610,49 @@ def make_text(buffer): wrapper.write('x') self.assertRaisesRegex(ValueError, "detached", wrapper.read) + def test_reentrant_seek_during_tell(self): + # gh-153539: reading short of _CHUNK_SIZE leaves residual bytes in the + # snapshot, so tell() re-decodes and calls the decoder's getstate(); a + # reentrant seek() there must not free the snapshot tell() still uses. + # C-only: _pyio binds next_input as a strong local and cannot crash. + wrapper = None + armed = False + + class ReentrantDecoder(codecs.IncrementalDecoder): + def decode(self, input, final=False): + return bytes(input).decode("latin-1") + def getstate(self): + nonlocal armed + if wrapper is not None and armed: + armed = False + wrapper.seek(0) + return (b"", 0) + def setstate(self, state): + pass + + def search(name): + if name != "reentrant_tell_test": + return None + return codecs.CodecInfo( + name=name, + encode=lambda s, e='strict': (s.encode("latin-1"), len(s)), + decode=lambda b, e='strict': (bytes(b).decode("latin-1"), len(b)), + incrementaldecoder=ReentrantDecoder) + + codecs.register(search) + self.addCleanup(codecs.unregister, search) + raw = self.BytesIO(b"abcdefghijklmnop" * 8) + wrapper = self.TextIOWrapper(self.BufferedReader(raw), + encoding="reentrant_tell_test", newline="") + wrapper._CHUNK_SIZE = 8 + wrapper.read(5) + armed = True + self.assertIsInstance(wrapper.tell(), int) + # tell() at the snapshot boundary takes the early return that owns and + # must release next_input; exercise it too (leak-checked under -R). + wrapper.seek(0) + self.assertIsInstance(wrapper.tell(), int) + class PyTextIOWrapperTest(TextIOWrapperTest, PyTestCase): shutdown_error = "LookupError: unknown encoding: ascii" diff --git a/Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst b/Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst new file mode 100644 index 00000000000000..035f757cbc2336 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst @@ -0,0 +1,2 @@ +Fix a crash in the C implementation of :meth:`io.TextIOWrapper.tell` when the +decoder's ``getstate`` method triggers a reentrant seek. Patch by tonghuaroot. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 5b2a20a30c28cb..feab9334779d6d 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -2823,7 +2823,7 @@ _io_TextIOWrapper_tell_impl(textio *self) PyObject *res; PyObject *posobj = NULL; cookie_type cookie = {0,0,0,0,0}; - PyObject *next_input; + PyObject *next_input = NULL; Py_ssize_t chars_to_skip, chars_decoded; Py_ssize_t skip_bytes, skip_back; PyObject *saved_state = NULL; @@ -2875,11 +2875,15 @@ _io_TextIOWrapper_tell_impl(textio *self) assert (PyBytes_Check(next_input)); + /* Own next_input: a reentrant seek in the decoder can drop self->snapshot. */ + Py_INCREF(next_input); + cookie.start_pos -= PyBytes_GET_SIZE(next_input); /* How many decoded characters have been used up since the snapshot? */ if (self->decoded_chars_used == 0) { /* We haven't moved from the snapshot point. */ + Py_DECREF(next_input); return textiowrapper_build_cookie(&cookie); } @@ -3020,6 +3024,7 @@ _io_TextIOWrapper_tell_impl(textio *self) } finally: + Py_XDECREF(next_input); res = PyObject_CallMethodOneArg( self->decoder, &_Py_ID(setstate), saved_state); Py_DECREF(saved_state); @@ -3032,6 +3037,7 @@ _io_TextIOWrapper_tell_impl(textio *self) return textiowrapper_build_cookie(&cookie); fail: + Py_XDECREF(next_input); if (saved_state) { PyObject *exc = PyErr_GetRaisedException(); res = PyObject_CallMethodOneArg(