1. 项目概述:从“黑盒”到“白盒”的逆向工程之旅
最近在分析一些电商平台的数据接口时,得物小程序成为了一个典型的研究案例。它的接口安全机制,尤其是对请求参数sign和data的加密,以及对响应体的解密,构成了一个完整的“黑盒”通信流程。对于数据分析师、爬虫工程师或是安全研究人员而言,理解并还原这套算法,意味着能够以程序化的方式与平台进行“合规”的数据交互,或者深入理解其安全设计逻辑。这不仅仅是破解几个参数那么简单,它涉及到对小程序运行机制、前端JavaScript代码保护、以及后端加密逻辑的完整逆向分析链条。整个过程就像在解一个设计精巧的谜题,每一步都需要耐心、细致的观察和逻辑推理。
本文将带你完整走一遍得物小程序(以5.19版本为例)的sign签名生成、data参数加密以及响应体解密的算法还原过程。我们会使用主流的逆向分析工具,如Charles/Fiddler抓包、Chrome开发者工具调试,以及Python进行算法复现。目标读者是具有一定网络协议基础和JavaScript知识的开发者或安全爱好者,通过本文,你将不仅能掌握针对得物小程序的特定方法,更能获得一套通用的、应对类似加密场景的逆向分析思路和实战技巧。
2. 逆向分析环境搭建与初步抓包
工欲善其事,必先利其器。在开始逆向算法之前,一个稳定、可控的分析环境至关重要。对于小程序的分析,我们通常需要在真机上进行,因为小程序的运行环境与浏览器有所不同,其网络请求可能受到更多限制。
2.1 核心工具链准备
首先,你需要准备以下工具:
- 抓包工具:Charles或Fiddler。我个人更倾向于Charles,它的界面更友好,对HTTPS流量的解密支持也更成熟。确保安装好CA证书,并配置好代理(通常为手机和电脑在同一局域网,手机Wi-Fi设置代理指向电脑IP和Charles监听端口,如8888)。
- 调试工具:PC端微信开发者工具或手机端调试。对于小程序,最直接的方式是在PC微信中打开小程序,然后利用微信开发者工具(需开启调试模式)进行动态调试。另一种更深入的方式是使用已Root的Android手机或越狱的iOS设备,配合
xposed、frida等框架进行Hook,但这门槛较高。我们优先采用PC端调试方案。 - 逆向分析工具:
- Chrome/Edge开发者工具:用于静态分析美化后的JavaScript代码,设置断点进行动态调试。
- Python环境:安装
requests,execjs(用于执行JavaScript代码),Crypto(或cryptography) 等库,用于算法验证和复现。 - 文本编辑器/IDE:如VSCode,用于查看和编辑代码。
2.2 关键配置与避坑指南
配置抓包环境时,有几个坑点需要特别注意:
注意:微信小程序和部分App默认会校验系统证书。仅安装Charles的CA证书到用户目录可能不够,需要将证书安装到系统信任区。在Android 7.0以上,这通常需要Root后手动移动证书文件。对于iOS,描述文件安装后需要在“设置-通用-关于本机-证书信任设置”中完全信任Charles证书。如果遇到“网络错误”或“证书验证失败”,大概率是证书信任问题。
启动Charles,确保Proxy -> SSL Proxying Settings中已经添加了需要解密的域名(如*.duapp.com,*.dewu.com)。然后,在PC微信中打开得物小程序,进行一些常规操作,比如浏览商品列表、查看商品详情。此时,Charles的会话列表(Sequence)中应该会出现大量的HTTPS请求。
2.3 首次抓包与请求特征观察
过滤出目标域名(例如api.dewu.com)的请求,仔细观察其中一个典型的接口请求,比如获取商品详情的接口。你会发现,其POST请求的载荷(Payload)并非明文,而是呈现为加密后的形态。通常,关键信息会体现在**请求头(Headers)和请求体(Body)**中。
一个典型的得物加密请求可能具有以下特征:
- Headers中可能包含一个自定义的签名头,如
x-sign或sign。 - Query Parameters中可能也包含一个
sign参数。 - Request Body不再是常见的JSON或表单格式,而是一个经过加密的字符串,可能以
data=xxx的形式提交,或者整个Body就是一个密文块。 - Response Body同样不是直接的JSON,而是一串看似乱码的加密数据,需要客户端解密后才能得到真正的JSON结构。
我们的首要任务,就是定位生成这些加密参数(尤其是sign和加密的data)的JavaScript代码在哪里,以及它是如何工作的。
3. 定位加密入口与核心逻辑分析
小程序的前端代码虽然经过压缩和混淆,但其核心逻辑必然存在于发送网络请求的模块中。微信小程序的网络请求主要使用wx.request方法。
3.1 搜索与断点定位
在微信开发者工具中打开得物小程序的调试模式,切换到“源代码(Sources)”面板。由于代码被压缩,我们可以使用全局搜索(Ctrl+Shift+F)来寻找关键线索。
搜索关键词策略:
- 直接搜索
sign。这可能会找到很多处,包括变量名、字符串等。可以尝试搜索"sign"(带引号的字符串)或sign:(作为对象属性)。 - 搜索网络请求相关的关键词,如
wx.request,uni.request(如果用了uni-app框架),或者JSON.stringify,encrypt,md5,sha256,hmac,AES,CBC等加密相关词汇。 - 搜索可能存在的自定义函数名,比如
getSign,encryptData,encodeParams等。这需要结合抓包看到的请求参数名进行猜测。
通过搜索,你很可能找到一个被高度混淆的JavaScript文件,里面的变量名都是a,b,c,o,n等单字母。不要慌,这是常态。我们的目标不是读懂每一行,而是找到加密发生的“入口函数”。
一个有效的方法是:在搜索到疑似加密函数的地方(比如一个函数里调用了CryptoJS库的方法,或者有很长的字符串运算),在该函数入口处打上断点。然后,在小程序前端触发一个网络请求(比如点击刷新商品列表)。如果断点被触发,那么恭喜你,找到了关键位置。
3.2 调用栈分析与参数追踪
当断点命中时,查看右侧的“调用栈(Call Stack)”。调用栈会显示当前函数是被谁调用的,一层层回溯,你就能找到整个加密调用的链条。通常,这个链条的顶端会与wx.request的调用相关。
在调试器中,你可以查看当前作用域(Scope)中所有变量的值。重点关注:
- 传入这个加密函数的参数是什么?它很可能是一个包含原始请求参数(如商品ID、页码、时间戳等)的JavaScript对象。
- 这个函数内部对参数做了什么处理?一步步单步执行(F10),观察变量的变化。注意看是否有以下操作:
- 添加固定参数:如
timestamp,nonce,appVersion,platform等。 - 参数排序:按照字母顺序对对象的键进行排序,这是生成签名的常见步骤。
- 字符串拼接:将排序后的键值对拼接成特定格式的字符串,如
key1=value1&key2=value2。 - 拼接密钥:在字符串的首尾或中间拼接一个固定的密钥(
secret或salt)。 - 哈希计算:将拼接后的字符串进行MD5、SHA256等哈希运算,得到
sign。 - 加密操作:对完整的参数对象或
data字段进行AES、RSA等加密。
- 添加固定参数:如
实操心得:在单步调试时,善用控制台(Console)。你可以将任何感兴趣的变量拖到控制台查看其完整内容,或者直接输入表达式进行计算。例如,当你看到拼接后的字符串s时,可以在控制台输入s来确认其内容,甚至可以手动计算md5(s)来验证是否与生成的sign一致。
3.3 得物sign算法还原实例分析
根据对历史版本和当前抓包的分析,得物的sign生成算法虽然可能随版本更新,但核心思路有迹可循。一个常见的模式是:
- 收集参数:收集所有需要参与签名的参数,包括URL查询参数(Query String)和请求体(Body)参数。对于
POST请求,Body参数可能是加密前的原始JSON对象。 - 规范化处理:
- 排除某些不参与签名的字段(如
sign本身)。 - 对所有参数名进行字典序排序。
- 将每个参数和值转换为字符串,并进行URL编码(或特定编码)。
- 排除某些不参与签名的字段(如
- 构造待签名字符串:将规范化后的参数以
key=value的形式用&连接起来,形成字符串str_to_sign。 - 拼接密钥:在
str_to_sign的末尾(或开头)拼接一个从服务器下发的、或硬编码在客户端的secret。这个secret的获取是逆向的另一个关键点,可能藏在代码的常量里,也可能通过某个初始化接口获取。 - 计算哈希:对拼接后的字符串计算MD5或SHA256,并将结果转换为小写十六进制字符串,即为最终的
sign值。
在调试器中,你需要一步步验证这个过程。找到排序、拼接、哈希计算的具体代码行。将关键逻辑的JavaScript代码片段提取出来。
4. data参数加密与响应体解密算法解析
sign保证了请求的完整性和不可篡改性,而data的加密则保证了请求/响应内容的机密性。得物很可能采用对称加密算法(如AES)来加密核心的业务数据。
4.1 定位加密/解密函数
在找到sign生成函数附近,通常也能找到加密函数。搜索encrypt,decrypt,AES,CBC,mode,padding等关键词。同样通过断点调试来定位。
当你在发送请求前的代码里找到加密调用时,观察:
- 加密密钥(Key)和初始化向量(IV)从哪里来?可能是固定的字符串,也可能是通过某个算法动态生成的(例如,用
sign的一部分作为Key)。 - 加密模式是什么?常见的是
AES-CBC或AES-ECB。CBC模式需要IV。 - 填充方式是什么?常见的是
PKCS7填充。 - 输出格式是什么?通常是Base64编码或十六进制字符串。
响应体的解密是加密的逆过程。你需要找到处理网络响应的地方(可能是wx.request的success回调函数里),在那里会有对返回数据进行解密的函数调用。其密钥、IV、模式、填充方式应与加密时一致。
4.2 算法还原与Python实现
一旦在调试器中理清了逻辑,就可以开始用Python还原算法了。这里以最常见的AES-CBC-PKCS7加密和MD5签名为例。
首先,安装必要的库:
pip install pycryptodome requests4.2.1 Sign签名还原示例
假设我们从逆向分析中得知,sign的生成规则是:将所有参数(除sign外)按key字典序排序,拼接成key1=value1&key2=value2的格式,末尾拼接固定字符串secret123,然后取MD5(小写)。
import hashlib import urllib.parse def generate_sign(params, secret='secret123'): """ 生成得物风格签名 :param params: dict, 请求参数字典 :param secret: str, 密钥 :return: str, 签名值 """ # 1. 移除sign参数本身(如果存在) params.pop('sign', None) # 2. 对参数键进行排序 sorted_keys = sorted(params.keys()) # 3. 构造待签名字符串 str_to_sign = '&'.join([f'{k}={params[k]}' for k in sorted_keys]) # 4. 拼接密钥 str_to_sign += secret # 5. 计算MD5并返回小写十六进制 m = hashlib.md5() m.update(str_to_sign.encode('utf-8')) return m.hexdigest() # 测试 test_params = { 'page': '1', 'size': '20', 'timestamp': '1684567890123', 'nonce': 'abc123' } signature = generate_sign(test_params) print(f"生成的sign: {signature}")4.2.2 Data加密解密还原示例
假设加密采用AES-128-CBC,密钥为16字节,IV为16字节,使用PKCS7填充。
from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad import base64 class DewuCrypto: def __init__(self, key: bytes, iv: bytes): """ 初始化加密器 :param key: 16/24/32字节的密钥 :param iv: 16字节的初始化向量 """ self.key = key self.iv = iv def encrypt_data(self, plaintext: str) -> str: """加密数据,返回Base64字符串""" cipher = AES.new(self.key, AES.MODE_CBC, self.iv) # 明文需要编码为bytes,并进行PKCS7填充 padded_data = pad(plaintext.encode('utf-8'), AES.block_size) ciphertext = cipher.encrypt(padded_data) return base64.b64encode(ciphertext).decode('utf-8') def decrypt_data(self, ciphertext_b64: str) -> str: """解密Base64编码的密文,返回明文字符串""" cipher = AES.new(self.key, AES.MODE_CBC, self.iv) ciphertext = base64.b64decode(ciphertext_b64) decrypted_padded = cipher.decrypt(ciphertext) # 去除PKCS7填充 plaintext_bytes = unpad(decrypted_padded, AES.block_size) return plaintext_bytes.decode('utf-8') # 测试 # 注意:真实的key和iv需要从逆向的代码中获取,这里是示例 key = b'thisis16bytekey!' # 16字节 iv = b'thisis16byteiv!!' # 16字节 crypto = DewuCrypto(key, iv) original_data = '{"productId": "123456", "skuId": "789"}' encrypted = crypto.encrypt_data(original_data) print(f"加密后的data: {encrypted}") decrypted = crypto.decrypt_data(encrypted) print(f"解密后的数据: {decrypted}") assert decrypted == original_data重要提示:以上
key、iv、secret以及具体的拼接规则都是示例。你必须通过逆向分析,从得物小程序的JavaScript代码中提取出真实的值和算法逻辑。这些关键信息可能以字符串常量的形式存在,也可能通过更复杂的逻辑计算得出。
5. 完整请求构建与算法验证
在还原了sign和data的算法后,下一步就是构建一个完整的、能够成功与服务器通信的请求。
5.1 请求参数组装流程
一个完整的自动化请求流程如下:
- 准备原始参数:构造业务需要的原始参数字典
raw_params。例如,{'page': 1, 'size': 20, 'keyword': '球鞋'}。 - 添加系统参数:根据逆向结果,添加必要的系统参数,如
timestamp(当前毫秒时间戳)、nonce(随机字符串)、appVersion、platform等。这些参数通常也参与签名。 - 生成签名:将包含业务和系统参数的字典,传入你的
generate_sign函数,计算出sign值。 - 加密数据:将需要加密的请求体(可能是全部参数,也可能是
raw_params)转换为JSON字符串,然后用你的encrypt_data函数进行加密,得到密文encrypted_data。 - 构建最终请求:
- Headers:设置必要的请求头,如
Content-Type: application/x-www-form-urlencoded(如果以表单形式提交),可能还包括x-sign,x-timestamp等。 - Body/Params:如果接口要求将加密数据放在
data字段,那么请求体可能就是data=encrypted_data。同时,sign和timestamp等参数可能作为查询参数(Query Params)附在URL上。
- Headers:设置必要的请求头,如
5.2 使用Python的requests库发送请求
import requests import time import json def make_dewu_request(api_url, raw_body_params, common_params): """ 模拟得物小程序请求 :param api_url: 接口地址 :param raw_body_params: 需要放在请求体并加密的业务参数dict :param common_params: 公共参数dict,如timestamp, nonce等,通常参与签名和拼接到URL :return: 解密后的响应JSON """ # 1. 合并参数(用于签名) all_params_for_sign = {**common_params, **raw_body_params} # 2. 生成签名 (使用之前逆向得到的算法和secret) sign = generate_sign(all_params_for_sign, secret='你的真实SECRET') # 3. 加密请求体 plain_body = json.dumps(raw_body_params, separators=(',', ':'), ensure_ascii=False) # 紧凑格式 encrypted_body_str = crypto.encrypt_data(plain_body) # 使用之前定义的crypto对象 # 4. 构建最终请求参数 # 假设签名和公共参数放在URL查询字符串中,加密数据放在POST表单的`data`字段 query_params = common_params.copy() query_params['sign'] = sign # 5. 准备请求数据 post_data = { 'data': encrypted_body_str } # 6. 发送请求 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...', # 模拟小程序UA 'Content-Type': 'application/x-www-form-urlencoded', } response = requests.post(api_url, params=query_params, data=post_data, headers=headers) # 7. 解密响应 if response.status_code == 200: # 假设响应也是加密的,并且结构可能是 {"code":0, "data": "加密字符串"} resp_json = response.json() encrypted_resp_data = resp_json.get('data') if encrypted_resp_data: decrypted_resp = crypto.decrypt_data(encrypted_resp_data) return json.loads(decrypted_resp) else: return resp_json # 可能某些接口不加密 else: raise Exception(f"请求失败: {response.status_code}, {response.text}") # 使用示例 common = { 'timestamp': int(time.time() * 1000), 'nonce': '随机字符串', 'appVersion': '5.19', 'platform': 'mini_program' } body_params = {'productId': '12345'} try: result = make_dewu_request('https://api.dewu.com/product/detail', body_params, common) print("请求成功,结果:", result) except Exception as e: print("请求异常:", e)6. 逆向过程中的常见问题与排查技巧
即使按照步骤操作,你也一定会遇到各种问题。下面是一些常见坑点和解决思路的实录。
6.1 抓包无数据或证书错误
- 现象:Charles看不到小程序流量。
- 排查:检查手机代理设置是否正确(IP和端口);检查电脑防火墙是否关闭或允许Charles;尝试在Charles中启用“透明代理(Proxy -> macOS Proxy/Windows Proxy)”。
- 现象:小程序提示网络错误或证书错误。
- 排查:这是最典型的证书问题。确保Charles的根证书已正确安装并被系统完全信任。对于Android高版本,可能需要使用
JustTrustMe(Xposed模块)或Frida脚本绕过证书锁定(SSL Pinning),但这需要Root环境。对于PC微信调试,可以尝试在微信开发者工具中关闭“域名校验”等安全选项(如果存在)。
- 排查:这是最典型的证书问题。确保Charles的根证书已正确安装并被系统完全信任。对于Android高版本,可能需要使用
6.2 无法定位加密代码或断点不触发
- 现象:搜索关键词找不到,或者找到的函数断点从不触发。
- 排查:
- 代码动态加载:小程序的代码可能不是一次性加载的。尝试在Network面板查看JS文件的加载,或者在Sources面板的Page标签下查找所有加载的脚本文件。
- 代码高度混淆:尝试使用
js-beautify等工具对混淆的代码进行格式化,虽然变量名无法恢复,但代码结构会清晰很多,便于搜索function定义和return语句。 - Hook通用方法:如果直接定位困难,可以尝试Hook网络请求的底层函数。在Chrome开发者工具的Console中,可以重写
XMLHttpRequest.prototype.send或fetch方法,在其中打印参数并打上debugger语句。这能帮你捕获到所有请求,并查看其调用栈。
- 排查:
6.3 算法还原后签名/加密仍然无效
- 现象:用自己的Python代码生成的
sign和服务端校验不通过,或者加密后的data服务器无法解密。- 排查:这是最考验耐心的环节。你需要像侦探一样对比每一个细节。
- 参数比对:在JavaScript加密函数入口处断点,记录下传入函数的所有参数的精确值(包括类型,数字还是字符串)。在你的Python代码中,确保传入
generate_sign的字典完全一致(键的顺序不影响,但值必须相同)。 - 字符串格式比对:在JavaScript中,将待签名的字符串(拼接后的结果)打印出来。在你的Python代码中,也将拼接后的字符串打印出来。进行逐字符比对,包括空格、换行、URL编码的差异(
encodeURIComponent和Python的urllib.parse.quote可能有细微差别)。 - 编码比对:确保哈希计算(MD5/SHA256)前,字符串的编码一致。JavaScript通常使用UTF-16或某种内部编码,而Python默认是UTF-8。一个稳妥的方法是在JavaScript调试器里,直接计算
CryptoJS.MD5(str).toString()的结果,与Python的hashlib.md5(str.encode(‘utf-8’)).hexdigest()对比。如果不一致,尝试在Python中使用str.encode(‘utf-16le’)或其他编码。 - 密钥/IV比对:确认AES加密使用的Key和IV的字节序列完全一致。JavaScript中可能将字符串通过
CryptoJS.enc.Utf8.parse转换成WordArray,你需要确认这个转换过程。在Python中,直接使用key.encode(‘utf-8’)得到的字节可能不同。有时密钥是Base64或Hex格式的,需要先解码。 - 加密模式与填充:再次确认AES的模式(CBC/ECB)、填充(PKCS7/ZeroPadding)、输出格式(Base64/Hex)是否完全一致。
CryptoJS库的默认模式可能与PyCryptodome有差异。
- 参数比对:在JavaScript加密函数入口处断点,记录下传入函数的所有参数的精确值(包括类型,数字还是字符串)。在你的Python代码中,确保传入
- 排查:这是最考验耐心的环节。你需要像侦探一样对比每一个细节。
6.4 版本更新导致算法失效
- 现象:之前好用的脚本突然全部失效,返回签名错误。
- 应对:这是常态。平台会定期更新加密算法或密钥。你需要重新进行抓包和逆向分析,看
sign的生成规则、secret、加密密钥等是否有变化。有时变化很小,只是增加了一个固定参数或改变了拼接顺序。
- 应对:这是常态。平台会定期更新加密算法或密钥。你需要重新进行抓包和逆向分析,看
个人经验:建立一个完整的“现场快照”非常重要。在成功逆向一个版本后,除了保存Python代码,最好也保存以下信息:
- 关键JavaScript函数的美化后代码片段。
- 一次成功请求的完整Charles抓包记录(包含请求头、请求体、响应体)。
- 调试器中关键变量(待签名字符串、密钥、IV等)的截图或文本记录。 这样,当算法更新时,你可以快速对比新旧版本的差异,极大提升排查效率。
逆向工程是一个与平台防御机制不断博弈的过程。它没有一成不变的答案,核心在于掌握通用的分析方法:抓包定位、静态搜索、动态调试、逻辑比对、代码还原。通过得物这个案例,希望你能将这套方法论内化,从而有能力去应对其他具有类似加密机制的应用或小程序。记住,耐心和细致是成功的关键,每一个字节的差异都可能导致失败。