NULL pointer dereference in BufferedIO methods after re-entrant detach()
Nadie ha tomado este issue todavía.
- Lenguaje dominante
- Python
- Estrellas
- 77.2k
- Forks
- 35.9k
- Métricas de merge de PR
- Métricas de PR pendientes
Descripción
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
- Linux
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
- gh-155017
Guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Línea de trabajo
Comienza con Modules/_io/bufferedio.c y los puntos de despacho de BufferedIO indicados, y después ejecuta repro_bufferedio_stale_raw.py con la implementación en C y en la comparación con _pyio. Se considera completado cuando los casos de detach reentrantes ya no provocan fallos y el comportamiento existente sigue cubierto por pruebas de regresión; gh-155017 ya está vinculado a este issue.
Escrito por el modelo de indexación a partir del texto del issue.
Evaluación
- Stack tecnológico
- c, python
- Área
- backend
- Tipo de issue
- Error
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Estado de actividad
- Estancado
- Claridad
- Bien especificado
- Aptitud para principiantes
- 25/100