1. 项目概述与核心价值
最近在分析一些主流电商平台的H5端数据交互逻辑,得物(Dewu)的接口签名机制引起了我的兴趣。对于从事数据采集、风控策略研究或者单纯想了解现代Web应用安全机制的朋友来说,逆向分析一个健壮的sign签名参数生成流程,是一次绝佳的实战演练。这不仅仅是“破解”一个参数那么简单,它涉及到对前端JavaScript混淆、加密算法识别、关键逻辑定位以及完整调用链还原的综合性考验。通过这个过程,我们能深刻理解客户端如何与服务器进行“对暗号”,以及服务端如何验证请求的合法性与时效性,这对于构建或加固我们自己的API安全体系有着直接的参考价值。
简单来说,这个项目就是搞清楚:当你在得物H5页面(比如浏览商品、查看订单)进行操作时,浏览器发出的每一个网络请求中,那个看似随机的sign参数到底是怎么算出来的。我们将从最基础的抓包开始,一步步拆解JavaScript代码,定位核心加密函数,最终用Python等语言复现整个签名算法。无论你是前端开发者想深入理解安全实践,还是对爬虫逆向感兴趣,这篇文章都将提供一份详尽的“地图”。
2. 前期环境准备与抓包分析
工欲善其事,必先利其器。逆向分析的第一步永远是观察。我们需要一套能清晰捕获和调试H5网络请求的环境。
2.1 工具链搭建
对于H5页面的分析,桌面浏览器自带的开发者工具是最直接高效的。我首选Chrome或Edge(基于Chromium内核),因为它们对JavaScript的调试支持非常完善。
- 浏览器:确保使用最新版本的Chrome/Edge。
- 开发者工具:快捷键
F12或Ctrl+Shift+I打开。重点关注两个面板:- Network(网络):用于捕获所有HTTP/HTTPS请求。
- Sources(源代码)或Debugger(调试器):用于查看、搜索和调试JavaScript文件。
- 抓包辅助工具:虽然浏览器自带的工具足够强大,但像Charles或Fiddler这类代理工具在需要观察手机端H5流量、或者进行请求重发测试时非常有用。你可以将手机代理到电脑,捕获其访问得物H5时的请求。不过对于初期的静态分析,纯浏览器环境即可。
2.2 关键请求捕获与特征观察
打开得物H5页面(例如其官网的移动端),进行任意能触发API请求的操作,比如搜索商品、查看商品详情、刷新个人主页等。
- 打开Network面板:确保勾选了“Preserve log”(保留日志)并清空现有记录。
- 触发请求:在页面进行交互。很快,你会看到一系列请求出现。我们需要从中筛选出携带
sign参数的接口。通常,这类接口的路径(Path)会包含api关键字,且请求方法为POST或GET。 - 定位目标请求:在请求列表中,仔细查看每个请求的
Headers(请求头)和Payload(请求负载,对于POST请求)。我们的目标是找到请求URL或请求体(body)中包含一个名为sign的参数的请求。这个sign值通常是一长串看似随机的字母数字组合(可能是Hex字符串或Base64编码)。 - 记录关键信息:找到目标请求后,需要完整记录以下信息,这些是后续逆向的“输入”:
- 请求URL:完整的接口地址。
- 请求方法:GET或POST。
- 请求参数:包括URL查询字符串(Query String)和请求体(Form Data或JSON)。特别注意除了
sign之外的其他参数,如timestamp(时间戳)、nonce(随机数)、token(用户令牌)等。sign的生成极大概率与这些参数有关。 - 请求头:有些签名算法也会将特定的Header(如
User-Agent、自定义的App-Version等)纳入计算。
注意:首次分析时,建议对一个简单的、不依赖复杂登录态的接口(如商品列表查询)进行抓包。过于复杂的接口可能涉及多层令牌,会增加初始逆向的难度。
3. 核心逆向流程:定位签名函数
捕获到包含sign的请求后,真正的挑战开始——在前端混淆压缩后的代码海洋里,找到生成这个sign的那几行关键逻辑。
3.1 搜索与断点设置策略
现代Web应用通常会将JavaScript代码打包、压缩、混淆,变量名可能变成a、b、c,函数名也难以辨认。但我们有办法。
全局搜索:在开发者工具的
Sources面板,按Ctrl+Shift+F进行全局文件搜索。尝试搜索以下关键词:sign:直接搜索参数名。encrypt、encode、signature:搜索可能的功能函数名。MD5、SHA、HmacSHA、AES:搜索常见的加密算法关键词。即使代码被混淆,这些算法库的调用特征或常量字符串可能依然存在。- 你抓包到的
sign值的一部分:有时可以直接搜索签名结果本身,如果它是硬编码在某个配置里(虽然可能性小),或者用于对比的代码中。
XHR/Fetch断点:这是一个非常高效的方法。在
Sources面板,找到“XHR/Fetch Breakpoints”区域,点击“+”号。由于我们不知道具体的请求URL全路径,可以尝试添加包含部分接口路径关键字的断点,例如*api*或*/v1/*。当浏览器发起匹配的请求时,代码执行会自动暂停,此时调用栈(Call Stack)会显示出发起这个网络请求的JavaScript代码位置。通过调用栈一步步向上回溯,就很有可能找到组装参数和计算sign的函数。Hook关键函数:在Console面板中,可以重写(Hook)一些原生方法,来拦截参数。例如,在页面加载前注入以下代码:
(function() { var originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function(body) { console.trace('XHR send called, body:', body); // 可以在这里检查body是否包含目标URL或sign return originalSend.apply(this, arguments); }; var originalFetch = window.fetch; window.fetch = function() { console.trace('Fetch called, args:', arguments); return originalFetch.apply(this, arguments); }; })();这能帮我们定位到发起请求的精确时刻和上下文。
3.2 调用栈分析与逻辑追踪
通过上述任何一种方法,我们最终目的是让代码在计算sign附近暂停下来。
- 理解调用栈:当断点触发后,右侧的
Call Stack面板显示了从当前断点位置一路回溯到最初入口的函数调用链。通常,最顶上是浏览器内置函数,越往下越接近业务代码。 - 逐层回溯:从调用栈的底部(通常是你的断点处)开始,逐个点击上层的函数。每点击一个,代码面板就会跳转到对应的位置。你需要仔细阅读每一层函数的代码,寻找参数被处理、尤其是被传递给某个加密函数的痕迹。
- 寻找加密函数:在回溯过程中,关注以下几种模式的代码:
crypto.subtle.digest或CryptoJS.MD5(...)等明显的加密库调用。- 将多个参数拼接成一个字符串的操作,例如
var s = a + "&" + b + "&" + c;。 - 调用一个名字可疑的函数,如
function e(t) { ... return n; },而其返回值被赋值给了sign字段。
一旦你疑似找到了计算sign的函数,就在该函数入口处打上断点,然后重新触发请求。观察函数的输入参数(入参)是什么,它的输出结果是否就是网络请求中的sign值。通过多次触发不同参数的请求,验证这个函数的输入输出关系是否稳定。
4. 算法解析与参数构造逻辑
假设我们已经成功定位到了核心的签名函数,命名为generateSign(params)。接下来要像解谜一样,弄清楚它内部的运作机制。
4.1 参数排序与拼接规则
绝大多数API签名都是为了验证请求的完整性和唯一性。一个常见的模式是:
- 筛选有效参数:并非所有发送的参数都参与签名。通常排除
sign本身,有时也排除file等二进制字段。函数内部会有一个参数白名单或黑名单逻辑。 - 参数排序:为了确保服务端和客户端以同样的顺序计算,需要对参与签名的参数名按**字典序(ASCII码顺序)**进行排序。这是非常关键且常见的一步。在JavaScript中,通常使用
Object.keys(params).sort()来实现。 - 键值对拼接:将排序后的参数,按
key=value的格式用特定的连接符(如&或|)拼接成一个长字符串。例如:a=1&b=2&c=3。 - 添加盐值(Salt)或密钥:为了增加安全性,拼接后的字符串前后可能会附加上一个只有客户端和服务端知道的固定字符串,称为“盐值”或“密钥”。这可能是硬编码在JS里的,也可能是从服务器动态获取的。拼接后可能形成
secretKey + queryString + secretKey的形式。
4.2 加密算法识别与还原
拼接好的字符串需要经过加密或哈希运算才能生成最终的sign。
识别算法:
- MD5:输出32位十六进制字符串。在代码中可能搜索到
MD5、md5或0x67452301等魔数。 - SHA-1:输出40位十六进制字符串。
- SHA-256:输出64位十六进制字符串。目前更常见。
- HMAC:基于密钥的哈希运算,如
HMAC-SHA256。代码中可能出现HmacSHA256的调用。 - AES:对称加密,输出通常是Base64编码的字符串。相对少见用于简单的参数签名,更多用于加密请求体。
在调试器中,你可以观察核心函数调用的返回值类型和长度,或者搜索相关算法库(如
CryptoJS)的典型初始化代码来辅助判断。- MD5:输出32位十六进制字符串。在代码中可能搜索到
还原计算过程:在调试模式下,你可以把参与拼接的最终字符串复制出来,然后在你自己的环境中(比如Node.js或Python)用猜测的算法和盐值尝试计算,看结果是否与请求中的
sign匹配。这是一个试错的过程,但一旦匹配成功,算法就确定了。
4.3 时间戳与随机数的处理
timestamp(时间戳)和nonce(随机数)是防重放攻击的常用手段,它们几乎一定会参与签名计算。
timestamp:确保请求在有效时间窗口内(如服务器时间前后5分钟)。客户端通常使用Date.now()或Math.floor(new Date().getTime() / 1000)生成。nonce:一个一次性随机字符串,确保同一签名短时间内不能重复使用。可能由Math.random().toString(36).substr(2)或UUID生成。
在逆向时,要确认这两个参数是如何生成的,以及它们是否被包含在待签名字符串中。有时服务端会拒绝timestamp偏差过大或nonce已使用过的请求。
5. 使用Python复现签名算法
当我们完全弄清楚了签名算法的每一步后,就可以用Python(或其他语言)来复现它,实现脱离浏览器环境的自动化请求。
5.1 环境依赖安装
首先确保你的Python环境安装了必要的加密库。最常用的是hashlib(内置)和hmac(内置),对于更复杂的可能需要pycryptodome。
pip install pycryptodome # 如果需要AES等算法5.2 算法复现代码示例
假设我们分析得出的得物H5某接口签名算法如下:
- 获取所有请求参数(排除
sign和file),包括timestamp和nonce。 - 将参数按键名ASCII码升序排序。
- 用
&连接键值对,形如key1=value1&key2=value2。 - 在字符串末尾拼接一个固定密钥
secret_key = "dewusecret2023"。 - 对拼接后的字符串计算MD5哈希值,并转为小写十六进制字符串。
那么Python复现代码可能如下:
import hashlib import time import uuid def generate_dewu_sign(params, secret_key="dewusecret2023"): """ 生成得物H5接口签名 :param params: dict, 请求参数字典 :param secret_key: str, 密钥 :return: str, 签名sign """ # 1. 移除sign参数本身(如果存在),并过滤掉值为None或空字符串的参数(根据实际情况调整) filtered_params = {k: v for k, v in params.items() if v is not None and k != 'sign'} # 2. 按键名ASCII码升序排序 sorted_keys = sorted(filtered_params.keys()) # 3. 拼接键值对 query_string_parts = [] for key in sorted_keys: # 确保所有值为字符串,列表或字典可能需要特殊处理(如JSON序列化) value = str(filtered_params[key]) query_string_parts.append(f"{key}={value}") query_string = "&".join(query_string_parts) # 4. 拼接密钥 string_to_sign = query_string + secret_key # 5. 计算MD5并返回小写hex m = hashlib.md5() m.update(string_to_sign.encode('utf-8')) sign = m.hexdigest().lower() return sign # 模拟请求参数 params = { "page": "1", "size": "20", "timestamp": str(int(time.time() * 1000)), # 毫秒时间戳 "nonce": str(uuid.uuid4()).replace('-', ''), "keyword": "球鞋" } # 计算签名 signature = generate_dewu_sign(params) params['sign'] = signature # 将签名加入请求参数 print("生成的签名:", signature) print("完整请求参数:", params)这段代码定义了一个通用的签名函数。你需要根据实际逆向结果,调整其中的过滤规则、排序方式、连接符、密钥拼接位置和加密算法。
5.3 验证与调试
将你的Python代码计算出的sign,与通过浏览器抓包获取的真实请求中的sign进行对比。
- 确保参数完全一致:包括所有键值对,甚至空格和编码(URL编码与否)。有时前端会对值进行
encodeURIComponent处理,Python中需要使用urllib.parse.quote。 - 注意数据类型:数字
1和字符串"1"经过str()后可能没区别,但布尔值true在前端可能是字符串"true",而在Python中是True,需要统一。 - 时间戳精度:确认前端用的是秒级还是毫秒级时间戳。
- 密钥是否正确:密钥可能是硬编码,也可能是从其他接口动态获取。如果静态密钥不对,需要检查是否有获取密钥的先行接口。
实操心得:在复现过程中,最棘手的往往是参数的预处理。有些参数值可能是嵌套的JSON对象,前端会将其
JSON.stringify后再进行签名。这时,你的Python代码也需要用json.dumps生成完全相同的字符串(包括空格和键序)。建议在JavaScript调试器中,在签名函数入口处打印出即将被加密的原始字符串,然后在你Python代码的对应步骤也打印出来,进行逐字符比对,这是最有效的调试方法。
6. 动态密钥与代码混淆的应对策略
简单的静态签名算法容易被逆向和固定,因此稍复杂的应用会引入动态元素。
6.1 动态密钥的获取与更新
你可能发现,硬编码的secret_key并不起作用。签名算法可能使用了动态密钥:
- 登录后下发:用户登录成功后,服务器可能在返回的数据中包含一个
sessionKey或token,此后的签名计算需要使用这个动态密钥,而非固定值。 - 定期刷新:通过一个特定的“获取密钥”接口,使用之前的密钥或独立令牌来获取一个新的临时密钥,用于后续一段时间内的请求签名。
应对方法:你需要完整地走一遍应用的主要流程(如登录),并监控所有接口的响应,寻找可能包含密钥的字段。然后修改你的签名函数,使其能够接收并应用这个动态密钥。
6.2 应对高级JavaScript混淆
如果核心代码被Webpack等工具打包并经过obfuscator等工具深度混淆,函数名和变量名会变得毫无意义,逻辑也可能被控制流扁平化等技巧打乱。
- 关注常量与字符串:加密算法常量、API路径、固定的密钥字符串可能未被完全混淆。搜索这些字符串依然是突破口。
- 调试执行流:即使代码难读,在调试器中一步步执行(F10单步跳过,F11单步进入)仍然是可行的。观察变量的变化,理解数据流向。
- Hook通用函数:如果混淆严重,可以尝试Hook更底层的函数,如
String.prototype.concat、Array.prototype.join或Object.keys,因为签名拼接过程必然会调用它们。 - 使用AST还原工具:对于有经验的开发者,可以尝试使用
Babel等工具对混淆代码进行解析和简化,但这需要较高的JavaScript和编译原理知识。
注意事项:逆向工程应仅用于学习、安全研究和授权测试。未经授权对他人服务进行大规模、恶意的数据抓取,可能违反网站的服务条款,甚至相关法律法规。务必遵守
robots.txt协议,控制请求频率,避免对目标服务器造成负担。
7. 完整请求构建与测试
算法复现成功后,最后一步是构建一个完整的、可成功获得数据的HTTP请求。
7.1 组装请求参数
除了业务参数和计算出的sign,通常还需要添加一些必要的请求头,以模拟真实的浏览器环境:
User-Agent: 模拟移动端浏览器,例如Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1Content-Type: 根据接口要求,通常是application/x-www-form-urlencoded或application/json。Referer: 请求来源页,有时会进行校验。Cookie: 如果需要维持登录态,则需要携带有效的Cookie。这通常需要通过模拟登录流程先获取。
7.2 使用Requests库发送请求
以下是一个使用Pythonrequests库发送带签名请求的完整示例:
import requests import json import time import uuid from generate_dewu_sign import generate_dewu_sign # 导入我们之前写的签名函数 def make_dewu_api_request(api_url, business_params, secret_key, extra_headers=None): """ 构造并发送得物API请求 """ # 1. 准备基础参数 base_params = { 'timestamp': str(int(time.time() * 1000)), 'nonce': str(uuid.uuid4()).replace('-', ''), # 可能还有其他固定参数,如'appVersion', 'platform'等,需根据实际情况添加 'appVersion': '6.0.0', 'platform': 'h5' } # 2. 合并业务参数 all_params = {**base_params, **business_params} # 3. 生成签名 sign = generate_dewu_sign(all_params, secret_key) all_params['sign'] = sign # 4. 准备请求头 headers = { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Referer': 'https://m.dewu.com/', # 如果有Cookie,在此添加 # 'Cookie': 'your_cookie_here' } if extra_headers: headers.update(extra_headers) # 5. 发送请求 (假设是POST表单请求) # 注意:根据接口实际要求,可能是GET(参数在URL)或POST JSON print(f"请求URL: {api_url}") print(f"请求参数: {all_params}") response = requests.post(api_url, data=all_params, headers=headers, timeout=10) # 6. 处理响应 print(f"状态码: {response.status_code}") if response.status_code == 200: try: result = response.json() print("响应JSON:", json.dumps(result, indent=2, ensure_ascii=False)) return result except json.JSONDecodeError: print("响应文本:", response.text) return response.text else: print(f"请求失败: {response.status_code}") print(response.text) return None # 使用示例 if __name__ == "__main__": # 替换为实际值 target_url = "https://app.dewu.com/api/v1/goods/list" # 示例接口 my_secret_key = "your_actual_secret_key" # 逆向得到的密钥 my_business_params = { "page": "1", "size": "20", "keyword": "AJ1" } result = make_dewu_api_request(target_url, my_business_params, my_secret_key)运行这个脚本,如果一切正确,你应该能收到和浏览器访问时间相同的JSON格式数据。如果失败,请仔细检查控制台输出的请求参数和响应信息,与浏览器抓包的数据进行一一比对。
8. 常见问题排查与解决思路
在实际操作中,你几乎一定会遇到各种问题。这里记录一些典型的坑和解决方向。
8.1 签名验证失败
这是最普遍的问题,表现为服务器返回“签名错误”、“非法请求”等。
- 问题:Python计算的
sign与浏览器发出的sign不一致。 - 排查:
- 字符串比对:在JavaScript签名函数入口处,用
console.log打印出待加密的原始字符串。在Python的generate_dewu_sign函数中,在计算MD5前也打印出string_to_sign。将两者进行逐字符比对(包括不可见字符、空格、引号)。这是最有效的方法。 - 参数遗漏:检查是否所有参与签名的参数都已包含?
timestamp、nonce的格式和值是否完全一致? - 编码问题:前端是否对参数值进行了URL编码?Python中是否需要使用
urllib.parse.quote?注意,有时键名也需要编码。 - 排序规则:确认排序规则是ASCII升序。对于包含数字、字母、下划线的键名,Python的
sorted默认排序与JavaScript的Array.sort()默认排序(将元素转换为字符串,比较Unicode码点)在绝大多数情况下一致,但最好验证。 - 密钥错误:密钥是否正确?是静态的还是动态的?是否需要先调用其他接口获取?
- 算法错误:确认加密算法是MD5、SHA256还是HMAC?输出是hex还是base64?是否需要转换为大写或小写?
- 字符串比对:在JavaScript签名函数入口处,用
8.2 请求返回403/404或其他非业务错误
- 问题:请求根本不到达业务逻辑层。
- 排查:
- 请求头缺失:检查
User-Agent、Referer、Origin、Cookie等头部是否被服务器校验。特别是User-Agent,有些服务器会拦截非浏览器或非指定客户端的请求。 - IP限制或风控:频繁请求或来自数据中心IP的请求可能被风控系统拦截。尝试降低请求频率,或使用更接近真实用户环境的IP。
- 接口路径或方法错误:确认URL完全正确,并且使用了正确的HTTP方法(GET/POST/PUT等)。
- HTTPS证书问题:在极少数情况下,自签证书或代理可能导致问题。
requests库默认验证证书,可通过verify=False参数关闭(仅用于测试,生产环境不安全)。
- 请求头缺失:检查
8.3 登录态维持问题
- 问题:需要登录才能访问的接口,使用代码请求时返回“未登录”。
- 排查:
- Cookie失效:通过模拟登录获取的Cookie可能有过期时间。需要实现Cookie的持久化存储和更新逻辑。
- Token机制:现代应用更多使用Token(如JWT)。登录接口返回一个
access_token,需要在后续请求的Authorization头部或特定参数中携带。你需要解析登录响应,提取并正确使用这个token。 - 签名依赖登录信息:签名算法本身可能依赖用户ID或token,这意味着不同用户的签名密钥或参数可能不同。
8.4 代码混淆导致逻辑难以追踪
- 问题:核心代码被严重混淆,无法直接阅读。
- 策略:
- 动态调试:不要试图静态阅读所有代码。在疑似入口打上断点,通过“单步执行”(F11)和“监视变量”(Watch)功能,跟踪数据的流动。混淆不会改变程序的执行逻辑。
- Hook关键点:如之前所述,Hook
JSON.stringify、Object.keys、Array.prototype.join等原生函数,可以快速定位参数被组装的位置。 - 搜索特征值:即使变量名混淆,加密算法产生的常量(如MD5的初始化向量)或固定的盐值字符串,可能依然以明文或简单变换的形式存在。搜索这些特征值。
逆向分析是一个需要耐心和细致观察的过程。每一个成功的签名验证,都是对前端安全机制和自身技术能力的一次深刻理解。保持好奇心,多动手测试,善用调试工具,你就能解开大多数签名算法的奥秘。记住,我们的目的是学习和研究其设计思路,从而更好地设计或评估自身系统的安全性。