Skip to content

Commit ea48dfe

Browse files
committed
Java: add experimental java/ldap-dn-injection-library-mode query
The supported java/ldap-injection query starts from remote flow sources, so it does not report on authentication frameworks, where the login principal arrives as a method parameter rather than at a servlet parameter or similar. This experimental query detects LDAP distinguished-name injection (CWE-90, RFC 2253) into a bind DN inside such a framework. Sources are library-boundary values: login-principal accessors of common auth frameworks (Apache Shiro AuthenticationToken, Spring Security Authentication, java.security.Principal) and the string parameters of DN-builder-shaped methods. The DN-builder source model is name-heuristic, a deliberate precision/recall trade for the library case where there is no remote flow source to anchor on; the query is therefore experimental and medium precision. Sinks are the bind-DN positions: javax.naming Context / DirContext bind, rebind, lookup, lookupLink, createSubcontext; the java.naming.security.principal environment value; and Apache Shiro LdapContextFactory.getLdapContext. Barriers are RFC 2253 DN escapers such as Rdn.escapeValue. Anchored on Apache Shiro CVE-2026-49268. Adds a qhelp, a true-positive/true-negative test, and the Shiro stubs the test needs.
1 parent e618883 commit ea48dfe

10 files changed

Lines changed: 502 additions & 0 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
6+
<overview>
7+
<p>
8+
An LDAP distinguished name (DN) identifies an entry in a directory, for example
9+
<code>uid=alice,ou=people,dc=example,dc=com</code>. When an authentication framework
10+
builds the bind DN by concatenating the login principal into a DN template without
11+
escaping it for RFC 2253, an attacker can supply DN metacharacters
12+
(<code>, + " \ &lt; &gt; ; =</code>, a leading <code>#</code>, or leading/trailing
13+
whitespace) to change the structure of the DN that is used to authenticate. Depending
14+
on the directory, this can bypass authentication or impersonate another principal.
15+
</p>
16+
<p>
17+
This query targets the defect inside an authentication <em>library or framework</em>
18+
(Apache Shiro, a custom Spring Security realm, a CAS or pac4j SPI, a Keycloak provider),
19+
where the login principal does not arrive at a remote flow source such as a servlet
20+
parameter, but as a method parameter at the library boundary. The supported
21+
<code>java/ldap-injection</code> query, which starts from remote flow sources, does not
22+
report on such a framework because there is no remote flow source to start from.
23+
</p>
24+
<p>
25+
The DN escape set (RFC 2253) differs from the LDAP search-filter escape set (RFC 4515).
26+
A value escaped for a search filter (for example with <code>LdapEncoder.filterEncode</code>)
27+
is still unsafe in a DN, and vice versa. This query treats only DN escapers as
28+
sanitizers.
29+
</p>
30+
<p>
31+
The library-mode source model is name-heuristic: it treats the login-principal
32+
accessors of common authentication frameworks, and the string parameters of
33+
DN-builder-shaped methods (for example <code>getUserDn</code> or
34+
<code>getUsernameWithSuffix</code>), as sources. This is a deliberate
35+
precision/recall trade for the library case, where there is no remote flow source to
36+
anchor on. A framework that builds the DN in a differently named helper is missed, and
37+
a benign method that matches the name pattern may produce a false positive; this is why
38+
the query is experimental and uses medium precision. Triage a result by confirming the
39+
value reaches a real bind sink unescaped.
40+
</p>
41+
</overview>
42+
43+
<recommendation>
44+
<p>
45+
Escape the login principal for RFC 2253 before placing it in a DN, for example with
46+
<code>javax.naming.ldap.Rdn.escapeValue</code>, Spring LDAP
47+
<code>LdapEncoder.nameEncode</code>, or OWASP ESAPI <code>encodeForDN</code>. Prefer
48+
building the DN from structured components (an <code>LdapName</code> and
49+
<code>Rdn</code> objects) rather than string concatenation.
50+
</p>
51+
</recommendation>
52+
53+
<example>
54+
<p>
55+
The following example concatenates the login principal into the bind DN with no
56+
escaping. An attacker who logs in as <code>*</code> or
57+
<code>admin,ou=admins,dc=example,dc=com&#x2B;uid=anything</code> can manipulate the DN.
58+
</p>
59+
<sample src="LdapDnInjectionLibraryModeBad.java" />
60+
<p>
61+
The following example escapes the principal with <code>Rdn.escapeValue</code> before
62+
building the DN, so DN metacharacters are neutralised.
63+
</p>
64+
<sample src="LdapDnInjectionLibraryModeGood.java" />
65+
</example>
66+
67+
<references>
68+
<li>
69+
OWASP: <a href="https://owasp.org/www-community/attacks/LDAP_Injection">LDAP Injection</a>.
70+
</li>
71+
<li>
72+
RFC 2253: <a href="https://datatracker.ietf.org/doc/html/rfc2253">UTF-8 String Representation of Distinguished Names</a>.
73+
</li>
74+
<li>
75+
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>.
76+
</li>
77+
</references>
78+
79+
</qhelp>
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* @name LDAP distinguished name injection in authentication framework code (library-mode sources)
3+
* @description Building an LDAP bind distinguished name (DN) from an unescaped login
4+
* principal lets an attacker manipulate the DN structure used to
5+
* authenticate, potentially bypassing authentication or impersonating
6+
* another principal. This variant uses library-boundary sources to find
7+
* the defect inside an authentication framework, where the principal
8+
* arrives as a method parameter rather than at a remote flow source.
9+
* @kind path-problem
10+
* @problem.severity error
11+
* @security-severity 8.1
12+
* @precision medium
13+
* @id java/ldap-dn-injection-library-mode
14+
* @tags security
15+
* experimental
16+
* external/cwe/cwe-090
17+
*/
18+
19+
import java
20+
import semmle.code.java.dataflow.TaintTracking
21+
import LdapDnLibraryModeFlow::PathGraph
22+
23+
/**
24+
* The `String name` argument of `javax.naming.Context` / `DirContext`
25+
* `bind` / `rebind` / `lookup` / `lookupLink` / `createSubcontext` -- interpreted as
26+
* a (composite or distinguished) name when given a `String`.
27+
*
28+
* `new javax.naming.ldap.LdapName(String)` is deliberately not used as a sink: it
29+
* commonly parses an existing certificate or principal DN to read its RDNs (e.g.
30+
* `new LdapName(cert.getSubjectX500Principal().getName()).getRdns()`), which is not
31+
* injection. The injection sinks are the positions where a DN string is used to bind,
32+
* look up, or authenticate.
33+
*/
34+
class JndiNameLookupMethod extends Method {
35+
JndiNameLookupMethod() {
36+
this.hasName(["bind", "rebind", "lookup", "lookupLink", "createSubcontext"]) and
37+
this.getDeclaringType()
38+
.getAnAncestor*()
39+
.hasQualifiedName("javax.naming", ["Context", "directory.DirContext"]) and
40+
this.getParameterType(0) instanceof TypeString
41+
}
42+
}
43+
44+
/**
45+
* A call to `Map.put` / `Hashtable.put` whose key is the
46+
* `javax.naming.Context.SECURITY_PRINCIPAL` constant or the literal string
47+
* `"java.naming.security.principal"`. The value argument is the bind DN.
48+
*/
49+
class SecurityPrincipalPut extends MethodCall {
50+
SecurityPrincipalPut() {
51+
this.getMethod().hasName("put") and
52+
(
53+
this.getArgument(0).(FieldRead).getField().hasName("SECURITY_PRINCIPAL")
54+
or
55+
this.getArgument(0).(CompileTimeConstantExpr).getStringValue() =
56+
"java.naming.security.principal"
57+
)
58+
}
59+
}
60+
61+
/**
62+
* The `principal` argument of Apache Shiro
63+
* `LdapContextFactory.getLdapContext(principal, credentials)` -- the bind DN. This is
64+
* the sink in Apache Shiro CVE-2026-49268.
65+
*/
66+
class ShiroGetLdapContextMethod extends Method {
67+
ShiroGetLdapContextMethod() {
68+
this.hasName("getLdapContext") and
69+
this.getDeclaringType()
70+
.getAnAncestor*()
71+
.hasQualifiedName("org.apache.shiro.realm.ldap", "LdapContextFactory")
72+
}
73+
}
74+
75+
/**
76+
* A login-principal accessor: Apache Shiro `AuthenticationToken.getPrincipal` /
77+
* `getUsername`, Spring Security `Authentication.getName` / `getPrincipal`, or
78+
* `java.security.Principal.getName`. These return the untrusted login identity inside
79+
* an authentication framework.
80+
*/
81+
class AuthPrincipalAccessor extends MethodCall {
82+
AuthPrincipalAccessor() {
83+
exists(Method m | m = this.getMethod() |
84+
m.hasName(["getPrincipal", "getUsername"]) and
85+
m.getDeclaringType()
86+
.getAnAncestor*()
87+
.hasQualifiedName("org.apache.shiro.authc",
88+
["AuthenticationToken", "UsernamePasswordToken", "HostAuthenticationToken"])
89+
or
90+
m.hasName(["getName", "getPrincipal"]) and
91+
m.getDeclaringType()
92+
.getAnAncestor*()
93+
.hasQualifiedName("org.springframework.security.core", "Authentication")
94+
or
95+
m.hasName("getName") and
96+
m.getDeclaringType().getAnAncestor*().hasQualifiedName("java.security", "Principal")
97+
)
98+
}
99+
}
100+
101+
/**
102+
* A `String` parameter of a DN-builder-shaped method, e.g. `getUserDn`,
103+
* `getUsernameWithSuffix`, `buildDn`, `resolveDn`. An authentication framework
104+
* receives the untrusted principal here and concatenates it into the bind DN.
105+
*
106+
* This source model is name-heuristic: it keys partly off method names. It is a
107+
* deliberate precision/recall trade for the library case, where there is no remote
108+
* flow source to anchor on. A framework that builds the DN in a differently named
109+
* helper is missed; a benign method that matches the name pattern may produce a false
110+
* positive. Triage a result by confirming the value reaches a real bind sink
111+
* unescaped.
112+
*/
113+
class DnBuilderParam extends Parameter {
114+
DnBuilderParam() {
115+
this.getType() instanceof TypeString and
116+
exists(string name | name = this.getCallable().getName().toLowerCase() |
117+
name.matches([
118+
"get%userdn", "%userdn", "build%dn", "make%dn", "resolve%dn", "create%dn", "to%dn",
119+
"compute%dn"
120+
])
121+
or
122+
name.matches("%usernamewithsuffix%")
123+
or
124+
name.matches(["get%principal", "build%principal"])
125+
)
126+
}
127+
}
128+
129+
/** A call to a recognised RFC 2253 DN escaper, e.g. `javax.naming.ldap.Rdn.escapeValue`. */
130+
class DnEscaperCall extends MethodCall {
131+
DnEscaperCall() {
132+
exists(Method m | m = this.getMethod() |
133+
m.hasName("escapeValue") and
134+
m.getDeclaringType().hasQualifiedName("javax.naming.ldap", "Rdn")
135+
or
136+
m.hasName("nameEncode") and
137+
m.getDeclaringType().hasQualifiedName("org.springframework.ldap.support", "LdapEncoder")
138+
or
139+
m.hasName("encodeForDN")
140+
or
141+
m.getName()
142+
.toLowerCase()
143+
.matches(["%escapedn%", "%escapeldapdn%", "%encodefordn%", "%escapedistinguished%"])
144+
)
145+
}
146+
}
147+
148+
/**
149+
* A taint-tracking configuration for an unescaped login principal flowing into an
150+
* LDAP bind DN inside an authentication framework.
151+
*/
152+
module LdapDnLibraryModeConfig implements DataFlow::ConfigSig {
153+
predicate isSource(DataFlow::Node source) {
154+
source.asExpr() instanceof AuthPrincipalAccessor
155+
or
156+
source.asParameter() instanceof DnBuilderParam
157+
}
158+
159+
predicate isSink(DataFlow::Node sink) {
160+
exists(MethodCall ma | ma.getMethod() instanceof JndiNameLookupMethod |
161+
sink.asExpr() = ma.getArgument(0)
162+
)
163+
or
164+
sink.asExpr() = any(SecurityPrincipalPut p).getArgument(1)
165+
or
166+
exists(MethodCall ma | ma.getMethod() instanceof ShiroGetLdapContextMethod |
167+
sink.asExpr() = ma.getArgument(0)
168+
)
169+
}
170+
171+
predicate isBarrier(DataFlow::Node node) { node.asExpr() instanceof DnEscaperCall }
172+
}
173+
174+
module LdapDnLibraryModeFlow = TaintTracking::Global<LdapDnLibraryModeConfig>;
175+
176+
from LdapDnLibraryModeFlow::PathNode source, LdapDnLibraryModeFlow::PathNode sink
177+
where LdapDnLibraryModeFlow::flowPath(source, sink)
178+
select sink.getNode(), source, sink,
179+
"LDAP bind DN depends on a $@ without RFC 2253 distinguished-name escaping.", source.getNode(),
180+
"library-boundary login principal"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import org.apache.shiro.authc.AuthenticationToken;
2+
import org.apache.shiro.realm.ldap.LdapContextFactory;
3+
4+
public class LdapDnInjectionLibraryModeBad {
5+
private final LdapContextFactory ldapContextFactory;
6+
7+
public LdapDnInjectionLibraryModeBad(LdapContextFactory ldapContextFactory) {
8+
this.ldapContextFactory = ldapContextFactory;
9+
}
10+
11+
// BAD: the login principal is concatenated into the bind DN with no escaping, so an
12+
// attacker can supply DN metacharacters to manipulate the DN used to authenticate.
13+
protected String getUserDn(String principal) {
14+
return "uid=" + principal + ",ou=people,dc=example,dc=com";
15+
}
16+
17+
public Object bind(AuthenticationToken token) throws Exception {
18+
String dn = getUserDn((String) token.getPrincipal());
19+
return ldapContextFactory.getLdapContext(dn, token.getCredentials());
20+
}
21+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import javax.naming.ldap.Rdn;
2+
import org.apache.shiro.authc.AuthenticationToken;
3+
import org.apache.shiro.realm.ldap.LdapContextFactory;
4+
5+
public class LdapDnInjectionLibraryModeGood {
6+
private final LdapContextFactory ldapContextFactory;
7+
8+
public LdapDnInjectionLibraryModeGood(LdapContextFactory ldapContextFactory) {
9+
this.ldapContextFactory = ldapContextFactory;
10+
}
11+
12+
// GOOD: the login principal is escaped for RFC 2253 with Rdn.escapeValue before it
13+
// is placed in the DN, so DN metacharacters are neutralised.
14+
protected String getUserDn(String principal) {
15+
return "uid=" + Rdn.escapeValue(principal) + ",ou=people,dc=example,dc=com";
16+
}
17+
18+
public Object bind(AuthenticationToken token) throws Exception {
19+
String dn = getUserDn((String) token.getPrincipal());
20+
return ldapContextFactory.getLdapContext(dn, token.getCredentials());
21+
}
22+
}

0 commit comments

Comments
 (0)