跳转到正文

JavaScript 逆向技术

当接口参数里出现 signtoken_signature 这类算不出来的字符串时,反爬对抗 里的手段全部失效——问题不在身份或频率,而在你不知道这个参数是怎么生成的

JS 逆向要回答的就是这一个问题:把浏览器里那段生成参数的 JavaScript 找出来、看懂,然后用 Python 复现或直接调用它。

⚖️ 适用边界

逆向分析用于理解公开页面的数据接口是常见的技术实践,但以下情况应当停止:目标代码有明确的许可协议禁止逆向、生成的凭证用于绕过付费墙或身份认证、还原的算法被用于伪造请求身份。分析行为本身和使用分析结果做什么,是两件独立的事。

一、方法论:四步定位

逆向 90% 的时间花在「找到那行代码」上,而不是看懂算法。按这个顺序走,可以避免在几万行压缩代码里瞎翻。

1.1 第一步:确定哪些参数是动态的

多次抓包同一接口,对比参数:

参数变化规律性质处理
每次都变,13 位数字毫秒时间戳int(time.time()*1000),直接造
每次都变,随机字符串nonce随机生成即可,通常不校验
固定不变应用 ID / 版本号硬编码
随参数内容变化签名必须逆向
固定一段时间后变化token,有有效期找到签发接口,或逆向生成逻辑

判断签名的关键测试:只改一个业务参数(如页码),看目标参数是否跟着变。变了就是签名,不变就是凭证。

1.2 第二步:定位生成位置

按可靠性从高到低:

方法操作适用
XHR/fetch 断点Sources → XHR Breakpoints → 填入 URL 片段最通用,请求发出前必然断下
调用栈回溯断下后看 Call Stack,逐帧向上找参数拼装处配合上一条,是标准动作
InitiatorNetwork → 请求 → Initiator → 点击跳转最快,但被异步封装后可能失真
全局搜索Ctrl+Shift+F 搜参数名,如 "sign"sign:sign=参数名未被混淆时有效
事件监听断点Sources → Event Listener Breakpoints → 勾 click 等参数在交互时生成
Hook 拦截注入代码劫持关键函数(第四节)前几种都失效时

💡 三个提速技巧

  1. 格式化代码:Sources 面板左下角 {} 按钮,把压缩成一行的代码展开。
  2. 条件断点:右键行号 → Add conditional breakpoint,填 url.includes('/api/list'),只在关心的请求处断下,避免高频断点卡死页面。
  3. Local Overrides:Sources → Overrides 把远程 JS 保存到本地并修改(如插入 debuggerconsole.log),刷新后浏览器加载你改过的版本——这是分析动态加载脚本的利器。

1.3 反调试的绕过

站点常用 debugger 死循环阻止调试。三种处理方式:

手段应对
定时器里的 debugger右键该行 → Never pause here
Function("debugger")() 构造Hook Function 构造器,过滤含 debugger 的入参
检测控制台开关(窗口尺寸差)把 DevTools 分离为独立窗口
console.log 被重写导致卡顿分析时避免打印大对象,或先恢复原生 console

二、识别加密算法:先看是不是标准算法

绝大多数「加密参数」用的是标准算法,只是加了自定义的盐、拼接顺序或多轮嵌套。先按特征识别,能省下大量读代码的时间。

结果特征算法识别线索
32 位十六进制MD5代码里有 hex_md5md5、大量位运算与 0x67452301 等魔数
40 位十六进制SHA-1魔数 0x5A827999
64 位十六进制SHA-256常与 HMAC 同时出现
末尾有 ===,字符集含 +/Base64btoa/atob,或自定义字符表
长度为 16 的倍数,输出常为 Base64AESCryptoJS.AES,关注 mode(CBC/ECB)、padding、key、iv
长度固定 128/256 字符RSAJSEncryptsetPublicKey,公钥硬编码在 JS 里
输出可逆、逻辑简单自定义异或/移位需逐行还原

💡 魔改是常态

遇到「算法认出来了但结果对不上」,通常是三种魔改:自定义 Base64 字符表(换掉标准表的字符顺序)、修改初始向量或魔数加盐位置特殊(盐插在中间而非首尾)。

排查方法:在算法入口下断点,把实际传入的完整输入打印出来,与自己 Python 侧的输入逐字节比对。多数情况下算法没错,是输入拼错了。

Python 侧的对应实现:

python
import hashlib, hmac, base64
from Crypto.Cipher import AES              # pip install pycryptodome
from Crypto.Util.Padding import pad

hashlib.md5(f"{data}{salt}".encode()).hexdigest()
hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest()

cipher = AES.new(key.encode(), AES.MODE_CBC, iv.encode())
base64.b64encode(cipher.encrypt(pad(data.encode(), AES.block_size))).decode()

三、混淆与还原

3.1 混淆类型识别

类型特征还原难度
压缩(minify)单行、变量名单字母低——格式化即可读
变量名混淆_0xabc12OOO0O低——不影响逻辑理解
字符串数组混淆顶部一个大数组 + 取值函数 _0x3a2b(i)中——还原字符串即可
控制流平坦化while(true){ switch(_0x1a){ case '1': ... } }高——需重建执行顺序
eval / Function 加密eval(function(p,a,c,k,e,d){...})低——把 eval 换成 console.log
数值/字符串表达式化0x1a2b ^ 0x3c'a'+'b'低——常量折叠即可
WebAssembly加载 .wasm很高——见第六节

3.2 快速还原:把 eval 变成输出

eval 型加密最容易处理——不执行它,而是把结果打出来:

javascript
// 原始:eval(function(p,a,c,k,e,d){...}('...'))
// 改为:
console.log(function(p,a,c,k,e,d){...}('...'))
// 或在控制台直接把 eval 重定义
window.eval = console.log;

3.3 AST:批量还原的正确工具

面对成千上万处混淆,手工替换不现实。AST(抽象语法树) 把代码解析成结构化的树,允许你写规则批量改写节点,再生成新代码。

text
源码 ──parse──> AST ──traverse/修改──> AST ──generate──> 还原后的源码

工具链是 Babel(Node 生态):

javascript
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const generator = require("@babel/generator").default;

const ast = parser.parse(code);

traverse(ast, {
  // 常量折叠:把 0x1a ^ 0x3c 这类表达式直接算成结果
  BinaryExpression(path) {
    const { confident, value } = path.evaluate();
    if (confident) path.replaceWith(types.valueToNode(value));
  },

  // 还原字符串数组取值:把 _0x3a2b(0x1) 替换成它返回的真实字符串
  CallExpression(path) {
    if (path.node.callee.name === "_0x3a2b") {
      const idx = path.node.arguments[0].value;
      path.replaceWith(types.stringLiteral(STRING_ARRAY[idx]));
    }
  },
});

console.log(generator(ast).code);

常用的还原规则:常量折叠、字符串数组内联、无用代码消除(if(false){})、成员表达式点号化(a["b"]a.b)、控制流平坦化还原(分析 switch 的分发变量,按序重排 case)。

💡 AST 的适用判断

写 AST 规则本身有成本。只提取一两个参数时,直接在浏览器里调试更快;需要完整移植一整个 JS 模块到 Node 环境,或同一套混淆会反复遇到时,AST 才划算。


四、Hook:在不改源码的前提下拦截

Hook 就是用自己的函数替换掉原生函数,在其中打印参数、下断点或篡改返回值。代码找不到时,Hook 是最有效的定位手段。

注入时机很关键——必须在目标代码执行之前。可用油猴脚本(@run-at document-start)、Local Overrides,或 Playwright 的 add_init_script

javascript
// Hook Cookie 设置:定位是谁写入了某个 cookie
(function () {
  const desc = Object.getOwnPropertyDescriptor(Document.prototype, "cookie");
  Object.defineProperty(document, "cookie", {
    set(val) {
      if (val.includes("target_key")) debugger;   // 命中即断下,看调用栈
      return desc.set.call(document, val);
    },
    get() { return desc.get.call(document); },
  });
})();

// Hook JSON.stringify:定位请求体在哪里被序列化
const _stringify = JSON.stringify;
JSON.stringify = function (...args) {
  console.log("stringify:", args[0]);
  debugger;
  return _stringify.apply(this, args);
};

// Hook 请求发送:拿到最终的完整参数
const _send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
  console.log("send:", this._url, body);
  return _send.call(this, body);
};

// Hook Function 构造器:绕过 Function("debugger")() 型反调试
const _Function = Function;
Function = function (...args) {
  if (args.join("").includes("debugger")) return function () {};
  return _Function.apply(this, args);
};

常见 Hook 点速查:document.cookie(定位 cookie 生成)、localStorage.setItemJSON.stringify/parseXMLHttpRequest.open/sendwindow.fetchFunctionevalatob/btoaArray.prototype.join(字符串拼接常经过它)。


五、执行方案:三条路线

找到并看懂算法后,剩下的是「怎么在 Python 里算出来」。三条路线的成本与稳定性差异很大。

5.1 路线一:Python 纯复现

最优解:无外部依赖、性能最高、可任意并发。

适用条件:算法是标准的或逻辑清晰,不依赖任何浏览器环境对象windowdocumentnavigatorcanvas)。

代价是移植工作量,且目标站改算法后需要重新逆向。

5.2 路线二:Node.js 执行原始 JS

把逆向出的 JS 代码原样跑起来,避免移植错误。

python
import execjs      # pip install PyExecJS,底层调用本地 Node

ctx = execjs.compile(open("sign.js", encoding="utf-8").read())
sign = ctx.call("getSign", params)

⚠️ PyExecJS 的性能陷阱

execjs 每次调用都会启动一个 Node 进程,单次开销在几十到几百毫秒。少量调用可以接受,高并发场景下它会直接成为瓶颈。

生产环境应改用常驻 Node 服务(路线三)。

补环境:直接跑浏览器里的 JS 通常会报 window is not defined。需要把缺失的环境对象补齐:

javascript
// 最小补环境骨架
window = global;
document = { cookie: "", createElement: () => ({ style: {} }), getElementById: () => null };
navigator = { userAgent: "Mozilla/5.0 ...", platform: "Win32", language: "zh-CN" };
location = { href: "https://example.com/", host: "example.com", protocol: "https:" };
screen = { width: 1920, height: 1080 };

高强度的风控代码会主动检测环境是否真实(检查 toString 结果、原型链、属性枚举顺序、函数是否为 native code)。此时需要用 Proxy 做代理式补环境,并记录被访问的属性来逐步补全:

javascript
window = new Proxy({}, {
  get(target, prop) {
    console.log("[env] 读取 window." + String(prop));   // 记录缺什么补什么
    return target[prop];
  },
});

5.3 路线三:Node RPC 常驻服务

工程化的标准做法:Node 侧起一个 HTTP 服务常驻,把加密函数暴露成接口,Python 侧只管调用。

javascript
// Node 侧:加载一次,常驻内存
const express = require("express");
const { getSign } = require("./sign.js");

express()
  .use(express.json())
  .post("/sign", (req, res) => res.json({ sign: getSign(req.body.params) }))
  .listen(3000);
python
# Python 侧:普通 HTTP 调用,可连接池复用
sign = httpx.post("http://127.0.0.1:3000/sign", json={"params": params}).json()["sign"]

优势:JS 只解析一次、支持并发、Python 与 JS 解耦、算法更新只需替换 JS 文件并重启服务。这是生产环境的推荐方案。

5.4 路线四:浏览器上下文直接调用

补环境成本过高时(环境检测极强、代码量巨大),放弃脱环境,把浏览器当成一台"签名机":

python
# 页面已加载完所有依赖,直接调用页面里的函数
sign = page.evaluate("(p) => window.getSign(p)", params)

牺牲性能换取绝对的正确性——浏览器里跑出来的结果不可能有环境差异问题。适合低频高价值的场景。

5.5 路线对比

路线性能稳定性移植成本适用
Python 复现最高标准算法、无环境依赖
PyExecJS最低调试期、低频调用
Node RPC中(需补环境)生产首选
浏览器调用最低最高强环境检测、低频

六、WebAssembly

部分站点把核心算法编译成 .wasm,在浏览器里由 WebAssembly 虚拟机执行。特征是 Network 面板出现 .wasm 文件,且 JS 里有 WebAssembly.instantiate

处理路线

方式说明
直接调用(推荐)不逆向,用 Python 的 wasm 运行时加载同一个 .wasm 并调用导出函数
反编译分析wasm2wat(转文本格式)或 wabt/Ghidra 分析,还原算法逻辑
浏览器内调用在页面上下文直接 evaluate 调用,最省事
python
# 直接加载 wasm 模块调用导出函数,绕开逆向
from wasmer import Store, Module, Instance

instance = Instance(Module(Store(), open("core.wasm", "rb").read()))
result = instance.exports.encrypt(12345)

关键判断:wasm 通常是纯计算、不依赖 DOM,因此「直接加载调用」的成功率很高,往往不需要真正读懂里面的逻辑。先试这条路,不行再考虑反编译。


七、工程化建议

问题做法
算法随时可能更新把加密逻辑独立成单个 JS 文件 / 单个模块,与爬虫业务代码解耦
结果对不上难排查在 JS 侧和 Python 侧分别打印中间值逐段比对,而不是只比最终结果
参数拼接顺序易错断点处直接打印算法函数的完整入参,以它为准,不要照着代码推测
Node 服务成为单点多实例 + 负载均衡,或改用 Python 复现
逆向成果易丢失记录:目标参数名、定位方法、算法类型、入参构造规则、验证用的样例输入输出

💡 成本判断

逆向的投入产出比取决于数据量。抓几千条数据时,用 浏览器渲染 直接拿结果,成本远低于花两天逆向;抓百万级数据时,逆向省下的机器成本才能覆盖人力投入。

先估算数据量,再决定要不要开始逆向。


📚 模块与官方文档

调试工具

资源说明
Chrome DevTools - Sources断点、调用栈、代码格式化
DevTools - 断点类型XHR/事件/DOM/条件断点的完整说明
Local Overrides本地覆盖远程资源,注入调试代码
Tampermonkey 文档油猴脚本,@run-at document-start 注入 Hook

加密算法

模块安装官方文档
hashlib / hmac标准库hashlib · hmac
pycryptodomepip install pycryptodomepycryptodome.readthedocs.io
cryptographypip install cryptographycryptography.io
base64标准库docs.python.org
CryptoJS(前端对照)cryptojs.gitbook.io
JSEncrypt(前端 RSA)GitHub

AST 与混淆还原

模块安装官方文档
@babel/parsernpm i @babel/parserbabeljs.io/docs/babel-parser
@babel/traversenpm i @babel/traversebabeljs.io/docs/babel-traverse
@babel/generatornpm i @babel/generatorbabeljs.io/docs/babel-generator
@babel/typesnpm i @babel/typesbabeljs.io/docs/babel-types
Babel 插件手册(中文)GitHub
AST Explorerastexplorer.net

JS 执行

模块安装官方文档
PyExecJSpip install PyExecJSPyPI
PyMiniRacerpip install mini-racerGitHub
playwrightpip install playwrightpage.evaluate API
Node.jsnodejs.org/docs
Express(RPC 服务)npm i expressexpressjs.com

💡 PyExecJS 的替代

PyExecJS 已多年未更新且每次调用都启动进程。追求性能可换 PyMiniRacer(内嵌 V8,无进程开销),或直接用 Node RPC 服务。

WebAssembly

模块安装官方文档
wasmerpip install wasmer wasmer-compiler-craneliftwasmerio.github.io
wasmtimepip install wasmtimewasmtime.dev
pywasmpip install pywasmGitHub
wabt(wasm2wat)系统包GitHub
MDN - WebAssemblydeveloper.mozilla.org

← 返回 Python 深度研究