為了保護代碼、干擾靜態分析,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; }