Diggid's Blog

shiro 权限绕过系列汇总

字数统计: 3.6k阅读时长: 15 min
2021/08/02 Share

前言

做渗透项目的时候测出来好几个shiro的权限绕过,形式各有不同。这次把这一系列的原理总结汇总一下。

准备

测试项目基于https://github.com/l3yx/springboot-shiro

IDEA打开然后tomcat跑就可以了。简单说明一下项目

  • shiro权限逻辑

image-20210803202636730

  • Spring业务层admin路由逻辑

image-20210803202859455

shiro权限绕过的目标就是不需要登录就能访问/admin/xxx

ant匹配

配置shiroConfig-Filter里的URL是ant格式,路径支持通配符表示。具体逻辑在AntPathMatcher#doMatch

1
2
3
?:匹配一个字符
*:匹配零个或多个字符串
**:匹配路径中的零个或多个路径

CVE-2020-1957

影响版本

  • Shiro < 1.5.2
  • Spring 框架中只使用 Shiro 鉴权

POC

  • POC1
1
2
3
/xxx/..;/admin/diggid

/[除admin外]/..;/[想越权访问的路由]

image-20210804153451530

  • POC2
1
/;/admin/diggid

分析

首先在admin的路由处下断,根据调用栈可以知道,请求先经过shiro处理,再转发到SpringBoot处理进行路由分发。因此权限处理层在shiro,转发到SpringBoot就只是路由分配了(如果在Spring处还进行权限校验的话就没办法利用了,但也没必要,用shiro不就是用来最小化轻量处理权限的嘛)

image-20210804153717871

image-20210804153847223

shiro鉴权部分(;截断)

直接定位到shiro处理url的地方org.apache.shiro.web.util.WebUtils#getPathWithinApplication()

这里的request是ShiroHttpServletRequest,继承于HttpServletRequestWrapper,是shiro用来处理web请求自个封装的request

image-20210804155524671

继续跟进getRequestUri(request。没有设置javax.servlet.include.request_uri的话默认是null,所以会进入request.getRequestURI();

image-20210804155842012

继续跟进,其中通过以下调用链来获取到uri的部分,即/xxx/..;/admin/diggid

image-20210804160038597

1
2
3
4
5
requestURI:232, Request (org.apache.coyote)
getRequestURI:2399, Request (org.apache.catalina.connector)
getRequestURI:868, RequestFacade (org.apache.catalina.connector)
getRequestURI:217, HttpServletRequestWrapper (javax.servlet.http)
getRequestUri:139, WebUtils (org.apache.shiro.web.util)

随后调用normalize(decodeAndCleanUriString(request, uri));来对获取的uri进行规范化处理。先跟进decodeAndCleanUriString

image-20210804160414564

该方法用于截断;后面的部分,因此uri变为/xxx/..

继续跟进normalize。根据一下代码,可以得到uri的处理逻辑

  • \\ -> /
  • uri只是/. -> /
  • 不以/开头,则添加开头/
  • // -> /,循环处理
  • /./ -> /
  • /../如果在开头,直接返回null。否则如/admin/../xxx就处理为/xxx
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
private static String normalize(String path, boolean replaceBackSlash) {

if (path == null)
return null;

// Create a place for the normalized path
String normalized = path;

if (replaceBackSlash && normalized.indexOf('\\') >= 0)
normalized = normalized.replace('\\', '/');

if (normalized.equals("/."))
return "/";

// Add a leading "/" if necessary
if (!normalized.startsWith("/"))
normalized = "/" + normalized;

// Resolve occurrences of "//" in the normalized path
while (true) {
int index = normalized.indexOf("//");
if (index < 0)
break;
normalized = normalized.substring(0, index) +
normalized.substring(index + 1);
}

// Resolve occurrences of "/./" in the normalized path
while (true) {
int index = normalized.indexOf("/./");
if (index < 0)
break;
normalized = normalized.substring(0, index) +
normalized.substring(index + 2);
}

// Resolve occurrences of "/../" in the normalized path
while (true) {
int index = normalized.indexOf("/../");
if (index < 0)
break;
if (index == 0)
return (null); // Trying to go outside our context
int index2 = normalized.lastIndexOf('/', index - 1);
normalized = normalized.substring(0, index2) +
normalized.substring(index + 3);
}

// Return the normalized path that we have completed
return (normalized);

}

因此我们的uri经过规范化处理还是/xxx/..

之后就会返回到PathMatchingFilterChainResolver#getChain进行权限匹配

1
2
map.put("/doLogin", "anon");
map.put("/admin/*", "authc");

如果此时uri和权限规则定义的路由匹配上了,那么就会进入相应的地方进行鉴权。而我们这里的路径是/xxx/..自然不匹配/admin/*,所以不会进行鉴权,因此这里就绕过了。这里的匹配引擎(规则)使用的是ant风格的匹配规则

image-20210804161722348

SpringBoot路由分发

绕过了shiro鉴权部分后,就会进入到SpringBoot路由分发的部分。

SpringBoot处理url的地方是UrlPathHelper#getPathWithinServletMapping(),该方法中会对uri处理并返回servletPath

先是获取了几个和uri有关的变量

1
2
3
4
String pathWithinApp = this.getPathWithinApplication(request);
String servletPath = this.getServletPath(request);
String sanitizedPathWithinApp = this.getSanitizedPath(pathWithinApp);
String path;

跟进看一下this.getServletPath(request);

image-20210804163508241

其中是调用request.getServletPath();来获取的,而这里获取到的是已经处理好的/admin/diggid,也就是对原先的uri做了符合预期的正常化处理。

最后获取到的几个变量值如下

image-20210804163616087

经过处理后最后获取到的servletPath就是/admin/diggid

image-20210804164445533

之后就是SpringBoot路由分配的部分了,最后就分配到/admin/{name}的路由,完成权限绕过

image-20210804164659958

修复

shiro在getRequestUri中原先通过getRequestURI()获取uri变成了以下三者的组合,因此这里获取到的就是/admin/diggid

image-20210804165048910

CVE-2020-11989

影响版本

  • Shiro < 1.5.3
  • Spring 框架中只使用 Shiro 鉴权

POC

  • POC1
1
/admin/a%25%32%66a or /admin/a%252fa -> /admin/a%2fa 

image-20210805104152476

  • POC2(需要项目部署时ContextPath不是根目录)
1
/;/context/admin/aaa

image-20210805104030530

分析

POC1(url二次解码\逃逸匹配)

在前面的分析中知道在decodeAndCleanUriString中会进行;的截断处理,再会看一下该方法

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

跟进看一下decodeRequestString方法,其中会对传进来的路径source进行url解码,因此我们POC传进来的URL会经过

1
/admin/a%25%32%66a -> /admin/a%2fa -> /admin/a/a 

image-20210805101950930

然后返回到PathMatchingFilterChainResolver#getChain进行规则匹配鉴权。由于我们的规则是/admin/*,根据ant风格的匹配规则,*可以匹配单个或多个字符,但不能匹配多个路径,类似a/a这样,而**可以。所以/admin/a/a自然就匹配不上/admin/*,所以又绕过了鉴权。

后面SpringBoot分发部分就一样了,通过getServletPath获取到的uri是符合预期的/admin/a%2fa,自然可以命中/admin/{name}

POC2(绕过修复)

POC2只能在项目不部署在根目录时生效。问题出在getRequestUri方法,前面说过该方法通过getContextPath getServletPath getPathInfo三者组合去获取真正的uri,但这里还是会出问题

image-20210805104634376

  • getContextPath:获取到/;/context
  • getServletPath:获取到/admin/aaa
  • getPathInfo:获取空

最后拼接得到/;/context//admin/aaa。因此getContextPath这一步实际上是不正常的,正常的话应该是获取到/context/context/

之后经过decodeAndCleanUriString中的截断处理,最后得到的uri是/,自然不匹配/context/admin/*,因此又绕过了鉴权。所以这里出现问题的点是getContextPath处理不正确,导致/;/context获取到的还是/;/context,而非正常的/context

到SpringBoot的分发部分,uri只是通过getServletPath来获取,因此获取到的就是/admin/aaa,又可以命中路由了。

顺便提一嘴,SpringBoot这边处理uri的方法是前面说到的UrlPathHelper#getPathWithinApplication,和WebUtils的不一样。这两个方法名虽然一样,但是SpringBoot的处理相较于shiro是正确的。跟进到UrlPathHelper#decodeAndCleanUriString中,通过一下三段处理获取到的/;/context/admin/aaa。第一个是去除;,第二个是二次URL解码,第三个是将//替换为/,最终得到/context/admin/aaa。这个uri是正确的。

image-20210805110852711

但是这里和SpringBoot的路由分配没有太大关系,因为路由分配是根据servletPath来分配的。

修复

shiro在1.5.3版本中,使用了标准的 getServletPathgetPathInfo 进行uri处理,同时取消了url解码。前者解决了POC2,后者解决了POC1

image-20210805112817399

CVE-2020-13933

影响版本

  • Shiro < 1.6.0
  • Spring 框架中只使用 Shiro 鉴权

POC

1
/admin/%3baaa

image-20210805113151733

分析(;截断问题)

根据shiro1.5.3的WebUtils#getPathWithinApplication()方法,跟进removeSemicolon中,进行;的截断处理,目的是为了处理类似

/admin/;jessid=xxxx这样的请求,但是这里被我们恶意利用了。这里和前面不一样的地方是,比如/;/admin/aaa经过getServletPath获取后是/admin/aaa,但是/admin/;aaa经过getServletPath获取还是/admin/;aaa,所以就会出现问题

image-20210805114112656

获取到的uri是/admin/,返回到AntPathMatcher中,还会去掉结尾的/,所以最后得到的是/admin,自然匹配不了/admin/*。一开始可能会觉得这里最后去掉/很奇怪,但其实这个处理是正确的,不去掉的话反而又会被绕过,因为假设传进去的是/admin/aaa/,这里处理后得到的还是/admin/aaa/,如果不去掉的话,还是无法匹配/admin/*。因此真正出问题的地方还是getPathWithinApplication()方法的截断处理不当

image-20210805114323829

修复

在shiro1.6.0中,增加了filter类InvalidRequestFilter来对特殊的url进行处理,在isAccessAllowed方法中进行校验

image-20210805115725642

image-20210805120520583

处理完uri之后会进行filter chain的调用,其中就会调用到InnvalidRequestFilter来处理。匹配到这些特殊字符之后就会返回false,最终会使得页面报错502。

image-20210805121145291

CVE-2020-17523

影响版本

  • Apache Shiro < 1.7.1
  • Spring 框架中只使用 Shiro 鉴权

POC

1
2
/admin/%20
%20可替换为%08、%09、%0a、%0d等其他trim可删除的空白字符,但有时不成功,因为SpringBoot + tomcat可能会400

image-20210805121523825

分析

这一次的绕过是针对ant匹配的绕过,之前提到过ant语法规定的匹配规则的具体逻辑实现在AntPathMatcher#doMatch方法中

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
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;
}
pattIdxStart++;
pathIdxStart++;
}

if (pathIdxStart > pathIdxEnd) {
// Path is exhausted, only match if rest of pattern is * or **'s
if (pattIdxStart > pattIdxEnd) {
return (pattern.endsWith(this.pathSeparator) ?
path.endsWith(this.pathSeparator) : !path.endsWith(this.pathSeparator));
}
if (!fullMatch) {
return true;
}
if (pattIdxStart == pattIdxEnd && pattDirs[pattIdxStart].equals("*") &&
path.endsWith(this.pathSeparator)) {
return true;
}
for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
if (!pattDirs[i].equals("**")) {
return false;
}
}
return true;
} else if (pattIdxStart > pattIdxEnd) {
// String not exhausted, but pattern is. Failure.
return false;
} else if (!fullMatch && "**".equals(pattDirs[pattIdxStart])) {
// Path start definitely matches due to "**" part in pattern.
return true;
}

// up to last '**'
while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
String patDir = pattDirs[pattIdxEnd];
if (patDir.equals("**")) {
break;
}
if (!matchStrings(patDir, pathDirs[pathIdxEnd])) {
return false;
}
pattIdxEnd--;
pathIdxEnd--;
}
if (pathIdxStart > pathIdxEnd) {
// String is exhausted
for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
if (!pattDirs[i].equals("**")) {
return false;
}
}
return true;
}

while (pattIdxStart != pattIdxEnd && pathIdxStart <= pathIdxEnd) {
int patIdxTmp = -1;
for (int i = pattIdxStart + 1; i <= pattIdxEnd; i++) {
if (pattDirs[i].equals("**")) {
patIdxTmp = i;
break;
}
}
if (patIdxTmp == pattIdxStart + 1) {
// '**/**' situation, so skip one
pattIdxStart++;
continue;
}
// Find the pattern between padIdxStart & padIdxTmp in str between
// strIdxStart & strIdxEnd
int patLength = (patIdxTmp - pattIdxStart - 1);
int strLength = (pathIdxEnd - pathIdxStart + 1);
int foundIdx = -1;

strLoop:
for (int i = 0; i <= strLength - patLength; i++) {
for (int j = 0; j < patLength; j++) {
String subPat = (String) pattDirs[pattIdxStart + j + 1];
String subStr = (String) pathDirs[pathIdxStart + i + j];
if (!matchStrings(subPat, subStr)) {
continue strLoop;
}
}
foundIdx = pathIdxStart + i;
break;
}

if (foundIdx == -1) {
return false;
}

pattIdxStart = patIdxTmp;
pathIdxStart = foundIdx + patLength;
}

for (int i = pattIdxStart; i <= pattIdxEnd; i++) {
if (!pattDirs[i].equals("**")) {
return false;
}
}

return true;
}

分析一下这里匹配的逻辑:

/分割pattern字符串与path字符串放到两个数组中,分别是pattDirspathDirs,而后循环遍历pattDirs中的值,通过matchStrings函数与pathDirs数组中的值进行匹配,匹配失败会直接返回false(也就是证明没有权限问题)。如果匹配到了的话则会每次使pattIdxStart自增1,表示匹配了一项,而pattIdxEnd是数组的长度减1,当pattIdxStart>pattIdxEnd时,则一次匹配结束。具体的匹配过程如下:

  1. 先进行第一次处理,正常匹配,匹配不到返回false。直到pattIdxStart>pattIdxEnd结束第一次处理
  2. 根据第一次处理的结果进行第二次处理,先判断是否是pathIdxStart > pathIdxEnd的情况(path数组先到结尾或者两者一起到结尾)
    1. 如果pathIdxStart > pathIdxEnd,那么说明path和pat数组等长,那么就判断这两个后缀的情况。如果都是/或都不是/则返回true,否则false。所以path:/admin/aaa/是匹配不到pat:/admin/aaa
    2. pattIdxStart == pattIdxEnd && pattDirs[pattIdxStart].equals("*") && path.endsWith(this.pathSeparator)则相当于这种情况:path:/admin/aaa/,pat:/admin/aaa/*,匹配到就返回true
    3. 最后就是判断是否有**,path:/admin/aaa,pat:/admin/**,没有直接返回false
    4. 判断pattIdxStart > pattIdxEnd,则直接返回false,相当于前面的情况都不符合那么就是不匹配了。
  3. 上面的if分支没进入pathIdxStart > pathIdxEnd,说明是pat先结束或碰到了**,那么决定匹配是否成功的pat就是**。相当于:path:/admin/aaa/bbb,pat:/admin/**

而这里漏洞出现的原因是在一开始获取pathDirsStringUtils.tokenizeToStringArray方法中,使用了trim方法删除了空白字符,导致最后得到的pathDirs["admin"],并且path/admin/[空白字符],根据上面的逻辑,会进入2的逻辑,然后进入2.3的逻辑直接就返回false了。匹配不到patDirs的["admin","*"]

image-20210805141813958

image-20210805142157135

image-20210805142252325

修复

shiro1.7.1,StringUtils.tokenizeToStringArray默认不进行trim操作,因此得到的是["admin"," "],会进入上述逻辑的2,再进入2.1的逻辑,由于path和pat的结尾都不是/,所以返回true匹配成功

image-20210805144044096

总结

这里以一张表作为总结。

漏洞 Shiro版本 POC 原因
CVE-2020-1957 < 1.5.2 (1) /xxx/..;/admin/diggid
(2) /;/admin/diggid
;截断导致处理差异
CVE-2020-11989 < 1.5.3 (1) /admin/a%252fa
(2) /;/context/admin/aaa(部署在context)
(1) 二次url解码为/admin/a/a
(2) getContextPath获取部署目录为未处理;
导致和前面一样的原因
CVE-2020-13933 < 1.6.0 /admin/%3baaa getServletPath()获取问题,导致;截断
CVE-2020-17523 < 1.7.1 /admin/[空白字符,一般是%20] ant匹配规则处理不当。不必要的trim导致绕过匹配

纵观四个漏洞,最大的锅是shiro这边对于路径的处理和匹配不当,SpringBoot这边没啥锅。通过;、二次编码、空白字符截断绕过匹配,最终还是利用shiro和SpringBoot的解析差异来绕过的,而实际这个绕过特指的是shiro的鉴权绕过,绕过之后SpringBoot这边都是能够正常分配到路由的。不过这也得利于Spring控制器的代码层在写路由注解时,使用通配符或者泛解模式,如/admin/{name},如果写死成/admin/diggid,即使绕过了Shiro鉴权,也无法完成SpringBoot这边的路由分配

参考

http://www.lmxspace.com/2020/08/24/Apache-shiro-%E6%9D%83%E9%99%90%E7%BB%95%E8%BF%87%E6%BC%8F%E6%B4%9E%E6%B1%87%E6%80%BB/

https://hpdoger.cn/2021/02/08/title:%20Shiro%E6%9D%83%E9%99%90%E7%BB%95%E8%BF%87%E6%B1%87%E6%80%BB/

https://paper.seebug.org/1196/

CATALOG
  1. 1. 前言
  2. 2. 准备
  3. 3. ant匹配
  4. 4. CVE-2020-1957
    1. 4.1. 影响版本
    2. 4.2. POC
    3. 4.3. 分析
      1. 4.3.1. shiro鉴权部分(;截断)
      2. 4.3.2. SpringBoot路由分发
    4. 4.4. 修复
  5. 5. CVE-2020-11989
    1. 5.1. 影响版本
    2. 5.2. POC
    3. 5.3. 分析
      1. 5.3.1. POC1(url二次解码\逃逸匹配)
      2. 5.3.2. POC2(绕过修复)
    4. 5.4. 修复
  6. 6. CVE-2020-13933
    1. 6.1. 影响版本
    2. 6.2. POC
    3. 6.3. 分析(;截断问题)
    4. 6.4. 修复
  7. 7. CVE-2020-17523
    1. 7.1. 影响版本
    2. 7.2. POC
    3. 7.3. 分析
    4. 7.4. 修复
  8. 8. 总结
  9. 9. 参考