前段时间遇到通过分号绕过nginx层屏蔽并顺利访问到Springboot项目actuator端点的问题,修复过程中偶然发现当项目使用shiro组件时,若将shiro升级到1.6.0可间接修复分号绕过的问题,当请求url中包含分号时响应状态码为400;
考虑到shiro主要用来执行身份验证授权等,理论上不适合直接阻断存在分号的请求;
为搞明白此问题,决定对shiro的权限校验问题进行整理学习,下面为常见的shiro权限绕过漏洞分析修复过程。
学习shiro权限绕过漏洞之前,有必要了解下shiro filter的过滤过程;
一个http请求过来,首先经过web容器的处理(这里默认为tomcat)被投放到相应的web应用,web应用会通过过滤器Filter链式的对http请求进行预处理,这里将会经过shiro的Filter(SpringShiroFilter)处理:
org.apache.catalina.core.ApplicationFilterChain#internalDoFilter --> org.apache.shiro.web.servlet.OncePerRequestFilter#doFilter -->
org.apache.shiro.web.servlet.AbstractShiroFilter#doFilterInternal -->
org.apache.shiro.web.servlet.AbstractShiroFilter#executeChain
shiro的Filter执行过会通过getExecutionChain()获取执行链并执行对应的doFilter函数:
重点在获取chain的org.apache.shiro.web.servlet.AbstractShiroFilter#getExecutionChain中;
首先会尝试获取到在springboot启动时加载的shiro配置文件中配置的Shiro filterChains(resolver)并判断是否为null,当为null则返回原始过滤器链origChain,当不为空时则尝试通过org.apache.shiro.web.filter.mgt.FilterChainResolver#getChain方法根据当前请求的url使用Ant模式获取相应的拦截器链 FilterChain代理(resolved),否则返回原始过滤器链origChain:
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;
}
其中获取shiro对应的FilterChain代理是在org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain中完成,主要通过获取所有的filter链(filterChainManager)及requestURI,并循环遍历filterChainManager进行匹配,当匹配时则直接返回相对应的FilterChain代理,否则返回null:
附上filterChainManager接口说明:
public interface FilterChainManager {
// 得到注册的拦截器
Map
// 获取拦截器链
NamedFilterList getChain(String chainName);
// 是否有拦截器链
boolean hasChains();
// 得到所有拦截器链的名字
Set
// 使用指定的拦截器链代理原始拦截器链
FilterChain proxy(FilterChain original, String chainName);
// 注册拦截器
void addFilter(String name, Filter filter);
// 注册拦截器
void addFilter(String name, Filter filter, boolean init);
// 根据拦截器链定义创建拦截器链
void createChain(String chainName, String chainDefinition);
// 添加拦截器到指定的拦截器链
void addToChain(String chainName, String filterName);
// 添加拦截器(带有配置的)到指定的拦截器链
void addToChain(String chainName, String filterName, String chainSpecificFilterConfig) throws ConfigurationException;
}
最终链式执行过滤器:
org.apache.catalina.core.ApplicationFilterChain#doFilter --> org.apache.catalina.core.ApplicationFilterChain#internalDoFilter
执行完过滤器后将调用servlet.service,进而执行controller解析及service等:
javax.servlet.http.HttpServlet#service(javax.servlet.ServletRequest, javax.servlet.ServletResponse):
接下来的SpringMVC controller解析部分详情可参见下面CVE-2020-1957漏洞分析部分。
可参考:
https://github.com/apache/shiro/releases
或
https://github.com/ttestoo/java-sec-study/tree/main/sec-shiro/java-shiro-bypass
Apache Shiro <1.5.2
1、正常情况下访问/hello/a会进行跳转:http://127.0.0.1:9091/hello/a
2、可通过分号进行绕过:
http://127.0.0.1:9091/;/hello/a
http://127.0.0.1:9091/aa/..;test=123/admin/index
…
shiro filter主要过程上述部分已简单介绍,其中根据分号绕过可初步判断问题可能出现在获取requestURI处,毕竟是拿requestURI和shiro配置的FilterChain进行匹配的,也就是说通过分号使得shiro filter过程的requestURI能绕过shiro的Ant格式的规则匹配;
可以直接在org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain中的String requestURI = getPathWithinApplication(request); 处下断点进行调试:
注意这里测试过程中URL为:http://127.0.0.1:9091/aa/..;test=123/admin/index
跟进getPathWithinApplication函数,将通过WebUtils.getPathWithinApplication —> WebUtils.getRequestUri 获取requestURI:
最终还是通过request.getRequestURI获取请求中的URI,即:/aa/..;test=123/admin/index
获取到请求中的URI后,将通过org.apache.shiro.web.util.WebUtils#normalize进行标准化处理,其中参数调用了decodeAndCleanUriString函数处理,在decodeAndCleanUriString中可以清晰的看到根据 ; 对uri进行了分割,并获取到 ; 之前的部分,即/aa/..
且在normalize函数中,主要标准化处理内容如下:
private static String normalize(String path, boolean replaceBackSlash) {
if (path == null)
return null;
// Create a place for the normalized path
String normalized = path;
if (replaceBackSlash && normalized.indexOf('\\') >= 0)
normalized = normalized.replace('\\', '/');
if (normalized.equals("/."))
return "/";
// Add a leading "/" if necessary
if (!normalized.startsWith("/"))
normalized = "/" + normalized;
// Resolve occurrences of "//" in the normalized path
while (true) {
int index = normalized.indexOf("//");
if (index <0)
break;
normalized = normalized.substring(0, index) +
normalized.substring(index + 1);
}
// Resolve occurrences of "/./" in the normalized path
while (true) {
int index = normalized.indexOf("/./");
if (index <0)
break;
normalized = normalized.substring(0, index) +
normalized.substring(index + 2);
}
// Resolve occurrences of "/../" in the normalized path
while (true) {
int index = normalized.indexOf("/../");
if (index <0)
break;
if (index == 0)
return (null); // Trying to go outside our context
int index2 = normalized.lastIndexOf('/', index - 1);
normalized = normalized.substring(0, index2) +
normalized.substring(index + 3);
}
// Return the normalized path that we have completed
return (normalized);
}
由于此时传入的为经过decodeAndCleanUriString处理后的值(/aa/..),所以这里经过normalize处理后结果不变:
也就是说此时通过WebUtils.getRequestUri获取到的requestUri的值为/aa/..
且getPathWithinApplication后的值也为/aa/..
接下来将对requestURI和在shiro配置文件中配置的过滤规则filterChains进行匹配,则/aa/..不会和任何规则匹配成功,返回null:
至此,我们通过在请求url中添加 ; 成功绕过了shiro的filter过滤匹配,并成功进入SpringMVC controller解析过程,其中入口为SpringMVC的DispatcherServlet.doService():
org.apache.catalina.core.ApplicationFilterChain#internalDoFilter --> javax.servlet.Servlet#service --> javax.servlet.http.HttpServlet#service --> javax.servlet.http.HttpServlet#doGet --> org.springframework.web.servlet.FrameworkServlet#processRequest -->
org.springframework.web.servlet.DispatcherServlet#doService -->
在DispatcherServlet.doService中将调用核心的doDispatch方法进行下一步处理:
其中doDispatch主要处理逻辑如下:
其中我们关心的是获取请求处理器的过程,即getHandler方法实现细节:
首先Spring会循环所有注册的HandlerMapping并返回第一个匹配的HandlerExecutionChain:
对于mapping为RequestMappingHandlerMapping时,则会调用org.springframework.web.servlet.handler.AbstractHandlerMapping#getHandler进行获取对应的handler,
在getHandler中调用getHandlerInternal方法,并在其中进行调用getLookupPathForRequest—>getPathWithinServletMapping解析请求路径lookupPath:
对于getPathWithinServletMapping中首先通过getPathWithinApplication获取请求URI在web应用中的路径,其中经过如下调用链:
最终在org.springframework.web.util.UrlPathHelper#getRequestUri中通过request.getRequestURI()获取请求的uri,即/aa/..;test=123/admin/index,并通过decodeAndCleanUriString方法对uri进行处理:
decodeAndCleanUriString方法中主要做了三件事:
1、调用removeSemicolonContent方法对uri进行处理,其中将分号及后面的key-value进行了去除得到新的requestUri为/aa/../admin/index:
2、通过调用decodeRequestString进行URL解码:
3、通过调用getSanitizedPath将//替换为/:
至此经过decodeAndCleanUriString方法处理后最终获取到的uri为/aa/../admin/index,即getPathWithinApplication返回的结果pathWithinApp的值也为/aa/../admin/index:
接下来回到getPathWithinServletMapping中,此时pathWithinApp值为/aa/../admin/index,servletPath为/admin/index,正常情况下将通过getRemainingPath方法将pathWithinApp中servletPath给截取掉,但此时由于两者值无法截取成功返回为null:
由于path为null,则进入else这种特殊情况,最终返回servletPath,即/admin/index:
即最终获取到的lookupPath值为/admin/index,并根据lookupPath的值寻找相对应的handlerMethod为com.ttestoo.bypass.controller.LoginController#admin_index():
至此,SpringMVC中获取handler的过程已结束,成功获取到/admin/index相对应的handler方法,并将继续获取HandlerAdapter,并执行HandlerAdapter的handle方法利用反射机制执行/admin/index相对应的controller方法com.ttestoo.bypass.controller.LoginController#admin_index():
总结:
当我们发起请求http://127.0.0.1:9091/aa/..;test=123/admin/index时,经过shiro filter进行权限校验,此时shiro解析到的路径为/aa/..不会和任何规则匹配成功,从而通过了shiro的权限校验;
接下来会由springmvc进行controller解析,此时springmvc解析到的路径为/admin/index,并获取到/admin/index对应的handler方法admin_index,从而正常的执行service并得到最终的响应。
漏洞本质:
当路径中包含特殊字符时,shiro解析得到的路径和SpringMVC解析得到的路径不一致,导致可正常通过shiro的权限校验并正常的完成service的执行获取执行结果。
在实际场景中每个API会通过网关层统一校验或@RequiresPermissions注解等方式校验所需访问权限,此漏洞仅能绕过shiro全局的Filter校验,无法绕过API配置的访问权限;
个人理解此问题在实战中利用场景相对有限。
根据官方commit记录,可知在1.5.2开始,在org.apache.shiro.web.util.WebUtils#getRequestUri中获取uri的方式从request.getRequestURI()换成了request.getContextPath()+request.getServletPath()+request.getPathInfo()组合的方式:
此时请求http://127.0.0.1:9091/aa/..;test=123/admin/index获取到的url为/admin/index,无法绕过shiro的权限校验:
主要是针对CVE-2020-1957修复后的绕过探索,主要两种方式:
URL双编码
shiro版本需为1.5.2,访问http://127.0.0.1:9091/hello/test,被shiro权限校验拦截:
访问http://127.0.0.1:9091/hello/te%25%32%66st可直接绕过权限校验:
分号绕过
需要配置server.servlet.context-path:
server.servlet.context-path=/test
http://127.0.0.1:9091/test/hello/test
http://127.0.0.1:9091/a/..;a=1/test/hello/test
URL双编码分析
首先发起http请求时,若url中存在URL编码字符,则会被容器进行一次URL解码,此时/hello/te%25%32%66st —> /hello/te%2fst,接下来则同CVE-2020-1957过程一样,在shiro处理过程中会在org.apache.shiro.web.util.WebUtils#getPathWithinApplication中通过getRequestUri函数获取uri:
在org.apache.shiro.web.util.WebUtils#getRequestUri中首先通过request.getContextPath()+request.getServletPath()+request.getPathInfo()组合的方式获取uri为://hello/te%2fst
接着在进入normalize函数进行格式化处理时,传入的参数经过了decodeAndCleanUriString方法的处理,其中通过调用org.apache.shiro.web.util.WebUtils#decodeRequestString方法,利用URLDecoder.decode进行了URL解码为://hello/te/st
继续进入normalize函数,将//替换为/,最终获取到的uri为:/hello/te/st
接下来就同上面shiro过程一致了,/hello/te/st不会和任何的规则匹配,成功解析controller并执行相对应的service获取响应结果:
补充,在1.5.1及之前版本中,获取uri采用request.getRequestURI方式,获取到的为URL双编码的值,shiro进行一次解码并格式化处理后为/hello/te%2fst,则会和/hello/*进行匹配:
分号绕过分析
当请求为http://127.0.0.1:9091/a/..;a=1/test/hello/test时,
同URL双编码主要区别在经过request.getContextPath()+request.getServletPath()+request.getPathInfo()组合获取到的uri为:/a/..;a=1/test//hello/test
request.getContextPath() --> /a/..;a=1/test
request.getServletPath() --> /hello/test
request.getPathInfo() --> null
接着经过org.apache.shiro.web.util.WebUtils#decodeAndCleanUriString处理后,会根据 ; 进行分割,最终uri结果为:/a/..
从而绕过shiro的权限校验,而接下来则和cve-2020-1957流程一致,springmvc会将 ; 进行剔除,最终根据/a/../test/hello/test,即/test/hello/test成功解析controller并执行service获取响应结果:
在1.5.3中,获取uri方式改成了getServletPath(request) + getPathInfo(request)组合的方式,且去除了解码过程:
此时,同样的请求http://127.0.0.1:9091/a/..;a=1/test/hello/test,获取到的requestURI为:/hello/test
请求http://127.0.0.1:9091/hello/te%25%32%66st获取到的requestURI为:/hello/te%27st
Apache shiro<=1.5.3 即 Apache Shiro <1.6.0
controller需为类似”/hello/{page}”的方式利用过程
http://127.0.0.1:9091/hello/test
http://127.0.0.1:9091/hello/%3btest
当发起请求http://127.0.0.1:9091/hello/%3btest时,
首先容器会进行URL解码,/hello/%3btest —> /hello/;test
进入shiro处理,org.apache.shiro.web.util.WebUtils#getPathWithinApplication中最终结果即requestURI为/hello/:
getServletPath(request) --> /hello/;test
getPathInfo(request) --> ""
removeSemicolon中根据;进行分割
而/hello/不会和/hello/*匹配,顺利进入controller解析并执行service获取响应结果:
在1.6.0中,执行shiro过滤器时增加了preHandle方法进行判断是否继续执行:
判断内容主要为:若请求URI中包含分号、反斜杠、非ASCII字符(均可配置),则直接响应400
核心逻辑均在新增的类org.apache.shiro.web.filter.InvalidRequestFilter中:https://shiro.apache.org/static/1.6.0/apidocs/org/apache/shiro/web/filter/InvalidRequestFilter.html
Apache Shiro <1.7.1
controller需为类似”/hello/{page}”的方式
空格绕过
1、http://127.0.0.1:9091/hello/test
2、http://127.0.0.1:9091/hello/%20
西式句号 全路径绕过
需配置开启全路径匹配:
1、http://127.0.0.1:9091/hello/.
空格绕过
当发起http://127.0.0.1:9091/hello/%20请求时,
首先经过容器URL解码,/hello/%20 —> /hello/空格
发现在shiro进行匹配过程中,/hello/* 和 /hello/空格 不匹配:
在org.apache.shiro.util.AntPathMatcher#doMatch函数中,/hello/* 和 /hello/空格 经过tokenizeToStringArray函数处理后的结果中/hello空格仅剩/hello:
跟进tokenizeToStringArray方法可知,调用过程中,trimTokens参数值为true:
而当trimTokens为true时,则会调用trim(),此时/hello/后面的空格将会被丢弃:
在接下来的判断过程中,匹配结果为false,即/hello/* 和 /hello/空格 不匹配从而导致shiro的权限绕过:
西式句号 全路径绕过
当请求http://127.0.0.1:9091/hello/.时,首先经过org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getPathWithinApplication处理时,会被normalize函数将/hello/.最后面的 . 删除掉,最终requestURI为/hello/:
在org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain处理过程中,由于此时requestURI以/结尾,则将会把最后一个/删除,变成/hello:
而/hello和/hello/*不匹配导致shiro权限绕过:
但是,此时SpringMVC进行controller解析时,请求路径为/hello/%2e,spring中.和/默认是作为路径分割符的,不会参与到路径匹配,此时将解析controller失败,返回404:
即,虽然绕过了shiro的权限校验,但默认无法解析到controller,也就无法执行想要的service;
特殊情况:
当手工配置开启springboot的全路径匹配时,可成功执行:
//开启全路径匹配
@ServletComponentScan
@SpringBootApplication
public class Application extends SpringBootServletInitializer implements BeanPostProcessor {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(Application.class);
}
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName)
throws BeansException {
if (bean instanceof RequestMappingHandlerMapping) {
((RequestMappingHandlerMapping) bean).setAlwaysUseFullPath(true);
}
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName)
throws BeansException {
return bean;
}
}
1、将tokenizeToStringArray函数的trimTokens参数设置为false,防止空格被抛弃:
2、将剔除结尾/的过程移到匹配的下方:
开头所说的利用分号绕过了nginx的403屏蔽,请求到springboot项目后携带分号解析成功,而当使用了shiro 1.6.0后,url中携带分号则直接报错400,原因就很清晰了,因为在1.6.0中增加了org.apache.shiro.web.filter.InvalidRequestFilter类,其中会判断请求中包含分号时响应400状态码;
shiro的权限绕过,本质还是shiro对uri的解析规则和后端开发框架的解析规则不一样所导致。
https://github.com/apache/shiro/commit/3708d7907016bf2fa12691dff6ff0def1249b8ce
https://shiro.apache.org/static/1.6.0/apidocs/org/apache/shiro/web/filter/InvalidRequestFilter.html
https://github.com/apache/shiro/commit/0842c27fa72d0da5de0c5723a66d402fe20903df
https://shiro.apache.org/security-reports.html
https://xlab.tencent.com/cn/2020/06/30/xlab-20-002/
https://mp.weixin.qq.com/s/yb6Tb7zSTKKmBlcNVz0MBA
https://github.com/jweny/shiro-cve-2020-17523
https://segmentfault.com/a/1190000039703198
……