从某电商站点参数加密入手:中级开发者的 Web 逆向实战与自动化复现
很多中级开发者第一次接触 Web 逆向,往往不是卡在“看不懂 JS”,而是卡在一个更具体的问题上:请求明明抓到了,参数也都看见了,但为什么自己一发就失效?
这类问题在电商站点尤其常见。接口并不一定完全隐藏,但它会在请求参数、请求头、时间戳、签名字段上做一层或多层加工。你如果只会“复制请求”,自动化脚本大概率活不过几轮。
这篇文章我就从一个典型场景入手:商品搜索接口存在参数加密,我们一步步完成分析、定位、复现,并最终做出一个可运行的自动化脚本。
说明:本文聚焦技术原理与调试方法,示例站点与字段做了泛化处理,请务必在合法合规、遵守目标站点协议与法律法规的前提下学习使用。
背景与问题
假设我们在某电商站点做商品搜索,浏览器中正常访问:
- 输入关键词
- 页面返回商品列表
- Network 面板能看到接口请求
- 但复制该接口到 Python 或 Postman 后,返回却是:
- 参数非法
- 签名错误
- 风控拦截
- 空数据
一个典型请求可能长这样:
POST /api/search HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Mozilla/5.0 ...
{
"q": "蓝牙耳机",
"page": 1,
"sort": "default",
"ts": 1720000000000,
"sign": "b8f5c1..."
}
表面上看,重点只有一个:sign。
但实际逆向时,常见问题有四类:
sign不是固定算法,依赖时间戳、随机数、设备指纹- 加密前的原文有字段排序、拼接规则
- 算法在混淆后的 JS 中,调用链很深
- 只复现签名还不够,Cookie、Header、Referer、UA 也参与校验
所以真正的问题不是“怎么抄一个 sign”,而是:
- 参数是怎么被组织的?
- 签名是在什么时机生成的?
- 浏览器环境对结果有没有影响?
- 如何稳定地自动化复现?
前置知识
如果你已经具备以下基础,阅读会非常顺:
- 能用 Chrome DevTools 看 Network / Sources / Debugger
- 知道 JS 基本语法、对象、闭包、异步
- 能写一点 Python 请求脚本
- 对
md5/sha256/aes/base64有基础概念
如果没有,也没关系。本文尽量不走“纯理论堆砌”,而是按实战路径来。
环境准备
本文示例使用以下环境:
- Chrome 浏览器
- Python 3.10+
- Node.js 16+
requestsexecjs或直接调用 Node 脚本- 可选:
mitmproxy、Fiddler
安装 Python 依赖:
pip install requests PyExecJS
如果你本机装了 Node,就可以直接让 Python 调 JS。
逐步验证清单
在开始之前,我建议你把这份清单记下来。很多人逆向失败,不是算法不会,而是每一步没有单独验证。
- 确认目标接口是真正的数据接口,而不是页面聚合层
- 确认请求方式、Header、Cookie 是否齐全
- 确认签名前原始参数有哪些
- 确认字段顺序是否影响签名
- 确认时间戳单位是秒还是毫秒
- 确认是否有随机盐值或 nonce
- 确认浏览器环境变量是否参与计算
- 用浏览器中的真实入参与输出做一次对拍
- 再迁移到 Node / Python 自动化
这份清单非常重要,后面你会发现,大多数坑都能归到这里。
核心原理
在电商类接口里,参数加密通常不是“真正保密”,而是为了:
- 防止接口被直接滥用
- 提高脚本调用门槛
- 增加风控判断维度
- 让请求具备时效性与完整性校验
最常见的签名模型大概是这样:
- 收集业务参数:关键词、页码、排序方式等
- 增加公共参数:时间戳、平台、版本号、设备标识
- 按约定规则排序或拼接
- 与固定密钥或动态盐值组合
- 做摘要或加密,得到
sign
比如下面这类非常常见:
sign = md5("page=1&q=蓝牙耳机&sort=default&ts=1720000000000" + secret)
或者:
sign = sha256(base64(json_stringify(sorted_params)) + nonce)
再复杂一点,可能是:
- 参数先 AES 加密
- 再对密文做摘要
- 最后附加某个前端生成的设备标识
参数加密链路图
flowchart LR
A[用户输入关键词] --> B[前端组装业务参数]
B --> C[补充公共字段 ts nonce ua_key]
C --> D[字段排序/序列化]
D --> E[摘要或加密生成 sign]
E --> F[发起接口请求]
F --> G[服务端验签]
G --> H[返回商品数据或错误]
调试时真正要找的东西
中级开发者最容易犯的一个错,是上来就在全局搜 md5、sha256。
这当然有时候有效,但很多站点会:
- 改函数名
- 自定义实现摘要逻辑
- Webpack 打包后模块碎片化
- 通过包装函数多层跳转
更稳定的办法是从请求发送点反推。
实战路径:从请求出发定位签名函数
第 1 步:在 Network 面板锁定目标接口
先在搜索框输入一个关键词,比如“蓝牙耳机”,观察 Network:
- 只看
fetch/xhr - 过滤
/api/search之类接口 - 记录请求体和返回体
这时候重点看:
- 是否有
sign/token/v/t/nonce - 是否每次刷新
sign都变化 - 改一个业务参数后,
sign是否一起变化
如果你发现:
- 关键词不变,刷新页面
sign也变
说明有时间戳或随机数参与 - 页码变化,
sign必变
说明业务参数参与签名 - 同一个请求在几秒后重放失效
说明签名有时效校验
第 2 步:在 Initiator / Sources 中追调用链
打开请求详情,查看 Initiator。
很多时候可以直接跳到发请求的位置:
fetch("/api/search", {
method: "POST",
body: JSON.stringify(payload)
})
重点不是这个 fetch 本身,而是 payload 从哪里来。
比如你可能会看到:
const payload = buildSearchPayload(keyword, page);
再继续跟进去:
function buildSearchPayload(q, page) {
const ts = Date.now();
const data = {
q,
page,
sort: "default",
ts
};
data.sign = makeSign(data);
return data;
}
这时候基本就到核心了:makeSign(data)。
第 3 步:打断点看真实入参
这一步非常关键。
我个人做这类分析时,最依赖的不是搜代码,而是断点看运行时值。
在 makeSign(data) 处下断点,观察:
data里有哪些字段- 字段值是否已经处理过
makeSign内部有没有排序、过滤空值、转小写等逻辑
你可能会看到类似代码:
function makeSign(data) {
const keys = Object.keys(data).sort();
const query = keys.map(k => `${k}=${data[k]}`).join("&");
return md5(query + "AppSecret2024");
}
如果真是这样,难度就很低了。
但实际情况往往会多一层包装,例如:
function makeSign(data) {
const raw = normalize(data);
const cipher = window.btoa(raw);
return hash(cipher, getSecret());
}
于是你还要继续展开:
normalize(data)做了什么hash()是 md5、sha1、sha256,还是自定义getSecret()返回固定值,还是依赖环境变量
调用关系示意
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant A as 接口服务端
U->>P: 输入关键词并点击搜索
P->>P: 组装 q/page/sort/ts
P->>S: makeSign(params)
S-->>P: 返回 sign
P->>A: 发送请求(params + sign)
A->>A: 验签、校验时效、风控判断
A-->>P: 返回商品列表
一个可运行的签名复现示例
下面我用一个简化但真实感很强的案例来演示。
假设我们已经在浏览器中分析到,签名规则如下:
- 取
q、page、sort、ts - 按 key 字典序排序
- 拼成
k=v&k=v... - 末尾拼接固定盐值
ECOM_SECRET - 计算 MD5,小写输出
浏览器侧还原出的 JS 逻辑
function normalizeParams(data) {
return Object.keys(data)
.sort()
.map(key => `${key}=${data[key]}`)
.join("&");
}
function makeSign(data) {
const raw = normalizeParams(data) + "ECOM_SECRET";
return md5(raw);
}
为了让示例能独立运行,我们自己写一个 Node 版本。
sign.js
const crypto = require("crypto");
function normalizeParams(data) {
return Object.keys(data)
.sort()
.map((key) => `${key}=${data[key]}`)
.join("&");
}
function makeSign(data) {
const raw = normalizeParams(data) + "ECOM_SECRET";
return crypto.createHash("md5").update(raw).digest("hex");
}
function buildPayload(q, page = 1, sort = "default") {
const data = {
q,
page,
sort,
ts: Date.now()
};
data.sign = makeSign(data);
return data;
}
if (require.main === module) {
const q = process.argv[2] || "蓝牙耳机";
const page = Number(process.argv[3] || 1);
console.log(JSON.stringify(buildPayload(q, page), null, 2));
}
module.exports = {
normalizeParams,
makeSign,
buildPayload
};
运行:
node sign.js "蓝牙耳机" 1
输出类似:
{
"q": "蓝牙耳机",
"page": 1,
"sort": "default",
"ts": 1720000000000,
"sign": "0b7f8d3a1f..."
}
Python 自动化复现
接下来,我们把签名逻辑接入 Python 请求流程。
方案一:Python 内部直接重写算法
如果算法不复杂,我更推荐直接在 Python 里实现,部署简单、性能更稳。
import time
import hashlib
import requests
def normalize_params(data: dict) -> str:
keys = sorted(data.keys())
return "&".join(f"{k}={data[k]}" for k in keys)
def make_sign(data: dict) -> str:
raw = normalize_params(data) + "ECOM_SECRET"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
def build_payload(q: str, page: int = 1, sort: str = "default") -> dict:
data = {
"q": q,
"page": page,
"sort": sort,
"ts": int(time.time() * 1000)
}
data["sign"] = make_sign(data)
return data
def search_goods(keyword: str, page: int = 1):
url = "https://example.com/api/search"
payload = build_payload(keyword, page)
headers = {
"User-Agent": "Mozilla/5.0",
"Content-Type": "application/json",
"Referer": "https://example.com/",
"Origin": "https://example.com"
}
session = requests.Session()
resp = session.post(url, json=payload, headers=headers, timeout=10)
resp.raise_for_status()
return resp.json()
if __name__ == "__main__":
result = search_goods("蓝牙耳机", 1)
print(result)
方案二:Python 调用 Node 中的原始 JS
如果站点算法复杂,尤其有:
- 混淆代码
- 大量前端辅助函数
- AES / RSA / 自定义编码
- 难以完整翻译到 Python
那就直接调 JS,通常更省时间。
call_js.py
import execjs
import requests
with open("sign.js", "r", encoding="utf-8") as f:
js_code = f.read()
ctx = execjs.compile(js_code)
def build_payload(q: str, page: int = 1):
return ctx.call("buildPayload", q, page)
def search_goods(keyword: str, page: int = 1):
url = "https://example.com/api/search"
payload = build_payload(keyword, page)
headers = {
"User-Agent": "Mozilla/5.0",
"Content-Type": "application/json",
"Referer": "https://example.com/",
"Origin": "https://example.com"
}
resp = requests.post(url, json=payload, headers=headers, timeout=10)
resp.raise_for_status()
return resp.json()
if __name__ == "__main__":
print(search_goods("蓝牙耳机", 1))
如何验证你复现对了
这是很多教程容易略过的部分,但实际最重要。
对拍原则
你需要拿浏览器里的真实值,与自己脚本生成的值逐项对比:
ts是否一致或格式一致- 参数顺序是否完全一致
- 是否漏了某个默认字段
sign是否一字不差
比如浏览器里断点暂停时,你拿到了:
{
"q": "蓝牙耳机",
"page": 1,
"sort": "default",
"ts": 1720000000000
}
那你本地脚本就不要直接“猜差不多对”,而是固定这组值做验证:
import hashlib
data = {
"q": "蓝牙耳机",
"page": 1,
"sort": "default",
"ts": 1720000000000
}
raw = "&".join(f"{k}={data[k]}" for k in sorted(data.keys())) + "ECOM_SECRET"
sign = hashlib.md5(raw.encode("utf-8")).hexdigest()
print(raw)
print(sign)
如果结果跟浏览器一致,再继续自动化。
先静态对拍,再动态跑接口,效率会高很多。
更接近真实场景的进阶情况
真实电商站点不一定这么“规矩”。中级开发者往往会遇到这些增强版玩法。
1. 字段不是简单排序,而是白名单参与
例如只有这几个字段参与签名:
const keys = ["q", "page", "sort", "ts"];
此时你把额外字段也带进去,签名就错了。
2. 空值、null、undefined 会被过滤
Object.keys(data)
.filter(k => data[k] !== undefined && data[k] !== null && data[k] !== "")
这时候你 Python 里传了空字符串,也可能导致不一致。
3. 参数值会先 URL 编码
encodeURIComponent(data[key])
中文关键词尤其要注意。
我当时踩过一个坑:浏览器签名前对中文做了编码,但我在 Python 中直接拼原始中文,结果死活不对。
4. 签名依赖设备指纹或环境变量
例如:
navigator.userAgentwindow.screen.widthlocalStorage里的设备 ID- 某个初始化接口返回的 token
这类场景下,你可能不能只抽一个签名函数,而需要补齐上下文。
常见坑与排查
这一节我按“现象 -> 原因 -> 排查方式”来讲,比较贴近实战。
坑 1:签名对了,但接口仍然返回失败
可能原因:
- Cookie 不完整
- 缺 Referer / Origin
- 请求体格式不对,服务端要
application/json,你发成了表单 - Header 中有额外校验字段
排查方法:
- 用浏览器
Copy as cURL - 本地先用 cURL 跑通
- 再逐步换成 Python 请求
- 一次只改一个变量
坑 2:本地复现的 sign 偶尔对、偶尔错
可能原因:
- 时间戳过期
nonce随机值没对齐- JS 中有异步初始化逻辑
- 依赖某个先请求到的 token
排查方法:
- 断点观察签名前的完整参数
- 看页面加载初期是否有配置接口
- 检查
localStorage/sessionStorage/cookie
坑 3:搜不到 md5 / sha256 关键字
可能原因:
- 算法被封装到第三方库
- 打包后函数名丢失
- 使用 WebAssembly
- 做了字符串拆分或混淆
排查方法:
- 从请求发起点反推,而不是全局搜关键字
- 对 XHR / fetch 下断点
- Hook 摘要函数调用
- 必要时用浏览器覆盖脚本注入日志
例如,可以在控制台简单 Hook 一下:
const oldFetch = window.fetch;
window.fetch = async function(...args) {
console.log("fetch args:", args);
return oldFetch.apply(this, args);
};
或者 Hook XMLHttpRequest.send:
const oldSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function(body) {
console.log("xhr body:", body);
return oldSend.call(this, body);
};
坑 4:Python 结果和浏览器只差一点点
这是最烦但也最常见的情况。
一般出在这些细节上:
- 字段顺序不一致
- 布尔值在 JS 里是
true/false,你拼成了True/False - 数字类型被转成字符串
- 中文编码不一致
- JSON 序列化时空格、分隔符不同
例如 Python 默认 json.dumps 可能与前端不完全一致,这时要显式控制:
import json
data = {"q": "蓝牙耳机", "page": 1}
s = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
print(s)
自动化复现的工程化思路
当你把一个接口跑通后,下一步不是立刻“开爬”,而是先把逻辑分层。
我建议按下面结构组织:
flowchart TD
A[参数构造层] --> B[签名生成层]
B --> C[请求发送层]
C --> D[响应解析层]
D --> E[重试与风控处理]
E --> F[数据落库/导出]
对应到代码里,可以拆成:
builder.py:参数构造signer.py:签名计算client.py:HTTP 请求parser.py:字段提取runner.py:任务调度
这样做有两个明显好处:
- 站点改版时,只需要替换签名层
- 便于做单元测试和对拍
一个稍微工程化一点的 Python 示例
import time
import hashlib
import requests
from typing import Dict, Any, List
class Signer:
SECRET = "ECOM_SECRET"
@staticmethod
def normalize(data: Dict[str, Any]) -> str:
keys = sorted(data.keys())
return "&".join(f"{k}={data[k]}" for k in keys)
@classmethod
def sign(cls, data: Dict[str, Any]) -> str:
raw = cls.normalize(data) + cls.SECRET
return hashlib.md5(raw.encode("utf-8")).hexdigest()
class SearchClient:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0",
"Content-Type": "application/json",
"Referer": "https://example.com/",
"Origin": "https://example.com"
})
def build_payload(self, keyword: str, page: int = 1) -> Dict[str, Any]:
data = {
"q": keyword,
"page": page,
"sort": "default",
"ts": int(time.time() * 1000)
}
data["sign"] = Signer.sign(data)
return data
def search(self, keyword: str, page: int = 1) -> Dict[str, Any]:
url = "https://example.com/api/search"
payload = self.build_payload(keyword, page)
resp = self.session.post(url, json=payload, timeout=10)
resp.raise_for_status()
return resp.json()
@staticmethod
def parse_items(data: Dict[str, Any]) -> List[Dict[str, Any]]:
items = data.get("data", {}).get("items", [])
result = []
for item in items:
result.append({
"title": item.get("title"),
"price": item.get("price"),
"shop": item.get("shopName"),
"url": item.get("detailUrl")
})
return result
if __name__ == "__main__":
client = SearchClient()
raw = client.search("蓝牙耳机", 1)
items = client.parse_items(raw)
for item in items:
print(item)
常见排查顺序建议
如果接口跑不通,不要乱试。我建议用下面顺序排查:
-
先固定参数
用浏览器同一组参数和同一时间戳,对拍签名 -
再看请求格式
json=payload和data=payload是两回事 -
再看 Header / Cookie
不要一开始就怀疑算法,很多时候是上下文没带齐 -
再看环境依赖
window、document、navigator是否参与 -
最后再处理风控
包括频率、IP、行为轨迹、验证码等
这个顺序能帮你减少很多无效劳动。
安全/性能最佳实践
这部分很容易被忽视,但如果你真要把自动化脚本长期跑起来,必须考虑。
安全方面
1. 不要把密钥硬编码到公开仓库
就算是你自己分析得到的盐值、token,也不要直接提交到 GitHub。
建议:
- 使用环境变量
- 使用本地配置文件且加入
.gitignore
例如:
import os
SECRET = os.getenv("ECOM_SECRET", "")
2. 谨慎处理 Cookie 与账号态
如果目标接口依赖登录态:
- 不要在日志里打印完整 Cookie
- 不要把账号态文件传来传去
- 最好做最小权限隔离
3. 控制采集范围与频率
技术上能跑通,不代表可以无限制采集。
建议始终遵守:
- robots / 平台协议
- 法律法规
- 最小必要原则
性能方面
1. 签名函数尽量本地纯实现
如果算法能翻译成 Python,就不要每次都启动 Node 子进程。
原因很简单:
- 调用成本高
- 并发性能差
- 部署更麻烦
2. 复用 Session
session = requests.Session()
这样可以减少 TCP/TLS 重建成本,也更接近真实浏览器行为。
3. 做限速与退避重试
不要一上来就高并发。
建议:
- 固定间隔
- 失败后指数退避
- 针对 429 / 403 单独处理
示例:
import time
import random
import requests
def safe_post(session, url, **kwargs):
for i in range(5):
try:
resp = session.post(url, timeout=10, **kwargs)
if resp.status_code == 429:
time.sleep((2 ** i) + random.random())
continue
resp.raise_for_status()
return resp
except requests.RequestException:
time.sleep((2 ** i) + random.random())
raise RuntimeError("request failed after retries")
4. 对签名层做缓存,但要注意时效
如果签名仅依赖固定参数且短时间内可复用,可以做缓存。
但如果包含时间戳、nonce,就不要硬缓存,否则失效率会很高。
边界条件:什么时候不要硬上纯逆向
有些场景,单靠参数加密复现并不能稳定解决问题,比如:
- 强依赖浏览器指纹
- 有复杂行为校验
- 启用了 WebAssembly 混淆
- 请求链路需要多轮动态 token 交换
- 出现频繁验证码或滑块
这时更现实的方案可能是:
- 使用 Playwright / Puppeteer 保持真实浏览器环境
- 让浏览器执行原始页面逻辑
- 再在自动化层做数据提取
也就是说,逆向不是唯一答案。
如果复现成本已经高于收益,及时换方案,反而是成熟开发者的表现。
总结
从电商站点参数加密入手做 Web 逆向,真正重要的不是“记住多少算法名”,而是掌握一套稳定的方法论:
- 从请求发送点反推,而不是盲搜加密函数
- 先断点看真实入参与输出,再做本地复现
- 先静态对拍,再动态请求
- 把签名、请求、解析拆层,便于维护
- 遇到环境依赖或强风控时,及时评估是否切换浏览器自动化
如果你是中级开发者,我最建议你练的不是“抄现成脚本”,而是下面这个能力:
给你一个接口、一个加密参数、一个混淆 JS,你能不能在 1~2 小时内定位到生成链路,并做出最小可运行复现?
一旦这个能力建立起来,你处理大多数参数签名类问题都会更稳。
而且说实话,我自己做这类分析时,最终拼的往往不是谁更懂密码学,而是谁更会缩小范围、逐步验证、严谨对拍。
如果你准备实战,建议先从一个结构简单、签名层级少的接口练手,别一开始就去啃最重风控的目标。
先把方法跑顺,再逐步升级难度,成长会快很多。