确定字符串解密函数进入vmp初始化逻辑分支，找到一些初始化函数进入函数，能够找到一些形如下方内容的代码，几乎毫无可阅读性任意抽取一段代码进行分析 v6 = (*(__int64 (__fastcall **)(__int64, __int64, char *, char *))(*(_QWORD *)v3 + 264LL))( v3, v5, byte_5F35E, &aF_2);v3 + 264LL 为一个指针，强制转换为(*(__int64 (__fastcall **)(__int64, __int64, char *, char *))，说明这是一个函数调用。查看参数，v3作为第一个参数被传入，回忆一下JNI函数的签名(*env)->NewStringUTF(env,"Hello from JNI !");很明显V3为JEnv*结构体，将v3强制转换为JNIEnv *类型，此时函数调用恢复正常v6 = (*v3)->GetMethodID(v3, v5, byte_5F35E, &aF_2);根据GetMethodID的定义，我们可以得知GetMethodID的作用是获取java方法的函数调用签名，其中第三、第四个参数传入的是方法名和方法签名，在so执行流程中，这些常量类型一般会存在rodata、data、bss段，因此我们需要查看一下byte_5F35E 和 &aF_2 的值jmethodID GetMethodID(JNIEnv *env, jclass clazz,const char *name, const char *sig)从data段查看 byte_5F35E 和 &aF_2 的值，很明显字符串做了加密，ida无法正确读取查找交叉引用，寻找到所有调用字符串的函数，查看函数签名int8x16_t *__fastcall sub_CDE8(int8x16_t *result, int8x16_t *a2, unsigned int a3, unsigned int a4)在arm64的函数规约下，函数跳转前，一般由X0-X7寄存器来声明前8个参数，并由X0保存返回值这个函数有4个参数，因此我们在函数返回地址前断点，对X0到X3寄存器进行监听跳转到X0寄存器所指向的data的地址，能够看出来字符串被解密，可以据此判定为字符串解密函数查看函数逻辑int8x16_t *__fastcall sub_CDE8(int8x16_t *result, int8x16_t *a2, unsigned int a3, unsigned int a4) { __int64 v4; // x9 char *v5; // x10 _BYTE *v6; // x11 __int64 v7; // x8 char v8; // t1 int8x16_t v9; // q1 int8x16_t *v10; // x10 int8x16_t *v11; // x11 int8x16_t v12; // q0 int8x16_t v13; // q1 int8x16_t v14; // q2 __int64 v15; // x12 int8x16_t v16; // q4 int8x16_t v17; // q5 int8x16_t v18; // q4 int8x16_t v19; // q3 if ( (int)a4 >= 1 ) { if ( a4 < 0x20 || (int8x16_t *)((char *)a2 + a4) > result && (int8x16_t *)((char *)result + a4) > a2 ) { v4 = 0LL; LABEL_6: v5 = (char *)a2 + v4; v6 = (char *)result + v4; v7 = a4 - v4; do { v8 = *v5++; --v7; *v6++ = v8 ^ a3; a3 += 3; } while ( v7 ); return result; } v4 = a4 & 0xFFFFFFE0; v9 = vdupq_n_s8(a3); v10 = a2 + 1; v11 = result + 1; v12.n128_u64[0] = 0x3030303030303030LL; v12.n128_u64[1] = 0x3030303030303030LL; a3 += 3 * (a4 & 0xFFFFFFE0); v13 = vaddq_s8(v9, (int8x16_t)xmmword_4AEE0); v14.n128_u64[0] = 0x6060606060606060LL; v14.n128_u64[1] = 0x6060606060606060LL; v15 = v4; do { v16 = v10[-1]; v17 = *v10; v10 += 2; v15 -= 32LL; v18 = veorq_s8(v16, v13); v19 = veorq_s8(v17, vaddq_s8(v13, v12)); v13 = vaddq_s8(v13, v14); v11[-1] = v18; *v11 = v19; v11 += 2; } while ( v15 ); if ( v4 != a4 ) goto LABEL_6; } return result; }将主要逻辑分块，分解步骤是不是看起来很晕，我们将它分解开来就好了，根据经验，和起始条件检查a4是否大于等于1。 并判断a4是否小于32或源与目标内存区域重叠，因此a4为源字符串长度if ( (int)a4 >= 1 ) if ( a4 < 0x20 || (a2 + a4) > result && （result + a4) > a2 )进入if为真的语句块v4 = 0LL; v5 = (char *)a2 + v4; v6 = (char *)result + v4;可以看出v5和v6为源和目标的起始字符指针向下继续跟，可以看出逻辑为逐字节执行，每字节异或a3并使a3 += 3，执行结束后返回resultdo { v8 = *v5++; --v7; *v6++ = v8 ^ a3; a3 += 3; } while ( v7 ); return result;因此a3为密钥，a2为密文NEON并行运算算法还原逻辑代码寻找跳出if，可以看到一堆难以理解的声明条件，并发现有很多v*_s8系列函数，这实际上是arm64系列处理器的 NEON 并行计算指令，类似x86的 SIMDv9 = vdupq_n_s8(a3);为什么我们要用到并行计算指令呢？在 NEON 中，借助这些指令，cpu可以用一条指令一次处理128位的数据，可显著提升批量数据处理速度。什么叫“可以用一条指令一次处理128位的数据”呢？举个例子：我们有一个长度为64的8bit字符数组，并同时含有一个长度为8的密钥，如果不使用并行计算，我们需要逐个将原文和密钥位进行运算，而使用NEON指令后，我们可以将8个8位字符一并加载并和密钥对齐进行运算，本来一次仅能处理1个字符，现在我们可以一次处理8个字符，这样我们就极大的缩短了cpu的运算时间，提高了运算效率，对于字符串和图像运算来说，并行计算功能尤为重要中间出现了一堆难以理解的运算，我们先跳过v4 = a4 & 0xFFFFFFE0; v9 = vdupq_n_s8(a3); v10 = a2 + 1; v11 = result + 1; v12.n128_u64[0] = 0x3030303030303030LL; v12.n128_u64[1] = 0x3030303030303030LL; a3 += 3 * (a4 & 0xFFFFFFE0); v13 = vaddq_s8(v9, (int8x16_t)xmmword_4AEE0); v14.n128_u64[0] = 0x6060606060606060LL; v14.n128_u64[1] = 0x6060606060606060LL;一般对字符串进行加解密时需要对整个字符串进行迭代，我们需要注意整个函数中的两个循环部分，因此我们可以跳过中间代码直接寻找关键的循环部分，根据我们的技巧，直接寻找循环部分do { v16 = v10[-1]; v17 = *v10; v10 += 2; v15 -= 32LL; v18 = veorq_s8(v16, v13); v19 = veorq_s8(v17, vaddq_s8(v13, v12)); v13 = vaddq_s8(v13, v14); v11[-1] = v18; *v11 = v19; v11 += 2; } while ( v15 );通过查看ida的代码，我们能找到有如下并行运算调用vdupq_n_s8(a3)：将标量 a3 填充到 128 位向量寄存器所有元素。vaddq_s8(v9, 常量向量)：将两个向量相加veorq_s8(v16, v13)：将两个向量异或并行运算规则如何理解上述向量之间的操作呢？首先我们可以从vdupq_n_s8 的名字来进行分解：vdupq_n_s8(a3)表示的是将标量 a3 填充到 128 位向量寄存器所有元素。v：表示这是一个向量（vector）操作。dup：表示复制操作，将标量值复制到整个向量。q：表示使用 128 位的寄存器（quadword）。n：表示操作数是一个标量（immediate scalar）。s8：表示数据类型为有符号8位整数。我们以python代码为例，可以将向量看作一个numpy数组或python列表：v1 = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], dtype=np.int8) v2 = np.array([16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1], dtype=np.int8)而neon对两个向量的操作则是python中对两个numpy数组的操作def vaddq_s8(v1, v2): # 使用 NumPy 对两个数组逐元素相加 return np.add(v1, v2).astype(np.int8)result = vaddq_s8(v1, v2) print(result) # v1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] # v2 = [16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] # 输出: [17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17]回到向量化部分开始初始化向量解读v4 = a4 & 0xFFFFFFE0; v9 = vdupq_n_s8(a3); v10 = raw + 16; v11 = dest + 16; v12.n128_u64[0] = 0x3030303030303030LL; v12.n128_u64[1] = 0x3030303030303030LL; a3 += 3 * (a4 & 0xFFFFFFE0); // 更新a3 v13 = vaddq_s8(v9, xmmword_4AFC0); // 初始化向量v13 v14.n128_u64[0] = 0x6060606060606060LL; v14.n128_u64[1] = 0x6060606060606060LL;a4 & 0xffffffe0 是二进制与运算，0xffffffe0 的二进制表示为 11111111111111111111111111100000 ，也就是说两个二进制数进行运算时，会移除掉被异或数的后五位，也就是2^5，即向下取整到32的倍数，用于表示因此前两行的意义为v4 = a4 & 0xFFFFFFE0; // 将密文长度向下对齐到32的倍数 v9 = vdupq_n_s8(a3); // 将密钥扩展到向量v9的每个元素其中有个难以理解的点，将v12的n128_u64数组赋值两个long long是什么意思？v12.n128_u64[0] = 0x3030303030303030LL; v12.n128_u64[1] = 0x3030303030303030LL;NEON 寄存器的规格为128位，而n128_u64[0]代表128位的前64位，n128_u64[1]代表128位的后64位，这两行代码实际上是v12向量的初始化过程但是看到这里，还是很难理解，因为在下方的循环中，veorq_s8(v17,vaddq_s8(v13, v12))将一个8*16的向量和这个64*2的向量一起运算了，我们可以进一步思考，v12会不会就是一个8位*16的向量呢？将0x3030303030303030转为int8数组后的表示为[0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60] 查看ida的汇编代码表示，发现这两行的c代码对应的汇编指令仅有一条MOVI V2.16B, #0x60MOVI：MOVI 代表 "Move Immediate"，即将一个立即数（常数值）移动到向量寄存器中。作用是将指定的立即数值复制（广播）到目标向量寄存器的所有元素中。V2.16B：指定寄存器 V2（Vector2）被视为包含 16 个 8 位字节（Byte）的向量。即，V2 被分割为 16 个独立的 8 位元素进行操作。也就是说，实际上v12的值为v12 = [0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60]我们可以发现，实际上这就是 vdupq_n_s8(0x60) ！为什么不直接表示为 vdupq_n_s8 呢？查看 vdupq_n_s8 的汇编，我们可以发现vdupq_n_s8的指令并不是MOVI，而是直接对寄存器操作的 DUP。DUP V1.16B, W2MOVI 指令涉及将一个立即数复制到向量寄存器的所有元素，由于高级语言中对常量的一些操作会被编译器在编译阶段优化，常量折叠以及常量传播都是编译器优化技术，会在编译时期将常量数值计算出来，使一些操作优化为向量操作。为了确保反编译的结果正确，IDA会在某些情况下选择将其分解为更低级的操作，并精确控制每个元素的初始化。因此，IDA 将 MOVI V2.16B, #0x60 转换为了对寄存器的两个 64 位部分分别赋值的c语句v12.n128_u64[0] = 0x3030303030303030LL; v12.n128_u64[1] = 0x3030303030303030LL; v14.n128_u64[0] = 0x6060606060606060LL; v14.n128_u64[1] = 0x6060606060606060LL;可以转换为v12 = vdupq_n_s8(0x30); v14 = vdupq_n_s8(0x60);向量化块循环运算继续查看循环逻辑do { v16 = *v10[-1]; // 加载16字节到向量v16 v17 = *v10; // 加载16字节到向量v17 v10 += 2; v15 -= 32LL; v18 = veorq_s8(v16, v13); // v16与v13按位异或 v19 = veorq_s8(v17, vaddq_s8(v13, v12)); // v17与(v13+v12)按位异或 v13 = vaddq_s8(v13, v14); // 更新v13 v11[-1] = v18; *v11 = v19; v11 += 2; } while ( v15 );这里出现了另一个令人难以理解的部分v16 = *v10[-1]; // 加载16字节到向量v16 v17 = *v10; // 加载16字节到向量v17 v10 += 2;我们从前文可以得知，v10 = a2 + 1 ，a2 为密文，因此v10则为循环向量，v16实则为密文起始地址，v17为密文+16个字节的位置，v16和v17在一轮循环中的意义为一轮读取密文32个字节，并将其转换为两个向量运算，每轮运算将指针的位置向后自增2个向量的宽度观察上下文，我们就可以看出这个循环的作用是主要对v11*进行了操作以32字节为单位，利用NEON寄存器批量异或和累加处理。 每块进行： 1) 取当前块的两个16字节（v16、v17）。 2) 利用NEON进行异或运算3) 更新累加常量与偏移。处理剩余部分由前文我们得知，a4为密文长度，v4为向下32位对齐后的结果，LABEL_6是第一个循环体的标签，而第一个循环体的作用是按字符处理小于32位的数据的因此如果对齐后的结果和对齐前不同的话，剩余的尾数则需要回到第一个循环，按8bit来处理if ( v4 != a4 ) goto LABEL_6;还有一点需要注意的是，a3在跳转回LABEL_6循环前，需要将密钥提前变换，因为在32位的处理方式下，需要按照每个字符，在每轮将密钥+3a3 += 3 * (a4 & 0xFFFFFFE0);因此算法的核心要点是：对较大长度时会用NEON指令 (v16, v17等) 批量处理，并且XOR关键字节实际上来自 “a2 + v4开始” 处，即以key为来源数据，再按密钥进行动态偏移、变形。对短于32字节或重叠时则退回单字节循环。据此我们可以将算法反混淆aligned_len = len & 0xFFFFFFE0; v_magic = vdupq_n_s8(magic); // 将magic拓展到128位向量的16个元素上 neon_loop_index = (int8x16_t *)(raw + 16); result_neon_ptr = (int8x16_t *)(result + 16); neon_48fill_vector.n128_u64[0] = '00000000';// salt向量，每个元素都是48 neon_48fill_vector.n128_u64[1] = '00000000'; magic += 3 * (len & 0xFFFFFFE0); magic_neon = vaddq_s8(v_magic, xmmword_4AEE0);// 将magic 的16个元素与 an = (n-1)3 相加 neon_96fill_vector.n128_u64[0] = '````````';// salt向量，每个元素96 neon_96fill_vector.n128_u64[1] = '````````'; neno_loop_index = aligned_len; do { raw_neon_chunk1 = neon_loop_index[-1]; // raw的neon向量的前16字节 raw_neon_chunk2 = *neon_loop_index; neon_loop_index += 2; neno_loop_index -= 32LL; chunk1 = veorq_s8(raw_neon_chunk1, magic_neon);// 将chunk1与magic异或 chunk2 = veorq_s8(raw_neon_chunk2, vaddq_s8(magic_neon, neon_48fill_vector));// 将chunk2与+48后的magic异或 magic_neon = vaddq_s8(magic_neon, neon_96fill_vector);// 将magic的每个元素+96 result_neon_ptr[-1] = chunk1; *result_neon_ptr = chunk2; result_neon_ptr += 2; } while ( neno_loop_index );python还原class Cipherdome: fill48_vector = [48] * 16 fill126_vector = [96] * 16 def vaddq_s8(self, v1: list[int], v2: list[int]) -> list[int]: """NEON加法""" return [(v1[i] + v2[i]) & 0xFF for i in range(16)] def vaorq_s8(self, v1: list[int], v2: list[int]) -> list[int]: """NEON异或""" return [(v1[i] ^ v2[i]) & 0xFF for i in range(16)] def decrypt_short(self, raw: bytearray, magic: int) -> bytearray: """解密简单场景：长度小于32""" result = bytearray() for ib in raw: result.append(ib ^ magic & 0xFF) magic = (magic + 3) & 0xFF return result def decrypt_extend(self, raw: bytearray, magic: int) -> bytearray: """解密复杂场景：长度大于32且拥有并行计算和轮密钥加密""" raw_length = len(raw) neon_length = raw_length & 0xFFFFFFE0 result = bytearray(neon_length) magic_neon = [(i * 3 + magic) & 0xFF for i in range(16)] # NEON块处理 for i in range(0, neon_length, 32): result[i : i + 16] = self.vaorq_s8(raw[i : i + 16], magic_neon) magic_neon_2 = self.vaddq_s8(magic_neon, self.fill48_vector) result[i + 16 : i + 32] = self.vaorq_s8(raw[i + 16 : i + 32], magic_neon_2) magic_neon = self.vaddq_s8(magic_neon, self.fill126_vector) if raw_length > neon_length: tun = self.decrypt_short(raw[neon_length::], magic + neon_length * 3) result.extend(tun) return result def decrypt(self, raw: bytearray, magic: int): """解密""" if 1 < len(raw) < 32: result = self.decrypt_short(raw, magic) return result, False else: result = self.decrypt_extend(raw, magic) return result, True有几点需要注意的是，在实现向量加法时，需要考虑到python的int是可变长度，而寄存器内int为8位，需要将每个向量元素的相加结果异或 0xFFdef vaddq_s8(self, v1: list[int], v2: list[int]) -> list[int]: """NEON加法""" return [(v1[i] + v2[i]) & 0xFF for i in range(16)]
jsvmp编译与反编译详解 (3)——某讯新版vmp反编译
腾讯这次还是挺狠的，直接把栈式虚拟机更新成寄存器式虚拟机了，并且将原先的60多条指令膨胀到80多条。栈式虚拟机的指令集通常较为简单，指令操作主要围绕栈进行，由于栈式虚拟机的广泛使用，现有的资料对栈式虚拟机的支持较为成熟。而相比于栈式虚拟机，寄存器式虚拟机的指令集复杂，我们需要处理更多的指令类型和寄存器操作，不过对于web虚拟机来说，其功能本质仍旧是对js内存空间的访问。两个月前才刚刚完成了旧vmp逆向，这下直接在风中凌乱……好吧，那只能我们继续重新分析了我们的目标实现反编译器，通过传入原始jsvmp程序，生成汇编最后生成js代码并保证运行逻辑一致//将 类似"JgQCFhoEBBZEHgASPkQEZp/4A4PzAg=="的jsvmp程序 还原成下方的js代码 locals[11] = "__lastWatirConfirm"虚拟机入口vmp部分共计800行，腾讯的vmp样例还是比较清晰的，整段代码没有太多无用的混淆，而仅仅用了一个朴实无华的vm我们将短的函数进行一下折叠，查看一下代码结构，可以看到整个vm由一个大的控制流和一些函数组成，向下寻找，找到缩进最多的地方，虚拟机的解释器通常通过循环和流程控制语句嵌套构成，因此会形成较多的缩进其中最内层的switch控制了整个流程的执行，整个虚拟机结构较为经典，为while-switch结构，没有多余的if或逻辑表达式混淆查看switch顶部，查看switch的条件由整个C数组控制，说明C就是传入的程序字节码，我们需要知道C数组的生成方式进入浏览器的devtools，打上断点，刷新执行一下，发现C数组是由传入的B参数经过Q函数生成的，Q函数实际上会经过别名赋值，跳转到E函数，因此E就是我们需要解决的字节码还原算法了。程序加载器解密变长编码控制流查看一下E函数的编码方式，每次循环中，都会读取一个字节 a，并将其与 127 进行按位与操作，得到 o事实上，大部分被混淆后的算法，都是来源于通用算法，总是有迹可循的。例如，base64编码算法通常要大量操作 0-9\a-z\A-Z\= 这些字符，urlencode会大量操作 %，变长字符串编码通常会通过分组拼接方式来完成字节生成，因此我们只要找到对应的特征，就能知道他使用了什么解密算法。例如，上图有一个比较明显的重复操作，即每轮都会将从B数组取出的8位int的低7位(通过和127 按位与运算来实现)，并将其和o拼接，如果 a 的最高位（第8位）为 0（即 a >= 0），则表示这是一个单字节的值，直接将 o 通过 C 函数处理后存入 g 数组，并继续下一次循环。这种操作很像是在处理变长整数编码（如 varint 编码），在这种变长整数编码方式中，每个字节的最高位用于指示是否有后续字节，并指示需要将多少个字节拼接为一个整数。不过只靠这些信息我们还是很难看出他在做什么，因为while循环体内的流程被扁平化了，实际上有4个if和continue对执行流程进行了控制，需要我们进行一下还原。看一下，这里在每个if的代码块中，存在一个continue，这意味着如果id的条件为真，就不再执行后面的代码，因此我们可以将后面的代码块看作是else分支因此我们第一步就可以将控制流代码还原成这样接下来看一下，在每一个else分支中，都通过B[e++]的形式拿到了数组的下一个8位int，并将其低 7 位左移 7 位后与 o 进行按位或操作，这一步的作用是增加o的位数长度。重复上述步骤，直到读取到一个最高位为 0 的字节。每一步的操作都和最开始的if分支操作相同，但是会将字节进行移位处理，并重复将a合并为到o中。因此可以将其简化为一个循环在上面的操作中，我们已经分析了，在变长编码中，每个字节的最高位用于指示是否有后续字节，最高位为0时，代表这个数结束了，所以说明E 函数很有可能实现了一个变长整数解码器，并将解码后的整数存储在数组 g 中ZigZag编码特征在将o插入g数组前，每个整数还通过 C函数进行了处理，看上去像是一种通用的算法，百度一下，果然，(A >> 1) ^ -(1 & A) 这种表现形式实际上是一种叫做 ZigZag 编码的解码算法，ZigZag 编码是一种将有符号整数编码为无符号整数的方法，使得绝对值较小的负数和正数都能使用较少的字节表示，从而提高编码效率。var C = function A(A) { return (A >> 1) ^ -(1 & A); };那么现在还剩下B数组的生成方式未知了，B数组由传入的Base64编码通过I函数生成，I函数实际上是将一个 Base64 编码的字符串转换为一个字节数组。它首先将输入字符串去掉尾部的等号，然后将每个字符转换为对应的 Base64 索引值，最后将这些索引值组合成字节数组。双虚拟机结构分析加载器折叠函数内容，可以看出vm的初始化流程我们在前文得知，程序码的加载过程是将传入vmp的Base64字符串先解码成无符号int8数组，这个int8数组实际上储存的是一段ZigZag编码通过E函数，将ZigZag编码的无符号整数数组，还原成正常的有符号指令数组按照上面的流程把程序加载器提取出来，去除混淆，并删除原始代码，我们就得到了程序加载器解释器查看虚拟机结构，发现里面居然有两个不同的vm执行流程，通过传入的参数I来控制具体执行哪个流程通过对参数寻找，我们很轻易地发现了传入的参数会使条件表达式I ? E : o 恒为假，因此，此虚拟机的执行流程中很有可能不执行E流程然而，不幸的是，我们发现o和E这两个虚拟机是相互调用的，因此，return的条件表达式只能代表o为入口虚拟机，这一下就亚历山大了，这代表我们需要同时对两个vm进行分析将两个虚拟机的解释器部分提取出来，进行文本比对，发现居然完全一致，说明两个虚拟机的指令完全是相同的观察两个虚拟机，发现二者只有"use strict"的区别"use strict" 是 JavaScript 中的严格模式，它会对代码的执行施加更严格的限制，在 ES6 中，严格模式也可以在块级作用域中生效，因此两个虚拟机的区别仅仅是是否启用严格模式{ "use strict"; // 这个块级作用域在严格模式下运行 function example() { // 这里也是严格模式 } } // 这里不是严格模式在严格模式下能够运行的代码，那么一定能在非严格模式中运行，因此我们将非严格模式的虚拟机代码删除，仅保留严格模式，利用抓包软件的重写功能替换响应，执行一次试试结果正常返回了内容，因此，我们仅需保留任意一项虚拟机代码虚拟机的实现必定会有栈、PC计数器、以及程序代码，这一步我们可以将疑似栈、PC计数器、以及指令码的变量先标识出来，以便分析变量还原后，可以看到case内有大量的opcodes[++PC]，这代表一条case指令在执行时还会获取多个连续的操作数，每获取一个会将PC计数器指向的程序地址+1，类似OP R1, R2, R3 查看代码，未发现明显的栈操作，且stack.pop()在执行时并未将出栈操作数保存下来这让我们联想到寄存器式的虚拟机。寄存器虚拟机使用寄存器来存储操作数和中间结果，而不是利用栈。在执行操作时，虚拟机指令通常同时包含指令码和多个寄存器操作数。例如 ADD R1, R2, R3 表示将寄存器 R2 和 R3 的值相加，并将结果存储在寄存器 R1 中。回想一下我们之前遇到的栈式虚拟机，在进行二元操作时，是什么样子的var _0x4db217 = _0xcc6308[_0x2e1055--]; //栈顶弹出操作数 _0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] + _0x4db217; //和栈顶相加并重新放回栈内而我们现在看到的样本代码的运行方式是在获取到指令码后，再连续读取和指令相关的寄存器进行操作，和我们遇到的栈式虚拟机特征完全不符合，其行为反而非常符合寄存器式虚拟机的执行方式，因此我们怀疑这个虚拟机为寄存器式虚拟机，h可能为寄存器保存区域。在所有二元表达式的地方输出记录，打印获取h的下标替换浏览器端代码后执行一下。输出结果表明，在二元运算中，的h的下标是固定的，二元运算从h[17]、h[22]取出值进行运算后，再存回h[26]的固定位置。这代表着h[17]、h[22]、h[26]是加法运算的寄存器，证实了我们的猜想。因此，我们可以得出结论，变量h储存了当前执行程序的上下文环境和操作数寄存器，h[index]实际上为获取寄存器值的操作，而opcode[++PC]为指令后的操作数callback栈查看catch分支，此处为异常处理流程当虚拟机执行过程中发生异常时，catch 块会捕获该异常。将异常对象 A 保存到 exception 变量中。如果 stack 不为空，表示当前有未完成的函数调用，需要返回上一个调用点继续执行。如果 stack 为空，表示没有更多的调用点可以返回，直接抛出异常。如果提供了异常处理函数 a，则调用该函数处理异常，否则直接抛出异常。注意一下这个入口的a，出现在了catch分支中，并传入了当前的异常、上下文和异常栈，说明这是个异常处理函数，但是并没有在虚拟机中被修改过，传入值也为void 0，可能用于腾讯官方的debugger或日志上报组件catch分支很重要，对程序当前PC计数器进行了状态保存，因此任何涉及这里的变量更改都要小心注意指令集假设当前PC寄存器的指向的命令为MOV，那么 MOV R1,R2，就可以混淆为MOV opcode[++PC]，opcode[++PC]case 3: h[opcode[++PC]] = opcode[++PC]; break;实际上他的意义是这样的：// case3为mov命令，将操作数的值赋值给寄存器 case OP.MOV: // 通过opcode[++PC]获取连续的两个操作数，获取的第二 const value,reg; value = opcode[PC+1]; reg = opcode[PC+2]; PC += 2; // 更新寄存器 h[reg] = value;相比于栈式虚拟机的指令集，这个虚拟机的指令集就精简的多了，一共仅有91条指令集，我们需要逐个定义出相应的指令其中大部分的指令集都非常简单，我们可以根据相应的操作，定义出我们的汇编指令然而，虽然我们知道了整个虚拟机的运行方式和指令集，但我们该如何还原指令集，从而让他更加简单呢？以下面这些二元运算为例，我们虽然知道这段代码的实际意义，但可读性依然较低，我们不知道这些指令对哪个寄存器进行了交互，也不知道汇编指令的参数长度，并且在执行时，还会对PC计数器进行自增，这大大影响了我们的阅读// 指令阅读起来较为困难 case 18: e = []; for (s = opcode[++PC]; s > 0; s--) e.push(h[opcode[++PC]]); h[opcode[++PC]] = o(PC + opcode[++PC], e, Y, strs, a); try { Object.defineProperty(h[opcode[PC - 1]], "length", { value: opcode[++PC], configurable: true, writable: false, enumerable: false, }); } catch (A) { } break;汇编指令通常没有动态的变长参数的概念，每条指令的操作数数量和类型在指令集中是明确规定的，我们可以因此制定一下转换规则h[opcode[++PC]] = h[opcode[++PC]] + h[opcode[++PC]]; // 可以转换为 h[args[2]] = h[args[0]] + h[args[1]]; PC += args.length并且重新处理一下指令集定义和操作码加载方式// 定义指令的执行方法 function ADD(arg0, arg1, arg2) { h[arg2] = h[arg0] + h[arg1]; } // ... // 定义指令集 const OP = { 41: ADD, 42: SUB, 43: MUL, }; // 定义一个指令加载器 function loadOP(opcode) { const args = []; for (let i = 0; i < OP[opcode].length; i++) { args.push(h[++PC]); } OP[opcode](...args); }这样子我们就将指令集、指令定义、解释流程抽离开来了// 解释器 while (true) { loadOP(opcode[++PC]) }指令抽取还原思路我们重新定义了解释器的执行方式，下一步需要将case 41的节点转换为如下的形式，以便于未来再次转换成汇编语言// 指令操作方式 function ADD(args) { h[args[0]] = h[args[1]] + h[args[2]]; } // 指令集定义 const OP = { 41: { code: 41, name: 'ADD', func: ADD, argnums: 3 } }比较困难的是，部分指令需要重新理解他的意义，例如指令10的作用，将一个值减去 0 可以强制将该值转换为数字类型，此处的意义实则为强制将任意类型转换为Number类型而并非是算术运算case 10: h[opcode[++PC]] = h[opcode[++PC]] - 0; break;此外opcode[++PC]在还原为args[0]的形式时，PC的偏移量也要和js的运行时执行逻辑完全一致，在下面这个例子里，条件表达式的条件未知，因此PC在程序执行完毕后的偏移量可能为2和3两种情况。case 2: PC += h[opcode[++PC]] ? opcode[++PC] : opcode[(++PC, ++PC)]; break;我们在进行ast还原时，还需要注意优先级js表达式求值特性这里有一个js的坑点，导致我第一次进行节点还原时完全还原错了，浪费了一天的时间排查问题：js在进行表达式求值的时候直接按照ast树顺序向下求值，也就是从左向右向求值，而不是像其他语言一样严格遵需从内向外的执行顺序h[opcode[++PC]][h[opcode[++PC]]] = h[opcode[++PC]];举个例子，在其他语言中，表达式的求值顺序是按照语句执行的优先级，从内向外求值。上面的这行语句，在python中会遵循如下的运行逻辑：赋值表达式右侧先于左侧执行，因此赋值表达式右侧的opcode[++PC]应当是args[0]成员表达式外层先于内层执行，因此赋值表达式左侧应当是 h[args[1]][h[args[2]]]从左往右的参数顺序是args[1]，args[2]，args[0]而Js中，表达式的求值顺序是从左向右因此，从左往右的参数顺序是args[0]，args[1]，args[2]function value(index) { console.log("value("+index+")执行") } a[value(0)][value(1)]+=a[value(2)]+value(3)上面这行代码的输出结果是：value(0)执行 value(1)执行 value(2)执行 value(3)执行而在python中的输出结果是：value(2)执行 value(3)执行 value(0)执行 value(1)执行可以看到在第一次进行还原时，我按照语句的执行顺序进行求值，导致还原出的参数顺序完全错了，事实上在赋值语句左侧的参数应该还原为arg0抽取思路不过，纯粹按照从左向右的顺序，反而更好还原了，因为我们只需要遍历ast树，而不需要关注具体的执行逻辑我们先将所有的case分支利用ast提取出来，并移除掉break语句，将其合成为函数列表//提取为函数 traverse(ast, { SwitchStatement(path) { path.node.cases.forEach(caseNode => { const caseCode = caseNode.test.value; const funcName = t.identifier("op_" + caseCode); for (const statement of caseNode.consequent) { if (t.isBreakStatement(statement)) { caseNode.consequent.pop(); } } const funcBody = t.blockStatement(caseNode.consequent); const func = t.functionDeclaration(funcName, [], funcBody); program.body.push(func); }); } });利用ast进一步转换，将其形参和实参对应上，很快便将大部分参数个数和顺序还原了还剩一些由于内部含有控制流，从而导致参数可能为变长参数，这种情况下我们暂时不要对参数直接进行处理op18和op19，实际上为同一内容，作用是导入变长参数并执行一段vmp函数，将返回结果储存在寄存器中函数调用，调用js函数，并传入一段上下文归并节点为了更好处理指令，我们在指令节点加上注释case 1: // jmp 命令，跳转到程序的指定位置 PC += opcode[++PC]; break;部分节点暂时不知道用处的，可以暂时标记为unknown命令case 28: // __unknown 疑似new操作 e = [,]; for (s = opcode[++PC]; s > 0; s--) e.push(locals[opcode[++PC]]); s = opcode[++PC]; S = locals[opcode[++PC]]; locals[s] = new (S.bind.apply(S, e))(); break;还原一下部分变量名，如 h 我们可以先设为 locals，以待后面分析通过 leadingComments 获取节点的注释，修改为我们设置的方法名，最后生成节点，归并入对象中储存部分指令其实仅有参数长度的区别，因此我们可以使用同一个指令名不明的指令我们使用unknown_指令码的方式来代替参数类型标注在汇编语言中，立即数（Immediate Value）是指在指令中直接包含的常数值。立即数是指令的一部分，通常用于指定操作数的值，而不是引用内存地址或寄存器中的值。// 检测参数类型并替换名称 if (innerPath.node.object.name == "opcode") { let idName = "imm" + immCount immCount += 1 if (t.isMemberExpression(innerPath.parent)) { const objName = innerPath.parentPath.node.object.name if (objName == "locals") { idName = "R" + regCount regCount += 1 immCount -= 1 } } const id = t.identifier(idName) innerPath.replaceWith(id); path.node.params.push(id); }还原一下参数类型，表示寄存器的类型使用R来替代，立即数用imm替代，方便我们后续生成汇编接近100条指令仅用8条未知，在后期运行时再进行分析又一个js“特性”抱着侥幸心理，将我们编写的简易版指令执行器替换腾讯vmp的executor，运行一下，果然报错这时候需要插桩查看一下问题了，发现js运行时报错出现在jmp指令之后。我们的jmp命令执行完毕后，PC计数器的值会比原版多+1,所以导致本应在下一条执行的字节码18变成了字节码1这不得不说是令人费解的，因为我们的jmp命令仅仅是将js代码包装进函数罢了// 包装前流程 PC += opcode[++PC]; // 包装后执行流程 arg0 = opcode[++PC] PC += arg0;实际上，这还是js的“特性”，执行以下代码，你可以发现，第一次输出的PC为3，第二次则为4var PC = 1 PC += ++PC // PC = 3var PC = 1 var value = ++PC PC += value // PC = 4我们将这段代码编译为v8字节码var PC = 0; PC +=1; 0x63e081d5b7a @ 0 : 0c LdaZero 0x63e081d5b7b @ 1 : 25 02 StaCurrentContextSlot [2] 0x63e081d5b7d @ 3 : 17 02 LdaImmutableCurrentContextSlot [2] 0x63e081d5b7f @ 5 : 45 01 00 AddSmi [1], [0] 0x63e081d5b87 @ 13 : c4 Star0 0x63e081d5b88 @ 14 : a9 Return LdaZero：将常量 0 （PC的值）加载到累加器（Accumulator）中。StaCurrentContextSlot [2]：将累加器中的值存储到当前上下文的槽位 2 中。LdaImmutableCurrentContextSlot [2]：将当前上下文槽位 2 中的不可变值加载到累加器中。AddSmi [1], [0]：将累加器中的值与小整数（Smi）1 相加，并将结果存储在累加器中。Return 返回累加器中的值看出什么端倪了吗？在第一条字节码中，就已经获取了PC的值，并将其存入累加器，这意味着累加时，赋值语句左侧的PC一直是最初的值PC += ++PC 实际上等同于 PC = (PC)+(++value)，变成了简单的覆盖操作，而不是在右侧表达式执行完毕后再进行累加因此对于 jump 和 jump_zero 命令这类会对PC计数器进行累加的命令，我们有两种操作思路，第一种即不将参数提取出来，这样指令操作就和原本的执行顺序一模一样了function jmp() { PC += opcode[++PC]; }第二种则是手动对PC结果进行处理环境污染运行到第94756行的时候，发生了一个问题，此处为RET指令，代表结束当前虚拟机并返回寄存器的值，然后恢复PC计数器的值，然而两份代码在该条命令结束后，PC计数器的值却并不相同腾讯原始代码在结束后，PC回到了18796我们的代码在结束后仅仅是将PC后移1位（此处两位是因为还有一位是读取的指令参数）在虚拟机执行函数时，程序计数器（PC计数器）是一个关键的组件，它用于跟踪当前正在执行的指令地址。当虚拟机准备执行一个函数时，它通常会将当前的PC计数器值保存到一个调用栈中，以便在函数执行完毕后能够返回到调用点。在进入vmp函数时，虚拟机会将PC计数器设置为被调用函数的入口地址。当函数执行完毕时，虚拟机会从调用栈中弹出先前保存的PC值，并将其恢复到PC计数器中，从而使得虚拟机能够返回到函数调用点并继续执行后续指令。因此，PC计数器几乎没有可能在RET命令执行后依旧仅作+1递增，这意味着ret没有跳出当前函数。回顾一下之前提取的指令集，似乎并没有恢复PC计数器状态的指令。观察代码，代码中的PC是由每一次vmp初始化时传入的，仅需要return出下层函数，就能恢复上层PC计数器的状态。因此PC计数器的保存是由JS的闭包实现的，我们实现的执行器，可能造成了上下文环境污染，从而使外层闭包的PC值出现问题问题定位到这里，我们的执行器在执行指令的时候，传入了this，this在严格模式下，为undefined，在非严格模式下为global或window，由此造成了环境的污染将其改为opFunc.apply(undefined,args)或opFunc(..args)，执行正常。接着运行，发现还是有ret不跳出的问题，再次观察，发现我们对于能否跳出当前vmp的设定出了些问题let ret = opFunc.apply(undefined, args); if (ret !== undefined) { return ret; }当bytecode为16时，说明当前为ret指令，然而ret也是可以返回undefined的，因此做如下修改if (bytecode === 16) { return ret; }重新运行，最终在执行了88万条汇编指令后退出了虚拟机反编译反汇编此时，我们通过编写代码拥有了程序加载器：用于加载虚拟机程序代码，并解密为虚拟机字节码指令集：对于虚拟机指令的定义，以及每个指令的参数元数据除此之外，我们还拥有vmp代码、常量、初始环境设定反混淆后的解释器现在我们可以建立一个反汇编器disassembler对程序代码进行反汇编以深入研究了分别建立program.b64， program.bin， program.asm， program.ir，四个文件，用于表示原始程序base64、解密后的二进制字节码、汇编asm、用于生成反编译目标代码的中间语言ir提取vmp程序代码解密为vmp字节码，一共有48000条字节码，可以看到，在程序部分固定的内存空间中实际上含有不少常量，这符合二进制程序的设计依据之前定义的指令集表instructions.js，编写一个静态反汇编器disassembler，分析出汇编代码最后转为汇编指令，输出指令地址、指令、参数值等，我们可以看到这个程序一共含有14000条汇编指令，并明确了地址、参数和指令，相比48000条字节码的分析难度小了不止一点此处展示的是一个低级的反汇编器，仅用于演示。实际上对于开发人员来说，我们还能为其添加更多的反汇编功能例如，locals中的寄存器实际上是代表不同角色的，我们这里并没有进行深入分析，仅用locals的下标进行标注；vmp初始化时加载了大量常量进入内存空间，可以通过静态分析将大量的寄存器内容还原为常量我们可以新增一部分伪汇编指令，部分低级汇编指令可以合并为我们新增的高级汇编指令以方便阅读缺少汇编编译器，我们对汇编代码的修改无法还原为程序字节码以进行动态调试分析到这一步，我们就可以通过对汇编的分析，还原出他的部分js代码了，到这里我们讲解一下目标代码生成的思想由于其中还含有大量的常量操作，我们可以进一步缩短汇编指令，例如，我们定义的STOSB共有5300条，占据了汇编代码的1/3，假使能将这些汇编指令合并为js代码，是否能够消除我们的分析量呢？查看一下相对应的汇编代码45148 SETEMP [11] 45150 STOSB [11,95] 45153 STOSB [11,95] 45156 STOSB [11,108] 45159 STOSB [11,97] 45162 STOSB [11,115] 45165 STOSB [11,116] 45168 STOSB [11,87] 45171 STOSB [11,97] 45174 STOSB [11,116] 45177 STOSB [11,105] 45180 STOSB [11,114] 45183 STOSB [11,67] 45186 STOSB [11,111] 45189 STOSB [11,110] 45192 STOSB [11,102] 45195 STOSB [11,105] 45198 STOSB [11,114] 45201 STOSB [11,109] 45204 SETMEMBERIMM [15,222,11]回忆一下我们对SETEMP和STOSB的定义，不难看出，SETEMP和STOSB同时出现时，实际上的意义为从 常量 声明一个字符串变量。SETEMP先在寄存器11开辟了一个空的内存空间，然后再创建了一个字符串，最后通过SETMEMBERIMM赋值给某个寄存器内obj的成员完成变量声明function stosb(R0, imm0) { locals[R0] += String.fromCharCode(imm0); }为什么我们能认定这个字符串是从常量声明而不是从寄存器中取得的呢？还记得我们之前将参数类型分为R类型的寄存器参数和imm类型的立即数参数吗，立即数类型的参数，是从程序代码中直接提取的，其自身必定为常量因此，上述的代码可以优化为const asciiValues = [ 95, 95, 108, 97, 115, 116, 87, 97, 116, 105, 114, 67, 111, 110, 102, 105, 114, 109 ]; const result = asciiValues.map(value => String.fromCharCode(value)).join(''); // 输出: "__lastWatirConfirm" locals[11] = resultNFA模式匹配利用NFA，我们可以像正则一样去匹配符合模式的汇编代码块，首先编写一个nfa定义状态间的转换方式定义拓展指令集，从999开始，用于缩短汇编代码进行反汇编，可以看到我们的汇编代码已经将指令合并好了由于传入的立即数参数均为常量，因此可以进一步优化为如下形式，这样我们就得到了一份js代码locals[49] = "Map"汇编控制流还原低级别的机器码或汇编代码转换回高级别的源代码目标语言的过程，就叫做“反编译”（Decompilation）。通过这种方式，我们就可以将汇编还原为原始代码，我们的这个例子中通过对立即数操作的分析，就完美找到了字符串声明的逻辑。其他的，如JMP、CALL、RET 等指令则可以揭示程序的控制流结构，通过分析函数调用和返回的模式，可以推断出函数的参数和返回值。能否掌握反编译jsvmp的技术，就看逆向人员对编译原理的理解了。 例如，在汇编代码中,我们可以看到以下几个指令序列:#Block_6961 6961 GETMEMBERIMM R44, R18, 0 6965 CALL R26, R44, R50 6969 EQ R23, R30, R26 6973 JNZ R23, 35380, 28425这里的 EQ 指令用于判断两个寄存器的值是否相等,随后的 JNZ 指令根据条件跳转到不同的代码块。这可以还原为以下 JavaScript 代码:if (R23 === R30) { // Block_35380 } else { // Block_28425 }汇编代码中有如下一段:#Block_201 201 MOVREG R23, R19 204 ASNUM R10, R23 207 INC R23, R23 210 MOVREG R19, R23 213 LT R38, R19, R12 217 JNZ R38, 37, 27169这里使用 LT 指令检查寄存器 R19 是否小于 R12,然后根据条件跳转到不同的代码块。这可以还原为以下 JavaScript while 循环:while (R19 < R12) { // Block_37 R19 = R23; R23 += 1; } // Block_27169汇编代码中也有类似的循环结构:#Block_1031 1031 GETMEMBERIMM R18, R2, 0 1035 GETMEMBERIMM R11, R18, 0 1039 MOVREG R13, R11 1042 GETMEMBERIMM R11, R4, 1 1046 GTIMM R21, R11, 0 1050 JNZ R21, 21297, 17955这里使用 GETMEMBERIMM 指令获取寄存器 R2 和 R4 中的值,然后通过 GTIMM 指令检查条件,最后根据条件跳转到不同的代码块。这可以还原为以下 JavaScript for 循环:for (let R13 = R11; R11 > 0; R11 = R18) { // Block_21297 } // Block_17955利用我们的nfa,我们可以尝试还原出原始的 JavaScript 代码结构,包括 if-else 语句、while 循环和 for 循环等。这些还原的过程需要仔细分析每个指令的语义和寄存器的用途,以确定正确的控制流结构
提示：右下角可以点击查看目录流程分析我们之前在 cloudflare逆向(3)js请求 中分析了他的请求流程，在js加密流程中，主要分为这四步：预处理：在第一个页面的script标签中进行初始化，主要围绕window._cf_chl_opt进行，并加载初始的反爬脚本第二步：向 /cdn-cgi/jsch/v1?ray=::rayId:: 发送初始GET请求，获取经动态混淆的加密脚本第三步：获取另一个post请求的加密内容，并加载进当前页面进行环境检测通过生成的md、r、sh等参数，获取获取5s盾的cookie，最后可携带cookie请求真实网页第一个预处理js，可从script标签中提取出来，(function(){...})();表示立即执行该匿名函数，仅做一些初始化工作，因此不需要过多解读(function() { window._cf_chl_opt = { cvId: '3', cZone: "paoluz.link", cType: 'non-interactive', cNounce: '31152', cRay: '8bdafe9fbcf0099b', cHash: '8530c6de9e8c3dc', cUPMDTk: "\/?__cf_chl_tk=coi3.ydg2.eGDziNfk7QyQegh9ZiKQ.0O4QZQIb5Ys8-1725421608-0.0.1.1-7572", cFPWv: 'g', cTTimeMs: '1000', cMTimeMs: '120000', cTplV: 5, cTplB: 'cf', cK: "", fa: "\/?__cf_chl_f_tk=coi3.ydg2.eGDziNfk7QyQegh9ZiKQ.0O4QZQIb5Ys8-1725421608-0.0.1.1-7572", ……… }());从第二步开始，我们可以将解密工作分为三层，第一层就是如下的代码了，约为5600行+。可以看出大量使用了花指令和字符串加密，如果直接使用扣代码或人肉跟栈分析，难度较大通过对原始文件的分析，我们可以确定cloudflare使用了多种混淆技术，包括但不限于：变量名混淆：使用无意义的变量名字符串混淆：对字符串进行混淆和解码，使得字符串内容不易被直接可读控制流：移除原有的if节点，使用复杂的switch和while循环结构实现跳转，代替顺序逻辑死代码插入：插入看似有用实则无用的代码片段，加大逆向工作量花指令：将运算符抽取为函数，让加减乘除也变得不可读内联函数：将函数定义嵌入到其他函数中，将多个函数合并成一个复杂的函数，使得函数的实际用途难以辨别。这些混淆技术的目的是增加代码的复杂性，使得逆向工程和理解代码变得更加困难，不过好在一层的代码量较少，初学者进行ast分析时可以快速查看到当前的结果。不ast可以帮我们解除大量的混淆，但是在去混淆后，我们仍然要进行逆向分析，在学习ast还原前，一定要掌握好js基础和逆向技巧，仅仅使用ast是不够的。花指令还原既然说ast无法一步到位，那我们为什么要多做这一步呢？直接扣代码和补环境不好吗？做ast分析第一步，我们要先观察一下整个js整体的加密情况，然后按照难易程度对相对应的加密方式进行排列，这样才能先从简易的地方下手，还原出我们需要的内容随便翻两下，从60行开始，发现这里将一些双目运算符提取出来了，这是一种最基础的花指令方式，与2023年的5s盾不同的是，以前是将所有的花指令集中存放，而2024是将这样的代码分散于各处，例如这里仅有3个指令，而其他的花指令则是被隐藏到了不同的对象中 'CsuEb': function (g, h) { return g + h }, 'HJrLT': function (g, h) { return g * h }, 'nlhGX': function (g, h) { return g << h }想象一下，1+2*3变成了obj.HJrLT(obj.CsuEb(0,1),obj.HJrLT(3,2))时，阅读起来该有多崩溃我们可以用ast先检查一下都有哪里调用了这些，如何检查呢？第一步，我们首先要提取出节点的特征，可以看出，5s的花指令特征还较为简单，普遍为函数套一个return，我们可以检测函数节点下的ReturnStatement是否包含BinaryExpression等节点function isObfuscatedFunction(node) { if (node.type === 'FunctionExpression' && node.body.body.length === 1) { const statement = node.body.body[0]; if (statement.type === 'ReturnStatement') { const argument = statement.argument; return ['BinaryExpression', 'LogicalExpression', 'UnaryExpression'].includes(argument.type); } } return false; }之后我们需要遍历所有节点，并输出符合特征的节点，这里有个小技巧，在 Babel 的 AST 中，ObjectProperty节点表示对象字面量中的属性，用于描述对象中的键值对。例如，在下面这个例子里，CsuEb 就是 ObjectProperty节点。通过遍历ObjectProperty，能大量减少节点数量，加快反混淆程序的运行速度。const obj = { 'CsuEb': function (g, h) { return g + h } };言归正传，利用我们之前编写的isObfuscatedFunction检查一下节点const obfuscatedFunctions = []; traverse(ast, { ObjectProperty(path) { const node = path.node; if (node.value.type === 'FunctionExpression') { if (isObfuscatedFunction(node.value)) { const code = generate(node).code; obfuscatedFunctions.push(code); } } } });输出一下看看结果：仅仅是这样的简单节点总计就有283个，而这样的节点每个占3行，也就是说，在还未进行还原，而仅仅删除花指令这一项混淆操作的情况下，就能够减少800多行使原代码量至少缩减1/5。如果我们将ObjectProperty替换为"FunctionDeclaration|FunctionExpression|ArrowFunctionExpression"，那么数量预计还能接着增加，可见ast对于我们的逆向有多大的帮助当找到了对应的节点后，我们只需要将return中的节点替换为传入的实参，然后替换掉对应的节点即可，有些情况下，花指令可能会嵌套多层，需要多次进行替换，需要注意的是，在2024版的5s盾中，还需要额外对超长标识符字符串先进行回填，才能够处理掉花指令标识符还原（2024）2024年开始，5s盾增加了一种将标识符、属性收集在一个字符串中的混淆方案，因此不能直接用旧有的反混淆插件。不过其类似之前的字符串加密大数组还原，和旧版不同的是，这种方式是作为一种变体出现的，使用字符串和spllit进行分割后还原至js代码中，长度大多在3000字符以上，如果不对这个长字符串进行还原的话，是无法还原花指令的return jl = 'WVTnQ5,IsxLS,favicon_alt,revokeObjectURL .....,set:,querySelector'.split(',')提取一下特征：左侧为字符串常量，右侧为split，且split里也是常量，这种情况下，表达式的执行结果肯定也是常量，因此我们可以利用这个特性进行还原// 遍历AST并替换特定节点 traverse(ast, { CallExpression({ node }) { if ( types.isStringLiteral(node.callee.object) && node.callee.property.name === 'split' && types.isStringLiteral(node.arguments[0]) ) { // 替换节点为split的结果 const splitResult = node.callee.object.value.split(node.arguments[0].value); const elements = splitResult.map(value => types.stringLiteral(value)); path.replaceWith(types.arrayExpression(elements)); // 生成注释，以便于我们定位观察 path.addComment('leading', 'split', true); } } });还原效果如下自执行函数解密将大数组回填前需要先看一下全文是否有多种这样的数组，并且要查看一下还原前结构经过查看整个全文就只有一个jl变量被用作字符串大数组，并且这个a函数实际上为花指令变形，返回值就是jl自身，那么我们可以将所有a的调用处替换为jl数组查看一下这个关连大数组的函数，发现其是一个自执行函数，且把我们的大数组生成函数作为参数传入，然后自执行，同来打乱jl数组的顺序。自执行函数有个特点，传入的参数都是固定的，我们可以简单把形参和实参相对比然后传入不过，由于js允许形参和实参不必一一对应，我们需要检测传入的实参是否匹配检查是否是带参自执行函数，而不是函数或方法调用：对于每个 CallExpression 节点，检查其 callee 是否是一个函数表达式（FunctionExpression），并且是否有传入参数。建立形参与实参的映射：遍历函数表达式的形参，将其与对应的实参建立映射关系，存储在 params_map 中。替换形参为实参：如果当前节点不是函数声明的形参，并且对应的实参节点不是自执行函数，则将当前节点替换为对应的实参节点。移除函数参数： 将原来的调用表达式替换为新的函数调用表达式，实参列表为空。// 遍历 AST 并查找自执行函数调用 traverse(ast, { CallExpression(path) { const callee = path.node.callee; const args = path.node.arguments; const params_map = {}; // 检查是否是自执行函数调用，并检测参数是否传入 if ( types.isFunctionExpression(callee) && args.length > 0 && args.length <= callee.params.length ) { callee.params.forEach((param, index) => { if (param.type === "Identifier" && args[index]) { params_map[param.name] = args[index]; } }); const functionBodyPath = path.get("callee.body"); // 遍历函数体，替换形参为实参 functionBodyPath.traverse({ Identifier(innerPath) { // 判断当前节点是否是函数声明的形参 const isFunctionParam = innerPath.parentPath.isFunction() && innerPath.parent.params.includes(innerPath.node); const argNode = params_map[innerPath.node.name]; // 判断当前节点是否是自执行函数 const isSelfCalledFunction = types.isCallExpression(argNode); // 如果有实参传入，并且当前节点不是函数声明的形参，则替换形参 if (argNode && !isFunctionParam && !isSelfCalledFunction) { const argIsVarOrLiteral = argNode.name || argNode.value; console.log( innerPath.node.loc.start.line, ":", innerPath.node.name, "->", argIsVarOrLiteral ); innerPath.replaceWith(argNode); } }, }); // 移除函数参数 callee.params = []; // 替换调用表达式为新的函数表达式 path.replaceWith(types.callExpression(callee, [])); } }, });执行一下，发现替换了不少形参，通过将自执行函数的形参替换为实参，可以自执行函数，不仅仅是为了使代码更易读、更易理解，如果自执行函数的参数均转化为字面量后，可以直接将当作表达式执行，从而减少大量的代码。可以看到，还原以后，5个形参和2个实参都被消除了，这时我们可以就直接将a()的值简单替换为jl数组了常量折叠jsfuck源于一门编程语言brainfuck，其主要的思想就是只使用8种特定的符号来编写代码，它使用了()+[]!这几个字符组合来编写代码，例如(!![]+[])[+[]]实际上的值为字母t!![] // true 变形为 (true+[])[+[]] !![]+[] // "true" 变形为 ("true")[+[]] +[] // 0 最终变形为 ("true")[0]而cloudflare也使用了这些混淆手段，例如，根据js的特性!![]对应的值在这里实际为true另一种较为简单的混淆视听的表达式，即通过将普通的常量值变为常量运算来增加阅读难度，例如false变为!1，或将一个简单的数字变为一长串加减乘除运算这类表达式也有类似的特征，我们可以利用常量折叠的编译原理来消除这些难以阅读的部分，将其替换为常量，具体操作是对常量表达式调用path.evaluate()直接计算出表达式的值，然后进行替换function isConstantFoldable(path) { const { confident, value } = path.evaluate(); if (!confident) return; if (typeof value == 'number' && (!Number.isFinite(value))) { return; } return true; } const constantFoldableNodes = []; traverse(ast, { "BinaryExpression|UnaryExpression"(path) { if (isConstantFoldable(path)) { constantFoldableNodes.push(generate(path.node).code); } } });简单寻找一下，发现了大量的jsfuck还有一种节点是void开头的表达式，对于void节点，也可以直接处理成为0字符串字面量还原对于16进制或unicode的字符串，我们也要去做一下处理，将其还原为可读类型，这一步可以放在所有的反混淆操作都进行完成之后进行。不过，在国内经常见到的16进制混淆，到了cloudflare变成了不值一提的操作，变得非常罕见。。。。traverse(ast, { NumericLiteral({ node }) { if (node.extra && /^0[obx]/i.test(node.extra.raw)) { simplifyLiteralNodes.push(node) } }, StringLiteral({ node }) { if (node.extra && /\\[ux]/gi.test(node.extra.raw)) { simplifyLiteralNodes.push(node) } }, }）条件表达式还原条件表达式也就是我们熟知的三目运算，有很多a ? b : c;类型的表达式，这些实际上是被混淆后出现的，我们需要将其还原为if代码块，以便于理解，例如：n ? a ? b : c : d;实际上为if (n) { if (a) { b; } else { c; } } else { d; }需要注意的是，我们还需要额外对条件表达式的父节点进行检测，因为父节点如果不是表达式语句，可能是以下几种特殊情况：声明语句：例如变量声明 、函数声明、类声明等。控制流语句：例如if语句、for 循环、while 循环、switch 语句等。当出现这种情况时，说明原代码调用了条件表达式的值，如果直接进行节点替换，那么很可能就丢掉了节点，因此需要提前判断。在还原时，如果consequent或alternate不是表达式语句，则应当将其包装在一个BlockStatement（{}代码块）中。这样可以确保在转换为if语句时，consequent和alternate都是有效的代码块。traverse(ast, { ConditionalExpression(path) { let { test, consequent, alternate } = path.node; const ParentPath = path.parentPath; if (types.isExpressionStatement(ParentPath)) { if (!types.isExpressionStatement(consequent)) { consequent = types.BlockStatement([ types.ExpressionStatement(consequent), ]); } if (!types.isExpressionStatement(alternate)) { alternate = types.BlockStatement([ types.ExpressionStatement(alternate), ]); } ParentPath.replaceInline(types.IfStatement(test, consequent, alternate)); } }, });成员归并继续寻找，可以找到大量这类的节点gq[i9(1699)] = i9(1061), gq[i9(1192)] = i9(703), gq[i9(1249)] = i9(691), gq[i9(1499)] = i9(1341), gq[i9(322)] = i9(663), gq[i9(1722)] = i9(698), gq[i9(662)] = i9(690), gq[i9(1640)] = i9(1015), gq[i9(1175)] = i9(404), gq[i9(430)] = i9(909), gq[i9(1050)] = i9(665),其基本操作是将成员提取出来，使ide的语法工具无法直接找到成员定义，这样即使借助ide也难以进行定向。这一类的就比较难以理解了，看起来比较复杂，然而实际上比较简单，这里我们需要先分析一下，写一个最小例子：let obj = { a: 1 }; obj.b = 2; obj.c = 3;我们需要还原成为这样：let obj = { a: 1, b: 2, c: 3 };这样看思路就比较明确了，我们只需要两步操作：识别对象字面量的赋值：代码首先检查赋值表达式的左侧是否是标识符，操作符是否为 "="，右侧是否是对象表达式。如果满足条件，则提取对象的名称和属性列表。合并后续的属性赋值：获取当前赋值表达式的所有后续兄弟节点。遍历这些兄弟节点，检查它们是否是赋值表达式，且左侧是成员表达式，右侧是字符串字面量。如果满足条件，则将这些属性添加到对象字面量的属性列表中，并移除这些兄弟节点。const collectKeyAndValueOfObject = { AssignmentExpression({ node, parentPath }) { // 检查父节点是否是表达式语句 if (!parentPath.isExpressionStatement({ expression: node })) return; const { left, operator, right } = node; // 检查赋值表达式的左侧是否是标识符，操作符是否为 "="，右侧是否是对象表达式 if (!types.isIdentifier(left) || operator !== "=" || !types.isObjectExpression(right)) return; // 获取所有后续兄弟节点 parentPath.getAllNextSiblings().some(nextSibling => { if (!nextSibling.isExpressionStatement()) return true; const expression = nextSibling.get('expression'); if (!expression.isAssignmentExpression({ operator: "=" })) return true; const { left, right } = expression.node; if (!types.isMemberExpression(left)) return true; const { object, property } = left; if (!types.isIdentifier(object, { left.name }) || !types.isStringLiteral(property)) return true; // 将属性添加到对象字面量中 right.properties.push(types.ObjectProperty(property, right)); nextSibling.remove(); }); }, };控制流还原控制流混淆是一种通过改变代码的执行路径来增加代码复杂性和难以理解性的技术，例如，你可以从cloudflare的代码中找到大量的控制流，控制流过程一般也有其固定的格式，例如本段代码的控制流就是for-switch控制流 for (M = hh(1139)[hh(229)]('|'), N = 0; !![];) { switch (M[N++]) { case '0': return !![]; case '1': O = e[hh(173)](hh(704)); continue; case '2': O.id = hh(969); continue; case '3': f[hh(1017)][hh(832)][hh(998)](K[hh(440)])[hh(460)](O); continue; case '4': O[hh(241)][hh(697)] = hh(268); continue } break }我们需要将其原本的let a = someFunction(); for (; true;) { switch (a++) { case 0: code_0(); continue; case 1: code_1(); continue; case 2: code_2(); break; } break; }还原为code_0(); code_1(); code_2();思路是大致三步检查 for 语句的测试条件是否为 true 字面量，检查 for 语句主体的第一个语句是否是 switch 语句，第二个语句是否是 break 语句。处理 switch 语句：获取 switch 语句的所有 case 语句。根据for (M = hh(1139)[hh(229)]('|')表达式中的调度数组（通过 | 分隔的字符串）来确定执行顺序。遍历调度数组，根据索引获取相应的 case 语句并合并到缓存节点中。如果 case 语句的最后一个语句是 continue 语句，则将其移除。替换 for 语句：用合并后的语句替换 for 语句。移除当前的赋值表达式语句。AssignmentExpression(path) { //控制流判断 const { node, parentPath } = path; if (!parentPath.isExpressionStatement({ expression: node })) return; const { left, operator, right } = node; if (!types.isIdentifier(left) || operator !== "=" || !types.isCallExpression(right)) return; const nextSibling = parentPath.getNextSibling(); if (!nextSibling.isForStatement()) return; const { test, body } = nextSibling.node; if (!types.isLiteral(test, { value: true }) || body.body.length !== 2) return; const [switchNode, breakNode] = body.body; if (!types.isSwitchStatement(switchNode) || !types.isBreakStatement(breakNode)) return; // 开始还原 const { cases } = switchNode; const disPatchArray = right.callee.object.value.split("|"); let retBody = []; disPatchArray.forEach(index => { const caseBody = cases[index].consequent; if (types.isContinueStatement(caseBody[caseBody.length - 1])) { caseBody.pop(); } retBody = retBody.concat(caseBody); }); //替换节点 nextSibling.replaceWithMultiple(retBody); parentPath.remove(); }死分支代码删除在代码混淆过程中，死代码（Dead Code）指的是那些在程序执行过程中永远不会被执行的代码。这些代码通常是通过某些逻辑条件（如恒为 true或 false 的条件语句）被故意插入到代码中的，以增加代码的复杂性和混淆程度，从而使得逆向工程和代码分析变得更加困难。if (false) { console.log("永远不会执行"); } else { console.log("永远执行"); }if (false) 语句块中的代码永远不会被执行，因为条件 false永远不会为真。因此，console.log("This will never run"); 就是死代码。当然，实际还原时，例子肯定不会这么简单，例如，if语句嵌套逗号表达式，再嵌套条件表达式，就问你看的懂吗：第一步：直接移除空语句节点最简单的一集，实际上这步是最后执行的EmptyStatement(path) { path.remove(); }第二步：评估测试条件这里要注意一点，就是需要把所有的常量、字符串、花指令之类的回填了，再进行这一步，不然很多if条件没办法计算出真假另外要记得ConditionalExpression也是可以出现死代码的"IfStatement|ConditionalExpression"(path) { let { consequent, alternate } = path.node; let testPath = path.get('test'); const testEvaluation = testPath.evaluateTruthy(); }第三步：对if语句块或者条件表达式块进行取舍这里的代码需要插入到第二步的代码块中if (testEvaluation === true) { if (types.isBlockStatement(consequent)) { consequent = consequent.body; } path.replaceWithMultiple(consequent); } else if (testEvaluation === false) { if (alternate != null) { if (types.isBlockStatement(alternate)) { alternate = alternate.body; } path.replaceWithMultiple(alternate); } else { path.remove(); } }这样处理后，我们的代码就会只保留有用的语句了死返回语句删除有多个return语句的函数，也可以进行处理，不过这里需要谨慎处理，因为有可能第一个return语句并没有执行，并且这种状况多见，以下给一个简单的例子，不考虑这种情况 FunctionExpression(path) { const { node } = path; let returnCount = 0; // 遍历函数体，统计return语句的数量 path.traverse({ ReturnStatement(innerPath) { returnCount++; // 如果有多个return语句，应当跳转回第一个有效的return语句，并移除后续内容 // ... } }); }作用域检测部分函数也可能是死代码，例如，未return，同时也未引用外部作用域的函数，一定是死代码，我们可以通过检查函数体是否引用了外部作用域的变量，移除未引用外部作用域的函数，不过需要注意的是，还需要检测函数是否调用了eval等动态的内容，以及是否对传入的容器类实参进行了修改 FunctionDeclaration(path) { const { node, scope } = path; let dependsOnExternalVariables = false; // 遍历函数体，检查是否依赖外部变量 path.traverse({ Identifier(innerPath) { if (!innerPath.scope.hasBinding(innerPath.node.name) && scope.hasBinding(innerPath.node.name)) { dependsOnExternalVariables = true; innerPath.stop(); } } }); // 如果不依赖外部变量，移除整个函数，注意一下，实际的代码需要变通，还需要检测return if (!dependsOnExternalVariables) { path.remove(); } },结果展示我们的ast反混淆插件一共为520行，我们对5s盾的代码进行多次执行反混淆，直到代码行数不缩减为止，即为反混淆完成对比一下，仅仅使用了以上几种简单的方案，从5600行到2100行，解混淆后的行数直接缩减到解混淆前的37%，此外代码的可读性也大大增强了，所以说ast这个大杀器，一定要牢牢掌握
目标站点：aHR0cHM6Ly93d3cubXBzLmdvdi5jbi9pbmRleC5odG1s混淆样本可以通过扣代码或补环境的方式执行，本文不采用上述方案，只分享ast反混淆思路，供安全行业分析学习还原效果本次混淆样例为经典的ob变种混淆，在请求网站时，会随机返回三种加密算法，通过babel的ast分析功能，我们可以动态的获取到反混淆后的js，原本样本1500行代码在反混淆后还原为76行，代码量减少了95%，还原了纯算算法初步分析打开某目标网站，发现加密参数寻找一下具体的加密参数位置，将混淆代码dump下来，放入ide进行分析，可以看到，代码大致由5部分组成大数组：用于字符串加密混淆自执行函数：用于将加密数组进行解密hash函数：用途非常明显，用于获取hash值其他函数：用于具体加密逻辑主函数入口go：用于执行传入的内容，并和浏览器api进行交互这种字符串大数组+混淆的思路，基本上可以确定是ob混淆变种，我们反混淆的思路也很明确：获取解密后的字符串，回填常量，解除花指令混淆，删除死代码，最后还原整个流程字符串解密分析代码结构从他的主函数go进入，可以发现首先声明了一个 _0x41e2c2 对象，随后对其成员进行了大量操作在使用方括号声明成员时，大量调用了_0x1b8d这个函数，说明 _0x1b8d 可能为字符串解密操作，我们对这个函数进行一下测试，发现果然是字符串解密函数console.log(_0x1b8d('0x31', 'H&r$')) //输出：cTYc利用ide查看大数组引用，可以看到大数组仅在解密函数_0x1b8d中和另一个自执行函数中有所引用查看一下自执行函数，并没有任何外部引用，这段代码是一个典型的数组打乱片段，它的作用是对数组 _0x19984c 进行循环移位操作。具体来说，它会将数组中的元素向前移动若干位置，类似于一个环形缓冲区(function (_0x19984c, _0x51da47) { // 函数体 }(_0x51da, 0x6a));这是一个自执行函数，立即调用并传入两个参数 _0x51da 和 0x6a（十六进制的106）var _0x1b8ded = function (_0x3f4bdb) { while (--_0x3f4bdb) { _0x19984c['push'](_0x19984c['shift']()); } };这个函数接受一个参数 _0x3f4bdb，并在一个 while 循环中不断执行以下操作，直到 _0x3f4bdb 递减到0：_0x19984c['shift']()：移除数组 _0x19984c 的第一个元素并返回该元素。_0x19984c['push']：将移除的元素添加到数组 _0x19984c 的末尾。_0x1b8ded(++_0x51da47);调用 _0x1b8ded 函数，并传入 _0x51da47 加1后的值（即107）。因此我们可以得知，这段代码的作用是将数组 _0x19984c 中的元素循环移位107次。每次移位操作将数组的第一个元素移到末尾，最终实现数组元素的重新排列。判断解密操作是否幂等在浏览器环境中将整个自执行函数复制，并反复执行字符串解密函数，发现输出结果一致，说明该字符串解密函数的执行操作幂等在本地执行，发现生成结果和浏览器一致，那么说明这段代码并未进行环境检测，我们后续可以进行本地调试还原流程回过来观察一下我们需要解密的代码的特点，可以得到以下还原思路将本地的大数组打乱函数扣出执行，得到位置更改后的大数组利用ast工具找到代码中_0x1b8d的引用位置将解密函数直接扣出，对代码中的字符串加密部分进行还原拼接字符串常量我们先利用ide的debugger工具，将大数组还原，并删除数组打乱自执行函数，将数组覆盖回代码查看一下解密函数的定义var _0x1b8d = function (_0x19984c, _0x51da47) {/.../}_0x1b8d 是一个变量定义的节点，且定义的标识符名称固定，我们利用这点找一下符合这个变量特征的引用// 遍历 AST traverse(ast, { VariableDeclarator(path) { const { id } = path.node; // 节点为变量定义且标识符名称为_0x1b8d if (id.name =='_0x1b8d') { // 获取作用域下的引用 const binding = path.scope.getBinding(id.name); if (binding && binding.constantViolations.length == 0) { // 获取引用节点 for (const { parentPath } of binding.referencePaths) { console.log(parentPath.toString()); } } } } }, );查看引用数量和ide中的数量是否相同，这里发现是相同的266个（ide中多出来的一个是变量定义）执行反混淆模块输出一下，发现还包含了很多成员引用将所有符合条件的成员引用的父函数打印，查看他们的引用位置if (binding && binding.constantViolations.length == 0) { for (const refPath of binding.referencePaths) { const { parentPath } = refPath; if (parentPath.isMemberExpression()) { const functionPath = refPath.findParent(p => p.isFunction()); console.log(functionPath.toString()); } } }发现这9个成员表达式全都是在字符串解密函数 _0x1b8d 中的闭包函数中所引用，并未涉及外部作用域，因此在还原时可以直接忽略这些节点将解密函数和大数组扣出，将扣出的解密函数和大数组放入ast反混淆模块中，以备使用。注意，原代码中的数组和解密函数不要手动删除，否则就无法获取变量的binding了，我们需要利用babel在反混淆过程中进行删除。调用函数并替换节点 for (const refPath of binding.referencePaths) { const { parentPath } = refPath; if (parentPath.isCallExpression()) { const args = parentPath.node.arguments; const result = _0x1b8d(args[0].value, args[1].value); parentPath.replaceWith(t.stringLiteral(result)); } }删除大数组和解密函数traverse(ast, { VariableDeclarator(path) { const { id } = path.node; if (id.name == '_0x1b8d' || id.name == '_0x51da') { path.parentPath.remove(); } } },);这样一来我们的代码就只剩下hash和主函数了字面量还原unicode、hex混淆还原对于字符串和数字来说，还有多种混淆方式，如unicode和16进制混淆，这种字面量虽然不会影响代码执行，但是不具有可读性遍历一下这些ast节点，可以看出他们[value]()值是已经是人类可读的值了，但和其他节点不同的是都含有一个人类不可读的extra.raw属性，因此我们可以修改extra属性来保证生成目标代码时为人类可读ast = parser.parse(generator(ast).code); traverse(ast, { StringLiteral(path) { const node = path.node; if (node.extra && /\\[ux]/gi.test(node.extra.raw)) { try { node_value = decodeURIComponent(escape(node.value)); } catch (error) { node_value = node.value; }; path.replaceWith(t.stringLiteral(node_value)); path.node.extra = { 'raw': JSON.stringify(node_value), 'rawValue': node_value }; } }, NumericLiteral(path) { if (path.node.extra ) { path.node.extra.raw = path.node.value.toString(); } } }, );字符串合并、字面量还原观察我们之前还原的字符串，很多都是需要拼接的，我们还需要让他合并一下BinaryExpression: { exit(path) { if (path.node.operator === '+') { const left = path.node.left; const right = path.node.right; if (t.isStringLiteral(left) && t.isStringLiteral(right)) { const mergedString = left.value + right.value; path.replaceWith(t.stringLiteral(mergedString)); } } } }这样的话字符串节点就合并完成了当然还有一种更简便的方式，可以直接利用babel的静态分析能力，计算出节点的值。对于可以通过静态分析求值的表达式，其执行结果也是幂等的，可以直接利用 path.evaluate 进行还原，不需要扣出代码并且这种方式适用于所有类型的表达式// 静态计算 traverse(ast, { "BinaryExpression|UnaryExpression"(path) { const result = path.evaluate(); if (result.confident) { path.replaceWith(t.valueToNode(result.value)) path.skip() } } }, );花指令还原成员归并有很多类似这样的代码，通过将操作符提取为对象方法，从而加大我们的还原难度我们的目标是获取这些节点的引用，如果要获取引用，我们就需要获取对象的scope和binding，然后使用对象的属性节点去判断然而，对于这种节点，其类型为AssignmentExpression，babel无法通过赋值表达式获取binding。花指令函数的节点是FunctionExpression，虽然babel支持从函数表达式获取binding，由于这是个匿名函数，也无法获取binding，因此我们需要先将这些函数移动至对象声明的内部，这样在后续处理时，我们就能够正常获取引用了// 成员归并 const objectProperties = {}; traverse(ast, { AssignmentExpression(path) { const left = path.node.left; const right = path.node.right; // 检查是否是可以归并的成员类型 if (t.isMemberExpression(left) && t.isPureish(left.property) && t.isPureish(right)) { const objectName = left.object.name; const propertyName = left.property.value; // 储存成员声明 if (!objectProperties[objectName]) { objectProperties[objectName] = []; } const property = t.objectProperty(t.identifier(propertyName), right) objectProperties[objectName].push(property); path.remove(); console.log(generator(right).code) } path.scope.crawl(); }, }); traverse(ast, { ObjectExpression(path) { const { properties } = path.node; const objectName = path.parent.id.name; if (objectProperties[objectName]) { properties.push(...objectProperties[objectName]); } path.scope.crawl(); } }); 合并后的效果是这样的去除别名还有一种方式会影响获取到变量的binding，即采用变量别名的方式，为一个对象添加多个别名，例如，下图中的_0x26c98a实则为_0x41e2c2的别名，如果不消除别名，那么我们就无法获取到花指令的所有引用位置并替换检查是否是将一标识符赋值给新的变量，是的话就移除这个变量声明，并添加到别名映射表中。在下一轮遍历中对别名进行替换// 变量别名替换 const aliasMap = {}; traverse(ast, { VariableDeclarator(path) { const id = path.node.id; const init = path.node.init; if (t.isIdentifier(init)) { aliasMap[id.name] = init.name; path.remove(); } } }); traverse(ast, { Identifier(path) { if (aliasMap[path.node.name]) { path.replaceWith(t.identifier(aliasMap[path.node.name])); } } }); 成员花指令回填我们需要找到对象引用之间的关系，从遍历 AST 中的 ObjectProperty 节点开始检查节点的值是否是函数表达式检查函数体是否是花指令找到所有引用花指令的地方。对每个引用路径按照参数进行替换// 寻找所有引用花指令的地方 function getObscoreOpRefPath(path) { const objectId = path.parentPath.parent.id; const objectBinding = path.scope.getBinding(objectId.name); const propertyName = path.node.key.name; let opRef = []; // 遍历所有引用路径，找到符合条件的引用 for (const refPath of objectBinding.referencePaths.reverse()) { const memberExpression = refPath.parent; if (!t.isMemberExpression(memberExpression)) continue; const callExpression = refPath.parentPath.parent; if (!t.isCallExpression(callExpression)) continue; if (memberExpression.property.value !== propertyName) continue; opRef.push(refPath.parentPath.parentPath); } return opRef; } // 替换操作 function replaceOperation(opRefPath, opExpression, params) { const callExpression = opRefPath.node; const args = callExpression.arguments; let replacedExpression = t.cloneDeepWithoutLoc(opExpression); // 替换相同名称的标识符以实现指令还原 params.forEach((param, index) => { traverse(replacedExpression, { noScope: true, Identifier(innerPath) { if (innerPath.node.name === param.name) { innerPath.replaceWith(args[index]); } } }); }); opRefPath.replaceWith(replacedExpression); } // 花指令替换 traverse(ast, { ObjectProperty(path) { const value = path.node.value; if (!t.isFunctionExpression(value)) return; const firstBodyStatement = value.body.body[0]; if (!t.isReturnStatement(firstBodyStatement)) return; const opExpression = firstBodyStatement.argument; const opRefPaths = getObscoreOpRefPath(path); opRefPaths.forEach(opRefPath => { replaceOperation(opRefPath, opExpression, value.params); }); } });回填后的效果如下，可以看到，对象调用混淆已经很少了函数花指令回填除了我们上面看到的普通的表达式花指令，还有一种这样的套娃函数这种套娃函数是将普通的函数调用包装为两层，因此我们在将对象花指令还原过一次后，还需要对这种函数花指令再进行一次还原，如图，函数版的花指令并没有被消除对象的花指令回填后便会引用这些函数版的花指令，我们将成员花指令替换的代码稍作修改，即可得到函数花指令替换版// 函数型花指令替换 traverse(ast, { FunctionDeclaration(path) { const id = path.node.id; const firstBodyStatement = path.node.body.body[0]; if (!t.isReturnStatement(firstBodyStatement)) return; const opExpression = firstBodyStatement.argument; const opRefPaths = path.scope.getBinding(id.name).referencePaths; opRefPaths.forEach(opRefPath => { if (!opRefPath.parentPath.isCallExpression()) return; replaceOperation(opRefPath.parentPath, opExpression, path.node.params); }); path.remove() } });处理过后，现在仅剩hash函数和主函数go，此外，hash函数中的闭包函数也仅剩下5个常量回填此处还有一类内容我们可以处理，即下图的这种自声明后就从未变更过的变量，仅作为常量传递我们可以借助binding.constantViolations查看这个变量是否被修改，再使用binding.referencePaths寻找引用。refPath.evaluate().confident可以帮我们计算出，依据静态类型计算出的这个节点的值是否完全可信，之后我们就可以将这一类的变量删除了traverse(ast, { VariableDeclarator: { exit(path) { const { id, init } = path.node; if (!t.isIdentifier(id) || !init) return; const binding = path.scope.getBinding(id.name); let refs_count = binding.references if (!binding || binding.constantViolations.length > 0 || binding.references == 0) return; binding.referencePaths.forEach(refPath => { const result = refPath.evaluate() if (result.confident) { refPath.replaceWith(t.valueToNode(result.value)); refs_count-- } }); if (refs_count == 0) { path.remove() } path.scope.crawl(); } }, MemberExpression: { enter(path) { const { object, property, computed } = path.node; if (computed && t.isObjectExpression(object) && t.isStringLiteral(property)) { const propName = property.value; const prop = object.properties.find(p => t.isObjectProperty(p) && t.isIdentifier(p.key, { name: propName })); if (prop) { console.log(prop.value) if (t.isStringLiteral(prop.value)) { path.replaceWith(t.stringLiteral(prop.value.value)); } else if (t.isNumericLiteral(prop.value)) { path.replaceWith(t.numericLiteral(prop.value.value)); } } } path.scope.crawl(); } } });经过这上面的操作后，我们的这个节点就变成了下面这样代码量被大大缩减，我们再也不用面对一堆混淆乱码发呆了控制流还原控制流混淆是一种代码混淆技术，通过改变代码的执行路径和结构，使代码更难以理解和逆向：插入无关代码：在代码中插入无关的或无用的代码片段，使得代码的逻辑变得复杂。改变代码结构：将代码块拆分成多个部分，并通过条件语句、循环和跳转语句（如 goto）重新组织这些部分。使用复杂的条件和循环：使用复杂的条件语句和循环来控制代码的执行路径，使得代码的逻辑变得难以跟踪。原始代码function add(a, b) { return a + b; } console.log(add(2, 3));控制流混淆后的代码function _0x1a2b(_0x3c4d, _0x5e6f) { var _0x7g8h = 0; while (true) { switch (_0x7g8h) { case 0: var _0x9i0j = _0x3c4d + _0x5e6f; _0x7g8h = 1; break; case 1: return _0x9i0j; } } } (function() { var _0x1m2n = [2, 3]; console.log(_0x1a2b(_0x1m2n[0], _0x1m2n[1])); })();控制流的特点是，原始代码被分割为多段内容，然后执行一个大循环，循环内部由流程控制语句（if或swich，甚至是短路运算）控制代码逻辑，通过指令数进行跳转，从而打乱原始的代码逻辑现在，我们还剩最后，也是最长的一段需要还原的部分了，看一下下面这段代码的结构，很明显就是我们所说的控制流实际上在这个例子中，这是应该是最简单的控制流了，手动还原的速度要远远大于ast还原。但是在其他的大型网站中，控制流往往是千级甚至万级，并且穿插着混淆和跳转，因此在这里学习ast还原方法，将大大有助于我们日后的工作仔细观察代码，该控制流的指令由_0x4ef9d2控制，通过split进行切分，然后传入到switch中，由_0x4ef9d2[_0x44c6c++]决定执行哪一条指令。switch外部有一个while循环，每循环一次，switch就会执行一条指令，并通过_0x44c6c++将指令的指针移到下一条。因此我们的还原思路是将switch中每个分支的代码块都抽取出来将操作数和代码块之间建立映射表根据_0x4ef9d2所代表的操作指令，重建整个代码块将重建的代码替换掉函数体先将所有的case节点dump出来，注意，此处的控制流中，每条case后都跟有continue，需要将continue语句移除// 控制流还原 const switchCases = {}; traverse(ast, { SwitchStatement(path) { // 从switch中提取控制流的代码块 for (const { test, consequent } of path.node.cases) { for (const statement of consequent) { if (t.isContinueStatement(statement)) { break } if (!switchCases[test.value]) { switchCases[test.value] = [] } switchCases[test.value].push(statement) } } } });根据操作数和case代码块的对应关系，重建控制流// 重建代码 traverse(ast, { VariableDeclarator(path) { // 获取控制流操作数 const { id } = path.node; if (id.name === "_0x4ef9d2") { const init = path.get("init") // 获取控制流操作数，重建控制流 const controlFlow = eval(init.toString()) const restoredBlock = []; for (const test of controlFlow) { restoredBlock.push(...switchCases[test]) } // 替换函数体 const functionParent = path.getFunctionParent() functionParent.get("body").replaceInline(restoredBlock) path.stop() } }, }) 还原成果经过了上面的反混淆后，我们成功将代码由1200行降到了150行，这对于逆向来说提高了不止十倍的效率，下面我们就可以对仅剩的函数内容进行分析了，通过人工分析，进一步削减代码数量并还原出加密逻辑主函数还原环境检测我们先从主函数go入手，go中的_0x5ec195 对浏览器navigator、window等对象实施检测，如果检测到非浏览器环境则返回true，并在下方跳出主函数，使参数生成逻辑不能正常进行，我们对此处可以直接进行删除操作主函数cookie生成继续向下查看，发现_0x417549就是我们生成的参数，这个数组内始终有数据，因此可以将else分支也移除掉进入if分支内查看，前几行主要是生成了一个 _0x3c639 参数，用于执行计时器，执行完毕后，主函数函数体直接结束，这个参数除了用于计时器延时执行以外没有任何引用，因此可以判断是一个无用参数，可以直接删除掉这段内容并将计时器中的代码块提出来合并代码后，思路就很清晰了，剩余的代码作用是从主函数的参数中提取部分字段，和我们之前获取到的加密参数之间进行拼接，最后得到结果，更新cookie后跳转页面回到这个cookie生成的函数中，返回的第二个参数没有任何引用，并且仅为时间差计算，因此可以判断属于无效输出，主要输出内容为_0x4c5b31，从整个函数结构来看，其作用是尝试生成所有可能的字符串组合，并计算它们的哈希值，直到找到一个匹配的哈希值。这属于pow工作量证明的一种方式，用于验证哈希值主函数还原后内容哈希函数分析hash函数内部共有5个函数32位整数模拟经典的哈希算法都是最初由c语言编写的，对于各种加密函数，其内部也是含有一定的c语言特征的。例如我们的第一个闭包函数function _0x32cd5e(_0x2980ae, _0x277f9d) { var _0x31c1c1 = (_0x2980ae & 65535) + (_0x277f9d & 65535); var _0x156de1 = (_0x2980ae >> 16) + (_0x277f9d >> 16) + (_0x31c1c1 >> 16); return _0x156de1 << 16 | _0x31c1c1 & 65535; }对于c语言来说，16位int上限的大小仅为65536，此处用了大量的magic number进行位运算和移位操作，可以联想到是在对整数溢出在处理。由于js为动态类型语言，所以是肯定支持大整数的，此处对整形溢出做处理，以和c语言特性一致。具体来说，它在模拟 32 位无符号整数的加法运算，并处理可能的溢出情况。低 16 位相加：var _0x31c1c1 = (_0x2980ae & 65535) + (_0x277f9d & 65535);将两个 32 位整数的低 16 位相加，并存储在变量 _0x31c1c1 中。65535 是十六进制的 0xFFFF，用于屏蔽高 16 位，只保留低 16 位。高 16 位相加：var _0x156de1 = (_0x2980ae >> 16) + (_0x277f9d >> 16) + (_0x31c1c1 >> 16);将两个 32 位整数的高 16 位相加，并加上低 16 位相加后的进位（如果有）。>> 16 用于将整数右移 16 位，从而获取高 16 位。组合结果：return _0x156de1 << 16 | _0x31c1c1 & 65535;将高 16 位和低 16 位组合成一个 32 位整数。_0x156de1 << 16 将高 16 位左移 16 位，_0x31c1c1 & 65535 保留低 16 位，然后使用按位或运算符 | 将它们组合在一起。通过模拟 32 位无符号整数的加法运算，来实现类似于 C 语言中的整形溢出效果。它将两个 32 位整数的低 16 位和高 16 位分别相加，并处理可能的溢出情况，然后将结果组合成一个新的 32 位整数。字符串分块编码第二个函数传入了我们需要加密的字符串，将输入字符串转换为一个整数数组return出来，数组的每个整数表示字符串的 32 位块，实现了分块编码功能function _0xef2754(_0x1a80ec) { var _0xb30bef = Array(); for (var _0x222989 = 0; _0x222989 < _0x1a80ec["length"] * 8; _0x222989 += 8) { _0xb30bef[_0x222989 >> 5] |= (_0x1a80ec["charCodeAt"](_0x222989 / 8) & 255) << 24 - _0x222989 % 32; } return _0xb30bef; }_0x1a80ec["charCodeAt"](_0x222989 / 8) 获取字符串中第 _0x222989 / 8 个字符的 ASCII 码。& 255 确保只保留字符的低 8 位。<< 24 - _0x222989 % 32 将字符的二进制数据左移到正确的位置。_0x222989 >> 5 计算当前字符属于数组中的哪个整数（每个整数包含 32 位，即 4 个字符）。|= 将二进制数据存储在数组中的相应位置。utf8编码第三个函数，进行了大量的字符串操作，并且在我们测试下，传入的英文会原样传出，而中文则会被编码，其中一样包含了许多magic number，例如ASCII 字符（0-127）、双字节字符（128-2047）、三字节字符（2048 及以上），因此这个函数是一个utf8编码函数。由于我们传入的cookie只会是ascii英文字符串，因此可以直接删除此函数 function _0xcf3a06(_0x37f576) { var _0xb9a176 = new RegExp("\n", "g"); _0x37f576 = _0x37f576["replace"](_0xb9a176, "\n"); var _0xb2fac = ""; for (var _0x1d9308 = 0; _0x1d9308 < _0x37f576["length"]; _0x1d9308++) { var _0x2bc062 = _0x37f576["charCodeAt"](_0x1d9308); if (_0x2bc062 < 128) { _0xb2fac += String["fromCharCode"](_0x2bc062); } else if (_0x2bc062 > 127 && _0x2bc062 < 2048) { if (true) { _0xb2fac += String["fromCharCode"](_0x2bc062 >> 6 | 192); _0xb2fac += String["fromCharCode"](_0x2bc062 & 63 | 128); } else { return x & y ^ ~x & z; } } else { _0xb2fac += String["fromCharCode"](_0x2bc062 >> 12 | 224); _0xb2fac += String["fromCharCode"](_0x2bc062 >> 6 & 63 | 128); _0xb2fac += String["fromCharCode"](_0x2bc062 & 63 | 128); } } return _0xb2fac; }十六进制转换函数使用一个 for 循环遍历 _0x2eed1d 数组的每一个元素。循环的范围是 _0x2eed1d 数组长度的四倍，这意味着每个数组元素将被处理四次。在循环体内，代码通过 charAt 方法从字符串 "0123456789abcdef" 中提取相应的十六进制字符。位运算部分 _0x1a73b7 >> 2 和 (3 - _0x1a73b7 % 4) * 8 用于确定当前处理的字节和位位置，& 15 操作则确保只取出当前字节的低四位，通过位运算和字符串操作将每个整数转换为十六进制字符。function _0x44bfe3(_0x2eed1d) { var _0x4c87b6 = ""; for (var _0x1a73b7 = 0; _0x1a73b7 < _0x2eed1d["length"] * 4; _0x1a73b7++) { _0x4c87b6 += "0123456789abcdef"["charAt"](_0x2eed1d[_0x1a73b7 >> 2] >> (3 - _0x1a73b7 % 4) * 8 + 4 & 15) + "0123456789abcdef"["charAt"](_0x2eed1d[_0x1a73b7 >> 2] >> (3 - _0x1a73b7 % 4) * 8 & 15); } return _0x4c87b6; }sha256从他传入的代码中，可以看出当前使用的是sha256常量数组：var _0x1dfb30 = new Array(...); var _0x547bc0 = new Array(...);数组包含了 SHA-256 算法中使用的常量值。_0x1dfb30 包含了 64 个常量值，_0x547bc0 包含了 8 个初始哈希值。主循环：for (var _0x36d061 = 0; _0x36d061 < _0x5a285e["length"]; _0x36d061 += 16) { ... for (var _0x2862cc = 0; _0x2862cc < 64; _0x2862cc++) { ... } }实现了 SHA-256 的主循环，处理每个 512 位的消息块。实际上，代码其余部分还实现了 SHA-256 的消息填充、消息调度和压缩函数。
jsvmp编译与反编译详解 (2)——字节栈式虚拟机反编译
ast预反混淆以抖音为例，首先获取webmsdk.js，可以看到大约一共9600行，这为我们的逆向分析工作增添了不少难度，因此我们的首要工作是将代码反混淆后再进行分析通过ast解混淆处理后，代码量削去了一半，并且大部分字符串也被还原，这一步为基础内容，不进行详细讲解补上缺失的环境处理一下canvas指纹，注意，canvas指纹是和硬件关联的，通过海量的收集真实设备从而达到判断哪些设备是伪造的。一般情况下随机生成一个也是能生产参数的，但是不一定能够通过服务器校验。而抖音这里在服务端没有做校验，我们可以放心使用进一步处理，在解决掉环境参数和canvas后，导出我们需要的目标函数，实际上已经能够获取到我们所需的加密参数了为什么我们一定要先ast进行还原，然后补环境拿到参数呢，直接分析不好吗？因为这样生成的代码可以保证我们能够在各种环境中正常地生成参数，只有这样我们才能更好地利用ide的调试工具，如果使用浏览器的调试工具，那么会大大降低逆向效率。当然，如果你不想学习vmp反编译的话，到这里也可以结束了。借助ide对函数结构分析仔细观察一下，按照我们上文的理解，vmp在执行时一定会有大段的字节码传入，因此这是非常显著的一种特征。注意图中我标注的vmpstr，整个参数的的生成，是通过_0x5a8f25这个函数进行的，而其中就有我们需要的vmp字节码直接跟栈进入，找到_$webrt_1668687510这个函数，搜索一下函数名，能看到有大量传入字节码的操作，那么这个就是我们的vmp主入口了不幸的是，整个入口函数足足有900多行，即使在ast还原过后，其中也充斥着大量的混淆代码和看不懂的标识符。这时万不要在原本的打断代码中盲目硬分析，我们可以将这段代码复制出来，粘贴到新的文件中，再次进行分析这时候ide的作用就显现了，利用vscode的大纲功能，我们可以看到这个函数是由7个闭包函数和大量的变量组成的，而如果利用浏览器，再未反混淆的情况下，仅仅这个函数就有3000行，我们将需要在浏览器中无任何工具的情况下进行分析经过对这几个函数的观察，除_0x1218ef以外，其他函数都比较简短在函数体最长的_0x1218ef中断点，能够看到传入了整段字节码查看函数体，发现整个函数体是由一个看似为for实则为while条件循环来控制的控制流，其中有大量的if分支跳转，那么这个就很有可能是虚拟机本身了，至于为什么靠这两点可以定位vmp，我们先按下不表，这里的分析较为复杂，我们先分析较为简单的其他函数虚拟机外层函数简化反射对象检测查看第一个闭包，检测当前JavaScript环境是否完全支持 Reflect.construct 和 Proxy，并且进行了字符串格式化检测，确保没有任何“假冒”实现。因此可以得出简化方案：直接返回true，或使用ast将调用_0x2f9ebc处全部替换为true。 function _0x2f9ebc() { if ('undefined' == typeof Reflect || !Reflect['construct']) { return false; } if (Reflect['construct']['sham']) { return false; } if ('function' == typeof Proxy) { return true; } try { Date['prototype']['toString']['call'](Reflect['construct'](Date, [], function () { })); return true; } catch (_0x1a9721) { return false; } }第二个函数 _0x265a42 ，调用了_0x2f9ebc来检测Reflect，作用是根据当前环境是否支持 Reflect.construct 方法来决定如何创建一个新的对象实例。具体来说，它会根据 _0x2f9ebc 函数的返回值来选择不同的实现方式。function _0x265a42(_0x3ce31a, _0x217675, _0x5569d0) { return (_0x265a42 = _0x2f9ebc() ? Reflect['construct'] : function (_0x2dc1b1, _0x54ae53, _0x5bec15) { var _0x2a793f = [null]; _0x2a793f['push']['apply'](_0x2a793f, _0x54ae53); var _0x60cd25 = new (Function['bind']['apply'](_0x2dc1b1, _0x2a793f))(); _0x5bec15 && _0x59d7fb(_0x60cd25, _0x5bec15['prototype']); return _0x60cd25; })['apply'](null, arguments); }提取一下核心部分(_0x2f9ebc() ? Reflect['construct'] : function (_0x2dc1b1, _0x54ae53, _0x5bec15) {/.../})['apply'](null, arguments)首先调用 _0x2f9ebc 函数来（也就是我们看到的第一个函数）检查当前环境是否支持 Reflect.construct 方法。如果 _0x2f9ebc 返回 true，则使用 Reflect.construct 方法来创建新的对象实例。如果 _0x2f9ebc 返回 false，则使用一个自定义的实现方式来创建新的对象实例。自定义实现方式通过 Function.bind 和 apply 方法来模拟 Reflect.construct 的行为。首先创建一个包含 null 的数组 _0x2a793f。然后将 _0x54ae53（构造函数参数）推入 _0x2a793f。使用 Function.bind.apply 方法将构造函数 _0x2dc1b1 绑定到 _0x2a793f，并创建一个新的实例 _0x60cd25。如果提供了 _0x5bec15（原型对象），则将其原型属性赋给新创建的实例 _0x60cd25，否则调用_0x59d7fb，最后返回新创建的实例 _0x60cd25 完成代理行为函数 _0x59d7fb （第三个函数）的作用是设置对象的原型（prototype）。它首先检查 Object.setPrototypeOf 方法是否存在，如果存在则使用它；如果不存在，则使用一个回退方法，通过直接设置对象的 __proto__ 属性来实现相同的功能function _0x59d7fb(_0x2e16aa, _0x2804b1) { return (_0x59d7fb = Object["setPrototypeOf"] || function (_0x1d595d, _0x48cf70) { _0x1d595d["__proto__"] = _0x48cf70; return _0x1d595d; })(_0x2e16aa, _0x2804b1); }最后，_0x265a42 函数通过 apply 方法调用，传递 null 作为上下文，并将 arguments 作为参数传递给所选的实现方式（相当于直接调用方法）。Reflect.construct 是 ES6 中引入的一个方法，用于调用构造函数并创建一个新的实例。那么我们可以得出结论，前三个闭包函数的作用仅为防止反射hook，能够正确调用反射创建实例。借助ide查看一下_0x2f9ebc（判断Reflect.construct是否被修改的函数）和_0x59d7fb（实现自定义原型对象的函数）的引用位置，发现仅在_0x265a42（实现Reflect.construct创建实例对象的函数）中发生过调用，因此我们可以简单删除掉这三个函数，并将第二个函数改为如下即可完成反混淆const _0x265a42 = Reflect.construct可迭代对象转数组检查一下下一个函数，这里由于我们疏忽，并没有将自执行函数转换为流程代码，不过可以很轻易的看出， _0x30aa3b 的作用是将一个可迭代对象（如数组或类数组对象）转换为一个数组。它通过一系列检查来确定输入对象的类型，并根据类型选择适当的方法进行转换。如果输入对象不是可迭代的，则抛出一个 TypeError。 function _0x30aa3b(_0x526356) { return ( (function (_0x50954b) { if (Array["isArray"](_0x50954b)) { for ( var _0x2e8a5e = 0, _0x5bc4e4 = new Array(_0x50954b["length"]); _0x2e8a5e < _0x50954b["length"]; _0x2e8a5e++ ) { _0x5bc4e4[_0x2e8a5e] = _0x50954b[_0x2e8a5e]; } return _0x5bc4e4; } })(_0x526356) || (function (_0x325d22) { if ( Symbol["iterator"] in Object(_0x325d22) || "[object Arguments]" === Object["prototype"]["toString"]["call"](_0x325d22) ) { return Array["from"](_0x325d22); } })(_0x526356) || (function () { throw new TypeError("Invalid attempt to spread non-iterable instance"); })() ); }简要还原const _0x30aa3b = Array.from;剩下的函数几乎都是一些花指令，我们不需要在这个阶段继续深入分析了这样我们就将几块较大的函数分析完毕了虚拟机解释器结构分析回到刚刚的_0x1218ef中，为什么我们能断定这个就是虚拟机的执行函数呢？还记得我们上一篇文章中制作的一个简易jsvmp虚拟机吗？回忆一下当时的代码是如何写的function VM() { this.stack = []; this.execute = function(instructions, context) { for (let i = 0; i < instructions.length; i++) { const instr = instructions[i]; switch (instr.op) { case 'LOAD': this.stack.push(context[instr.arg]); break; case 'ADD': const b = this.stack.pop(); const a = this.stack.pop(); this.stack.push(a + b); break; case 'RETURN': return this.stack.pop(); } } }; }如何还不知道为什么，这就需要好好复习一下编译原理相关的知识了，虚拟机的内层循环的执行过程可以简化为如下状态加载程序字节码； while(程序未结束){ 取操作符; 根据操作符的值执行一个动作; }由于指令系统的简单性，使得虚拟机执行的过程十分简单，从而有利于提高执行的效率，因此，vmp的虚拟机核心特点就是，一定会有一个大的循环结构来控制虚拟机的指令操作。并且在循环体内，有明显的根据不同指令执行不同任务的特征。可以看到_0x1218ef的核心特点便是如此，从外部加载程序字节码，并由一个大循环体来控制流程执行，在循环体内依据操作数_0x2458f0跳转不不同分支流程（if跳转），此处的if跳转不便于对流程逻辑还原，我们后期可以利用ast更改为switch跳转此外，我们还可以发现，这个函数中有从字节码中一次读取2个字节并转换为16进制操作码，并将字节码索引向后推2个的过程，这更加说明了这一段就是虚拟机执行流程这里不知道网上博客一堆文章你抄我我抄你，抄到哪里去了，都认定_0x217611（在这些相似的博客中，这个变量为_0x1383c7）这个循环变量的值为0-255。事实上0-255是_0x3f0f70字节码范围，而并非指令的跳转顺序var _0x3f0f70 = parseInt( "" + _0x2232d0[_0x217611] + _0x2232d0[_0x217611 + 1], 16 ); _0x217611 += 2; var _0x2458f0 = 3 & (_0xf24f2b = (13 * _0x3f0f70) % 241);我们截取一下这段初始化代码：第一步：_0x3f0f70 是通过将字节码中两个连续的元素拼接成一个字符串，然后解析为十六进制数得到的。因此，_0x3f0f70 的范围是 0x00 到 0xFF，即 0 到 255。第二步：_0xf24f2b 是通过将 _0x3f0f70 乘以 13 然后对 241 取模得到的。因此，_0xf24f2b 的范围是 0 到 240（因为取模 241 的结果范围是 0 到 240）。第三步：_0x2458f0 是通过 _0xf24f2b 与 3 进行按位与操作得到的。按位与 3 的结果范围是 0 到 3（因为 3 的二进制表示是 11，即只保留最低两位）。这也就得出了_0x2458f0初始值为0-3的结论。不过，_0x2458f0 代表的指令的值确实会随着流程的执行而变化，甚至大于3，但将循环变量误认为为0-255仅仅是原创者的一项书写错误，各位其他的博主却原封不动的抄了下来将整个执行流程折叠，可以看到有一个变量和否运算值被同时运用于两个if，这还说明了虚拟机执行流程中可能存在一段虚假流程代码，如果直接将其削除，我们又可以减少一半的代码量然而，在函数体开头和两个if前对条件变量进行插桩，发现所有函数体传入的条件值都是0，但到了第二个if的时候极少数情况会变为1，这下可就有点难受了，说明第二个分支流程可能并不是虚假流程分支，无法直接删除，我们需要在之后对流程分支进行判断栈式虚拟机命名去混淆通过对虚拟机的初步分析，我们大致了解了这个虚拟机的基础结构这段代码非常可能实现了一个栈式虚拟机，主要通过一个数组 _0xcc6308 来模拟栈，并使用变量 _0x2e1055 作为栈指针。为何如此断定呢？观察一下源代码，我们可以从代码中确定这个jsvmp含有如下特征指令生成代码通过解析输入的字节码 _0x2232d0 来执行不同的操作。每次读取两个字节，并将其转换为一个整数 _0x3f0f70，然后根据 _0x3f0f70 的值来决定执行哪种操作var _0x3f0f70 = parseInt("" + _0x2232d0[_0x217611] + _0x2232d0[_0x217611 + 1], 16); _0x217611 += 2;指令条件跳转根据条件跳转到不同的代码位置if (_0x2458f0 > 2) { // 执行某些操作* } else { // 执行其他操作* }初始化存在的可疑的栈数组初始化了两个可疑变量，在这两个变量上我们可以看到大量的栈操作var _0xcc6308 = []; // 栈数组 var _0x2e1055 = 0; // 栈指针，初始化为0压栈操作_0xcc6308[++_0x2e1055] = value;：将 value 压入栈中，并将栈指针 _0x2e1055 增加1。例如：_0xcc6308[++_0x2e1055] = null; 将 null 压入栈中。出栈操作var value = _0xcc6308[_0x2e1055--];：从栈顶弹出一个值，并将栈指针 _0x2e1055 减少1。例如：_0x4db217 = _0xcc6308[_0x2e1055--]; 从栈顶弹出一个值赋给 _0x4db217。栈操作根据不同的指令值，代码会在if分支结构中执行不同的操作，而这些操作都是围绕栈展开的1. 值操作压栈：将值压入栈中。_0xcc6308[++_0x2e1055] = value;出栈：从栈顶弹出一个值。var _0x4db217 = _0xcc6308[_0x2e1055--];2. 算术操作_0x4db217均为栈顶弹出的值加法：将栈顶两个值相加，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] + _0x4db217;减法：将栈顶两个值相减，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] - _0x4db217;乘法：将栈顶两个值相乘，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] * _0x4db217;除法：将栈顶两个值相除，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] / _0x4db217;3. 逻辑操作与操作：将栈顶两个值进行与操作，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] & _0x4db217;或操作：将栈顶两个值进行或操作，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] | _0x4db217;异或操作：将栈顶两个值进行异或操作，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] ^ _0x4db217;4. 比较操作大于等于：比较栈顶两个值，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] >= _0x4db217;小于：比较栈顶两个值，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] < _0x4db217;等于：比较栈顶两个值是否相等，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] === _0x4db217;不等于：比较栈顶两个值是否不相等，并将结果压入栈中。_0xcc6308[_0x2e1055] = _0xcc6308[_0x2e1055] !== _0x4db217;5. 类型操作获取类型：获取栈顶值的类型，并将结果压入栈中。_0xcc6308[_0x2e1055] = typeof _0x4db217;6. 函数调用调用函数：从栈顶弹出一个函数并调用它。_0xcc6308[_0x2e1055] = _0x2458f0(_0x1f1790);7. 异常处理抛出异常：从栈顶弹出一个值并抛出异常。throw _0xcc6308[_0x2e1055--];上下文环境入栈stack[++stackPointer] = context;*这里我使用context来代表压入的作用域，实际上传入的为this或global根据上文给出的特征，我们可以改写变量名，以实现虚拟机简化stack：用于模拟操作数栈，存储计算过程中需要的中间值。stackPointer：用于跟踪操作数栈的当前指针位置，指示栈顶的位置。PC（程序计数器）：用于跟踪当前执行的位置，初始化为 startIndex，并在每次读取操作码后递增。endIndex：计算并存储代码段的结束位置，确保程序计数器在执行过程中不会越界。operationCode：用于存储计算得到的操作码，通过对 byteValue 进行特定的数学运算和位操作得到。operandReg：用于存储操作数寄存器的值，通常用于临时存储从操作数栈中弹出的值，以便进行进一步的计算或操作。例如如下操作，从栈顶对两个操作数进行大小判断operandReg = stack[stackPointer--]; stack[stackPointer] = stack[stackPointer] >= operandReg;regA 和 regB：通用寄存器，用于储存从栈顶弹出的数据regA = stack[stackPointer--]; regB = stack[stackPointer--];这些变量共同作用，模拟了一个简单的虚拟机执行环境，其中 stack 和 stackPointer 用于操作数栈的管理，PC 和 endIndex 用于控制程序的执行流程，operandReg、regA 和 regB 用于存储中间计算结果，_0xf24f2b 用于位操作和条件判断。到这里为止，虚拟机的结构就十分清晰了
介绍CHIP-8 是一种解释型语言，设计之初就是为了编写简单的小游戏。我猜是作者嫌老机器的汇编语言太复杂繁琐，从而自己设计了一门汇编语言，并且摆脱硬件的束缚，在模拟器上运行。其实这个思想和 Java 等基于虚拟机的高级语言也是类似的，提供方便程序员编写的指令集，在硬件之上空架一层虚拟机，实现 “Write Once, Run Everywhere”。我们的目的是在python中实现一个chip8虚拟机CHIP8 虚拟机的结构和现代的计算机一样，虚拟起也有内存，CPU，寄存器等计算机的“基础零件”：Memory：CHIP-8 最多有 4096 字节的内存CHIP-8 解释器本身占用这些机器上的前 512 字节内存空间。因此，为原始系统编写的大多数程序都从内存位置 512 (0x200) 开始，并且不会访问位置 512 (0x200) 以下的任何内存。最上面的 256 个字节 (0xF00-0xFFF) 保留用于显示刷新，下面的 96 个字节 (0xEA0-0xEFF) 保留用于调用堆栈、内部使用和其他变量。Program Counter：16 位的 PC，记录当前程序指令运行的内存位置，因为需要访问最多 4K 的内存（0xFFF）Stack：16 位地址的堆栈，用于调用函数和返回。栈调用深度最初设计位 12 层，可以自行调整。Registers：16 个 8 位数据寄存器（data register），名为 V0 至 VF。 VF 寄存器兼作某些指令的标志；因此，应该避免这种情况。在加法运算中，VF 是进位标志，而在减法运算中，VF 是“无借位”标志。在绘制指令中，VF 在像素冲突时设置。一个 16 位索引寄存器（index register），用于记录内存地址Timers8 位延迟定时器，以 60 Hz（每秒 60 次）的速率递减，直至达到 08 位声音定时器，当其值非零时，会发出蜂鸣声。Display：64 x 32 像素（或 128 x 64 对于 SUPER-CHIP）单色，即黑或白Inputs：16 个输入键，与前 16 个十六进制值匹配：0 到 F。上面就是 CHIP-8 虚拟机的全部“硬件”了，接下来就是实现用软件模拟硬件。导入模块import pygame import sys import random import os我们需要实现图形界面、系统操作、随机数生成器和CHIP8指令集寄存器class Register: def __init__(self, bits): self.value = 0 self.bits = bits def checkCarry(self): hexValue = hex(self.value)[2:] if len(hexValue) > self.bits / 4: self.value = int(hexValue[-int(self.bits / 4):], 16) return 1 return 0 def checkBorrow(self): if self.value < 0: self.value = abs(self.value) return 0 return 1 def readValue(self): return hex(self.value) def setValue(self, value): self.value = valueRegister 类表示一个寄存器，包含寄存器的值和位数。checkCarry 和 checkBorrow 方法用于检查进位和借位。延迟计时器CHIP-8 有两个独立的定时器寄存器：延迟定时器和声音定时器。 大小为 1 个字节，只要它们的值大于 0，它们就应该每秒减少 60 次（即 60 Hz ），并且与执行指令的速度无关。 也就是说无论代码怎么执行，即使进入了死循环，定时器也需要以 60 Hz 的频率运行。class DelayTimer: def __init__(self): self.timer = 0 def countDown(self): if self.timer > 0: self.timer -= 1 def setTimer(self, value): self.timer = value def readTimer(self): return self.timer这样可以设计一个最基础的延迟计时器，包含计时器的值和倒计时方法。蜂鸣器class SoundTimer(DelayTimer): def __init__(self): DelayTimer.__init__(self) def beep(self): if self.timer > 1: os.system('play --no-show-progress --null --channels 1 synth %s triangle %f' % (self.timer / 60, 440)) self.timer = 0SoundTimer 类继承自 DelayTimer，可以利用系统延时器播放声音。Stack 类chip8虚拟机提供了一个栈机，我们可以使用python的队列来实现class Stack: def __init__(self): self.stack = [] def push(self, value): self.stack.append(value) def pop(self): return self.stack.pop()Stack 类表示一个堆栈，包含入栈和出栈方法。Emulator 类Emulator 初始化内存、寄存器、堆栈、计时器、键盘映射和显示屏。 我们之前导入的pygame 这会儿可以用于图形界面chip8使用的是点阵字体，我们可以用16进制数，每一位来表示一个像素class Emulator: def __init__(self): self.Memory = [0x0] * 4096 fonts = [ 0xF0, 0x90, 0x90, 0x90, 0xF0, # 0 0x20, 0x60, 0x20, 0x20, 0x70, # 1 0xF0, 0x10, 0xF0, 0x80, 0xF0, # 2 0xF0, 0x10, 0xF0, 0x10, 0xF0, # 3 0x90, 0x90, 0xF0, 0x10, 0x10, # 4 0xF0, 0x80, 0xF0, 0x10, 0xF0, # 5 0xF0, 0x80, 0xF0, 0x90, 0xF0, # 6 0xF0, 0x10, 0x20, 0x40, 0x40, # 7 0xF0, 0x90, 0xF0, 0x90, 0xF0, # 8 0xF0, 0x90, 0xF0, 0x10, 0xF0, # 9 0xF0, 0x90, 0xF0, 0x90, 0x90, # A 0xE0, 0x90, 0xE0, 0x90, 0xE0, # B 0xF0, 0x80, 0x80, 0x80, 0xF0, # C 0xE0, 0x90, 0x90, 0x90, 0xE0, # D 0xF0, 0x80, 0xF0, 0x80, 0xF0, # E 0xF0, 0x80, 0xF0, 0x80, 0x80 # F ] for i in range(len(fonts)): self.Memory[i] = fonts[i] self.Registers = [Register(8) for _ in range(16)] self.IRegister = Register(16) self.ProgramCounter = 0x200 self.stack = Stack() self.delayTimer = DelayTimer() self.soundTimer = SoundTimer() pygame.init() pygame.time.set_timer(pygame.USEREVENT+1, int(1000 / 60)) self.keys = [False] * 16 self.keyDict = { 49 : 1, 50 : 2, 51 : 3, 52 : 0xc, 113 : 4, 119 : 5, 101 : 6, 114 : 0xd, 97 : 7, 115 : 8, 100 : 9, 102 : 0xe, 122 : 0xa, 120 : 0, 99 : 0xb, 118 : 0xf } self.grid = [[0] * 64 for _ in range(32)] self.emptyGrid = self.grid[:] self.zeroColor = [0, 0, 50] self.oneColor = [255, 255, 255] self.size = 10 width = 64 height = 32 self.screen = pygame.display.set_mode([width * self.size, height * self.size]) self.screen.fill(self.oneColor) pygame.display.flip()execOpcode 方法cpu最重要的就是指令集了，chip8的指令集非常精简，一共只有35条指令，execOpcode 方法根据操作码执行相应的指令。 def execOpcode(self, opcode): if opcode[0] == '0': if opcode[1] != '0': print("ROM attempts to run RCA 1802 program at <0x" + opcode[1:] + '>') else: if opcode == '00e0': self.clear() elif opcode == '00ee': self.ProgramCounter = self.stack.pop() elif opcode[0] == '1': self.ProgramCounter = int(opcode[1:], 16) - 2 elif opcode[0] == '2': self.stack.push(self.ProgramCounter) self.ProgramCounter = int(opcode[1:], 16) - 2 elif opcode[0] == '3': vNum = int(opcode[1], 16) targetNum = int(opcode[2:], 16) if self.Registers[vNum].value == targetNum: self.ProgramCounter += 2 elif opcode[0] == '4': vNum = int(opcode[1], 16) targetNum = int(opcode[2:], 16) if self.Registers[vNum].value != targetNum: self.ProgramCounter += 2 elif opcode[0] == '5': v1 = int(opcode[1], 16) v2 = int(opcode[2], 16) if self.Registers[v1].value == self.Registers[v2].value: self.ProgramCounter += 2 elif opcode[0] == '6': vNum = int(opcode[1], 16) targetNum = int(opcode[2:], 16) self.Registers[vNum].value = targetNum elif opcode[0] == '7': vNum = int(opcode[1], 16) targetNum = int(opcode[2:], 16) self.Registers[vNum].value += targetNum self.Registers[vNum].checkCarry() elif opcode[0] == '8': if opcode[3] == '0': v1 = int(opcode[1], 16) v2 = int(opcode[2], 16) self.Registers[v1].value = self.Registers[v2].value elif opcode[3] == '1': v1 = int(opcode[1], 16) v2 = int(opcode[2], 16) self.Registers[v1].value = self.Registers[v1].value | self.Registers[v2].value elif opcode[3] == '2': v1 = int(opcode[1], 16) v2 = int(opcode[2], 16) self.Registers[v1].value = self.Registers[v1].value & self.Registers[v2].value elif opcode[3] == '3': v1 = int(opcode[1], 16) v2 = int(opcode[2], 16) self.Registers[v1].value = self.Registers[v1].value ^ self.Registers[v2].value elif opcode[3] == '4': v1 = int(opcode[1], 16) v2 = int(opcode[2], 16) self.Registers[v1].value += self.Registers[v2].value self.Registers[0xf].value = self.Registers[v1].checkCarry() elif opcode[3] == '5': v1 = int(opcode[1], 16) v2 = int(opcode[2], 16) self.Registers[v1].value -= self.Registers[v2].value self.Registers[0xf].value = self.Registers[v1].checkBorrow() elif opcode[3] == '6': v1 = int(opcode[1], 16) leastBit = int(bin(self.Registers[v1].value)[-1]) self.Registers[v1].value = self.Registers[v1].value >> 1 self.Registers[0xf].value = leastBit elif opcode[3] == '7': v1 = int(opcode[1], 16) v2 = int(opcode[2], 16) self.Registers[v1].value = self.Registers[v2].value - self.Registers[v1].value self.Registers[0xf].value = self.Registers[v1].checkBorrow() elif opcode[3] == 'e': v1 = int(opcode[1], 16) mostBit = int(bin(self.Registers[v1].value)[2]) self.Registers[v1].value = self.Registers[v1].value << 1 self.Registers[0xf].value = mostBit elif opcode[0] == '9': v1 = int(opcode[1], 16) v2 = int(opcode[2], 16) if self.Registers[v1].value != self.Registers[v2].value: self.ProgramCounter += 2 elif opcode[0] == 'a': addr = int(opcode[1:], 16) self.IRegister.value = addr elif opcode[0] == 'b': addr = int(opcode[1:], 16) self.ProgramCounter = self.Registers[0].value + addr - 2 elif opcode[0] == 'c': vNum = int(opcode[1], 16) targetNum = int(opcode[2:], 16) rand = random.randint(0, 255) self.Registers[vNum].value = targetNum & rand elif opcode[0] == 'd': Vx = int(opcode[1], 16) Vy = int(opcode[2], 16) N = int(opcode[3], 16) addr = self.IRegister.value sprite = self.Memory[addr: addr + N] for i in range(len(sprite)): if type(sprite[i]) == str: sprite[i] = int(sprite[i], 16) if self.draw(self.Registers[Vx].value, self.Registers[Vy].value, sprite): self.Registers[0xf].value = 1 else: self.Registers[0xf].value = 0 elif opcode[0] == 'e': if opcode[2:] == '9e': Vx = int(opcode[1], 16) key = self.Registers[Vx].value if self.keys[key]: self.ProgramCounter += 2 elif opcode[2:] == 'a1': Vx = int(opcode[1], 16) key = self.Registers[Vx].value if not self.keys[key]: self.ProgramCounter += 2 elif opcode[0] == 'f': if opcode[2:] == '07': Vx = int(opcode[1], 16) self.Registers[Vx].value = self.delayTimer.readTimer() elif opcode[2:] == '0a': Vx = int(opcode[1], 16) key = None while True: self.keyHandler() isKeyDown = False for i in range(len(self.keys)): if self.keys[i]: key = i isKeyDown = True if isKeyDown: break self.Registers[Vx].value = key elif opcode[2:] == '15': Vx = int(opcode[1], 16) value = self.Registers[Vx].value self.delayTimer.setTimer(value) elif opcode[2:] == '18': Vx = int(opcode[1], 16) value = self.Registers[Vx].value self.soundTimer.setTimer(value) elif opcode[2:] == '1e': Vx = int(opcode[1], 16) self.IRegister.value += self.Registers[Vx].value elif opcode[2:] == '29': Vx = int(opcode[1], 16) value = self.Registers[Vx].value self.IRegister.value = value * 5 elif opcode[2:] == '33': Vx = int(opcode[1], 16) value = str(self.Registers[Vx].value) fillNum = 3 - len(value) value = '0' * fillNum + value for i in range(len(value)): self.Memory[self.IRegister.value + i] = int(value[i]) elif opcode[2:] == '55': Vx = int(opcode[1], 16) for i in range(0, Vx + 1): self.Memory[self.IRegister.value + i] = self.Registers[i].value elif opcode[2:] == '65': Vx = int(opcode[1], 16) for i in range(0, Vx + 1): self.Registers[i].value = self.Memory[self.IRegister.value + i] self.ProgramCounter += 2图形显示配置 def execution(self): index = self.ProgramCounter high = self.hexHandler(self.Memory[index]) low = self.hexHandler(self.Memory[index + 1]) opcode = high + low self.execOpcode(opcode) def draw(self, Vx, Vy, sprite): collision = False spriteBits = [] for i in sprite: binary = bin(i) line = list(binary[2:]) fillNum = 8 - len(line) line = ['0'] * fillNum + line spriteBits.append(line) for i in range(len(spriteBits)): for j in range(8): try: if self.grid[Vy + i][Vx + j] == 1 and int(spriteBits[i][j]) == 1: collision = True self.grid[Vy + i][Vx + j] = self.grid[Vy + i][Vx + j] ^ int(spriteBits[i][j]) except: continue return collision def clear(self): for i in range(len(self.grid)): for j in range(len(self.grid[0])): self.grid[i][j] = 0 def readProg(self, filename): rom = self.convertProg(filename) offset = int('0x200', 16) for i in rom: self.Memory[offset] = i offset += 1 def convertProg(self, filename): rom = [] with open(filename, 'rb') as f: wholeProgram = f.read() for i in wholeProgram: opcode = i rom.append(opcode) return rom def hexHandler(self, Num): newHex = hex(Num)[2:] if len(newHex) == 1: newHex = '0' + newHex return newHex def keyHandler(self): for event in pygame.event.get(): if event.type == pygame.QUIT: sys.exit() elif event.type == pygame.USEREVENT+1: self.delayTimer.countDown() elif event.type == pygame.KEYDOWN: try: targetKey = self.keyDict[event.key] self.keys[targetKey] = True except: pass elif event.type == pygame.KEYUP: try: targetKey = self.keyDict[event.key] self.keys[targetKey] = False except: pass def mainLoop(self): clock = pygame.time.Clock() while True: clock.tick(300) self.keyHandler() self.soundTimer.beep() self.execution() self.display() def display(self): for i in range(0, len(self.grid)): for j in range(0, len(self.grid[0])): cellColor = self.zeroColor if self.grid[i][j] == 1: cell项目地址https://github.com/dream2333/chip8-emulator
发现发件人在黑名单中，立即重置TCP链接from scapy.all import * send(IP(dst='1.2.3.4', ttl=9) / TCP(dport=25, flags='S', seq=0)) send(IP(dst='1.2.3.4', ttl=9) / TCP(dport=25, flags='A', seq=1)) send(IP(dst='1.2.3.4', ttl=9) / TCP(dport=25, flags='A', seq=1) / 'MAIL FROM: xiazai@upup.info\r\n') 看似普通的三个包其实暗藏玄机。首先，目标地址是1.2.3.4，这显然是我胡写的一个地址，而且ttl设置为9。所以这个包发出去就没有打算让最终的目标机器接到，而只是发给GFW看的。这个ttl值要大于你的机器到GFW的跳数，一般11是一个保险的值。然后要触发GFW的响应，有以下几个缺一不可的条件目标端口是25，我尝试了其他几个端口没有发现触发响应第二个包虽然内容是空的，但是必须存在。而且必须是ACK。内容也可以不为空，GFW似乎不care内容是什么，只要有这个包就可以。第三个包的seq必须为1，哪怕第二包有内容了，这个包的seq也必须为1。而且MAIL FROM: \r\n这个格式必须对，不能替换成FROM MAIL啥的。还有一个条件就是邮件地址必须上了黑名单。这里举的例子是一个翻墙软件的索取地址，所以上了黑名单。观测到的响应是GFW发一个TCP RST包。而且每次都是一个，从来不多发。如果少了中间那个空的ACK，则是连做两次探测触发一个TCP RST。貌似GFW把两次探测认为是一个连接了。发现收件人在黑名单中，立即重置TCP链接from scapy.all import * send(IP(dst='1.2.3.4', ttl=9) / TCP(dport=25, flags='S', seq=0)) send(IP(dst='1.2.3.4', ttl=9) / TCP(dport=25, flags='A', seq=1)) send(IP(dst='1.2.3.4', ttl=9) / TCP(dport=25, flags='A', seq=1) / 'RCPT TO: xiazai@upup.info\r\n') 这与上面的MAIL FROM的例子基本上是一样的。不同之处只有一点就是有的时候可以看到两个TCP RST的回包。发现收件人在黑名单中，发回用户不存在的错误消息from scapy.all import * send(IP(dst='1.2.3.4', ttl=9) / TCP(dport=25, flags='S', seq=0)) send(IP(dst='1.2.3.4', ttl=9) / TCP(dport=25, flags='A', seq=1) / 'EHLO anything-here\nRCPT TO: xiazai@upup.info\n') 得到的错误消息是 “551 User not local; please try \r\n”。有的时候还会伴随有数个连续的TCP RST。同样因为包根本没有到对方的服务器，而且这个服务器压根就不存在，所以这个用户不存在的错误消息只能是GFW做出的响应。触发的条件是目标端口必须是25第二个包的第一个命令必须是EHLO或者HELO，内容没有关系第二个包的第二个命令必须是RCPT TO，而且邮件地址要在黑名单中。\r\n没有关系，\n也是可以触发的这次触发的条件是一个合法的SMTP请求过程。而之前的触发过程根本就不是合法的SMTP请求。而且另外一个特征是这样触发的TCP RST，会有三个重叠，ack递加的现象，与HTTP全文关键字的响应非常类似。我推测，两型的响应是两个不同的模块。单独对MAIL FROM和RCPT TO的封锁，与对HTTP关键字的封锁类似，属于看到就封型。而后一种更智能的，还会回答错误消息的是能够真正理解SMTP协议的模块，可能还用于做邮件全文内容的关键字检测。属于深包中的深包检测了
poetry如何更换国内源（清华源/阿里源/豆瓣源）
有两种换源方式，全局源或虚拟环境源，区别在于:全局源设置好一劳永逸，后续建立新的虚拟环境不需要重新配置。虚拟环境源可以在不同的虚拟环境中使用不同的源，更加灵活，但一般情况下用不到，大多用于测试PyPI测试平台上的源全局源如果你希望只用设置一次镜像源，后续在创建新项目时不需要重新配置镜像，可以如此操作推荐使用poetry-plugin-pypi-mirror 插件，可以方便地给poetry换源第一步：添加poetry插件poetry-plugin-pypi-mirrorpipx 方案如果你使用的是poetry官方推荐的pipx方案安装的poetry，可以利用pipx安装此插件：pipx inject poetry poetry-plugin-pypi-mirror 注意，如果pipx注入插件安装失败，是因为pipx也需要使用pip进行安装，可以在这条命令后添加如下命令以使用镜像安装插件--pip-args="--index-url https://pypi.tuna.tsinghua.edu.cn/simple" poetry 插件方案如果你使用的是pip安装的poetry，或直接使用官方提供的shell脚本（powershell/bash/zsh/fish）在系统中安装的poetry，可以通过poetry的self add添加插件poetry self add poetry-plugin-pypi-mirror 这样就将poetry全局源插件安装上了第二步：修改插件的默认源假设我们现在需要添加腾讯的镜像源到poetry：https://mirrors.cloud.tencent.com/pypi/simple/方法一：添加配置文件（⭐推荐）新建或修改配置文件 config.toml，根据不同的系统，在不同的目录下创建poetry-plugin-pypi-mirror插件的配置文件Windows系统:%APPDATA%\pypoetry\config.tomlLinux系统~/.config/pypoetry/config.tomlMac系统(2025/04/07 修复了路径的错误):~/Library/Application\ Support/pypoetry/config.toml[plugins] [plugins.pypi_mirror] url = "https://mirrors.cloud.tencent.com/pypi/simple/" 方法二：添加系统环境变量（修改不如上种方式方便）Linux系统环境变量在终端中执行以下命令，可以对当前终端会话生效，但重启终端或系统后失效export POETRY_PYPI_MIRROR_URL=https://mirrors.cloud.tencent.com/pypi/simple/或是添加系统级环境变量，终端在每次打开时会自动加载环境变量：vim /etc/profile POETRY_PYPI_MIRROR_URL=https://mirrors.cloud.tencent.com/pypi/simple/ export POETRY_PYPI_MIRROR_URL source /etc/profile Windows系统环境变量此电脑->右键属性->高级系统设置->高级->环境变量添加变量名POETRY_PYPI_MIRROR_URL，值为https://mirrors.cloud.tencent.com/pypi/simple/虚拟环境源如果你想让源仅在单个的虚拟环境中生效，可以如此操作设置虚拟环境源不需要使用插件，官方的设置文档有详细操作：https://python-poetry.org/docs/repositories/在项目中指定备用源编辑项目根目录下的 pyproject.toml 文件，增加或修改 [tool.poetry.source] 部分，例如使用清华大学的镜像：[tool.poetry.source] name = "pypi-tuna" url = "https://pypi.tuna.tsinghua.edu.cn/simple" priority = "primary"这里设置 priority = "primary" 即表示该源为首选源，覆盖默认的pypi源使用命令行添加或切换源你可以使用 poetry source 命令添加新的源，然后安装依赖时指定使用该源。例如：poetry source add pypi-tuna https://pypi.tuna.tsinghua.edu.cn/simple poetry install --source pypi-tuna完成检验修改完成后，切换回项目的poetry环境，执行poetry install 或 poetry update 查看是否成功