淺談內聯鉤取原理與實現

蚁景网安实验室發表於2024-06-13

前言

匯入地址表鉤取的方法容易實現但是存在缺陷,若需要鉤取的函式不存在匯入地址表中,那麼我們就無法進行鉤取,出現以下幾種情況時,匯入函式是不會儲存在匯入地址表中的。

  • 延遲載入:當匯入函式還沒呼叫時,匯入函式還未寫入到匯入地址表中。

  • 動態連結:使用LoadLibraryGetProcAddress函式時,程式是顯示獲取函式地址的,因此不會寫入到匯入地址表中。

  • 手動解析匯入函式:即程式自身實現一套匯入方法,那麼此時也不會將匯入函式寫入到匯入地址表中。

有一種鉤取方法解決上述問題即內聯鉤取(inline hook)。

內聯鉤取(inline hook)

內聯鉤取實際是找到需要鉤取的函式地址,這裡與匯入地址表鉤取不同的是我們不再侷限於匯入地址表,而是程式中所有的函式地址都能夠作為鉤取的物件。

這裡以CreateProcessW函式為例,在CreateProcessW函式中,第一條指令是mov edi,edi

image-20240506224301537

那麼根據鉤取的思路,我們將mov edi,edi這條指令修改為jmp xxxxxx為我們自定義函式的地址),那麼在執行CreateaProcessW函式時即可跳轉到我們的自定義函式中。

我們獲取mov edi,edi指令的地址,並且將該指令篡改為jmp指令,並且把mov edi,edi指令的資料進行儲存,那麼在執行到CreateProcessW函式時就會執行jmp指令跳轉到自定義函式中,在鉤取操作時需要將指令寫回,還原CreateProcessW函式的執行邏輯,就可以在鉤取的同時無礙的執行程式。

image-20240506225530172

那麼總結一下內聯鉤取函式的流程

  • 找到需要鉤取的函式的指令地址,這個指令並不僅限於函式起始的指令。

  • 將該指令篡改成跳轉指令,跳轉的目的就是自定義的函式。

  • 在自定義函式內需要還原被鉤取函式的指令。

因此內聯鉤取的實際就是修改程式執行邏輯,劫持程式的執行流程。由於32位程式與64位程式的組合語言與定址方式有些許差異,因此不同機器位數的程式的內聯鉤取方式不同。

機器碼的獲取

由於在篡改記憶體時需要將jmp xxx的機器碼填寫到記憶體中,因此做內聯鉤取時需要獲取指令對應的機器碼。在C語言中支援內聯彙編,因此可以使用內聯彙編然後檢視對應的機器碼即可。

但是直接使用visual studio編譯64位程式的內聯彙編程式碼會出錯,這是因為visual studio自帶的編譯工具不支援x64的內聯彙編。

image-20240507131700203

因此需要先安裝clang編譯器

image-20240507131840623

在專案的編譯工具選擇clang即可

image-20240507131913336

在反彙編視窗中就有機器碼了。

image-20240507132015250

【----幫助網安學習,以下所有學習資料免費領!加vx:dctintin,備註 “部落格園” 獲取!】

 ① 網安學習成長路徑思維導圖
 ② 60+網安經典常用工具包
 ③ 100+SRC漏洞分析報告
 ④ 150+網安攻防實戰技術電子書
 ⑤ 最權威CISSP 認證考試指南+題庫
 ⑥ 超1800頁CTF實戰技巧手冊
 ⑦ 最新網安大廠面試題合集(含答案)
 ⑧ APP客戶端安全檢測指南(安卓+IOS)

32位的內聯鉤取

首先第一步是確定在32位程式下是如何進行跳轉的,在32位情況使用跳轉指令是根據偏移獲取目的地址,偏移的計算公式如下

跳轉偏移 = 跳轉目的地址 - 當前指令地址 - 指令長度

因此jmp xxx中,xxx是偏移值而不是目的函式的絕對地址。

緊接著需要確定在32位下跳轉指令的機器碼是多少,用下面例子看看

void MyCreateProcess()
{
​
}
​
int main()
{
​
    __asm {
        jmp MyCreateProcess;
    };
​
​
}

可以看到對應的機器碼為E9 EB FF FF FF

image-20240507134049072

可以看到目標函式的地址為0xA71000,使用上述公式計算一下偏移為0xA71000 - 0x0A71010 - 5 = 0xffffffeb,因此E9jmp的機器碼

因此需要將待鉤取函式的第一條指令修改為E9 XX XX XX XX XX,長度為5個位元組

然後選擇一個目標函式,這裡還是使用CreateProcessW函式作為例子,需要先獲取CreateProcessW函式的地址

    ...
    hMoudle = GetModuleHandleA(szDllName); //獲取Kernel32.dll模組的地址
    if (hMoudle == NULL)
    {
        GetLastError();
    }
    
    pfnOld = GetProcAddress(hMoudle, funName);//獲取CreateProcessW函式地址
    if (pfnOld == NULL)
    {
        GetLastError();
    }
    ...

然後需要儲存原始指令,然後修改區域為可寫許可權,緊接著計算一下偏移把完整的指令寫進到待鉤取函式即可。

    ...
    //修改許可權
    VirtualProtect(pfnOld, 5, PAGE_EXECUTE_READWRITE, &dwOldProtect);
    //儲存原始的5個位元組
    memcpy(pOrgBytes, pfnOld, 5);
    //計算需要跳轉到的地址
    //跳轉偏移 = 跳轉目的地址 - 當前指令地址 - 指令長度
    dwAddress = (ULONGLONG)pfnNew - (ULONGLONG)pfnOld - 5;
    //將目標函式的地址寫入到指令中
    memcpy(&pBuf[1], &dwAddress, 4);
    //篡改為跳轉指令
    memcpy(pfnOld, pBuf, 5);
    //還原許可權
    VirtualProtect(pfnOld, 5, dwOldProtect, &dwOldProtect);
    ...

64位的內聯鉤取

64位下的規則會與32位有差異,但是總體思路是一致的。在32位下我們採用了偏移的方式找到目標函式,在64位下可以換種方式,採用mov rax, xxx; jmp rax,將函式的絕對地址寫入暫存器,然後跳轉到指定暫存器的方式。

如下例子,我們首先獲取自定義函式的絕對地址,緊接著將它存放於暫存器中,緊接著跳轉即可。

int main()
{
    __asm {
        mov rax, 0x1122334455667788;
        jmp rax;
    };
​
}

可以看到mov rax, xxx; jmp rax指令的機器碼為48 B8 xx xx xx xx xx xx xx xx FF E0,其中由於64位地址都是8位元組的,因此需要xx需要填充8位元組

image-20240507153239222

因此總體程式碼與32位區別不大,這裡需要注意的是篡改的指令長度需要根據實際進行更改。

    /*
    * 48 B8 88 77 66 55 44 33 22 11 mov rax, 0x1122334455667788
    * FF E0                         jmp rax
    * 需要12個位元組進行跳轉
    */
​
    //修改區域許可權
    VirtualProtect((LPVOID)pfnOrg, 12, PAGE_EXECUTE_READWRITE, &dwOldProtect);
    //儲存原有的12位元組資料
    memcpy(pOrgBytes, pfnOrg, 12);
    //將HOOK函式的地址填進緩衝區
    //將目標地址複製到指令中
    memcpy(&pBuf[2], &pfnNew, 8);
    //篡改待鉤取函式
    memcpy(pfnOrg, pBuf, 12);
    //恢復許可權
    VirtualProtect((LPVOID)pfnOrg, 12, dwOldProtect, &dwOldProtect);

因此任意可以修改函式執行流程的彙編指令實際都可以例如push xxx; ret

完整程式碼可以參考:

https://github.com/h0pe-ay/HookTechnology/tree/main/Hook-InlineHook

總結

優勢

  • 內聯鉤取相較於匯入表鉤取的選擇性更廣,可以選擇任意的函式及函式內的任意指令地址。

劣勢

  • 每次都需要脫鉤後再進行掛鉤,影響效率

  • 多執行緒寫入時可能會出錯

更多網安技能的線上實操練習,請點選這裡>>

相關文章