double free in openssl's quic channel bind path
- advisory
- CVE-2026-18798
- posted
- · about 6 min
- filed under
- cve, openssl, quic, c
tldr: a pointer in OpenSSL's QUIC bind path does double duty as a return value and an ownership handoff. when a bind fails, it's left unwritten, so the caller assumes the channel never took the QRX and frees it again after the channel already did. fixed in 3.5.8 / 3.6.4 / 4.0.2.
glossary
- QUIC // UDP-based transport protocol, each connection opens with an Initial packet exchange before application data flows
- QRX // the object each QUIC connection uses to decrypt and track its incoming packets, it holds the connection's decryption keys and remembers which packets have already arrived
- DCID / ODCID // destination / original connection ID, values QUIC peers exchange to correlate packets across a handshake
- Retry // a QUIC server response that forces a client to prove it owns its claimed source address before the server allocates state
- AEAD // authenticated encryption, ciphertext can't be forged or replayed with different content without the key
- tcache // glibc's per-thread cache of recently freed heap chunks under ~1032 bytes, checked before the general allocator paths
CVE-2026-18798, moderate severity, CWE-415, hits OpenSSL 3.5, 3.6, and 4.0 if your application uses the QUIC listener API directly. fixed in 3.5.8, 3.6.4, 4.0.2 by Alexandr Nedvedicky. reporting this one was about as good as it gets: quickly triaged and verified, with an open channel on fix talk the whole way through. it's a double free that stays hidden on the happy path, since only a failed bind reaches it.
the mechanism
port_bind_channel() creates a QRX to validate an incoming Initial packet, then passes it down:
port_bind_channel(port, &e->peer, &hdr.dst_conn_id, &odcid, qrx, &new_ch);
inside, ossl_quic_channel_alloc() adopts it (quic_channel.c:474):
ch->qrx = args->qrx;
and ch_cleanup() frees it later (quic_channel.c:417):
ossl_qrx_free(ch->qrx);
on a bind failure, port_bind_channel() frees the channel and returns without writing *new_ch (quic_port.c:909-912). the caller decides ownership from that one variable:
if (new_ch == NULL) /* quic_port.c:1815 */
goto undesirable;
...
undesirable: /* quic_port.c:1852 */
ossl_qrx_free(qrx); /* quic_port.c:1853 */
new_ch == NULL is supposed to mean the channel never took ownership. here it means the channel took ownership, then destroyed itself and the QRX along with it. the guard that would stop the second free, setting qrx = NULL, only exists on the success path (quic_port.c:1824-1827).
ownership here is communicated through whether a pointer got written. nothing in the function signature says so, which makes any early return that skips the write an ownership change.
port_bind_channel() has three exits that free the channel and return, and only one of them double-frees:
| exit | why |
|---|---|
ossl_quic_provide_initial_secret fails | reached only when qrx == NULL, so the caller frees NULL |
ossl_quic_bind_channel fails | this one |
ossl_quic_channel_on_new_conn fails | reached only when address validation is off, where the caller already nulled qrx at quic_port.c:1737-1753 |
the trigger
the failing check itself is correct and RFC-mandated (quic_types.h:76):
#define QUIC_MIN_ODCID_LEN 8 /* RFC 9000 s. 7.2 */
enforced at quic_lcidm.c:387-389:
if (initial_odcid == NULL || initial_odcid->id_len < QUIC_MIN_ODCID_LEN
|| initial_odcid->id_len > QUIC_MAX_CONN_ID_LEN)
return 0;
the sequence: an Initial packet with a 4-byte DCID gets a Retry back. generate_token() had stored that DCID verbatim (quic_port.c:1274, stored at :997 as token->odcid = odcid), so replaying the token hands the server its own 4-byte ODCID back. that fails the minimum-length check and takes the failure path that double-frees.
address validation is QUIC's anti-DoS mechanism, meant to stop a spoofing attacker from making the server allocate state for free. port_bind_channel() is only reached with a non-NULL qrx on the validated path, so you have to pass address validation to reach this DoS. turning address validation off (SSL_LISTENER_FLAG_NO_VALIDATE) makes you immune, because the caller nulls qrx before the call on that path. that isn't a mitigation anyone should use.
single-packet detection rules will miss this. the advisory describes an Initial packet with a short DCID, which is where the sequence starts but not where it fails. this is two packets, and the second one carries a token that's AEAD-authenticated (AES-256-GCM) with a server key. it can't be forged, and the round trip means the source address can't be spoofed either.
consequence
tested against three allocators:
| allocator | result |
|---|---|
glibc 2.39, stock linux-aarch64 no-shared, no sanitizer | aborts: double free or corruption (!prev), 5/5 runs |
| macOS libmalloc | aborts |
| size-binned free list, no double-free checking (a model allocator i wrote to bound the consequence) | hands the block to two live callers |
glibc catches it, but not with its tcache double-free check. the QRX allocation is 1088 bytes, above glibc's default tcache ceiling of 1032, so the chunk never enters tcache and the unsorted-bin !prev consistency check fires instead. which check fires depends on which bin the chunk lands in. (1032 is glibc's documented default; what i actually measured is the error string, which tells you which check fired.)
on the allocator without double-free checking, two live accepted QUIC channels end up holding the same QRX, and one connection's ordinary Retry handling frees the record layer the other is still using. that result comes from the model allocator alone and says nothing about any shipped product. there's no code execution anywhere in this.
mistakes i made along the way
first pass, for a double free my instinct was to check whether the block gets reused between the two frees. i instrumented it, measured zero allocations in that window, and concluded there was no controllable primitive. that was the wrong question: a double free's primitive comes from the chunk sitting on a free list twice, which only shows up after the second free. the real experiment is to free twice, then allocate twice, and compare the pointers.
then my own harness lied to me. the aliasing test counted every hand-out of the freed block and printed a success line claiming five aliased objects. every one of those hand-outs was actually followed by a free before the next hand-out, which is just ordinary sequential reuse any allocator does. the counter never checked whether the block was still live, and the driver called SSL_free() on every accepted connection, so no channel ever held onto its QRX long enough to alias. after adding a liveness check and holding connections open, exactly one real aliasing event showed up. if i hadn't read the trace line by line, a false claim would have gone into the report.
the fix
the fix refcounts the QRX. the caller takes a reference, hands it over, and both sides drop their own copy independently:
qrx_ref = ossl_qrx_newref(qrx);
port_bind_channel(port, &e->peer, &hdr.dst_conn_id, &odcid, qrx_ref, &new_ch);
i would have written this differently: enumerate every failure path and null the pointer on each one. refcounting is better, it covers paths nobody enumerated, including whatever gets added later.
ownership only transfers at quic_channel.c:474, after ossl_quic_channel_alloc() has already succeeded. if that allocation itself fails (quic_channel.c:466-467 returns NULL before adoption), the QRX was never adopted and the caller's free is correct, so an unconditional qrx = NULL in the caller would have traded the double free for a leak. the actual patch handles that case explicitly, with a new ossl_qrx_free(qrx) call in port_make_channel().
applied the patch to master, rebuilt, reran the poc. server survives.
this only bites you if your application uses the QUIC listener API directly, most OpenSSL deployments are plain TLS and never touch this code.
emi.