OpenBSD · ldapd(8) · CVE-2026-103547 · CVSS 4.0 9.2 Critical
CVE-2026-103547 is a bug with no memory corruption and no exotic side channel. It is two processes agreeing to identify a client by a number that the operating system is allowed to hand out again five milliseconds later. Under the right timing, ldapd would take the authentication result of one LDAP connection and apply it to a completely different one. A remote client could finish a Bind as somebody else. The same missing check could also dereference a NULL pointer and take the LDAP engine down.
This one is mine. The OpenBSD fix credits it as “Based on a report from Franz Bettag of Bettag Systems”, and i want to walk through exactly what was wrong and why the fix is so small.
The setup
ldapd is OpenBSD’s LDAP daemon. It is not enabled by default, which is the main reason this is a 9.2 and not a “drop everything” event. But if you do run it and let it authenticate against the local BSD authentication stack, it was exploitable before errata 057 (7.8) and 021 (7.9).
Like almost everything in OpenBSD base, ldapd is privilege separated. Two roles matter here:
ldape, the LDAP engine. It accepts client sockets, parses BER, and handles the protocol. This is the process talking to the network.ldapd, the parent. Among other things it performs the actual BSD authentication for a Bind, because that work does not belong in the process chewing on attacker-controlled input.
Because the two live in different processes, a Bind does not resolve inside one blocking function call. It becomes a message. ldape sends “please authenticate this user with this password” to the parent, keeps servicing other clients, and picks up the answer later when it arrives over the imsg socket.
That asynchronicity is the whole story. The moment authentication became a round trip, ldapd needed a token to remember which connection a given result belongs to. It picked the wrong token.
The wrong token
When a Bind came in, ldape built an auth request like this (usr.sbin/ldapd/auth.c):
1 | auth_req.fd = req->conn->fd; |
The correlation token was the client’s socket file descriptor plus the LDAP message ID. That request went to the parent. The parent ran the actual check and echoed the very same identifiers back in its result (usr.sbin/ldapd/ldapd.c):
1 | ares.ok = ldapd_auth_classful(areq->name, areq->password); |
And back in the engine, the result got matched to a live connection by looking up that file descriptor (usr.sbin/ldapd/ldape.c):
1 | conn = conn_by_fd(ares->fd); |
conn_by_fd is exactly what it sounds like: walk the connection list, return the one whose fd matches.
1 | struct conn * |
Here is the problem in one sentence: a file descriptor is not a stable identity for a connection over time. It identifies an open file right now. Close the socket and the kernel is free to reuse that integer for the very next thing you open. POSIX even encourages it, the lowest free descriptor wins.
So the “unique” token that ties an authentication result back to the client who asked for it is a small integer that the OS actively recycles.
The race
Put those two facts together and the attack writes itself:
- Client A connects. The kernel gives ldape socket fd
8. A sends a Bind that will succeed, say as an admin identity, with message ID1. ldapefires the auth request{fd: 8, msgid: 1}off to the parent and moves on.- A disconnects immediately, before the answer comes back. fd
8is now closed and free. - Client B connects. It gets fd
8, the lowest free descriptor. B sends its own Bind with message ID1. - The parent finishes A’s authentication and sends back
{ok: 1, fd: 8, msgid: 1}. ldapecallsconn_by_fd(8), which now returns B’s connection. The msgid matches.ldap_bind_continueruns. B is now authenticated with the result that was computed for A.
The attacker controls both ends of this. The message ID is chosen by the client, so lining it up is trivial. The file descriptor is not directly chosen, but it is very predictable: descriptors are allocated lowest-first, so under a steady connect/disconnect pattern you can reliably land on the same number. The only genuinely hard part is timing the reconnect into the window between the parent computing the result and the engine consuming it.
That difficulty is exactly what the CVSS vector encodes: AV:N/AC:H/AT:P, network reachable, high attack complexity, and a present-but-not-guaranteed race condition. Win the race and the impact is a full authentication confusion: you complete a Bind as an identity that was never yours. That is CWE-863, incorrect authorization, straight down the middle.
The other half: the NULL deref
Look again at the consumer:
1 | conn = conn_by_fd(ares->fd); |
conn_by_fd can return NULL, and nobody checked. If A disconnects and no new client grabs that descriptor before the result arrives, the lookup finds nothing and returns NULL. Then conn->bind_req dereferences a NULL pointer and the LDAP engine crashes.
So the same root cause gives you two outcomes depending on whether the descriptor got reused in time: win the race and you get an authentication mix-up, lose it the other way and you get a denial of service. Both from trusting a recycled integer.
The fix
The patch is tiny, which is the satisfying part. Stop identifying connections by something the kernel recycles. Give each connection a monotonic 64-bit identifier that is never reused within the lifetime of the process.
A single global counter in conn.c:
1 | uint64_t conn_id; |
Stamped once when a connection is accepted:
1 | conn->id = conn_id++; |
conn_by_fd becomes conn_by_id, comparing the durable id instead of the descriptor. The auth_req and auth_res structs carry id instead of fd, so the token that round-trips through the parent is now the stable one. And the missing check finally appears:
1 | conn = conn_by_id(ares->id); |
A 64-bit counter incremented once per accepted connection will not wrap in any realistic deployment. Even at a million connections per second it takes over half a million years to overflow. So the identifier is, for practical purposes, unique forever. The recycled descriptor problem simply cannot happen anymore, and the stale-result case now logs a warning instead of dereferencing NULL.
That is the entire fix. Five files, mostly mechanical fd to id renames, one real behavioral change (the NULL guard), and one new counter.
Why i like this bug
There is no shellcode here. No heap grooming, no ROP chain, no leaked pointer. The vulnerability is a type error in the loosest sense of the word: the code used a value that means “currently open file” as if it meant “this specific client, forever”. Those are different concepts that happen to share a representation, a small integer, and the gap between them is only visible once you notice that authentication crosses a process boundary and comes back later.
That is a recurring shape in privilege-separated and event-driven systems. The instant you split work across processes or defer it across an event loop, every ambient identifier you were relying on, a pointer, an fd, an index, stops being ambient. It has to be serialized into a token and sent along, and that token has to stay unique for as long as anyone might answer. File descriptors fail that test quietly, because in a single synchronous function they look perfectly unique.
It is also a good reminder that “unique right now” and “unique over time” are not the same guarantee, and authentication logic only ever cares about the second one.
If you run ldapd, apply errata 057 (7.8) or 021 (7.9), or syspatch. The upstream commit is 4f3f58e if you want to read the whole diff yourself, it is short and worth the five minutes.