1. 项目概述:当爬虫遇上AES加密
最近在分析一个数据源时,又碰到了老朋友——AES加密。请求参数或者返回的数据,不再是明晃晃的JSON字符串,而是一串看着像乱码的密文。对于刚接触JS逆向的朋友来说,这堵“加密墙”往往就是第一个拦路虎。今天,我就结合一个实战案例,把AES加密在JavaScript中的实现原理、逆向分析方法以及如何在Python中复现解密过程,掰开揉碎了讲清楚。无论你是想学习JS逆向的爬虫工程师,还是对前端加密实现好奇的开发者,这篇文章都能给你一套清晰的“解题思路”和“操作手册”。我们不止要会用工具,更要明白背后的门道,这样下次遇到变种的加密方式,你也能自己找到突破口。
2. AES加密核心原理与模式解析
要逆向AES,首先得知道它正着是怎么工作的。AES(Advanced Encryption Standard)是一种对称加密算法,意思是加密和解密用的是同一把钥匙,我们称之为密钥。它的核心思想是把数据分成一个个固定大小的“块”,然后通过多轮的“混淆”和“扩散”操作,让原始数据和密钥充分混合,最终输出密文。
2.1 关键参数:密钥、初始向量与填充
AES加密不是简单地输入明文和密钥就能出密文,它依赖于几个关键参数,理解这些是逆向的基础:
密钥(Key):这是加密解密的根本。AES标准支持三种长度的密钥:128位(16字节)、192位(24字节)和256位(32字节)。密钥越长,安全性越高,但计算也稍慢。在Web前端,为了兼顾安全与性能,128位和256位比较常见。
初始向量(IV, Initialization Vector):这不是所有模式都需要的,但在最常见的CBC(Cipher Block Chaining)模式下,IV至关重要。它是一个随机生成的、长度与加密块大小(AES固定为128位,即16字节)相同的字节序列。IV的作用是确保即使相同的明文用相同的密钥加密,每次产生的密文也不同,防止攻击者通过对比密文模式来推测信息。在逆向时,如果发现每次请求的密文都不同,但用同一个密钥又能解密,那几乎可以肯定使用了CBC模式且有随机IV。
填充(Padding):AES是块加密,一次处理一个128位的块。如果明文长度不是16字节的整数倍怎么办?这就需要填充。常见的填充方式有PKCS#7(也叫PKCS#5)。它的规则很简单:缺几个字节,就用几来填充。例如,一个15字节的数据,需要补1个字节,填充值就是
0x01;如果是14字节,就补两个0x02。解密后,需要正确移除这些填充字节才能得到原始数据。
注意:在JavaScript的CryptoJS库或Node.js的crypto模块中,默认的填充方式往往是PKCS#7。但在一些“魔改”的加密实现里,开发者可能会自定义填充规则,这是逆向中的一个常见坑点。
2.2 工作模式:CBC与ECB
AES有不同的工作模式,决定了块与块之间如何关联。逆向时必须确定模式。
- ECB(Electronic Codebook):最简单的模式,每个数据块独立加密。相同的明文块会产生相同的密文块。这会导致模式泄露,安全性很差,在Web中已很少用于敏感数据,但逆向时如果发现没有IV参数,可能就是它。
- CBC(Cipher Block Chaining):目前最常用的模式。它引入了IV,并且每个明文块在加密前,会先与前一个密文块进行异或操作(第一个块与IV异或)。这种链式结构让密文块之间产生了依赖,安全性大大增强。绝大多数网站的前端AES加密都采用CBC模式。
在逆向分析时,我们首要目标就是找到这三个要素:密钥(Key)、初始向量(IV)和工作模式(通常是CBC)。它们可能硬编码在JavaScript文件里,也可能通过某个接口动态获取,或者由其他参数计算得出。
3. JavaScript中的AES加密实现与逆向切入点
前端实现AES加密,最常用的库是CryptoJS。它是一个功能强大的加密算法库,提供了简洁的API。理解它的常见用法,就等于拿到了逆向的“地图”。
3.1 CryptoJS的典型用法
一个标准的CryptoJS AES-CBC加密代码示例如下:
// 引入CryptoJS(在Web中通常通过CDN) // <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script> // 假设这是要加密的数据 var plainText = '{"page":1,"size":20}'; // 定义密钥和IV(这里以字符串形式,实际会转换为WordArray) var key = CryptoJS.enc.Utf8.parse('1234567890123456'); // 16字节密钥 var iv = CryptoJS.enc.Utf8.parse('abcdefghijklmnop'); // 16字节IV // 执行AES-CBC加密 var encrypted = CryptoJS.AES.encrypt(plainText, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 // 默认就是Pkcs7,常省略 }); // 将加密结果转换为Base64字符串(常见传输格式) var ciphertextBase64 = encrypted.toString(); console.log(ciphertextBase64); // 输出类似:'U2FsdGVkX1+...'逆向时的关键观察点:
CryptoJS.enc.Utf8.parse:这是将普通字符串转换为CryptoJS内部WordArray对象的方法。逆向时,搜索encrypt函数,往前找它的参数,很可能会找到类似的parse调用,其参数就是明文的Key或IV。- 加密选项对象:
{iv: iv, mode: ...}这个对象明确指明了加密模式和IV。如果代码里没写mode和padding,那大概率就是默认的CBC和PKCS#7。 encrypted.toString():默认输出的是OpenSSL兼容格式的字符串。它不仅仅是Base64编码的密文,实际上是一个特殊结构:以"Salted__"开头(如果使用了盐值),或者直接是密文的Base64。在简单场景下,我们直接拿到这个字符串作为密文。
3.2 逆向分析实战:从混淆代码中定位关键参数
实际网站的JavaScript代码通常是经过压缩和混淆的,变量名变成a, b, c, d,逻辑也变得难以阅读。但加密函数的调用痕迹很难完全抹去。以下是逆向排查的步骤:
搜索关键词:在开发者工具的Sources面板中,全局搜索(Ctrl+Shift+F)以下关键词:
encryptAESCryptoJS(如果库被直接引用)mode、iv、padding- 有时密钥是固定字符串,也可以搜索疑似密钥的字符串,如长段的16、24、32位字符。
设置XHR断点:在Network面板找到发送加密数据的请求(通常是XHR或Fetch),右键选择“Break on -> XHR/Fetch”。当请求发起时,代码执行会自动暂停在发送请求的前一刻。此时调用栈(Call Stack)会显示完整的函数调用链,你可以一步步往回(向上)跟踪,找到数据是在哪个函数里被加密的。
分析加密函数:找到疑似加密函数后,重点观察:
- 输入:什么数据被传入了这个函数?(通常是包含查询参数或表单数据的对象)。
- 输出:这个函数返回了什么?(一个字符串,很可能就是加密后的密文)。
- 内部逻辑:函数内部有没有调用类似
CryptoJS.AES.encrypt的方法?其参数(key, iv)是硬编码的字符串,还是来自其他变量或函数计算?如果是计算得来的,需要继续追溯这些变量的来源。
还原关键参数:这是核心。假设你找到了这样一行混淆代码:
var c = f.encrypt(b, g, {iv: h});你需要去查看
b(明文)、g(密钥)、h(IV)的值是什么。在调试器里,将鼠标悬停在变量上,或是在Console中直接输入变量名并回车,就能看到其当前值。记录下这些值,特别是g和h,它们很可能就是我们要找的Key和IV。
实操心得:很多网站的Key和IV并不是直接写在当前JS文件里的。它们可能由服务器下发的某个令牌(Token)通过某种哈希算法(如MD5、SHA256)生成,或者由页面上的某个固定元素(如时间戳、用户ID)拼接后再哈希。这时,你需要顺着代码逻辑,找到生成Key/IV的函数,并用Python复现其生成逻辑。一个常见模式是:
key = CryptoJS.MD5(某个字符串).toString().substr(0, 16)。
4. 在Python中复现AES解密流程
在浏览器里找到了Key和IV,验证了加密逻辑,下一步就是在爬虫脚本(通常是Python)中复现解密过程,让程序能自动化解密返回的密文。这里我们使用Python中最常用的加密库pycryptodome。
4.1 环境准备与基础解密
首先安装库:pip install pycryptodome
假设我们从服务器获得了一个Base64编码的密文,并且已知Key和IV(均为16字节的字符串):
from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 # 已知的参数(从JS逆向中获得) key_str = '1234567890123456' # 密钥字符串 iv_str = 'abcdefghijklmnop' # 初始向量字符串 ciphertext_b64 = 'U2FsdGVkX19qBzV5H8lT6M8e5zZvJ7wP/...' # 模拟的Base64密文 # 将字符串转换为字节类型 key = key_str.encode('utf-8') iv = iv_str.encode('utf-8') # 解码Base64密文 ciphertext_bytes = base64.b64decode(ciphertext_b64) # 创建AES解密器,使用CBC模式 cipher = AES.new(key, AES.MODE_CBC, iv) # 执行解密 decrypted_padded = cipher.decrypt(ciphertext_bytes) # 移除PKCS#7填充 plaintext_bytes = unpad(decrypted_padded, AES.block_size) # 将解密后的字节转换为字符串 plaintext = plaintext_bytes.decode('utf-8') print(f"解密结果:{plaintext}")4.2 处理OpenSSL格式密文
一个非常重要的细节是:CryptoJS的encrypt方法默认返回的字符串,是经过Salt处理的OpenSSL兼容格式。如果你直接把整个字符串做Base64解码然后解密,会失败。这种格式的密文结构是:Salted__+ 8字节盐值(salt) + 实际密文。
我们需要先分离出盐值和实际密文:
def decrypt_openssl_format(ciphertext_b64, key_str, iv_str): """ 解密CryptoJS默认生成的OpenSSL格式密文 """ key = key_str.encode('utf-8') iv = iv_str.encode('utf-8') ciphertext_bytes = base64.b64decode(ciphertext_b64) # 检查是否是Salted开头的OpenSSL格式 if ciphertext_bytes.startswith(b'Salted__'): salt = ciphertext_bytes[8:16] # 8字节盐值 actual_ciphertext = ciphertext_bytes[16:] # 真正的密文 # 使用盐值和密码生成实际的Key和IV (使用OpenSSL的EVP_BytesToKey方法) # 注意:这里演示的是CryptoJS的默认密钥派生方式。有些场景可能直接使用提供的key/iv,无需此步骤。 # 更安全的做法是使用专门的库如`Crypto.Protocol.KDF`中的`PBKDF2`,但CryptoJS默认是较旧的EVP。 # 如果逆向时发现key/iv是直接指定的字符串,则跳过此派生步骤,直接用上面的简单方法。 from Crypto.Protocol.KDF import PBKDF1 from hashlib import md5 # 简化演示:实际CryptoJS的EVP_BytesToKey迭代一次,使用MD5 # 关键:密码是key_str,盐是salt,生成16+16=32字节数据,前16位为key,后16位为iv derived = PBKDF1(key_str.encode('utf-8'), salt, 16, count=1, hashfunc=md5) # 生成16字节 derived += PBKDF1(key_str.encode('utf-8') + derived, salt, 16, count=1, hashfunc=md5) # 再生成16字节 key = derived[:16] iv = derived[16:32] cipher = AES.new(key, AES.MODE_CBC, iv) decrypted_padded = cipher.decrypt(actual_ciphertext) else: # 如果不是Salted格式,直接使用提供的key和iv解密 cipher = AES.new(key, AES.MODE_CBC, iv) decrypted_padded = cipher.decrypt(ciphertext_bytes) # 移除填充 plaintext_bytes = unpad(decrypted_padded, AES.block_size) return plaintext_bytes.decode('utf-8') # 使用示例 result = decrypt_openssl_format(ciphertext_b64, key_str, iv_str) print(result)这里有个极易踩坑的点:很多教程和实际网站的实现并不一致。有些网站虽然用了CryptoJS,但在调用encrypt时,传入的key和iv已经是WordArray对象(通过CryptoJS.enc.Utf8.parse转换),这时CryptoJS默认不会使用Salt和EVP_BytesToKey派生,而是直接使用你提供的Key和IV。这种情况下,你直接用最开始的简单解密方法(4.1节)就能成功。如何判断?一个方法是看JS代码里有没有设置format: CryptoJS.format.OpenSSL选项,或者直接看生成的密文Base64字符串,如果以U2FsdGVkX1开头(这是Salted__的Base64编码),就是带了Salt的格式。
核心技巧:最稳妥的方法是在浏览器控制台做实验。用你找到的Key、IV和加密函数,加密一段已知的明文(如
"test"),得到密文A。然后用你的Python解密脚本,用同样的Key和IV去解密密文A。如果成功得到"test",说明你的解密逻辑正确;如果失败,再尝试处理OpenSSL格式。这是验证逆向结果的金标准。
5. 实战案例拆解:模拟一个常见的加密请求
假设我们要爬取一个网站,其查询API的请求参数data是经过AES加密的。我们通过开发者工具,定位到了加密函数位于一个叫encryptData的混淆函数里。
浏览器端分析过程:
- 在发送请求的XHR处打上断点,刷新页面触发请求。
- 在调用栈中找到
encryptData函数,单步进入。 - 发现其内部核心代码如下(已稍作反混淆):
function encryptData(paramObj) { var key = CryptoJS.MD5(window._global_token).toString().substr(0, 16); var iv = CryptoJS.enc.Utf8.parse('1234567812345678'); var plaintext = JSON.stringify(paramObj); var encrypted = CryptoJS.AES.encrypt(plaintext, CryptoJS.enc.Utf8.parse(key), {iv: iv}); return encrypted.toString(); } - 分析得知:
- Key生成:取一个全局变量
window._global_token(可能由登录后接口返回),计算其MD5值,并取前16个字符作为密钥。这说明密钥是动态的,但算法固定。 - IV:固定字符串
'1234567812345678'。 - 模式与填充:未指定,即默认CBC和PKCS#7。
- 输出:直接返回
encrypted.toString(),即OpenSSL格式的密文字符串。
- Key生成:取一个全局变量
Python端复现:现在我们需要在爬虫中,模拟这个加密过程,生成合法的data参数。
import json import hashlib from Crypto.Cipher import AES import base64 def encrypt_request_data(param_dict, token): """ 模拟JS端的encryptData函数 param_dict: 要加密的参数字典 token: 从登录接口获取的_global_token """ # 1. 生成Key (MD5(token)取前16位) key_md5 = hashlib.md5(token.encode('utf-8')).hexdigest() key_str = key_md5[:16] # 取前16个字符 key = key_str.encode('utf-8') # 2. 固定IV iv_str = '1234567812345678' iv = iv_str.encode('utf-8') # 3. 准备明文(JSON字符串) plaintext = json.dumps(param_dict, separators=(',', ':'), ensure_ascii=False) # separators参数用于移除JSON中的空格,与JSON.stringify行为一致 plaintext_bytes = plaintext.encode('utf-8') # 4. 进行PKCS#7填充 from Crypto.Util.Padding import pad padded_bytes = pad(plaintext_bytes, AES.block_size) # 5. 创建加密器并加密 cipher = AES.new(key, AES.MODE_CBC, iv) ciphertext_bytes = cipher.encrypt(padded_bytes) # 6. 转换为OpenSSL兼容格式(Salted__ + 随机盐 + 密文) # 注意:CryptoJS.encrypt当key是WordArray时,默认不使用Salt。 # 但根据我们看到的JS代码,它直接用了key和iv,没有Salt。 # 然而,encrypted.toString()默认输出OpenSSL格式。为了完全匹配, # 我们需要模拟生成一个不带Salt的OpenSSL格式?实际上,如果key是WordArray,CryptoJS默认格式是CipherParams对象。 # 更准确的测试发现:当key是字符串时,CryptoJS会使用Salt;当key是WordArray时,不会。 # 我们的JS代码中key是WordArray(CryptoJS.enc.Utf8.parse(key)),所以不会加Salt。 # 但encrypted.toString()的结果是什么?我们直接在浏览器控制台测试。 # 假设测试发现,直接返回ciphertext_bytes的Base64就能用,那我们就不包装。 # 最可靠的方案:在浏览器控制台运行加密,看输出格式。 # 假设我们发现输出是纯Base64密文(没有Salted__头),则: ciphertext_b64 = base64.b64encode(ciphertext_bytes).decode('utf-8') return ciphertext_b64 # 使用示例 token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." # 模拟获取到的token params = {"page": 1, "keyword": "爬虫"} encrypted_data = encrypt_request_data(params, token) print(f"加密后的data参数:{encrypted_data}") # 然后将encrypted_data作为表单的data字段发送即可这个案例清晰地展示了从逆向分析到Python复现的完整闭环:定位加密函数 -> 分析密钥/IV生成逻辑 -> 在Python中复现该逻辑 -> 生成加密参数。
6. 常见问题排查与进阶技巧
在实际操作中,你几乎一定会遇到各种问题。下面是一些常见错误和排查思路:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
Python解密报错ValueError: Invalid padding bytes. | 1. 密钥或IV错误。 2. 密文格式不对(如包含非Base64字符)。 3. 加密模式或填充方式不匹配。 4. 密文在传输中被修改(如URL编码问题)。 | 1.核对Key/IV:确保与JS中使用的完全一致,包括字符串编码(通常UTF-8)。 2.检查密文:确保Base64解码前字符串正确,无换行、空格。尝试在浏览器中加密一个短字符串,用同样的Key/IV在Python中解密,验证基础流程。 3.确认模式与填充:JS默认CBC+PKCS7,Python需对应。如果JS用了ECB,Python需改为 AES.MODE_ECB且无需IV。4.处理URL编码:如果密文作为URL参数传递,可能被URL编码。需先 urllib.parse.unquote解码。 |
| 解密出的中文是乱码 | 解密后的字节流用错误的编码解码。 | 解密后得到plaintext_bytes,尝试不同的编码解码:plaintext_bytes.decode('utf-8')、decode('gbk')、decode('latin-1')。通常UTF-8是正确的。 |
| 每次加密结果都不同,但同一个密钥能解密 | 使用了CBC模式且IV是随机生成的。 | 这是正常现象。逆向时,需要找到IV是如何生成或传递的。IV可能: 1. 硬编码在JS里。 2. 由服务器在首次请求时返回。 3. 由时间戳等参数通过哈希生成。你需要复现IV的生成逻辑。 |
| 在JS里能加密解密,Python复现失败 | 1. Key/IV的字符串到字节的转换不一致。 2. 使用了Salt和密钥派生(EVP_BytesToKey),但Python未复现。 3. 自定义了加密模式或填充(如CTR模式、ZeroPadding)。 | 1. 确保JS中的CryptoJS.enc.Utf8.parse('key')对应Python的'key'.encode('utf-8')。2. 仔细检查JS代码,看是否有 format: CryptoJS.format.OpenSSL或类似设置。用4.2节的方法处理Salt。3. 在JS加密函数处仔细查看选项对象,确认 mode和padding的值。 |
找不到明显的CryptoJS或encrypt调用 | 1. 加密逻辑被深度混淆或封装。 2. 使用了Web Crypto API等原生接口。 3. 加密在WebAssembly中实现。 | 1. 使用XHR断点法,从网络请求发起处反向追踪。 2. 搜索 subtle.encrypt、window.crypto等关键词。3. Wasm逆向较复杂,可先尝试在控制台Hook相关函数,或搜索 .wasm文件请求。 |
进阶技巧:
- Hook大法:在页面加载前注入代码,覆盖关键的加密函数,让其执行时打印出参数和结果。这是动态分析的神器。
// 在控制台执行,或作为油猴脚本注入 var _originalEncrypt = CryptoJS.AES.encrypt; CryptoJS.AES.encrypt = function(plaintext, key, cfg) { console.log('[Hook] Plaintext:', plaintext); console.log('[Hook] Key:', key); console.log('[Hook] Config:', cfg); var result = _originalEncrypt.call(this, plaintext, key, cfg); console.log('[Hook] Ciphertext:', result.toString()); return result; }; - 关注非对称加密混合使用:有些网站会用RSA加密AES的密钥,然后将加密后的密钥和AES密文一起发送。你需要先逆向RSA公钥,用Python的
rsa或Crypto.PublicKey库解密出AES密钥,再进行AES解密。 - 调试符号(Source Map):如果网站开发环境未关闭Source Map,你可以在开发者工具的Sources面板看到近乎原始的JavaScript代码,极大降低逆向难度。
逆向分析是一个需要耐心和细致观察的过程。每一次成功破解,都是对加密原理和代码逻辑理解的一次深化。从最标准的AES-CBC开始,掌握这套“定位-分析-复现-验证”的方法论,你就能应对越来越复杂的加密场景。记住,浏览器开发者工具是你最强大的盟友,而控制台则是你的实验场。多动手、多验证,思路自然会越来越清晰。