长亭百川云 - 文章详情

Shiro反序列化源码分析学习

VegetaY

42

2024-02-23

​最近因为工作问题需要接触到shiro继而想起shiro两个非常有名的反序列漏洞,shiro550(CVE-2016-4437)和shiro721(CVE-2019-12422),这两个洞曾经不管是在大大小小的攻防演练中大放异彩,也是很多安全公司的面试官喜欢问的面试题目,借着工作的间隙,重温一下这两个经典的漏洞。

​ 本片文章主要再讲, shiro550(CVE-2016-4437)shiro721(CVE-2019-12422)、以及利用工具 **ShiroAttack2(https://github.com/SummerSec/ShiroAttack2)**。能力一般水平有限,写的不好请多指教。

shiro550(CVE-2016-4437)

找源码

  1. 源码:vulhub shiro/CVE-2016-4437 ,克隆vulhub项目进入目录执行docker-compose up -d,等到服务起来之后进入容器就通过ps命令找到运行的jar
  • image-20240121141429156
  1. 把jar包cp到宿主机上,然后从虚拟机里面拿出来,打开idea建个目录 libs 把jar放进去,右键目录选择添加为库
  • image-20240121141655008

看路由

  1. 通过 MANIFEST.MF 确定了jar包运行的入口,我们看到这里它用了spring,不过对我们没啥影响,可以看到这里代码量很少而且命名很清晰,直接根据dada老师的快速代审技巧找路由看鉴权一套小连招。看到 UserController,看过mvc的道友一眼就能看出它不是人(控制器)。

  2. 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"; } }

找鉴权

doLoginPage()

image-20240121204946348

  1. 代码量少都不需要进行debug了,直接看到 doLoginPage 函数,首先执行 Subjectsubject=SecurityUtils.getSubject() 通过 SecurityUtils.getSubject();获取了一个安全主体赋值给变量subject(在shiro中Subject 代表当前用户或系统的一个安全主体,它可以是一个用户、一个程序、一个服务等)。

UsernamePasswordToken

  1. 接着执行 subject.login(newUsernamePasswordToken(username,password,rememberMe.equals("remember-me")));,首先new了一个 UsernamePasswordToken 类的对象,并把用户名、密码,还有请求参数是否包含"remember-me"的判断结果(rememberMe.equals("remember-me"))一并传递给 UsernamePasswordToken 的构造方法。

  2. 这边因为是将shirodemo-1.0-SNAPSHOT.jar添加为库了,没办法直接使用ctrl+鼠标左键跳到对应的方法,所以我把shirodemo-1.0-SNAPSHOT.jar整个解压出来,把它的lib目录下所有的内容一股脑复制到我的libs目录下就可以跟过去了。

  3. 先看到 UsernamePasswordToken 类的构造方法

  • image-20240121205207458

  • 这里很简单,只用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强制转换成的字符串

  • 找到满足参数的构造方法,直接跟过去就行,发现只有简简单单的赋值操作

    • image-20240121145331386

subject.login()

  1. 回到 UserController,开始进入 subject.login 方法,就正式开始进入shiro。代码位于shiro-core的 Subject.class
  • 进入login方法发现Subject是一个接口,点击左边那个图标idea自动会找到具体的实现代码,还好实现就一个直接过去了。

    • image-20240121145614279

    • 具体实现代码位于shiro-core的 DelegatingSubject.class,内容如下:

    • image-20240121205420938

clearRunAsIdentitiesInternal()
  1. 第一行 this.clearRunAsIdentitiesInternal(); 跟过去搂一眼

image-20240121150302979

  1. 发现又调用了自己的 this.clearRunAsIdentities();,接着跟过去。

image-20240121150348383

  1. getSession(false) 方法通常用于获取当前用户的会话,如果用户尚未有会话(未登录),则返回 null。,然后如果session不等于空就执行 session.removeAttribute(RUN_AS_PRINCIPALS_SESSION_KEY);, removeAttribute(Objectvar1) 方法是 org.apache.shiro.session.Session 接口中定义的方法。该方法用于从会话中移除指定名称的属性,并返回被移除的属性的值。

  2. 先看 getSession,第一个if是日志相关的,不看。第二个因为传入的 create 值是flase,不会进入,也不看,最后直接 returnthis.session;

image-20240121150757167

  1. 还没开始debug,我不知道返回的session是不是等于null,所以搂一眼 session.removeAttribute(RUN_AS_PRINCIPALS_SESSION_KEY);

image-20240121151111287

  1. RUNASPRINCIPALSSESSIONKEY 是一个全局变量(一般大写的都是,idea也会标颜色),它的值是一个字符串,以类名加上.RUNASPRINCIPALSSESSIONKEY

image-20240121151312726

  1. 回来看 session.removeAttribute(RUN_AS_PRINCIPALS_SESSION_KEY);,跟进removeAttribute一看实现怪多的嘞,我也不知道会走哪一个,先跳过这个继续看下去,一会动调的时候就知道了

image-20240121151501809

this.securityManager.login()
  1. 从clearRunAsIdentitiesInternal出来,继续向下走到 this.securityManager.login(this,token);,这个方法除了传入当前对象,还传入的new出来的token类(就是前面的UsernamePasswordToken),很有必要瞅一瞅

    image-20240121151933037

  2. 跟进 this.securityManager.login 方法发现又进入了个接口,好在login只有一个实现,直接跟过去看看

  3. login的实现代码位于shiro-core的 DefaultSecurityManager.class

    内容如下

    image-20240121205751403

  4. 第一行声明了一个AuthenticationInfo类型的变量info,先去看看 info = this.authenticate(token);

  5. 下面是 authenticate 的代码,又开始套娃,接着跟。

    image-20240121152720005

  6. 又是个接口,不过实现只有两个,可以往下看看

    image-20240121152907495

    image-20240121152931361

  7. 尬住了,一路只记得看函数,第二个实现就是上面那个 authenticate,所以直接看第一个实现就好了。

  8. 第一个实现就是 public final AuthenticationInfo authenticate(AuthenticationToken token),函数有些长,一点点看吧。

  9. 首先有判断token是不是null,很明显不是。直接看else

    image-20240121205858505

  10. 首先是日志,不看,第二行新建了个变量,没得看,第三行进入try,开始调用函数并将返回值给info

    image-20240121154231490

  11. 跟进 this.doAuthenticate(token);,跟过去是个接口,不过实现就一个,老样子跟过去看。

  12. 实现代码如下:

    image-20240121205934443

    1. 先看this.assertRealmsConfigured();的代码,这看起来就是个检查某个配置,先获取对象的realms属性,然后判断它是否为空,为空就抛出一个异常

      image-20240121200136021

    2. 再看Collection realms = this.getRealms();,获取对象的realms属性

    3. 最后看 realms.size()==1?this.doSingleRealmAuthentication((Realm)realms.iterator().next(),authenticationToken):this.doMultiRealmAuthentication(realms,authenticationToken);。熟悉的三目运算,判断上一步的获取的变量realms的size是不是等于1,等于1执行doSingleRealmAuthentication,不等于1执行doMultiRealmAuthentication

      1. 先看doSingleRealmAuthentication

        image-20240121160237893

      2. 直接看 AuthenticationInfoinfo=realm.getAuthenticationInfo(token); Realm也是个接口,doMultiRealmAuthentication只有一个实现,直接跟进去。

      3. getAuthenticationInfo 的实现代码如下,这里首先通过 getCachedAuthenticationInfo 获取了变量info,它又2个if判断分别检查info为null和不为null的情况,而且都有各自的函数调用,没有动调不知道它会怎么走,但是最后会返回一个info,先继续看下去。

        image-20240121162236630

      4. 回到doSingleRealmAuthentication函数,如果info不是null,他也直接返回了info。doMultiRealmAuthentication的逻辑也与之类似,不同的是它获取了info之后使用另一个函数将info做参数传入,最后返回一个AuthenticationInfo类型的变量aggregat。doSingleRealmAuthentication和doMultiRealmAuthentication命名和代码逻辑都很相似,所以直接跳过doMultiRealmAuthentication不再分析。

  13. 这样又回到了authenticate方法中,如果info = this.doAuthenticate(token);没有发生异常的话就会进入 notifySuccess 方法。

    image-20240121210119865

  14. 继续跟踪 notifySuccess,并没有发现有反序列化的地方

    image-20240121163655469

  15. 接着就返回了info,看到这里,就大胆的猜测一下,这个info应该是有一些用户的信息或其他校验时需要用到的数据在里头,它通过这一层层代码获取到这个玩意儿,在后面应该就是漏洞触发的地方了。

createSubject()
  1. 取回info之后,就回到 DefaultSecurityManager.class,如果没有发生异常将进入 Subject loggedIn = this.createSubject(token, info, subject); 方法传入了login接收的参数subject、token,以及后面获取到的info变量

    image-20240121164645378

  2. 在createSubject方法中,首先执行SubjectContext context = this.createSubjectContext();得到一个 DefaultSubjectContext 的对象context ,然后三个set开头的函数分别设置了context的三个属性。

    image-20240121202509251

  3. setAuthenticated 传入了个 truesetAuthenticated 是一个接口,跟进他的实现,它给 backingMap 这个map添加了个键值对。

  4. setAuthenticationToken,传入了 token,也是个接口,继续跟,不过看起来也是在往 backingMap 添加数据

    image-20240121203220982

  5. 第三个 setAuthenticationInfo,传入了了 info,和上面那个差不多。

  6. 接下去判断 existing 是不是null,如果它不是null就执行context.setSubject(existing);将,最后调用另一个 createSubject 并把结果return,这个 createSubject 又有一堆调用,代码如下

    image-20240121173021650

  7. 然后又像上面一样一个个的跟踪,直到跟踪到 context = this.resolvePrincipals(context); 反序列的过程就在这个函数中,直接看到 resolvePrincipals 的代码,留意这一行 principals = this.getRememberedIdentity(context);,这里面出现了我熟悉的Remember字眼于是我先看了这个函数

    image-20240121201808173

  8. getRememberedIdentity 的代码如下

    image-20240121203505088

  9. RememberMeManagerrmm=this.getRememberMeManager(); 声明了个 RememberMeManager 类型的变量rmm, RememberMeManager 是个接口

    image-20240121203752315

  10. 如果rmm不等于null会执行 returnrmm.getRememberedPrincipals(subjectContext); getRememberedPrincipals 的实现代码如下

    image-20240121204040837

  11. 直接看 getRememberedSerializedIdentity 的实现,直接看高亮部分,这里去http请求中获取数据并且尝试base64解码然后返回解码数据

    image-20240121204209053

  12. 回到 getRememberedPrincipals,将 getRememberedSerializedIdentity 返回的数据赋值给 bytes,如bytes不为null且长度大于0就执行 principals = this.convertBytesToPrincipals(bytes, subjectContext);

  13. convertBytesToPrincipals 的代码如下

    image-20240121204514530

  14. getCipherService(): 获取用于加密和解密的 CipherService。这是 Shiro 中与加密相关的服务,从后续调用中可以发现这是AES加密,很明显这返回值不会是null,会执行 bytes=this.decrypt(bytes);

    image-20240121210537240

  15. decrypt(bytes) 的代码如下

    image-20240121210755365

    首先声明一个变量 serialized,其次获取AES的加解密服务赋值个 cipherService,接着判断 cipherService 是否为null,如果不是null就开始解密。

  16. cipherService.decrypt(encrypted, this.getDecryptionCipherKey());,在这个解密函数中encrypted是密文, this.getDecryptionCipherKey() 会返回一个密钥用于解密。

    image-20240121211926514

  17. 最最关键的来了, decryptionCipherKey 一开始只是一个空的字节数组,但是在这个 AbstractRememberMeManager 类的构造方法中,将 decryptionCipherKey 设置为一个固定的值。

    也就是说我们可以构造一个恶意的类将其序列化之后使用这个写死的key进行AES加密之后再将密文进行base64编码,最后将这编码之后的恶意数据放到cookie中发送给shiro,shiro就会获取恶意数据进行base64解码然后使用相同的密钥成功解密最后反序列化它达到RCE

    image-20240121212238332

  18. 最后将解密之后的字节数组重新赋值给bytes,通过decrypt方法的返回变量名不妨猜一猜,这个解密之后的字节数组应该是一个经过序列化得到的数据

  19. 接着将解密之后的字节数据带入 deserialize 方法,看名字也能猜出来这是反序列化的函数。大致流程应该是这样,接下去找个利用工具然后进行debug验证我们的猜想。

    image-20240121211312506

利用

debug配置

  1. 新建一个远程jvm调试配置

image-20240122193627162

  1. 运行我们的shirodemo-1.0-SNAPSHOT.jar,运行时将复制的参数加上,等服务跑起来就可以进行调试了。


  1. java8 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=65500 -jar .\shirodemo-1.0-SNAPSHOT.jar

利用工具

  1. 下载利用工具,这里直接上github找了个大佬的项目

[Releases · SummerSec/ShiroAttack2]https://github.com/SummerSec/ShiroAttack2/releases

运行工具



  1. java8 -jar .\shiro_attack-4.7.0-SNAPSHOT-all.jar
  2. 我这里直接复制代码里面的密钥进行检测

image-20240122194151164

开始调试

  1. 接着检测利用链,然后进入命令执行执行命令,输入个whoami然后点执行,在这里我将断点下在 createSubject 方法的 context=this.resolvePrincipals(context);

image-20240122194607991

  1. 接着步入 resolvePrincipals 方法,直接步过一直到 getRememberedIdentity

image-20240122194712376

  1. 进入 getRememberedIdentity 方法,先去获取RememberMeManager然后调用RememberMeManager的 getRememberedPrincipals 方法

image-20240122194908115

  1. 进入 getRememberedPrincipalsgetRememberedSerializedIdentity 去获取cookie值

image-20240122195043254

  1. 进入 getRememberedSerializedIdentity 方法,这里的base64变量值就是获取到的cookie值。

image-20240122195221478

如果base64不为null就进行base64解码操作,然后将解码之后得到的字节数组返回

image-20240122195450797

  1. 从getRememberedSerializedIdentity出来,将getRememberedSerializedIdentity的返回值给bytes,很明显这时候bytes有值所以进入if执行 convertBytesToPrincipals 方法

image-20240122195524329

  1. 进入 convertBytesToPrincipals 方法,我们可以看到接下去就是解密,然后反序列化

image-20240122195758842

  1. 直接恢复程序,到shiro_attack查看结果

image-20240122195951704

ShiroAttack2

接下去看一下大佬的利用工具咋整的。s

总结

​ 我们在没有借助debug的情况下成功找到漏洞触发点,整理之后的文档比原来的笔记短太多了。之前没有这么完整的跟过代码,都只是看一下各位大佬写的文章列出的链,自己的水平还是有所欠缺,在对这个漏洞以及有所了解的情况下硬读代码走了不少歪路,期间多次跟丢代码走错函数。

​ 最后总结以下我们上面路过的所有文件和函数:

Shiro550

相关推荐
关注或联系我们
添加百川云公众号,移动管理云安全产品
咨询热线:
4000-327-707
百川公众号
百川公众号
百川云客服
百川云客服

Copyright ©2024 北京长亭科技有限公司
icon
京ICP备 2024055124号-2