掘金 后端 ( ) • 2024-05-07 10:41

前后端对接,多语言实现 CryptoJS 的 AES 简单加密解密

前言

在逆向某个网站接口时,发现的其参数使用了 CryptoJS 的 AES 简单加密,只需要两个参数,而在使用其他语言解密时却无法复现

var encrypted = CryptoJS.AES.encrypt("Message", "Secret Passphrase");
var decrypted = CryptoJS.AES.decrypt(encrypted, "Secret Passphrase");

折腾了好久,查阅了多篇文章,终于解决

为什么要用多语言实现?因为参考的文章使用的是 Go ,自己测试的时候为了方便用 Python,最后的业务用的是 Java

解密思路

解密时会出现第一个问题:AES 的秘钥长度是固定的,而 CryptoJS.AES 的 passphrase 却是随意长度

首先,在 Crypto 官网 得知 CryptoJS 的 AES 简单加密默认使用 aes-256

image-20240506105756897

翻译:CryptoJS支持AES-128、AES-192和AES-256。它会根据你传入的密钥的大小来选择变体。如果你使用密码短语,它会生成一个256位的密钥。

也就是说:

  • CryptoJS 会根据 passphrase 生成一个256位的密钥,这个秘钥才是 AES 加密的真正秘钥(256bit/8 = 32byte)
  • CryptoJS 的 AES 简单加密默认使用 aes-256

继续往下找,可以得知:加密模式默认为 CBC 填充方式默认为 Pkcs7

image-20240506105655072

得知加密模式后出现了两个问题:

  1. 秘钥 Key 是如何生成的?
  2. CBC 模式需要的偏移量 IV 是如何生成的?
用于加密的六种常见分组密码操作模式

(图片来自Wikipedia)

继续从官方文档往下翻,发现 OpenSSL 和 CryptoJS 可以互通,也就是说,CryptoJS 生成 Key 和 iv 的方式与 OpenSSL 一致

image-20240506112733824

经过多次尝试与搜索,找到这篇文章 AES解密中IV值的默认选择方法 (我的很多思路都从这里来,包括后面的 go 解密代码),之后又根据文章内容找到了 OpenSSL AES 算法中 Key 和 IV 是如何生成的? 这篇文章在博客园也有转载 https://www.cnblogs.com/findumars/p/12627336.html

从这两篇文章得到了解密的方式

hash1 = MD5(Passphrase + Salt)
hash2 = MD5(hash1 + Passphrase + Salt)
hash3 = MD5(hash2 + Passphrase + Salt)
Key = hash1 + hash2
IV  = hash3

先使用 Base64 将加密字符串解码,会发现其开头为 "Salted__",在这个前缀后面的八个字节就是 salt,使用该 salt 与 Passphrase 根据上述计算方式可以计算出 Key 和 IV

加密思路

模拟这个“简单加密”:解密思路已经有了,加密与解密思路相反即可

先随机生成 8 字节的 salt,根据 salt 生成 Key 和 IV

用生成的 Key 和 IV 加密得到密文,密文拼接 "Salted__" 和 salt

最后使用 Base64 加密得到加密结果

代码过程

以下代码仅供参考

Go 实现

package main

import (
	"bytes"
	"crypto/aes"
	"crypto/cipher"
	"crypto/md5"
	"crypto/rand"
	"encoding/base64"
	"errors"
)

// AES256Decode : 解密函数 参考:https://www.cnblogs.com/caiawo/p/17255857.html
func AES256Decode(encodeStr string, passphrase string) (string, error) {
	// base64Decode
	ciphertext, err := base64.StdEncoding.DecodeString(encodeStr)
	if err != nil {
		return "", err
	}

	salt := ciphertext[8:16]
	// 这个方法里面是上面所说的伪代码部分
	calKey, iv := getKeyAndIv(salt, passphrase)
	block, err := aes.NewCipher(calKey)
	if err != nil {
		return "", err
	}

	mode := cipher.NewCBCDecrypter(block, iv)

	// 去除前缀与salt
	ciphertext = ciphertext[16:]
	plaintext := make([]byte, len(ciphertext))
	mode.CryptBlocks(plaintext, ciphertext)

	// 去除填充
	paddingLen := int(plaintext[len(plaintext)-1])
	if paddingLen > len(plaintext) {
		return "", errors.New("padding len error")
	}

	return string(plaintext[:len(plaintext)-paddingLen]), nil
}

// AES256Encode : 加密函数
func AES256Encode(plaintext string, passphrase string) (string, error) {
	// 将明文编码为字节串
	textBytes := []byte(plaintext)
	// 进行填充
	blockSize := aes.BlockSize
	padding := blockSize - len(textBytes)%blockSize
	paddedText := append(textBytes, bytes.Repeat([]byte{byte(padding)}, padding)...)

	// 获取 salt
	salt := make([]byte, 8) // 生成一个长度为8的随机字节串作为salt
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}
	// 使用 salt 和密钥生成 key 和 iv
	calKey, iv := getKeyAndIv(salt, passphrase)

	// 创建 AES 加密器
	block, err := aes.NewCipher(calKey)
	if err != nil {
		return "", err
	}
	// 创建加密模式
	mode := cipher.NewCBCEncrypter(block, iv)

	// 加密
	cipherText := make([]byte, len(paddedText))
	mode.CryptBlocks(cipherText, paddedText)

	// 添加 Salted__ 和 salt 前缀
	cipherText = append([]byte("Salted__"), append(salt, cipherText...)...)
	// 返回 base64 编码的密文
	return base64.StdEncoding.EncodeToString(cipherText), nil
}

// 获取 Key 和 IV
func getKeyAndIv(salt []byte, passphrase string) (calKey []byte, iv []byte) {
	hash1 := md5.Sum([]byte(passphrase + string(salt)))
	hash2 := md5.Sum(append(hash1[:], []byte(passphrase+string(salt))...))
	hash3 := md5.Sum(append(hash2[:], []byte(passphrase+string(salt))...))
	calKey = append(hash1[:], hash2[:]...)
	iv = hash3[:]
	return
}
func main() {
	plaintext := "Hello, world"
	passphrase := "dTUvPrClrXwho&%q]N+*ZDF*O]OZAo"
	encode, _ := AES256Encode(plaintext, passphrase)
	println(encode) // U2FsdGVkX1+vx7KHqsuhaFnv7ADSZEkIEZYdAdhzIso=
	decode, _ := AES256Decode(encode, passphrase)
	println(decode) // Hello, world
}

Python 实现

依赖

# AES 加密解密库
pip install pycryptodome

代码

"""
模拟 CryptoJS, 复现 AES 加密解密
"""
import base64
import hashlib
import os
from typing import Tuple

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Util.Padding import unpad


def get_key_and_iv(salt: bytes, passphrase: str) -> Tuple[bytes, bytes]:
    """
    根据 salt 和秘钥生成 key 和 iv
    :param salt: salt
    :param passphrase: 密码短语
    :return: key, iv
    """
    passphrase_bytes = passphrase.encode('utf-8')
    hash1 = hashlib.md5(passphrase_bytes + salt).digest()
    hash2 = hashlib.md5(hash1 + passphrase_bytes + salt).digest()
    hash3 = hashlib.md5(hash2 + passphrase_bytes + salt).digest()
    key = hash1 + hash2
    iv = hash3
    return key, iv


def encrypt_AES_CBC(plaintext: str, passphrase: str) -> str:
    """
    CBC 模式的 AES 加密
    :param plaintext: 明文
    :param passphrase: 密码短语
    :return: 加密后的结果字符串
    """
    # 将明文编码为字节串
    text_bytes = plaintext.encode('utf-8')
    # 进行填充
    padded_text = pad(text_bytes, AES.block_size)
    # 获取 salt
    salt = os.urandom(8)  # 生成一个长度为8的随机字节串作为salt
    # 获取 key 和 iv
    key, iv = get_key_and_iv(salt, passphrase)
    # 创建 CBC 模式的 AES 加密器
    aes = AES.new(key, AES.MODE_CBC, iv)
    # AES 加密
    cipher_text = aes.encrypt(padded_text)
    # 添加 Salted__ 和 salt 前缀
    cipher_text = b"Salted__" + salt + cipher_text
    # 返回 base64 编码的密文
    return base64.b64encode(cipher_text).decode('utf-8')


def decrypt_AES_CBC(ciphertext: str, passphrase: str) -> str:
    """
    CBC 模式的 AES 解密
    :param ciphertext: 加密文本
    :param passphrase: 密码短语
    :return: 解密后的结果字符串
    """
    # 将 base64 编码的密文解码为字节串
    ciphertext = base64.b64decode(ciphertext)
    # 从密文中提取 salt
    salt = ciphertext[8:16]
    # 获取 iv 和 key
    key, iv = get_key_and_iv(salt, passphrase)
    # 去除前缀与 salt
    ciphertext = ciphertext[16:]
    # 创建 CBC 模式的 AES 解密器
    aes = AES.new(key, AES.MODE_CBC, iv)
    # AES 解密
    blob_ciphertext = aes.decrypt(ciphertext)
    # 去除填充
    return unpad(blob_ciphertext, AES.block_size).decode('utf-8')


plaintext = 'Hello, world'
passphrase = "dTUvPrClrXwho&%q]N+*ZDF*O]OZAo"

encrypt = encrypt_AES_CBC(plaintext, passphrase)
print("Encrypted text: ", encrypt) # U2FsdGVkX19II99DvAA6quSaWbcMjE1vvg13hZyCpqw=
decrypt = decrypt_AES_CBC(encrypt, passphrase)
print("Decrypted text: ", decrypt) # Hello, world

Java 实现

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;

/**
 * 模拟 CryptoJS AES 加密解密
 *
 * @author soladuor
 */
public class AnalogCryptoJS {

    private static final Charset utf8 = StandardCharsets.UTF_8;

    /**
     * 辅助方法:连接多个字节数组
     *
     * @param arrays byte 数组
     * @return 连接后的 byte 数组
     */
    public static byte[] connectByteArray(byte[]... arrays) {
        int length = 0;
        for (byte[] array : arrays) {
            length += array.length;
        }
        byte[] result = new byte[length];
        int offset = 0;
        for (byte[] array : arrays) {
            // 参数:原数组,复制起始点,结果数组,粘贴起始点,复制的长度
            System.arraycopy(array, 0, result, offset, array.length);
            offset += array.length;
        }
        return result;
    }

    /**
     * 辅助方法:生成长度为 8 的随机 salt
     */
    public static byte[] generateSalt() {
        // SecureRandom 是比 Random 更满足加密要求的强随机数生成器
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[8];
        random.nextBytes(salt);
        return salt;
    }

    /**
     * 根据 salt 和秘钥生成 key 和 iv
     *
     * @param salt       salt
     * @param passphrase 密码短语
     * @return 由 key 和 iv 组成的数组
     */
    public static byte[][] getKeyAndIv(byte[] salt, String passphrase) throws NoSuchAlgorithmException {
        byte[] passphraseBytes = passphrase.getBytes(utf8);
        MessageDigest md5 = MessageDigest.getInstance("MD5");
        // 计算 MD5 哈希值
        byte[] hash1 = md5.digest(connectByteArray(passphraseBytes, salt));
        byte[] hash2 = md5.digest(connectByteArray(hash1, passphraseBytes, salt));
        byte[] hash3 = md5.digest(connectByteArray(hash2, passphraseBytes, salt));

        // 生成 key 和 iv
        // key = hash1 + hash2
        byte[] key = connectByteArray(hash1, hash2);
        // iv = hash3
        byte[] iv = hash3;
        return new byte[][]{key, iv};
    }

    /**
     * CBC 模式的 AES 加密
     *
     * @param plaintext  明文
     * @param passphrase 密码短语
     * @return 加密后的结果字符串
     */
    public static String encrypt_AES_CBC(String plaintext, String passphrase) {
        try {
            // 将明文编码为字节串
            byte[] textBytes = plaintext.getBytes(utf8);
            // 进行填充
            // paddedText = pad(text_bytes, AES.block_size)

            // 获取 salt
            byte[] salt = generateSalt(); // 生成长度为 8 的随机 salt
            // 获取 key 和 iv
            byte[][] keyAndIV = getKeyAndIv(salt, passphrase);
            byte[] key = keyAndIV[0];
            byte[] iv = keyAndIV[1];

            // 创建 CBC 模式的 AES 加密器
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
            IvParameterSpec ivSpec = new IvParameterSpec(iv);
            cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);

            // AES 加密
            byte[] cipherText = cipher.doFinal(textBytes);
            // 添加 Salted__ 和 salt 前缀
            byte[] saltedCipherText = connectByteArray("Salted__".getBytes(utf8), salt, cipherText);

            // 返回 base64 编码的密文
            return Base64.getEncoder().encodeToString(saltedCipherText);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * CBC 模式的 AES 解密
     *
     * @param ciphertext 加密文本
     * @param passphrase 密码短语
     * @return 解密后的结果字符串
     */
    public static String decrypt_AES_CBC(String ciphertext, String passphrase) {
        try {
            // 将 base64 编码的密文解码为字节串
            byte[] ciphertextBytes = Base64.getDecoder().decode(ciphertext);

            // 从密文中提取 salt
            byte[] salt = Arrays.copyOfRange(ciphertextBytes, 8, 16);
            // 获取 iv 和 key
            byte[][] keyAndIV = getKeyAndIv(salt, passphrase);
            byte[] key = keyAndIV[0];
            byte[] iv = keyAndIV[1];

            // 去除前缀与 salt
            byte[] ciphertextWithoutSalt = Arrays.copyOfRange(ciphertextBytes, 16, ciphertextBytes.length);

            // 创建 AES 解密器
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
            IvParameterSpec ivSpec = new IvParameterSpec(iv);
            cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);

            // AES 解密(自动去除填充)
            byte[] decryptedBytes = cipher.doFinal(ciphertextWithoutSalt);

            // 去除填充,转为 utf-8 字符串
            return new String(decryptedBytes, utf8);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

}

测试

public static void main(String[] args) {
    String plaintext = "Hello, world";
    String passphrase = "dTUvPrClrXwho&%q]N+*ZDF*O]OZAo";
    String encrypt = AnalogCryptoJS.encrypt_AES_CBC(plaintext, passphrase);
    System.out.println("hello-enc = " + encrypt); // U2FsdGVkX18zrOB/HZlV5DeZirUDqu5lTvRh7iXf6nM=
    String decrypt = AnalogCryptoJS.decrypt_AES_CBC(encrypt, passphrase);
    System.out.println("hello-dec = " + decrypt); // Hello, world
}

其他参考

分组密码操作模式 - 维基百科,自由的百科全书 (wikipedia.org)

理解AES加密解密的使用方法_aes iv任意长度-CSDN博客