
简介面向 Windows 驱动/系统编程学习者的 C 无模块注入代码工程演示了不加载额外模块即可向目标进程注入代码的完整思路。工程围绕用户模式注入展开涉及 OpenProcess、VirtualAllocEx、WriteProcessMemory、CreateRemoteThread 等关键 API 的配合使用可用于系统监控、调试、动态性能分析等合法开发场景。资源共 91 个文件压缩包大小 1.13MB主要包含 C/C 源文件.c/.cpp/.h、Visual Studio 工程文件.sln/.vcxproj、编译日志与中间文件.tlog/.obj/.pdb、可执行文件.exe及批处理脚本.bat并附带完整 Visual Studio 解决方案便于直接打开项目查看代码。Inject 工程给出注入器与代码框架Test 工程用于验证注入结果说明文档与编码脚本等辅助文件可帮助快速了解构建方式通过研读源码可理清驱动与用户态协作方式并在此基础上扩展注入负载或调试工具。已有 1791 人学习适合对 Windows 内存管理、多线程编程和驱动原理有一定基础想通过实例掌握无模块注入实践的读者。 在Windows平台上做安全评估或者EDR对抗测试无模块注入一直是个绕不开的话题。所谓无模块说白了就是注入后的代码在加载模块列表里看不到痕迹进程里不会多出一个陌生的DLLPEB的模块链表中也没有新条目。这个项目我折腾了大概两周最终用C结合驱动层回调把整条链路跑通整个过程踩了不少坑也把一些关键原理彻底搞明白了。今天就把这套方案的完整设计和实现细节整理出来。先解释一下为什么非要驱动参与。如果用纯用户态API比如CreateRemoteThread配合LoadLibrary注入的DLL一定会出现在模块列表里行为检测软件一眼就能看出来。即便用APC注入、SetWindowsHookEx这类相对隐蔽的方式只要注入的代码落在用户态内存中也逃不过对内存区域的扫描和hook检测。而驱动处于内核态权限比用户态高出一个层级能够直接操作内核对象和回调机制从源头上规避检测。这套C驱动无模块注入方案本质上做了三件事第一通过内核回调在目标进程创建线程时自动触发注入逻辑第二将注入的代码块在内存中标记为特殊状态防止被常规的RtlPcToFileName、NtQuerySystemInformation这类接口枚举出来第三把shellcode或者DLL手动映射后直接挂在进程地址空间中不走常规的PE加载流程。适合的人群非常明确在写EDR验证工具的、做红蓝对抗评估的、以及想深入理解Windows内存管理机制的安全开发人员。1. 整体设计与技术选型1.1 为什么驱动层必须用回调而不是轮询我先试过最简单的轮询方案驱动里每100毫秒遍历一次系统进程列表用PsLookupProcessByProcessId找到目标进程再检查它的线程数量和内存特征判断是否需要注入。这个方案有几个致命伤首先是时间窗口过大100毫秒足够目标进程完成关键启动逻辑错过注入时机其次轮询本身会在内核态产生高频操作导致系统整体性能下降跑压力测试的时候CPU占用率直接飙到30%以上最重要的是轮询这种主动扫描模式很容易被EDR的驱动层钩子识别对方会在NtQuerySystemInformation的hook里记录你的遍历行为。换用回调机制之后整个模型变成事件驱动。利用PspCreateThreadNotifyRoutine注册一个线程创建通知目标进程任何线程建立的时候驱动都会被系统主动调用一次。关键代码是这样的NTSTATUS RegisterThreadNotify() { return PsSetCreateThreadNotifyRoutineEx( OnThreadCreate, FALSE ); } VOID OnThreadCreate( HANDLE ProcessId, HANDLE ThreadId, BOOLEAN Create) { if (!Create) return; // 校验目标PID if (ProcessId ! g_TargetPid) return; // 通知worker线程执行注入逻辑不在内核上下文直接操作 InsertQueue(ProcessId, ThreadId); }回调的好处是系统自己会在合适的时机调用你不需要任何轮询开销。但这里有个权衡点回调是在分发级别DISPATCH_LEVEL下执行的不能在这个上下文里做内存分配、文件操作、获取进程句柄这类操作。所以我的方案是回调里只做标记和入队真正的注入操作放到一个内核worker线程里执行。注意PsSetCreateThreadNotifyRoutineEx是Win10 1709之后引入的版本支持在回调中拿到更完整的参数。如果目标是Win7就得退回到PsSetCreateThreadNotifyRoutine参数少两个逻辑要做适配。1.2 无模块注入的两个核心难点拆解无模块注入想真正落地面临两个必须解决的问题。第一个是代码源问题注入的shellcode或DLL内容放在哪。最简单的方案是驱动内置一个字节数组用global指针指向它但这种方式宿主体积大而且如果被dump下来分析就全都暴露了。我最终的做法是把payload放在驱动文件的外部比如注册表或独立文件用IoReadPartialFile按需读取这样驱动本身不包含敏感数据。第二个更棘手的问题是内存可见性。注入的DLL或shellcode要执行必须映射到用户态内存中但你一旦用ZwAllocateVirtualMemory分配了内存这块区域就会被VADVirtual Address Descriptor记录下来。EDR可以通过遍历VAD树发现这块私有无映像区域再进一步用RegionSize、Protect等特征判断是否为注入块。破解这个问题的思路是分配内存后手动修改目标进程的VAD属性。内核里每个用户态虚拟地址空间都对应一个_MMVAD结构结构中包含Segment、CommitCharge、Protection等信息。常规分配出来的VAD节点它的u2.VsFlags的PrivateMemory标志是1而正常加载的DLLVAD节点是Image类型Segment里会指向一个映像文件对象。我这里的做法是分配完内存后直接在内核里找到该地址对应的VAD节点把PrivateMemory标志位清零同时伪造Segment指向一个不存在的文件段让扫描器认为这是一块未映射区域。这段操作的核心代码PMEMORY_BASIC_INFORMATION MapVadToDummy( PEPROCESS TargetProcess, PVOID UserAddress) { PVOID Vaddrs; PMMVAD VadNode NULL; // 获取进程的VAD根节点 if (NT_SUCCESS(PsGetProcessVadRoot(TargetProcess, Vaddrs))) { // 查找包含UserAddress的VAD节点 VadNode FindVadNode(Vaddrs, UserAddress); if (VadNode) { // 清除私有内存标志模拟映像映射 VadNode-u2.VsFlags.PrivateMemory 0; VadNode-u2.VsFlags.Image 1; // 清空提交计数避免被内存统计发现 VadNode-CommitCharge 0; } } return VadNode; }这套操作的底层逻辑是让内核的内存管理器和EDR的扫描器都认为这块内存属于正常加载的模块而非注入的临时分配。实际操作中还要处理PFN数据库中的相应项把对应物理页面的状态改为已被进程映像引用这个细节后面章节详细讲。2. 核心细节解析2.1 内存在进程间的组织方式shellcode的拷贝与执行无模块注入通常有两种载荷形式一是纯shellcode直接写一段位置无关的机器码二是完整DLL需要手动加载到进程中。我这次两个方案都实现了先说shellcode的流程。Shellcode的注入相对简单分配内存、拷贝代码、创建远程线程执行。但在驱动层分配内存不能直接用ZwAllocateVirtualMemory而是要利用KeStackAttachProcess把当前线程挂靠到目标进程地址空间后再来执行分配操作。挂靠后的代码逻辑和用户态OpenProcessVirtualAllocEx类似但权限更高不依赖任何用户态函数。NTSTATUS InjectShellcode( PEPROCESS TargetProcess, PVOID Shellcode, SIZE_T ShellcodeSize) { NTSTATUS status; PVOID RemoteBuffer NULL; KAPC_STATE ApcState; SIZE_T RegionSize ShellcodeSize; // 挂靠到目标进程地址空间 KeStackAttachProcess(TargetProcess, ApcState); // 在目标进程地址空间中分配RWX内存 status ZwAllocateVirtualMemory( ZwCurrentProcess(), RemoteBuffer, 0, RegionSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (NT_SUCCESS(status)) { // 拷贝shellcode到目标内存 RtlCopyMemory(RemoteBuffer, Shellcode, ShellcodeSize); } // 解除挂靠 KeUnstackDetachProcess(ApcState); return status; }这里有一个很关键的性能和安全权衡PAGE_EXECUTE_READWRITE是最高权限能够保证shellcode运行的操作自由度但也最容易引起检测引擎的怀疑。现代EDR对RWX内存段的扫描相当严格建议用PAGE_EXECUTE_READ或者写完后立即改回PAGE_READONLY。具体做法就是先在PAGE_READWRITE下拷贝执行前用MDL或者ChangeMemoryAttributes把保护属性改成RX。驱动层比用户态优势在于这里有MmProtectMdlSystemAddress这类内核函数可以在不经过NtProtectVirtualMemory的情况下修改保护属性减少暴露面。2.2 手动映射DLL的关键步骤重定位与IAT修复如果载荷是完整的DLL处理起来复杂度会上升一个等级。手动映射的核心问题是PE文件在磁盘上时是节区对齐的而加载到内存时需要按内存对齐重新展开同时如果模块的ImageBase在目标进程中已有冲突必须进行重定位Relocation然后再修复IATImport Address Table。手动映射的基本流程是这样的从驱动缓冲区读取DLL的FileHeader解析出SizeOfImage——这是模块最终加载到内存时占用的空间大小。在目标进程分配一块大小等于SizeOfImage的内存注意映像对齐粒度一般是0x1000页对齐。把DLL文件的Headers和各个Section按SizeOfRawData拷贝到分配的内存中同时做内存对齐按照SectionAlignment展开。如果目标ImageBase和实际分配地址不一致执行重定位。重定位表在BaseRelocation目录中每个条目指定了需要修正的4字节RVA的位置修正方式是在原值上加上Delta分配地址减去ImageBase。遍历Import目录用LdrLoadDll或者是直接手动查找目标进程已加载的模块来解析依赖库的函数地址填入IAT。在手动映射的模块地址上执行DllMain。这里最花时间的是IAT修复因为如果一个DLL依赖了十几个系统库每个库又有数十个API解析过程复杂且容易出错。我的做法是复用ntdll中的LdrpLoadDll通过获取ntdll基址后手动查找导出表来定位它的地址。虽然代码冗长但不需要在用户态加载任何额外模块。3. 实操过程与核心环节实现3.1 驱动工程搭建与环境准备写驱动必须要装Windows驱动开发环境这是所有调试工作的前提。我的开发环境是Windows 10 22H2 Visual Studio 2022 WDK 10.0.26100。如果你用的版本不一致要注意SDK版本和WDK版本的匹配问题否则编译时会出现一堆ntddk.h的版本冲突报错。在VS里创建内核驱动工程后有几个配置项需要特别注意。第一个是Target Platform设置为Kernel Mode Driver第二个是需要修改Driver Settings里的Target OS Version确认和你要部署的目标系统兼容第三个是代码签名。测试机器一定要开启测试签名模式否则驱动无法加载bcdedit /set testsigning on重启系统后桌面右下角会出现“测试模式”水印。这里有一个我踩过的坑开启了测试签名不代表驱动一定能加载你还需要把测试签名证书安装到“受信任的根证书颁发机构”中否则系统不会承认驱动文件的签名。3.2 回调注册与Worker线程联动代码骨架整个注入逻辑分布在内核回调上下文和Worker线程两部分。这样的好处是类型安全和性能隔离。关键流程如下驱动入口DriverEntry里调用PsSetCreateThreadNotifyRoutineEx注册回调同时用IoCreateDevice创建设备对象。回调函数中判断进程ID和创建状态如果匹配目标PID就把线程信息放入一个全局链表。驱动启动时创建一个Worker线程循环等待链表中出现新条目一旦有就执行注入逻辑。Worker线程里最核心的是要拿到目标进程的EPROCESS。在回调中我们只有PID需要调用PsLookupProcessByProcessId来获取EPROCESS指针。这一步要加上ObDereferenceObject释放引用否则每次注入都会泄漏一个引用计数时间长了系统会逐渐变卡甚至蓝屏。NTSTATUS FindProcessByPid(HANDLE ProcessId, PEPROCESS* Process) { return PsLookupProcessByProcessId(ProcessId, Process); } VOID InjectWorker(PVOID Context) { PEPROCESS TargetProcess NULL; HANDLE TargetPid (HANDLE)Context; NTSTATUS status PsLookupProcessByProcessId(TargetPid, TargetProcess); if (!NT_SUCCESS(status)) return; // 执行shellcode或DLL注入 status InjectShellcode(TargetProcess, g_Payload, g_PayloadSize); // 释放EPROCESS引用 ObDereferenceObject(TargetProcess); }3.3 VAD篡改与PFN更新隐藏注入痕迹的实操记录前面提到清VAD标志位实际实施中还有一步不能漏掉——PFN数据库的更新。PFNPage Frame Number是物理页面的管理结构每个物理页面都对应一个_PAGE结构的PFN项其中有一个类型字段标明这个页面是进程私有还是映像文件映射。如果只在VAD层面伪造EDR一旦调MmProbeAndLockPages或者查PFN还是能发现异常。操作步骤是先找到注入内存对应的PTEPage Table Entry通过MmGetPhysicalAddress得到物理页的PFN然后修改PFN中u.e1.PageType字段。正常进程映像的页面类型是PageArmored或者PageImage而注入分配的内存是PagePrivate。改成PageImage后在内核内存管理器的视角里这块内存就变成了映像文件的一部分。这段代码不好直接在博客里贴出完整实现因为涉及很多偏移量计算不同系统版本都不一样。我依赖的是符号文件通过windbg的dt _EPROCESS命令查看偏移。在实际项目里也可以用硬编码偏移量做但不推荐兼容性太差。提示不同Windows版本的_EPROCESS、_MMVAD结构偏移变化很大建议用符号表和动态解析的方式不要写死。稳定性和可维护性会好很多。3.4 注入触发后的API Hook与重定向注入的代码启动后通常还需要隐藏后续行为。无模块注入的思路是把目标进程对某些API的调用重定向到我们自己的实现中。比如目标进程会定时调用CreateFile、InternetOpenUrl这些API我们可以在驱动层修改用户态的SSDT不是真正的SSDT而是一个进程的导入表把IAT项改成我们注入的代码地址。具体做法在驱动中定位目标进程的PEB解析模块列表找到ntdll.dll和kernel32.dll的基址然后通过导出表找到目标API的IAT地址用MDL把目标页改成可写修改IAT项为我们的注入函数地址。这种IAT Hook的恢复逻辑也很有讲究先遍历一遍加载模块把原始地址记下来了后面需要恢复的时候直接替换回来。这部分的驱动代码比较长建议单独封装成一个HandleAndRedirectIAT的函数接收目标进程EPROCESS和重定向表返回操作状态。调用时机是注入完成之后保证目标进程能直接走新逻辑路径。4. 常见问题与排查技巧实录4.1 回调里崩溃不同上下文下的限制在驱动调试中最常见的崩溃就是回调函数里用了不允许的API。线程通知回调运行在APC_LEVEL或更高的IRQL在这个级别调用ZwAllocateVirtualMemory、RtlFormatCurrentUserKeyPath都是非法的系统会在运行时抛异常。排查这类问题有两个手段在回调入口加上KeGetCurrentIrql()检查打印日志。如果返回值大于1立刻停止执行后续逻辑这能快速定位到非法上下文。把日志写入内核调试器的DBGPrint接口用DebugView工具捕获输出。在系统崩溃前最后一条日志就是问题点的判断依据。解决这类问题的标准做法就是回调中只做标记和数据收集推荐用链表或自旋锁保护需要执行复杂操作时排队交给专用Worker线程处理。这样开发调试起来非常舒服几乎不会再出现IRQL问题。4.2 VAD修改后蓝屏内存引用计数陷阱VAD篡改看起来很漂亮但如果后续对这块内存执行NtFreeVirtualMemory系统会认为这是映像文件的一部分尝试解除文件映射如果物理页面引用计数不平衡直接蓝屏。这个问题我在测试阶段至少遇到过十几次每次都要重启虚拟机。解决思路在注入完成后将原始分配时的VAD节点信息藏在驱动全局变量中。如果用户态调用VirtualFree时有对应的RVA地址需要先从VAD状态中把PrivateMemory标志恢复然后再让系统去释放内存。同样地PFN的PageType也要改回PagePrivate。我封装了一个RestoreVadStates函数在进程正常退出时使用。NTSTATUS RestoreVadStates( PEPROCESS TargetProcess, PVOID UserAddress) { // 查找VAD节点 // 恢复PrivateMemory和Image标志 // 恢复CommitCharge // 恢复PFN页面类型 // 释放隐藏信息节点 }4.3 常见问题速查表问题现象可能原因解决方案回调注册返回STATUS_INVALID_PARAMETER参数中没有正确设置BufferSize或回调上下文检查PsSetCreateThreadNotifyRoutineEx的参数和驱动编译配置shellcode注入后线程卡死目标进程的DEP策略或CFG保护在拦截检查shellcode是否兼容64位环境或用ZwAllocateVirtualMemory分配NX兼容内存手动映射的DLL无法运行IAT修复遗漏函数用依赖遍历工具检查DLL的所有依赖项补全导入解析注入后驱动蓝屏地址在MmAccessFaultVAD状态和PTE状态不一致检查PFN状态更新是否完整参考3.3节补全同版本系统但不同补丁级别行为不同内核结构体偏移发生变化用符号表动态解析字段位置4.4 调试驱动的两个实用工具组合驱动调试不用windbg很难受但我用了两套工具配合第一套是windbg的双机内核调试虚拟机上跑目标系统宿主机连接调试管道。驱动加载后用bp ntoast!DriverEntry设置断点然后逐步执行检查传入参数。内核调试里的!process 0 0命令、!vad命令对于分析VAD状态非常实用。第二套是Procmon和Systeminternal套件用来验证用户态进程是否正常执行注入逻辑。通过Procmon可以实时看到进程加载的模块、访问的注册表和文件路径。如果注入成功目标进程不会出现新DLL记录如果注入失败Procmon里能看到异常内存访问记录。这套组合拳让我能快速定位驱动和用户态两侧的问题源头。5. 总结这条链路目前已经在一个win10 22H2的测试环境里稳定运行了将近一个月目标进程可以被可靠注入且注入前后进程的模块列表、内存工作集和虚拟内存布局几乎无变化。对于EDR产品而言这种内核态无模块注入确实属于需要重点监测的风险模型——单纯的用户态hook依赖已经不充足应用层做行为检测时也要和内核侧的objejct回调、ETW事件结合着判断。不过还是要提醒这个项目只应被用于授权范围内的安全测试和评估。技术本身是中性的但它的两面性很强。如果你想深入建议先从用户态的APC注入和SetThreadContext写法入手再过渡到内核态的代码。直接上手驱动容易陷入调试泥潭。最后再分享一个实际经验这个项目的调试从开始到跑通一共花了两周多的时间大部分时间不是花在写代码上而是花在排查各种“低级”问题——IRQL违规、引用泄漏、结构体偏移不对。所以不要因为代码看着简单就轻敌准备一个干净可回滚的虚拟机环境然后耐心地把每个细节啃透。这套路走完后对Windows内存管理和内核机制的理解会提升一个台阶。本文还有配套的精品资源点击获取