curl · Report #4121685
curl RTSP connection reuse routes response headers to the RTP callback (rtsp_conn state machine not reset between sessions)
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).
| Variant | Trigger | RTP callback receives | Client result |
|---|---|---|---|
Partial RTP header after PLAY ($\x00\x00\x10, 4 bytes) | connection reused for next request | 20 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 control | connection reused for next request | nothing | response 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.
- Build curl with RTSP enabled (debug build, ASan). Point a C harness at libcurl's RTSP API (
curl_easy_perform) with anINTERLEAVEFUNCTIONRTP callback installed. - The mock server accepts the
SETUPrequest and responds withTransport: RTP/AVP/TCP;unicast;interleaved=0-1, registering channels 0/1. - The client reuses the same connection and easy handle to send
PLAY. The server replies with a normalPLAYresponse (noContent-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. - The
PLAYtransfer completes without error (size = 0,download_done = TRUE); the connection is returned to the pool withrtspc->state = RTP_PARSE_DATA,rtp_len = 20. - The client reuses the same connection and easy handle to send
GET_PARAMETER. The server replies with a normal response (Content-Length+ body). - Because
rtspc->in_header = FALSEis leftover,rtsp_filter_rtp()runs first and resumes fromRTP_PARSE_DATA. The first 16 bytes of theGET_PARAMETERresponse headers are accumulated intortspc->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\x10to a normalPLAYresponse and hold the TCP connection open; no client-side validation is bypassed. The client contributes a standardSETUP → 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 noContent-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).
- Data misrouting / confidentially relevant: RTSP response headers (e.g.
- 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.