前言
之前分析了 Shiro550 反序列化漏洞 , 今天我们来分析一波 Shiro721 反序列化漏洞 , 这两个漏洞都是最近比较火的 Apache Shiro RCE 漏洞.
由于篇幅原因 , Apache Shiro721 的分析分为两部分 , 一部分为基础知识介绍 , 环境搭建 , 漏洞复现 , 一部分为漏洞代码调试分析.
本章内容为第一部分 : 基础知识介绍 , 环境搭建 , 漏洞复现 .
Apache Shiro721
Apache Shiro721 简介
与 Shiro550 类似 , Shiro721 同样来自于 apache.org 上的一个 Issue , 由 loopx9 前辈提交.
Apapche Shiro RememberMe Cookie 默认通过
AES-128-CBC
模式加密 , 这种加密模式容易受到 Padding Oracle Attack( Oracle 填充攻击 ) , 攻击者可以使用有效的 RememberMe Cookie 作为 Paddding Oracle Attack 的前缀 , 然后精心构造 RememberMe Cookie 值来实现反序列化漏洞攻击.漏洞复现步骤 :
- 登录网站并且获取 RememberMe Cookie 值
- 使用 RememberMe Cookie 值来作为 Padding Oracle Attack 的前缀
- 通过 Padding Oracle Attack 的攻击方式精心构造可利用的 YSoSerial 反序列化数据
- 将构造好的反序列化数据填充到 RememberMe Cookie 字段中并发送 , 即可在目标服务器上执行任意代码.
我们能从上文的描述中得知该漏洞的利用存在局限性 :
- 漏洞利用需要有效的 RememberMe Cookie 值 , 因此需要一个可登录 Shiro Web 的账户和口令
对于像我这样没有密码学基础的同学 , 需要了解如下两个知识点 :
-
什么是 AES-128-CBC 加密模式
-
什么是 Padding Oracle Attack( 填充 Oracle 攻击 )
Apache Shiro721 分析基础
这一块内容主要面向上文两个知识点 .
网上大部分漏洞分析文章在介绍这部分内容时都提到了 Rorot 前辈的 Padding Oracle Attack 一文 , 这篇文章讲的确实非常好 ; 但是由于语言文化差异等因素 , 一些内容直接翻译成中文可能不容易理解 , 所以这里我对 Rorot 前辈在文章中提到的内容做一下简单的归纳 .
AES/128/CBC/PKCS5Padding 加密
这得从密码体制的划分说起.
-
按照密钥特征的不同 , 密码体制可以分为两类 : "对称加密" 和 "非对称加密"
对称加密 : 加密 , 解密过程使用的密钥相同
非对称加密 : 加密 , 解密过程使用不同的密钥
-
按照加密方式的不同 , 密码体制可以分为两类 : "分组密码( 块密码 )" 和 "流密码( 序列密码 )"
分组密码( 块密码 ) : 当加密明文时 , 会先将明文编码表示为二进制序列( 明文流 ) , 然后将其分为若干个固定长度的组 , 最后分别对每个组进行加密 , 生成密文流.
流密码( 序列密码 ) : 当加密明文时 , 会先将明文编码表示为二进制序列( 明文流 ) , 然后由种子密钥生成一个密钥流 , 最后利用加密算法把明文流和密钥流加密,生成密文流.
而 Apache Shiro 中使用的 AES( 高级加密标准 ) 加密算法 , 就是一种对称加密的分组加密算法 .
那么运用时肯定需要考虑如下三个问题:
-
加解密密钥的长度
-
分组加密的加密模式
-
加密模式对应的填充方式
先来回答第一个问题 : AES 加解密的密钥长算法度
在 AES 加密标准中 , 每个分组的长度固定为
128 bits
, 即 16 Bytes . 而密钥的长度则可以变化 , 通常可以使用128 bits
,192 bits
,256 bits
, 不同的密钥长度对应了不同的加密轮数 .不同密钥长度的 AES 加密标准被命名为不同的名称 , 上述三个密钥长度分别对应
AES-128
,AES-192
,AES-256
.TimeShatter 前辈的 AES加密算法的详细介绍与实现 一文中有个图标非常简洁明了 , 引用如下 :
AES加密标准 密钥长度 分组长度 加密轮数 AES-128 128 bits ( 4 Bytes × 32 bits/Bytes ) 128 bits 10 AES-192 192 bits ( 6 Bytes × 32 bits/Bytes ) 128 bits 12 AES-256 256 bits ( 8 Bytes × 32 bits/Bytes ) 128 bits 14
再来回答第二个问题 : AES 分组加密的加密模式
上面介绍分组加密时 , 我将加密过程统称为 "加密变换" ; 但是在实际加密过程中 , 我们可以选择不同的加密模式 .
分组加密算法中有 5 种可选的加密变换方式( 加密模式 )
ECS
( Electronic Codebook Book , 电话本模式 )CBC
( Cipher Block Chaining , 密码分组链接模式 )CTR
( Counter , 计算器模式 )CFB
( Cipher FeedBack , 密码反馈模式 )OFB
( Output FeedBack , 输出反馈模式 )
在分析 Shiro521 时我们就发现 , Apache Shiro 中使用 CBC
作为加密模式 , 因此这里仅需要关注 CBC
这个加密模式 .
简单学习一下什么是 CBC 加密模式 :
CBC
加密模式 : 将明文切分成若干小段 , 然后每一段分别与上一段的密文进行异或运算 , 再与密钥进行加密 , 生成本段明文的密文 , 这段密文用于下一段明文的加密 .第一段明文没有对应的密文 , 为了确保分组的唯一性 ,
CBC
加密模式使用了初始化向量( IV , Initialization Vector ) . 初始化向量是一个固定长度的随机数 , 该向量会作为密文第一个块 , 随密文一同传输 .在
CBC
模式中 , 初始化向量( IV ) 的长度与分组大小相同 , 为 16 Bytes( 128 bits ) , 因为链接模式中的异或操作是等长操作 .
下面是 CBC 模式的加密 / 解密流程图 , 后文会多次用到这两个图 .
最后来回答第三个问题 : 加密模式对应的填充方式
在分组加密时 , 需要把明文切割成多个分组 , 且每个分组的大小为 128 bits ( 16 Bytes ) ; 但是明文长度是不固定的 , 所以可能最后一个分组不足 16 Bytes . 例如下面例子2 :
例子1 :
AAAAAAAABBBBBBBBCCCCCCCC
: 刚好能够切割成 3 个分组例子2 :
AAAAAAAABBBBBBBBCCCCC
: 能够切割成 3 个分组 , 但是最后一个分组不足 16 Bytes .
当遇到这种情况时 , 就需要用到 Padding 填充机制 . "Padding" 用于在最后一个分组的结尾填充一些额外的 bits , 使分组成为标准的 16 Bytes .
CBC
加密模式下可用的 Padding 方式有 3 个 :
-
AES/CBC/NoPadding
: 明文长度必须是 8 Bytes 的倍数 , 否则会报错 . -
AES/CBC/PKCS5Padding
: 以完整字节填充 , 每个填充字节的值是用于填充的字节数 . 即要填充N
个字节 , 每个字节都为N
.
举例 : 使用 PKCS5Padding 方式填充 3 个字节 :
| AA BB CC DD EE 03 03 03 |
AES/CBC/ISO10126Padding
: 以随机字节填充 , 最后一个字节为填充字节的个数 .
举例 : 使用 ISO10126Padding 方式填充 5 个字节 :
| AA BB CC A9 3B 78 04 05 |
同理 , 在研究 Apache Shiro 时 , 我们只需要关注 PKCS5Padding
方式即可 , 下面是一些经典的案例 :
Padding Oracle Attack( 填充 Oracle 攻击 )
知道了 AES/128/CBC/PKCS5Padding
的含义 , 那么到底什么是 Oracle 填充攻击呢?
首先需要说明 , 这里的 Oracle 与甲骨文公司没有任何关系 .
Padding Oracle Attack
也不是针对某一种分组加密算法的攻击 , 而是针对CBC
加密模式的 .根据 Rorot 前辈的论文 , 密码学中的 Oracle 是一种通过接收特定加密数据 , 解密并验证填充是否正确的方式 .
上文这句话可能比较难理解 , 下面来说一个经典的场景:
-
客户端实现了用户登录功能 ; 用户登录后 , 客户端会将用户的ID进行
AES/CBC/PKCS5Padding
加密后发送到服务器处理 , 来验证用户是否存在.客户端将 AES 加密后的UID值放到URL中进行传输 :
https://guildhab.top?uid=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-
服务端会接收客户端发送的数据 , 对参数UID的值进行AES解密处理.
假设服务端会产生如下三种 AES 解密结果 :
-
密文有效 , 填充有效 ===> 用户存在 , 登录成功 . 服务端返回
HTTP 200
-
密文无效 ===> 服务端返回用户不存在的报错信息 . 服务端返回
HTTP 500
-
密文有效 , 填充无效 ===> 服务端返回后端自定义报错信息 . 服务端返回
HTTP 301
玩过 SQL 注入的师傅很快便能发现问题 : 根据第一个返回结果和第三个返回结果 . 可以通过暴力破解的方式来确定有效的填充值 .
当服务端返回 "用户存在 , 登录成功" 的信息时 . 填充数据即为有效填充值 . 这个概念非常类似 SQL 注入里的 "盲注" , 属于一种侧信道攻击 .
-
-
那么到底对哪个字段进行暴力破解呢? 我们结合加解密流程图来研究一下 .
在看下面内容时 , 你需要先知道这些名词的含义 :
-
RawIV
: 原始的IV , 解密时即为前一个密文分组 . -
FuzzIV
: 枚举的IV , 下文会通过枚举 IV 的方式来计算出明文的值 -
Key
: 密钥 -
PlainText
: 明文分组 -
CipherText
: 密文分组 -
MediumValue
: 我们把CipherText
和Key
进行Block_Cipher_Decryption
运算后的值称为MediumValue
( 中间值 ).
在解密时 ,
CipherText
会被密钥Key
解密为MediumValue
, 然后MediumValue
会与RawIV
进行异或运算 , 得到该分组对应的PlainText
.但是我们不需要去解密( 如前文所说 : Oracle 的核心是提交数据让服务端解密 , 并验证解密后明文分组的 Padding 是否符合规范 ) , 因此我们创建一个新的 IV (
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
) 作为FuzzIV
, 并结合CipherText
提交给服务端 .服务端会将接收到的
FuzzIV
与 解密后的MediumValue
进行异或运算 , 得到一个明文分组 , 然后去验证这个明文分组的 Padding 是否有效 .注意 ! 上面的 "明文分组" 不是指
PlainText
, 它只是用于验证异或计算结果是否满足PKCS5Padding
规范 .根据 PKCS5Padding 规范 , Padding 值的范围在
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
到0x08 0x08 0x08 0x08 0x08 0x08 0x08 0x08
之间在某一轮解密中 , 服务端异或运算后的明文分组为
0x29 0x34 0x5A 0x6B 0x07 0xA3 0xB2 0x3C
, 这个分组的 Padding 不满足PKCS5Padding
规范 , 因此这轮解密中提交的FuzzIV
是无效的 , 服务端会返回 "密文有效 , 填充无效" 的报错信息.不断修正
FuzzIV
的值(0x00 - 0xFF
, 最多修正 255 次 ) . 直到某一轮解密中 , 服务端异或运算后的明文分组为0x39 0x73 0x23 0x32 0x5A 0x7B 0x9C 0x01
, 这个 Padding 符合PKCS5Padding
规范 , 因此这轮提交的FuzzIV
是有效的 . 服务端会返回 "密文有效 , 填充有效" 的信息.当获取了有效的
FuzzIV
后我们能做什么呢 ? 我们能够根据等价代换得到如下公式 :∵ FuzzIV[8] ^ MediumValue[8] = 0x01 ∴ MediumValue[8] = FuzzIV[8] ^ 0x01
现在我们得到了
MediumValue[8]
的值 , 让我们回到加密流程中.MediumValue
与RawIV
的异或运算结果就是真正的明文 ! 存在如下公式 :∵ MediumValue[8] ^ RawIV[8] = PlainText[8] ∴ PlainText[8] = RawIV[8] ^ FuzzIV[8] ^ 0x01
FuzzIV[8]
,RawIV[8]
都是已知的 , 因此我们可以直接计算出plainText[8]
的值 . 这就是Padding Oracle Attack
的攻击原理 . -
-
按照上面的步骤 , 我们可以依次计算其他位置的
PlainText
.当计算
PlainText[7]
时, 我们希望异或运算后得到的明文分组的最后两个字节为0x02 0x02
. 这样 Padding 就是有效的了 .由于我们已经计算了出了
PlainText[8]
的值 , 因此这轮解密中我们可以直接确定FuzzIV[8]
的值 . 来看下面的公式 :∵ MediumValue[8] = RawIV[8] ^ PlainText[8] ∵ FuzzIV[8] ^ MediumValue[8] = 0x02 ∴ FuzzIV[8] = RawIV[8] ^ PlainText[8] ^ 0x02
根据上述公式我们能够确定
FuzzIV[8]
的值 , 然后固定FuzzIV[8]
, 去暴力破解FuzzIV[7]
的值.当
FuzzIV[7]
和MediumValue[7]
异或运算为0x02
时 , 根据如下公式可以得到PlainText[7]
的值 .PlainText[7] = Raw[7] ^ FuzzIV[7] ^ 0x02`
后面的过程都是类似的 , 我们可以通过这种 Padding Oracle 的方法获取每一位明文的值.
至此 , Padding Oracle Attack 的攻击原理与攻击流程就已经介绍完了 , 整个过程比较繁琐枯燥 , 看不明白可以多看几遍 , 看明白后其实非常好玩的 .
CBC Byte-Flipping Attack ( CBC字节翻转攻击 )
通过上面提到的 Padding Oracle Attack , 我们可以在不知道密钥 Key
的情况下得到全部明文的值 . 但仅能得到明文值似乎还差点什么 , 我们是否能够篡改明文的值的 ?
标题出卖了一切 通过 CBC字节翻转攻击
可以实现对明文的篡改 !
回顾 CBC 加密模式的解密过程 , 我们不难发现如下特性
IV 向量影响第一个明文分组( IV 一般被放在密文开头 , 单独作为一个分组 )
第 n 个密文分组可以影响第 n+1 个明文分组 .
设第 n 个明文分组为 P(n)
, 第 n 个密文分组为 C(n)
, 则存在以下公式 :
P(n+1) = C(n) ^ Block_Cipher_Decryption(C(n+1))
如果明文和密文已知 , 那么我们可以手动修改 C(n)
的值 , 使得 P(n+1)
变成任何值.
令 C(n) = C(n) ^ P(n+1) ^ Payload
∵ Block_Cipher_Decryption(C(n+1)) = P(n+1) ^ C(n)
∴ P(n+1) = C(n) ^ P(n+1) ^ Payload ^ Block_Cipher_Decryption(C(n+1))
∴ P(n+1) = Block_Cipher_Decryption(C(n+1)) ^ Block_Cipher_Decryption(C(n+1)) ^ Payload
∴ P(n+1) = 0 ^ Payload
∴ P(n+1) = Payload
大概就是上面这个意思 , 我们能够通过修改密文字节( C(n) = C(n) ^ P(n+1) ^ Payload
) , 实现篡改明文字节( P(n+1) = Payload
).
Daniel Regalado 前辈在 CBC Byte Flipping Attack—101 Approach 中的总结非常简练 :
CBC字节翻转攻击的原理 : 通过损坏密文字节来改变明文字节 .
基础知识学的差不多了 , 但是我们仍不清楚如何利用 Padding Oracle Attack
与 CBC Byte-Flipping Attack
实现 Java 反序列化攻击 , 看上去这几个知识点是完全不相关的 . 看来只有通过调试代码 , 我们才能知道 Shiro721 的真正奥秘了~
一个简单的总结
上面讲的东西实在比较多 , 这里来简单总结一下吧~
-
AES
是指 "高级加密标准" , 是一种对称加密的分组加密算法 ,128
是指密钥长度 ,CBC
是指 "密码分组链接" 加密模式 ,PKCS5Padding
是 Apache Shiro 中默认填充方式 , 最后一个明文分组缺少 N 个字节 , 则填充 N 个 0x0N . -
在 Apache Shiro 中默认使用
CBC
加密模式与PKCS5Padding
填充方式 .CBC
加密模式容易遭到Padding Oracle Attack
, 攻击者可以通过枚举 IV 的方式计算出全部明文 , 并且可以通过CBC Byte-Flipping Attack
篡改某一段的明文 . -
Padding Oracle Attack 利用前提 :
-
攻击者能够获得密文( CipherText )与附带在密文前面的初始化向量( IV ).
-
服务端对密文解密后会判断 Padding 是否有效 . 并根据不同的判定结果返回不同的响应信息 .
-
-
CBC Byte-Flipping Attack 利用前提 :
- 明文和密文已知
下面 , 我们将步入正题 , 来看一看 Shiro721 反序列化漏洞到底是怎么一回事.
Apache Shiro721 漏洞复现
漏洞环境搭建
复现环境 : JDK1.7
+ Tomcat7
+ Shiro-1.4.1
.
-
首先还是从 Github 上下载一份 Shrio 源码 , 并切换到存在漏洞的分支( 1.4.1 )
-
然后直接通过 IDEA 打开
shiro/samples/web
目录即可 . Maven 会自动下载依赖. -
手工添加 Apache 服务 , 并设置
samples-web:war
作为 Artifacts -
运行 Apache 服务 , 即可看到 Apache Shiro 初始页面.
失败的漏洞复现
复现过程其实就是拿网上公开的 EXP 打一遍 , 这个没什么和好说的 . 我选择的是 3ND 前辈编写的 Shiro_exp.py 脚本
-
按照前辈的方法 , 首先通过 YSoSerial 生成可利用的 Payload . 由于之前配置 pom.xml 时没有添加任何组件 , 所以我们使用 URLDNS Payload 来探测漏洞是否存在.
-
接下来我们需要获取一个有效的 RememberMe Cookie . 因此需要通过 BurpSuite 拦截一个有效用户的 Cookie .
先用一个正常用户登录 , 注意登录时需要勾选
Remember Me
选项框然后开启 BurpSuite 拦截功能 , 随便点击一个链接 , 即可看到 rememberMe Cookie.
这个 Cookie 就是我们需要的 "有效 rememberMe Cookie" , 我们保存下来 .
-
填充 Python 脚本对应的参数并执行
拿到脚本计算出的新 rememberMe Cookie 值 , 直接填充到正常的 HTTP 请求中并重放 .
现在只需要去 Ceye 上查看是否能收到 DNS 请求就可以了 ! 本来我都准备收工吃饭了 , 但是等了半天也没有收到 DNS 请求 . 这啥情况 ? 难道是我的姿势不对 ?
按照前辈的思路又跑了一边 Exp , 发现还是没有收到 DNS 请求 .
脸上的笑容逐渐消失, 难不成是 URLDNS Payload 坏啦 ? 这肯定不可能呀 , 那么到底是什么原因导致利用不成功呢 ?反复试了好几次都不行 , 而且网上也没有前辈提到过这个问题 . 看来必须得静下心来调试一波代码了 !
凭什么 Exp 跑不成功 ?!
提交的 rememberMe Cookie 压根没有被处理
我们有调试 Shrio550 的经验 , 所以这里的调试并不难 , 根据经验 , 我们直接一发断点打在反序列化处( org.apache.shiro.io.DefaultSerializer.deserialize()
) , 并再次重放恶意请求 .
程序没有在断点处停下 , 看来提交的 rememberMe Cookie 解密后没有被反序列化 , 我们继续尝试其他断点 , 来看一看 rememberMe Cookie 有没有被服务器处理 .
按照这个思路 , 我们下一个断点打在 org.apache.shiro.web.mgt.CookieRememberMeManager.getRememberedSerializedIdentity()
方法中 , 该方法会对传入的 rememberMe Cookie 做 Base64 解密处理.
程序依旧没有停下 . 看来我们提交的 rememberMe Cookie 压根没被处理呀 , 这就非常奇怪了 !
resolvePrincipals() 中没有调用 getRememberedIdentity()
下一个断点暂时不知道打哪里 , 我们回顾一下 Shrio550 的函数调用栈 , 看能不能找到哪里出了问题.
根据这个调用栈 , 我们把断点打在 org.apache.shiro.mgt.DefaultSecurityManager.createSubject()
方法中 , 然后跟进 resolvePrincipals()
方法.
随即便能发现, 由于判断 isEmpty(principals)
的结果为 false
, 因此直接进入 else 语句中 , 并未调用 this.getRememberedIdentity()
方法 .
所以 , 问题应该就出在这个 principals
变量中 !
isEmpty(principals) 返回 false
变量 principals 的值即 context.resolvePrincipals()
方法的返回值 , 我们跟进该方法 , 然后重放请求.
通过几步调试 , 可以发现原本变量 principals
为 null
, 但是经过下面这几不操作后 , 变量 principals
的值变了 , 这也导致 isEmpty(principals)
返回 false , 程序没有调用 getRememberedIdentity()
方法.
那么现在核心问题就转到这个 session
变量上了 ! 由于获取的 session
不为空 , 才导致变量 principals
的值被修改.
变量 session 值不为 null
跟进 this.resolveSession()
方法 , 发现由于 this.getSession()
返回值不为空 , 才导致变量 session
不为 null
的 .
跟进 this.getSession()
方法 . 发现该方法实际上是调用 this.getTypedValue()
方法来返回 Session.class
类型的 SESSION 值
继续跟进 this.getTypedValue()
方法一探究竟 .
JSESSIONID 的问题
先停一停 , 回顾一下我们这一步的目的 , 我们最初的目的是寻找变量 principals
不为空的原因 , 即变量 session
不为空的原因 , 即 this.getSession()
返回值不为空的原因 , 即 this.getTypedValue()
返回值不为空的原因 .
在 org.apache.shiro.util.MapContext.getTypedValue()
方法中 , 我们发现该函数的返回值由 this.backingMap.get(key)
方法控制 .
this.backingMap.get(key)
方法实际上是在 this.backingMap
这个 HashMap 寻找名为 org.apache.shiro.subject.support.DefaultSubjectContext.SESSION
的键值对 , 而调试时发现 this.backingMap
中恰好有这个键名
这个键值对是什么呢 ? 实际上就是HTTP请求中键名为 JSESSIONID
的 Cookie !
换句话说 , 如果我们想让前面一连串变量或方法的返回值为空 , 只要让提交的 HTTP 请求中没有这个 键名为 JSESSIONID 的 Cookie 即可 .
成功的漏洞复现
在提交的 HTTP 请求中删除键名为 JSESSIONID
的 Cookie 然后再次重放请求.
提前在 IDEA 中打上断点 , 然后一步到位~
Ceye 上也收到了久违的 DNS 请求 , 可以证明我们植入的 URLDNS Payload 被服务端执行 , 反序列化攻击成功 !
至此 , Apache Shiro721漏洞已经成功复现 !
为什么我没有删除 JSESSIONID , 却依旧复现成功了 ?
我这里出现了类似的情况 , 当我写完上述内容再去重放 HTTP 请求时 , 发现不删除 JSESSIONID 同样可以执行到反序列化 , 这是为什么呢 ?
其实原因很简单 : 原来的 JSESSIONID 失效了
如果想进一步研究 , 可以在 org.apache.shiro.mgt.DefaultSecurityManager.resolveSession()
方法中的 InvalidSessionException var3
异常处打上断点
异常信息 : Resolved SubjectContext context session is invalid. Ignoring and creating an anonymous (session-less) Subject instance.
我们重新请求主页面 , 并通过 BurpSuite 抓取 HTTP 请求 , 可以看到此时更换了另一个 JSESSIONID .
当我们将这个新的 JSESSIONID
填充到恶意 HTTP 请求中时 , 同样是无法利用成功的 . 因此还需我们手工删除 JSESSIONID Cookie
, 恶意代码才能利用成功 .
总结
没想到通过一个 Shiro721 漏洞学到了这么多东西 , 不过弄明白原理后还真是爽啊 !
由于篇幅原因 , Shiro721 本身的环境调试就放到下一章节吧~ 希望本章内容能对各位师傅有所帮助~~