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

《从抓包到还原签名流程:一次典型 Web 逆向中前端加密参数生成的实战分析》

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

从抓包到还原签名流程:一次典型 Web 逆向中前端加密参数生成的实战分析

很多人第一次做 Web 逆向时,都会遇到一个非常典型的问题:

明明接口地址找到了,参数格式也看懂了,但请求一发出去就是“签名错误”或者“非法请求”。

这类问题的核心,往往不在接口本身,而在前端生成的加密参数或签名参数。页面里看起来只是多了一个 signtokennoncets,但它背后可能串着一整条前端逻辑链:取时间戳、拼接参数、排序、加盐、哈希、编码,甚至还会混入动态变量或环境检测。

这篇文章我会用一种“带你走一遍”的方式,完整演示一次典型的分析过程:从抓包定位、到前端代码追踪、到还原签名算法、再到写出可运行代码复现请求。重点不是某个网站的具体实现,而是掌握这一类问题的通用方法。


背景与问题

先设定一个常见场景。

某个站点的接口请求如下:

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

{
  "keyword": "phone",
  "page": 1,
  "ts": 1723980000123,
  "nonce": "a8f3d2c1",
  "sign": "9c7b1e7d0f2c..."
}

直接改参数重放时,服务端返回:

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

这说明接口防护不只校验业务参数,还校验了一套前端生成逻辑。

我们要解决的核心问题

  1. sign 是怎么生成的?
  2. 它依赖哪些字段?
  3. 参数顺序是否影响签名?
  4. 是否有固定盐值、动态 token、环境变量参与?
  5. 能否脱离浏览器,在本地脚本中复现?

前置知识

如果你已经熟悉以下内容,阅读会很顺:

  • Chrome DevTools 基本使用
  • 抓包工具基本概念
  • JavaScript 基础语法
  • 常见摘要算法:MD5 / SHA1 / SHA256
  • 编码形式:Hex / Base64 / URL Encode

如果不熟也没关系,本文会尽量按“观察现象 -> 推测逻辑 -> 验证结果”的节奏来讲。


环境准备

这类分析我通常会准备下面这些工具:

  • 浏览器:Chrome
  • 开发者工具:Network / Sources / Console
  • 可选抓包:Fiddler / Charles / mitmproxy
  • Node.js:用于本地复现签名
  • 文本搜索工具:全局搜关键字
  • JS 美化工具:浏览器格式化或本地 prettier / js-beautify

建议先确保本机有 Node.js 18+。


整体分析路线

先别急着抠混淆代码。经验上,逆向签名最省时间的路线通常是:

  1. 抓到真实请求
  2. 定位可疑参数
  3. 在前端代码中追踪参数生成位置
  4. 抽离签名核心逻辑
  5. 写脚本复现
  6. 对比浏览器结果,逐步修正

下面这张图能帮助你建立整体路径感。

flowchart TD
    A[抓包定位目标接口] --> B[识别动态参数 ts/nonce/sign]
    B --> C[前端全局搜索 sign 或接口路径]
    C --> D[定位请求发送函数]
    D --> E[向上追踪签名生成逻辑]
    E --> F[抽离排序/拼接/加盐/哈希]
    F --> G[Node.js 本地复现]
    G --> H[对比浏览器请求结果]
    H --> I[修正细节并完成还原]

核心原理

前端签名本质上通常是这几步的组合:

  1. 收集参数
  2. 按规则排序
  3. 拼接成字符串
  4. 附加密钥/盐值
  5. 执行哈希或加密
  6. 输出固定编码格式

一个很典型的例子:

keyword=phone&page=1&ts=1723980000123&nonce=a8f3d2c1 + secret

然后执行:

SHA256(拼接结果)

最后得到:

9c7b1e7d0f2c...

常见签名要素

要素作用常见表现
ts防重放毫秒/秒时间戳
nonce防重复随机字符串
appKey标识客户端固定公开值
secret签名密钥前端硬编码或拆分隐藏
token用户态绑定来自 cookie/localStorage
body 摘要防篡改请求体先做一次 hash

你需要特别注意的“细节杀手”

很多人卡住,不是算法不会,而是漏了这些细节:

  • 参数是否按 ASCII 字典序 排序
  • 空值字段是否参与签名
  • 数字是否转成字符串
  • 布尔值是 true 还是 "true"
  • 请求体是原始 JSON 还是压缩后字符串
  • 是否在末尾拼接固定盐值
  • 是否先 JSON.stringify 再哈希
  • 哈希输出是小写 hex 还是大写 hex
  • 是否对参数值做了 URL 编码

这些问题我基本都踩过。尤其是排序规则序列化方式,最容易让你“看起来只差一点,但永远不对”。


一次典型实战:从请求到签名还原

为了让过程可运行、可验证,下面我用一个“典型前端签名模型”来完整演示。

第一步:抓包确认动态参数

先在 Network 中找到目标接口,重点看 Request Payload 或 Form Data。

假设抓到如下请求:

{
  "keyword": "phone",
  "page": 1,
  "ts": 1723980000123,
  "nonce": "a8f3d2c1",
  "sign": "0c4f2d9f7c5a6a0b6fd9a0c1241d4c7f46d2bb0a2d3f8f658d3c6a13aa8c1f31"
}

这时可以初步判断:

  • keywordpage 是业务参数
  • tsnoncesign 是安全参数
  • sign 很可能依赖前面几项

第二步:搜索请求发起位置

在 Sources 全局搜索:

  • /api/search
  • sign
  • nonce
  • ts
  • invalid sign

通常能定位到类似这样的代码:

function requestSearch(data) {
  const ts = Date.now();
  const nonce = randomString(8);
  const payload = {
    ...data,
    ts,
    nonce
  };
  payload.sign = makeSign(payload);
  return http.post("/api/search", payload);
}

到这里,方向就明确了:重点跟进 makeSign(payload)

第三步:进入签名函数

继续追进去,可能会看到压缩混淆后的代码。美化后,常见结构如下:

function makeSign(params) {
  const secret = "webapp_secret_2024";
  const keys = Object.keys(params).sort();
  const str = keys.map(k => `${k}=${params[k]}`).join("&");
  return sha256(str + secret);
}

如果站点不复杂,到这里其实已经还原完成了。

但真实环境中,经常还会再包一层,比如:

function makeSign(params) {
  const secret = getSecret();
  const normalized = normalize(params);
  const bodyStr = serialize(normalized);
  return encodeHex(hash(bodyStr + "|" + secret)).toLowerCase();
}

这时候就不要只看函数名,要看实际的数据流


用时序图理解一次签名调用链

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant S as 签名函数
    participant A as 接口服务端

    U->>P: 输入 keyword=phone
    P->>P: 组装业务参数
    P->>P: 生成 ts / nonce
    P->>S: makeSign(payload)
    S->>S: 排序、拼接、加盐、哈希
    S-->>P: 返回 sign
    P->>A: 发送完整请求
    A->>A: 用相同规则验签
    A-->>P: 返回业务数据

这张图里最关键的一点是:

服务端不会“理解你的前端写法”,它只会验证最终规则。
所以我们在逆向时的目标不是“完全复刻源代码结构”,而是“提取出等价规则”。


实战代码:本地复现签名流程

下面我们写一套可运行代码,用 Node.js 复现上面的签名逻辑。

示例签名规则

假设通过分析确认签名规则为:

  1. 收集参数:keywordpagetsnonce
  2. 去掉空值和 sign 字段
  3. 按 key 升序排序
  4. 拼接为 k=v&k=v
  5. 末尾追加密钥:webapp_secret_2024
  6. 做 SHA256,输出小写 hex

可运行代码

import crypto from "crypto";

function randomString(length = 8) {
  const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
  let result = "";
  for (let i = 0; i < length; i++) {
    result += chars[Math.floor(Math.random() * chars.length)];
  }
  return result;
}

function normalizeParams(params) {
  const out = {};
  for (const [key, value] of Object.entries(params)) {
    if (key === "sign") continue;
    if (value === undefined || value === null || value === "") continue;
    out[key] = String(value);
  }
  return out;
}

function buildSignString(params) {
  const normalized = normalizeParams(params);
  const keys = Object.keys(normalized).sort();
  return keys.map(key => `${key}=${normalized[key]}`).join("&");
}

function sha256Hex(input) {
  return crypto.createHash("sha256").update(input, "utf8").digest("hex");
}

function makeSign(params) {
  const secret = "webapp_secret_2024";
  const signStr = buildSignString(params);
  return sha256Hex(signStr + secret).toLowerCase();
}

function buildPayload(keyword, page = 1) {
  const payload = {
    keyword,
    page,
    ts: Date.now(),
    nonce: randomString(8)
  };
  payload.sign = makeSign(payload);
  return payload;
}

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

运行方式

node demo.js

输出大概像这样:

payload = {
  keyword: 'phone',
  page: 1,
  ts: 1723980000123,
  nonce: 'a8f3d2c1',
  sign: '0c4f2d9f7c5a6a0b6fd9a0c1241d4c7f46d2bb0a2d3f8f658d3c6a13aa8c1f31'
}
sign source = keyword=phone&nonce=a8f3d2c1&page=1&ts=1723980000123

加一步:直接发请求验证

如果你已经确认接口和请求头,也可以直接用 fetchaxios 发请求。

下面给出一个 fetch 版示例:

import crypto from "crypto";

function randomString(length = 8) {
  const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
  let result = "";
  for (let i = 0; i < length; i++) {
    result += chars[Math.floor(Math.random() * chars.length)];
  }
  return result;
}

function normalizeParams(params) {
  const out = {};
  for (const [key, value] of Object.entries(params)) {
    if (key === "sign") continue;
    if (value === undefined || value === null || value === "") continue;
    out[key] = String(value);
  }
  return out;
}

function buildSignString(params) {
  const normalized = normalizeParams(params);
  return Object.keys(normalized)
    .sort()
    .map(key => `${key}=${normalized[key]}`)
    .join("&");
}

function makeSign(params) {
  const secret = "webapp_secret_2024";
  const source = buildSignString(params) + secret;
  return crypto.createHash("sha256").update(source, "utf8").digest("hex");
}

async function main() {
  const payload = {
    keyword: "phone",
    page: 1,
    ts: Date.now(),
    nonce: randomString(8)
  };
  payload.sign = makeSign(payload);

  const res = await fetch("https://example.com/api/search", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "User-Agent": "Mozilla/5.0"
    },
    body: JSON.stringify(payload)
  });

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

main().catch(console.error);

如果代码被混淆了,怎么追?

实际项目里,签名函数往往不是这么“裸奔”的。你更常见到的是:

  • 变量名全是 abc
  • 函数层层嵌套
  • 字符串被拆分进数组
  • 哈希库被 webpack 打包
  • 请求逻辑统一走拦截器

这时我的建议是:不要试图一眼看懂全部代码,而是只追“值从哪里来、到哪里去”

一个简单的数据流排查思路

flowchart LR
    A[Network 中的 sign 值] --> B[搜索请求函数]
    B --> C[定位 payload 组装点]
    C --> D[找到 sign 赋值语句]
    D --> E[进入 makeSign]
    E --> F[识别排序逻辑]
    F --> G[识别拼接字符串]
    G --> H[识别哈希函数]
    H --> I[抽出最小可复现代码]

实用技巧

1. 在赋值点打断点

看到:

payload.sign = makeSign(payload);

直接在这一行打断点,然后在 Console 里看:

payload
makeSign(payload)
Object.keys(payload).sort()

这样比在一堆混淆代码里硬读要高效得多。

2. Hook 哈希函数

如果你怀疑最终用了 SHA256 / MD5,可以在控制台临时改写相关函数,打印输入内容。

例如浏览器中如果站点用了某个 sha256 方法:

const rawSha256 = window.sha256;
window.sha256 = function(input) {
  console.log("sha256 input =>", input);
  return rawSha256.apply(this, arguments);
};

这样一发请求,你就能直接看到哈希前的原文。

3. 观察固定字符串

很多时候真正的“秘密”不在算法,而在那个盐值:

"webapp_secret_2024"

混淆代码也许把它拆成:

["web", "app", "_secret", "_2024"].join("")

你只要把最终结果拼出来就行,不需要过度纠结它原本怎么写的。


逐步验证清单

这部分很重要。签名还原不是“写完就完”,而是要一项一项验。

验证顺序建议

  1. 固定浏览器中的一个请求样本
  2. 记录原始参数与 sign
  3. 本地脚本使用完全相同的参数
  4. 计算本地 sign
  5. 对比结果是否一致
  6. 一致后再改成动态时间戳和随机数
  7. 最后再接上自动请求

最小验证样本

例如先固定:

{
  "keyword": "phone",
  "page": 1,
  "ts": 1723980000123,
  "nonce": "a8f3d2c1"
}

然后你的本地结果必须和浏览器 sign 完全一致。
注意,是完全一致,不是“看着差不多”。

如果不一致,就回头排查:

  • 拼接顺序
  • 是否转字符串
  • 是否漏字段
  • 是否多拼了 sign
  • 是否大小写不一致
  • 是否盐值位置错了

常见坑与排查

这部分我尽量写得接地气一点,因为很多坑真的是反复出现。

1. 参数顺序错了

你以为是对象原始顺序,实际上前端做了排序。

错误示例:

keyword=phone&page=1&ts=1723980000123&nonce=a8f3d2c1

正确可能是:

keyword=phone&nonce=a8f3d2c1&page=1&ts=1723980000123

排查方法:

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

2. 把数字当数字,前端却转成了字符串

浏览器里最终拼接的是:

page=1

而不是某种二进制意义上的数字。
如果你在其他语言中复现,务必显式转字符串。


3. JSON 序列化不一致

有些站点不是按 k=v&k=v 拼接,而是:

JSON.stringify(obj)

这时对象字段顺序会直接影响签名。

排查思路:

  • 看签名前传入的是对象还是字符串
  • 看是否调用了 JSON.stringify
  • 看是否做了自定义 serializer

4. 漏掉隐藏依赖

例如某些签名还会拼:

  • cookie 中的 token
  • localStorage 中的 deviceId
  • 请求头中的 x-client-id
  • 页面注入的 version

这种情况最容易让人误判为“算法没还原”。其实算法对了,只是少了输入。

排查方式:

  • makeSign 的参数来源
  • 向上追到调用栈
  • 观察是否读取了 document.cookielocalStoragesessionStorage

5. 哈希前先做编码

有的网站会这样:

sha256(encodeURIComponent(str) + secret)

或:

md5(btoa(str))

如果你只看到最终调用 sha256,但没注意前面的编码层,就会一直算不出来。


6. 时间戳精度不一致

前端可能是:

Date.now()          // 毫秒

也可能是:

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

两者只差 3 位,但签名会完全不同。


7. 随机数生成规则不同

你本地随便生成一个 nonce 看似没问题,但有的站点要求:

  • 固定长度
  • 指定字符集
  • 必须小写
  • 必须以某个前缀开头

这种情况如果服务端也校验 nonce 规则,请求同样会失败。


安全/性能最佳实践

这里分成两个视角:分析者视角开发者视角

对分析者来说

1. 先做最小复现,不要一上来写大脚本

我的经验是,先用一个固定样本把 sign 算对,比一开始就写完整爬虫更重要。
否则你会同时调试“签名问题、请求头问题、cookie 问题、频率问题”,非常乱。

2. 保留中间结果

建议把这些都打印出来:

  • 原始参数
  • 排序后的 key 列表
  • 拼接字符串
  • 加盐后的最终输入
  • 哈希输出

这样一旦算错,能迅速定位哪一层出了问题。

3. 关注浏览器环境依赖

有些签名函数依赖:

  • window
  • navigator
  • document
  • Canvas/WebGL 指纹
  • WebAssembly

这时候纯 Node.js 可能跑不起来,需要补环境或者直接在浏览器里执行。


对开发者来说

如果你是从防护角度看这件事,需要明白一个现实:

只要签名逻辑在前端可执行,就存在被分析和复现的可能。

所以前端签名的正确定位应该是:

  • 提高滥用成本
  • 防低门槛重放
  • 配合服务端风控
  • 而不是把它当作绝对安全边界

更合理的安全实践

  1. 服务端持有真正密钥
  2. 签名结合用户态、设备态、时效性
  3. 加入重放保护
  4. 对异常频率做风控
  5. 不要把核心安全完全交给前端混淆

性能方面的建议

  1. 避免在主线程执行过重加密逻辑
  2. 签名字段尽量简洁,不要重复计算大型 payload
  3. 对大请求体可先摘要再参与签名
  4. 高频请求可考虑 nonce/ts 复用窗口,但要平衡安全性

一个更贴近真实项目的扩展判断

很多时候你最终会发现,站点并不是“加密”了参数,而只是“签名”了参数。

这两者要分清:

  • 加密:目标是让别人看不懂内容
  • 签名:目标是让服务端确认内容未被篡改

在 Web 场景中,前端所谓“加密参数”大多数其实属于:

  • 哈希签名
  • 简单编码
  • 对称加密但密钥在前端可见
  • 请求摘要校验

也正因为如此,分析重点不该放在“它看起来很神秘”,而应该放在:

  • 输入是什么
  • 顺序是什么
  • 中间变换是什么
  • 最终输出格式是什么

一旦把这个链路拆开,很多“神秘参数”都会变得很普通。


总结

做 Web 逆向里的签名还原,最重要的不是某个具体算法,而是方法论:

  1. 先抓包,确认动态参数
  2. 定位请求发起点
  3. 顺着数据流找到签名函数
  4. 拆出排序、拼接、加盐、哈希规则
  5. 用固定样本做本地校验
  6. 确认一致后再自动化请求

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

不要试图一次看懂全部混淆代码,只要把“sign 是怎么从参数变出来的”这条链追清楚就够了。

最后给几个可执行建议:

  • 第一次分析时,优先找 payload.sign = xxx(...)
  • 永远保留一个浏览器真实样本做对照
  • 本地脚本必须打印中间字符串
  • 算不对时,优先怀疑排序、序列化、隐藏输入
  • 遇到环境依赖,再考虑补浏览器环境,而不是先硬抄代码

当然,本文演示的是典型流程,不适用于所有场景。
如果目标站点用了更强的防护,比如:

  • 动态下发密钥
  • 服务端挑战响应
  • WebAssembly 混淆
  • 强环境绑定
  • 风控联动校验

那分析成本会明显上升,这时就不能只靠“找个哈希函数”解决问题了。

但对于大多数常见 Web 接口签名场景,这套思路已经足够实用。只要你肯耐心做“抓包 -> 定位 -> 抽离 -> 验证”这四步,签名流程大概率是能还原出来的。


分享到:

上一篇
《自动化测试中接口与UI联动回归的实战方案:从用例分层到持续集成落地》
下一篇
《大模型推理优化实战:从量化、KV Cache 到并发调度的性能提升路径》