本帖最后由 ClumsyBear 于 2026-6-11 23:34 编辑
【新手向】动态获取系统调用号 —— 绕过用户态 Hook 的利器
很多刚接触 Windows 底层的同学都会遇到一个问题:杀软/EDR 把 ntdll.dll 里的函数 Hook 了,我们的调用被拦截怎么办?
一个经典的解法是 直接系统调用(Direct System Call)——不经过 ntdll.dll,自己触发 syscall 指令。但这里有个坑:系统调用号(System Service Number, SSN)在不同 Windows 版本间是会变化的。硬编码 SSN 意味着你的代码只能在特定版本上跑,于是便有了这篇文章。
本文将介绍一种动态获取调用号的方法:从磁盘上的 ntdll.dll 文件中读取原始字节,提取 SSN,然后填入自己的调用模板。这样无论 Win10 还是 Win11,无论什么稀奇古怪版本,应该都能自动适配。
我们知道一个API进入内核执行的流程
应用程序【kernel32!OpenProcess】 → 用户态(kernelbase!OpenProcess) → (ntdll!NtOpenProcess) → 系统调用(syscall 指令)
→ 内核态(ntoskrnl!KiSystemService / KiSystemService64)
→ 查表(根据 SSN 在 SSDT 中查找对应内核函数)
→ 检查(参数校验、权限检查、ETW 日志、回调等)
→ 执行(调用真正的内核函数,如 NtOpenProcess → ObOpenObjectByPointer)
→ 返回(原路返回:内核→syscall 返回→ntdll→kernelbase→kernel32→应用程序)
什么是系统调用号(SSN)
Windows 用户态程序想要执行特权操作(如打开文件、创建进程),必须通过系统调用进入内核。每个内核服务都有一个唯一的编号,这个编号就是 SSN。
例如 NtOpenProcess 的 SSN 在不同版本中可能是 0x26。
ntdll.dll 的调用桩(Stub)
在ida中打开‘ntdll.dll`,查看函数NtOpenProcess(其他导出函数也一样),你会看到这样的 x64 汇编:[C] 纯文本查看 复制代码
NtOpenProcess:
mov r10, rcx ; 4C 8B D1
mov eax, 26h ; B8 26 00 00 00 //这里就是 SSN
syscall ; 0F 05
ret ; C3
关键点:- 偏移 0x00:mov r10, rcx(3 字节:4C 8B D1)
- 偏移 0x03:mov eax, XXXXXXXX — 这个 XXXXXXXX 就是我们要提取的 SSN(1 字节 opcode + 4 字节立即数)
- 偏移 0x04:SSN 的第一个字节
所以直接从函数地址 + 0x04 处读取 4 个字节(DWORD),就是当前系统版本对应的调用号。
file:///C:/Users/youxing/AppData/Roaming/marktext/images/2026-06-11-02-49-30-image.png?msec=1781153787677 思路
加载 ntdll.dll
↓
GetProcAddress 获取目标函数地址
↓
读取函数起始处 +0x04 偏移的 DWORD → 得到 SSN
↓
将 SSN 填入我们的 "调用模板"
↓
调用模板 → 直接 syscall 进入内核
为什么要从磁盘加载? 因为内存中已映射的 ntdll.dll 可能已经被安全软件 Hook,指令开头的 SSN 可能被修改。从磁盘重新加载可以拿到原始的、未被篡改的调用桩。
完整代码
模板
我们需要一段汇编代码来实现"传入 SSN 并执行 syscall"。x64 下的 syscall 指令会:
——通过 rcx 传递第一个参数给内核
——通过 eax 传递系统调用号
代码[C] 纯文本查看 复制代码
#include <windows.h>
#include <winternl.h>
#include <stdio.h>
//通用 syscall 模板
typedef NTSTATUS(NTAPI* pSyscallFn)();
//生成一个可执行的 syscall 桩,调用号由 ssn 参数填入
pSyscallFn CreateSyscallStub(DWORD ssn)
{
//syscall 桩:mov r10, rcx; mov eax, SSN; syscall; ret
unsigned char stub_template[] =
{
0x4C, 0x8B, 0xD1, // mov r10, rcx
0xB8, 0x00, 0x00, 0x00, 0x00, // mov eax, SSN(占位)
0x0F, 0x05, // syscall
0xC3 // ret
};
//把调用号填入偏移 4 的位置
*(DWORD*)&stub_template[4] = ssn;
//分配内存
LPVOID execMem = VirtualAlloc(
NULL,
sizeof(stub_template),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
if (!execMem)
{
return NULL;
}
memcpy(execMem, stub_template, sizeof(stub_template));
return (pSyscallFn)execMem;
}
//动态获取NtOpenProcess函数的 SSN
DWORD GetSyscallNumber(const char* funcName)
{
//从磁盘加载 ntdll.dll,避免被 Hook 影响
HMODULE hNtdll = LoadLibraryExW(
L"C:\\Windows\\System32\\ntdll.dll",//防止DLL劫持
NULL,
DONT_RESOLVE_DLL_REFERENCES
);
if (!hNtdll)
{
printf("LoadLibraryEx 失败!错误码: %lu\n", GetLastError());
return 0;
}
// 获取函数地址
FARPROC pFunc = GetProcAddress(hNtdll, funcName);
if (!pFunc)
{
printf("找不到函数: %s\n", funcName);
FreeLibrary(hNtdll);
return 0;
}
//x64 下,ntdll 的 syscall 桩结构固定:
// +0x00: mov r10, rcx (3 字节)
// +0x03: mov eax, SSN (opcode 0xB8 + 4 字节立即数)
// +0x04: SSN 的起始字节
DWORD ssn = *(DWORD*)((BYTE*)pFunc + 4);
printf("\t%s -> SSN: 0x%04X (%u)\n", funcName, ssn, ssn);
FreeLibrary(hNtdll);
return ssn;
}
//使用示例
int main()
{
//获取 NtOpenProcess 的调用号
DWORD ssn = GetSyscallNumber("NtOpenProcess");
if (!ssn)
{
printf("获取 SSN 失败\n");
return 1;
}
//创建专用的 syscall 桩
pSyscallFn NtOpenProcess = CreateSyscallStub(ssn);
if (!NtOpenProcess)
{
printf("创建 syscall 桩失败!\n");
return 1;
}
//使用直接系统调用打开进程
HANDLE hProcess;
OBJECT_ATTRIBUTES oa = { sizeof(oa) };
CLIENT_ID cid = { (HANDLE)1234, NULL }; //目标进程 PID
NTSTATUS status = NtOpenProcess(
&hProcess,
PROCESS_ALL_ACCESS,
&oa,
&cid
);
printf("\tNtOpenProcess 返回: 0x%08X\n", status);
printf("\t打开的句柄: 0x%p\n", hProcess);
return 0;
}
我们直接看内存
file://C:%5CUsers%5Cyouxing%5CAppData%5CRoaming%5Cmarktext%5Cimages%5C2026-06-11-01-19-05-image.png?msec=1781153787677 批量获取调用表
如果你需要完整的调用表(比如写一个通用的 syscall 框架),可以遍历 ntdll 的所有导出函数并自动提取:
该代码通过手动解析 ntdll.dll的 PE Export Table,在不依赖 Windows API 的情况下,批量枚举所有 NtNative API,并直接从函数序言中提取系统调用号(SSN),是构建 Direct Syscall / Hell’s Gate / Halo’s Gate 的基础技术
[C] 纯文本查看 复制代码
//批量提取所有 Nt* 函数的 SSN
#include <windows.h>
#include <winternl.h>
#include <stdio.h>
void DumpSyscallTable()
{
HMODULE hNtdll = LoadLibraryExW(
L"C:\\Windows\\System32\\ntdll.dll",
NULL,
DONT_RESOLVE_DLL_REFERENCES
);
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
IMAGE_DATA_DIRECTORY exportDir =
pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
PIMAGE_EXPORT_DIRECTORY pExport =
(PIMAGE_EXPORT_DIRECTORY)((BYTE*)hNtdll + exportDir.VirtualAddress);
DWORD* names = (DWORD*)((BYTE*)hNtdll + pExport->AddressOfNames);
WORD* ordinals = (WORD*)((BYTE*)hNtdll + pExport->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)((BYTE*)hNtdll + pExport->AddressOfFunctions);
printf("========== ntdll Syscall Table ==========\n");
DWORD i = 0;
for (i = 0; i < pExport->NumberOfNames; i++)
{
char* funcName = (char*)((BYTE*)hNtdll + names[i]);
//只看 Nt* 且不是 Ntdll* 的函数,过滤内部函数
if (strncmp(funcName, "Nt", 2) == 0)//只保留真正的 Native API
{
BYTE* funcAddr = (BYTE*)hNtdll + funcs[ordinals[i]];
DWORD ssn = *(DWORD*)(funcAddr + 4);
printf(" %-40s -> 0x%04X\n", funcName, ssn);
}
}
FreeLibrary(hNtdll);
}
int main()
{
DumpSyscallTable();
return 0;
}
结果
file://C:%5CUsers%5Cyouxing%5CAppData%5CRoaming%5Cmarktext%5Cimages%5C2026-06-11-02-07-20-image.png?msec=1781153787678---
关键注意事项
为什么要 LoadLibraryEx 而不是 GetModuleHandle?
GetModuleHandle 返回的是当前进程中已加载的 ntdll。如果安全软件做了 IAT Hook 或 Inline Hook,你读到的可能已经是修改后的指令。
LoadLibraryEx + DONT_RESOLVE_DLL_REFERENCES 会从磁盘重新映射一个干净的副本,不执行 DllMain,不解析导入表,拿到的是原始字节。
注意:微软没有以文档形式保证过 ntdll 内部实现永不改变,因此理论上未来系统更新后这个模式可能发生变化
调用约定
直接 syscall 时,你必须完全正确地设置参数。内核期望的参数顺序和数量与 ntdll 导出的函数签名一致。参数错误可能导致 STATUS_ACCESS_VIOLATION (0xC0000005) 甚至蓝屏。x86 的情况
x86 下的调用桩不同:
[C] 纯文本查看 复制代码
mov eax, SSN ; B8 XX XX XX XX
mov edx, 0x7FFE0300 ; BA 00 03 FE 7F
call dword ptr [edx] ; FF 12
ret n ; C2 XX XX
SSN 偏移也是 +0x01(在 mov eax 后紧跟着),但整体逻辑类似。不过现在 Windows 10+ 的 32 位系统越来越少,建议优先关注 x64。
规避检测
一些 EDR 会通过 ETW(Event Tracing for Windows)或内核回调来检测 syscall 指令的来源地址。如果你的 syscall 来自非 ntdll 的内存区域(比如你 VirtualAlloc 出来的那块),就可能触发告警。更进阶的做法包括:
-->间接 syscall:只跳转到 ntdll 中的 syscall; ret 指令
-->硬件断点(HWBP)syscall:利用调试寄存器
-->天堂之门(Heaven's Gate):在 Wow64 环境下切换到 64 位模式
感兴趣可以自行搜索。
小结[td]| 步骤 | 操作 | | ① | 用 LoadLibraryEx(DONT_RESOLVE_DLL_REFERENCES) 从磁盘加载干净的 ntdll | | ② | GetProcAddress 找到目标函数 | | ③ | 读取 函数地址 + 0x04 处的 4 字节 DWORD → 得到 SSN | | ④ | 将 SSN 填入 mov eax, XXXXXXXX; syscall; ret 模板 | | ⑤ | 通过函数指针调用生成的桩,直达内核 |
这种方法让你的代码能自适应不同 Windows 版本,不需要为每个版本硬编码调用号,也绕过了用户态的 Inline Hook,是 Windows 系统编程中非常实用的技巧。 参考链接
-->https://j00ru.vexillium.org/syscalls/nt/64/
-->https://idiotc4t.com/defense-evasion/dynamic-get-syscallid
-->https://xz.aliyun.com/news/17898
希望这篇文章对你有所帮助,祝你在逆向对抗/安全研究的道路上越走越远!如果文章中有任何理解不当或技术错误的地方,欢迎大神们指出,我会虚心学习。
免责声明:本文仅供技术研究和学习交流,请勿用于任何非法用途。读者因使用本文代码而产生的任何后果由本人自行承担,技术无罪,人心有罪。
|