HexBrain

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

Informativestatus
lowseverity
5upvotes
19 Oct 2026disclosed

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.c now 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 header Content-Length: <req_clen> is emitted — but the cr_lc converter grows the body by the number of bare LFs. The guard at rtsp.c:235 checks only set.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-3806 uses (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 crlf and prefer_ascii, IMAP re-reads the total length, RTSP checks only crlf.
  • 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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original