跳转到内容
123xiao | 无名键客

《从某电商站点参数加密入手:中级开发者的 Web 逆向实战与自动化复现》

字数: 0 阅读时长: 1 分钟

从某电商站点参数加密入手:中级开发者的 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
但实际逆向时,常见问题有四类:

  1. sign 不是固定算法,依赖时间戳、随机数、设备指纹
  2. 加密前的原文有字段排序、拼接规则
  3. 算法在混淆后的 JS 中,调用链很深
  4. 只复现签名还不够,Cookie、Header、Referer、UA 也参与校验

所以真正的问题不是“怎么抄一个 sign”,而是:

  • 参数是怎么被组织的?
  • 签名是在什么时机生成的?
  • 浏览器环境对结果有没有影响?
  • 如何稳定地自动化复现?

前置知识

如果你已经具备以下基础,阅读会非常顺:

  • 能用 Chrome DevTools 看 Network / Sources / Debugger
  • 知道 JS 基本语法、对象、闭包、异步
  • 能写一点 Python 请求脚本
  • md5 / sha256 / aes / base64 有基础概念

如果没有,也没关系。本文尽量不走“纯理论堆砌”,而是按实战路径来。


环境准备

本文示例使用以下环境:

  • Chrome 浏览器
  • Python 3.10+
  • Node.js 16+
  • requests
  • execjs 或直接调用 Node 脚本
  • 可选:mitmproxyFiddler

安装 Python 依赖:

pip install requests PyExecJS

如果你本机装了 Node,就可以直接让 Python 调 JS。


逐步验证清单

在开始之前,我建议你把这份清单记下来。很多人逆向失败,不是算法不会,而是每一步没有单独验证

  • 确认目标接口是真正的数据接口,而不是页面聚合层
  • 确认请求方式、Header、Cookie 是否齐全
  • 确认签名前原始参数有哪些
  • 确认字段顺序是否影响签名
  • 确认时间戳单位是秒还是毫秒
  • 确认是否有随机盐值或 nonce
  • 确认浏览器环境变量是否参与计算
  • 用浏览器中的真实入参与输出做一次对拍
  • 再迁移到 Node / Python 自动化

这份清单非常重要,后面你会发现,大多数坑都能归到这里。


核心原理

在电商类接口里,参数加密通常不是“真正保密”,而是为了:

  • 防止接口被直接滥用
  • 提高脚本调用门槛
  • 增加风控判断维度
  • 让请求具备时效性与完整性校验

最常见的签名模型大概是这样:

  1. 收集业务参数:关键词、页码、排序方式等
  2. 增加公共参数:时间戳、平台、版本号、设备标识
  3. 按约定规则排序或拼接
  4. 与固定密钥或动态盐值组合
  5. 做摘要或加密,得到 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[返回商品数据或错误]

调试时真正要找的东西

中级开发者最容易犯的一个错,是上来就在全局搜 md5sha256
这当然有时候有效,但很多站点会:

  • 改函数名
  • 自定义实现摘要逻辑
  • 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: 返回商品列表

一个可运行的签名复现示例

下面我用一个简化但真实感很强的案例来演示。
假设我们已经在浏览器中分析到,签名规则如下:

  1. qpagesortts
  2. 按 key 字典序排序
  3. 拼成 k=v&k=v...
  4. 末尾拼接固定盐值 ECOM_SECRET
  5. 计算 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.userAgent
  • window.screen.width
  • localStorage 里的设备 ID
  • 某个初始化接口返回的 token

这类场景下,你可能不能只抽一个签名函数,而需要补齐上下文。


常见坑与排查

这一节我按“现象 -> 原因 -> 排查方式”来讲,比较贴近实战。

坑 1:签名对了,但接口仍然返回失败

可能原因:

  • Cookie 不完整
  • 缺 Referer / Origin
  • 请求体格式不对,服务端要 application/json,你发成了表单
  • Header 中有额外校验字段

排查方法:

  1. 用浏览器 Copy as cURL
  2. 本地先用 cURL 跑通
  3. 再逐步换成 Python 请求
  4. 一次只改一个变量

坑 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:任务调度

这样做有两个明显好处:

  1. 站点改版时,只需要替换签名层
  2. 便于做单元测试和对拍

一个稍微工程化一点的 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)

常见排查顺序建议

如果接口跑不通,不要乱试。我建议用下面顺序排查:

  1. 先固定参数
    用浏览器同一组参数和同一时间戳,对拍签名

  2. 再看请求格式
    json=payloaddata=payload 是两回事

  3. 再看 Header / Cookie
    不要一开始就怀疑算法,很多时候是上下文没带齐

  4. 再看环境依赖
    windowdocumentnavigator 是否参与

  5. 最后再处理风控
    包括频率、IP、行为轨迹、验证码等

这个顺序能帮你减少很多无效劳动。


安全/性能最佳实践

这部分很容易被忽视,但如果你真要把自动化脚本长期跑起来,必须考虑。

安全方面

1. 不要把密钥硬编码到公开仓库

就算是你自己分析得到的盐值、token,也不要直接提交到 GitHub。
建议:

  • 使用环境变量
  • 使用本地配置文件且加入 .gitignore

例如:

import os

SECRET = os.getenv("ECOM_SECRET", "")

如果目标接口依赖登录态:

  • 不要在日志里打印完整 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 逆向,真正重要的不是“记住多少算法名”,而是掌握一套稳定的方法论:

  1. 从请求发送点反推,而不是盲搜加密函数
  2. 先断点看真实入参与输出,再做本地复现
  3. 先静态对拍,再动态请求
  4. 把签名、请求、解析拆层,便于维护
  5. 遇到环境依赖或强风控时,及时评估是否切换浏览器自动化

如果你是中级开发者,我最建议你练的不是“抄现成脚本”,而是下面这个能力:

给你一个接口、一个加密参数、一个混淆 JS,你能不能在 1~2 小时内定位到生成链路,并做出最小可运行复现?

一旦这个能力建立起来,你处理大多数参数签名类问题都会更稳。
而且说实话,我自己做这类分析时,最终拼的往往不是谁更懂密码学,而是谁更会缩小范围、逐步验证、严谨对拍

如果你准备实战,建议先从一个结构简单、签名层级少的接口练手,别一开始就去啃最重风控的目标。
先把方法跑顺,再逐步升级难度,成长会快很多。


分享到:

上一篇
《区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-91》
下一篇
《Java 开发踩坑实战:排查并修复线程池误用导致的接口超时与内存飙升问题》