Skip to content
HN On Hacker News ↗

Dead.Letter (CVE-2026-45185) How XBOW found an unauthenticated RCE on Exim

▲ 72 points 45 comments by fedek_ 2w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is fully human-written

4 %

AI likelihood · overall

Human
100% human-written 0% AI-generated
SEGMENTS · HUMAN 6 of 6
SEGMENTS · AI 0 of 6
WORD COUNT 1,567
PEAK AI % 15% · §1
Analyzed
May 12
backend: pangram/v3.3
Segments scanned
6 windows
avg 261 words each
Distribution
100 / 0%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 1,567 words · 6 segments analyzed

Human AI-generated
§1 Human · 15%

CVE-2026-45185.IntroDear reader,What follows is, before anything else, a story. One of those old, well-worn ones. A story of encounters and misencounters, of broken hearts and quiet betrayals, of loves once thought to be forever turning out to be something else entirely. Told, this time, in a setting where stories of that shape are not usually told.These pages are the by-product of the early days of testing a product we are building. A product focused on finding and detecting vulnerabilities in native code. So what you are about to read is two things at once. It is the technical account of a vulnerability of worldwide reach that we found and reported and also it is, more quietly, the account of how I tried to make peace with the new shape of the world we are now living in.I have spent almost ten years writing exploits professionally, and twenty in security altogether. In recent years large language models have arrived to shift the paradigm, and until now I had kept myself on the sidelines whenever it came to using them for writing exploits. This is the first time I have set that watch down and let one of these models into the place where, until now, only my own hands had ever been. Furthermore, in my entire career I had never once read a line of Exim's source. I remembered, vaguely, a Qualys write-up (https://www.qualys.com/2021/05/04/21nails/21nails.txt) from years ago that had blown my mind at that time, but I had never sat down with the code itself.I hope what follows finds two kinds of readers: the ones who came for the technical depth, and the ones who came for the story. I would be glad if it found both at once.The vulnerabilityThe bug is a use-after-free triggered when a TLS connection is handled by GnuTLS (the default TLS library on many Debian-based distributions, including Ubuntu). During TLS shutdown, Exim frees its TLS transfer buffer — but a nested BDAT receive wrapper can still process incoming bytes and end up calling ungetc(), which writes a single character (\n) into the freed region. That one-byte write lands on Exim's allocator metadata, corrupting the allocator's internal shape; the exploit then leverages that corruption to gain further primitives.

§2 Human · 3%

What matters here is that triggering this bug requires almost no special configuration on the server. That, more than the technical shape of the corruption itself, is what makes it one of the highest-caliber bugs discovered in Exim to date. The write primitive might seem deceptively weak at first glance: it puts a single newline character into a freed memory region. But as the rest of this post will show, that one byte is enough to escalate all the way to remote code execution. What follows is a walk through the technical details of how Exim works, and where the bug lives inside that machinery. If you came only for the story, feel free to jump ahead to [Setting the challenge].Note: all code in this post is from Exim 4.97 as it ships in the Debian-based, including Ubuntu 24.04 LTS, default installations.Exim BasicsWhen a client issues STARTTLS over a plaintext SMTP session, Exim's command dispatcher runs the following handler:#File: src/smtp_in.c int smtp_setup_msg(void) { // ... switch (smtp_read_command(...)) { // ... case STARTTLS_CMD: HAD(SCH_STARTTLS); if (!fl.tls_advertised) { done = synprot_error(L_smtp_protocol_error, 503, NULL, US"STARTTLS command used when not advertised"); break; } /* Apply an ACL check if one is defined */ if ( acl_smtp_starttls && (rc = acl_check(ACL_WHERE_STARTTLS, NULL, acl_smtp_starttls, &user_msg, &log_msg)) != OK) { done = smtp_handle_acl_fail(ACL_WHERE_STARTTLS, rc, user_msg, log_msg); break; } // ... s = NULL; if ((rc = tls_server_start(&s)) == OK) //[1] { // ... DEBUG(D_tls) debug_printf("TLS active\n"); break; /* Successful STARTTLS */ } // ... } // ... } [1] calls tls_server_start(), which ends up calling tls_init(), then allocates a fresh GnuTLS server session #File: src/tls-gnu.cstatic int tls_init( const host_item *host, smtp_transport_options_block * ob, const uschar * require_ciphers,

§3 Human · 1%

exim_gnutls_state_st **caller_state, tls_support * tlsp, uschar ** errstr) { //... state = &state_server; state->tlsp = tlsp; DEBUG(D_tls) debug_printf("initialising GnuTLS server session\n"); rc = gnutls_init(&state->session, GNUTLS_SERVER); //... }state->session is a gnutls_session_t, which is the GnuTLS handle that owns every piece of cryptographic state for this TLS connection: the negotiated cipher suite, the record-layer keys, the read and write sequence numbers, the ALPN selection, etc., but the important thing is that it also handles transport callbacks that bridge GnuTLS to Exim. Due to this being used to handle every subsequent TLS operation Exim performs, it is treated as a kind of object that handles the TLS state, so it is passed as an argument for any operation. For example:#File: src/tls-gnu.crc = gnutls_handshake(state->session); //... inbytes = gnutls_record_recv(state->session, state->xfer_buffer, ...); //... outbytes = gnutls_record_send(state->session, buff, left); //... gnutls_bye(state->session, GNUTLS_SHUT_WR); //... gnutls_deinit(state->session);Back in tls_server_start(), once the session exists Exim configures certificate verification, registers the SNI post-client-hello callback, replies 220 TLS go ahead, wires the GnuTLS transport layer into the SMTP input and output file descriptors, and finally runs the handshake.#File: src/tls-gnu.c int tls_server_start(uschar ** errstr) { int rc; exim_gnutls_state_st * state = NULL; // ... already-active check, tls_init() call, ALPN/resume setup ... if (verify_check_host(&tls_verify_hosts) == OK) { state->verify_requirement = VERIFY_REQUIRED; gnutls_certificate_server_set_request(state->session, GNUTLS_CERT_REQUIRE); } // ... gnutls_handshake_set_post_client_hello_function(state->session, exim_sni_handling_cb); if (!

§4 Human · 5%

state->tlsp->on_connect) { smtp_printf("220 TLS go ahead\r\n", FALSE); fflush(smtp_out); } gnutls_transport_set_ptr2(state->session, (gnutls_transport_ptr_t)(long) fileno(smtp_in), (gnutls_transport_ptr_t)(long) fileno(smtp_out)); state->fd_in = fileno(smtp_in); state->fd_out = fileno(smtp_out); //... }After the handshake succeeds, Exim allocates the transfer buffer with store_malloc() and replaces the SMTP receive functions with TLS wrapper functions. The purpose of these wrappers is to abstract callers of the receive_* functions from the underlying connection type.#File: exim-gnutls-noasan/src/tls-gnu.cint tls_server_start(uschar ** errstr) { //... state->xfer_buffer = store_malloc(ssl_xfer_buffer_size); receive_getc = tls_getc; receive_getbuf = tls_getbuf; receive_get_cache = tls_get_cache; receive_hasc = tls_hasc; receive_ungetc = tls_ungetc; receive_feof = tls_feof; receive_ferror = tls_ferror; return OK; }xfer_buffer is a 4096-byte plaintext area that Whenever the parser calls tls_getc() and the buffer is empty, tls_refill() decrypts a record into it; bytes are then consumed one at a time through the low-water mark xfer_buffer_lwm. The detail worth keeping in mind is that this buffer is a direct store_malloc() which is a wrapper of internal_store_malloc() which in turn is a wrapper for mallocFile: src/store.cvoid store_malloc_3(size_t size, const char func, int linenumber) { if (n_nonpool_blocks++ > max_nonpool_blocks) max_nonpool_blocks = n_nonpool_blocks; return internal_store_malloc(size, func, linenumber); //[1] }Once tls_server_start() returns, the entire SMTP I/O path goes through the TLS-aware callbacks installed.

§5 Human · 13%

Exim doesn't call tls_getc() directly — it calls the indirect function pointers we talked about before (receive_getc, receive_getbuf, etc).BDAT ChunkingBDAT (RFC 3030 CHUNKING) is the part of the SMTP grammar where a client says "the next N octets on the wire are body bytes; do not interpret them as SMTP commands." Unlike DATA, which delivers the body as a CRLF-terminated stream ending in ., BDAT N [LAST] declares the size up front and the receiver simply reads N bytes verbatim. For Exim's parser this poses a small implementation challenge. The parser is a state machine driven by indirect function pointers — receivegetc, receive_getbuf, receive_hasc, receive_ungetc. As we explained before, in a plaintext session that layer is smtp; after a successful STARTTLS, the entire row gets rewritten to point at tls_. But BDAT is a modal operation: it does not replace the underlying transport, it composes on top of it for a bounded number of bytes and then steps out of the way. To handle this, Exim keeps a second row of the same shape, lwrreceive*. When a BDAT chunk begins, bdat_push_receive_functions() copies the current top row down into the lower row and overwrites the top row with the BDAT wrappers:#File: src/smtp_in.cstatic inline void bdat_push_receive_functions(void) { /* push the current receive_* function on the "stack", and replace them by bdat_getc(), which in turn will use the lwr_receive_* functions to do the dirty work. */ if (!lwr_receive_getc) { lwr_receive_getc = receive_getc; lwr_receive_getbuf = receive_getbuf; lwr_receive_hasc = receive_hasc; lwr_receive_ungetc = receive_ungetc; } else { DEBUG(D_receive) debug_printf("chunking double-push receive functions\n"); } receive_getc = bdat_getc; receive_getbuf = bdat_getbuf; receive_hasc = bdat_hasc; receive_ungetc = bdat_ungetc; } The BDAT wrappers themselves do not parse SMTP commands.

§6 Human · 3%

What they do is defers all actual I/O to the lower layer they just saved:#File: src/smtp_in.c int bdat_getc(unsigned lim) { // ... for (;;) { if (chunking_data_left > 0) return lwr_receive_getc(chunking_data_left--); bdat_pop_receive_functions(); } } uschar * bdat_getbuf(unsigned * len) { uschar * buf; if (chunking_data_left == 0) { *len = 0; return NULL; } if (*len > chunking_data_left) *len = chunking_data_left; buf = lwr_receive_getbuf(len); /* Either smtp_getbuf or tls_getbuf */ chunking_data_left -= *len; return buf; } int bdat_ungetc(int ch) { chunking_data_left++; bdat_push_receive_functions(); /* we're not done yet, calling push is safe, because it checks the state before pushing anything */ return lwr_receive_ungetc(ch); } So while BDAT is active, every body byte the message reader pulls flows the following path:bdat_getc lwr_receive_getc tls_getc gnutls_record_recv xfer_buffer When the chunk is consumed, BDAT is supposed to unstack itself via bdat_pop_receive_functions(), which copies the lower row back up to the top and clears the lower row to NULL:#File: src/smtp_in.c static inline void dat_pop_receive_functions(void) { if (!lwr_receive_getc) { DEBUG(D_receive) debug_printf("chunking double-pop receive functions\n"); return; } receive_getc = lwr_receive_getc; receive_getbuf = lwr_receive_getbuf; receive_hasc = lwr_receive_hasc; receive_ungetc = lwr_receive_ungetc; lwr_receive_getc = NULL; lwr_receive_getbuf = NULL; lwr_receive_hasc = NULL; lwr_receive_ungetc = NULL; } The lower row is owned by BDAT, so once bdat_push_receive_functions() has put tls_* (or smtp_*) into lwr_receive_*, only bdat_pop_receive_functions() is supposed to take it back out. No other code path in Exim reads or writes the lower row.