Roster

Auth Bypass in LDAP Services via StartTLS Desync in 389-ds (CVE-2026-86345)

Forged Bind Result

Affected
389 Directory Server
Identifiers
CVE-2026-86345GHSA-c4v7-fhgq-r988

TL;DR

I found a StartTLS flaw in 389 Directory Server that lets an on-path attacker make LDAP-backed services accept a wrong password. The attacker injects an anonymous bind before TLS starts. Its success response arrives inside TLS with the message ID the client expects for its real password check.

I used this to open an SSH session as alice on a Fedora 44 host while 389-ds rejected the wrong password with err=49. Certificate validation stayed enabled. The SSH result depends on the client’s follow-up checks and the directory’s access rules; I tested nss-pam-ldapd, not sssd. Deployments using StartTLS should apply the fix or switch to implicit TLS (ldaps://).

The tested path

I tested this through sshd, the SSH server. It called Linux-PAM, Linux’s authentication framework. The pam_ldap module passed the password check to nslcd, the LDAP client connecting to 389-ds.

The attacker submits a chosen account name and wrong password to the service. They also need an on-path position between the service and 389-ds, where they can read, append to, and delay its initial LDAP traffic. A login attempt alone is insufficient; the account owner need not participate.

The attacker submits Alice and a wrong password to a login service and controls a relay between nslcd, the LDAP client, and 389-ds. TLS terminates at nslcd and 389-ds; the relay forwards encrypted traffic after the upgrade.
nslcd looked up Alice on one connection, then opened another for her bind and a verification search. The relay injected on both; the bind-result mix-up mattered on the second. This setup allowed anonymous LDAP reads.

What a normal login does

With StartTLS, the LDAP connection begins in plaintext. The client requests an upgrade, 389-ds agrees, and they negotiate TLS on the same socket. The client then sends a bind, LDAP’s authentication request, with the account name and password. A wrong password returns err=49 (invalid credentials); an empty account name and password establish an anonymous connection.

Each LDAP request carries a messageID that the client uses to match its response. In the clients I tested, StartTLS used ID 1 and the password bind used ID 2. A wrong password should produce the exchange below (C is the client; S is 389-ds):

C: StartTLS                 ID 1
S: StartTLS OK              ID 1
   [TLS + certificate check]
C: bind(alice, wrong password)  ID 2
S: bind err=49              ID 2

How fourteen bytes change the answer

My relay read the StartTLS message ID and appended a 14-byte anonymous BindRequest using the next ID, the one the client would use for its password bind. The injected request had an empty account name and password, so its success only established an anonymous connection.

The relay first kept its upstream connection busy with anonymous whoami operations for seven seconds. It then sent the StartTLS request and extra bind in one sendall() call, before the TLS handshake. After the upgrade, it forwarded ciphertext without decrypting it or replacing the certificate.

Four-step result collision: the relay appends anonymous bind ID 2 to plaintext StartTLS ID 1; 389-ds upgrades to TLS while retaining buffered plaintext; the anonymous bind receives success ID 2 inside TLS; 389-ds then handles the real user bind with ID 2 and returns error 49, but the client accepts the anonymous success as its bind result.
The anonymous bind's success arrived inside TLS with the same message ID as the client's password bind. The directory produced both responses; the real password check failed.

In one ldapwhoami -ZZ trial against 389-ds-base-3.2.2-2.fc44, the client exited 0 and printed anonymous despite a wrong password for the Directory Manager administrator account. On that same connection, 389-ds logged the anonymous bind request before its TLS1.3 line, its success result after that line, then the real Directory Manager bind returning err=49. These are server events; I did not measure the resulting TCP packets or the client’s send and read times.

The client accepted a real success response for the wrong bind, leaving the connection anonymous.

Why 389-ds let the request through

In the source I examined, start_tls_io_enable() installed the TLS I/O layer without clearing plaintext still held in the connection’s read buffer. Later LDAP reads could consume those bytes after the upgrade. The StartTLS guard checked for pending operations, but buffered bytes that had not yet become an Operation escaped that check. RFC 4511 §4.14.1 requires the client to finish the upgrade before sending another LDAP message.

The seven-second warm-up made this setup reproducible. A cold attempt was rejected by the StartTLS guard. I did not isolate the scheduling change that let the buffered request through, so seven seconds is a condition of this setup, not a general delay requirement.

An SSH session with the wrong password

On a Fedora 44 host running nss-pam-ldapd and openssh-server-10.2p1-16.fc44, sshd opened an interactive terminal session as alice and ran commands in it for a client sending TOTALLY-WRONG-PASSWORD, while 389-ds refused that same credential with err=49 on the same LDAP connection. This worked in 3 of 3 attempts. sshd logged Accepted keyboard-interactive/pam for alice and permanently_set_uid: 5000/5000, and id -un inside the session returned alice.

sshd completed the account phase, logging do_pam_account pam_acct_mgmt = 0 (Success), then pam_setcred and pam_open_session. Fedora ships KbdInteractiveAuthentication no; with that setting sshd used its plain password method instead, and the outcome was the same in 3 of 3 attempts.

Without injection, 0 of 3 logins succeeded. Moving the injected bind’s message ID from the StartTLS ID plus one to plus two also produced 0 of 3, with the same attacker and seven-second warm-up. The directory still executed the smuggled bind with err=0, but nslcd no longer logged bind successful for Alice, despite running the same ldap_sasl_bind in the same session. This one-byte change tested the response-ID collision named in Red Hat’s advisory.

I used the authentication and account lookup configuration that Debian’s libpam-ldapd generates. Fedora’s own LDAP integration uses sssd, which I did not test. The SSH result also depended on nslcd’s post-bind verification search, covered below.

Authentication and account lookup configuration

The setup adds pam_ldap lines to password-auth and points passwd, shadow and group at LDAP in nsswitch.conf. Fedora’s authselect profiles are local, nis, sssd and winbind; none reference pam_ldap.

/etc/pam.d/sshd was Fedora’s own file, unmodified. No Fedora session module was relaxed or removed. The only added session line was session optional pam_ldap.so.

What the attack needs from the directory

At this point, nslcd thinks Alice’s password was accepted. But 389-ds rejected her bind, so the directory connection is still anonymous. The SSH login now depends on an extra check that nslcd performs after the bind.

By default, nslcd searches for Alice’s entry, her account record in the directory, using that same connection. This verification search only needs to find the entry. Since the connection is anonymous, the directory’s access rules decide whether an unauthenticated reader can see it. If the search finds nothing, nslcd refuses the login despite accepting the bind result.

Using a service account for the initial user lookup does not avoid this check. That lookup uses a separate connection; its permissions do not carry over to the connection where Alice’s password check failed.

I tested whether allowing or skipping this follow-up search changed the SSH result:

ConfigurationWrong-password SSH logins
Verification enabled; no anonymous read (service account for initial lookups)0 of 3
Verification enabled; anonymous access to the entry’s type field (objectClass) only3 of 3
Verification disabled (pam_authc_search NONE); no anonymous read3 of 3
Default nslcd verification; shipped sample-entry access rules3 of 3

With the verification search enabled and no anonymous read, it returned SRCH base="uid=alice,…" RESULT err=0 nentries=0: no entry was returned, so the login stopped. Alice’s record still existed: a legitimate login in that configuration succeeded.

Granting anonymous read,search permission on objectClass was enough to pass the check. The search returned only the entry’s distinguished name (its full name within the directory), with no attribute values. Finding the record was sufficient; nslcd did not need to read the account details. Setting pam_authc_search NONE, which this nslcd build honours, skipped the search entirely, so anonymous read permission was no longer needed.

For the sample-entry test, I created the directory subtree with dsconf backend create --create-entries and added no access rule. The rules installed with those sample entries already allowed the anonymous lookup. A legitimate login succeeded first, then the wrong-password logins succeeded. The server logged:

op=446 BIND dn=""                      smuggled, in cleartext
conn=25 TLS1.3 128-bit AES-GCM[PQC]
op=446 RESULT err=0 tag=97 dn=""       answered inside TLS
op=447 BIND dn="uid=alice,..."
op=447 RESULT err=49 - Invalid credentials
op=448 SRCH base="uid=alice,..." scope=0
op=448 RESULT err=0 tag=101 nentries=1

Here err=49 shows the failed password check, while nentries=1 shows that the follow-up search still found Alice’s entry. That was enough for nslcd to continue the login.

The bind-result mix-up itself needs no directory read permission. The earlier ldapwhoami trial had no follow-up search, and those LDAP-client tests ran before I added a directory access rule. The permission requirement comes from nslcd’s verification step in the SSH login.

Additional test: could an injected account bind provide read access?

I also tried smuggling a bind as an account that could read Alice’s entry. The directory accepted it, but 0 of 2 logins succeeded. That bind’s own result carried dn="", an empty distinguished name; the account’s identity did not survive to the verification search.

Which directory-creation methods install the read rule?

The two stock methods for creating a directory subtree install different access rules. My setup’s lib389 create_suffix_entry path installs one instruction scoped to the domain entry. The SSH chain failed in that configuration. The sample-entry path installs thirteen instructions, five granting to ldap:///anyone.

The relevant rule sits on ou=people, filters on (objectClass=posixaccount), and grants read, search, compare over objectClass, uid, uidNumber, gidNumber, gecos, homeDirectory, loginShell and cn among others. It has shipped since 1.4.0.

dscreate with sample_entries = yes and dsidm initialise reach the same lib389 code as the dsconf command I tested, but I did not run them. I have not measured how common either method is in deployed directories.

Fix and disclosure

Red Hat assigned CVE-2026-86345. My submitted proof of concept stopped at Linux-PAM’s answer through pamtester, which returned success for the same wrong password in 3 of 3 attempts. Red Hat rates the issue Moderate, with a CVSS 3.1 score of 9.0. GitHub advisory GHSA-c4v7-fhgq-r988 was published on 2 October 2026. Upstream merged pull request #7922 on 7 October 2026. The fix prevents another worker from processing extra buffered data during StartTLS, rejects remaining plaintext, and tightens the pending-operation check. I did not test the fixed code or backports.

I reproduced the desync on two Fedora 44 builds, 389-ds-base-3.2.2-2.fc44 and 389-ds-base-3.2.3-5.fc44. At the time of writing, dnf resolves 3.2.3-5 from Fedora 44’s updates repository; 3.2.2-2 has aged out. The newer build dates from 9 September 2026, before the upstream fix, and no fixed build has reached that repository. I checked Fedora 44 only and did no version bisect. The GitHub advisory lists both affected and patched versions as unknown.

Red Hat recommends disabling StartTLS and requiring ldaps://, so the connection begins with TLS before any LDAP message and exposes no plaintext upgrade request. The vendor says no configuration-only mitigation fully closes the issue with StartTLS enabled. I tested standalone Fedora builds, not Red Hat Enterprise Linux, Red Hat Directory Server, FreeIPA or upstream main; use the vendor’s affected product and update information for those deployments.