HexBrain

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

Informativestatus
mediumseverity
13upvotes
18 Apr 2026disclosed

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:

  1. Start the local RTSP harness from the attached PoC directory: python3 -u rtsp_session_leak_server.py
  2. Run the attached PoC: python3 poc_rtsp_session_leak.py
  3. Observe the base case in the client output:
    • request 1: SETUP rtsp://127.0.0.1:18201/media returns Session: VICTIM-SESSION-123456
    • request 2: OPTIONS rtsp://127.0.0.1:18202/evil on the same easy handle sends Session: VICTIM-SESSION-123456
  4. Observe the controls:
    • with a new easy handle for the attacker request, no Session: header is sent
    • after clearing CURLOPT_RTSP_SESSION_ID on the reused handle, no Session: header is sent
  5. Observe the replay control:
    • a fresh handle sending PLAY to the victim without a session gets RTSP/1.0 454 Session Not Found
    • a fresh handle sending PLAY to the victim with the leaked Session: VICTIM-SESSION-123456 gets RTSP/1.0 200 OK
  6. 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,...}

Line-Level Root Cause

  • The RTSP session identifier is automatically learned from server responses and stored on the easy handle.
    • lib/rtsp.c:1015-1033 parses the Session: header from RTSP responses.
    • If data->set.str[STRING_RTSP_SESSION_ID] is not already set, lib/rtsp.c:1031 stores the received value there.
    • STRING_RTSP_SESSION_ID is defined in lib/urldata.h:955.
  • That storage is easy-handle scoped, not request scoped.
    • The UserDefined structure in lib/urldata.h:878-883 is documented as data set once and reused across independent connections.
    • data->set is the easy handle’s struct UserDefined in lib/urldata.h:1405-1406.
    • The backing string array is lib/urldata.h:1143: char *str[STRING_LAST];
  • The stored Session: value is then automatically attached to later RTSP requests.
    • lib/rtsp.c:373 loads p_session_id = data->set.str[STRING_RTSP_SESSION_ID];
    • lib/rtsp.c:513-515 emits Session: %s whenever 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-2351 via CURLOPT_RTSP_SESSION_ID, so applications can clear it manually by setting it to NULL, but libcurl does not clear it automatically when switching to a different RTSP host.
    • The public getter in lib/getinfo.c:159-161 reads 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, where Curl_freeset() frees data->set.str[i].
    • In this tree, references to STRING_RTSP_SESSION_ID only 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.

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.

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