[exim/exim] CVE-2026-45185 seems to be unexploitable. (Issue #3225)
Rabb1t via Exim-dev <[email protected]> Thu, 14 May 2026 10:12:22 +0100
| Newsgroups | gmane.mail.exim.devel |
|---|---|
| Message-ID | <exim/exim/issues/[email protected]> |
Hello Exim team, I downloaded version 4.97 from GitHub to reproduce the vulnerability mentioned in the Xbox case. However, this vulnerability seems unable to be reproduced normally and is theoretically unattainable.
--------------
0. Environment
--------------
Item Value Exim version 4.97 (upstream source tarball) TLS backend GnuTLS 3.8.3-1.1ubuntu3.5 Compiler GCC (Ubuntu), -g -O0 -fno-inline Linker flags -rdynamic OS Ubuntu 24.04 LTS Trigger client Go 1.x ( crypto/tls ), C ( gnutls client library) Configuration /opt/exim/configure , spool_wireformat not set (default FALSE ) CHUNKING chunking_advertise_hosts = * (enabled) DKIM Disabled for loopback by default relay ACL
------------------------------
1. Source Code Instrumentation
------------------------------
1.1 Files Modified
------------------
File Functions/Locations Modified src/tls-gnu.c tls_refill() , tls_close() src/tls.c tls_ungetc() src/smtp_in.c bdat_getc() , bdat_ungetc() , smtp_ungetc() src/receive.c read_message_bdat_smtp() , read_message_bdat_smtp_wire() , receive_msg()
1.2 tls_refill() — TLS EOF Detection
------------------------------------
static BOOL
tls_refill ( unsigned lim )
{
exim_gnutls_state_st * state = & state_server ;
ssize_t inbytes ;
DEBUG ( D_tls ) debug_printf ( "Calling gnutls_record_recv(session=%p, buffer=%p, buffersize=%u) \n " ,
state -> session , state -> xfer_buffer , ssl_xfer_buffer_size );
sigalrm_seen = FALSE ;
if ( smtp_receive_timeout > 0 ) ALARM ( smtp_receive_timeout );
errno = 0 ;
do
inbytes = gnutls_record_recv ( state -> session , state -> xfer_buffer ,
MIN ( ssl_xfer_buffer_size , lim ));
while ( inbytes == GNUTLS_E_AGAIN );
// ... timeout handling ...
else if ( inbytes == 0 )
{
fprintf ( stderr , "[tls_refill] TLS_EOF: inbytes=0, about to call tls_close() \n " );
fflush ( stderr );
DEBUG ( D_tls ) debug_printf ( "Got TLS_EOF \n " );
tls_close ( NULL , TLS_NO_SHUTDOWN );
fprintf ( stderr , "[tls_refill] tls_close() returned, xfer_buffer=%p \n " ,
state -> xfer_buffer );
fflush ( stderr );
return FALSE ;
}
else if ( inbytes < 0 )
{
DEBUG ( D_tls ) debug_printf ( "tls_refill: err from gnutls_record_recv \n " );
record_io_error ( state , ( int ) inbytes , US "recv" , NULL );
state -> xfer_error = TRUE ;
return FALSE ;
}
// ...
}
*Note* : fprintf(stderr, ...) was only used in early debugging. In later stages, all instrumentation was changed to write to /tmp/exim_trace.txt because stderr is closed in daemon mode ( -bd ).
1.3 tls_close() — receive_ungetc Pointer Verification
-----------------------------------------------------
void
tls_close ( void * ct_ctx , int do_shutdown )
{
// ...
if ( ! ct_ctx ) /* server */
{
receive_getc = smtp_getc ;
receive_getbuf = smtp_getbuf ;
receive_get_cache = smtp_get_cache ;
receive_hasc = smtp_hasc ;
receive_ungetc = smtp_ungetc ;
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[tls_close] receive_ungetc=%p smtp_ungetc=%p \n " ,
( void * ) receive_ungetc , ( void * ) smtp_ungetc ); fclose ( fp ); } }
receive_feof = smtp_feof ;
receive_ferror = smtp_ferror ;
}
gnutls_deinit ( state -> session );
// ...
if ( state -> xfer_buffer ) store_free ( state -> xfer_buffer );
// ...
}
1.4 tls_ungetc() — Entry Logging
--------------------------------
/* File: src/tls.c */
int
tls_ungetc ( int ch )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[tls_ungetc] HIT ch=%d (0x%02x) \n " , ch , ch ); fclose ( fp ); } }
if ( ssl_xfer_buffer_lwm <= 0 )
log_write ( 0 , LOG_MAIN | LOG_PANIC_DIE , "buffer underflow in tls_ungetc" );
ssl_xfer_buffer [ -- ssl_xfer_buffer_lwm ] = ch ;
return ch ;
}
1.5 bdat_getc() — Chunking Data Tracking, EOD Return, Backtrace
---------------------------------------------------------------
/* File: src/smtp_in.c */
int
bdat_getc ( unsigned lim )
{
uschar * user_msg = NULL ;
uschar * log_msg ;
for (;;)
{
#ifndef DISABLE_DKIM
unsigned dkim_save ;
#endif
if ( chunking_data_left > 0 )
{
int ch = lwr_receive_getc ( chunking_data_left -- );
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[bdat_getc] chunk_left=%d ch=%d (0x%02x) \n " ,
chunking_data_left + 1 , ch , ch ); fclose ( fp ); } }
return ch ;
}
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[bdat_getc] chunking_data_left=0, pop_receive_functions \n " ); fclose ( fp ); } }
bdat_pop_receive_functions ();
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[bdat_getc] after pop: receive_getc=%p lwr_receive_getc=%p \n " ,
( void * ) receive_getc , ( void * ) lwr_receive_getc ); fclose ( fp ); } }
#ifndef DISABLE_DKIM
dkim_save = dkim_collect_input ;
dkim_collect_input = 0 ;
#endif
/* pipelining check (skipped because pipelining=1 short-circuits) */
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[bdat_getc] chunking_state=%d, pipelining=%d \n " ,
chunking_state , f. smtp_in_pipelining_advertised ); fclose ( fp ); } }
if ( chunking_state == CHUNKING_LAST )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ) {
void * bt [ 20 ];
int n = backtrace ( bt , 20 );
fprintf ( fp , "[bdat_getc] RETURNING EOD bt_size=%d \n " , n );
backtrace_symbols_fd ( bt , n , fileno ( fp ));
fclose ( fp );
}
}
#ifndef DISABLE_DKIM
dkim_collect_input = dkim_save ;
dkim_exim_verify_feed ( NULL , 0 );
dkim_collect_input = 0 ;
#endif
return EOD ;
}
// ... (BDAT command loop follows, not reached in our test)
}
}
1.6 bdat_ungetc() — Entry Logging
---------------------------------
/* File: src/smtp_in.c */
int
bdat_ungetc ( int ch )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[bdat_ungetc] REACHED! ch=%d (0x%02x) chunk_left=%d lwr_ungetc=%p \n " ,
ch , ch , chunking_data_left , ( void * ) lwr_receive_ungetc ); fclose ( fp ); } }
chunking_data_left ++ ;
bdat_push_receive_functions ();
return lwr_receive_ungetc ( ch );
}
1.7 smtp_ungetc() — Entry Logging
---------------------------------
/* File: src/smtp_in.c */
int
smtp_ungetc ( int ch )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[smtp_ungetc] HIT ch=%d (0x%02x) \n " , ch , ch ); fclose ( fp ); } }
if ( smtp_inptr <= smtp_inbuffer )
log_write ( 0 , LOG_MAIN | LOG_PANIC_DIE , "buffer underflow in smtp_ungetc" );
*-- smtp_inptr = ch ;
return ch ;
}
1.8 read_message_bdat_smtp() — Entry, Switch, EOD Branch
--------------------------------------------------------
/* File: src/receive.c */
static int
read_message_bdat_smtp ( FILE * fout )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[ENTER] read_message_bdat_smtp \n " ); fclose ( fp ); } }
int linelength = 0 , ch ;
enum CH_STATE ch_state = LF_SEEN ;
BOOL fix_nl = FALSE ;
for (;;)
{
ch = bdat_getc ( GETC_BUFFER_UNLIMITED );
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[SWITCH] ch=%d EOD=%d EOF=%d ERR=%d \n " ,
ch , EOD , EOF , ERR ); fclose ( fp ); } }
switch ( ch )
{
case EOF : return END_EOF ;
case ERR : return END_PROTOCOL ;
case EOD :
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[EOD] fout=%p linelength=%d \n " ,
( void * ) fout , linelength ); fclose ( fp ); } }
if ( fout )
{
if ( fseek ( fout , - 1 , SEEK_CUR ) < 0 )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[EOD] fseek failed -> END_PROTOCOL \n " ); fclose ( fp ); } }
return END_PROTOCOL ;
}
int lastch = fgetc ( fout );
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[EOD] fgetc=%d (0x%02x) \n " , lastch , lastch ); fclose ( fp ); } }
if ( lastch == '\n' )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[EOD] last char \\ n -> END_DOT \n " ); fclose ( fp ); } }
return END_DOT ;
}
}
if ( linelength == - 1 )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[EOD] linelength==-1 -> bdat_ungetc(' \\ n') \n " ); fclose ( fp ); } }
bdat_ungetc ( '\n' );
continue ;
}
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[EOD] -> bdat_ungetc(' \\ r') \n " ); fclose ( fp ); } }
bdat_ungetc ( '\r' );
fix_nl = TRUE ;
continue ;
// ... (other cases)
}
// ... (state machine)
}
}
1.9 read_message_bdat_smtp_wire() — EOD Branch
----------------------------------------------
/* File: src/receive.c */
static int
read_message_bdat_smtp_wire ( FILE * fout )
{
// ...
for (;;)
{
if ( chunking_data_left > 0 )
{
unsigned len = MAX ( chunking_data_left , thismessage_size_limit - message_size + 1 );
uschar * buf = bdat_getbuf ( & len );
if ( ! buf ) return END_EOF ;
// ...
}
else switch ( ch = bdat_getc ( GETC_BUFFER_UNLIMITED ))
{
case EOF : return END_EOF ;
case EOD :
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[EOD-WIRE] -> END_DOT \n " ); fclose ( fp ); } }
return END_DOT ;
case ERR : return END_PROTOCOL ;
// ...
}
}
}
1.10 receive_msg() — BDAT Dispatch Point
----------------------------------------
/* File: src/receive.c */
// ...
if ( ! ferror ( spool_data_file ) && ! ( receive_feof )() && message_ended != END_DOT )
{
if ( smtp_input )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[receive_msg] chunking_state=%d CHUNKING_OFFERED=%d spool_wireformat=%d \n " ,
chunking_state , CHUNKING_OFFERED , spool_wireformat ); fclose ( fp ); } }
message_ended = chunking_state <= CHUNKING_OFFERED
? read_message_data_smtp ( spool_data_file )
: spool_wireformat
? read_message_bdat_smtp_wire ( spool_data_file )
: read_message_bdat_smtp ( spool_data_file );
receive_linecount ++ ;
}
// ...
}
1.11 receive_msg() — receive_ungetc Call Site (dot-ends handling)
-----------------------------------------------------------------
/* File: src/receive.c */
if ( f. dot_ends && ptr == 0 && ch == '.' )
{
ch = ( receive_getc )( GETC_BUFFER_UNLIMITED );
if ( ch == '\r' )
{
ch = ( receive_getc )( GETC_BUFFER_UNLIMITED );
if ( ch != '\n' )
{
if ( ch >= 0 )
{
{ FILE * fp = fopen ( "/tmp/exim_trace.txt" , "a" );
if ( fp ){ fprintf ( fp , "[receive_msg] receive_ungetc=%p \n " ,
( void * ) receive_ungetc ); fclose ( fp ); } }
receive_ungetc ( ch );
}
ch = '\r' ;
}
}
// ...
}
-----------------
2. Trigger Client
-----------------
2.1 Go Client (Final Working Version)
-------------------------------------
package main
import (
"crypto/tls"
"fmt"
"net"
"time"
)
func main () {
conn , _ := net. Dial ( "tcp" , "127.0.0.1:25" )
buf := make ([] byte , 4096 )
conn. Read ( buf )
conn. Write ([] byte ( "EHLO test\r\n" ))
conn. Read ( buf )
conn. Write ([] byte ( "STARTTLS\r\n" ))
conn. Read ( buf )
tlsConfig := & tls. Config { InsecureSkipVerify : true }
tlsConn := tls. Client ( conn , tlsConfig )
tlsConn. Handshake ()
tlsConn. Write ([] byte ( "EHLO test\r\n" ))
tlsConn. Read ( buf )
tlsConn. Write ([] byte ( "MAIL FROM:<test@localhost>\r\n" ))
tlsConn. Read ( buf )
tlsConn. Write ([] byte ( "RCPT TO:<root@localhost>\r\n" ))
tlsConn. Read ( buf )
// BDAT 4095 LAST, body = 4094 * "X" + "\r"
body := make ([] byte , 4095 )
for i := 0 ; i < 4094 ; i ++ {
body [ i ] = 'X'
}
body [ 4094 ] = '\r'
tlsConn. Write ([] byte ( "BDAT 4095 LAST\r\n" ))
tlsConn. Write ( body )
time. Sleep ( 300 * time. Millisecond )
// CloseWrite: send close_notify, do NOT wait for peer response
tlsConn. CloseWrite ()
time. Sleep ( 300 * time. Millisecond )
// Underlying TCP still usable, send plaintext to trigger smtp_getc() fallback
conn. Write ([] byte ( "\r\n" ))
time. Sleep ( time. Second )
conn. SetReadDeadline ( time. Now (). Add ( 2 * time. Second ))
n , _ := conn. Read ( buf )
if n > 0 {
fmt. Printf ( "[resp] %s\n" , buf [: n ])
}
conn. Close ()
}
2.2 C Client (Alternative, for GnuTLS-native close_notify)
----------------------------------------------------------
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <gnutls/gnutls.h>
int main () {
setbuf ( stdout , NULL );
int sock = socket ( AF_INET , SOCK_STREAM , 0 );
struct sockaddr_in addr = { 0 };
addr. sin_family = AF_INET ;
addr. sin_port = htons ( 25 );
inet_pton ( AF_INET , "127.0.0.1" , & addr. sin_addr );
connect ( sock , ( struct sockaddr * ) & addr , sizeof ( addr ));
char buf [ 8192 ] = { 0 };
recv ( sock , buf , sizeof ( buf ) - 1 , 0 );
send ( sock , "EHLO test \r\n " , 11 , 0 );
recv ( sock , buf , sizeof ( buf ) - 1 , 0 );
send ( sock , "STARTTLS \r\n " , 10 , 0 );
recv ( sock , buf , sizeof ( buf ) - 1 , 0 );
gnutls_session_t session ;
gnutls_certificate_credentials_t xcred ;
gnutls_global_init ();
gnutls_certificate_allocate_credentials ( & xcred );
gnutls_init ( & session , GNUTLS_CLIENT );
gnutls_set_default_priority ( session );
gnutls_credentials_set ( session , GNUTLS_CRD_CERTIFICATE , xcred );
gnutls_transport_set_int ( session , sock );
gnutls_handshake ( session );
gnutls_record_send ( session , "EHLO test \r\n " , 11 );
gnutls_record_recv ( session , buf , sizeof ( buf ) - 1 );
gnutls_record_send ( session , "MAIL FROM:<test@localhost> \r\n " , 28 );
gnutls_record_recv ( session , buf , sizeof ( buf ) - 1 );
gnutls_record_send ( session , "RCPT TO:<root@localhost> \r\n " , 27 );
gnutls_record_recv ( session , buf , sizeof ( buf ) - 1 );
char body [ 4096 ];
memset ( body , 'X' , 4094 );
body [ 4094 ] = '\r' ;
gnutls_record_send ( session , "BDAT 4095 LAST \r\n " , 16 );
gnutls_record_send ( session , body , 4095 );
usleep ( 300000 );
printf ( "[*] gnutls_bye(SHUT_WR)... \n " );
int ret = gnutls_bye ( session , GNUTLS_SHUT_WR );
printf ( "[*] bye returned: %d \n " , ret );
usleep ( 300000 );
printf ( "[*] plaintext send... \n " );
send ( sock , " \r\n " , 2 , 0 );
usleep ( 500000 );
int n = recv ( sock , buf , sizeof ( buf ) - 1 , MSG_DONTWAIT );
if ( n > 0 ) printf ( "[resp] %.*s \n " , n , buf );
else printf ( "[resp] none (n=%d) \n " , n );
gnutls_deinit ( session );
gnutls_certificate_free_credentials ( xcred );
gnutls_global_deinit ();
close ( sock );
return 0 ;
}
-----------------
3. Build Commands
-----------------
# Configure compiler flags
# /opt/exim/src/Local/Makefile:
# CC=gcc -g -O0 -fno-inline
# LDFLAGS=-rdynamic
# Build
cd /opt/exim/src
make clean
make EXIM_RELEASE_VERSION = 4.97 -j $( nproc )
# Verify symbols
nm build-Linux-x86_64/exim | grep -E "read_message_bdat|bdat_ungetc|receive_msg|tls_ungetc|smtp_ungetc"
# Start Exim
pkill -9 exim 2>/dev/null
rm -f /tmp/exim_trace.txt
./build-Linux-x86_64/exim -bd -d+tls 2>/tmp/exim_dbg.log &
sleep 1
# Run trigger
timeout 10 go run /opt/test_go_trace.go
# Collect results
sleep 1
cat /tmp/exim_trace.txt
grep -E "TLS_EOF|tls_close|inbytes=0|xfer_error|tls_ungetc" /tmp/exim_dbg.log
tail -10 /opt/exim/log/mainlog
----------
4. Results
----------
4.1 Symbol Table (nm output)
----------------------------
00000000000a6c5f T bdat_ungetc
000000000009229e t read_message_bdat_smtp
0000000000091f3f t read_message_bdat_smtp_wire
0000000000094033 T receive_msg
* T = global symbol
* t = local (static) symbol — eligible for automatic inlining within the same translation unit
4.2 Exim Debug Log (TLS EOF Confirmation)
-----------------------------------------
[tls_refill] TLS_EOF: inbytes=0, about to call tls_close()
109738 Got TLS_EOF
[tls_refill] tls_close() returned, xfer_buffer=0x620a01f81a28
inbytes==0 (clean TLS EOF) path confirmed. tls_close() called. xfer_buffer freed.
4.3 /tmp/exim_trace.txt (File-based Instrumentation Output)
-----------------------------------------------------------
[bdat_getc] chunk_left=4095 ch=88 (0x58)
[bdat_getc] chunk_left=4094 ch=88 (0x58)
...
[bdat_getc] chunk_left=2 ch=88 (0x58)
[bdat_getc] chunk_left=1 ch=13 (0x0d)
[bdat_getc] chunking_data_left=0, pop_receive_functions
[bdat_getc] after pop: receive_getc=0x5d6d72e4b629 lwr_receive_getc=(nil)
[bdat_getc] chunking_state=2, pipelining=1
./build-Linux-x86_64/exim(bdat_getc+0x27f)[0x5d6d72e4ea7c]
./build-Linux-x86_64/exim(receive_msg+0x862)[0x5d6d72e39895]
./build-Linux-x86_64/exim(+0x40ade)[0x5d6d72de5ade]
./build-Linux-x86_64/exim(daemon_go+0x1f0c)[0x5d6d72de81cc]
./build-Linux-x86_64/exim(main+0x5ae1)[0x5d6d72e01d1c]
/lib/x86_64-linux-gnu/libc.so.6(+0x2a1ca)[0x71f0bd82a1ca]
/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x71f0bd82a28b]
./build-Linux-x86_64/exim(_start+0x25)[0x5d6d72ddaeb5]
[bdat_getc] RETURNING EOD bt_size=8
[tls_close] receive_ungetc=0x5d6d72e4b629 smtp_ungetc=0x5d6d72e4b629
4.4 What Appeared vs. What Did NOT Appear
-----------------------------------------
Instrumentation Point Appeared? [tls_refill] TLS_EOF YES [tls_close] receive_ungetc=... YES [bdat_getc] chunk_left=... (4095→1) YES [bdat_getc] chunking_data_left=0, pop... YES [bdat_getc] after pop: lwr_receive_getc=(nil) YES [bdat_getc] chunking_state=2, pipelining=1 YES [bdat_getc] RETURNING EOD bt_size=8 YES [ENTER] read_message_bdat_smtp *NO* [SWITCH] ch=... *NO* [EOD] fout=... linelength=... *NO* [EOD] linelength==-1 -> bdat_ungetc... *NO* [EOD] -> bdat_ungetc('\\r') *NO* [EOD-WIRE] -> END_DOT *NO* [bdat_ungetc] REACHED... *NO* [tls_ungetc] HIT... *NO* [smtp_ungetc] HIT... *NO* [receive_msg] chunking_state=... *NO* [receive_msg] receive_ungetc=... *NO*
4.5 Exim Mainlog
----------------
2026-05-14 15:03:06 TLS error on connection from (test) [127.0.0.1] (gnutls_handshake): (unknown error code)
Note: Some runs showed TLS error ... non-properly terminated. The Go client occasionally encountered tls: internal error during handshake due to a Go crypto/tls ↔ GnuTLS compatibility issue. Successful runs (using the C client or Go with retries) showed SMTP connection from ... lost while reading message data (header).
-----------
5. Analysis
-----------
5.1 Confirmed Execution Flow
----------------------------
1. Go CloseWrite() → TLS close_notify alert sent to server
2. GnuTLS server: gnutls_record_recv() returns 0 (clean EOF)
3. tls_refill(): inbytes==0 branch taken
4. tls_close(NULL, TLS_NO_SHUTDOWN):
a. receive_getc = smtp_getc
b. receive_ungetc = smtp_ungetc ← CONFIRMED: pointer == smtp_ungetc
c. gnutls_deinit(state->session)
d. store_free(state->xfer_buffer) ← xfer_buffer freed
5. tls_getc() fallback: smtp_getc(lim) reads remaining body bytes in plaintext
6. bdat_getc() consumes body bytes, chunking_data_left → 0
7. bdat_pop_receive_functions():
a. receive_getc = lwr_receive_getc
b. lwr_receive_getc = NULL ← CONFIRMED: (nil)
8. chunking_state == CHUNKING_LAST (value 2)
9. bdat_getc() returns EOD ← CONFIRMED: RETURNING EOD
10. EOD processing within receive_msg (inlined read_message_bdat_smtp)
11. receive_ungetc(ch) called
12. Actual call: smtp_ungetc(ch) ← INFERRED: tls_ungetc NOT triggered
13. Write to smtp_inbuffer ← Safe: plaintext buffer, not freed xfer_buffer
5.2 Critical Finding: receive_ungetc Pointer Value
--------------------------------------------------
The instrumentation in tls_close() captured:
[tls_close] receive_ungetc=0x5d6d72e4b629 smtp_ungetc=0x5d6d72e4b629
Both pointers are identical. After tls_close() executes, receive_ungetc points to smtp_ungetc , NOT tls_ungetc.
5.3 Functions NOT Called
------------------------
Despite bdat_getc() returning EOD and receive_msg internally processing the EOD repair path (evidenced by the call *receive_ungetc at receive_msg+0x862 in disassembly), the following functions were *never entered* :
* bdat_ungetc() — entry log never appeared
* tls_ungetc() — entry log never appeared
* smtp_ungetc() — entry log never appeared
The absence of smtp_ungetc log suggests that even though receive_ungetc points to smtp_ungetc after tls_close() , the actual receive_ungetc(ch) call at the EOD repair site may have been inlined by the compiler, or the EOD repair path was not reached in the exact code location we instrumented.
5.4 Inlining of read_message_bdat_smtp
--------------------------------------
Several pieces of evidence point to read_message_bdat_smtp being inlined into receive_msg :
* nm shows read_message_bdat_smtp as t (local/static symbol) — -fno-inline does not prevent automatic inlining of static functions within the same translation unit
* Backtrace shows bdat_getc → receive_msg with no intermediate frame for read_message_bdat_smtp
* Disassembly at receive_msg+0x862 shows call *receive_ungetc — code that corresponds to the EOD repair logic inside read_message_bdat_smtp , appearing in receive_msg 's address space
* The [ENTER] read_message_bdat_smtp entry log never fired, consistent with the function prologue being eliminated by inlining
* GDB breakpoint at read_message_bdat_smtp never triggered
5.5 Disassembly at receive_msg+0x862
------------------------------------
948a4: call *0x9b27e(%rip) # indirect call through GOT → receive_ungetc
948aa: movslq %ebp,%rax
948ad: mov 0x10(%r12),%rdx
948b2: movb $0xa,(%rdx,%rax,1) # direct write of '\n' to buffer
948b6: addl $0x1,0xad45f(%rip) # message_size++
The indirect call to receive_ungetc is followed by what appears to be inline-expanded buffer write code. At the moment of the call, receive_ungetc points to smtp_ungetc (confirmed in §5.2).
5.6 Why tls_ungetc Was Not Called
---------------------------------
The root cause is straightforward: tls_close() restores receive_ungetc = smtp_ungetc before freeing xfer_buffer. The CVE description claims that lwr_receive_ungetc still points to tls_ungetc and that bdat_ungetc calls through lwr_receive_ungetc. However:
* bdat_ungetc() itself was never called in our test
* The EOD repair path calls receive_ungetc (the top-level pointer), not lwr_receive_ungetc
* tls_close() already set receive_ungetc = smtp_ungetc
* Therefore, even if the EOD repair path executes, it calls smtp_ungetc , which writes to smtp_inbuffer (a safely allocated plaintext buffer), not to the freed ssl_xfer_buffer
5.7 Open Question: bdat_pop_receive_functions() and lwr_receive_ungetc
----------------------------------------------------------------------
There is a theoretical scenario not fully eliminated by our instrumentation:
tls_close() → receive_ungetc = smtp_ungetc
→ bdat_pop_receive_functions() → receive_ungetc = lwr_receive_ungetc
(could be tls_ungetc if not yet cleared)
→ EOD repair → receive_ungetc('\n') → tls_ungetc('\n') → UAF write
For this to happen, lwr_receive_ungetc must still be tls_ungetc at the time of bdat_pop_receive_functions(). Our log shows lwr_receive_getc=(nil) after pop, but we did not log lwr_receive_ungetc. However, bdat_pop_receive_functions() clears all four lwr_receive_* pointers together, so if lwr_receive_getc is NULL, lwr_receive_ungetc should also be NULL.
Regardless, even in this scenario, tls_ungetc() was never called (its entry log would have fired). This is the strongest evidence that the UAF write path was not reached.
-------------
6. Conclusion
-------------
6.1 Verified
------------
* *Clean TLS EOF triggerable* : Go CloseWrite() successfully triggers inbytes==0 in Exim's GnuTLS backend
* *tls_close() executes* : xfer_buffer is freed via store_free()
* *bdat_getc() returns EOD* : BDAT body consumed, CHUNKING_LAST branch returns EOD
* *receive_ungetc = smtp_ungetc* : After tls_close() , the function pointer is restored to the safe plaintext handler
* *EOD repair code executes within receive_msg* : Confirmed by disassembly and backtrace
6.2 NOT Verified (Not Reached)
------------------------------
* bdat_ungetc() was never called
* tls_ungetc() was never called
* smtp_ungetc() was never called (possibly inlined at the call site)
* No UAF write occurred — no write to freed ssl_xfer_buffer
6.3 Root Cause of Non-Reachability
----------------------------------
tls_close() restores receive_ungetc = smtp_ungetc *before* freeing xfer_buffer. The EOD repair path calls through receive_ungetc (the top-level function pointer), which has been set to smtp_ungetc. The write target is smtp_inbuffer (plaintext input buffer), not the freed ssl_xfer_buffer.
The CVE-2026-45185 exploit chain assumes that lwr_receive_ungetc remains pointing to tls_ungetc and that bdat_ungetc calls through it. In our test environment, bdat_ungetc was never invoked, and even if the EOD repair path executed, it used receive_ungetc (pointing to smtp_ungetc ), not lwr_receive_ungetc.
6.4 Scope and Limitations
-------------------------
This conclusion applies to the tested environment:
* Exim 4.97 upstream source, compiled with GCC -g -O0 -fno-inline
* GnuTLS 3.8.3 backend
* BDAT chunk: 4095 bytes, ending with \r
* Trigger: Go CloseWrite() (TLS clean shutdown, no bidirectional close_notify wait)
The following were NOT tested and may produce different results:
* Ubuntu/Debian distribution builds of Exim (with distro-specific patches)
* OpenSSL TLS backend (different shutdown semantics)
* Different BDAT chunk sizes and body content (affecting linelength state machine)
* Different compiler optimization levels (affecting inlining of function pointers)
* Multiple BDAT chunks (affecting push/pop state of lwr_receive_* pointers)
---
View it on Exim Forgejo ( https://code.exim.org/exim/exim/issues/3225 ) or reply to this email directly.
--
## subscription configuration (requires account):
## https://lists.exim.org/mailman3/postorius/lists/exim-dev.lists.exim.org/
## unsubscribe (doesn't require an account):
## [email protected]
## Exim details at https://www.exim.org/
## Please use the Wiki with this list - https://code.exim.org/exim/wiki/wiki