Neon指令集并行计算加密算法逆向

默认分类 · 6 天前 · 132 人浏览

确定字符串解密函数

进入vmp初始化逻辑分支,找到一些初始化函数

image-20250107164424828

进入函数,能够找到一些形如下方内容的代码,几乎毫无可阅读性

image-20250107170159628

任意抽取一段代码进行分析

 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执行流程中,这些常量类型一般会存在rodatadatabss段,因此我们需要查看一下byte_5F35E&aF_2 的值

jmethodID GetMethodID(JNIEnv *env, jclass clazz,const char *name, const char *sig)

从data段查看 byte_5F35E&aF_2 的值,很明显字符串做了加密,ida无法正确读取

image-20250107164805737
查找交叉引用,寻找到所有调用字符串的函数,查看函数签名

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寄存器进行监听

image-20250107171427416

跳转到X0寄存器所指向的data的地址,能够看出来字符串被解密,可以据此判定为字符串解密函数

image-20250107171328775

查看函数逻辑

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
  • MOVIMOVI 代表 "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)]
Theme Jasmine by Kent Liao