什么是PaddingOracle填充攻击?

**PaddingOracle填充攻击(Padding Oracle Attack)**是比较早的一种漏洞利用方式了,早在2011年的Pwnie Rewards中被评为"最具有价值的服务器漏洞"。

这个漏洞主要是由于设计使用的场景不当,导致可以利用密码算法通过 “旁路攻击” 被破解。

值得强调的是,这个漏洞启示并不是对算法本身的破解,而是利用算法本身的某些padding特性以及系统中不必要的一些错误提示,进而分析出系统当前的漏洞利用路径。

同时也需要强调下,这里的Oracle,其实与甲骨文公司关系并不大,这里的Oracle可以理解为"提示、暗示"的含义。

Padding Oracle旁路攻击,最早由谷歌安全研究员在分析SSLv3进行Http信道保护时被发现。当然,现在https基本使用的都是TLS了,这个漏洞在这里只作为学习用途。

构造一种看起来安全的场景

假设现在有一个服务,名为:dangerous_oracle_sslv3_server

这个服务将会暴露出两个接口:

1
2
get_token(self, user_id: bytes) -> Dict[str, bytes]
verify_token(self, token: bytes, user_id: bytes, iv: bytes) -> Dict[str, bool]
  • get_token允许客户端传入部分用户标识信息,如user_id(这里只是为了方便理解强行加上的含义,这里可以直接理解为明文),然后由服务端生成其身份token(这里可以直接理解为密文)。
  • verify_token将会对用户的token进行鉴定(这里为了简化,直接将逻辑定义为对token解密,看看解密出来的文件与用户自身user_id是否匹配)。

但是,这里有几个关键细节需要强调:

客户端在调用get_token时,服务端会将token密文与IV一起返回给用户

1
2
3
4
{
    'token': b'[\xf1\xd9\x16\x8b\xb5\x8d\x17\xe2{\xc7\xfdvl=\x95\xb0\xd9g\n\x1d\x1d-\x8a;\xc5\xf1\x8eh\x15\xb5\xa0',
    'iv': b'0000000000000000'
}

在验证Token时,服务端的开发不知道出于什么目的(可能是为了更清晰的记录错误码方便日志排查等等原因),在返回的信息中,显式的对密文是否正常padding和密文是否解密成功(即:解密后的明文与原始明文是否一致)做了标记

此外,由于现在的加密库例如:cryptography.hazmat.primitives中的padding库已经对这种漏洞做了修复,因此为了演示这种漏洞利用,这里我们自定义AES的padding逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def oracle_sslv3_padding(input_data: bytes, block_bytes_size: int = 16) -> bytes:
    """
    假设填充默认以16个字节即128bits 为一个block
    只将填充长度放到填充完成后的最后一个字节中
    其余的填充字节中默认填充0x00
    
    也就是说:
    无论原文是否为block_bytes_size的整数倍,末尾都会添加一个block_bytes_size的填充块
    最后的那个填充块,只有最有一个字节是有意义的,代表了填充的长度
    而最有一个填充块的前block_bytes_size - 1个字节都是无意义的(因为都是0x00)
    """
    needed_bytes = block_bytes_size - len(input_data) % block_bytes_size
    # 由于这种攻击一般发生在SSLv3的网络通信链路上
    # 因此这里默认使用大端字节序
    padding_value = needed_bytes.to_bytes(needed_bytes, "big")
    return input_data + padding_value

def oracle_sslv3_unpadding(input_data: bytes) -> bytes:
    """
    使用不太安全的方式进行padding的去除:
    只关心最后一个字节的值,将其作为填充的长度
    """
    padded_len = int(input_data[-1])
    return input_data[: len(input_data) - padded_len]

并且为了方便服务端返回填充是否正确的错误码,我们需要对每个填充块做如下校验:

1
2
3
4
def check_padding_data(input_data: bytes) -> bool:
    padded_len = input_data[-1]
    padded_data = input_data[0 - padded_len:]
    return padded_data == bytes([0x00] * (padded_len - 1)) + bytes([padded_len])

现在服务端的场景已经构造完毕,总结下服务端的特性:

  1. 攻击者能够获取到密文(基于分组密码模式),以及IV向量(通常附带在密文前面,初始化向量)
  2. 攻击者能够修改密文触发解密过程,解密成功和解密失败存在差异性

此时,如果用户正常调用服务端接口,是可以正确运行的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def test_oracle_encrypt_decrypt(self):
    """ 验证在参数合法的情况下是否可以正常加解密 """
    key = bytes().zfill(32)
    iv = bytes().zfill(16)
    user_id = b"hello,world12345"
    
    oracle_server = aes_attack.dangerous_oracle_sslv3_server(key, iv)
    ret = oracle_server.get_token(user_id)
    print(ret)
    
    self.assertTrue("token" in ret)
    self.assertTrue("iv" in ret)
    
    verify_ret = oracle_server.verify_token(ret["token"], user_id, ret["iv"])
    print(verify_ret)
    
    self.assertTrue("token_padded_ok" in verify_ret)
    self.assertTrue(verify_ret["token_padded_ok"])
    self.assertTrue("verify_success" in verify_ret)
    self.assertTrue(verify_ret["verify_success"])

基础知识回顾:AES-CBC块加密的工作流程

我们来回顾下AES-CBC块加密的流程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
CBC模式加密过程:
1. 明文经过填充后,分为不同的组block,以组的方式对数据进行处理
2. 初始化向量(IV)首先和第一组明文进行XOR(异或)操作,得到"中间值"
3. 采用密钥对中间值进行块加密,删除第一组加密的密文 (加密过程涉及复杂的变换移位等)
4. 第一组加密的密文作为第二组的初始向量(IV),参与第二组明文的异或操作
5. 依次执行块加密,最后将每一块的密文拼接成密文

CBC模式解密过程:
1. 将密文进行分组(按照加密采用的分组大小),默认将前面的一组密文作为后面密文块的初始化向量,第一个密文块的初始化向量使用用户自定义的初始化向量,即原始的IV
2. 使用加密密钥对密文的第一组进行解密,得到"中间值"
3. 将中间值和初始化向量进行异或,得到该组的明文
4. 前一块密文是后一块密文的IV,通过异或中间值,得到明文
5. 块全部解密完成后,拼接得到明文,密码算法校验明文的格式(填充格式是否正确)
6. 校验通过得到明文,校验失败得到密文

整个过程,其实也就是这张经典的图:

Clipboard_Screenshot_1766566065

这里需要强调的是,在解密过程中,形成真正的明文之前,AES-CBC算子需要先对密文做一次解密,这次解密形成的中间值:

  • 如果密文正确,那么中间值一定是正确的
  • 如果密文不变,那么中间值一定是不变的

能够真正影响最终解密的明文的步骤,只在中间值与IV异或的这一个步骤之中

攻击者视角:解密过程分析

众所周知,AES的块大小为128bits,也就是16字节。

现在攻击者首先把密文按照AES的块大小(128bits,也就是16Bytes)分组:

对于密文的第一个block,按照解密的流程,会首先由AES-CBC解密算子解密得到中间值 plain_block_mid_0

然后 plain_block_mid_0与IV进行异或操作,得到明文的第一个block:plain_block_0,也就是有:

1
plain_block_mid_0 ^ IV == plain_block_0

同时也可以得到:plain_block_mid_0的最后一个字节 异或 IV的最后一个字节 == plain_block_0的最后一个字节

结合AES的块大小为16字节,我们可以推断出:plain_block_0一定是16字节

在忽略密文解密后是否与原始明文一致的结果的前提下,我们不妨来做一种假设:plain_block_0本身是被填充的,并且填充了一个字节,即plain_block_0的最后一个字节一定是0x01。

那么,现在我们可以开始尝试构造一种IV:当他与plain_block_mid_0进行异或之后,使得plain_block_0的最后一个字节刚好是0x01。

如果此时可以找到这个IV,那么此时将第一块密文传给服务器进行解密时,会得到这样的结果:

  • 填充是正常的:token_padded_ok == True
  • 解密验证是失败的:verify_success == False

而基于基础的异或运算逻辑,无论是加密还是解密过程,都需要基于一个基础的数学逻辑:

假设 c == a ^ b,那么:b == a ^ ca == b ^ c

由于每次通过AES-CBC算子解密得到的中间值plain_block_mid_0都是正确且不变的

那么我们可以推断出:plain_block_mid_0的最后一个字节 == 0x01 ^ IV的最后一个字节

有了上面的步骤,我们可以进一步假设:plain_block_0本身是被填充的,并且填充了两个个字节,即plain_block_0的最后一个字节一定是0x02,倒数第二个字节一定是0x00,

继续上面的步骤,计算出:in_block_mid_0的倒数第二个字节 == 0x00 ^ IV的倒数第二个字节。

重复上面的步骤,直到我推导出来中间值plain_block_mid_0的每个字节的值,进而通过plain_block_mid_0 ^ 真实的IV可以得到真正的 plain_block_0的每个字节的值。

此时,我们完整地破解了第一个明文块。

而对于其他的密文块,其IV值默认为上一个密文块,我们只需要将真实的IV替换为上一个密文块时,即可计算出来其他密文块的真正明文。

基于上述逻辑,于是我们可以构造出如下的攻击代码,首先,我们需要获取每一个密文块的中间值:

 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
def get_mid_value(cipher_block: bytes, server: dangerous_oracle_sslv3_server) -> bytes:
    """
    输入分组的密文数据块和解密接口
    输出这个密文块被AES-CBC算子解密后的中间值
    """
    plain_block_mid = [0x00] * len(cipher_block)
    
    for byte_index in range(1, 17):
        """
        byte_index表示当前正在破解的倒数第几个字节
        当前破解倒数第一个字节,则表示 当前假设明文块填充了一个字节
        当前破解倒数第二个字节,则表示,当前假设明文块填充了两个字节
        以此类推
        """
        for v in range(0, 256):
            """
            当前破解第几个字节,则构造测试IV的第几个字节就需要被遍历赋值并测试填充是否正常
            """
            test_iv = [0x00] * 16
            test_iv[0 - byte_index] = v
            
            if byte_index > 1:
                test_iv[-1] = byte_index ^ plain_block_mid[-1]
                for x in range(2, byte_index):
                    test_iv[0 - x] = 0x00 ^ plain_block_mid[0 - x]
            
            ret = server.verify_token(cipher_block, bytes().zfill(16), bytes(test_iv))
            if ret["token_padded_ok"]:
                if byte_index == 1:
                    plain_block_mid[0 - byte_index] = byte_index ^ test_iv[0 - byte_index]
                else:
                    plain_block_mid[0 - byte_index] = 0x00 ^ test_iv[0 - byte_index]
                print("crack byte_index={} success, test_iv={}, plain_block_mid={}".format(
                    byte_index, list(test_iv), list(plain_block_mid)))
                break
                
    return bytes(plain_block_mid)

当可以获取到每一个密文块的中间值之后,我们可以对整体密文块进行分割并最终获取其真正的明文块:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def crack_cipher(cipher: bytes, original_iv: bytes, server: dangerous_oracle_sslv3_server) -> bytes:
    """
    输入完整的AES-CBC密文
    返回破解出的明文
    """
    cipher_len = len(cipher)
    group = int(cipher_len / 16)
    ret = [0x00] * cipher_len
    
    for i in range(group):
        mid_block = get_mid_value(cipher[i * 16:(i + 1) * 16], server)
        for j in range(0, len(mid_block)):
            if i == 0:
                ret[i * 16 + j] = list(mid_block)[j] ^ list(original_iv)[j]
            else:
                ret[i * 16 + j] = list(mid_block)[j] ^ list(cipher[(i-1) * 16: i * 16])[j]
    
    print("=" * 32)
    return bytes(ret)

小试牛刀

现在攻击者准备进行攻击发起:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
""" 模拟创建带有漏洞的oracle服务"""
key = bytes().zfill(32)
iv = bytes().zfill(16)
danger_oracle_server = dangerous_oracle_sslv3_server(key, iv)

""" 攻击者首先随便创建了一个user_id进行试探,以获取到服务端返回的iv
攻击者此时可以根据自己输入的明文计算出来明文被填充后的完整block"""
user_id = b"hello,world1234567890123456"
user_id_padded = oracle_sslv3_padding(user_id)

ret = danger_oracle_server.get_token(user_id)
token = ret["token"]
iv = ret["iv"]

print(
    "user_id:{}\nuser_id_padded:{}\ntoken:{}, length:{}\niv:{}\n{}".format(
        list(user_id), list(user_id_padded), list(token), len(token), list(iv)
        32))

ret = crack_cipher(token, iv, danger_oracle_server)
print("cracked_plain={}\norigin plain={}".format(list(ret), list(user_id)))

当密文只有一个分组时:

Clipboard_Screenshot_1766566098

当密文有多个分组时:

Clipboard_Screenshot_1766566111

结语

  1. 最基本的,SSLv3.0本身已经不安全,在业务生产中不能够再使用。
  2. 在工程实践中,我们的API错误码需要能够合适的隐藏内部细节,否则可能会造成类似的旁路攻击。
  3. 对于工程设计方来说,数据加密本身只是一种机密性的保障手段,加密能力的设计需要与系统功能进行适配,业务安全不能仅仅依赖于某一项加密手段或防护手段,业务安全体系是一整套体系中的各个系统相互配合的作用,不仅仅机密性一种需要关注。