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

《从前端加密到接口还原:中级开发者实战 Web 逆向中的请求签名分析与自动化复现》

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

从前端加密到接口还原:中级开发者实战 Web 逆向中的请求签名分析与自动化复现

很多中级开发者在做接口联调、自动化测试、数据采集或安全研究时,都会碰到一个现实问题:

接口明明已经抓到了,但一请求就 401 / 403 / 参数非法。

这通常不是接口“坏了”,而是前端在发请求之前,额外做了一层或多层处理,比如:

  • 时间戳拼接
  • 参数排序
  • 摘要签名(MD5 / SHA 系)
  • 对称加密(AES)
  • 混淆后的 JS 逻辑
  • 动态 token、nonce、deviceId
  • 环境校验(浏览器指纹、Header、Cookie 联动)

我自己刚开始做这类分析时,最容易掉进两个坑:

  1. 只盯接口返回,不盯前端调用链
  2. 拿到一个签名函数就急着抄,结果忽略上下文依赖

这篇文章我会按“先看现象,再拆原理,最后自动化复现”的路径,带你完整走一遍。目标不是讲玄学,而是让你能把一个“前端加密接口”从浏览器里还原到可运行脚本里。

说明:本文内容仅用于合法测试、接口联调、安全研究与教学演示。请勿用于未授权目标。


背景与问题

我们先定义一个典型场景。

前端发起请求:

POST /api/order/list
Content-Type: application/json
X-Timestamp: 1710000000
X-Nonce: abc123
X-Sign: 9f8a...
Cookie: session=...

请求体:

{
  "page": 1,
  "pageSize": 20,
  "keyword": "phone"
}

浏览器里请求成功,但你用 Python 或 Node 直接复现时却失败。常见报错包括:

  • invalid sign
  • timestamp expired
  • nonce duplicated
  • unauthorized
  • bad request

这时问题的本质通常不是“你没抓全包”,而是:

  1. 签名依赖多个字段
  2. 字段参与顺序有要求
  3. 前端代码做了加密或序列化变换
  4. 服务端还校验 Header / Cookie / Referer / User-Agent
  5. 有动态逻辑,比如时间窗口、一次性 nonce

一个中级开发者常见误区

很多人会直接在 Sources 里搜 signmd5sha256。这当然有用,但不够稳。

因为真实项目里,签名逻辑常常长这样:

  • 函数名被混淆成 _0x3f12a
  • 字符串被数组映射
  • 最终签名不是简单 md5(params),而是
    sha256(sort(params)+timestamp+nonce+secret)
  • 有时请求体还先 AES 加密,再参与签名

也就是说,你分析的不是一个函数,而是一条数据变换链。


前置知识

如果你已经熟悉这些,可以直接跳到实战。

建议掌握

  • 浏览器开发者工具:Network、Sources、Console、Application
  • 基本 HTTP 请求结构:Header、Cookie、Body、Query
  • JavaScript 基础:对象、字符串、数组、JSON、Promise
  • 常见摘要算法:MD5、SHA1、SHA256
  • 一点点 Node.js 使用经验

环境准备

本文示例使用:

  • Chrome DevTools
  • Node.js 18+
  • Python 3.10+(用于可选复现)
  • 一个本地模拟服务端
  • 一个前端签名脚本

核心原理

我们先把问题抽象出来。

一个“带签名的前端请求”,常见处理链大致如下:

flowchart LR
A[原始业务参数] --> B[参数标准化]
B --> C[排序/拼接]
C --> D[加入时间戳 nonce token]
D --> E[摘要签名或加密]
E --> F[组装 Header/Body]
F --> G[发送请求]
G --> H[服务端按同样规则验签]

1. 参数标准化

所谓标准化,指的是前端发送前对参数进行统一处理,比如:

  • 去掉 null / undefined
  • 布尔值转 true/false1/0
  • 对象序列化为 JSON 字符串
  • 数组按固定格式拼接
  • URL 编码

这里很容易出错。你以为自己传的是同样的参数,但实际上服务端参与签名的字符串已经变了。

2. 排序与拼接

最常见的签名规则之一:

  1. 取所有参与签名的字段
  2. 按 key 的字典序排序
  3. 拼成 k1=v1&k2=v2...
  4. 末尾再拼 secret
  5. 计算摘要

例如:

keyword=phone&page=1&pageSize=20&timestamp=1710000000&nonce=abc123&secret=demo_secret

再对它做 sha256

3. 摘要签名 vs 加密

这两个概念很容易混。

  • 签名:主要用于校验请求是否被篡改。通常是摘要,不可逆。
  • 加密:主要用于隐藏明文内容。通常可逆,比如 AES。

很多接口同时做两件事:

  • body 先 AES 加密
  • 再对密文 + 时间戳做签名

4. 前端混淆并不等于高强度安全

我见过不少项目,代码混淆得很花,但核心逻辑还是:

sign = md5(sortedParams + secret)

混淆只是在提高阅读门槛,不是在改变算法本身。对于逆向分析来说,关注“输入是什么、输出是什么、中间变换是什么”,比关注函数名更重要。


一个可操作的分析路径

建议你按下面顺序做,而不是一上来就扣代码。

sequenceDiagram
    participant U as 分析者
    participant B as 浏览器前端
    participant J as 签名JS逻辑
    participant S as 服务端

    U->>B: 触发一次真实请求
    B->>J: 处理参数、时间戳、nonce
    J-->>B: 返回 sign / encryptedBody
    B->>S: 发送最终请求
    S-->>B: 验签并返回结果
    U->>B: 对比原始参数与最终请求
    U->>J: 断点/Hook提取签名输入输出
    U->>S: 用脚本自动化复现

第一步:抓“最终请求”

在 DevTools 的 Network 中拿到:

  • URL
  • Method
  • Query
  • Header
  • Cookie
  • Body
  • Response

重点关注这些字段:

  • sign
  • timestamp
  • nonce
  • token
  • deviceId
  • x-* 自定义头

第二步:回看 Initiator / 调用栈

Chrome 里可以看请求由哪个 JS 发起。顺着调用栈往上追,你常能找到:

  • 封装后的 request 方法
  • axios/fetch 拦截器
  • 请求前统一加签逻辑

第三步:定位“签名输入”

这是关键一步。你要回答:

  • 签名用到了哪些字段?
  • 是 body 参与,还是 query 参与?
  • 参数排序了吗?
  • 签名前是否 JSON.stringify?
  • timestamp / nonce 是不是一起参与?

第四步:验证最小闭环

当你怀疑签名公式是:

sign = sha256(sortedParams + timestamp + nonce + secret)

不要立刻写完整项目脚本。先用 Console 或 Node 验证一个固定输入,看输出能否与浏览器一致。

这一步能帮你排除很多“差一个空格、少一个字段、顺序不对”的低级问题。


实战代码(可运行)

下面我构造一个完整但简化的示例,模拟常见 Web 请求签名流程。

示例规则

假设服务端验签规则如下:

  1. 请求参数为 JSON
  2. pagepageSizekeyword
  3. 加入 Header 中的 x-timestampx-nonce
  4. 所有字段按 key 升序排序
  5. 拼接成查询串
  6. 末尾拼接 &secret=demo_secret
  7. sha256
  8. 放入 x-sign

示例一:前端签名实现(Node 可直接运行)

// sign.js
const crypto = require('crypto');

function normalizeParams(obj) {
  const result = {};
  Object.keys(obj).forEach((key) => {
    const value = obj[key];
    if (value !== undefined && value !== null) {
      result[key] = String(value);
    }
  });
  return result;
}

function buildSignString(params, timestamp, nonce, secret) {
  const merged = {
    ...normalizeParams(params),
    timestamp: String(timestamp),
    nonce: String(nonce),
  };

  const sortedKeys = Object.keys(merged).sort();
  const query = sortedKeys
    .map((key) => `${key}=${merged[key]}`)
    .join('&');

  return `${query}&secret=${secret}`;
}

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

function signRequest(params, timestamp, nonce) {
  const secret = 'demo_secret';
  const signString = buildSignString(params, timestamp, nonce, secret);
  const sign = sha256(signString);

  return {
    signString,
    sign,
  };
}

if (require.main === module) {
  const params = {
    page: 1,
    pageSize: 20,
    keyword: 'phone',
  };
  const timestamp = 1710000000;
  const nonce = 'abc123';

  const result = signRequest(params, timestamp, nonce);
  console.log(result);
}

module.exports = {
  normalizeParams,
  buildSignString,
  signRequest,
};

运行:

node sign.js

示例二:本地模拟服务端

// server.js
const express = require('express');
const { signRequest } = require('./sign');

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

app.post('/api/order/list', (req, res) => {
  const timestamp = req.header('x-timestamp');
  const nonce = req.header('x-nonce');
  const sign = req.header('x-sign');

  if (!timestamp || !nonce || !sign) {
    return res.status(400).json({
      code: 4001,
      message: 'missing headers',
    });
  }

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) {
    return res.status(401).json({
      code: 4002,
      message: 'timestamp expired',
    });
  }

  const expected = signRequest(req.body, timestamp, nonce).sign;

  if (expected !== sign) {
    return res.status(403).json({
      code: 4003,
      message: 'invalid sign',
      expected,
      actual: sign,
    });
  }

  return res.json({
    code: 0,
    message: 'ok',
    data: {
      list: [
        { id: 1, name: 'phone A' },
        { id: 2, name: 'phone B' },
      ],
    },
  });
});

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

安装依赖:

npm init -y
npm install express

启动服务:

node server.js

示例三:自动化复现客户端

// client.js
const axios = require('axios');
const { signRequest } = require('./sign');

async function main() {
  const url = 'http://localhost:3000/api/order/list';

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

  const timestamp = Math.floor(Date.now() / 1000);
  const nonce = Math.random().toString(36).slice(2, 10);

  const { signString, sign } = signRequest(data, timestamp, nonce);

  console.log('signString:', signString);
  console.log('sign:', sign);

  const response = await axios.post(url, data, {
    headers: {
      'Content-Type': 'application/json',
      'x-timestamp': String(timestamp),
      'x-nonce': nonce,
      'x-sign': sign,
    },
  });

  console.log(response.data);
}

main().catch((err) => {
  if (err.response) {
    console.error(err.response.status, err.response.data);
  } else {
    console.error(err.message);
  }
});

安装依赖:

npm install axios

运行:

node client.js

示例四:Python 版本复现

很多实际工作流还是 Python 更顺手,这里给一个可运行版本。

# client.py
import time
import random
import string
import hashlib
import requests

SECRET = "demo_secret"

def normalize_params(params):
    result = {}
    for k, v in params.items():
        if v is not None:
            result[k] = str(v)
    return result

def build_sign_string(params, timestamp, nonce, secret):
    merged = normalize_params(params)
    merged["timestamp"] = str(timestamp)
    merged["nonce"] = str(nonce)

    parts = []
    for key in sorted(merged.keys()):
        parts.append(f"{key}={merged[key]}")
    query = "&".join(parts)
    return f"{query}&secret={secret}"

def sha256_hex(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

def random_nonce(n=8):
    return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))

def main():
    url = "http://localhost:3000/api/order/list"

    data = {
        "page": 1,
        "pageSize": 20,
        "keyword": "phone"
    }

    timestamp = int(time.time())
    nonce = random_nonce()
    sign_string = build_sign_string(data, timestamp, nonce, SECRET)
    sign = sha256_hex(sign_string)

    print("sign_string:", sign_string)
    print("sign:", sign)

    headers = {
        "Content-Type": "application/json",
        "x-timestamp": str(timestamp),
        "x-nonce": nonce,
        "x-sign": sign
    }

    resp = requests.post(url, json=data, headers=headers, timeout=10)
    print(resp.status_code)
    print(resp.text)

if __name__ == "__main__":
    main()

安装依赖:

pip install requests

运行:

python client.py

进阶:如何在真实前端中定位签名逻辑

上面是我们自己构造的规则。真实项目里,关键在于把浏览器中的逻辑提取出来

方法一:全局搜索关键词

优先搜:

  • sign
  • signature
  • token
  • nonce
  • md5
  • sha1
  • sha256
  • CryptoJS
  • encrypt
  • decrypt

但如果代码被混淆,这招可能效果一般。

方法二:从请求库入口打断点

如果项目使用 axios / fetch,可以优先盯这两个位置:

  • 请求拦截器
  • 请求发送前的统一封装函数

因为大多数项目都会在这里统一加:

  • 时间戳
  • 公共参数
  • 签名 Header

方法三:Hook 关键函数

如果你怀疑项目用 CryptoJS.SHA256JSON.stringify 来生成签名输入,可以临时 Hook。

Hook JSON.stringify

// 在浏览器 Console 中执行
(function () {
  const raw = JSON.stringify;
  JSON.stringify = function (...args) {
    const result = raw.apply(this, args);
    console.log('[hook stringify] input:', args[0]);
    console.log('[hook stringify] output:', result);
    return result;
  };
})();

Hook 摘要函数

如果页面里暴露了 CryptoJS

(function () {
  if (!window.CryptoJS || !CryptoJS.SHA256) return;

  const raw = CryptoJS.SHA256;
  CryptoJS.SHA256 = function (msg) {
    console.log('[hook sha256] input:', msg);
    const result = raw.call(this, msg);
    console.log('[hook sha256] output:', result.toString());
    return result;
  };
})();

这类 Hook 的核心价值,不是“偷算法”,而是抓到签名前的原始字符串。一旦输入串拿到了,复现难度会大幅下降。


逐步验证清单

这是我平时最常用的一套检查顺序,建议你照着过一遍。

第 1 层:请求结构对齐

  • URL 是否完全一致
  • GET/POST 是否一致
  • Query 参数是否一致
  • Body 格式是否一致
  • application/json 还是 x-www-form-urlencoded

第 2 层:参与签名字段对齐

  • 是否包含公共参数
  • 是否包含时间戳
  • 是否包含 nonce
  • body 字段是否全部参与
  • 空值字段是否被过滤

第 3 层:字符串构造对齐

  • 是否排序
  • 排序规则是否区分大小写
  • 值是否转字符串
  • JSON 是否压缩成单行
  • 拼接分隔符是 &, 还是空串
  • secret 是前置还是后置

第 4 层:算法对齐

  • MD5 / SHA1 / SHA256
  • 小写 hex / 大写 hex
  • Base64 还是 hex
  • AES 模式是 CBC / ECB
  • padding 是否一致

第 5 层:环境依赖对齐

  • Cookie 是否必须
  • User-Agent 是否被校验
  • Referer / Origin 是否必须
  • 是否需要登录态
  • 是否依赖 localStorage/sessionStorage 的 token

常见坑与排查

这一节很重要,因为真实项目里,失败大多不是“完全不会”,而是“差一点点”。

坑一:参数顺序不一致

比如浏览器里签名串是:

keyword=phone&nonce=abc123&page=1&pageSize=20&timestamp=1710000000&secret=demo_secret

你脚本里却写成:

page=1&pageSize=20&keyword=phone&timestamp=1710000000&nonce=abc123&secret=demo_secret

很多签名算法对顺序极其敏感。

排查建议: 把浏览器中的签名前字符串打印出来,与脚本生成的逐字符对比。


坑二:数字、布尔、null 处理不一致

比如:

  • 前端把 1 转成 "1"
  • 布尔值 true 转成 "true"
  • null 字段直接剔除

而你在 Python 中直接拿原始对象参与签名,结果自然不一致。

排查建议: 显式做参数标准化,不要“相信默认序列化”。


坑三:JSON.stringify 的细节差异

对象一旦嵌套,你很可能遇到:

  • 键顺序不同
  • 空格不同
  • Unicode 转义不同

尤其是前端可能拿整个 JSON 字符串参与签名,而不是逐字段拼接。

排查建议: 直接 Hook JSON.stringify,抓浏览器里的最终字符串。


坑四:时间戳过期

有些接口只允许 60 秒或 300 秒窗口。

你抓到一个成功请求后,过几分钟再拿来重放,就会失败。

排查建议: 签名必须实时生成,不要直接重放旧包。


坑五:nonce 一次性使用

有些服务端会缓存 nonce,防重放。你第一次成功,第二次同包发过去就失败。

排查建议: 每次请求都生成新的 nonce,并重新签名。


坑六:Header 参与签名但你没发现

有些项目把这些也纳入签名:

  • User-Agent
  • appVersion
  • deviceId
  • platform
  • Authorization

排查建议: 从请求构造入口看“传给签名函数的完整对象”,不要只盯 body。


坑七:前端代码能跑,Node 里却跑不起来

因为前端代码依赖:

  • window
  • document
  • navigator
  • atob/btoa
  • localStorage

排查建议: 先抽出纯算法部分;若确实依赖浏览器环境,可用 jsdom、补 polyfill,或直接在无头浏览器中执行。


安全/性能最佳实践

这部分我想从“复现者”和“系统设计者”两个角度都说一下。

1. 对于复现脚本:不要把签名逻辑写死成不可维护的脚本

很多人第一次复现成功后,会把逻辑散落在多个文件里,过几天自己都看不懂。

建议把流程拆成:

  • 参数标准化
  • 签名串构造
  • 摘要/加密
  • 请求发送
  • 重试与日志

这样接口规则一改,你只需要调整某一层。

可以参考这个结构:

classDiagram
    class ParamNormalizer {
      +normalize(params)
    }

    class SignBuilder {
      +build(params, timestamp, nonce)
    }

    class CryptoEngine {
      +sha256(text)
      +encrypt(text)
    }

    class ApiClient {
      +send(data)
    }

    ApiClient --> ParamNormalizer
    ApiClient --> SignBuilder
    ApiClient --> CryptoEngine

2. 记录“签名前字符串”

这是排障效率最高的做法之一。

请求失败时,至少日志里打印:

  • 原始参数
  • 标准化参数
  • sign string
  • 最终 sign
  • timestamp / nonce

只要这几项有了,问题一般都能定位。


3. 不要滥用高频重试

签名错误不是网络抖动。你一旦签错,重试 10 次还是错。

正确做法:

  • 先打印签名串
  • 再检查字段和排序
  • 最后才考虑重试机制

4. 对于服务端设计:不要把“前端加密”当作真正安全边界

这是一个很现实的问题。

如果 secret 下发到了前端,或者签名逻辑完全跑在前端,那么从安全设计角度看:

  • 它能提高调用门槛
  • 但不能作为强安全手段

更可靠的措施应该包括:

  • 服务端签发短时令牌
  • 登录态绑定
  • 频控与风控
  • 设备指纹校验
  • nonce 防重放
  • 时间窗口校验
  • 行为审计

5. 性能上避免重复计算

如果你的自动化脚本有批量请求需求:

  • 公共参数可以复用
  • 仅变化字段重新计算签名
  • 注意连接池复用
  • 控制并发,避免触发风控

尤其是某些接口还会做昂贵的加密逻辑,频繁初始化会拖慢吞吐。


一套通用的实战模板

如果你面对的是陌生站点,我建议按这套最小模板推进。

模板步骤

  1. 抓一条成功请求
  2. 标记所有动态字段
  3. 定位请求发起函数
  4. 找到签名函数输入
  5. 固定参数做单次验证
  6. 抽出纯 JS 算法
  7. 在 Node 跑通
  8. 再迁移到 Python 或自动化框架
  9. 增加日志和异常处理
  10. 最后再考虑批量化

最小复现原则

先只复现一个成功请求,不要一开始就做:

  • 批量任务
  • 多线程并发
  • 代理轮换
  • 复杂调度

因为一旦基础签名都没完全对齐,这些复杂度只会把问题藏起来。


边界条件:什么时候不适合纯脚本复现

这点也要说清楚,不然容易误判。

如果目标站点有下面这些机制,纯 requests/axios 不一定够:

  • 强依赖浏览器环境检测
  • WebAssembly 中生成关键参数
  • 滑块/验证码联动
  • token 与页面生命周期绑定
  • 某些签名在原生 App 或小程序容器中生成
  • TLS 指纹/JA3 参与风控

这时更现实的方案是:

  • 无头浏览器执行原始前端逻辑
  • 注入 Hook 提取参数
  • 浏览器自动化与请求脚本混合使用

也就是说,不要迷信“必须纯接口复现”。工程上能稳定跑通,才是最优解。


总结

从前端加密到接口还原,真正要掌握的不是某个具体算法,而是一套分析方法:

  1. 先抓最终请求
  2. 再追请求构造链
  3. 定位签名输入输出
  4. 做最小闭环验证
  5. 抽离可维护的自动化代码

你可以把整个过程理解为一句话:

不是“找 sign 函数”,而是“还原请求数据从业务参数到最终报文的变换过程”。

如果你是中级开发者,我很建议把注意力放在这三个能力上:

  • 对比能力:浏览器请求与脚本请求逐项对比
  • 抽象能力:把混乱代码抽象成参数、排序、拼接、摘要几个阶段
  • 工程能力:把一次性复现变成可维护的自动化模块

最后给你一个非常实用的建议:

每次分析到签名逻辑时,务必保存“签名前字符串 + 最终 sign + 请求样本”。
这份样本会在后续排障时救你很多次。我自己踩坑后,几乎把它当成固定动作了。

如果你能把本文示例真正跑一遍,再去看真实项目里的签名逻辑,很多“看起来很复杂”的东西,其实就没那么吓人了。


分享到:

上一篇
《Java 中使用 CompletableFuture 构建高并发异步任务编排的实战指南》
下一篇
《区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-56》