1、什麼是hook?
hook的中文含義是鉤子,介紹hook含義之前,先放一個“現實世界裡的hook”:
2021年9月,曾報道“水門事件”的華盛頓郵報記者鮑勃·伍德沃德,披露出了一件大事,直接引發了世界震動。
馬克·米利,美國的4星上將,美參聯主席,目前美國軍方的4號人物,絕對的美國高層。
在2021年1月8日,美國國會山騷亂事件的兩天後,米利再次給中國打了個電話:
“美國的情況“很穩定”,一切都很好,國會山騷亂僅僅只是“偶然事件””。
“你知道的,民主有時候就是有點亂糟糟的”。
同一天,米利在五角大樓裡秘密召開了一次會議,告知其他高階將領:
“自今天之後,軍隊執行任何重大行動之前必須首先與他磋商。”
“不管誰給你們下達命令,你們都要按照“程式”來執行,而我就是這個“程式”!”
如果把作戰行動當作一個函式,下達作戰指令當作一次函式呼叫,川建國同志當作函式呼叫者,那麼米利的行為就相當於hook了作戰函式。這樣,米利會監控所有作戰行動,並且可以中止作戰行動。
透過上面的例子,大家應該對hook有了一個初步的認識了,hook後呼叫原函式時,直接跳到指定的函式。
2、hook技術有什麼用?
hook技術的應用很廣泛,例如筆記本上的防毒軟體、網咖系統裡的監控軟體、pc遊戲的反外掛防護系統、螢幕錄製軟體fraps都會使用hook來實現一些高階功能。
安全軟體:360等安全軟體會hook一些入口函式(例如驅動載入)獲得系統最高許可權。
監控軟體:你在網咖上網時,監控軟體hook了connect、send、recv等函式,這樣訪問的所有網站都會被記錄下來(不要做壞事)。
遊戲攻防:例如fps遊戲外掛會hook d3d的DrawIndexedPrimitive實現透視功能。
3、什麼是inlinehook?
hook的實現有很多種,inlinehook是hook的一種方式。透過修改原函式開頭的彙編指令,直接跳轉到指定函式。
開源的inlinehook庫有很多,例如subhook和微軟的Detours,本文會帶大家從0開始,一步一步實現一個基礎的inlinehook庫。
4、inlinehook庫程式碼結構
程式碼已經上傳gitee,不用github的主要原因是國內gitee網速好一些。文章先介紹一些hook庫程式碼結構,再介紹典型的使用場景。
關注微信“東北碼農”,回覆inlinehook可獲取程式碼地址。
4.1、hook庫程式碼
xx_mem.hpp:修改程式碼段屬性,改為可讀寫、可執行
xx_asm.hpp:工具函式,向目標地址寫入彙編指令,例如jmp,ret,push等。
xx_inline_hook.hpp:封裝hook的c介面。包括hook、製作跳板、偏移重定位等操作。
4.2、經典場景
test_hook_jmp32:inlinehook入門示例,基礎0xe9的jmp跳轉。
test_hook_jmp64:64bit作業系統跳轉函式,hook系統函式必備。
test_trampoline:跳板函式,hook以後如何再呼叫原函式?
test_trampoline_relocation:跳板函式中包含相對偏移,如何重定位?
5、inlinehook程式碼實現
咱們透過這幾個經典場景,來說一說。
場景一:初級、經典inlinehook
inlinehook本質是一種彙編程式碼修改技術,修改原函式,開頭第一條彙編指令改為jmp跳轉到我們的函式。先看一下demo:
//test_hook_jmp32
///////////////////////////////////////////////////////////
void hello_world()
{
printf("[call %s]\n",__FUNCTION__);
}
void my_hello_world()
{
printf("[call %s]\n", __FUNCTION__);
}
void test_hook_jmp32()
{
// 修改被hook函式記憶體屬性為可寫
xx_mem_unprotect(hello_world, 4096);
// 在函式開頭插入jmp語句,跳轉到my_hello_world
xx_setjmp32(&hello_world, &my_hello_world);
// 測試一下
hello_world();
}
int main()
{
printf("\n\n======test_hook_jmp32=================\n");
test_hook_jmp32();
}
執行結果螢幕輸出
======test_hook_jmp32=================
[call my_hello_world]
可見,呼叫hello_world時,實際執行的是my_hello_world。
下面說說實現,主要有兩步:修改程式碼段記憶體屬性;插入jmp彙編指令。
修改程式碼段記憶體屬性
// 修改被hook函式記憶體屬性為可寫
xx_mem_unprotect(hello_world, 4096);
一段記憶體有是否可讀、是否可寫、是否可執行等屬性。程式碼段預設是不可寫的(不可修改),所以需要先設定為可寫才能修改。xx_mem_unprotect的windows實現,呼叫VirtualProtect系統api來實現(linux下的api是mmap)
static bool xx_mem_unprotect(void* address, size_t size) {
DWORD old_flags;
BOOL result = VirtualProtect(address,
size,
PAGE_EXECUTE_READWRITE,
&old_flags);
return result == TRUE;
}
插入jmp彙編指令
// 在函式開頭插入jmp語句,跳轉到my_hello_world
xx_setjmp32(&hello_world, &my_hello_world);
hook以後,hello_world的彙編指令如下:
void hello_world()
{
00007FF6642410B0 E9 8B 00 00 00 jmp my_hello_world (07FF664241140h)
00007FF6642410B5
jmp指令共佔用5個位元組,指令opcode 0xe9佔用1位元組,偏移量佔用4位元組。偏移量是目標地址相對本地址的偏移。
上面的07FF664241140(下一條指令地址)-00007FF6642410B5(目標地址)=8b,剛好是e9後面的值。
程式碼實現部分
首先製作一個輔助類,用於向目標地址寫入jmp彙編指令
彙編指令
//JMP rel32
class jmp_rel32
{
public:
struct asm_cmd {
uint8_t opcode_;
int32_t rel32_;
};
static void write(void* cmd_addr, int32_t rel32) {
auto* cmd = (asm_cmd*)cmd_addr;
cmd->opcode_ = 0xe9;
cmd->rel32_ = rel32;
}
static uint8_t size() { return sizeof(asm_cmd); }
};
其中write是寫入指令,size返回本指令長度,後續會不斷擴充彙編指令類,都有這兩個介面。
下面再實現xx_setjmp32 就簡單多了,計算一下偏移,然後寫入指令。
// ret:兩個地址的偏移
static int64_t xx_get_offset(void* src, void* dst) {
return (char*)dst - (char*)src;
}
// ret:返回從src jmp 到dst的偏移
static int64_t xx_get_jmp32_offset(void* src, void* dst) {
return xx_get_offset((char*)src + jmp_rel32::size(), dst);
}
// 寫入彙編,32bit位移跳轉,jmp到dst
// ret 5(修改5 byte)
static uint32_t xx_setjmp32(void* src, void* dst) {
int32_t offset = (int32_t)xx_get_jmp32_offset(src, dst);
jmp_rel32::write(src, offset);
return jmp_rel32::size();
}
現在,我們的hook庫支援功能如下:
- 32位跳轉
場景二:64位系統的hook
上一個場景使用的jmp指令跳轉,jmp指令有一個限制是,跳轉地址與原地址的偏移不能超過int32的範圍,在64bit作業系統下可能無法跳過去。我們的hook庫需要進化一下,支援在64位地址空間下任意跳轉。
偏移足夠小時,儘量使用32位jmp跳轉。因為32位跳轉只修改5位元組,而64位大跳需要修改14位元組,hook時儘量減少對原函式修改。
先看一下demo,為了展示“大跳”,hook一個系統函式
//test_hook_jmp64
///////////////////////////////////////////////////////////
int __cdecl my_fclose(
_Inout_ FILE* _Stream
) {
printf("[call %s]\n", __FUNCTION__);
return 0;
}
void test_hook_jmp64()
{
// 判斷偏移是否滿足32位
int64_t offset = xx_get_offset(&fclose, &my_fclose);
bool need_far_jmp = xx_int32_overflow(offset);
printf("need_far_jmp=%u \n", need_far_jmp);
// 修改被hook函式記憶體屬性為可寫
xx_mem_unprotect(&fclose, 1024);
// 藉助ret彙編指令實現64位跳轉
xx_setjmp64(&fclose, &my_fclose);
// 測試一下
fclose(nullptr);
}
int main()
{
printf("\n\n======test_hook_jmp64=================\n");
test_hook_jmp64();
}
執行結果螢幕輸出
======test_hook_jmp64=================
need_far_jmp=1
[call my_fclose]
need_far_jmp=1代表需要“大跳”。
如何判斷是否需要大跳
判斷時,計算原函式和跳轉函式的偏移,是否在int32的範圍即可。
// ret:是否超過int32範圍
static bool xx_int32_overflow(int64_t val) {
return val < INT32_MIN || val > INT32_MAX;
}
64位大跳實現
實現時藉助ret彙編指令。先說說ret彙編指令,在正常函式呼叫時,會先將返回地址入棧,等函式執行完後再呼叫ret指令完成返回地址出棧+跳轉。
實現64位跳轉時,先把要跳轉的地址入棧,然後再呼叫ret指令實現跳轉。
先看一下修改後,原函式的彙編程式碼:
00007FF97A2596A0 68 D0 10 A0 BE push 0FFFFFFFFBEA010D0h
00007FF97A2596A5 C7 44 24 04 F7 7F 00 00 mov dword ptr [rsp+4],7FF7h
00007FF97A2596AD C3 ret
push+mov指令負責把地址壓棧,ret指令負責跳轉。
程式碼實現部分
首先也是彙編指令輔助類,這裡需要3個push、mov、ret。每個輔助類還是有write和size兩個介面。
// PUSH imm32
class push_imm32
{
public:
struct asm_cmd {
uint8_t opcode_;
uint32_t imm32_;
};
static void write(void* cmd_addr, uint32_t imm32) {
auto* cmd = (asm_cmd*)cmd_addr;
cmd->opcode_ = 0x68;//jmp
cmd->imm32_ = imm32;
}
static uint8_t size() { return sizeof(asm_cmd); }
};
// mov dword ptr[rsp + offset],imm32
class mov_rsp_ptr_imm32
{
public:
struct asm_cmd {
uint8_t opcode_;
uint8_t para1_;
uint8_t reg_type_;
int8_t offset_;
uint32_t imm32_;
};
static void write(void* cmd_addr, int8_t off, uint32_t imm32) {
auto* cmd = (asm_cmd*)cmd_addr;
cmd->opcode_ = 0xc7;//mov
cmd->para1_ = 0x44;// to reg ptr
cmd->reg_type_ = 0x24;//rsp
cmd->offset_ = off;
cmd->imm32_ = imm32;
}
static uint8_t size() { return sizeof(asm_cmd); }
};
// ret
class ret
{
public:
struct asm_cmd {
uint8_t opcode_;
};
static void write(void* cmd_addr) {
auto* cmd = (asm_cmd*)cmd_addr;
cmd->opcode_ = 0xc3;
}
static uint8_t size() { return sizeof(asm_cmd); }
};
寫入這3條彙編,程式碼如下:
// 寫入彙編,64bit位移跳轉利用ret來跳轉
// ret 14(修改14 byte)
static uint32_t xx_setjmp64(void* src, void* dst) {
char* cmd_addr = (char*)src;
push_imm32::write(cmd_addr, (uint32_t)(uintptr_t)dst);
cmd_addr += push_imm32::size();
mov_rsp_ptr_imm32::write(cmd_addr, 4, (uint32_t)(((uintptr_t)dst) >> 32));
cmd_addr += mov_rsp_ptr_imm32::size();
ret::write(cmd_addr);
return push_imm32::size() + mov_rsp_ptr_imm32::size() + ret::size();
}
現在,我們的hook庫支援功能如下:
- 32位跳轉
- 64位跳轉
為了方便使用,封裝了一個xx_setjmp函式,自動判斷偏移量,選擇合適的跳轉方式,程式碼如下:
// 寫入彙編,自動判斷偏移選擇適合彙編指令
// ret 修改位元組數
static uint32_t xx_setjmp(void* src, void* dst) {
// 64bit system,check jmp32 ok.
int64_t dis = xx_get_jmp32_offset(src, dst);
if (xx_int32_overflow(dis))
return xx_setjmp64(src, dst);
else
return xx_setjmp32(src, dst);
}
場景三:使用跳板,跳回原函式
前兩個場景都沒有呼叫原函式,如果想呼叫原函式怎麼做呢?例如實現監控程式,跳轉函式中記錄函式引數,然後再呼叫原函式。直接呼叫原函式是不行的,會再次跳轉到跳轉函式。
我們先看一下demo,模擬監控功能,記錄函式呼叫引數後,藉助“跳板”執行原邏輯。
//test_trampoline
///////////////////////////////////////////////////////////
char xx_trampoline[1024];
int sum(int a,int b)
{
return a + b;
}
int mysum(int a, int b)
{
printf("[call %s]!a=%d,b=%d\n", __FUNCTION__,a,b);
//typedef int(sum_func_t)(int, int);
//sum_func_t *f = (sum_func_t*)((char*)xx_trampoline);
// 執行原邏輯
auto ori_func = xx_trampoline_to_func(&sum, xx_trampoline);
return (*ori_func)(a, b);;
}
void test_trampoline()
{
// 製作跳板
xx_mem_unprotect(xx_trampoline,1024);
xx_make_trampoline(&sum, xx_trampoline, 4+4);
// hook
xx_mem_unprotect(&sum, 1024);
xx_setjmp(&sum, &mysum);
// test
int a = sum(1, 2);
printf("a=%u\n",a);
}
int main()
{
printf("\n\n======test_trampoline=================\n");
test_trampoline();
}
執行結果螢幕輸出
======test_trampoline=================
[call mysum]!a=1,b=2
a=3
如何製作跳板?例如hook時破壞了原函式的前3條彙編指令,那麼hook前需要申請一塊記憶體把前3條指令複製過去,然後再跳轉到原函式第四條指令,這塊記憶體就是所謂的跳板。
跳板需要複製多少程式碼
複製彙編程式碼時,是以彙編指令為單位來複制的,不能修改了5位元組,就複製5位元組,那樣有可能複製到半條指令。究竟複製多少位元組呢?好多hook庫都內嵌了一個反彙編引擎,自動判斷需要複製條數,本文為了讓大家更深入理解,決定人工計算,透過引數指定複製位元組數。
首先先判斷原函式需要修改多少位元組(使用32位跳轉還是64位跳轉),xx_setjmp 的返回值是修改位元組數,就是為了這裡使用。我們的demo是修改5位元組。
// 寫入彙編,自動判斷偏移選擇適合彙編指令
// ret 修改位元組數
static uint32_t xx_setjmp(void* src, void* dst)
然後觀察原函式彙編程式碼
int sum(int a,int b)
{
00007FF728C411F0 89 54 24 10 mov dword ptr [b],edx
00007FF728C411F4 89 4C 24 08 mov dword ptr [a],ecx
00007FF728C411F8 8B 44 24 10 mov eax,dword ptr [b]
00007FF728C411FC 8B 4C 24 08 mov ecx,dword ptr [a]
最少修改5位元組 ,完整指令,所以需要複製2條指令,4+4=8位元組。
複製程式碼+跳回去
製作跳板比較簡單,複製程式碼後面跟著跳轉語句跳回原函式。
// 製作跳板,hook以後再跳回去
// ret:跳板長度
static uint32_t xx_make_trampoline(void* src, void* trampoline, uint32_t copy_len) {
memcpy(trampoline, src, copy_len);
return copy_len + xx_setjmp((char*)trampoline + copy_len,(char*)src + copy_len);
}
呼叫前,別忘了給跳板記憶體增加可執行屬性!
// 製作跳板
xx_mem_unprotect(xx_trampoline,1024);
xx_make_trampoline(&sum, xx_trampoline, 4+4);
現在,我們的hook庫支援功能如下:
- 32位跳轉
- 64位跳轉
- 跳板功能
場景四:跳板偏移修復
上個場景中,跳板直接複製原函式程式碼就行了,不過有時複製來的程式碼確是錯的,例如下面這個demo,我們看一下
bool g_ready = true;
char xx_trampoline2[1024];
void flag_print() {
if (g_ready) {
printf("[call %s]!ready\n",__FUNCTION__);
}
else {
printf("[call %s]!not ready\n", __FUNCTION__);
}
}
void my_flag_print() {
printf("[call %s]!\n", __FUNCTION__);
auto ori = xx_trampoline_to_func(&flag_print, xx_trampoline2);
(*ori)();
}
void test_trampoline_relocation() {
// 製作跳板
xx_mem_unprotect(xx_trampoline2, 1024);
xx_make_trampoline(&flag_print, xx_trampoline2, 4 + 7);
// 跳板偏移重定位
xx_reloc_offset(&flag_print, xx_trampoline2, 4 + 3 );
xx_setjmp(&flag_print, &my_flag_print);
flag_print();
}
什麼時候跳板需要重定位?
我們在第25行執行了偏移重定位,我們看一下重定位之前的原函式和跳板函式的彙編指令。
原函式
void flag_print() {
00007FF635091070 48 83 EC 28 sub rsp,28h
00007FF635091074 0F B6 05 85 3F 00 00 movzx eax,byte ptr [g_ready (07FF635095000h)]
跳板
00007FF635095450 48 83 EC 28 sub rsp,28h
00007FF635095454 0F B6 05 85 3F 00 00 movzx eax,byte ptr [7FF6350993E0h]
00007FF63509545B E9 1B BC FF FF jmp flag_print+0Bh (07FF63509107Bh)
大家發現沒有,第二條指令,位元組是一樣的,但是指令不同!
原函式
00007FF635091074 0F B6 05 85 3F 00 00 movzx eax,byte ptr [g_ready (07FF635095000h)]
跳板
00007FF635095454 0F B6 05 85 3F 00 00 movzx eax,byte ptr [7FF6350993E0h]
因為這條mov彙編指令使用的是相對偏移,指令所在位置不同,絕對地址就不同,大家可以回想一下前面的jmp指令。這種引數是偏移量的指令,就需要重定位。
以後我們會內嵌一個反彙編引擎,查表可以判斷是否需要重定位,不用肉眼看了。
重定位概念很有用,以後手動載入dll或記憶體完整性校驗時也會提到。
重定位過程
首先透過原函式計算絕對地址,然後根據跳板地址計算偏移。
// 偏移重定位,從src+val_offset獲取絕對addr,寫入trampoline+val_offset
// val_offset:偏移的位移
static bool xx_reloc_offset(void* src, void* trampoline, uint32_t val_offset)
{
// 獲取絕對地址
int32_t* src_offset = (int32_t*)((char*)src + val_offset);
char* src_next_cmd = (char*)src + val_offset + sizeof(int32_t);
char* real_addr = src_next_cmd + *src_offset;
// 判斷trampoline相對於絕對地址的偏移是否超過int32
char* tra_next_cmd = (char*)trampoline + val_offset + sizeof(int32_t);
int64_t tra_offset_val = xx_get_offset(tra_next_cmd,real_addr);
if (xx_int32_overflow(tra_offset_val)) {
return false;
}
// trampoline重定位
int32_t* tra_offset = (int32_t*)((char*)trampoline + val_offset);
*tra_offset = (int32_t)tra_offset_val;
return true;
}
至此,我們的hook庫功能基本齊全,可以用了。支援功能如下:
- 32位跳轉
- 64位跳轉
- 跳板功能
- 跳板偏移重定位
接下來會介紹遠端執行緒注入、然後配合inlinehook去觀察其它軟體如何工作。
最後,求關注、點贊、轉發,謝謝~