Crash report
What happened?
Modules/_io/bufferedio.c does not re-validate self->raw after executing arbitrary Python code.
This reproduces on released CPython 3.14.4 as well as the current main branch.
The trigger is ordinary Python: a BufferedReader, BufferedWriter, or BufferedRandom subclass that calls detach() from inside a callback (flush(), read(), readinto(), write(), etc.) while the original C method is still executing.
_io__Buffered_detach_impl() sets
self->raw = NULL;
self->detached = 1;
self->ok = 0;
Several functions in Modules/_io/bufferedio.c then continue and dispatch through self->raw without re-validating it.
The six reproduced NULL-receiver dispatch sites are:
| Line |
Function |
| 591 |
_io__Buffered_close_impl |
| 788 |
_buffered_raw_tell |
| 1485 |
_io__Buffered_truncate_impl |
| 1640 |
_bufferedreader_raw_read |
| 1748 |
_bufferedreader_read_all |
| 1996 |
_bufferedwriter_raw_write |
A related site exists at:
| Line |
Function |
Result |
| 818 |
_buffered_raw_seek |
SystemError (not a crash) |
_buffered_raw_seek() uses PyObject_CallMethodObjArgs(), which NULL-checks its receiver and therefore reports a contract violation (SystemError: null argument to internal routine). The six crashing sites instead use PyObject_CallMethodNoArgs() or PyObject_CallMethodOneArg(), which proceed directly into _PyObject_GetMethodStackRef() and dereference Py_TYPE(NULL).
Representative AddressSanitizer output:
AddressSanitizer: SEGV on unknown address 0x000000000008
#0 _PyObject_GetMethodStackRef Objects/object.c
#1 PyObject_VectorcallMethod Objects/call.c
#2 PyObject_CallMethodNoArgs
#3 _io__Buffered_close_impl
0x8 is the Py_TYPE offset from a NULL receiver.
The pure-Python implementation (_pyio) does not crash on any of these inputs. Depending on the code path it raises an exception or completes normally, demonstrating that the operation is survivable and that the crash is specific to the C implementation.
Prior art
This appears to be the same bug shape that was fixed in Modules/_io/textio.c by gh-143008 (commit db4b1948bc4, PR #145957), which introduced buffer_access_safe() with the comment:
self->buffer can be detached (set to NULL) by any user code that is called leading to NULL pointer dereferences
That change modified textio.c, its clinic header, tests, and NEWS entries, but did not touch bufferedio.c.
Within bufferedio.c itself, _io__Buffered__dealloc_warn_impl() (around line 489) is already the only self->raw dispatch in the file that re-checks self->raw before using it (if (self->ok && self->raw)).
The corresponding fix in bufferedio.c appears to be mechanical: introduce a raw_access_safe(buffered *self) mirroring buffer_access_safe() and use it for every self->raw dispatch that follows code capable of executing arbitrary Python.
This report is not the same bug as gh-143375 / PR #143577. That change reordered checks in _io__Buffered_seek_impl() around PyNumber_AsOff_t() and CHECK_CLOSED(). The crashes reported here occur after re-entrant callbacks (flush(), read(), readinto(), write(), etc.) have detached the raw stream.
Minimal reproducer
The following is sufficient to reproduce one representative crash by simply copying and pasting it into a CPython interpreter:
import io
class B(io.BufferedWriter):
armed = True
def flush(self):
if self.armed:
self.armed = False
super().detach()
B(io.BytesIO()).close()
Under an AddressSanitizer build this crashes with:
AddressSanitizer: SEGV on unknown address 0x000000000008
#0 _PyObject_GetMethodStackRef Objects/object.c
#1 PyObject_VectorcallMethod Objects/call.c
#2 PyObject_CallMethodNoArgs
#3 _io__Buffered_close_impl
The comprehensive reproducer below exercises every investigated call site.
Reproducer
Save the following as repro_bufferedio_stale_raw.py.
To reproduce one representative crash:
./python -c "$(./python repro_bufferedio_stale_raw.py close:591 io)"
To exercise every investigated site:
./python repro_bufferedio_stale_raw.py --all
Representative output:
site C source C function C (io) twin (_pyio)
close:591 bufferedio.c:591 _io__Buffered_close_impl SIGSEGV 3/3 exc 3/3
raw_read:1640 bufferedio.c:1640 _bufferedreader_raw_read SIGSEGV 3/3 exc 3/3
read_all:1748 bufferedio.c:1748 _bufferedreader_read_all SIGSEGV 3/3 exc 3/3
raw_write:1996 bufferedio.c:1996 _bufferedwriter_raw_write SIGSEGV 3/3 exc 3/3
truncate:1485 bufferedio.c:1485 _io__Buffered_truncate_impl SIGSEGV 3/3 exc 3/3
raw_tell:788 bufferedio.c:788 _buffered_raw_tell SIGSEGV 3/3 ok 3/3
raw_seek:818 bufferedio.c:818 _buffered_raw_seek exc 3/3 ok 3/3
raw_seek:818 intentionally soft-fails with SystemError because it dispatches through PyObject_CallMethodObjArgs(). It should not be interpreted as evidence that the stale-self->raw shape is already handled.
import argparse
import subprocess
import sys
# site -> (C file:line, C function, driver source)
SITES = {
"close:591": ("bufferedio.c:591", "_io__Buffered_close_impl", """
class B(io.BufferedWriter):
armed = True
def flush(self):
if self.armed:
self.armed = False
super().detach()
B(io.BytesIO()).close()
"""),
"raw_read:1640": ("bufferedio.c:1640", "_bufferedreader_raw_read", """
class Raw(io.RawIOBase):
def readable(self): return True
def readinto(self, b):
fire()
b[0:1] = b"a"
return 1
b = mk(io.BufferedReader, Raw(), 4)
b.read(64)
"""),
"read_all:1748": ("bufferedio.c:1748", "_bufferedreader_read_all", """
class Duck: # duck-typed: no RawIOBase.readall
closed = False
n = 0
def readable(self): return True
def writable(self): return False
def seekable(self): return False
def close(self): pass
def flush(self): pass
def read(self, *a):
Duck.n += 1
if Duck.n == 1:
fire()
return b"abc" if Duck.n < 3 else b""
def readinto(self, buf):
d = self.read()
buf[0:len(d)] = d
return len(d)
b = mk(io.BufferedReader, Duck(), 4)
b.read()
"""),
"raw_write:1996": ("bufferedio.c:1996", "_bufferedwriter_raw_write", """
class Raw(io.RawIOBase):
def writable(self): return True
def write(self, b):
fire()
return 1 # PARTIAL -> the flush loop iterates
b = mk(io.BufferedWriter, Raw(), 4)
b.write(b"0123456789abcdef")
"""),
"truncate:1485": ("bufferedio.c:1485", "_io__Buffered_truncate_impl", """
class Raw(io.RawIOBase):
def readable(self): return False # skip _buffered_raw_seek
def writable(self): return True
def seekable(self): return True
def tell(self): return 0
def seek(self, p, w=0): return 0
def truncate(self, p=None): return 0
def write(self, b):
fire()
return len(b)
b = mk(io.BufferedWriter, Raw(), 64)
b.write(b"012")
b.truncate(1)
"""),
"raw_tell:788": ("bufferedio.c:788", "_buffered_raw_tell", """
class Raw(io.RawIOBase):
def readable(self): return False
def writable(self): return True
def seekable(self): return True
def tell(self): return 0
def seek(self, p, w=0): return 0
def write(self, b): return len(b)
def truncate(self, p=None):
fire() # detach from raw.truncate, AFTER :1485
return 0
b = mk(io.BufferedWriter, Raw(), 64)
b.write(b"012")
b.truncate(1) # crashes at :1489 -> _buffered_raw_tell:788
"""),
# not a crash -- the contract-violation sibling, kept for completeness
"raw_seek:818": ("bufferedio.c:818", "_buffered_raw_seek", """
class Raw(io.RawIOBase):
def readable(self): return True
def writable(self): return True
def seekable(self): return True
def tell(self): return 0
def seek(self, p, w=0): return 0
def readinto(self, b): b[0:1] = b"a"; return 1
def write(self, b):
fire()
return len(b)
b = mk(io.BufferedRandom, Raw(), 64)
b.write(b"012")
b.seek(0)
"""),
}
HEADER = """\
import sys
{importline}
_state = {{}}
def mk(cls, raw, bufsize):
class B(cls):
def flush(self): # keeps detach()'s _PyFile_Flush off the lock
return None
b = B(raw, buffer_size=bufsize)
_state["b"] = b
return b
def fire():
if _state.get("fired") or "b" not in _state:
return
_state["fired"] = True
try:
_state["b"].detach()
print("[detached]", file=sys.stderr)
except BaseException as exc:
print("[detach raised %s]" % type(exc).__name__, file=sys.stderr)
"""
def build(site, backend):
importline = "import io" if backend == "io" else "import _pyio as io"
import textwrap
return HEADER.format(importline=importline) + textwrap.dedent(SITES[site][2])
def main():
ap = argparse.ArgumentParser()
ap.add_argument("site", nargs="?")
ap.add_argument("backend", nargs="?", default="io")
ap.add_argument("--all", action="store_true")
ap.add_argument("--python", default=sys.executable)
ap.add_argument("-n", type=int, default=3)
args = ap.parse_args()
if not args.all:
if not args.site:
for s, (loc, fn, _) in SITES.items():
print("%-16s %-22s %s" % (s, loc, fn))
return 0
sys.stdout.write(build(args.site, args.backend))
return 0
print("%-16s %-22s %-34s %-14s %s"
% ("site", "C source", "C function", "C (io)", "twin (_pyio)"))
worst = 0
for site, (loc, fn, _) in SITES.items():
cells = []
for backend in ("io", "_pyio"):
rcs = []
for _ in range(args.n):
p = subprocess.run([args.python, "-c", build(site, backend)],
capture_output=True, text=True, timeout=90)
rcs.append(p.returncode)
uniq = sorted(set(rcs))
tag = {-11: "SIGSEGV", -6: "SIGABRT", 0: "ok"}.get(uniq[0], "exc")
if backend == "io" and uniq[0] < 0:
worst = 1
cells.append("%s %d/%d" % (tag, rcs.count(uniq[0]), len(rcs)))
print("%-16s %-22s %-34s %-14s %s" % (site, loc, fn, cells[0], cells[1]))
return worst
if __name__ == "__main__":
raise SystemExit(main())
CPython versions tested on
- CPython 3.14.4 (released)
- CPython main branch
Operating systems tested on
Output from running python -VV
Python 3.16.0a0 (heads/investigate-bufferedio-raw-detach:6a139d6e548, Jul 31 2026, 14:34:37) [GCC 13.3.0]
Linked PRs
Crash report
What happened?
Modules/_io/bufferedio.cdoes not re-validateself->rawafter executing arbitrary Python code.This reproduces on released CPython 3.14.4 as well as the current main branch.
The trigger is ordinary Python: a
BufferedReader,BufferedWriter, orBufferedRandomsubclass that callsdetach()from inside a callback (flush(),read(),readinto(),write(), etc.) while the original C method is still executing._io__Buffered_detach_impl()setsSeveral functions in
Modules/_io/bufferedio.cthen continue and dispatch throughself->rawwithout re-validating it.The six reproduced NULL-receiver dispatch sites are:
_io__Buffered_close_impl_buffered_raw_tell_io__Buffered_truncate_impl_bufferedreader_raw_read_bufferedreader_read_all_bufferedwriter_raw_writeA related site exists at:
_buffered_raw_seekSystemError(not a crash)_buffered_raw_seek()usesPyObject_CallMethodObjArgs(), which NULL-checks its receiver and therefore reports a contract violation (SystemError: null argument to internal routine). The six crashing sites instead usePyObject_CallMethodNoArgs()orPyObject_CallMethodOneArg(), which proceed directly into_PyObject_GetMethodStackRef()and dereferencePy_TYPE(NULL).Representative AddressSanitizer output:
0x8is thePy_TYPEoffset from a NULL receiver.The pure-Python implementation (
_pyio) does not crash on any of these inputs. Depending on the code path it raises an exception or completes normally, demonstrating that the operation is survivable and that the crash is specific to the C implementation.Prior art
This appears to be the same bug shape that was fixed in
Modules/_io/textio.cby gh-143008 (commitdb4b1948bc4, PR #145957), which introducedbuffer_access_safe()with the comment:That change modified
textio.c, its clinic header, tests, and NEWS entries, but did not touchbufferedio.c.Within
bufferedio.citself,_io__Buffered__dealloc_warn_impl()(around line 489) is already the onlyself->rawdispatch in the file that re-checksself->rawbefore using it (if (self->ok && self->raw)).The corresponding fix in
bufferedio.cappears to be mechanical: introduce araw_access_safe(buffered *self)mirroringbuffer_access_safe()and use it for everyself->rawdispatch that follows code capable of executing arbitrary Python.This report is not the same bug as gh-143375 / PR #143577. That change reordered checks in
_io__Buffered_seek_impl()aroundPyNumber_AsOff_t()andCHECK_CLOSED(). The crashes reported here occur after re-entrant callbacks (flush(),read(),readinto(),write(), etc.) have detached the raw stream.Minimal reproducer
The following is sufficient to reproduce one representative crash by simply copying and pasting it into a CPython interpreter:
Under an AddressSanitizer build this crashes with:
The comprehensive reproducer below exercises every investigated call site.
Reproducer
Save the following as
repro_bufferedio_stale_raw.py.To reproduce one representative crash:
./python -c "$(./python repro_bufferedio_stale_raw.py close:591 io)"To exercise every investigated site:
Representative output:
raw_seek:818intentionally soft-fails withSystemErrorbecause it dispatches throughPyObject_CallMethodObjArgs(). It should not be interpreted as evidence that the stale-self->rawshape is already handled.CPython versions tested on
Operating systems tested on
Output from running
python -VVLinked PRs