Skip to content

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 or encodeForLDAP() 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 answers InvalidSearchFilterException: Unbalanced parenthesis without 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=\2a and matches nothing
  • Admins,DC=evil,DC=com as an OU component - confirm the allowlist refuses it. Then drop the allowlist and check the layer underneath still holds: Rdn renders it as ou=Admins\,DC\=evil\,DC\=com, one RDN rather than three, so the lookup fails with NameNotFoundException instead 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 an Rdn, which passes control characters through
  • A legitimate username and department - confirm normal lookups still succeed after adding validation
  • If using Spring LDAP, confirm LdapQueryBuilder is used everywhere rather than manually built filter strings

Common Pitfalls

  • Building filterExpr with + concatenation instead of {i} placeholders and a filterArgs array - the parameterized search() overload escapes only the values passed through the array, so a concatenated string gets no protection even when the signature has a filterArgs parameter.
  • Calling encoder.encodeForLDAP() on a value destined for a DN, or encodeForDN() 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() and nameEncode().
  • Building a DN by concatenating the output of a DN encoder rather than adding an Rdn to an LdapName - 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 LdapName and 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, bind and search all have a Name overload; add the last component with dn.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 - LdapQueryBuilder only 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() or escapeXml11(), instead of ESAPI's encodeForLDAP() - 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.

Additional Resources