Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<!DOCTYPE qhelp PUBLIC
"-//Semmle//qhelp//EN"
"qhelp.dtd">
<qhelp>

<overview>
<p>
An LDAP distinguished name (DN) identifies an entry in a directory, for example
<code>uid=alice,ou=people,dc=example,dc=com</code>. When an authentication framework
builds the bind DN by concatenating the login principal into a DN template without
escaping it for RFC 2253, an attacker can supply DN metacharacters
(<code>, + " \ &lt; &gt; ; =</code>, a leading <code>#</code>, or leading/trailing
whitespace) to change the structure of the DN that is used to authenticate. Depending
on the directory, this can bypass authentication or impersonate another principal.
</p>
<p>
This query targets the defect inside an authentication <em>library or framework</em>
(Apache Shiro, a custom Spring Security realm, a CAS or pac4j SPI, a Keycloak provider),
where the login principal does not arrive at a remote flow source such as a servlet
parameter, but as a method parameter at the library boundary. The supported
<code>java/ldap-injection</code> query, which starts from remote flow sources, does not
report on such a framework because there is no remote flow source to start from.
</p>
<p>
The DN escape set (RFC 2253) differs from the LDAP search-filter escape set (RFC 4515).
A value escaped for a search filter (for example with <code>LdapEncoder.filterEncode</code>)
is still unsafe in a DN, and vice versa. This query treats only DN escapers as
sanitizers.
</p>
<p>
The library-mode source model is name-heuristic: it treats the login-principal
accessors of common authentication frameworks, and the string parameters of
DN-builder-shaped methods (for example <code>getUserDn</code> or
<code>getUsernameWithSuffix</code>), as sources. This is a deliberate
precision/recall trade for the library case, where there is no remote flow source to
anchor on. A framework that builds the DN in a differently named helper is missed, and
a benign method that matches the name pattern may produce a false positive; this is why
the query is experimental and uses medium precision. Triage a result by confirming the
value reaches a real bind sink unescaped.
</p>
</overview>

<recommendation>
<p>
Escape the login principal for RFC 2253 before placing it in a DN, for example with
<code>javax.naming.ldap.Rdn.escapeValue</code>, Spring LDAP
<code>LdapEncoder.nameEncode</code>, or OWASP ESAPI <code>encodeForDN</code>. Prefer
building the DN from structured components (an <code>LdapName</code> and
<code>Rdn</code> objects) rather than string concatenation.
</p>
</recommendation>

<example>
<p>
The following example concatenates the login principal into the bind DN with no
escaping. An attacker who logs in as <code>*</code> or
<code>admin,ou=admins,dc=example,dc=com&#x2B;uid=anything</code> can manipulate the DN.
</p>
<sample src="LdapDnInjectionLibraryModeBad.java" />
<p>
The following example escapes the principal with <code>Rdn.escapeValue</code> before
building the DN, so DN metacharacters are neutralised.
</p>
<sample src="LdapDnInjectionLibraryModeGood.java" />
</example>

<references>
<li>
OWASP: <a href="https://owasp.org/www-community/attacks/LDAP_Injection">LDAP Injection</a>.
</li>
<li>
RFC 2253: <a href="https://datatracker.ietf.org/doc/html/rfc2253">UTF-8 String Representation of Distinguished Names</a>.
</li>
<li>
Java SE API: <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.naming/javax/naming/ldap/Rdn.html#escapeValue(java.lang.Object)">Rdn.escapeValue</a>.
</li>
</references>

</qhelp>
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* @name LDAP distinguished name injection in authentication framework code (library-mode sources)
* @description Building an LDAP bind distinguished name (DN) from an unescaped login
* principal lets an attacker manipulate the DN structure used to
* authenticate, potentially bypassing authentication or impersonating
* another principal. This variant uses library-boundary sources to find
* the defect inside an authentication framework, where the principal
* arrives as a method parameter rather than at a remote flow source.
* @kind path-problem
* @problem.severity error
* @security-severity 8.1
* @precision medium
* @id java/ldap-dn-injection-library-mode
* @tags security
* experimental
* external/cwe/cwe-090
*/

import java
import semmle.code.java.dataflow.TaintTracking
import LdapDnLibraryModeFlow::PathGraph

/**
* The `String name` argument of `javax.naming.Context` / `DirContext`
* `bind` / `rebind` / `lookup` / `lookupLink` / `createSubcontext` -- interpreted as
* a (composite or distinguished) name when given a `String`.
*
* `new javax.naming.ldap.LdapName(String)` is deliberately not used as a sink: it
* commonly parses an existing certificate or principal DN to read its RDNs (e.g.
* `new LdapName(cert.getSubjectX500Principal().getName()).getRdns()`), which is not
* injection. The injection sinks are the positions where a DN string is used to bind,
* look up, or authenticate.
*/
class JndiNameLookupMethod extends Method {
JndiNameLookupMethod() {
this.hasName(["bind", "rebind", "lookup", "lookupLink", "createSubcontext"]) and
this.getDeclaringType()
.getAnAncestor*()
.hasQualifiedName("javax.naming", ["Context", "directory.DirContext"]) and
this.getParameterType(0) instanceof TypeString
}
}

/**
* A call to `Map.put` / `Hashtable.put` whose key is the
* `javax.naming.Context.SECURITY_PRINCIPAL` constant or the literal string
* `"java.naming.security.principal"`. The value argument is the bind DN.
*/
class SecurityPrincipalPut extends MethodCall {
SecurityPrincipalPut() {
this.getMethod().hasName("put") and
(
this.getArgument(0).(FieldRead).getField().hasName("SECURITY_PRINCIPAL")
or
this.getArgument(0).(CompileTimeConstantExpr).getStringValue() =
"java.naming.security.principal"
)
}
}

/**
* The `principal` argument of Apache Shiro
* `LdapContextFactory.getLdapContext(principal, credentials)` -- the bind DN. This is
* the sink in Apache Shiro CVE-2026-49268.
*/
class ShiroGetLdapContextMethod extends Method {
ShiroGetLdapContextMethod() {
this.hasName("getLdapContext") and
this.getDeclaringType()
.getAnAncestor*()
.hasQualifiedName("org.apache.shiro.realm.ldap", "LdapContextFactory")
}
}

/**
* A login-principal accessor: Apache Shiro `AuthenticationToken.getPrincipal` /
* `getUsername`, Spring Security `Authentication.getName` / `getPrincipal`, or
* `java.security.Principal.getName`. These return the untrusted login identity inside
* an authentication framework.
*/
class AuthPrincipalAccessor extends MethodCall {
AuthPrincipalAccessor() {
exists(Method m | m = this.getMethod() |
m.hasName(["getPrincipal", "getUsername"]) and
m.getDeclaringType()
.getAnAncestor*()
.hasQualifiedName("org.apache.shiro.authc",
["AuthenticationToken", "UsernamePasswordToken", "HostAuthenticationToken"])
or
m.hasName(["getName", "getPrincipal"]) and
m.getDeclaringType()
.getAnAncestor*()
.hasQualifiedName("org.springframework.security.core", "Authentication")
or
m.hasName("getName") and
m.getDeclaringType().getAnAncestor*().hasQualifiedName("java.security", "Principal")
)
}
}

/**
* A `String` parameter of a DN-builder-shaped method, e.g. `getUserDn`,
* `getUsernameWithSuffix`, `buildDn`, `resolveDn`. An authentication framework
* receives the untrusted principal here and concatenates it into the bind DN.
*
* This source model is name-heuristic: it keys partly off method names. It is a
* deliberate precision/recall trade for the library case, where there is no remote
* flow source to anchor on. A framework that builds the DN in a differently named
* helper is missed; a benign method that matches the name pattern may produce a false
* positive. Triage a result by confirming the value reaches a real bind sink
* unescaped.
*/
class DnBuilderParam extends Parameter {
DnBuilderParam() {
this.getType() instanceof TypeString and
exists(string name | name = this.getCallable().getName().toLowerCase() |
name.matches([
"get%userdn", "%userdn", "build%dn", "make%dn", "resolve%dn", "create%dn", "to%dn",
"compute%dn"
])
or
name.matches("%usernamewithsuffix%")
or
name.matches(["get%principal", "build%principal"])
)
}
}

/** A call to a recognised RFC 2253 DN escaper, e.g. `javax.naming.ldap.Rdn.escapeValue`. */
class DnEscaperCall extends MethodCall {
DnEscaperCall() {
exists(Method m | m = this.getMethod() |
m.hasName("escapeValue") and
m.getDeclaringType().hasQualifiedName("javax.naming.ldap", "Rdn")
or
m.hasName("nameEncode") and
m.getDeclaringType().hasQualifiedName("org.springframework.ldap.support", "LdapEncoder")
or
m.hasName("encodeForDN")
or
m.getName()
.toLowerCase()
.matches(["%escapedn%", "%escapeldapdn%", "%encodefordn%", "%escapedistinguished%"])
)
}
}

/**
* A taint-tracking configuration for an unescaped login principal flowing into an
* LDAP bind DN inside an authentication framework.
*/
module LdapDnLibraryModeConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
source.asExpr() instanceof AuthPrincipalAccessor
or
source.asParameter() instanceof DnBuilderParam
}

predicate isSink(DataFlow::Node sink) {
exists(MethodCall ma | ma.getMethod() instanceof JndiNameLookupMethod |
sink.asExpr() = ma.getArgument(0)
)
or
sink.asExpr() = any(SecurityPrincipalPut p).getArgument(1)
or
exists(MethodCall ma | ma.getMethod() instanceof ShiroGetLdapContextMethod |
sink.asExpr() = ma.getArgument(0)
)
}

predicate isBarrier(DataFlow::Node node) { node.asExpr() instanceof DnEscaperCall }
}

module LdapDnLibraryModeFlow = TaintTracking::Global<LdapDnLibraryModeConfig>;

from LdapDnLibraryModeFlow::PathNode source, LdapDnLibraryModeFlow::PathNode sink
where LdapDnLibraryModeFlow::flowPath(source, sink)
select sink.getNode(), source, sink,
"LDAP bind DN depends on a $@ without RFC 2253 distinguished-name escaping.", source.getNode(),
"library-boundary login principal"
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.realm.ldap.LdapContextFactory;

public class LdapDnInjectionLibraryModeBad {
private final LdapContextFactory ldapContextFactory;

public LdapDnInjectionLibraryModeBad(LdapContextFactory ldapContextFactory) {
this.ldapContextFactory = ldapContextFactory;
}

// BAD: the login principal is concatenated into the bind DN with no escaping, so an
// attacker can supply DN metacharacters to manipulate the DN used to authenticate.
protected String getUserDn(String principal) {
return "uid=" + principal + ",ou=people,dc=example,dc=com";
}

public Object bind(AuthenticationToken token) throws Exception {
String dn = getUserDn((String) token.getPrincipal());
return ldapContextFactory.getLdapContext(dn, token.getCredentials());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import javax.naming.ldap.Rdn;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.realm.ldap.LdapContextFactory;

public class LdapDnInjectionLibraryModeGood {
private final LdapContextFactory ldapContextFactory;

public LdapDnInjectionLibraryModeGood(LdapContextFactory ldapContextFactory) {
this.ldapContextFactory = ldapContextFactory;
}

// GOOD: the login principal is escaped for RFC 2253 with Rdn.escapeValue before it
// is placed in the DN, so DN metacharacters are neutralised.
protected String getUserDn(String principal) {
return "uid=" + Rdn.escapeValue(principal) + ",ou=people,dc=example,dc=com";
}

public Object bind(AuthenticationToken token) throws Exception {
String dn = getUserDn((String) token.getPrincipal());
return ldapContextFactory.getLdapContext(dn, token.getCredentials());
}
}
Loading
Loading