python / python/cpython

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

未关闭
#154,997 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

interpreter-core topic-IO type-crash
主要语言
Python
星标
77.2k
派生
35.9k
PR 合并指标
PR 指标待抓取

描述

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

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

从 Modules/_io/bufferedio.c 和列出的 BufferedIO 分发位置开始,然后在 C 实现和 _pyio 对比下运行 repro_bufferedio_stale_raw.py。当可重入的 detach 情况不再崩溃,并且现有行为仍由回归测试覆盖时,即表示完成;gh-155017 已经关联到此 issue。

由索引模型根据 Issue 内容生成。

评估

技术栈
c, python
领域
backend
Issue 类型
缺陷
难度
4/5
预计耗时
3-5 天
活跃度
停滞
描述清晰度
描述清楚
新手友好度
25/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。