HexBrain

curl · Report #4113412

libcurl RTSP `CURLOPT_CRLF` conversion allows a request body to become a second RTSP request

Informativestatus
lowseverity
16upvotes
17 Oct 2026disclosed

How it was found

AI WAS USED IN THE PROCESS OF IDENTIFYING THIS VULNERABILITY.

The vulnerability was tested manually and the report was carefully read.

Suggested severity

Low

Affected version

curl 8.23.0, tag curl-8_23_0, commit 3163ec065643ba20709f190111f42c3e1fe95273.

Affected interface

The issue is reachable through the libcurl API. It is not directly reachable through the curl command-line frontend because the tool does not expose CURLOPT_RTSP_REQUEST. In particular, -X ANNOUNCE sets CURLOPT_CUSTOMREQUEST; it does not select CURL_RTSPREQ_ANNOUNCE.

Summary

For RTSP ANNOUNCE, SET_PARAMETER, and GET_PARAMETER uploads, libcurl sends a Content-Length based on the raw input size. If CURLOPT_CRLF is enabled, libcurl then expands bare LF bytes while transmitting the body but does not update or reject the now-unknown length.

The server reads only the declared body length. The remaining bytes can form a second RTSP request on the same connection.

This appears to be the RTSP sibling of CVE-2026-87864. Its fix changed IMAP APPEND to consult Curl_creader_total_length() after the reader stack was created and reject CRLF conversion when the final size is unknown. The RTSP path still uses its pre-conversion req_clen.

Root cause

In lib/rtsp.c, rtsp_setup_body() does the following:

req_clen = data->state.infilesize;
result = Curl_creader_set_fread(data, req_clen);
...
curlx_dyn_addf(reqp, "Content-Length: %" FMT_OFF_T "\r\n", req_clen);

Curl_creader_set_fread() installs the generic CRLF conversion reader when data->set.crlf is true. That reader's cr_lc_total_length() correctly returns -1, but RTSP does not query it after reader construction.

Reproduction

I used the attached rtsp-crlf-framing.c with a one-shot loopback RTSP listener. The client constructs a body containing K bare LF bytes followed by a K-byte RTSP OPTIONS request. CRLF conversion expands the prefix from K to 2K bytes. The advertised raw body length is also 2K, so the body boundary lands exactly before the complete injected request.

Build the client against curl 8.23.0:

cc -O2 -DCURL_STATICLIB -I/path/to/curl/include \
  rtsp-crlf-framing.c /path/to/libcurl.a -o rtsp-crlf-framing \
  [the normal static libcurl dependency flags]

In terminal 1:

python3 rtsp_capture_server.py

In terminal 2:

./rtsp-crlf-framing rtsp://127.0.0.1:21031/test

Observed client result:

raw_body=126 injected=63 result=0 (No error)

Observed by the server:

Content-Length: 126
advertised=126
actual_body=189
overflow=63
overflow_bytes=b'OPTIONS rtsp://127.0.0.1:21031/injected RTSP/1.0\r\nCSeq: 999\r\n\r\n'

The overflow is an intact second request, and curl reports success.

Public disclosure

I have not opened a public issue or otherwise disclosed this report.

Impact

Impact

If an application enables CURLOPT_CRLF and sends attacker-influenced RTSP body content, the attacker can escape the declared body and append RTSP methods on the application's persistent connection. Depending on the server's connection, authentication, and session behavior, this can allow actions under the application's RTSP context.

This is not ordinary user-selected CRLF header injection: these bytes are provided as framed body content, and curl's conversion moves them outside that boundary. That is the same distinction as CVE-2026-87864.

The prerequisites are uncommon, so I suggest Low severity.

Suggested fix

After installing the RTSP body reader, use Curl_creader_total_length(data) for Content-Length. If it returns a negative value, reject the upload with CURLE_UPLOAD_FAILED, as RTSP cannot switch these requests to chunked framing.

A regression test can extend RTSP test568 with a known-size ANNOUNCE body that contains bare LF bytes and enables CURLOPT_CRLF. It should assert CURLE_UPLOAD_FAILED and no transmitted overflow.

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