Skip to content

NULL pointer dereference in BufferedIO methods after re-entrant detach() #154997

Description

@BHUVANSH855

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)topic-IOtype-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions