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/elseor ternary whose condition folds to a constant (integer literals, andintlocals assigned once from a literal), or acasethat aswitchon 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
returnis a constant; - a request value stored in a list, map or builder and read back is followed, so
list.add(request.getParameter("x"))followed bylist.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_CASEname, 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 asarg, 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
newexpression 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
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 providesorg.springframework.ldap.filterclasses 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);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);Use Spring LDAP’s
LdapTemplatefor all LDAP operations.LdapTemplateprovides a higher-level, Spring-idiomatic API that encourages the use of filter builder objects and handles connection lifecycle. Pair it withorg.springframework.ldap.query.LdapQueryBuilderfor 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() );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"); }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.
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
- CWE-90: Improper Neutralization of Special Elements used in an LDAP Query
- CAPEC-136: LDAP Injection
- MITRE ATT&CK T1190 – Exploit Public-Facing Application
- OWASP LDAP Injection Prevention Cheat Sheet
- Spring LDAP – Filter Classes
- Spring LDAP – LdapQueryBuilder
- OWASP ASVS V5 – Validation, Sanitization and Encoding