Apache Shiro 是一个强大且易用的 Java 安全框架,执行身份验证、授权、密码和会话管理。使用 Shiro 的易于理解的 API,您可以快速、轻松地获得任何应用程序,从最小的移动应用程序到最大的网络和企业应用程序。
本文针对 Shiro 进行了一个原理性的讲解,从源码层面来分析了 Shiro 的认证和授权的整个流程,并在认证与授权的这个流程讲解冲,穿插说明 rememberme 的作用,以及为何该字段会导致反序列化漏洞。
在该小节中我们将会详细讲解 Shiro 是如何认证一个用户为合法用户的 Shiro 漏洞环境测试代码修改自 Vulhub 中的 CVE-2016-4437。首先是 Shiro 的配置文件,代码如下所示
@Configuration
public class ShiroConfig {
@Bean
MainRealm mainRealm() {
return new MainRealm();
}
@Bean
RememberMeManager cookieRememberMeManager() {
return (RememberMeManager)new CookieRememberMeManager();
}
@Bean
SecurityManager securityManager(MainRealm mainRealm, RememberMeManager cookieRememberMeManager) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
manager.setRealm((Realm)mainRealm);
manager.setRememberMeManager(cookieRememberMeManager);
return (SecurityManager)manager;
}
@Bean(name = {"shiroFilter"})
ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
bean.setSecurityManager(securityManager);
//设置登录页面uri
bean.setLoginUrl("/login");
//设置登录失败页面uri
bean.setUnauthorizedUrl("/unauth");
Map map = new LinkedHashMap<>();
map.put("/doLogin", "anon");
map.put("/doLogout", "authc");
map.put("/user/add","perms[user:add]");
map.put("/user/update","perms[user:update]");
map.put("/user/delete","perms[user:delete]");
map.put("/user/select","perms[user:select]");
map.put("/**", "authc");
bean.setFilterChainDefinitionMap(map);
return bean;
}
}
小伙伴们有兴趣想了解更多相关学习资料请点赞收藏+评论转发+关注我之后私信我,注意回复【000】即可获取更多免费资料!
然后是 Controller 的代码
@Controller
public class UserController {
@PostMapping({"/doLogin"})
public String doLoginPage(@RequestParam("username") String username, @RequestParam("password") String password, @RequestParam(name = "rememberme", defaultValue = "") String rememberMe) {
Subject subject = SecurityUtils.getSubject();
try {
subject.login(new UsernamePasswordToken(username, password, rememberMe.equals("remember-me")));
} catch (AuthenticationException e) {
return "forward:/login";
}
return "forward:/";
}
@RequestMapping({"/doLogout"})
public String doLogout() {
Subject subject = SecurityUtils.getSubject();
subject.logout();
return "forward:/login";
}
@RequestMapping({"/"})
public String helloPage() {
return "hello";
}
@RequestMapping({"/unauth"})
public String errorPage() {
return "error";
}
@RequestMapping({"/login"})
public String loginPage() {
return "loginUser";
}
@RequestMapping({"/user/add"})
public String add(){
return "/user/add";
};
@RequestMapping({"/user/delete"})
public String delete(){
return "/user/delete";
};
@RequestMapping({"/user/update"})
public String update(){
return "/user/update";
};
@RequestMapping({"/user/select"})
public String select(){
Subject subject = SecurityUtils.getSubject();
return "/user/select";
};
}
最后是 Realm
public class MainRealm extends AuthorizingRealm {
@Autowired
UserServiceImpl userService;
/**该方法用来为登陆的用户进行授权*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
System.out.println("执行了=>授权doGetAuthorizationInfo");
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
Subject subject = SecurityUtils.getSubject();
System.out.println(subject.isAuthenticated());
System.out.println(subject.isRemembered());
if(!subject.isAuthenticated()){
return null;
}
Users users = (Users) subject.getPrincipal();
if(users.getPerm()!=null){
String[] prems = users.getPerm().split(";");
info.addStringPermissions(Arrays.asList(prems));
}
return info;
}
/**该方法用来校验登陆的用户*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
System.out.println("执行了=>认证doGetAuthenticationInfo");
Subject subject= SecurityUtils.getSubject();
System.out.println(subject.isAuthenticated());
System.out.println(subject.isRemembered());
UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
String username = usernamePasswordToken.getUsername();
char[] password = usernamePasswordToken.getPassword();
Users users = userService.queryUserByName(username);
if (users.getUsername()==null){
return null;
}
return new SimpleAuthenticationInfo(users,users.getPassword(),"");
}
}
这里来看一下自定义的 MainRealm 的类继承和实现关系图
Realm 所起到的作用通常是获取后台用户的相关信息,然后获取前端传递进来的用户信息,将二者封装好然后交由 shiro 进行认证比对从而判断用户是否为合法用户,然后在用户访问后台资源时,为用户授予指定好的权限。那么认证是怎么认证的呢?下面来从 Shiro 源码的角度来进行详细的分析。首先是登陆页面,和登陆页面的代码。
当点击 Singn in 按钮的时候 后台对应的 Controller 就会执行但是在执行到 Controller 之前,Shiro 会进行一个操作,如下所示
首先就是 Shiro 的 Filter,在 Shiro 的配置文件中,通过@Bean注解让 SpringBoot 在启动的时候自动装配了当前方法的返回值,也就是一个 ShiroFilterFactoryBean 对象,该对象的类继承关系如下所示。
该类实现了 SpringFrameWork 中的 FactoryBean 接口和 BeanPostProcessor 接口。SpringBoot 在启动的时候会扫描当前目录以及子目录下所有.java 文件的注解,然后进行装配,这一过程中就会调用 FactoryBean.getObject()方法。也就是 FactoryBean 的实现类 ShiroFilterFactoryBean.getObject()方法,
在 shiroFilter 的执行的堆栈中,会创建一个 Subject,Subject 是 Shiro 中很重要的一个概念,简单来说就是当前所操作的用户。当前线程中的用户所进行的认证和授权等等操作,都会以操作这个 Subject 对象来进行,所以 Subject 也被称之为主体,最终实例化的是一个 WebDelegatingSubject 对象。请求继续往下执行,来到 UserController.doLoginPage()方法,该方法中会调用 Subject.login()方法,并传入一个 UsernamePasswordToken 对象。这个 UsernamePasswordToken 从这个类的名字我们就可以猜出这个类是用来做什么的,跟进该类中看一下
从这个类提供的方法和属性就可以看出来,UsernamePasswordToken 类就是一个单纯的 pojo 类,登陆时的用户名和密码以及对应的 ip 信息都会在这个类中暂时存放。跟进 Subject.login()方法,经过一系列的调用来到了 ModularRealmAuthenticator.doAuthenticate,该方法会获取我们自定义的 Realm 并一次进行调用,我们自定义的 Realm 是文章开头的 MainRealm,所谓的 Realm,就是对传入的用户进行认证和授权的地方,Realm 的自定义需要继承自 AuthorizingRealm,Realm 我们可以自定义多个,只需要将自定义好的多个 Realm 放入一个 Collection 对象中,然后在配置文件中通过 SecurityManager.setRealms()传入,这样在 Shiro 在认证时就会依次调用我们自定义的 Realms,Shiro 本身也自带有一些 Reamls 可以直接调用,如下图所示
自定义的 Realm 有两个方法必须要实现,分别是继承自 AuthencationgRealm 的 doGetAuthenticationInfo()方法,和 AuthorizingRealm 的 doGetAuthorizationInfo 方法,如下图所示
下面根据程序执行流程,先讲 doGetAuthenticationInfo,根据之前所讲调用 subject.login()方法时会调用到我们自定义的 Realm 的 doGetAuthenticationInfo 方法,我们在该方法中的实现非常简单,即从后台数据库中根据用户名进行查询用户是否存在,如果存在则将查询出来的数据封装成 Users 对象,然后将封装好的 Users 对象传入和查询出的该用户的密码一同传入 SimpleAuthenticationInfo 类构造方法中并进行返回。这一步说是用来进行用户的认证,但是不难发现,该方法中并没有对用户的密码进行校验,那么真正的校验点在哪里呢,在如下图所示的位置
在 AuthenticatingRealm 的 getAuthenticationInfo 方法中不仅调用了我们自定义的 MainRealm 中的 doGetAuthenticationInfo 方法,还调用了自身的 assertCredentialsMatch 方法,如下图所示,而 assertCredentialsMatch 方法就是用来校验前端传递来的用户名和密码,以及后台从数据库查询出的密码进行比对的。
在 assertCredentialsMatch 方法中跟如 cm.doCredentialsMatch(token, info),然后就可以看到 shiro 如何进行用户密码比对的了。
token 是前端传入的用户名和密码封装成的 UsernamePasswordToken 对象,info 是从数据库中查询出的数据封装成的 SimpleAuthenticationInfo 对象,如此一来,获取二者的密码,进行 equals 比对,相同则程序继续执行,不相同则抛出异常,返回登陆界面。那么 Shiro 认证到这里就结束了么?当然不是,之前提到过,Shiro 中有一个概念叫 Subject,Subject 代表的就是用户当前操作的主体,在这第一次登陆认证中我们也是通过调用了一个 Subject 对象的 login 方法才进行的身份验证,但是在这个 Subject 中是没有任何的用户信息的,当用户的信息通过校验之后,Shiro 又会实例化一个 WebDelegatingSubject,而这个位置就在 DefaultSecurityManager 的 login 方法中,如下图所示
我们之前看到的认证过程就在 authenticate 方法里,身份真正成功后会返回用户的信息,封装在一个 SimplePrincipalCollection 对象里,如果认证失败,则会抛出异常。认证成功后,Shiro 就会根据当前用户的一些信息,再创建一个 Subject,后续该用户进行的任何操作都会以这个 Subject 为主,授权也是 Shiro 给这个 Subject 进行授权。如此以来我们就了解了 Shiro 是如何认证一个用户的,下面来总结一下 Shiro 认证用户的一个思路,首先在用户没有进行认证的时候访问一些资源,Shiro 会生成一个 Subject,这个 Subject 没有任何的用户信息。当用户开始登陆,Shiro 会调用 Subject 的 login 方法,对用户的用户名和密码进行校验,校验通过后,会生成一个新的 Subject,后续用户的授权等操作,都会基于这个新生成的 Subject。
看完了 Shiro 的认证过程,接下来我们来看 Shiro 的授权过程。我们将每位用户所拥有的授权都存入数据库中如下所示
这里以 admin 为例,来分析下 Shiro 授权的过程。书接上文 Shiro 认证部分,认证完成功 Shiro 会生成一个新的 Subject,而 shiro 的授权过程也就是围绕着这个 Subject 来进行的,那么 Shiro 何时会为用户进行授权行为呢?在之前的内容中说过,自定义的 Realm 有两个方法必须要实现,分别是继承自 AuthencationgRealm 的 doGetAuthenticationInfo()方法,和 AuthorizingRealm 的 doGetAuthorizationInfo 方法。doGetAuthenticationInfo()方法我们已经清楚了,是用来进行用户身份验证的,而 doGetAuthorizationInfo()方法就是用来进行用户授权的。再回顾一下之前的配置文件中我们为每个资源所授予的访问权限,权限如下所示。
@Bean(name = {"shiroFilter"})
ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
bean.setSecurityManager(securityManager);
bean.setLoginUrl("/login");
bean.setUnauthorizedUrl("/unauth");
/**
* anon:无需认证就可以访问
* authc: 必须认证了才能访问
* user: 必须拥有记住我功能才能访问
* perms: 拥有对某个资源的权限才能访问
* role: 拥有某个角色权限才能访问
* */
Map map = new LinkedHashMap<>();
map.put("/doLogin", "anon");
map.put("/doLogout", "authc");
map.put("/user/add","perms[user:add]");
map.put("/user/update","perms[user:update]");
map.put("/user/delete","perms[user:delete]");
map.put("/user/select","perms[user:select]");
map.put("/**", "authc");
bean.setFilterChainDefinitionMap(map);
return bean;
}
我们在 doGetAuthorizationInfo()方法中下断点,当已经经过认证的用户访问制定资源的时候,shiro 就会调用 doGetAuthorizationInfo()方法来为该用户进行授权,具体怎么执行到该方法的就不细说了,将调用链粘贴一下。
doGetAuthorizationInfo()方法的具体实现如下所示
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String name = getName();
System.out.println("执行了=>授权doGetAuthorizationInfo");
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//获取当前用户的subject
Subject subject = SecurityUtils.getSubject();
System.out.println(subject.isAuthenticated());
System.out.println(subject.isRemembered());
if(!subject.isAuthenticated()){
return null;
}
// Users users = userService.queryUserByName((String) subject.getPrincipal());
//
//获取当前用户的信息
Users users = (Users) subject.getPrincipal();
//判断当前用户的权限字段是否为空,如果不为空的话就传入SimpleAuthorizationInfo的addStringPermissions方法中。
if(users.getPerm()!=null){
String[] prems = users.getPerm().split(";");
info.addStringPermissions(Arrays.asList(prems));
}
return info;
}
之前在认证的那一步中,我们将数据库中的数据封装成一个 Users 对象,该对象存放入了 Subject 中,doGetAuthorizationInfo()方法中我们将其取出。在该方法中,我们所做的只是将用户数据库中的权限字段取出然后封装入一个 SimpleAuthorizationInfo 对象中,并进行返回,我们跟随看一下 Shiro 后续的操作。在获取完当前用户的权限后,堆栈返回到 AuthorizingRealm 的 isPermitted 方法中,该方法又调用了 isPermitted()方法,isPermitted()方法就是用来判断用户是否有权限访问指定资源的方法。isPermitted()方法具体内容如下所示
该方法会讲用户所拥有的权限循环遍历出来,然后和当前资源所需要访问权限进行一一比对,如果相同则返回 true。那么比对规则是怎样的呢?跟进 implies()方法,内容如下所示
这里简述一下比对的规则,当前资源所需的访问权限[user:add],对于 shiro 来说所谓的访问权限不过就是一串字符串而已,shiro 会将[user:add]以“:”进行分割,分割成 user 和 add 两个字符串,而假如用户具有[user:add],和[user:select]这两个权限,第一次循环就是判断[user:select]和[user:add]是否相同,会首先判断“:”之前的字符串是否相同,也就是 user 这部分是否相同,相同则继续,不相同则返回 false。判断相同以后,会第二次循环判断“:”之后的部分是否相同,也就是 add 和 select。那自然是不相同的,所以返回 false。shiro 接下来会继续判断[user:add]和[user:add]是否相同。这就是 shiro 授权和鉴权的代码流程,也是 shiro 的核心。了解了 shiro 的这部分内容之后,我们接下来就该讲 CVE-2016-4437 这个漏洞的具体内容了。
shiro 在用户登陆的时候,除了用户名和密码以外 还有一个可传递的选项,也就是 shiro 发序列化漏洞产生的根源,Rememberme。Rememberme 的核心作用时什么呢?就是用户在登录时勾选 rememberme 选项,Cookie 中就会增加一个 rememberme 字段,该字段中会存储一些序列化数据,开发者可以指定 rememberme 字段的有效时间,同时开发者可以指定一些资源,这些资源允许携带 rememberme 字段的用户访问,由于 rememberme 是存储在浏览器中的,并在用户的每一次请求中被携带,所以只要不清除 Cookie,用户就可以在 rememberme 的有效时间内,无需再次登陆,就可以访问指定资源。在不勾选 rememberme 的情况下,通常就是浏览器关闭,会话就会立刻结束,活着等待一段时间后结束,届时用户想要访问一些资源则需要重新登陆,勾选 rememberme 后,即使推出浏览器结束与服务端的会话,rememberme 仍然存储在浏览器中,重新打开浏览器访问指定资源,浏览器在请求时仍会携带上 rememberme,如此一来就不需要重新登陆了。那么接下来就来分析 rememberme 是如何生成,以及如何实现无需登录即可访问指定资源的。如果登陆的时候不勾选 rememberme 选项的情况下,Shiro 是不会生成 rememberme 的,勾选了 rememberme 选项后,才会在认证的过程中生成该值。生成 rememberme 的位置在 DefaultSecurityManager 的 login 方法中,如下图所示。
位置就是在 Shiro 完成用户认证,生成一个新的 Subject 之后。跟进 onSuccessfulLogin()方法,经过嵌套调用,来到 AbstractRememberMeManager 的 onSuccessfulLogin 方法,
在该方法中,会先判断此次请求中 remebmberme 字段是否存在,如果存在则调用 rememberIdentity()方法,想要知道 rememberme 中存储了什么东西那么就要继续深入。
这里是获取到了一个 PrincipalCollection 对象,继续深入。
接下来就是将这个 PrincipalCollection 转化成 byte 数组。这个方法很关键,我们需要跟入看一下
看到这里大家应该就明白了,Shiro 为什么会有反序列化漏洞,以及 rememberme 所传递的数据就究竟是什么,其实就是一个序列化后的 PrincipalCollection 对象,而这个 encrypt 就是通过 AES 来加密序列化后的数据,密钥呢?当然就是硬编码在 AbstractRememberMeManager 类中的这段 base64 编码后的字符串了,如下图所示
最后的最后,Shiro 会将这段加密后的数据 base64 编码一遍,然后放入 Cookie 中,至此 Shiro 生成 rememberme 的过程就结束了
那么知道了 rememberme 是怎么加密生成的,那么自然也就可以很轻易的解密了,尤其还是在密钥硬编码在代码中的这种情况,下面是解密的 demo
那么反序列化漏洞产生的点在哪里呢?当用户在登录时勾选了 rememberme 的时候,Shiro 会返回一个 rememberme 通过 Cookie 字段传递,然后存储在浏览器中,正常情况下当用户关闭浏览器或者手动删除浏览器中存储的 SesseionID 的时候,与服务端的当前会话就结束了,当下次打开浏览器再此访问服务端的时候,就需要重新登录。而勾选了 rememberme 的用户登录后,关闭浏览器后,会话同样会关闭,但是下次打开浏览器请求访问服务端的时候,Cookie 中会携带 Rememberme 来进行请求,从而达到无需登录的效果。漏洞的产生关键就在于再重新建立会话的这个过程,所以想要触发 rememberme 的话请求包中就不能有 SessionID,删除 SessionID 后再携带 rememberme 进行一次请求就可以触发 rememberme 的反序列化点,如下图所示
最终通过 base64 解码,然后 AES 解密,解密后的结果再经过反序列化,就还原成了一个 PrincipalCollection 对象,至此 Shiro rememberme 的生成以及作用,还有如何触发 rememberme 反序列化,都已讲解完毕。
Apache Shiro 是一款相当优秀的认证授权框架,虽然在护网等大型攻防项目中,经常被作为突破口,但是仍然瑕不掩瑜,shiro 的反序列化在流量识别中是比较容易判断的,因为序列化数据的传递必须要通过 Cookie 中的 rememberme 字段,但是纵使识别出来,但是如果不知道密钥的话,也无法得知传递的内容。