最近因为工作问题需要接触到shiro继而想起shiro两个非常有名的反序列漏洞,shiro550(CVE-2016-4437)和shiro721(CVE-2019-12422),这两个洞曾经不管是在大大小小的攻防演练中大放异彩,也是很多安全公司的面试官喜欢问的面试题目,借着工作的间隙,重温一下这两个经典的漏洞。
本片文章主要再讲, shiro550(CVE-2016-4437) 和 shiro721(CVE-2019-12422)、以及利用工具 **ShiroAttack2(https://github.com/SummerSec/ShiroAttack2)**。能力一般水平有限,写的不好请多指教。
通过 MANIFEST.MF 确定了jar包运行的入口,我们看到这里它用了spring,不过对我们没啥影响,可以看到这里代码量很少而且命名很清晰,直接根据dada老师的快速代审技巧找路由看鉴权一套小连招。看到 UserController,看过mvc的道友一眼就能看出它不是人(控制器)。
UserController 的代码如下,我们直接从这里开始看,我们知道shiro是一个提供身份验证、授权、密码学和会话管理工具,很明显这里唯一一个接收传参还是POST请求的函数就是 doLoginPage,同时它还获取了关键的 rememberme
javaimportorg.apache.shiro.SecurityUtils;importorg.apache.shiro.authc.AuthenticationException;importorg.apache.shiro.authc.UsernamePasswordToken;importorg.apache.shiro.subject.Subject;//......多余的省略 @Controller public class UserController { public 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"))); return "forward:/"; } catch (AuthenticationException var6) { return "forward:/login"; } } @RequestMapping({"/"}) public String helloPage() { return "hello"; } @RequestMapping({"/unauth"}) public String errorPage() { return "error"; } @RequestMapping({"/login"}) public String loginPage() { return "login"; } }
Subjectsubject=SecurityUtils.getSubject()
通过 SecurityUtils.getSubject();获取了一个安全主体赋值给变量subject(在shiro中Subject 代表当前用户或系统的一个安全主体,它可以是一个用户、一个程序、一个服务等)。接着执行 subject.login(newUsernamePasswordToken(username,password,rememberMe.equals("remember-me")));
,首先new了一个 UsernamePasswordToken 类的对象,并把用户名、密码,还有请求参数是否包含"remember-me"的判断结果(rememberMe.equals("remember-me"))一并传递给 UsernamePasswordToken 的构造方法。
这边因为是将shirodemo-1.0-SNAPSHOT.jar添加为库了,没办法直接使用ctrl+鼠标左键跳到对应的方法,所以我把shirodemo-1.0-SNAPSHOT.jar整个解压出来,把它的lib目录下所有的内容一股脑复制到我的libs目录下就可以跟过去了。
先看到 UsernamePasswordToken 类的构造方法
这里很简单,只用this调用了自己的另一个构造方法,java的类支持多个构造方法,只要传参不同就可以。简单看一下几个参数:
username String 直接传参没有变化
(char[])(password!=null?password.toCharArray():null)
(char[])就是将右边的变量强制转换成字符数组,右边括号里的叫做三目运算也可以叫三元表达式等等,就是个简单的if语言,如果password != null,就走 password.toCharArray(),否则走null。如果我们正常输入用户名和密码并勾选rememberMe那么password != null成立于是执行了 password.toCharArray()
将密码从字符串变成字符数组并强制转换成(char[])类型。
rememberMe boolean 直接传参,没有变化
(String)null String 传入一个有null强制转换成的字符串
找到满足参数的构造方法,直接跟过去就行,发现只有简简单单的赋值操作
进入login方法发现Subject是一个接口,点击左边那个图标idea自动会找到具体的实现代码,还好实现就一个直接过去了。
具体实现代码位于shiro-core的 DelegatingSubject.class,内容如下:
this.clearRunAsIdentitiesInternal();
跟过去搂一眼this.clearRunAsIdentities();
,接着跟过去。getSession(false)
方法通常用于获取当前用户的会话,如果用户尚未有会话(未登录),则返回 null
。,然后如果session不等于空就执行 session.removeAttribute(RUN_AS_PRINCIPALS_SESSION_KEY);
, removeAttribute(Objectvar1)
方法是 org.apache.shiro.session.Session
接口中定义的方法。该方法用于从会话中移除指定名称的属性,并返回被移除的属性的值。
先看 getSession,第一个if是日志相关的,不看。第二个因为传入的 create 值是flase,不会进入,也不看,最后直接 returnthis.session;
session.removeAttribute(RUN_AS_PRINCIPALS_SESSION_KEY);
session.removeAttribute(RUN_AS_PRINCIPALS_SESSION_KEY);
,跟进removeAttribute一看实现怪多的嘞,我也不知道会走哪一个,先跳过这个继续看下去,一会动调的时候就知道了从clearRunAsIdentitiesInternal出来,继续向下走到 this.securityManager.login(this,token);
,这个方法除了传入当前对象,还传入的new出来的token类(就是前面的UsernamePasswordToken),很有必要瞅一瞅
跟进 this.securityManager.login 方法发现又进入了个接口,好在login只有一个实现,直接跟过去看看
login的实现代码位于shiro-core的 DefaultSecurityManager.class
内容如下
第一行声明了一个AuthenticationInfo类型的变量info,先去看看 info = this.authenticate(token);
下面是 authenticate 的代码,又开始套娃,接着跟。
又是个接口,不过实现只有两个,可以往下看看
尬住了,一路只记得看函数,第二个实现就是上面那个 authenticate,所以直接看第一个实现就好了。
第一个实现就是 public final AuthenticationInfo authenticate(AuthenticationToken token),函数有些长,一点点看吧。
首先有判断token是不是null,很明显不是。直接看else
首先是日志,不看,第二行新建了个变量,没得看,第三行进入try,开始调用函数并将返回值给info
跟进 this.doAuthenticate(token);
,跟过去是个接口,不过实现就一个,老样子跟过去看。
实现代码如下:
先看this.assertRealmsConfigured();的代码,这看起来就是个检查某个配置,先获取对象的realms属性,然后判断它是否为空,为空就抛出一个异常
再看Collection realms = this.getRealms();,获取对象的realms属性
最后看 realms.size()==1?this.doSingleRealmAuthentication((Realm)realms.iterator().next(),authenticationToken):this.doMultiRealmAuthentication(realms,authenticationToken);
。熟悉的三目运算,判断上一步的获取的变量realms的size是不是等于1,等于1执行doSingleRealmAuthentication,不等于1执行doMultiRealmAuthentication
先看doSingleRealmAuthentication
直接看 AuthenticationInfoinfo=realm.getAuthenticationInfo(token);
Realm也是个接口,doMultiRealmAuthentication只有一个实现,直接跟进去。
getAuthenticationInfo 的实现代码如下,这里首先通过 getCachedAuthenticationInfo 获取了变量info,它又2个if判断分别检查info为null和不为null的情况,而且都有各自的函数调用,没有动调不知道它会怎么走,但是最后会返回一个info,先继续看下去。
回到doSingleRealmAuthentication函数,如果info不是null,他也直接返回了info。doMultiRealmAuthentication的逻辑也与之类似,不同的是它获取了info之后使用另一个函数将info做参数传入,最后返回一个AuthenticationInfo类型的变量aggregat。doSingleRealmAuthentication和doMultiRealmAuthentication命名和代码逻辑都很相似,所以直接跳过doMultiRealmAuthentication不再分析。
这样又回到了authenticate方法中,如果info = this.doAuthenticate(token);没有发生异常的话就会进入 notifySuccess 方法。
继续跟踪 notifySuccess,并没有发现有反序列化的地方
接着就返回了info,看到这里,就大胆的猜测一下,这个info应该是有一些用户的信息或其他校验时需要用到的数据在里头,它通过这一层层代码获取到这个玩意儿,在后面应该就是漏洞触发的地方了。
取回info之后,就回到 DefaultSecurityManager.class,如果没有发生异常将进入 Subject loggedIn = this.createSubject(token, info, subject); 方法传入了login接收的参数subject、token,以及后面获取到的info变量
在createSubject方法中,首先执行SubjectContext context = this.createSubjectContext();得到一个 DefaultSubjectContext 的对象context ,然后三个set开头的函数分别设置了context的三个属性。
setAuthenticated 传入了个 true, setAuthenticated 是一个接口,跟进他的实现,它给 backingMap 这个map添加了个键值对。
setAuthenticationToken,传入了 token,也是个接口,继续跟,不过看起来也是在往 backingMap 添加数据
第三个 setAuthenticationInfo,传入了了 info,和上面那个差不多。
接下去判断 existing 是不是null,如果它不是null就执行context.setSubject(existing);将,最后调用另一个 createSubject 并把结果return,这个 createSubject 又有一堆调用,代码如下
然后又像上面一样一个个的跟踪,直到跟踪到 context = this.resolvePrincipals(context); 反序列的过程就在这个函数中,直接看到 resolvePrincipals 的代码,留意这一行 principals = this.getRememberedIdentity(context);,这里面出现了我熟悉的Remember字眼于是我先看了这个函数
getRememberedIdentity 的代码如下
RememberMeManagerrmm=this.getRememberMeManager();
声明了个 RememberMeManager
类型的变量rmm, RememberMeManager
是个接口
如果rmm不等于null会执行 returnrmm.getRememberedPrincipals(subjectContext);
getRememberedPrincipals 的实现代码如下
直接看 getRememberedSerializedIdentity 的实现,直接看高亮部分,这里去http请求中获取数据并且尝试base64解码然后返回解码数据
回到 getRememberedPrincipals,将 getRememberedSerializedIdentity 返回的数据赋值给 bytes,如bytes不为null且长度大于0就执行 principals = this.convertBytesToPrincipals(bytes, subjectContext);
convertBytesToPrincipals 的代码如下
getCipherService()
: 获取用于加密和解密的 CipherService。这是 Shiro 中与加密相关的服务,从后续调用中可以发现这是AES加密,很明显这返回值不会是null,会执行 bytes=this.decrypt(bytes);
decrypt(bytes) 的代码如下
首先声明一个变量 serialized,其次获取AES的加解密服务赋值个 cipherService,接着判断 cipherService 是否为null,如果不是null就开始解密。
cipherService.decrypt(encrypted, this.getDecryptionCipherKey());,在这个解密函数中encrypted是密文, this.getDecryptionCipherKey() 会返回一个密钥用于解密。
最最关键的来了, decryptionCipherKey 一开始只是一个空的字节数组,但是在这个 AbstractRememberMeManager 类的构造方法中,将 decryptionCipherKey 设置为一个固定的值。
也就是说我们可以构造一个恶意的类将其序列化之后使用这个写死的key进行AES加密之后再将密文进行base64编码,最后将这编码之后的恶意数据放到cookie中发送给shiro,shiro就会获取恶意数据进行base64解码然后使用相同的密钥成功解密最后反序列化它达到RCE
最后将解密之后的字节数组重新赋值给bytes,通过decrypt方法的返回变量名不妨猜一猜,这个解密之后的字节数组应该是一个经过序列化得到的数据
接着将解密之后的字节数据带入 deserialize 方法,看名字也能猜出来这是反序列化的函数。大致流程应该是这样,接下去找个利用工具然后进行debug验证我们的猜想。
java8 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=65500 -jar .\shirodemo-1.0-SNAPSHOT.jar
[Releases · SummerSec/ShiroAttack2]https://github.com/SummerSec/ShiroAttack2/releases
运行工具
java8 -jar .\shiro_attack-4.7.0-SNAPSHOT-all.jar
context=this.resolvePrincipals(context);
如果base64不为null就进行base64解码操作,然后将解码之后得到的字节数组返回
接下去看一下大佬的利用工具咋整的。s
我们在没有借助debug的情况下成功找到漏洞触发点,整理之后的文档比原来的笔记短太多了。之前没有这么完整的跟过代码,都只是看一下各位大佬写的文章列出的链,自己的水平还是有所欠缺,在对这个漏洞以及有所了解的情况下硬读代码走了不少歪路,期间多次跟丢代码走错函数。
最后总结以下我们上面路过的所有文件和函数: