Skip to content

Instantly share code, notes, and snippets.

@devdanzin
Last active June 20, 2026 01:08
Show Gist options
  • Select an option

  • Save devdanzin/ceb4b16662d22b3dcb1b56cd8481c9e7 to your computer and use it in GitHub Desktop.

Select an option

Save devdanzin/ceb4b16662d22b3dcb1b56cd8481c9e7 to your computer and use it in GitHub Desktop.
OOM-0014: Abort/Segfault: unchecked NULL in `channelsmod__channel_id` (`_interpchannelsmodule.c:3487`)
# Authoritative gdb backtrace (3.16_ft_debug_asan, commit 15d7406)
# Abort: assert(mod == self) in channelsmod__channel_id (_interpchannelsmodule.c:3487).
# Under OOM, get_module_from_owned_type() -> _get_current_module() returns NULL
# (its first allocation, PyUnicode_FromString(MODULE_NAME_STR), fails), so
# mod == NULL while self is the live module object: NULL != self -> assert fires.
Program received signal SIGABRT, Aborted.
#0 __pthread_kill_implementation (threadid=..., signo=..., no_tid=...) at ./nptl/pthread_kill.c:44
#1 __pthread_kill_internal (threadid=..., signo=...) at ./nptl/pthread_kill.c:89
#2 __GI___pthread_kill (threadid=..., signo=...) at ./nptl/pthread_kill.c:100
#3 __GI_raise (sig=...) at ../sysdeps/posix/raise.c:26
#4 __GI_abort () at ./stdlib/abort.c:77
#5 __libc_message_impl (...) at ../sysdeps/posix/libc_fatal.c:138
#6 __libc_message_wrapper (...) at ../include/stdio.h:203
#7 __assert_fail (assertion=..., file=..., line=..., function=...) at ./assert/assert.c:37
#8 channelsmod__channel_id (self=..., args=..., kwds=...) at ./Modules/_interpchannelsmodule.c:3487 <- assert mod == self
#9 cfunction_call (func=..., args=..., kwargs=...) at Objects/methodobject.c:564
#10 _PyObject_Call (tstate=..., callable=..., args=..., kwargs=...) at Objects/call.c:361
#11 _PyEval_EvalFrameDefault (tstate=..., frame=..., throwflag=...) at Python/generated_cases.c.h:2831
#12 _PyEval_EvalFrame (...) at ./Include/internal/pycore_ceval.h:122
#13 _PyEval_Vector (...) at Python/ceval.c:2141
#14 PyEval_EvalCode (co=..., globals=..., locals=...) at Python/ceval.c:679
# Release builds (assert compiled out) crash at adjacent sites in the same frame:
# ft_release (start=0): SIGSEGV in Py_DECREF(mod) at _interpchannelsmodule.c:3488 (Py_DECREF(NULL)).
# upstream (start=0): SIGSEGV in PyImport_GetModule (import_get_module / PyDict_GetItemRef)
# reached from _get_current_module() at _interpchannelsmodule.c:155 (line 3486 frame).
# jit (debug): same SIGABRT assert as ft_debug_asan.

Abort/Segfault: unchecked NULL in channelsmod__channel_id (_interpchannelsmodule.c:3487)

_channel_id() treats get_module_from_owned_type() as infallible; under OOM the PyUnicode_FromString inside _get_current_module() fails and returns NULL, tripping assert(mod == self) (abort) or Py_DECREF(NULL) (segfault).

AI Disclaimer: this gist was drafted by Claude Code, which also generated the reduced reproducer.

Crash report

_interpchannels._channel_id() (C function channelsmod__channel_id) looks up its own module via get_module_from_owned_type() -> _get_current_module(), then immediately does assert(mod == self) and Py_DECREF(mod) without checking for NULL. Under OOM the first allocation inside _get_current_module() (a PyUnicode_FromString(MODULE_NAME_STR)) fails, so mod == NULL. On debug builds assert(mod == self) aborts; on release builds the assert is compiled out and the unchecked Py_DECREF(NULL) segfaults. The pre-existing MemoryError is also masked.

Reproducer

import _interpchannels, _testcapi, faulthandler
faulthandler.enable()
_testcapi.set_nomemory(0, 0)   # fail every allocation from #0 onward
try:
    _interpchannels._channel_id(0)   # _get_current_module() -> NULL
                                     # -> assert mod == self (abort) / Py_DECREF(NULL) (segv)
finally:
    _testcapi.remove_mem_hooks()

Deterministic at start=0 on every build: the very first allocation the function performs is inside _get_current_module(), so failing allocation #0 drives mod == NULL. The argument to _channel_id is irrelevant — the failure happens before it is ever used.

Backtrace

#8  channelsmod__channel_id   Modules/_interpchannelsmodule.c:3487   <- assert mod == self
#9  cfunction_call            Objects/methodobject.c:564
#10 _PyObject_Call            Objects/call.c:361
#11 _PyEval_EvalFrameDefault  Python/generated_cases.c.h:2831

(gdb) frame 8; print mod -> (PyObject *) 0x0; print self -> the live <module '_interpchannels'>. On the ft_release build the same NULL reaches Py_DECREF(mod) at :3488 (Include/refcount.h -> SIGSEGV); on upstream the NULL-free OOM instead faults earlier inside PyImport_GetModule reached from _get_current_module() at :155 (same frame, :3486).

Root cause

Modules/_interpchannelsmodule.c, channelsmod__channel_id() (L3478):

    module_state *state = get_module_state(self);
    ...
    PyTypeObject *cls = state->ChannelIDType;

    PyObject *mod = get_module_from_owned_type(cls);   /* L3486: can return NULL */
    assert(mod == self);                               /* L3487: NULL != self -> abort */
    Py_DECREF(mod);                                    /* L3488: Py_DECREF(NULL) -> segv */

    return _channelid_new(self, cls, args, kwds);

get_module_from_owned_type() (L165) is a thin shim over _get_current_module() (L149):

    PyObject *name = PyUnicode_FromString(MODULE_NAME_STR);   /* L151: fails under OOM */
    if (name == NULL) {
        return NULL;                                          /* L153 */
    }
    PyObject *mod = PyImport_GetModule(name);                 /* L155 */
    Py_DECREF(name);
    if (mod == NULL) {
        return NULL;                                          /* L158 */
    }

So _get_current_module() legitimately returns NULL (with an exception set) when an allocation fails, but the caller treats the result as infallible. The assert(mod == self) encodes an invariant that is only ever true on the success path; under OOM mod is NULL. The defect is a missing NULL check, not a use-after-free.

Suggested fix

Check the return value before asserting / decref'ing, and propagate the error:

    PyObject *mod = get_module_from_owned_type(cls);
    if (mod == NULL) {
        return NULL;                 /* propagate the MemoryError */
    }
    assert(mod == self);             /* invariant only meaningful once mod != NULL */
    Py_DECREF(mod);

(Long term the XXX notes at L169 suggest replacing the _get_current_module() shim with PyType_GetModule(cls), which is infallible here and would remove the allocation entirely.)

Notes

Found by OOM-injection fuzzing (set_nomemory). Unlike the free-threading-specific asserts in this catalog, this defect is build-agnostic: the unchecked NULL is a real memory-safety bug on every configuration.

  • ft_debug_asan: SIGABRT on assert(mod == self) at :3487.
  • jit (also a debug build): identical SIGABRT on the same assert.
  • ft_release: assert compiled out (-DNDEBUG); SIGSEGV in Py_DECREF(mod) at :3488 (Py_DECREF(NULL)) — confirms the same NULL.
  • upstream: assert compiled out; SIGSEGV inside PyImport_GetModule (import_get_module / PyDict_GetItemRef) reached from _get_current_module() at :155, i.e. the get_module_from_owned_type(cls) call at :3486. Same function/site, an allocation-timing-shifted instance of the same OOM defect.

Six fuzzer vehicles across python-5 and python-7 all abort at the identical _interpchannelsmodule.c:3487 assertion (faulthandler Python stack: _interpchannels._channel_id(...) under the OOM sweep). The same NULL-from-_get_current_module() hazard exists in the sibling shim get_module_from_type() (L176) and at its other unchecked or assert-only callers; this report covers the _channel_id entry point that the fuzzer reached.

Versions

  • main (3.16.0a0, commit 15d7406). Reproduces on all four builds: SIGABRT on the debug builds (ft_debug_asan, jit), SIGSEGV on the release builds (ft_release, upstream).

Part of python/cpython#151763 — an umbrella tracking 35 OOM-related crash findings.

"""
Minimal reproducer: crash in channelsmod__channel_id() when
_get_current_module() fails under OOM.
Affected: CPython 3.16.0a0 (main), all build configurations.
Crash: SIGABRT on debug builds (ft_debug_asan, jit):
./Modules/_interpchannelsmodule.c:3487
Assertion `mod == self' failed.
SIGSEGV on release builds (ft_release, upstream): the assert is
compiled out, so the same NULL module pointer is dereferenced a
few lines later (Py_DECREF(NULL) at L3488), or inside the import
lookup at L3486 (see Notes).
Requires: a build exposing _testcapi.set_nomemory.
Backtrace (gdb, ft_debug_asan):
#8 channelsmod__channel_id _interpchannelsmodule.c:3487 (assert mod == self)
#9 cfunction_call Objects/methodobject.c:564
#10 _PyObject_Call Objects/call.c:361
#11 _PyEval_EvalFrameDefault Python/generated_cases.c.h:2831
Root cause (Modules/_interpchannelsmodule.c):
channelsmod__channel_id() (L3478) is the C implementation of
_interpchannels._channel_id(). After fetching the module state it does:
PyObject *mod = get_module_from_owned_type(cls); // L3486
assert(mod == self); // L3487
Py_DECREF(mod); // L3488
get_module_from_owned_type() (L165) just calls _get_current_module()
(L149), whose very first step is:
PyObject *name = PyUnicode_FromString(MODULE_NAME_STR); // L151
if (name == NULL) {
return NULL; // L153
}
PyObject *mod = PyImport_GetModule(name); // L155
Under OOM, PyUnicode_FromString() (or PyImport_GetModule's internal
allocations) fails and _get_current_module() returns NULL *with an
exception set*. The caller never checks the return value: it asserts
mod == self (NULL != self -> abort on debug builds) and then does
Py_DECREF(mod) -> Py_DECREF(NULL) (segfault on release builds). The
pre-existing MemoryError is also silently masked.
The OOM sweep needs only start=0: the first allocation the function performs
is inside _get_current_module(), so failing allocation #0 deterministically
drives mod == NULL.
Likely fix: check the return value instead of asserting, e.g.
PyObject *mod = get_module_from_owned_type(cls);
if (mod == NULL) {
return NULL;
}
assert(mod == self); // keep the invariant only once mod is known non-NULL
Py_DECREF(mod);
Self-sweeping: `python repro.py` runs the trigger under set_nomemory(N, 0) for N in a
sweep, each in a FRESH subprocess (a fresh process avoids cache warm-up shifting the OOM
window), and stops at the first N that crashes. Needs a debug build (the check is compiled
out under NDEBUG). Bare trigger (fixed N=0):
import _interpchannels, _testcapi
_testcapi.set_nomemory(0, 0)
_interpchannels._channel_id(0)
"""
import os
import sys
import subprocess
TRIGGER = r"""
import _interpchannels
import _testcapi
import faulthandler
faulthandler.enable()
_testcapi.set_nomemory({n}, 0)
try:
_interpchannels._channel_id(0) # -> get_module_from_owned_type -> NULL
# -> assert mod == self (SIGABRT, debug)
# -> Py_DECREF(NULL) (SIGSEGV, release)
finally:
_testcapi.remove_mem_hooks()
"""
SIGNATURE = "_interpchannelsmodule.c:3487: PyObject *channelsmod__channel_id"
def main():
env = {**os.environ, "ASAN_OPTIONS": "detect_leaks=0:abort_on_error=0"}
# This bug is build-agnostic (not free-threading-only); GIL=1 works.
for n in range(80):
out = subprocess.run([sys.executable, "-c", TRIGGER.format(n=n)],
capture_output=True, text=True, env=env)
if SIGNATURE in out.stdout + out.stderr:
print("reproduced at set_nomemory(%d, 0):" % n)
sys.stdout.write(out.stderr or out.stdout)
return 1
print("no crash in range(80); widen it for your build")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment