https://github.com/python/cpython/commit/d3fe3de54b50b96b9b96a18195128f55093d7c30
commit: d3fe3de54b50b96b9b96a18195128f55093d7c30
branch: 3.14
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-21T15:53:33Z
summary:

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

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.

(cherry picked from commit fa0ec86ab682a561e2dc6f50127f2a78f76ce311)

Co-authored-by: tonghuaroot (童话) <[email protected]>

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

diff --git a/Lib/test/test_io.py b/Lib/test/test_io.py
index 262fb86a3cc599..d4b11b5bf01936 100644
--- a/Lib/test/test_io.py
+++ b/Lib/test/test_io.py
@@ -4099,6 +4099,49 @@ class CTextIOWrapperTest(TextIOWrapperTest):
     io = io
     shutdown_error = "LookupError: unknown encoding: ascii"
 
+    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)
+
     def test_chunk_size(self):
         t = self.TextIOWrapper(self.BytesIO(), encoding="utf-8")
         self.assertGreater(t._CHUNK_SIZE, 0)
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..241d6794968092
--- /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 4815aa26ab5986..8750c01bf17772 100644
--- a/Modules/_io/textio.c
+++ b/Modules/_io/textio.c
@@ -2745,7 +2745,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;
@@ -2797,11 +2797,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);
     }
 
@@ -2942,6 +2946,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);
@@ -2954,6 +2959,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]

Reply via email to