确定字符串解密函数
进入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,执行结束后返回result
do
{
v8 = *v5++;
--v7;
*v6++ = v8 ^ a3;
a3 += 3;
}
while ( v7 );
return result;
因此a3为密钥,a2为密文
NEON并行运算算法还原
逻辑代码寻找
跳出if,可以看到一堆难以理解的声明条件,并发现有很多v*_s8
系列函数,这实际上是arm64系列处理器的 NEON
并行计算指令,类似x86的 SIMD
v9 = 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, #0x60
MOVI
: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, W2
MOVI
指令涉及将一个立即数复制到向量寄存器的所有元素,由于高级语言中对常量的一些操作会被编译器在编译阶段优化,常量折叠以及常量传播都是编译器优化技术,会在编译时期将常量数值计算出来,使一些操作优化为向量操作。
为了确保反编译的结果正确,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位的处理方式下,需要按照每个字符,在每轮将密钥+3
a3 += 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位,需要将每个向量元素的相加结果异或 0xFF
def vaddq_s8(self, v1: list[int], v2: list[int]) -> list[int]:
"""NEON加法"""
return [(v1[i] + v2[i]) & 0xFF for i in range(16)]