gh-153539: Fix use-after-free in TextIOWrapper.tell() with a reentrant decoder (GH-153540)

serhiy-storchaka <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/fa0ec86ab682a561e2dc6f50127f2a78f76ce311
commit: fa0ec86ab682a561e2dc6f50127f2a78f76ce311
branch: main
author: tonghuaroot (童话) <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-17T21:13:33+03:00
summary:

gh-153539: Fix use-after-free in TextIOWrapper.tell() with a reentrant decoder (GH-153540)

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.

files:
A Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst
M Lib/test/test_io/test_textio.py
M Modules/_io/textio.c

diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py
index 82096ab09873955..07f6b1415d0dbda 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 000000000000000..241d67949680925
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst
@@ -0,0 +1,3 @@
+Fix a crash in the C implementation of :meth:`io.TextIOWrapper.tell` when the
+decoder's ``getstate`` method triggers a reentrant seek, or when another thread
+seeks the same stream concurrently. Patch by tonghuaroot.
diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c
index ea8ed2713d8a146..5b3d379e4e75543 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 or concurrent seek can drop the 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(

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.