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

《Web逆向实战:从接口签名分析到自动化还原的完整方法论》

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

Web逆向实战:从接口签名分析到自动化还原的完整方法论

做 Web 逆向时,很多人一上来就盯着“加密算法”本身,结果越看越乱。实际上,真正决定效率的不是你会不会背某个 MD5/SHA/AES,而是有没有一套稳定的方法论:先定位、再缩圈、后还原、最终自动化。

这篇文章我会从一个中级开发者/逆向学习者更容易落地的角度,带你走完整个流程:从接口签名分析,到在 Node.js 中自动化复现签名。重点不是“炫技”,而是把事情做成。

说明:本文内容用于学习 Web 安全与前端调试分析方法,请在合法授权范围内使用。


背景与问题

很多站点的接口并不是简单发个 fetch 就能拿到数据,常见会遇到这些情况:

  • 请求里带有 signtokennoncetimestamp
  • 参数顺序变了,签名就失效
  • 前端代码经过混淆、压缩、webpack 打包
  • 签名依赖浏览器环境,如 windowdocumentnavigator
  • 服务端还会校验时间漂移、重放请求、UA、Referer

初学者常见误区有两个:

  1. 看到加密字段就直接搜 MD5
  2. 复制浏览器请求头后机械重放

这两种方式在简单场景下可能蒙对,但在稍复杂的站点上几乎走不通。

更靠谱的思路是:

  • 先明确:接口到底校验了什么
  • 再搞清:签名输入是什么、顺序是什么、有没有环境依赖
  • 最后实现:最小可运行还原代码

前置知识

如果你准备跟着做,建议先具备这些基础:

  • 会用 Chrome DevTools 看 Network / Sources
  • 知道 fetch / XMLHttpRequest 的基本调用方式
  • 了解常见摘要算法:MD5、SHA1、SHA256
  • 能读基础 JavaScript,知道闭包、对象、数组、字符串处理
  • 会用 Node.js 跑脚本

环境准备

本文示例使用以下环境:

  • Chrome 或 Edge
  • Node.js 16+
  • 一个可编辑器,如 VS Code
  • 可选:mitmproxy / Charles / Fiddler
  • 可选:AST 工具,如 @babel/parser

安装 Node 环境后,可先准备一个目录:

mkdir web-sign-demo
cd web-sign-demo
npm init -y

如果后面要跑服务端验证示例,再安装依赖:

npm install express

先建立整体方法论

在我自己的实战里,签名还原通常按下面 5 步做。顺序很重要,别跳步。

flowchart TD
    A[抓包定位目标接口] --> B[识别关键参数 sign/token/t]
    B --> C[全局搜索参数生成位置]
    C --> D[还原签名输入与算法]
    D --> E[脱离浏览器自动化复现]
    E --> F[逐步校验与稳定运行]

你会发现,真正难的往往不是“加密”,而是中间的两件事:

  • 定位是谁生成了签名
  • 判断签名依赖了哪些上下文

背景与问题:一个典型接口长什么样

假设我们看到一个请求:

POST /api/list HTTP/1.1
Content-Type: application/json

{
  "page": 1,
  "size": 20,
  "timestamp": 1720000000,
  "nonce": "8f3a2d1c",
  "sign": "5b4f2f6d3d8b..."
}

返回结果如果签名不对,通常会是:

{
  "code": 403,
  "msg": "invalid sign"
}

这时不要急着猜算法,先问 4 个问题:

  1. sign 是基于哪些字段算出来的?
  2. 字段有没有排序?
  3. 是否拼接了某个固定 secret?
  4. 有没有额外环境值参与,如 UA、Cookie、设备指纹?

核心原理

1. 接口签名的本质

绝大多数前端接口签名,本质上是在做下面几件事之一:

  • 参数规范化:把请求参数按特定规则拼成字符串
  • 摘要/加密:对字符串做 MD5/SHA/HMAC/AES 等处理
  • 附加盐值:拼接固定 secret、版本号、时间戳
  • 环境绑定:把 Cookie、UA、设备信息等混进去

一个简化版签名公式可能是:

sign = md5("nonce=xxx&page=1&size=20&timestamp=1720000000" + secret)

也可能是:

sign = sha256(sort(params) + "|" + ua + "|" + secret)

2. 为什么前端必须暴露逻辑

很多同学觉得“既然有签名,那一定很安全”。其实从 Web 逆向角度看,只要签名在浏览器里计算,逻辑就一定会暴露到客户端。区别只在于:

  • 是不是混淆了
  • 是不是拆在多个模块里
  • 是不是加了环境校验
  • 是不是做了动态下发

也就是说,Web 逆向的核心不是“破解密码学”,而是恢复业务逻辑和执行路径

3. 逆向分析的三个对象

我一般会把目标拆成三层:

classDiagram
    class Request {
      +url
      +method
      +headers
      +body
    }

    class Signature {
      +timestamp
      +nonce
      +sign
      +algorithm
      +serializeRule
    }

    class RuntimeEnv {
      +window
      +document
      +navigator
      +cookie
      +localStorage
    }

    Request --> Signature
    Signature --> RuntimeEnv

这张图的意思很简单:

  • Request:你看到的请求长什么样
  • Signature:签名字段如何生成
  • RuntimeEnv:生成签名时依赖了什么浏览器环境

很多“明明算法找到了但跑不通”的问题,其实都卡在第三层。


实战思路:从抓包到定位签名函数

下面我用一个可运行的简化案例带你走流程。虽然示例是教学版,但流程和真实站点一致。

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

先找目标接口,重点看:

  • Query String Parameters
  • Request Payload
  • Request Headers
  • Response 中的报错信息

假设我们发现每次请求都有这些字段:

  • page
  • size
  • timestamp
  • nonce
  • sign

这时可以先做一个简单实验:

  • page,看 sign 是否变化
  • 保持参数不变只重发,看 nonce / timestamp 是否变化
  • 删掉 sign 看服务端返回什么

第二步:全局搜索关键字段

在 Sources 面板里搜索:

  • "sign"
  • "timestamp"
  • "nonce"
  • 接口路径的一部分,如 "/api/list"

通常能搜到以下线索之一:

data.sign = m(n(data))

或者:

const sign = crypto(params, secret)

或者 webpack 打包后的形式:

r.a = function(e){return i()(o()(e)+c)}

别被这种压缩形式吓到。你要做的是:

  • 找到请求发送点
  • 往上回溯 sign 是在哪一层注入的
  • 在关键函数上打断点

第三步:打断点看“签名前原文”

这一招非常关键。很多人花大量时间在猜算法,我更推荐先看签名输入字符串

例如在生成签名前一行打断点,观察:

const raw = buildQuery(params) + secret;
const sign = md5(raw);

只要你看到了 raw,问题就已经解决了一半。

重点确认:

  • 参数是否排序
  • 是否过滤空值
  • 是否 URL 编码
  • 是否拼接固定 secret
  • 字符串分隔符是什么:&|,、空串

一个完整的教学案例

下面先构造一个服务端,再写一个客户端去复现它的签名。这样你可以本地完整跑通。


实战代码(可运行)

1. 服务端:校验签名

创建 server.js

const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const SECRET = 'demo_secret_2024';

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

  const query = keys.map(k => `${k}=${params[k]}`).join('&');
  return crypto
    .createHash('md5')
    .update(query + SECRET, 'utf8')
    .digest('hex');
}

app.post('/api/list', (req, res) => {
  const body = req.body || {};
  const clientSign = body.sign || '';
  const serverSign = buildSign(body);

  if (clientSign !== serverSign) {
    return res.status(403).json({
      code: 403,
      msg: 'invalid sign',
      expect: serverSign
    });
  }

  res.json({
    code: 0,
    data: {
      list: [
        { id: 1, name: 'nodejs' },
        { id: 2, name: 'reverse' }
      ],
      page: body.page,
      size: body.size
    }
  });
});

app.listen(3000, () => {
  console.log('server running at http://localhost:3000');
});

启动:

node server.js

2. 客户端:自动生成签名并请求接口

创建 client.js

const crypto = require('crypto');

const SECRET = 'demo_secret_2024';

function randomNonce(len = 8) {
  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
  let s = '';
  for (let i = 0; i < len; i++) {
    s += chars[Math.floor(Math.random() * chars.length)];
  }
  return s;
}

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

  const query = keys.map(k => `${k}=${params[k]}`).join('&');
  return crypto
    .createHash('md5')
    .update(query + SECRET, 'utf8')
    .digest('hex');
}

async function main() {
  const payload = {
    page: 1,
    size: 20,
    timestamp: Math.floor(Date.now() / 1000),
    nonce: randomNonce()
  };

  payload.sign = buildSign(payload);

  const resp = await fetch('http://localhost:3000/api/list', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(payload)
  });

  const data = await resp.json();
  console.log('request payload:', payload);
  console.log('response:', data);
}

main().catch(console.error);

运行:

node client.js

3. 故意制造错误,验证你的理解

buildSign 中的 .sort() 去掉,再执行一次。你大概率会看到:

{
  "code": 403,
  "msg": "invalid sign"
}

这一步特别重要,因为它能帮你建立一个概念:

签名还原不是“算法对了就行”,而是参数处理规则也必须完全一致


如何把浏览器里的签名逻辑“搬”出来

真实站点的难点通常不是你自己写签名,而是把网站前端里的逻辑提取出来

我一般用下面这条路线:

sequenceDiagram
    participant U as 你
    participant B as 浏览器页面
    participant J as JS签名函数
    participant S as 服务器

    U->>B: 触发接口请求
    B->>J: 组装参数并计算sign
    J-->>B: 返回sign
    B->>S: 发起带sign请求
    S-->>B: 校验通过并返回数据
    U->>J: 提取/复刻签名逻辑
    U->>S: 脱离浏览器自动请求

方法一:直接复刻逻辑

如果签名函数不复杂,比如:

  • 参数排序
  • 拼接字符串
  • MD5/SHA

那最稳妥的方式是直接在 Node.js 里重写。优点是:

  • 代码清晰
  • 依赖少
  • 性能稳定
  • 后期维护容易

方法二:执行原始 JS

如果签名逻辑非常绕,且依赖大量原始代码,可以考虑:

  • 把目标函数和依赖模块扣出来
  • 在 Node.js 中补齐 windowdocument 等最小环境
  • 直接执行原始 JS 获取签名

例如:

global.window = global;
global.navigator = {
  userAgent: 'Mozilla/5.0 Demo'
};
global.document = {
  cookie: ''
};

然后 require('./sign.js')eval 对应代码。

这种方式适合:

  • webpack 模块较多
  • 算法夹杂大量业务逻辑
  • 短期验证优先

但缺点也明显:

  • 环境兼容问题多
  • 升级后容易失效
  • 调试成本高

方法三:浏览器内调用后对外暴露

还有一种折中方案:在浏览器环境中注入脚本,直接调用页面上的签名函数,再把结果通过接口或控制台拿出来。

适合:

  • 目标函数强依赖 DOM/Canvas/WebCrypto
  • Node.js 模拟成本太高
  • 需要快速 PoC

不过从长期自动化角度,优先级通常不如“纯 Node 复刻”


逐步验证清单

这一节我强烈建议你在真实项目里照着做。很多问题不是不会,而是没建立“逐步收敛”的习惯。

第 1 层:只验证参数结构

确认请求是否至少长得像浏览器请求:

  • URL 对不对
  • 方法对不对
  • Body 字段齐不齐
  • Content-Type 是否一致

第 2 层:只验证签名输入串

打印:

console.log(query);
console.log(query + SECRET);

拿它和浏览器断点看到的内容逐字符对比。

第 3 层:验证摘要算法

分别尝试:

  • MD5
  • SHA1
  • SHA256
  • HMAC-MD5
  • HMAC-SHA256

如果你已经看到原始代码,这一步只是确认,不是瞎猜。

第 4 层:验证环境依赖

观察是否依赖:

  • navigator.userAgent
  • document.cookie
  • location.href
  • localStorage
  • 某个动态下发 token

第 5 层:验证时序问题

有些接口会检查:

  • 时间戳必须在 5 秒或 60 秒窗口内
  • nonce 不能重复
  • token 必须先通过前一个接口换取

常见坑与排查

这一节是最容易帮你省时间的。我自己踩过不少坑,很多问题看起来像“算法不对”,其实不是。

1. 参数顺序不一致

最常见。

浏览器可能会:

  • 按 key 字典序排序
  • 按对象插入顺序
  • 按固定白名单顺序

排查方法:

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

别主观判断,一定看原代码。


2. 空值处理不一致

比如前端会过滤:

  • undefined
  • null
  • ''

但你在 Node 里全拼上去了,签名就不同。

错误示例:

page=1&keyword=&size=20

真实可能应该是:

page=1&size=20

3. URL 编码规则不同

有些站点签名前会先做编码:

encodeURIComponent(value)

有些不会。

尤其是参数包含中文、空格、斜杠、加号时,非常容易翻车。

排查建议:

  • 把签名前字符串原样打印
  • 把浏览器里的中间结果复制出来逐字符比对

4. 时间戳单位搞错

常见有两种:

  • 秒级:1720000000
  • 毫秒级:1720000000000

看起来差不多,实际直接导致失败。


5. 签名外还有前置 token

很多接口不是只有一个 sign,还会有:

  • 先请求 /init 拿 token
  • 再请求业务接口
  • token 还要写入 header 或 cookie

也就是说,你看到的 sign 只是校验链中的一环。


6. Node 与浏览器环境差异

原始 JS 里常见这些依赖:

window
document
navigator
atob
btoa
crypto.subtle

如果直接在 Node 跑,可能报:

ReferenceError: window is not defined

解决思路:

  • 缺什么补什么
  • 只补最小依赖
  • 能重写就别硬搬整页代码

7. 混淆代码误导判断

有些混淆代码会把简单逻辑搞得很复杂,比如:

  • 字符串数组映射
  • 控制流平坦化
  • 动态索引访问
  • 自执行函数套娃

这时候不要先“读懂全部”,要先找:

  • 请求发起点
  • 关键参数赋值点
  • 最终摘要调用点

抓主干,比通读所有混淆代码有效得多。


一个更贴近实战的“扣函数”示例

假设你在页面里找到了类似函数:

function signPayload(payload) {
  var base = Object.keys(payload)
    .filter(function(k) {
      return payload[k] !== '' && payload[k] != null;
    })
    .sort()
    .map(function(k) {
      return k + '=' + payload[k];
    })
    .join('&');

  return md5(base + getSecret() + navigator.userAgent);
}

那么你在 Node 里最小复现时,需要注意的不只是 md5,还有:

  • getSecret() 返回值
  • navigator.userAgent

可以这样还原:

const crypto = require('crypto');

global.navigator = {
  userAgent: 'Mozilla/5.0 Demo'
};

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

function getSecret() {
  return 'demo_secret_2024';
}

function signPayload(payload) {
  const base = Object.keys(payload)
    .filter(k => payload[k] !== '' && payload[k] != null)
    .sort()
    .map(k => `${k}=${payload[k]}`)
    .join('&');

  return md5(base + getSecret() + navigator.userAgent);
}

const payload = {
  page: 1,
  size: 20,
  timestamp: 1720000000
};

console.log(signPayload(payload));

这个例子说明一件事:

签名函数本身只是表层,真正要还原的是它的“输入闭环”。


安全/性能最佳实践

这一节站在工程落地角度说,不只是“能跑”,还要“跑得稳”。

1. 优先做“最小还原”

不要一上来把整个站点 JS 都搬进 Node。

更好的做法:

  • 只扣签名函数
  • 只保留必要依赖
  • 只补最小环境

这样好处是:

  • 排查链路短
  • 性能更好
  • 升级后更容易修

2. 建立中间结果日志

建议至少打印这些内容:

console.log('payload:', payload);
console.log('sorted keys:', keys);
console.log('raw string:', query);
console.log('sign:', sign);

生产环境可以做成 debug 开关:

const DEBUG = true;
if (DEBUG) {
  console.log({ payload, query, sign });
}

我实际做自动化时,很多问题就是靠“中间态可见”快速定位的。


3. 给签名逻辑写单元测试

尤其是你已经把浏览器逻辑重写成 Node 函数后,最好写几个固定用例。

const assert = require('assert');

const payload = {
  page: 1,
  size: 20,
  timestamp: 1720000000,
  nonce: 'abcd1234'
};

const sign = buildSign(payload);
assert.strictEqual(typeof sign, 'string');
assert.strictEqual(sign.length, 32);
console.log('test passed');

这样站点更新时,你会更快知道是哪一层变了。


4. 避免高频重复计算

如果某些 token 或签名在一定时间内可复用,不要每次都重算。

可以做简单缓存:

const cache = new Map();

function getCached(key) {
  const item = cache.get(key);
  if (!item) return null;
  if (Date.now() > item.expire) {
    cache.delete(key);
    return null;
  }
  return item.value;
}

function setCached(key, value, ttlMs) {
  cache.set(key, {
    value,
    expire: Date.now() + ttlMs
  });
}

适用于:

  • 初始化 token
  • 配置下发结果
  • 短期有效签名种子

5. 注意合法合规与访问边界

这点必须强调:

  • 仅在授权范围内分析和验证
  • 不要绕过访问控制进行未授权抓取
  • 不要对目标服务发起高并发冲击
  • 不要泄露或传播他人的签名密钥与内部逻辑

技术能力越强,越要有边界感。


什么时候该“重写”,什么时候该“运行原始 JS”

这是实战中非常常见的取舍问题,我给一个简单判断表。

场景建议
签名逻辑简单、规则明确直接重写
混淆较轻、依赖少扣函数后运行
强依赖浏览器 API优先浏览器内调用或补环境
需要长期维护尽量重写
只是快速验证 PoC先跑原始 JS

我的经验是:

  • 短期验证求快:先跑原始 JS
  • 长期稳定求维护:尽量重写

一套实战中的排查顺序

如果你现在手里有个真实目标,我建议按这个顺序动手:

  1. 抓到成功请求
  2. 记录所有关键字段
  3. 搜索 sign / 接口路径
  4. 在请求发起前打断点
  5. 找到签名前原文
  6. 确认参数排序和过滤规则
  7. 确认算法与盐值
  8. 确认环境依赖
  9. 在 Node 里最小复现
  10. 与浏览器逐步比对中间结果
  11. 再封装成自动化脚本

这个顺序的核心价值在于:每一步都可验证,失败也知道卡在哪。


总结

Web 逆向里,接口签名分析看上去神秘,实际上可以拆成一条很务实的链路:

  • 先从请求中识别关键字段
  • 再从前端代码中定位签名生成路径
  • 重点观察签名前的原始字符串
  • 搞清参数排序、过滤、编码、盐值和环境依赖
  • 最后在 Node.js 中做最小自动化还原

真正高效的,不是会多少“算法名词”,而是你能不能形成下面这个习惯:

先定位输入,再确认规则,最后还原执行。

如果你只能记住一句话,我建议记这个:

签名问题八成不是“加密太难”,而是“输入不一致”。

当你把“签名前原文”抓住,整个逆向过程就会从模糊猜测,变成可验证、可复现、可自动化的工程问题。到了这一步,很多原本看起来很玄的站点,其实都能一点点拆开。


分享到:

上一篇
《区块链节点数据索引实战:面向中级开发者的链上事件解析与高性能查询设计》
下一篇
《分布式架构中基于一致性哈希与服务治理的灰度发布实战指南》