Python爬虫实战:解决AES解密中的UTF-8解码错误

📅 2026/7/6 23:42:47
Python爬虫实战:解决AES解密中的UTF-8解码错误
1. 项目概述当爬虫遇上AES加密做爬虫的朋友估计都遇到过这个让人头疼的场景目标网站的数据不再是明晃晃的HTML标签而是变成了一串串看不懂的、像乱码一样的加密字符串。你兴冲冲地用requests把数据抓下来准备用BeautifulSoup或者xpath大展拳脚结果发现response.text里除了几个无关紧要的div核心数据空空如也。这时候打开浏览器的开发者工具切到Network网络标签页仔细查看那个返回数据的XHR请求你大概率会在Response里看到类似U2FsdGVkX1...这样以特定字符开头的密文或者在Initiatior里发现一个名为crypto-js的脚本库。恭喜你你遇到了前端加密而AESAdvanced 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模式CBCCipher 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 工具选型为什么是pycryptodomePython处理加密的库有很多如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_length32, iv_length16, iterations1): 模拟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_lengthiv_length, iterations) key derived[:key_length] iv derived[key_length:key_lengthiv_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密文的传递格式多种多样你需要根据实际情况解析。以下是两种常见情况的处理情况一密文为简单Base64IV单独提供这种情况最简单。假设你通过抓包发现返回的密文是一个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字节。派生后的密钥长度必须匹配。IVIV是否正确获取并解码是固定字符串还是动态生成长度必须是16字节。检查密钥派生过程盐Salt这是最大的坑确认盐是否被使用。抓取加密过程的JS搜索salt或Salted__。如果使用了盐必须用EVP_BytesToKey派生密钥。如果JS中直接使用CryptoJS.enc.Utf8.parse(‘key‘)作为密钥而不是字符串密码那么它就没有使用派生你应该直接使用这个字符串的UTF-8字节作为密钥。迭代次数EVP_BytesToKey的迭代次数默认是1但有些配置可能会改。验证密文和IV的编码密文和IV从网络抓下来可能是Base64也可能是16进制Hex。确保你用对了解码方法base64.b64decode或bytes.fromhex。警惕URL安全的Base64-和_替代和/Python的base64.b64decode需要先替换回来或者使用base64.urlsafe_b64decode。解密后先检查原始字节在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解密代码去解密这个密文看是否能还原。这是最可靠的验证方法。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, passwordNone, keyNone, ivNone, modeAES.MODE_CBC, paddingpkcs7): 一个尝试自动处理常见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}, headersheaders) # 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, indent2, ensure_asciiFalse)) 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加密对你来说就不再是障碍了。