
CVE-2026-49268 — Analyse et remédiation d'une vulnérabilité de contournement d'authentification par injection LDAP
| Branche | 1.13-CVE-2026-49268 |
| Auteur | Jinwoo Hwang (https://JinwooHwang.com) |
Cette branche (1.13-CVE-2026-49268) contient une remédiation de sécurité complète d'une vulnérabilité de contournement d'authentification par injection LDAP dans la version 1.13 d'Apache Shiro.
Description officielle NVD
Un attaquant distant peut injecter des caractères spéciaux LDAP dans la construction du Distinguished Name (DN) dans la classe DefaultLdapRealm. L'entrée du nom d'utilisateur fournie par l'utilisateur est directement concaténée dans le modèle de DN LDAP sans aucun échappement des caractères spéciaux RFC 2253. Cela permet à un attaquant de manipuler la structure du DN utilisée pour l'authentification par bind LDAP, contournant potentiellement l'authentification ou usurpant l'identité d'autres utilisateurs. Ce problème affecte toutes les versions d'Apache Shiro jusqu'à la 2.2.0, et la 3.0.0-alpha-1 lors de l'utilisation de DefaultLdapRealm.
Apache Shiro peut authentifier les utilisateurs auprès d'un annuaire LDAP. Pour ce faire, il transforme un
nom d'utilisateur soumis en un Distinguished Name — l'adresse de l'annuaire pour l'entrée de cet utilisateur —
en insérant le nom d'utilisateur dans un modèle configuré, par exemple
uid={0},ou=users,dc=mycompany,dc=com.
Dans les versions affectées, le nom d'utilisateur est inséré dans ce modèle en tant que texte brut. Une poignée de caractères de ponctuation — le plus important étant la virgule — ne sont pas du texte ordinaire pour un serveur d'annuaire ; ils constituent la syntaxe qui sépare une partie d'une adresse de la suivante. Un nom d'utilisateur contenant ces caractères cesse donc de se comporter comme une valeur à l'intérieur de l'adresse et commence à se comporter comme une partie de l'adresse elle-même.
La conséquence pratique est qu'une personne qui se connecte peut influencer l'entrée d'annuaire contre laquelle Shiro tente de s'authentifier, plutôt que de simplement fournir son propre nom. Au lieu d'être recherchée dans le conteneur prévu par le déploiement, la recherche peut être redirigée ailleurs dans l'annuaire. Selon la façon dont l'annuaire est structuré et ce qu'il autorise, cela peut conduire à s'authentifier avec la mauvaise identité, ou à une authentification réussissant alors qu'elle ne le devrait pas.
Profil de risque. Ce qui augmente le risque : aucun accès ni identifiant préalable n'est requis — l'entrée
arrive à la frontière de connexion, qui est accessible à quiconque peut atteindre
l'application ; et la classe affectée est la manière standard et documentée de connecter Shiro à LDAP,
il ne s'agit donc pas d'une configuration exotique. Ce qui le réduit : le déploiement doit réellement utiliser
DefaultLdapRealm (ou JndiLdapRealm) avec un modèle de DN configuré ; les déploiements qui
s'authentifient par d'autres moyens, ou qui transmettent un DN complet ou un identifiant non textuel tel qu'un
certificat, ne sont pas affectés. Qu'une recherche redirigée produise une authentification utilisable
dépend également de la structure et des règles d'accès propres à l'annuaire cible, qui varient selon les sites.
Une seconde conséquence, non liée à la sécurité, mérite d'être notée pour la planification des versions : les utilisateurs dont
les noms d'utilisateur légitimes contiennent une barre oblique inverse ou commencent par # ne peuvent actuellement pas se connecter du tout,
car l'adresse construite pour eux n'est pas un nom bien formé et est rejetée
d'emblée. Les autres ponctuations n'échouent pas d'emblée — elles modifient silencieusement l'entrée à laquelle
l'adresse se réfère, ce qui constitue le problème de sécurité ci-dessus. Le même correctif résout les deux.
core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java, getUserDn(String),
lignes 227–250 sur la base non corrigée :```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();
}
Le template est découpé une fois, au moment de la configuration, autour du token `{0}`
(`setUserDnTemplate`, lignes 181–200), en un `prefix` et un `suffix`. Au moment de l'authentification,
le principal est concaténé entre eux. **Aucun encodage n'est appliqué à aucun moment.** Il n'existe
aucun utilitaire d'échappement nulle part dans `org/apache/shiro/realm/ldap/`.
### 3.2 L'invariant supposé mais jamais appliqué
Le code environnant traite le résultat de `getUserDn` comme un Distinguished Name bien formé —
il est transmis directement à `LdapContextFactory.getLdapContext(...)` et de là à JNDI comme
bind DN. Cela n'est valide que si le principal substitué est une *valeur d'attribut unique*.
La concaténation de chaînes ne peut pas garantir cela. La RFC 2253 (et la RFC 4514) réservent
`,` `+` `"` `\` `<` `>` `;` `=`, un `#` en tête, ainsi que les espaces en début et fin comme syntaxe
structurelle au sein d'un DN. Lorsque l'un de ces caractères apparaît dans le principal, il est lu par
l'analyseur comme de la structure, et non comme du contenu. L'invariant — *« le principal occupe exactement une valeur RDN »* —
était supposé par chaque consommateur en aval et appliqué par aucun.
### 3.3 Flux d'exécution, du point d'entrée au défaut```
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 :
| DN construit | RDN analysés | |
|---|---|---|
| Prévu | uid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com | 4 |
| Référence (non corrigé) | uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com | 5 |
Le nom gagne un composant. ou=users n'est plus le conteneur adressé —
ou=admins est interposé. Vérifié par analyse avec javax.naming.ldap.LdapName, qui est
indépendant de toute implémentation d'échappement.
Comportement mesuré de la référence sur l'ensemble réservé (JDK 8, analyse LdapName) :
Deux caractères altèrent la structure du nom, pas seulement son contenu : la virgule, et le
point-virgule — que la RFC 1779 accepte comme séparateur de RDN alternatif. + introduit une seconde
valeur d'attribut dans le même RDN. Seuls \ et un # en tête rendent le nom non analysable.
userDnTemplate non défini. getUserDn renvoie le principal tel quel. C'est le
mode documenté « le principal soumis est le DN » ; l'appelant fournit un DN complet
et assume sa validité. Non affecté.String. getLdapPrincipal ne route que les principaux String vers
getUserDn ; tout le reste (certificats X.509, jetons personnalisés) passe à travers. Non affecté.setUserDnTemplate (lignes 181–200) rejette correctement un template nul, vide, ou
sans {0}. Elle valide le template fourni par l'opérateur, qui n'a jamais été l'entrée
non fiable — c'est donc du code correct qui simplement ne traite pas ce défaut.JndiLdapRealm étend DefaultLdapRealm et ne redéfinit pas getUserDn, il
hérite donc du défaut. Confirmé par test (§6.1). Dans le périmètre, et corrigé par le même changement.AbstractLdapRealm.searchFilter (ligne 89) porte une seconde substitution {0}, par défaut
(&(objectClass=*)(userPrincipalName={0})), utilisée sur le chemin d'autorisation. Il n'est pas affecté.
Un balayage de chaque appel search(, de chaque appelant getLdapContext(, et de chaque littéral
de chaîne de forme LDAP à travers les sources principales core, support et web a trouvé exactement un usage de
searchFilter — ActiveDirectoryRealm.getRoleNamesForUser, ligne 172 :```java
Object[] searchArguments = new Object[]{userPrincipalName};
NamingEnumeration answer = ldapContext.search(searchBase, searchFilter, searchArguments, searchCtls);
Il s'agit de la surcharge **paramétrée** `DirContext.search(String, String, Object[], SearchControls)`.
Le nom d'utilisateur est passé comme *argument* de filtre, jamais concaténé dans la chaîne
de filtre. Selon la javadoc de `javax.naming.directory.DirContext` du JDK 8, textuellement :
> « Lorsqu'un argument de filtre de type chaîne est substitué à une variable, le filtre est
> interprété comme si la chaîne était donnée à la place de la variable, tous les caractères
> ayant une signification particulière au sein des filtres (tels que `'*'`) ayant été échappés
> conformément aux règles de la RFC 2254. »
Le fournisseur JNDI effectue l'échappement. La trace historique de cela se trouve dans la
déclaration du champ elle-même.
**Source : `core/src/main/java/org/apache/shiro/realm/ldap/AbstractLdapRealm.java`, lignes 88–89**```java
//SHIRO-115 - prevent potential code injection:
protected String searchFilter = "(&(objectClass=*)(userPrincipalName={0}))";
Source : core/src/main/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealm.java, lignes 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);
Le nom d'utilisateur parvient au répertoire en tant que `searchArguments[0]`, jamais en tant que texte inséré dans `searchFilter`. Le jeton `{0}` dans le filtre est résolu par le fournisseur JNDI, et non par Shiro.
`ActiveDirectoryRealm` ligne 108 transmet le nom d'utilisateur brut à `getLdapContext(username, password)`. Cette valeur devient une entrée d'environnement JNDI `SECURITY_PRINCIPAL` unique ; elle n'est pas substituée dans un modèle et aucun DN n'est construit à partir de celle-ci, elle est donc en dehors du mécanisme de ce défaut.
**Vérifié empiriquement contre un serveur de répertoire en fonctionnement.** L'échappement JNDI n'a pas été accepté sur parole : il a été observé sur le réseau. Un serveur LDAP in-process (UnboundID `InMemoryDirectoryServer`) a été instrumenté avec un `InMemoryOperationInterceptor` qui enregistre le filtre de chaque requête de recherche qu'il reçoit, et le `searchFilter` par défaut de Shiro a été émis via le même appel JNDI paramétré utilisé par `ActiveDirectoryRealm`, avec un argument forgé :```
filter template : (&(objectClass=*)(userPrincipalName={0}))
argument passed : *)(uid=jsmith
server received : (&(objectClass=*)(userPrincipalName=\2a\29\28uid=jsmith))
Les *, ) et ( sont arrivés au serveur sous la forme \2a, \29 et \28 — échappés conformément à la RFC 2254. Le filtre ne conserve que deux clauses ; l'argument n'a pas pu en ajouter une troisième.
Contrôle, le même modèle avec l'argument inséré en texte brut au lieu d'être passé comme argument de filtre :``` CONTROL, raw splice: (&(objectClass=)(userPrincipalName=)(uid=jsmith)) server received : (&(objectClass=)(userPrincipalName=)(uid=jsmith))
Le contrôle atteint le serveur avec sa structure altérée — une troisième clause ajoutée et le
test `userPrincipalName` réduit à un joker. La forme paramétrée, elle, ne l'est pas. Cela
confirme, par observation plutôt que par contrat, que le chemin `searchFilter` n'est pas
affecté.
---
## 4. Étapes de reproduction pas à pas
Déterministes, à partir d'un checkout propre. Le répertoire de travail est la racine du dépôt tout au long.
### 4.1 Prérequis
| Exigence | Valeur utilisée |
|---|---|
| JDK | **8** — la branche définit `jdk.version` à 1.8. Les résultats ci-dessous ont été produits avec Zulu 1.8.0_432 (arm64). Pointez `JAVA_HOME` vers une installation JDK 8 avant d'exécuter toute commande de cette section : |
| Build | Apache Maven, accès réseau requis (POM parent `org.apache:apache:38`) |
| Référence | `origin/1.13.x` |
| Configuration | `userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com` |
| Serveur d'annuaire | **Non requis pour §4.3-§4.4** — le défaut se situe dans la construction du DN, avant tout appel réseau, donc ces étapes simulent `LdapContextFactory`. §4.5 reproduit en outre l'ensemble du chemin contre un **vrai** serveur LDAP via HTTP. |
Définition de `JAVA_HOME` vers une installation JDK 8 :```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
Confirmez avec mvn -v, qui indique le JDK que Maven utilisera. Les commandes ci-dessous supposent que
celui-ci est déjà défini.
git clone https://github.com/apache/shiro.git cd shiro git checkout -b repro origin/1.13.x
### 4.3 Observer directement le défaut
Il s'agit du déclencheur minimal, indépendant de la compilation de Shiro. Écrivez `Repro.java` dans un répertoire temporaire :```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());
}
}
Exécutez-le :```bash javac Repro.java && java Repro
Sortie observée :```
benign : uid=jsmith,ou=users,dc=mycompany,dc=com -> RDNs=4
crafted: uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com -> RDNs=5
Le nombre de composants passe de 4 à 5. La valeur soumise est devenue une structure de nom.
Depuis la racine du dépôt sur la base non corrigée, appliquez les tests de la §6.3 et exécutez :```bash mvn -B clean verify
La compilation s'arrête à `Apache Shiro :: Core` avec les tests de validation qui échouent — voir §6.1 pour la sortie complète capturée :```
DefaultLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
Reproduit à travers l'intégralité de la pile — une véritable requête HTTP, un véritable conteneur de servlets,
le propre FormAuthenticationFilter de Shiro, et un véritable serveur LDAP. Rien n'est simulé à aucun
niveau.
Fixture d'annuaire — un conteneur privilégié imbriqué, une disposition courante dans le monde réel :``` dc=mycompany,dc=com └── ou=users ├── uid=jsmith userPassword: userpass (ordinary account) └── ou=admins └── uid=jsmith userPassword: adminpass (privileged account)
#### 4.5.1 Build affecté
**Requête**```http
POST /login HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
username=jsmith%2Cou%3Dadmins&password=adminpass
Réponse```http HTTP/1.1 302 Found Location: http://127.0.0.1/;jsessionid=node0qu3v2n612vy71alsuyir2bgzz1.node0
**Bind DN reçu par l'annuaire**```
uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
Le 302 est la redirection de succès de FormAuthenticationFilter : la requête authentifiée.
Le DN porte cinq composants là où le template en définit quatre, et l'entrée atteinte est
celle privilégiée sous ou=admins.
Contrôle — le même nom d'utilisateur avec le mot de passe du compte ordinaire```http POST /login HTTP/1.1 Content-Type: application/x-www-form-urlencoded
username=jsmith%2Cou%3Dadmins&password=userpass
| `-s` | `--server` | `string` | URL du serveur cible (par défaut : `http://localhost:8080`) |
| `-t` | `--token` | `string` | Jeton d'authentification pour le serveur |
| `-o` | `--output` | `string` | Chemin du fichier de sortie pour les résultats |
| `-f` | `--format` | `string` | Format de sortie : `json`, `yaml`, `table` (par défaut : `table`) |
| `-v` | `--verbose` | `bool` | Activer la sortie détaillée |
| `-q` | `--quiet` | `bool` | Supprimer toute la sortie non essentielle |
| `-c` | `--config` | `string` | Chemin du fichier de configuration |
| `-n` | `--no-color` | `bool` | Désactiver la sortie colorée |
| `-h` | `--help` | `bool` | Afficher le message d'aide |
### Exemples
```bash
# Exécuter un scan de base
scanner scan --target example.com
# Exécuter un scan avec un fichier de configuration
scanner scan --config /path/to/config.yaml
# Exporter les résultats au format JSON
scanner scan --target example.com --format json --output results.json
# Exécuter avec une sortie détaillée
scanner scan --target example.com --verbose
Le scanner peut être configuré à l'aide d'un fichier de configuration YAML :
server:
url: "http://localhost:8080"
token: "your-token-here"
timeout: 30s
scan:
target: "example.com"
ports: "1-1000"
threads: 10
timeout: 5s
output:
format: "json"
file: "results.json"
verbose: false
Le scanner ne se connecte pas au serveur
Vérifiez que le serveur est en cours d'exécution et que l'URL est correcte :
curl -v http://localhost:8080/health
Erreur d'authentification
Assurez-vous que votre jeton est valide et qu'il dispose des autorisations nécessaires :
scanner auth verify --token your-token-here
Le scan prend trop de temps
Réduisez le nombre de threads ou augmentez le délai d'attente :
scanner scan --target example.com --threads 5 --timeout 10s
Les contributions sont les bienvenues ! Veuillez consulter le fichier CONTRIBUTING.md pour plus de détails.
Ce projet est sous licence MIT - voir le fichier LICENSE pour plus de détails.```http HTTP/1.1 200 OK
Rejeté. C'est ce qui établit une usurpation d'identité plutôt qu'une coïncidence : le nom d'utilisateur forgé s'authentifie avec le mot de passe du **compte privilégié** et échoue avec celui de l'utilisateur ordinaire, donc les identifiants vérifiés appartiennent à une entrée d'annuaire différente de celle adressée par le template.
#### 4.5.2 Build corrigé — requêtes identiques
| Requête | Affecté | Corrigé |
|---|---|---|
| `username=jsmith&password=userpass` | `302` authentifié | `302` authentifié |
| `username=jsmith%2Cou%3Dadmins&password=adminpass` | **`302` authentifié** | **`200` rejeté** |
| `username=jsmith%2Cou%3Dadmins&password=userpass` | `200` rejeté | `200` rejeté |
Bind DN reçu par l'annuaire pour le nom d'utilisateur forgé :```
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)
Les connexions ordinaires sont inchangées — la ligne 1 s'authentifie de manière identique sur les deux versions, l'annuaire recevant uid=jsmith,ou=users,dc=mycompany,dc=com.
Les deux exécutions ont utilisé le même harnais et le même fixture ; seul shiro-core sur le classpath différait. La classe réellement chargée a été confirmée avec -verbose:class pour chaque exécution.
Encoder le principal comme une valeur d'attribut DN unique avant substitution, en utilisant l'encodeur RFC 2253 du JDK lui-même, javax.naming.ldap.Rdn.escapeValue. C'est le même mécanisme que le JDK utilise pour construire les noms, donc sa sortie est par construction cohérente avec l'analyseur qui la consomme.
Fichier : `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);
}
**Ce que le hunk impose.** `Rdn.escapeValue` échappe par barre oblique inverse l'ensemble réservé de la RFC 2253
(`\ , = + < > # ; "`) ainsi que les espaces en début et en fin. Après cela, le texte substitué
ne peut plus jamais occuper qu'une seule valeur RDN : le parseur lit les caractères échappés comme du contenu, donc
le nombre de composants du résultat est fixé par le template et ne peut pas être influencé par le
principal. L'indication de capacité du `StringBuilder` est mise à jour avec la longueur encodée — un détail de dimensionnement,
pas un détail comportemental.
**Placement.** L'encodage se situe *après* le retour anticipé pour le cas de template non configuré.
C'est délibéré : dans ce mode, l'appelant fournit un DN complet et l'encoder
le corromprait. Les deux branches conservent leurs contrats existants.
### 5.2 Changement de comportement pour les appelants légitimes
Ce correctif **modifie la chaîne DN** produite pour tout principal contenant un caractère
réservé. Trois conséquences qu'il vaut la peine d'énoncer clairement :
1. **Les connexions auparavant cassées fonctionnent désormais.** Les noms d'utilisateur contenant une barre oblique inverse ou commençant
par `#` produisaient un DN qui n'était pas un nom bien formé, donc aucune tentative de bind ne pouvait être effectuée du
tout. Ils s'encodent désormais en un DN valide et tenteront un bind. Les sites peuvent voir des comptes commencer
à s'authentifier alors qu'ils ne le pouvaient pas auparavant — un correctif, mais un changement visible. Les noms d'utilisateur
contenant `,`, `;`, `+` ou `=` n'étaient pas rejetés auparavant ; ils se résolvaient vers la mauvaise
entrée, et se résolvent désormais vers celle prévue.
2. **Les DN de bind envoyés à l'annuaire diffèrent** pour les noms d'utilisateur concernés. Les journaux côté annuaire,
les pistes d'audit, et toute extraction de journaux qui correspond à des chaînes DN littérales verront des formes
échappées (`uid=jsmith\,ou\=admins,...`). Aucun changement de configuration n'est requis.
3. **Les déploiements qui s'appuyaient sur l'ancien comportement pour construire des DN multi-composants à partir du
champ nom d'utilisateur se casseraient.** Ce n'est pas une configuration prise en charge — le template
existe pour définir la structure — mais c'est le seul risque de migration, et c'est le même
comportement que le correctif existe pour supprimer.
`getUserDnTemplate()` est implémenté comme `getUserDn("{0}")`. `{` et `}` ne sont pas réservés dans
la RFC 2253, donc la valeur de retour de l'accesseur est inchangée. Confirmé par le
`testUserDnTemplate` préexistant, qui passe sans modification.
---
## 6. Vérification et tests
### 6.1 Avant — référence, non corrigé
Branche `1.13-CVE-2026-49268`, JDK Zulu 1.8.0_432. Commande comme au §4.4.```
DefaultLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
Sortie d'échec capturée — c'est la preuve, et elle n'est pas récupérable une fois le correctif en place :``` 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` **a réussi sur la base de référence**, comme prévu — il
s'agit d'une protection contre la sur-correction, pas d'un test de validation.
### 6.2 Après — avec le correctif appliqué
Même commande, même JDK :```
DefaultLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
Les quatre tests de vérification passent désormais sur les deux classes. Réexécuter §4.3 avec le correctif en place donne RDNs=4 pour le principal forgé.
Fichier : core/src/test/java/org/apache/shiro/realm/ldap/DefaultLdapRealmTest.java
(+124 lignes, 0 suppressions). Comme JndiLdapRealmTest extends DefaultLdapRealmTest, chaque
test ci-dessous s'exécute contre les deux realms — 10 exécutions à partir de 5 méthodes.
Note de conception. Les assertions structurelles analysent le résultat avec javax.naming.ldap.LdapName
et comparent le nombre de composants et la valeur feuille après aller-retour, plutôt que d'affirmer une
chaîne échappée attendue. Les tests vérifient donc la propriété réellement requise et ne
présupposent pas Rdn.escapeValue comme implémentation — un encodeur correct alternatif
passerait également.
Commande :```bash mvn -B clean verify
### 6.4 Régression — build complet
Le build complet du reactor passe avec **aucun flag et aucun skip** :```
mvn -B clean verify
BUILD SUCCESS — 907 tests, 0 failures, 0 errors, 3 skipped, across the full reactor. Chaque garde-fou est actif dans cette exécution : tests unitaires (surefire), tests d'intégration (failsafe), l'audit de licence Apache RAT, maven-enforcer et japicmp.
Les 3 tests ignorés sont des @Ignore préexistants dans des modules sans rapport avec cette modification ; core — le
seul module touché — n'ignore rien.
https://cveawg.mitre.org/api/cve/CVE-2026-49268https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5shttps://shiro.apache.org/security-reports.htmlAuteur de la recherche et de la remédiation des vulnérabilités de sécurité : Jinwoo Hwang (https://JinwooHwang.com)
| Champ | Valeur |
|---|
| CVE | CVE-2026-49268 — Apache Shiro : injection de DN LDAP dans DefaultLdapRealm |
| CWE ID | CWE-90 — Neutralisation incorrecte des éléments spéciaux utilisés dans une requête LDAP (« injection LDAP ») |
| CVSS v4.0 | 8.8 ÉLEVÉ — 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 CRITIQUE — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Versions affectées | org.apache.shiro:shiro-core 0 à 2.2.0 inclus ; 3.0.0-alpha-0 à 3.0.0-alpha-1 inclus |
| Corrigé en amont dans | 2.2.1 et 3.0.0-alpha-2 |
| Référence | https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s |
| Principal | Résultat de référence | Effet |
|---|
jsmith,ou=admins | s'analyse, 5 RDN | structure altérée — un conteneur est interposé |
jsmith;ou=admins | s'analyse, 5 RDN | structure altérée — ; est un séparateur de RDN selon la RFC 1779 |
jsmith+uid=admin | s'analyse, 4 RDN | devient un RDN multi-valué ; uid gagne une seconde valeur |
jsmith=admin | s'analyse, 4 RDN | valeur corrompue |
quo"te, angle<br>ackets, leadingSpace, trailingSpace | s'analysent, 4 RDN | valeur corrompue |
back\slash | IllegalArgumentException | malformé — la liaison ne peut pas être tentée |
#leadingNumberSign | IllegalArgumentException | malformé — la liaison ne peut pas être tentée |
| Couche | Composant |
|---|
| Client HTTP | HttpURLConnection, POST de formulaire brut |
| Conteneur de servlets | Jetty 9.4.58.v20250814 embarqué |
| Filtre de sécurité | ShiroFilter + EnvironmentLoaderListener, authc (FormAuthenticationFilter) |
| Realm | DefaultLdapRealm, userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com |
| Annuaire | UnboundID InMemoryDirectoryServer, instrumenté pour enregistrer chaque DN de bind |
| Variable | Description |
|---|
SCANNER_SERVER_URL | URL du serveur cible |
SCANNER_TOKEN | Jeton d'authentification |
SCANNER_CONFIG | Chemin du fichier de configuration |
SCANNER_OUTPUT_FORMAT | Format de sortie par défaut |
SCANNER_VERBOSE | Activer la sortie détaillée |
| Code | Description |
|---|
0 | Succès |
1 | Erreur générale |
2 | Mauvaise utilisation de la ligne de commande |
3 | Erreur de configuration |
4 | Erreur de connexion au serveur |
5 | Erreur d'authentification |
| Test | Vérifie | Référence |
|---|
testGetUserDnLeavesOrdinaryPrincipalUnchanged | Un principal ordinaire produit exactement le DN attendu, 4 RDNs, valeur intacte. Protège contre le sur-échappement. | réussi (garde) |
testGetUserDnPreservesTemplateStructure | Pour jsmith,ou=admins le DN a toujours 4 composants et le principal survit comme une seule valeur d'attribut. | échoué |
testGetUserDnPreservesTemplateStructureForReservedCharacters | Idem, sur les 10 principaux à caractères réservés ; fait aussi échouer le test si le DN n'est pas un nom bien formé. | échoué |
testGetUserDnEncodesSubstitutedValue | La valeur substituée est encodée de telle sorte qu'elle effectue un aller-retour vers le principal soumis. | échoué |
testUserDnTemplateSubstitutionPreservesStructure | De bout en bout via getAuthenticationInfo : le DN transmis à LdapContextFactory conserve la structure du template. | échoué |
| Garde-fou | Résultat |
|---|
| Tests unitaires, réacteur complet | 907 exécutés, 0 échecs, 0 erreurs, 3 ignorés |
Module core seul | 321 exécutés, 0 échecs, 0 erreurs, 0 ignorés |
Tests LDAP (DefaultLdapRealmTest + JndiLdapRealmTest) | 32 exécutés, 0 échecs |
| Tests d'intégration (failsafe) | exécutés sur l'ensemble du réacteur, aucun échec |
| Audit de licence Apache RAT | Non approuvés : 0, inconnus : 0 |
| maven-enforcer | aucune violation |
| japicmp | aucune incompatibilité signalée |