小程序动态密钥逆向分析与自动化加解密实战
1. 项目概述当小程序加密遇上动态密钥做小程序逆向或者接口自动化测试的朋友估计都遇到过这个让人头疼的场景你费了九牛二虎之力用抓包工具把小程序的前后端交互流程摸清楚了正准备写个脚本自动化调用接口结果发现每次请求的加密密钥都是动态变化的。今天抓到的包明天脚本就跑不通了因为密钥换了。这种“动态密钥”机制就像给接口上了一把每天都会换锁芯的锁让你的自动化脚本生命周期短得可怜。这个项目要解决的正是这个核心痛点。它的目标不是去破解某种高深的加密算法——很多小程序用的就是常见的AES、RSA或者国密SM4。真正的难点在于如何找到那个生成动态密钥的“源头”并把它“固定”下来。这里的“固定”不是指硬编码一个静态密钥而是指通过逆向分析定位到小程序前端JavaScript代码中生成密钥的逻辑然后通过技术手段比如补环境、Hook关键函数让这个逻辑在每次运行时都输出一个我们预设的、不变的密钥或者直接拦截并替换掉密钥生成的结果。一旦密钥固定了后续的加解密过程就变成了一个纯粹的、可预测的数学计算。我们就可以用Python、Java等后端语言完全复现前端的加密逻辑生成与服务端预期格式完全一致的密文从而实现稳定、可靠的接口自动化调用、数据抓取或安全测试。这个过程我们称之为“CE固定小程序动态密钥后自动化加解密”。CE在这里可以理解为“逆向工程”的泛指也暗指了需要像侦探一样去“勘察”代码执行环境。这不仅仅是写一个加解密函数那么简单。它涉及对小程序运行机制的理解、对JavaScript代码的静态分析与动态调试、对加密库调用链的追踪以及最终在脱离小程序环境下的算法复现。接下来我就结合自己的实操经验把这个过程掰开揉碎了讲清楚。2. 核心思路与逆向工程准备2.1 逆向目标与工具链选型我们的终极目标是获得一个稳定的、可复用的加密/解密函数。为此我们需要拆解出几个子目标定位加密入口找到小程序前端发起网络请求通常是wx.request时对请求体或参数进行加密的代码位置。追踪密钥生成从加密函数向上回溯找到生成加密密钥如AES的key和iv的逻辑。分析加密算法确定使用的是哪种算法AES-CBC/PKCS7PaddingRSA-OAEPSM4-ECB以及具体的参数密钥长度、模式、填充方式。实现环境固定通过补全小程序运行环境或Hook关键函数使密钥生成逻辑返回固定值。剥离与移植将处理后的加密逻辑从复杂的JavaScript代码中剥离出来翻译或封装成独立的、可在Node.js或Python中运行的模块。工欲善其事必先利其器。以下是我实战中常用的工具链抓包与调试工具Charles / Fiddler / mitmproxy用于拦截和查看HTTPS流量这是分析的起点。必须配置好手机代理和CA证书以解密HTTPS。对于微信小程序可能需要开启“调试模式”或使用旧版微信才能顺利抓包。浏览器开发者工具对于PC端微信打开的小程序直接使用Chrome DevTools进行调试是最高效的。可以断点、查看调用栈、监控网络请求。逆向分析工具微信开发者工具虽然主要用于开发但其模拟器和调试器对于理解小程序基础结构很有帮助。可以尝试导入小程序包.wxapkg进行粗略的静态分析。反编译工具如wxappUnpacker用于解包小程序的.wxapkg文件得到前端JavaScript、WXML、WXSS等源码。这是获取可读代码的关键一步。但需要注意代码通常是经过压缩和混淆的。代码编辑器VSCode用于查看和搜索反编译后的大量JS文件。全局搜索关键词如encrypt、decode、CryptoJS、sm4、rsa、key、iv是常用手段。动态调试与Hook工具Node.js我们的主战场。很多加密库如crypto-js本身就是Node.js模块或其变体。我们最终要在Node环境下复现逻辑。vm2或isolated-vm安全的沙箱环境用于执行不可信或经过我们修改的小程序JS代码片段观察其行为。puppeteer/playwright无头浏览器自动化工具。当加密逻辑极度依赖浏览器环境如使用了window、document对象时可以考虑用其加载小程序页面并注入调试脚本进行更复杂的动态Hook。自定义Hook脚本这是核心技巧。通过重写Function.prototype.constructor、Object.defineProperty或利用Proxy来拦截关键函数的调用和返回值。注意所有逆向分析行为应仅用于学习、安全测试在授权范围内或对自己开发的小程序进行调试。请严格遵守相关法律法规和服务条款勿用于非法目的。2.2 从抓包到定位加密点一切从抓包开始。用Charles拦截小程序的网络请求你会看到两种可能请求体/参数明文那么加密可能发生在更早的阶段或者是对特定字段如密码的加密。需要关注请求URL或Header中是否携带了加密相关的参数如encryptKey。请求体为乱码密文这是最常见的情况。通常Content-Type可能是application/octet-stream或text/plain内容是一串Base64编码的字符串或直接是二进制数据。关键操作对比多次相同操作的请求比如两次登录。如果请求体完全不同且长度可能变化那基本可以确定使用了带随机IV的块加密如AES-CBC如果某一部分固定不变另一部分变化可能是混合加密如RSA加密AES密钥AES加密数据。定位到加密的请求后下一步就是在反编译的JS代码中找到负责这部分工作的函数。在微信开发者工具或浏览器中给wx.request方法打条件断点是一个高效的方法。断点触发后查看调用栈Call Stack一步步向上回溯就能找到业务代码中调用加密函数的地方。3. 密钥生成逻辑分析与固定策略3.1 常见的密钥生成方式找到加密函数后顺藤摸瓜找到密钥来源。小程序动态密钥的生成方式五花八门但常见的有以下几类前端随机生成每次启动小程序或每次会话前端用Math.random()、Crypto.getRandomValues()生成一个随机数作为密钥或IV然后通过非对称加密RSA传给服务端。服务端解密后得到该次会话的对称密钥。服务端下发首次请求一个初始化接口服务端返回一个本次会话有效的密钥可能用RSA公钥加密过。后续请求使用这个密钥。基于固定种子生成密钥看似随机但实际上是由一个固定的“种子”如设备ID、用户Token、当前时间戳取整通过一个确定的算法如哈希函数计算出来的。只要种子不变密钥就不变。混合模式以上几种方式的组合。例如IV随机Key由服务端下发。我们的“固定”策略需要针对不同的生成方式。3.2 “固定”密钥的实战技巧“固定”的本质是控制输入或拦截输出让密钥生成函数返回我们期望的值。场景一密钥来自Math.random()或Date.now()这是最简单的。我们可以在Node.js环境中或者在注入的Hook脚本里重写这些全局函数。// 在Node.js执行小程序加密代码前先污染全局环境 const fixedRandomValue 0.123456789; const fixedTimestamp 1672502400000; // 2023-01-01 00:00:00 global.Math.random () fixedRandomValue; global.Date.now () fixedTimestamp; // 然后加载或require小程序的加密模块这样原代码中所有依赖随机数和当前时间戳生成密钥的逻辑都会输出固定值。场景二密钥由某个特定函数生成假设我们通过调用栈找到了一个函数generateSessionKey()。我们可以用Proxy或直接重写这个函数。const originalModule require(./decrypted-wxapp-module); const fixedKey abcdef1234567890; // 方法1直接替换 originalModule.generateSessionKey function() { console.log([Hook] generateSessionKey被调用返回固定密钥); return fixedKey; // 或者返回一个结构固定的对象 {key: fixedKey, iv: fixedIv} }; // 方法2使用Proxy进行更精细的控制 const handler { apply: function(target, thisArg, argumentsList) { console.log([Proxy] ${target.name}被调用参数:, argumentsList); // 可以在这里根据参数决定返回什么但最终我们返回固定值 return fixedKey; } }; originalModule.generateSessionKey new Proxy(originalModule.generateSessionKey, handler);场景三密钥来自网络请求如果密钥是通过第一个初始化请求从服务端获取的那么“固定”的策略就变成了“模拟”或“重放”这个请求。先用抓包工具抓到一次完整的初始化请求和响应。在你的自动化脚本中首先模拟这个初始化请求。你可以选择真实请求每次都真的去请求这个接口使用最新的密钥。但这没有“固定”。固定响应分析该接口响应是否真的每次不同。如果相同或者其变化不影响最终密钥例如只是用固定RSA私钥加密了同一个密钥那么你可以写死这个响应。更彻底的做法是Hook网络请求库如wx.request当发现请求的是初始化接口URL时直接返回我们事先保存好的固定响应数据绕过真实网络。实操心得在Hook时一定要小心地维护函数原本的输入输出类型。如果原函数返回一个Promise你的Hook函数也要返回Promise如果返回一个对象你也要返回结构类似的对象。否则可能会导致后续代码报错增加调试复杂度。4. 加密算法识别与复现4.1 算法识别线索固定了密钥来源接下来要搞清楚它用什么加密。查看加密函数附近的代码引入的模块查找require或import语句。常见的有crypto-js非常流行的前端加密库通常用于AES、DES、MD5、SHA等。sm-crypto国密算法库支持SM2、SM3、SM4。jsencrypt/node-rsa用于RSA加密。bcryptjs用于密码哈希。甚至可能直接使用微信小程序自带的wx.getRandomValues和 SubtleCrypto APIWeb Crypto API。函数调用名如CryptoJS.AES.encrypt(message, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 })。这直接指明了算法、模式和填充。常量与配置查找mode、padding、iv、keySize等配置对象。在线搜索将代码中的关键函数名或常量字符串如0x67452301可能是MD5的初始常量复制到搜索引擎很可能找到对应的算法库。4.2 在Node.js/Python中复现加密识别出算法后就需要用后端语言实现相同的加密。核心原则是确保每一个参数都完全一致。密钥和IV的处理前端JS中的密钥可能是一个字符串、一个Hex字符串、一个Base64字符串或一个WordArray对象。你需要精确地将固定下来的密钥转换成后端加密库所要求的格式通常是字节数组或Base64字符串。注意字符串的编码UTF-8ASCII。算法参数模式AES的CBC、ECB、CTR等。填充PKCS7、PKCS5、ZeroPadding等。这里是最容易出错的地方JS的CryptoJS.pad.Pkcs7和 Pythoncryptography库的默认填充可能表现一致但最好显式指定。输出格式加密结果是直接输出二进制字节数组还是转换成Hex字符串或Base64字符串必须和前端保持一致。示例复现一个CryptoJS风格的AES-CBC加密假设前端代码是var encrypted CryptoJS.AES.encrypt(plaintext, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString(); // 默认输出是OpenSSL格式的Base64字符串包含Salt等信息 // 或者 .ciphertext.toString(CryptoJS.enc.Base64) 输出纯密文的Base64在Python中使用pycryptodome库复现from Crypto.Cipher import AES from Crypto.Util.Padding import pad import base64 def encrypt_aes_cbc_pkcs7(plaintext: str, key: bytes, iv: bytes) - str: # 确保明文是bytes并使用PKCS7填充到块大小的整数倍 plaintext_bytes plaintext.encode(utf-8) padded_data pad(plaintext_bytes, AES.block_size) # 创建加密器 cipher AES.new(key, AES.MODE_CBC, iv) # 加密 ciphertext_bytes cipher.encrypt(padded_data) # 转换为Base64字符串纯密文无Salt ciphertext_b64 base64.b64encode(ciphertext_bytes).decode(ascii) return ciphertext_b64 # 你的固定key和iv需要从Hex或Base64转换成bytes key bytes.fromhex(你的16/24/32字节密钥Hex字符串) iv bytes.fromhex(你的16字节IV Hex字符串) plaintext {username:test,password:123456} result encrypt_aes_cbc_pkcs7(plaintext, key, iv) print(result)在Node.js中复现使用crypto原生模块const crypto require(crypto); function encryptAesCbcPkcs7(plaintext, key, iv) { const cipher crypto.createCipheriv(aes-128-cbc, key, iv); // 默认使用PKCS7填充在Node.js中叫PKCS5但效果相同 let encrypted cipher.update(plaintext, utf8, base64); encrypted cipher.final(base64); return encrypted; } const key Buffer.from(你的16字节密钥Hex字符串, hex); const iv Buffer.from(你的16字节IV Hex字符串, hex); const plaintext {username:test,password:123456}; const result encryptAesCbcPkcs7(plaintext, key, iv); console.log(result);关键验证将你的固定密钥、IV和明文分别用你Hook后的小程序环境输出固定密钥和你的后端复现代码进行加密对比两者的输出结果Base64字符串是否完全一致。一个字符都不能差。这是检验复现是否成功的唯一标准。5. 构建自动化加解密流程当加解密函数在后端被成功复现后就可以将其集成到自动化流程中了。这里以Python为例构建一个完整的自动化请求流程。5.1 流程设计一个健壮的自动化加解密流程通常包含以下模块密钥管理模块负责存储和使用我们“固定”下来的密钥、IV等敏感信息。可以从配置文件、环境变量或安全的存储中读取。加密模块封装复现好的加密函数接收明文数据通常是字典将其转换为JSON字符串再加密成密文。请求构造模块将密文放入请求体并设置正确的HTTP头如Content-Type: application/json或application/octet-stream。请求发送与重试模块使用requests或httpx库发送请求并处理网络异常、超时实现重试逻辑。响应解密模块如果服务端返回的数据也是加密的则需要用对应的解密函数进行解密。会话管理模块处理登录态如Cookie、Token并在后续请求中自动携带。5.2 代码结构示例# config.py import os from dotenv import load_dotenv load_dotenv() class Config: # 从环境变量或配置文件中读取固定密钥 AES_KEY bytes.fromhex(os.getenv(FIXED_AES_KEY, e43ee68382dc550fbd1d329486febdd4)) AES_IV bytes.fromhex(os.getenv(FIXED_AES_IV, 1234567890abcdef1234567890abcdef)) API_BASE_URL os.getenv(API_BASE_URL, https://api.target-miniapp.com) INIT_ENDPOINT os.getenv(INIT_ENDPOINT, /api/v1/init) # 如果有初始化接口 LOGIN_ENDPOINT os.getenv(LOGIN_ENDPOINT, /api/v1/login) # crypto.py import base64 import json from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from config import Config class MiniAppCrypto: def __init__(self, keyNone, ivNone): self.key key or Config.AES_KEY self.iv iv or Config.AES_IV if len(self.key) not in [16, 24, 32]: raise ValueError(密钥长度必须为16(AES-128), 24(AES-192)或32(AES-256)字节) if len(self.iv) ! 16: raise ValueError(IV长度必须为16字节) def encrypt_data(self, data_dict: dict) - str: 加密字典数据为Base64字符串 # 1. 字典转JSON字符串 json_str json.dumps(data_dict, ensure_asciiFalse, separators(,, :)) # 2. 加密 cipher AES.new(self.key, AES.MODE_CBC, self.iv) padded_data pad(json_str.encode(utf-8), AES.block_size) ciphertext cipher.encrypt(padded_data) # 3. 输出Base64 return base64.b64encode(ciphertext).decode(ascii) def decrypt_data(self, encrypted_b64: str) - dict: 解密Base64字符串为字典 ciphertext base64.b64decode(encrypted_b64) cipher AES.new(self.key, AES.MODE_CBC, self.iv) padded_plaintext cipher.decrypt(ciphertext) json_str unpad(padded_plaintext, AES.block_size).decode(utf-8) return json.loads(json_str) # api_client.py import time import logging from typing import Optional, Dict, Any import httpx from crypto import MiniAppCrypto from config import Config logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) class MiniAppClient: def __init__(self): self.crypto MiniAppCrypto() self.client httpx.Client(base_urlConfig.API_BASE_URL, timeout30.0) self.session_token: Optional[str] None self._init_session() def _init_session(self): 模拟初始化请求如果需要 # 如果密钥是服务端下发的这里需要调用初始化接口 # init_data {deviceId: fixed_device_id} # encrypted_init_data self.crypto.encrypt_data(init_data) # resp self._post(Config.INIT_ENDPOINT, encrypted_init_data, need_encryptFalse) # 解析resp获取并更新self.crypto的key/iv pass def _post(self, endpoint: str, data: Any, need_encryptTrue, headersNone): 发送POST请求的核心方法 url endpoint final_headers { User-Agent: Mozilla/5.0 (模拟小程序客户端), Content-Type: application/json, # 根据实际情况调整 } if headers: final_headers.update(headers) if self.session_token: final_headers[Authorization] fBearer {self.session_token} request_data data if need_encrypt and isinstance(data, dict): request_data self.crypto.encrypt_data(data) # 如果服务端接收的是纯密文可能需要改Content-Type # final_headers[Content-Type] text/plain logger.debug(f请求URL: {url}) logger.debug(f请求头: {final_headers}) logger.debug(f请求体: {request_data[:200]}...) # 日志截断 for attempt in range(3): # 简单重试3次 try: resp self.client.post(url, jsonrequest_data if not need_encrypt else None, contentrequest_data if need_encrypt else None, headersfinal_headers) resp.raise_for_status() # 处理响应如果响应也是加密的 resp_content resp.text # 假设响应是JSON且有一个encryptedData字段 # resp_json resp.json() # if encryptedData in resp_json: # decrypted self.crypto.decrypt_data(resp_json[encryptedData]) # return decrypted return resp_content # 或 resp.json() except httpx.RequestError as e: logger.warning(f请求失败第{attempt1}次重试。错误: {e}) time.sleep(2 ** attempt) # 指数退避 except httpx.HTTPStatusError as e: logger.error(fHTTP错误: {e.response.status_code} - {e.response.text}) break # 服务器错误可能不需要重试 raise Exception(请求多次失败) def login(self, username, password): 登录示例 plain_data { username: username, password: password, timestamp: int(time.time() * 1000) # 常见的时间戳参数 } result self._post(Config.LOGIN_ENDPOINT, plain_data, need_encryptTrue) # 解析result获取token # self.session_token result[token] return result def get_user_info(self): 获取用户信息示例 if not self.session_token: raise Exception(请先登录) # 可能不需要加密或者加密空对象/特定参数 result self._post(/api/v1/userinfo, {}, need_encryptFalse) return result # main.py 使用示例 if __name__ __main__: client MiniAppClient() try: login_resp client.login(test_user, your_encrypted_password) print(f登录结果: {login_resp}) user_info client.get_user_info() print(f用户信息: {user_info}) except Exception as e: logger.error(f自动化流程执行失败: {e})这个框架提供了一个清晰的起点。你需要根据目标小程序的具体情况调整加密/解密的触发条件、请求头、响应解析逻辑等。6. 常见问题与排查技巧实录即使按照上述流程操作在实际复现过程中也一定会遇到各种问题。下面是我踩过的一些坑和对应的排查思路。6.1 加密结果不一致这是最普遍的问题。你的代码运行了没报错但生成的密文和小程序生成的就是不一样。排查清单密钥和IV是否100%一致检查来源确认你Hook后获取的密钥是否真的是加密函数使用的那个。有可能存在多个密钥用于不同场景。检查格式在Hook函数里把密钥用console.log(Buffer.from(key).toString(hex))打印出来和你后端代码里使用的Hex字符串进行逐字节对比。注意大小写。检查编码如果密钥是字符串前端JS是UTF-8还是Latin1编码用Buffer.from(key, latin1).toString(hex)试试。算法模式和填充是否一致模式AES-CBC和AES-ECB的结果天差地别。确认前端代码中的mode对象。填充这是重灾区。除了PKCS7还有ZeroPadding、NoPadding等。对于CryptoJS默认是PKCS7。但在其他库或自定义实现中可能不同。如果密文长度不是16的整数倍可能用了NoPadding。可以尝试在加密前手动对明文进行PKCS7填充然后使用NoPadding模式加密看结果是否匹配。明文处理是否一致字符串化前端在加密前是把对象JSON.stringify了还是直接拼接成key1value1key2value2格式字符串化的顺序、空格、缩进都要一致。建议使用JSON.stringify(obj, null, 0)或指定separators来消除格式差异。编码明文字符串在加密前转换成什么编码的字节通常是UTF-8。但在某些老旧系统或特定场景下可能是GBK。输出格式是否一致CryptoJS的.toString()和.ciphertext.toString(CryptoJS.enc.Base64)输出不同。前者是“OpenSSL格式”包含了盐Salt等信息后者是纯密文的Base64。你需要和前端的输出方式保持一致。调试技巧分步输出在Hook的加密函数里把传入的明文、密钥、IV、以及加密后的中间结果如CryptoJS的CipherParams对象都详细打印出来。在线工具辅助使用在线的加密工具如一些提供AES加密的网站用你获取到的密钥、IV和明文进行加密看结果是否和前端一致。这可以帮助你快速判断是密钥问题还是算法实现问题。单元测试为你的后端加密函数写一个单元测试输入从Hook中捕获的真实数据明文、密钥、IV断言输出必须等于Hook捕获的真实密文。6.2 Hook失败或代码无法执行环境依赖缺失小程序代码可能依赖了某些浏览器特有的对象如window、document、navigator或微信JSAPI如wx.getSystemInfoSync。你需要在Node.js环境中“补”上这些对象。// 在加载小程序代码前补环境 global.window global; global.document { createElement: () ({}), // ... 其他必要属性 }; global.navigator { userAgent: Mozilla/5.0 ... }; // 微信API可以模拟成空函数或返回固定值 global.wx { getSystemInfoSync: () ({ model: iPhone, system: iOS 15.0 }), // ... };代码混淆干扰混淆后的变量名、函数名可能很短且逻辑被拆散。这需要耐心。可以尝试用JS反混淆工具如de4js进行初步处理但效果不一定好。更多时候需要依靠调用栈和关键字符串如API URL、加密算法常量来定位。加密逻辑被动态加载或混淆有时加密函数是动态eval出来的或者被极度混淆。这种情况下动态调试在浏览器或微信开发者工具中下断点比静态分析代码更有效。在运行时查看内存中的函数定义。6.3 自动化请求被风控当你成功复现加密并开始高频调用接口时可能会遇到封IP、返回验证码、要求滑动验证等情况。请求频率加入随机延迟模拟真人操作节奏。time.sleep(random.uniform(1, 3))。请求头尽可能模拟真实小程序的请求头。除了User-Agent还可能有Referer、X-Requested-With等。用抓包工具仔细对比。会话管理妥善处理登录后的Cookie或Token并在后续请求中携带。注意Token可能有有效期需要实现刷新逻辑。设备指纹有些风控会检查设备指纹。你的脚本IP是固定的可能被识别。考虑使用代理IP池。但请注意使用代理必须合法合规。行为模式不要只调用一个接口。模拟完整的用户操作流比如先访问首页再点击某个按钮最后提交数据。6.4 密钥“固定”失效这是最棘手的情况。你花大力气固定了密钥过了几天脚本又不能用了。原因一小程序更新。开发者更新了代码加密逻辑或密钥生成方式变了。你需要重新抓包、逆向、固定。这是常态需要有心理准备。原因二密钥有有效期。服务端下发的密钥可能只在短时间内有效如30分钟。你的“固定”只是固定了某一次的值它过期后自然失效。这时你的自动化脚本需要包含“重新获取密钥”的逻辑。要么定期模拟初始化请求要么在收到“密钥过期”的错误码后重新执行密钥获取流程。原因三环境检测。小程序可能检测运行环境如果发现不在真正的微信环境里就启用另一套加密逻辑或返回假数据。这需要更高级的对抗比如更完善的环境模拟这可能变成一个无底洞。面对这种情况一个务实的建议是将逆向得到的加密逻辑封装成一个独立的服务或函数。当小程序更新导致失效时你只需要重新分析并更新这个加密函数而不用改动整个自动化业务流程。同时为你的自动化脚本设计良好的监控和告警机制一旦连续失败立即通知你进行检查。这个过程就像一场持久的“攻防战”没有一劳永逸的解决方案。真正的价值在于通过这样深入的逆向分析你不仅能实现自动化更能深刻理解该小程序的通信安全设计和潜在薄弱点无论是对于开发更安全的应用还是进行更深入的安全评估都是一笔宝贵的经验财富。

相关新闻