之前看到过一个关于Apache Shiro的漏洞(Shiro-721),漏洞原因是Cookie使用AES-128-CBC模式进行加密,导致攻击者可以通过Padding Oracle攻击方式构造序列化数据进行反序列化攻击,如下链接可以复现该漏洞:
https://github.com/3ndz/Shiro-721
由于之前不了解Padding Oracle Attack的漏洞原理,于是学习了一下,本篇写一下我的理解,并用java编写了相应poc。
我们常见的如DES、AES的这种对称加密算法(加解密使用同一个密钥即为对称加密)都属于分组加密算法的一种,顾名思义,就是把明文按照固定长度进行分组(DES以8字节为一组,AES以16字节为一组),然后对每一组的数据分别进行加密,最后将每组密文拼接到一块即可。
分组加密模式有很多,我在做代码审计时,最常见的就是ECB模式和CBC模式。
ECB:它直接将分组后的明文使用给定的密钥进行加密,不做任何其他处理,是最基础的模式,也是Java语言中默认的加密模式。
所以这也是最不安全的模式,因为同一个明文经过该模式加密后,密文是固定的,因此带有明文的统计特征,即使使用强壮的AES-256算法,在分组较多的情况下,仍然会暴露一些私密信息。
CBC,它是一种链接模式,它会使原本独立的每个分组产生联系,使前一分组的密文影响后一分组的密文:
从上图可以看到,CBC模式与ECB模式的区别:
1、多了一个初始向量IV;
2、在进行加密前,第一个分组的明文会与IV进行异或运算,后面的分组则与前一个分组的密文进行异或运算。
其解密流程如下:
因此,CBC模式不容易对明文进行主动攻击,相较ECB模式安全了许多。
前面讲到分组加密算法是按照固定字节数对明文分组后进行加密,那最后一个分组长度不够怎么办,这时就需要填充了,也就是Padding。
这里以最常见的PKCS5Padding填充方式为例,填充方式如下:
一目了然,缺少几字节,就填充几,如果最后一个分组刚好是8字节时,也需要添加一个全部填充0x08的分组。
这里可以得出一个结论:
Padding部分值必定是 N个0x0N(0<=N<=8)
上面第二章我们从宏观上了解了一下CBC模式加解密的流程,下面我们看一个较详细的CBC模式加密流程:
下面我们定义7个名词,后文会用到,很重要:
1、iv,初始向量,也就是上图中的Initialzation Vetor。
2、tmp,在进行加密前,每组明文与前一组密文异或运算后产生的中间值(第一组明文与iv进行异或),即上图中的Intermediary Value,一定与iv区分开,
3、plaintext,明文
4、ciphertext,密文
5、key,密钥
6、block,分组
7、^,异或运算
下面是CBC模式较为详细的解密流程图:
我们总结一下解密的流程:
1、使用key将ciphertext解密得到tmp。
2、block>1时,plaintext = tmp ^ 前一组ciphertext;
block=1时,plaintext = tmp ^ iv。
由第二步我们发现,其实我们没有key,也能得到plaintext:
1、ciphertext是攻击者可以控制的。
2、iv一般会跟密文一块进行传输,因为iv是随机的,也就是一个密文对应一个iv,没有iv,服务端也没办法解密。
3、所以只要拿到tmp,就可以拿到plaintext,所以Padding Oracle Attack攻击的核心就是获取tmp
如何拿到tmp?
下面以plaintext只有一个block为例:
还记得我们在第三章得到的结论吗?Padding部分必须满足N个0x0N,当服务端使用tmp ^ iv得到的plaintext中的Padding部分不满足N个0x0N时,解密方法就会抛出异常,这时服务端会返回500错误,当满足时,服务端则会返回200。
因此可以通过服务端返回的状态码来获取tmp,详情如下:
因为plaintext = tmp ^ iv,所以tmp = plaintext ^ iv:
我们假设服务端将ciphertext解密后的tmp如下,后文均以此tmp为例:
[ 0x50 0x50 0x50 0x50 0x31 0x32 0x33 0x3c ]
1、首先先获取tmp的第8个字节,即tmp[8]:
1)攻击者伪造iv,将其发往服务端,我们把伪造的iv叫做attackiv,一定与真实iv区分开,attackiv如下:
[ 0x00 0x00 0x00 0x00 0x00 0x00 0x00 {0x00} ]
服务端的解密方法计算tmp ^ attackiv得到的值如下,我们把它叫做fake_plaintext,注意并不是真正的plaintext:
[ 0x50 0x50 0x50 0x50 0x31 0x32 0x33 {0x3c} ]
然后解密方法发现fake_plaintext的Padding部分不满足N个0x0N的规则,所以返回500错误
2)攻击者发现返回500,于是就从0x00递增到0xff,来遍历attackiv[8],因为攻击者相信,一定会存在一个数N,使N ^ tmp[8] = 0x01,也就是存在N ^ 0x3C = 0x01,终于当attackiv[8] = 0x3D时,即attackiv如下时:
[ 0x00 0x00 0x00 0x00 0x00 0x00 0x00 {0x3D} ]
解密方法计算的fake_plaintext如下:
[ 0x50 0x50 0x50 0x50 0x31 0x32 0x33 {0x01} ]
这时,解密方法发现Padding部分为1个0x01,符合规范,可以解密成功,所以返回200,攻击者发现返回了200,于是就知道了fake_plaintext[8]=0x01,这时attackiv[8]=0x3D,于是:
tmp[8] = fake_plaintext[8] ^ attackiv[8] = 0x01 ^ 0x3D = 0x3C
很好,由此就确定了tmp[8] = 0x3C。
2、下面继续获取tmp[7]:
第一步我们通过让fake_plaintext的Padding为1个0x01得到了tmp[8]的值,下面就要通过让fake_plaintext的Padding为2个0x02来获取tmp[7]的值了:
1)构造的attackiv如下,因为已确定tmp[8]的值,所以本次的attackiv[8]也可以确定,即0x3C ^ 0x02 = 0x3E,所以只需要遍历attackiv[7]即可:
[ 0x00 0x00 0x00 0x00 0x00 0x00 {0x00} 0x3E ]
服务端解密方法得到的fake_plaintext如下:
[ 0x50 0x50 0x50 0x50 0x31 0x32 {0x33 0x02} ]
同样,解密方法发现fake_plaintext的Padding部分不满足N个0x0N的规则,所以返回500错误
2)继续从0x00递增到0xff,遍历attackiv[7],当遍历到0x31时,即attackiv如下时:
[ 0x00 0x00 0x00 0x00 0x00 0x00 {0x31} 0x3E ]
服务端解密方法得到的fake_plaintext如下:
[ 0x50 0x50 0x50 0x50 0x31 0x32 {0x02 0x02} ]
解密方法发现Padding部分为2个0x02,符合规范,所以返回200,于是攻击者就确定了tmp[7]:
tmp[7] = fake_plaintext[7] ^ attackiv[7] = 0x02 ^ 0x31 = 0x33
3、继续遍历,直到遍历到fake_plaintext的Padding部分满足8个0x08时,就确认了整个tmp的值。
4、拿到tmp的值后,就可以拿到plaintext了:
plaintext = tmp ^ iv
原理终于讲完了,不太好理解,需要仔细捋捋。
下面我们仍然以plaintext只有一个block为例,来编写POC。
为了方便,我这里没有提供Web的环境,整个测试是在一个Java类里面完成的(我也用SpringBoot搭的一个简易的漏洞环境进行了测试,这里就不提供Web代码了)。
1、下面是我用Java代码实现的DES加密方法:
//加密方法,plaintext代表明文,key代表密钥,iv代表初始向量
public static byte[] encrypt(byte[] plaintext, byte[] key, byte[] iv) {
byte[] ciphertext = null;
try {
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
//创建密钥对象
DESKeySpec keySpec = new DESKeySpec(key);
SecretKey secretKey = keyFactory.generateSecret(keySpec);
//创建iv对象
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
//指定使用DES加密算法,CBC模式,PKCS5Padding填充方式
Cipher cipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
//初始化cipher对象,Cipher.ENCRYPT_MODE代表这个cipher是用来加密的
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec);
//加密
ciphertext = cipher.doFinal(plaintext);
} catch (Exception e) {
e.printStackTrace();
}
return ciphertext;
}
main方法如下:
public static void main(String[] args) {
byte[] ciphertext = encrypt("abcd".getBytes(), "87654321".getBytes(), "12345678".getBytes());
System.out.println(bytes2hex(ciphertext));
}
运行main函数,输出密文如下:
[ 0x62 0x74 0x68 0x37 0xce 0x4c 0xde 0xb8 ]
2、下面是Java实现的DES解密方法,我用此方法来模拟服务端的解密方法,用程序有没有抛异常来判断Padding部分是否满足N个0x0N的规则:
//解密方法,ciphertext代表密文,key代表密钥,iv代表初始向量
public static byte[] decrypt(byte[] ciphertext, byte[] key, byte[] iv) {
byte[] plaintext = null;
try {
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
//创建密钥对象
DESKeySpec keySpec = new DESKeySpec(key);
SecretKey secretKey = keyFactory.generateSecret(keySpec);
//创建iv对象
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
//指定使用DES加密算法,CBC模式,PKCS5Padding填充方式
Cipher cipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
//初始化cipher对象,Cipher.DECRYPT_MODE代表这个cipher是用来解密的
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec);
//解密
plaintext = cipher.doFinal(ciphertext);
//若程序能运行到这里,说明没有抛异常,也就是Padding部分满足N个0x0N规则,flag值将被置为1
flag = 1;
} catch (Exception e) {
// e.printStackTrace();
}
return plaintext;
}
3、下面就是攻击代码了,因为我们在攻击方法中调用的上面写的解密方法是模拟服务端的解密方法,所以要给解密方法传入正确的key:
//攻击方法,ciphertext为密文,key为密钥,iv为初始向量
public static void attack(byte[] ciphertext, byte[] key, byte[] iv) {
byte[] attackIv = new byte[8]; //用于遍历的构造的iv
//循环1-8,就是遍历tmp的8个字节,
for (int i = 1; i <= 8; ++i) {
flag = 0; //每次遍历前,要把标记异常的记号清0
int k;
//循环1-(8-i),就是例如此次正在遍历第8个字节,则给1-7个字节赋值为0x00
for (k = 1; k <= 8 - i; ++k) {
attackIv[i - 1] = 0;
}
//这里就是从0x00递增到0xff,来遍历
for (int j = 0; j < 256; j++) {
attackIv[k - 1] = (byte) j;
//循环(9-i)-7,就是例如正在遍历第6个字节,则需要用已确定的第7、8字节的数据异或0x03来确定本次7、8字节的数据
for (int x = 9 - i; x < 8; ++x) {
attackIv[x] = (byte) (tmp[x] ^ i);
}
//调用解密方法,传入正确的key和构造的iv
decrypt(ciphertext, key, attackIv);
//如果flag为1,说明解密成功,也就确定了本次遍历的位置对应的tmp的值
if (flag == 1) {
tmp[8 - i] = (byte) (j ^ i);
break;
}
}
}
System.out.println("tmp: " + bytes2hex(tmp));
byte[] plaintext = new byte[8];
//用遍历获得的tmp和iv进行异或,拿到明文
for (int i = 0; i < 8; ++i) {
plaintext[i] = (byte) (iv[i] ^ tmp[i]);
}
System.out.println("plaintext: " + new String(plaintext));
}
下面就在main方法中调用attack来测试一下:
public static void main(String[] args) {
attack(hex2bytes(" 0x62 0x74 0x68 0x37 0xce 0x4c 0xde 0xb8 "), "87654321".getBytes(), "12345678".getBytes());
}
运行结果如下:
可以看到,攻击代码成功计算出了tmp的值,并且成功获得了明文。因为Padding部分为4个0x04,为不可见字符,所以显示乱码,可以在代码里面可以加一个判断,来去掉Padding部分,这些小问题我这里就不处理了。
1、上面的原理讲解和POC的编写,我都是以plaintext只有一个block,且block=8为例,要实现block=16,只需要从0x0000遍历到0xffff即可,如果想实现攻击N个block,只需要在上面的攻击代码中最外层再加个for循环即可,再注意当block>1时,iv为前一组的ciphertext。
2、Padding Oracle Attack攻击的核心就是攻击者能否知道fake_plaintext的Padding部分是否满足N个0x0N。所以各位开发人员在使用分组加密的CBC模式时注意这一点即可。
3、到了这里各位应该明白使用Padding Oracle Attack如何解密数据,但如何加密数据我没有讲到,其实就是逆运算,大家可以思考思考这个问题。