//nefariousplan

CVE-2026-23918: m->spurge Was an h2_ihash. The Array That Replaced It Kept the Assertion, Not the Dedup.

pattern

cve

proof of concept

The Apache HTTP Server 2.4.67 release notes credit r1930444 and r1930796 for closing CVE-2026-23918. Both backport one upstream commit, icing/mod_h2#312, titled "stream purge." The patch adds twenty-three lines to mod_http2/h2_mplx.c. Six of them are the four-line loop that closes the CVE.

Stefan Eissing wrote that loop on December 10, 2025. He wrote the assertion the loop now satisfies, ap_assert("stream should not be in spurge" == NULL), on July 13, 2021. At that time m->spurge was an h2_ihash, and the invariant the assertion checked was guaranteed by the container the assertion was checking.

He converted m->spurge to an apr_array twenty-nine days later, kept the assertion in the consumer, and shipped without producer-side enforcement for fifty-two months.

The trigger is two frames

The minimal proof-of-concept reaches the bug in forty-six lines of Python. After the HTTP/2 connection preface and a SETTINGS handshake, the trigger is two frames on one stream:

# The trigger: open stream then immediately abort
tls.send(make_headers(1, end_stream=False, auth=target.encode()))
tls.send(make_rst(1, ERROR_CANCEL))

A HEADERS frame opens stream id 1. A RST_STREAM frame with error code CANCEL (0x8) closes it. No body, no continuation, no end-stream flag, no authentication. The frames are sent inside the same TLS write call when possible, to land both before the server's worker thread picks the stream up.

Reliability against a vulnerable Apache 2.4.66 with mod_http2 on the Event MPM is timing-dependent and high. The rhasan-com PoC widens the window with one hundred concurrent connections, fifty streams per connection, RST after every third HEADERS, and reports SIGSEGV in the child process within thirty seconds to three minutes. The CYFARE PoC is the same trigger reduced to one stream and one shot, kept in a loop.

What the two frames make happen

mod_http2's multiplexer (h2_mplx, "mplx") tracks every active HTTP/2 stream across three containers in the same struct, with hand-coded transitions between them:

  • m->streams: an h2_ihash of streams the session knows about.
  • m->shold: an h2_ihash of streams whose connection-1 (the HTTP/2 session thread) side has finished but whose connection-2 (the worker that runs the request) side has not joined back yet. "shold" is short for "stream hold".
  • m->spurge: a list of streams whose memory pools are ready to be destroyed at the next safe point. "spurge" is "stream purge".

Two code paths call into the cleanup machinery for the same stream when an early RST_STREAM arrives. The first is m_stream_cleanup, called from the session thread when it processes the reset. The second is c1c2_stream_joined, called from the multiplexer when the worker connection finishes and rejoins the main connection. Both paths end by adding the stream to m->spurge. The pointer is the same h2_stream pointer in both calls.

Here is the m_stream_cleanup branch that fires when the worker has finished and there is no need to abort it (h2_mplx.c, current trunk):

static void m_stream_cleanup(h2_mplx *m, h2_stream *stream)
{
    h2_conn_ctx_t *c2_ctx = h2_conn_ctx_get(stream->c2);
    /* ... unsubscribe from beam events ... */
    h2_stream_cleanup(stream);
    h2_ihash_remove(m->streams, stream->id);
    h2_iq_remove(m->q, stream->id);

    if (c2_ctx) {
        if (!stream_is_running(stream)) {
            /* processing has finished */
            add_for_purge(m, stream);            // <-- one
        }
        else {
            h2_c2_abort(stream->c2, m->c1);
            h2_ihash_add(m->shold, stream);
        }
    }
    else {
        /* never started */
        int added = add_for_purge(m, stream);    // <-- two
        ...
    }
}

And here is the c1c2_stream_joined path, called from the multiplexer when the worker side is done:

static void c1c2_stream_joined(h2_mplx *m, h2_stream *stream)
{
    ap_assert(!stream_is_running(stream));
    h2_ihash_remove(m->shold, stream->id);
    add_for_purge(m, stream);                    // <-- three
}

Three call sites, one m->spurge. Under an early reset, the stream can be visited by m_stream_cleanup (because the session sees the RST and unsubscribes the stream from beam events) and by c1c2_stream_joined (because the worker, having been started briefly, finishes and rejoins) for the same h2_stream*. Both paths add to m->spurge. Eventually m_purge_streams walks the array and calls apr_pool_destroy(stream->pool) once per element.

for (i = 0; i < m->spurge->nelts; ++i) {
    stream = APR_ARRAY_IDX(m->spurge, i, h2_stream*);
    apr_pool_destroy(stream->pool);
}
apr_array_clear(m->spurge);

Two entries pointing at the same stream means apr_pool_destroy is called twice on the same pool. APR pool destruction releases memory back to the pool's allocator. With each h2_stream owning its own apr_allocator_t (we will get to that), the second destroy enters allocator metadata that has already been freed and walked off into a freelist. CWE-415, free of an already-freed allocator, with all of the heap-corruption consequences attendant.

The fix is six lines

The entire patch to h2_mplx.c is twenty-three lines. The new function is six:

static int add_for_purge(h2_mplx *m, h2_stream *stream)
{
    int i;
    for (i = 0; i < m->spurge->nelts; ++i) {
        h2_stream *s = APR_ARRAY_IDX(m->spurge, i, h2_stream*);
        if (s == stream)  /* already scheduled for purging */
            return FALSE;
    }
    APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
    return TRUE;
}

The remaining seventeen lines of the diff replace three raw APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream; statements (the three call sites above) with add_for_purge(m, stream), and reflow the log message that was unconditionally emitted before. The fix is a four-line linear scan against an array that, in this codebase, is bounded by the number of concurrent HTTP/2 streams a session is allowed.

That is the patch the Apache HTTP Server Project shipped on December 11, 2025, as 2.4.67. CVE assignment, oss-security disclosure, and a wave of public PoCs followed five months later, on May 4, 2026.

The assertion was named in 2021. The producer was not enforcing it.

The most interesting line in h2_mplx.c post-patch is not the new function. It is a four-year-old assertion in s_c2_done, the callback the multiplexer invokes when a worker connection finishes:

for (i = 0; i < m->spurge->nelts; ++i) {
    stream = APR_ARRAY_IDX(m->spurge, i, h2_stream*);
    if (stream && (stream->id == conn_ctx->stream_id)) {
        ap_log_cerror(APLOG_MARK, APLOG_WARNING, 0, c2,
                      H2_STRM_LOG(APLOGNO(03517), stream, "already in spurge"));
        ap_assert("stream should not be in spurge" == NULL);
        return;
    }
}

ap_assert("stream should not be in spurge" == NULL) is a load-bearing string. It is an assertion authored to fail loudly with a self-describing message: the comparison is always false, so reaching this line aborts the process with the literal text "stream should not be in spurge" in the failure trace. The author named the invariant.

Stefan Eissing committed that assertion in c909ade on July 13, 2021, as part of the larger refactor "Further dismemberments of h2_task into the h2_conn_ctx_t." At that revision, m->spurge was an h2_ihash. The producer side wrote:

h2_ihash_add(m->spurge, stream);

h2_ihash_add is a thin wrapper over apr_hash_set, keyed by the stream's id field at a fixed struct offset. apr_hash_set of an existing key is an update, not an insert. Adding the same stream twice yielded one entry. The invariant the assertion checked was a property of the container.

Twenty-nine days later, on August 11, 2021, in commit 9df7f7f ("mplx purge list turned into an array since it is always processed completely"), the same author replaced the h2_ihash with an apr_array_header_t:

-    m->spurge = h2_ihash_create(m->pool, offsetof(h2_stream,id));
+    m->spurge = apr_array_make(m->pool, 10, sizeof(h2_stream*));
-    h2_ihash_add(m->spurge, stream);
+    APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;

The same commit translated the consumer-side assertion from h2_ihash_get to a linear scan over the array, and kept it:

-    else if ((stream = h2_ihash_get(m->spurge, conn_ctx->stream_id)) != NULL) {
+    else {
+        for (i = 0; i < m->spurge->nelts; ++i) {
+            if (stream == APR_ARRAY_IDX(m->spurge, i, h2_stream*)) {
                                  H2_STRM_LOG(APLOGNO(03517), stream, "already in spurge"));
                 ap_assert("stream should not be in spurge" == NULL);

The producer changed from "container guarantees uniqueness" to "caller is trusted to not double-add." The consumer, which had previously relied on the same container guarantee, was rewritten to scan the array for a duplicate and abort if it found one. Producer enforcement was deleted. Consumer enforcement was preserved as a panic.

For fifty-two months between that commit and b18fc7d, three call sites pushed onto m->spurge without checking whether the stream was already there, and one consumer aborted the process when it noticed.

The maintainer reverted the wrong thing first

The OSS-Security disclosure timeline reads:

2025-12-10: reported in PR 69899 2025-12-11: fixed in r1930444, r1930796

The upstream mod_h2 history is finer-grained. Three commits land in three days, by the same author:

Date Commit Title Effect
2025-12-09 c6258e7 "revert streams having their own allocator (#311)" Removes per-stream apr_allocator_t from h2_session_open_stream.
2025-12-10 b18fc7d "stream purge (#312)" Adds add_for_purge dedup, restores per-stream allocators.
2026-02-20 4568d71 "Remove streams own memory allocator after reports of memory problems (#315)" Removes per-stream allocators again.

The December 9 commit message says "Problems reports in PR 69899 by users." PR 69899 is the same bug report ticket that, the next day, the security disclosure attributes to Bartłomiej Dmitruk and Stanisław Strzałkowski as the formal CVE-2026-23918 finding. The maintainer's first instinct was to revert the change he believed had introduced the crashes: per-stream allocators, added in 454c106 on July 10, 2025, with the rationale "streams: return memory to system on close."

The instinct was wrong by one day. The actual root cause was not the per-stream allocator; the per-stream allocator was what made the latent double-add fatal instead of harmless. With session-shared allocators, a second apr_pool_destroy on a destroyed child pool is undefined behavior in a quieter way. With per-stream allocators, the second destroy frees an apr_allocator_t whose metadata sits in the freelist.

The next day, b18fc7d shipped the actual fix, deduped at the producer, and restored per-stream allocators in the same diff. Two months later, #315 removed per-stream allocators again, this time over reports of memory-pressure interactions with third-party modules. As of trunk today, streams do not own their allocators and add_for_purge is the only thing standing between an early reset and a double apr_pool_destroy.

The substrate

The Design Debt Driver pattern says: a component whose architecture is a fertile substrate for one bug class will produce that class repeatedly, with each patch closing an instance and the substrate holding the primitive. mod_http2's multiplexer is a substrate for stream-lifecycle races. Streams are tracked in three containers (m->streams, m->shold, m->spurge) with hand-coded transitions across at least four code paths (m_stream_cleanup, c1c2_stream_joined, s_c2_done, the various RST handlers). The invariants between containers are documented in assertions and comments rather than in the type system or in the data structures.

The CVE list reads from the same machinery. CVE-2023-44487 was the protocol-level Rapid Reset attack, weaponizing HTTP/2 stream cancellation against every implementation; mod_http2's mitigation lived in this same cleanup path. CVE-2023-45802 was Apache's own follow-on, described in the changelog as "a bug that prevented immediate memory release of reset streams on rare occasions. Memory was reclaimed instead at connection close. This could be exploited ... in a variation of the Rapid Reset attack." Same file, different memory accounting bug. CVE-2026-23918 is the third stream-cleanup CVE in twenty-six months, this time from the cleanup path's interaction with the per-stream allocator decision.

Each patch closes the instance that produced the CVE. The substrate that produces the next one is unchanged, because the substrate is "many code paths transition the same h2_stream pointer between three containers, and the invariants between them are enforced as assertions at consumers rather than as constraints at producers." The Apache 2.4.67 patch enforces one invariant at one producer. The other transitions still rely on the assertions.

The version surface

CVE-2026-23918 affects exactly one Apache HTTP Server release. 2.4.65 had no per-stream allocators (#302 had not landed yet) and the same missing dedup; the double-add was harmless. 2.4.66 had per-stream allocators (since July 2025) and the same missing dedup; the double-add was fatal. 2.4.67 has the dedup. Six months of public binaries have the bug. Five of those months are after the fix was committed and four are before any public CVE.

The CYFARE PoC is two frames. The rhasan-com PoC is two frames in a hundred-thread loop. The xeloxa PoC is two frames with optional --mode rce-detect that documents the open question: a double apr_allocator_t free on a Debian/mmap APR build is theoretically a control-flow primitive; the public PoCs do not weaponize it. The author of the xeloxa README writes: "RCE is a lot more 'finicky' and I wouldn't count on it in a real-world scenario unless the target has a very predictable heap state." That is a defender-friendly read of the work. The work that has been published is a reliable DoS that crashes a worker on demand and forces the parent to fork another. Apache's process model means the attack is sustained at single-connection bandwidth.

PoCs:

Patch: icing/mod_h2#312, backported to httpd 2.4.x as r1930444 and r1930796, shipped in Apache HTTP Server 2.4.67. Disclosure: oss-security thread, May 4, 2026.

CVE-2026-23918 is the assertion firing.