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

《从浏览器 DevTools 到脚本复现:中级开发者实战拆解 Web 逆向中的签名参数生成逻辑》

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

从浏览器 DevTools 到脚本复现:中级开发者实战拆解 Web 逆向中的签名参数生成逻辑

很多中级开发者第一次接触 Web 逆向时,卡住的不是“怎么发请求”,而是“为什么我照着请求头和参数抄了一遍,服务端还是返回签名错误”。

我当时第一次碰到这类接口时,也天真地以为:把 Network 面板里看到的参数原样复制出来,不就完了?
结果很快发现,真正关键的 signtokentsnonce 往往不是静态值,而是前端运行时计算出来的。

这篇文章不讲偏门技巧,也不走“玄学猜签名”的路线,而是带你从 浏览器 DevTools 定位生成逻辑,一步步走到 脚本可运行复现。重点不是某个网站的特例,而是一套中级开发者可以迁移复用的方法。


背景与问题

在现代 Web 应用中,签名参数常用于:

  • 防止接口被随意调用
  • 验证请求是否被篡改
  • 绑定时间戳、设备信息、会话状态
  • 提高自动化调用门槛

典型现象通常是这样的:

  1. 你在浏览器里请求接口,一切正常
  2. 你把 URL、Header、Body 复制到脚本里
  3. 服务端返回:
    • sign invalid
    • timestamp expired
    • illegal request
    • auth check failed

这说明问题往往不在“请求格式”,而在“动态参数生成过程”。

常见签名参数长什么样

常见字段包括:

  • sign
  • signature
  • token
  • x-sign
  • x-timestamp
  • nonce
  • did
  • traceid

这些字段背后一般涉及:

  • 请求参数排序
  • JSON 序列化规则
  • 时间戳拼接
  • 固定盐值
  • 用户态 token
  • 哈希算法,如 md5sha1sha256
  • 某些简单加密或编码,如 Base64URL encode

前置知识与环境准备

开始前,建议你准备:

  • Chrome 或 Edge 浏览器
  • DevTools 基础使用能力
  • Node.js 16+
  • 一点点 JavaScript 调试能力

推荐工具

  • Chrome DevTools
    • Network
    • Sources
    • Console
  • Node.js
    • 用于脚本复现
  • 可选代理工具
    • Charles / Fiddler / mitmproxy
  • 可选格式化工具
    • 在线 JS Beautify
    • SourceMap 支持

核心原理

把问题先抽象一下。绝大多数签名生成逻辑,都可以归纳成下面这条链路:

flowchart LR
A[用户操作/页面加载] --> B[前端组装业务参数]
B --> C[追加动态字段 ts nonce token]
C --> D[按规则排序/序列化]
D --> E[拼接盐值或密钥]
E --> F[哈希/编码生成 sign]
F --> G[发送请求]
G --> H[服务端按相同规则校验]

也就是说,你要复现签名,不是“拿到 sign 值”,而是找到:

  1. 输入是什么
  2. 规则是什么
  3. 算法是什么
  4. 上下文依赖是什么

一个典型签名公式

很多站点本质上都是类似下面这种:

sign = md5(path + sortedQuery + bodyString + timestamp + secret)

或者:

sign = sha256(token + nonce + JSON.stringify(data) + salt)

真正难的点通常不是算法本身,而是这些细节:

  • 参数排序是否按字典序
  • 空值是否参与签名
  • JSON 是否压缩无空格
  • 数字和字符串是否严格区分
  • Header 中某个字段是否也参与签名
  • 时间戳单位是秒还是毫秒
  • secret 是写死的、动态下发的,还是由别处计算出来的

如何在 DevTools 里定位签名生成逻辑

这是全文最核心的实战方法。

第一步:在 Network 面板找到目标请求

先打开页面,触发目标接口,然后在 Network 里筛选:

  • XHR / Fetch
  • 请求 URL
  • 特征参数,比如 signtimestamp

重点看这几处:

  • Query String Parameters
  • Request Payload
  • Request Headers

你要先确认:

  • sign 在 URL、Body 还是 Header
  • 哪些参数每次都变
  • 哪些参数稳定不变

快速判断思路

如果你连续发两次相同操作,观察到:

  • ts 每次变化:说明时间戳参与签名概率很高
  • nonce 每次变化:说明存在随机数
  • sign 随着参数变化而变化:说明是内容相关签名
  • sign 长度固定 32 位:大概率 md5
  • sign 长度固定 40 位:大概率 sha1
  • sign 长度固定 64 位:大概率 sha256

当然,这只是经验,不是绝对。


第二步:从请求发起栈反查调用位置

在 Network 里点开目标请求,查看:

  • Initiator
  • Call Stack

如果是未压缩站点,通常能直接跳到发请求的代码位置。
如果是打包代码,也不用慌,重点找这些关键词:

  • sign
  • signature
  • timestamp
  • nonce
  • md5
  • sha
  • crypto
  • headers
  • interceptors
  • fetch
  • axios

一个很实用的经验

很多项目不是在业务函数里直接算签名,而是统一放在:

  • Axios request interceptor
  • 封装的 request() 方法
  • SDK 层
  • 公共 util 函数

所以如果你只盯着某个页面组件,容易找偏。


第三步:用断点把“输入”和“输出”钉住

定位到可疑代码后,不要急着读完所有混淆逻辑。
先干一件最值钱的事:打断点,看签名前后的变量值。

sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant N as Network请求
participant B as 后端

U->>P: 点击查询
P->>S: 传入业务参数
S->>S: 排序/拼接/哈希
S-->>P: 返回 sign
P->>N: 发送请求
N->>B: 携带 sign/ts/nonce
B-->>N: 返回结果
N-->>P: 渲染页面

你要重点观察这些变量:

  • 原始业务参数对象
  • 时间戳
  • 随机串
  • 序列化后的字符串
  • 最终哈希输入串
  • 输出的 sign 值

断点位置建议

优先打在这些地方:

md5(...)
sha256(...)
JSON.stringify(...)
Object.keys(...)
sort(...)
setRequestHeader(...)
axios.interceptors.request.use(...)
fetch(...)
XMLHttpRequest.prototype.send(...)

如果站点用了第三方加密库,这种方式尤其高效。


第四步:验证“签名是否只依赖你看到的参数”

这是很多人容易忽略的一步。

有些签名不仅依赖请求体,还会依赖:

  • Cookie 中的某个值
  • LocalStorage 的 token
  • 页面初始化时下发的配置
  • 设备指纹
  • 当前 URL path
  • Referer
  • 某个自定义 Header

所以你要有意识地做“变量排除实验”:

  1. 只改业务参数,看 sign 是否变
  2. 只改 ts,看 sign 是否变
  3. 只改 Header,看 sign 是否变
  4. 切换账号,看 sign 规则是否变

这样可以快速圈定真正参与签名的输入集合。


一个通用的定位流程图

flowchart TD
A[抓到目标请求] --> B{sign 在哪}
B -->|Query| C[检查 URL 参数]
B -->|Body| D[检查请求体]
B -->|Header| E[检查请求头]

C --> F[查看 Initiator/Call Stack]
D --> F
E --> F

F --> G[全局搜索 sign md5 sha crypto]
G --> H[锁定 request 封装或拦截器]
H --> I[在签名函数前后打断点]
I --> J[记录输入参数和最终拼接串]
J --> K[在控制台复算验证]
K --> L[迁移到 Node 脚本复现]

实战案例:从页面请求到 Node 脚本复现

下面我用一个自造但高度贴近真实项目的例子演示。
场景:某接口请求时带有以下参数:

  • appId
  • ts
  • nonce
  • data
  • sign

前端签名规则如下:

  1. appIdtsnoncedata 放入对象
  2. 按 key 字典序排序
  3. key=value& 拼接
  4. 在末尾追加固定盐值 secret=demo_secret
  5. 对完整字符串做 md5

最终:

sign = md5("appId=web001&data=...&nonce=...&ts=...&secret=demo_secret")

浏览器侧示例代码

假设你在 Sources 里看到了类似代码:

function buildSign(params) {
  const secret = 'demo_secret';
  const sortedKeys = Object.keys(params).sort();
  const str = sortedKeys
    .map((key) => `${key}=${params[key]}`)
    .join('&') + `&secret=${secret}`;

  return md5(str);
}

function sendRequest(keyword) {
  const payload = {
    keyword,
    page: 1,
    pageSize: 20
  };

  const params = {
    appId: 'web001',
    ts: Date.now().toString(),
    nonce: Math.random().toString(36).slice(2, 10),
    data: JSON.stringify(payload)
  };

  params.sign = buildSign(params);

  return fetch('/api/search', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(params)
  });
}

这类代码已经很友好了。真实环境中可能是压缩后的:

function a(b){var c="demo_secret";return d(Object.keys(b).sort().map(function(e){return e+"="+b[e]}).join("&")+"&secret="+c)}

别怕,本质一样。


在控制台先做最小验证

在彻底写 Node 脚本前,我建议先在浏览器 Console 做一次原地复算。
这是降低试错成本最有效的方法。

const payload = {
  keyword: 'phone',
  page: 1,
  pageSize: 20
};

const params = {
  appId: 'web001',
  ts: '1710000000000',
  nonce: 'abcd1234',
  data: JSON.stringify(payload)
};

const sortedKeys = Object.keys(params).sort();
const raw = sortedKeys.map(k => `${k}=${params[k]}`).join('&') + '&secret=demo_secret';

console.log(raw);

如果你已经能拿到页面里的 md5 函数,继续:

console.log(md5(raw));

如果输出和请求中的 sign 一致,说明规则基本确认了。


实战代码:Node.js 可运行复现

下面给出一个可运行版本。你可以保存为 sign-demo.js

const crypto = require('crypto');

function md5(text) {
  return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}

function buildSign(params, secret) {
  const sortedKeys = Object.keys(params).sort();
  const raw = sortedKeys
    .map((key) => `${key}=${params[key]}`)
    .join('&') + `&secret=${secret}`;

  return {
    raw,
    sign: md5(raw)
  };
}

function buildRequestData(keyword) {
  const payload = {
    keyword,
    page: 1,
    pageSize: 20
  };

  const params = {
    appId: 'web001',
    ts: Date.now().toString(),
    nonce: Math.random().toString(36).slice(2, 10),
    data: JSON.stringify(payload)
  };

  const { raw, sign } = buildSign(params, 'demo_secret');

  return {
    raw,
    body: {
      ...params,
      sign
    }
  };
}

const result = buildRequestData('phone');
console.log('签名原串:');
console.log(result.raw);
console.log('\n最终请求体:');
console.log(JSON.stringify(result.body, null, 2));

示例输出

{
  "appId": "web001",
  "ts": "1710000000000",
  "nonce": "k3j9ab2x",
  "data": "{\"keyword\":\"phone\",\"page\":1,\"pageSize\":20}",
  "sign": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

加上真实请求发送

如果你想继续发请求,可以这样写。这里用 Node 18+ 自带 fetch

const crypto = require('crypto');

function md5(text) {
  return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}

function buildSign(params, secret) {
  const raw = Object.keys(params)
    .sort()
    .map((key) => `${key}=${params[key]}`)
    .join('&') + `&secret=${secret}`;

  return md5(raw);
}

async function search(keyword) {
  const payload = {
    keyword,
    page: 1,
    pageSize: 20
  };

  const params = {
    appId: 'web001',
    ts: Date.now().toString(),
    nonce: Math.random().toString(36).slice(2, 10),
    data: JSON.stringify(payload)
  };

  const sign = buildSign(params, 'demo_secret');

  const body = {
    ...params,
    sign
  };

  const resp = await fetch('https://example.com/api/search', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(body)
  });

  const text = await resp.text();
  console.log(text);
}

search('phone').catch(console.error);

逐步验证清单

写脚本时,我建议你按下面顺序验证,不要一上来就“整包发出去再看命”。

第 1 步:验证原始输入

确认这些值是否和浏览器一致:

  • appId
  • ts
  • nonce
  • data

第 2 步:验证排序结果

打印排序后的 key:

console.log(Object.keys(params).sort());

第 3 步:验证签名原串

这是最关键的一步:

console.log(raw);

只要原串不一致,最终 sign 一定不一致。

第 4 步:验证哈希结果

console.log(sign);

第 5 步:验证完整请求

确认:

  • Body 是否是 JSON
  • Header 是否缺少必要字段
  • Cookie / token 是否缺失

常见坑与排查

这一节很重要。很多“看起来规则一样却总失败”的问题,都栽在细节上。

1. JSON.stringify 结果不一致

最常见的坑之一。

比如浏览器侧是:

JSON.stringify({a:1,b:2})

你脚本侧如果手工拼成:

'{"b":2,"a":1}'

虽然语义相同,但字符串不同,签名就变了。

排查建议

  • 永远打印签名原串
  • 尽量使用和前端同样的序列化方式
  • 注意对象 key 的插入顺序

2. 时间戳单位搞错

有些接口要秒:

Math.floor(Date.now() / 1000).toString()

有些接口要毫秒:

Date.now().toString()

差 1000 倍,服务端直接判过期。

排查建议

观察浏览器请求里的 ts 长度:

  • 10 位:通常是秒
  • 13 位:通常是毫秒

3. 签名前是否包含 sign 自己

有些人会把 sign 字段也放进去排序,然后再算 sign
这肯定错。

正确做法

签名时一般只对原始待签字段做计算,最后再把 sign 放入请求。


4. URL 编码时机不一致

比如 data 里包含中文、空格、特殊字符。
浏览器可能先:

  • JSON.stringify
  • 再参与签名
  • 最后请求时由 HTTP 层编码

而你脚本可能先 encodeURIComponent,这就会导致原串不同。

排查建议

明确这几个阶段:

  • 参与签名的是原始字符串?
  • 还是 URL 编码后的字符串?
  • 编码发生在签名前还是签名后?

5. 随机数生成规则不同

有些站点的 nonce 看起来像随机,其实有固定格式:

  • 固定长度
  • 指定字符集
  • 前缀/后缀
  • 时间戳 + 随机段

如果你生成规则偏差太大,服务端也可能拒绝。


6. 浏览器环境依赖没补齐

有些签名函数依赖:

  • window
  • document
  • navigator.userAgent
  • location.href
  • localStorage

你直接把函数复制到 Node 跑,很可能报错,或者算出来不对。

处理方式

  • 补 mock 环境
  • 抽取纯计算函数
  • 在浏览器控制台先验证
  • 必要时用 jsdom 或轻量模拟对象

7. 混淆代码看不懂,搜索也搜不到

我踩过这个坑。站点把函数名压成了 n(), r(), o(),看起来很恶心。

应对思路

不要硬读所有代码,换成“围点打援”:

  • 从请求发起位置逆推
  • fetch / xhr.send 前打断点
  • 在参数对象写入点观察
  • Hook 哈希函数输入输出

一个很实用的 Hook 思路

如果你怀疑页面用了 md5sha256,可以在 Console 里临时包一层。
下面是一个示意写法:

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

如果你已经拿到了可疑签名函数,也可以直接包裹:

function wrapSign(fn) {
  return function (...args) {
    console.log('sign input:', args);
    const result = fn.apply(this, args);
    console.log('sign output:', result);
    return result;
  };
}

这类方式比“盲猜算法”高效得多。


安全/性能最佳实践

这里我想强调一个边界:本文讨论的是调试与复现方法,用于理解前端签名机制、排查接口问题、做自动化测试与安全研究。不要把它用于未授权场景。

安全实践

1. 不要把前端签名当成真正的安全边界

只要签名逻辑在前端运行,就存在被观察、被调试、被复现的可能。
真正关键的权限控制,仍然应该在服务端完成。

2. 服务端要做完整校验

至少应校验:

  • 签名正确性
  • 时间戳有效期
  • nonce 去重
  • 用户身份与 token 绑定
  • 请求频率限制

3. 避免硬编码高价值密钥到前端

前端里出现固定 secret,本质上只是“增加门槛”,不是“保密”。


性能实践

1. 复现脚本里缓存稳定参数

例如:

  • 固定 appId
  • 固定公共 Header
  • 静态业务参数模板

这样可以减少重复计算和调试噪音。

2. 把签名逻辑做成纯函数

这是我很推荐的工程化做法。

function signRequest(input) {
  // 不依赖外部环境
  // 输入明确,输出稳定
}

好处是:

  • 容易测试
  • 容易对比浏览器结果
  • 容易迁移到其他语言

3. 做最小化日志

调试时打印这些信息就够了:

  • 原始参数
  • 排序结果
  • 签名原串
  • 最终 sign

不要一股脑把所有对象都打出来,容易淹没关键信息。


建议的工程结构

如果你准备长期维护某类复现脚本,可以按下面方式拆分:

classDiagram
class RequestBuilder {
  +buildPayload(keyword)
  +buildHeaders()
}

class Signer {
  +buildSign(params, secret)
  +md5(text)
}

class Client {
  +search(keyword)
}

Client --> RequestBuilder
Client --> Signer

对应到代码目录可以是:

project/
  signer.js
  request-builder.js
  client.js
  index.js

这样后续遇到参数变更、Header 更新、签名升级时,不会改成一团。


总结

从浏览器 DevTools 到脚本复现,真正要掌握的不是某个网站的“答案”,而是一套稳定的方法:

  1. 先抓到目标请求
  2. 确认 sign 所在位置
  3. 通过 Initiator / Call Stack 找调用链
  4. 在签名前后打断点,拿到输入和输出
  5. 优先验证签名原串,而不是只看最终 hash
  6. 再迁移到 Node 脚本做可运行复现

如果你现在正卡在“脚本发出去总是签名错误”,我建议你先别继续猜算法,回到最基础的一步:

打印浏览器里的签名原串,再打印你脚本里的签名原串,逐字符比对。

绝大多数问题,都会在这里现形。

最后再强调一次边界条件:

  • 前端签名可以提高门槛,但不是最终安全防线
  • 复现时要关注环境依赖、编码细节和时间窗口
  • 适合用于授权调试、测试、研究,不适用于未授权调用

如果你把这套流程真正走顺了,之后再遇到 signtokenx-signature 这类字段,心里就不会慌了:
无非还是那件事——找到输入,确认规则,复现过程,逐步验证。


分享到:

上一篇
《区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-62》
下一篇
《微服务架构中服务拆分与边界治理实战:从领域划分到接口演进》