curl · Report #4124486
RTSP ANNOUNCE/SET_PARAMETER upload with --use-ascii / CURLOPT_TRANSFERTEXT sends more body bytes than the declared Content-Length (incomplete fix of e
How it was found
Summary
curl refuses RTSP uploads that combine a body with --crlf / CURLOPT_CRLF, because the
LF→CRLF conversion makes the number of bytes actually sent differ from the Content-Length
curl declares to the server. That guard was added as a security fix:
- RTSP: commit
e84f95a"rtsp: refuse doing uploads with CRLF conversions",lib/rtsp.c:235:if(data->set.crlf) { failf(data, "Cannot do CRLF conversions on upload with RTSP"); return CURLE_BAD_FUNCTION_ARGUMENT; } - The sibling IMAP case is CVE-2026-87864 (
lib/imap.cnow re-reads the reader-stack total length and aborts when it is unknown).
The RTSP guard is too narrow. The same LF→CRLF converter (cr_lc) is inserted whenever
data->set.crlf OR data->state.prefer_ascii is set:
lib/sendf.c:1137-1147 (do_init_reader_stack), guarded by
#ifdef CURL_PREFER_LF_LINEENDS
if(data->set.crlf || data->state.prefer_ascii)
result = cr_lc_add(data); /* LF -> CRLF converter */
#endif
and CURL_PREFER_LF_LINEENDS is defined on every non-Windows/non-MSDOS platform
(lib/urldata.h:32-36). prefer_ascii is turned on by --use-ascii /
CURLOPT_TRANSFERTEXT (src/config2setopts.c:995, lib/setopt.c → data->set.prefer_ascii
→ lib/transfer.c:484 data->state.prefer_ascii).
RTSP computes Content-Length from the raw declared size and never re-reads the converted
total:
lib/rtsp.c:203-246:req_clen = data->state.infilesize;(raw) and the headerContent-Length: <req_clen>is emitted — but thecr_lcconverter grows the body by the number of bare LFs. The guard atrtsp.c:235checks onlyset.crlf, so with--use-ascii(prefer_ascii == TRUE,crlf == FALSE) it does not fire.
Contrast — the codebase already knows the correct, wider condition:
- FTP accounts for both flags:
lib/ftp.c:3803-3806uses(data->set.crlf || data->state.prefer_ascii). - IMAP re-reads
Curl_creader_total_length()after building the stack and aborts on -1 (lib/imap.c:883-890). - RTSP checks only
data->set.crlf— this is the omission / incomplete fix.
Root cause
Declared size (Content-Length) is taken from the un-converted infilesize, while the body is
sent through a reader stack that includes the LF→CRLF converter under prefer_ascii. The RTSP
size-safety guard was written against set.crlf only and does not cover the prefer_ascii
trigger of the very same converter.
Attacker model / reachability
An application (or the curl CLI) performing an RTSP ANNOUNCE or SET_PARAMETER upload with
CURLOPT_TRANSFERTEXT/--use-ascii, where the uploaded body contains attacker-influenced
content with bare \n bytes. Realistic case: a service embedding libcurl that relays
user-supplied SDP/parameter data into an RTSP ANNOUNCE body in text mode. On a persistent
RTSP/TCP connection, the bytes sent past the declared Content-Length are parsed by the origin
RTSP server as the start of the next RTSP request → RTSP request injection / smuggling.
PoC (live, with controls)
Two PoCs are attached: poc_rtsp_ascii.py proves the Content-Length/on-wire desync with three
cases; poc_rtsp_smuggle.py proves a full second RTSP request is smuggled past the body and
parsed by the server (see "Demonstrated request smuggling" below).
attachments/rtsp_client.c is a minimal libcurl program that sets
CURLOPT_RTSP_REQUEST = CURL_RTSPREQ_ANNOUNCE, CURLOPT_UPLOAD, CURLOPT_TRANSFERTEXT, and
CURLOPT_INFILESIZE_LARGE = <raw file size>, then uploads a body file. It is linked against the
libcurl built from this commit. attachments/poc_rtsp_ascii.py starts a local RTSP listener that
records the declared Content-Length and counts the body bytes actually received, then runs three
cases. Captured output (attachments/rtsp_transcript.txt):
=== CASE: EXPLOIT --use-ascii + bare-LF body ===
raw body length : 58
bare LF count : 4
method seen : ANNOUNCE
declared C-L : 58
actual body bytes : 62
>>> DESYNC: 4 extra bytes sent past declared Content-Length
=== CASE: NEGATIVE no --use-ascii + bare-LF body ===
declared C-L : 58
actual body bytes : 58
OK: actual == declared (no desync)
=== CASE: NEGATIVE --use-ascii + all-CRLF body ===
declared C-L : 62
actual body bytes : 62
OK: actual == declared (no desync)
CONFIRMED: --use-ascii makes curl send MORE RTSP body bytes than the
Content-Length it declared; both negative controls stay in sync.
The exploit case declares Content-Length: 58 but writes 62 bytes on the wire; the 4 extra
bytes are exactly the CRs inserted for the 4 bare LFs. Both negative controls (no --use-ascii;
and --use-ascii with an all-CRLF body that has nothing to expand) stay perfectly in sync,
isolating the cause to the prefer_ascii LF→CRLF path that the RTSP guard misses.
Demonstrated request smuggling (second PoC)
attachments/poc_rtsp_smuggle.py goes further and proves the primitive is a true request
injection, not just a byte-count mismatch. It crafts the ANNOUNCE body so that the bytes which
fall past the declared Content-Length form a complete, well-formed second RTSP request.
The crafting relies only on the LF→CRLF arithmetic (each bare LF adds one byte): with
raw = P + S_raw, padding P so it contains exactly len(S_raw) bare LFs makes the post-body
remainder equal to expand(S_raw) — the smuggled request, correctly CRLF-terminated.
Captured output (attachments/rtsp_smuggle_transcript.txt):
first request method : ANNOUNCE
declared Content-Length : 223 (== raw body len 223 )
bytes server reads as body : 223
bytes PAST Content-Length : 94
smuggled bytes (repr) :
b'TEARDOWN rtsp://victim.example/stream RTSP/1.0'
b'CSeq: 99'
b'X-Smuggled: injected-by-attacker'
>>> SERVER-SIDE PARSE of the post-body bytes:
method : TEARDOWN
uri : rtsp://victim.example/stream
version : RTSP/1.0
headers : {'CSeq': '99', 'X-Smuggled': 'injected-by-attacker'}
######## NEGATIVE CONTROL: same body, NO --use-ascii ########
bytes PAST Content-Length : 0
smuggled request present? : NO (in sync)
The server reads 223 bytes as the ANNOUNCE body (exactly the declared Content-Length) and the
remaining 94 bytes parse as a full TEARDOWN rtsp://victim.example/stream RTSP/1.0 request with
its own CSeq and headers — a request the attacker never sent as a request of their own. Without
--use-ascii, zero bytes are smuggled.
Steps to reproduce
# build curl from this commit (./configure --with-openssl && make)
gcc -I include attachments/rtsp_client.c lib/.libs/libcurl.a \
-lssl -lcrypto -lbrotlidec -lbrotlicommon -lz -o rtsp_client
python3 -I attachments/poc_rtsp_ascii.py # edit paths at top if needed
Suggested fix
Widen the RTSP guard at lib/rtsp.c:235 to the same condition the converter uses, i.e.
if(data->set.crlf || data->state.prefer_ascii), or (better, matching IMAP) re-read
Curl_creader_total_length() after the reader stack is built and refuse / switch to an unknown
length when it no longer equals the declared size.
Impact
Impact
On an RTSP ANNOUNCE / SET_PARAMETER upload with --use-ascii / CURLOPT_TRANSFERTEXT,
curl declares a Content-Length that is smaller than the body it actually transmits. The
surplus bytes (one CR per bare LF in the body) fall outside the declared message body, so a
server reading the kept-alive RTSP/TCP connection treats them as the beginning of the next RTSP
request.
When the body content is attacker-influenced — e.g. a service embedding libcurl that relays
user-supplied SDP or parameter data into an RTSP ANNOUNCE in text mode — the attacker can push
chosen bytes past the body boundary and smuggle a crafted continuation. This is demonstrated
end-to-end in the second PoC: the bytes past the declared Content-Length form a complete,
well-formed TEARDOWN rtsp://victim.example/stream RTSP/1.0 request (with its own CSeq and
headers) that the receiving server parses as the next request on the connection. This is the
RTSP sibling of the upload size-desync class curl already closed for --crlf on RTSP (commit
e84f95a) and for IMAP (CVE-2026-87864); the prefer_ascii trigger of the same converter was
left uncovered, so the earlier fix is incomplete.
What it is / is not
- It is a declared-size-vs-on-wire-size desync, confirmed live end-to-end against a local RTSP listener, with a positive control and two negative controls isolating the cause; and the smuggling of a complete second RTSP request past the body is demonstrated, not merely asserted.
- It is an incomplete fix: FTP checks both
crlfandprefer_ascii, IMAP re-reads the total length, RTSP checks onlycrlf. - It is not a default-configuration issue: it requires the caller to enable
--use-ascii/CURLOPT_TRANSFERTEXT, and the body must contain bare LF bytes. An all-CRLF body or binary mode is unaffected. - It is not memory-unsafe and leaks no local data.
Honest severity
Low. It depends on a non-default option and on RTSP upload (a niche protocol path), which is why
I rate it Low rather than higher. I am reporting it because it is the same security class curl
chose to fix with an explicit refusal for --crlf, the fix demonstrably does not cover the
prefer_ascii path that drives the identical converter, and the result is a reproducible
request-smuggling primitive rather than a cosmetic mismatch. Triage may reasonably rate it
Low/Medium; I defer to the curl security team on the final rating.