-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The trigger is two frames\n\nThe minimal proof-of-concept reaches the bug in [forty-six lines of Python](https://github.com/CYFARE/CVE-2026-23918-Apache-HTTP-Server-DoubleFree-PoC/blob/main/CVE-2026-23918.py). After the HTTP/2 connection preface and a SETTINGS handshake, the trigger is two frames on one stream:\n\n```python\n# The trigger: open stream then immediately abort\ntls.send(make_headers(1, end_stream=False, auth=target.encode()))\ntls.send(make_rst(1, ERROR_CANCEL))\n```\n\nA `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.\n\nReliability against a vulnerable Apache 2.4.66 with mod_http2 on the Event MPM is timing-dependent and high. The [rhasan-com PoC](https://github.com/rhasan-com/CVE-2026-23918) 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.\n\n## What the two frames make happen\n\n`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:\n\n- `m->streams`: an `h2_ihash` of streams the session knows about.\n- `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\".\n- `m->spurge`: a list of streams whose memory pools are ready to be destroyed at the next safe point. \"spurge\" is \"stream purge\".\n\nTwo 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.\n\nHere 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](https://github.com/apache/httpd/blob/trunk/modules/http2/h2_mplx.c)):\n\n```c\nstatic void m_stream_cleanup(h2_mplx *m, h2_stream *stream)\n{\n h2_conn_ctx_t *c2_ctx = h2_conn_ctx_get(stream->c2);\n /* ... unsubscribe from beam events ... */\n h2_stream_cleanup(stream);\n h2_ihash_remove(m->streams, stream->id);\n h2_iq_remove(m->q, stream->id);\n\n if (c2_ctx) {\n if (!stream_is_running(stream)) {\n /* processing has finished */\n add_for_purge(m, stream); // <-- one\n }\n else {\n h2_c2_abort(stream->c2, m->c1);\n h2_ihash_add(m->shold, stream);\n }\n }\n else {\n /* never started */\n int added = add_for_purge(m, stream); // <-- two\n ...\n }\n}\n```\n\nAnd here is the `c1c2_stream_joined` path, called from the multiplexer when the worker side is done:\n\n```c\nstatic void c1c2_stream_joined(h2_mplx *m, h2_stream *stream)\n{\n ap_assert(!stream_is_running(stream));\n h2_ihash_remove(m->shold, stream->id);\n add_for_purge(m, stream); // <-- three\n}\n```\n\nThree 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.\n\n```c\nfor (i = 0; i < m->spurge->nelts; ++i) {\n stream = APR_ARRAY_IDX(m->spurge, i, h2_stream*);\n apr_pool_destroy(stream->pool);\n}\napr_array_clear(m->spurge);\n```\n\nTwo 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.\n\n## The fix is six lines\n\nThe entire patch to `h2_mplx.c` is twenty-three lines. The new function is six:\n\n```c\nstatic int add_for_purge(h2_mplx *m, h2_stream *stream)\n{\n int i;\n for (i = 0; i < m->spurge->nelts; ++i) {\n h2_stream *s = APR_ARRAY_IDX(m->spurge, i, h2_stream*);\n if (s == stream) /* already scheduled for purging */\n return FALSE;\n }\n APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;\n return TRUE;\n}\n```\n\nThe 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.\n\nThat 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.\n\n## The assertion was named in 2021. The producer was not enforcing it.\n\nThe 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:\n\n```c\nfor (i = 0; i < m->spurge->nelts; ++i) {\n stream = APR_ARRAY_IDX(m->spurge, i, h2_stream*);\n if (stream && (stream->id == conn_ctx->stream_id)) {\n ap_log_cerror(APLOG_MARK, APLOG_WARNING, 0, c2,\n H2_STRM_LOG(APLOGNO(03517), stream, \"already in spurge\"));\n ap_assert(\"stream should not be in spurge\" == NULL);\n return;\n }\n}\n```\n\n`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.\n\nStefan Eissing committed that assertion in [`c909ade`](https://github.com/icing/mod_h2/commit/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:\n\n```c\nh2_ihash_add(m->spurge, stream);\n```\n\n`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.\n\nTwenty-nine days later, on August 11, 2021, in commit [`9df7f7f`](https://github.com/icing/mod_h2/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`:\n\n```diff\n- m->spurge = h2_ihash_create(m->pool, offsetof(h2_stream,id));\n+ m->spurge = apr_array_make(m->pool, 10, sizeof(h2_stream*));\n```\n\n```diff\n- h2_ihash_add(m->spurge, stream);\n+ APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;\n```\n\nThe same commit translated the consumer-side assertion from `h2_ihash_get` to a linear scan over the array, and kept it:\n\n```diff\n- else if ((stream = h2_ihash_get(m->spurge, conn_ctx->stream_id)) != NULL) {\n+ else {\n+ for (i = 0; i < m->spurge->nelts; ++i) {\n+ if (stream == APR_ARRAY_IDX(m->spurge, i, h2_stream*)) {\n H2_STRM_LOG(APLOGNO(03517), stream, \"already in spurge\"));\n ap_assert(\"stream should not be in spurge\" == NULL);\n```\n\nThe 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.\n\nFor 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.\n\n## The maintainer reverted the wrong thing first\n\nThe OSS-Security disclosure timeline reads:\n\n> 2025-12-10: reported in PR 69899\n> 2025-12-11: fixed in r1930444, r1930796\n\nThe upstream `mod_h2` history is finer-grained. Three commits land in three days, by the same author:\n\n| Date | Commit | Title | Effect |\n|---|---|---|---|\n| 2025-12-09 | [`c6258e7`](https://github.com/icing/mod_h2/commit/c6258e7) | \"revert streams having their own allocator (#311)\" | Removes per-stream `apr_allocator_t` from `h2_session_open_stream`. |\n| 2025-12-10 | [`b18fc7d`](https://github.com/icing/mod_h2/commit/b18fc7d) | \"stream purge (#312)\" | Adds `add_for_purge` dedup, restores per-stream allocators. |\n| 2026-02-20 | [`4568d71`](https://github.com/icing/mod_h2/commit/4568d71) | \"Remove streams own memory allocator after reports of memory problems (#315)\" | Removes per-stream allocators again. |\n\nThe 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`](https://github.com/icing/mod_h2/commit/454c106) on July 10, 2025, with the rationale \"streams: return memory to system on close.\"\n\nThe 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.\n\nThe 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`.\n\n## The substrate\n\nThe 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.\n\nThe 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](https://github.com/icing/mod_h2/commit/26cb4aa) 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.\n\nEach 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.\n\n## The version surface\n\nCVE-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.\n\nThe 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.\n\nPoCs:\n- [CYFARE/CVE-2026-23918-Apache-HTTP-Server-DoubleFree-PoC](https://github.com/CYFARE/CVE-2026-23918-Apache-HTTP-Server-DoubleFree-PoC)\n- [rhasan-com/CVE-2026-23918](https://github.com/rhasan-com/CVE-2026-23918)\n- [xeloxa/CVE-2026-23918-Apache-H2-PoC](https://github.com/xeloxa/CVE-2026-23918-Apache-H2-PoC)\n\nPatch: [`icing/mod_h2#312`](https://github.com/icing/mod_h2/commit/b18fc7d2f8f5efe1336ba05ef25ada52fdaf3967), backported to httpd 2.4.x as r1930444 and r1930796, shipped in [Apache HTTP Server 2.4.67](https://httpd.apache.org/security/vulnerabilities_24.html). Disclosure: [oss-security thread, May 4, 2026](https://seclists.org/oss-sec/2026/q2/387).","closing_line":"CVE-2026-23918 is the assertion firing.","hook_md":"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`](https://github.com/icing/mod_h2/commit/b18fc7d2f8f5efe1336ba05ef25ada52fdaf3967), 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.\n\nStefan 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.\n\nHe 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.","post_id":195,"slug":"apache-h2-spurge-was-an-ihash","title":"CVE-2026-23918: m->spurge Was an h2_ihash. The Array That Replaced It Kept the Assertion, Not the Dedup.","type":"initial","unreadable_sentence":"The invariant was named in 2021 and enforced in 2025. The container that originally enforced it was deleted twenty-nine days after the assertion was written, by the same author, in the same file, with the assertion left in place."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaf9HfQAKCRDeZjl4jgkQ JhNIAQC+HsrRpWAd3onj/xPUh0CeMdsbCiG/Z1gI6rvmK2qgjAEA9A7zLyipA91n zQmxIxfsBaXkLbsaax3QI9V3aNOG6Q8= =/K68 -----END PGP SIGNATURE-----