浅析 Shiro 未授权 CVE

浅跟一下。

request 方法的差异性

方法 描述
request.getPathInfo 仅返回传递到Servlet的路径,如果没有传递额外的路径信息,则此返回NULL /test
request.getContextPath 返回工程名部分,如果工程映射为/,则返回为空
request.getServletPath 返回除去Host和工程名部分的路径 /admin
request.getRequestURI 返回除去Host部分的路径 /admin/test
request.getRequestURL 返回全路径 http://localhost:9090/admin/test

Tomcat 的请求转换与映射

首先 Tomcat 会先处理 HTTP 请求,检查起合法性后映射请求到 MappingData,然后再执行引擎的反射判断路由最终调用匹配的方法。

而在映射的过程中,normalize 方法进行了很多的格式化操作,诸如替换 /./ 为空。

调用栈

1
2
3
4
5
6
7
8
9
10
11
12
normalize:1212, CoyoteAdapter (org.apache.catalina.connector)
postParseRequest:652, CoyoteAdapter (org.apache.catalina.connector)
service:351, CoyoteAdapter (org.apache.catalina.connector)
service:382, Http11Processor (org.apache.coyote.http11)
process:65, AbstractProcessorLight (org.apache.coyote)
process:893, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1723, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:750, Thread (java.lang)

postParseRequest

首先是 CoyoteAdapter 类的 postParseRequest 方法,调用 normalize 进行标准化判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
// CoyoteAdapter (org.apache.catalina.connector)
protected boolean postParseRequest(org.apache.coyote.Request req, Request request, org.apache.coyote.Response res, Response response) throws IOException, ServletException {
// Normalization
if (normalize(req.decodedURI())) {
// Character decoding
convertURI(decodedURI, request);
if (!checkNormalize(req.decodedURI())) {
response.sendError(400, "Invalid URI");
}
} else {
response.sendError(400, "Invalid URI");
}
}

normalize

normalize 方法匹配到 /. 结尾的 URI,末尾添加 /,然后截断,因此当 URI/admin/. 时,先处理为 /admin/./ ,然后替换为空得到 /admin/,但此时并没有直接赋值,而是进行了 setEnd 操作,标记了结束位。

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
// CoyoteAdapter (org.apache.catalina.connector)
public static boolean normalize(MessageBytes uriMB) {

ByteChunk uriBC = uriMB.getByteChunk();
final byte[] b = uriBC.getBytes();
final int start = uriBC.getStart();
int end = uriBC.getEnd();

// An empty URL is not acceptable
if (start == end) {
return false;
}

int pos = 0;
int index = 0;

// Replace '\' with '/'
// Check for null byte
for (pos = start; pos < end; pos++) {
if (b[pos] == (byte) '\\') {
if (ALLOW_BACKSLASH) {
b[pos] = (byte) '/';
} else {
return false;
}
}
if (b[pos] == (byte) 0) {
return false;
}
}

// The URL must start with '/'
if (b[start] != (byte) '/') {
return false;
}

// Replace "//" with "/"
for (pos = start; pos < (end - 1); pos++) {
if (b[pos] == (byte) '/') {
while ((pos + 1 < end) && (b[pos + 1] == (byte) '/')) {
copyBytes(b, pos, pos + 1, end - pos - 1);
end--;
}
}
}

// If the URI ends with "/." or "/..", then we append an extra "/"
// Note: It is possible to extend the URI by 1 without any side effect
// as the next character is a non-significant WS.
if (((end - start) >= 2) && (b[end - 1] == (byte) '.')) {
if ((b[end - 2] == (byte) '/')
|| ((b[end - 2] == (byte) '.')
&& (b[end - 3] == (byte) '/'))) {
b[end] = (byte) '/';
end++;
}
}

uriBC.setEnd(end);

index = 0;

// Resolve occurrences of "/./" in the normalized path
while (true) {
index = uriBC.indexOf("/./", 0, 3, index);
if (index < 0) {
break;
}
copyBytes(b, start + index, start + index + 2,
end - start - index - 2);
end = end - 2;
uriBC.setEnd(end);
}

index = 0;

// Resolve occurrences of "/../" in the normalized path
while (true) {
index = uriBC.indexOf("/../", 0, 4, index);
if (index < 0) {
break;
}
// Prevent from going outside our context
if (index == 0) {
return false;
}
int index2 = -1;
for (pos = start + index - 1; (pos >= 0) && (index2 < 0); pos --) {
if (b[pos] == (byte) '/') {
index2 = pos;
}
}
copyBytes(b, start + index2, start + index + 3,
end - start - index - 3);
end = end + index2 - index - 3;
uriBC.setEnd(end);
index = index2;
}

return true;
}

convertURI

如果 normalize 的合法性校验通过,此时才会调用 convertURI 方法,完成 URI 的标准化操作。

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
// CoyoteAdapter (org.apache.catalina.connector)
protected void convertURI(MessageBytes uri, Request request) throws IOException {

ByteChunk bc = uri.getByteChunk();
int length = bc.getLength();
CharChunk cc = uri.getCharChunk();
cc.allocate(length, -1);

Charset charset = connector.getURICharset();

B2CConverter conv = request.getURIConverter();
if (conv == null) {
conv = new B2CConverter(charset, true);
request.setURIConverter(conv);
} else {
conv.recycle();
}

try {
conv.convert(bc, cc, true);
uri.setChars(cc.getBuffer(), cc.getStart(), cc.getLength());
} catch (IOException ioe) {
// Should never happen as B2CConverter should replace
// problematic characters
request.getResponse().sendError(HttpServletResponse.SC_BAD_REQUEST);
}
}

map

返回标准化的 URI 之后,Tomcat 开始使用 map 方法处理映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Mapper (org.apache.catalina.mapper)
public void map(MessageBytes host, MessageBytes uri, String version,
MappingData mappingData) throws IOException {

if (host.isNull()) {
String defaultHostName = this.defaultHostName;
if (defaultHostName == null) {
return;
}
host.getCharChunk().append(defaultHostName);
}
host.toChars();
uri.toChars();
internalMap(host.getCharChunk(), uri.getCharChunk(), version, mappingData);
}

internalMapWrapper

map 调用 internalMap ,然后继续调用 internalMapWrapper,在 internalMapWrapper 中对 mappingData 的成员变量 wrapperPath 进行了 setChars 赋值操作。

1
2
3
4
5
6
7
8
9
10
11
// Mapper (org.apache.catalina.mapper)
private final void internalMapWrapper(ContextVersion contextVersion, CharChunk path, MappingData mappingData) throws IOException {
// ...
if (contextVersion.defaultWrapper != null) {
mappingData.wrapper = contextVersion.defaultWrapper.object;
mappingData.requestPath.setChars(path.getBuffer(), path.getStart(), path.getLength());
mappingData.wrapperPath.setChars(path.getBuffer(), path.getStart(), path.getLength());
mappingData.matchType = MappingMatch.DEFAULT;
}
// ...
}

CVE-2010-3863

漏洞简介

影响版本:shiro < 1.1.0

漏洞描述:Shiro 没有处理 /./Spring Controller 处理了,因为这样对 URL 解析的不同操作,匹配到不同的过滤链,导致权限绕过。

测试环境:1.0.0-incubatingSpringBoot 2.2.6.RELEASE

漏洞分析

配置如下。

1
2
# application.properties
server.port=9090

路由如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Bean
ShiroFilterFactoryBean shiroFilterFactoryBean() {
ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
bean.setSecurityManager(securityManager());
bean.setLoginUrl("/login");
bean.setSuccessUrl("/index");
bean.setUnauthorizedUrl("/unauthorizedurl");
Map<String, String> map = new LinkedHashMap<>();
map.put("/doLogin", "anon");
map.put("/admin/**", "authc");
map.put("/**", "anon");
bean.setFilterChainDefinitionMap(map);
return bean;
}

HTTP 请求如下。

1
GET http://127.0.0.1:9090/./admin/whoami

/./ 绕过

Shiro 处理部分

getChain

getChain 调用 getPathWithinApplication 获取 URI/./admin/whoami,然后 for 循环匹配到 /** 而不是 /admin/**,而后 proxy 根据 /** 获取 anno 路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
}

String requestURI = getPathWithinApplication(request);

//the 'chain names' in this implementation are actually path patterns defined by the user. We just use them
//as the chain name for the FilterChainManager's requirements
for (String pathPattern : filterChainManager.getChainNames()) {

// If the path does match, then pass on to the subclass implementation for specific checks:
if (pathMatches(pathPattern, requestURI)) {
if (log.isTraceEnabled()) {
log.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + requestURI + "]. " +
"Utilizing corresponding filter chain...");
}
return filterChainManager.proxy(originalChain, pathPattern);
}
}

return null;
}
getPathWithinApplication

getPathWithinApplication 中调用 getRequestUri 获取 URI

1
2
3
4
5
6
7
8
9
10
11
12
13
// WebUtils (org.apache.shiro.web.util)
public static String getPathWithinApplication(HttpServletRequest request) {
String contextPath = getContextPath(request);
String requestUri = getRequestUri(request);
if (StringUtils.startsWithIgnoreCase(requestUri, contextPath)) {
// Normal case: URI contains context path.
String path = requestUri.substring(contextPath.length());
return (StringUtils.hasText(path) ? path : "/");
} else {
// Special case: rather unusual.
return requestUri;
}
}
getRequestUri

getRequestUri 传入 decodeAndCleanUriString 进行一些格式化处理。

1
2
3
4
5
6
7
8
// WebUtils (org.apache.shiro.web.util)
public static String getRequestUri(HttpServletRequest request) {
String uri = (String) request.getAttribute(INCLUDE_REQUEST_URI_ATTRIBUTE);
if (uri == null) {
uri = request.getRequestURI();
}
return decodeAndCleanUriString(request, uri);
}
decodeAndCleanUriString

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);
}
proxy

proxy 中根据 chainName 获取到 anno 路由。

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

Tomcat 处理部分

getHandlerInternal

getHandlerInternal 使用 getLookupPathForRequestgetPathWithinServletMapping 调用 getServletPath 返回的是 /admin/whoami

然后 getHandlerInternal 使用 lookupHandlerMethod 方法,而 lookupHandlerMethod 调用 addMatchingMappings 匹配到 /admin/{name}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// AbstractHandlerMethodMapping (org.springframework.web.servlet.handler)
@Override
protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
// 使用 getLookupPathForRequest 转 getPathWithinServletMapping 调用 getServletPath 返回的是 /admin/whoami。
String lookupPath = getUrlPathHelper().getLookupPathForRequest(request);
request.setAttribute(LOOKUP_PATH, lookupPath);
this.mappingRegistry.acquireReadLock();
try {
// lookupHandlerMethod 调用 addMatchingMappings 匹配到 /admin/{name}。
HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
}
finally {
this.mappingRegistry.releaseReadLock();
}
}
getPathWithinServletMapping

getPathWithinServletMapping 最后返回的是 getServletPath 的值。

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
// UrlPathHelper (org.springframework.web.util)
public String getPathWithinServletMapping(HttpServletRequest request) {
String pathWithinApp = getPathWithinApplication(request);
String servletPath = getServletPath(request);
String sanitizedPathWithinApp = getSanitizedPath(pathWithinApp);
String path;

// If the app container sanitized the servletPath, check against the sanitized version
if (servletPath.contains(sanitizedPathWithinApp)) {
path = getRemainingPath(sanitizedPathWithinApp, servletPath, false);
}
else {
path = getRemainingPath(pathWithinApp, servletPath, false);
}

if (path != null) {
// Normal case: URI contains servlet path.
return path;
}
else {
// Special case: URI is different from servlet path.
String pathInfo = request.getPathInfo();
if (pathInfo != null) {
// Use path info if available. Indicates index page within a servlet mapping?
// e.g. with index page: URI="/", servletPath="/index.html"
return pathInfo;
}
if (!this.urlDecode) {
// No path info... (not mapped by prefix, nor by extension, nor "/*")
// For the default servlet mapping (i.e. "/"), urlDecode=false can
// cause issues since getServletPath() returns a decoded path.
// If decoding pathWithinApp yields a match just use pathWithinApp.
path = getRemainingPath(decodeInternal(request, pathWithinApp), servletPath, false);
if (path != null) {
return pathWithinApp;
}
}
// Otherwise, use the full servlet path.
return servletPath;
}
}
getServletPath

跟进 getServletPath,可见是调用了映射处理时候封装的 wrapperPath 成员变量,而 wrapperPath 在解析 HTTP 请求的时候就处理过,其中的 /./ 被替换为空,因此返回了 /admin/whoami

1
2
3
4
5
// Request (org.apache.catalina.connector)
@Override
public String getServletPath() {
return mappingData.wrapperPath.toString();
}
lookupHandlerMethod

最后在 lookupHandlerMethodaddMatchingMappings 方法中匹配到 /admin/{name}

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
// AbstractHandlerMethodMapping (org.springframework.web.servlet.handler)
@Nullable
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
List<Match> matches = new ArrayList<>();
List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);
if (directPathMatches != null) {
addMatchingMappings(directPathMatches, matches, request);
}
if (matches.isEmpty()) {
// No choice but to go through all mappings...
addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request);
}

if (!matches.isEmpty()) {
Match bestMatch = matches.get(0);
if (matches.size() > 1) {
Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
matches.sort(comparator);
bestMatch = matches.get(0);
if (logger.isTraceEnabled()) {
logger.trace(matches.size() + " matching mappings: " + matches);
}
if (CorsUtils.isPreFlightRequest(request)) {
return PREFLIGHT_AMBIGUOUS_MATCH;
}
Match secondBestMatch = matches.get(1);
if (comparator.compare(bestMatch, secondBestMatch) == 0) {
Method m1 = bestMatch.handlerMethod.getMethod();
Method m2 = secondBestMatch.handlerMethod.getMethod();
String uri = request.getRequestURI();
throw new IllegalStateException(
"Ambiguous handler methods mapped for '" + uri + "': {" + m1 + ", " + m2 + "}");
}
}
request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.handlerMethod);
handleMatch(bestMatch.mapping, lookupPath, request);
return bestMatch.handlerMethod;
}
else {
return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request);
}
}

漏洞修复

ShiroCommit 中新增了 normalize 方法用于格式化。

CVE-2016-6802

CVE-2020-1957

CVE-2020-11989

漏洞简介

影响版本:shiro < 1.5.3

漏洞描述:在 Shiro < 1.5.3 的情况下,将 ShiroSpring Controller 一起使用时,由于对 URL 解析的不同操作,匹配到不同的过滤链,导致权限绕过。

测试环境:Shiro 1.5.2SpringBoot 2.2.6.RELEASE

漏洞分析

为了修复 CVE-2020-1957,shiro1.5.2 版本进行了更新,将 request.getRequestURI() 修改为 request.getContextPath()request.getServletPath()request.getPathInfo() 拼接构造 URI

context-path 绕过

配置如下。

1
2
3
# application.properties
server.port=9090
server.servlet.context-path=/test

HTTP 请求如下。

1
GET http://127.0.0.1:9090/test;/admin/cmd

Shiro 处理部分

executeChain

executeChain 方法开始,执行过滤链,先调用 getExecutionChain 方法获取 Shiro 配置的过滤链,然后执行 doFilter 方法进行过滤。

1
2
3
4
5
6
// AbstractShiroFilter (org.apache.shiro.web.servlet)
protected void executeChain(ServletRequest request, ServletResponse response, FilterChain origChain)
throws IOException, ServletException {
FilterChain chain = getExecutionChain(request, response, origChain);
chain.doFilter(request, response);
}
getExecutionChain

getFilterChainResolver 方法获取解析器,然后调用 getChain 根据 URL 获取要执行的链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// AbstractShiroFilter (org.apache.shiro.web.servlet)
protected FilterChain getExecutionChain(ServletRequest request, ServletResponse response, FilterChain origChain) {
FilterChain chain = origChain;

FilterChainResolver resolver = getFilterChainResolver();
if (resolver == null) {
log.debug("No FilterChainResolver configured. Returning original FilterChain.");
return origChain;
}

FilterChain resolved = resolver.getChain(request, response, origChain);
if (resolved != null) {
log.trace("Resolved a configured FilterChain for the current request.");
chain = resolved;
} else {
log.trace("No FilterChain configured for the current request. Using the default.");
}

return chain;
}
getChain

调用 getPathWithinApplication 方法拿到 requestURI/test ,去匹配 shiroFilterFactoryBean中 的 filter,匹配不到,返回为空。

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
// PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
}

// 获取到的URI为 "/test"
String requestURI = getPathWithinApplication(request); // requestURI: "/test"

// 防止 "/resource/menus" 绕过 "/resource/menus/" 的保护
if(requestURI != null && !DEFAULT_PATH_SEPARATOR.equals(requestURI)
&& requestURI.endsWith(DEFAULT_PATH_SEPARATOR)) {
requestURI = requestURI.substring(0, requestURI.length() - 1);
}

// ["/doLogin", "/admin/*"]
for (String pathPattern : filterChainManager.getChainNames()) {
if (pathPattern != null && !DEFAULT_PATH_SEPARATOR.equals(pathPattern)
&& pathPattern.endsWith(DEFAULT_PATH_SEPARATOR)) {
pathPattern = pathPattern.substring(0, pathPattern.length() - 1);
}

if (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);
}
}

return null;
}
getPathWithinApplication

之所以在 getChain 中匹配不到,是因为 getPathWithinApplication 获取 URI 中部署路径之后的路径,例如 context-path/test/ 的时候 /test/admin/cmd 返回 /admin/cmd, 而 /test;/admin/cmd 由于解析差异则返回了 /test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// WebUtils (org.apache.shiro.web.util)
public static String getPathWithinApplication(HttpServletRequest request) {
// 获取当前Web应用程序的上下文路径
String contextPath = getContextPath(request); // contextPath: "/test;"
// 获取当前请求的请求URI
String requestUri = getRequestUri(request); // requestUri: "/test"
// 检查请求URI是否以上下文路径开头
// 如果 "/test/admin/cmd" 则最后会返回 "/admin/cmd", 而 "/test;/admin/cmd" 由于解析差异则返回了 "/test"
if (StringUtils.startsWithIgnoreCase(requestUri, contextPath)) {
String path = requestUri.substring(contextPath.length());
return (StringUtils.hasText(path) ? path : "/");
} else {
return requestUri; // requestUri: "/test"
}
}
getContextPath

返回给定请求的上下文路径,即前两个 / 之间的参数 /test;

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
public String getContextPath() {
int lastSlash = mappingData.contextSlashCount;
// Special case handling for the root context
if (lastSlash == 0) {
return "";
}

String canonicalContextPath = getServletContext().getContextPath(); // canonicalContextPath: "/test"

String uri = getRequestURI(); // uri: "/test;/admin/cmd"
int pos = 0;
if (!getContext().getAllowMultipleLeadingForwardSlashInPath()) {
// 确保以 "/" 开头
do {
pos++;
} while (pos < uri.length() && uri.charAt(pos) == '/');
pos--;
uri = uri.substring(pos);
}

char[] uriChars = uri.toCharArray(); // uriChars: [/, t, e, s, t, ;, /, a, d, m, i, n, /, c, m, d]
// Need at least the number of slashes in the context path
while (lastSlash > 0) {
pos = nextSlash(uriChars, pos + 1); // pos: 6
if (pos == -1) {
break;
}
lastSlash--;
}
// 规范化路径
String candidate;
if (pos == -1) {
candidate = uri;
} else {
candidate = uri.substring(0, pos); // candidate: "/test;"
}
// 移除 ";" 返回 "/test"
candidate = removePathParameters(candidate);
// URL解码
candidate = UDecoder.URLDecode(candidate, connector.getURICharset());
// 替换 "\\" 为 "/", 若URL不以 "/" 开头则加上,替换 "//"、"/./"、"/../" 为 "/"
candidate = org.apache.tomcat.util.http.RequestUtil.normalize(candidate);
// 检测处理前后是否相等
boolean match = canonicalContextPath.equals(candidate);
while (!match && pos != -1) {
pos = nextSlash(uriChars, pos + 1);
if (pos == -1) {
candidate = uri;
} else {
candidate = uri.substring(0, pos);
}
candidate = removePathParameters(candidate);
candidate = UDecoder.URLDecode(candidate, connector.getURICharset());
candidate = org.apache.tomcat.util.http.RequestUtil.normalize(candidate);
match = canonicalContextPath.equals(candidate);
}
if (match) {
if (pos == -1) {
return uri;
} else {
return uri.substring(0, pos); // return: "/test;"
}
} else {
// Should never happen
throw new IllegalStateException(sm.getString(
"coyoteRequest.getContextPath.ise", canonicalContextPath, uri));
}
}
getRequestUri

Shiro 中获取的 URI 还未经 servlet 解码,因此需要解码一次,对于如 JBoss/Jetty 等容器,在 URI 使用如 ;jsessionid 样式进行传参,因此需要进行截断,返回 /test

1
2
3
4
5
6
7
8
9
10
// WebUtils (org.apache.shiro.web.util)
public static String getRequestUri(HttpServletRequest request) {
String uri = (String) request.getAttribute(INCLUDE_REQUEST_URI_ATTRIBUTE);
if (uri == null) {
uri = valueOrEmpty(request.getContextPath()) + "/" + // "/test;"
valueOrEmpty(request.getServletPath()) + // "/admin/cmd"
valueOrEmpty(request.getPathInfo()); // ""
} // uri: "/test;//admin/cmd"
return normalize(decodeAndCleanUriString(request, uri));
}
decodeAndCleanUriString

该方法对传入的 URI 字符串进行解码,并删除任何在分号 ; 字符之后的多余部分。

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

Tomcat 处理部分

doFilter

executeChain 调用 doFilter 之后进入 Tomcat 处理的部分。

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
// ApplicationFilterChain (org.apache.catalina.core)
@Override
public void doFilter(ServletRequest request, ServletResponse response)
throws IOException, ServletException {

if( Globals.IS_SECURITY_ENABLED ) {
final ServletRequest req = request;
final ServletResponse res = response;
try {
java.security.AccessController.doPrivileged(
new java.security.PrivilegedExceptionAction<Void>() {
@Override
public Void run()
throws ServletException, IOException {
internalDoFilter(req,res);
return null;
}
}
);
} catch( PrivilegedActionException pe) {
Exception e = pe.getException();
if (e instanceof ServletException)
throw (ServletException) e;
else if (e instanceof IOException)
throw (IOException) e;
else if (e instanceof RuntimeException)
throw (RuntimeException) e;
else
throw new ServletException(e.getMessage(), e);
}
} else {
internalDoFilter(request,response);
}
}
getPathWithinServletMapping

返回请求 URLservlet 之后的部分,例如 context-path/test/ 的时候 /test/admin/cmd 返回 /admin/cmd, /test;/admin/cmd 返回 /admin/cmd

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
// UrlPathHelper (org.springframework.web.util)
public String getPathWithinServletMapping(HttpServletRequest request) {
String pathWithinApp = getPathWithinApplication(request); // pathWithinApp: "/test/admin/cmd"
String servletPath = getServletPath(request); // servletPath: "/admin/cmd"
// 替换//为/
String sanitizedPathWithinApp = getSanitizedPath(pathWithinApp); // sanitizedPathWithinApp: "/test/admin/cmd"
String path;


if (servletPath.contains(sanitizedPathWithinApp)) {
path = getRemainingPath(sanitizedPathWithinApp, servletPath, false);
}
else {
path = getRemainingPath(pathWithinApp, servletPath, false); // path: null
}

if (path != null) {
return path;
}
else {
String pathInfo = request.getPathInfo(); // pathInfo: null
if (pathInfo != null) {
return pathInfo;
}
if (!this.urlDecode) {
path = getRemainingPath(decodeInternal(request, pathWithinApp), servletPath, false);
if (path != null) {
return pathWithinApp;
}
}
return servletPath; // servletPath: "/admin/cmd"
}
}
getPathWithinApplication

其中的 getRemainingPath 将给定的 mappingrequestUri 的开头进行匹配,如果匹配成功,则返回额外的部分。之所以需要此方法,是因为 HttpServletRequest 返回的上下文路径和 servlet 路径不同,requestUri 保留了分号内容用于传参。

1
2
3
4
5
6
7
8
9
10
11
12
// UrlPathHelper (org.springframework.web.util)
public String getPathWithinApplication(HttpServletRequest request) {
String contextPath = getContextPath(request); // contextPath: "/test;"
String requestUri = getRequestUri(request); // requestUri: "/test/admin/cmd"
String path = getRemainingPath(requestUri, contextPath, true); // path: null
if (path != null) {
return (StringUtils.hasText(path) ? path : "/");
}
else {
return requestUri; // requestUri: "/test/admin/cmd"
}
}
getRequestUri

getRequestUri 返回的是 /test/admin/cmd,主要是经过了 decodeAndCleanUriString 方法的解析。

1
2
3
4
5
6
7
8
// UrlPathHelper (org.springframework.web.util)
public String getRequestUri(HttpServletRequest request) {
String uri = (String) request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE); // uri: "/test;/admin/cmd"
if (uri == null) {
uri = request.getRequestURI();
}
return decodeAndCleanUriString(request, uri); // return: "/test/admin/cmd"
}
decodeAndCleanUriString

而在 decodeAndCleanUriString 方法中最主要的还是 removeSemicolonContent 方法,

1
2
3
4
5
6
7
// UrlPathHelper (org.springframework.web.util)
private String decodeAndCleanUriString(HttpServletRequest request, String uri) {
uri = removeSemicolonContent(uri);
uri = decodeRequestString(request, uri);
uri = getSanitizedPath(uri);
return uri;
}
removeSemicolonContent

this.removeSemicolonContenttrue 的时候,调用 removeSemicolonContentInternal 方法。

1
2
3
4
5
// UrlPathHelper (org.springframework.web.util)
public String removeSemicolonContent(String requestUri) {
return (this.removeSemicolonContent ?
removeSemicolonContentInternal(requestUri) : removeJsessionid(requestUri));
}
removeSemicolonContentInternal

循环检测并删除 ; 使得 /test;/admin/cmd 变成 /test/admin/cmd

1
2
3
4
5
6
7
8
9
10
11
// UrlPathHelper (org.springframework.web.util)
private String removeSemicolonContentInternal(String requestUri) { // requestUri: "/test;/admin/cmd"
int semicolonIndex = requestUri.indexOf(';'); // semicolonIndex: 5
while (semicolonIndex != -1) {
int slashIndex = requestUri.indexOf('/', semicolonIndex); // slashIndex: 6
String start = requestUri.substring(0, semicolonIndex); // start: "/test"
requestUri = (slashIndex != -1) ? start + requestUri.substring(slashIndex) : start;
semicolonIndex = requestUri.indexOf(';', semicolonIndex);
}
return requestUri; // requestUri: "/test/admin/cmd"
}
getHandlerInternal

getPathWithinServletMapping 返回的 /admin/cmd 进入 lookupHandlerMethod 方法寻找相匹配的 serlvet,因为 Shiro 的差异性解析没有匹配上,最后进入 SpringBoot 匹配到 admin 路由句柄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// AbstractHandlerMapping (org.springframework.web.servlet.handler)
@Override
protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
String lookupPath = getUrlPathHelper().getLookupPathForRequest(request); // lookupPath: "/admin/cmd"
request.setAttribute(LOOKUP_PATH, lookupPath);
this.mappingRegistry.acquireReadLock();
try {
HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null); // handlerMethod: "st.southsea.shiro.LoginController#admin(String)"
}
finally {
this.mappingRegistry.releaseReadLock();
}
}

双层 URL 编码绕过

配置如下

1
2
# application.properties
server.port=9090

HTTP 请求如下

1
GET http://127.0.0.1:9090/admin/a%252Fb

context-path 绕过类似的调用过程。

Shiro 处理部分

executeChain

executeChain 方法开始,执行过滤链,先调用 getExecutionChain 方法获取 shiro 配置的过滤链,然后执行 doFilter 方法进行过滤。

1
2
3
4
5
6
// AbstractShiroFilter (org.apache.shiro.web.servlet)
protected void executeChain(ServletRequest request, ServletResponse response, FilterChain origChain)
throws IOException, ServletException {
FilterChain chain = getExecutionChain(request, response, origChain);
chain.doFilter(request, response);
}
调用栈

一路往下一直到 getRequestUri 方法。

1
2
3
4
5
getRequestUri:143, WebUtils (org.apache.shiro.web.util)
getPathWithinApplication:113, WebUtils (org.apache.shiro.web.util)
getPathWithinApplication:164, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getChain:103, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getExecutionChain:415, AbstractShiroFilter (org.apache.shiro.web.servlet)
getRequestUri

此处 getServletPath 获取的值是经过一次 URL 解码的。

1
2
3
4
5
6
7
8
9
10
// WebUtils (org.apache.shiro.web.util)
public static String getRequestUri(HttpServletRequest request) {
String uri = (String) request.getAttribute(INCLUDE_REQUEST_URI_ATTRIBUTE);
if (uri == null) {
uri = valueOrEmpty(request.getContextPath()) + "/" + // ""
valueOrEmpty(request.getServletPath()) + // "/admin/a%2fb"
valueOrEmpty(request.getPathInfo()); // ""
} // uri: "/test;//admin/cmd"
return normalize(decodeAndCleanUriString(request, uri));
}
decodeAndCleanUriString

而后调用的 decodeAndCleanUriString 方法中又做了一次 URL 解码,返回的值为 /admin/a/b,无法在 shiro 中根据 Ant 规则匹配到 /admin/*,因此同样的,getChain 方法返回空。

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

Tomcat 处理部分

调用栈

一路往下一直到 SpringBootgetRequestUri 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
getRequestUri:349, UrlPathHelper (org.springframework.web.util)
getPathWithinApplication:265, UrlPathHelper (org.springframework.web.util)
getPathWithinServletMapping:216, UrlPathHelper (org.springframework.web.util)
getLookupPathForRequest:172, UrlPathHelper (org.springframework.web.util)
getHandlerInternal:363, AbstractHandlerMethodMapping (org.springframework.web.servlet.handler)
getHandlerInternal:110, RequestMappingInfoHandlerMapping (org.springframework.web.servlet.mvc.method)
getHandlerInternal:59, RequestMappingInfoHandlerMapping (org.springframework.web.servlet.mvc.method)
getHandler:395, AbstractHandlerMapping (org.springframework.web.servlet.handler)
getHandler:1234, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1016, DispatcherServlet (org.springframework.web.servlet)
doService:943, DispatcherServlet (org.springframework.web.servlet)
processRequest:1006, FrameworkServlet (org.springframework.web.servlet)
doGet:898, FrameworkServlet (org.springframework.web.servlet)
service:634, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:741, HttpServlet (javax.servlet.http)
internalDoFilter:231, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
executeChain:449, AbstractShiroFilter (org.apache.shiro.web.servlet)
getRequestUri

SpringBootgetRequestUri 方法中调用的 getRequestURI 方法返回的是原始的值 /admin/a%252Fb,并未经过 URL 解码。

1
2
3
4
5
6
7
8
// UrlPathHelper (org.springframework.web.util)
public String getRequestUri(HttpServletRequest request) {
String uri = (String) request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE);
if (uri == null) {
uri = request.getRequestURI();
}
return decodeAndCleanUriString(request, uri);
}
decodeAndCleanUriString

而后调用 decodeAndCleanUriString 方法解码一次,返回 /admin/a%2fb,匹配到 admin/* 路由,成功绕过。

1
2
3
4
5
6
7
// UrlPathHelper (org.springframework.web.util)
private String decodeAndCleanUriString(HttpServletRequest request, String uri) {
uri = removeSemicolonContent(uri);
uri = decodeRequestString(request, uri);
uri = getSanitizedPath(uri);
return uri;
}

漏洞修复

ShiroCommit 中修改了 URL 获取的逻辑。

WebUtils#getPathWithinApplication 中,使用 getServletPath(request) + getPathInfo(request)

WebUtils#getRequestUri 中,修改处理逻辑与 SpringBoot 一致。

CVE-2020-13933

漏洞简介

影响版本:shiro < 1.6.0

漏洞描述:Shiro 由于处理身份验证请求时存在权限绕过漏洞,特制的HTTP请求可以绕过身份验证过程并获得对应用程序的未授权访问。

测试环境:Shiro 1.5.3SpringBoot 2.2.6.RELEASE

漏洞分析

; 绕过

配置如下。

1
2
# application.properties
server.port=9090

HTTP 请求如下。

1
GET http://127.0.0.1:9090/admin/%3bcmd

Shiro 处理部分

调用栈

一路往下一直到 getPathWithinApplication 方法。

1
2
3
4
5
getPathWithinApplication:112, WebUtils (org.apache.shiro.web.util)
getPathWithinApplication:164, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getChain:103, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getExecutionChain:415, AbstractShiroFilter (org.apache.shiro.web.servlet)
executeChain:448, AbstractShiroFilter (org.apache.shiro.web.servlet)
getPathWithinApplication

同上一个版本类似,即使修复了,但是在 getPathWithinApplication 中调用的 getServletPath 方法获得的 URL 是依旧是经过 URL 解码的,然后才调用了 removeSemicolon 方法,移除了 ; 之后的字符串,返回了 /admin/

1
2
3
4
// WebUtils (org.apache.shiro.web.util)
public static String getPathWithinApplication(HttpServletRequest request) {
return normalize(removeSemicolon(getServletPath(request) + getPathInfo(request)));
}
removeSemicolon
1
2
3
4
5
// WebUtils (org.apache.shiro.web.util)
private static String removeSemicolon(String uri) { // uri: "/admin/;cmd"
int semicolonIndex = uri.indexOf(59);
return semicolonIndex != -1 ? uri.substring(0, semicolonIndex) : uri; // "/admin/"
}
getChain

getChain 中获取到 requestURI/admin/,然后被 Shiro 的安全机制替换掉了最后的斜杠,得到 /admin,接着循环遍历匹配路径,由于配置为 /admin/*,不会匹配到 /admin,因此返回空。

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
// PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
}

String requestURI = getPathWithinApplication(request);

if(requestURI != null && !DEFAULT_PATH_SEPARATOR.equals(requestURI)
&& requestURI.endsWith(DEFAULT_PATH_SEPARATOR)) {
requestURI = requestURI.substring(0, requestURI.length() - 1);
}


for (String pathPattern : filterChainManager.getChainNames()) {
if (pathPattern != null && !DEFAULT_PATH_SEPARATOR.equals(pathPattern)
&& pathPattern.endsWith(DEFAULT_PATH_SEPARATOR)) {
pathPattern = pathPattern.substring(0, pathPattern.length() - 1);
}

// If the path does match, then pass on to the subclass implementation for specific checks:
if (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);
}
}

return null;
}

Tomcat 处理部分

调用栈

一路往下一直到 SpringBootgetRequestUri 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
getRequestUri:349, UrlPathHelper (org.springframework.web.util)
getPathWithinApplication:265, UrlPathHelper (org.springframework.web.util)
getPathWithinServletMapping:216, UrlPathHelper (org.springframework.web.util)
getLookupPathForRequest:172, UrlPathHelper (org.springframework.web.util)
getHandlerInternal:363, AbstractHandlerMethodMapping (org.springframework.web.servlet.handler)
getHandlerInternal:110, RequestMappingInfoHandlerMapping (org.springframework.web.servlet.mvc.method)
getHandlerInternal:59, RequestMappingInfoHandlerMapping (org.springframework.web.servlet.mvc.method)
getHandler:395, AbstractHandlerMapping (org.springframework.web.servlet.handler)
getHandler:1234, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1016, DispatcherServlet (org.springframework.web.servlet)
doService:943, DispatcherServlet (org.springframework.web.servlet)
processRequest:1006, FrameworkServlet (org.springframework.web.servlet)
doGet:898, FrameworkServlet (org.springframework.web.servlet)
service:634, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:741, HttpServlet (javax.servlet.http)
internalDoFilter:231, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
executeChain:449, AbstractShiroFilter (org.apache.shiro.web.servlet)
getRequestUri

同上一个 CVE 的处理方式一样,在 SpringBootgetRequestUri 方法中调用的 getRequestURI 方法返回的是原始的值 /admin/%3bcmd,并未经过 URL 解码。

1
2
3
4
5
6
7
8
// UrlPathHelper (org.apache.shiro.web.util)
public String getRequestUri(HttpServletRequest request) {
String uri = (String) request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE);
if (uri == null) {
uri = request.getRequestURI();
}
return decodeAndCleanUriString(request, uri);
}
decodeAndCleanUriString

而后先移除了 ;,然后才进行 URL 解码,得到 /admin/;cmd,因此匹配到 /admin/*,得以绕过。

1
2
3
4
5
6
7
// UrlPathHelper (org.springframework.web.util)
private String decodeAndCleanUriString(HttpServletRequest request, String uri) {
uri = removeSemicolonContent(uri);
uri = decodeRequestString(request, uri);
uri = getSanitizedPath(uri);
return uri;
}

漏洞修复

Commit 如下,org.apache.shiro.spring.web#ShiroFilterFactoryBean 中增加了 /** 的默认路径配置,使其可以全局匹配进行过滤校验。

默认的 /** 配置对应一个全局的 InvalidRequestFilter,这个类继承了 AccessControlFilter。用来过滤特殊字符(分号、反斜线、非 ASCII 码字符),并返回 400 状态码。

CVE-2020-17510

漏洞简介

影响版本:shiro < 1.7.0

漏洞描述:第三种 AntPathMatcher 的绕过方式

测试环境:Shiro 1.6.0SpringBoot 2.5.3

漏洞分析

这个漏洞还是对 AntPathMatcher 的继续绕过,在 CVE-2020-11989CVE-2020-13933 分别尝试了 / 的双重 URL 编码和 ;URL 编码绕过,归根到底这种方式还是因为ShiroSpringURI处理的差异化导致的。

上一个版本使用了全局的 InvalidRequestFilter 方法过滤 ;,但是字符 . 同样会在 getServletPath 方法中进行标准化处理,

. 绕过

配置如下。

1
2
# application.properties
server.port=9090

HTTP 请求如下。

1
GET http://127.0.0.1:9090/admin/.

Shiro 处理部分

调用栈

一路往下一直到 getPathWithinApplication 方法。

1
2
3
4
5
getPathWithinApplication:112, WebUtils (org.apache.shiro.web.util)
getPathWithinApplication:164, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getChain:103, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getExecutionChain:415, AbstractShiroFilter (org.apache.shiro.web.servlet)
executeChain:448, AbstractShiroFilter (org.apache.shiro.web.servlet)
getPathWithinApplication

getPathWithinApplication 调用了 getServletPath 来获取 URI

1
2
3
4
// WebUtils (org.apache.shiro.web.util)
public static String getPathWithinApplication(HttpServletRequest request) {
return normalize(removeSemicolon(getServletPath(request) + getPathInfo(request)));
}
getServletPath

跟进 getServletPath,可见是调用了映射处理时候封装的 wrapperPath 成员变量,直接返回 /admin/,而后便同 CVE-2020-13933 一样,/admin/ 被处理为 /admin,无法匹配上 /admin/*,从而 Shiro 的过滤链失效,然后进入 Tomcat 的默认过滤链。

1
2
3
4
5
// Request (org.apache.catalina.connector)
@Override
public String getServletPath() {
return mappingData.wrapperPath.toString();
}

Tomcat 处理部分

调用栈

一路往下一直到 SpringBootgetRequestUri 方法。

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
getRequestUri:433, UrlPathHelper (org.springframework.web.util)
getPathWithinApplication:355, UrlPathHelper (org.springframework.web.util)
getLookupPathForRequest:249, UrlPathHelper (org.springframework.web.util)
resolveAndCacheLookupPath:200, UrlPathHelper (org.springframework.web.util)
initLookupPath:579, AbstractHandlerMapping (org.springframework.web.servlet.handler)
getHandlerInternal:376, AbstractHandlerMethodMapping (org.springframework.web.servlet.handler)
getHandlerInternal:125, RequestMappingInfoHandlerMapping (org.springframework.web.servlet.mvc.method)
getHandlerInternal:67, RequestMappingInfoHandlerMapping (org.springframework.web.servlet.mvc.method)
getHandler:498, AbstractHandlerMapping (org.springframework.web.servlet.handler)
getHandler:1258, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1040, DispatcherServlet (org.springframework.web.servlet)
doService:963, DispatcherServlet (org.springframework.web.servlet)
processRequest:1006, FrameworkServlet (org.springframework.web.servlet)
doGet:898, FrameworkServlet (org.springframework.web.servlet)
service:655, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:764, HttpServlet (javax.servlet.http)
internalDoFilter:228, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:190, ApplicationFilterChain (org.apache.catalina.core)
doFilter:163, ApplicationFilterChain (org.apache.catalina.core)
doFilter:61, ProxiedFilterChain (org.apache.shiro.web.servlet)
executeChain:108, AdviceFilter (org.apache.shiro.web.servlet)
doFilterInternal:137, AdviceFilter (org.apache.shiro.web.servlet)
doFilter:125, OncePerRequestFilter (org.apache.shiro.web.servlet)
doFilter:66, ProxiedFilterChain (org.apache.shiro.web.servlet)
executeChain:450, AbstractShiroFilter (org.apache.shiro.web.servlet)
getRequestUri

同上一个 CVE 的处理方式一样,在 SpringBootgetRequestUri 方法中调用的 getRequestURI 方法返回的是原始的值 /admin/.

1
2
3
4
5
6
7
8
// UrlPathHelper (org.springframework.web.util)
public String getRequestUri(HttpServletRequest request) {
String uri = (String) request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE);
if (uri == null) {
uri = request.getRequestURI();
}
return decodeAndCleanUriString(request, uri);
}
decodeAndCleanUriString

URL 解码,依旧是 /admin/.,因此匹配到 /admin/*,得以绕过。

1
2
3
4
5
6
7
// UrlPathHelper (org.springframework.web.util)
private String decodeAndCleanUriString(HttpServletRequest request, String uri) {
uri = removeSemicolonContent(uri);
uri = decodeRequestString(request, uri);
uri = getSanitizedPath(uri);
return uri;
}

漏洞修复

Commit 中发现 org.apache.shiro.spring.web 下新增了 ShiroUrlPathHelper 类,属于 UrlPathHelper 的子类,重写了 getPathWithinApplicationgetPathWithinServletMapping 两个方法,通过相关配置后,SpringBoot 就会使用 ShiroUrlPathHelper,这样两者判断逻辑一致,就不存在因差异性问题而导致的绕过了。

CVE-2020-17523

漏洞简介

影响版本:Shiro < 1.7.1

漏洞描述:Shiro 1.7.1 之前的版本,在将 ShiroSpring 结合使用时,特制的 HTTP 请求可能会导致身份验证绕过。

测试环境:Shiro 1.7.0SpringBoot 2.5.3

漏洞分析

CVE-2020-17510 那样,这个漏洞可以使用空格 %20 进行绕过。这个漏洞的主要原因是代码中,Shiro 会对请求的 URI 进行鉴权操作,校验的函数为 PathMatches,当 PathMatches 返回 true 时才会进入鉴权。

空格绕过分析

配置如下。

1
2
# application.properties
server.port=9090

HTTP 请求如下。

1
GET http://127.0.0.1:9090/admin/%20

Shiro 处理部分

调用栈

CVE-2020-17510 类似,一路往下一直到 getPathWithinApplication 方法。

1
2
3
4
5
getPathWithinApplication:114, WebUtils (org.apache.shiro.web.util)
getPathWithinApplication:164, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getChain:103, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getExecutionChain:416, AbstractShiroFilter (org.apache.shiro.web.servlet)
executeChain:449, AbstractShiroFilter (org.apache.shiro.web.servlet)
getPathWithinApplication

同样是调用 getPathWithinApplication 方法,并且进行标准化处理,直接返回 /admin/[空格]

1
2
3
4
 // WebUtils (org.apache.shiro.web.util)
public static String getPathWithinApplication(HttpServletRequest request) {
return normalize(removeSemicolon(getServletPath(request) + getPathInfo(request)));
}
getChain

跟进 pathMatches 方法。

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
// PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = this.getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
} else {
String requestURI = this.getPathWithinApplication(request); // requestURI: "/admin/ "
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(); // pathPattern: "/admin/*"
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);
}
}
tokenizeToStringArray

pathMatches 开始一路转至 doMatch,进入 tokenizeToStringArray 方法,在 tokenizeToStringArray 中,trimTokens 默认设置为 true,所以在按 / 分割字符串后经 token.trim() 处理,空格等字符会被清除,返回 admin

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
// StringUtils (org.apache.shiro.util)
public static String[] tokenizeToStringArray(String str, String delimiters) { // str:"/admin/ ", delimiters: "/"
return tokenizeToStringArray(str, delimiters, true, true);
}

public static String[] tokenizeToStringArray(String str, String delimiters, boolean trimTokens, boolean ignoreEmptyTokens) {
if (str == null) { // str: "/admin/ "
return null;
} else {
StringTokenizer st = new StringTokenizer(str, delimiters);
List tokens = new ArrayList();

while(true) {
String token;
do {
if (!st.hasMoreTokens()) {
return toStringArray(tokens);
}

token = st.nextToken();
if (trimTokens) { // true
token = token.trim();
}
} while(ignoreEmptyTokens && token.length() <= 0);
tokens.add(token);
}
}
}
doMatch

tokenizeToStringArray 返回的是 ["admin"],数组长度不同于 pattern,因此循环到第二次比对时退出,匹配失败,然后继续进入 SpringBoot 的过滤链。

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
// AntPathMatcher (org.apache.shiro.util)
protected boolean doMatch(String pattern, String path, boolean fullMatch) { // pattern: "/admin/*", path: "/admin/ ", fullMatch: true
if (path.startsWith(this.pathSeparator) != pattern.startsWith(this.pathSeparator)) {
return false;
}

String[] pattDirs = StringUtils.tokenizeToStringArray(pattern, this.pathSeparator); // pattern: "/admin/*", pattDirs: ["admin", "*"]
String[] pathDirs = StringUtils.tokenizeToStringArray(path, this.pathSeparator); // path: "/admin/ ", pathDirs: ["admin"], pathSeparator: "/"

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

// 匹配所有的元素知道第一个 **
while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
String patDir = pattDirs[pattIdxStart]; // patDir: ["admin", "*"]
if ("**".equals(patDir)) {
break;
}
//
if (!matchStrings(patDir, pathDirs[pathIdxStart])) { // pathDirs: ["admin"], patDir: "admin"
return false;
}
pattIdxStart++;
pathIdxStart++;
}

// ...

return true;
}

Tomcat 处理部分

调用栈

和之前的 CVE-2020-17510 类似,一路往下一直到 SpringBootgetRequestUri 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
getRequestUri:345, UrlPathHelper (org.springframework.web.util)
getPathWithinApplication:265, UrlPathHelper (org.springframework.web.util)
getLookupPathForRequest:169, UrlPathHelper (org.springframework.web.util)
getHandlerInternal:363, AbstractHandlerMethodMapping (org.springframework.web.servlet.handler)
getHandlerInternal:110, RequestMappingInfoHandlerMapping (org.springframework.web.servlet.mvc.method)
getHandlerInternal:59, RequestMappingInfoHandlerMapping (org.springframework.web.servlet.mvc.method)
getHandler:395, AbstractHandlerMapping (org.springframework.web.servlet.handler)
getHandler:1234, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1016, DispatcherServlet (org.springframework.web.servlet)
doService:943, DispatcherServlet (org.springframework.web.servlet)
processRequest:1006, FrameworkServlet (org.springframework.web.servlet)
doGet:898, FrameworkServlet (org.springframework.web.servlet)
service:634, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:741, HttpServlet (javax.servlet.http)
internalDoFilter:231, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:61, ProxiedFilterChain (org.apache.shiro.web.servlet)
executeChain:108, AdviceFilter (org.apache.shiro.web.servlet)
getRequestUri

同上一个 CVE 的处理方式一样,在 SpringBootgetRequestUri 方法中调用的 getRequestURI 方法返回的是原始的值 /admin/%20,并未经过 URL 解码。

1
2
3
4
5
6
7
8
// UrlPathHelper (org.springframework.web.util)
public String getRequestUri(HttpServletRequest request) {
String uri = (String) request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE);
if (uri == null) {
uri = request.getRequestURI();
}
return decodeAndCleanUriString(request, uri);
}
decodeAndCleanUriString

URL 解码,得到 /admin/[空格]

1
2
3
4
5
6
7
// UrlPathHelper (org.springframework.web.util)
private String decodeAndCleanUriString(HttpServletRequest request, String uri) {
uri = removeSemicolonContent(uri);
uri = decodeRequestString(request, uri);
uri = getSanitizedPath(uri);
return uri;
}
tokenizeToStringArray

接下来同 Shiro 一样,按照 / 分割并调用 trim 方法,差别在默认的 trimTokensfalse,因此空格还在,返回了 ["admin", " "],得以匹配到 /admin/*。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// StringUtils (org.springframework.util)
public static String[] tokenizeToStringArray(
@Nullable String str, String delimiters, boolean trimTokens, boolean ignoreEmptyTokens) {

if (str == null) {
return EMPTY_STRING_ARRAY;
}

StringTokenizer st = new StringTokenizer(str, delimiters);
List<String> tokens = new ArrayList<>();
while (st.hasMoreTokens()) {
String token = st.nextToken();
if (trimTokens) {
token = token.trim();
}
if (!ignoreEmptyTokens || token.length() > 0) {
tokens.add(token);
}
}
return toStringArray(tokens);
}

漏洞修复

Commit 中将 tokenizeToStringArraytrimTokens 参数置为 false,这样同 SpringBoot 一致,默认不会调用 trim() 函数处理,因此也不会造成经过处理后的鉴权失败。

CVE-2021-41303

漏洞简介

影响版本:shiro < 1.8.0

漏洞描述:1.8.0 之前的 Apache Shiro,在 Spring Boot 中使用 Apache Shiro 时,特制的 HTTP 请求可能会导致身份验证绕过。用户应该更新到 Apache Shiro 1.8.0

测试环境:Shiro 1.7.1SpringBoot 2.5.3

漏洞分析

/ 绕过

配置如下。

1
2
# application.properties
server.port=9090

权限配置,这个需要这样的特殊配置,因此有些鸡肋。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Bean
ShiroFilterFactoryBean shiroFilterFactoryBean() {
ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
bean.setSecurityManager(securityManager());
bean.setLoginUrl("/login");
bean.setSuccessUrl("/index");
bean.setUnauthorizedUrl("/unauthorizedurl");
Map<String, String> map = new LinkedHashMap<>();
map.put("/doLogin", "anon");
map.put("/admin/*", "authc");
map.put("/admin/page", "anon");
bean.setFilterChainDefinitionMap(map);
return bean;
}

HTTP 请求如下。

1
GET http://127.0.0.1:9090/admin/page/

Shiro处理部分

调用栈

和之前的 CVE 类似,一路往下一直到 getPathWithinApplication 方法。

1
2
3
4
5
getPathWithinApplication:114, WebUtils (org.apache.shiro.web.util)
getPathWithinApplication:166, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getChain:103, PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
getExecutionChain:416, AbstractShiroFilter (org.apache.shiro.web.servlet)
executeChain:449, AbstractShiroFilter (org.apache.shiro.web.servlet)
getChain

getPathWithinApplication 返回 /admin/page/removeTrailingSlash 删除 / 得到 /admin/page,由于拦截器的顺序,URL会经过 pathMatches 先匹配 /admin/* 拦截规则。

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
// PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
}

final String requestURI = getPathWithinApplication(request); // requestURI: "/admin/test/page/"
final String requestURINoTrailingSlash = removeTrailingSlash(requestURI); // requestURI: "/admin/test/page/", requestURINoTrailingSlash: "/admin/test/page"
for (String pathPattern : filterChainManager.getChainNames()) {
// for 循环匹配调用 pathMatches
if (pathMatches(pathPattern, requestURI)) {
if (log.isTraceEnabled()) {
log.trace("Matched path pattern [{}] for requestURI [{}]. " +
"Utilizing corresponding filter chain...", pathPattern, Encode.forHtml(requestURI));
}
return filterChainManager.proxy(originalChain, pathPattern);
} else {
pathPattern = removeTrailingSlash(pathPattern); // pathPattern: "/admin/*/page"
if (pathMatches(pathPattern, requestURINoTrailingSlash)) {
if (log.isTraceEnabled()) {
log.trace("Matched path pattern [{}] for requestURI [{}]. " +
"Utilizing corresponding filter chain...", pathPattern, Encode.forHtml(requestURINoTrailingSlash));
}
return filterChainManager.proxy(originalChain, requestURINoTrailingSlash);
}
}
}

return null;
}
pathMatches

pathMatches 一路调到 doMatch,在 doMatch 中调用 tokenizeToStringArray,根据上一个 CVE-2020-17523 的修复已经把 trimToken 默认设置为 false,通过 tokenizeToStringArray 方法将路径以 / 拆分成数组,最后返回 false

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
// AntPathMatcher (org.apache.shiro.util)
protected boolean doMatch(String pattern, String path, boolean fullMatch) { // pattern: "/admin/*", path: "/admin/page/", fullMatch: true
if (path == null || path.startsWith(this.pathSeparator) != pattern.startsWith(this.pathSeparator)) {
return false;
}

String[] pattDirs = StringUtils.tokenizeToStringArray(pattern, this.pathSeparator, false, true); // pattern: "/admin/*", pattDirs: ["admin", "*"]
String[] pathDirs = StringUtils.tokenizeToStringArray(path, this.pathSeparator, false, true); // path: "/admin/page/", pathDir: ["admin", "page"], pathSeparator: "/"

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

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) {
// pattern 以 * 结尾,因此返回 !path.endsWith("/") 的结果,为 false
return (pattern.endsWith(this.pathSeparator) ?
path.endsWith(this.pathSeparator) : !path.endsWith(this.pathSeparator));
}
// ...
}
// ...
}
getChain

pathMatches 返回 false 得以进入 else 分支,然后 removeTrailingSlash 移除末尾的 / 得到 /admin/page,继续进入 pathMatches,这次返回 true,因此返回 filterChainManager.proxy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// PathMatchingFilterChainResolver (org.apache.shiro.web.filter.mgt)
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
// ...
for (String pathPattern : filterChainManager.getChainNames()) {
// for 循环匹配调用 pathMatches
if (pathMatches(pathPattern, requestURI)) {
// ...
} else {
pathPattern = removeTrailingSlash(pathPattern); // pathPattern: "/admin/*/page"
if (pathMatches(pathPattern, requestURINoTrailingSlash)) {
if (log.isTraceEnabled()) {
log.trace("Matched path pattern [{}] for requestURI [{}]. " +
"Utilizing corresponding filter chain...", pathPattern, Encode.forHtml(requestURINoTrailingSlash));
}
return filterChainManager.proxy(originalChain, requestURINoTrailingSlash);
}
}
}

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

此处 chainName 传入为 /admin/page 调用 getChain 方法获取过滤链,返回的是 ShiroConfig 中配置的 map.put("/admin/page", "anon"),因此得以绕过。

漏洞修复

Commit 如下,直接将 filterChainManager.proxy 的第二个参数改为 pathPattern,直接传配置中的 URI 了,修复了此处的 / 的绕过问题。

CVE-2022-32532

Refer

Apache Shiro Vulnerability Reports

Shiro 历史漏洞分析