Apache Shiro 权限绕过(CVE-2020-13933)
漏洞影响范围
Apache Shiro 版本 < 1.6.0
漏洞环境搭建
下载官方直接写好的源码github-apache-Shiro:
<!--more-->

由于漏洞版本为 Apache shiro < 1.6.0 所以我们直接下载tag中的1.5.3版本即可。

下载好对应源码之后,解压导入idea项目中,idea会为我们安装好pom.xml中的依赖(该过程有点长,请耐心等待),安装好后,选择WEBAPP启动,(这里会访问外网资源,由于众所周知的原因访问比较慢,可以选择断网调试,或者开飞机)

看到该页面,就说明测试环境已经搭建完成了:

环境改造
从漏洞报告来看,应该是Shiro对动态URL的解析出现了问题,导致了未授权访问的情况,因此为了方便查看,我们新添加一个Controller,作为测试页面。
TestController.java:
package org.apache.shiro.samples;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
public class TestController {
@RequestMapping(value = "/test/{id}",method = RequestMethod.GET)
public String test(@PathVariable String id, Model model){
model.addAttribute("id",id);
return "test";
}
}test.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vuln Test Page</title>
</head>
<body>
<h1 th:text="'Auth Page Test:'+${id}"></h1>
</body>
</html>当然别忘了将这个路由加入FilterChain:
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
chainDefinition.addPathDefinition("/login.html", "authc"); // need to accept POSTs from the login form
chainDefinition.addPathDefinition("/test/*","authc");
// 新添加的需要验证的路由,注意这里只使用了一个*号
chainDefinition.addPathDefinition("/logout", "logout");
return chainDefinition;
}项目结构如下:

最后效果只要如下图就算可以开始复现了,当未登录时,自动跳转登录界面:

登录后可以正常访问:

PS:有关Shiro-FilterChain是什么
如果简单阅读这个Shiro的官方例子,你会发现此处登录页面提交参数之后,完全没有在Controller处进行UsernamePasswordToken函数进行验证,那么自然会产生参数是在哪里处理这个疑问。由于使用的是Springboot,很自然就明白应该是在filter处进行了参数处理,我们可以将FIlterChain简单理解成为Filter的列表,它告诉应用在什么时候,该调用哪个FIlter。Shiro会对Servlet容器进行代理,先执行自己的FIlterChain,再执行Servlet的FIlterChain,下面这些代码就是在添加Shiro:
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
chainDefinition.addPathDefinition("/login.html", "authc"); // need to accept POSTs from the login form
chainDefinition.addPathDefinition("/test/*","authc"); // 新添加的需要验证的路由,一个星号表示弱匹配
chainDefinition.addPathDefinition("/logout", "logout");
return chainDefinition;
}如果跟进addPathDefinition这个函数,可以发现最后是返回一个map类型的参数

实质上和网上目前的复现方式使用map.put设置路由是差不多的。
Shiro有自己的默认FilterChain,也可以自己实现,默认FilterChain及其对应功能如下图(抄的网上的表):

漏洞复现
其实漏洞复现很简单,只需要对;,进行url编码即可,如下图:
http://<Your-server>/test/%3B1<Any String>/ 
需要注意的是,如果我们此处路由为强匹配,则不会出现权限绕过的情况:


具体为什么,见后文强匹配与弱匹配的区别
漏洞分析
漏洞成因
Shiro处理
此处漏洞成因和Shiro爆出的CVE-2020-11989类似,是由于Shiro和Spring对URL解析的差异造成的,调试过程中我们顺便梳理一下匹配的流程。根据前文所提到的FilterChain,我们能够找到Shiro对路径匹配的FIlter。
Shiro的处理URL入口在这里/org/apache/shiro/shiro-web/1.5.3/shiro-web-1.5.3.jar!/org/apache/shiro/web/util/WebUtils.class:

后面改用windows环境调试了,所以看着主题不一样2333
这里注释也写的很清楚,该函数会返回web应用程序中当前访问的路径,自然路径解析是从这里开始的,跟一下,主要注意的是,我们发现这里已经进行过一次URL编码了
removeSemicolon会去除掉;这个字符

最后结果是,将;号后面的内容去除:

而经过处理,最后参与匹配的字符串为:


然后shiro仅仅只会和源码中写明了FilterChain的路由进行匹配,毕竟shiro只是进行鉴权工作。
其他不用shiro参与的处理,需要调用Spring自己Filter。



在此处Shiro完成了自己的任务,将移交给spring的Filter(注意函数栈出现了spring)

因此最后Shiro得到的路由为/test,这个路由在我们源码中不存在,会移交给error处理路由,返回404,这个是任何用户有权访问的。

也因为如此shiro判断可以放权访问。
Spring的处理
一路疯狂F7我们发现SpringBoot的处理URL处在这:

继续跟发现会在此处进行URL字符处理:

随后根据得到的URL,遍历所有Controller寻找匹配

梳理了一下,共五次匹配操作:
- First Match

这里第一次匹配与Accountinfo进行比较,查看是不是该路由。
- Second Match

第二次匹配与/比较,看看是不是该路由。
- Third Match

第三次匹配与login.html比较,看是不是该路由。
- Fourth Match

第四次匹配与error路由匹配,看是不是该路由。
- Fifth Match

第五次匹配和 GET/test/{id},发现匹配上了,直接返回结果。
细心的同学会发现这不是和我们Controller定义的顺序一模一样吗?

我们在第五次匹配处一路F8看看。发现最后第五次匹配成功后会返回对应的路由:

从这里可知最后spring得到的路由为/test/;Bypass,而之前Shiro已经放权完成,因此我们可以正常访问该路由。
不同的处理方式
Shiro也存在和Spring处理URL的同名函数decodeAndCleanUriString,函数体如下:

这里Shiro的处理是先进行url解码,然后再对;进行处理
Spring函数体如下:

Spring则完全相反,首先进行了;号的处理,然后再进行URL解码。
像往常一样,一张图进行总结:

强匹配与弱匹配的区别
从漏洞的整体流程来看,其实我们可以把关注点放在shiro的AntPath处理引擎上,正是这一步的判断导致了越权
代码位置org.apache.shiro.util.AntPathMatcher#doMatch
- 弱匹配的情况


函数体如下,我将常量进行了替换,看着方便一点:
protected boolean doMatch(String pattern, String path, boolean fullMatch) {
if (path.startsWith("/") != pattern.startsWith("/")) {
return false;
}
String[] pattDirs = StringUtils.tokenizeToStringArray(pattern, "/");
String[] pathDirs = StringUtils.tokenizeToStringArray(path, "/");
//通过"/"符号划分数组
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];
//patDir = "test"
if ("**".equals(patDir)) {
break;
}
if (!matchStrings(patDir, pathDirs[pathIdxStart])) {
return false;
}
//matchStrings(patDir, pathDirs[pathIdxStart] = true
pattIdxStart++; // pattIdxStart=1
pathIdxStart++;// pathIdxStart=1
}
if (pathIdxStart > pathIdxEnd) {
// 1 > 0 走这个分支
// Path is exhausted, only match if rest of pattern is * or **'s
if (pattIdxStart > pattIdxEnd) {
// 1 > 1 False 不走该分支
return (pattern.endsWith("/") ?
path.endsWith("/") : !path.endsWith("/"));
}
if (!fullMatch) {
// 初始传参fullMatch = true 不走该分支
return true;
}
if (pattIdxStart == pattIdxEnd && pattDirs[pattIdxStart].equals("*") &&
path.endsWith("/")) {
// false
return true;
}
//走下面这个循环
for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
if (!pattDirs[i].equals("**")) {
// pattDirs里的元素均不满足
return false; // <-函数将在这里退出
}
}
return true;
.....
}
/*
* 因此通过该函数没有匹配成功,会交给error或者shiro自带的错误处理器处理(错误为404没有匹配,即找不到该路由)
* 一般也不会有给错误页面设置权限的需求,所以该请求可放行
*/- 强匹配的情况
前面匹配的流程和弱匹配一模一样除了这一步:

for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
if (!pattDirs[i].equals("**")) {
// pattDirs里的1号元素满足 => 该判断返回!true 跳出循环
return false;
}
}
return true;<-函数将在这里退出这里由于能够匹配到**,所以退出该循环,即匹配成功,这时候Shiro会判断该路由,是否有权限访问,这个路由我们是用auth标记的,因此该用户无权访问,即不放行该请求,此处shiro进行的操作是,将访问路由替换为登陆路由:

Shiro的修复方式
我们比较一下shiro-web-1.5.3.jar和shiro-web1.6.0.jar两个jar包看看shiro是怎么修复的,我们使用相同的payload,进行访问发现1.6.0返回了400而非404,

翻看shiro-web-1.6.0.jar,发现WebUtil和AntPathMather没有变化,最终在filter文件夹中找到了多了一个InvalidRequestFilter
该类注释为,从源码来看过滤是默认开启的,满足Secure By Default原则:

很显然是为了修复权限绕过而做的了,我们看看源码是如何过滤的:
public class InvalidRequestFilter extends AccessControlFilter {
private static final List<String> SEMICOLON = Collections.unmodifiableList(Arrays.asList(";", "%3b", "%3B"));
private static final List<String> BACKSLASH = Collections.unmodifiableList(Arrays.asList("\\", "%5c", "%5C"));
//这里对分号和反斜杠的URL编码进行了标记
private boolean blockSemicolon = true;
private boolean blockBackslash = true;
private boolean blockNonAscii = true;
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
String uri = WebUtils.toHttp(request).getRequestURI();
return !containsSemicolon(uri)
&& !containsBackslash(uri)
&& !containsNonAsciiCharacters(uri);
}
//分别判断是分号,反斜杠,还是不可打印字符的情况,如果检测到了就由下面这个函数 返回
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
WebUtils.toHttp(response).sendError(400, "Invalid request");
return false;
}
//判断不可打印的字符采用了类似白名单的模式,个人感觉修复是比较彻底的。
private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
int length = uri.length();
for (int i = 0; i < length; i++) {
char c = uri.charAt(i);
if (c < '\u0020' || c > '\u007e') {
return false;
}
}
return true;
}参考链接
Apache Shiro 权限绕过漏洞 CVE-2020-13933