android逆向奇技淫巧十二:dexVMP解释器原理及简易模拟实现
为了保护代码、干扰静态分析,android客户端可以通过OLLVM干扰整个so层代码执行的控制流,但不会改变函数调用的关系,所以抓住这点并不难破解OLLVM;另一个大家耳熟能详的代码保护方式就是VMP了!我之前介绍了windos下VMP代码混淆的原理,其实在android下也类似:对原来的smail代码做替换,然后用自己的解释器“执行”替换后的代码,实现的功能和原smail代码一样!但由于使用了VMP自己的代码,并不是原生标准的smail代码,jadx、jeb、GDA等反编译工具大概率是会失效的,逆向人员静态分析时大概率是看不懂的!
VMP的全称是虚拟机保护。从名字看,是通过虚拟机保护代码的;在详细阐述原理之前,先回顾一下与之对应的“物理机”的概念!
物理CPU通过数据总线,从内存读取指令,再解析和执行(由此诞生了三级流水线的概念:读取、解析、执行)!本文的重点就是解析了:指令的格式都是opcode操作码和操作数组成的!解析指令时先读取操作码,根据不同的操作码确定指令的类型(加减乘除、mov等);然后再解析操作数,最后执行指令;以上所有的步骤都是CPU硬件完成的,不需要程序员编写软件去干预(所以才叫物理机的嘛)!这就是物理CPU执行代码的基本流程和逻辑!虚拟机在执行指令的流程和原理上与物理CPU没本质区别,完全一样!那么虚拟机的读取、解析和执行都是怎么模拟的了?
只要是码农,肯定都知道java、python、php等语言,这些语言的执行都是由各自对应的虚拟机解释执行的;还有逆向人员很熟悉的unicorn、unidbg、bochs等虚拟机,原理也都是这样的!既然市面上已经有这么多款虚拟机了,说明这种“虚拟化技术”已经非常成熟;为了一探究竟,这里以android下的art为例(毕竟是开源的嘛),说明这类虚拟化的执行原理!
1、先用GDA打开一个apk,随便选个函数,选择“show ByteCode”选项,这事能看到smail的字节码了,如下:
art虚拟机会挨个读取这些字节码,然后自己去解析和执行;这里以8.0版本的art为例,在 http://androidxref.com/8.0.0_r4/xref/art/runtime/interpreter/interpreter_switch_impl.cc 这里能看到interpreter_switch_impl文件的源码,里面有个非常长的do while循环,部分代码截取如下(代码太长了,没法全部复制,感兴趣的小伙伴建议自行打开链接查看):
do { 125 dex_pc = inst->GetDexPc(insns); 126 shadow_frame.SetDexPC(dex_pc); 127 TraceExecution(shadow_frame, inst, dex_pc); 128 inst_data = inst->Fetch16(0); 129 switch (inst->Opcode(inst_data)) { 130 case Instruction::NOP: 131 PREAMBLE(); 132 inst = inst->Next_1xx(); 133 break; 134 case Instruction::MOVE: 135 PREAMBLE(); 136 shadow_frame.SetVReg(inst->VRegA_12x(inst_data), 137 shadow_frame.GetVReg(inst->VRegB_12x(inst_data))); 138 inst = inst->Next_1xx(); 139 break; 140 case Instruction::MOVE_FROM16: 141 PREAMBLE(); 142 shadow_frame.SetVReg(inst->VRegA_22x(inst_data), 143 shadow_frame.GetVReg(inst->VRegB_22x())); 144 inst = inst->Next_2xx(); 145 break; 146 case Instruction::MOVE_16: 147 PREAMBLE(); 148 shadow_frame.SetVReg(inst->VRegA_32x(), 149 shadow_frame.GetVReg(inst->VRegB_32x())); 150 inst = inst->Next_3xx(); 151 break; 152 case Instruction::MOVE_WIDE: 153 PREAMBLE(); 154 shadow_frame.SetVRegLong(inst->VRegA_12x(inst_data), 155 shadow_frame.GetVRegLong(inst->VRegB_12x(inst_data))); 156 inst = inst->Next_1xx(); 157 break;
这里说明一下:不同版本的art有不同的解释smail代码的方式,除了这种switch case形式,还有汇编、GotoTale等;因为swtich case是所有版本公用的形式,所以这里以swtich case举例;
大伙看到switch的条件了么?就是opcode,也就是操作码;case分支就是根据不同的操作码做不同的操作;比如第一个操作码如果是NOP,那么指令直接向前加1,其他啥事也不干!第二个case的操作码是MOVE指令,这时就要进一步解析操作数了,取出原寄存器、目标寄存器,然后把目标寄存器的值设置程原寄存器的值,最后照例把指令直接向前加1;由于opcode是8位的,一共有255总情况,所以case的分支也是有很多的(暂时没用完);把libart.so用IDA打开反编译,看到的就是如下效果:有点像OLLVM的控制流平坦化混淆;
以上就是art解释器的实现方式之一。怎么样,都看懂了吧,原理其实并不复杂,不就是个swtich case嘛,自己都能动手做个简单的!
2、自己写个简单测试代码,如下:
public int add(int a, int b) { return a + b; } public int sub(int a, int b) { return a - b; } public int mul(int a, int b) { return a * b; } public int div(int a, int b) { return a / b; } public int compute(int a,int b){ int c=a*a; int d=a*b; int e=a-b; int f=a/b; int result=c+d+e+f; return result; }
最重要的就是最后的compute函数,比如很多时候网络通信的sign签名字段,就需要通过类似compute函数对传的参数做签名。目的地收到后用同样的算法对参数做计算,看看两个sign字段的值是不是一样的。如果不是,说明参数被篡改了! 这里为了防止compute函数被篡改,可以先把compute编译后的smail字节码抽取藏起来,再通过自己的解释器执行,达到用android原生art解释器一样的效果!上述代码编译后,看到的smail如下:
这里只展示了smail代码部分的字节码,还有部分codeItem字节码没展示,完整的字节码如下:注意看注释,每个字段的含义都解释清楚了;
const unsigned char Compute[] = { 0x08, 0x00, //寄存器使用的个数 0x03, 0x00, //参数个数 0x00, 0x00, //调用其他方法时使用寄存器的个数 0x00, 0x00, //try catch个数 0x6e, 0x77, 0x14,0x00, //指令调试信息偏移 0x0d, 0x00, 0x00, 0x00, //指令集个数,以2字节为单位;这里是d,那么指令总长度是13*2=26个字节,可以用这个确认函数结尾 0x92, 0x00, 0x06, 0x06, //指令开始了;具体指令看上面的截图 0x92, 0x01, 0x06, 0x07, 0x91, 0x02, 0x06, 0x07, 0x93, 0x03, 0x06, 0x07, 0x90, 0x04, 0x00, 0x01, 0xb0, 0x24, 0xb0, 0x34, 0x0f, 0x04};
现在的问题就简单了,我们自己实现的解释器核心两个功能:
- 解析上面的字节码,按照codeItem的格式读取每个字段
- 读取指令后模拟执行指令,得到正确的结果!
(1)为了高效和安全,解释器一般都是在so里面的,所以需要用C来实现。第一步,先从字节码读取需要用到的寄存器数量,分配对等长度的内存空间来模拟寄存器;
注意:这里有个很重要的结构体codeItem,描述了smail中每个函数的“元数据”,其成员见末尾代码;在指令抽取加壳、dex2oat时都要用到,建议牢记!
CodeItem *codeItem = (CodeItem *) Compute; int registersize = codeItem->registers_size_; int result = 0; int *VREG = reinterpret_cast<int *>(malloc(sizeof(int) * registersize));
(2)读取参数的个数,用寄存器总数减去参数个数,就是函数本身局部变量能使用的寄存器个数:
int insNum = codeItem->ins_size_;//参数的个数 int startIndex = registersize - insNum;//总的寄存器数量减去参数个数,剩下的才是解释器能自由使用的寄存器个数 VREG[startIndex] = 0; VREG[++startIndex] = a; VREG[++startIndex] = b;
(3)找到指令开始的地址
unsigned long address = (unsigned long) Compute; unsigned char *opOffset = reinterpret_cast<unsigned char *>(address + 16);//指令集的地址
(4)最核心的开始了:读取指令,解析出opcode,根据不同的opcode走不同的case处理分支,如下:
while (true) { unsigned char op = *opOffset; switch (op) { case 0x90: {//90040001 |0008: add-int v4, v0, v1 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] + VREG[arg1]; opOffset = opOffset + 4; break; } case 0x91: {//91020607 |0004: sub-int v2, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] - VREG[arg1]; opOffset = opOffset + 4; break; } case 0x92: {//92010607 |0002: mul-int v1, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] * VREG[arg1]; opOffset = opOffset + 4; break; } case 0x93: {//93030607 |0006: div-int v3, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] / VREG[arg1]; opOffset = opOffset + 4; break; } case 0xb0: {//b024 |000a: add-int/2addr v4, v2 unsigned char des = *(opOffset + 1); int arg0 = des & 0x0F; int arg1 = des >> 4; VREG[arg0] = VREG[arg0] + VREG[arg1]; opOffset = opOffset + 2; break; } case 0x0f: {//123cf4: 0f04 |000c: return v4*/ unsigned char des = *(opOffset + 1); return VREG[des]; } } }
从上面的case分支看,这个函数所有的指令对应的处理方法都覆盖到了;每次处理万,结果都存放在了寄存器,实现原理和art虚拟机一摸一样!至此,虚拟机本身的核心功能就完成了;这样就能直接当成VMP来用了? 哈哈,如果这样认为,就图样、图森破了!VMP的本意是保护代码,增加静态分析的成本。然而截至目前,所有的工作都是围绕解释器执行smail代码完成的,而smail代码本身和编译出来的一模一样,没任何变化!就算人为抽取出来,这段代码以数组形式(本质就是连续内存)被执行,也容易在静态分析的时候被发现,和果奔没本质区别!这里又该怎么做了? VMP最重要的功能之二:代码映射表!
(5)映射的原理很简单,相当于把原smail代码加密,改成一套虚拟机自己能认识的代码。比如上面的0x90,就是两个数相加。为了迷惑反编译器和逆向人员,可以把0x90换成其他的字节,比如0x20,这样一来GDA、jadx、jeb等反编译器要么不认识,要么反编译出错,逆向人员肯定看不懂啥意思,干扰静态分析的目的就达到了!为了简化说明原理,这里统一让原加减乘除的opcode减去0x70,这样一来新的smail代码如下:
const unsigned char Compute[] = {0x08, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6e, 0x77, 0x14,0x00, 0x0d, 0x00, 0x00, 0x00, 0x22, 0x00, 0x06, 0x06, 0x22, 0x01,0x06, 0x07, 0x21, 0x02, 0x06, 0x07, 0x23, 0x03, 0x06, 0x07, 0x20,0x04, 0x00, 0x01, 0xb0, 0x24, 0xb0, 0x34, 0x0f, 0x04};
感兴趣的小伙伴可以自行反编译试试,绝对和以前的smail代码面目全非!为了执行这些混淆后的代码,解释器也要相应调整:
case 0x20: {//90040001 |0008: add-int v4, v0, v1 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] + VREG[arg1]; opOffset = opOffset + 4; break; } case 0x21: {//91020607 |0004: sub-int v2, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] - VREG[arg1]; opOffset = opOffset + 4; break; } case 0x22: {//92010607 |0002: mul-int v1, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] * VREG[arg1]; opOffset = opOffset + 4; break; } case 0x23: {//93030607 |0006: div-int v3, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] / VREG[arg1]; opOffset = opOffset + 4; break; } case 0xb0: {//b024 |000a: add-int/2addr v4, v2 unsigned char des = *(opOffset + 1); int arg0 = des & 0x0F; int arg1 = des >> 4; VREG[arg0] = VREG[arg0] + VREG[arg1]; opOffset = opOffset + 2; break; } case 0x0f: {//123cf4: 0f04 |000c: return v4*/ unsigned char des = *(opOffset + 1); return VREG[des]; }
实际使用时,为了更加深层次地混淆,还可以混淆参数个数、寄存器的顺序、操作数等,只要自己执行的时候参考映射表还原就行了!由此也很容易理解破解VMP混淆的两个要点:
- 找到映射表
- 找到解释器
以上两点我们后续再分享;这里解释器的完整代码如下:
#include <jni.h> #include <string> // Raw code_item. struct CodeItem { uint16_t registers_size_; // the number of registers used by this code // (locals + parameters) uint16_t ins_size_; // the number of words of incoming arguments to the method // that this code is for uint16_t outs_size_; // the number of words of outgoing argument space required // by this code for method invocation uint16_t tries_size_; // the number of try_items for this instance. If non-zero, // then these appear as the tries array just after the // insns in this instance. uint32_t debug_info_off_; // file offset to debug info stream uint32_t insns_size_in_code_units_; // size of the insns array, in 2 byte code units uint16_t insns_[1]; // actual array of bytecode. }; /*123cdc: 92000606 |0000: mul-int v0, v6, v6 123ce0: 92010607 |0002: mul-int v1, v6, v7 123ce4: 91020607 |0004: sub-int v2, v6, v7 123ce8: 93030607 |0006: div-int v3, v6, v7 123cec: 90040001 |0008: add-int v4, v0, v1 123cf0: b024 |000a: add-int/2addr v4, v2 123cf2: b034 |000b: add-int/2addr v4, v3 123cf4: 0f04 |000c: return v4*/ //90 20 //91 21 //92 22 //93 23 const unsigned char Compute[] = {0x08, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6e, 0x77, 0x14,0x00, 0x0d, 0x00, 0x00, 0x00, 0x22, 0x00, 0x06, 0x06, 0x22, 0x01,0x06, 0x07, 0x21, 0x02, 0x06, 0x07, 0x23, 0x03, 0x06, 0x07, 0x20,0x04, 0x00, 0x01, 0xb0, 0x24, 0xb0, 0x34, 0x0f, 0x04}; /*const unsigned char Compute[] = { 0x08, 0x00, //寄存器使用的个数 0x03, 0x00, //参数个数 0x00, 0x00, //调用其他方法时使用寄存器的个数 0x00, 0x00, //try catch个数 0x6e, 0x77, 0x14,0x00, //指令调试信息偏移 0x0d, 0x00, 0x00, 0x00, //指令集个数,以2字节为单位;这里是d,那么指令总长度是13*2=26个字节,可以用这个确认函数结尾 0x92, 0x00, 0x06, 0x06, //指令开始了 0x92, 0x01, 0x06, 0x07, 0x91, 0x02, 0x06, 0x07, 0x93, 0x03, 0x06, 0x07, 0x90, 0x04, 0x00, 0x01, 0xb0, 0x24, 0xb0, 0x34, 0x0f, 0x04}; */ extern "C" JNIEXPORT jstring JNICALL Java_com_kanxue_vmpprotect_MainActivity_stringFromJNI( JNIEnv *env, jobject /* this */) { std::string hello = "Hello from C++"; return env->NewStringUTF(hello.c_str()); } int myinterpreter(JNIEnv *env, jobject obj, jint a, jint b) { /* .prologue .insnsSize 13 (16-bit) .registers 8 [ v0 v1 v2 v3 v4 v5 v6 v7 ]*/ CodeItem *codeItem = (CodeItem *) Compute; int registersize = codeItem->registers_size_; int result = 0; int *VREG = reinterpret_cast<int *>(malloc(sizeof(int) * registersize));//分配一块连续的内存模拟寄存器,这里也可以用数组实现 if (VREG != nullptr) { memset(VREG, 0, registersize * sizeof(int)); int insNum = codeItem->ins_size_;//参数的个数 int startIndex = registersize - insNum;//总的寄存器数量减去参数个数,剩下的才是解释器能自由使用的寄存器个数 VREG[startIndex] = 0; VREG[++startIndex] = a; VREG[++startIndex] = b; int pc = 0; unsigned long address = (unsigned long) Compute; unsigned char *opOffset = reinterpret_cast<unsigned char *>(address + 16);//指令集的地址 while (true) { unsigned char op = *opOffset; switch (op) { /*case 0x90: {//90040001 |0008: add-int v4, v0, v1 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] + VREG[arg1]; opOffset = opOffset + 4; break; } case 0x91: {//91020607 |0004: sub-int v2, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] - VREG[arg1]; opOffset = opOffset + 4; break; } case 0x92: {//92010607 |0002: mul-int v1, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] * VREG[arg1]; opOffset = opOffset + 4; break; } case 0x93: {//93030607 |0006: div-int v3, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] / VREG[arg1]; opOffset = opOffset + 4; break; }*/ case 0x20: {//90040001 |0008: add-int v4, v0, v1 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] + VREG[arg1]; opOffset = opOffset + 4; break; } case 0x21: {//91020607 |0004: sub-int v2, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] - VREG[arg1]; opOffset = opOffset + 4; break; } case 0x22: {//92010607 |0002: mul-int v1, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] * VREG[arg1]; opOffset = opOffset + 4; break; } case 0x23: {//93030607 |0006: div-int v3, v6, v7 unsigned char des = *(opOffset + 1); unsigned char arg0 = *(opOffset + 2); unsigned char arg1 = *(opOffset + 3); VREG[des] = VREG[arg0] / VREG[arg1]; opOffset = opOffset + 4; break; } case 0xb0: {//b024 |000a: add-int/2addr v4, v2 unsigned char des = *(opOffset + 1); int arg0 = des & 0x0F; int arg1 = des >> 4; VREG[arg0] = VREG[arg0] + VREG[arg1]; opOffset = opOffset + 2; break; } case 0x0f: {//123cf4: 0f04 |000c: return v4*/ unsigned char des = *(opOffset + 1); return VREG[des]; } } } } } extern "C" JNIEXPORT jint JNICALL Java_com_vmpprotect_Compute_compute(JNIEnv *env, jobject obj, jint a, jint b) { int result = myinterpreter(env, obj, a, b); return result; }
(6)在分析so源码时:cmp非常重要,因为在switch结构中需要根据不同的指令做不同的动作,所以cmp也可以成为逆向vmp的突破口之一!比如trace某app时遇到的这种:
(7)真实的art虚拟机:核心就是switch case结构解析从0x00~0xff的opcode(理论上最多支持255种opcode),不同的opcode对应不同的处理方法,整个switch case函数有2300+多行,代码在这:http://aospxref.com/android-8.1.0_r81/xref/art/runtime/interpreter/interpreter_switch_impl.cc,部分代码如下:
do { 173 dex_pc = inst->GetDexPc(insns); 174 shadow_frame.SetDexPC(dex_pc); 175 TraceExecution(shadow_frame, inst, dex_pc); 176 inst_data = inst->Fetch16(0); 177 switch (inst->Opcode(inst_data)) { 178 case Instruction::NOP: 179 PREAMBLE(); 180 inst = inst->Next_1xx(); 181 break; 182 case Instruction::MOVE: 183 PREAMBLE(); 184 shadow_frame.SetVReg(inst->VRegA_12x(inst_data), 185 shadow_frame.GetVReg(inst->VRegB_12x(inst_data))); 186 inst = inst->Next_1xx(); 187 break; 188 case Instruction::MOVE_FROM16: 189 PREAMBLE(); 190 shadow_frame.SetVReg(inst->VRegA_22x(inst_data), 191 shadow_frame.GetVReg(inst->VRegB_22x())); 192 inst = inst->Next_2xx(); 193 break; 194 case Instruction::MOVE_16: 195 PREAMBLE(); 196 shadow_frame.SetVReg(inst->VRegA_32x(), 197 shadow_frame.GetVReg(inst->VRegB_32x())); 198 inst = inst->Next_3xx(); 199 break; 200 case Instruction::MOVE_WIDE: 201 PREAMBLE(); 202 shadow_frame.SetVRegLong(inst->VRegA_12x(inst_data), 203 shadow_frame.GetVRegLong(inst->VRegB_12x(inst_data))); 204 inst = inst->Next_1xx(); 205 break;
看吧,就是一个死循环,不停的根据不同的opcode做不同的action;其实VMP和jvm这种解释器没本质区别,只不过运行的地方不同:vmp运行在客户端,解释器运行在操作系统!
(8) 除了大家耳熟能详的dexVMP,还有dex2C,就是分析smali语法,然后转成同等功效的C代码,最后编译成native代码,项目地址在:https://github.com/amimo/dcc ;由于每个java函数都要“翻译”成C函数,所以每个函数都没啥规律可言(vmp因为有解释器,函数的结构看起来很类似),所以dex2C隐蔽性更好!但是部分使用人员反馈dex2c会导致app卡顿,可能这也是其没有大规模铺开的根本原因!