HexBrain

curl · Report #4121685

curl RTSP connection reuse routes response headers to the RTP callback (rtsp_conn state machine not reset between sessions)

Informativestatus
highseverity
6upvotes
19 Oct 2026disclosed

How it was found

Summary

libcurl's RTSP client keeps a per-connection RTP parsing state machine in struct rtsp_conn (state, in_header, rtp_len, buf). This state machine is initialised exactly once, when a brand-new connection is allocated, and is never reset thereafter. RTSP connections are pooled and reused across requests (PROTOPT_CONN_REUSE), so a normal SETUP → PLAY → GET_PARAMETER session runs over a single connection. None of the per-request hooks (rtsp_connect, rtsp_done, Curl_req_soft_reset) touch the RTP state-machine fields, and when the multi layer detects conn->bits.reuse it skips protocol_connect()/rtsp_connect() entirely.

A malicious RTSP server (or any party able to control the server's responses) can exploit this by appending an incomplete interleaved RTP frame header to the PLAY response — the 4 bytes $\x00\x00\x10 (channel 0, declaring 16 bytes of payload that never arrive). The state machine advances SKIP → CHANNEL → LEN → DATA and parks in RTP_PARSE_DATA with rtp_len = 20. Because the PLAY response carries no Content-Length (size = 0, download_done = TRUE), the transfer completes without error, rtsp_conn_is_dead() only probes TCP liveness, and the connection is returned to the pool with the stuck state machine still attached.

When the client reuses that connection for the next request, rtsp_rtp_write_resp() is invoked with rtspc->in_header = FALSE left over from the previous request, so rtsp_filter_rtp() runs first and resumes from RTP_PARSE_DATA. The new response's headers (RTSP/1.0 200 OK\r\n...) are accumulated into rtspc->buf as if they were RTP frame payload, and once rtp_len bytes are collected rtp_client_write() dispatches them to the RTP interleave callback. The RTSP response headers are therefore never parsed, the body is misrouted, and the transfer hangs until timeout (exit 28).

VariantTriggerRTP callback receivesClient result
Partial RTP header after PLAY ($\x00\x00\x10, 4 bytes)connection reused for next request20 bytes: $\x00\x00\x10 + first 16 bytes of the next response's status line (RTSP/1.0 200 OK\r)response headers never parsed; GET_PARAMETER hangs until CURLE_OPERATION_TIMEDOUT (exit 28)
Clean PLAY response (no partial RTP header) — negative controlconnection reused for next requestnothingresponse parsed normally, exit 0

Affected versions

Confirmed on curl 8.21.0-DEV (RTSP enabled) and still present on current master 9b2caf3 (8.22.0-DEV, verified 2026-08-31). On master the vulnerable code in lib/rtsp.c (rtsp_connect() and rtsp_done()) is byte-identical to 8.21.0-DEV; the end-to-end reproduction (PLAY leaves rtspc->state = 3, the reused connection routes the next response's headers to the RTP callback, exit 28) reproduces verbatim against a master build. Source-verified still present on master as of 2026-10-16: rtsp_connect() still resets only rtp_channel (not state/buf/rtp_len/in_header), rtsp_done() still performs only CSeq checks with no rtsp_conn reset, and neither the 8.22.0 nor the 8.23.0 changelog contains a relevant fix.

Root cause

The RTP state machine lives in the connection-level rtsp_conn struct and is only zero-initialised on first allocation:

// lib/rtsp.c:105-122  rtsp_setup_connection()
//   rtspc = Curl_conn_meta_get(...); curlx_calloc(...) -> all fields zero
//   (state = RTP_PARSE_SKIP, in_header = FALSE, rtp_len = 0, buf empty)
//   Called ONLY when a new connection is created — not on reuse.

On connection reuse the multi layer short-circuits the connect phase, so rtsp_connect() is never reached:

// lib/multi.c:2553-2557  multistate_protoconnect()
//   if(conn->bits.reuse) -> jump straight to MSTATE_DO,
//   skipping protocol_connect() / rtsp_connect()

Even when rtsp_connect() does run, it only resets the channel, not the state machine; rtsp_done() touches none of these fields:

// lib/rtsp.c:138-153  rtsp_connect()
//   rtspc->rtp_channel = -1;     // the only rtsp_conn field touched
//   (state / in_header / rtp_len / buf NOT reset)

// lib/rtsp.c:157-196  rtsp_done()
//   (does not touch rtsp_conn state-machine fields at all)

The error-free residual path. A PLAY response without Content-Length plus a 4-byte partial RTP header parks the state machine without raising an error:

// lib/rtsp.c:875-878  rtsp_rtp_write_resp()
//   no Content-Length -> data->req.size = 0, data->req.download_done = TRUE

// lib/rtsp.c:754-784  rtsp_filter_rtp()
//   '$' -> CHANNEL (mask check passes) -> LEN -> DATA
//   partial frame ($\x00\x00\x10, declares 16 bytes) leaves state = RTP_PARSE_DATA,
//   rtp_len = 20, buf holds 4 bytes

// lib/rtsp.c:889-890
//   state != SKIP -> data->req.done = FALSE, but download_done already TRUE

// lib/transfer.c:425-426  Curl_sendrecv()
//   !CURL_REQ_WANT_IO -> data->req.done = TRUE; size=0, bytecount=0
//   -> no CURLE_PARTIAL_FILE, multi_done(CURLE_OK), connection pooled

Liveness check does not inspect the state machine, so the stuck connection survives in the pool:

// lib/rtsp.c:131-136  rtsp_conn_is_dead()
//   TCP-level liveness only; does not check rtspc->state

On reuse, the leftover in_header = FALSE makes rtsp_filter_rtp() run first and resume the stuck DATA state, consuming the new response's headers as RTP payload:

// lib/rtsp.c:837-852  rtsp_rtp_write_resp()  (on the reused connection)
//   data->req.header = TRUE, but rtspc->in_header = FALSE (leftover)
//   -> !rtspc->in_header => rtsp_filter_rtp() runs first

// lib/rtsp.c:760-786  rtsp_filter_rtp()
//   resumes RTP_PARSE_DATA: response headers appended to rtspc->buf via curlx_dyn_addn
//   once rtp_len bytes accumulate -> rtp_client_write()

// lib/rtsp.c:615-642  rtp_client_write()
//   fwrite_rtp / fwrite_func callback invoked with response-header bytes as "RTP frame"
//   bypasses Curl_client_write; bytecount not updated; RTSP headers never parsed

PoC

The reproduction drives libcurl's RTSP API against a mock RTSP server controlled by the researcher, on a single easy handle, issuing the standard SETUP → PLAY → GET_PARAMETER sequence over one reused connection.

  1. Build curl with RTSP enabled (debug build, ASan). Point a C harness at libcurl's RTSP API (curl_easy_perform) with an INTERLEAVEFUNCTION RTP callback installed.
  2. The mock server accepts the SETUP request and responds with Transport: RTP/AVP/TCP;unicast;interleaved=0-1, registering channels 0/1.
  3. The client reuses the same connection and easy handle to send PLAY. The server replies with a normal PLAY response (no Content-Length) and immediately appends the 4-byte partial RTP frame header $\x00\x00\x10 (channel 0, length 16), sending no further payload. It keeps the TCP connection open.
  4. The PLAY transfer completes without error (size = 0, download_done = TRUE); the connection is returned to the pool with rtspc->state = RTP_PARSE_DATA, rtp_len = 20.
  5. The client reuses the same connection and easy handle to send GET_PARAMETER. The server replies with a normal response (Content-Length + body).
  6. Because rtspc->in_header = FALSE is leftover, rtsp_filter_rtp() runs first and resumes from RTP_PARSE_DATA. The first 16 bytes of the GET_PARAMETER response headers are accumulated into rtspc->buf, combined with the 4 residual bytes to form a 20-byte "RTP frame", and delivered to the RTP callback. The RTSP response headers are never parsed; the transfer hangs until the timeout fires.

The negative control runs the identical SETUP → PLAY → GET_PARAMETER sequence but omits the partial RTP header after PLAY. The state machine stays in RTP_PARSE_SKIP throughout, the GET_PARAMETER response is parsed normally, no RTP callback fires, and the client exits 0.

Reproduction output

Verbose trace from the vulnerable run (8.21.0-DEV; identical on master 9b2caf3):

* Reusing existing rtsp: connection with host 127.0.0.1     (PLAY reuses the connection)
< RTSP/1.0 200 OK
< CSeq: 2
< Session: testsession123
* rtsp_rtp_write_resp(len=0, in_header=0, done=0, rtspc->state=0, req.size=0)   (PLAY done; state advanced by the partial frame)
* Connection #0 to host 127.0.0.1:18086 left intact

* Reusing existing rtsp: connection with host 127.0.0.1     (GET_PARAMETER reuses the connection)
* rtsp_rtp_write_resp(len=4, in_header=0, eos=0)
* rtsp_rtp_write_resp(len=0, in_header=0, done=0, rtspc->state=3, req.size=-1)  (stuck in RTP_PARSE_DATA)
* rtsp_rtp_write_resp(len=96, in_header=0, eos=0)
* RTP write channel 0 rtp_len 20                            (response headers routed to RTP callback)
* rtsp_rtp_write_resp(len=0, in_header=0, done=0, rtspc->state=0, req.size=-1)
* Operation timed out after 10002 milliseconds with 0 bytes received

RTP callback payload (the response headers were delivered here instead of to the RTSP header parser):

hex:   24 00 00 10 52 54 53 50 2f 31 2e 30 20 32 30 30 20 4f 4b 0d
ascii: $\x00\x00\x10RTSP/1.0 200 OK\r

The first 4 bytes are the residual RTP frame header; the remaining 16 bytes are the leading bytes of the GET_PARAMETER response status line. Client result:

=== STEP 3: GET_PARAMETER ===
!!! RTP_CALLBACK: received 20 bytes
GET_PARAMETER result: 28 (Timeout was reached)

Negative control (no partial RTP header after PLAY):

* rtsp_rtp_write_resp(... rtspc->state=0, req.size=0)   (state stays SKIP)
=== STEP 3: GET_PARAMETER ===
GET_PARAMETER result: 0 (No error)                      (response parsed; no RTP callback)

Impact

  • CVSS 3.1: 7.0 (High) — AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L. The attacker is a malicious RTSP server (or any position that can shape the server's responses). Attack complexity is low: the attacker only needs to append $\x00\x00\x10 to a normal PLAY response and hold the TCP connection open; no client-side validation is bypassed. The client contributes a standard SETUP → PLAY → <next request> session over one easy handle, which is the default RTSP usage pattern and the default connection-reuse behaviour.
  • Trigger conditions: the client uses a single easy handle for a multi-request RTSP session (so the connection is reused) and has an RTP interleave callback installed (CURLOPT_INTERLEAVEFUNCTION); the server delivers a partial interleaved RTP frame header after a response that carries no Content-Length.
  • Realized impact:
    • Data misrouting / confidentially relevant: RTSP response headers (e.g. Session, server version, server-chosen header values) are diverted to the RTP interleave callback rather than the RTSP header parser. Applications that wire RTP media to a separate callback/sink from RTSP control data receive control-plane bytes on the media path.
    • Integrity: the subsequent request's response is never correctly parsed; the body is incomplete or garbled.
    • Availability: the transfer hangs waiting for header data that has already been consumed, terminating in CURLE_OPERATION_TIMEDOUT (exit 28).
  • Severity bound: exploitation requires the attacker to be the RTSP server the client has chosen to connect to (or to control that server's responses); it is not reachable from an arbitrary network position. The impact is bounded to the RTSP session being misrouted and timing out — no memory corruption, no out-of-bounds write, and no cross-protocol code execution was observed. The data exposed is the RTSP response headers of the session the client already established with the attacker-controlled server. CVSS reflects the network-reachable, low-complexity availability impact plus the control/media-plane data misrouting.

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