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

《Web逆向实战:基于浏览器抓包与 JavaScript 动态调试定位前端签名算法的完整方法》

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

Web逆向实战:基于浏览器抓包与 JavaScript 动态调试定位前端签名算法的完整方法

很多人第一次做 Web 逆向,都会卡在同一个地方:接口明明抓到了,请求参数也抄全了,但服务端就是提示签名错误
这类场景往往不是“少了个字段”这么简单,而是前端在发送请求前,动态计算了一个签名值,比如 signtokenauthx-signature 之类。

这篇文章我想从“实战排查”的角度,带你完整走一遍:

  1. 先用浏览器抓包确认签名存在;
  2. 再用 JavaScript 动态调试锁定签名生成位置;
  3. 最后把核心算法抽出来,在浏览器控制台或 Node.js 中复现。

重点不是某个站点的特定实现,而是一套可迁移的方法。只要页面签名逻辑运行在前端,这套思路大概率都能落地。


背景与问题

典型场景如下:

  • 页面接口请求头里带有 X-SignAuthorization 或加密参数;
  • 相同 URL、相同业务参数,直接重放请求却失败;
  • 请求成功必须依赖浏览器当前执行环境;
  • 页面 JS 经过混淆、压缩、Webpack 打包,直接搜关键字很难找到。

你会发现,问题并不在“会不会抓包”,而在于:

如何从一堆前端代码里,定位出签名算法的真实入口。

我当时踩过一个很典型的坑:只盯着 Network 面板里请求参数看,始终以为签名是请求发出前拼出来的。后来动态下断点才发现,签名是在一个公共请求封装器里统一注入的,而且字段名还会被二次映射。
所以经验是:不要一开始就猜算法,先确认调用链。


前置知识与环境准备

建议你至少具备这些基础:

  • 会用 Chrome DevTools
  • 知道 XHR / Fetch 的基本区别
  • 能看懂基本的 JavaScript
  • 对 MD5、SHA1、SHA256、HMAC、Base64、时间戳有基本概念

准备环境:

  • Chrome 浏览器
  • DevTools
  • 一个本地 Node.js 环境(用于复现算法)
  • 可选:Charles / Fiddler / mitmproxy 辅助观察流量

核心原理

前端签名算法的本质,通常是下面这条链路:

  1. 收集业务参数
  2. 补充公共参数,如时间戳、随机数、版本号
  3. 以固定顺序拼接
  4. 使用摘要或加密算法处理
  5. 将结果放到请求头或请求参数中

常见模式有:

  • sign = md5(sorted_query + secret)
  • sign = sha256(timestamp + body + nonce)
  • sign = hmac_sha256(data, key)
  • sign = base64(某种序列化结果)
  • JSON.stringify,再加密
  • 先经过 encodeURIComponent,再拼接

一个典型调用路径

flowchart TD
    A[用户触发请求] --> B[业务层组装参数]
    B --> C[公共请求封装器]
    C --> D[注入时间戳/nonce]
    D --> E[调用签名函数]
    E --> F[写入Header或Query]
    F --> G[发送XHR/Fetch请求]

从逆向角度看,真正关键的不是“算法名字”,而是这几个问题:

  • 签名前的原始数据是什么?
  • 字段排序规则是什么?
  • 是否过滤空值?
  • 是否有固定盐值或 secret?
  • 是否依赖浏览器环境,如 window.navigatorlocalStorage、cookie?
  • 是否有二次编码?

方法总览:先抓包,再断点,再复现

如果让我总结成一句操作原则,就是:

从网络请求反推调用点,再从调用点回溯签名输入。

整体步骤图

flowchart LR
    A[Network抓到目标请求] --> B[确认签名字段位置]
    B --> C[观察Payload/Header变化]
    C --> D[Sources中拦截XHR或Fetch]
    D --> E[定位请求封装函数]
    E --> F[回溯sign生成函数]
    F --> G[提取输入参数与算法]
    G --> H[在控制台/Node中复现]

第一步:浏览器抓包,确认签名在哪

打开 DevTools 的 Network 面板,触发目标请求,重点看三处:

  • Headers:请求头里是否有签名字段
  • Payload / Query String Parameters:是否有 sign
  • Initiator:由哪个脚本触发

重点观察内容

  1. 签名是在 Header 还是 Body 里?
  2. 同一接口多次请求,签名会不会变化?
  3. 请求体不变时,签名是否仍会变化?
  4. 是否存在时间戳、nonce、traceId 之类的动态字段?

比如看到:

POST /api/data/list
Content-Type: application/json
X-Timestamp: 1710000000
X-Nonce: 8f3d1c
X-Sign: 5f4dcc3b5aa765d61d8327deb882cf99

这时先别急着猜 X-Sign 是 MD5。
更应该立刻想到:

  • X-Sign 很可能依赖 X-Timestamp
  • X-Nonce 可能也参与签名
  • 如果 Body 是 JSON,签名输入可能是字符串化后的 JSON

一个简单验证动作

重复发送相同请求,观察:

  • 只改时间戳,签名是否变化?
  • 只改一个业务字段,签名是否变化?
  • 不刷新页面,多次发相同请求,nonce 是否递增?

这些现象会直接缩小排查范围。


第二步:定位请求发起方式

现代站点常见两种:

  • XMLHttpRequest
  • fetch

要先确认页面是怎么发请求的,否则断点位置会打偏。

方法 1:看 Network 的 Initiator

在请求详情中查看 Initiator,可以直接跳到调用栈相关脚本。

方法 2:全局 Hook

如果代码太混淆,我常用这种“笨但有效”的方式:先 Hook 一层,把参数打印出来。

Hook XHR

(() => {
  const open = XMLHttpRequest.prototype.open;
  const send = XMLHttpRequest.prototype.send;
  const setRequestHeader = XMLHttpRequest.prototype.setRequestHeader;

  XMLHttpRequest.prototype.open = function(method, url, ...rest) {
    this._method = method;
    this._url = url;
    console.log('[XHR open]', method, url);
    return open.call(this, method, url, ...rest);
  };

  XMLHttpRequest.prototype.setRequestHeader = function(key, value) {
    if (!this._headers) this._headers = {};
    this._headers[key] = value;
    console.log('[XHR header]', key, value);
    return setRequestHeader.call(this, key, value);
  };

  XMLHttpRequest.prototype.send = function(body) {
    console.log('[XHR send]', {
      method: this._method,
      url: this._url,
      headers: this._headers,
      body
    });
    return send.call(this, body);
  };
})();

Hook Fetch

(() => {
  const rawFetch = window.fetch;
  window.fetch = async function(input, init = {}) {
    console.log('[fetch request]', {
      input,
      init
    });
    const res = await rawFetch.apply(this, arguments);
    return res;
  };
})();

这段代码可以直接在控制台运行。
作用不是“破解”,而是先看清:

  • 请求在哪发
  • Header 什么时候被写入
  • Body 最终长什么样

第三步:动态调试,卡住签名生成瞬间

这是整篇最关键的部分。

如果你已经知道签名字段名,比如 X-Sign,那么最直接的方法是:

方法 A:对 setRequestHeader 下断点

当请求头被设置时,断住执行,再顺着调用栈往上找。

示例思路

  1. 在 Sources 中找到 XMLHttpRequest.prototype.setRequestHeader
  2. 或者直接用上面 Hook 代码,在打印处加 debugger
(() => {
  const raw = XMLHttpRequest.prototype.setRequestHeader;
  XMLHttpRequest.prototype.setRequestHeader = function(key, value) {
    if (String(key).toLowerCase().includes('sign')) {
      console.log('命中签名Header', key, value);
      debugger;
    }
    return raw.call(this, key, value);
  };
})();

页面再次发请求时,会在浏览器里停住。
这时看右侧的:

  • Call Stack
  • Scope
  • Local Variables

你通常能看到:

  • 当前签名值
  • 调用签名函数的上层函数
  • 输入数据对象
  • 时间戳、nonce 等中间变量

方法 B:XHR/Fetch Breakpoints

Chrome DevTools 自带断点能力:

  • Sources
  • XHR/fetch Breakpoints
  • 添加目标 URL 关键字,比如 /api/data/list

请求发起时就会中断。

这招适合你已经知道目标接口,但不知道代码入口在哪。

方法 C:搜索可疑特征

如果签名在 Body 里,不好从 Header 下断点,可以搜这些关键词:

  • md5
  • sha1
  • sha256
  • hmac
  • CryptoJS
  • sign
  • token
  • nonce
  • timestamp
  • sort
  • Object.keys

但要注意,现代打包代码中变量名可能被压缩成 a, b, c
此时搜索“算法库名”往往比搜“业务字段名”更有效。


第四步:识别签名输入与处理顺序

找到签名函数后,不要只抄最后一行。
真正要搞清楚的是:输入是怎么来的。

常见签名伪代码

function buildSign(params, secret) {
  const keys = Object.keys(params)
    .filter(k => params[k] !== '' && params[k] !== null && params[k] !== undefined)
    .sort();

  const plain = keys.map(k => `${k}=${params[k]}`).join('&');
  return md5(plain + secret);
}

这里面至少有 4 个关键点:

  1. 空值被过滤
  2. 键名按字典序排序
  3. & 拼接
  4. 最后拼上 secret

如果你漏掉任何一个,结果都对不上。

一个常见误区

很多同学看到 JSON.stringify(body) 就直接照搬。
但你要注意:

  • 字段顺序是否稳定?
  • 是不是先排序再 stringify?
  • 嵌套对象是否递归排序?
  • 数组是否参与排序?

这类差异会直接影响结果。

签名生成时序图

sequenceDiagram
    participant U as 用户操作
    participant P as 页面业务逻辑
    participant R as 请求封装器
    participant S as 签名函数
    participant A as 接口服务端

    U->>P: 触发查询
    P->>R: 传入业务参数
    R->>R: 注入timestamp/nonce
    R->>S: 传入待签名数据
    S-->>R: 返回sign
    R->>A: 发送带sign的请求
    A-->>R: 校验通过并返回数据

实战代码:从页面签名到 Node.js 复现

下面给一个可运行的完整示例。
这个示例模拟常见前端签名规则:

  • 合并业务参数与公共参数
  • 过滤空值
  • 键名排序
  • 拼接成查询串
  • md5(query + secret)

注意:这里是教学示例,用于演示定位与复现方法,不针对任何具体站点。

浏览器端模拟代码

可以直接在控制台执行:

function fakeMd5(str) {
  // 教学环境下不引第三方库,这里不是真实 md5
  // 真正复现请使用 CryptoJS 或 Node.js crypto
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) - hash) + str.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash).toString(16);
}

function buildQuery(params) {
  return Object.keys(params)
    .filter(key => params[key] !== '' && params[key] !== null && params[key] !== undefined)
    .sort()
    .map(key => `${key}=${String(params[key])}`)
    .join('&');
}

function signParams(params, secret) {
  const plain = buildQuery(params);
  const sign = fakeMd5(plain + secret);
  return { plain, sign };
}

function sendRequest() {
  const data = {
    keyword: 'phone',
    page: 1,
    pageSize: 20,
    timestamp: 1710000000,
    nonce: 'abc123'
  };

  const secret = 'demo_secret';
  const { plain, sign } = signParams(data, secret);

  console.log('待签名字符串:', plain);
  console.log('签名结果:', sign);

  return {
    url: '/api/data/list',
    headers: {
      'X-Sign': sign,
      'X-Timestamp': data.timestamp,
      'X-Nonce': data.nonce
    },
    body: JSON.stringify(data)
  };
}

console.log(sendRequest());

Node.js 复现代码

下面用 Node.js 自带 crypto 做一个真实 MD5 版本:

const crypto = require('crypto');

function buildQuery(params) {
  return Object.keys(params)
    .filter(key => params[key] !== '' && params[key] !== null && params[key] !== undefined)
    .sort()
    .map(key => `${key}=${String(params[key])}`)
    .join('&');
}

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

function signParams(params, secret) {
  const plain = buildQuery(params);
  return {
    plain,
    sign: md5(plain + secret)
  };
}

const params = {
  keyword: 'phone',
  page: 1,
  pageSize: 20,
  timestamp: 1710000000,
  nonce: 'abc123'
};

const secret = 'demo_secret';
const result = signParams(params, secret);

console.log('待签名字符串:', result.plain);
console.log('签名结果:', result.sign);

运行方式

node sign-demo.js

逐步验证清单

我建议你在复现时,不要一步到位直接跑最终请求,而是按下面顺序逐项确认:

1. 先验证原始输入

  • 业务参数是否完整
  • 公共参数是否一致
  • 时间戳单位是秒还是毫秒
  • nonce 是否需要随机生成

2. 再验证拼接结果

先打印待签名字符串:

console.log(plain);

确保它与浏览器调试时看到的字符串一致。

3. 再验证摘要输出

如果浏览器里生成的是小写十六进制:

  • 你在 Node 里也要输出小写
  • 不要误用 Base64

4. 最后再发请求

如果签名已对上,请求仍失败,再排查:

  • Cookie
  • Origin / Referer
  • CSRF Token
  • 本地存储中的设备标识
  • Header 大小写差异
  • 请求体序列化方式

常见坑与排查

这部分非常重要,很多“明明算法对了还是不行”的问题,都出在这里。

1. 时间戳单位不一致

常见两种:

  • 秒:1710000000
  • 毫秒:1710000000000

如果服务端校验时间窗口,单位错了会直接失败。

2. 排序规则理解错

有些站点不是简单 sort(),而是:

  • ASCII 排序
  • 只排第一层对象
  • 排序后转大写键名
  • 排除某些字段,如 sign 本身

3. 参数值被编码过

比如浏览器里真正参与签名的是:

encodeURIComponent(value)

而你复现时直接拿原始中文字符串去算,结果必然不同。

4. JSON 序列化不一致

这类问题最隐蔽。比如:

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

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

在某些语言里生成顺序未必一致。
如果签名依赖原始 JSON 字符串,你必须还原一致的序列化过程。

5. Webpack 打包后函数名全变了

不要执着于找 sign() 这种“好看”的函数名。
现实里常见的是:

t(n(u(e)))

这时更有效的方法是:

  • 从请求发送点向上追调用栈
  • 看局部变量内容
  • 对关键中间值加条件断点

6. 签名依赖运行时环境

例如:

  • document.cookie
  • localStorage.getItem('token')
  • navigator.userAgent
  • canvas 指纹
  • window.location.href

这意味着你在纯 Node 环境复现时,可能需要补环境或手动传值。

7. 请求发送前被二次处理

有些框架会在请求拦截器里再次改参数,例如 Axios:

  • request interceptor 中加 sign
  • transformRequest 中重写 body

所以看到业务代码里没有签名,不代表真的没算。


一个实用的定位套路

如果你现在面对的是一个真实页面,我建议按这个顺序来:

  1. Network 确认签名字段
  2. 看 Initiator
  3. Hook XHR / Fetch
  4. setRequestHeader 或目标 URL 下断点
  5. 在断点现场查看局部变量
  6. 找到签名函数入口
  7. 抽取最小复现代码
  8. 先打印待签名字符串,再比对摘要结果

这套流程最大的优势是:
不依赖你一开始就能读懂混淆代码。


安全/性能最佳实践

这里补一句边界条件。本文讨论的是前端签名定位方法,适用于:

  • 自有系统联调
  • 安全研究
  • 接口兼容分析
  • 合法授权的测试环境

不应用于未授权的数据获取或绕过访问控制。

安全建议

1. 不要把真正的长期密钥放前端

如果前端能直接算签名,那么密钥迟早可能暴露。
更合理的方式是:

  • 前端只持有短期令牌
  • 核心签名在服务端完成
  • 使用时效性强的临时凭证

2. 签名要结合时效与上下文

只校验固定摘要是不够的,建议至少加入:

  • 时间戳
  • nonce
  • 用户会话信息
  • 请求路径
  • 请求体摘要

这样能降低重放风险。

3. 前端混淆不是安全边界

混淆、压缩、拆分模块只能增加分析成本,不能替代真正的鉴权设计。

性能建议

1. 避免重复计算大对象签名

如果请求体很大,频繁 JSON.stringify + hash 会有明显开销。
可以考虑:

  • 仅签关键字段
  • 做增量签名
  • 避免不必要的深拷贝

2. 公共请求封装要可观测

建议在开发环境保留:

  • 请求拦截日志
  • 签名原文输出开关
  • 错误码追踪

否则线上一旦签名失效,排查成本会很高。


调试现场示例:如何从断点一路追到算法

这里再给一个更贴近真实场景的例子。

假设你在断点处看到这样的代码:

var payload = {
  keyword: "phone",
  page: 1
};

var common = {
  timestamp: Date.now(),
  nonce: randomString(6)
};

var finalData = merge(payload, common);
var sign = h(finalData);
xhr.setRequestHeader("X-Sign", sign);

这时不要只看 h(finalData)
你应该继续点进 h,确认它内部到底做了什么:

function h(data) {
  var keys = Object.keys(data).sort();
  var text = "";
  for (var i = 0; i < keys.length; i++) {
    text += keys[i] + "=" + data[keys[i]] + "&";
  }
  text += "k=" + "secret123";
  return md5(text);
}

那么你要记录的不是“它用了 md5”,而是完整规则:

  • 键名排序
  • 拼接格式是 k=v&
  • 最后多拼了 k=secret123
  • 没有过滤最后一个 &,而是继续拼 secret
  • 输出为 md5 小写 hex

这些细节才是复现成功的关键。


总结

前端签名逆向最怕两件事:

  • 一上来就陷入混淆代码细节
  • 只关注结果,不追踪输入和时序

更稳妥的方法是:

  1. 先抓包确认签名位置
  2. 再用 Hook 和断点拦住请求发送现场
  3. 顺着调用栈回溯签名函数
  4. 提取输入、排序、编码、拼接、摘要规则
  5. 用最小代码在浏览器或 Node 中复现
  6. 逐项对比待签名字符串,而不是只比最终 sign

如果你只能记住一个建议,那就是这个:

先打印“待签名字符串”,再谈算法复现。

因为很多时候,问题根本不在 MD5、SHA256 本身,而在签名之前的数据处理步骤。
把这个步骤吃透,绝大多数前端签名定位都会顺很多。


分享到:

上一篇
《从 Prompt Engineering 到 Agent Workflow:中级开发者构建可落地 AI 自动化流程的实践指南》
下一篇
《Java开发踩坑实战:定位并修复线程池误用导致的接口雪崩问题》