Segfault: 12 types dereference NULL on `Type.__new__(Type)` without `__init__()`
メンテナーはふだん 4 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 48/100
調査の方向性
影響を受ける c-ext ファイルの PyType_Slot 定義と初期化経路から着手します。特に compressor.c、decompressor.c、compressionparams.c、compressionwriter.c、decompressionwriter.c、compressionreader.c、bufferutil.c、および factory-type ファイルを確認してください。変更の前後で、記載されている直接の 1 行再現コードと type() の 1 行再現コードを実行します。完了条件は、影響を受ける 13 個すべての型が init なしの構築を安全に処理し、通常の初期化とクリーンアップも引き続き正しく行われることです。
索引モデルが issue の本文から書いたものです。
説明
Summary
PyType_GenericNew creates zero-initialized instances, but zstandard's method implementations assume the C-level pointer fields (cctx, dctx, params, ...) are non-NULL. T.__new__(T).method(...) therefore segfaults for 12 of 13 extension types with nothing more than a standard-library import.
Impact
- Severity: Segfault (some paths hit an assertion abort on debug builds).
- Reachability: A single line of pure Python on a public type. No ZSTD-library involvement, no unusual input.
- Version: 0.25.0 (commit
7a77a75); pattern very likely present in prior releases as well. - Platform: Confirmed Linux x86_64 / CPython 3.14 debug; the bug is platform-independent.
Reproducers
Each one-liner segfaults:
import zstandard as zstd
zstd.ZstdCompressor.__new__(zstd.ZstdCompressor).compress(b'x')
zstd.ZstdDecompressor.__new__(zstd.ZstdDecompressor).decompress(b'x')
zstd.ZstdCompressionParameters.__new__(zstd.ZstdCompressionParameters).estimated_compression_context_size()
zstd.ZstdCompressionWriter.__new__(zstd.ZstdCompressionWriter).write(b'x')
zstd.ZstdDecompressionWriter.__new__(zstd.ZstdDecompressionWriter).write(b'x')
zstd.ZstdCompressionReader.__new__(zstd.ZstdCompressionReader).read(10)
zstd.BufferWithSegmentsCollection.__new__(zstd.BufferWithSegmentsCollection)[0]
Five more types aren't in the top-level namespace but are reachable via type() introspection and crash the same way:
c = zstd.ZstdCompressor()
T = type(c.compressobj()) # ZstdCompressionObj
T.__new__(T).compress(b'x') # segfault
# likewise: ZstdDecompressionObj, ZstdCompressorIterator,
# ZstdDecompressorIterator, ZstdCompressionChunker
ZstdDecompressionReader (the 13th affected type) does not crash — read() returns b'' because input.pos == input.size == 0 takes an early-return branch. The instance is still in an invalid state; any future method that doesn't short-circuit on this state will crash.
Root cause
All 13 type specs install {Py_tp_new, PyType_GenericNew}. PyType_GenericNew zero-initializes the instance. tp_init is where real allocation (ZSTD_createCCtx, ZSTD_createDCtx, PyMem_Malloc, ...) happens — skip __init__ and the pointers stay NULL. Methods then do things like ZSTD_CCtx_reset(self->cctx, ...) on NULL.
Affected types
| Type | File | NULL field / observable | Trigger |
|---|---|---|---|
ZstdCompressor |
c-ext/compressor.c |
cctx |
.compress(b'x') |
ZstdDecompressor |
c-ext/decompressor.c |
dctx (via ensure_dctx) |
.decompress(b'x') |
ZstdCompressionParameters |
c-ext/compressionparams.c |
params |
any accessor |
ZstdCompressionWriter |
c-ext/compressionwriter.c |
compressor |
.write(b'x') |
ZstdDecompressionWriter |
c-ext/decompressionwriter.c |
decompressor |
.write(b'x') |
ZstdCompressionReader |
c-ext/compressionreader.c |
assertion abort | .read(10) |
BufferWithSegmentsCollection |
c-ext/bufferutil.c |
firstElements |
[0] |
ZstdCompressionObj |
c-ext/compressobj.c |
compressor |
via type() |
ZstdDecompressionObj |
c-ext/decompressobj.c |
decompressor |
via type() |
ZstdCompressorIterator |
c-ext/compressoriterator.c |
compressor/reader |
via type() |
ZstdDecompressorIterator |
c-ext/decompressoriterator.c |
decompressor/reader |
via type() |
ZstdCompressionChunker |
c-ext/compressionchunker.c |
compressor |
via type() |
ZstdDecompressionReader (silent) |
c-ext/decompressionreader.c |
zero-state early return | .read(10) → b'' |
Suggested fix
Two options; Option A is recommended. It yields a usable object immediately after __new__ and composes cleanly with a re-init leak finding noted below.
Option A — allocate in tp_new
Replace {Py_tp_new, PyType_GenericNew} with a type-specific tp_new that installs the minimum non-NULL state the methods need:
static PyObject *
ZstdCompressor_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
ZstdCompressor *self = (ZstdCompressor *)type->tp_alloc(type, 0);
if (!self) {
return NULL;
}
self->cctx = ZSTD_createCCtx();
if (!self->cctx) {
Py_DECREF(self);
PyErr_NoMemory();
return NULL;
}
/* other fields stay zero-initialized; tp_init will configure them. */
return (PyObject *)self;
}
static PyType_Slot ZstdCompressor_slots[] = {
{Py_tp_dealloc, ZstdCompressor_dealloc},
{Py_tp_methods, ZstdCompressor_methods},
{Py_tp_init, (initproc)ZstdCompressor_init},
{Py_tp_new, ZstdCompressor_new}, /* was: PyType_GenericNew */
{0, 0},
};
tp_init then only configures the already-allocated context. For types whose tp_init also allocates (ZstdCompressionParameters, BufferWithSegmentsCollection, ...), the allocation moves into tp_new and tp_init is reduced to argument parsing + configuration.
Option B — guard every method
Add a NULL check at the top of every method that dereferences an initializable field:
static PyObject *
ZstdCompressor_compress(ZstdCompressor *self, PyObject *args)
{
if (!self->cctx) {
PyErr_SetString(PyExc_ValueError,
"ZstdCompressor not initialized - call __init__");
return NULL;
}
/* ... */
}
Less invasive but requires auditing every method on every affected type, and new methods have to remember the guard.
Related / follow-ups
- This composes with a re-init leak finding (
comp.__init__(...)on an already-initialized instance allocates new contexts without freeing the previous ones — ~5.5 KB per call) covered in the full report linked below. Option A cleanly separates "create fresh state" (tp_new) from "configure from kwargs" (tp_init), making it easy to either reject re-init or free-then-recreate. - The
ZstdCompressionObj/ZstdDecompressionObj/ iterator / chunker types are returned from factories and not directly constructable, but their__new__remains reachable viatype(). Same fix applies.
Methodology
Found via cext-review-toolkit (Tree-sitter-based static analysis with structured naive/informed review passes). All 7 direct-namespace reproducers and 5 type()-introspection reproducers were verified live on CPython 3.14.3 debug build. Happy to open a PR — the Option A change is mechanical across the 13 specs. I'd propose a single PR with the atomic set of changes, but can split into a 2-commit PR (simple-allocation types first, more-complex-init types second) if you prefer.
Discovery, root-cause analysis, and issue drafting were performed by Claude Code and reviewed by a human before filing.
Full report
Complete multi-agent analysis (48 FIX findings across 13 categories, plus a reproducer appendix): https://gist.github.com/devdanzin/b86039ac097141579590c1a0f3a43605
- 主要言語
- C
- スター
- 641
- フォーク
- 117
- 平均マージ
- 8日 2時間
- マージ済み PR(30日)
- 7
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
indygreg/python-zstandard のほかの issue
-
`multi_decompress_to_buffer([])` terminates the process with SIGFPE対応中かも @mikamikasuki が 11 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
indygreg/python-zstandard#335 ·
メンテナーはふだん 4 日以内に返信
-
Silent data-correctness bug: `readinto()` / `readinto1()` on `stream_reader` return `tell() == 0` after successful reads対応中かも @CAOShurong が 4 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
indygreg/python-zstandard#295 ·
メンテナーはふだん 4 日以内に返信
-
Python 3.15 wheels対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 55/100
indygreg/python-zstandard#354 · リアクション 1 件 ·
メンテナーはふだん 4 日以内に返信
-
難易度 3/5 1〜2日 初心者へのやさしさ 64/100
indygreg/python-zstandard#345 ·
メンテナーはふだん 4 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 55/100
indygreg/python-zstandard#334 ·
メンテナーはふだん 4 日以内に返信
indygreg/python-zstandard の issue をすべて見る
似ている issue
-
bug good first issue
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
-
enhancement
難易度 1/5 1時間未満 初心者へのやさしさ 85/100
FujiNetWIFI/fujinet-lib#122 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
メンテナーはふだん 12 日以内に返信
-
bug format I/O medium priority rt_lib
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
メンテナーはふだん 1 日以内に返信