From 979a884a1b0a7bd45b78d94e8699cede87adfd77 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sat, 12 Sep 2026 21:07:27 +0300 Subject: [PATCH 1/6] fix(io): keep TextIOWrapper buffer alive during calls --- Lib/test/test_io/test_textio.py | 27 ++++++++++++ ...-09-12-18-03-15.gh-issue-157364.Qd7mKs.rst | 2 + Modules/_io/textio.c | 42 +++++++++++++------ 3 files changed, 59 insertions(+), 12 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 a210b81d877dc1..0fa6d5ee65bfed 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1633,6 +1633,33 @@ 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") + 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 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 00000000000000..3ca44d25b57910 --- /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 a744a885932cdf..f5aa3be011c9c2 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -753,43 +753,55 @@ buffer_access_safe(textio *self) return NULL; } - /* Returning a borrowed reference is safe since TextIOWrapper methods are - protected by critical sections. */ + /* The critical section protects this borrowed reference until the caller + can acquire its own reference. */ _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(self); return self->buffer; } +static PyObject * +buffer_acquire_safe(textio *self) +{ + return Py_XNewRef(buffer_access_safe(self)); +} + static PyObject * buffer_getattr(textio *self, PyObject *attr_name) { - PyObject *buffer = buffer_access_safe(self); + PyObject *buffer = buffer_acquire_safe(self); if (buffer == NULL) { return NULL; } - return PyObject_GetAttr(buffer, attr_name); + PyObject *res = PyObject_GetAttr(buffer, attr_name); + Py_DECREF(buffer); + return res; } static PyObject * buffer_callmethod_noargs(textio *self, PyObject *name) { - PyObject *buffer = buffer_access_safe(self); + PyObject *buffer = buffer_acquire_safe(self); if (buffer == NULL) { return NULL; } - return PyObject_CallMethodNoArgs(buffer, name); + PyObject *res = PyObject_CallMethodNoArgs(buffer, name); + Py_DECREF(buffer); + return res; } static PyObject * buffer_callmethod_onearg(textio *self, PyObject *name, PyObject *arg) { - PyObject *buffer = buffer_access_safe(self); + PyObject *buffer = buffer_acquire_safe(self); if (buffer == NULL) { return NULL; } - return PyObject_CallMethodOneArg(buffer, name, arg); + PyObject *res = PyObject_CallMethodOneArg(buffer, name, arg); + Py_DECREF(buffer); + return res; } static void @@ -1862,8 +1874,13 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text) } if (needflush) { - PyObject *buffer = buffer_access_safe(self); - if (buffer == NULL || _PyFile_Flush(buffer) < 0) { + PyObject *buffer = buffer_acquire_safe(self); + if (buffer == NULL) { + return NULL; + } + int res = _PyFile_Flush(buffer); + Py_DECREF(buffer); + if (res < 0) { return NULL; } } @@ -2677,11 +2694,12 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) Py_DECREF(res); } - PyObject *buf = buffer_access_safe(self); + PyObject *buf = buffer_acquire_safe(self); if (buf == NULL) { goto fail; } res = _PyObject_CallMethod(buf, &_Py_ID(seek), "ii", 0, 2); + Py_DECREF(buf); Py_CLEAR(cookieObj); if (res == NULL) goto fail; @@ -3439,7 +3457,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_acquire_safe(self); } static PyMethodDef incrementalnewlinedecoder_methods[] = { From c3755e49e4504417b1df1818cd89da0ff61b7464 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Sun, 13 Sep 2026 10:58:42 +0300 Subject: [PATCH 2/6] refactor(io): return strong ref from buffer_access_safe --- Modules/_io/textio.c | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index f5aa3be011c9c2..0520ce674c49b0 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -753,22 +753,14 @@ buffer_access_safe(textio *self) return NULL; } - /* The critical section protects this borrowed reference until the caller - can acquire its own reference. */ _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(self); - return self->buffer; -} - -static PyObject * -buffer_acquire_safe(textio *self) -{ - return Py_XNewRef(buffer_access_safe(self)); + return Py_NewRef(self->buffer); } static PyObject * buffer_getattr(textio *self, PyObject *attr_name) { - PyObject *buffer = buffer_acquire_safe(self); + PyObject *buffer = buffer_access_safe(self); if (buffer == NULL) { return NULL; } @@ -781,7 +773,7 @@ buffer_getattr(textio *self, PyObject *attr_name) static PyObject * buffer_callmethod_noargs(textio *self, PyObject *name) { - PyObject *buffer = buffer_acquire_safe(self); + PyObject *buffer = buffer_access_safe(self); if (buffer == NULL) { return NULL; } @@ -794,7 +786,7 @@ buffer_callmethod_noargs(textio *self, PyObject *name) static PyObject * buffer_callmethod_onearg(textio *self, PyObject *name, PyObject *arg) { - PyObject *buffer = buffer_acquire_safe(self); + PyObject *buffer = buffer_access_safe(self); if (buffer == NULL) { return NULL; } @@ -1651,8 +1643,8 @@ _io_TextIOWrapper_detach_impl(textio *self) if (buffer == NULL) { return NULL; } - self->buffer = NULL; self->detached = 1; + Py_CLEAR(self->buffer); return buffer; } @@ -1874,7 +1866,7 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text) } if (needflush) { - PyObject *buffer = buffer_acquire_safe(self); + PyObject *buffer = buffer_access_safe(self); if (buffer == NULL) { return NULL; } @@ -2694,7 +2686,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) Py_DECREF(res); } - PyObject *buf = buffer_acquire_safe(self); + PyObject *buf = buffer_access_safe(self); if (buf == NULL) { goto fail; } @@ -3457,7 +3449,7 @@ static PyObject * _io_TextIOWrapper_buffer_get_impl(textio *self) /*[clinic end generated code: output=d265a34555aa5d4b input=5951cfa148f7350a]*/ { - return buffer_acquire_safe(self); + return buffer_access_safe(self); } static PyMethodDef incrementalnewlinedecoder_methods[] = { From 976304399ac8677a045a238ae03fe477c2b9bf80 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Tue, 22 Sep 2026 14:07:42 +0300 Subject: [PATCH 3/6] fix(io): keep BufferedReader alive via readinto memoryview --- Lib/test/test_io/test_bufferedio.py | 21 ++++++++++++++++ ...-09-12-18-03-15.gh-issue-157364.Qd7mKs.rst | 4 +++- Modules/_io/bufferedio.c | 24 +++++++++++++++---- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_io/test_bufferedio.py b/Lib/test/test_io/test_bufferedio.py index e83dd0d4e28d00..7f226219700f5b 100644 --- a/Lib/test/test_io/test_bufferedio.py +++ b/Lib/test/test_io/test_bufferedio.py @@ -607,6 +607,27 @@ def test_args_error(self): with self.assertRaisesRegex(TypeError, "BufferedReader"): self.tp(self.BytesIO(), 1024, 1024, 1024) + def test_readinto_buffer_keeps_reader_alive(self): + # gh-157364: raw.readinto() may store the memoryview it is given, so + # the memoryview must keep the BufferedReader owning its memory alive. + stored = [] + + class StoringRawIO(self.MockRawIO): + def readinto(self, b): + stored.append(b) + return super().readinto(b) + + bufio = self.tp(StoringRawIO([b"abc"])) + self.assertEqual(bufio.read(3), b"abc") + self.assertIs(stored[0].obj, bufio) + wr = weakref.ref(bufio) + del bufio + support.gc_collect() + self.assertIsNotNone(wr()) + stored.clear() + support.gc_collect() + self.assertIsNone(wr()) + def test_bad_readinto_value(self): rawio = self.tp(self.BytesIO(b"12")) rawio.readinto = lambda buf: -1 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 index 3ca44d25b57910..a3582d7186e329 100644 --- 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 @@ -1,2 +1,4 @@ Fix a use-after-free in :class:`io.TextIOWrapper` when a call to the -underlying buffer reentrantly detaches it. Patched by Shamil Abdulaev. +underlying buffer reentrantly detaches it. The :class:`memoryview` that +:class:`io.BufferedReader` passes to the raw stream's ``readinto()`` now keeps +the reader alive. Patched by Shamil Abdulaev. diff --git a/Modules/_io/bufferedio.c b/Modules/_io/bufferedio.c index 5537947f6a51c1..7468aed1086d8f 100644 --- a/Modules/_io/bufferedio.c +++ b/Modules/_io/bufferedio.c @@ -10,6 +10,7 @@ #include "Python.h" #include "pycore_call.h" // _PyObject_CallNoArgs() #include "pycore_fileutils.h" // _PyFile_Flush +#include "pycore_memoryobject.h" // _PyMemoryView_FromBufferProc() #include "pycore_object.h" // _PyObject_GC_UNTRACK() #include "pycore_pyerrors.h" // _Py_FatalErrorFormat() #include "pycore_pylifecycle.h" // _Py_IsInterpreterFinalizing() @@ -254,6 +255,10 @@ typedef struct { isn't ready for writing. */ Py_off_t write_end; + /* Region handed to the raw stream by _bufferedreader_raw_read(). */ + char *raw_read_start; + Py_ssize_t raw_read_len; + PyThread_type_lock lock; volatile unsigned long owner; @@ -1619,16 +1624,25 @@ _io_BufferedReader___init___impl(buffered *self, PyObject *raw, return 0; } +/* The memoryview passed to raw.readinto() owns a reference to the buffered + object so that storing it in Python code cannot outlive its memory. */ +static int +_bufferedreader_raw_read_getbuffer(PyObject *op, Py_buffer *view, int flags) +{ + buffered *self = buffered_CAST(op); + return PyBuffer_FillInfo(view, op, self->raw_read_start, + self->raw_read_len, 0, flags); +} + static Py_ssize_t _bufferedreader_raw_read(buffered *self, char *start, Py_ssize_t len) { - Py_buffer buf; PyObject *memobj, *res; Py_ssize_t n; - /* NOTE: the buffer needn't be released as its object is NULL. */ - if (PyBuffer_FillInfo(&buf, NULL, start, len, 0, PyBUF_CONTIG) == -1) - return -1; - memobj = PyMemoryView_FromBuffer(&buf); + self->raw_read_start = start; + self->raw_read_len = len; + memobj = _PyMemoryView_FromBufferProc((PyObject *)self, PyBUF_CONTIG, + _bufferedreader_raw_read_getbuffer); if (memobj == NULL) return -1; /* NOTE: PyErr_SetFromErrno() calls PyErr_CheckSignals() when EINTR From 7e1eedd3b62ca90cf93d4d8003f81f03437e25dc Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Wed, 7 Oct 2026 00:32:34 +0300 Subject: [PATCH 4/6] fix(io): scope UAF patch to TextIOWrapper --- Lib/test/test_io/test_bufferedio.py | 21 ---------------- Lib/test/test_io/test_textio.py | 3 +++ ...-09-12-18-03-15.gh-issue-157364.Qd7mKs.rst | 4 +--- Modules/_io/bufferedio.c | 24 ++++--------------- 4 files changed, 9 insertions(+), 43 deletions(-) diff --git a/Lib/test/test_io/test_bufferedio.py b/Lib/test/test_io/test_bufferedio.py index 7f226219700f5b..e83dd0d4e28d00 100644 --- a/Lib/test/test_io/test_bufferedio.py +++ b/Lib/test/test_io/test_bufferedio.py @@ -607,27 +607,6 @@ def test_args_error(self): with self.assertRaisesRegex(TypeError, "BufferedReader"): self.tp(self.BytesIO(), 1024, 1024, 1024) - def test_readinto_buffer_keeps_reader_alive(self): - # gh-157364: raw.readinto() may store the memoryview it is given, so - # the memoryview must keep the BufferedReader owning its memory alive. - stored = [] - - class StoringRawIO(self.MockRawIO): - def readinto(self, b): - stored.append(b) - return super().readinto(b) - - bufio = self.tp(StoringRawIO([b"abc"])) - self.assertEqual(bufio.read(3), b"abc") - self.assertIs(stored[0].obj, bufio) - wr = weakref.ref(bufio) - del bufio - support.gc_collect() - self.assertIsNotNone(wr()) - stored.clear() - support.gc_collect() - self.assertIsNone(wr()) - def test_bad_readinto_value(self): rawio = self.tp(self.BytesIO(b"12")) rawio.readinto = lambda buf: -1 diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index 0fa6d5ee65bfed..d499aa49c071be 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1659,6 +1659,9 @@ def readinto(self, b): 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"): + wrapper.buffer def test_reentrant_seek_during_tell(self): # gh-153539: reading short of _CHUNK_SIZE leaves residual bytes in the 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 index a3582d7186e329..3ca44d25b57910 100644 --- 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 @@ -1,4 +1,2 @@ Fix a use-after-free in :class:`io.TextIOWrapper` when a call to the -underlying buffer reentrantly detaches it. The :class:`memoryview` that -:class:`io.BufferedReader` passes to the raw stream's ``readinto()`` now keeps -the reader alive. Patched by Shamil Abdulaev. +underlying buffer reentrantly detaches it. Patched by Shamil Abdulaev. diff --git a/Modules/_io/bufferedio.c b/Modules/_io/bufferedio.c index 7468aed1086d8f..5537947f6a51c1 100644 --- a/Modules/_io/bufferedio.c +++ b/Modules/_io/bufferedio.c @@ -10,7 +10,6 @@ #include "Python.h" #include "pycore_call.h" // _PyObject_CallNoArgs() #include "pycore_fileutils.h" // _PyFile_Flush -#include "pycore_memoryobject.h" // _PyMemoryView_FromBufferProc() #include "pycore_object.h" // _PyObject_GC_UNTRACK() #include "pycore_pyerrors.h" // _Py_FatalErrorFormat() #include "pycore_pylifecycle.h" // _Py_IsInterpreterFinalizing() @@ -255,10 +254,6 @@ typedef struct { isn't ready for writing. */ Py_off_t write_end; - /* Region handed to the raw stream by _bufferedreader_raw_read(). */ - char *raw_read_start; - Py_ssize_t raw_read_len; - PyThread_type_lock lock; volatile unsigned long owner; @@ -1624,25 +1619,16 @@ _io_BufferedReader___init___impl(buffered *self, PyObject *raw, return 0; } -/* The memoryview passed to raw.readinto() owns a reference to the buffered - object so that storing it in Python code cannot outlive its memory. */ -static int -_bufferedreader_raw_read_getbuffer(PyObject *op, Py_buffer *view, int flags) -{ - buffered *self = buffered_CAST(op); - return PyBuffer_FillInfo(view, op, self->raw_read_start, - self->raw_read_len, 0, flags); -} - static Py_ssize_t _bufferedreader_raw_read(buffered *self, char *start, Py_ssize_t len) { + Py_buffer buf; PyObject *memobj, *res; Py_ssize_t n; - self->raw_read_start = start; - self->raw_read_len = len; - memobj = _PyMemoryView_FromBufferProc((PyObject *)self, PyBUF_CONTIG, - _bufferedreader_raw_read_getbuffer); + /* NOTE: the buffer needn't be released as its object is NULL. */ + if (PyBuffer_FillInfo(&buf, NULL, start, len, 0, PyBUF_CONTIG) == -1) + return -1; + memobj = PyMemoryView_FromBuffer(&buf); if (memobj == NULL) return -1; /* NOTE: PyErr_SetFromErrno() calls PyErr_CheckSignals() when EINTR From aa9a1794ec25fbbb7e0bb7283fa595d2448f4710 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Wed, 7 Oct 2026 11:19:00 +0300 Subject: [PATCH 5/6] refactor(io): preserve detach state update order --- Modules/_io/textio.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 0520ce674c49b0..e9967a17f70507 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -1643,8 +1643,8 @@ _io_TextIOWrapper_detach_impl(textio *self) if (buffer == NULL) { return NULL; } - self->detached = 1; Py_CLEAR(self->buffer); + self->detached = 1; return buffer; } From e65401749ed0c99af717f3cda494d0c1b6d54468 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Wed, 7 Oct 2026 16:34:31 +0200 Subject: [PATCH 6/6] Apply suggestion from @vstinner --- Lib/test/test_io/test_textio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index d499aa49c071be..e22b74ef5beb14 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1661,7 +1661,7 @@ def readinto(self, b): self.assertEqual(method(), "ab\n") with self.assertRaisesRegex(ValueError, "underlying buffer has been detached"): - wrapper.buffer + getattr(wrapper, 'buffer') def test_reentrant_seek_during_tell(self): # gh-153539: reading short of _CHUNK_SIZE leaves residual bytes in the