notemrovsky/tiktok-reverse-engineering
GitHub: notemrovsky/tiktok-reverse-engineering
一个对 TikTok 客户端 JavaScript 虚拟机进行逆向分析的教学工具包,涵盖操作码映射、去混淆与字节码反汇编。
Stars: 149 | Forks: 29
# TikTok 基于栈的虚拟机逆向工程
对 TikTok 客户端基于栈的虚拟机实现进行逆向工程分析,包括字符串去混淆、字节码解析和指令反汇编。
## 概述
本仓库包含用于逆向工程 TikTok JavaScript 虚拟机的工具和分析过程,该虚拟机用于客户端保护和代码混淆。该 VM 实现了具有 77 个不同操作码的基于栈的架构,并使用自定义的字节码解释。
**注意:整个分析是在 5-6 小时内完成的,因此可能存在一些错误或需要改进的地方。欢迎随时贡献并修复任何问题。**
我不打算继续这个项目了,因为在完成了去虚拟化器之后我的兴趣就完全消失了(这也是我处理 VM 时经常遇到的情况)。我想帮助那些正在 VM 逆向工程中挣扎的人,所以我在这里分享我所有的工作。
**重要提示**:如果 TikTok 对此仓库感到不满,请先联系我,而不是直接发送 DMCA 下架通知:(本仓库中的所有工作都是出于我个人的学习目的,我没有编写任何完整的签名器。)
- Discord: notemrovsky
- Discord 服务器: https://discord.gg/jsreversing
- 电子邮件: eemrovsky@proton.me
## 目录
- [字符串去混淆](#string-deobfuscation)
- [字节码分析](#bytecode-analysis)
- [控制流处理](#control-flow-handling)
- [虚拟机架构](#virtual-machine-architecture)
- [查找加密函数](#finding-encryption-functions)
- [用法](#usage)
- [贡献](#contributing)
## 字符串去混淆
我必须处理的第一件事是高度混淆的字符串。TikTok 的代码使用多个字符串数组来隐藏实际发生的事情。
### 混淆的工作原理
原始代码中包含如下引用:
```
// Obfuscated references
Kg[123] // References to main string array
aa[45] // References to numeric constants array
```
### 我的方法
1. **查找字符串数组**:定位了包含 1000 多个编码字符串的主数组(`giggernigger` 和 `aa`)
2. **使用 Babel 进行静态分析**:遍历 AST 以查找引用这些数组的所有 `MemberExpression` 节点
3. **直接替换**:只需将数组查找替换为实际值即可
```
// deobf.js - The core deobfuscation logic
const deobfuscateEncodedStringVisitor = {
MemberExpression(path) {
if (path.node && path.node.object && path.node.object.name == "Kg") {
toReplace = giggernigger[path.node.property.value]
path.replaceWith(t.valueToNode(toReplace))
}
if (path.node && path.node.object && path.node.object.name == "aa") {
toReplace = aa[path.node.property.value]
path.replaceWith(t.valueToNode(toReplace))
}
},
};
```
### 结果
运行此操作后,无意义的数组引用变得可读了:
```
// Before: Kg[123]
// After: "navigator"
// Before: aa[45]
// After: 2654435769
```
## 字节码分析
### 理解指令格式
VM 使用存储在 `Uint8Array` 中的自定义字节码。每条指令包含:
- **操作码**(1 字节):操作类型 (0-76)
- **操作数**(可变长度):立即数、偏移量或索引
### fetchInstructions 函数
该函数解析二进制格式。我必须逆向工程它读取字节码的方式:
```
function fetchInstructions() {
var R = [];
var r = blabla(t); // Read string count
j = []
for (var n = 0; n < r; ++n) {
j.push(decodestring(t)); // Decode strings
}
var o = blabla(t); // Read instruction count
for (n = 0; n < o; ++n) {
var i = blabla(t); // Function index
var u = Boolean(blabla(t)); // Strict mode flag
var a = new Array();
var s = blabla(t);
for (var c = 0; c < s; ++c) {
a.push([blabla(t), blabla(t), blabla(t), blabla(t)]); // Exception handlers
}
var f = new Array();
var d = blabla(t);
for (var l = 0; l < d; ++l) {
f.push(blabla(t)); // Bytecode instructions
}
R.push([f, i, u, a]);
}
return {strings: j, instructions: R}
}
```
### 为什么我硬编码了字节码
我没有实现 zip 解压缩算法(这会花费更多时间),而是直接提取了字节码并将其硬编码,以便更快地进行静态分析:
```
t = {
d: new Uint8Array([/* extracted bytecode */]),
i: 0
}
```
这让我能够专注于 VM 分析,而不是将时间花在解压缩实现上。
## 控制流处理
### 手动分析方法
我是手动完成这部分工作的,因为自动化的控制流分析会花费更长的时间。以下是我的操作:
1. **映射所有操作码**:创建了包含所有 77 个操作码及其功能的完整列表
2. **追踪跳转**:手动跟踪了条件跳转和无条件跳转
3. **异常处理程序**:弄清楚了 try-catch-finally 块的工作原理
4. **函数调用**:跟踪了函数之间是如何相互调用的
### 将嵌套条件转换为清晰的操作码
原始的 VM 代码是一堆混乱的嵌套 if-else 语句,让人难以理解。我把这个:
```
function h() {
while (true) {
var e = o[c++];
if (e < 38) {
if (e < 19) {
if (e < 9) {
if (e < 4) {
if (e < 2) {
if (e === 0) {
var t = v[l--];
v[l] -= t;
} else {
var r = o[c++];
if (!v[l--]) {
c += r;
}
}
} else if (e === 2) {
var n = o[c++];
l -= n;
var h = v.slice(l + 1, l + n + 1);
var m = v[l--];
var b = v[l--];
// ... more nested code
```
转换成了一个清晰的 switch 语句:
```
function h() {
while (true) {
var opcode = o[c++];
switch (opcode) {
case 0: //SUB
var t = v[l--];
v[l] -= t;
break;
case 1: //JUMP_IF_FALSE
var offset = o[c++];
if (!v[l--]) {
c += offset;
}
break;
case 2: //CALL
var argCount = o[c++];
l -= argCount;
var args = v.slice(l + 1, l + argCount + 1);
var func = v[l--];
var thisArg = v[l--];
if (typeof func != "function") {
f = 3;
d = new TypeError(typeof func + " is not a function");
return;
}
var w = C.get(func);
if (w) {
g.push([o, i, u, a, s, c, f, d]);
Ag[1](w[0], thisArg, args, w[1]);
} else {
var result = func.apply(thisArg, args);
v[++l] = result;
}
break;
// ... 74 more cases
}
}
}
```
### 关键控制流指令
| 操作码 | 指令 | 功能描述 |
|--------|-------------|-------------|
| 1 | JUMP_IF_FALSE | 如果栈顶为假则跳转 |
| 3 | JUMP | 无条件跳转 |
| 19 | TRY | 设置异常处理程序 |
| 20 | JUMP_IF_FALSE_OR_POP | 根据条件跳转或弹出 |
| 26 | JUMP_IF_TRUE_OR_POP | 根据条件跳转或弹出 |
| 29 | SWITCH_CASE | Switch 语句 case 处理 |
| 31 | JUMP_IF_TRUE | 如果栈顶为真则跳转 |
## 虚拟机架构
### 基于栈的设计
该 VM 是一台经典的栈机器,包含以下组件:
- **求值栈**(`v`):操作数存放以进行计算的地方
- **栈指针**(`l`):指向当前栈顶
- **程序计数器**(`c`):当前正在执行的指令
- **作用域链**(`a`):处理变量作用域
- **异常状态**(`f`, `d`):用于错误处理
### 主 VM 循环
```
function h() {
while (true) {
var opcode = o[c++];
switch (opcode) {
case 0: // SUB
var t = v[l--];
v[l] -= t;
break;
case 2: // CALL
var argCount = o[c++];
l -= argCount;
var args = v.slice(l + 1, l + argCount + 1);
var func = v[l--];
var thisArg = v[l--];
var result = func.apply(thisArg, args);
v[++l] = result;
break;
// ... 75 more opcodes
}
}
}
```
### 指令类型
VM 支持以下类别的指令:
**栈操作**:PUSH_*, POP, DUP
**算术运算**:ADD, SUB, MUL, DIV, MOD
**按位运算**:AND, OR, XOR, NOT, SHIFT
**比较运算**:EQ, NEQ, LT, GT, LTE, GTE
**控制流**:JUMP*, CALL, RETURN, TRY
**对象操作**:GET_PROP, SET_PROP, NEW_OBJECT
**变量访问**:GET_VAR, SET_VAR, GET_GLOBAL
## 查找加密函数
主要目标之一是找出 TikTok 在哪里处理请求签名和加密。这是我用来追踪这些函数的方法:
### 第 1 步:查找入口点
我首先定位了 token 实际生成的地方。通过调试,我找到了这些关键调用:
```
a = yn(u, new Uint8Array(t)); // a is the X-Bogus token
s = bn(u, new Uint8Array(r)); // s is the X-Gnarly token
```
### 第 2 步:VM 执行追踪
然后我在主 VM 执行循环中放置了一个断点,并添加了日志记录来跟踪发生了什么:
```
while (true) {
var opcode = o[c++]; // <-- Breakpoint here
if (window.thatarray) {
console.log(`[VM] Opcode ${opcode} at position ${c-1}, stack level: ${l}, instruction length ${o.length}`);
window.thatarray.push(`[VM] Opcode ${opcode} at position ${c-1}, stack level: ${l}, instruction length ${o.length}`);
}
if (window.oparrays) {
for (let i = 0; i < R.length; i++) {
if (R[i][0] === o) {
window.oparrays.push(`${i}`)
}
}
}
// ... rest of VM loop
}
```
### 第 3 步:查找 strData 函数
对于 strData 函数,我使用了稍微不同的方法。我在代码中找到了这个返回语句:
```
return xe({
magic: 538969122,
version: 1,
dataType: e,
strData: t, // <-- This is what I was looking for
tspFromClient: new Date().getTime()
});
```
我在这里设置了一个断点,然后在执行中稍微回退一些,以追踪 `strData` 值的来源。使用与 `window.thatarray` 和 `window.oparrays` 相同的日志记录方法,我跟踪了执行流程,以找出是哪个 VM 函数负责生成该数据。
### 第 4 步:一个小小的调试优势
这里就变得有趣了——如果你从断点处稍微往回追踪,你实际上可以在 strData 被 VM 处理之前看到它的原始版本。这是一个小疏忽,但对于想要了解哪些数据被输入到加密过程中的浏览器工程师来说非常有用。
虽然你仍然需要逆向工程实际的加密算法,但能够看到输入数据使得更容易理解数据流并验证你的分析。
### 第 5 步:收集执行追踪
要跟踪哪些 VM 函数参与了 token 的生成:
1. 设置 `window.thatarray = [];` 以收集操作码执行信息
2. 设置 `window.oparrays = [];` 以收集正在使用的函数索引
3. 从断点处恢复执行
4. 使用 `[...new Set(window.oparrays)]` 获取唯一的函数索引
### 第 6 步:结果
在追踪了 token 生成过程中的执行之后,我确定了这些关键函数:
```
vm249 | "X-Gnarly"
vm103 | "X-Bogus"
vm42 | strData
```
这些函数是 token 生成流程的起点——它们负责处理 TikTok 用于 API 身份验证和反机器人保护的请求头生成。"X-Bogus" 和 "X-Gnarly" 请求头包含加密的请求签名,这对于绕过 TikTok 的保护机制至关重要。
请注意,根据执行路径的不同,返回的可能是另一个操作码。你可以查看 `debug_asf/bogus_flow.txt` 以获取完整执行流程的示例,从而了解 VM 是如何逐步处理这些函数的。

这种方法的绝妙之处在于,只需设置日志数组并跟踪执行流程,你就可以准确追踪哪些 VM 部分参与了任何特定操作。无论你是在跟踪 token 生成还是任何其他功能,都适用相同的方法论——找到输出,在那里设置断点,然后向后追踪以查看哪些 VM 函数对结果做出了贡献。
在我看来,最好的方法是从调试中获取执行流程和使用的操作码,然后将这些模式与反汇编输出进行匹配,以构建可用的签名器。这既能让你从反汇编中获得高层级的理解,又能从调试中获得真实的执行追踪。
## 用法
### 运行去混淆器
```
# 反混淆 JavaScript 代码中的 strings
node deobf.js
# 从 bytecode 生成 disassembly
node disasm.js
```
### 分析 VM 函数
```
# disassembler 输出单个函数文件
ls functions/
# vm0.js, vm1.js, vm2.js, ... vm271.js
```
### 输出示例
```
------------------------103--------------------------
// 0 PUSH_STRING → stack[0] = "d41d8cd98f00b204e9800998ecf8427e"
// 3 SET_VAR scope[0][4] ← stack[0]
// 6 GET_VAR → stack[0] = scope[0][3]
// 9 PUSH_UNDEFINED → stack[1] = undefined
// 10 STRICT_NOT_EQUAL stack[0] = stack[0] !== stack[1]
```
## 许可证
MIT 许可证 - 用于教育和研究目的。
## 免责声明
本项目仅供教育和研究目的使用。请勿用于任何恶意用途。
*这是一次 5-6 小时的速通,因此请仔细检查所有内容并尽可能改进。*
标签:CMS安全, JavaScript, Wayback Machine, 云资产清单, 代码混淆, 反汇编, 密码学分析, 数据可视化, 浏览器安全, 生成式AI安全, 自动化payload嵌入, 自定义脚本, 虚拟机, 逆向工程