1. 项目概述:当爬虫遇上AES加密
做爬虫的朋友,估计都遇到过这个让人头疼的场景:目标网站的数据,不再是明晃晃的HTML标签,而是变成了一串串看不懂的、像乱码一样的加密字符串。你兴冲冲地用requests把数据抓下来,准备用BeautifulSoup或者xpath大展拳脚,结果发现response.text里除了几个无关紧要的div,核心数据空空如也。这时候,打开浏览器的开发者工具,切到Network(网络)标签页,仔细查看那个返回数据的XHR请求,你大概率会在Response里看到类似U2FsdGVkX1...这样以特定字符开头的密文,或者在Initiatior里发现一个名为crypto-js的脚本库。恭喜你,你遇到了前端加密,而AES(Advanced Encryption Standard,高级加密标准)正是其中最常用的“拦路虎”之一。
我最近在爬取某个数据平台时,就撞上了标准的AES-CBC加密。流程很典型:网页先加载一个巨大的JavaScript文件,里面包含了加密密钥和偏移量(IV),然后前端通过Ajax请求数据时,会对某些参数进行加密,服务器返回的也是加密后的数据,前端再解密渲染。我的任务就是模拟这个解密过程,把数据还原出来。按照常规思路,我找到了加密函数,用Python的pycryptodome库依葫芦画瓢写了解密代码,满心以为能一次成功。结果运行后,迎头就是一记重击:UnicodeDecodeError和ValueError: Incorrect padding交替出现,最让我记忆深刻的就是那个UnicodeDecodeError: ‘utf-8’ codec can‘t decode byte 0xb2 in position 0: invalid start byte,以及在某些库版本下可能抛出的UnicodeDecodeError: ‘utf-8’ codec can‘t decode byte 0xa3 in position 1: invalid start byte。这感觉就像你拿到了保险箱的密码,但锁芯却对不上。
这个错误信息“Malformed UTF-8 data”或者各种Invalid start byte,本质上指向同一个核心问题:解密出来的字节序列(bytes),无法被正确地解码(decode)成我们期望的UTF-8格式的字符串。这通常不是密码学算法本身错了,而是在“算法解密”到“字符显示”这个链条的某个环节出了问题。今天,我就把这个爬虫日常中经典的AES解密踩坑案例拆解清楚,从原理到实操,一步步带你绕过这些深坑,把加密数据稳稳地“扒”下来。
2. 核心原理与常见加密模式解析
在动手写代码之前,我们必须先理解对手。AES是一种对称加密算法,意味着加密和解密使用同一把密钥。在Web前端,尤其是使用CryptoJS库时,最常见的组合是AES-CBC模式与PKCS7填充(在Python中通常对应PKCS5/PKCS7)。
2.1 为什么是CBC模式?
CBC(Cipher Block Chaining,密码分组链接)模式之所以流行,是因为它比基础的ECB模式安全得多。ECB模式下,相同的明文块会产生相同的密文块,容易暴露模式。而CBC模式引入了一个关键概念:初始化向量(IV)。每个明文块在加密前,会先与前一个密文块进行异或操作(第一个块与IV异或)。这样,即使完全相同的明文,使用不同的IV,也会产生完全不同的密文,安全性大大增强。在Web环境中,IV有时会随机生成并预置在密文前(例如CryptoJS的默认输出格式),有时则会作为固定值或动态参数从服务器获取。
2.2 前端CryptoJS的“默认套餐”
当我们说“前端AES加密”,十有八九指的是CryptoJS这个库的默认行为。它有一套自己的“约定俗成”:
- 加密模式:CBC。
- 填充方式:PKCS7。
- 密钥和IV处理:CryptoJS接受字符串形式的密钥和IV,但它内部会使用一个特定的“EvpKDF”函数(基于OpenSSL的
EVP_BytesToKey)将你传入的字符串(密码)派生(derive)出实际用于AES算法的密钥和IV。这是第一个大坑!如果你在Python里直接用原始的字符串作为密钥,和解密结果肯定对不上。 - 输出格式:CryptoJS默认将加密结果输出为一个CipherParams对象,其
.toString()默认是Base64字符串。但更常见的是,它会把IV随机生成,并拼接在密文前面,最后一起做Base64编码,格式类似U2FsdGVkX1...(这是OpenSSL的“Salted__”开头格式),或者是IV和密文直接拼接后的Base64。
2.3 错误根源:“Malformed UTF-8 data” 与 “Invalid start byte”
Python解密后得到的是字节串(bytes)。当我们试图用.decode(‘utf-8’)将其转换为字符串时,Python的UTF-8解码器会严格检查字节序列的合法性。UTF-8是一种变长编码,有严格的格式规范。如果解密得到的字节串中包含:
- 不符合UTF-8编码规则的字节序列(例如,一个字节的值大于
0x7F却不在一个合法多字节序列的起始位置)。 - 或者,这个字节串根本就不是文本,而是二进制数据(比如图片、序列化对象等)。
- 再或者,解密本身就不正确,得到的是一堆毫无意义的乱码字节。
这时,decode(‘utf-8’)就会抛出UnicodeDecodeError,提示“Malformed UTF-8 data”或“invalid start byte”。所以,这个错误是一个结果,而不是原因。原因可能在上游的任何一个环节。
3. 实战:从零构建Python AES解密流程
理论说再多,不如一行代码。我们假设一个最常见的场景:目标网站使用CryptoJS进行AES-CBC加密,密钥是字符串“my-secret-key”,IV是字符串“1234567890123456”(16字节),填充为PKCS7。
3.1 工具选型:为什么是pycryptodome?
Python处理加密的库有很多,如pycrypto(已停止维护)、cryptography。我首选pycryptodome,它是pycrypto的一个积极维护的分支,API友好,功能完整。安装非常简单:
pip install pycryptodome注意:在有些环境中,可能需要使用
pycryptodomex来避免与旧的pycrypto命名空间冲突。如果导入Crypto失败,可以尝试安装pycryptodomex并使用from Cryptodome.Cipher import AES。
3.2 第一步:模拟CryptoJS的密钥派生
这是最关键也是最容易出错的一步。CryptoJS的AES.encrypt(plaintext, password, { iv: iv })中,password并不是直接作为密钥。我们需要在Python中复现这个派生过程。
CryptoJS使用的EvpKDF函数,其本质是使用MD5哈希函数,以指定的盐(Salt)和迭代次数,将密码和IV派生出来。幸运的是,pycryptodome提供了现成的实现:
from Crypto.Protocol.KDF import EVP_BytesToKey from Crypto.Hash import MD5 from Crypto.Cipher import AES import base64 def derive_key_and_iv(password, salt, key_length=32, iv_length=16, iterations=1): """ 模拟CryptoJS的EvpKDF密钥派生函数。 :param password: 字符串形式的密码 :param salt: 字节串形式的盐 :param key_length: 所需密钥长度(AES-256为32) :param iv_length: 所需IV长度(16) :param iterations: 迭代次数,CryptoJS默认似乎是1 :return: (key, iv) """ # EVP_BytesToKey 函数需要密码和盐的字节串 password_bytes = password.encode('utf-8') # 派生总长度 = 密钥长度 + IV长度 derived = EVP_BytesToKey(MD5, password_bytes, salt, key_length+iv_length, iterations) key = derived[:key_length] iv = derived[key_length:key_length+iv_length] return key, iv关键点解析:
EVP_BytesToKey函数是OpenSSL中的标准函数,pycryptodome的Crypto.Protocol.KDF模块直接提供了它。MD5是CryptoJS默认使用的哈希算法。salt(盐)非常重要!如果CryptoJS加密时没有显式提供盐,它可能使用一个固定值或从IV中派生。很多时候,盐就包含在密文之中。例如,以“Salted__”开头的Base64密文,紧随其后的8个字节就是盐。我们需要先将其提取出来。iterations迭代次数通常为1,但某些配置下可能不同,需要根据目标JS代码确认。
3.3 第二步:处理密文格式与提取盐、IV
密文的传递格式多种多样,你需要根据实际情况解析。以下是两种常见情况的处理:
情况一:密文为简单Base64,IV单独提供这种情况最简单。假设你通过抓包,发现返回的密文是一个Base64字符串,而IV在请求参数或另一个响应字段里。
import base64 ciphertext_b64 = "密文Base64字符串" iv_b64 = "IV的Base64字符串" # 或者 iv_hex = "IV的16进制字符串" # 解码 ciphertext_bytes = base64.b64decode(ciphertext_b64) iv_bytes = base64.b64decode(iv_b64) # 如果IV是Base64 # 或者 iv_bytes = bytes.fromhex(iv_hex) # 如果IV是16进制情况二:密文为OpenSSL “Salted__” 格式这是CryptoJS的默认输出格式之一。格式为:b‘Salted__’ + 8字节盐 + 实际密文,整体再做Base64编码。
def extract_salt_and_ciphertext(encrypted_b64): """ 从OpenSSL格式的密文中提取盐和密文。 """ encrypted_bytes = base64.b64decode(encrypted_b64) # 检查前8个字节是否为 b‘Salted__’ if encrypted_bytes[:8] == b‘Salted__’: salt = encrypted_bytes[8:16] # 接下来的8字节是盐 ciphertext_bytes = encrypted_bytes[16:] # 剩余的是密文 return salt, ciphertext_bytes else: # 如果不是Salted__格式,可能没有盐,或者盐在别处 raise ValueError("密文不是‘Salted__’格式,请检查加密方式。")3.4 第三步:执行解密并处理结果
现在,我们有了派生的密钥key、IViv和纯密文ciphertext_bytes。接下来进行AES-CBC解密。
def decrypt_aes_cbc(key, iv, ciphertext_bytes): """ 使用AES-CBC模式进行解密。 """ cipher = AES.new(key, AES.MODE_CBC, iv) # 解密 decrypted_padded = cipher.decrypt(ciphertext_bytes) # 去除PKCS7填充 padding_length = decrypted_padded[-1] # 验证填充是否合法 if padding_length < 1 or padding_length > AES.block_size: raise ValueError("无效的PKCS7填充") if decrypted_padded[-padding_length:] != bytes([padding_length]) * padding_length: raise ValueError("PKCS7填充验证失败") decrypted_bytes = decrypted_padded[:-padding_length] return decrypted_bytes解密后处理:现在decrypted_bytes是解密后的原始字节。不要立即解码!先尝试打印它的十六进制表示或直接print(decrypted_bytes),看看它像什么。
- 如果看起来像
b‘{“name”: “value”}’,那恭喜,它是JSON字符串,可以直接decrypted_bytes.decode(‘utf-8’)。 - 如果开头是
b‘\x89PNG’,那它是个PNG图片。 - 如果是一堆不可打印字符,可能是其他二进制格式,或者解密仍然不正确。
4. 深度排坑:解决“Malformed UTF-8 data”错误
当你按照上述流程操作,却依然遇到UnicodeDecodeError时,请按照以下清单系统性排查。
4.1 排查清单:从源头到结果
确认加密参数完全一致:
- 模式:一定是CBC吗?有没有可能是ECB、CFB、OFB?查看JS代码中的
mode: CryptoJS.mode.XXX。 - 填充:一定是PKCS7吗?查看JS代码中的
padding: CryptoJS.pad.XXX。如果是NoPadding,解密后不需要去除填充。 - 密钥长度:AES-128、AES-192还是AES-256?这决定了密钥的长度(16、24、32字节)。派生后的密钥长度必须匹配。
- IV:IV是否正确获取并解码?是固定字符串还是动态生成?长度必须是16字节。
- 模式:一定是CBC吗?有没有可能是ECB、CFB、OFB?查看JS代码中的
检查密钥派生过程:
- 盐(Salt):这是最大的坑!确认盐是否被使用。抓取加密过程的JS,搜索
salt或Salted__。如果使用了盐,必须用EVP_BytesToKey派生密钥。如果JS中直接使用CryptoJS.enc.Utf8.parse(‘key‘)作为密钥(而不是字符串密码),那么它就没有使用派生,你应该直接使用这个字符串的UTF-8字节作为密钥。 - 迭代次数:
EVP_BytesToKey的迭代次数默认是1,但有些配置可能会改。
- 盐(Salt):这是最大的坑!确认盐是否被使用。抓取加密过程的JS,搜索
验证密文和IV的编码:
- 密文和IV从网络抓下来,可能是Base64,也可能是16进制(Hex)。确保你用对了解码方法(
base64.b64decode或bytes.fromhex)。 - 警惕URL安全的Base64(
-和_替代+和/),Python的base64.b64decode需要先替换回来,或者使用base64.urlsafe_b64decode。
- 密文和IV从网络抓下来,可能是Base64,也可能是16进制(Hex)。确保你用对了解码方法(
解密后先检查原始字节:
- 在
decrypt_aes_cbc函数返回decrypted_bytes后,绝对不要立刻.decode()。 - 先
print(decrypted_bytes)或print(decrypted_bytes.hex())查看。 - 如果末尾有明显的规律字节(如
\x04\x04\x04\x04),说明填充去除可能有问题。 - 如果开头有
b‘\xef\xbb\xbf’,这是UTF-8 BOM头,解码时需要忽略。
- 在
尝试不同的解码方式:
- 如果解密字节看起来部分像中文但解码失败,可能是目标网站使用了
GBK或GB2312编码。尝试decrypted_bytes.decode(‘gbk’, errors=‘ignore’)。 - 使用
chardet库检测编码:pip install chardet,然后import chardet; print(chardet.detect(decrypted_bytes))。
- 如果解密字节看起来部分像中文但解码失败,可能是目标网站使用了
终极验证:加密-解密闭环测试。
- 如果条件允许,在本地用Node.js和CryptoJS库,用你推测的参数加密一个已知字符串(如
“Hello, Spider!”)。 - 然后用你的Python解密代码去解密这个密文,看是否能还原。这是最可靠的验证方法。
- 如果条件允许,在本地用Node.js和CryptoJS库,用你推测的参数加密一个已知字符串(如
4.2 一个完整的、容错性更强的解密函数示例
结合以上排查点,这里给出一个更健壮的解密函数入口:
from Crypto.Protocol.KDF import EVP_BytesToKey from Crypto.Hash import MD5 from Crypto.Cipher import AES import base64 import chardet def versatile_aes_decrypt(encrypted_data, password=None, key=None, iv=None, mode=AES.MODE_CBC, padding='pkcs7'): """ 一个尝试自动处理常见AES解密场景的函数。 :param encrypted_data: Base64编码的密文字符串。 :param password: 字符串密码(如果使用EvpKDF派生)。 :param key: 字节串形式的直接密钥(如果未使用派生)。 :param iv: 字节串形式的IV,或Base64/Hex字符串。 :param mode: 加密模式,如 AES.MODE_CBC。 :param padding: 填充方式,‘pkcs7‘ 或 ‘nopadding‘。 :return: 解密后的字符串(尝试多种编码)。 """ # 1. 解码密文 try: ciphertext_bytes = base64.b64decode(encrypted_data) except: # 如果不是标准Base64,尝试URL安全或Hex try: ciphertext_bytes = base64.urlsafe_b64decode(encrypted_data) except: try: ciphertext_bytes = bytes.fromhex(encrypted_data) except: raise ValueError("无法解码密文,请确认是Base64或Hex格式。") # 2. 处理可能的‘Salted__‘格式 salt = None if ciphertext_bytes[:8] == b‘Salted__’: salt = ciphertext_bytes[8:16] ciphertext_bytes = ciphertext_bytes[16:] print(f“检测到Salt: {salt.hex()}”) # 3. 处理IV if isinstance(iv, str): try: iv = base64.b64decode(iv) except: iv = bytes.fromhex(iv) if iv is None: raise ValueError(“必须提供IV”) # 4. 派生或使用直接密钥 if password is not None: if salt is None: # 如果没有从密文中提取到salt,可能需要一个默认值,有时是空或与IV相关 # 这是一个需要根据实际情况调整的点! salt = b‘’ # 或 iv[:8] ?需要分析JS key, derived_iv = derive_key_and_iv(password, salt) # 注意:如果JS中同时指定了iv,可能以指定的iv为准,而不是派生的iv # 这里逻辑需根据实际情况调整,有时派生出的iv会被忽略 iv = iv or derived_iv # 优先使用传入的iv elif key is None: raise ValueError(“必须提供password或key”) if isinstance(key, str): key = key.encode(‘utf-8’) # 确保key长度是16, 24, 32 if len(key) not in [16, 24, 32]: # 尝试用MD5哈希一下字符串密钥,这是另一种常见做法 from Crypto.Hash import MD5 key = MD5.new(key).digest() print(f“警告:密钥长度不符,已自动MD5哈希处理为16字节: {key.hex()}”) # 5. 解密 cipher = AES.new(key, mode, iv) decrypted_padded = cipher.decrypt(ciphertext_bytes) # 6. 去除填充 if padding.lower() == ‘pkcs7’: padding_length = decrypted_padded[-1] if 1 <= padding_length <= AES.block_size and decrypted_padded[-padding_length:] == bytes([padding_length]) * padding_length: decrypted_bytes = decrypted_padded[:-padding_length] else: print(“警告:PKCS7填充验证失败,可能解密密钥或模式错误,将返回原始解密数据。”) decrypted_bytes = decrypted_padded # 返回未去填充的数据 else: # NoPadding decrypted_bytes = decrypted_padded # 7. 尝试解码 # 首先尝试检测编码 detection = chardet.detect(decrypted_bytes) print(f“编码检测结果: {detection}”) for encoding in [‘utf-8’, ‘gbk’, ‘latin-1’, ‘ascii’]: try: return decrypted_bytes.decode(encoding) except UnicodeDecodeError: continue # 如果所有编码都失败,返回十六进制表示 return f“<无法解码为文本,原始字节: {decrypted_bytes.hex()}>”5. 实操案例:解密一个真实的数据包
假设我们通过浏览器抓包,发现以下信息:
- 请求URL:
https://api.example.com/data - 请求体中有一个加密参数:
payload: “U2FsdGVkX19K4T6c6P...(很长一串Base64)” - 响应头中有一个
X-IV字段,值是“MTIzNDU2Nzg5MDEyMzQ1Ng==”(这是 “1234567890123456” 的Base64)。 - 通过搜索JS文件,发现加密代码为:
CryptoJS.AES.encrypt(JSON.stringify(data), ‘myPassword123’, { iv: CryptoJS.enc.Base64.parse(‘MTIzNDU2Nzg5MDEyMzQ1Ng==’) }).toString()
分析:
- 密文以
U2FsdGVkX1开头,这是Salted__的Base64,说明使用了Salt。 - JS中直接传入了字符串密码
‘myPassword123’和IV。这意味着使用了EvpKDF派生,并且IV是显式指定的(不是派生的)。 - IV在请求头中,与JS中一致。
Python解密代码:
import requests import base64 from Crypto.Protocol.KDF import EVP_BytesToKey from Crypto.Hash import MD5 from Crypto.Cipher import AES import json def decrypt_response(): # 模拟请求 url = ‘https://api.example.com/data’ encrypted_payload_b64 = “U2FsdGVkX19K4T6c6P...” # 从抓包中复制 headers = { ‘X-IV’: ‘MTIzNDU2Nzg5MDEyMzQ1Ng==’, # ... 其他请求头 } # 假设我们需要在请求时加密参数,这里先演示解密响应 # resp = requests.post(url, data={‘payload’: encrypted_payload_b64}, headers=headers) # encrypted_response_b64 = resp.json()[‘encrypted_data’] # 假设响应也是加密的 # 本例我们直接解密一个假设的加密响应 encrypted_response_b64 = “U2FsdGVkX1...” # 从响应中获取的密文 # 1. 提取Salt和密文 encrypted_bytes = base64.b64decode(encrypted_response_b64) salt = encrypted_bytes[8:16] ciphertext = encrypted_bytes[16:] # 2. 获取IV并解码 iv_b64 = headers[‘X-IV’] iv = base64.b64decode(iv_b64) # b‘1234567890123456’ # 3. 派生密钥 password = ‘myPassword123’ key, _ = EVP_BytesToKey(MD5, password.encode(‘utf-8’), salt, 32, 16, 1) # 注意:这里迭代次数为1,派生出的iv我们忽略,使用header中的iv # 4. 解密 cipher = AES.new(key, AES.MODE_CBC, iv) decrypted_padded = cipher.decrypt(ciphertext) # 去除PKCS7填充 padding_len = decrypted_padded[-1] decrypted_json_bytes = decrypted_padded[:-padding_len] # 5. 解码为JSON data_str = decrypted_json_bytes.decode(‘utf-8’) data = json.loads(data_str) print(json.dumps(data, indent=2, ensure_ascii=False)) if __name__ == ‘__main__’: decrypt_response()6. 进阶技巧与注意事项
动态密钥与IV:很多网站为了反爬,会使用动态的密钥或IV。这些信息可能藏在某个初始化的JS变量里,或者通过一个单独的接口获取。你需要分析前端代码的执行逻辑,找到它们生成或获取这些参数的地方。有时密钥是由服务器下发的,需要先请求一个“密钥接口”。
调试与验证:使用
execjs或PyExecJS库,可以直接在Python中执行JavaScript代码片段。这对于验证密钥派生逻辑或加密过程是否正确极其有用。你可以把关键的CryptoJS代码抠出来,用Python传参调用,对比结果。性能考虑:
EVP_BytesToKey派生在大量解密时可能成为瓶颈。如果密钥固定,可以将派生好的密钥缓存起来,避免重复计算。错误处理:解密过程可能失败的原因很多。一定要用
try...except包裹核心解密代码,并记录详细的日志(如输入参数、中间结果),方便排查。法律与道德边界:爬虫解密数据必须遵守网站的
robots.txt协议和相关法律法规。确保你的行为在合法合规的范围内,仅用于学习和技术研究,不侵犯他人隐私和商业利益。
回过头看,“Malformed UTF-8 data”这个错误就像是一个警报,它告诉你解密流程在最后一步“翻译”时失败了。真正的病灶往往在前面的环节:密钥不对、IV错了、没处理Salt、模式或填充不匹配。解决这类问题的过程,就是一个标准的逆向工程和调试过程,需要耐心、细心和对加密原理的基本理解。掌握了这套方法,绝大多数基于CryptoJS的前端AES加密,对你来说就不再是障碍了。