Skip to content

Curl_auth_create_digest_md5_message does not quote digest-uri #23018

Description

@qwaz

I did this

When libcurl answers a SASL DIGEST-MD5 challenge, Curl_auth_create_digest_md5_message builds a comma-separated list of key=value directives. Three of those values (username, realm, and nonce) are escaped with auth_digest_string_quoted before they are wrapped in quotes. digest-uri is not. It is filled with the raw SPN from Curl_auth_build_spn(service, hostname, NULL), and service is CURLOPT_SERVICE_NAME when that option is set. A quote and comma in the service string close the quoted digest-uri field early and add extra directives to the message. That buffer is then base64-encoded and written by the protocol continuation callback.

The same response path already had quoting bugs in the other fields: realm and nonce in #20482 / #20545, and username in #20940 (HackerOne 3608522). On that HackerOne report the maintainer said "curl makes no guarantees for this. Users should pass in correct data or there might be errors.", and that "We do not consider this security problems." Still, given that other parameters received the quotation, I thought it might still worth file an issue for the digest-uri parameter, too.

Demonstration

A local SMTP listener offers DIGEST-MD5, returns a fixed md5-sess / qop=auth challenge, and prints the decoded client response. Run the listener server, then the curl command against the printed PORT.

`server.py`
#!/usr/bin/env python3
from __future__ import annotations

import base64
import socket
import threading

HOST = "127.0.0.1"
CHALLENGE = b'nonce="n",realm="r",algorithm=md5-sess,qop="auth"'
CH_B64 = base64.b64encode(CHALLENGE).decode("ascii")


class SmtpDigest:
    def __init__(self) -> None:
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.bind((HOST, 0))
        self.sock.listen(8)
        self.port = self.sock.getsockname()[1]
        self._stop = threading.Event()

    def serve(self) -> None:
        self.sock.settimeout(0.5)
        while not self._stop.is_set():
            try:
                conn, _addr = self.sock.accept()
            except socket.timeout:
                continue
            except OSError:
                break
            threading.Thread(target=self.handle, args=(conn,), daemon=True).start()

    def stop(self) -> None:
        self._stop.set()
        try:
            self.sock.close()
        except OSError:
            pass

    def handle(self, conn: socket.socket) -> None:
        conn.settimeout(10)
        try:
            conn.sendall(b"220 scan2-candidate8 smtp\r\n")
            rest = b""
            while not self._stop.is_set():
                try:
                    chunk = conn.recv(4096)
                except socket.timeout:
                    break
                if not chunk:
                    break
                rest += chunk
                while b"\r\n" in rest:
                    raw, rest = rest.split(b"\r\n", 1)
                    line = raw.decode("ascii", "replace").strip()
                    if not line:
                        conn.sendall(b"535 empty\r\n")
                        continue
                    print(f"> {line}", flush=True)
                    cmd = line.split(" ", 1)[0].upper()
                    if cmd in ("EHLO", "HELO"):
                        conn.sendall(b"250-localhost\r\n250 AUTH DIGEST-MD5\r\n")
                    elif cmd == "AUTH":
                        conn.sendall(f"334 {CH_B64}\r\n".encode("ascii"))
                    elif cmd == "QUIT":
                        conn.sendall(b"221 bye\r\n")
                        return
                    else:
                        decoded = base64.b64decode(line).decode("utf-8", "replace")
                        print(f"DECODED: {decoded}", flush=True)
                        conn.sendall(b"535 authentication failed\r\n")
        finally:
            try:
                conn.close()
            except OSError:
                pass


def main() -> None:
    srv = SmtpDigest()
    print(f"PORT={srv.port}", flush=True)
    try:
        srv.serve()
    except KeyboardInterrupt:
        pass
    finally:
        srv.stop()


if __name__ == "__main__":
    main()
python3 server.py
# PORT=40627

printf 'hi\n' | curl --max-time 8 --user 'user:secret' \
  --service-name 'smtp",injected=1,x="' \
  --mail-from sender@example.com --mail-rcpt recipient@example.com \
  --login-options 'AUTH=DIGEST-MD5' -T - smtp://127.0.0.1:40627/

Output

--service-name 'smtp",injected=1,x="' terminates digest-uri and adds injected=1:

DECODED: username="user",realm="r",nonce="n",cnonce="9a4367aa915d8c454f0cebf54aeeefd2",nc="00000001",digest-uri="smtp",injected=1,x="/127.0.0.1",response=d8aafa956dd9d15f98fa61480eeec606,qop=auth
Code walkthrough

1. CURLOPT_SERVICE_NAME is stored unfiltered

Curl_setstropt copies a C string up to CURL_MAX_INPUT_LENGTH with no character filtering:

lib/setopt.c:81-90

CURLOPT_SERVICE_NAME use that helper:

lib/setopt.c:2307-2308

url_set_data_creds then copies STRING_SERVICE_NAME unchanged into the connection credentials:

lib/url.c:1365-1376

2. DIGEST-MD5 continuation uses that service string

With a username configured and DIGEST-MD5 enabled, sasl_choose_digest selects the mechanism and sets SASL_DIGESTMD5 as the first continuation state:

lib/curl_sasl.c:387-393

On the challenge continuation, Curl_sasl_continue calls Curl_auth_create_digest_md5_message. That function prefers the credentials' SASL service over the protocol default ("smtp" / "imap" / "pop"):

lib/curl_sasl.c:646-654, lib/vauth/digest.c:350-351

3. SPN is concatenated and interpolated without quoting

Curl_auth_build_spn concatenates service and the connection hostname with no escaping:

lib/vauth/vauth.c:49-58, lib/vauth/digest.c:435

The response formatter quote-escapes username, realm, and nonce, then drops the raw SPN into digest-uri="%s":

lib/vauth/digest.c:487-498

The HTTP Digest path in the same file does quote the URI (uri_quoted = auth_digest_string_quoted(...)).

Curl_sasl_continue then base64-encodes the buffer (SASL_FLAG_BASE64) and passes it to the protocol contauth callback with no structured-field check. SMTP writes it with Curl_pp_sendf(..., "%s", ...):

lib/curl_sasl.c:248-266, lib/curl_sasl.c:793-796, lib/smtp.c:763-773

For --service-name 'smtp",injected=1,x="' the SPN is smtp",injected=1,x="/127.0.0.1, so the emitted field list contains digest-uri="smtp",injected=1,x="/127.0.0.1" instead of one quoted digest-uri.


Reporting on behalf of Microsoft FORGE Lab. This bug was initially discovered with an agentic code scanner; the author verified the finding and prepared this report.

I expected the following

digest-uri escaped with auth_digest_string_quoted the same way as username, realm, and nonce.

curl/libcurl version

curl 8.23.0-DEV (Linux) libcurl/8.23.0-DEV zlib/1.3.1
Release-Date: [unreleased]
Protocols: dict file ftp gopher http imap ipfs ipns mqtt pop3 rtsp smtp telnet tftp ws
Features: alt-svc AsynchDNS IPv6 Largefile libz threadsafe UnixSockets

Built from 39088cd295592ca6d4c5e687c75b400a67a87906 (2026-09-16). gcc 15.2.0, Python 3.14.4.

Note: This does not trigger on Ubuntu's packaged curl (8.18.0-1ubuntu2.5). That package's CVE-2026-8458 patch is a partial backport of upstream 5e99b73cf441d9c369768b9cd48b5389b9a2503d (creds: add sasl service name, first in 8.21.0). Upstream also stopped reading STRING_SERVICE_NAME in Curl_sasl_continue, but moved the lookup into Curl_auth_create_digest_md5_message via Curl_creds. Ubuntu applied only the curl_sasl.c side, so DIGEST-MD5 always gets the protocol default ("smtp") and --service-name never reaches digest-uri.

operating system

Linux Abraxas 6.6.114.1-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Mon Dec  1 20:46:23 UTC 2025 x86_64 GNU/Linux

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions