天天看點

Android SDK安全加強問題與分析

作者:閃念基因

作者 | 百度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 SDK安全加強問題與分析

利用 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 解釋執行自定義指令。

Android SDK安全加強問題與分析

如圖,當 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 加強方案對比

可以看到,加強技術是不斷攻防更新的過程,下面我們将以上加強技術分為五代進行對比:

Android SDK安全加強問題與分析

由以上對比我們可以看出,在加強技術演進過程中,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 方法對應的指令資料:

Android SDK安全加強問題與分析

可以看到藍色區域包含的方法所需要的寄存器數,内部參數,外部參數及指令長度。這些都是 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

繼續閱讀