NULL pointer dereference in BufferedIO methods after re-entrant detach()
Personne n'a encore pris cette issue.
- Langage dominant
- Python
- Étoiles
- 77.2k
- Forks
- 35.9k
- Métriques de merge des PR
- Métriques de PR en attente
Description
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
Guide de contribution
Ouvrir le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Piste de recherche
Commencez par Modules/_io/bufferedio.c et les sites de dispatch de BufferedIO indiqués, puis exécutez repro_bufferedio_stale_raw.py avec l’implémentation C et en comparaison avec _pyio. C’est terminé lorsque les cas de detach réentrants ne provoquent plus de crash et que le comportement existant reste couvert par des tests de régression ; gh-155017 est déjà lié à cette issue.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Évaluation
- Stack technique
- c, python
- Domaine
- backend
- Type d'issue
- Bug
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Activité
- À l'abandon
- Clarté
- Clairement spécifiée
- Accessibilité débutants
- 25/100