爬虫逆向实战:3DES加密原理与Python模拟实现详解

1. 项目概述:为什么爬虫工程师必须懂3DES?

如果你正在和网站的反爬机制斗智斗勇,尤其是那些数据被一层“密文”包裹着的网站,那么“对称加密算法”绝对是你绕不开的一道坎。而3DES,作为对称加密家族中一个承上启下的经典算法,在今天的互联网数据交互中依然有它的身影。我处理过不少爬虫项目,发现很多看似复杂的加密参数,其底层核心就是3DES。不理解它,你连数据包都看不懂,更别提逆向还原出可用的明文数据了。

简单来说,3DES(Triple Data Encryption Standard)就是DES算法的“威力加强版”。在爬虫逆向的语境下,它的角色通常是这样的:网站服务器为了保护关键数据(比如登录令牌、查询参数、列表数据等),会在前端JavaScript代码中使用3DES算法进行加密,然后将密文传输给后端。后端用对应的密钥解密后处理。我们的目标,就是逆向分析出这个加密过程,用Python模拟出来,从而构造出合法的请求参数。

这不仅仅是“找到加密函数然后调用”那么简单。你需要理解它的密钥管理(是单密钥、双密钥还是三密钥?)、加密模式(ECB、CBC?)、填充方式(PKCS5?),以及初始化向量IV的使用。这些细节稍有差错,生成的密文就无法被服务器正确解密,你的爬虫也就卡在了第一步。接下来,我会结合具体场景,带你从原理到实战,彻底拆解3DES在爬虫逆向中的应用。

2. 核心原理拆解:3DES如何为数据“上锁”?

要逆向,先得搞懂正向流程。3DES的原理是理解一切的基础。

2.1 从DES到3DES:一次不够,就来三次

DES(Data Encryption Standard)是上世纪70年代的标准,密钥长度56位,在当今算力下已不再安全。3DES的诞生,并非设计一个全新算法,而是巧妙地通过多次DES操作来增加强度。它主要有三种密钥方案:

  1. 密钥选项1(三个独立密钥):使用三个完全不同的56位密钥(K1, K2, K3)。加密过程是加密(K1) -> 解密(K2) -> 加密(K3)。这是最安全的方式,有效密钥长度达到168位。解密过程则相反:解密(K3) -> 加密(K2) -> 解密(K1)
  2. 密钥选项2(两个独立密钥):K1和K3使用同一个密钥。即加密(K1) -> 解密(K2) -> 加密(K1)。有效密钥长度112位。这是目前较为常见的实现。
  3. 密钥选项1(三个相同密钥):K1=K2=K3。这实际上退化成了标准的DES,仅用于向后兼容,现已不推荐。

在爬虫逆向中,你遇到的绝大多数是密钥选项2(两个独立密钥)。因为它在安全性和性能之间取得了较好的平衡,也是许多标准库(如Python的pycryptodome)的默认模式。

注意:很多前端加密库(如CryptoJS)在调用3DES时,看似只传入了一个24字节(192位)的密钥。实际上,这个长密钥在内部被等分成了K1(前8字节)、K2(中间8字节)、K3(后8字节)。如果K1等于K3,就是密钥选项2的模式。这是分析源码时要留意的关键点。

2.2 工作模式与填充:决定加密的“样式”

光有算法和密钥还不够,3DES在实际使用时还需要指定“工作模式”和“填充方案”。这是爬虫逆向中最容易出错的地方。

常见工作模式:

  • ECB(电子密码本):最简单的模式,将明文分成块,每块独立加密。缺点非常致命:相同的明文块会产生相同的密文块,容易受到模式分析攻击。在爬虫中,如果数据本身规律性强(如序列化的JSON有大量重复结构),ECB模式会留下明显的“指纹”。
  • CBC(密码分组链接)这是目前最常用的模式,也是爬虫逆向的重点关注对象。它在加密前,先将当前明文块与前一个密文块进行异或操作。对于第一个块,需要一个“初始化向量”(IV)来参与异或。IV不需要保密,但必须不可预测(通常随机生成)。CBC模式能很好地隐藏明文模式。

填充方案:因为DES/3DES是分组加密算法,一次处理8个字节(64位)。如果明文长度不是8的倍数,就需要填充。常见的是PKCS5/PKCS7填充(两者在8字节分组下等价)。例如,如果最后一个块缺3个字节,就填充3个值为0x03的字节。

一个完整的加密流程(CBC模式 + PKCS5填充)可以表示为:Ciphertext = 3DES_Encrypt_CBC(Key, IV, PKCS5_Padding(Plaintext))逆向时,你的任务就是找到前端代码中对应的KeyIVModePadding

3. 逆向实战:定位与分析前端3DES加密逻辑

理论说再多,不如动手跟一遍。假设我们遇到一个网站,其搜索接口的keyword参数是一串看不懂的密文。

3.1 抓包与初步判断

首先,用浏览器开发者工具(F12)的Network面板抓取搜索请求。你会发现POST数据或Query参数中有一个长得像Base64编码的长字符串,例如params=U2FsdGVkX1+4m...

  1. 观察特征:虽然不能仅凭外观断定,但经过3DES CBC加密后再Base64编码的数据,本身没有特别固定的前缀。但你可以尝试一个简单判断:改变搜索词(明文),观察密文是否完全改变。如果只是局部变化,可能是ECB模式;如果全部变化,则可能是CBC等带IV的模式。
  2. 搜索关键函数:在Sources面板全局搜索(Ctrl+Shift+F)与加密相关的关键词。对于3DES,可以搜索:
    • TripleDES
    • 3DES
    • CryptoJS.DESCryptoJS.TripleDES(如果用了CryptoJS库)
    • mode:padding:(用于查找模式配置)
    • encryptdecrypt
    • 有时密钥是硬编码的字符串或可追溯的变量,搜索key=secret=iv=也可能有收获。

3.2 逆向分析CryptoJS示例

假设我们幸运地找到了类似下面的代码片段(这是CryptoJS的典型用法):

function encryptData(word) { var key = CryptoJS.enc.Utf8.parse("123456781234567812345678"); // 24字节密钥 var iv = CryptoJS.enc.Utf8.parse("01234567"); // 8字节IV var encrypted = CryptoJS.TripleDES.encrypt(word, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(); // 默认输出OpenSSL格式的Base64字符串 }

逐行分析:

  • key:24字节的UTF-8字符串,被解析成WordArray对象。这印证了之前说的“24字节密钥”。
  • iv:8字节的初始化向量。
  • CryptoJS.TripleDES.encrypt:调用3DES加密函数。
  • 选项{iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7}:明确指定了CBC模式和PKCS7填充。
  • encrypted.toString():将加密结果转换为字符串。CryptoJS默认会输出一个特殊的Base64字符串,它实际上包含了“Salted__”前缀和盐值(salt),用于派生密钥和IV。这是一个超级重要的坑!

实操心得:CryptoJS的“Salted”格式当你不传递iv参数,或者以字符串形式直接传递keyiv时,CryptoJS默认会使用一个随机盐(salt),并通过EVP_BytesToKey函数派生实际的加密密钥和IV。其输出格式是Salted__+ 8字节盐值 + 实际密文。服务器端(如PHP、OpenSSL)通常也兼容这种格式。但在Python中模拟时,你必须重现这个密钥派生过程,或者确保前端代码像上面示例一样显式地、以WordArray形式提供keyiv,这样才能避免使用随机盐。大多数为爬虫设计的前端加密,为了保持一致性,会采用显式指定keyiv的方式。

3.3 无源码的Hook与调试

如果代码被混淆或打包,找不到清晰的函数定义怎么办?这时需要动用动态调试技术。

  1. Hook CryptoJS:在Console中注入代码,重写CryptoJS.TripleDES.encrypt方法,将其赋值给一个临时变量,并在调用时打印出参数和结果。

    var originalEncrypt = CryptoJS.TripleDES.encrypt; CryptoJS.TripleDES.encrypt = function(message, key, cfg) { console.log("[Hook] 3DES Encrypt Called!"); console.log("Message:", message); console.log("Key:", key); console.log("Key (hex):", CryptoJS.enc.Hex.stringify(key)); console.log("Cfg:", cfg); var result = originalEncrypt.apply(this, arguments); console.log("Ciphertext (Base64):", result.toString()); console.log("Ciphertext (Hex):", CryptoJS.enc.Hex.stringify(result.ciphertext)); return result; };

    执行搜索操作,加密函数的详细信息就会在控制台打印出来,密钥和IV一览无余。

  2. 下断点调试:在Network面板中找到加密参数生成的请求,右键选择“Replay XHR”或“Copy as fetch”。在Initiator调用栈中,一步步向上追溯,找到触发加密的JavaScript代码行,打上断点,然后重新触发请求,单步跟踪变量的值。

4. Python模拟实现:从逆向结果到可运行代码

拿到密钥、IV、模式、填充方式后,下一步就是用Python复现加密过程。这里强烈推荐使用pycryptodome库,它功能完整且文档清晰。

4.1 环境准备与基础加密

首先安装库:pip install pycryptodome

假设我们逆向出的参数如下:

  • 密钥(Key):b'123456781234567812345678'(24字节)
  • 初始化向量(IV):b'01234567'(8字节)
  • 模式:CBC
  • 填充:PKCS5/PKCS7

基础加密代码如下:

from Crypto.Cipher import DES3 from Crypto.Util.Padding import pad import base64 def encrypt_3des_cbc(plain_text, key, iv): """ 使用3DES CBC模式加密文本 :param plain_text: 明文字符串 :param key: 字节串,长度必须为16或24 :param iv: 字节串,长度必须为8 :return: Base64编码的密文字符串 """ # 确保明文是字节串 plain_bytes = plain_text.encode('utf-8') # 使用PKCS7填充(与PKCS5在8字节块下相同) padded_bytes = pad(plain_bytes, DES3.block_size) # 创建加密器,使用CBC模式 cipher = DES3.new(key, DES3.MODE_CBC, iv) # 执行加密 cipher_bytes = cipher.encrypt(padded_bytes) # 将密文转换为Base64,便于网络传输 cipher_b64 = base64.b64encode(cipher_bytes).decode('utf-8') return cipher_b64 # 使用示例 key = b'123456781234567812345678' # 24字节密钥 iv = b'01234567' # 8字节IV plaintext = '{"page":1,"keyword":"手机"}' encrypted_data = encrypt_3des_cbc(plaintext, key, iv) print(f"加密结果: {encrypted_data}")

4.2 处理CryptoJS的“Salted”格式

如果前端使用的是CryptoJS的默认行为(即没有显式传递IV,输出了带Salted__前缀的字符串),那么Python端也需要模拟其密钥派生过程。这稍微复杂一些。

from Crypto.Cipher import DES3 from Crypto.Protocol.KDF import PBKDF1 from Crypto.Util.Padding import pad import base64 import hashlib def encrypt_3des_cbc_openssl_format(plain_text, passphrase, salt=None): """ 模拟CryptoJS/OpenSSL的3DES加密,输出'Salted__'格式 :param plain_text: 明文字符串 :param passphrase: 密码字符串(前端传入的key字符串) :param salt: 盐值(8字节),如果为None则随机生成 :return: OpenSSL兼容的Base64密文字符串 """ if salt is None: salt = b'\x00' * 8 # 简单示例,实际应使用os.urandom(8)生成随机盐 # 1. 使用PBKDF1派生密钥和IV (与CryptoJS的EVP_BytesToKey兼容) # 注意:CryptoJS使用MD5,迭代次数为1 key_iv = PBKDF1(passphrase.encode('utf-8'), salt, 16, count=1, hashAlgo=hashlib.md5) # 先取16字节 key_iv += PBKDF1(passphrase.encode('utf-8') + key_iv[:8], salt, 8, count=1, hashAlgo=hashlib.md5) # 再取8字节 # 派生出的前24字节作为3DES密钥,后8字节作为IV key = key_iv[:24] iv = key_iv[24:32] # 2. 加密明文 plain_bytes = plain_text.encode('utf-8') padded_bytes = pad(plain_bytes, DES3.block_size) cipher = DES3.new(key, DES3.MODE_CBC, iv) cipher_bytes = cipher.encrypt(padded_bytes) # 3. 组合成OpenSSL格式: 'Salted__' + salt + ciphertext openssl_bytes = b'Salted__' + salt + cipher_bytes # 4. Base64编码 cipher_b64 = base64.b64encode(openssl_bytes).decode('utf-8') return cipher_b64 # 使用示例(模拟前端只传了一个密码字符串的情况) passphrase = "mySecretPass" plaintext = 'search query' encrypted = encrypt_3des_cbc_openssl_format(plaintext, passphrase) print(f"OpenSSL格式加密结果: {encrypted}")

注意事项:密钥与IV的生成一致性这是爬虫模拟加密时失败率最高的环节。前端JavaScript(尤其是CryptoJS)和后端(可能是Java、PHP、Python)在将字符串密码转换为实际加密密钥和IV时,使用的算法(如PBKDF、EVP_BytesToKey)、哈希函数(MD5、SHA1)、迭代次数、盐值处理方式必须完全一致。在逆向时,一定要通过Hook或调试,确认前端最终传入加密函数的keyiv确切字节值,而不仅仅是字符串。最稳妥的方法是在Python中直接使用这些字节值,而不是尝试重现其派生过程。

4.3 解密验证:确保双向可逆

为了百分百确认我们的Python加密代码是正确的,一个最好的验证方法是:用Python加密,然后用同一段Python代码解密,看是否能还原明文。更进一步,可以尝试用前端JavaScript代码解密Python生成的密文,或者用Python解密前端生成的密文。

Python解密函数:

from Crypto.Cipher import DES3 from Crypto.Util.Padding import unpad import base64 def decrypt_3des_cbc(cipher_b64, key, iv): """ 使用3DES CBC模式解密 :param cipher_b64: Base64编码的密文字符串 :param key: 字节串,长度必须为16或24 :param iv: 字节串,长度必须为8 :return: 明文字符串 """ # Base64解码 cipher_bytes = base64.b64decode(cipher_b64) # 创建解密器 cipher = DES3.new(key, DES3.MODE_CBC, iv) # 执行解密 padded_bytes = cipher.decrypt(cipher_bytes) # 去除填充 plain_bytes = unpad(padded_bytes, DES3.block_size) return plain_bytes.decode('utf-8') # 验证测试 key = b'123456781234567812345678' iv = b'01234567' plaintext = "这是一条测试数据" encrypted = encrypt_3des_cbc(plaintext, key, iv) print(f"加密后: {encrypted}") decrypted = decrypt_3des_cbc(encrypted, key, iv) print(f"解密后: {decrypted}") assert plaintext == decrypted, "加解密验证失败!" print("加解密验证成功!")

5. 爬虫集成与参数构造实战

现在,我们已经有了可靠的3DES加密函数,接下来就是把它集成到爬虫中,动态构造请求参数。

5.1 场景模拟:加密搜索参数

假设目标网站/api/search接口接收一个JSON格式的请求体,其中encryptedParams字段是经过3DES加密的搜索条件。

未加密的原始参数可能是:

{ "page": 1, "size": 20, "keyword": "笔记本电脑", "sort": "price_asc" }

爬虫构造流程:

import requests import json from your_3des_module import encrypt_3des_cbc # 导入之前写好的加密函数 def build_encrypted_payload(key, iv, search_keyword, page=1): """ 构造加密的请求参数 """ # 1. 构造原始参数字典 raw_params = { "page": page, "size": 20, "keyword": search_keyword, "sort": "price_asc" } # 2. 将字典转换为JSON字符串 # 注意:必须确保JSON序列化的格式(如空格、键顺序)与前端完全一致。 # 有时前端会使用 JSON.stringify(params, null, 0) 来压缩格式。 params_json = json.dumps(raw_params, separators=(',', ':'), ensure_ascii=False) # separators=(',', ':') 移除空格,这是常见优化,需根据实际情况调整 # 3. 使用3DES加密JSON字符串 encrypted_b64 = encrypt_3des_cbc(params_json, key, iv) # 4. 构造最终请求体 payload = { "encryptedParams": encrypted_b64, # 可能还有其他固定参数,如时间戳、版本号等 "timestamp": int(time.time() * 1000), "appVersion": "1.0.0" } return payload def search_products(keyword): key = b'从逆向中获取的24字节密钥' iv = b'从逆向中获取的8字节IV' url = "https://target-site.com/api/search" # 构造加密载荷 payload = build_encrypted_payload(key, iv, keyword) # 添加必要的请求头(User-Agent, Content-Type等) headers = { "User-Agent": "Mozilla/5.0...", "Content-Type": "application/json;charset=UTF-8", # 可能还需要Cookie或特定的Auth Header } # 发送请求 response = requests.post(url, json=payload, headers=headers) if response.status_code == 200: # 响应数据可能也是加密的,需要同样的方式解密 resp_data = response.json() encrypted_result = resp_data.get('data') if encrypted_result: # 假设响应也是3DES加密,使用相同的key/iv解密(注意:响应可能使用不同的密钥) decrypted_result = decrypt_3des_cbc(encrypted_result, key, iv) return json.loads(decrypted_result) else: print(f"请求失败: {response.status_code}") return None

5.2 处理动态密钥与IV

更复杂的情况是,密钥和IV不是硬编码的,而是每次请求动态生成的(例如,从服务器接口获取一个临时token,或由前端根据时间戳计算得出)。这就需要你进一步逆向密钥的生成逻辑。

  1. 从接口获取:在页面加载或初始化时,网站可能会通过一个公开的接口返回一个加密用的keyseed。你需要先请求这个接口,提取出密钥材料。
  2. 本地计算:密钥可能由一些固定字符串和可变参数(如用户ID、时间戳、随机数)拼接后,再经过MD5、SHA1等哈希函数计算得出。你需要通过Hook或静态分析,找到这个计算函数并用Python复现。
    # 示例:密钥由固定字符串加时间戳取整后MD5生成 import hashlib import time def generate_dynamic_key(): fixed_str = "static_salt_" timestamp = int(time.time()) // 60 # 每分钟变化一次 raw_key = fixed_str + str(timestamp) # 取MD5的前24位字符作为密钥(注意,这里是字符,不是字节。前端可能再做一次转换) md5_hash = hashlib.md5(raw_key.encode()).hexdigest()[:24] # 前端可能将hex字符串转换为字节,或者直接作为字符串使用 # 需要根据实际情况确认,这里假设前端用 CryptoJS.enc.Utf8.parse(md5_hash) return md5_hash.encode('utf-8') # 或 bytes.fromhex(md5_hash)

6. 常见问题排查与调试技巧实录

即使按照步骤操作,模拟加密也可能失败。下面是我踩过无数坑后总结的排查清单。

6.1 密文比对:从十六进制入手

最直接的验证方法是比对密文的十六进制(Hex)值,而不是Base64字符串。Base64编码可能因为换行符、填充字符(=)导致视觉差异,但Hex是纯数据表示。

操作步骤:

  1. 在浏览器中,通过Hook或控制台,获取前端加密结果的密文字节数组的Hex字符串。在CryptoJS中,可以通过CryptoJS.enc.Hex.stringify(ciphertext.ciphertext)获得。
  2. 在你的Python代码中,加密后不要立即Base64编码,先输出密文字节数组的Hex表示:cipher_bytes.hex()
  3. 对比两个Hex字符串。如果完全一致,恭喜你,加密过程完全正确。如果不一致,问题一定出在密钥、IV、明文、模式或填充上。

6.2 分步隔离定位法

当Hex值不一致时,采用“分步隔离”法定位问题:

  1. 检查明文输入是否一致?

    • 问题:前端加密的明文到底是什么?是一个JSON字符串吗?字符串末尾有换行符吗?键的顺序是否固定?
    • 排查:Hook前端加密函数,打印出message.toString(CryptoJS.enc.Utf8)message的Hex值。在Python中,同样打印plain_bytes.hex()进行比对。
  2. 检查密钥和IV的字节值是否一致?

    • 问题:这是最常见的错误来源。你以为的密钥字符串,在前端可能被CryptoJS.enc.Utf8.parseCryptoJS.enc.Hex.parse处理过。
    • 排查:Hook前端,打印keyiv的Hex值(CryptoJS.enc.Hex.stringify(key))。确保Python中使用的keyiv字节串与之一模一样。
  3. 检查加密模式和填充方式?

    • 问题:默认模式是不是ECB?填充是不是NoPadding?
    • 排查:仔细查看前端加密函数的配置对象。如果没指定,CryptoJS的默认模式是CBC,默认填充是PKCS7。但其他库可能不同。
  4. 检查是否有额外的编码或处理?

    • 问题:加密后,前端是否对结果进行了二次处理?比如先转Hex,再Base64,或者进行了URL编码。
    • 排查:追踪加密函数返回的结果,直到它被放入请求参数前的每一步转换。

6.3 典型错误案例表

错误现象可能原因解决方案
Python加密结果长度与前端不同填充方式不一致。前端可能是ZeroPaddingNoPadding确认前端使用的padding方案。Python中pad函数可指定padmode,如pad(..., style='pkcs7')或使用zero_pad
服务器返回“解密失败”或“参数错误”密钥或IV字节值错误。最常见的是字符串到字节的转换方式不对。使用Hex比对法,确保密钥/IV字节级一致。检查前端是否有parse过程(Utf8/Hex/Base64)。
只有第一次请求成功,后续失败IV是固定的,但服务器可能要求每次加密使用随机IV。或者,CBC模式需要前一个密文块作为下一个的IV,在流式加密中需要链式传递。确认IV的生成逻辑。如果是随机IV,需要将IV本身(明文)和密文一起发送给服务器(通常拼接在密文前)。
加密结果Hex值前16位相同,后面不同明文、密钥、IV、模式都正确,但明文的前8个字节相同。这恰好说明是CBC模式,因为第一个块加密结果相同,后续块因IV链式影响而不同。检查从第二个明文块开始的内容是否不同。这通常是正常的,重点确认第一个块的加密结果是否完全匹配。
完全无法找到加密函数代码被重度混淆,加密可能被封装在WebAssembly中,或者使用了不常见的库。尝试搜索特征字节或字符串。在Network面板观察请求发起前的最后一个栈帧。使用“XHR/fetch Breakpoints”功能在发送请求时断住,反向追溯。

6.4 高级技巧:使用Node.js作为“桥梁”验证

当Python模拟异常复杂,尤其是遇到CryptoJS那种独特的密钥派生逻辑时,一个取巧但极其有效的方法是:用Node.js直接执行前端的加密函数

  1. 将你找到的包含完整加密逻辑的JavaScript代码片段保存为一个.js文件。
  2. 在Node.js环境中,使用node -e执行这段代码,或者写一个简单的脚本,导出加密函数。
  3. 在Python爬虫中,使用subprocess模块调用这个Node.js脚本,传入明文参数,获取加密结果。
  4. 这样能100%保证加密结果与浏览器端一致。虽然增加了系统依赖,但在验证阶段和解决疑难杂症时非常有用。稳定后,可以再根据Node.js的执行过程,慢慢推导出纯Python的实现。

逆向3DES加密,就像在解一个结构已知但参数未知的锁。核心在于细致入微的观察精准的比对。每一次成功的逆向,都是对前端代码逻辑的一次深刻理解。记住,Hex比对是你的终极武器,它能将模糊的问题转化为精确的数据差异,帮你快速定位到那个出错的字节。