Python实战AES-CBC解密:逆向分析某服务平台加密数据接口

1. 项目概述与核心需求解析

最近在做一个数据采集项目时,遇到了一个典型的场景:目标服务平台的关键数据,比如列表信息、详情内容,在传输过程中被AES加密了。前端页面展示是正常的,但直接抓取接口返回的是一串串看似乱码的密文。这对于需要批量、自动化获取数据的爬虫来说,就成了必须跨过去的一道坎。标题里的“难度一般”很贴切,这种AES-CBC模式、PKCS7填充的方案,在Web逆向中算是常客,只要摸清它的“套路”,解密就是水到渠成的事。这篇文章,我就结合这个实战案例,把从发现加密、分析逻辑到用Python完整实现解密的每一步拆开揉碎了讲清楚,目标是让你看完后,不仅能搞定这个案例,更能掌握一套通用的AES数据解密分析思路。

这个案例的价值在于,它非常具有代表性。现在越来越多的网站,尤其是涉及用户敏感信息或核心业务数据的服务平台,都会对接口数据进行加密,以此增加自动化爬取的难度,保护自身数据资产。作为开发者,无论是为了数据聚合、市场分析还是自动化测试,理解并突破这层加密是必备技能。整个过程不涉及任何高危操作,纯粹是基于公开的接口响应和前端JavaScript逻辑进行技术分析,核心是理解加密算法如何被应用,并用代码复现这个过程。

2. 逆向分析前的准备工作与工具链

工欲善其事,必先利其器。在开始逆向分析之前,准备好顺手的工具能事半功倍。我的核心工具组合是浏览器开发者工具和Python环境。

浏览器开发者工具(以Chrome为例):这是主战场。重点关注“网络”(Network)和“源代码”(Sources)面板。在Network面板中,勾选“Preserve log”(保留日志),然后进行页面操作(如点击查询、翻页),观察XHR或Fetch请求。找到返回加密数据的那个接口,查看其“响应”(Response)内容,确认是密文。然后,最关键的一步是查看该请求的“发起者”(Initiator)或点击请求详情中的“发起者”标签,它能一步步跳转到最终发起这个网络请求的JavaScript代码位置,这往往是加密函数所在。

Python环境:我们需要用Python来复现解密过程。主要用到两个库:requests用于模拟网络请求,pycryptodome用于AES解密。pycryptodomeCrypto库的一个维护更活跃的分支,功能强大且兼容性好。安装非常简单:pip install requests pycryptodome。如果你之前安装过老旧的pycrypto,建议先卸载以免冲突。

注意:在分析过程中,务必遵守网站的robots.txt协议,并控制请求频率,避免对目标服务器造成压力。我们的目的是学习技术原理,而非恶意爬取。

分析的第一步是数据定位。打开目标网站,按F12进入开发者工具,切换到Network面板。我通常先清空现有记录,然后触发一次数据加载动作。比如,在这个案例中,我点击了查询按钮。很快,在请求列表里出现了一个接口,其响应(Preview或Response标签页)不是熟悉的JSON结构,而是一大段像U2FsdGVkX1...或更随机的Base64字符串。这基本就是加密的迹象了。把这个接口的URL、请求头(特别是Content-TypeUser-Agent、可能存在的自定义Token)、请求参数完整地记录下来。

3. 核心加密逻辑的定位与剖析

找到加密接口只是第一步,接下来要找到加密是如何发生的。这里有个技巧:在Network面板中,右键点击那个加密数据的请求,选择“Copy” -> “Copy as cURL”。这能帮你完整复现请求,但更重要的是,你可以将它导入到Postman或直接用于Python脚本测试,确认单独调用这个接口是否真的返回密文,排除是浏览器插件或其他因素导致的干扰。

确认之后,就要深入JavaScript世界了。回到开发者工具的Sources面板。由于现代前端项目大多经过打包和混淆,直接看到的代码可能难以阅读。我们可以利用“搜索”功能。在加密接口的Initiator调用栈里,通常会看到一些包含encryptCryptoJSAESenc等关键词的非混淆文件名或函数名,这是一个很好的切入点。

如果调用栈不明显,就需要全局搜索。在Sources面板按Ctrl+Shift+F(Windows)或Cmd+Opt+F(Mac),打开全局搜索。尝试搜索关键词,如:

  • encrypt
  • AES
  • CryptoJS(一个非常常用的前端加密库)
  • 接口URL的一部分
  • 请求参数中某个固定的键名

以本案例为例,通过搜索CryptoJS,我很快定位到了关键的加密函数。代码虽然可能被压缩,但结构清晰:

function E(e, t, n) { var r = CryptoJS.enc.Utf8.parse(t) , o = CryptoJS.enc.Utf8.parse(n) , a = CryptoJS.AES.encrypt(e, r, { iv: o, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) , i = a.toString(); return i }

这段代码信息量极大。函数E接受三个参数e, t, nCryptoJS.enc.Utf8.parse是将字符串转换成CryptoJS内部WordArray的方法。所以,t被当作密钥(Key),n被当作初始化向量(IV)。加密时指定了模式为CBC,填充方式为PKCS7。这正是AES-CBC加密的核心配置。

那么,t(密钥)和n(IV)从哪里来?继续查看调用E函数的地方。我发现了两处:

usrname = E(e.username, p, i) passwd = E(md5(e.password), p, i)

这里pi分别传给了tn,也就是说,p是密钥,i是IV。而pi又是从哪来的?在代码上下文中,我看到它们似乎是硬编码的字符串常量。但根据经验,更安全的做法是动态从服务器获取。于是我在Network面板中搜索,果然发现了一个先于登录请求的、获取密钥的接口(例如/api/secret-key)。调用它,服务器返回了一个JSON,里面包含了keyiv(或类似命名的字段)。这就构成了完整的闭环:前端先请求密钥,然后用这个密钥对登录凭证进行AES-CBC加密,最后提交加密后的密文。

实操心得:不要看到硬编码的密钥就以为万事大吉,一定要在Network里确认是否有前置的密钥交换请求。很多应用会使用“一次一密”或“会话密钥”来提升安全性。同时,注意密钥和IV的编码,它们可能是十六进制字符串、Base64字符串或纯文本,需要用对应的方法解析。

4. Python复现AES-CBC解密全过程

分析清楚了前端逻辑,接下来就是用Python复现。目标很明确:模拟前端,先获取密钥和IV,然后用它们解密数据接口返回的密文。

4.1 获取动态密钥与IV

首先,我们模拟请求获取密钥的接口。

import requests import json def get_secret_key(): url = "https://目标网站.com/api/secret-key" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json, text/plain, */*", # 其他必要的头部,如Referer, Origin等,从浏览器拷贝 } # 如果接口需要特定Cookie或Token,也需要加上 # cookies = {...} # headers['X-CSRF-Token'] = '...' resp = requests.post(url, headers=headers) # 假设是POST请求 if resp.status_code == 200: key_data = resp.json() # 假设返回格式为 {"key": "a6459365fb4fec734bd8f6899055865e", "iv": "54d92ed5e010cfad"} secret_key = key_data.get('key') iv = key_data.get('iv') return secret_key, iv else: raise Exception(f"获取密钥失败: {resp.status_code}, {resp.text}") # 测试获取 secret_key, iv = get_secret_key() print(f"密钥: {secret_key}") print(f"IV: {iv}")

这一步的关键在于请求头的模拟。务必使用浏览器中抓取到的完整Headers,特别是User-AgentContent-TypeReferer以及任何看起来像令牌(如X-CSRF-Token)的字段。缺少这些,服务器可能会返回403或401错误。

4.2 实现AES-CBC解密函数

拿到密钥和IV后,就可以实现解密函数了。这里使用pycryptodome库。

from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 def decrypt_aes_cbc(ciphertext_base64, key_hex, iv_hex): """ 解密AES-CBC加密的数据。 :param ciphertext_base64: Base64编码的密文 :param key_hex: 十六进制字符串格式的密钥 :param iv_hex: 十六进制字符串格式的初始化向量 :return: 解密后的原始字符串 """ # 1. 将十六进制的密钥和IV转换为字节串 key = bytes.fromhex(key_hex) iv = bytes.fromhex(iv_hex) # 2. 将Base64密文解码为字节串 ciphertext_bytes = base64.b64decode(ciphertext_base64) # 3. 创建AES-CBC解密器 cipher = AES.new(key, AES.MODE_CBC, iv) # 4. 执行解密 decrypted_padded = cipher.decrypt(ciphertext_bytes) # 5. 移除PKCS7填充 decrypted = unpad(decrypted_padded, AES.block_size) # 6. 解码为字符串(假设原文是UTF-8) return decrypted.decode('utf-8') # 假设从数据接口获取的密文是`encrypted_data` encrypted_data_from_api = "PhcdT3t2Rpn83aXD9ZEavRYR88o53F1C51wQ0diEXrZp7zx/Dl/0twS/wEoG4rEc" # 示例密文 secret_key = "a6459365fb4fec734bd8f6899055865e" iv = "54d92ed5e010cfad" try: plaintext = decrypt_aes_cbc(encrypted_data_from_api, secret_key, iv) print(f"解密结果: {plaintext}") except Exception as e: print(f"解密失败: {e}")

这个函数是核心。它严格遵循了前端CryptoJS的配置:AES-CBC模式,PKCS7填充。注意几个关键点:

  1. 密钥和IV的格式:前端CryptoJS.enc.Utf8.parse是把UTF-8字符串转换成内部格式。但很多时候,服务器返回的密钥和IV是十六进制字符串(就像本例),所以我们需要用bytes.fromhex()来转换。如果服务器返回的是Base64或纯文本,则需要相应调整。
  2. 密文格式CryptoJS.AES.encrypt返回的密文对象,调用.toString()默认输出的是OpenSSL格式的字符串(以U2FsdGVkX1...开头),或者是经过Base64编码的纯密文。本例中是后者。所以我们用base64.b64decode解码。
  3. 填充:必须使用unpad来移除PKCS7填充,否则解密出来的末尾会有乱码。

4.3 整合:请求数据并自动解密

现在,我们将获取密钥和解密流程整合起来,形成一个完整的爬虫解密模块。

import requests import json from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 import time class ServiceDataSpider: def __init__(self): self.session = requests.Session() self.session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Content-Type": "application/json; charset=utf-8", }) self.secret_key = None self.iv = None def fetch_key(self): """获取动态密钥和IV""" key_url = "https://目标网站.com/api/secret-key" # 可能需要特定的Headers或Cookies resp = self.session.post(key_url) resp.raise_for_status() data = resp.json() self.secret_key = data['key'] self.iv = data['iv'] print(f"[INFO] 密钥已更新: key={self.secret_key[:8]}..., iv={self.iv[:8]}...") def decrypt_data(self, ciphertext_b64): """解密数据""" if not self.secret_key or not self.iv: raise ValueError("请先调用 fetch_key() 获取密钥") key_bytes = bytes.fromhex(self.secret_key) iv_bytes = bytes.fromhex(self.iv) ciphertext_bytes = base64.b64decode(ciphertext_b64) cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes) decrypted_padded = cipher.decrypt(ciphertext_bytes) decrypted = unpad(decrypted_padded, AES.block_size) return json.loads(decrypted.decode('utf-8')) # 假设解密后是JSON def get_encrypted_data(self, api_url, params=None): """请求数据接口,返回解密后的数据""" # 1. 确保有密钥 if not self.secret_key: self.fetch_key() # 2. 请求加密数据 resp = self.session.get(api_url, params=params) # 假设是GET请求 resp.raise_for_status() encrypted_response = resp.json() # 假设接口返回一个包含加密字段的JSON # 3. 提取密文并解密。密文可能在某个字段里,例如 `encrypted_data` ciphertext = encrypted_response['data'] # 根据实际响应结构调整 decrypted_obj = self.decrypt_data(ciphertext) return decrypted_obj # 使用示例 if __name__ == "__main__": spider = ServiceDataSpider() try: # 获取密钥(通常一次会话获取一次即可) spider.fetch_key() # 请求目标数据接口 data_api = "https://目标网站.com/api/some-data-list" result = spider.get_encrypted_data(data_api, params={"page": 1, "size": 20}) print(json.dumps(result, indent=2, ensure_ascii=False)) except requests.exceptions.RequestException as e: print(f"网络请求错误: {e}") except (KeyError, json.JSONDecodeError) as e: print(f"数据解析错误: {e}") except Exception as e: print(f"其他错误: {e}")

这个ServiceDataSpider类封装了完整流程。使用requests.Session()可以保持Cookies和部分Headers,模拟浏览器会话。fetch_key方法获取密钥,decrypt_data是核心解密方法,get_encrypted_data则串联起“请求-解密”的整个过程。

5. 关键细节、陷阱与调试技巧

在实际操作中,几乎不可能一帆风顺。下面分享几个我踩过坑的细节和调试技巧。

5.1 密钥与IV的编码问题

这是最容易出错的地方。前端CryptoJS.enc.Utf8.parse(key)到底做了什么?它并不是进行哈希或编码,而是将字符串(如"abcdef1234567890")按照UTF-8编码转换成字节,然后包装成CryptoJS内部的WordArray对象。如果服务器返回的密钥是16字节的十六进制字符串(32个字符),那么CryptoJS.enc.Utf8.parse会将其作为一个32字符的普通字符串处理,转换成32字节的数据,这显然不对,因为AES-128的密钥应该是16字节。

正确的做法是,如果密钥是十六进制字符串,前端应该使用CryptoJS.enc.Hex.parse(key)。但很多开发图省事,或者服务器返回的就是一个随机字符串(而非hex),他们就直接用Utf8.parse了。所以,在Python端,我们需要和前端的处理方式严格一致。

  • 如果前端用Utf8.parse:Python里用key.encode('utf-8')
  • 如果前端用Hex.parse:Python里用bytes.fromhex(key)
  • 如果前端用Base64.parse:Python里用base64.b64decode(key)

如何判断?最可靠的方法是看服务器返回的密钥值。如果是一串像a6459365fb4fec734bd8f6899055865e(32位,字符范围0-9, a-f),那很可能是Hex。如果是一串无规律的字母数字组合,长度不定,那可能就是直接用UTF-8字符串。最稳妥的方法是在JavaScript代码里打断点,查看经过parse之后,密钥变量的实际字节长度(可以通过CryptoJS.enc.Hex.stringify(r).length估算,一个字节对应两个hex字符)。AES-128密钥长度16字节(32 hex字符),AES-192是24字节(48 hex字符),AES-256是32字节(64 hex字符)。

5.2 密文的格式与处理

CryptoJS.AES.encrypt返回的是一个CipherParams对象。调用.toString()时,行为如下:

  • 默认(无参数):返回一个OpenSSL兼容的字符串,格式为Salt-ed__开头(U2FsdGVkX1...),其中包含了盐值(salt)。解密时需要用到CryptoJS.AES.decrypt并传入同样的盐值,在Python端处理会复杂一些。
  • 传入CryptoJS.enc.Base64:返回纯Base64编码的密文(无盐)。CryptoJS.AES.encrypt(..., {..., format: CryptoJS.format.Base64})或在.toString(CryptoJS.enc.Base64)

在案例代码中,a.toString()没有参数,但返回的看起来是纯Base64(没有U2FsdGVkX1前缀)。这可能是因为在加密配置中指定了format,或者CryptoJS的某个版本在特定模式下默认行为不同。我们以实际抓取到的密文为准。如果密文以U2FsdGVkX1开头,说明是OpenSSL格式,需要提取出盐值和实际密文进行解密,这个过程会更复杂。本例是纯Base64,所以直接用base64.b64decode即可。

5.3 填充模式(Padding)的确认

代码中明确写了padding: CryptoJS.pad.Pkcs7,所以我们用unpad。这是最常见的。但有些场景可能使用NoPadding(无填充),这就要求待加密的数据长度必须是16字节(AES块大小)的整数倍。如果遇到解密后尾部有规律乱码,或者解密直接报错ValueError: Padding is incorrect.,可以检查一下填充模式。在CryptoJS中,如果没有指定,默认可能就是PKCS7。

5.4 使用Python进行交互式调试

当解密失败时,不要慌。一个强大的调试方法是:在浏览器控制台(Console)中执行成功的加密操作,然后在Python中严格复现每一步的中间结果。

  1. 在浏览器Sources面板找到加密函数,在关键行打上断点。
  2. 触发加密操作,让代码停住。
  3. 在Console中,打印出此时的密钥t、IVn、明文e的原始字符串值。以及执行CryptoJS.enc.Utf8.parse(t).toString()看看它的Hex表示是什么。
  4. 执行完加密后,获取密文a.toString()的值。
  5. 切换到Python环境,用你获取到的原始字符串密钥、IV,尝试加密同样的明文,看是否能得到浏览器里一样的密文。如果加密结果一致,说明你的加密复现是正确的,那么解密逻辑也应该是正确的。如果不一致,就一步步对比中间步骤。

5.5 处理会话与密钥过期

很多网站的密钥不是永久有效的。它可能和一次登录会话绑定,或者有过期时间。如果你的爬虫运行一段时间后突然解密失败,首先应该怀疑密钥是否失效。解决方案是在解密失败时(捕获解密异常,如UnicodeDecodeError,JSONDecodeError,或者检查解密结果是否包含乱码),重新调用一次fetch_key()方法获取新的密钥,然后用新密钥重试解密操作。可以在get_encrypted_data方法中加入重试逻辑。

6. 扩展思考与方案优化

掌握了基础解密后,我们可以思考如何让爬虫更健壮、更高效。

6.1 错误处理与重试机制

网络请求和解密都可能失败。必须添加完善的错误处理。

def get_encrypted_data_with_retry(self, api_url, params=None, max_retries=3): for attempt in range(max_retries): try: return self.get_encrypted_data(api_url, params) except (requests.RequestException, ValueError, json.JSONDecodeError) as e: print(f"第{attempt+1}次尝试失败: {e}") if attempt == max_retries - 1: raise time.sleep(2 ** attempt) # 指数退避 # 如果是解密错误,可能是密钥过期,尝试更新密钥 if "decrypt" in str(e).lower() or "padding" in str(e).lower(): print("检测到解密错误,尝试更新密钥...") self.fetch_key()

6.2 性能优化:密钥缓存

如果密钥在一段时间内有效,频繁请求密钥接口会造成不必要的开销。可以添加简单的缓存逻辑。

class ServiceDataSpider: def __init__(self): # ... 其他初始化 ... self.key_expiry_time = 0 # 密钥过期时间戳 self.key_ttl = 300 # 假设密钥有效期为5分钟(300秒) def fetch_key(self, force=False): """获取密钥,如果未过期且不强制更新,则使用缓存""" current_time = time.time() if not force and self.secret_key and self.iv and current_time < self.key_expiry_time: print("[INFO] 使用缓存的密钥") return # ... 原有的请求密钥逻辑 ... self.secret_key = data['key'] self.iv = data['iv'] self.key_expiry_time = current_time + self.key_ttl print(f"[INFO] 密钥已更新并缓存,有效期至{time.ctime(self.key_expiry_time)}")

6.3 应对更复杂的加密方案

本例是相对简单的AES-CBC。你可能会遇到更复杂的方案:

  • AES-GCM:除了加密还提供认证。需要处理认证标签(Tag)。
  • RSA + AES:先用RSA加密一个随机的AES密钥,再用该AES密钥加密数据。需要处理非对称加密。
  • 自定义编码/混淆:在加密前后对数据进行额外的编码(如Hex、Base64换表、自定义算法)。

应对的思路不变:首先在JavaScript中找到完整的加密流程,理解每一步的输入输出,然后在Python中寻找对应的库(如cryptography)或自己实现编码逻辑,一步步复现。

6.4 关于代码编译与分发

文章开头提到的热词中,有关于.py文件编译成.exe的问题。这通常是为了在没有Python环境的Windows电脑上运行你的爬虫脚本。常用的工具有PyInstallercx_Freeze等。以PyInstaller为例,基本用法是:

pip install pyinstaller pyinstaller -F -w your_spider_script.py
  • -F:打包成单个exe文件。
  • -w:运行时不显示命令行窗口(对于GUI或后台脚本)。 但要注意,打包包含pycryptodome这类有C扩展的库时,可能会复杂一些,可能需要额外的钩子(hook)文件来确保所有依赖都被正确打包。最好在纯净的虚拟环境中进行打包测试。

最后,我想说的是,逆向分析就像解谜,需要耐心和细心。从抓包定位接口,到搜索关键函数,再到理解加密参数和模式,最后用代码复现,每一步都有迹可循。这个“某服务平台数据解密”的案例,提供了一个非常标准的学习范本。当你成功运行解密脚本,看到规整的JSON数据被打印出来时,那种成就感就是驱动我们不断探索的动力。希望这篇详细的拆解,能帮你打通任督二脉,下次遇到类似的加密数据时,能够从容应对。