简介:面向Windows平台驱动开发与系统编程学习者,这份C++驱动无模块注入代码展示如何在目标进程中不加载额外模块、借助内存分配与远程线程机制注入并执行代码,可用于动态性能分析与底层调试研究。zip压缩包共含91个文件,大小约1.13MB,以C/C++源文件与头文件、解决方案及工程配置为核心,附有编译过程生成的obj、pdb、tlog等中间文件与可执行程序,并提供构建和编码辅助脚本,目录划分注入工程与测试工程,便于对照编译与验证。已有1794人学习下载。通过完整源码可梳理进程打开、内存分配、代码写入、远程线程创建等系统调用的配套用法,也能参考头文件中的封装思路、直接修改测试工程进行二次开发,同时借助附带的构建脚本和说明快速验证注入效果,是理解驱动领域内存操作与多线程程序设计的实用范例。
1. 驱动无模块注入:内核侧“隐身”投递与检测模型的突破点
做过注入实验的人大多从 LoadLibrary 练手,但 DLL 一旦进模块列表,几乎等于在目标进程里贴了一张“我是谁”的名片。无模块注入的思路完全不同——它把执行体以数据形态写进目标进程内存,不产生新模块、不触发加载器回调;驱动参与进来之后,连用户态反作弊常用的远程线程扫描和句柄检测也能绕开一部分。对安全研究、EDR 规则验证和对抗检测测试来说,这套方案的价值在于把注入的执行点从“API 调用痕迹”换成了“内核内存分配痕迹”,检测方需要主动检查内核侧行为才能发现。下面就从可复现的 C++ 示例出发,把驱动侧与用户态控制端串起来讲,并列出实测中会遇到的几个突出问题。
2. 原理先行:无模块注入的三种玩法与驱动介入的价值
2.1 用户态的载荷投递与手动映射
无模块注入的统一定义是:目标进程的内存中出现了一段可执行数据,但进程的 PEB 模块链表和任何加载器维护的结构里都看不到它。用户态最常见的实现有几种:第一种是经典手动映射,枚举 kernel32 的导出表,手工解析重定位和导入表,然后把 DLL 像剥壳一样放进进程地址空间;第二种是 APC 注入,把载荷挂到目标线程的 APC 队列,等目标线程进入可告警状态时执行;第三种是线程劫持,挂起目标线程后改写上下文寄存器指向载荷。三种玩法都比 LoadLibrary 隐蔽,但 trace 并不干净。
以手动映射为例,VirtualAllocEx 加上 WriteProcessMemory 两次调用的组合本身就是一个高特征事件,远程线程扫描器看到线程起始地址落在非映像区域就能告警。APC 注入不需要创建远程线程,避免了线程起点特征,但 APC 记录同样在内核里可见,EDR 的 ETW 内核日志一样能对出来。线程劫持的隐蔽性最好,但挂起和改上下文这两个动作在检测方看来等同于调试器操作,反而容易触发调试检测规则。
2.2 内核驱动作业点:分配、执行与隐藏
驱动参与之后,作业点从用户态 API 换成内核态原生函数。同一个注入动作在内核里拆成三步:用 ZwAllocateVirtualMemory 在目标进程分配内存,用 RtlCopyMemory 从内核缓冲写入载荷,最后用 ZwCreateThreadEx 在目标进程创建线程。对用户态检测来说,这三步的执行者变成了 System 或某个驱动上下文,调用栈上没有可疑的进程身份。即便检测方挂钩了 NtCreateThreadEx 的用户态转发,也很难断定是谁发起的,因为线程入口地址落在刚才分配的那块区域里,而分配动作本身是在内核态完成的。
更重要的是,驱动还能控制“内存属性的可信度”。早期对抗工具喜欢一步到位分配 RWX,但这几年 RWX 检测规则已经烂大街,静态扫描 VAD 时“可读可写可执行”三权齐全的页面几乎必被标记。用驱动做这件事,可以先把页面权限设成 PAGE_READWRITE,写完载荷后用 ZwProtectVirtualMemory 翻转成 PAGE_EXECUTE_READ,整个生命周期里 VAD 记录都看不到 RWX,检测方如果只看权限标志,很容易漏掉。
2.3 选型理由:什么场景值得上驱动
是不是所有注入需求都要上驱动?不是。如果目标是自用工具、调试器插件、游戏 Mod,用户态的进程注入就够了。引入驱动的最大代价是签名和兼容性:64 位系统要求驱动有受信任签名,开发机还得开测试签名模式,否则 StartService 直接返回 577。驱动方案的适用场景是:需要绕开用户态挂钩、需要在目标进程生命周期的早期完成投递、或者需要同时控制系统层回调的对抗验证。
另一个判断依据是载荷体积。驱动可以通过 ZwAllocateVirtualMemory 直接走目标进程的虚拟地址空间,几个 MB 的独立模块也塞得下;但如果只是几十字节的验证用 beacon,用户态手动映射反而更快。我一般建议先把载荷压到几 KB 以内,再用驱动投递,这样分配、拷贝、执行三步的失败面都小很多。驱动把分配地址和线程句柄返回给用户态之后,后续清理逻辑也会简单不少。
3. 驱动侧实现:IOCTL 通道、内存分配与远程线程创建
3.1 IOCTL 通道与请求定义
驱动按 WDK 常规结构写:DriverEntry 里创建设备对象和符号链接,设置 IRP_MJ_DEVICE_CONTROL 分发函数;应用侧用 CreateFile 打开符号链接,再通过 DeviceIoControl 发送注入请求。请求结构我一般这样定义:
typedef struct _INJECT_REQUEST { ULONG TargetPid; PVOID PayloadBuffer; // 指向用户态载荷缓冲,仅作为入参 SIZE_T PayloadSize; ULONG OptionFlags; // 0 = 普通线程, 1 = 挂起线程后改上下文 } INJECT_REQUEST, *PINJECT_REQUEST; #define IOCTL_NMI_INJECT \ CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_READ_ACCESS | FILE_WRITE_ACCESS)METHOD_BUFFERED 模式下,I/O 管理器会先把输入缓冲复制到内核池,再把输出缓冲复制回用户态。这个模式对小型请求足够,但如果载荷本身有几百 KB,SystemBuffer 的最大长度受限于 IRP 栈和池空间,容易返回 STATUS_BUFFER_TOO_SMALL。我实际项目中会把 PayloadBuffer 换成“用户态起始地址 + 大小”的形式,驱动用 MmCopyVirtualMemory 直接去源进程读,IOCTL 里只传描述符,不动大缓冲。这个细节放到第 5 章展开说。
分发函数里先做请求参数校验,再调用下面的注入辅助函数。一个重要原则:所有从用户态传来的指针都不能直接解引用,PayloadBuffer 只是一个逻辑描述,驱动必须自己重新获取物理或虚拟内存引用。
3.2 分配内核内存并写入载荷
注入辅助函数是核心,代码如下:
NTSTATUS PerformInjection(PEPROCESS TargetProcess, PVOID PayloadData, SIZE_T PayloadSize) { KAPC_STATE ApcState; PVOID RemoteBuffer = NULL; SIZE_T RegionSize = PayloadSize; NTSTATUS Status; ULONG OldProtect = 0; SIZE_T ProtectSize = PayloadSize; KeStackAttachProcess(TargetProcess, &ApcState); Status = ZwAllocateVirtualMemory( (HANDLE)-1, &RemoteBuffer, 0, &RegionSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!NT_SUCCESS(Status)) { KeUnstackDetachProcess(&ApcState); return Status; } RtlCopyMemory(RemoteBuffer, PayloadData, PayloadSize); Status = ZwProtectVirtualMemory( (HANDLE)-1, &RemoteBuffer, &ProtectSize, PAGE_EXECUTE_READ, &OldProtect); if (!NT_SUCCESS(Status)) { ZwFreeVirtualMemory((HANDLE)-1, &RemoteBuffer, &RegionSize, MEM_RELEASE); KeUnstackDetachProcess(&ApcState); return Status; } KeUnstackDetachProcess(&ApcState); return Status; }逻辑说明:先 KeStackAttachProcess 把当前线程附加到目标进程上下文,后续 ZwAllocateVirtualMemory 的 (HANDLE)-1 参数代表当前进程,实际操作的就是目标进程地址空间。RegionSize 是双向参数,成功返回后会被更新为实际提交大小,调用前建议让 PayloadSize 按页对齐,否则底层会多提交一个尾页,增加暴露面。RtlCopyMemory 之后立刻做保护翻转,VAD 里先出现 PAGE_READWRITE,再变成 PAGE_EXECUTE_READ,全程没有 RWX 状态。
参数说明:PayloadData 必须是内核缓冲里的拷贝,不能直接用用户态指针。TargetProcess 由 PsLookupProcessByProcessId 获取,调用完成后由调用者负责 ObDereferenceObject。这里没有释放 RemoteBuffer,是因为后续创建线程还要用这块地址;线程执行完成后由载荷自己决定是否清理,驱动侧不轻易释放正在执行的页面。
3.3 创建远程线程的驱动出口与参数传递
内存写好后,在同一个附加上下文里创建远程线程。代码可以复用上面的附加,也可以独立封装:
NTSTATUS CreateRemoteThreadInProcess(PEPROCESS TargetProcess, PVOID StartAddress) { HANDLE ThreadHandle = NULL; NTSTATUS Status; KAPC_STATE ApcState; KeStackAttachProcess(TargetProcess, &ApcState); Status = ZwCreateThreadEx( &ThreadHandle, THREAD_ALL_ACCESS, NULL, (HANDLE)-1, StartAddress, NULL, 0, 0, 0, 0, NULL); if (NT_SUCCESS(Status)) { ZwClose(ThreadHandle); } KeUnstackDetachProcess(&ApcState); return Status; }这里的参数含义需要说清楚:第一个 &ThreadHandle 接收线程句柄,第二个 THREAD_ALL_ACCESS 是访问掩码,第四个 (HANDLE)-1 仍然表示当前进程(此时附加到目标进程),第五个 StartAddress 就是要执行的载荷入口,第六个参数是线程启动时要传给载荷的参数块指针,可以留空。参数七到十分别控制 CreateSuspended、栈大小等,全 0 表示默认行为。
如果把参数七改成 1,线程会以挂起状态创建,之后驱动可以进一步修改线程上下文再恢复,这条路比直接创建普通线程更隐蔽,但操作复杂度也更高。驱动里做完 ZwCreateThreadEx 后,最好把线程 ID 或句柄备份到一个全局结构里,方便后续对线程做生命周期管理。我习惯在 DriverUnload 里统一处理这些残留线程,避免卸载时线程还在跑导致蓝屏。
整个驱动分发流程串起来是:PsLookupProcessByProcessId 解析目标 PID 对应的 EPROCESS,接着 PerformInjection 分配并写入,再调用 CreateRemoteThreadInProcess 创建线程,最后返回用户态。需要注意:分配和创建线程必须发生在同一次附加上下文里,不能把 RemoteBuffer 地址带出附加后再二次进入,否则目标进程可能已经退出或地址被回收,竞态窗口会明显变大。
4. 用户态控制台:驱动加载、载荷组织与完整调用时序
4.1 驱动加载与句柄获取
用户态用标准服务控制管理器加载内核驱动,流程是 OpenSCManager、CreateService、StartService。测试环境通常开启测试签名模式,但即使开了,驱动也得有测试证书签名。加载代码大致如下:
bool LoadDriver(const wchar_t* serviceName, const wchar_t* path) { SC_HANDLE scm = OpenSCManager(nullptr, nullptr, SC_MANAGER_ALL_ACCESS); if (!scm) return false; SC_HANDLE svc = CreateServiceW(scm, serviceName, serviceName, SERVICE_ALL_ACCESS, SERVICE_KERNEL_DRIVER, SERVICE_DEMAND_START, SERVICE_ERROR_NORMAL, path, nullptr, nullptr, nullptr, nullptr, nullptr); if (!svc) { if (GetLastError() == ERROR_SERVICE_EXISTS) { svc = OpenServiceW(scm, serviceName, SERVICE_ALL_ACCESS); } else { CloseServiceHandle(scm); return false; } } BOOL ok = StartServiceW(svc, 0, nullptr); DWORD err = GetLastError(); CloseServiceHandle(svc); CloseServiceHandle(scm); return ok || err == ERROR_SERVICE_ALREADY_RUNNING; }逻辑说明:CreateServiceW 的 path 参数要用 NT 路径形式,比如 L"\??\C:\drivers\nmi_drv.sys"。如果服务已经存在,CreateServiceW 会返回 ERROR_SERVICE_EXISTS,这时需要 OpenServiceW 拿到已有句柄,不能重复创建。StartServiceW 返回 ERROR_SERVICE_ALREADY_RUNNING 不应该算失败,代码里把这个错误码当成成功处理,因为重复调试时服务往往已经加载过。
加载成功后打开设备句柄:
HANDLE hDev = CreateFileW( L"\\\\.\\NMI_DRV", GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); if (hDev == INVALID_HANDLE_VALUE) { // GetLastError() 常见 2 = 符号链接未创建,5 = 权限不足 return; }这里注意:设备名要与驱动 DriverEntry 里 IoCreateSymbolicLink 创建的名称一致。调试时如果改了驱动里的设备名,用户态这边忘了同步,CreateFileW 会返回 2,这种低级错误最容易浪费一下午。
4.2 载荷生成与字节数组处理
在用户态准备载荷时,推荐把 shellcode 以 unsigned char 数组形式嵌入源码,编译期落到 rdata 段。注入前把数组指针和长度传给驱动。对于体积较大的载荷,建议压缩后再投递,驱动里再解压,否则 DeviceIoControl 的缓冲区上限很容易卡住。一个最小示例:
std::vector<uint8_t> payload = { 0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28 0x48, 0x31, 0xC0, // xor rax, rax 0x48, 0x31, 0xC9, // xor rcx, rcx 0x48, 0x31, 0xD2, // xor rdx, rdx // ... 按需补全载荷逻辑 ... 0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28 0xC3 // ret };这里只有一个最小可执行框架,真正的载荷需要自己在调试器里验证首字节、栈平衡和内存引用。shellcode 里不要用外部全局变量或函数调用,位置无关代码的栈对齐和重定位问题是新手最容易翻车的点。字节数组的生成方式可以是裸函数编译后从二进制里提取,也可以是手写汇编后用汇编器输出;我一般用裸函数方式,维护起来比手写字节数组直观得多。
4.3 完整调用时序
把上面的步骤串起来,整个注入调用是:先加载驱动,再打开设备句柄,构造 INJECT_REQUEST,填充 TargetPid、PayloadBuffer、PayloadSize,然后 DeviceIoControl,接着检查返回状态,等待载荷执行,最后 StopService 卸载驱动。
DeviceIoControl 的调用:
INJECT_REQUEST req = {}; req.TargetPid = targetPid; req.PayloadBuffer = payload.data(); req.PayloadSize = payload.size(); BYTE output[128] = {}; DWORD returned = 0; BOOL ok = DeviceIoControl( hDev, IOCTL_NMI_INJECT, &req, sizeof(req), output, sizeof(output), &returned, nullptr); if (!ok) { // 高频翻车点:CreateFile 成功但 DeviceIoControl 失败 // 先用内核调试器看驱动返回什么 STATUS,再决定是改 BOOL 还是改驱动逻辑 }这段代码看起来简单,但实际调试中 DeviceIoControl 返回失败时,很多人直接怀疑驱动没加载,其实大概率是 IOCTL 定义里的访问权限和缓冲区大小不匹配。驱动在分发函数里要记得检查 IRP 的 Parameters.DeviceIoControl.InputBufferLength 和 OutputBufferLength,长度不足时返回 STATUS_BUFFER_TOO_SMALL,用户态这边 GetLastError 会变成一个不直观的数值。最省事的做法是先打印输出缓冲里的 NTSTATUS,再对照 ntstatus.h 查编号。
5. 避坑与排查:五个让注入翻车的细节
5.1 现象:驱动加载失败,错误码 577 或 1275
现象:StartServiceW 返回失败,GetLastError 是 577 或 1275,提示驱动签名不合法。
原因:64 位系统强制所有内核驱动具有交叉证书签名。即使开启了测试签名模式,驱动本身也必须完成测试签名,或者系统处于内核调试模式,否则加载器直接拒绝。
解决:开发阶段在系统启动配置里打开测试签名,给驱动做测试证书签名;整个链路用内核调试状态运行也可以规避签名校验。建议准备一个单独虚拟机专门做驱动调试,别在主力开发机上折腾签名策略,否则蓝屏一次,半天的工作量就没了。
5.2 现象:内存分配成功但目标进程崩溃
现象:注入后目标进程秒退,或者过几秒在调试器里看到访问违例,崩溃点恰好是载荷入口。
原因:载荷栈不平衡;载荷内部使用相对地址跳转但基址与编译时预期不一致;分配页基址与 shellcode 内部假设的位置不同导致绝对地址引用失效。
解决:先不要急着注入真实载荷,用最简 ret 指令(单字节 0xC3)做冒烟测试。能正常返回说明分配、线程创建链路是通的,再把载荷逐步加复杂,每加一段就验证一段。载荷内部如果有直接地址引用,改用相对寻址或者让驱动返回真实基址给载荷作为参数。
5.3 现象:线程创建成功但载荷未执行
现象:ZwCreateThreadEx 返回成功,但目标进程内看不到线程运行,按暂停键后线程栈停在系统等待函数里。
原因:线程被创建成了挂起状态;或者目标进程有线程监视回调把新线程挂起;另一个常见原因是载荷入口指向 PAGE_EXECUTE_READ 页面,但代码里试图写数据段,触发保护异常后线程直接消亡。
解决:检查 ZwCreateThreadEx 的 CreateSuspended 参数是否为 0;再确认页面保护翻转后是否丢掉了写权限。如果载荷需要自修改,建议把数据段和代码段分开放,入口地址指向代码段,数据段保留写权限。
5.4 现象:杀软拦截驱动签名
现象:驱动加载到一半被杀软提示“不安全的驱动”并强制删除文件,或者加载成功后设备对象被隔离。
原因:安全产品维护了驱动黑名单和强签名校验,未签名或证书状态异常的驱动会直接被拦。
解决:这种场景通常需要申请正规的代码签名证书,或者把驱动限制在白名单验证的测试环境里。硬绕杀软签名校验只会制造更多特征,不是长期方案。实际项目中,我一般建议把驱动做成“测试签名 + 内部白名单”双轨:测试环境用测试证书,正式环境换正式证书,代码逻辑不变。
5.5 现象:注入后无法卸载驱动
现象:StopServiceW 失败,或驱动卸载后系统蓝屏,内核调试器显示引用计数不为 0。
原因:驱动卸载流程没有处理待决 IOCTL、没有撤回符号链接;用户态句柄没有关闭导致设备对象引用计数不减。
解决:保证卸载路径覆盖 IRP_MJ_DEVICE_CONTROL 里所有进行中的请求,在 DriverUnload 里按顺序删除设备对象、删符号链接、释放全局资源。用户态侧必须先 CloseHandle(hDev) 再 StopService,否则驱动卸载时 DeviceObject 引用计数踩满,蓝屏是大概率事件。
6. 验证与进阶:从能跑到敢用的几个技巧
6.1 用验证码确认载荷执行
最稳的验证方式不是靠日志或肉眼看,而是让载荷在目标进程里写入一个验证码到预设地址。载荷执行完后,用户态程序读取共享缓冲,确认验证码等于约定的魔数。这样能区分“分配成功但没跑”和“跑了但没效果”。调试阶段我会在载荷开头放一段写验证码的指令,先确认它真的运行了,再继续调真实逻辑。
6.2 避免硬编码 PID 的进程名匹配
多数注入样例写死 PID,但实际调试时目标进程会重启。常见做法是用 CreateToolhelp32Snapshot 加 Process32FirstW 按进程名动态解析 PID,驱动侧仍然接收 PID,只是用户态把它换成动态值。更激进的方案是把进程名传给驱动,让驱动自己遍历 ActiveProcessLinks,这个有竞态问题,不推荐新手直接用。
6.3 收尾技巧
还有两个值得养成的习惯:一是每次注入前用 PAGE_NOACCESS 试分配一次,确认目标进程的 VAD 检查逻辑正常,避免载荷真正执行时被拦截;二是保留卸载阶段的最小驱动,DriverUnload 先释放所有资源再删设备,确保卸载后系统仍然稳定。从那以后我每次跑这套注入链路,都强制走一遍“加载 → 冒烟验证 → 注入 → 读取验证码 → 卸载”的完整流程,最后顺手把服务删掉,避免残留驱动影响下一次测试。希望这套思路和踩坑记录能帮到正在折腾驱动注入的你。
本文还有配套的精品资源,点击获取