CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection')
Overview
LDAP Injection occurs when untrusted input is concatenated into an LDAP filter or Distinguished Name without context-correct escaping, so the value is read as query syntax rather than as data and an attacker can change what the query matches.
Relationship to Other CWEs
CWE-90 is a child of CWE-943 (Improper Neutralization of Special Elements in Data Query Logic), the general weakness behind injection into any structured query language, including SQL, NoSQL, and LDAP. Use CWE-943 for the general query-injection defense pattern (parameterized, structured query construction); use this page for LDAP-specific guidance - escaping filter values per RFC 4515, handling Distinguished Names per RFC 4514, and preferring search-then-use-returned-DN over constructing DNs from input.
OWASP Classification
A05:2025 - Injection
Risk
High: An unescaped value can turn an equality test into one that matches every entry, so a search scoped to a single account returns the whole directory, and a filter that compares a password matches any account that has one set.
Remediation Steps
Core Principle: Never concatenate untrusted input into an LDAP filter or DN; build it with the structured API or the escaping function for that context.
Trace the Data Path
Work out how untrusted data reaches the LDAP query:
- Source: Where untrusted data enters (user input, external file, database, network request)
- Sink: LDAP query construction or search function
- String concatenation: The untrusted value is embedded directly in the filter or DN, with no context-specific escaping between source and sink
Use a Structured Builder or Context-Correct Escaping (Primary Defense)
Stop assembling filters and DNs by concatenation. No LDAP client parameterizes at the protocol level the way a database driver does - a filter travels as a structure the client builds from the string you hand it, so every "parameterized" LDAP API is really escape-on-substitution. That still removes the defect, because it removes the chance to forget. What it looks like depends on the ecosystem:
- Java has a placeholder overload,
DirContext.search(name, "(uid={0})", args, controls), which escapes each argument as it substitutes; andLdapName/Rdnfor building distinguished names - Node.js has structured builders - an
escapeFiltertagged template for filters, aDNclass for distinguished names - Python's python-ldap has the same shape as Java's,
ldap.filter.filter_format("(uid=%s)", [username]); ldap3 escapes one value at a time withescape_filter_chars() - C# has no equivalent in the framework, so the fix there is allowlist validation plus an escaping function for the context in question
- Follow the secure code examples in the language-specific guidance rather than assuming a placeholder syntax exists
Escape for the Right Context
Filter values and DN components have different rules, and neither escaper is safe to use for the other's context:
- Filter values (RFC 4515): escape
*,(,),\and NUL as backslash-hex - DN components (RFC 4514): escape
,,+,",\,<,>,;,=, NUL, a leading#, and a leading or trailing space - Use the escaping function your ecosystem provides for each context, not one "escape LDAP" helper for both
- Apply escaping where the query is constructed, and never rely on client-side validation
Validate and Sanitize Input (Defense in Depth)
Input validation is a second layer, not a replacement for escaping:
- Validate untrusted data against the format you expect: a username pattern, an email address
- Use allowlists for enumerated values
- Reject or sanitize unexpected characters
- Apply length limits to prevent DoS
Apply Least Privilege to Directory Accounts
Bound what an injection can reach if escaping is missed somewhere:
- Bind with an account that has only the permissions the application needs: read-only for search operations, never an admin or root account
- Restrict access to sensitive directory attributes
- Use separate accounts for different application functions
Monitor and Test
Verify your fixes and enable detection:
- Test with a bare
*in each interpolated value. It is the payload that works against the common single-term filter: unescaped it turns(uid=alice)into(uid=*), a presence test matching every entry, and in a filter that compares a password it matches any account that has one set - The payload most write-ups quote,
*)(uid=*))(|(uid=*, needs a compound filter and a server that parses the first filter and ignores the rest. Interpolated into a single-term filter it produces two top-level filters, which the JNDI, ldapts, ldap3 and .NET clients all reject in their own parsers before contacting the server - so an unescaped run raises rather than leaking, and the test cannot distinguish a working fix from a broken filter. Use it against the fixed code, where it should arrive as one literal assertion value - Log all LDAP queries and failed authentication attempts
- Alert on anomalous query patterns
- Re-test with the input from the original finding and confirm it no longer changes the query
- Confirm legitimate lookups still return the expected entries
- Re-scan with the security scanner to confirm the issue is resolved
Common Vulnerable Patterns
- Directly embedding untrusted data in LDAP filters
- Accepting arbitrary input for search or bind operations
Unescaped User Input in LDAP Filter (Pseudocode)
Why this is vulnerable: The filter is parsed as an expression tree, so a value containing ) and ( does not become part of the term it was interpolated into - it ends that term and starts another. A * is enough on its own to convert an equality test into a wildcard that matches every entry.
The sketch is language-neutral because every ecosystem has the same two escaping problems and they are not interchangeable: filter values follow RFC 4515 and DN components follow RFC 4514. Language pages name the encoder for each; the point that survives translation is that concatenating into either is the flaw, and a single "escape LDAP" helper is usually a sign that only one of them was considered.
Secure Patterns
LDAP Filter Escaping (Pseudocode)
# Safe: use parameterized query or escape input
safe_input = escape_filter_chars(user_input) # Escapes *, (, ), \, NUL
search_filter = f"(uid={safe_input})"
Why this works:
- The special characters (
*,(,),\, NUL) reach the server as literal data, not filter syntax - In a compound filter, the value can no longer close its own term and add another, such as
(uid=*)(|(password=*)) - A bare
*no longer becomes a wildcard that enumerates every directory entry - Attackers can't modify query logic to access unauthorized user accounts or data
Common Pitfalls
- Reusing filter-escaping for DN values (or vice versa): Escaping a value with LDAP filter-escaping rules and then using that same value inside a Distinguished Name, or the reverse - filter escaping and DN escaping cover different special-character sets, so a value escaped for one context still carries unescaped metacharacters for the other.
- Blocklisting only the "obvious" characters: Stripping or rejecting
*and(but leaving),\, or NUL untouched - a partial blocklist still leaves enough filter syntax available to close one clause and open another. - Escaping input but still building the DN from it: Properly escaping a DN component and then concatenating it into a DN path used for bind or lookup, instead of searching by attribute and using the DN the directory returns - escaping reduces risk, but search-then-bind removes the injection point entirely.
- Treating escaping as a substitute for least privilege: Relying entirely on escaping being correct everywhere while leaving the LDAP bind account with broad read/write access - any gap in escaping (a missed field, a later refactor) is then immediately exploitable at full account privilege.
Language-Specific Guidance
- C# - DirectorySearcher with allowlist validation and RFC 4515/4514 escaping
- Java - javax.naming parameterized filter search, LdapName for DNs
- JavaScript/Node.js - ldapts with filter and DN escaping
- Python - ldap3 escape_filter_chars and escape_rdn