1 | 在最近这几年,我们在渗透的过程中经常会用到shiro的rce漏洞来打点,直到目前为止还经常会在一些项目或者HW中也会经常遇到shrio的rce,因此了解shiro的RememberMe反序列化导致的命令执行漏洞的原理是至关重要的,在本次分享中,我将和大家一起学习shiro RememberMe反序列化漏洞。 |
环境搭建
我这里选择网上已经有人搭建好的漏洞环境来搭建环境https://github.com/potats0/ShiroDemo
,下载好项目以后使用IDEA导入pom.xml文件,由于我这里使用的MAVEN是阿里云镜像,我使用shiro-core为1.2.4时提示我找不到包,因此我这里使用的是1.2.2版本。
需要的包导入后,找到App.java文件,通过调试来运行。
运行成功后界面如下:
Shiro基本知识
我们知道,shiro是一款用来进行权限认证和权限管理的框架,可以帮我们完成认证、授权、加密、会话管理、与Web集成、缓存等功能。
下面我结合着这个漏洞环境的代码带大家一起学习一下shiro的基本知识。
在这个项目的源码文件中,主要包含了4个文件,APP.java内容为启动springboot的内容,这里不做解释了,看下其他文件的内容,首先是MainRealm.java,在介绍这个文件的内容前,我们先了解几个基本的概念。
我们知道,shiro框架的一个主要的功能是用来做身份认证的,在shiro中,主要通过principals (身份)和 credentials(证明)一起来验证用户的身份。
principals
指用户身份的标识,可以是用户的用户名,手机号等等,但需要确保其唯一性。
credentials
凭证,一般来说就是密码。
Realm
域,shiro会从Realm中获取安全数据(用户,角色,权限),当SecurityManager要身份认证,需要从Realm中来确定用户身份以及用户可以访问的权限。
在shiro中,SecurityManager负责身份认证的逻辑,它会委托给Authenticator进行身份认证,Authenticator 会把相应的 token 传入 Realm,从 Realm 获取身份验证信息,如果没有返回 / 抛出异常表示身份验证失败了。此处可以配置多个 Realm,将按照相应的顺序及策略进行访问。
AuthorizingRealm
在shiro中,默认提供了一些Realm,他们的继承关系如下:
CachingRealm是带缓存的Realm,里面包含了多个CacheManager属性,具体的缓存在其子类中进行实现。
AuthenticatingRealm是带认证的Realm,该类实现了认证的基本逻辑和缓存逻辑。
AuthorizingRealm主要用来鉴权和获取授权信息的Realm,该类实现了Authorizer,所以可以做鉴权。也实现了PermissionResolverAware,RolePermissionResolverAware因此可以对用户的访问权限做判断。
在shiro认证过程中,依赖AuthenticatingRealm的getAuthenticationInfo方法,getAuthenticationInfo会调用我们自定义的doGetAuthenticationInfo方法获取认证的结果。
在登录的时候需要将数据封装到Shiro
的一个token
中,执行shiro的login()
方法,Shiro
就会自动的调用doGetAuthenticationInfo(AuthenticationToken token)
方法获取身份认证信息,在本次环境中,首先通过token.getPrincipal()方法获取username信息,通过authenticationToken.getCredentials()获取密码信息,对用户的用户名和密码进行判断,如果用户用户名为admin,密码为vulhub,认证成功则返回SimpleAuthenticationInfo对象,mainrealm.java的代码如下:
shiro拦截器
在shiro中使用了与 Servlet 一样的 Filter 接口进行扩展,shiro拦截器的基础类及其继承关系如下:
ShiroFilter是整个 Shiro 的入口点,用于拦截需要安全控制的请求进行处理,除了上面的基础拦截器类外,shiro还提供了一些比较常用的默认拦截器。
FormAuthenticationFilter登录拦截器,它主要有两个作用,一个是拦截登录表单提交的路径,创建登录认证所需要的Token令牌,并进入登录认证流程。另一个作用是拦截要求登录后才可以访问的路径,如果已经登录则直接进入到要访问路径,如果未登录则访问被拒绝并跳转到登录页。登录拦截器常用的方法如下:
1 | createToken创建认证令牌,令牌内存储了登录认证时所需的数据。 |
UserFilter用户拦截器,用户已经登录认证 或 已经记住我 的都可以通过。
AnonymousFilter无需认证即可通过。
拦截器链
Shiro 对 Servlet 容器的 FilterChain 进行了代理,即 ShiroFilter 在继续 Servlet 容器的 Filter 链的执行之前,通过 ProxiedFilterChain 对 Servlet 容器的 FilterChain 进行了代理。当Filter执行的过程中,首先执行shiro的拦截器链,再经过Servlet容器的拦截器链。
在shiro中提供了PathMatchingFilterChainResolver来判断请求的url和拦截器的规则是否匹配。
DefaultFilterChainManager中维护者一个拦截器链,我们可以通过DefaultFilterChainManager中的方法添加拦截器。
ShiroFilterFactoryBean
通过ShiroFilterFactoryBean类可以方便的配置拦截器的各种基本属性。
1 | setSecurityManager:注入一个SecurityManager类,SecurityManager负责管理整个shiro核心验证功能。 |
拦截规则是通过MAP来进行设置的,基本形式如下
1 | Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>(); |
拦截器路径是一个从根路径开始的url,并支持通配符。拦截器名称既可以是shiro内置拦截器的名称比如anon(无需认证的拦截器)、authc(需要认证的拦截器)、user (已经登录成功或使用记住我的拦截器),perms[role_name] - 需要权限验证的路径使用perms拦截器。中括号内为权限名称列表。
接下来我们看一下shiroConfig.java,这个类是shiro的配置类,在这个类的shiroFilterFactoryBean中,通过setSecurityManager来设置securityManager,在securityManager中,设置了Realm为我们自己定义的mainRealm,RememberMeManager为cookieRememberMeManager,也就是cookie的”记住我”功能。通过setLoginUrl方法来设置未登录时需要认证的地址也就是登录地址。setUnauthorizedUrl方法来设置无权访问时跳转的地址。通过创建LinkedHashMap,设置map.put(“/doLogin”, “anon”)来设置不需要登录就能访问的地址。通过map.put(“/xxx/**”, “user”);来设置用户登录后才能访问的地址。最后通过setFilterChainDefinitionMap将这个map设置到FilterChain中。
Subject
Shiro中认证授权组件Subject,为我们提供了当前用户、角色和授权的相关信息,可以进行登录,退出,权限验证,获取用户信息,session。
通过SecurityUtils.getSubject获取subject对象
1 | Subject subject = SecurityUtils.getSubject(); |
subject包含如下主要接口:
1 | Subject login(Subject subject, AuthenticationToken authenticationToken) throws AuthenticationException; //登陆 |
了解了这些知识,我们看下UserController.java是如何实现的,通过SecurityUtils.getSubject方法获取subject对象,通过login来进行登录,传入的参数为UsernamePasswordToken对象。
这里,shiro的基础知识我们大概了解了一些了,下面我们再看一下漏洞。
漏洞分析
因为之前已经有人分析过shiro的反序列化漏洞了,并且在文章中给出了存在反序列化漏洞的方法,在DefaultSerializer类的deserialize方法中,因此我们可以直接找到这个方法并打上断点 。
在这个函数中调用了ObjectInputStream类的readObject方法来进行反序列化操作,下面是整个过程的调用栈
1 | deserialize:75, DefaultSerializer (org.apache.shiro.io) |
下面我们分析一下这个漏洞的调用过程,再调用的过程中,我们可以看到拦截器的调用链,已经将shiroFilter写入到tomcat拦截器之前。
下面依次调用对应的拦截器,OrderedCharacterEncodingFilter–>OrderedFormContentFilter–>OrderedRequestContextFilter–>ShiroFilterFactoryBean–>WsFilter。
首先调用OrderedCharacterEncodingFilter设置编码
调用OrderedFormContentFilter获取参数,这里参数为空,因此会调用else中的代码。
调用OrderedRequestContextFilter完成requestContext的初始化操作。
下来就是shiorFilter的调用链,再shior拦截器中调用了createSubject来创建subject对象。
跟进createSubject方法,调用了buildWebSubject方法。
在 buildWebSubject中调用了其父类的buildSubject方法。
跟进父类的buildSubject方法,调用了DefaultSecurityManager的createSubject方法。
继续跟进,我们需要关注resolvePrincipals方法。
跟进resolvePrincipals方法,判断RememberMeManager是否为空,不为空则调用getRememberedPrincipals。
跟进getRememberedPrincipals方法,首先调用了getRememberedSerializedIdentity。
跟进getRememberedSerializedIdentity方法,在该方法中,通过this.getCookie().readValue(request, response);获取了cookie的内容,并且判断是否为deleteMe,若为DeleteMe则返回null,否则将继续执行,对获取的cookie的内容进行base64解码并返回。
返回后会判断获取的cookie的内容是否为空,如果不为空,则调用convertBytesToPrincipals方法。
在convertBytesToPrincipals调用了decrypt对cookie的内容进行解密。
在decrypt中,调用cipherService.decrypt进行解密,同时传入了this.getDecryptionCipherKey()的内容。
我们看一下getDecryptionCipherKey中的key是如何来的,返回了当前对象的decryptionCipherKey属性。
decryptionCipherKey是在setDecryptionCipherKey中设置的。
在setCipherKey中调用了setDecryptionCipherKey进行设置、
setCipherKey中的参数来自于DEFAULT_CIPHER_KEY_BYTES,而这个值是kPH+bIxk5D2deZiIxcaaaA== base64解密后的内容,所以这个key的内容在我们当前的环境下是写死的。
继续跟进解密算法,可以看到使用的解密方式是AES方式。
将解密后的结果返回后,再转换为字节数组的形式并返回。
将返回的bytes作为参数传递到deserialize方法中。
继续跟踪下面的调用
最终我们可以看到,将我们通过cookie传入的内容转换为ObjectInputStream,并调用了readObject进行反序列化操作。
通过上面的分析,我相信我们已经对于shiro反序列化漏洞的基本原理有了大致的了解,这个漏洞要想正常的利用,至关重要的一点是找到解密cookie中传入payload的key,在这个环境中,key是写死的,实际上这个key也可以自己在shiorconfig类中配置加密的key。
当我们更换key后,再去利用这个漏洞,由于key不正确无法解密导致无法继续利用。
既然重新生成key这么简单,为什么还是有人使用默认的key或者网上公布的其他key?我们看一下这个key解密后的结果就知道了,由于这些key解密后都是一些乱码,不太容易编辑,所以可能很多人会去使用网上别人生成好的key,所以在漏洞利用的过程中有人想到了爆破key的方法。
那如果想自己生成key,怎样生成才能满足要求呢,只要使用任意一个16位,24位,32位的字符串base64编码后都是可以作为key的。
漏洞修复
我们尝试切换shiro为高版本,看一下shiro是如何修复反序列化漏洞的,我切换到1.2.7版本的shiro,我们可以看到在该版本中,key默认并不是写死的,而是由cipherService.generateNewKey().getEncoded()来生成的key。
生成key的代码如下
漏洞检测
如何识别shiro?
要检测一个shiro是否存在反序列化漏洞,首先需要对shiro这个框架做一个识别,目前大部分的方法都是通过rememberMe=deleteMe;来识别的,但是并不是我们请求的所有地址都会返回rememberMe=deleteMe,但是在实战中我们可能需要批量去检测shiro框架,我们该如何识别?
通过之前的分析我们知道,无论我们访问哪个url,都会经过shiro的拦截器,而在shiro的拦截器中会获取cookie中rememberMe的内容并进行解密,并且通过之前的分析,我们知道解密是由convertBytesToPrincipals()完成的,如果我们传入的rememberMe不能正常的反序列化,就会抛出异常,调用onRememberedPrincipalFailure方法。
该方法经过几层调用最终调用了this.getCookie().removeFrom(request, response);方法
在removeFrom中,会在返回包header加上rememberMe=deleteMe,因此无论我们访问任何使用了shiro框架的路由,只需要在请求头中的cookie中加入rememberMe=xxx,如果目标使用了shiro,则会返回rememberMe=deleteMe,也就是说我们可以通过一个包来识别是否使用了shiro框架。
了解了这些,我们可以写一个简单的python脚本来批量识别shiro,这里注意我将重定向设置为false,否则我们在请求时会跟进302跳转,但跳转后的结果里可能没有rememberMe=deleteMe。
1 | import requests |
如何判断key是否正确?
通过上面的测试我们知道当我们使用的加密key正确可以正常进行反序列化操作,即使key正确,但生成的内容无法正常反序列化,则还是会返回rememberMe=deleteMe,因为反序列化的操作是在convertBytesToPrincipals方法完成的,如果反序列化的过程中出现异常,还是会设置rememberMe=deleteMe这个请求头,因此我们测试key是否正确,需要找到一个可以不依赖第三方组件的java中默认存在且和jdk版本无关的类来进行反序列化。
我尝试使用URLDNS来进行判断,虽然可以正常发起DNS请求,但 由于执行过程中的类型转换错误,因此还是会返回rememberMe=deleteMe。
我们正常的登录,看看正常登录过程中的反序列化的类是什么类型
使用这个rememberMe的内容利用,看shiro再进行反序列化的过程中反序列化的是哪个类?
跟进后发现是反序列化了SimplePrincipalCollection类,所以我们只要创建一个SimplePrincipalCollection对应的对象进行序列化就可以了,当传入的序列化内容可以正常被解析,就不会出现rememberMe=deleteMe,因此可以通过这个特性来判断我们的key是否正确。
我们查看一下ShiroExploit这个工具,看看他是怎么实现的。
这个工具在检测key是否正确的过程中调用了ysoserial的ShiroCheck,可我去查看ysoserial的github项目,发现并没有这个选项,也就是这个是作者自己扩展编写的。我们反编译ShiroExploit自带的ysoserial,他的实现也非常简单,就是创建一个SimplePrincipalCollection对象。
如何检测利用链?
在JAVA的反序列化漏洞中,仅仅找到readObject反序列化并不一定能造成RCE,还有一点比较重要的是需要找到利用链,这个要具体展开讲需要依赖的知识点有点多,我之后会单独写一篇文章来讲解。