JS 逆向入门指南
1. 逆向的核心思路(#1-%E9%80%86%E5%90%91%E7%9A%84%E6%A0%B8%E5%BF%83%E6%80%9D%E8%B7%AF) 2. 第一步:抓包定位目标参数(#2-%E7%AC%AC%E4%B8%80%E6%AD%A5%EF%BC%9A%E6%8A%93%E5%8C%85%E5%AE%9A%E4%BD%8D%E7%9B%AE%E6%A0%87%E5%8F%82%E6%95%B0) 3. 第二步:找到签名来源(断点 + Hook)(#3-%E7%AC%AC%E4%BA%8C%E6%AD%A5%EF%BC%9A%E6%89%B
以抖音
a_bogus逆向为实战案例,从零讲解思路、工具与 Hook 片段
目录
- 逆向的核心思路
- 第一步:抓包定位目标参数
- 第二步:找到签名来源(断点 + Hook)
- 第三步:在浏览器环境里验证
- 第四步:识别加密算法
- 第五步:在 Node.js 中还原签名
- 第六步:插桩(Instrumentation)深入分析
- 常见坑与解决方案
- 常用 Hook 片段速查
- 工具链推荐
1. 逆向的核心思路
逆向不是破解,是追踪数据流。
浏览器发出一个神秘参数
↓
这个参数从哪来?(定位生成点)
↓
生成它需要哪些输入?(追踪输入)
↓
中间做了什么运算?(识别算法)
↓
用 Python/Node.js 复现同样的运算
黄金原则:
- 先观察,再动手。不要一上来就扣代码,先用 DevTools 观察几个请求,感受参数的规律。
- 缩小范围。JS 文件往往几十万行,不要试图全部读懂,只找签名相关的那一小块。
- 验证要及时。每往前推进一步就立刻验证,避免在错误路径上走太远。
2. 第一步:抓包定位目标参数
2.1 找到含签名的请求
打开 Chrome DevTools → Network 面板,过滤关键词:
a_bogus # 抖音签名参数
sign # 通用签名
X-Bogus
token
点击那个请求,查看:
- Headers → 有没有自定义请求头(
X-Sign、x-secsdk-web-signature等) - URL → 查询参数里有没有签名字段(
a_bogus=...)
2.2 观察参数规律
多刷新几次,对比多个请求的签名值:
- 每次都不一样?→ 含时间戳或计数器
- 同一个 URL 每次相同?→ 纯哈希,可离线复现
- 长度固定?→ 猜算法(32字节=MD5/SM3,64字节=SHA-512...)
3. 第二步:找到签名来源(断点 + Hook)
3.1 最简单的方法:XHR/Fetch 断点
DevTools → Sources → 右侧 "XHR/fetch Breakpoints" → 添加 URL 关键词
/aweme/v1/ ← 抖音 API 路径
刷新页面,断点触发后查看调用栈,往上翻就能找到签名函数。
3.2 Hook URLSearchParams.append
很多签名是最后通过 URLSearchParams 拼到 URL 上的。在 DevTools Console 运行:
// 在 Console 里运行,拦截所有 append 调用
const _orig = URLSearchParams.prototype.append;
URLSearchParams.prototype.append = function(key, value) {
if (key === 'a_bogus' || key === 'sign') {
console.log('[Hook] append:', key, '=', value);
console.trace(); // 打印调用栈!
}
return _orig.call(this, key, value);
};
注意:如果 SDK 在页面加载时就 hook 了原型,Console 里注入已经太晚了。
需要用 initScript(见下面)。
3.3 Hook fetch / XMLHttpRequest
// 拦截 fetch,查看请求 URL
const _fetch = window.fetch;
window.fetch = function(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
if (url?.includes('aweme')) {
console.log('[Hook] fetch:', url);
console.trace();
}
return _fetch.apply(this, args);
};
// 拦截 XHR
const _open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
if (String(url).includes('aweme')) {
console.log('[Hook] XHR.open:', url);
console.trace();
}
return _open.apply(this, arguments);
};
3.4 initScript:在所有脚本之前注入(最强)
适用于 SDK 在页面加载时就读取原型引用的情况。
使用 Chrome DevTools Protocol (CDP) 或 Playwright 的 addInitScript:
// Playwright:
await page.addInitScript(() => {
const _orig = URLSearchParams.prototype.append;
URLSearchParams.prototype.append = function(key, value) {
if (key === 'a_bogus') {
window.__captured_bogus = value;
console.log('[initScript] a_bogus:', value);
}
return _orig.call(this, key, value);
};
});
或用 Chrome DevTools MCP / Puppeteer:
// 在页面加载前通过 CDP 注入
await session.send('Page.addScriptToEvaluateOnNewDocument', {
source: `
const _orig = URLSearchParams.prototype.append;
URLSearchParams.prototype.append = function(k, v) {
if (k === 'a_bogus') window.__ab = v;
return _orig.call(this, k, v);
};
`
});
4. 第三步:在浏览器环境里验证
找到签名函数后,先在 DevTools Console 里手动调用它,确认能复现签名,再考虑搬到 Node.js。
// 假设找到了签名函数叫 bdms.sign
bdms.sign('https://www.douyin.com/aweme/v1/web/social/count?aid=6383')
// 看看能不能返回一个 a_bogus 值
如果函数依赖 this 或闭包变量,在 Console 里直接调用可能报错,这很正常,继续往调用栈上层找包装函数。
5. 第四步:识别加密算法
5.1 搜索魔法常量
每种哈希/加密算法都有固定的初始化常量,这些常量不会被混淆。
MD5: 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476
SHA-1: 0x67452301, 0xEFCDABCD, 0x98BADCFE
SHA-256: 0x6a09e667, 0xbb67ae85, 0x3c6ef372
SM3: 0x7380166f, 0x4914b2b9 ← 中国国密算法
AES: 0x63, 0x7c, 0x77, 0x7b (S-box 前几个值)
在 DevTools 搜索(Ctrl+Shift+F):
2043430169 ← SM3 的轮常量 Tj (十进制)
1937774191 ← SM3 的 IV[0] (十进制)
5.2 看函数名残留
混淆工具有时保留部分函数名,搜索:
SM3, sm3, Sm3
FF, GG ← SM3 的轮函数名
SHA, sha
MD5, md5
AES, aes
HMAC
pbkdf2, bcrypt
5.3 看输出长度
| 长度 | 可能算法 |
|---|---|
| 32 hex 字符 (16B) | MD5 |
| 40 hex 字符 (20B) | SHA-1 |
| 64 hex 字符 (32B) | SHA-256 / SM3 |
| 128 hex 字符 (64B) | SHA-512 |
5.4 用 Python 快速验证猜测
import hashlib, hmac
data = b"your_input_here"
# 测试各种算法
print("MD5: ", hashlib.md5(data).hexdigest())
print("SHA1: ", hashlib.sha1(data).hexdigest())
print("SHA256: ", hashlib.sha256(data).hexdigest())
# SM3 (需要 gmssl 库: pip install gmssl)
from gmssl import sm3, func
print("SM3: ", sm3.sm3_hash(func.bytes_to_list(data)))
对比浏览器里捕获到的哈希值,看哪个匹配。
6. 第五步:在 Node.js 中还原签名
6.1 最简单的方式:直接扣 SDK 在 Node.js 里跑
如果 SDK 是纯 JS(不依赖 DOM),可以直接用 Node.js 运行:
// run_sdk.mjs
import { readFileSync } from 'fs';
// 加载 SDK 代码
const sdk_code = readFileSync('./lib/some_sdk.js', 'utf-8');
// 创建沙箱全局对象
const globalObj = {
window: {},
document: { createElement: () => ({}) },
navigator: { userAgent: 'Mozilla/5.0...' },
location: { href: 'https://www.example.com/' },
};
// 用 Function 构造器在沙箱里执行
const fn = new Function('window', 'document', 'navigator', 'location',
sdk_code + '\n return typeof sign !== "undefined" ? sign : typeof module !== "undefined" ? module.exports : null;'
);
const sign = fn(globalObj.window, globalObj.document, globalObj.navigator, globalObj.location);
6.2 用 jsdom 模拟完整浏览器环境
适用于 SDK 依赖 DOM API 的情况:
import { JSDOM } from 'jsdom';
import { readFileSync } from 'fs';
const src = readFileSync('./lib/sdk.js', 'utf-8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://www.example.com/', // 必须设置,SDK 会读取 location
pretendToBeVisual: true, // 重要!防止 canvas 相关代码崩溃
runScripts: 'dangerously', // 允许执行脚本
});
const win = dom.window;
// 在加载 SDK 之前安装 Mock
class MockXHR {
open(method, url) { this._url = url; }
send() {}
setRequestHeader() {}
addEventListener() {}
removeEventListener() {}
}
win.XMLHttpRequest = MockXHR;
// 加载 SDK
const scr = win.document.createElement('script');
scr.textContent = src;
win.document.head.appendChild(scr);
// 调用 SDK 的初始化函数
win.bdms.init({ aid: 6383, paths: ['^/aweme/v1/'] });
// Hook URLSearchParams 捕获签名(必须在 init 之前,或用 initScript)
const origAppend = win.URLSearchParams.prototype.append;
win.URLSearchParams.prototype.append = function(k, v) {
if (k === 'a_bogus') {
console.log('Captured a_bogus:', v);
}
return origAppend.call(this, k, v);
};
6.3 封装为 HTTP 服务(Python 调用)
Node.js 启动一个本地 HTTP 服务,Python 调用它:
// sign_server.mjs
import http from 'http';
// ... 初始化 SDK(同上)...
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const target = url.searchParams.get('url');
const a_bogus = await getSignature(target);
res.end(JSON.stringify({ a_bogus }));
});
server.listen(8888, () => {
// Python 端监听这行输出来判断服务已就绪
process.stdout.write(JSON.stringify({ status: 'ready' }) + '\n');
});
# Python 端
import subprocess, requests, json, time
class Signer:
def __init__(self, port=8888):
self._proc = subprocess.Popen(
['node', 'sign_server.mjs'],
stdout=subprocess.PIPE, text=True
)
# 等待服务就绪
for _ in range(30):
line = self._proc.stdout.readline()
if '"ready"' in line:
break
time.sleep(0.2)
self._port = port
def sign(self, url):
r = requests.get(f'http://localhost:{self._port}/sign',
params={'url': url}, timeout=5)
return r.json()['a_bogus']
def close(self):
self._proc.terminate()
def __enter__(self): return self
def __exit__(self, *_): self.close()
# 使用
with Signer() as s:
token = s.sign('https://www.douyin.com/aweme/v1/...')
7. 第六步:插桩(Instrumentation)深入分析
当算法藏在 VM 里看不懂时,不必读懂 VM,只需在关键函数的入口/出口插入日志。
7.1 源码字符串替换插桩
最稳定的方法:加载 SDK 前,用字符串替换在源码里直接插入日志。
let src = readFileSync('./lib/sdk.js', 'utf-8');
// 目标:在 someFunc(t) 的开头插入日志
// 原代码片段:
// function someFunc(t){var r=...
// 替换为:
// function someFunc(t){globalThis.__logs.push({fn:'someFunc',in:t});var r=...
src = src.replace(
'function someFunc(t){var r=',
'function someFunc(t){if(globalThis.__logs)globalThis.__logs.push({fn:"someFunc",in:typeof t==="string"?t:Array.from(t)});var r='
);
关键技巧:
- 用
globalThis.__logs而不是局部变量,避免变量名冲突 - 用
if(globalThis.__logs)做开关,只在需要时开启 - 对 byte array 用
Array.from(t)转换,方便打印 - 替换的代码片段要足够独特,不能在文件里有多处匹配
7.2 验证替换成功
// 检查替换是否生效
if (!src.includes('globalThis.__logs')) {
console.error('替换失败!原始代码片段未找到');
process.exit(1);
}
console.log('替换成功,共', (src.match(/globalThis\.__logs/g) || []).length, '处');
7.3 控制日志开关
win.__logs = null; // 默认关闭
// 需要分析时打开
win.__logs = [];
// ... 触发目标操作 ...
const log = [...win.__logs];
win.__logs = null; // 关闭
// 分析日志
log.forEach((entry, i) => {
console.log(`[${i}] ${entry.fn}: input=`, entry.in);
});
7.4 完整的哈希函数插桩示例
以抖音 SM3 分析为例:
// 插桩 write() 入口,记录所有输入
src = src.replace(
'key:"write",value:function(t){var r="string"==typeof t?',
'key:"write",value:function(t){' +
'if(globalThis.__sm3Calls)globalThis.__sm3Calls.push({op:"W",d:typeof t==="string"?t:Array.prototype.slice.call(t)});' +
'var r="string"==typeof t?'
);
// 插桩 sum() 输出,记录哈希值
src = src.replace(
'return this.reset(),u}},{key:"_compress"',
'if(globalThis.__sm3Calls)globalThis.__sm3Calls.push({op:"S",d:u});return this.reset(),u}},{key:"_compress"'
);
8. 常见坑与解决方案
坑 1:Hook 时机太晚
现象:在 Console 里 hook 了 URLSearchParams.prototype.append,但签名时没有触发。
原因:SDK 在初始化时就把原型方法的引用存到了内部变量里,之后的修改不影响它。
// SDK 内部(简化):
var _append = URLSearchParams.prototype.append; // 启动时存了引用
// ... 之后调用 _append(key, value) ← 不受后来的 hook 影响
解决:使用 initScript(在 SDK 加载前注入),或用 Playwright/Puppeteer 的 addInitScript。
坑 2:Hook 内部抛异常破坏 VM
现象:某些 hook 注入后,SDK 的 VM 报各种奇怪错误(TypeError: undefined is not a function 等)。
原因:VM 有 try/catch,hook 抛出的错误被 VM 捕获,导致 VM 状态错乱。
错误做法:
// 在原生方法上 hook 并抛出 Error 获取堆栈——会破坏 VM
Number.prototype.toString = function() {
throw new Error('trace'); // ← 危险!VM 内部大量调用 toString
};
正确做法:hook 里只做记录,不抛异常,调用原始方法后正常返回。
坑 3:测试 URL 不匹配路径过滤规则
现象:签名函数调用了,但 a_bogus 就是没出现。
原因:SDK 只对特定路径签名,测试 URL 不在白名单里。
// 抖音 bdms 只签名这些路径:
paths: ['^/aweme/v1/', '^/aweme/v2/', '^/webcast/']
// 用 http://test.com/api/xxx 当然不会签名!
// 必须用真实的 API URL
解决:在 Network 面板找一个真实请求的 URL 来测试。
坑 4:jsdom 缺少 canvas 导致崩溃
现象:jsdom 里运行 SDK 时报 TypeError: Cannot read properties of undefined (reading 'getContext')。
解决:pretendToBeVisual: true 让 jsdom 声称自己支持 canvas,SDK 一般会降级处理。
const dom = new JSDOM('...', {
pretendToBeVisual: true, // ← 加上这行
runScripts: 'dangerously',
});
坑 5:Node.js 进程挂起不退出
现象:脚本执行完了,但进程一直不退出。
原因:jsdom 内部有 setTimeout/setInterval 保持了事件循环。
解决:在所有输出打印完后强制退出:
// 放在文件最末尾
process.exit(0);
坑 6:Windows 终端中文乱码
现象:Python 脚本输出中文时报 UnicodeEncodeError。
解决:
import sys, io
# 强制使用 UTF-8 输出
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
或在运行脚本前设置环境变量:
set PYTHONIOENCODING=utf-8
9. 常用 Hook 片段速查
URLSearchParams 签名捕获
// 适合 initScript 注入
(function() {
const _orig = URLSearchParams.prototype.append;
URLSearchParams.prototype.append = function(key, value) {
if (['a_bogus', 'sign', 'signature'].includes(key)) {
window.__signatures = window.__signatures || {};
window.__signatures[key] = value;
// 可选:通知外部代码
window.dispatchEvent(new CustomEvent('sign-captured', { detail: { key, value } }));
}
return _orig.call(this, key, value);
};
})();
fetch 请求拦截
(function() {
const _fetch = window.fetch;
window.fetch = async function(input, init) {
const url = typeof input === 'string' ? input : input.url;
console.log('[fetch]', url?.substring(0, 100));
const resp = await _fetch.apply(this, arguments);
return resp;
};
})();
XHR 全拦截
(function() {
const _open = XMLHttpRequest.prototype.open;
const _send = XMLHttpRequest.prototype.send;
const _setHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function(method, url) {
this.__url = url;
return _open.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
console.log('[XHR header]', name, ':', value?.substring(0, 60));
return _setHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
console.log('[XHR send]', this.__url?.substring(0, 100));
return _send.apply(this, arguments);
};
})();
函数调用追踪(不破坏原函数)
function wrapFn(obj, fnName, label) {
const orig = obj[fnName];
obj[fnName] = function() {
const args = Array.from(arguments).map(a =>
typeof a === 'string' ? a.substring(0, 60) : typeof a
);
console.log(`[${label}] ${fnName}(`, args.join(', '), ')');
const result = orig.apply(this, arguments);
console.log(`[${label}] ${fnName} => `, String(result).substring(0, 60));
return result;
};
}
// 使用
wrapFn(window.crypto.subtle, 'digest', 'Crypto');
wrapFn(window, 'btoa', 'Encode');
localStorage/sessionStorage 监控
(function() {
['localStorage', 'sessionStorage'].forEach(storeName => {
const store = window[storeName];
const _setItem = store.setItem.bind(store);
const _getItem = store.getItem.bind(store);
Object.defineProperty(window, storeName, {
get() {
return new Proxy(store, {
get(target, prop) {
if (prop === 'setItem') return function(k, v) {
console.log(`[${storeName}] set "${k}" =`, String(v).substring(0, 80));
return _setItem(k, v);
};
return typeof target[prop] === 'function'
? target[prop].bind(target) : target[prop];
}
});
}
});
});
})();
Cookie 变动监控
(function() {
let _cookie = document.cookie;
Object.defineProperty(document, 'cookie', {
get() { return _cookie; },
set(val) {
console.log('[cookie set]', val.substring(0, 80));
_cookie = val;
}
});
})();
10. 工具链推荐
必装
| 工具 | 用途 |
|---|---|
| Chrome DevTools | 抓包、断点、Console 注入 |
| Node.js (v18+) | 运行 JS SDK |
jsdom npm 包 |
模拟浏览器 DOM 环境 |
gmssl pip 包 |
Python 里验证 SM3 |
requests pip 包 |
Python HTTP 请求 |
npm install jsdom # Node.js 浏览器模拟
pip install gmssl requests # Python 工具
进阶
| 工具 | 用途 |
|---|---|
| Playwright | 带真实浏览器的自动化,支持 initScript |
| Fiddler / mitmproxy | HTTPS 流量解密抓包 |
| AST Explorer | 分析混淆 JS 的语法树 |
| de4js.com | 在线 JS 反混淆 |
| CyberChef | 在线算法验证工具箱 |
DevTools 快捷键
Ctrl+Shift+F 全局搜索(所有 JS 文件中搜索)
Ctrl+P 按文件名搜索
{} 格式化压缩代码(点击底部花括号图标)
F8 继续执行(断点中)
F10 单步跳过
F11 单步进入
Ctrl+Shift+E 查看 Event Listener
实战流程回顾:抖音 a_bogus
1. Network 找到含 a_bogus 的请求
↓
2. 全局搜索 "a_bogus" → 找到 bdms SDK 加载位置
↓
3. initScript hook URLSearchParams.append → 确认 a_bogus 从 bdms 产生
↓
4. 下载 bdms JS 文件,搜索常量 2043430169 → 发现 SM3 实现
↓
5. 在 jsdom 中加载 bdms,Mock XHR,成功生成 a_bogus
↓
6. 源码插桩 SM3 的 write/sum 方法 → 记录完整 5 步哈希链
↓
7. Python gmssl 验证每一步哈希值完全一致
↓
8. 封装 Node.js HTTP 服务 + Python ABogus 类,完成集成
记住:逆向的本质是调试和观察,而不是猜测。
每一步都要有验证,不要在没有证据的假设上构建逻辑。
最佳实践
从网络请求入手,不要从代码入手:打开 DevTools Network,过滤目标请求,观察哪些参数在刷新后变化、哪些不变。变化的参数就是签名目标,然后再去找代码。直接搜索代码库效率低,因为混淆后的变量名毫无规律。
XHR/fetch 断点优先于全局搜索:在 DevTools Sources 面板设置 XHR 断点匹配目标 URL,比全局搜索参数名快得多。断到请求发出的位置后,调用栈里就能看到参数从哪里来。
Hook 验证参数来源:通过调用栈定位到疑似生成签名的函数后,在该函数入口 console.log(arguments) + console.trace(),刷新页面验证输入输出,再和 Network 里的实际值比对,确认后才开始分析逻辑。
魔法数字是识别算法的最快方式:MD5 的 0x67452301、SHA-1 的 0x67452301、SM3 的 0x79cc4519 这类初始化常量在混淆代码里不会被改变。搜索这些常量立刻定位算法实现,比从函数名、调用链入手快一个数量级。参见 魔法数字速查。
用 Python 做交叉验证,不要只靠浏览器:还原出算法后,在 Python 里用标准库(hashlib、pycryptodome)实现同样的计算,用相同输入对比输出。一致才算真的还原,否则可能有字节序、编码或 padding 细节遗漏。
常见陷阱
陷阱:在混淆代码里搜索参数名找不到
现象: Network 里看到 a_bogus=xxx,全局搜索 a_bogus 在 JS 里找不到任何赋值。
原因: 混淆代码把字符串分散存储,或通过字符串拼接、解密后动态生成键名,静态搜索无法找到。
解决: 改用运行时 Hook:Object.defineProperty(URLSearchParams.prototype, 'append', ...) 拦截所有 URLSearchParams 添加操作,或 Hook Object.defineProperty(document, 'cookie', ...) 拦截 Cookie 写入,在断点的调用栈里找来源。
陷阱:在 Node.js 里跑浏览器代码,window/document 报错
现象: 复制 JS 文件到 Node.js 执行,立刻报 ReferenceError: window is not defined 或 document is not defined。
原因: 浏览器 JS 依赖全局的 window、document、navigator 等对象,Node.js 环境没有。
解决: 使用 jsdom 提供完整 DOM 环境,或手动补充全局变量。参见 jsdom完全指南 和 补环境。
陷阱:逆向出的算法输出和真实值不一致
现象: 以为还原了签名算法,Python 里算出的结果和抓包数据不一样。
原因: 常见遗漏点:字节序(大端/小端)、字符串编码(UTF-8/GBK/Latin-1)、HMAC 密钥的编码方式、哈希输入是字符串还是字节数组、输出是 hex 还是 Base64。
解决: 在浏览器里打印算法每一步的中间值,Python 里逐步对比,定位到第几步开始不一致,就能精确找到差异。