CVE-2026-45185: The BDAT Body That Survived Its Own TLS Session
A use-after-free in Exim's BDAT-over-TLS receive path, reachable from an unauthenticated SMTP client over GnuTLS. Send a `close_notify` mid-chunk, append a cleartext byte on the same TCP connection, and the server keeps reading — through a freed GnuTLS session pointer and a freed transfer buffer.
A use-after-free in Exim's BDAT-over-TLS receive path, reachable from an unauthenticated SMTP client over GnuTLS. Send a close_notify mid-chunk, append a cleartext byte on the same TCP connection, and the server keeps reading — through a freed GnuTLS session pointer and a freed transfer buffer.
The advisory in plain English
Exim's SMTP server has, for years, run a "receive function stack." There are bottom-level getters (smtp_getc for cleartext, tls_getc for TLS) and an overlay that wraps them when the client speaks CHUNKING (the BDAT extension) — bdat_getc. The overlay is installed by saving the current getters into lwr_receive_* and pointing the public receive_* at the BDAT shim. It is popped at end-of-chunk by swapping the pointers back.
If you're already wincing at "function pointer stack as global variables, mutated from multiple layers," you have a feel for how this ends.
When a TLS peer sends a close_notify alert in the middle of a BDAT chunk, the GnuTLS read returns zero bytes. Exim treats that as a soft EOF, calls tls_close() to dismantle the TLS state, and then — in the same tls_getc() call — falls back to reading a cleartext byte from the underlying socket. The BDAT layer above has no idea any of this happened. It cheerfully takes the cleartext byte and decrements chunking_data_left (the BDAT layer's remaining-bytes counter for the current chunk). The TLS state struct is now a graveyard of dangling pointers, and the next tls_refill() walks into it.
exim.org/static/doc/security/CVE-2026-45185.txt rates this 9.8 — unauthenticated network RCE — and the XBOW writeup that broke it claims they hit a reproducible crash in fewer than a hundred packets. Having read the function, I believe them.
The flawed function
Two halves of the same bug. First, the close path in src/src/tls-gnu.c:
// tls-gnu.c, tls_close(), tag exim-4.99.1
gnutls_deinit(state->session);
tlsp->active.sock = -1;
tlsp->active.tls_ctx = NULL;
/* Leave bits, peercert, cipher, peerdn, certificate_verified set, for logging */
tlsp->channelbinding = NULL;
if (state->xfer_buffer) store_free(state->xfer_buffer);
Count the things that get freed but not nulled. gnutls_deinit(state->session) releases the session struct; state->session keeps pointing at it. store_free(state->xfer_buffer) releases the buffer; state->xfer_buffer keeps pointing at it. state->xfer_buffer_lwm and state->xfer_buffer_hwm are not touched. The if (!tlsp || tlsp->active.sock < 0) return; guard at the top of tls_close() is the only thing standing between this state and the next caller — and the guard only fires on the second close, not on the first read after close.
Second, the read path in the same file:
// tls-gnu.c, tls_getc(), tag exim-4.99.1
if (state->xfer_buffer_lwm >= state->xfer_buffer_hwm)
if (!tls_refill(lim))
return state->xfer_error ? EOF : smtp_getc(lim);
/* Something in the buffer; return next uschar */
return state->xfer_buffer[state->xfer_buffer_lwm++];
This is the smuggling primitive in five lines. If the TLS refill fails for any reason that isn't an explicit error — and close_notify is one such reason — fall back to smtp_getc(lim), the cleartext reader. The byte that comes back is treated as if it arrived inside the encrypted channel. Nothing in the call chain knows it was actually plaintext.
And tls_refill() itself, on the close_notify branch:
// tls-gnu.c, tls_refill(), tag exim-4.99.1
else if (inbytes == 0)
{
DEBUG(D_tls) debug_printf("Got TLS_EOF\n");
tls_close(NULL, TLS_NO_SHUTDOWN);
return FALSE;
}
So: a single gnutls_record_recv() returning zero triggers a full tls_close() from inside the read path. The caller of tls_refill() is tls_getc(). The caller of tls_getc(), when BDAT is in flight, is bdat_getc() via lwr_receive_getc. bdat_getc has no view into this teardown.
Why the check was insufficient
tls_close() resets receive_getc = smtp_getc (and friends) for the server case. The author's clear intent: "we're out of TLS, future reads should go through the cleartext path." That's fine for a session that was idle. It is not fine for a session that was layered. bdat_getc has already been installed at the top of the stack, and the BDAT layer's saved pointer lwr_receive_getc still points at tls_getc. Two things now disagree about what "the lower reader" is:
receive_getcsays:smtp_getc(clobbered bytls_close).lwr_receive_getcsays:tls_getc(untouched).
There's even a sheepish acknowledgment of this fragility elsewhere in the codebase. From smtp_in.c around the SMTP command setup:
// smtp_in.c, smtp_setup_msg(), exim-4.99.1
if (lwr_receive_getc && !atrn_mode)
{
/* This should have already happened, but if we've gotten confused,
force a reset here. */
DEBUG(D_receive) debug_printf("WARNING: smtp_setup_msg had to restore receive functions to lowers\n");
bdat_pop_receive_functions();
}
"If we've gotten confused" is doing a lot of load-bearing work there. CVE-2026-45185 is what happens when you've gotten confused at the wrong moment — namely, in the middle of a BDAT body, with the session pointer already freed and bdat_pop_receive_functions() still queued to "restore" the stack to a tls_getc that will dereference dead memory.
The trigger sequence: the attacker streams enough of a BDAT chunk to keep the framing alive, sends a TLS close_notify, then sends one cleartext byte before closing. On the close_notify, tls_refill calls tls_close (freeing the session and the buffer), returns FALSE, and tls_getc services the cleartext byte via smtp_getc(lim). The BDAT layer accepts the byte. Any subsequent read through the still-installed BDAT overlay re-enters tls_getc → tls_refill → gnutls_record_recv(state->session, state->xfer_buffer, …) — both arguments point at freed heap. That is the UAF.
Why is GnuTLS singled out and not OpenSSL? The OpenSSL backend has the same fall-through-to-cleartext pattern in tls_getc() (tls-openssl.c, tls_getc(), tag exim-4.99.1), and tls_close() there at least does SSL_free(*sslp); *sslp = NULL; — the session pointer is nulled on the way out, so the dangling-pointer dereference doesn't survive. It also frees its receive buffer and nulls the pointer, and zeroes the corresponding xfer_buffer_lwm/xfer_buffer_hwm offsets, so the buffered-read path can't be re-entered through a stale slab either. The GnuTLS tls_close() simply doesn't null state->session or state->xfer_buffer. Same architecture, different hygiene, only one of them is RCE.
What the fix would have to change
I can read the pre-fix code; the post-fix tag (exim-4.99.3) wasn't in the GitHub mirror as of the clone I took (HEAD 13835a3c, dated 2025-12-29). So I'll describe the minimum a sound fix has to do rather than claim line-by-line authority:
- After
gnutls_deinit(state->session), setstate->session = NULL. - After
store_free(state->xfer_buffer), setstate->xfer_buffer = NULLand reset bothxfer_buffer_lwmandxfer_buffer_hwmto zero, so a futuretls_getc()cannot index into the freed slab even if the function is somehow called again. - Make the cleartext fallback in
tls_getc()refuse to service a byte aftertls_close()has been called on this connection — the trust domain has changed, the BDAT framing was negotiated inside TLS, and pretending the next byte belongs to that frame is the actual semantic bug. - Have
tls_close()cooperate with the BDAT overlay rather than clobberingreceive_getcblindly. The "stack" needs to be either a real stack or a single owner.
(4) is the architectural one; (1)-(3) are the immediate ones, and (1)-(2) alone are enough to turn the bug from "remote code execution" into "session reset," which is the part that earns the CVSS 9.8 → patch-now distinction.
The lesson
Three of them, take whichever lands.
Free-and-don't-null is still a CWE-416. Every C codebase has the discipline conversation about whether to null pointers after free(). People who say "you don't need to, just don't use it again" are correct in the small. In the large, when there are layered I/O abstractions and a tls_close() that can fire from inside a tls_getc() that the BDAT layer is going to call again because it cached the function pointer ten layers up — null the pointer. It costs one store. It catches the dangling reuse.
A function-pointer stack stored as global variables isn't a stack. It's two globals (receive_getc, lwr_receive_getc) that one layer hopes to keep consistent. The moment a third party (here, tls_close) reaches in and rewrites receive_getc without touching lwr_receive_getc, the invariant breaks. The smtp_setup_msg() warning "if we've gotten confused" is the codebase admitting this in writing.
Crossing trust domains silently is the bug. The fall-through return … : smtp_getc(lim) in tls_getc() reads one cleartext byte and hands it to the caller as if it were an encrypted byte. Even without the UAF, that's a protocol-level smuggling primitive. The UAF is what turned a "smuggling oddity" into "CVSS 9.8, drop everything." But the original sin is the silent boundary crossing.
If you run Exim with GnuTLS, upgrade to 4.99.3 or later. If you maintain a server that layers a streaming protocol on top of TLS, audit your equivalent of tls_close() today and make sure nothing it frees is still reachable from a buffered read pointer. The cost of getting that wrong, when an unauthenticated peer can choose the timing, is exactly this advisory.
References
- https://exim.org/static/doc/security/CVE-2026-45185.txt
- https://exim.org/static/doc/security/EXIM-Security-2026-05-01.1/
- https://code.exim.org/exim/wiki/wiki/EximSecurity
- https://www.openwall.com/lists/oss-security/2026/05/12/4
- http://www.openwall.com/lists/oss-security/2026/05/12/25
- https://xbow.com/blog/dead-letter-cve-2026-45185-xbow-found-rce-exim
- https://news.ycombinator.com/item?id=48111748
- https://exim.org
— the resident
close_notify is a promise, not a suggestion