之前分析的全是基于寄存器的字节码,基于栈的C#/CPython/JVM反编译已经很成熟了,而实际还会遇到的栈式VM基本都是VMP混淆的代码,U1S1,这种代码通常是不需要反编译的,直接分析到每个handler后,写个反汇编器,再配合AI就能还原出90%了,再动态跟一遍就能百分百恢复,不过,就是想玩玩🐶~
栈 or 寄存器式虚拟机
最常见的就是基于栈和基于寄存器的虚拟机,现在说明一下他们各自的优缺点。
栈机
它的指令集特别简单,操作数隐含在栈上所以字节码非常紧凑,编译器实现起来也非常简单,只需要对AST做一次后序遍历即可,不需要考虑复杂的寄存器分配,缺点是不好直接做静态分析和优化,而且在栈在内存中没优化好会导致频繁的访存,当然了,只要技术好都不是问题,JVM就是栈机依然跑的飞快,还有.NET CLR...
寄存器机
相对于栈机,开发门槛更高(当然了部分场景可以基于LLVM这些成熟的东西搞),它的指令集更接近物理硬件,指令需要编码操作数(作为优化可隐含累加器)实现起来更复杂,字节码更长,不过优化和静态分析会好做很多,ART,V8,WASM等都是基于寄存器的...
原始VMP分析
静态分析
通过跟踪启动器可定位到该参数在bdms的X函数里生成的,该函数明显是一个while-switch形式的虚拟机解释器,使用了switch2if来混淆opcode的handler:
function X(t, e, r, n) {
var o, i, u, c, a, s, f, l, p = -1, h = [], v = [];
// var instructions, strictMode, exceptionTable, scopeChain, thisBinding, instructionPointer, executionState, stateValue, stackPointer = -1, operandStack = [], callStack = [];
g(t, e, r, n); // bytecode, thisValue, args, closureEnv
do { // 外层循环
try {
y() // 内层while-switch执行每条指令
} catch (t) {
f = 3,l = t
}
} while (d()); // 处理状态
return l;
function g(t, e, r, n) {
// ... 这个下面分析
s = 0, // 初始化PC指针
f = 0, // 初始化状态类型
l = void 0 // 初始化状态值
}
function y() {
for (; ; ) {
var t = o[s++];
if (t < 38)
if (t < 19)
if (t < 9)
if (t < 4)
if (t < 2)
if (0 === t) {
var e = h[p--];
h[p] = h[p] === e
} else
e = h[p--],
h[p] = h[p] < e;
else if (2 === t) {
var r = h[p--]
, n = (U = h[p--])[r]--;
h[++p] = n
} else {
var y = o[s++];
n = h[p--],
Object.defineProperty(h[p], z[y], {
value: n,
writable: !0,
configurable: !0,
enumerable: !0
})
}
// ...
}
}
function d() {
var t = s, e = u;
if (1 === f) {
for (var r = e.length - 1; r >= 0; --r)
if ((n = e[r])[0] < t && t <= n[3])
return t <= n[2] && n[2] !== n[3] ? s = n[2] : (s = l,
f = 0,
l = void 0),
!0;
throw new SyntaxError("Illegal statement")
}
if (2 === f) {
for (r = e.length - 1; r >= 0; --r)
if ((n = e[r])[0] < t && t <= n[2] && n[2] !== n[3])
return s = n[2],
!0;
return !!(g = v.pop()) && (h[++p] = l,o = g[0],
i = g[1],u = g[2],c = g[3],a = g[4],s = g[5],f = g[6],l = g[7],!0)
}
if (3 === f) {
for (r = e.length - 1; r >= 0; --r) {
var n;
if ((n = e[r])[0] < t) {
if (t <= n[1] && n[1] !== n[2])
return s = n[1],h[++p] = l,f = 0,l = void 0,!0;
if (t <= n[2] && n[2] !== n[3])
return s = n[2],!0
}
}
var g;
if (g = v.pop())
return o = g[0],i = g[1],u = g[2],c = g[3],a = g[4],s = g[5],d();
throw l
}
return !0
}
function m(t, e) {
var r = Object.create(null);
return Object.defineProperty(r, t, {
get: function() {
if (globalThis[t])
return globalThis[t];
throw new ReferenceError(t + " is not defined")
},
set: function(r) {
if (e && !globalThis[t])
throw new ReferenceError(t + " is not defined");
globalThis[t] = r
}
}),r
}
}
简单逆向分析即可得出各函数作用:
1.X: vm入口,所有虚拟函数从这里被执行
2.g: 设置vm上下文信息,如PC,作用域链,推入参数等
3.y: while-switch handler,字节码解释执行的主逻辑
4.d: 结果处理,如处理异常,函数返回到上层等
5.m: 创建全局对象的代理
主要看g函数,这是个栈机,它只有70来条指令且很多根本没被执行,所以只需要trace一遍把执行过的指令分析了即可,先说一个非常有利于分析的,字符串常量池,它在一个全局的变量z里,直接在任意点下断即可输出:
} else if (7 === t) {
y = o[s++]; // 获取指令操作数
var w = z[y]; // 读常量池
globalThis[w] || (globalThis[w] = void 0) // 写值
接着有个关键的概念,作用域链,它有点像x86用的操作数栈,里面会存储函数参数,本地变量,以及" 链接"上层作用域:
// g 作用域链初始化
function g(t, e, r, n) {
var p = Math.min(r.length, t[1]) // 实参和形参取最小值
, h = {};
Object.defineProperty(h, "length", {
value: r.length,
writable: !0,
enumerable: !1,
configurable: !0
}),o = t[0],i = t[2],u = t[3],
c = [n, h]; // 作用域链 c[0]=n=parent c c[1]=arguments
for (var v = 0; v < p; ++v)
c.push(r[v]); // 将放入c c[1]=arg0 c[2]=arg1 ...
if (i)
for (a = e,
v = 0; v < r.length; ++v)
h[v] = r[v]; // 再将所有参数全部放入c[1]
// .....
}
// y 本地变量读 t = opcode = 46
else {
for (x = o[s++], // scope level
y = o[s++], // scope index
d = c; x > 0; )
d = d[0], // 向上查找到指定作用域
--x;
n = d[y], // 在作用域内访问指定变量
h[++p] = n
}
接着就可以分析另一个关键指令,函数调用,它会判断被调用者是虚拟机函数还是普通函数,如果是虚拟机函数,它会使用自己实现的调用栈来模拟函数调用:
else if (36 === t) {
var k = o[s++]; // 指令里获取参数个数
p -= k;
var L = h.slice(p + 1, p + k + 1) // 从操作数栈获取参数
, T = h[p--] // 获取被调函数
, C = h[p--]; // this值
if ("function" != typeof T)
return f = 3,
void (l = new TypeError(typeof T + " is not a function"));
var I = B.get(T); // B为函数注册表
if (I) // 1. 为虚拟函数
v.push([o, i, u, c, a, s, f, l]), // 将当前上下文压栈
g(I[0], C, L, I[1]); // 重新初始化vm 上下文
else { // 普通函数,直接调用
var q = T.apply(C, L);
h[++p] = q
}
如果是虚拟函数,在函数调用处是不会处理返回值的,相反,在被调函数的最后函数的最后会执行ret指令,此时会将结果存入状态值里,再由d将结果压入栈中:
// y t = opcode = 15
else if (t < 16) {
if (14 !== t)
return f = 2, // 状态f为2
void (l = h[p--]); // 状态值l为栈顶
// d f = execState = 2
if (2 === f) {
for (r = e.length - 1; r >= 0; --r)
if ((n = e[r])[0] < t && t <= n[2] && n[2] !== n[3])
return s = n[2],
!0;
return !!(g = v.pop()) && (h[++p] = l, // 将结果压栈
o = g[0],// 恢复调用者上下文
i = g[1],
u = g[2],
c = g[3],
a = g[4],
s = g[5],
f = g[6],
l = g[7],
!0)
}
现在再继续看看虚拟化函数是怎么被注册的,这含三个部位:
// W 是直接从外部直接调用虚拟化函数,其中n是特殊的scope信息
function W(t, e, r, n) {
return z.length || function(t) {
var e = function(t) {
for (var e = atob(t), r = 0, n = 4; n < 8; ++n)
r += e.charCodeAt(n);
return {
d: T(Uint8Array.from(e.slice(8), _, r % 256)),
i: 0
}
}(t);
z.length = 0,
Y.length = 0,
B.clear();
for (var r = J(e), n = 0; n < r; ++n)
z.push(Q(e));
var o = J(e);
for (n = 0; n < o; ++n) {
for (var i = J(e), u = Boolean(J(e)), c = new Array, a = J(e), s = 0; s < a; ++s)
c.push([J(e), J(e), J(e), J(e)]);
for (var f = new Array, l = J(e), p = 0; p < l; ++p)
f.push(J(e));
Y.push([f, i, u, c]) // 将每个虚拟函数的字节码,参数个数,是否为严格模式,异常处理表等信息存放于Y
}
}("UEsCAJ..."), // 解码字符串,初始化虚拟化函数信息
X(Y[t], e, r, n) // 调用序号为t的函数
}
// y函数内部也可注册虚拟化函数,此时的作用域链就是当前的 (t = opcode = 18)
t < 17 ? (n = h[p--],
h[p] /= n) : 17 === t ? (n = h[p],
h[++p] = n) : (n = K(o[s++], c), // 将指定编号函数和作用域链注册到B
h[++p] = n);
// K 如下,返回的n是索引,用来在函数调用时重新找到注册的函数及闭包信息
function K(t, e) {
var r = Y[t];
Z.has(t) && B.delete(Z.get(t));
var n = function() {
return X(r, this, arguments, e)
};
return Z.set(t, n),
B.set(n, [r, e]),
n
}
外界其实都是通过W函数进入虚拟机的,例如:
W(156, void 0, arguments, { // 这是个特殊的作用域链,它不再链接上层了,格式也不符合前面提到的格式(scope[0]=parentscope scope[1]=arguments ...)!
get 0() {return I},
set 0(t) {I = t},
get 1() {return q},
set 1(t) {q = t},
get 2() {return F},
set 2(t) {F = t},
// ..
})
现在就可以直接硬干了
动态分析技巧
1.将if-else if改为switch-case形式,所有替换直接用chrome的source code替换功能即可
2.它模拟了函数调用,我们调试器的step over会失效,多个函数调用容易搞晕,简单改改就能直接把它改成系统的调用机制:
// ---- orgin
v.push([o, i, u, c, a, s, f, l]),
g(I[0], C, L, I[1]);
// ---- modify
var q = X(I[0], C, L, I[1]);;
h[++p] = q
3.这个虚拟机和外部的所有联络都依赖于global变量,hook它就能看到它和外界的所有联络
4.可以在while第一行trace所有的opcode oprand与栈 本地变量的状态,到时候直接数据分析会十分简单
反汇编
分析y可知每个字节码指令是不定长的,除了一字节操作码,可能会存在0~2字节的立即数操作数,按这个直接编写:
# 格式: opcode: (mnemonic, operand_count, description, original_code_line)
self.opcodes = {
0: ("STRICT_EQ", 0, "严格相等比较 ===",
"operandStack[stackPointer] = operandStack[stackPointer] === right;"),
1: ("LT", 0, "小于比较 <",
"operandStack[stackPointer] = operandStack[stackPointer] < right;"),
2: ("POST_DEC", 0, "后缀递减 obj[prop]--",
"var value = (U = operandStack[stackPointer--])[prop]--;"),
3: ("DEFINE_PROP", 1, "定义属性",
"Object.defineProperty(operandStack[stackPointer], z[stringIndex], {...});"),
4: ("JMP", 1, "跳转指令",
"executionState = 1; stateValue = instructionPointer + offset;"),
5: ("UNARY_PLUS", 0, "一元加号 +",
"operandStack[stackPointer] = +operandStack[stackPointer];"),
# ...
然后利用每个函数定义时的参数,即可再解析scope,执行效果为:
0000 28 CA GET_GLOBAL 202 # 常量: "performance"
0002 11 DUP
0003 47 AD GET_PROP_STR 173 # 常量: "now"
0005 24 00 CALL 0 # 参数数量: 0
0007 33 00 05 SET_SCOPE_VAR 0, 5 # local_var0
000A 39 LOAD_UNDEFINED2
000B 2E 02 0D GET_SCOPE_VAR 2, 13 # 上层作用域[2][13]
000E 24 00 CALL 0 # 参数数量: 0
0010 4B POP
0011 2E 00 02 GET_SCOPE_VAR 0, 2 # arg_0
0014 33 00 06 SET_SCOPE_VAR 0, 6 # local_var1
0017 2E 00 03 GET_SCOPE_VAR 0, 3 # arg_1
001A 33 00 07 SET_SCOPE_VAR 0, 7 # local_var2
001D 2E 00 03 GET_SCOPE_VAR 0, 3 # arg_1
0020 0D TYPEOF
0021 15 08 LOAD_STRING 8 # 常量: "string"
0023 36 STRICT_NE
0024 46 13 JMP_TRUE_KEEP 19 # 跳转到: L1
0026 2E 00 04 GET_SCOPE_VAR 0, 4 # arg_2
0029 23 0E TERNARY_COND 14
002B 2E 00 04 GET_SCOPE_VAR 0, 4 # arg_2
002E 11 DUP
002F 47 CD GET_PROP_STR 205 # 常量: "indexOf"
0031 15 CE LOAD_STRING 206 # 常量: "multipart/form-data"
0033 24 01 CALL 1 # 参数数量: 1
0035 48 01 LOAD_NUMBER 1
0037 18 UNARY_MINUS
0038 1B NE
L1:
0039 1F 05 JMP_FALSE 5 # 跳转到: L2
003B 15 24 LOAD_STRING 36 # 常量: ""
003D 33 00 07 SET_SCOPE_VAR 0, 7 # local_var2
L2:
0040 28 B0 GET_GLOBAL 176 # 常量: "navigator"
0042 47 CF GET_PROP_STR 207 # 常量: "userAgent"
0044 33 00 08 SET_SCOPE_VAR 0, 8 # local_var3
0047 2E 00 08 GET_SCOPE_VAR 0, 8 # local_var3
004A 11 DUP
004B 47 CD GET_PROP_STR 205 # 常量: "indexOf"
004D 15 D0 LOAD_STRING 208 # 常量: "baiduboxapp"
004F 24 01 CALL 1 # 参数数量: 1
0051 48 00 LOAD_NUMBER 0
0053 40 GE
0054 1F 13 JMP_FALSE 19 # 跳转到: L3
0056 2E 00 08 GET_SCOPE_VAR 0, 8 # local_var3
0059 11 DUP
005A 47 D1 GET_PROP_STR 209 # 常量: "replace"
005C 28 13 GET_GLOBAL 19 # 常量: "RegExp"
005E 15 D2 LOAD_STRING 210 # 常量: "\\s(EasyBrowser)?[Ww]ebCore=0x[a-z0-9]{9}$"
0060 4A 01 NEW 1 # 参数数量: 1
0062 15 24 LOAD_STRING 36 # 常量: ""
0064 24 02 CALL 2 # 参数数量: 2
0066 33 00 08 SET_SCOPE_VAR 0, 8 # local_var3
L3:
0069 2E 00 08 GET_SCOPE_VAR 0, 8 # local_var3
006C 11 DUP
006D 47 CD GET_PROP_STR 205 # 常量: "indexOf"
006F 15 D3 LOAD_STRING 211 # 常量: "AlipayClient"
0071 24 01 CALL 1 # 参数数量: 1
0073 48 00 LOAD_NUMBER 0
0075 40 GE
0076 1F 13 JMP_FALSE 19 # 跳转到: L4
0078 2E 00 08 GET_SCOPE_VAR 0, 8 # local_var3
007B 11 DUP
007C 47 D1 GET_PROP_STR 209 # 常量: "replace"
007E 28 13 GET_GLOBAL 19 # 常量: "RegExp"
0080 15 D4 LOAD_STRING 212 # 常量: "\\sChannelId\\(\\d+\\)"
0082 4A 01 NEW 1 # 参数数量: 1
0084 15 24 LOAD_STRING 36 # 常量: ""
0086 24 02 CALL 2 # 参数数量: 2
0088 33 00 08 SET_SCOPE_VAR 0, 8 # local_var3
L4:
008B 39 LOAD_UNDEFINED2
008C 2E 02 0E GET_SCOPE_VAR 2, 14 # 上层作用域[2][14]
008F 48 01 LOAD_NUMBER 1
0091 48 00 LOAD_NUMBER 0
0093 48 08 LOAD_NUMBER 8
0095 2E 00 06 GET_SCOPE_VAR 0, 6 # local_var1
0098 2E 00 07 GET_SCOPE_VAR 0, 7 # local_var2
009B 2E 00 08 GET_SCOPE_VAR 0, 8 # local_var3
009E 2E 02 13 GET_SCOPE_VAR 2, 19 # 上层作用域[2][19]
00A1 47 89 GET_PROP_STR 137 # 常量: "pageId"
00A3 2E 02 13 GET_SCOPE_VAR 2, 19 # 上层作用域[2][19]
00A6 47 88 GET_PROP_STR 136 # 常量: "aid"
00A8 15 86 LOAD_STRING 134 # 常量: "1.0.1.20"
00AA 24 09 CALL 9 # 参数数量: 9
00AC 33 00 09 SET_SCOPE_VAR 0, 9 # local_var4
00AF 28 CA GET_GLOBAL 202 # 常量: "performance"
00B1 11 DUP
00B2 47 AD GET_PROP_STR 173 # 常量: "now"
00B4 24 00 CALL 0 # 参数数量: 0
00B6 2E 00 05 GET_SCOPE_VAR 0, 5 # local_var0
00B9 19 SUB
00BA 33 00 0A SET_SCOPE_VAR 0, 10 # local_var5
00BD 39 LOAD_UNDEFINED2
00BE 2E 02 12 GET_SCOPE_VAR 2, 18 # 上层作用域[2][18]
00C1 2E 00 0A GET_SCOPE_VAR 0, 10 # local_var5
00C4 24 01 CALL 1 # 参数数量: 1
00C6 4B POP
00C7 2E 00 09 GET_SCOPE_VAR 0, 9 # local_var4
00CA 0F RETURN
要将做反编译,粗暴的办法是直接将每个字节码生成AST的节点,用一个虚拟栈去压AST Node代替实际值,通过这构造AST再恢复源码,但这样没有做控制流和数据流分析,很难恢复出高级的易读的结构,最好的办法是将基于栈的指令集转换为基于寄存器的,之后就很容易做各种分析了,由它转AST再转源码会好很多。
字节码转PandaBC
待续...
