CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection') - Java
Overview
LDAP injection in Java applications happens through javax.naming/javax.naming.directory (DirContext.search()) or Spring LDAP when filter strings or Distinguished Names (DNs) are built by string concatenation instead of using an encoder or the framework's query builder.
Primary Defence: Both halves have a JDK-native answer that needs no dependency. For filter values, use the parameterized DirContext.search(name, filterExpr, Object[] filterArgs, cons) overload, which escapes each argument as it substitutes it. For DN construction, build the name with javax.naming.ldap.LdapName and Rdn rather than concatenating strings, and prefer searching by attribute and using the DN the directory returns over building one at all. Spring applications have LdapQueryBuilder for filters and LdapEncoder for the values a builder cannot reach; OWASP ESAPI is a fallback for codebases already carrying it. Validate input against an allowlist first as defense in depth - never as a substitute for encoding.
Common Vulnerable Patterns
Direct String Concatenation in LDAP Queries
// VULNERABLE - no encoding
public User findUser(String username) throws NamingException {
String filter = "(uid=" + username + ")"; // Dangerous!
DirContext ctx = new InitialDirContext();
SearchControls controls = new SearchControls();
NamingEnumeration<SearchResult> results =
ctx.search("ou=users,dc=example,dc=com", filter, controls);
return processResults(results);
}
// Attack: username = "*"
// Resulting filter: (uid=*) - a presence test, so the search returns every entry in the subtree
Why this is vulnerable: An LDAP filter is a parenthesised expression tree, not a flat string, so the metacharacters an attacker needs are structural: ) closes the current term and (| opens an OR that is trivially satisfied. * alone turns an equality test into a wildcard, which is why (uid=*) matches every entry and authentication checks written as filters can be answered without a password.
The payload most write-ups quote for this shape, *)(uid=*))(|(uid=*, is worth knowing about but does not fire here: interpolated into (uid= … ) it yields (uid=*)(uid=*))(|(uid=*), which is two top-level filters rather than one, and the JDK parses the filter string itself before sending anything - InvalidSearchFilterException: Unbalanced parenthesis on JDK 26. It belongs to a compound filter on a server that parses the first filter and ignores the rest. Against a single-term filter the working payload is the bare *, and that is the one to reach for when confirming the finding.
Filters and distinguished names need different escaping and this is where a partial fix usually goes wrong. RFC 4515 governs filter values - escape *, (, ), \ and NUL as \XX hex - while RFC 4514 governs DN components, where the significant characters are ,, +, ", \, <, >, ;, =, NUL, a leading #, and a leading or trailing space. An encoder written for one leaves the other injectable.
Building DN Paths Without Encoding
// VULNERABLE
public void updateUser(String username, String ou) throws NamingException {
String dn = "cn=" + username + ",ou=" + ou + ",dc=example,dc=com"; // Dangerous!
DirContext ctx = new InitialDirContext();
ctx.lookup(dn); // vulnerable to DN injection
}
Why these are vulnerable: DirContext.search() and lookup() perform no escaping of the strings you pass. Special characters in a filter (*, (, ), \) change its logic; special characters in a DN (,, +, ", \, <, >, ;, =) change which object is addressed.
Secure Patterns
JDK Parameterized Filter Search (Primary)
// SECURE - JDK-native parameterized filter, no extra dependency
public User findUser(String username) throws NamingException {
String filterExpr = "(uid={0})";
Object[] filterArgs = { username };
DirContext ctx = new InitialDirContext();
SearchControls controls = new SearchControls();
controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
NamingEnumeration<SearchResult> results =
ctx.search("ou=users,dc=example,dc=com", filterExpr, filterArgs, controls);
return processResults(results);
}
Why this works: The search(name, filterExpr, Object[] filterArgs, cons) overload substitutes each filterArgs[i] for the {i} placeholder in filterExpr, escaping RFC 2254 filter special characters (*, (, ), \, NUL) in the substituted value before it becomes part of the filter - the same structural separation of query and data that PreparedStatement gives you for SQL, shipped in the JDK with no ESAPI dependency required. It only covers filter values: lookup(), bind(), and createSubcontext() take a DN and have no parameterized-args equivalent, so DN components need the pattern below.
JDK-Native DN Construction with LdapName and Rdn
// SECURE - the JDK builds and escapes the DN; no string concatenation, no dependency
import javax.naming.*;
import javax.naming.directory.*;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import java.util.regex.Pattern;
public class SecureLdapDirectory {
// Every value that becomes an RDN needs one of these - Rdn escapes DN syntax but
// passes control characters through, so nothing else stops a NUL or a newline
private static final Pattern USERNAME_PATTERN = Pattern.compile("[a-zA-Z0-9._-]{1,64}");
private static final Pattern OU_PATTERN = Pattern.compile("[a-zA-Z0-9 ._-]{1,64}");
private static final LdapName BASE_DN;
static {
try {
BASE_DN = new LdapName("dc=example,dc=com"); // fixed configuration, not input
} catch (InvalidNameException e) {
throw new ExceptionInInitializerError(e);
}
}
public Attributes readUser(DirContext ctx, String username, String ou) throws NamingException {
if (!USERNAME_PATTERN.matcher(username).matches()) {
throw new IllegalArgumentException("Invalid username");
}
if (!OU_PATTERN.matcher(ou).matches()) {
throw new IllegalArgumentException("Invalid OU name");
}
LdapName dn = (LdapName) BASE_DN.clone(); // clone: LdapName is mutable
dn.add(new Rdn("ou", ou));
dn.add(new Rdn("uid", username));
return ctx.getAttributes(dn); // the Name overload - the DN is never re-parsed
}
}
Why this works: Rdn holds the attribute type and the value as separate fields and only renders the escaped form when the name is turned back into a string, so a , or = in the value can never become DN structure. Measured against a directory on JDK 26, readUser(ctx, "svc-backup,ou=service-accounts", "users") builds uid=svc-backup\,ou\=service-accounts,ou=users,dc=example,dc=com and comes back NameNotFoundException - the injected comma stays inside the uid value instead of adding an RDN. Passing the LdapName itself to the getAttributes(Name) / lookup(Name) overloads keeps the name structural from end to end - dn.toString() does render correctly and reparses cleanly, but every String overload is also the door back to concatenation, which is the defect being fixed. Rdn does not escape control characters - measured on JDK 26, new Rdn("uid", "a\0b") renders the NUL unchanged - so every value that becomes an RDN needs an allowlist of its own, not just the one that looked riskier. Pattern.matches() is the right check here: unlike a $-anchored find(), and unlike Python's re.match() or .NET's IsMatch against ^...$, it will not accept a trailing newline. Prefer searching by attribute and using the DN the directory returns wherever the flow allows it; build a name only when the base DN is fixed configuration and one validated component comes from input.
ESAPI Filter Encoding (Fallback)
// SECURE - validate, then encode with ESAPI, where the parameterized overload cannot be used
import org.owasp.esapi.ESAPI;
import org.owasp.esapi.Encoder;
import javax.naming.*;
import javax.naming.directory.*;
import java.util.regex.Pattern;
public class SecureLdapService {
private final Encoder encoder = ESAPI.encoder(); // needs ESAPI.properties on the classpath
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9._-]+$");
public User findUser(String username) throws NamingException {
// Step 1: allowlist validation - rejects most injection attempts outright
if (!USERNAME_PATTERN.matcher(username).matches()) {
throw new IllegalArgumentException("Invalid username format");
}
// Step 2: encode the (already-validated) value - defense in depth
String safeUsername = encoder.encodeForLDAP(username);
String filter = "(uid=" + safeUsername + ")";
DirContext ctx = new InitialDirContext();
SearchControls controls = new SearchControls();
controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
return processResults(ctx.search("ou=users,dc=example,dc=com", filter, controls));
}
}
Why this works: encodeForLDAP() escapes RFC 4515 filter special characters (*, (, ), \, NUL) to their backslash-hex form, turning an attacker-supplied * into the literal \2a rather than a wildcard. Allowlist validation catches malformed input before it reaches LDAP at all; encoding ensures anything that passes validation still can't carry filter syntax.
Two things to know before reaching for ESAPI. It will not start without configuration: ESAPI.encoder() throws ConfigurationException unless ESAPI.properties and validation.properties are on the classpath, and the jar does not ship them - they come from the ESAPI distribution. And its companion encodeForDN() is a weaker choice than LdapName/Rdn above: measured on ESAPI 2.7.0.0, it leaves = unescaped, which RFC 4514 does not permit inside a value, and escapes / as \/, which RFC 4514 does not define as an escape at all. JNDI's own parser is lenient enough to round-trip both, so this is a portability problem rather than an injection one - but it is a reason to prefer the JDK builder, which produces \= and leaves / alone. On a Spring project, org.springframework.ldap.support.LdapEncoder.filterEncode() does the same job as encodeForLDAP() with no configuration to install.
Spring LDAP Query Builder (Automatic Encoding)
// SECURE - Spring LDAP encodes filter values automatically
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.query.LdapQueryBuilder;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SpringLdapService {
private final LdapTemplate ldapTemplate;
public SpringLdapService(LdapTemplate ldapTemplate) {
this.ldapTemplate = ldapTemplate;
}
public User findUser(String username) {
return ldapTemplate.findOne(
LdapQueryBuilder.query().where("uid").is(username), // automatically encoded
User.class
);
}
public List<User> searchUsers(String firstName, String lastName, String department) {
return ldapTemplate.find(
LdapQueryBuilder.query()
.where("givenName").is(firstName)
.and("sn").is(lastName)
.and("department").is(department),
User.class
);
}
}
Why this works: LdapQueryBuilder's .where(...).is(...) methods encode each value internally before building the filter string, so you never handle raw filter syntax yourself. This removes the class of bug where a developer forgets to call an encoder before concatenating a value. It only protects you when you use the builder methods - if you fall back to ldapTemplate.search(baseDn, filterString, mapper) with a manually built filter string, you're responsible for encoding it yourself, with LdapEncoder.filterEncode() from the same library.
Testing
*as the username - the payload that works against a single-term filter; unescaped it turns(uid=alice)into(uid=*)and returns every entry in the subtree, and through the parameterized overload orencodeForLDAP()it returns none*)(uid=*))(|(uid=*- the payload most write-ups quote, worth running only against the fixed code. Concatenated into(uid=…)it yields two top-level filters, and JDK 26 answersInvalidSearchFilterException: Unbalanced parenthesiswithout contacting the server - so the unescaped run throws instead of leaking, and the test cannot tell a working fix from a broken filter. Escaped, it reaches the directory as the single assertion value\2a\29\28uid=\2a\29\29\28|\28uid=\2aand matches nothingAdmins,DC=evil,DC=comas an OU component - confirm the allowlist refuses it. Then drop the allowlist and check the layer underneath still holds:Rdnrenders it asou=Admins\,DC\=evil\,DC\=com, one RDN rather than three, so the lookup fails withNameNotFoundExceptioninstead of resolving into another subtree. Both assertions matter - an allowlist that happens to be the only thing working is one refactor from being the only thing missing- Null byte (
\00) - confirm the filter encoder emits\00, and that validation rejects it before it reaches anRdn, which passes control characters through - A legitimate username and department - confirm normal lookups still succeed after adding validation
- If using Spring LDAP, confirm
LdapQueryBuilderis used everywhere rather than manually built filter strings
Common Pitfalls
- Building
filterExprwith+concatenation instead of{i}placeholders and afilterArgsarray - the parameterizedsearch()overload escapes only the values passed through the array, so a concatenated string gets no protection even when the signature has afilterArgsparameter. - Calling
encoder.encodeForLDAP()on a value destined for a DN, orencodeForDN()on a value destined for a filter - the two methods implement different RFCs (4515 vs 4514) and are not interchangeable. The same applies to Spring's pair,LdapEncoder.filterEncode()andnameEncode(). - Building a DN by concatenating the output of a DN encoder rather than adding an
Rdnto anLdapName- the encoder can only see one value, so it cannot tell you the caller has passed something that belongs in a different position, and the concatenation is still yours to get right. - Building an
LdapNameand then going back to a string to attach one more component -lookup(dn + ",ou=" + ou)throws away everything the builder did for the value that needed it most.lookup,getAttributes,bindandsearchall have aNameoverload; add the last component withdn.add(new Rdn(...))and pass the name. - Falling back to
ldapTemplate.search(baseDn, filterString, mapper)with a manually concatenated filter string and forgetting to encode it -LdapQueryBuilderonly protects the call sites that actually use its.where(...).is(...)methods. - Reaching for a general-purpose escaper already on the classpath, such as Apache Commons
StringEscapeUtils.escapeHtml4()orescapeXml11(), instead of ESAPI'sencodeForLDAP()- HTML/XML escaping doesn't touch LDAP's special characters (*,(,),\), so the filter injection is unaffected.
Dependencies and Installation
The parameterized search() overload and LdapName/Rdn are both part of javax.naming and need no extra dependency, which covers the primary fix for filters and for DNs. A library is only needed for filter code that cannot move to the parameterized overload.
<!-- Spring applications - LdapQueryBuilder and LdapEncoder, no configuration files needed -->
<dependency>
<groupId>org.springframework.ldap</groupId>
<artifactId>spring-ldap-core</artifactId>
<version>4.1.1</version>
</dependency>
<!-- ESAPI - only where it is already in use; see the configuration note below -->
<dependency>
<groupId>org.owasp.esapi</groupId>
<artifactId>esapi</artifactId>
<version>2.7.0.0</version>
</dependency>
The ESAPI artifact contains no configuration, and ESAPI.encoder() throws ConfigurationException until it finds one. Take ESAPI.properties and validation.properties from the ESAPI distribution and put them on the classpath (src/main/resources), in .esapi/ under the working directory, or in a directory named by the org.owasp.esapi.resources system property. A build that resolves the dependency and stops there fails on the first call, not at startup.