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

《Web逆向实战:基于浏览器开发者工具定位并还原前端加密请求参数的完整方法》

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

Web逆向实战:基于浏览器开发者工具定位并还原前端加密请求参数的完整方法

前端“加密参数”这件事,很多人第一次碰到时都会有点懵:接口明明抓到了,参数也看见了,但一旦换成脚本复现,请求就失败。最典型的情况是 signtokenciphert_signature 这类字段,看起来像是前端临时算出来的。

这篇文章我不讲“玄学经验”,而是按一条可重复、能落地、能排错的路径,带你从浏览器开发者工具出发,把前端加密请求参数一步步定位并还原出来。你看完后,至少能建立一套判断框架:参数在哪里生成、依赖哪些输入、如何最小化复现

说明:本文聚焦技术分析方法,用于学习前端调试、接口联调、安全研究与自动化测试。请仅在合法合规授权范围内使用。


背景与问题

我们先定义一下问题。

在实际站点中,前端常见的“加密请求参数”通常不是传统意义上的严格加密,而是下面几类之一:

  • 摘要签名:如 md5 / sha256 / hmac
  • 对称加密:如 AES-CBC / AES-ECB
  • 非对称加密:如 RSA 加密某个字段
  • 混淆编码:如 base64、字符位移、数组重排
  • 动态字段拼接:时间戳、随机数、设备指纹、页面状态共同生成

典型现象包括:

  • 浏览器里接口正常,脚本复现返回“签名错误”
  • 参数每次都变,无法直接复用
  • 请求头里有特殊字段,如 x-signx-timestamp
  • 参数是长字符串,表面看不出规律
  • 同一个接口,刷新页面后关键算法文件名变化

这些场景,本质上都绕不开三个问题:

  1. 参数是在哪生成的?
  2. 生成时依赖了哪些输入?
  3. 如何脱离浏览器环境稳定复现?

前置知识与环境准备

如果你已经熟悉 DevTools 和一点 JavaScript,可以直接跳到实战部分。否则建议先准备好这些工具:

  • Chrome 或 Edge 浏览器
  • 开发者工具(Network / Sources / Console)
  • 一个本地 Node.js 环境
  • 可选:格式化 JS 的插件或在线 beautify 工具

建议至少知道以下概念:

  • XMLHttpRequestfetch
  • 请求头、请求体、查询参数
  • 断点、调用栈、作用域链
  • 常见加密库特征:CryptoJSJSEncryptmd5

核心原理

1. 前端加密参数的真实生成链路

很多人上来就全局搜索 sign,结果搜不到,于是觉得“被混淆了,没法搞”。其实更稳的办法不是从变量名猜,而是从请求发起链路反推。

一个加密参数从“页面动作”到“真正发出请求”,通常会经过这条链路:

flowchart LR
    A[用户操作/页面初始化] --> B[业务函数组装参数]
    B --> C[签名或加密函数]
    C --> D[请求封装器 axios/fetch/xhr]
    D --> E[浏览器发包]
    E --> F[服务端验签/解密]

我们的目标,就是在 C 或 D 这个位置拦住它。

2. DevTools 的核心思路:先抓包,再逆调用栈

最有效的方法一般是:

  1. Network 找到目标请求
  2. 看清楚:
    • URL
    • Method
    • Query String
    • Request Payload / Form Data
    • Request Headers
  3. 锁定关键字段,例如 sign
  4. 回到 Sources,对请求发起点或加密 API 打断点
  5. 利用 Call Stack 倒推出参数生成函数

这一步非常关键。
因为变量名可以混淆,调用链很难伪装

3. 加密参数常见输入源

你定位到签名函数后,不要急着抄代码,先看它依赖什么。大多会来自:

  • 请求路径,如 /api/search
  • 请求方法,如 POST
  • 请求体 JSON 字符串
  • 时间戳 Date.now()
  • 随机数 Math.random()
  • Cookie / LocalStorage / SessionStorage
  • 页面内置常量或“盐值”
  • 设备信息,如 UA、屏幕信息、指纹值

可以把它理解成一个公式:

sign = F(url, method, body, timestamp, nonce, secret, env)

只要你漏掉一个输入,复现就可能失败。


一套实战方法:从请求到算法还原

下面我按我自己常用的一条路径来演示,这种路径的好处是:即使站点混淆较重,也能逐层缩小范围。

第一步:在 Network 中锁定目标请求

打开 DevTools,进入 Network 面板,勾选:

  • Preserve log
  • Disable cache

然后执行一次目标操作,比如点击“搜索”或“加载更多”。

重点观察:

  • 是否有接口返回业务数据
  • 参数是否包含明显签名字段
  • 请求头中是否有定制字段

例如我们看到这样的请求体:

{
  "keyword": "phone",
  "page": 1,
  "ts": 1720000000000,
  "sign": "8f8e5f0d2a0f..."
}

这时可以初步推断:

  • ts 是参与签名的时间戳
  • sign 很可能是 keyword + page + ts + secret 一类组合计算

第二步:优先看 Initiator

在 Network 选中请求后,看 Initiator 或调用来源。
这里常常能直接跳到发起请求的 JS 文件。

如果是打包后的文件名如:

  • app.3a91f.js
  • chunk-vendors.82a0.js

也别慌,先点进去。

第三步:对请求发起点下断点

对以下位置优先下断点:

  • fetch(...)
  • XMLHttpRequest.prototype.open
  • XMLHttpRequest.prototype.send
  • axios 请求拦截器
  • 构造请求对象的那一行

如果站点使用 fetch,我常用的一个简单技巧是在 Console 里先做 hook:

const rawFetch = window.fetch;
window.fetch = async function (...args) {
  console.log('fetch args:', args);
  debugger;
  return rawFetch.apply(this, args);
};

如果是 XHR:

(function () {
  const rawOpen = XMLHttpRequest.prototype.open;
  const rawSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this._method = method;
    this._url = url;
    return rawOpen.call(this, method, url, ...rest);
  };

  XMLHttpRequest.prototype.send = function (body) {
    console.log('XHR =>', this._method, this._url, body);
    debugger;
    return rawSend.call(this, body);
  };
})();

这样做的意义不是直接拿算法,而是先让请求停下来,你才能顺着调用栈往上追。

第四步:从调用栈追到签名函数

当断点触发后,打开 Call Stack

一般你会看到类似这样一串调用:

sendRequest
buildPayload
genSign
md5

此时重点观察:

  • 哪一层开始出现 sign
  • sign 的原始输入是什么
  • 是否调用了第三方库,如 CryptoJS.MD5(...)

如果你看到这类代码,基本就接近答案了:

function genSign(data, ts) {
  const raw = JSON.stringify(data) + "|" + ts + "|" + "SECRET_123";
  return md5(raw);
}

这时候不要只记下 md5,而要记完整公式:

  • data 是否需要固定字段顺序
  • JSON.stringify 前是否做了排序
  • 分隔符是不是 |
  • secret 是硬编码还是动态来的

第五步:验证参数依赖关系

我建议在浏览器里现场验证,而不是直接去 Python 或 Node 复现。

比如在 Console 执行:

JSON.stringify({ keyword: "phone", page: 1 }) + "|" + 1720000000000 + "|" + "SECRET_123"

再和断点中实际输入做比对。

这里我踩过一个很常见的坑:
看起来对象内容一样,但字段顺序不一样,最终 sign 就不一样。

所以要特别注意:

  • 对象键顺序
  • 数字是否被转成字符串
  • 请求体是否压缩空格
  • undefined 字段是否被过滤
  • URL 参数是否做了 encodeURIComponent

Mermaid:完整定位流程图

flowchart TD
    A[Network 找到目标请求] --> B[识别关键参数 sign/ts/token]
    B --> C[查看 Initiator 和 Sources]
    C --> D[对 fetch/xhr/axios 下断点]
    D --> E[断点触发后查看 Call Stack]
    E --> F[定位参数构造函数]
    F --> G[确认输入项 body/url/ts/nonce/secret]
    G --> H[浏览器内 Console 复算验证]
    H --> I[Node/Python 脱离浏览器复现]

实战代码(可运行)

下面我用一个简化但真实感很强的例子演示。目标是还原一个前端签名请求。

场景设定

前端请求发送的数据如下:

{
  "keyword": "phone",
  "page": 1,
  "ts": 1720000000000,
  "sign": "..."
}

经过断点分析后,发现前端签名逻辑是:

  1. 取业务字段 keywordpage
  2. 按键名升序排序
  3. 拼成查询串:keyword=phone&page=1
  4. 再拼接时间戳与 secret
  5. 最后做 md5

公式如下:

sign = md5("keyword=phone&page=1|1720000000000|SECRET_123")

浏览器端验证代码

可以先在 Console 里执行这段代码验证思路:

function buildQuery(obj) {
  return Object.keys(obj)
    .sort()
    .map(k => `${k}=${obj[k]}`)
    .join("&");
}

// 一个简化的 md5 占位说明:
// 在真实站点里你可能会直接调用页面已有的 md5 函数。
// 如果页面里已经加载了 CryptoJS,可直接用 CryptoJS.MD5。
function demoRawString(data, ts, secret) {
  const query = buildQuery(data);
  return `${query}|${ts}|${secret}`;
}

const data = { keyword: "phone", page: 1 };
const ts = 1720000000000;
const secret = "SECRET_123";

console.log(demoRawString(data, ts, secret));
// 输出:keyword=phone&page=1|1720000000000|SECRET_123

如果页面里已有 CryptoJS,继续这样验证:

function buildQuery(obj) {
  return Object.keys(obj)
    .sort()
    .map(k => `${k}=${obj[k]}`)
    .join("&");
}

function genSign(data, ts, secret) {
  const raw = `${buildQuery(data)}|${ts}|${secret}`;
  return CryptoJS.MD5(raw).toString();
}

const data = { keyword: "phone", page: 1 };
const ts = 1720000000000;
const secret = "SECRET_123";

console.log(genSign(data, ts, secret));

Node.js 复现代码

下面给出一个可直接运行的 Node.js 版本。

先保存为 sign_demo.js

const crypto = require("crypto");

function buildQuery(obj) {
  return Object.keys(obj)
    .sort()
    .map((k) => `${k}=${obj[k]}`)
    .join("&");
}

function genSign(data, ts, secret) {
  const raw = `${buildQuery(data)}|${ts}|${secret}`;
  return crypto.createHash("md5").update(raw).digest("hex");
}

function buildPayload(keyword, page) {
  const data = { keyword, page };
  const ts = Date.now();
  const secret = "SECRET_123";
  const sign = genSign(data, ts, secret);

  return {
    ...data,
    ts,
    sign,
  };
}

const payload = buildPayload("phone", 1);
console.log(payload);

运行:

node sign_demo.js

你会得到类似结果:

{
  keyword: 'phone',
  page: 1,
  ts: 1720000000000,
  sign: '8d1b4d2f9f6e2f7d...'
}

Python 复现代码

如果你更习惯 Python,也可以这样写:

import time
import hashlib

def build_query(data):
    return "&".join(f"{k}={data[k]}" for k in sorted(data.keys()))

def gen_sign(data, ts, secret):
    raw = f"{build_query(data)}|{ts}|{secret}"
    return hashlib.md5(raw.encode("utf-8")).hexdigest()

def build_payload(keyword, page):
    data = {
        "keyword": keyword,
        "page": page
    }
    ts = int(time.time() * 1000)
    secret = "SECRET_123"
    sign = gen_sign(data, ts, secret)

    result = dict(data)
    result["ts"] = ts
    result["sign"] = sign
    return result

if __name__ == "__main__":
    payload = build_payload("phone", 1)
    print(payload)

请求还原的逐步验证清单

实际做项目时,我建议你不要“一把梭”直接写完整脚本,而是按下面顺序逐项验证:

1. 先验证原始拼接串

确认 raw string 是否和浏览器里的一致:

keyword=phone&page=1|1720000000000|SECRET_123

2. 再验证 hash / 加密结果

确认 md5(raw) 与浏览器一致。

3. 再验证请求体结构

确认最终发送的是:

  • JSON 体
  • 表单体
  • URL 查询参数

4. 再验证请求头

尤其是:

  • content-type
  • referer
  • origin
  • x-sign
  • authorization

5. 最后验证时效性

如果签名 5 秒、30 秒或 60 秒失效,重放旧参数一定会失败。


Mermaid:请求参数生成时序图

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant S as 签名函数
    participant R as 请求模块
    participant B as 浏览器
    participant API as 服务端

    U->>P: 点击搜索
    P->>S: 传入业务参数 data
    S->>S: 拼接 data + ts + secret
    S->>P: 返回 sign
    P->>R: 组装 payload
    R->>B: 发起 HTTP 请求
    B->>API: 发送 payload
    API->>API: 验签
    API-->>B: 返回结果

常见坑与排查

这一节非常重要。很多时候不是你没找到算法,而是复现时细节差了一点点。

1. 参数排序问题

常见现象:

  • 浏览器里正常
  • 自己复现总是“签名错误”

排查重点:

  • 是否按键名排序
  • 是否排除了空字段
  • 是否包含 sign 自身参与签名

例如下面两种 raw 就不同:

keyword=phone&page=1
page=1&keyword=phone

如果服务端按第一种验签,你发第二种肯定不行。


2. JSON 序列化不一致

比如前端实际签的是:

JSON.stringify({ keyword: "phone", page: 1 })

而你自己手写成:

{"page":1,"keyword":"phone"}

虽然语义一样,但字符串已经变了。

建议做法:

  • 尽量复用原始序列化逻辑
  • 不要“看起来一样就行”

3. 时间戳精度不同

有些站点要:

  • 秒级:Math.floor(Date.now() / 1000)
  • 毫秒级:Date.now()
  • 微秒字符串:后端自定义格式

如果你把毫秒传成秒,签名当然对不上。


4. 随机数或 nonce 漏掉

有些请求参数除了 ts 还要 nonce

nonce = Math.random().toString(36).slice(2)

如果你只还原了 sign,但没同步生成 nonce,服务端也可能拒绝。


5. 环境依赖没补齐

这是我最常见到的一个坑。
前端代码可能依赖:

  • window
  • document
  • navigator.userAgent
  • location.href
  • canvas 指纹
  • localStorage

你把函数直接复制到 Node.js 里跑,会报:

window is not defined

这时不要一股脑上 jsdom,先判断到底依赖了哪些环境值。很多时候只需要最小 mock:

global.window = {};
global.navigator = {
  userAgent: "Mozilla/5.0 ..."
};

6. 混淆后找不到函数名

常见表现:

  • 全是 a(b,c)_0x12af(...)
  • signmd5 没结果

这时建议从这几个点切入:

  • CryptoJS
  • digest
  • encrypt
  • setRequestHeader
  • fetch(.send(
  • 在 XHR/fetch 上打断点,靠调用栈回溯

记住一句话:不要和混淆后的变量名硬刚,直接追执行链。


7. 不是“算法错”,而是 Header 错

有些接口验签之外,还校验:

  • origin
  • referer
  • x-requested-with
  • sec-fetch-site

特别是一些风控较强的站点,签名正确也不代表一定通过。


如何判断是摘要、加密还是混淆

很多读者卡在第一步:看见一段长字符串,不知道该从哪里判断。

这里给一个很实用的经验表。

表现可能类型特征
32 位十六进制MD5例如 5d41402abc4b2a76b9719d911017c592
40 位十六进制SHA1较常见于旧实现
64 位十六进制SHA256常用于 stronger sign
很长的 Base64 字符串AES / RSA / 编码常带 = 结尾
每次不同且超长RSA / AES + 随机 IV需继续看输入
可逆解码后仍像结构化文本Base64 / 混淆优先尝试解码

我的经验是:

  • 短定长十六进制,优先猜摘要
  • 长 Base64,优先猜加密
  • 参数不长但复杂,优先怀疑是拼接规则 + hash

安全/性能最佳实践

这一节既是给做分析的人,也是给前端/服务端开发者的建议。

1. 不要把“前端加密”当真正安全边界

前端代码最终会下发到用户浏览器。
只要代码能执行,就理论上能被调试、观察、还原。

所以:

  • 前端签名只能提升滥用门槛
  • 不能代替服务端认证、授权、频控
  • 不要把核心密钥完全暴露在前端

2. 服务端必须做二次校验

建议至少校验:

  • 时间戳窗口
  • nonce 去重
  • 用户身份绑定
  • 签名字段完整性
  • 请求频率限制

否则前端再复杂,仍然只是“拦君子不拦会调试的人”。

3. 前端实现尽量可维护

如果你是开发方,不建议为了“防逆向”把代码混淆到自己都维护不了。更合理的做法是:

  • 核心逻辑放服务端
  • 前端只做轻量签名
  • 版本升级时保留日志与埋点
  • 对异常请求做风险评分

4. 自动化复现时尽量做最小实现

从分析者角度,性能最佳实践是:

  • 不要整个浏览器环境照搬
  • 先提取纯算法函数
  • 必要时再补环境 mock
  • 尽量把算法与请求逻辑解耦

这样后续维护成本低,也更容易定位更新点。


一个更贴近实战的定位策略

如果目标站点比较复杂,我一般会按这个优先级来:

路线 A:从请求发起点追

适合大多数场景。

  • Network 找请求
  • Sources 找 initiator
  • 断在 fetch/xhr
  • 看调用栈

优点:稳。
缺点:调用链长时要耐心一点。

路线 B:从加密特征追

适合明显使用常见库的场景。

比如搜索:

  • CryptoJS.MD5
  • CryptoJS.AES.encrypt
  • JSEncrypt
  • createHash

优点:快。
缺点:被封装或混淆后容易失效。

路线 C:从请求封装器追

适合 Vue/React 项目里统一封装 axios 的情况。

找:

  • request interceptor
  • response interceptor
  • headers 注入逻辑
  • 统一签名函数入口

优点:经常能一次抓到全部接口共用逻辑。
缺点:个别接口可能有额外处理。


实战中的边界条件

这里把话说透一点:不是所有站点都能轻松“纯算法还原”。

以下情况会明显提高难度:

  • 签名依赖 WebAssembly
  • 混合使用 Canvas/WebGL 指纹
  • 动态下发临时公钥或 token
  • 服务端参与 challenge-response
  • 参数依赖登录态、cookie、设备行为轨迹

这时你要做的不是死磕“单函数还原”,而是先判断目标属于哪一层:

  1. 纯前端算法可复现
  2. 前端算法 + 少量环境依赖
  3. 浏览器行为强绑定,必须带真实环境
  4. 服务端动态参与,无法完全离线复现

判断清楚层级,比盲目抄代码重要得多。


总结

把前端加密请求参数还原出来,真正有用的不是“记住某个站点的算法”,而是掌握一套通用方法:

  1. 先在 Network 锁定目标请求
  2. 再从 Initiator / Sources 找发起链路
  3. 对 fetch / XHR / axios 下断点
  4. 借助调用栈定位签名函数
  5. 确认完整输入,不只盯着 sign 本身
  6. 先在浏览器内复算验证,再脱离浏览器复现
  7. 遇到失败优先排查排序、序列化、时间戳、环境依赖

如果你现在就要上手,我建议按这个最小行动清单开始:

  • 找到一个带 sign 的请求
  • 记录请求体和请求头
  • fetchxhr.send 上打断点
  • 观察 sign 生成前的原始输入串
  • 用 Console 手动复算一次
  • 再迁移到 Node 或 Python

只要你把“抓包 -> 断点 -> 调用栈 -> 输入验证 -> 脱环境复现”这条链走顺了,大多数前端签名问题都不会再显得那么神秘。真正难的,不是算法本身,而是有没有按正确顺序把它拆开。


分享到:

上一篇
《从智能合约审计到链上监控:中级开发者构建区块链安全防护体系的实战指南》
下一篇
《Spring Boot 3 实战:基于 JWT 与 Spring Security 6 构建可扩展的 Java Web 登录鉴权体系》