长亭百川云 - 文章详情

Shiro_v1.7.1_身份认证绕过分析(CVE-2020-17523)

bluE0x00

55

2024-07-13

前言:

最近正好工作碰到需要重新分析一下这个漏洞。

正文:

关于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与自己复现时不一样,大概看了一下应该是不同版本对某些路由中的符号处理不同,可看这一篇:

https://xz.aliyun.com/t/8281

参考:

https://github.com/jweny/shiro-cve-2020-17523

https://www.cnblogs.com/waycx/p/12800393.html

相关推荐
关注或联系我们
添加百川云公众号,移动管理云安全产品
咨询热线:
4000-327-707
百川公众号
百川公众号
百川云客服
百川云客服

Copyright ©2024 北京长亭科技有限公司
icon
京ICP备 2024055124号-2