HexBrain

curl · Report #4121675

CRLF injection in the RTSP request-line and Transport header via `CURLOPT_RTSP_STREAM_URI` and `CURLOPT_RTSP_TRANSPORT`

N/Astatus
highseverity
6upvotes
18 Oct 2026disclosed

How it was found

Summary

Two RTSP option strings reach the wire with no CR/LF neutralization, even though each is formatted directly into an RTSP request-line or header field that is terminated by CRLF. Curl_setstropt() (lib/setopt.c) stores each option string after checking only the 8 MB length cap (CURL_MAX_INPUT_LENGTH) — it never inspects for \r or \n — and both construction sites inside rtsp_do() (lib/rtsp.c) then splice the stored value verbatim into the request buffer, using curlx_dyn_addf("%s …") for the request-line and curl_maprintf("…: %s\r\n") for the Transport header, neither of which filters control characters. An embedded \r\n therefore terminates the current request-line/header early and lets the caller write arbitrary additional request-lines or headers into the same TCP stream, enabling header injection and RTSP request smuggling.

The two injection points share one construction function (rtsp_do()) and one missing-validation pattern:

VariantSource option (entry)Sink (wire construction)Injected frame positionWhat the RTSP server receives
V1. RTSP request-lineCURLOPT_RTSP_STREAM_URIrtsp_do() → curlx_dyn_addf("%s %s RTSP/1.0\r\n", p_request, p_stream_uri)RTSP request-line (stream URI)DESCRIBE rtsp://foo/\r\nTEARDOWN rtsp://victim/ RTSP/1.0\r\n… — a complete smuggled second RTSP request (e.g. TEARDOWN)
V2. RTSP Transport headerCURLOPT_RTSP_TRANSPORTrtsp_do() → curl_maprintf("Transport: %s\r\n", STRING_RTSP_TRANSPORT)RTSP Transport header valueTransport: RTP/AVP;unicast\r\nSession: fake-session-id\r\n\r\nTEARDOWN rtsp://victim/ RTSP/1.0\r\n… — forged Session header plus, via double CRLF, a complete smuggled second request

A related CRLF-injection issue in the HTTP request-line (CURLOPT_REQUEST_TARGET, lib/http.c) is reported separately.

Affected versions

  • curl 8.21.0-DEV (debug build, mbedTLS 3.6.5) — both variants reproduced.
  • curl master 9b2caf3 (8.22.0-DEV, verified 2026-08-31) — still affected; both variants re-run against a master build and reproduce byte-for-byte.
  • Source-verified still present on current master as of 2026-10-16: rtsp_do() still splices both option strings with no CRLF filtering (the Transport value goes straight into curl_maprintf("…: %s\r\n"), the stream URI straight into the "%s %s RTSP/1.0\r\n" request-line format), and neither the 8.22.0 nor the 8.23.0 changelog contains a relevant fix.
  • Both variants affect every curl version since the RTSP options were introduced (7.20.0).

Root cause

Curl_setstropt() is the common store for both options. It only enforces the length cap and never looks at byte content:

// lib/setopt.c:88-96  (Curl_setstropt)
if(strlen(s) > CURL_MAX_INPUT_LENGTH)
    /* …too long… */;
else
    dest = curlx_strdup(s);   // no CR/LF (or control-char) check; CRLF preserved verbatim

Each option is then written into a CRLF-delimited frame with no additional filtering:

V1 — RTSP request-line (CURLOPT_RTSP_STREAM_URI):

// lib/rtsp.c:373-374  (rtsp_do)
if(data->set.str[STRING_RTSP_STREAM_URI])
    p_stream_uri = data->set.str[STRING_RTSP_STREAM_URI];   // CRLF intact
// lib/rtsp.c:490-493
curlx_dyn_addf(&req_buffer, "%s %s RTSP/1.0\r\n" "CSeq: %u\r\n",
               p_request, p_stream_uri, rtsp->CSeq_sent);   // %s does not filter \r\n

V2 — RTSP Transport header (CURLOPT_RTSP_TRANSPORT):

// lib/rtsp.c:381-388  (rtsp_do, SETUP request, no custom Transport header set)
p_transport = Curl_checkheaders(data, STRCONST("Transport"));
if(rtspreq == RTSPREQ_SETUP && !p_transport) {
    if(data->set.str[STRING_RTSP_TRANSPORT])
        data->state.aptr.rtsp_transport =
            curl_maprintf("Transport: %s\r\n",
                          data->set.str[STRING_RTSP_TRANSPORT]);   // %s does not filter \r\n
}
// lib/rtsp.c:510-520  then curlx_dyn_addf() splices this header into req_buffer;
// lib/rtsp.c:554  Curl_req_send() flushes req_buffer to the socket.

Because the injected CRLF lands inside the request-line (V1) or the Transport header value (V2), the attacker controls framing, not just a single header field: a single \r\n ends the current line and starts a new header, and a \r\n\r\n ends the current request's header section and starts a new request.

PoC

A mock RTSP server is run on loopback to capture the raw bytes curl writes. Both variants are driven the way a real application would drive them — a small C harness linked against the static libcurl build, calling curl_easy_setopt():

V1 — RTSP request-line. The harness sets CURLOPT_RTSP_REQUEST to CURL_RTSPREQ_DESCRIBE, CURLOPT_RTSP_STREAM_URI to "rtsp://foo/\r\nTEARDOWN rtsp://victim/ RTSP/1.0\r\n", and performs the transfer against the mock RTSP server.

  • Negative control: CURLOPT_RTSP_STREAM_URI set to "rtsp://foo/test" (no CRLF).

V2 — RTSP Transport header. The harness sets CURLOPT_RTSP_REQUEST to CURL_RTSPREQ_SETUP, CURLOPT_RTSP_TRANSPORT to "RTP/AVP;unicast\r\nSession: fake-session-id\r\n\r\nTEARDOWN rtsp://victim/ RTSP/1.0\r\n", and performs the transfer.

  • Negative control: CURLOPT_RTSP_TRANSPORT set to "RTP/AVP;unicast" (no CRLF).

Both variants were re-run against a curl master 9b2caf3 build with the same harnesses; the behavior is identical to 8.21.0-DEV.

Reproduction output

V1 — raw bytes captured by the mock RTSP server (CURLOPT_RTSP_STREAM_URI payload, curl_easy_perform returns CURLE_OK):

DESCRIBE rtsp://foo/\r\n
TEARDOWN rtsp://victim/ RTSP/1.0\r\n
… RTSP/1.0\r\n
CSeq: 1\r\n

Hex dump shows 0d 0a at offset 0x16 terminating the DESCRIBE rtsp://foo/ request-line, immediately followed by TEARDOWN rtsp://victim/ RTSP/1.0 as an independent second RTSP request-line. Negative control: a clean stream URI produces a single-line DESCRIBE rtsp://foo/test RTSP/1.0 request.

V2 — raw bytes captured by the mock RTSP server (CURLOPT_RTSP_TRANSPORT payload, curl_easy_perform returns CURLE_OK):

SETUP rtsp://foo/ RTSP/1.0\r\n
…\r\n
Transport: RTP/AVP;unicast\r\n
Session: fake-session-id\r\n
\r\n
TEARDOWN rtsp://victim/ RTSP/1.0\r\n
…\r\n

Hex dump shows 0d 0a at offset 0x62 terminating the Transport: header value, followed by the forged Session: fake-session-id header line, then a double CRLF (0d 0a 0d 0a) ending the first request's header section, then a complete TEARDOWN rtsp://victim/ RTSP/1.0 second request smuggled onto the same connection. Negative control: a clean transport value produces a single Transport: RTP/AVP;unicast\r\n header with no injection.

Impact

  • CVSS 3.1: 7.5 (High) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N, scored on the most reliable vector across the two variants (header injection → authentication bypass → read of protected data, achievable in a single request). The request-smuggling variants additionally raise integrity and availability impact (injected TEARDOWN/SETUP reaching the RTSP server) — e.g. session disruption or session hijacking on the RTSP server; the smuggled request's response is not returned to the caller.
  • Trigger conditions: an application forwards attacker-controlled data into one of the two CURLOPT_RTSP_* options without filtering CR/LF first. Realistic settings include RTSP client/gateway code that lets a user or upstream component choose the stream URI or transport parameters.
  • Severity bound: exploitation is gated on an application forwarding user input into these setopt options without CRLF filtering, and on the RTSP server honoring injected headers or disagreeing on request boundaries. Neither the libcurl security page's "CRLF in Headers" section nor the option documentation mentions any CRLF risk for CURLOPT_RTSP_STREAM_URI or CURLOPT_RTSP_TRANSPORT, so application authors are not signposted toward filtering.

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