JavaScript 逆向技术
当接口参数里出现 sign、token、_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,逐帧向上找参数拼装处 | 配合上一条,是标准动作 |
| Initiator | Network → 请求 → Initiator → 点击跳转 | 最快,但被异步封装后可能失真 |
| 全局搜索 | Ctrl+Shift+F 搜参数名,如 "sign"、sign:、sign= | 参数名未被混淆时有效 |
| 事件监听断点 | Sources → Event Listener Breakpoints → 勾 click 等 | 参数在交互时生成 |
| Hook 拦截 | 注入代码劫持关键函数(第四节) | 前几种都失效时 |
💡 三个提速技巧
- 格式化代码:Sources 面板左下角
{}按钮,把压缩成一行的代码展开。 - 条件断点:右键行号 → Add conditional breakpoint,填
url.includes('/api/list'),只在关心的请求处断下,避免高频断点卡死页面。 - Local Overrides:Sources → Overrides 把远程 JS 保存到本地并修改(如插入
debugger或console.log),刷新后浏览器加载你改过的版本——这是分析动态加载脚本的利器。
1.3 反调试的绕过
站点常用 debugger 死循环阻止调试。三种处理方式:
| 手段 | 应对 |
|---|---|
定时器里的 debugger | 右键该行 → Never pause here |
Function("debugger")() 构造 | Hook Function 构造器,过滤含 debugger 的入参 |
| 检测控制台开关(窗口尺寸差) | 把 DevTools 分离为独立窗口 |
console.log 被重写导致卡顿 | 分析时避免打印大对象,或先恢复原生 console |
二、识别加密算法:先看是不是标准算法
绝大多数「加密参数」用的是标准算法,只是加了自定义的盐、拼接顺序或多轮嵌套。先按特征识别,能省下大量读代码的时间。
| 结果特征 | 算法 | 识别线索 |
|---|---|---|
| 32 位十六进制 | MD5 | 代码里有 hex_md5、md5、大量位运算与 0x67452301 等魔数 |
| 40 位十六进制 | SHA-1 | 魔数 0x5A827999 |
| 64 位十六进制 | SHA-256 | 常与 HMAC 同时出现 |
末尾有 = 或 ==,字符集含 +/ | Base64 | btoa/atob,或自定义字符表 |
| 长度为 16 的倍数,输出常为 Base64 | AES | CryptoJS.AES,关注 mode(CBC/ECB)、padding、key、iv |
| 长度固定 128/256 字符 | RSA | JSEncrypt、setPublicKey,公钥硬编码在 JS 里 |
| 输出可逆、逻辑简单 | 自定义异或/移位 | 需逐行还原 |
💡 魔改是常态
遇到「算法认出来了但结果对不上」,通常是三种魔改:自定义 Base64 字符表(换掉标准表的字符顺序)、修改初始向量或魔数、加盐位置特殊(盐插在中间而非首尾)。
排查方法:在算法入口下断点,把实际传入的完整输入打印出来,与自己 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) | 单行、变量名单字母 | 低——格式化即可读 |
| 变量名混淆 | _0xabc12、OOO0O | 低——不影响逻辑理解 |
| 字符串数组混淆 | 顶部一个大数组 + 取值函数 _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 型加密最容易处理——不执行它,而是把结果打出来:
// 原始: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(抽象语法树) 把代码解析成结构化的树,允许你写规则批量改写节点,再生成新代码。
源码 ──parse──> AST ──traverse/修改──> AST ──generate──> 还原后的源码工具链是 Babel(Node 生态):
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。
// 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.setItem、JSON.stringify/parse、XMLHttpRequest.open/send、window.fetch、Function、eval、atob/btoa、Array.prototype.join(字符串拼接常经过它)。
五、执行方案:三条路线
找到并看懂算法后,剩下的是「怎么在 Python 里算出来」。三条路线的成本与稳定性差异很大。
5.1 路线一:Python 纯复现
最优解:无外部依赖、性能最高、可任意并发。
适用条件:算法是标准的或逻辑清晰,不依赖任何浏览器环境对象(window、document、navigator、canvas)。
代价是移植工作量,且目标站改算法后需要重新逆向。
5.2 路线二:Node.js 执行原始 JS
把逆向出的 JS 代码原样跑起来,避免移植错误。
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。需要把缺失的环境对象补齐:
// 最小补环境骨架
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 做代理式补环境,并记录被访问的属性来逐步补全:
window = new Proxy({}, {
get(target, prop) {
console.log("[env] 读取 window." + String(prop)); // 记录缺什么补什么
return target[prop];
},
});5.3 路线三:Node RPC 常驻服务
工程化的标准做法:Node 侧起一个 HTTP 服务常驻,把加密函数暴露成接口,Python 侧只管调用。
// 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 侧:普通 HTTP 调用,可连接池复用
sign = httpx.post("http://127.0.0.1:3000/sign", json={"params": params}).json()["sign"]优势:JS 只解析一次、支持并发、Python 与 JS 解耦、算法更新只需替换 JS 文件并重启服务。这是生产环境的推荐方案。
5.4 路线四:浏览器上下文直接调用
补环境成本过高时(环境检测极强、代码量巨大),放弃脱环境,把浏览器当成一台"签名机":
# 页面已加载完所有依赖,直接调用页面里的函数
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 调用,最省事 |
# 直接加载 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 |
pycryptodome | pip install pycryptodome | pycryptodome.readthedocs.io |
cryptography | pip install cryptography | cryptography.io |
base64 | 标准库 | docs.python.org |
| CryptoJS(前端对照) | — | cryptojs.gitbook.io |
| JSEncrypt(前端 RSA) | — | GitHub |
AST 与混淆还原
| 模块 | 安装 | 官方文档 |
|---|---|---|
@babel/parser | npm i @babel/parser | babeljs.io/docs/babel-parser |
@babel/traverse | npm i @babel/traverse | babeljs.io/docs/babel-traverse |
@babel/generator | npm i @babel/generator | babeljs.io/docs/babel-generator |
@babel/types | npm i @babel/types | babeljs.io/docs/babel-types |
| Babel 插件手册(中文) | — | GitHub |
| AST Explorer | — | astexplorer.net |
JS 执行
| 模块 | 安装 | 官方文档 |
|---|---|---|
PyExecJS | pip install PyExecJS | PyPI |
PyMiniRacer | pip install mini-racer | GitHub |
playwright | pip install playwright | page.evaluate API |
| Node.js | — | nodejs.org/docs |
| Express(RPC 服务) | npm i express | expressjs.com |
💡 PyExecJS 的替代
PyExecJS 已多年未更新且每次调用都启动进程。追求性能可换 PyMiniRacer(内嵌 V8,无进程开销),或直接用 Node RPC 服务。
WebAssembly
| 模块 | 安装 | 官方文档 |
|---|---|---|
wasmer | pip install wasmer wasmer-compiler-cranelift | wasmerio.github.io |
wasmtime | pip install wasmtime | wasmtime.dev |
pywasm | pip install pywasm | GitHub |
wabt(wasm2wat) | 系统包 | GitHub |
| MDN - WebAssembly | — | developer.mozilla.org |