HexBrain

curl · Report #3684614

Heap-buffer-overflow in `Curl_ssl_push_certinfo_len()` — sole bounds check is `DEBUGASSERT`

N/Astatus
highseverity
7upvotes
29 Apr 2026disclosed

How it was found

Summary

Curl_ssl_push_certinfo_len() in lib/vtls/vtls.c uses DEBUGASSERT(certnum < ci->num_of_certs) as its only bounds check before writing a heap pointer into ci->certinfo[certnum]. DEBUGASSERT is a no-op in every release/production build (lib/curl_setup.h:1084). Any mismatch between the count passed to Curl_ssl_init_certinfo() and the certnum values subsequently passed to Curl_ssl_push_certinfo_len() results in an unguarded heap out-of-bounds read and write.

This function is the single shared certinfo write path for all five TLS backends (OpenSSL, GnuTLS, mbedTLS, Rustls, Schannel). The certnum argument in every backend derives directly from the server-supplied certificate chain length. There is no runtime check in any production build.

Affected version

Reproduced against:

  • curl 8.19.0 (tag curl-8_19_0, commit 8c908d2, released 2025-03-10) — current stable release
  • curl 8.20.0-DEV (commit 759f2e5) — current development tip

Tested on Linux x86_64 with clang, AddressSanitizer, and UndefinedBehaviorSanitizer.

Call path from CURLOPT_CERTINFO to the vulnerable write

When an application sets CURLOPT_CERTINFO=1, every successful TLS handshake runs the following sequence (shown for the OpenSSL backend; all five backends are identical in structure):

curl_easy_perform(handle)
  Curl_ossl_check_peer_cert()                      openssl.c:4736
    ossl_certchain(data, ssl)                       openssl.c:4739
      numcerts = sk_X509_num(peer_cert_chain)   <- server controls N
      Curl_ssl_init_certinfo(data, numcerts)     <- alloc N-slot table
      for i = 0 .. N-1:                         <- certnum = i
        Curl_ssl_push_certinfo_len(data, i, ...) <- called N times
          DEBUGASSERT(i < num_of_certs)          <- SOLE GUARD, no-op in release
          ci->certinfo[i] = ...                  <- heap write, unbounded in release

The same funneling path exists in all five backends:

BackendFileLineCaller
OpenSSLopenssl.c392, 409–505ossl_certchain()
GnuTLSgtls.c1630, 1638Curl_extract_certinfo() loop
mbedTLSmbedtls.c433, 438mbed_extract_certinfo()
Rustlsrustls.c1225, 1250certinfo loop
Schannelschannel.c1679, 1550add_cert_to_certinfo()

Vulnerable code

lib/vtls/vtls.c:647–675:

CURLcode Curl_ssl_push_certinfo_len(struct Curl_easy *data,
                                    int certnum, ...)
{
  struct curl_certinfo *ci = &data->info.certs;

  DEBUGASSERT(certnum < ci->num_of_certs);   /* :658 — no-op in release */

  /* ... build label:value string ... */

  nl = Curl_slist_append_nodup(ci->certinfo[certnum], ...); /* :667 OOB READ  */
  ci->certinfo[certnum] = nl;                               /* :674 OOB WRITE */
}

lib/curl_setup.h:1084:

#define DEBUGASSERT(x) do {} while(0)   /* release/production builds */

Steps To Reproduce

  1. Build curl with AddressSanitizer and UndefinedBehaviorSanitizer:
autoreconf -fi
CC=clang CFLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1" \
LDFLAGS="-fsanitize=address,undefined" \
./configure --disable-shared --with-openssl \
  --disable-docs --disable-manual
make -j"$(nproc)"
# If configure fails on optional deps (e.g. libpsl), add --without-libpsl
  1. Compile the attached poc.c against the static libcurl:
clang -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \
  -I./include -I./lib \
  poc.c ./lib/.libs/libcurl.a \
  -lssl -lcrypto -lz -lpthread -ldl \
  -o /tmp/poc_curl_certinfo_oob
  1. Run it:
ASAN_OPTIONS="halt_on_error=0:print_stacktrace=1:detect_leaks=0" \
UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=0" \
  /tmp/poc_curl_certinfo_oob

The PoC calls the real Curl_ssl_init_certinfo() (verified via nm in the static library) to allocate a production certinfo table for a 2-cert chain. It then replicates the exact read+write pattern from Curl_ssl_push_certinfo_len() at lines :667/:674 with certnum=5, bypassing the DEBUGASSERT to demonstrate release-build behavior.

Observed sanitizer output (curl 8.19.0):

[*] certnum=5  OOB (only 2 allocated) — ASan should fire:

/work03/poc.c:173:20: runtime error: load of address 0x7be1db0e1a18 with insufficient
    space for an object of type 'struct curl_slist *'
    #0 in main poc.c:173:20
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior poc.c:173:20

==25989==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7be1db0e1a18
READ of size 8 at 0x7be1db0e1a18 thread T0
    #0 in main poc.c:173:20
SUMMARY: AddressSanitizer: heap-buffer-overflow poc.c:173:20 in main

Impact

Curl_ssl_push_certinfo_len() is the sole write path for all five TLS backends when CURLOPT_CERTINFO=1 is set. The only guard DEBUGASSERT is compiled out in every production build.

Memory corruption in release builds. Without the assert, ci->certinfo[certnum] is an unbounded heap array access. The write at :674 stores a heap pointer at a server-influenced offset past the end of the allocated table, overwriting adjacent allocator metadata or live heap objects. This is a heap pointer write primitive relative to the certinfo array.

All five TLS backends are affected equally. Every backend derives certnum from the server-controlled certificate chain length and calls this unguarded function directly. Any future count mismatch in any backend — a filter applied before init but not before push, a new backend, a refactoring error — immediately becomes silent heap corruption in production with no fallback.

Severity is High because: the vulnerable code runs on every HTTPS connection that uses CURLOPT_CERTINFO=1; the input that drives certnum (certificate chain length) is server-controlled; the result in release builds is an unguarded heap pointer write; and the sole protection (DEBUGASSERT) is unconditionally absent in production.

The fix is a single runtime bounds check before the array access at :658.

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