Skip to content

Commit d3fe3de

Browse files
[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 fa0ec86) Co-authored-by: tonghuaroot (童话) <tonghuaroot@gmail.com>
1 parent ccaacd8 commit d3fe3de

3 files changed

Lines changed: 53 additions & 1 deletion

File tree

Lib/test/test_io.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4099,6 +4099,49 @@ class CTextIOWrapperTest(TextIOWrapperTest):
40994099
io = io
41004100
shutdown_error = "LookupError: unknown encoding: ascii"
41014101

4102+
def test_reentrant_seek_during_tell(self):
4103+
# gh-153539: reading short of _CHUNK_SIZE leaves residual bytes in the
4104+
# snapshot, so tell() re-decodes and calls the decoder's getstate(); a
4105+
# reentrant seek() there must not free the snapshot tell() still uses.
4106+
# C-only: _pyio binds next_input as a strong local and cannot crash.
4107+
wrapper = None
4108+
armed = False
4109+
4110+
class ReentrantDecoder(codecs.IncrementalDecoder):
4111+
def decode(self, input, final=False):
4112+
return bytes(input).decode("latin-1")
4113+
def getstate(self):
4114+
nonlocal armed
4115+
if wrapper is not None and armed:
4116+
armed = False
4117+
wrapper.seek(0)
4118+
return (b"", 0)
4119+
def setstate(self, state):
4120+
pass
4121+
4122+
def search(name):
4123+
if name != "reentrant_tell_test":
4124+
return None
4125+
return codecs.CodecInfo(
4126+
name=name,
4127+
encode=lambda s, e='strict': (s.encode("latin-1"), len(s)),
4128+
decode=lambda b, e='strict': (bytes(b).decode("latin-1"), len(b)),
4129+
incrementaldecoder=ReentrantDecoder)
4130+
4131+
codecs.register(search)
4132+
self.addCleanup(codecs.unregister, search)
4133+
raw = self.BytesIO(b"abcdefghijklmnop" * 8)
4134+
wrapper = self.TextIOWrapper(self.BufferedReader(raw),
4135+
encoding="reentrant_tell_test", newline="")
4136+
wrapper._CHUNK_SIZE = 8
4137+
wrapper.read(5)
4138+
armed = True
4139+
self.assertIsInstance(wrapper.tell(), int)
4140+
# tell() at the snapshot boundary takes the early return that owns and
4141+
# must release next_input; exercise it too (leak-checked under -R).
4142+
wrapper.seek(0)
4143+
self.assertIsInstance(wrapper.tell(), int)
4144+
41024145
def test_chunk_size(self):
41034146
t = self.TextIOWrapper(self.BytesIO(), encoding="utf-8")
41044147
self.assertGreater(t._CHUNK_SIZE, 0)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix a crash in the C implementation of :meth:`io.TextIOWrapper.tell` when the
2+
decoder's ``getstate`` method triggers a reentrant seek, or when another thread
3+
seeks the same stream concurrently. Patch by tonghuaroot.

Modules/_io/textio.c

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2745,7 +2745,7 @@ _io_TextIOWrapper_tell_impl(textio *self)
27452745
PyObject *res;
27462746
PyObject *posobj = NULL;
27472747
cookie_type cookie = {0,0,0,0,0};
2748-
PyObject *next_input;
2748+
PyObject *next_input = NULL;
27492749
Py_ssize_t chars_to_skip, chars_decoded;
27502750
Py_ssize_t skip_bytes, skip_back;
27512751
PyObject *saved_state = NULL;
@@ -2797,11 +2797,15 @@ _io_TextIOWrapper_tell_impl(textio *self)
27972797

27982798
assert (PyBytes_Check(next_input));
27992799

2800+
/* Own next_input: a reentrant or concurrent seek can drop the snapshot. */
2801+
Py_INCREF(next_input);
2802+
28002803
cookie.start_pos -= PyBytes_GET_SIZE(next_input);
28012804

28022805
/* How many decoded characters have been used up since the snapshot? */
28032806
if (self->decoded_chars_used == 0) {
28042807
/* We haven't moved from the snapshot point. */
2808+
Py_DECREF(next_input);
28052809
return textiowrapper_build_cookie(&cookie);
28062810
}
28072811

@@ -2942,6 +2946,7 @@ _io_TextIOWrapper_tell_impl(textio *self)
29422946
}
29432947

29442948
finally:
2949+
Py_XDECREF(next_input);
29452950
res = PyObject_CallMethodOneArg(
29462951
self->decoder, &_Py_ID(setstate), saved_state);
29472952
Py_DECREF(saved_state);
@@ -2954,6 +2959,7 @@ _io_TextIOWrapper_tell_impl(textio *self)
29542959
return textiowrapper_build_cookie(&cookie);
29552960

29562961
fail:
2962+
Py_XDECREF(next_input);
29572963
if (saved_state) {
29582964
PyObject *exc = PyErr_GetRaisedException();
29592965
res = PyObject_CallMethodOneArg(

0 commit comments

Comments
 (0)