前言:
最近正好工作碰到需要重新分析一下这个漏洞。
正文:
关于Shiro框架:
从功能的角度来看shiro主要有三个核心组件,分别为:
`Subject :经过认证的操作主体。``SecurityManager: Shiro内部用于提供各种安全管理接口的内部实体。``Realm: 可以看作用于Shiro进行主体认证的凭证,Shiro会从应用配置的Realm中查找对应的主体认证信息以及其权限信息。`
今天分析的漏洞主要关注SecurityManager中Filter对于路由的处理逻辑。
漏洞环境来自jwenj师傅:
https://github.com/jweny/shiro-cve-2020-17523
(springboot用的 2.4.4)
参考如下代码:
ShiroConfig:
`@Configuration``public class ShiroConfig {` `@Bean` `MyRealm myRealm() {` `return new MyRealm();` `}`` ` `@Bean` `DefaultWebSecurityManager securityManager(){` `DefaultWebSecurityManager manager = new DefaultWebSecurityManager();` `manager.setRealm(myRealm());` `return manager;` `}`` ` `@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");` `bean.setFilterChainDefinitionMap(map);` `return bean;` `}``}`
MyRealm:
`public class MyRealm extends AuthorizingRealm {` `@Override` `protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {` `return null;` `}`` ` `@Override` `protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {` `String username = (String) authenticationToken.getPrincipal();` `if (!"java".equals(username)){` `throw new UnknownAccountException("unkown user");` `}` `return new SimpleAuthenticationInfo(username, "123", getName());` `}``}`
LoginController:
`@RestController``public class LoginController {` `@PostMapping("/doLogin")` `public void doLogin(String username, String password) {` `Subject subject = SecurityUtils.getSubject();` `try {` `subject.login(new UsernamePasswordToken(username, password));` `System.out.println("success");` `} catch (AuthenticationException e) {` `e.printStackTrace();` `System.out.println("failed");` `}` `}` `@GetMapping("/admin/{name}")` `public String admin(String name) {` `return "admin page";` `}`` ` `@GetMapping("/login")` `public String login() {` `return "please login!";` `}``}`
其中比较核心的ShiroFilterFactoryBean类:
通过继承FactoryBean接口,在Filter beans实例化时通过getObjectFromFactoryBean方法最终调用到ShiroFilterFactoryBean中的createInstance方法将securityManager与chainResovler设置到我们的Shiro Filter中,并将该filter实例放入后置处理器列表中
`//org.springframework.boot.web.servlet.ServletContextInitializerBeans`` ``protected void addAdaptableBeans(ListableBeanFactory beanFactory) {` `MultipartConfigElement multipartConfig = this.getMultipartConfig(beanFactory);` `//先注册servlet,然后注册filter` `this.addAsRegistrationBean(beanFactory, Servlet.class, new ServletContextInitializerBeans.ServletRegistrationBeanAdapter(multipartConfig));` `this.addAsRegistrationBean(beanFactory, Filter.class, new ServletContextInitializerBeans.FilterRegistrationBeanAdapter());` `Iterator var3 = ServletListenerRegistrationBean.getSupportedTypes().iterator();`` ` `while(var3.hasNext()) {` `Class<?> listenerType = (Class)var3.next();` `this.addAsRegistrationBean(beanFactory, EventListener.class, listenerType, new ServletContextInitializerBeans.ServletListenerRegistrationBeanAdapter());` `}`` ``}`
并通过实现postProcessBeforeInitialization方法在剩余的Filter bean调用doCreateBean方法时进入Shiro的后置处理器中将默认的Spring Filters并入到Shiro Filter中:
`public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {` `if (bean instanceof Filter) {` `log.debug("Found filter chain candidate filter '{}'", beanName);` `Filter filter = (Filter)bean;` `this.applyGlobalPropertiesIfNecessary(filter);` `this.getFilters().put(beanName, filter);` `} else {` `log.trace("Ignoring non-Filter bean '{}'", beanName);` `}`` ` `return bean;``}`
至此Shiro Filter完成了我们所关心的相关初始化以及合并操作。
PathMatchingFilterChainResolver
问题出在这个不安全的chainResovler上:
tokenizeToStringArray
`//private static final String DEFAULT_PATH_SEPARATOR = "/";`` ``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);` `}``}`
在getChain方法中匹配到URL和循环匹配路由末位为 "/"时将会将其删除。
跟进tokenizeToStringArray方法一路step into:
跟到这里就比较清晰了,StringTokenizer以 "/"为分隔符划分token,而函数中有调用trim()方法,使得路由中的空格" "被忽略,导致与"/admin/*"的比较结果为false:
`public static String[] tokenizeToStringArray(String str, String delimiters, boolean trimTokens, boolean ignoreEmptyTokens) {` `if (str == null) {` `return null;` `} else {` `StringTokenizer st = new StringTokenizer(str, delimiters);` `ArrayList 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);` `}` `}``}`
最后成功匹配的路由为 "/**",导致权限绕过
而spring默认对空格处理为null,正常返回/admin页面
getPathWithinApplication
观察getPathWithinApplication
一路step into可以看到最后使用normalize函数对path进行处理:
`private static String normalize(String path, boolean replaceBackSlash) {` `if (path == null) {` `return null;` `} else {` `String normalized = path;` `if (replaceBackSlash && path.indexOf(92) >= 0) {` `normalized = path.replace('\\', '/');` `}`` ` `if (normalized.equals("/.")) {` `return "/";` `} else {` `if (!normalized.startsWith("/")) {` `normalized = "/" + normalized;` `}`` ` `while(true) {` `int index = normalized.indexOf("//");` `if (index < 0) {` `while(true) {` `index = normalized.indexOf("/./");` `if (index < 0) {` `while(true) {` `index = normalized.indexOf("/../");` `if (index < 0) {` `return normalized;` `}`` ` `if (index == 0) {` `return null;` `}`` ` `int index2 = normalized.lastIndexOf(47, index - 1);` `normalized = normalized.substring(0, index2) + normalized.substring(index + 3);` `}` `}`` ` `normalized = normalized.substring(0, index) + normalized.substring(index + 2);` `}` `}`` ` `normalized = normalized.substring(0, index) + normalized.substring(index + 1);` `}` `}` `}``}`
这里传入的path为wrapperPath,原始request中的decodedURI由CoyoteAdapter中的normalize函数与convertURI函数处理:
最终通过map()构造mappingData并最终给wrapperPath赋值:
`public static boolean normalize(MessageBytes uriMB) {` `ByteChunk uriBC = uriMB.getByteChunk();` `byte[] b = uriBC.getBytes();` `int start = uriBC.getStart();` `int end = uriBC.getEnd();` `if (start == end) {` `return false;` `} else {` `int pos = false;` `int index = false;` `if (b[start] != 47 && b[start] != 92) {` `return false;` `} else {` `int pos;` `for(pos = start; pos < end; ++pos) {` `if (b[pos] == 92) {` `if (!ALLOW_BACKSLASH) {` `return false;` `}`` ` `b[pos] = 47;` `} else if (b[pos] == 0) {` `return false;` `}` `}`` ` `for(pos = start; pos < end - 1; ++pos) {` `if (b[pos] == 47) {` `while(pos + 1 < end && b[pos + 1] == 47) {` `copyBytes(b, pos, pos + 1, end - pos - 1);` `--end;` `}` `}` `}`` ` `if (end - start >= 2 && b[end - 1] == 46 && (b[end - 2] == 47 || b[end - 2] == 46 && b[end - 3] == 47)) {` `b[end] = 47;` `++end;` `}`` ` `uriBC.setEnd(end);` `int index = 0;`` ` `while(true) {` `index = uriBC.indexOf("/./", 0, 3, index);` `if (index < 0) {` `index = 0;`` ` `while(true) {` `index = uriBC.indexOf("/../", 0, 4, index);` `if (index < 0) {` `return true;` `}`` ` `if (index == 0) {` `return false;` `}`` ` `int index2 = -1;`` ` `for(pos = start + index - 1; pos >= 0 && index2 < 0; --pos) {` `if (b[pos] == 47) {` `index2 = pos;` `}` `}`` ` `copyBytes(b, start + index2, start + index + 3, end - start - index - 3);` `end = end + index2 - index - 3;` `uriBC.setEnd(end);` `index = index2;` `}` `}`` ` `copyBytes(b, start + index, start + index + 2, end - start - index - 2);` `end -= 2;` `uriBC.setEnd(end);` `}` `}` `}``}`
所以这里严格来说是tomcat的解析问题,导致下面这些URL所对应的wrapperPath
`/admin/.``/admin/./``/admin/..``/admin/../``/admin/..??????``/admin/.????%20%20%20``/admin/..????%20%20%20``......`
分别被处理为
`/admin/``/admin/``/``/``/``/``/``......`
从而导致绕过。
后记
网上还有一些poc与自己复现时不一样,大概看了一下应该是不同版本对某些路由中的符号处理不同,可看这一篇:
参考: