VNX-JAVA-012 – Java LDAP Injection

Overview

This rule detects calls to DirContext.search(), ctx.search(), or dirContext.search() where the LDAP filter argument includes a value read directly from request.getParameter() or req.getParameter(), as well as patterns where LDAP filter syntax characters are concatenated around a parameter value. Unsanitized user input in an LDAP filter enables LDAP injection (CWE-90), allowing attackers to alter the filter logic, bypass authentication, and exfiltrate directory entries.

Severity: High | CWE: CWE-90 – Improper Neutralization of Special Elements used in an LDAP Query (‘LDAP Injection’)

Why This Matters

LDAP is commonly used for authentication and authorisation in enterprise applications — particularly those backed by Active Directory or OpenLDAP. LDAP injection attacks against authentication filters are analogous to SQL injection against login queries: by manipulating the filter logic the attacker can authenticate as any user, including administrators.

A filter like (&(uid= + username + )(userPassword= + password + )) is vulnerable. Supplying a username of admin)(&( causes the filter to become (&(uid=admin)(&()(userPassword=anything)) — the wildcard sub-filter always evaluates to true, granting access as admin regardless of the password. More sophisticated payloads can enumerate all users and groups by exploiting the filter’s boolean logic, disclosing the entire directory tree structure including email addresses, phone numbers, manager hierarchies, and group memberships.

JNDI lookups (particularly in Log4j-style contexts) can also trigger network requests to attacker-controlled LDAP servers, leading to JNDI/RCE as demonstrated by Log4Shell — though that attack vector is distinct from the filter injection detected by this rule.

What Gets Flagged

The rule reports the search call when its filter or base DN carries request data (a servlet request value, an annotated handler parameter, or a local built from one earlier in the same method). For UnboundID, a SearchRequest built from request data is reported where it is constructed; the conn.search(sr) that merely runs a prebuilt request is not reported a second time. Filters with {0} placeholders, values passed through LdapEncoder.filterEncode or Filter.encodeValue, and EqualsFilter/AndFilter objects are not reported.

// FLAGGED: user input directly in search() filter
String username = request.getParameter("username");
NamingEnumeration results = ctx.search("ou=users,dc=example,dc=com",
    "(uid=" + username + ")", searchControls);

// FLAGGED: filter construction with concatenation
String filter = "(&(objectClass=person)(cn=" + request.getParameter("name") + "))";
dirContext.search("dc=example,dc=com", filter, controls);

// FLAGGED: direct parameter as filter argument
ctx.search(baseDn, request.getParameter("filter"), searchControls);

Following the value through the method

The look-back stops at the enclosing method’s declaration (at most 120 lines up), and the method is read before the call is reported:

  • a definition on a branch that never runs is dropped: an if/else or ternary whose condition folds to a constant (integer literals, and int locals assigned once from a literal), or a case that a switch on a constant does not select;
  • a local that provably holds a constant at the call stops carrying the request value: a literal on every path, the element a list index or map key actually holds, or the result of a method in the same file (including a nested class) whose every return is a constant;
  • a request value stored in a list, map or builder and read back is followed, so list.add(request.getParameter("x")) followed by list.get(0) is request data.
// NOT FLAGGED: 2 * 3 >= 6, so cn is always "administrators"
int tier = 3;
if (2 * tier >= 6) cn = "administrators";
else cn = request.getParameter("group");
ctx.search(BASE, "(cn=" + cn + ")", controls);

// FLAGGED: the map key read back holds the header value
attrs.put("lookup", "uid");
attrs.put("user", request.getHeader("X-User"));
ctx.search(BASE, "(uid=" + attrs.get("user") + ")", controls);

Anything the analysis cannot resolve stays reported: a condition on request data, a call into another class that is handed a run-time value (a call with constant arguments is read as inert, below), a collection filled in a loop or handed to another method, a helper that returns (a transform of) its parameter, which stays as tainted as the argument it is handed (see Helpers in other files).

Helpers in other files

A call into another class of the project is read from that class’s own source instead of being guessed at. For obj.m(args), Cls.m(args) and new Cls(args).m(args) the class is found by the declared type of obj (a local, a parameter, or the new Cls(...) it was assigned from) or by the static class name, and the method (at most 60 lines, overloads told apart by arity) is summarised as one of:

  • constant: every return is a literal, an UPPER_CASE name, a field initialised to a literal and never assigned again, or the result of a constant method of the same class. The call is as constant as that literal, whatever it is handed.
  • a function of its arguments: every return is constant once the parameters, and the fields the class’s one constructor stores from its own parameters, are taken as constants. The call is only as risky as the arguments it depends on: a getter of a value stored by new Cls(arg) is as tainted as arg, and a method that ignores one of its parameters is not tainted by it.
  • opaque: anything else, such as a method that reads a request value or a header, a field a setter can change, or a call the summary cannot see through.

A value of an interface or base class type runs the method of every project class that extends or implements it, so one implementation that returns its parameter keeps the call as tainted as that parameter. What is still reported: opaque calls, a simple class name that two files declare (ambiguous), a class outside the project, a type with an anonymous subclass, and a callee that hands the value to a third class (a callee is read one level deep). A method of the same file called without a receiver is summarised the same way.

// NOT FLAGGED: Sanitizer.clean ignores its argument and returns a literal
String value = sanitizer.clean(request.getParameter("value"));
ctx.search(BASE, "(uid=" + value + ")", controls);

// NOT FLAGGED: the constructor argument is a literal, so the getter is too
Holder holder = new Holder("report");
ctx.search(BASE, "(uid=" + holder.get() + ")", controls);

// FLAGGED: Echo.same returns its argument
String value = echo.same(request.getParameter("value"));
ctx.search(BASE, "(uid=" + value + ")", controls);

// FLAGGED: the getter returns what the constructor was given
Holder holder = new Holder(request.getParameter("value"));
ctx.search(BASE, "(uid=" + holder.get() + ")", controls);

Calls that cannot bring data in

A call into code the scan cannot see (another class or module) is treated as inert, and its value as a constant, when all of these hold:

  • its name is not that of a source (read*, next*, getParameter*, getHeader*, getCookie*, getenv, getProperty, query, execute);
  • every argument is a constant: a literal, or a name the method fixes;
  • its receiver is a type name, a new expression with constant arguments, or a local built from such a call, and that local is only ever called with constant arguments.
// NOT REPORTED: the helper is given literals only
Roles roles = RolesFactory.create();
String role = roles.lookupName("staff");
ctx.search(BASE, "(employeeType=" + role + ")", controls);

// REPORTED: a run-time argument
String role = roles.lookupName(param);

// REPORTED: the receiver was built from, or fed, a run-time value
Roles roles = new Roles(request.getParameter("realm"));
String role = roles.lookupName("staff");

// REPORTED: a source-like method name
String role = Prompts.readLine("role");

Anything that fails one of the tests stays reported, so a helper that hands back its argument is never mistaken for a constant.

Remediation

  1. Encode all user-supplied values using LDAP filter encoding before inserting them into a filter. LDAP special characters that must be escaped in filter values are: *, (, ), \, NUL. Encode each as a backslash-hex sequence. The Spring LDAP library provides org.springframework.ldap.filter classes that handle encoding automatically.

    // SAFE: Spring LDAP filter API with automatic encoding
    import org.springframework.ldap.filter.AndFilter;
    import org.springframework.ldap.filter.EqualsFilter;
    
    AndFilter filter = new AndFilter();
    filter.and(new EqualsFilter("objectClass", "person"));
    filter.and(new EqualsFilter("uid", request.getParameter("username")));
    // filter.encode() produces a safe, encoded filter string
    String encodedFilter = filter.encode();
    NamingEnumeration results = ctx.search("ou=users,dc=example,dc=com",
        encodedFilter, searchControls);
    
  2. If you use raw filter strings, manually escape the value using LdapEncoder.filterEncode(). Spring LDAP exposes this utility if the full filter builder API is not convenient:

    // SAFE: manual encoding of the user-supplied value
    import org.springframework.ldap.core.support.LdapEncoder;
    
    String safeUid = LdapEncoder.filterEncode(request.getParameter("username"));
    String filter = "(&(objectClass=person)(uid=" + safeUid + "))";
    ctx.search(baseDn, filter, searchControls);
    
  3. Use Spring LDAP’s LdapTemplate for all LDAP operations. LdapTemplate provides a higher-level, Spring-idiomatic API that encourages the use of filter builder objects and handles connection lifecycle. Pair it with org.springframework.ldap.query.LdapQueryBuilder for a fully safe query API:

    // SAFE: LdapQueryBuilder — type-safe and injection-proof
    import static org.springframework.ldap.query.LdapQueryBuilder.query;
    
    List<String> users = ldapTemplate.search(
        query()
            .base("ou=users,dc=example,dc=com")
            .where("objectClass").is("person")
            .and("uid").is(request.getParameter("username")),
        (Attributes attrs) -> (String) attrs.get("cn").get()
    );
    
  4. Validate input with an allowlist before any LDAP operation. UIDs and CNs in most directories contain only alphanumeric characters and a small set of punctuation. Reject any input containing LDAP metacharacters (*, (, ), \, NUL) before the value reaches the LDAP layer at all.

    // SAFE: allowlist validation on username format
    String username = request.getParameter("username");
    if (username == null || !username.matches("[a-zA-Z0-9._@-]{1,64}")) {
        throw new IllegalArgumentException("Invalid username format");
    }
    
  5. Restrict the LDAP service account’s permissions. The account used by the application to bind to LDAP should be a read-only service account with access limited to the specific OUs and attributes the application requires. Never use the directory administrator account.

  6. Encode LDAP output before displaying it. If attribute values read from LDAP are rendered in HTML, encode them for the output context (HTML-encode for web pages) to prevent stored XSS through directory data.

References