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

《Web逆向实战:基于浏览器 DevTools 与 AST 还原前端签名算法的完整方法》

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

Web逆向实战:基于浏览器 DevTools 与 AST 还原前端签名算法的完整方法

前端签名算法这件事,很多人第一次碰到时都会有点懵:接口明明找到了,请求参数也看清楚了,甚至 Cookie、Header 都补齐了,结果服务端还是返回“签名错误”或者“请求非法”。

这类问题的难点不在“会不会发请求”,而在于:你得先把浏览器里那段混淆、压缩、动态拼装的签名逻辑找出来,并确认输入、执行路径、输出格式都还原正确。

这篇文章我不讲“纯理论”,而是按一个可落地的实战路径来带你走一遍:

  1. Chrome DevTools 找到签名入口
  2. 沿调用链定位核心函数
  3. AST 对混淆代码做结构化还原
  4. 在本地复现一个可运行的签名函数
  5. 用验证清单排查“看起来一样但结果不一样”的坑

说明:本文讨论的是前端分析与算法理解方法,适用于调试、自测、安全研究、接口联调等合法场景。不要用于未授权的数据抓取或绕过访问控制。


背景与问题

现在很多站点会把接口签名放在前端执行,常见形式包括:

  • 请求参数排序后拼接,再做哈希
  • 时间戳 + 随机串 + 固定盐值
  • 对参数进行编码、位运算、字符替换
  • Webpack 打包后藏在某个模块里
  • 再配合混淆器,把变量名改成 a,b,_0x12ab

你会遇到几个典型问题:

  • Network 里看到了请求,但不知道签名在哪算的
  • 全局搜索 sign / token 根本找不到
  • 断点进去了,但调用栈层层封装,看不懂
  • 把函数抠出来运行,结果和浏览器里不一致
  • 算法里混入了环境依赖,比如 windowdocumentnavigatorDate.now()

我自己一开始也经常卡在“已经找到函数了,但本地跑不通”。后来总结下来,一个高效流程是:

先动态定位,再静态还原,最后最小化复现。

这比一上来就硬啃压缩代码省力很多。


前置知识与环境准备

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

  • 会看浏览器 Network / Sources / Console
  • 知道 JS 闭包、对象、数组、字符串常见操作
  • 对 Webpack 模块、source map、混淆代码有基本概念
  • 能运行 Node.js 脚本

环境准备

  • Chrome 或 Edge 最新版
  • Node.js 16+
  • 一个代码编辑器,比如 VS Code
  • AST 工具库:
    • @babel/parser
    • @babel/traverse
    • @babel/generator

安装示例:

npm init -y
npm install @babel/parser @babel/traverse @babel/generator

核心原理

在实战里,前端签名算法的定位通常依赖两条线并行推进:

  1. 动态分析线:DevTools 跟执行路径
  2. 静态分析线:AST 还原结构

一句话理解二者分工

  • DevTools 解决的是:
    “签名到底在哪一刻、哪一个函数里算出来的?”
  • AST 解决的是:
    “这段压缩/混淆代码怎样变得可读、可抽取、可复现?”

整体流程图

flowchart TD
    A[抓到目标请求] --> B[在 Network 查看请求参数/Header]
    B --> C[定位 sign/timestamp/noncestr 等字段]
    C --> D[在 Sources 中对 XHR/fetch 下断点]
    D --> E[跟调用栈找到签名函数]
    E --> F[记录输入参数与执行上下文]
    F --> G[导出相关 JS 代码]
    G --> H[使用 AST 做解混淆/重命名/常量折叠]
    H --> I[最小化复现签名函数]
    I --> J[对比浏览器结果验证]

背景与问题:签名算法通常藏在哪里

在真实站点中,签名逻辑常见于以下位置:

  • 请求封装器,比如 request(), http(), axios.interceptors.request.use
  • 某个工具模块,比如 utils.js, encrypt.js, security.js
  • Webpack 模块数组中的一个匿名函数
  • 运行时拼装代码,比如 evalnew Function
  • WASM 模块调用前后的 JS 包装层

所以,不要只搜“sign”这个词。很多时候它根本不叫 sign,而叫:

  • s
  • x-s
  • auth
  • token
  • _0x3f2a(...)
  • 甚至根本没字段名,而是最后统一塞进 Header

核心原理:如何用 DevTools 定位签名入口

方法 1:从目标请求反推

先打开 Network,找到目标接口,重点看:

  • Query String Parameters
  • Request Payload
  • Request Headers
  • 发起者 Initiator

你要先确认:

  • 哪个字段最像签名?
  • 是否和时间戳、随机串一起出现?
  • 每次刷新页面是否变化?

方法 2:在 XHR/fetch 断点处拦截

在 DevTools 的 Sources 面板中,找到:

  • XHR/fetch Breakpoints
  • 添加接口关键字,比如 /api/search

请求发起时会断住,此时看:

  • Call Stack
  • Scope
  • 当前函数参数
  • 上一层调用者

这个步骤特别关键,因为你能直接看到“签名前最后一跳”。

方法 3:猴子补丁(Hook)快速打印

如果站点代码很复杂,我常用 Console 先 hook 一层:

(function () {
  const oldFetch = window.fetch;
  window.fetch = async function (...args) {
    console.log('[fetch url]', args[0]);
    console.log('[fetch options]', args[1]);
    debugger;
    return oldFetch.apply(this, args);
  };

  const oldOpen = XMLHttpRequest.prototype.open;
  const oldSend = XMLHttpRequest.prototype.send;

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

  XMLHttpRequest.prototype.send = function (body) {
    console.log('[xhr]', this._method, this._url, body);
    debugger;
    return oldSend.call(this, body);
  };
})();

这样做的好处是:不需要先看懂整个项目,就能先卡住请求发起点。


调用链定位示意

sequenceDiagram
    participant U as 用户操作
    participant P as 页面业务代码
    participant R as 请求封装器
    participant S as 签名函数
    participant N as Network 请求

    U->>P: 点击按钮/翻页/搜索
    P->>R: 组装参数
    R->>S: 计算 sign
    S-->>R: 返回签名字符串
    R->>N: 发起请求

核心原理:为什么 AST 对还原签名算法很重要

很多前端签名逻辑不是“复杂”,而是“难读”。

比如原始代码可能长这样:

var _0xabc = function(_0x1, _0x2) {
  return _0x1['split']('')['reverse']()['join']('') + _0x2;
};

你肉眼当然也能看,但当代码变成几千行、嵌套十几层、字符串字典到处跳转时,人工阅读效率会急剧下降。

AST 的价值在于:

  • 把代码转成结构化树
  • 批量改变量名
  • 内联简单函数
  • 还原字符串常量
  • 删除迷惑性分支
  • 找出真正参与签名的路径

一个简单的 AST 处理思路

  1. 解析源码为 AST
  2. 找到特定函数定义
  3. 替换混淆变量名
  4. 输出更易读代码
  5. 手工补充少量语义

实战:从一个简化案例还原签名算法

为了让流程完整,这里我用一个可运行的简化案例模拟实际场景。

假设前端发请求前执行了如下混淆代码:

var _0xarr = ['join', 'split', 'reverse', 'toString'];
function _0xmix(a, b) {
  return a[_0xarr[1]]('')[_0xarr[2]]()[_0xarr[0]]('') + b;
}
function buildSign(params, ts) {
  var keys = Object.keys(params).sort();
  var str = '';
  for (var i = 0; i < keys.length; i++) {
    str += keys[i] + '=' + params[keys[i]] + '&';
  }
  str += 'ts=' + ts;
  return _0xmix(str, 'SALT');
}

它做的事其实很简单:

  1. 参数按 key 排序
  2. 拼接 k=v&
  3. 再加上 ts
  4. 整体字符串反转
  5. 末尾追加 SALT

第一步:在浏览器里确认输入与输出

假设页面里最终发请求前,断点看到:

const params = {
  q: 'phone',
  page: 2
};
const ts = 1700000000;
const sign = buildSign(params, ts);

我们先在 Console 验证:

const params = { q: 'phone', page: 2 };
const ts = 1700000000;

function _0xmix(a, b) {
  return a.split('').reverse().join('') + b;
}

function buildSign(params, ts) {
  var keys = Object.keys(params).sort();
  var str = '';
  for (var i = 0; i < keys.length; i++) {
    str += keys[i] + '=' + params[keys[i]] + '&';
  }
  str += 'ts=' + ts;
  return _0xmix(str, 'SALT');
}

console.log(buildSign(params, ts));

第二步:用 AST 把迷惑性结构还原

下面这段 Node.js 脚本演示一个最小 AST 处理:把 _0xarr[1] 这种数组索引替换成真正字符串。

deobfuscate.js

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

const code = `
var _0xarr = ['join', 'split', 'reverse', 'toString'];
function _0xmix(a, b) {
  return a[_0xarr[1]]('')[_0xarr[2]]()[_0xarr[0]]('') + b;
}
function buildSign(params, ts) {
  var keys = Object.keys(params).sort();
  var str = '';
  for (var i = 0; i < keys.length; i++) {
    str += keys[i] + '=' + params[keys[i]] + '&';
  }
  str += 'ts=' + ts;
  return _0xmix(str, 'SALT');
}
`;

const ast = parser.parse(code);

let arrMap = null;

// 先收集 _0xarr 数组内容
traverse(ast, {
  VariableDeclarator(path) {
    const { id, init } = path.node;
    if (
      t.isIdentifier(id, { name: '_0xarr' }) &&
      t.isArrayExpression(init)
    ) {
      arrMap = init.elements.map(el => el.value);
    }
  }
});

// 再替换 _0xarr[n]
traverse(ast, {
  MemberExpression(path) {
    const { object, property, computed } = path.node;
    if (
      computed &&
      t.isIdentifier(object, { name: '_0xarr' }) &&
      t.isNumericLiteral(property) &&
      arrMap
    ) {
      const value = arrMap[property.value];
      if (typeof value === 'string') {
        path.replaceWith(t.stringLiteral(value));
      }
    }
  }
});

const output = generate(ast, { retainLines: false }).code;
console.log(output);

运行:

node deobfuscate.js

可能得到输出:

var _0xarr = ['join', 'split', 'reverse', 'toString'];

function _0xmix(a, b) {
  return a["split"]('')["reverse"]()["join"]('') + b;
}

function buildSign(params, ts) {
  var keys = Object.keys(params).sort();
  var str = '';

  for (var i = 0; i < keys.length; i++) {
    str += keys[i] + '=' + params[keys[i]] + '&';
  }

  str += 'ts=' + ts;
  return _0xmix(str, 'SALT');
}

虽然这还不是“最终可读形态”,但已经比原始代码清晰很多了。


第三步:手工最小化复现签名函数

到这一步,不要急着把整份 JS 都搬到 Node 里。
正确做法是只保留与签名有关的最小依赖。

sign.js

function mix(str, salt) {
  return str.split('').reverse().join('') + salt;
}

function buildSign(params, ts) {
  const keys = Object.keys(params).sort();
  let str = '';

  for (const key of keys) {
    str += `${key}=${params[key]}&`;
  }

  str += `ts=${ts}`;
  return mix(str, 'SALT');
}

function buildSignedPayload(params) {
  const ts = 1700000000;
  const sign = buildSign(params, ts);
  return {
    ...params,
    ts,
    sign
  };
}

const result = buildSignedPayload({
  q: 'phone',
  page: 2
});

console.log(result);

运行:

node sign.js

第四步:逐步验证本地结果与浏览器一致

我建议每次都按这个顺序比对,不然很容易“整体不对但不知道差在哪”。

验证清单

  1. 原始参数对象是否一致
  2. 参数顺序是否一致
  3. 空值、布尔值、数组是否被特殊处理
  4. 时间戳单位是秒还是毫秒
  5. 拼接时是否多了 & 或少了分隔符
  6. 是否做了 encodeURIComponent
  7. 是否转成了 JSON 字符串
  8. 哈希前是否还有一层字符反转/替换/位运算
  9. 输出是小写还是大写
  10. 是否依赖浏览器环境字段

下面给一个很实用的“中间结果打印法”:

function debugBuildSign(params, ts) {
  const keys = Object.keys(params).sort();
  console.log('[keys]', keys);

  let str = '';
  for (const key of keys) {
    const pair = `${key}=${params[key]}&`;
    console.log('[pair]', pair);
    str += pair;
  }

  str += `ts=${ts}`;
  console.log('[plain]', str);

  const reversed = str.split('').reverse().join('');
  console.log('[reversed]', reversed);

  const sign = reversed + 'SALT';
  console.log('[sign]', sign);

  return sign;
}

debugBuildSign({ q: 'phone', page: 2 }, 1700000000);

很多时候你会发现,错的不是“大逻辑”,而是一个小细节,比如:

  • 你本地 page 是数字,浏览器里其实先转成字符串了
  • 你排序按对象插入顺序,浏览器实现里是显式 sort()
  • 浏览器里的 ts 用的是 Math.floor(Date.now()/1000)

一类典型复杂场景:签名函数嵌在模块加载体系里

现代前端项目里,经常是 Webpack/Vite 打包结果,签名函数可能藏在某个模块里,通过模块 ID 调用。

定位策略

  • 在请求前断点,看调用栈里出现哪些模块文件
  • 搜索请求 URL 片段、Header 名、常量盐值
  • 观察是否经过拦截器、hooks、公共 SDK
  • 在 Console 打印入口函数源码:
    • someFunc.toString()
  • 如果有 source map,优先切回源代码看

模块调用关系示意

classDiagram
    class Page {
      +search()
    }
    class RequestClient {
      +request(config)
      +setHeaders()
    }
    class SignModule {
      +buildCanonicalString(params)
      +sign(input)
    }
    class CryptoHelper {
      +reverse(str)
      +hash(str)
    }

    Page --> RequestClient
    RequestClient --> SignModule
    SignModule --> CryptoHelper

常见坑与排查

这一节非常重要。很多人不是不会找,而是容易掉进“差一点就成功”的坑里。

1. 断点打到了请求处,但签名已经算完了

现象:

  • 你在 fetch 上断住了
  • 看到请求 Header 里有 sign
  • 但上层调用栈已经看不到签名细节

排查方法:

  • 往上层函数逐步单步执行
  • 搜索对 headersparamsdata 的写入
  • setRequestHeader 或请求封装器下断点

2. 复制浏览器函数到 Node 后直接报错

常见原因:

  • 依赖 window
  • 依赖 document.cookie
  • 依赖 navigator.userAgent
  • 依赖 location.href
  • 依赖 Web Crypto API

解决办法:

  • 先 stub 最小环境
  • 不要全量模拟浏览器,只补必要字段

示例:

global.window = global;
global.navigator = {
  userAgent: 'Mozilla/5.0'
};
global.document = {
  cookie: 'sessionid=demo'
};
global.location = {
  href: 'https://example.com/page'
};

3. 结果只差一点,长度对但内容不对

这通常是编码问题。

重点检查:

  • encodeURIComponent 是否执行过
  • UTF-8 / Base64 是否一致
  • 哈希输入前是否转成字符串
  • 十六进制输出大小写是否一致

4. 混淆代码里有“假函数”或“烟雾弹分支”

经验上,很多混淆器会插一些没用的函数和死分支,目的就是拖慢你阅读。

判断方法:

  • 看函数返回值是否真正参与最终 sign
  • 看分支条件是不是恒真/恒假
  • 用 AST 做简单常量折叠
  • 在运行时打印真实执行路径

5. 时间戳和随机数导致每次结果不同

这个坑最常见。你以为算法没还原对,其实只是输入不固定。

建议:

  • 先把 Date.now() 固定
  • Math.random() 固定
  • 浏览器和本地都用同一组参数重放

示例:

Date.now = () => 1700000000000;
Math.random = () => 0.123456789;

6. 算法并不在 JS,而在 WASM

现象:

  • JS 里只看到一个很短的包装函数
  • 核心逻辑在 WebAssembly.instantiate 之后

处理建议:

  • 先确认签名是否真的由 WASM 生成
  • 观察 JS 与 WASM 的输入输出边界
  • 优先抓“入参”和“出参”,不一定要先逆向整个 wasm 二进制

安全/性能最佳实践

这部分我想讲得更务实一点:还原签名算法不只是“跑通”,还要保证过程稳、代码可维护、不会误伤线上环境。

1. 只做最小必要 Hook

Hook 太多会带来两个问题:

  • 页面行为异常
  • 你自己把调用链扰乱了

建议优先 Hook:

  • fetch
  • XMLHttpRequest.prototype.open/send
  • 必要时再 Hook Object.definePropertyevalFunction

2. 优先记录“边界数据”,不要盲目全量日志

最有价值的是这些信息:

  • 签名前原始参数
  • 时间戳、随机串
  • 签名函数输入字符串
  • 签名函数输出结果

而不是把整个页面所有脚本调用都打印出来。后者不仅噪声大,还会拖慢页面。

3. AST 改写要小步进行

我的建议是:

  • 一次只做一种转换
  • 每改一步就生成代码验证
  • 不要一口气做十种替换

因为 AST 处理一旦错了,最后输出“看起来更清晰”,但语义已经变了,排查反而更痛苦。

4. 本地复现时尽量剥离页面环境

目标是得到这种结构:

  • 一个纯函数 buildSign(params, ctx)
  • 一个上下文对象 ctx
  • 一个调用入口用于测试

而不是把整站 JS 原封不动搬来运行。越纯,越稳定,越容易自动化测试。

5. 建立测试样本集

至少保存 3 组浏览器抓到的样本:

  • 固定参数 + 固定时间戳
  • 含中文/特殊字符参数
  • 含空值/数组/嵌套对象参数

然后本地逐个回放,确保结果一致。


一个更接近实战的本地验证模板

下面给一个可直接改造的模板,适合你把浏览器里抠出的逻辑塞进去做验证。

verify-sign.js

function canonicalize(params) {
  return Object.keys(params)
    .sort()
    .map(key => `${key}=${params[key]}`)
    .join('&');
}

function signCore(input, salt) {
  return input.split('').reverse().join('') + salt;
}

function buildSign(params, ts, salt = 'SALT') {
  const plain = `${canonicalize(params)}&ts=${ts}`;
  return signCore(plain, salt);
}

function verifyCase() {
  const params = {
    page: 2,
    q: 'phone'
  };
  const ts = 1700000000;

  const plain = `${canonicalize(params)}&ts=${ts}`;
  const sign = buildSign(params, ts);

  console.log('plain =', plain);
  console.log('sign  =', sign);

  return { plain, sign };
}

if (require.main === module) {
  verifyCase();
}

module.exports = {
  canonicalize,
  signCore,
  buildSign
};

这个模板的好处是:

  • canonicalize 单独可测
  • signCore 单独可测
  • buildSign 负责组合流程
  • 后续替换成 md5/sha1/hmac/base64 也方便

逐步验证清单

如果你正在实战,我建议照着这份清单走,效率会高很多。

动态定位阶段

  • 在 Network 中锁定目标请求
  • 确认 sign 在 Query / Body / Header 中的位置
  • 对目标 URL 设置 XHR/fetch 断点
  • 记录断点处调用栈
  • 找到签名前最后一个加工函数

静态还原阶段

  • 导出相关 JS 代码片段
  • 标记常量、盐值、时间戳、随机串来源
  • 识别字符串字典、控制流平坦化、数组映射
  • 用 AST 替换简单索引访问
  • 对关键函数重命名

本地复现阶段

  • 固定时间戳与随机数
  • 保持参数顺序一致
  • 保持编码方式一致
  • 对比中间字符串
  • 对比最终 sign
  • 使用多组样本回归测试

总结

还原前端签名算法,最怕的是一开始就扎进混淆代码里硬读。更高效的方式其实是:

先用 DevTools 动态定位真实执行路径,再用 AST 把局部代码变得可读,最后在本地做最小化复现。

你可以把整套方法记成三句话:

  1. 找入口: 从请求出发,断在发起前
  2. 找核心: 沿调用栈定位真正参与签名的函数
  3. 找一致: 用固定输入对齐浏览器与本地输出

如果你已经是中级阶段,我特别建议把注意力放在这两个能力上:

  • 记录边界数据的能力:知道该记什么,少走弯路
  • 最小复现的能力:把一团项目代码缩成一个纯函数

最后给几个很实用的边界建议:

  • 如果页面逻辑复杂,优先抓“签名前输入”和“签名后输出”,不要先追求 100% 看懂全站代码
  • 如果本地结果总差一点,优先检查编码、排序、时间戳单位,而不是怀疑整个算法
  • 如果遇到 WASM、动态 eval、强混淆控制流,不要硬拆全量代码,先卡住输入输出边界

能稳定复现签名,往往不是因为你“看懂了所有代码”,而是因为你抓住了最关键的那条数据流。这就是 DevTools + AST 在 Web 逆向里最有价值的地方。


分享到:

上一篇
《集群架构中服务发现与负载均衡的实战设计:从注册中心到故障切换策略》
下一篇
《Web3 中级实战:基于 Solidity 与 Ethers.js 构建可升级智能合约的部署、交互与安全校验》