前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >30分钟搞定AES系列(中):PaddingOracle填充攻击分析与启示

30分钟搞定AES系列(中):PaddingOracle填充攻击分析与启示

作者头像
bowenerchen
发布2022-11-18 14:12:27
2.4K5
发布2022-11-18 14:12:27
举报
文章被收录于专栏:数安视界

什么是PaddingOracle填充攻击?

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

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

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

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

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

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

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

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

代码语言:javascript
复制
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一起返回给用户
代码语言:javascript
复制
{
    '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和密文是否解密成功(即:解密后的明文与原始明文是否一致)做了标记
代码语言:javascript
复制
{
    'token_padded_ok': False, 
    'verify_success': False
}
  • 此外,由于现在的加密库例如:cryptography.hazmat.primitives中的padding库已经对这种漏洞做了修复,因此为了演示这种漏洞利用,这里我们自定义AES的padding逻辑:
代码语言:javascript
复制
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]
  • 并且为了方便服务端返回填充是否正确的错误码,我们需要对每个填充块做如下校验:
代码语言:javascript
复制
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])

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

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

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

代码语言:javascript
复制
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块加密的流程:

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

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

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

这里需要强调的是,在解密过程中,形成真正的明文之前,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, 也就是有: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 ^ c 且 a == 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替换为上一个密文块时,即可计算出来其他密文块的真正明文。

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

代码语言:javascript
复制
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)

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

代码语言:javascript
复制
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)

小试牛刀

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

代码语言:javascript
复制
"""
    模拟创建带有漏洞的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)))

当密文只有一个分组时:

当密文有多个分组时:

结语

最基本的,SSLv3.0本身已经不安全,在业务生产中不能够再使用。

在工程实践中,我们的API错误码需要能够合适的隐藏内部细节,否则可能会造成类似的旁路攻击。

对于工程设计方来说,数据加密本身只是一种机密性的保障手段,加密能力的设计需要与系统功能进行适配,业务安全不能仅仅依赖于某一项加密手段或防护手段,业务安全体系是一整套体系中的各个系统相互配合的作用,不仅仅机密性一种需要关注。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-11-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 bowenerchen 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是PaddingOracle填充攻击?
  • 构造一种看起来安全的场景
  • 基础知识回顾:AES-CBC块加密的工作流程
  • 攻击者视角:解密过程分析
  • 小试牛刀
  • 结语
相关产品与服务
业务风险情报
业务风险情报(Business Risk Intelligence,BRI)为您提供全面、实时、精准的业务风险情报服务。通过简单的 API 接入,您即可获取业务中 IP、号码、APP、URL 等的画像数据,对其风险进行精确评估,做到对业务风险、黑产攻击实时感知、评估、应对、止损。您也可利用业务风险情报服务搭建或完善自身的风控体系,补充自身风险情报数据,提升对风险的感知、应对能力。BRI 支持按需付费,您可根据您的需求,选取不同的套餐,更易优化成本。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档