ART 在 Android 安全攻防中的應用

語言: CN / TW / HK

在日常的 Android 應用安全分析中,經常會遇到一些對抗,比如目標應用加殼、混淆、加固,需要進行脫殼還原;又或者會有針對常用注入工具的檢測,比如 frida、Xposed 等,這時候也會想知道這些工具的核心原理以及是否自己可以實現。

其實這些問題的答案就在 Android 的 Java 虛擬機器實現中。可以是早期的 Dalvik 虛擬機器,也可以是最新的 ART 虛擬機器。從時代潮流來看,本文主要專注於 ART。不過,為了銘記歷史,也會對 Dalvik 虛擬機器做一個簡單的介紹。最後會通過分析 ART 的實現來對一些實際的應用場景進行討論。

注: 本文分析基於 AOSP android-12.0.0_r11

Java VM

我們知道,Java 是一門跨平臺的語言,系統實際執行的是 Java 位元組碼,由 Java 虛擬機器去解釋執行。如果讀者之前看過 如何破解一個Python虛擬機器殼並拿走12300元ETH 一文或者對 Python 虛擬機器有所瞭解的話就會知道,解釋執行的過程可以看做是一個迴圈,對每條指令進行解析,並針對指令的名稱通過巨大的 switch-case 分發到不同的分支中處理。其實 Java 虛擬機器也是類似的,但 JVM 對於效能做了很多優化,比如 JIT 執行時將位元組碼優化成對應平臺的二進位制程式碼,提高後續執行速度等。

Android 程式碼既然是用 Java 程式碼編寫的,那麼執行時應該也會有一個解析位元組碼的虛擬機器。和標準的 JVM 不同,Android 中實際會將 Java 程式碼編譯為 Dalvik 位元組碼,執行時解析的也是用自研的虛擬機器實現。之所以使用自研實現,也許一方面有商業版權的考慮,另一方面也確實是適應了移動端的的執行場景。Dalvik 指令基於暫存器,佔 1-2 位元組,Java 虛擬機器指令基於棧,每條指令只佔 1 位元組;因此 Dalvik 虛擬機器用空間換時間從而獲得比 Oracle JVM 更快的執行速度。

啟動

其實 Java 程式碼執行並不慢,但其啟動時間卻是一大瓶頸。如果每個 APP 執行都要啟動並初始化 Java 虛擬機器,那延時將是無法接受的。在 Android 12 應用啟動流程分析 一文中我們說到,APP 應用程序實際上是通過 zygote 程序 fork 出來的。這樣的好處是子程序繼承了父程序的程序空間,對於只讀部分可以直接使用,而資料段也可以通過 COW(Copy On Write) 進行延時對映。檢視 zygote 與其子程序的 /proc/self/maps 可以發現大部分系統庫的對映都是相同的,這就是 fork 所帶來的好處。

Android 使用者態啟動流程分析 中我們分析了 init、zygote 和 system_server 的啟動流程,其中在介紹 zygote 的啟動流程時說到這是個 native 程式,在其中 main 函式的結尾有這麼一段程式碼:

int main(int argc, char* const argv[]) {
    // ...
    if (zygote) {
        runtime.start("com.android.internal.os.ZygoteInit", args, zygote);
    } else if (className) {
        runtime.start("com.android.internal.os.RuntimeInit", args, zygote);
    } else {
        fprintf(stderr, "Error: no class name or --zygote supplied.\n");
        // ....
    }
}

上述程式碼在 frameworks/base/cmds/app_process/app_main.cpp 中, runtime.start 的作用就是啟動 Java 虛擬機器並將執行流轉交給對應的 Java 函式。

void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote)
{
    /* start the virtual machine */
    JniInvocation jni_invocation;
    jni_invocation.Init(NULL);
    JNIEnv* env;
    if (startVm(&mJavaVM, &env, zygote) != 0) {
        return;
    }
    onVmCreated(env);
    /*
     * Register android functions.
     */
    if (startReg(env) < 0) {
        ALOGE("Unable to register all android natives\n");
        return;
    }
		
  	 /*
     * Start VM.  This thread becomes the main thread of the VM, and will
     * not return until the VM exits.
     */
    jclass startClass = env->FindClass(slashClassName);
		jmethodID startMeth = env->GetStaticMethodID(startClass, "main", "([Ljava/lang/String;)V");
    env->CallStaticVoidMethod(startClass, startMeth, strArray);
}

詳細介紹可以會看 Android 使用者態啟動流程分析 一文,這裡我們只需要知道 Java 虛擬機器是在 Zygote 程序建立的,並由子程序繼承,因此 APP 從 zygote 程序中 fork 啟動後就無需再次啟動 Java 虛擬機器,而是複用原有的虛擬機器執行輕量的初始化即可。

介面

Android Java 虛擬機器包括早期的 Dalvik 虛擬機器和當前的 ART 虛擬機器,我們將其統稱為 Java 虛擬機器,因為對於應用程式而言應該是透明的,也就是說二者應該提供了統一的對外介面。

這個介面可以分為兩部分,一部分是提供給 Java 應用的介面,即我們常見的 JavaVM、JNIEnv 結構體提供的諸如 FindClass、GetMethodID、CallVoidMethod 等介面;另一部分則是提供給系統開發者的介面,系統通過這些介面去初始化並建立虛擬機器,從而使自身具備執行 Java 程式碼的功能。

JniInvocation.Init方法中即進行了第二部分介面的初始化操作,其中主要邏輯是根據系統屬性 (persist.sys.dalvik.vm.lib.2) 判斷待載入的虛擬機器動態庫,Dalvik 虛擬機器對應的是 libdvm.so,ART 虛擬機器對應的是 libart.so;然後通過 dlopen 進行載入,並通過 dlsym 獲取其中三個函式符號,作為抽象 Java 虛擬機器的介面:

  • JNI_GetDefaultJavaVMInitArgs : 獲取預設的 JVM 初始化引數;
  • JNI_CreateJavaVM : 建立 Java 虛擬機器;
  • JNI_GetCreatedJavaVMs : 獲取已經建立的 Java 虛擬機器例項;

例如,在上述 zygote 的 AndroidRuntime::startVm 方法實現中,就是通過指定引數最終呼叫 JNI_CreateJavaVM 來完成 Java 虛擬機器的建立工作。

通過這三個介面實現了對於不同 Java 虛擬機器細節的隱藏,既可以用 ART 無縫替換 Dalvik 虛擬機器,也可以在未來用某個新的虛擬機器無縫替換掉 ART 虛擬機器。

總的來說,Java 虛擬機器只在 Zygote 程序中建立一次,子程序通過 fork 獲得虛擬機器的一個副本,因此 zygote 才被稱為所有 Java 程序的父程序;同時,也因為每個子程序擁有獨立的虛擬機器副本,所以某個程序的虛擬機器崩潰後不影響其他程序,從而實現安全的執行時隔離。

Dalvik

Dalvik 是早期 Android 的 Java 虛擬機器,伴隨著 Android 5.0 的更新,正式宣告其歷史使命的結束:

commit 870b4f2d70d67d6dbb7d0881d101c61bed8caad2
Author: Brian Carlstrom <[email protected]>
Date:   Tue Aug 5 12:46:17 2014 -0700

    Dalvik is dead, long live Dalvik!

雖然現在 Dalvik 已經被 ART 虛擬機器所取代,但其簡潔的實現有助於我們理解 Java 程式碼的執行流程,因此還是先對其進行簡單的介紹。

上節中我們知道 zygote 程序建立並初始化 Java 虛擬機器後執行的第一個 Java 函式是 com.android.internal.os.ZygoteInit 的 main 方法,這是個靜態方法,因此在 Native 層呼叫的是 JNI 介面函式 CallStaticVoidMethod 。其呼叫流程可以簡化如下所示:

method file
JNIEnv .CallStaticVoidMethod dalvik/libnativehelper/include/nativehelper/jni.h
JNINativeInterface .CallStaticVoidMethodV dalvik/vm/Jni.c
Jni .dvmCallMethodV dalvik/vm/interp/Stack.c
Stack .dvmInterpret dalvik/vm/interp/Interp.c
dvmInterpretStd dalvik/vm/mterp/out/InterpC-portstd.c (動態生成)

Dalvik 虛擬機器支援三種執行模式,分別是:

  • kExecutionModeInterpPortable: 可移植模式,能執行在不同的平臺中,對應的執行方法是 dvmInterpretStd;
  • kExecutionModeInterpFast: 快速模式,針對特定平臺優化,對應的執行方法是 dvmMterpStd;
  • kExecutionModeJit: JIT 模式,執行時編譯為特定平臺的 native 程式碼,對應執行方法也是 dvmMterpStd;

以上述呼叫流程中的 portable 模式為例,對應 dvmInterpretStd 實現的核心程式碼如下所示:

#define INTERP_FUNC_NAME dvmInterpretStd
bool INTERP_FUNC_NAME(Thread* self, InterpState* interpState) {
    // ...

    /* core state */
    const Method* curMethod;    // method we're interpreting
    const u2* pc;               // program counter
    u4* fp;                     // frame pointer
    u2 inst;                    // current instruction
 
    /* copy state in */
    curMethod = interpState->method;
    pc = interpState->pc;
    fp = interpState->fp;
    retval = interpState->retval;   /* only need for kInterpEntryReturn? */
 
    methodClassDex = curMethod->clazz->pDvmDex;

    while (1) {
        /* fetch the next 16 bits from the instruction stream */
        inst = FETCH(0);
        switch (INST_INST(inst)) {
            HANDLE_OPCODE(OP_INVOKE_DIRECT /*vB, {vD, vE, vF, vG, vA}, [email protected]*/)
                GOTO_invoke(invokeDirect, false);
            OP_END
            HANDLE_OPCODE(OP_RETURN /*vAA*/)
            HANDLE_OPCODE(...)
        }
    }

    /* export state changes */
    interpState->method = curMethod;
    interpState->pc = pc;
    interpState->fp = fp;

    /* debugTrackedRefStart doesn't change */
    interpState->retval = retval;   /* need for _entryPoint=ret */
    interpState->nextMode = 
        (INTERP_TYPE == INTERP_STD) ? INTERP_DBG : INTERP_STD;
    return true;
}

可以看到其核心在於一個巨大的 switch/case,以 PC 為起點不斷讀取位元組碼(4位元組對齊),並根據 op_code 去分發解釋執行不同的指令直到 Java 方法執行結束返回或者丟擲異常。之所以稱為可移植模式(portable)正是因為該程式碼純粹是解釋執行,既沒有提前優化也沒有執行時的 JIT 優化,也因此具有平臺無關性,只要 C 編譯器支援對應平臺即可執行。

雖然 Dalvik 已經被 ART 取代,但其中的 Dalvik 位元組碼格式還是被保留了下來。即便在最新版本的 Android 中,編譯 Java 生成的依舊是 DEX 檔案,其格式可以參考 Dalvik Executable format ,Dalvik 位元組碼的介紹可以參考官方文件 Dalvik bytecode

ART

ART 全稱為 Android Runtime,是繼 Dalvik 之後推出的高效能 Android Java 虛擬機器。在本文中我們重點關注 ART 虛擬機器執行 Java 程式碼的流程。在介紹 ART 的程式碼執行流程之前,我們需要先了解在 ART 中針對 DEX 的一系列提前優化方案,以及由此產生的各類中間檔案。

提前優化

在我們使用 Android-Studio 編譯應用時,實際上是通過 Java 編譯器先將 .java 程式碼編譯為對應的 Java 位元組碼,即 .class 類檔案;然後用 dx (在新版本中是 d8 ) 將 Java 位元組碼轉換為 Dalvik 位元組碼,並將所有生成的類打包到統一的 DEX 檔案中,最終和資原始檔一起 zip 壓縮為 .apk 檔案。

在安裝使用者的 APK 時,Android 系統主要通過 PacketManager 對應用進行解包和安裝。其中在處理 DEX 檔案時候,會通過 installd 程序呼叫對應的二進位制程式對位元組碼進行優化,這對於 Dalvik 虛擬機器而言使用的是 dexopt 程式,而 ART 中使用的是 dex2oat 程式。

dexopt 將 dex 檔案優化為 odex 檔案,即 optimized-dex 的縮寫,其中包含的是優化後的 Dalvik 位元組碼,稱為 quickend dex;dex2oat 基於 LLVM,優化後生成的是對應平臺的二進位制程式碼,以 oat 格式儲存,oat 的全稱為 Ahead-Of-Time。oat 檔案實際上是以 ELF 格式進行儲存的,並在其中 oatdata 段(section) 包含了原始的 DEX 內容。

在 Android 8 之後,將 OAT 檔案一分為二,原 oat 仍然是 ELF 格式,但原始 DEX 檔案內容被儲存到了 VDEX 中,VDEX 有其獨立的檔案格式。整體流程如下圖所示:

LIEF Documentation - Android formats

值得一提的是,在 Andorid 系統中 dex2oat 會將優化後的程式碼儲存在 /data/app 對應的應用路徑下,系統應用會儲存在 /data/dalvik-cache/ 下,對於後者,產生的實際有三個檔案,比如:

$ ls -l | grep Settings.apk
-rw-r----- 1 system system           77824 2021-12-10 10:33 [email protected]@[email protected]@classes.art
-rw-r----- 1 system system          192280 2021-11-19 12:50 [email protected]@[email protected]@classes.dex
-rw-r----- 1 system system           59646 2021-12-10 10:33 [email protected]@[email protected]@classes.vdex

[email protected]@[email protected]@classes.dex 實際上是 ELF 格式的 OAT 檔案,所以我們不能以貌(字尾)取人; .art 也是一個特殊的檔案格式,如前文所言,Android 實現了自己的 Java 虛擬機器,這個虛擬機器本身是用 C/C++ 實現的,其中的一些 Java 原語有對應的 C++ 類,比如:

  • java.lang.Class 對應 art::mirror::Class
  • java.lang.String 對應 art::mirror::String
  • java.lang.reflect.Method 對應 art::mirror::Method
  • ……

當建立一個 Java 物件時,記憶體中會建立對應的 C++ 物件並呼叫其建構函式,JVM 管理者這些 C++ 物件的引用。為了加速啟動過程,避免對這些常見類的初始化,Android 使用了 .art 格式來儲存這些 C++ 物件的例項,簡單來說,art 檔案可以看做是一系列常用 C++ 物件的記憶體 dump。

不論是 oat、vdex 還是 art,都是 Android 定義的內部檔案格式,官方並不保證其相容性,事實上在 Android 各個版本中這些檔案格式都有不同程度的變化,這些變化是不反映在文件中的,只能通過程式碼去一窺究竟。因此對於這些檔案格式我們現在只需要知道其大致作用,無需關心其實現細節。

檔案載入

在前一篇文章 ( Android 12 應用啟動流程分析 ) 中我們知道 APP 最終在 ActivityThread 中完成 Application 的建立和初始化,最終呼叫 Activity.onCreate 進入檢視元件的生命週期。但這裡其實忽略了一個問題: APP 的程式碼(DEX/OAT 檔案) 是如何載入到程序中的?

在 Java 中負責載入指定類的物件是 ClassLoader ,Android 中也是類似,BaseDexClassLoader 繼承自 ClassLoader 類,實現了許多 DEX 相關的載入操作,其子類包括:

  • DexClassLoader: 負責從 .jar 或者 .apk 中載入類;
  • PathClassLoader: 負責從本地檔案中初始化類載入器;
  • InMemoryDexClassLoader: 從記憶體中初始化類載入器;

ClassLoader

以常見的 PathClassLoader 為例,其建構函式會呼叫父類的建構函式,整體呼叫鏈路簡化如下表:

method file
new PathClassLoader
new BaseDexClassLoader libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java
new DexPathList libcore/dalvik/src/main/java/dalvik/system/DexPathList.java
DexPathList.makeDexElements
DexPathList.loadDexFile
new DexFile libcore/dalvik/src/main/java/dalvik/system/DexFile.java
DexFile.openDexFile
DexFile.openDexFileNative
DexFile_openDexFileNative art/runtime/native/dalvik_system_DexFile.cc
OatFileManager::OpenDexFilesFromOat art/runtime/oat_file_manager.cc

OpenDexFilesFromOat 中執行了真正的程式碼載入工作,虛擬碼如下:

std::vector<std::unique_ptr<const DexFile>> OatFileManager::OpenDexFilesFromOat() {
    std::vector<std::unique_ptr<const DexFile>> dex_files = OpenDexFilesFromOat_Impl(...);
    for (std::unique_ptr<const DexFile>& dex_file : dex_files) {
      if (!dex_file->DisableWrite()) {
        error_msgs->push_back("Failed to make dex file " + dex_file->GetLocation() + " read-only");
      }
    }
    return dex_files;
}

通過 OpenDexFilesFromOat_Impl 載入獲取 DexFile 結構體陣列,值得注意的是載入完 DEX 之後會將記憶體中的 dex_file 設定為不可寫,當然目前還沒有強制,但可見這是未來的趨勢。

繼續看實現部分是如何載入 Dex 檔案的:

std::vector<std::unique_ptr<const DexFile>> OatFileManager::OpenDexFilesFromOat_Impl() {
    // Extract dex file headers from `dex_mem_maps`.
    const std::vector<const DexFile::Header*> dex_headers = GetDexFileHeaders(dex_mem_maps);

    // Determine dex/vdex locations and the combined location checksum.
    std::string dex_location;
    std::string vdex_path;
    bool has_vdex = OatFileAssistant::AnonymousDexVdexLocation(dex_headers,
                                                             kRuntimeISA,
                                                             &dex_location,
                                                             &vdex_path);

    if (has_vdex && OS::FileExists(vdex_path.c_str())) {
        vdex_file = VdexFile::Open(vdex_path,
                                /* writable= */ false,
                                /* low_4gb= */ false,
                                /* unquicken= */ false,
                                &error_msg);
    }

    // Load dex files. Skip structural dex file verification if vdex was found
    // and dex checksums matched.
    std::vector<std::unique_ptr<const DexFile>> dex_files;
    for (size_t i = 0; i < dex_mem_maps.size(); ++i) {
        static constexpr bool kVerifyChecksum = true;
        const ArtDexFileLoader dex_file_loader;
        std::unique_ptr<const DexFile> dex_file(dex_file_loader.Open(
            DexFileLoader::GetMultiDexLocation(i, dex_location.c_str()),
            dex_headers[i]->checksum_,
            std::move(dex_mem_maps[i]),
            /* verify= */ (vdex_file == nullptr) && Runtime::Current()->IsVerificationEnabled(),
            kVerifyChecksum,
            &error_msg));
        if (dex_file != nullptr) {
            dex::tracking::RegisterDexFile(dex_file.get());  // Register for tracking.
            dex_files.push_back(std::move(dex_file));
        }
    }

    // Initialize an OatFile instance backed by the loaded vdex.
    std::unique_ptr<OatFile> oat_file(OatFile::OpenFromVdex(
        MakeNonOwningPointerVector(dex_files),
        std::move(vdex_file),
        dex_location));
    if (oat_file != nullptr) {
        VLOG(class_linker) << "Registering " << oat_file->GetLocation();
        *out_oat_file = RegisterOatFile(std::move(oat_file));
    }
    return dex_files;
}

載入過程首先將 vdex 對映到記憶體中,然後將已經對映到記憶體中的 dex 或者在磁碟中的 dex 轉換為 DexFile 結構體,最後再將 vdex 和 oat 檔案關聯起來。

VdexFile

vdex 是 Android 8.0 加入的新檔案格式,主要用於儲存優化程式碼的原始 DEX 資訊,而 OAT 中則主要儲存 dex2oat 編譯後的 Native 程式碼。

VdexFile 的結構大致如下所示,其中 D 代表 VDEX 中包含的 DEX 檔案個數:

VdexFileHeader    fixed-length header
VdexSectionHeader[kNumberOfSections]

Checksum section
  VdexChecksum[D]

Optionally:
   DexSection
       DEX[0]                array of the input DEX files
       DEX[1]
       ...
       DEX[D-1]

VerifierDeps
   4-byte alignment
   uint32[D]                  DexFileDeps offsets for each dex file
   DexFileDeps[D][]           verification dependencies
     4-byte alignment
     uint32[class_def_size]     TypeAssignability offsets (kNotVerifiedMarker for a class that isn't verified)
     uint32                     Offset of end of AssignabilityType sets
     uint8[]                    AssignabilityType sets
     4-byte alignment
     uint32                     Number of strings
     uint32[]                   String data offsets for each string
     uint8[]                    String data

VdexFile 結構詳見: art/runtime/vdex_file.h

DexFile

dex_file_loader.Open的呼叫路徑如下:

  • ArtDexFileLoader::Open
  • ArtDexFileLoader::OpenCommon
  • DexFileLoader::OpenCommon
    • magic == “dex\n” -> new StandardDexFile()
    • magic == “cdex” -> new CompactDexFile()

實際根據起始 4 位元組判斷是標準 DEX 還是緊湊型 DEX (cdex, compat dex),並使用對應的結構體進行初始化。cdex 是當前 ART 內部使用的 DEX 檔案格式,主要是為了減少磁碟和記憶體的使用。但不論是 StandardDexFile 還是 CompactDexFile 都繼承於 DexFile,二者的建構函式最終還是會呼叫 DexFile 的建構函式。

DexFile 結構詳見 art/libdexfile/dex/dex_file.h

OatFile

在完成所有 DexFile 的初始化之後,會繼續使用 OatFile::OpenFromVdex 建立 oat_file 並進行註冊。該函式的呼叫鏈路如下:

  • OatFile::OpenFromVdex
  • OatFileBackedByVdex::Open
  • new OatFileBackedByVdex
  • OatFileBase::OatFileBase
  • OatFile::OatFile

與早期使用 odex 的區別是現在在建立完 OatFile 之後,會呼叫 oat_file->SetVdex 獲取 vdex 物件的所有權,用以實現 OAT 的部分介面,比如獲取記憶體中對應 DEX 檔案的起始地址:

const uint8_t* OatFile::DexBegin() const {
  return vdex_->Begin();
}

詳見: art/runtime/oat_file.h

方法呼叫

本來按照時間線來看的話,這裡應該先介紹 ART 執行時類和方法的載入過程,但我從實踐出發,先看 Java 方法的呼叫過程,並針對其中涉及到的概念在下一節繼續介紹。

在 Web 安全中,Java 服務端通常帶有一個稱為 RASP (Runtime Application Self-Protection) 的動態防護方案,比如監控某些執行命令的敏感函式呼叫並進行告警,其實際 hook 點是在 JVM 中,不論是方法直接呼叫還是反射呼叫都可以檢測到。因此我們有理由猜測在 Android 中也有類似的呼叫鏈路,為了方便觀察,這裡先看反射呼叫的場景,一般反射呼叫的示例如下:

import java.lang.reflect.*;
public class Test {
    public static void main(String args[]) throws Exception {
        Class c = Class.forName("com.evilpan.DemoClass");
        Method m = c.getMethod("foo", null);
        m.invoke();
    }
}

因此一個方法的呼叫會進入到 Method.invoke 方法,這是一個 native 方法 ,實際實現在 art/runtime/native/java_lang_reflect_Method.cc :

static jobject Method_invoke(JNIEnv* env, jobject javaMethod, jobject javaReceiver,
                             jobjectArray javaArgs) {
  ScopedFastNativeObjectAccess soa(env);
  return InvokeMethod<kRuntimePointerSize>(soa, javaMethod, javaReceiver, javaArgs);
}

InvokeMethod 定義在 art/runtime/reflection.cc ,其實現的核心程式碼如下:

template <PointerSize kPointerSize>
jobject InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod,
                     jobject javaReceiver, jobject javaArgs, size_t num_frames) {
    ObjPtr<mirror::Executable> executable = soa.Decode<mirror::Executable>(javaMethod);
    const bool accessible = executable->IsAccessible();
    ArtMethod* m = executable->GetArtMethod();

    if (UNLIKELY(!declaring_class->IsVisiblyInitialized())) {
        Thread* self = soa.Self();
        Runtime::Current()->GetClassLinker()->EnsureInitialized(
            self, h_class,
            /*can_init_fields=*/ true,
            /*can_init_parents=*/ true)
    }

    if (!m->IsStatic()) {
        if (declaring_class->IsStringClass() && m->IsConstructor()) {
            m = WellKnownClasses::StringInitToStringFactory(m);
        } else {
            m = receiver->GetClass()->FindVirtualMethodForVirtualOrInterface(m, kPointerSize);
        }
    }

    if (!accessible && !VerifyAccess(/*...*/)) {
        ThrowIllegalAccessException(
        StringPrintf("Class %s cannot access %s method %s of class %s", ...));
    }

    InvokeMethodImpl(soa, m, np_method, receiver, objects, &shorty, &result);
}

上面省略了許多細節,主要是做了一些呼叫前的檢查和預處理工作,流程可以概況為:

  1. 判斷方法所屬的類是否已經初始化過,如果沒有則進行初始化;
  2. String.<init> 建構函式呼叫替換為對應的工廠 StringFactory 方法呼叫;
  3. 如果是虛擬函式呼叫,替換為執行時實際的函式;
  4. 判斷方法是否可以訪問,如果不能訪問則丟擲異常;
  5. 呼叫函式;

值得注意的是,jobject 型別的 javaMethod 可以轉換為 ArtMethod 指標,該結構體是 ART 虛擬機器中對於具體方法的描述。之後經過一系列呼叫:

method->Invoke()

最終進入 ArtMethod::Invoke 函式,還是隻看核心程式碼:

void ArtMethod::Invoke(Thread* self, uint32_t* args, uint32_t args_size, JValue* result,
                       const char* shorty) {
    Runtime* runtime = Runtime::Current();
    if (UNLIKELY(!runtime->IsStarted() ||
               (self->IsForceInterpreter() && !IsNative() && !IsProxyMethod() && IsInvokable()))) {
        art::interpreter::EnterInterpreterFromInvoke(...);
    } else {
        bool have_quick_code = GetEntryPointFromQuickCompiledCode() != nullptr;
        if (LIKELY(have_quick_code)) {
            if (!IsStatic()) {
                (*art_quick_invoke_stub)(this, args, args_size, self, result, shorty);
            } else {
                (*art_quick_invoke_static_stub)(this, args, args_size, self, result, shorty);
            }
        } else {
            LOG(INFO) << "Not invoking '" << PrettyMethod() << "' code=null";
        }
    }
    self->PopManagedStackFragment(fragment);
}

ART 對於 Java 方法實現了兩種執行模式,一種是像 Dalvik 虛擬機器一樣解釋執行位元組碼,姑且稱為解釋模式;另一種是快速模式,即直接呼叫通過 OAT 編譯後的原生代碼。

在 ART 早期指定原生代碼還細分為 Portable 和 Quick 兩種模式,但由於對極致速度的追求以及隨著 Quick 模式的不斷優化,Portable 也逐漸退出了歷史舞臺。

閱讀上述程式碼可以得知,當 ART 執行時尚未啟動或者指定強制使用解釋執行時,虛擬機器執行函式使用的是解釋模式,ART 可以在啟動時指定 -Xint 引數強制使用解釋執行,但即便指定了使用解釋執行模式,還是有一些情況無法使用解釋執行,比如:

  1. 當所執行的方法是 Native 方法時,這時只有二進位制程式碼,不存在位元組碼,自然無法解釋執行;
  2. 當所執行的方法無法呼叫,比如 access_flag 判定無法訪問或者當前方法是抽象方法時;
  3. 當所執行的方式是代理方法時,ART 對於代理方法有單獨的本地呼叫方式;

解釋執行

解釋執行的入口是 art::interpreter::EnterInterpreterFromInvoke ,該函式定義在 art/runtime/interpreter/interpreter.cc ,關鍵程式碼如下:

void EnterInterpreterFromInvoke(Thread* self,
                                ArtMethod* method,
                                ObjPtr<mirror::Object> receiver,
                                uint32_t* args,
                                JValue* result,
                                bool stay_in_interpreter) {
    CodeItemDataAccessor accessor(method->DexInstructionData());
    if (accessor.HasCodeItem()) {
        num_regs =  accessor.RegistersSize();
        num_ins = accessor.InsSize();
    }
    // 初始化棧幀 ......
    if (LIKELY(!method->IsNative())) {
        JValue r = Execute(self, accessor, *shadow_frame, JValue(), stay_in_interpreter);
        if (result != nullptr) {
        *result = r;
        }
  }
}

其中的 CodeItem 就是 DEX 檔案中對應方法的位元組碼,還是老樣子,直接看簡化的呼叫鏈路:

method file
Execute art/runtime/interpreter/interpreter.cc
ExecuteSwitch
ExecuteSwitchImpl art/runtime/interpreter/interpreter_switch_impl.h
ExecuteSwitchImplAsm
ExecuteSwitchImplAsm art/runtime/arch/arm64/quick_entrypoints_arm64.S
ExecuteSwitchImplCpp art/runtime/interpreter/interpreter_switch_impl-inl.h

ExecuteSwitchImplAsm 為了速度直接使用匯編實現,在 ARM64 平臺中的定義如下:

//  Wrap ExecuteSwitchImpl in assembly method which specifies DEX PC for unwinding.
//  Argument 0: x0: The context pointer for ExecuteSwitchImpl.
//  Argument 1: x1: Pointer to the templated ExecuteSwitchImpl to call.
//  Argument 2: x2: The value of DEX PC (memory address of the methods bytecode).
ENTRY ExecuteSwitchImplAsm
    SAVE_TWO_REGS_INCREASE_FRAME x19, xLR, 16
    mov x19, x2                                   // x19 = DEX PC
    CFI_DEFINE_DEX_PC_WITH_OFFSET(0 /* x0 */, 19 /* x19 */, 0)
    blr x1                                        // Call the wrapped method.
    RESTORE_TWO_REGS_DECREASE_FRAME x19, xLR, 16
    ret
END ExecuteSwitchImplAsm

本質上是呼叫儲存在 x1 暫存器的第二個引數,呼叫處的程式碼片段如下:

template<bool do_access_check, bool transaction_active>
ALWAYS_INLINE JValue ExecuteSwitchImpl() {
    //...
    void* impl = reinterpret_cast<void*>(&ExecuteSwitchImplCpp<do_access_check, transaction_active>);
    const uint16_t* dex_pc = ctx.accessor.Insns();
    ExecuteSwitchImplAsm(&ctx, impl, dex_pc);
}

即呼叫了 ExecuteSwitchImplCpp ,在該函式中,可以看見典型的解釋執行程式碼:

template<bool do_access_check, bool transaction_active>
void ExecuteSwitchImplCpp(SwitchImplContext* ctx) {
    Thread* self = ctx->self;
    const CodeItemDataAccessor& accessor = ctx->accessor;
    ShadowFrame& shadow_frame = ctx->shadow_frame;
    self->VerifyStack();

    uint32_t dex_pc = shadow_frame.GetDexPC();
    const auto* const instrumentation = Runtime::Current()->GetInstrumentation();
    const uint16_t* const insns = accessor.Insns();
    const Instruction* next = Instruction::At(insns + dex_pc);

    while (true) {
        const Instruction* const inst = next;
        dex_pc = inst->GetDexPc(insns);
        shadow_frame.SetDexPC(dex_pc);
        TraceExecution(shadow_frame, inst, dex_pc);
        uint16_t inst_data = inst->Fetch16(0); // 一條指令 4 位元組

        if (InstructionHandler(...).Preamble()) {
            switch (inst->Opcode(inst_data)) {
                case xxx: ...;
                case yyy: ...;
                ...
            }
        }
    }
}

在當前版本中 (Android 12),實際上是通過巨集展開去定義了所有 op_code 的處理分支,不同版本實現都略有不同,但解釋執行的核心思路從 Android 2.x 版本到現在都是一致的,因為位元組碼的定義並沒有太多改變。

快速執行

再回到 ArtMethod 真正呼叫之前,如果不使用解釋模式執行,則通過 art_quick_invoke_stub 去呼叫。stub 是一小段中間程式碼,用於跳轉到實際的 native 執行,該符號使用匯編實現,在 ARM64 中的定義在 art/runtime/arch/arm64/quick_entrypoints_arm64.S ,核心程式碼如下:

.macro INVOKE_STUB_CALL_AND_RETURN
    REFRESH_MARKING_REGISTER
    REFRESH_SUSPEND_CHECK_REGISTER

    // load method-> METHOD_QUICK_CODE_OFFSET
    ldr x9, [x0, #ART_METHOD_QUICK_CODE_OFFSET_64]
    // Branch to method.
    blr x9
.endm

/*
 *  extern"C" void art_quick_invoke_stub(ArtMethod *method,   x0
 *                                       uint32_t  *args,     x1
 *                                       uint32_t argsize,    w2
 *                                       Thread *self,        x3
 *                                       JValue *result,      x4
 *                                       char   *shorty);     x5
 */
ENTRY art_quick_invoke_stub
    // ...
    INVOKE_STUB_CALL_AND_RETURN
END art_quick_invoke_static_stub

中間省略了一些儲存上下文以及呼叫後恢復暫存器的程式碼,其核心是呼叫了 ArtMethod 結構體偏移 ART_METHOD_QUICK_CODE_OFFSET_64 處的指標,該值對應的程式碼為:

ASM_DEFINE(ART_METHOD_QUICK_CODE_OFFSET_64,
           art::ArtMethod::EntryPointFromQuickCompiledCodeOffset(art::PointerSize::k64).Int32Value())

entry_point_from_quick_compiled_code_ 屬性所指向的地址。

// art/runtime/art_method.h
static constexpr MemberOffset EntryPointFromQuickCompiledCodeOffset(PointerSize pointer_size) {
return MemberOffset(PtrSizedFieldsOffset(pointer_size) + OFFSETOF_MEMBER(
    PtrSizedFields, entry_point_from_quick_compiled_code_) / sizeof(void*)
        * static_cast<size_t>(pointer_size));
}

可以認為這就是所有快速模式執行程式碼的入口,至於該指標指向什麼地方,又是什麼時候初始化的,可以參考下一節程式碼載入部分。實際在方法呼叫時,快速模式執行的方法可能在其中執行到了需要以解釋模式執行的方法,同樣以解釋模式執行的方法也可能在其中呼叫到 JNI 方法或者其他以快速模式執行的方法,所以在單個函式執行的過程中執行狀態並不是一成不變的,但由於每次切換呼叫前後都儲存和恢復了當前上下文,使得不同調用之間可以保持透明,這也是模組化設計的一大優勢所在。

程式碼載入

在上節我們知道在 ART 虛擬機器中,Java 方法的呼叫主要通過 ArtMethod::Invoke 去實現,那麼 ArtMethod 結構是什麼時候建立的呢?為什麼 jmethod/jobject 可以轉換為 ArtMethod 指標呢?

在 Java 這門語言中,方法是需要依賴類而存在的,因此要分析方法的初始化需要先分析類的初始化。雖然我們前面知道如何從 OAT/VDEX/DEX 檔案中構造對應的 ClassLoader 來進行類查詢,但那個時候類並沒有初始化,可以編寫一個簡單的類進行驗證:

public class Demo {
    static {
        Log.i("Demo", "static block called");
    }
    {
        Log.i("Demo", "IIB called");
    }
}

如果 Demo 類在程式碼中沒有使用,那麼上述兩個列印都不會觸發;如果使用 Class.forName("Demo") 進行反射引用,則 static block 中的程式碼會被呼叫。跟蹤 Class.forName 呼叫:

@CallerSensitive
public static Class<?> forName(String className)
            throws ClassNotFoundException {
    Class<?> caller = Reflection.getCallerClass();
    // 筆者注: initialize = true
    return forName(className, true, ClassLoader.getClassLoader(caller));
}

最終呼叫到名為 classForName 的 native 方法,其定義在 art/runtime/native/java_lang_Class.cc :

// "name" is in "binary name" format, e.g. "dalvik.system.Debug$1".
static jclass Class_classForName(JNIEnv* env, jclass, jstring javaName, jboolean initialize,
                                 jobject javaLoader) {
    ScopedFastNativeObjectAccess soa(env);
    ScopedUtfChars name(env, javaName);

    std::string descriptor(DotToDescriptor(name.c_str()));
    Handle<mirror::ClassLoader> class_loader(
      hs.NewHandle(soa.Decode<mirror::ClassLoader>(javaLoader)));
    ClassLinker* class_linker = Runtime::Current()->GetClassLinker();
    Handle<mirror::Class> c(
      hs.NewHandle(class_linker->FindClass(soa.Self(), descriptor.c_str(), class_loader)));

    if (initialize) {
        class_linker->EnsureInitialized(soa.Self(), c, true, true);
    }
    return soa.AddLocalReference<jclass>(c.Get());
}

首先將 Java 格式的類表示轉換為 smali 格式,然後通過指定的 class_loader 去查詢類,查詢過程主要通過 class_linker 實現。由於 forName 函式中指定了 initializetrue ,因此在找到對應類後還會額外執行一步 EnsureInitialized ,在後文會進行詳細介紹。

FindClass

FindClass 實現了根據類名查詢類的過程,定義在 art/runtime/class_linker.cc 中,關鍵流程如下:

ObjPtr<mirror::Class> ClassLinker::FindClass(Thread* self,
                                             const char* descriptor,
                                             Handle<mirror::ClassLoader> class_loader) 
    if (descriptor[1] == '\0') 
        return FindPrimitiveClass(descriptor[0]);

    const size_t hash = ComputeModifiedUtf8Hash(descriptor);
    // 在已經載入的類中查詢
    ObjPtr<mirror::Class> klass = LookupClass(self, descriptor, hash, class_loader.Get());
    if (klass != nullptr) {
        return EnsureResolved(self, descriptor, klass);
    }
    // 尚未載入
    if (descriptor[0] != '[' && class_loader == nullptr) {
        // 類載入器為空,且不是陣列型別,在啟動類中進行查詢
        ClassPathEntry pair = FindInClassPath(descriptor, hash, boot_class_path_);
        return DefineClass(self, descriptor, hash,
                           ScopedNullHandle<mirror::ClassLoader>(),
                           *pair.first, *pair.second);
    }

    ObjPtr<mirror::Class> result_ptr;
    bool descriptor_equals;
    ScopedObjectAccessUnchecked soa(self);
    // 先通過 classLoader 的父類查詢
    bool known_hierarchy =
        FindClassInBaseDexClassLoader(soa, self, descriptor, hash, class_loader, &result_ptr);
    if (result_ptr != nullptr) {
        descriptor_equals = true;
    } else if (!self->IsExceptionPending()) {
        // 如果沒找到,再通過 classLoader 查詢
        std::string class_name_string(descriptor + 1, descriptor_length - 2);
        std::replace(class_name_string.begin(), class_name_string.end(), '/', '.');
        ScopedLocalRef<jobject> class_loader_object(
            soa.Env(), soa.AddLocalReference<jobject>(class_loader.Get()));
        ScopedLocalRef<jobject> result(soa.Env(), nullptr);
        result.reset(soa.Env()->CallObjectMethod(class_loader_object.get(),
                                                 WellKnownClasses::java_lang_ClassLoader_loadClass,
                                                 class_name_object.get()));
    }

    // 將找到的類插入到快取表中
    ClassTable* const class_table = InsertClassTableForClassLoader(class_loader.Get());
    class_table->InsertWithHash(result_ptr, hash);

    return result_ptr;
}

首先會通過 LookupClass 在已經載入的類中查詢,已經載入的類會儲存在 ClassTable 中,以 hash 表的方式儲存,該表的鍵就是類對應的 hash,通過 descriptor 計算得出。如果之前已經載入過,那麼這時候就可以直接返回,如果沒有就需要執行真正的載入了。從這裡我們也可以看出,類的載入過程屬於懶載入 (lazy loading),如果一個類不曾被使用,那麼是不會有任何載入開銷的。

然後會判斷指定的類載入器是否為空,為空表示要查詢的類實際上是一個系統類。系統類不存在於 APP 的 DEX 檔案中,而是 Android 系統的一部分。由於每個 Android (Java) 應用都會用到系統類,為了提高啟動速度,實際通過 zygote 去載入,並由所有子程序一起共享。上述 boot_class_path_ 陣列在 Runtime::Init 中通過 ART 啟動的引數進行初始化,感興趣的可以自行研究細節。

我們關心的應用類查詢過程可以分為兩步,首先在父類的 ClassLoader 進行查詢,如果沒找到才會通過指定的 classLoader 進行查詢,這也是很多類似 Java 文章中提到的 “雙親委派” 機制。保證關鍵類的查詢過程優先通過系統類載入器,可以防止關鍵類實現被應用篡改。

FindClassInBaseDexClassLoader 的實現使用虛擬碼描述如下所示:

Class ClassLinker::FindClassInBaseDexClassLoader(ClassLoader class_loader, size_t hash) {
    if (class_loader == java_lang_BootClassLoader) {
        return FindClassInBootClassLoaderClassPath(class_loader, hash);
    }
    if (class_loader == dalvik_system_PathClassLoader ||
        class_loader == dalvik_system_DexClassLoader ||
        class_loader == dalvik_system_InMemoryDexClassLoader) {
        // For regular path or dex class loader the search order is:
        //    - parent
        //    - shared libraries
        //    - class loader dex files
        FindClassInBaseDexClassLoader(class_loader->GetParent, hash) && return result;
        FindClassInSharedLibraries(...) && return result;
        FindClassInBaseDexClassLoaderClassPath(...) && return result;
        FindClassInSharedLibrariesAfter(...) && return result;
    }
    if (class_loader == dalvik_system_DelegateLastClassLoader) {
        // For delegate last, the search order is:
        //    - boot class path
        //    - shared libraries
        //    - class loader dex files
        //    - parent
        FindClassInBootClassLoaderClassPath(...) && return result;
        FindClassInBaseDexClassLoaderClassPath(...) && return result;
        FindClassInSharedLibrariesAfter(...) && return result;
        FindClassInBaseDexClassLoader(class_loader->GetParent, hash) && return result;
    }
    return null;
}

根據不同的 class_loader 型別使用不同的搜尋順序,如果涉及到父 ClassLoader 的搜尋,則使用遞迴查詢,遞迴的停止條件是當前 class_loader 為 java.lang.BootClassLoader

FindClassInBootClassLoaderClassPath 的關鍵程式碼如下:

using ClassPathEntry = std::pair<const DexFile*, const dex::ClassDef*>;
bool ClassLinker::FindClassInBootClassLoaderClassPath(Thread* self,
                                                      const char* descriptor,
                                                      size_t hash,
                                                      /*out*/ ObjPtr<mirror::Class>* result) {
    ClassPathEntry pair = FindInClassPath(descriptor, hash, boot_class_path_);
    if (pair.second != nullptr) {
        ObjPtr<mirror::Class> klass = LookupClass(self, descriptor, hash, nullptr);
        if (klass != nullptr) {
            *result = EnsureResolved(self, descriptor, klass);
        } else {
            *result = DefineClass(self, ...);
        }
    }
    return true;

如果在 BaseClassLoader 中沒有找到對應的類,那麼最終會通過傳入的 classLoader 查詢,即呼叫指定類載入器的 loadClass 方法。在這個場景中(Class.forName),實際指定的是 caller 的 classLoader,編寫一個 APK 進行動態分析,打印出當前的 classLoader 如下:

dalvik.system.PathClassLoader[
DexPathList[[
zip file "/data/app/~~0FBqwacokhdG5rhF1RDZGg==/com.evilpan.test-fCJvsE74xP_SdvTlAfJDcA==/base.apk"
],
nativeLibraryDirectories=[/data/app/~~0FBqwacokhdG5rhF1RDZGg==/com.evilpan.test-fCJvsE74xP_SdvTlAfJDcA==/lib/arm64, /system/lib64, /system_ext/lib64]]]

所以這是一個 PathClassLoader 物件,該類沒有定義 loadClass,因此是呼叫了父類的 loadClass 方法,整體呼叫路徑如下所示:

sequenceDiagram
%% loadClass
participant P as PathClassLoader
participant B as BaseDexClassLoader
participant C as ClassLoader
participant D as DexFile
P ->> C: loadClass
C ->> B: findClass
B ->> P: pathList.findClass
P ->> D: loadClassBinaryName
Note right of D: defineClass <br> defineClassNative
loadClass

最終呼叫了 DexFile 的 native 方法 defineClassNative,實現在 art/runtime/native/dalvik_system_DexFile.cc ,關鍵程式碼如下:

static jclass DexFile_defineClassNative(JNIEnv* env,
                                        jclass,
                                        jstring javaName,
                                        jobject javaLoader,
                                        jobject cookie,
                                        jobject dexFile) {
    std::vector<const DexFile*> dex_files;
    ConvertJavaArrayToDexFiles(env, cookie, /*out*/ dex_files, /*out*/ oat_file);

    ScopedUtfChars class_name(env, javaName);
    const std::string descriptor(DotToDescriptor(class_name.c_str()));
    const size_t hash(ComputeModifiedUtf8Hash(descriptor.c_str()));
    for (auto& dex_file : dex_files) {
        const dex::ClassDef* dex_class_def = OatDexFile::FindClassDef(*dex_file, descriptor.c_str(), hash);
        // dex_class_def != nullptr
        ClassLinker* class_linker = Runtime::Current()->GetClassLinker();
        Handle<mirror::ClassLoader> class_loader(
          hs.NewHandle(soa.Decode<mirror::ClassLoader>(javaLoader)));
        ObjPtr<mirror::DexCache> dex_cache =
          class_linker->RegisterDexFile(*dex_file, class_loader.Get());
        // dex_cache != nullptr
        ObjPtr<mirror::Class> result = class_linker->DefineClass(soa.Self(),
                                                               descriptor.c_str(),
                                                               hash,
                                                               class_loader,
                                                               *dex_file,
                                                               *dex_class_def);
        class_linker->InsertDexFileInToClassLoader(soa.Decode<mirror::Object>(dexFile),
                                                 class_loader.Get());
    }
}

也就是說,不論是通過 FindClassInBaseDexClassLoader 查詢還是通過指定 classLoader 的 loadClass 載入,最終執行的流程都是類似的,即在對應的 DexFile(OatDexFile) 中根據類名搜尋對應類的 ClassDef 欄位,瞭解 Dex 檔案結構的對這個欄位應該不會陌生,後面可能會單獨寫一篇 DexFile 檔案格式的介紹,這裡限於篇幅先不展開,只需要知道這個欄位包含類的定義即可。

在找到類在對應 Dex 檔案中的 ClassDef 內容後,會通過 ClassLinker 完成該類的後續註冊流程,包括:

dex_caches_
ClassLinker::DefineClass

其中 DefineClass 是我們比較關心的,因此下面單獨進行介紹。

DefineClass

先看程式碼:

ObjPtr<mirror::Class> ClassLinker::DefineClass(Thread* self,
                                               const char* descriptor,
                                               size_t hash,
                                               Handle<mirror::ClassLoader> class_loader,
                                               const DexFile& dex_file,
                                               const dex::ClassDef& dex_class_def) {
    ScopedDefiningClass sdc(self);
    StackHandleScope<3> hs(self);
    auto klass = hs.NewHandle<mirror::Class>(nullptr);

    // Load the class from the dex file.
    if (UNLIKELY(!init_done_)) {
        // [1] finish up init of hand crafted class_roots_
    }

    ObjPtr<mirror::DexCache> dex_cache = RegisterDexFile(*new_dex_file, class_loader.Get());
    klass->SetDexCache(dex_cache);
    ObjPtr<mirror::Class> existing = InsertClass(descriptor, klass.Get(), hash);
    if (existing != nullptr) {
        // 其他執行緒正在連結該類,阻塞等待其完成
        return sdc.Finish(EnsureResolved(self, descriptor, existing));
    }
    LoadClass(self, *new_dex_file, *new_class_def, klass);
    // klass->IsLoaded
    LoadSuperAndInterfaces(klass, *new_dex_file))
    Runtime::Current()->GetRuntimeCallbacks()->ClassLoad(klass);
    // klass->IsResolved
    LinkClass(self, descriptor, klass, interfaces, &h_new_class)
    Runtime::Current()->GetRuntimeCallbacks()->ClassPrepare(klass, h_new_class);

    jit::Jit::NewTypeLoadedIfUsingJit(h_new_class.Get());
    return sdc.Finish(h_new_class);
}

這裡只列出一些關鍵程式碼, init_done_ 用於表示當前 ClassLinker 的初始化狀態,初始化過程用於從 Image 空間或者手動建立內部類,手動建立的內部類包括:

  • Ljava/lang/Object;
  • Ljava/lang/Class;
  • Ljava/lang/String;
  • Ljava/lang/ref/Reference;
  • Ljava/lang/DexCache;
  • Ldalvik/system/ClassExt;

它們都直接定義在了 art::runtime::mirror 名稱空間中,比如 Object 定義為 mirror::Object ,所屬檔案為 art/runtime/mirror/object.h

LoadClass

ClassLinker::LoadClass 用於從指定 DEX 檔案中載入目標類的屬性和方法等內容,注意這裡其實是在對應類新增到 ClassTable 之後才載入的,這是出於 ART 的內部優化考慮,另外一個原因是類的屬性根只能通過 ClassTable 訪問,因此需要在訪問前先在 ClassTable 中佔好位置。其實現如下:

void ClassLinker::LoadClass(Thread* self,
                            const DexFile& dex_file,
                            const dex::ClassDef& dex_class_def,
                            Handle<mirror::Class> klass) {
    ClassAccessor accessor(dex_file,
                         dex_class_def,
                         /* parse_hiddenapi_class_data= */ klass->IsBootStrapClassLoaded());
    Runtime* const runtime = Runtime::Current();
    accessor.VisitFieldsAndMethods(
        [&](const ClassAccessor::Field& field) {
            LoadField(field, klass, &sfields->At(num_sfields));
            ++num_sfields;
        },
        [&](const ClassAccessor::Field& field) {
            LoadField(field, klass, &ifields->At(num_ifields));
            ++num_ifields;
        },
        [&](const ClassAccessor::Method& method) {
            ArtMethod* art_method = klass->GetDirectMethodUnchecked(
                class_def_method_index,
                image_pointer_size_);
            LoadMethod(dex_file, method, klass, art_method);
            LinkCode(this, art_method, oat_class_ptr, class_def_method_index);
            ++class_def_method_index;
        },
        [&](const ClassAccessor::Method& method) {
            ArtMethod* art_method = klass->GetVirtualMethodUnchecked(
                class_def_method_index - accessor.NumDirectMethods(),
                image_pointer_size_);
            LoadMethod(dex_file, method, klass, art_method);
            LinkCode(this, art_method, oat_class_ptr, class_def_method_index);
            ++class_def_method_index;
        }
    );
    klass->SetSFieldsPtr(sfields);
    klass->SetIFieldsPtr(ifields);
}

上面用到了 C++11 的 lambda 函式來通過迭代器訪問類中的關聯元素,分別是:

  1. sfields: static fields,靜態屬性
  2. ifields: instance fields,物件屬性
  3. direct method: 物件方法
  4. virtual method: 抽象方法

對於屬性的載入通過 LoadField 實現,主要作用是初始化 ArtField 並與目標類關聯起來; LoadMethod 的實現亦是類似,主要是使用 dex 檔案中對應方法的 CodeItem 對 ArtMethod 進行初始化,並與 klass 關聯。但是對於方法而言,還好進行額外的一步,即 LinkCode

LinkCode

LinkCode 顧名思義是對程式碼進行連結,關鍵程式碼如下:

static void LinkCode(ClassLinker* class_linker,
                     ArtMethod* method,
                     const OatFile::OatClass* oat_class,
                     uint32_t class_def_method_index) {
    Runtime* const runtime = Runtime::Current();
    const void* quick_code = nullptr;
    if (oat_class != nullptr) {
         // Every kind of method should at least get an invoke stub from the oat_method.
         // non-abstract methods also get their code pointers.
         const OatFile::OatMethod oat_method = oat_class->GetOatMethod(class_def_method_index);
         quick_code = oat_method.GetQuickCode();
    }
    runtime->GetInstrumentation()->InitializeMethodsCode(method, quick_code);

    if (method->IsNative()) {
    // Set up the dlsym lookup stub. Do not go through `UnregisterNative()`
    // as the extra processing for @CriticalNative is not needed yet.
        method->SetEntryPointFromJni(
            method->IsCriticalNative() ? GetJniDlsymLookupCriticalStub() : GetJniDlsymLookupStub());
  }
}

其中 quick_code 指標指向的是 OatMethod 中的 code_offset_ 偏移處的值,該值指向的是 OAT 優化後的原生代碼位置。 InitializeMethodsCodeInstrumentation 類的方法,實現在 art/runtime/instrumentation.cc ,如果看過之前分析應用啟動流程的文章應該對這個類不會陌生,儘管不是同一個類,但它們的功能卻是類似的,即作為某些關鍵呼叫的收口,並在其中實現可插拔的追蹤行為。其內部實現如下:

void Instrumentation::InitializeMethodsCode(ArtMethod* method, const void* aot_code) {
    // Use instrumentation entrypoints if instrumentation is installed.
    if (UNLIKELY(EntryExitStubsInstalled())) {
        if (!method->IsNative() && InterpretOnly()) {
            UpdateEntryPoints(method, GetQuickToInterpreterBridge());
        } else {
            UpdateEntryPoints(method, GetQuickInstrumentationEntryPoint());
        }
        return;
    }
    if (UNLIKELY(IsForcedInterpretOnly())) {
        UpdateEntryPoints(
            method, method->IsNative() ? GetQuickGenericJniStub() : GetQuickToInterpreterBridge());
        return;
    }
    // Use the provided AOT code if possible.
    if (CanUseAotCode(method, aot_code)) {
        UpdateEntryPoints(method, aot_code);
        return;
    }
    // Use default entrypoints.
    UpdateEntryPoints(
      method, method->IsNative() ? GetQuickGenericJniStub() : GetQuickToInterpreterBridge());
}

第一部分正是用於追蹤的判斷,如果當前已經安裝了追蹤監控,那麼會根據當前方法的類別分別設定對應的入口點;否則就以常規方式設定方法的呼叫入口:

  • 對於強制解釋執行的執行時環境:
    art_quick_generic_jni_trampoline
    art_quick_to_interpreter_bridge
    
  • 如果 AOT 編譯的原生代碼可用,則直接將方法入口點設定為 AOT 程式碼;
  • 如果 AOT 程式碼不可用,那麼就回到解釋執行場景進行處理;

設定 ArtMethod 入口地址的方法是 UpdateEntryPoints,其內部實現非常簡單:

static void UpdateEntryPoints(ArtMethod* method, const void* quick_code)
    REQUIRES_SHARED(Locks::mutator_lock_) {
    if (kIsDebugBuild) {
        ...
    }
    // If the method is from a boot image, don't dirty it if the entrypoint
    // doesn't change.
    if (method->GetEntryPointFromQuickCompiledCode() != quick_code) {
        method->SetEntryPointFromQuickCompiledCode(quick_code);
    }
}

內部實質上是呼叫了 ArtMethod::SetEntryPointFromQuickCompiledCode :

void SetEntryPointFromQuickCompiledCode(const void* entry_point_from_quick_compiled_code)
      REQUIRES_SHARED(Locks::mutator_lock_) {
    SetEntryPointFromQuickCompiledCodePtrSize(entry_point_from_quick_compiled_code,
                                              kRuntimePointerSize);
  }

回顧我們前面分析方法呼叫的章節,對於快速執行的場景, ArtMethod::Invoke 最終是跳轉到 entry_point_from_quick_compiled_code 進行執行,而這個欄位就是在這裡進行設定的。

至此,我們完成了 ART 方法呼叫流程分析的最後一塊拼圖。

類初始化

此時我們已經完成了類的載入,包括類中的所有方法、屬性的初始化。在前文 classForName 的實現中,完成類載入後還呼叫了一次 EnsureInitialized,在其中呼叫了 ClassLinker::InitializeClass 對類進行初始化,主要包括靜態屬性的初始化以及呼叫類中的 <clinit> 程式碼,這也是為什麼本節開頭 Demo 類的 static block 中程式碼會被呼叫的原因。

初始化流程嚴格按照 Java 語言標準實現,詳見 Java Language Specification 12.4.2 “Detailed Initialization Procedure”

應用場景

通過上面的分析,我們大致瞭解了 ART 虛擬機器的檔案、程式碼載入流程,以及對應 Java 方法和指令的執行過程。正所謂無利不起早,之所以花費這麼多時間精力去學習 ART,是因為其在 Android 執行過程中起著舉足輕重的作用,下面就列舉一些常見的應用場景。

熱修復 & Hook

所謂熱修復,就是在不修改原有程式碼的基礎上修改應用功能,比如替換某些類方法的實現,達到熱更新的目的。猶記得在幾年前,熱修復的概念在 Android 生態中甚囂塵上,隨著 ART 替換 Dalvik,以及碎片化引入的一系列問題導致這種方案逐漸銷聲匿跡。但是熱修復的使用場景並沒有完全消失,比如在 Android 應用安全研究中 Hook 的概念也是熱修復的一種延續。

那麼根據前面總結的知識可以考慮一個問題,如何在執行時劫持某個 Java 方法的執行流程?最好是可以在指定方法呼叫前以及返回前分別觸發我們自己定義的回撥,從而實現呼叫引數和返回值的觀察和修改。

根據前文對方法呼叫和程式碼載入的分析,Android 中的 Java 方法在 ART 中執行都會通過 ArtMethod::Invoke 進行呼叫,在其內部要麼通過直譯器直接解釋執行(配合 JIT);要麼通過 GetEntryPointFromQuickCompiledCode 獲取原生代碼進行執行,當然後者在某些場景下依然會回退到直譯器,但入口都是固定的,即 entry_point_from_quick_compiled_code 所指向的 quick 程式碼。因此,要想實現 Java 方法呼叫的劫持,可以有幾種思路:

ArtMethod::Invoke
entry_point_from_quick_compiled_code

當然,前途是光明的,道路是曲折的,這些方法看起來都很直觀,但實現起來有很多工程化的難點。比如需要仔細處理呼叫前後的堆疊令其保持平衡,這涉及到 inline-hook 框架本身的魯棒性;有比如在新版本中對於系統類方法的呼叫,ART 會直接優化成彙編跳轉而繞過 ArtMethod 方法的查詢過程,因此方法 1、2 無法覆蓋到這些場景,……不一而足。

以大家常用的 frida 為例,其對 Java 方法 Hook 的實現在 frida-java-bridge ,關鍵程式碼在 lib/android.js 檔案中:

class ArtMethodMangler {
    replace (impl, isInstanceMethod, argTypes, vm, api) {
        this.originalMethod = fetchArtMethod(this.methodId, vm);
        const originalFlags = this.originalMethod.accessFlags;
        if ((originalFlags & kAccXposedHookedMethod) !== 0 && xposedIsSupported()) {
            // 檢測 Xposed,如果已經被 Xposed hook 了會從新獲取源函式 ...
        }
        const replacementMethodId = cloneArtMethod(hookedMethodId, vm);
        patchArtMethod(replacementMethodId, {
    jniCode: impl,
    accessFlags: ((originalFlags & ~(kAccCriticalNative | kAccFastNative | kAccNterpEntryPointFastPathFlag)) | kAccNative) >>> 0,
    quickCode: api.artClassLinker.quickGenericJniTrampoline,
    interpreterCode: api.artInterpreterToCompiledCodeBridge
}, vm);

    // 修改 flags 使直譯器執行到我們想要的分支
    let hookedMethodRemovedFlags = kAccFastInterpreterToInterpreterInvoke | kAccSingleImplementation | kAccNterpEntryPointFastPathFlag;
    if ((originalFlags & kAccNative) === 0) {
      hookedMethodRemovedFlags |= kAccSkipAccessChecks;
    }

    patchArtMethod(hookedMethodId, {
      accessFlags: (originalFlags & ~(hookedMethodRemovedFlags)) >>> 0
    }, vm);

    // 將 Nterp 直譯器的入口替換為 art_quick_to_interpreter_bridge 從而令程式碼跳轉到 quick 入口
    const quickCode = this.originalMethod.quickCode;
    const { artNterpEntryPoint } = api;
    if (artNterpEntryPoint !== undefined && quickCode.equals(artNterpEntryPoint)) {
      patchArtMethod(hookedMethodId, {
        quickCode: api.artQuickToInterpreterBridge
      }, vm);
    }

    // 開啟劫持
    if (!isArtQuickEntrypoint(quickCode)) {
        const interceptor = new ArtQuickCodeInterceptor(quickCode);
        interceptor.activate(vm);

        this.interceptor = interceptor;
    }

    // 使用 hash 表記錄已經替換的方法,方便後續恢復
    artController.replacedMethods.set(hookedMethodId, replacementMethodId);
    notifyArtMethodHooked(hookedMethodId, vm);
    }
}

其中 Nterp 是 ART 中一個改良過的直譯器,用於替代早期 Dalvik 的 mterp 直譯器,這裡先不展開實現的細節,只需關注實際執行劫持的地方,即 interceptor.activate(vm) 。interceptor 在例項化時指定的 quickCode 即為對應 ArtMethod 的快速執行入口,activate 程式碼如下:

activate (vm) {
    this._createTrampoline();

    const { trampoline, quickCode, redirectSize } = this;

    const writeTrampoline = artQuickCodeReplacementTrampolineWriters[Process.arch];
    const prologueLength = writeTrampoline(trampoline, quickCode, redirectSize, vm);
    this.overwrittenPrologueLength = prologueLength;

    this.overwrittenPrologue = Memory.dup(this.quickCodeAddress, prologueLength);

    const writePrologue = artQuickCodePrologueWriters[Process.arch];
    writePrologue(quickCode, trampoline, redirectSize);
}

可以看到 frida 實際上是使用了我們上述的第 3 種 Hook 思路,即修改 stub code 為我們的劫持程式碼,這種方式一般稱之為 dynamic callee-side rewriting ,優點是即便對於 OAT 極致優化的系統類方法也同樣有效。當然,我們這裡只是管中規豹,實際的實現上還有很多細節值得學習,感興趣的可以自行閱讀程式碼。

安全加固

瞭解過 Android 逆向工程的人應該都知道,基於 Java 編譯出來的 Dalvik 位元組碼其實很好理解,加上一些開源或者商業的反編譯工具,甚至可以將位元組碼還原為和原始碼非常接近的 Java 程式碼表示。這對於很多想在程式碼中隱藏祕密的公司而言是很不願意看到的。

因此,安全工程師們就想出了一些保護程式碼防止靜態逆向分析的方案,業內常稱為 加殼 ,國外叫做 Packer ,即在原始位元組碼上套上一層保護殼,並在執行時進行執行解密還原。

回顧我們學習的知識可以腦暴出幾種安全加固方案(其實是業內已有方案):

  1. 把整個 DEX 檔案加密,然後在殼程式啟動時還原解密檔案並載入;
  2. 優化上述方案,不落地檔案,直接在記憶體中解密載入;
  3. 提取出 DEX 檔案中的位元組碼,並在執行時還原;
  4. 替換掉 DEX 檔案中每個方法的位元組碼為解密程式碼,執行時解密執行;
  5. ……

這些加固方案根據解密粒度不同也常稱為整體殼、抽取殼。對於整體加密的方案不必多說,在 PC 時代也有很多類似的混淆方法;而對於抽取殼,實現就百花齊放了,比如有的加固方案是在類初始化期間進行還原,有的是在方法執行前進行還原。

回顧上面介紹熱修復的內容,殼程式碼其實也可以看做是一個熱修復框架,只不過是對於每個函式都進行了劫持,在目標函式執行前對實際的位元組碼進行還原;

有些類級別的加固則是基於上文中程式碼載入流程,在類的初始化函式( <clinit> )中執行解密操作,因為 Java 標準保證了這是一個類最先執行的程式碼。

由於抽取殼本身對位元組碼進行了加密,因此在應用安裝期間 dex2oat 就無法優化這些程式碼,以至於在執行時只能通過解釋執行,雖然有一部分 JIT 的加持,但還是讓 ART 的大部分優化心血付諸東流;另外,加殼本身會使用到 ART 中的一些內部符號和偏移,因此需要針對不同版本進行適配,一個不小心就是使用者端的持續崩潰。

也因為這些原因,很多頭部廠商的 Android 應用其實是不加殼的,對於真正需要保護的程式碼,可以選擇 JNI 用 C/C++ 實現,並配上 LLVM 成熟的混淆方案進行加固。

脫殼

由於很多安全公司把加固做成了商業服務,因此除了正常應用,大部分惡意軟體和非法應用也都用上了商業的加固方案,這對於正義的安全研究員而言是一個確實的阻礙,因此脫殼也就成了常見需求。

一開始我們在遇到加固的應用時候會先嚐試進行手動進行分析、除錯、還原,但是後來大家發現其實基於 ART 的執行模式有更通用的解決方式。

這裡以目前相對較新的抽取殼為例,回顧上文程式碼方法呼叫和程式碼載入的章節,不論加固的抽取和還原方法如何,最終還是要回到解釋執行的(至少在 JIT 之前),因為加密的程式碼在安裝時並沒有被 AOT 優化。而且為了保證原始程式碼邏輯不變,對應加密方法在實際執行之前肯定需要被正確解密還原。

基於這點事實,我們可以在 ArtMethod 呼叫前進行斷點,然後通過 method->GetDexFile() 獲得對應 dex 檔案在記憶體中的地址並進行轉儲儲存。如果當前記憶體中的 dex 部分偏移被惡意修改,那麼還可以通過 method->GetCodeItem() 獲取對應方法解密後的位元組碼地址進行手動轉儲恢復。

如果要恢復完整的 dex 檔案,則需要令目標程式在執行時呼叫所有類的所有方法,這顯然不太現實;不過網上已經有了一些開源的方案基於主動呼叫的思路去批量偽造方法呼叫,觸發殼的解密邏輯從而實現全量還原,比如 DexHunterFART ,都是通過修改 Android 原始碼實現的脫殼方案。

正如上節所說,安全加固方案五花八門,很難有一種絕對通用的方法去還原所有加固,往往還需要針對不同的殼做一些微小的適配工作。但總的來說,脫殼一方比寫殼一方還是佔優勢的,前者只需要針對一種環境實現,不用考慮效能成本;後者則需要對 ART 有更深的理解來保證加固程式的穩定性,同時還要針對不同環境都進行覆蓋,這也是攻防不對等的一個典型案例吧。

方法跟蹤

對於上述 Android 應用加殼的方案,在數次攻防角鬥下已經被證明了只能作為輔助防護,因此移動安全廠商又提出了一些新的加固方案,比如直接對位元組碼本身下手,套用 LLVM 控制流和資料流混淆的那一套方案,將位元組碼的執行順序打亂,插入各種無效指令來阻礙逆向工程;又或者將位元組碼的實現抽批量自動取到 JNI 層,並輔以二進位制級別的安全加固,這種方案通常稱為 Java2C,即將 Java 程式碼轉譯成 C 程式碼編譯來防止逆向分析。……

這時,傳統的脫殼方法就不見得有效了,因為即便還原出位元組碼或者 Java 程式碼,其流程也是混亂的,對於 Java2C 則更不用說,只能在二進位制中想辦法將 JNI 呼叫還原。

不過我們可以思考一下,逆向工程的目的是什麼?如果是為了分析還原程式的執行流程,對其行為進行畫像和取證,那麼完全可以通過動態跟蹤的方式實現。上文中已經介紹瞭如果對某個指定方法進行熱修復或者說 hook,那麼這裡的思路就是對應用中的所有 Java 方法都進行 hook,從而實現我們的執行時方法跟蹤行為。

例如針對每個 Java 方法在進入和退出前都插入我們的 hook 程式碼,作用就是傳送函式進出事件及其相關資訊,如程序、執行緒 ID、方法名、引數等,接收端處理資料後實現一個樹狀的呼叫流圖。

一個簡單的呼叫流圖示例如下所示:

com.evilpan.Foo.onCreate
├── com.evilpan.Foo.getContacts
│   ├── Context.getContentResolver
│   ├── ContentResolver.query
│   ├── Cursor.getColumnIndex
│   ├── Cursor.getString
│   ├── ...
│   └── Cursor.close
└── com.evilpan.Foo.upload
    ├── URL.<init>
    ├── URL.openConnection
    ├── HttpURLConnection.getOutputStream
    ├── BufferWriter.write
    └── ...

前端通過處理和過濾這些資料,可以在很大程度上還原程式行為。那麼要如何實現所有 Java 方法的追蹤呢? entry_point_from_quick_compiled_code_ 是一個重點關注的點,但如果我們想要像 frida 一樣劫持,就需要對每個方法做許多額外的工作,比如修改函式的 access_flag,修改直譯器執行流程等。因此關鍵點還是在於如何同時處理解釋執行和快速執行的程式碼,並將潛在的 JIT 執行時優化考慮進去,自己造一個輪子無可厚非,但其實 ART 中已經提供了這麼一個“後門”,那就是在上文 LinkCode 程式碼中的那句:

runtime->GetInstrumentation()->InitializeMethodsCode(method, quick_code);

Instrumentation::InitializeMethodsCode 的實現中,會先判斷當前是否已經註冊了追蹤的 stub,如果有的話會直接替換對應方法的入口點:

// art/runtime/instrumentation.cc
void Instrumentation::InitializeMethodsCode(ArtMethod* method, const void* aot_code)
    REQUIRES_SHARED(Locks::mutator_lock_) {
  // Use instrumentation entrypoints if instrumentation is installed.
  if (UNLIKELY(EntryExitStubsInstalled())) {
    if (!method->IsNative() && InterpretOnly()) {
      UpdateEntryPoints(method, GetQuickToInterpreterBridge());
    } else {
      UpdateEntryPoints(method, GetQuickInstrumentationEntryPoint());
    }
    return;
  }
  // ...

對於已經初始化過的 ArtMethod,還可以用 Instrumentation::InstallStubsForMethod 去為指定方法安裝跟蹤程式碼。關於 Instrumentation 網上還沒有太多公開資料,需要通過原始碼去進一步研究。

當然還是那句老話,想法是簡單的,實現是複雜的,這其中目前可預計到的問題就有:

  1. 執行時開銷;
  2. 開啟和停止方式,可以通過中斷去控制;
  3. 傳送事件的方式,使用單獨的執行緒進行佇列傳送,多程序通訊方式;
  4. 動態跟蹤的過濾,比如進入到系統方法中就不再進行跟蹤;
  5. 迴圈呼叫的識別,接收端只能看到一系列迴圈事件;
  6. ……

因此再展開就說來話長了,目前也只是在探索階段,後續有機會再單獨分享這部分內容吧。

總結

本文主要目的是分析 Android 12 中 ART 的實現,包括 Java 方法初始化和執行的過程。基於對 ART 的深入理解,我們也列舉了幾種實踐中經常遇到的場景,比如熱修復、動態注入、安全加固、脫殼等。也許在工作中信奉拿來主義,只需要工具能用就行,但瞭解工具背後的原理,才能更好適應當前不斷激化的攻防對抗環境,從而更好地迎接未來的挑戰。

參考資料