作者:0x7F@知道創宇404實驗室
時間: 2023年10月17日

0x00 前言

TinyInst 是一個基于調試器原理的輕量級動態檢測庫,由 Google Project Zero 團隊開源,支持 Windows、macOS、Linux 和 Android 平臺。同 DynamoRIO、PIN 工具類似,解決二進制程序動態檢測的需求,不過相比于前兩者 TinyInst 更加輕量級,更加便于用戶理解,更加便于程序員進行二次開發。

本文將通過分析 TinyInst 在 Windows 平臺上的插樁源碼,來理解 TinyInst 的基本運行原理;為后續調試 TinyInst 的衍生工具(如 Jackalope fuzzing 工具)或二次開發打下基礎。

本文實驗環境

Windows10 x64 專業版
Visual Studio 2019
TinyInst (commit:5a45ad40007e00fb2172dc4139ef1e2a9532992a)

0x01 編譯運行

在搭建好 Visual Studio 和 Python3 的開發環境后,從 github 拉取 TinyInst 的源碼:

git clone --recurse-submodules https://github.com/googleprojectzero/TinyInst.git

可以參考官方提供的 cmake 的編譯流程,在 Developer Command Prompt for VS 2019 開發者命令行中:

# C:\Users\john\Desktop\TinyInst
mkdir build
cd buildmake 
cmake -G "Visual Studio 16 2019" -A x64 ..
cmake --build . --config Release

編譯完成后,二進制文件位于 [src]\build\Release\litecov.exe

這里我們使用 Visual Studio 來編譯項目,以便于后續進行源碼分析和調試;打開 Visual Studio 后點擊 文件-打開-CMake 使用 CMakeLists.txt 文件加載 TinyInst 項目如下:

1.使用vs加載CMakeLists項目

其默認為 x64-Debug 的配置方案,使用 生成-全部生成 編譯項目,二進制文件位于 [src]\out\build\x64-Debug\litecov.exe

隨后我們使用 Visual Studio 編譯一個 HelloWorld 作為目標程序(Debug/x64):

#include <stdio.h>

int main(int argc, char* argv[]) {
    printf("Hello World\n");
    return 0;
}

如下命令使用 litecov.exe 對目標程序 HelloWorld.exe 進行動態檢測,發現了 282 條新路徑:

.\litecov.exe -instrument_module HelloWorld.exe -trace_debug_events -- .\HelloWorld.exe

執行如下: 2.litecov.exe運行測試

TinyInst 默認使用 basic-block(基礎塊) 覆蓋統計,如上即產生了 282 個基礎塊覆蓋。

0x02 實現原理概要

通過官方文檔的介紹(https://github.com/googleprojectzero/TinyInst#how-tinyinst-works),我們可以大致了解其運行原理;TinyInst 以調試器的身份啟動/附加目標程序,通過監視目標進程中的調試事件,如加載模塊、命中斷點、觸發異常等,實現對目標程序的完全訪問和控制,進而實現插樁和覆蓋率收集等功能。

當 TinyInst 首次加載目標模塊時,他會將目標模塊中的代碼段設置為不可執行(原始內存空間),在后續執行流抵達后,目標程序將觸發 0xC0000005(Access Violation) 異常;同時 TinyInst 還會在目標模塊地址范圍的 2GB 范圍內,開辟內存空間以放置二進制重寫的代碼(工作內存空間);

當執行流進入目標模塊后,TinyInst 將收到目標程序拋出的 0xC0000005 的異常,此時 TinyInst 將從執行流的位置按 basic-block(基礎塊) 解析代碼指令,在基礎塊頭部添加插樁代碼、修正末尾的跳轉指令偏移,再將整塊指令代碼寫入工作內存空間中,隨后跟隨跳轉指令,遞歸發現、解析和重寫所有的基本塊代碼。

最后 TinyInst 將目標程序的 RIP 寄存器指向二進制重寫的代碼的開始位置(工作內存空間),目標程序真正開始運行,并在運行過程中完成覆蓋率的記錄。

簡單梳理 TinyInst 的源碼,程序入口位于 tinyinst-coverage.cpp#main(),按照類的繼承關系,整體可以分為三大模塊:

  1. Debugger:底層的調試器實現,負責處理調試事件
  2. TinyInst:繼承于 Debugger,負責目標程序的訪問和控制、插樁相關實現,是程序核心部分
  3. LiteCov:繼承于 TinyInst,負責覆蓋率的相關實現

3.核心類關系示意圖

有了以上基礎了解后,下面我們就通過源碼級的靜態分析 + 動態調試來深入剖析 TinyInst 詳細的實現原理。

0x03 調試器原理

TinyInst 基于調試器進行實現,我們先來簡單了解調試器原理,TinyInst 在完成初始化操作后,會以 DEBUG_PROCESS 的方式啟動目標程序,隨后循環處理調試事件,以此方式訪問目標程序的數據并控制目標程序的執行情況。

其底層調試器的簡易實現,如下:

#include <Windows.h>
#include <stdio.h>

int main(int argc, char* argv[]) {
    STARTUPINFO si = { 0 };
    si.cb = sizeof(si);

    PROCESS_INFORMATION pi = { 0 };
    if (CreateProcess("HelloWorld.exe", "HelloWorld.exe", NULL, NULL, FALSE, DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, NULL, NULL, &si, &pi) == FALSE) {
        printf("CreateProcess failed : %d\n", GetLastError());
        return -1;
    }

    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);

    BOOL waitEvent = TRUE;
    DEBUG_EVENT debugEvent;
    while (waitEvent == TRUE && WaitForDebugEvent(&debugEvent, INFINITE)) {
        DWORD status = DBG_CONTINUE;

        switch (debugEvent.dwDebugEventCode) {
        case CREATE_PROCESS_DEBUG_EVENT:
        case CREATE_THREAD_DEBUG_EVENT:
        case EXCEPTION_DEBUG_EVENT:
        case EXIT_PROCESS_DEBUG_EVENT:
        case EXIT_THREAD_DEBUG_EVENT:
        case LOAD_DLL_DEBUG_EVENT:
        case UNLOAD_DLL_DEBUG_EVENT:
        case OUTPUT_DEBUG_STRING_EVENT:
        case RIP_EVENT:
        default:
            printf("unhandle/unknown debug event\n");
        }

        if (waitEvent == TRUE) {
            ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, status);
        }
    }

    return 0;
}

在 TinyInst 中調試器實現的核心邏輯位于 [src]\Windows\debugger.cpp# Debugger::DebugLoop(),如下:

4.TinyInst調試事件處理

0x04 配置源碼調試

上文我們通過 Visual Studio 加載了 TinyInst 項目,Visual Studio 能夠很好的幫助我們進行靜態分析,這里我們還需配置其源碼的動態調試環境。

首先配置 cmake 項目的啟動參數,在 Visual Studio 中右鍵 CMakeLists.txt 選擇 添加調試配置,隨后在 launch.vs.json 文件中添加啟動參數如下:

{
  "version": "0.2.1",
  "defaults": {},
  "configurations": [
    {
      "type": "default",
      "project": "CMakeLists.txt",
      "projectTarget": "litecov.exe",
      "name": "litecov.exe",
      "args": [ "-instrument_module", "HelloWorld.exe", "-trace_debug_events", "--", ".\\HelloWorld.exe" ]
    }
  ]
}

隨后設置啟動項為 litecov.exe,如下: 5.設置litecov啟動項

tinyinst-coverage.cpp#main() 打下斷點,啟動調試如下: 6.源碼動態調試

至此 TinyInst 的分析環境我們搭建好了。

0x05 目標程序初始化

下面我們跟隨 TinyInst 完整的執行流程來分析其實現。以 .\litecov.exe -instrument_module HelloWorld.exe -trace_debug_events -- .\HelloWorld.exe 命令啟動后,其最終將調用 CreateProcess() 以調試模式啟動目標程序,如下:

7.CreateProcess啟動目標程序

此處調用棧為:

8.啟動目標程序的調用棧

目標程序啟動后,TinyInst 進入 debugger.cpp#Debugger::DebugLoop() 調試事件循環中;目標程序默認會在初始化前拋出 0x80000003(EXCEPTION_BREAKPOINT) 斷點異常,TinyInst 接收到該斷點異常后,從目標程序加載的模塊中找到目標模塊(HelloWorld.exe),隨后在目標模塊的入口點(start())添加 0xCC 斷點指令,如下:

9.在目標模塊入口處設置斷點

隨后,TinyInst 繼續運行目標程序(默認斷點無需額外處理),目標程序執行流抵達目標模塊后,將如期觸發我們在 start() 設置的斷點,TinyInst 接過控制權后,將調用核心插樁函數 tinyinst.cpp#TinyInst::InstrumentModule(),在該函數中調用 ExtractCodeRanges() 設置目標模塊的代碼段為 可讀可寫不可執行 權限,如下:

10.設置代碼段為不可執行

這樣操作的目的是當目標程序執行流抵達時,由于代碼為不可執行權限,將拋出 0xC0000005 異常,從而將控制權轉交給 TinyInst;

調用 ExtractCodeRanges() 后緊接著 TinyInst 將在目標模塊前或后的 2GB 內存空間內申請空間,作為二進制重寫的工作內存空間,其申請的大小為 原始代碼段大小 * 插樁指令放大系數4 + 全局跳轉表大小,其中全局跳轉表項為固定值 0x2000 個,隨后通過 InitGlobalJumptable() 初始化全局跳轉表,如下:

11.TinyInst工作內存空間初始化

我們將在「0x07 全局跳轉表」進行分析,接下來將先分析插樁操作。

0x06 二進制重寫

TinyInst 采用的是二進制重寫的方案進行插樁,緊接著上文的代碼邏輯繼續跟進;TinyInst 還原模塊入口點的斷點后繼續執行目標程序,執行流抵達目標模塊后拋出 0xC0000005 異常,隨后控制權轉交給 TinyInst,TinyInst 最終調用 tinyinst.cpp#TinyInst::TryExecuteInstrumented() 開始插樁操作,這里調用棧為:

12.TryExecuteInstrumented調用棧

跟入 TinyInst::TryExecuteInstrumented() 函數,最終調用 TranslateBasicBlockRecursive() 循環解析基礎代碼塊(basic-block),其中 queue 為待解析的基礎塊,由 TranslateBasicBlock() 進行解析當前基礎塊并添加新的基礎塊,如下:

13.TranslateBasicBlock代碼片段

TranslateBasicBlock() 函數中,執行實際的插樁操作如下:

14.插樁和指令代碼解析片段

首先使用 InstrumentBasicBlock() 在基礎塊的頭部寫入插樁代碼 mov 指令,這里的地址是 TinyInst 在目標模塊的工作內存空間初始化的覆蓋率 bitmap, 并且 TinyInst 將基礎塊地址和 bitmap 的索引一一對應,當執行到該基礎塊時,將在 bitmap 中設置為 1,TinyInst 就以此方法進行覆蓋率的記錄。(這里 mov 的地址為占位符,根據實際偏移進行修正)

// mov byte ptr [rip+offset], 1
// note: does not clobber flags
static unsigned char MOV_ADDR_1[] = {0xC6, 0x05, 0xAA, 0xAA, 0xAA, 0x0A, 0x01};

隨后通過 while 循環逐條解析并復制指令,直到遇到跳轉指令(如:jmp/call/ret),然后在 HandleBasicBlockEnd() 函數中處理跳轉指令,如下處理有條件的跳轉指令:

在 TinyInst 中將跳轉指令分為 4 個大類:

  1. 返回指令:ret
  2. 有條件的跳轉指令: je / jp / ...
  3. 無條件的跳轉指令: jmp
  4. 函數調用指令: call

不同的跳轉指令有不同的處理方式,但其本質都是為了連接上下文以及修正跳轉地址;但這里還有更為重要的一個操作是區分遠跳轉(外部調用)和近跳轉(內部調用),若為近跳轉則拼接基礎塊代碼即可,若為遠跳轉,則將其調用地址改為全局跳轉表的地址,由全局跳轉表完成后續的調用過程。

循環發現并解析完所有的基礎塊后,再統一修復在解析過程中待定的跳轉地址,最后將二進制重寫的代碼寫入目標模塊的工作內存空間內,修改目標程序的 RIP 到二進制重寫的代碼的入口,隨后目標程序正式開始執行。

二進制重寫示例
HelloWorld.exe 為例,我們這里可以通過比較原始代碼和二進制重寫的代碼,來演示二進制重寫的過程;如上文描述,當 TinyInst 收到 HelloWorld.exe0xC0000005 異常,此時 RIP 正位于程序入口處 start(),其原始代碼如下:

16.HelloWorld的start代碼

以及其 jmp 后的 mainCRTStartup() 原始代碼如下:

17.HelloWorld的mainCRTStartup代碼

經過 TinyInst 二進制重寫后,start()mainCRTStartup() 對應的代碼如下:

18.HelloWorld的二進制重寫代碼

這里有個小技巧,我們可以使用 WinDBG 非侵入模式的觀測被調試程序的內存,如上我們觀測 HelloWorld.exe 中二進制重寫的代碼;不過需要注意一點,WinDBG detach 后目標程序才可以繼續運行。

0x07 全局跳轉表

經過以上二進制重寫后,目標模塊可以順利執行模塊本身的代碼,但還無法處理外部調用,這就需要全局跳轉表來完成。

我們回到全局跳轉表 InitGlobalJumptable() 初始化函數,其首先在二進制重寫的內存空間前 0x2000 項中循環寫入一個跳轉地址,該跳轉地址為 內存起始地址 + 指針大小(8) * 0x2000 + 0x08,并在跳轉地址寫入 0xCC 斷點指令,同時在第 0x2000 項的位置寫入全局跳轉表的起始地址,如下:

19.InitGlobalJumptable代碼

初始化后的全局跳轉表示例如下:

0:000> dq 0x00007ff73b950000
00007ff7`3b950000  00007ff7`3b960008 00007ff7`3b950008
00007ff7`3b950010  00007ff7`3b960008 00007ff7`3b950008
00007ff7`3b950020  00007ff7`3b960008 00007ff7`3b950008
......
00007ff7`3b95fff0  00007ff7`3b960008 00007ff7`3b950008
00007ff7`3b960000  00007ff7`3b950000 xxxxxxxx`xxxxxxcc

在「0x06 二進制重寫」解析基礎塊的過程中,若發現目標模塊進行遠跳轉(外部調用)則會使用全局跳轉表來完成,以 call(far) 指令為例,TinyInst 將其轉換為:

# call function_address

    call label
    jmp  return_address
label:
    pushfq
    push rax
    push rbx
    mov  rax, function_address
    mov  rbx, rax
    and  rbx, 0x0FFF8 (length of JUMPTABLE)
    add  rbx, JUMPTABLE_START_ADDRESS
    jmp  rbx

以上二進制重寫的代碼主要操作為:保存 eflags/rax/rbx 到棧中,將要調用的函數地址 function_address 保存在 rax 中,隨后將其與全局跳轉表長度 0x0FFF8 計算 hash 并保存在 rbx 中,從 rbx 繼續運行。

全局跳轉表中所對應的 hash 位置,默認指向跳轉地址,其對應的指令為 0xCC,TinyInst 捕獲該斷點異常后,調用 tinyinst.cpp#TinyInst::HandleIndirectJMPBreakpoint 繼續完成遠跳轉流程;跟進 tinyinst.cpp#TinyInst::AddTranslatedJump 函數如下:

20.AddTranslatedJump代碼片段

該函數將寫入如下函數調用指令:首先通過 rax 檢查目標函數地址(用于 hash 碰撞檢測),隨后從棧中還原 rbx/rax/eflags,最終調用目標函數執行,完成整個外部函數調用流程。

    cmp rax, original_address
    je  label
    jmp JUMPTABLE_JUMP_ADDRESS
label:
    pop rbx
    pop rax
    popfq
    jmp [actual_address]
    [original_address]
    [actual_address]

這里的實現較為復雜,原因是 TinyInst 兼容實現了 jmp 指令的遠跳轉,本文這里不進行拓展分析。

除此之外,該函數還會修正全局調用表中對應的 hash 位置,再次調用該函數時將直接跳轉至以上代碼,以代碼緩存的方式提高執行性能。

0x08 執行流程示意圖

通過以上「二進制重寫」和「全局跳轉表」的相互配合,TinyInst 實現了基本的動態檢測功能;下面我們用狀態圖來總結概括 TinyInst 的插樁實現流程,如下:

21.TinyInst插樁流程

在以上 TinyInst 的控制下,目標程序的執行流程如下:

22.目標程序執行流程

0x09 References

  1. https://github.com/googleprojectzero/TinyInst

  2. https://www.anquanke.com/post/id/234925


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.jmbmsq.com/3053/