
CVE-2026-49268 — Análisis y remediación de una vulnerabilidad de omisión de autenticación por inyección LDAP
| Rama | 1.13-CVE-2026-49268 |
| Autor | Jinwoo Hwang (https://JinwooHwang.com) |
Esta rama (1.13-CVE-2026-49268) contiene una remediación de seguridad integral de una Vulnerabilidad de Omisión de Autenticación por Inyección LDAP en la versión 1.13 de Apache Shiro.
Descripción oficial del NVD
Un atacante remoto puede inyectar caracteres especiales LDAP en la construcción del Distinguished Name (DN) en la clase DefaultLdapRealm. La entrada de nombre de usuario proporcionada por el usuario se concatena directamente en la plantilla de DN LDAP sin ningún escape de los caracteres especiales de RFC 2253. Esto permite a un atacante manipular la estructura del DN utilizada para la autenticación bind LDAP, potencialmente omitiendo la autenticación o suplantando a otros usuarios. Este problema afecta a todas las versiones de Apache Shiro hasta la 2.2.0, y a la 3.0.0-alpha-1 cuando se utiliza DefaultLdapRealm.
Apache Shiro puede autenticar usuarios contra un directorio LDAP. Para ello, convierte un
nombre de usuario enviado en un Distinguished Name — la dirección del directorio para la entrada de ese usuario —
insertando el nombre de usuario en una plantilla configurada, por ejemplo
uid={0},ou=users,dc=mycompany,dc=com.
En las versiones afectadas, el nombre de usuario se pega en esa plantilla como texto sin formato. Un puñado de caracteres de puntuación — el más importante la coma — no son texto ordinario para un servidor de directorio; son la sintaxis que separa una parte de una dirección de la siguiente. Un nombre de usuario que contiene esos caracteres, por lo tanto, deja de comportarse como un valor dentro de la dirección y comienza a comportarse como parte de la dirección misma.
La consecuencia práctica es que una persona que inicia sesión puede influir en contra qué entrada del directorio Shiro intenta autenticarse, en lugar de solo proporcionar su propio nombre. En lugar de buscarse en el contenedor que la implementación pretendía, la búsqueda puede redirigirse a otra parte del directorio. Dependiendo de cómo esté organizado el directorio y qué permita, esto puede llevar a autenticarse como la identidad equivocada, o a que la autenticación tenga éxito cuando no debería.
Perfil de riesgo. Lo que aumenta el riesgo: no se requieren accesos ni credenciales previos — la
entrada llega al límite de inicio de sesión, que es accesible por cualquiera que pueda alcanzar la
aplicación; y la clase afectada es la forma estándar y documentada de conectar Shiro a LDAP,
por lo que no es una configuración exótica. Lo que lo reduce: la implementación debe usar realmente
DefaultLdapRealm (o JndiLdapRealm) con una plantilla de DN configurada; las implementaciones que
se autentican por otros medios, o que pasan un DN completo o una credencial no textual como un
certificado, no se ven afectadas. Si una búsqueda redirigida produce una autenticación utilizable
también depende de la propia organización y las reglas de acceso del directorio de destino, que varían según el sitio.
Una segunda consecuencia, no relacionada con la seguridad, merece mención para la planificación de versiones: los usuarios cuyos
nombres de usuario legítimos contienen una barra invertida o comienzan con # actualmente no pueden iniciar sesión en
absoluto, porque la dirección construida para ellos no es un nombre bien formado y es rechazada
de plano. Otra puntuación no falla de plano — cambia silenciosamente a qué entrada
se refiere la dirección, que es el problema de seguridad anterior. La misma corrección resuelve ambos.
core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java, getUserDn(String),
líneas 227–250 en la línea base sin corregir:```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();
}
La plantilla se divide una vez, en tiempo de configuración, alrededor del token `{0}`
(`setUserDnTemplate`, líneas 181–200), en un `prefix` y un `suffix`. En el momento de la autenticación
el principal se concatena entre ellos. **No se aplica ninguna codificación en ningún punto.** No hay
ningún helper de escape en ninguna parte de `org/apache/shiro/realm/ldap/`.
### 3.2 La invariante que se asumía pero nunca se aplicaba
El código circundante trata el resultado de `getUserDn` como un Distinguished Name bien formado —
se pasa directamente a `LdapContextFactory.getLdapContext(...)` y de ahí a JNDI como un
bind DN. Eso solo es correcto si el principal sustituido es un *único valor de atributo*.
La concatenación de cadenas no puede garantizar eso. RFC 2253 (y RFC 4514) reservan
`,` `+` `"` `\` `<` `>` `;` `=`, un `#` inicial, y espacios en blanco iniciales/finales como sintaxis
estructural dentro de un DN. Cuando cualquiera de esos aparece en el principal, son leídos por el analizador como
estructura, no como contenido. La invariante — *"el principal ocupa exactamente un valor de RDN"* —
era asumida por todos los consumidores posteriores y no era aplicada por ninguno.
### 3.3 Flujo de ejecución, desde el punto de entrada hasta el defecto```
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
Plantilla uid={0},ou=users,dc=mycompany,dc=com, principal jsmith,ou=admins:
| DN construido | RDN analizados | |
|---|---|---|
| Previsto | uid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com | 4 |
| Línea base (sin corregir) | uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com | 5 |
El nombre gana un componente. ou=users ya no es el contenedor al que se dirige —
se interpone ou=admins. Verificado mediante el análisis con javax.naming.ldap.LdapName, que es
independiente de cualquier implementación de escape.
Comportamiento medido de la línea base sobre el conjunto reservado (JDK 8, análisis con LdapName):
Dos caracteres alteran la estructura del nombre, no solo su contenido: la coma y el
punto y coma — que RFC 1779 acepta como separador de RDN alternativo. + introduce un segundo
valor de atributo en el mismo RDN. Solo \ y un # inicial hacen que el nombre no se pueda analizar.
userDnTemplate sin establecer. getUserDn devuelve el principal sin modificar. Este es el
modo documentado "el principal enviado es el DN"; el llamador proporciona un DN completo
y es responsable de su corrección. No afectado.String. getLdapPrincipal solo enruta los principales String hacia
getUserDn; cualquier otra cosa (certificados X.509, tokens personalizados) pasa de largo. No afectado.setUserDnTemplate (líneas 181–200) rechaza correctamente una plantilla nula, en blanco o
sin {0}. Valida la plantilla proporcionada por el operador, que nunca fue la
entrada no confiable — por lo que es código correcto que simplemente no aborda este defecto.JndiLdapRealm extiende DefaultLdapRealm y no sobrescribe getUserDn, por lo que
hereda el defecto. Confirmado mediante prueba (§6.1). En alcance, y corregido por el mismo cambio.AbstractLdapRealm.searchFilter (línea 89) contiene una segunda sustitución de {0}, por defecto
(&(objectClass=*)(userPrincipalName={0})), utilizada en la ruta de autorización. No está afectada.
Un barrido de cada llamada a search(, cada llamador de getLdapContext( y cada literal de cadena
con forma de LDAP en las fuentes principales de core, support y web encontró exactamente un uso de
searchFilter — ActiveDirectoryRealm.getRoleNamesForUser, línea 172:```java
Object[] searchArguments = new Object[]{userPrincipalName};
NamingEnumeration answer = ldapContext.search(searchBase, searchFilter, searchArguments, searchCtls);
Este es el overload **parametrizado** `DirContext.search(String, String, Object[], SearchControls)`.
El nombre de usuario se pasa como *argumento* del filtro, nunca se concatena en la cadena
del filtro. Según el javadoc de `javax.naming.directory.DirContext` del JDK 8, textualmente:
> "Cuando un argumento de filtro con valor de cadena se sustituye por una variable, el filtro se
> interpreta como si la cadena se hubiera proporcionado en lugar de la variable, con cualquier carácter
> que tenga significado especial dentro de los filtros (como `'*'`) escapado de acuerdo
> con las reglas de la RFC 2254."
El proveedor JNDI realiza el escapado. El registro histórico de esto está en la propia
declaración del campo.
**Fuente: `core/src/main/java/org/apache/shiro/realm/ldap/AbstractLdapRealm.java`, líneas 88–89**```java
//SHIRO-115 - prevent potential code injection:
protected String searchFilter = "(&(objectClass=*)(userPrincipalName={0}))";
Fuente: core/src/main/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealm.java, líneas 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);
El nombre de usuario llega al directorio como `searchArguments[0]`, nunca como texto insertado en
`searchFilter`. El token `{0}` en el filtro es resuelto por el proveedor JNDI, no por
Shiro.
`ActiveDirectoryRealm` en la línea 108 pasa el nombre de usuario sin procesar a
`getLdapContext(username, password)`. Ese valor se convierte en una única entrada de entorno
`SECURITY_PRINCIPAL` de JNDI; no se sustituye en una plantilla ni se construye un DN a partir de él,
por lo que queda fuera del mecanismo de este defecto.
**Verificado empíricamente contra un servidor de directorio en vivo.** El escape de JNDI no se aceptó
sin más: se observó en el tráfico de red. Un servidor LDAP en proceso
(UnboundID `InMemoryDirectoryServer`) fue instrumentado con un
`InMemoryOperationInterceptor` que registra el filtro de cada solicitud de búsqueda que recibe,
y el `searchFilter` predeterminado de Shiro se emitió a través de la misma llamada JNDI parametrizada que
utiliza `ActiveDirectoryRealm`, con un argumento manipulado:```
filter template : (&(objectClass=*)(userPrincipalName={0}))
argument passed : *)(uid=jsmith
server received : (&(objectClass=*)(userPrincipalName=\2a\29\28uid=jsmith))
El *, ) y ( llegaron al servidor como \2a, \29 y \28 — escapados según
RFC 2254. El filtro conserva exactamente dos cláusulas; el argumento no pudo añadir una tercera.
Control, la misma plantilla con el argumento insertado como texto sin procesar en lugar de pasarse como argumento de filtro:``` CONTROL, raw splice: (&(objectClass=)(userPrincipalName=)(uid=jsmith)) server received : (&(objectClass=)(userPrincipalName=)(uid=jsmith))
El control llega al servidor con su estructura alterada — se añade una tercera cláusula y la prueba de `userPrincipalName` queda reducida a un comodín. La forma parametrizada no. Esto confirma, por observación más que por contrato, que la ruta `searchFilter` no se ve afectada.
---
## 4. Pasos de reproducción paso a paso
Deterministas, desde un checkout limpio. El directorio de trabajo es la raíz del repositorio en todo momento.
### 4.1 Requisitos previos
| Requisito | Valor utilizado |
|---|---|
| JDK | **8** — la rama establece `jdk.version` 1.8. Los resultados a continuación se produjeron con Zulu 1.8.0_432 (arm64). Apunte `JAVA_HOME` a una instalación de JDK 8 antes de ejecutar cualquier comando en esta sección: |
| Build | Apache Maven, se requiere acceso a la red (POM padre `org.apache:apache:38`) |
| Baseline | `origin/1.13.x` |
| Configuración | `userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com` |
| Servidor de directorio | **No requerido para §4.3-§4.4** — el defecto está en la construcción del DN, antes de cualquier llamada de red, por lo que esos pasos simulan `LdapContextFactory`. §4.5 además reproduce la ruta completa contra un servidor LDAP **real** sobre HTTP. |
Establecer `JAVA_HOME` a una instalación de 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
Confirma con mvn -v, que informa el JDK que utilizará Maven. Los comandos a continuación asumen que
esto ya está configurado.
git clone https://github.com/apache/shiro.git cd shiro git checkout -b repro origin/1.13.x
### 4.3 Observar el defecto directamente
Este es el disparador mínimo, independiente de la compilación de Shiro. Escribe `Repro.java` en un directorio temporal:```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());
}
}
Ejecútalo:```bash javac Repro.java && java Repro
Salida observada:```
benign : uid=jsmith,ou=users,dc=mycompany,dc=com -> RDNs=4
crafted: uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com -> RDNs=5
El recuento de componentes cambia de 4 a 5. El valor enviado se ha convertido en estructura de nombre.
Desde la raíz del repositorio en la línea base sin corregir, aplique las pruebas de la §6.3 y ejecute:```bash mvn -B clean verify
La compilación se detiene en `Apache Shiro :: Core` con las pruebas de verificación fallando — véase §6.1 para la salida completa capturada:```
DefaultLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
Reproducido a través de todo el stack — una solicitud HTTP real, un contenedor de servlets real,
el propio FormAuthenticationFilter de Shiro y un servidor LDAP real. Nada está simulado en ninguna
capa.
Fixture de directorio — un contenedor privilegiado anidado, una disposición común del mundo real:``` dc=mycompany,dc=com └── ou=users ├── uid=jsmith userPassword: userpass (ordinary account) └── ou=admins └── uid=jsmith userPassword: adminpass (privileged account)
#### 4.5.1 Compilación afectada
**Solicitud**```http
POST /login HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
username=jsmith%2Cou%3Dadmins&password=adminpass
Respuesta```http HTTP/1.1 302 Found Location: http://127.0.0.1/;jsessionid=node0qu3v2n612vy71alsuyir2bgzz1.node0
**Bind DN que recibió el directorio**```
uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
El 302 es la redirección exitosa de FormAuthenticationFilter: la solicitud autenticada.
El DN lleva cinco componentes donde la plantilla define cuatro, y la entrada alcanzada es
la privilegiada bajo ou=admins.
Control — el mismo nombre de usuario con la contraseña de la cuenta ordinaria```http POST /login HTTP/1.1 Content-Type: application/x-www-form-urlencoded
username=jsmith%2Cou%3Dadmins&password=userpass
| `--no-color` | Desactiva la salida con color |
| `--debug` | Habilita el registro de depuración |
| `--verbose` | Habilita el registro detallado |
| `--silent` | Suprime toda la salida excepto los resultados |
| `--output <file>` | Escribe la salida en un archivo |
| `--format <format>` | Formato de salida (json, yaml, table) |
| `--timeout <seconds>` | Tiempo de espera de la solicitud |
| `--retry <count>` | Número de reintentos |
| `--proxy <url>` | URL del proxy |
| `--headers <headers>` | Encabezados HTTP personalizados |
| `--cookies <cookies>` | Cookies personalizadas |
| `--user-agent <ua>` | Cadena de User-Agent personalizada |
| `--follow-redirects` | Seguir redirecciones HTTP |
| `--max-redirects <count>` | Número máximo de redirecciones |
| `--insecure` | Omitir la verificación del certificado TLS |
| `--rate-limit <rps>` | Limitar las solicitudes por segundo |
| `--concurrency <count>` | Número de solicitudes concurrentes |
| `--threads <count>` | Número de hilos |
| `--wordlist <file>` | Archivo de lista de palabras |
| `--extensions <ext>` | Extensiones de archivo a probar |
| `--status-codes <codes>` | Códigos de estado HTTP a mostrar |
| `--exclude-status <codes>` | Códigos de estado HTTP a excluir |
| `--recursive` | Habilitar el escaneo recursivo |
| `--depth <count>` | Profundidad máxima de recursión |
| `--random-agent` | Usar un User-Agent aleatorio |
| `--delay <ms>` | Retardo entre solicitudes en milisegundos |
| `--config <file>` | Archivo de configuración |
| `--profile <name>` | Perfil de configuración a usar |
| `--list-profiles` | Listar los perfiles de configuración disponibles |
| `--save-profile <name>` | Guardar la configuración actual como un perfil |
| `--delete-profile <name>` | Eliminar un perfil de configuración |
| `--show-config` | Mostrar la configuración actual |
| `--validate-config` | Validar el archivo de configuración |
| `--generate-config` | Generar un archivo de configuración predeterminado |
| `--version` | Mostrar la información de la versión |
| `--help` | Mostrar el mensaje de ayuda |
### Ejemplos
```bash
# Escaneo básico
./tool -u https://example.com
# Escaneo con lista de palabras
./tool -u https://example.com -w wordlist.txt
# Escaneo con extensiones
./tool -u https://example.com -w wordlist.txt -e php,html,js
# Escaneo con concurrencia
./tool -u https://example.com -w wordlist.txt -c 50
# Escaneo con proxy
./tool -u https://example.com -w wordlist.txt -p http://127.0.0.1:8080
# Escaneo con encabezados personalizados
./tool -u https://example.com -H "Authorization: Bearer token"
# Escaneo con cookies
./tool -u https://example.com -b "session=abc123"
# Escaneo con salida JSON
./tool -u https://example.com -o results.json -f json
# Escaneo con salida YAML
./tool -u https://example.com -o results.yaml -f yaml
# Escaneo con salida de tabla
./tool -u https://example.com -o results.txt -f table
# Escaneo con tiempo de espera
./tool -u https://example.com -t 30
# Escaneo con reintentos
./tool -u https://example.com -r 3
# Escaneo con limitación de velocidad
./tool -u https://example.com --rate-limit 10
# Escaneo con seguimiento de redirecciones
./tool -u https://example.com --follow-redirects
# Escaneo con omisión de la verificación TLS
./tool -u https://example.com --insecure
# Escaneo con User-Agent aleatorio
./tool -u https://example.com --random-agent
# Escaneo con archivo de configuración
./tool -u https://example.com --config config.yaml
# Escaneo con perfil
./tool -u https://example.com --profile default
# Escaneo con depuración
./tool -u https://example.com --debug
# Escaneo con salida detallada
./tool -u https://example.com --verbose
# Escaneo con salida silenciosa
./tool -u https://example.com --silent
La herramienta admite un archivo de configuración en formato YAML:
# config.yaml
target: https://example.com
wordlist: wordlist.txt
extensions:
- php
- html
- js
concurrency: 50
timeout: 30
retry: 3
rate_limit: 10
follow_redirects: true
insecure: false
random_agent: false
output: results.json
format: json
headers:
Authorization: "Bearer token"
X-Custom-Header: "value"
cookies:
session: "abc123"
proxy: http://127.0.0.1:8080
user_agent: "Mozilla/5.0"
status_codes:
- 200
- 301
- 302
- 403
exclude_status:
- 404
recursive: true
depth: 3
delay: 100
threads: 10
La herramienta admite múltiples perfiles de configuración:
# Listar perfiles
./tool --list-profiles
# Guardar perfil
./tool --save-profile myprofile
# Eliminar perfil
./tool --delete-profile myprofile
# Usar perfil
./tool --profile myprofile -u https://example.com
Errores de conexión
Errores de tiempo de espera
Errores de certificado TLS
--insecure para omitir la verificaciónErrores de permisos
Habilite el registro de depuración para solucionar problemas:
./tool -u https://example.com --debug
Habilite el registro detallado para obtener información más detallada:
./tool -u https://example.com --verbose
¡Agradecemos las contribuciones! Consulte CONTRIBUTING.md para obtener más detalles.
Este proyecto está licenciado bajo la Licencia MIT: consulte el archivo LICENSE para obtener más detalles.
Esta herramienta está destinada únicamente a pruebas de seguridad autorizadas y fines educativos. Los autores no son responsables de ningún uso indebido o daño causado por esta herramienta. Use esta herramienta de manera responsable y ética.
Consulte CHANGELOG.md para obtener el historial de cambios.
```http
HTTP/1.1 200 OK
Rechazado. Esto es lo que establece la suplantación en lugar de una coincidencia: el nombre de usuario manipulado se autentica con la contraseña de la **cuenta privilegiada** y falla con la del usuario común, por lo que las credenciales que se están verificando pertenecen a una entrada de directorio diferente de la que abordan las plantillas.
#### 4.5.2 Compilación corregida — solicitudes idénticas
| Solicitud | Afectado | Corregido |
|---|---|---|
| `username=jsmith&password=userpass` | `302` autenticado | `302` autenticado |
| `username=jsmith%2Cou%3Dadmins&password=adminpass` | **`302` autenticado** | **`200` rechazado** |
| `username=jsmith%2Cou%3Dadmins&password=userpass` | `200` rechazado | `200` rechazado |
Bind DN recibido por el directorio para el nombre de usuario manipulado:```
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)
Los inicios de sesión ordinarios no cambian — la fila 1 se autentica de forma idéntica en ambas compilaciones, y el directorio recibe uid=jsmith,ou=users,dc=mycompany,dc=com.
Ambas ejecuciones usaron el mismo arnés y el mismo fixture; solo difería shiro-core en el classpath. La clase que realmente se cargó se confirmó con -verbose:class para cada ejecución.
Codificar el principal como un único valor de atributo DN antes de la sustitución, usando el propio codificador RFC 2253 del JDK, javax.naming.ldap.Rdn.escapeValue. Este es el mismo mecanismo que el JDK usa para construir nombres, por lo que su salida es, por construcción, consistente con el analizador que la consume.
Archivo: `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);
}
**Qué impone el hunk.** `Rdn.escapeValue` escapa con barra invertida el conjunto reservado de la RFC 2253
(`\ , = + < > # ; "`) más los espacios en blanco iniciales y finales. Después de esto, el texto
sustituido solo puede ocupar un valor de RDN: el analizador lee los caracteres escapados como contenido, por lo que
el número de componentes del resultado queda fijado por la plantilla y no puede ser influenciado por el
principal. La sugerencia de capacidad del `StringBuilder` se actualiza a la longitud codificada — un detalle de dimensionamiento,
no de comportamiento.
**Ubicación.** La codificación se sitúa *después* del retorno temprano para el caso de plantilla no configurada.
Esto es deliberado: en ese modo el llamador proporciona un DN completo y codificarlo
lo corrompería. Ambas ramas mantienen sus contratos existentes.
### 5.2 Cambio de comportamiento para llamadores legítimos
Esta corrección **cambia la cadena de DN** producida para cualquier principal que contenga un carácter
reservado. Tres consecuencias que vale la pena enunciar claramente:
1. **Los inicios de sesión antes rotos ahora funcionan.** Los nombres de usuario que contenían una barra invertida o comenzaban
con `#` producían un DN que no era un nombre bien formado, por lo que no se podía intentar ningún bind en
absoluto. Ahora se codifican a un DN válido e intentarán un bind. Los sitios pueden ver cuentas que comienzan
a autenticarse cuando antes no podían — una corrección, pero un cambio visible. Los nombres de usuario
que contenían `,`, `;`, `+` o `=` no eran rechazados antes; resolvían a la entrada
incorrecta, y ahora resuelven a la deseada.
2. **Los DN de bind enviados al directorio difieren** para los nombres de usuario afectados. Los registros del lado del directorio,
las pistas de auditoría y cualquier extracción de registros que coincida con cadenas de DN literales verán formas
escapadas (`uid=jsmith\,ou\=admins,...`). No se requiere ningún cambio de configuración.
3. **Las implementaciones que dependían del comportamiento antiguo para construir DN de múltiples componentes a partir del
campo de nombre de usuario se romperían.** Esta no es una configuración soportada — la plantilla
existe para definir la estructura — pero es el único riesgo de migración, y es el mismo
comportamiento que la corrección existe para eliminar.
`getUserDnTemplate()` está implementado como `getUserDn("{0}")`. `{` y `}` no son reservados en
la RFC 2253, por lo que el valor de retorno del accesor no cambia. Confirmado por el
`testUserDnTemplate` preexistente, que pasa sin modificaciones.
---
## 6. Verificación y pruebas
### 6.1 Antes — línea base, sin corregir
Rama `1.13-CVE-2026-49268`, JDK Zulu 1.8.0_432. Comando según §4.4.```
DefaultLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
Salida de fallo capturada — esta es la evidencia, y no es recuperable una vez que la corrección está en su lugar:``` 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` **pasó en la línea base**, como se pretendía — es
una protección contra la sobrecorrección, no una prueba de validación.
### 6.2 Después — con la corrección aplicada
Mismo comando, mismo JDK:```
DefaultLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
JndiLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
Todas las cuatro pruebas de verificación ahora pasan en ambas clases. Volver a ejecutar §4.3 con la corrección aplicada
produce RDNs=4 para el principal elaborado.
Archivo: core/src/test/java/org/apache/shiro/realm/ldap/DefaultLdapRealmTest.java
(+124 líneas, 0 eliminaciones). Dado que JndiLdapRealmTest extends DefaultLdapRealmTest, cada
prueba a continuación se ejecuta contra ambos realms — 10 ejecuciones de 5 métodos.
Nota de diseño. Las aserciones estructurales analizan el resultado con javax.naming.ldap.LdapName
y comparan los recuentos de componentes y el valor hoja convertido de ida y vuelta, en lugar de afirmar una
cadena escapada esperada. Por lo tanto, las pruebas verifican la propiedad requerida real y no
presuponen Rdn.escapeValue como la implementación — un codificador correcto alternativo
seguiría pasando.
Comando:```bash mvn -B clean verify
### 6.4 Regresión — compilación completa
La compilación completa del reactor pasa con **sin flags y sin omisiones**:```
mvn -B clean verify
BUILD SUCCESS — 907 pruebas, 0 fallos, 0 errores, 3 omitidas, en todo el reactor. Todas las puertas están activas en esta ejecución: pruebas unitarias (surefire), pruebas de integración (failsafe), la auditoría de licencias Apache RAT, maven-enforcer y japicmp.
Las 3 omisiones son @Ignore preexistentes en módulos no relacionados con este cambio; core — el
único módulo modificado — no omite nada.
https://cveawg.mitre.org/api/cve/CVE-2026-49268https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5shttps://shiro.apache.org/security-reports.htmlAutor de Investigación y Remediación de Vulnerabilidades de Seguridad: Jinwoo Hwang (https://JinwooHwang.com)
| Campo | Valor |
|---|
| CVE | CVE-2026-49268 — Apache Shiro: Inyección de DN LDAP en DefaultLdapRealm |
| ID de CWE | CWE-90 — Neutralización incorrecta de elementos especiales utilizados en una consulta LDAP ('Inyección LDAP') |
| CVSS v4.0 | 8.8 ALTO — 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 CRÍTICO — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Versiones afectadas | org.apache.shiro:shiro-core 0 hasta 2.2.0 inclusive; 3.0.0-alpha-0 hasta 3.0.0-alpha-1 inclusive |
| Corregido en upstream en | 2.2.1 y 3.0.0-alpha-2 |
| Referencia | https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s |
| Principal | Resultado de la línea base | Efecto |
|---|
jsmith,ou=admins | analiza, 5 RDN | estructura alterada — se interpone un contenedor |
jsmith;ou=admins | analiza, 5 RDN | estructura alterada — ; es un separador de RDN según RFC 1779 |
jsmith+uid=admin | analiza, 4 RDN | se convierte en un RDN multivaluado; uid gana un segundo valor |
jsmith=admin | analiza, 4 RDN | valor corrompido |
quo"te, angle<br>ackets, leadingSpace, trailingSpace | analizan, 4 RDN | valor corrompido |
back\slash | IllegalArgumentException | malformado — no se puede intentar el bind |
#leadingNumberSign | IllegalArgumentException | malformado — no se puede intentar el bind |
| Capa | Componente |
|---|
| Cliente HTTP | HttpURLConnection, POST de formulario sin procesar |
| Contenedor de servlets | Jetty 9.4.58.v20250814 embebido |
| Filtro de seguridad | ShiroFilter + EnvironmentLoaderListener, authc (FormAuthenticationFilter) |
| Realm | DefaultLdapRealm, userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com |
| Directorio | UnboundID InMemoryDirectoryServer, instrumentado para registrar cada bind DN |
| Prueba | Verifica | Línea base |
|---|
testGetUserDnLeavesOrdinaryPrincipalUnchanged | Un principal ordinario produce el DN exacto esperado, 4 RDNs, valor intacto. Protege contra el escape excesivo. | pasó (guarda) |
testGetUserDnPreservesTemplateStructure | Para jsmith,ou=admins el DN todavía tiene 4 componentes y el principal sobrevive como un valor de atributo. | falló |
testGetUserDnPreservesTemplateStructureForReservedCharacters | Lo mismo, en los 10 principales con caracteres reservados; también falla la prueba si el DN no es un nombre bien formado. | falló |
testGetUserDnEncodesSubstitutedValue | El valor sustituido se codifica de tal manera que se convierte de ida y vuelta al principal enviado. | falló |
testUserDnTemplateSubstitutionPreservesStructure | De extremo a extremo a través de getAuthenticationInfo: el DN entregado a LdapContextFactory conserva la estructura de la plantilla. | falló |
| Puerta | Resultado |
|---|
| Pruebas unitarias, reactor completo | 907 ejecutadas, 0 fallos, 0 errores, 3 omitidas |
Módulo core por sí solo | 321 ejecutadas, 0 fallos, 0 errores, 0 omitidas |
Pruebas LDAP (DefaultLdapRealmTest + JndiLdapRealmTest) | 32 ejecutadas, 0 fallos |
| Pruebas de integración (failsafe) | ejecutadas en todo el reactor, sin fallos |
| Auditoría de licencias Apache RAT | No aprobadas: 0, desconocidas: 0 |
| maven-enforcer | sin violaciones |
| japicmp | sin incompatibilidades reportadas |