作者 | 百度APP技術平台
導讀
introduction
在移動網際網路快速發展的背景下,保護Android應用程式的安全性和知識産權變得尤為重要。為了防止惡意攻擊和未授權通路,通常采用對dex檔案進行代碼加強來保護應用程式。随着Android加強技術經過動态加載、不落地加載、指令抽取、java2cpp、VMP等技術不斷演進和改進,VMP加強技術成為一種高安全性解決方案。是以,本文将着重介紹一種實作和落地VMP技術的思路,以幫助大家了解其工作原理和應用場景。
全文8359字,預計閱讀時間21分鐘。
GEEK TALK
01
問題背景
在移動網際網路快速發展的背景下,Android 作為全球最受歡迎的移動作業系統,吸引了大量開發者和使用者。随着應用市場的競争加劇,保護應用程式的安全性和知識産權變得越來越重要。
同時,随着公司業務的發展,百度與外部友商深度合作,需要對外輸出了百度業務能力SDK。在這種背景下,對Android代碼進行加強成為了一種必要的安全措施。加強可以提高應用程式的安全性,保護知識産權,防止逆向工程和破解。
GEEK TALK
02
問題分析
Android 應用程式是由 Java/Kotlin 語言編寫而成,然後打包成 APK 檔案。Java 代碼被編譯成 APK/AAR 中的 dex 檔案,dalvik/art 虛拟機解釋執行 dex 中的位元組碼。攻擊者可以使用反編譯工具很容易的逆向分析 dex 檔案,了解代碼關鍵邏輯,增加惡意代碼,再打包回 APK 檔案。
可以看到,dex 檔案就是代碼加強的保護核心!
GEEK TALK
03
加強調研
為了解決對 dex檔案的代碼加強,我們進行了相關技術調研,其實在Android代碼安全領域,相關技術一直屬于不斷攻防演進的過程。如下是業界常用的加強技術方案:比如最初的360加強給APK加殼,通過不落地動态加載實作加強;市場上常用的類方法抽取指令加強;以及将java方法轉native方法jni調用等。
3.1 DexClassLoader 動态加載機制
利用 Android 系統的 DexClassLoader 動态加載機制,通過将保護的 dex 檔案解壓解密後,動态加載到記憶體中執行。
這種方式有效地抵禦了 APK 檔案的靜态分析,使得逆向分析者無法在 APK 檔案中找到真實的 dex 檔案。但是由于動态加載技術主要依賴于java的動态加載機制,是以要求關鍵邏輯部分必須進行解壓,并且釋放到檔案系統。
這種動态加載技術不足之處在于:1.這一解壓釋放機制就給攻擊者留下直接擷取對應檔案的機會; 2.可以通過hook虛拟機關鍵函數,進行dump出原始的dex檔案資料。
3.2 Hook 技術
針對 DexClassLoader 動态加載機制的保護缺陷,采用 Hook 技術來解決問題。
在動态加載過程中,通過替換 DexClassLoader 執行過程中的 dex 記憶體,将其替換為真實 dex 檔案的記憶體,進而實作了無需将 dex 落地的加載方式。
然而,dex 檔案雖然不會解密并儲存到檔案系統,但它在記憶體中是完整存在的。是以,在應用程式運作後,逆向分析者可以通過記憶體搜尋的方式将 dex 檔案轉儲出來。
3.3 指令抽取
為了對抗逆向開發通過記憶體搜尋的方式将 dex 檔案轉儲出來,加強技術采用了函數抽取的方法,使得 dex 檔案在記憶體中一直處于不完整的狀态。
其實作思路大緻如下:
1、對要保護的 dex 檔案進行預處理,将需要保護的函數指令抽取出來并進行加密存儲,同時在原位置填充 nop 指令。
2、當 dalvik/art 執行到抽取的函數時,利用 hook 技術攔截 libdalvik.so/libart.so 中的指令讀取部分,将函數對應的真實指令解密并填充,使得 dalvik/art 能夠繼續解釋執行。
随着逆向技術的不斷發展,改造 dalvik 并周遊所有 dex 方法,以及記憶體重組 dex,成為了對抗此種加強保護的有效方法。其中,dexhunter 是該領域的主要代表之一。
3.4 java2cpp 技術
随着記憶體脫殼機的出現,指令抽取的保護方式逐漸失去有效性。為了應對這一問題,java2cpp 技術開始被引入到加強保護中。
核心是對 dex 中的函數進行處理,将函數中的 dalvik 指令轉換成等效的 cpp 代碼(基于 JNI),然後編譯成本地的動态連結庫(native so 庫),并将保護的方法标記為 native 屬性。這樣,在執行到受保護的方法時,執行流會轉移到本地層執行對應的 cpp 代碼。
比如原函數:
public class HelloVMP2 {
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;
}
}
轉換後:
public class HelloVMP2 {
static {
System.loadLibrary("hello_vmp2");
}
public native int compute(int a, int b);
}
extern "C" JNIEXPORT jint JNICALL
Java_com_vmp_mylibrary_HelloVMP2_compute(JNIEnv* env, jobject obj, jint a, jint b) {
jint c = a + a;
jint d = a * b;
jint e = a - b;
jint f = a / b;
jint result = c + d + e + f;
return result;
}
這種方式下,僅将 java 轉 cpp 編譯成動态連結庫,但是so代碼依然可以被破解,在此基礎上其實還是可以繼續提高代碼保護的安全性,那就是 DEX-VMP 技術。
3.5 DEX-VMP
DEX-VMP 原理了解起來比較容易,其針對的保護機關也是函數。将方法的 dalvik 指令轉換成等價的自定義指令,函數原指令替換成自定義 VM 的調用入口指令,再将函數參數通過 VMP 入口傳入到自定義 VM 中執行,自定義 VM 解釋執行自定義指令。
如圖,當 Dalvik VM 執行到 DEX-VMP 保護的函數時,執行的是 VMP native 入口函數,開始進入 VMP 的執行流程,VMP 首先會初始化 dex 檔案資訊,接着擷取該保護方法的一些資訊,比如寄存器數量,待執行指令的記憶體位置等,然後初始化寄存器存儲結構,最後進入到解釋器中解釋執行每一條指令。在解釋執行的過程,如果執行到外部函數,就會使用 JNI CallMethod 的形式調用,讓其切換回 Dalvik VM,讓 Dalvik 去執行真正的函數。
加強過程原函數的代碼邏輯替換為 native 方法,同時對 Custom VM 進行初始化,原函數 native 方法負責将參數傳入到 Custom VM 中,Custom VM 解釋執行原代碼的等價指令。
實作 DEX-VMP 總體來說需要兩步:
1、對原 dex 處理,找到要保護的方法,将原指令翻譯成等價指令,加密存儲,并将原指令替換為 VMP 入口指令
2、實作 VM,解釋執行存儲的等價指令
3.6 加強方案對比
可以看到,加強技術是不斷攻防更新的過程,下面我們将以上加強技術分為五代進行對比:
由以上對比我們可以看出,在加強技術演進過程中,VMP方案是發展到目前,加強安全度最高的方式,本着安全性角度出發,我們選擇VMP方案重點介紹與分析,以下是對于項目中VMP加強的分析過程。
GEEK TALK
04
DEX-VMP加強落地實作
以下是我們要保護的一段示例代碼:
package com.vmp.mylibrary;
public class HelleVMP3 {
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;
}
}
4.1 dex 檔案預處理
dex 預處理主要做兩方面工作:
1、保護方法的原指令拷貝出來并存儲
2、保護方法的原指令替換成 VMP 入口方法
将要保護的 java 代碼編譯成 dex 檔案,放入 010editor 中可以檢視 compute 方法對應的指令資料:
可以看到藍色區域包含的方法所需要的寄存器數,内部參數,外部參數及指令長度。這些都是 VM 需要的關鍵資訊,需要存儲起來。然後将指令替換為 DEX-VMP 的 native 入口指令。
有一些工具可以幫我們實作以上操作,比如 dexlib2,使用該工具可以對指定方法構造 dalvik 指令,或擷取方法的指令資料。該工具的具體使用方法大家可以自定搜尋。
4.2 寄存器結構設計
通過dexdump 指令檢視,原方法二進制結構内容如下:
Virtual methods -
#0 : (in Lcom/vmp/mylibrary/HelloVMP3;)
name : 'compute'
registers : 6
ins : 3
outs : 0
insns size : 11 16-bit code units
28e588: |[28e588] com.vmp.mylibrary.HelloVMP3.compute:(II)I
28e598: 9000 0404 |0000: add-int v0, v4, v4
28e59c: 9201 0405 |0002: mul-int v1, v4, v5
28e5a0: 9102 0405 |0004: sub-int v2, v4, v5
28e5a4: b354 |0006: div-int/2addr v4, v5
28e5a6: b010 |0007: add-int/2addr v0, v1
28e5a8: b020 |0008: add-int/2addr v0, v2
28e5aa: b040 |0009: add-int/2addr v0, v4
28e5ac: 0f00 |000a: return v0
從示例 compute 方法的一些 hex 資料中,可以得到一些關鍵資訊:
compute 方法在執行過程中需要使用到 6 個寄存器,傳入參數 3 個, 沒有使用 try 結構,指令資料為 16 個字。
Dalvik 寄存器最大長度為 32bit,我們可以直接申請一段記憶體來表示寄存器:
regptr_t regs[6];
regs[0] = 0;
regs[1] = 0;
regs[2] = 0;
regs[3] = 0;
regs[4] = 0;
regs[5] = 0;
regs[3] = (regptr_t) thiz;
regs[4] = p1;
regs[5] = p2;
u1 reg_flags[6];
reg_flags[0] = 0;
reg_flags[1] = 0;
reg_flags[2] = 0;
reg_flags[3] = 0;
reg_flags[4] = 0;
reg_flags[5] = 0;
reg_flags[3] = 1;
regs 表示寄存器,4 個寄存器分别為 regs [0], regs [1], regs [2], regs [3]。regs_bits_obj 表示對應寄存器是否是 Object,比如 regs [3] 是 Object,則 regs_bits_obj [3] = 1,非 object 的情況均為 0;
每一個保護方法在進入 VM 後,我們就像示例這樣建立好這樣的寄存器單元,供 VM 在解釋執行階段使用,執行完畢銷毀即可。
注意這個過程的專業的加強工具會在 dex 預處理過程中識别二進制結構内容進行執行,無需每保護一個方法單獨開發。
4.3 虛拟機實作
我們就以示例 compute 方法中的 add-int, mul-int, sub-int, div-int 這幾條指令來實作一個簡易的解釋器
介紹一下這幾條指令的作用:add-int、mul-int、sub-int、div-int 對兩個源寄存器執行已确定的二進制運算,并将結果存儲到目标寄存器中。
首先定義自定義虛拟機需要執行的vmCode結構:
typedef struct {
const u2 *insns; // 指令
const u4 insnsSize; // 指令大小
regptr_t *regs; // 寄存器
u1 *reg_flags; // 寄存器資料類型标記,主要标記是否為對象
const u1 *triesHandlers; // 異常表
} vmCode;
自定義Opcode:
enum Opcode {
OP_ADD_INT = 0x3a,
OP_MUL_INT = 0xe4,
OP_SUB_INT = 0x77,
OP_DIV_INT_2ADDR = 0x6c,
OP_ADD_INT_2ADDR = 0xcf,
OP_RETURN = 0xde,
};
目标方法轉化的 native 方法:
static jint Java_com_vmp_mylibrary_HelloVMP3_compute__II_I(JNIEnv *env, jobject thiz , jint p1, jint p2) {
regptr_t regs[6];
regs[0] = 0;
regs[1] = 0;
regs[2] = 0;
regs[3] = 0;
regs[4] = 0;
regs[5] = 0;
regs[3] = (regptr_t) thiz;
regs[4] = p1;
regs[5] = p2;
u1 reg_flags[6];
reg_flags[0] = 0;
reg_flags[1] = 0;
reg_flags[2] = 0;
reg_flags[3] = 0;
reg_flags[4] = 0;
reg_flags[5] = 0;
reg_flags[3] = 1;
static const u2 insns[] = {
0x00b3, 0x0404, 0x0120, 0x0504, 0x02ee, 0x0504, 0x546c, 0x10a9, 0x20a9, 0x40a9,
0x00ad,
};
const u1 *tries = NULL;
const vmCode code = {
.insns=insns,
.insnsSize=11,
.regs=regs,
.reg_flags=reg_flags,
.triesHandlers=tries
};
jvalue value = vmInterpret(env,
&code,
&dvmResolver);
return value.i;
}
執行指令處理邏輯:
#define OP_END
#define INST_AA(_inst) ((_inst) >> 8)
#define FETCH(_offset) (pc[(_offset)])
#define SET_REGISTER(_idx, _val) \
DELETE_LOCAL_REF(_idx); \
(fp[(_idx)] =(u4) (_val)); \
SET_REGISTER_FLAGS(_idx, 0)
#define HANDLE_OP_X_INT(_opcode, _opname, _op, _chkdiv)
HANDLE_OPCODE(_opcode /*vAA, vBB, vCC*/)
{
u2 srcRegs;
vdst = INST_AA(inst);
srcRegs = FETCH(1);
vsrc1 = srcRegs & 0xff;
vsrc2 = srcRegs >> 8;
ILOGV("|%s-int v%d,v%d", (_opname), vdst, vsrc1);
......
}
FINISH(2);
#define HANDLE_OP_X_INT(_opcode, _opname, _op, _chkdiv) \
HANDLE_OPCODE(_opcode /*vAA, vBB, vCC*/) \
{ \
u2 srcRegs; \
vdst = INST_AA(inst); \
srcRegs = FETCH(1); \
vsrc1 = srcRegs & 0xff; \
vsrc2 = srcRegs >> 8; \
ILOGV("|%s-int v%d,v%d", (_opname), vdst, vsrc1); \
if (_chkdiv != 0) { \
s4 firstVal, secondVal, result; \
firstVal = GET_REGISTER(vsrc1); \
secondVal = GET_REGISTER(vsrc2); \
if (secondVal == 0) { \
dvmThrowArithmeticException(env,"divide by zero"); \
GOTO_exceptionThrown(); \
} \
if ((u4)firstVal == 0x80000000 && secondVal == -1) { \
if (_chkdiv == 1) \
result = firstVal; /* division */ \
else \
result = 0; /* remainder */ \
} else { \
result = firstVal _op secondVal; \
} \
SET_REGISTER(vdst, result); \
} else { \
/* non-div/rem case */ \
SET_REGISTER(vdst, (s4) GET_REGISTER(vsrc1) _op (s4) GET_REGISTER(vsrc2)); \
} \
} \
FINISH(2);
__attribute__((visibility("default")))
jvalue vmInterpret(JNIEnv *env, const vmCode *code, const vmResolver *dvmResolver) {
jvalue args_tmp[5]; // 方法調用時參數傳遞(參數數量小于等于5)
jvalue retval;
regptr_t *fp = code->regs; // 寄存器
u1 *fp_flags = code->reg_flags; // 寄存器類型辨別
const u2 *pc = code->insns;
......
/* File: c/OP_ADD_INT.cpp */
HANDLE_OP_X_INT(OP_ADD_INT, "add", +, 0)
OP_END
/* File: c/OP_SUB_INT.cpp */
HANDLE_OP_X_INT(OP_SUB_INT, "sub", -, 0)
OP_END
/* File: c/OP_MUL_INT.cpp */
HANDLE_OP_X_INT(OP_MUL_INT, "mul", *, 0)
OP_END
/* File: c/OP_DIV_INT.cpp */
HANDLE_OP_X_INT(OP_DIV_INT, "div", /, 1)
OP_END
/* File: c/OP_REM_INT.cpp */
HANDLE_OP_X_INT(OP_REM_INT, "rem", %, 2)
OP_END
end:
return 0;
}
上面是一個解析自定義 opcode 的解釋器,大家可以從其中看到解釋器就是 while switch 的程式結構,執行到 return 指令時退出循環。
4.4 總結
通過以上實作,可以發現虛拟機加強核心自定義一套opcode用于對保護方法的指令替換,同時還需要對替換後的指令識别後,如果對Java函數的調用交給DVM進行處理,如果是原函數指令則建立寄存器交給機器處理。整個加強過程中分為編譯器+解釋器兩部分。
其中編譯器負責對打包的AAR或者APK進行加強,加強過程則是将要保護的方法轉換為JNI調用,同時C++部分根據原方法指令生成需要的寄存器與opcode;而解釋器則是在運作過程,當執行到JNI調用時,能夠對建立的opcode進行識别,轉化原指令與寄存器交由真正的DVM進行執行。
GEEK TALK
05
相容與性能
5.1 相容性風險
相容風險:
- 加強方案主要的相容問題在于無法脫離JNI實作,而 VM 中 JNI 實作細節不盡相同。比如 Android 5.0 某個小版本中 JNI 實作會存在一個隐含的 jobject(local reference)忘記 delete 掉,當多次調用該 JNI 函數時,記憶體溢出不可避免。這個BUG 在之後的 Android 版本中更正過來,也就是說每個 Android 版本出來之後,我們都要看看 VMP 會不會存在 JNI 相容性方面的 BUG。
規避建議:
- 每個Android 版本更新需要重點關注JNI實作的變化,是否存在 JNI 相容性方面問題。
5.2 性能問題
産生性能消耗的主要有兩點:
- JNI 調用
- DEX-VMP 與 系統 VM 的切換
優化建議:
- JNI 調用是性能消耗主要因素。對于一些常用的 java class,可以在初始化時統一擷取 jclass 緩存起來,這可以一定程度上提高性能,類似的還有避免重複查找 class。
- 盡量避免全量代碼保護(dex 中所有的方法都 DEX-VMP 保護,包含 Android SDK 的基礎類庫),排除Android基礎類庫和開源類庫,僅将業務自己的核心邏輯代碼方法進行保護。
GEEK TALK
06
結語
總結來說,虛拟機加強是一種可以提高應用程式安全性的技術,但它也帶來了性能、相容性和維護成本等方面的挑戰。
我們在使用代碼虛拟化時,需要根據應用程式的特點和安全需求,合理選擇和優化虛拟化方案。
作者:百度APP技術平台
來源:微信公衆号:百度Geek說
出處:https://mp.weixin.qq.com/s/42WJQwQ-eccRUJauo0b7fA