NULL pointer dereference in BufferedIO methods after re-entrant detach()
還沒有人認領這個 Issue。
- 主要語言
- 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
貢獻指南
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
研究方向
從 Modules/_io/bufferedio.c 和列出的 BufferedIO dispatch 位置開始,然後在 C 實作和 _pyio 比對下執行 repro_bufferedio_stale_raw.py。當可重入的 detach 情況不再當機,且現有行為仍由回歸測試涵蓋時,即表示完成;gh-155017 已經連結到此 issue。
由索引模型根據 Issue 內容生成。
評估
- 技術堆疊
- c, python
- 領域
- backend
- Issue 類型
- 缺陷
- 難度
- 4/5
- 預估耗時
- 3-5 天
- 活躍度
- 停滯
- 描述清晰度
- 描述清楚
- 新手友好度
- 25/100