作者: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 項目如下:

其默認為 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
執行如下:

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(),按照類的繼承關系,整體可以分為三大模塊:
- Debugger:底層的調試器實現,負責處理調試事件
- TinyInst:繼承于 Debugger,負責目標程序的訪問和控制、插樁相關實現,是程序核心部分
- LiteCov:繼承于 TinyInst,負責覆蓋率的相關實現

有了以上基礎了解后,下面我們就通過源碼級的靜態分析 + 動態調試來深入剖析 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(),如下:

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,如下:

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

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

此處調用棧為:

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

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

這樣操作的目的是當目標程序執行流抵達時,由于代碼為不可執行權限,將拋出 0xC0000005 異常,從而將控制權轉交給 TinyInst;
調用 ExtractCodeRanges() 后緊接著 TinyInst 將在目標模塊前或后的 2GB 內存空間內申請空間,作為二進制重寫的工作內存空間,其申請的大小為 原始代碼段大小 * 插樁指令放大系數4 + 全局跳轉表大小,其中全局跳轉表項為固定值 0x2000 個,隨后通過 InitGlobalJumptable() 初始化全局跳轉表,如下:

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

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

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

首先使用 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 個大類:
- 返回指令:
ret - 有條件的跳轉指令:
je / jp / ... - 無條件的跳轉指令:
jmp - 函數調用指令:
call
不同的跳轉指令有不同的處理方式,但其本質都是為了連接上下文以及修正跳轉地址;但這里還有更為重要的一個操作是區分遠跳轉(外部調用)和近跳轉(內部調用),若為近跳轉則拼接基礎塊代碼即可,若為遠跳轉,則將其調用地址改為全局跳轉表的地址,由全局跳轉表完成后續的調用過程。
循環發現并解析完所有的基礎塊后,再統一修復在解析過程中待定的跳轉地址,最后將二進制重寫的代碼寫入目標模塊的工作內存空間內,修改目標程序的 RIP 到二進制重寫的代碼的入口,隨后目標程序正式開始執行。
二進制重寫示例
以 HelloWorld.exe 為例,我們這里可以通過比較原始代碼和二進制重寫的代碼,來演示二進制重寫的過程;如上文描述,當 TinyInst 收到 HelloWorld.exe 的 0xC0000005 異常,此時 RIP 正位于程序入口處 start(),其原始代碼如下:

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

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

這里有個小技巧,我們可以使用 WinDBG 非侵入模式的觀測被調試程序的內存,如上我們觀測
HelloWorld.exe中二進制重寫的代碼;不過需要注意一點,WinDBG detach 后目標程序才可以繼續運行。
0x07 全局跳轉表
經過以上二進制重寫后,目標模塊可以順利執行模塊本身的代碼,但還無法處理外部調用,這就需要全局跳轉表來完成。
我們回到全局跳轉表 InitGlobalJumptable() 初始化函數,其首先在二進制重寫的內存空間前 0x2000 項中循環寫入一個跳轉地址,該跳轉地址為 內存起始地址 + 指針大小(8) * 0x2000 + 0x08,并在跳轉地址寫入 0xCC 斷點指令,同時在第 0x2000 項的位置寫入全局跳轉表的起始地址,如下:

初始化后的全局跳轉表示例如下:
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 函數如下:

該函數將寫入如下函數調用指令:首先通過 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 的插樁實現流程,如下:

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

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