| 分支 | 1.13-CVE-2026-49268 |
| 作者 | Jinwoo Hwang (https://JinwooHwang.com) |
此分支(1.13-CVE-2026-49268)包含对 Apache Shiro 1.13 版本中 LDAP 注入认证绕过漏洞的全面安全修复。
官方 NVD 描述
远程攻击者可以将 LDAP 特殊字符注入到 DefaultLdapRealm 类中的 可分辨名称(DN)构造过程中。用户提供的用户名输入被直接拼接到 LDAP DN 模板中,未对 RFC 2253 特殊字符进行任何转义。这允许攻击者操纵 用于 LDAP 绑定认证的 DN 结构,从而可能绕过认证或 冒充其他用户。此问题影响所有 Apache Shiro 版本直至 2.2.0,以及 3.0.0-alpha-1 (在使用 DefaultLdapRealm 时)。
| 字段 | 值 |
|---|---|
| CVE | CVE-2026-49268 — Apache Shiro:DefaultLdapRealm 中的 LDAP DN 注入 |
| CWE ID | CWE-90 — 对 LDAP 查询中使用的特殊元素的不当中和(“LDAP 注入”) |
| CVSS v4.0 | 8.8 HIGH — 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 CRITICAL — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| 受影响版本 | org.apache.shiro:shiro-core 0 至 2.2.0(含);3.0.0-alpha-0 至 3.0.0-alpha-1(含) |
| 上游修复版本 | 2.2.1 和 3.0.0-alpha-2 |
| 参考 | https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s |
Apache Shiro 可以针对 LDAP 目录对用户进行认证。为此,它将
提交的用户名转换为一个可分辨名称——即目录中该用户条目的地址——
方法是将用户名放入配置的模板中,例如
uid={0},ou=users,dc=mycompany,dc=com。
在受影响的版本中,用户名被作为原始文本粘贴到该模板中。少数 标点字符——最重要的是逗号——对目录服务器来说并非普通文本; 它们是用于分隔地址中各个部分的语法。 因此,包含这些字符的用户名不再表现为地址中的值, 而是开始表现为地址本身的一部分。
实际后果是,登录的人可以影响Shiro 尝试针对哪个目录条目进行认证, 而不仅仅是提供自己的名称。查找不再在部署所预期的容器中进行, 而是可以被重定向到目录中的其他位置。取决于目录的布局方式及其允许的操作, 这可能导致以错误身份通过认证,或在不应通过认证时认证成功。
风险概况。 提高风险的因素:不需要事先访问权限或凭据——
输入到达登录边界,任何能访问应用程序的人都可以到达该边界;
并且受影响的类是连接 Shiro 与 LDAP 的标准、有文档记录的方式,
因此这不是一种罕见的配置。降低风险的因素:部署必须实际使用
DefaultLdapRealm(或 JndiLdapRealm)并配置了 DN 模板;
通过其他方式进行认证的部署,或传递完整 DN 或非文本凭据(如
证书)的部署不受影响。重定向的查找是否会产生可用的认证
还取决于目标目录自身的布局和访问规则,这些因站点而异。
第二个非安全后果值得在发布规划中注意:合法用户名包含反斜杠或以 # 开头的用户
目前完全无法登录,因为为他们构建的地址不是格式正确的名称,会被
直接拒绝。其他标点不会直接失败——它会静默地改变
地址所指向的条目,这就是上述安全问题。同一个修复可以解决两者。
core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java,getUserDn(String),
未修复基线的第 227–250 行:```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();
}
模板在配置时围绕 `{0}` 令牌被拆分一次(`setUserDnTemplate`,第 181–200 行),分为 `prefix` 和 `suffix`。在认证时,主体被拼接在它们之间。**在任何环节都没有应用编码。** 在 `org/apache/shiro/realm/ldap/` 中没有任何转义辅助函数。
### 3.2 被假定但从未强制实施的不变量
周围的代码将 `getUserDn` 的结果视为格式良好的 Distinguished Name——它被直接传递给 `LdapContextFactory.getLdapContext(...)`,并从那里作为绑定 DN 传递给 JNDI。只有当被替换的主体是*单个属性值*时,这才是合理的。
字符串拼接无法强制这一点。RFC 2253(以及 RFC 4514)将 `,` `+` `"` `\` `<` `>` `;` `=`、前导 `#` 以及前导/尾随空白保留为 DN 内的结构语法。当这些字符中的任何一个出现在主体中时,它们会被解析器读取为结构,而非内容。该不变量——*“主体恰好占据一个 RDN 值”*——被每一个下游消费者所假定,却没有任何一方强制实施。
### 3.3 执行流程,从入口点到缺陷```
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
模板 uid={0},ou=users,dc=mycompany,dc=com,主体 jsmith,ou=admins:
| 构造的 DN | 解析出的 RDN | |
|---|---|---|
| 预期 | uid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com | 4 |
| 基线(未修复) | uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com | 5 |
该名称增加了一个组件。ou=users 不再是所寻址的容器——
ou=admins 被插入其中。通过使用 javax.naming.ldap.LdapName 进行解析得到验证,该解析
独立于任何转义实现。
基线在保留字符集上的实测行为(JDK 8,LdapName 解析):
| 主体 | 基线结果 | 效果 |
|---|---|---|
jsmith,ou=admins | 可解析,5 个 RDN | 结构被改变——插入了一个容器 |
jsmith;ou=admins | 可解析,5 个 RDN | 结构被改变——根据 RFC 1779,; 是 RDN 分隔符 |
jsmith+uid=admin | 可解析,4 个 RDN | 变为多值 RDN;uid 获得第二个值 |
jsmith=admin | 可解析,4 个 RDN | 值被破坏 |
quo"te、angle<br>ackets、 leadingSpace、trailingSpace | 可解析,4 个 RDN | 值被破坏 |
back\slash | IllegalArgumentException | 格式错误——无法尝试绑定 |
#leadingNumberSign | IllegalArgumentException | 格式错误——无法尝试绑定 |
有两个字符会改变名称的结构,而不仅仅是其内容:逗号,以及
分号——RFC 1779 接受分号作为替代的 RDN 分隔符。+ 将第二个
属性值引入同一个 RDN。只有 \ 和开头的 # 会使名称无法解析。
userDnTemplate。 getUserDn 原样返回主体。这是
文档所述的“提交的主体就是 DN”模式;调用方提供完整的 DN
并对其正确性负责。不受影响。String 主体。 getLdapPrincipal 仅将 String 主体路由到
getUserDn;其他任何内容(X.509 证书、自定义令牌)都会直接通过。不受影响。setUserDnTemplate 校验(第 181–200 行)正确地拒绝 null、空白或
不含 {0} 的模板。它校验的是操作员提供的模板,而该模板从来不是
不可信输入——因此它是正确的代码,只是没有解决此缺陷。JndiLdapRealm 继承自 DefaultLdapRealm,且未覆盖 getUserDn,因此它
继承了该缺陷。已通过测试确认(§6.1)。在范围内,并由同一变更修复。AbstractLdapRealm.searchFilter(第 89 行)包含第二处 {0} 替换,默认
为 (&(objectClass=*)(userPrincipalName={0})),用于授权路径。它不受影响。
对 core、support 和 web 主源码中每个 search( 调用、每个
getLdapContext( 调用方以及每个 LDAP 形式的字符串字面量进行扫描后发现,
searchFilter 恰好只有一处使用——ActiveDirectoryRealm.getRoleNamesForUser,第 172 行:```java
Object[] searchArguments = new Object[]{userPrincipalName};
NamingEnumeration answer = ldapContext.search(searchBase, searchFilter, searchArguments, searchCtls);
这是**参数化**的 `DirContext.search(String, String, Object[], SearchControls)`
重载。用户名作为过滤器*参数*传递,绝不会被拼接到过滤器
字符串中。根据 JDK 8 `javax.naming.directory.DirContext` javadoc,原文如下:
> “当字符串值的过滤器参数被替换为变量时,过滤器会被
> 解释为如同该字符串被直接置于变量位置,其中任何在过滤器中
> 具有特殊含义的字符(例如 `'*'`)都已根据
> RFC 2254 的规则进行了转义。”
JNDI 提供程序负责执行转义。这一点的历史记录就在字段
声明本身之中。
**来源:`core/src/main/java/org/apache/shiro/realm/ldap/AbstractLdapRealm.java`,第 88–89 行**```java
//SHIRO-115 - prevent potential code injection:
protected String searchFilter = "(&(objectClass=*)(userPrincipalName={0}))";
来源:core/src/main/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealm.java,第 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);