之前一直没花时间深入研究过Padding Oracle漏洞的原理细节,恰好最近团队里一位精通密码学的大佬做了次分享,经过多轮的交流请教,终于算是理解得比较清晰了,结合自己分析出来的一些内容,整理分享出来。
网上讲这个的文章其实很多,但是把关键细节原理讲清楚的很少,查了一堆资料,都没能完全解答我对某些细节的疑惑,以至于当我最终把这些问题想明白的时候,发觉自己又失去了几根头发。
也叫块加密算法(Block Cipher)。用于将明文消息划分为相等长度的数据块,再对每个数据块进行加密处理。使用分组加密方式的加密算法有很多种,其中最广泛应用的有AES(Advanced Encryption Standard)、DES(Data Encryption Standard)、3DES(TripleDES)算法。
DES/3DES算法的分组长度为64bit(8bytes),AES算法的分组长度为 128bit(16bytes)。注意AES-128、AES-192、AES-256只是秘钥长度不同,分组长度是一样的。
分组加密算法要求输入内容长度为分组长度的整数倍,如果加密过程中原始数据长度不满足此要求,则需要在后面进行填充。例如下图,假设算法规定的分组长度是64bit,也就是8个字节,那么我们就要对明文进行填充,让填充后的数据长度为8字节的整数倍。缺几个字节,就在最后一个分组中填充几个字节,并且填充的内容就是缺失的长度。
例如FIG还差5个字节,那么就填充5个0x05,以此类推。如果明文恰好是分组长度整数倍,例如plantain,恰好是8个字节,那么也要在后面填充8个0x08。这样的填充方式在满足分组加密的规则同时,也便于解密后对填充位的校验和移除。
比如我想加密的内容就是8个字节的A、B、C、D、E、F、0x02、0x02,如果后面不填充8个0x08,解密后就会把0x02当做填充位给移除掉,也就会导致解密结果丢失2个字节。
我们经常看到PKCS#5和PKCS#7填充方式的说法,这两个名词分别代表什么呢?又有哪些区别?
实际上PKCS (Public Key Cryptography Standards) 公钥密码学标准,是 RSA Security LLC公司定义的一系列密码学标准。5和7指的是两个不同的版本。
在PKCS#5标准中规定:Padding填充是8字节的。因为这个标准只讨论了8字节(64位) 块的加密。
而PKCS#7标准中规定:Padding填充可以是1-255任意字节的,分块需要多大,填充就是多少个字节。填充方式两者没有任何区别。由于填充的内容就是缺失的长度,而一个字节最大是255,所以填充长度最大只能是255个字节。
所以PKCS#7其实是在PKCS#5的基础上做了扩展,是兼容PKCS#5的,PKCS#5相当于PKCS#7的子集。
我们说的DES、3DES、AES都是加密算法,这些加密算法用来对每一个单独的分组(Block)加密,而加密操作模式,是在加密算法的基础上,增加了分组(Block)之间的关系处理逻辑。任何基于分组加密的算法都可以套用这些模式。
常见加密操作模式:
ECB(Electronic Code Book Mode),电子密码本模式。
CBC(Cipher Block Chaining Mode),密码分组连接模式。
CFB(Cipher Feedback Mode),密码反馈模式。
OFB(Output Feedback Mode),输出反馈模式。
CTR(Counter Mode),计数器模式。
如果对这些模式不熟悉也没关系,除了CBC其他混个眼熟就好,不影响我们讨论CBC模式,它是Padding Oracle的主角。
CBC加密模式中,一段明文首先会被划分成等长的分组,然后在最后一个分组按照我们前面讲过的规则进行Padding填充。
每一个分组的明文,都跟前一个分组的加密后的密文进行异或,之后再使用秘钥key加密。第一个分组跟谁异或呢?就是一个叫初始化向量IV(initialization vector)的串,它的长度和分组的长度一致。在这样的加密模式中,我们可以看出IV对后面加密内容的影响是有传递性的。通常安全实践里我们会要求IV要随机,这样的好处是即使对同一段明文进行加密,使用不同的IV加密后的密文看起来也是完全不相关的。
如图,方块中的ENC_key表示使用秘钥key进行加密(Encryption)。⊕符号代表异或计算。
图中最下面的一行IV和所有加密块拼在一起,通常就是在实践中传递的加密数据内容。也有些实现是IV和加密块分开放在不同的参数里传递的。
CBC模式解密过程:
如图,DEC_key表示使用秘钥key进行解密(Decryption)。正常的思路来看,如果我们不知道加解密秘钥key,那么就无法直接计算出DEC_key后的解密内容。
Padding Oracle 让我们能够不通过秘钥key,也能暴力猜解出DEC_key的结果。
Padding就是前面我们说的填充。Oracle在这里跟我们熟悉的Oracle公司并没有什么关系,Oracle的中文翻译是:”神谕;权威;女祭司;(传达神谕的)牧师;(古希腊常有隐含意义的)神示;(古希腊的)神示所;能提供宝贵信息的人(或书);智囊”。
而在计算机领域,Oracle也是一个专业术语,叫预言机(Oracle Machine),又称谕示机,是一种抽象电脑,用来研究决定型问题。预言机具备图灵机的一切功能,并额外拥有一种能力:可以不通过计算直接得到某些问题的答案,这个过程叫做Oracle(神谕)。也就是说,预言机可以解决图灵机通过计算也无法解决的问题,比如从外界获取问题的答案。百度百科对Padding Oracle的解释中说Oracle是提示的意思,这样理解也没毛病,神谕就是“来自神的提示”。有些资料对Padding Oracle的翻译就是“填充预言机”。
所以我们可以把Padding Oracle理解成一个能够给我们提示的服务(或者抽象实体),我们把一段任意的密文传给它,它会告诉我们它对这段密文Padding填充合法性的判断,比如说,如果是错误的Padding,就报错,如果是正确的Padding,就不报错。
为了解释这个问题,我写了一个AES CBC加密模式的Web服务作为Demo:
from flask import Flask, request
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
app = Flask(__name__)
# KEY = os.urandom(16)
# print(KEY)
# 测试的时候,可以固定一个秘钥和iv,这样每次启动服务秘钥不会变化,密文可以一直测试。
KEY = bytes.fromhex("d7c910a97f037785efb23566ae99d3fc")
iv = bytes.fromhex("e14950e98b0904dd282de280609b7314")
@app.route('/encrypt')
def encrypt():
plain_text = request.args.get('pt', '').encode('utf-8')
cipher = AES.new(KEY, AES.MODE_CBC, iv)
cipher_text = cipher.encrypt(pad(plain_text, AES.block_size))
return 'crypted: {}{}'.format(iv.hex(),cipher_text.hex())
@app.route('/decrypt')
def decrypt():
cipher_text = bytes.fromhex(request.args.get('ct', ''))
iv = cipher_text[:16]
cipher = AES.new(KEY, AES.MODE_CBC, iv)
# 对解密后的内容进行unpad操作,去除加密时的Padding内容,得到最终的明文
plain_text = unpad(cipher.decrypt(cipher_text[16:]), AES.block_size)
return 'decrypted: {}'.format(plain_text)
if __name__ == '__main__':
app.run(debug=True, port=8083)
代码比较简单,服务启动后会在8083端口提供两个接口,一个是加密接口/encrypt,参数pt为要加密的明文内容:
访问URL:http://localhost:8083/encrypt?pt=w4ter0%20is%20awesome!
得到加密结果:
crypted: e14950e98b0904dd282de280609b7314fcc1b431b32bc2d5f0f290dd64df1a3f66cce304e5ea77bec3237bd42546f029
这段密文按照16字节进行分组可以分成三组,分别是:
IV:e14950e98b0904dd282de280609b7314
ciphertext block 1:fcc1b431b32bc2d5f0f290dd64df1a3f
ciphertext block 2:66cce304e5ea77bec3237bd42546f029
对应我们之前讲过的加密流程,“w4ter0 is awesome!”,这段文本会被拆成2部分,第一部分“w4ter0 is awesom”(16个字节,包括空格)与iv异或后使用秘钥key进行加密,得到Block1;第二部分“e!”(2个字节)经过Padding填充0x0e(14)个0x0e后,与第一部分的加密结果Block1进行异或,再使用秘钥key进行加密,得到Block2。
另外一个接口是解密接口/decrypt,参数ct为传输的密文(iv+密文分组)。
访问URL,传入刚刚得到的加密密文:
http://localhost:8083/decrypt?ct=e14950e98b0904dd282de280609b7314fcc1b431b32bc2d5f0f290dd64df1a3f66cce304e5ea77bec3237bd42546f029
可以成功解密。
上面crackme()方法代码中有一个unpad操作,作用是去除加密时的Padding内容,得到最终的明文。
如果unpad时,传入的解密结果不符和填充规则会怎样呢?我们试一下只传IV和第一个块。
IV:e14950e98b0904dd282de280609b7314
ciphertext block 1:fcc1b431b32bc2d5f0f290dd64df1a3f
参考这张图来看,DEC_key计算得到的结果,与IV异或,得到的明文就是“w4ter0 is awesom”这16个字符(包括空格),按照前面讲过的Padding规则,这里的解密结果是没有填充位的,所以程序运行到unpad方法时就会出错。
访问:
http://localhost:8083/decrypt?ct=e14950e98b0904dd282de280609b7314fcc1b431b32bc2d5f0f290dd64df1a3f
我们在上面代码中再增加一个接口/crackme,代码和decrypt()基本一致,区别是解密成功不再返回解密结果:
@app.route('/crackme')
def crackme():
cipher_text = bytes.fromhex(request.args.get('ct', ''))
iv = cipher_text[:16]
cipher = AES.new(KEY, AES.MODE_CBC, iv)
plain_text = unpad(cipher.decrypt(cipher_text[16:]), AES.block_size)
return 'nothing'
这样更贴近真实的漏洞场景。
从现在起,我们仅有的已知信息就是,/crackme接口和一段密文:
http://localhost:8083/crackme?ct=e14950e98b0