curl · Report #4085412
Rustls backend discloses out-of-bounds heap data during legal IMAP upload cancellation
How it was found
Affected: Rustls-backed libcurl, curl 8.8.0 through 8.22.0 and current 8.23 development
Summary
A malicious IMAP server can apply TLS socket backpressure during an APPEND
upload. If the application then legally removes the active easy handle from its
multi handle, curl's IMAP cleanup sends a two-byte APPEND terminator while the
Rustls backend still accounts for plaintext accepted from a longer upload call.
After flushing that retained record, cr_send() reports the old plaintext
length as progress for the new two-byte send. Curl_pp_flushsend() trusts the
oversized count, underflows its remaining length, and calls Rustls again with a
pointer beyond curl's command allocation and a near-SIZE_MAX length. Rustls
copies, encrypts, and transmits out-of-bounds heap data, or the client crashes
while reading it.
The application uses ordinary IMAP upload and multi APIs. It does not call
curl_easy_send(), curl_ws_send(), connect-only mode, or Rustls directly.
The only application action at the trigger is curl_multi_remove_handle()
outside a callback, which curl documents as legal during an active transfer.
Minimal reproduction
The attachment contains:
imap_cancel_client.cimap_server.pyrun.sh
Build a release libcurl with Rustls, point PKG_CONFIG_PATH at that
installation, and run:
export PKG_CONFIG_PATH=/path/to/rustls-libcurl/lib/pkgconfig
./run.sh
For a static or nonstandard build, set CURL_CFLAGS and CURL_LIBS directly.
The script creates a temporary self-signed certificate, starts the local IMAP
TLS peer, compiles the client, and performs one transfer.
The client performs a real IMAP CAPABILITY, STARTTLS, TLS handshake,
LOGIN, and 65,536-byte APPEND. Its upload callback returns 1,024 bytes and
arms a one-shot send() interposer that returns EAGAIN on the next client
socket write. This is a deterministic substitute for a peer filling the
nonblocking socket. The event loop then calls curl_multi_remove_handle()
outside the callback.
A current uninstrumented release run produced:
libcurl=8.23.0-DEV TLS=rustls-ffi/0.15.4/rustls/0.23.45/aws-lc-rs
PRE_REMOVE callback_bytes=1024 injected_eagain=1
Segmentation fault
PEER_RESULT captured=132096 expected_callback=1024 surplus=131072 prefix_ok=1
CLIENT_EXIT=139 SERVER_EXIT=0
prefix_ok=1 confirms that the first 1,024 bytes are the callback data. The
remaining 131,072 decrypted bytes were never returned by the callback.
With ASan, deterministic-asan.log contains:
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 16384
memcpy
OutboundChunks::copy_to_vec
GcmMessageEncrypter::encrypt
rustls_connection_write
cr_send
Curl_pp_flushsend
imap_done
Curl_multi_remove_handle
curl_multi_remove_handle
Attacker-controlled reachability
The deterministic reproducer changes one client send() result to EAGAIN.
It does not modify Rustls state, curl state, the upload buffer, the cleanup
buffer, or any returned byte count.
The independent network-paced harness uses a real TLS 1.3 peer, a 1 KiB socket
receive buffer, bounded peer reads, and a 16 KiB upload callback. It reproduced
the same ASan read in 9/10 fresh processes without interposing any client I/O.
See network-peer-asan.log and network-peer-repeat-summary.tsv.
This route does not rely on an application replacing a pending raw send with a shorter buffer. Curl itself changes from the upload buffer to its internal two-byte IMAP terminator during documented transfer cancellation.
Root cause
When the upload write blocks, Rustls has already accepted plaintext and curl
stores its length in backend->plain_out_buffered. On the later two-byte IMAP
cleanup send, current cr_send() flushes the old record and executes:
if(blen > backend->plain_out_buffered) {
blen -= backend->plain_out_buffered;
buf += backend->plain_out_buffered;
}
else
blen = 0;
*pnwritten += backend->plain_out_buffered;
backend->plain_out_buffered = 0;
For a retained count of 1,024 and a current length of two, it reports 1,024 bytes written for the two-byte command.
Curl_pp_flushsend() then subtracts without validating the backend count:
if(written != pp->sendleft)
pp->sendleft -= written;
The remaining length underflows. The next iteration derives an out-of-range pointer from the corrupted progress values and Rustls attempts to copy a full 16 KiB record from it.
The internal shorter send occurs because imap_done() ignores the
premature argument and terminates the APPEND even during cancellation:
(void)premature;
...
result = Curl_pp_sendf(data, &imapc->pp, "%s", "");
Affected range and current status
Commit 6eee810db42ec097760e7ac57b95a80abe2e4aaa (rustls: fix partial send handling) introduced plain_out_buffered and the oversized old-count
return. It first shipped in curl 8.8.0. Curl 8.7.1 lacks this retained-plaintext
accounting shape.
Validated:
- curl 8.22.0: affected
- current development commit
39c8f9dc: affected - rustls-ffi 0.15.4 / Rustls 0.23.45 / aws-lc-rs
Proposed fix
proposed-bounds-guard.patch rejects a current send shorter than the retained
Rustls plaintext before flushing or changing progress state:
if(backend->plain_out_buffered > plainlen) {
failf(data, "re-send data shorter than previously buffered");
return CURLE_BAD_FUNCTION_ARGUMENT;
}
With that guard, the identical injected EAGAIN and removal schedule exits
without an ASan finding (guard-control.log). The IMAP cleanup path should
also avoid sending the APPEND terminator when premature is true. The backend
guard remains useful defense in depth for other protocol transitions.
AI assistance disclosure
AI models assisted with harness development, source review, and drafting. I validated the report with source inspection, an uninstrumented release build, peer-decrypted byte counts, ASan, repeated fresh processes, a stable-release run, a bounds-guard control, and an independent peer-backpressure route. This report and reproducer have not been published.
Impact
Security impact
The malicious TLS endpoint receives client-process heap bytes that were never provided as upload data. Because the endpoint terminates TLS, it decrypts those bytes normally. The invalid read also crashes the client.
Confirmed on current development commit
39c8f9dcde7d7fe68dedda6ba7c68cbd305168a6:
- An uninstrumented release build crashed with
SIGSEGVafter the peer received 132,096 decrypted literal bytes. The upload callback had supplied only 1,024 bytes, leaving 131,072 bytes of surplus process memory. - The release disclosure and crash reproduced identically in 10/10 fresh processes.
- ASan reports a 16,384-byte heap-buffer-overflow read in Rustls encryption,
reached through
cr_send -> Curl_pp_flushsend -> imap_done -> curl_multi_remove_handle. The deterministic schedule reproduced in 10/10. - A separate harness using only peer receive-buffer pressure and read pacing,
without a
send()interposer, reproduced the same ASan fault in 9/10 runs. - curl 8.22.0 reproduces the same 16,384-byte read with the ordinary IMAP route.
Preconditions are a Rustls-backed libcurl, an IMAP APPEND, TLS backpressure,
and cancellation of the stalled transfer. The server controls backpressure and
can keep the cancellation window open. No write primitive was found.