LDAP injection is tracked by MITRE as CWE-90 — Improper Neutralization of Special Elements used in an LDAP Query — and it works the same way SQL injection does: untrusted input gets concatenated straight into a query string, letting an attacker rewrite the logic of a search filter sent to a directory service like Active Directory or OpenLDAP. Unlike SQL, LDAP has no universal bind-parameter API across client libraries, so the actual fix depends on escaping five characters to their hex form under RFC 4515. That gap is not theoretical: CVE-2023-0476, disclosed in Tenable.sc versions 5.23.1 and earlier, let an authenticated attacker perform blind LDAP injection through the application's own service account to write data into Active Directory. A month prior, CVE-2023-23749 hit a WordPress LDAP-integration plugin (miniorange's NTLM & Kerberos login extension, version 5.0.2) where an unsanitized username POST parameter let anyone dump arbitrary directory contents, scoring 7.5 on CVSS. Both bugs trace to the same root cause OWASP has documented for over a decade in its Injection Prevention Cheat Sheet. This primer covers how the injection actually works, what RFC 4515 requires, and how to close it off with escaping, allowlisting, and least-privilege bind accounts — the three controls that separate a directory service from an open filter interpreter.
What does a vulnerable LDAP query actually look like?
A vulnerable LDAP query looks almost identical to a vulnerable SQL query: a format string with a user-controlled field spliced in directly. A login check built as a filter like (&(uid= ${username} )(userPassword= ${password} )) is safe only as long as neither variable contains LDAP metacharacters. Because the LDAP filter grammar treats *, |, !, =, ~=, >=, <=, and parentheses as structural operators, an attacker who submits *)(uid=* as the username can close the intended uid= clause early and reopen a wildcard match, producing something logically equivalent to (&(uid=*)(userPassword=*)) — a filter that matches every entry in the directory regardless of password. This is the same class of always-true bypass SQL injection made famous with ' OR '1'='1, just expressed in LDAP's prefix-parenthesized filter syntax instead of infix boolean logic. The attack surface extends past authentication forms to any search, group-membership check, or attribute lookup built by concatenating request data into a filter string.
What exactly does RFC 4515 require you to escape?
RFC 4515 defines LDAP's string representation for search filters and specifies that five characters or byte values must be escaped to a backslash followed by their two-digit hex code before insertion into a filter: * becomes \2a, ( becomes \28, ) becomes \29, \ becomes \5c, and the NUL byte becomes \00. Escaping these five values neutralizes every metacharacter an attacker needs to alter filter structure — without them, * can't turn into a wildcard, and parentheses can't be used to close and reopen filter clauses. Because LDAP has no equivalent of a SQL prepared statement with bound parameters built into the wire protocol, this escaping step is the actual mechanism that prevents injection, not an optional hardening layer. Most mature LDAP client libraries — including Java's UnboundID/JNDI helpers and Python's ldap3 — expose a dedicated filter-escape or encode function precisely so developers don't hand-roll the hex substitution themselves and miss an edge case like a raw backslash inside a username.
Why can't you just parameterize LDAP queries like SQL?
You can't parameterize LDAP queries the way you parameterize SQL because the LDAP protocol, as specified across RFC 4510–4519, never defined a bind-variable mechanism analogous to a SQL prepared statement — search filters are always assembled and transmitted as a single encoded string. SQL injection defenses evolved around parameterized queries because the wire protocol and most database drivers support sending the query template and the untrusted values separately, so the database itself never has to parse user data as syntax. LDAP filters, by contrast, are constructed client-side as a complete string per RFC 4515 before being sent to the directory server, so escaping has to happen in application code before construction, not at the transport layer. This is exactly why OWASP's cheat sheet leads with escaping and allowlisting rather than parameterization as the primary LDAP injection defense — there's no lower-level substitute to fall back on.
What does defense-in-depth look like beyond escaping?
Defense-in-depth for LDAP injection layers allowlist input validation, least-privilege bind accounts, and controlled error handling on top of RFC 4515 escaping. Allowlisting usernames to alphanumeric characters (rejecting *, (, ), and \ outright before a query is ever built) removes most injection payloads before escaping logic even runs, which matters because a missed escape path in one code branch is a common way these bugs slip through review. Binding to the directory with a service account scoped to only the attributes and subtrees the application needs limits the blast radius of a successful injection — CVE-2023-0476 was exploitable specifically because the vulnerable service account had write access to Active Directory attributes the application never needed to modify. Suppressing verbose directory errors also matters: blind LDAP injection, where an attacker infers directory contents one true/false response at a time without ever seeing raw output, is exactly the technique used against Tenable.sc, and it depends entirely on the application leaking a distinguishable signal between a matching and non-matching filter.
How do WAFs and scanners catch LDAP injection attempts?
Web application firewalls catch LDAP injection primarily through signature matching on filter metacharacters in request parameters, since the payloads are short and syntactically distinctive compared to broader injection classes. The OWASP Core Rule Set flags this behavior in its protocol-attack ruleset — rule 921200 in REQUEST-921-PROTOCOL-ATTACK.conf — at Paranoia Level 1 with Critical severity, matching on LDAP filter operator patterns appearing in user-supplied fields. That signature-based detection is a compensating control, not a substitute for fixing the code: it catches obvious *)(uid=* style payloads in a request body but won't catch injection carried through less obvious channels like a stored display name later re-used in a group-lookup filter. Static analysis rule sets that map findings to CWE-90 — the same categorization MITRE assigns the weakness class — give teams a way to triage LDAP-filter construction as a named, prioritized risk category rather than an undifferentiated string-concatenation warning, the same way CWE mapping turns any injection class from noise into an actionable backlog item.