1. 项目概述:当爬虫遇上AES加密
做爬虫的朋友,尤其是从入门开始往进阶方向走的,迟早会遇到一个绕不开的坎儿:加密接口。你兴冲冲地打开开发者工具,准备分析网络请求,结果发现关键的请求参数或者返回的数据,是一串你看不懂的“乱码”。这串“乱码”很可能就是AES加密的产物。今天,我就以一个真实的爬虫场景为例,手把手带你走一遍从发现加密、分析加密逻辑,到最终用Python代码成功破解并拿到明文数据的完整流程。这不是一个简单的“调用库解密”的教程,而是聚焦于实战中最关键的一步:
如何从纷繁复杂的网页JavaScript代码中,找到那个关键的密钥(Key)和初始化向量(IV)
。毕竟,给你一个AES解密函数很简单,但告诉你密钥在哪,才是真正的“干货”。
AES(高级加密标准)作为一种对称加密算法,在Web前端数据保护中应用非常广泛。网站用它来加密传输敏感数据,比如登录凭证、交易信息、列表数据等,以防止被轻易窥探。这对安全是好事,但对数据采集工作来说,就多了一道需要破解的屏障。不过别担心,只要加密发生在客户端(即浏览器里),理论上我们就有办法通过逆向工程还原其逻辑。整个过程,更像是一场在代码丛林里的“寻宝游戏”,目标就是找到开启数据宝箱的那把“钥匙”。
2. 逆向分析:在JavaScript的海洋里寻找密钥
面对一个加密接口,直接莽撞地去尝试各种解密算法是不现实的。第一步永远是冷静分析,而浏览器开发者工具(F12)就是我们最强大的武器。我们的目标很明确:找到生成加密参数或解密响应的JavaScript代码,并从中提取出AES加密所用的模式、填充方式、密钥和IV。
2.1 抓包与初步定位
首先,我们通过浏览器正常操作,触发目标数据的加载。在开发者工具的“网络”(Network)面板中,筛选XHR或Fetch请求,找到那个携带加密数据(可能是一个长字符串放在
data
、
encryptData
、
params
等字段里)的请求。同时,观察服务器返回的数据,如果也是加密的,通常会有一个字段(如
isEncrypt: true
)或直接就是一段Base64编码的密文。
一个非常实用的技巧是
搜索关键字段
。在开发者工具的“源代码”(Sources)面板,按
Ctrl+Shift+F
(Windows)或
Cmd+Opt+F
(Mac)开启全局搜索。你可以尝试搜索:
-
加密请求参数的名称,如
"data"
、
"encryptData"
。
-
可能标识加密的字段,如
"isEncrypt"
、
"encrypted"
。
-
加密算法名称,如
"AES"
、
"CryptoJS"
、
"encrypt"
、
"decrypt"
。
-
甚至是一些可能的关键词,如
"mode"
、
"padding"
、
"iv"
。
注意
:现代网站大量使用Webpack等打包工具,源代码可能被压缩、混淆。你搜到的代码可能变量名都是
a
、
b
、
c
,函数名也被缩短。这时不要慌,我们关注的是
字符串常量
和
核心API调用
,这些通常不会被混淆。比如搜索
"AES"
或
"CBC"
仍然可能直接定位到相关代码块。
2.2 深入分析与逻辑追踪
假设我们通过搜索
"isEncrypt"
定位到了一段疑似解密响应的代码。它可能长这样(已格式化):
function decryptData(encryptedData, keyStr) {
var key = CryptoJS.enc.Utf8.parse(keyStr);
var iv = CryptoJS.enc.Utf8.parse(keyStr); // 注意这里,IV和Key相同
var decrypted = CryptoJS.AES.decrypt(encryptedData, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return decrypted.toString(CryptoJS.enc.Utf8);
}
这段代码信息量极大:
加密库
:使用了
CryptoJS
,这是前端最常用的加密库之一。
模式与填充
:
CBC
模式,
Pkcs7
填充。这是AES非常常见的组合。
密钥与IV
:
key
和
iv
都来自
keyStr
这个参数,且都是通过UTF-8编码转换而来。
这里揭示了一个关键点:IV等于Key
。很多简单的实现为了省事,会直接用密钥作为初始化向量,这其实降低了安全性,但对我们分析来说是好事。
输入输出
:
encryptedData
是密文,解密后通过
toString(CryptoJS.enc.Utf8)
转回字符串。
那么,下一个核心问题来了:这个
keyStr
从哪里来?我们需要继续向上追踪。可能它来自某个全局变量,可能是对某个接口返回值的处理(比如时间戳),也可能是写死在代码里的一个固定值。这时,我们需要在代码中搜索
keyStr
或者观察这个解密函数被调用时传入的参数是什么。
2.3 关键密钥的获取
在实战中,密钥的来源五花八门,但常见的有以下几种:
固定值
:直接硬编码在JS文件里。搜索一些可能的密钥格式(如16/24/32位的字符串)或
"key"
字段。
接口返回
:在某个初始化请求或登录请求的响应中,服务器会返回一个密钥或用于生成密钥的种子。你需要关联多个请求来看。
时间戳衍生
:非常常见!密钥可能是当前时间戳,或者时间戳经过简单运算(如取前N位、后N位、乘以/除以一个固定数)后的结果。例如,你可能会看到
key = Date.now().toString()
或
key = "prefix_" + timestamp
。
其他参数计算
:用请求中的其他参数(如用户ID、页面ID)通过一个固定算法计算得出。
在我们的示例中,假设通过追踪发现
keyStr
的值就是请求URL中的一个参数
lastFetchTime
,或者直接是服务器返回的一个字段
lastFetchTime
。那么,密钥就是
1572353144793000
这样的时间戳字符串。
实操心得
:在逆向时,善用开发者工具中的“调试器”(Debugger)。在疑似关键函数处打上断点,然后重新触发请求,程序会在断点处暂停。这时你可以查看此时的调用栈(Call Stack)、作用域(Scope)里所有变量的值,这是追踪数据流向最直观的方法。你可以看到传入
decryptData
函数的
keyStr
具体是什么值,从而确认密钥来源。
3. Python复现:构建我们的解密工具
一旦我们在前端JavaScript中摸清了加密/解密的全部逻辑(算法、模式、填充、密钥、IV),下一步就是用Python将其复现出来。Python拥有强大的
cryptography
和
pycryptodome
库,可以完美实现AES加解密。
3.1 环境准备与库选择
首先,确保你的Python环境已经安装了必要的库。我们推荐使用
pycryptodome
,它是
pycrypto
的一个维护更活跃的分支。
pip install pycryptodome
如果安装速度慢,可以使用国内镜像源:
pip install pycryptodome -i https://pypi.tuna.tsinghua.edu.cn/simple
3.2 核心代码实现
根据我们逆向分析的结果:AES-128-CBC模式,PKCS7填充,Key和IV相同,且都是UTF-8编码的字符串。下面我们来编写对应的Python解密函数。
import base64
from Crypto.Cipher import AES
import hashlib
def pkcs7_padding(data):
"""
实现 PKCS7 填充。
AES 块大小为 16 字节,需要将数据填充至其整数倍。
"""
block_size = AES.block_size # 16
padding_length = block_size – len(data) % block_size
# PKCS7 填充的字节值等于需要填充的字节数
padding = bytes([padding_length]) * padding_length
return data + padding
def pkcs7_unpadding(data):
"""
去除 PKCS7 填充。
"""
padding_length = data[-1] # 最后一个字节的值就是填充长度
# 验证填充是否有效(可选但推荐)
if padding_length < 1 or padding_length > AES.block_size:
raise ValueError("Invalid padding length.")
if data[-padding_length:] != bytes([padding_length]) * padding_length:
raise ValueError("Invalid padding bytes.")
return data[:-padding_length]
def decrypt_aes_cbc_with_same_key_iv(encrypted_b64, key_str):
"""
解密 AES-CBC 模式,且 Key 和 IV 相同的密文。
:param encrypted_b64: Base64编码的密文字符串
:param key_str: 密钥字符串(同时也是IV)
:return: 解密后的明文字符串
"""
# 1. 将密钥字符串转换为字节,并确保长度符合AES要求(16, 24, 32字节)
# 通常前端CryptoJS.enc.Utf8.parse会按UTF-8编码成字节。
key_bytes = key_str.encode('utf-8')
# 2. 检查密钥长度,如果不够,可以用特定方式补齐(如MD5),具体需根据JS逻辑判断。
# 这里假设 key_str 本身长度就是16(AES-128)。如果不是,需要按JS中的逻辑处理。
# 例如,JS中可能用了 MD5 哈希一个字符串得到16字节密钥。
# 本例假设 key_str 直接作为密钥,且长度已满足要求。
if len(key_bytes) not in [16, 24, 32]:
# 常见情况:key_str是一个时间戳字符串,JS里直接用了。
# 但AES要求密钥长度固定。此时需要看JS源码如何处理。
# 假设JS里用CryptoJS.enc.Utf8.parse,它不会自动补长。
# 如果密钥长度不对,解密会失败。可能需要用MD5生成固定长度密钥。
# 例如:key_bytes = hashlib.md5(key_str.encode()).digest() # 生成16字节密钥
# 这里我们先按原样处理,如果失败再调整。
pass
iv_bytes = key_bytes # IV 等于 Key
# 3. 创建 AES 解密器
cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
# 4. Base64 解码密文
encrypted_bytes = base64.b64decode(encrypted_b64)
# 5. 执行解密
decrypted_padded_bytes = cipher.decrypt(encrypted_bytes)
# 6. 去除 PKCS7 填充
decrypted_bytes = pkcs7_unpadding(decrypted_padded_bytes)
# 7. 解码为字符串(假设原文是UTF-8)
plaintext = decrypted_bytes.decode('utf-8')
return plaintext
# 使用示例
if __name__ == '__main__':
# 这是我们从网络请求中捕获的加密数据(Base64格式)
encrypted_data_b64 = "WPZVRdF+hMHReWs0rgM+SR+uV3TjVPhsVKOCrW+cOF8jfhT/JL/faU3tYyBVPkyJsV+P6ReJ46/Pi0…"
# 这是我们从JS逆向中找到的密钥(假设是时间戳字符串)
secret_key = "1572353144793000"
try:
result = decrypt_aes_cbc_with_same_key_iv(encrypted_data_b64, secret_key)
print("解密成功!")
print("明文内容:", result)
# 通常结果是一个JSON字符串
import json
data_obj = json.loads(result)
print("解析为JSON:", json.dumps(data_obj, indent=2, ensure_ascii=False))
except Exception as e:
print("解密失败,错误信息:", e)
print("可能的原因:密钥长度不对、IV不对、填充方式不对、密文损坏等。")
3.3 代码关键点解析
编码一致性
:JavaScript的
CryptoJS.enc.Utf8.parse
将字符串按UTF-8编码转换为二进制字数组。在Python中,我们用
key_str.encode('utf-8')
实现完全相同的效果。
这是最容易出错的地方之一
,必须确保编码方式一致。
密钥长度
:AES标准密钥长度是128位(16字节)、192位(24字节)或256位(32字节)。如果前端传入的密钥字符串长度不符合,CryptoJS内部可能会进行哈希(如MD5)或裁剪来适配。
你必须通过阅读JS代码确认其处理逻辑
。上面的代码注释中给出了用MD5处理不定长字符串的示例。
IV处理
:本例中IV等于Key,所以直接
iv_bytes = key_bytes
。如果JS中IV是独立生成的(比如全零
\\x00
或另一个字符串),则需要单独获取并处理。
填充模式
:PKCS7是标准填充。我们的
pkcs7_unpadding
函数不仅去除了填充,还加入了简单的验证,提高了代码的健壮性。
错误处理
:解密过程可能因各种原因失败(密钥错误、密文格式错误、编码问题等)。用
try…except
包裹并打印详细错误信息,对于调试至关重要。
4. 实战演练与问题排查
理论说得再多,不如一次实战。假设我们遇到了一个真实网站,其列表数据通过AJAX加载,返回的JSON数据中,
data
字段是一个加密的字符串。
4.1 完整爬虫流程整合
我们的爬虫脚本将整合请求和解密过程:
import requests
import json
from decrypt_utils import decrypt_aes_cbc_with_same_key_iv # 假设我们把解密函数放在这个模块里
def fetch_encrypted_data(page):
"""模拟请求获取加密数据"""
url = "https://target-website.com/api/getList"
# 通常加密接口会需要一些参数,这些参数可能也参与了密钥生成
params = {
'page': page,
't': get_current_timestamp(), # 时间戳,可能就是密钥的一部分
# … 其他必要参数
}
headers = {
'User-Agent': 'Mozilla/5.0…',
# 可能需要Cookie或其他认证头
}
resp = requests.get(url, params=params, headers=headers)
resp.raise_for_status()
return resp.json() # 假设返回的是JSON,里面有一个encrypted_data字段
def get_current_timestamp():
"""生成与JS逻辑一致的时间戳"""
# 注意:JS的 Date.now() 返回毫秒级时间戳(13位)。
# Python的 time.time() 返回秒级浮点数(10位+小数)。
# 需要乘以1000并取整来匹配。
import time
return str(int(time.time() * 1000))
def main():
all_data = []
for page in range(1, 6):
print(f"正在抓取第 {page} 页…")
encrypted_json = fetch_encrypted_data(page)
# 提取加密字符串和密钥(这里需要根据实际逆向结果调整)
encrypted_b64 = encrypted_json['data']
# 密钥来源:可能是当前时间戳,也可能是响应里的另一个字段
secret_key = get_current_timestamp() # 示例:直接用请求时的时间戳
# 或者 secret_key = encrypted_json['keyField']
try:
decrypted_text = decrypt_aes_cbc_with_same_key_iv(encrypted_b64, secret_key)
page_data = json.loads(decrypted_text)
all_data.extend(page_data['list'])
print(f"第 {page} 页解密成功,获取到 {len(page_data['list'])} 条数据。")
except Exception as e:
print(f"第 {page} 页解密失败:{e}")
# 可以记录日志,或者尝试其他密钥生成方式
break
print(f"总共获取到 {len(all_data)} 条数据。")
# 保存数据
with open('result.json', 'w', encoding='utf-8') as f:
json.dump(all_data, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
main()
4.2 常见问题与排查清单
即使你严格按JS逻辑复现了代码,解密过程仍可能失败。下面是一个问题排查指南:
|
ValueError: Invalid padding bytes. 或解密后乱码 |
1. 密钥错误 :Key或IV不正确。 2. 密文损坏 :Base64解码出错或传输中有更改。 3. 算法/模式/填充不匹配 :不是CBC模式或PKCS7填充。 |
1.
核对密钥 :在JS调试器中,在解密函数断点处,确认传入的 keyStr 和 iv 的 精确值 (包括类型,是字符串还是对象)。 2. 检查密文 :确保从网络请求中复制的密文完整无误,没有遗漏等号 = 。 3. 验证算法 :确认JS代码中 CryptoJS.AES.decrypt 的第三个参数(配置对象)里的 mode 和 padding 属性。 |
|
TypeError: Incorrect IV length (must be 16 bytes long) |
IV长度不对 。AES-CBC模式的IV必须是16字节。 |
1. 检查JS中IV的生成方式。如果IV来自一个字符串,确认
CryptoJS.enc.Utf8.parse 后的字节长度。如果不足16字节,CryptoJS可能会怎么处理?可能需要用特定值(如 \\x00 )填充。 2. 如果IV是 CryptoJS.lib.WordArray.random(16) 生成的,则需要在Python中模拟生成16字节随机数,但这通常不可行。需要看IV是否在通信中传递(如密文前16字节作为IV)。 |
| 解密出的字符串开头有乱码,但后面是正确JSON |
IV不正确 ,导致第一块数据解密错误,但由于CBC模式链式影响,后续块可能“歪打正着”。 |
重点检查IV。确认它是否真的是密钥本身。有时IV可能是密钥的MD5哈希值,或者是一个固定字符串。 |
| 解密结果为空或非常短 |
填充去除逻辑错误 。可能不是PKCS7填充,或者是NoPadding(无填充),但密文长度不是16的倍数。 |
1. 尝试不解密,直接打印
decrypted_padded_bytes ,看看末尾字节是否有规律(如 \\x04\\x04\\x04\\x04 )。 2. 查看JS代码,确认padding配置。可能是 CryptoJS.pad.NoPadding 、 CryptoJS.pad.ZeroPadding 等。 3. 如果是NoPadding,需要确保明文长度原本就是16的倍数,在Python解密后直接解码即可,无需 unpadding 。 |
| 能解密但中文是乱码 |
字符编码问题 。JS解密后是UTF-8字节,但Python解码时用了错误编码(如 decode('gbk') )。 |
确保最后一步
decrypted_bytes.decode('utf-8') 。如果原文不是UTF-8,则需要换成对应的编码(如 gb2312 )。 |
踩坑心得
:最让人头疼的不是复杂的算法,而是
细节不一致
。JS的
CryptoJS.enc.Utf8.parse('abc')
和 Python的
'abc'.encode('utf-8')
在99%的情况下是一样的,但如果你遇到的密钥字符串里包含特殊字符或多字节字符,就需要格外小心。另一个大坑是
时间戳
。JS的
Date.now()
和Python的
int(time.time()*1000)
在绝大多数时候是同步的,但如果服务器时间与本地时间有较大偏差,或者密钥使用的是服务器返回的某个特定时间戳(而非本地生成),就会导致密钥对不上。
务必在JS运行时通过断点拿到“此时此刻”用于加密的真实密钥值,与你的Python脚本生成的密钥进行比对
,这是最直接的验证方法。
5. 进阶思考与策略优化
成功破解一个加密接口很有成就感,但作为爬虫工程师,我们不能只满足于一次性的成功。我们需要思考更稳健、更高效的策略。
5.1 密钥的动态性处理
很多网站的加密密钥是动态变化的,比如每次会话都不同,或者每分钟变化一次。应对策略有:
-
关联请求
:密钥可能藏在另一个初始化接口的响应里。你需要先请求接口A拿到密钥
key
,再用这个
key
去请求接口B解密数据。
-
本地计算
:密钥可能由页面加载时生成的某个随机数(
nonce
)或用户令牌(
token
)计算得出。你需要把这个计算过程也复现到Python中。
-
同步时间戳
:如果密钥是时间戳,要确保你的爬虫服务器时间与目标网站服务器时间基本同步。可以考虑在首次请求时,从响应头里获取服务器时间,并计算本地与服务器的时间差,后续请求根据这个差值来校准本地生成的时间戳。
5.2 提升逆向效率的工具与技巧
-
使用自动化工具
:对于简单的、固定模式的CryptoJS加密,可以尝试使用
execjs
库直接在Python中调用原始的JavaScript解密函数,省去复现算法的步骤。但这会稍微影响性能,且依赖Node.js环境。
import execjs
with open('decrypt.js', 'r', encoding='utf-8') as f:
js_code = f.read()
ctx = execjs.compile(js_code)
result = ctx.call('decryptData', encrypted_b64, key_str) -
关注加密库版本
:CryptoJS有不同的版本,其API和默认行为可能有细微差别。在JS代码里搜索
CryptoJS.version
或查看文件引用路径,有助于确定版本。
-
Hook关键函数
:对于反调试能力强的网站,可以尝试在控制台提前注入代码,“钩住”(Hook)
CryptoJS.AES.encrypt
或
decrypt
方法,直接打印出其调用参数和结果,这是最“暴力”但最有效的获取密钥和明文的方式。
// 在页面JS加载前,在控制台执行
var _encrypt = CryptoJS.AES.encrypt;
CryptoJS.AES.encrypt = function(message, key, cfg){
console.log('AES Encrypt Called:');
console.log('Message:', message);
console.log('Key:', key);
console.log('Cfg:', cfg);
var result = _encrypt(message, key, cfg);
console.log('Result:', result.toString());
return result;
}
5.3 关于爬虫道德的再次强调
在兴奋于技术突破的同时,我们必须时刻牢记爬虫的伦理与法律边界。破解加密接口是为了学习逆向工程技术和应对常见的反爬机制,但绝不能用于:
- 侵犯个人隐私。
- 窃取未公开的、受版权保护的数据。
- 对目标网站进行超出其承受能力的频繁请求,导致服务器瘫痪(DDoS效应)。
- 绕过付费墙,获取本应付费的内容。
在编写爬虫时,务必遵守网站的
robots.txt
协议,合理设置请求间隔(如添加
time.sleep(2)
),模拟正常用户行为,并使用清晰的User-Agent标识自己。技术是一把双刃剑,用它来学习、研究和在合法合规的范围内获取公开数据,才是长久之道。
整个破解AES加密接口的过程,就像一场侦探游戏,需要耐心、细心和对细节的执着。从抓包定位到JS逆向,再到Python复现和问题排查,每一步都可能遇到坑。但每当你成功解密,看到原本杂乱无章的密文变成结构清晰的JSON数据时,那种解决问题的成就感是无与伦比的。希望这篇详细的实战指南,能为你下次遇到加密接口时提供清晰的路径和实用的工具。记住,核心永远在于仔细阅读和理解前端JavaScript代码的逻辑,任何一步的想当然都可能导致失败。



