前言
在 CVE-2019-12422 Shiro721 ( Apache Shiro RememberMe Padding Oracle 1.4.1 反序列化漏洞) 分析[ 上 ] 中我们学习了以下知识点 :
-
AES/128/CBC/PKCS5Padding 加密方式
-
Padding Oracle Attack 原理
-
CBC Byte-Flipping Attack 原理
-
Apache Shiro 721 漏洞环境搭建
-
Apache Shiro 721 漏洞复现
在本章中 , 我们会真正开始研究 Shiro721 反序列化漏洞 , 来看一看该漏洞到底是如何被触发的 .
Apache Shiro721
Apache Shiro721 漏洞分析
从 CookieRememberMeManager.getRememberedSerializedIdentity() 方法入手
有了调试 Shiro 的基础 , 我们直接把断点打在 org.apache.shiro.web.mgt.CookieRememberMeManager.getRememberedSerializedIdentity()
方法中 , 该方法会对传入的 rememberMe Cookie 进行 Base64 解码 .
通过 BurpSuite Repeater 模块重放构造好的恶意 HTTP 请求 , 可以看到程序会断在断点处 . 进一步调试 , 后端在获取到 RememberMe Cookie 后 , 会先判断其值是否为 "deleteMe" .
如果 Cookie 值不为 "deleteMe" , 则会调用 ensurePadding()
方法进行处理 . 跟进该方法 , 发现它只是用于判断 Base64 字符串是否合法以及填充等于号 =
. 并不是我们想找的校验 PKCS5Padding 的方法 .
继续调试代码 , 可以看到该函数在进行 Base64 解码后就会返回 . 没有我们需要的内容 .
返回其父方法 getRememberedPrincipals()
中 , 可见该方法会对返回的字节数组进行简单的判断 , 然后再调用 convertBytesToPrincipals()
方法进行处理.
convertBytesToPrincipals() 方法中开始将密文字节数组转换为用户身份凭证
跟进到 convertBytesToPrincipals()
方法中 , 该方法会先判断是否设置了密钥服务对象( CipherService ), 如果存在目标对象 , 则调用该对象的 decrypt()
方法进行解密 .
Apache Shiro 中默认使用 AES 来提供加密解密服务 . 因此这里最终会调用 AesCipherService.decrypt()
方法 .
进入该方法后查看上下文的代码逻辑 , 发现该方法最后直接返回 Java 序列化字节数组 . 因此所有的 AES 解密过程都在 decrypt()
方法中 , 该方法就是我们此次调试的关键点 .
程序最终会跟进到 JcaCipherService.decrypt()
方法中 .
获取初始向量( Initialization Vector )
在 JcaCipherService.decrypt()
方法中会获取解密的 IV .
-
首先程序会调用
isGenerateInitializationVectors()
方法判断是否能提取初始化向量 . 该方法实际上返回的是this.generateInitializationVectors
属性的值 , 该值在 JcaCipherService 构造方法中已经被设置为 true , 因此可以直接进入 If 语句.这里传入的参数
false
对函数执行结果没有任何影响 , 我也不清楚这么做的意义 , 欢迎各位师傅指点. -
生成 IV 实例对象 , 并对 IV 进行赋值
在 if 语句中 , 程序首先通过
this.getInitializationVectorSize()
方法获取 IV 长度 , 并初始化一个 IV 实例对象 .然后通过
System.arraycopy()
方法从 CiperText 中提取 IV 与 "真正的密文" .Java.lang.System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length)
概念 : 将源数组中从指定位置开始的数据复制到目标数组的指定位置 .
src
: 源数组
srcPos
: 源数组要复制的起始位置
dest
: 目的数组
destPos
: 目的数组放置的起始位置
length
: 复制的长度综上所述 , 这里会将
CipherText
前16 Bytes
作为初始向量 IV , 其余的数据作为真正的密文( encrypted ) .
当上述过程处理完毕后 , 程序会将 "encrypted
" , "IV
" 以及 "密钥Key
" 作为参数 , 然后调用 JcaCipherService.decrypt()
的重载方法.
选择提供解密功能的密钥服务( Cipher Service )
跟进到 JcaCipherService.decrypt()
重载方法中 , 根据 slf4j
的日志提示信息 , 我们得知此处正式开始解密.
通过调试信息 , 程序会调用 JcaCipherService.crypt()
方法进行解密 , 该方法会添加一个 "2" 作为参数 , 继续调用 JcaCipherService.crypt()
重载方法 .
跟进 JcaCipherService.crypt()
重载方法 , 该方法会先调用 initNewCipher()
方法 , 返回一个 Cipher 实例对象 . Java.crypto.Cipher.Class
是一个为加密和解密提供密码功能的引擎类 , 是 Java Cryptographic Extension( JCE ) 的核心 .
根据官方文档可以确认 : initNewCipher()
的方法调用并不涉及 Padding 的校验 , 所以我们不对这一步做深入研究 .
程序随后会调用 JcaCipherService.crypt()
的重载方法 . 该重载方法中 , 程序尝试调用 Cipher.doFinal()
方法 , 如果调用失败则会抛出异常 .
这个 doFinal()
方法需要重点关注下 , 它会根据 Cipher 对象的初始化方式对数据进行加密或解密 .
该方法会依次判断 " Cipher 对象是否被初始化 " , " 传入的字节数组数据是否为空 " , "解密服务的服务提供商" , 然后调用 this.spi.engineDoFinal()
方法对字节数组进行解密 .
这里
this.spi
为AESCipher
在该方法中终于看到了久违的 BadPaddingException 异常 , 看来 this.spi.engineDoFinal()
方法中会判断 Padding 是否有效 , 那么我们来分析一下 Padding 是如何校验的吧 .\
AES/128/CBC/PKCS5Padding 解密
跟进 com.sun.crypto.provider.CipherCore.doFinal()
方法 , 查看 Padding 是如何校验的.
-
首先判断密码块( blockSize )与单元块( unitBytes )大小是否一致 , 如果不一致则对输入的数据长度进行调整.
unitBytes : 即单元块 , 单元块的大小即为一次可以处理的输入字节数 .
然后判断 Padding 值是否为空 , 如果不为空则获取 Padding 的长度 . 并确定 Padding 的长度是否合法 , 如果不合法就抛出
Input length must be multiple of this.blockSize when decrypting with padded cipher
异常 , 合法则继续执行解密操作 . -
在剩下的解密操作中 , 程序首先会再次判断当前过程是否为解密过程 , Padding 值是否为空 , 输出的缓冲区是否为被初始化 等条件.
如果判断条件全部通过 , 则会去判断缓冲区中是否有数据 , 如果有数据则先调用
System.arraycopy()
方法将缓冲区中的数据拷贝到变量var10
中 . 但是之前我们没有向缓冲区中写入任何数据 . 因此条件判断未通过 , 程序不会进入该 if 语句中 . -
调用
CipherCore.finalNoPadding()
方法进行解密跟进该方法 , 该方法会先判断当前的加密模式是否为 :
CFB
,OFB
,CTS
, 并且确定传入的加密数据是否是整数个单元块长度. 当通过上述判断 , 则会调用this.cipher.decryptFinal()
方法进行解密 .此外 , 该方法返回的变量
var5
的值其实就是调用函数时传递进来的var5
, 即密文字节数组长度 .解密后的明文放在变量
var3
中 , 通过开头[ -84 , -19 , 0 , 5 ]
可得知这是规范的 Java 序列化字节数组.当服务端解密出明文后 , 会对填充在明文最后一个分组的 Padding 进行校验 , 验证其是否合法 .
Padding 校验
程序会调用 PKCS5Padding.unpad()
方法来检验 Padding 是否正确 , 并返回填充开始的索引 .
判断的方式也比较简单 , 首先获取明文字节数组最后一位值 , 确认其是否小于块大小 . 如果判断不通过则直接返回 -1 . 比如这里明文字节数组最后一位为 9 ,而 9 < 16 , 因此通过判断.
这里实际上是确认明文字节数组最后一个字节的十六进制值是否小于或等于
0x0FH
.
以本次的 9
为例 , 程序随后会判断 [ 明文长度 - 9 ]
的值是否小于 0 . 因为正常情况下 , 当我们发现明文字节数组最后一位是 0x09H
, 要满足 PKCS5Padding 规范 , 前面9位应该都是 0x09H
.
因此这里会取明文字节数组 [ 288 - 9 = 279 ]
的值 , 判断其是否小于 0 .
我当时调到这个地方都愣住了 , 要知道 PKCS5Padding 最多会在明文字节数组后添加一个分组 , 也就是 16 Bytes . 因此 Padding 的最大值为
0x08 0x08 0x08 0x08 0x08 0x08 0x08 0x08
, 但是这里 0x09 居然也能通过 , 很明显是判断不严谨啊 !!!但是后面又想了想 , 这个地方好像也没有啥利用的可能 , 所以不做判断很可能是为了减少条件 , 提升性能吧 ...
接下来程序会判断这 9 位数字是否都等于 0x09H
, 这是 PKCS5Padding 的规范 .
程序会返回 279
, 这其实是返回去除 Padding 后的明文字节数组长度. 在上面的判断中 , 只要发现 Padding 不合法 , 就会直接返回 -1 . 因此下面会判断 Padding 校验的结果 , 如果为 -1 则直接抛出 BadPaddingExeption
异常 Given final block not properly padded
.
在进行一些简单的判断后 , 程序会根据上面返回的长度将删除 Padding 的明文字节数组拷贝到一个新的字节数组中 . 至此 AES 解密过程基本就结束了 .
最后 , 就是通过AES解密和Padding校验的明文字节数组被反序列化后执行的过程了 , 这部分我在分析 Shiro550 时已经详细研究过了 , 这里不再赘述.
Apache Shiro721 Exploit 构造分析
前置工作
这里分析的 Exploit 依旧是 3ND 前辈的 shiro_exp.py . 我我们直接定位到 main 函数 .
main 函数会从命令行依次获取 rememberMeCookie
与 PayloadSer
, 其中 : rememberMeCookie
必须是一个真实用户的 Cookie , 而 PayloadSer
是我们用 YSoSerial 生成的反序列化 Payload ( 比如 URLDNS Payload ).
接着程序会去获取一个 PadBuster
对象 , 并调用 padbuster.encrypt()
方法来实现 Padding Oracle 攻击 .
跟进 encrypt()
方法 , 该方法会先计算出 padding 的长度 , 并得到明文( PlainText
)长度 , IV
的长度以及总长度
例如这里 URLDNS Payload 的长度为 279 Bytes , 块大小为 16 Bytes .
279 % 16 = 9
, 按照 PKCS5Padding 规范 , 需要将 9 个0x09H
.而明文(
PlainText
) 的长度就是279 + ( 9 % 16 ) = 279 + 9 = 288
Bytes接下来会判断 IV 是否已存在 , 如果 IV 不存在 , 则创建一个与分组大小相等的字节数组(
ByteArray
) 作为 IV .最后程序会将
IV
长度与明文(PlainText
)长度相加 , 得到一个总长度 , 这个总长度一定是分组块大小的倍数 , 后面会根据分组大小分段进行加密.
然后就是分段进行加密了 , 当然在 Oracle Padding 攻击中 , 加密其实就是不断枚举来爆破中间值的过程 . 这个中间值在代码中就是 intermediate_bytes
, 而在 CVE-2019-12422 Shiro721 ( Apache Shiro RememberMe Padding Oracle 1.4.1 反序列化漏洞) 分析[ 上 ] 一文中我把它称为 MediumValue
, 这俩是同一个东西 , 有兴趣的师傅可以作为参考 .
为了与代码结合 , 后文会用
intermediate_bytes
来表示中间值
通过代码不难看出 , Oracle Padding 攻击的核心就是 self.bust()
函数与 xor()
方法 , 最后将拼接后的密文返回 . 期间通过 "n - block_size
" 的方式来控制加密过程 .
因此 , 我们先跟进 self.bust()
方法 , 来看看第一个分组是如何被处理的 .
paddingoracle.bust() : Padding Oracle Attack
该方法是一个 "块枚举器" , 一次枚举一个密文块( CipherText
) . 该方法会先创建两个字节数组 , 一个作为中间值( intermediate_bytes
) , 一个作为枚举的初始向量( FuzzIV
) .
值得一提的是 , 这里还通过 bytesarray.extend()
方法将 FuzzIV
扩容了一倍 . 因为在加密过程中 , 我们会将前一个分组的密文作为 IV
来使用 . 这里其实是在 IV
后添加了一个值为 0000 0000
的明文( PlainText
) .
接下来程序会从分组最后一个字节开始 , 逐一爆破每一个字节 .
那么理论上第一次枚举的 FuzzIV
应该是 0000000255
, 加上默认的密文( 0000 0000
) 就是 0000 000255 0000 0000
. 下面我们可以通过自定义的 oracle()
函数来使用这个 FuzzIV
.
_USERDEFINE.oracle()
跟进到用户自定义的 oracle()
方法 . 赶紧看下 data 值是否符合上文的猜想 .
和我们的猜想一致 ! 程序随后会将解密后的正常用户的 rememberMe Cookie
与生成的 FuzzIV
值进行拼接 , 作为 HTTP Cookie 中 rememberMe
的键值 . 同时判断 Cookie 中是否存在 JSESSIONID 这个键名 , 如果存在则删除该键值对 .
这与我们漏洞复现时遇到的问题一致 : 在发送 POC 时必须要删除有效的 JSESSIONID 值 , 可惜没有早点分析这个 Exploit 脚本 , 不然就不会走那么多弯路了. 哈哈
然后就是从命令行获取目标 URL , 构造 HTTP GET 请求并发送数据包 , 获得响应数据包了 , 这一步没什么好说的.
最后就是非常关键的一步 : 根据 HTTP 响应数据包的内容来判断 PKCS5Padding
是否合法 , 即定位到不抛出 BadPaddingException
异常时使用的 FuzzIV
.
如果服务端在解密请求后发现 PKCS5Padding 不合法 , 则会在 HTTP 响应数据包中添加
Set-Cookie: rememberMe=deleteMe;
字段 , 并抛出 BadPaddingException 异常.相反的 , 如果响应数据包中没有这个字段 , 那么就可以认为此时解密后的 PKCS5Padding 值合法 .
所以我们直接将断点打在不抛出异常的地方 , 查看此时的 FuzzIV 值
可见 , 当 FuzzIV
为 0000 000190
( 0xBEH
) 时 . 服务端解密后的 PKCS5Padding 合法 . 下面来看一下后面的流程 .
序列化字节数组后的脏数据不影响反序列化过程
调试到这里时很容易产生这样一个疑问 : 我们在正常用户的 rememberMeCookie 后添加异常数据 , 是否会对服务端的反序列化过程造成影响呢?
这里的原因可以参考 cL0und 前辈的 Java原生序列化与反序列化代码简要分析 一文 . 讲的非常详细 .
简单来说 , 就是数据序列化时会先写入各字段的长度 , 然后再写入序列化数据 . 而反序列化时也是先读取字段的长度 , 再根据这个长度获取字节数组中的序列化数据 , 最后再反序列化 . 因此序列化数据后的脏数据并不会影响反序列化的过程 .
paddingoracle.bust()
回到 bust()
函数中 , 程序会获取此时的 FuzzIV[15]
值 , 并计算中间值( intermediate_bytes[15]
)
当获得了 intermediate_bytes[15]
后 , 我们可以计算出当需要填充 2 个 Bytes 时 , FuzzIV[15]
的值 .
因此 , 下一轮枚举循环中( 也就是计算 intermediate_bytes[14]
的过程中 ) , FuzzIV[15] 的值为 Dec(189)
, 我们跟进来验证一下 .
可见 , 在第二轮循环中 , 当 FuzzIV[14] == Dec(42) , FuzzIV[15] == Dec(189)
时 , 服务端解密后的 Padding 验证通过 . 此时 , 我们可以根据 FuzzIV[14]
计算出 intermediate_bytes[14]
, 以及第三轮枚举循环( 也就是计算 intermediate_bytes[13]
)中的 FuzzIV[14]
, FuzzIV[15]
intermediate_bytes[14] == Dec(40)
FuzzIV[15] == Dec(188)
# 第三轮循环
FuzzIV[14] == Dec(43)
# 第三轮循环
依次类推 , 我们能够得到最后一个分组对应的全部 intermediate_bytes
. 即当 FuzzIV为 1192192436810100136261521992241898917856175
时 , 服务端解密后的 Padding 验证通过 .
随后程序会将这个 intermediate_bytes
返回 , 进行后续的运算.
paddingoracle.xor() : CBC Byte-Flipping Attack
接下来程序会调用 paddingoracle.xor()
函数进行异或运算 .
这一步是在做什么呢 ? 我们回到 AES/128/CBC/PKCS5Padding
的解密流程中
在 CVE-2019-12422 Shiro721 ( Apache Shiro RememberMe Padding Oracle 1.4.1 反序列化漏洞) 分析[ 上 ] 一文中我提到过 , Padding Oracle Attack
+ CBC Byte-Flipping Attack
可以实现对明文值的篡改 . 只需要实现如下条件 .
PlainText(n+1) = CipherText(n) ^ Block_Cipher_Decryption(C(n+1))
同理 , 我们可以指定明文 , 来获取前一分组密文的内容 .
CIpherText(n) = PlainText[n+1] ^ Block_Cipher_Decryption(C(n+1))
而这里 Block_Cipher_Decryption(C(n+1))
是刚才返回的 intermediate_bytes
. PlainText
是我们编写的 YSoSerial Payload , 我们只需要剪切 Payload 中对应的那段带入运算即可.
将计算出来的 CipherText
拼接到 encrypted 变量中 , 待全部分组运算完毕后 , 该变量会作为密文返回 . 最后返回的密文如下 :
至此 , 该利用脚本就运行完毕了 . 回顾下来主要是三个核心点:
-
通过
Padding Oracle Attack
计算出每一分组的intermediate_bytes
. -
通过
CBC Byte-Flipping Attack
将每一分组的YSoSerial Payload
与intermediate_bytes
进行异或运算 , 返回前一分组密文(CipherText
) . -
将计算出的全部密文分组拼接起来 , 然后返回 .
现在看下来 , 整个过程并不复杂 , 整个漏洞的分析就到这了 .
总结
这个漏洞比较有意思 , 特别是 Exploit 的构造 , 能够学到很多东西 !