curl · Report #3680234
libcurl reuses a learned RTSP Session header across different hosts on the same easy handle, enabling cross-host session leak and replay
How it was found
Summary:
libcurl automatically learns RTSP Session: headers from server responses and stores them in data->set.str[STRING_RTSP_SESSION_ID] in lib/rtsp.c:1015-1033. On later RTSP requests using the same easy handle, rtsp_do() reads that easy-handle-scoped value at lib/rtsp.c:373 and unconditionally emits Session: %s into the next request at lib/rtsp.c:513-515, without any host, port, or origin boundary check. Because the value lives in data->set / struct UserDefined rather than request-scoped state (lib/urldata.h:878-883, lib/urldata.h:1143, lib/urldata.h:1405-1406), it survives across subsequent RTSP requests to different hosts until manually cleared with CURLOPT_RTSP_SESSION_ID = NULL (lib/setopt.c:2346-2351) or the easy handle is destroyed (lib/url.c:156-164). In the attached PoC, request 1 to a victim RTSP server learns Session: VICTIM-SESSION-123456, request 2 to an attacker RTSP server on the same easy handle leaks that value, and a fresh handle can then replay the leaked session back to the victim with PLAY successfully accepted. A new-easy-handle control and a manual-clear control both suppress the leak. I reproduced this on curl 8.19.0 / libcurl 8.19.0 on x86_64 Linux and attached a zip with the PoC, logs, and a line-level root-cause note.
Affected version
Reproduced on:
curl 8.19.0 (x86_64-pc-linux-gnu) libcurl/8.19.0 OpenSSL/3.5.5 zlib/1.3.1 brotli/1.2.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.21.5 libssh2/1.11.1 nghttp2/1.68.1 ngtcp2/1.21.0 nghttp3/1.15.0 librtmp/2.3 mit-krb5/1.22.1 OpenLDAP/2.6.10
Platform:
Linux x86_64
Steps To Reproduce:
- Start the local RTSP harness from the attached PoC directory:
python3 -u rtsp_session_leak_server.py - Run the attached PoC:
python3 poc_rtsp_session_leak.py - Observe the base case in the client output:
- request 1:
SETUP rtsp://127.0.0.1:18201/mediareturnsSession: VICTIM-SESSION-123456 - request 2:
OPTIONS rtsp://127.0.0.1:18202/evilon the same easy handle sendsSession: VICTIM-SESSION-123456
- request 1:
- Observe the controls:
- with a new easy handle for the attacker request, no
Session:header is sent - after clearing
CURLOPT_RTSP_SESSION_IDon the reused handle, noSession:header is sent
- with a new easy handle for the attacker request, no
- Observe the replay control:
- a fresh handle sending
PLAYto the victim without a session getsRTSP/1.0 454 Session Not Found - a fresh handle sending
PLAYto the victim with the leakedSession: VICTIM-SESSION-123456getsRTSP/1.0 200 OK
- a fresh handle sending
- Observe the attached sink-side server log:
- attacker receives
{"role":"attacker","method":"OPTIONS","session":"VICTIM-SESSION-123456",...} - victim then receives
{"role":"victim","method":"PLAY","session":"VICTIM-SESSION-123456","session_ok":true,...}
- attacker receives
Line-Level Root Cause
- The RTSP session identifier is automatically learned from server responses and stored on the easy handle.
lib/rtsp.c:1015-1033parses theSession:header from RTSP responses.- If
data->set.str[STRING_RTSP_SESSION_ID]is not already set,lib/rtsp.c:1031stores the received value there. STRING_RTSP_SESSION_IDis defined inlib/urldata.h:955.
- That storage is easy-handle scoped, not request scoped.
- The
UserDefinedstructure inlib/urldata.h:878-883is documented as data set once and reused across independent connections. data->setis the easy handle’sstruct UserDefinedinlib/urldata.h:1405-1406.- The backing string array is
lib/urldata.h:1143:char *str[STRING_LAST];
- The
- The stored
Session:value is then automatically attached to later RTSP requests.lib/rtsp.c:373loadsp_session_id = data->set.str[STRING_RTSP_SESSION_ID];lib/rtsp.c:513-515emitsSession: %swhenever that value is present.- There is no host, port, or origin boundary check between the read at line 373 and the emitted header at line 514.
- The state is not automatically cleared when the RTSP target changes.
- The explicit setter/clear path is
lib/setopt.c:2346-2351viaCURLOPT_RTSP_SESSION_ID, so applications can clear it manually by setting it toNULL, but libcurl does not clear it automatically when switching to a different RTSP host. - The public getter in
lib/getinfo.c:159-161reads the same field, confirming that the “most recent” RTSP session ID is stored on the handle. - Generic cleanup only happens when the easy handle is destroyed in
lib/url.c:156-164, whereCurl_freeset()freesdata->set.str[i]. - In this tree, references to
STRING_RTSP_SESSION_IDonly appear in the manual setter, getter, RTSP response parse/store path, and RTSP request auto-attach path. There is no pretransfer, host-change, or per-request reset path that clears the field when the RTSP URL changes to a different host or port.
- The explicit setter/clear path is
Impact
Summary:
An attacker-controlled RTSP server contacted later on the same easy handle can receive a Session: token that was learned from a different RTSP server. In the attached PoC, that leaked token is replayable: a fresh handle can send PLAY back to the victim with the leaked session ID and the victim accepts it, while the same request without the session is rejected with 454 Session Not Found. This turns the issue from a cross-host RTSP session leak into replayable session control mix-up on reused easy handles.