PEB伪装实战:修改进程路径与命令行绕过检测的攻防解析 简介PEBFake 是一份 C 编写的进程环境块PEB修改工具源码面向 Windows 安全研究、逆向工程与进程隐蔽技术学习者。压缩包共含 3 个文件核心是 C 源码搭配 Markdown 说明文档和一张 PNG 图片源码展示 PEB 操作实现文档说明原理与用法图片便于直观对照测试结果整体仅 52KB轻量精简适合快速研读目前已有 764 人学习下载。资源围绕 PEB 结构展开演示如何通过内存操作修改 ImagePath、ProcessName 等关键字段实现当前进程路径与参数伪装并覆盖进程是 32 位还是 64 位、模块列表、环境变量等 PEB 中的常见信息。代码中涉及 CreateToolhelp32Snapshot、NtQueryInformationProcess、VirtualProtect 等 API 的使用包含读取目标进程 PEB 地址、修改内存属性、写入伪装数据及异常恢复等步骤适合希望理解用户态与内核态边界、研究检测绕过原理的中高级开发者通过学习可掌握 PEB 结构定位、字段篡改与恢复的完整方法为安全自测、恶意样本分析或漏洞研究提供参考。1. 为什么伪装 PEB一次失败的主防绕过给的上等经验有段时间我沉迷做免杀某次内部对抗里我把一个测试样本的进程路径和参数都改成C:\Windows\System32\svchost.exe -k netsvcs -p -s EventLog结果主防第一次真没拦。不是玄学而是 PEB 里这套字符串绝大多数终端防护默认不去校验。后来我又撞了一次墙同样的样本换个环境被拦得死死的回头看才发现问题出在参数来源——终端从 ETW 拿启动数据根本不看 PEB。这篇文章把 PEBFake 的思路完整拆开讲PEB 怎么读、ImagePathName 和 CommandLine 怎么改、改了之后怎么验证、哪些坑会让进程直接崩成 0xc0000374。搞攻防、做样本分析的人能直接拿走思路没接触过 PEB 的新手也能照着编译跑一遍。2. PEB 数据结构先看清这个结构体再动手2.1 从 TEB 到 PEB系统怎么拿到进程的“户口本”进程一启动内核会给它分配两个用户态结构TEB线程环境块和 PEB进程环境块。TEB 是线程维度的PEB 是进程维度的。每个线程自己的 TEB 里有一个固定位置存放着当前进程 PEB 的指针x64 下是gs:[0x60]x86 下是fs:[0x30]。这个指针链是理解 PEBFake 的根基。套一层关系就是线程 TEB → PEB → ProcessParameters → 路径和参数。任务管理器要显示命令行调试器要打印 PEB 信息EDR 要判断进程是不是伪装最终都要走到这条链上。而这些地址全部位于用户态意味着进程自己可以随意改写。常见的搜狗说法是“PEB 是内核的结构”这话不准确。PEB 的初始化由内核完成但它的物理内存映射在用户态地址空间里没有只读保护。也就是说进程只要知道自己 PEB 的地址就能把它当成普通内存一样写。PEBFake 的核心就一句话系统信任这张户口本而户口本本身没有防伪标识。2.2 ProcessParameters 的关键字段与偏移PEB 里直接和路径、参数相关的字段是ProcessParameters它的类型是RTL_USER_PROCESS_PARAMETERS。这个结构体挂在 PEB 偏移0x20x64的位置里面字段很多但我们真正需要动的只有下面这几个字段类型相对 ProcessParameters 的偏移x64说明ImagePathNameUNICODE_STRING0x60进程可执行文件的完整路径CommandLineUNICODE_STRING0x70完整的命令行参数WindowTitleUNICODE_STRING0x80窗口标题部分检测会看UNICODE_STRING不是 C 风格的字符串它是一个三字段结构Buffer指向字符串内存Length记录当前字符串的字节数不含结尾的空字符MaximumLength记录这个缓冲区最多能容纳多少字节含结尾空字符的位置。所以修改路径和参数本质上是修三个值缓冲区内容、Length、MaximumLength。另一个关键点Windows 的GetCommandLineW和GetModuleFileNameW这类 API内部直接引用 PEB 里的这些缓冲区并不会自己拷贝一份。你在 PEB 里把内容改了下一次调用这些 API 返回的就已经是伪造后的值。这也是 PEBFake 能骗过大量命令行采集逻辑的根本原因。2.3 两种修改路线原地覆盖与堆重分配拿到 UNICODE_STRING 之后修改路线有两条各有权衡路线适用场景风险原地覆盖新路径/参数长度不超过原 Buffer 的 MaximumLength低风险不涉及堆操作改完立刻生效堆重分配新串比旧串长或者要把短参数拉得很长涉及堆分配和释放堆归属、释放时机都要小心我第一次写 PEBFake 时贪省事不管三七二十一直接原地wcscpy结果新串比旧缓冲区长把内存写冒了进程当场崩掉。后来养成的习惯是先比较新旧长度长度够就原地覆盖长度不够就走堆重分配绝不硬写。堆重分配时用GetProcessHeap()拿进程堆句柄分配新块、拷贝字符串、更新 Buffer 指针和 MaximumLength再释放旧块。这里有个取舍释放旧块理论上更干净但实测某些环境下旧块并不一定属于进程堆释放会触发校验失败所以我一般选择不释放让进程自己回收。工具类进程跑完就退出泄漏几十个字节根本无所谓稳定优先。3. 实战 PEBFake路径和参数的双写技巧3.1 取 PEBx64 与 x86 的写法差异既然要改 PEB第一件事就是拿到 PEB 指针。这段代码我放在一个宏里编译成 x64 和 x86 都能自适应#ifdef _WIN64 #define CurrentPeb() ((MY_PEB*)__readgsqword(0x60)) #else #define CurrentPeb() ((MY_PEB*)__readfsdword(0x30)) #endif__readgsqword和__readfsdword是编译器内建指令分别从 GS 和 FS 段寄存器里读内容。在 64 位进程里用fs:0x30会读到一个无效地址这几乎是所有新手第一次写 PEB 相关代码必踩的坑。为了编译方便我不用 winternl.h 里那个不完全公开的 PEB 结构而是自定义一个最小结构体只保留访问需要用的字段typedef struct _MY_PEB { BYTE Reserved1[2]; BYTE BeingDebugged; BYTE Reserved2[1]; PVOID Reserved3[2]; PVOID Ldr; PVOID ProcessParameters; // 偏移 0x20 } MY_PEB, *PMY_PEB;3.2 更新 UNICODE_STRING 的完整实现改字符串的核心是两个字配对。Buffer、Length、MaximumLength 三者必须保持一致否则 ntdll 内部的校验逻辑会把它当成非法参数。下面是我整理过的一份可直接编译的完整实现#include windows.h #include winternl.h #include stdio.h typedef struct _MY_PEB { BYTE Reserved1[2]; BYTE BeingDebugged; BYTE Reserved2[1]; PVOID Reserved3[2]; PVOID Ldr; PVOID ProcessParameters; } MY_PEB, *PMY_PEB; #ifdef _WIN64 #define CurrentPeb() ((PMY_PEB)__readgsqword(0x60)) #else #define CurrentPeb() ((PMY_PEB)__readfsdword(0x30)) #endif // 原地覆盖新串长度不超过原缓冲区时使用 BOOL ReplaceStringInPlace(PUNICODE_STRING pUS, LPCWSTR lpNew) { SIZE_T cch wcslen(lpNew); SIZE_T cb cch * sizeof(wchar_t); // 不含结尾空字符的字节数 SIZE_T cbAlloc cb sizeof(wchar_t); // 含结尾空字符 if (cbAlloc pUS-MaximumLength) { return FALSE; // 装不下需要走堆重分配 } RtlZeroMemory(pUS-Buffer, pUS-MaximumLength); memcpy(pUS-Buffer, lpNew, cbAlloc); pUS-Length (USHORT)cb; pUS-MaximumLength (USHORT)cbAlloc; return TRUE; } // 堆重分配新串比旧缓冲区更长时使用 BOOL ReplaceStringWithHeap(PUNICODE_STRING pUS, LPCWSTR lpNew) { SIZE_T cch wcslen(lpNew); SIZE_T cb cch * sizeof(wchar_t); SIZE_T cbAlloc cb sizeof(wchar_t); HANDLE hHeap GetProcessHeap(); wchar_t* pNewBuf (wchar_t*)HeapAlloc(hHeap, HEAP_ZERO_MEMORY, cbAlloc); if (pNewBuf NULL) { return FALSE; } memcpy(pNewBuf, lpNew, cbAlloc); pUS-Buffer pNewBuf; pUS-Length (USHORT)cb; pUS-MaximumLength (USHORT)cbAlloc; // 旧缓冲区不释放让进程自然回收避免堆归属问题 return TRUE; } void FakeProcessParameters(LPCWSTR lpImagePath, LPCWSTR lpCmdLine) { PMY_PEB pPeb CurrentPeb(); if (pPeb NULL || pPeb-ProcessParameters NULL) { return; } PRTL_USER_PROCESS_PARAMETERS pParams (PRTL_USER_PROCESS_PARAMETERS)pPeb-ProcessParameters; // 改 PEB 前加锁防止其他线程同时读参数 RtlAcquirePebLock(); if (lpImagePath ! NULL) { if (!ReplaceStringInPlace(pParams-ImagePathName, lpImagePath)) { ReplaceStringWithHeap(pParams-ImagePathName, lpImagePath); } } if (lpCmdLine ! NULL) { if (!ReplaceStringInPlace(pParams-CommandLine, lpCmdLine)) { ReplaceStringWithHeap(pParams-CommandLine, lpCmdLine); } } RtlReleasePebLock(); }逻辑说明先尝试原地覆盖失败再走堆重分配。Length用的是字节数但不含结尾空字符MaximumLength含结尾空字符这是 UNICODE_STRING 的规范定义少一个或多一个都可能在调试时出现“字符串后面跟乱码”的现象。RtlAcquirePebLock是 ntdll 导出的锁函数改 PEB 期间持有锁避免出现读一半的中间态。3.3 给自己留的验证函数改动有没有生效不能靠猜得有一个能直接读 PEB 内容的函数void PrintCurrentPebStrings() { PMY_PEB pPeb CurrentPeb(); PRTL_USER_PROCESS_PARAMETERS pParams (PRTL_USER_PROCESS_PARAMETERS)pPeb-ProcessParameters; wprintf(L[] ImagePathName : %ls\n, pParams-ImagePathName.Buffer); wprintf(L[] CommandLine : %ls\n, pParams-CommandLine.Buffer); wprintf(L[] GetCommandLineW: %ls\n, GetCommandLineW()); }GetCommandLineW的返回值也在输出里打印一次是为了验证“系统 API 读到的是不是修改后的值”。如果只改 PEB 但 API 不走 PEB这场伪装就白做了。实际跑下来确认GetCommandLineW返回的就是伪造后的内容这一步才算闭环。3.4 编译和使用的注意事项编译时记住两点一是把平台选成 x6432 位进程在 64 位系统上跑的是 WOW64那一套 PEB32 的结构和这里完全不同混着用必然翻车二是以 Release 模式编译Debug 模式的 CRT 会多做一堆参数校验对改 PEB 这种操作不友好。调用时机越早越好放在main的第一行或者更早的入口点。如果放在某个库初始化之后CRT 里__wargv可能已经把原始命令行缓存了你改 PEB 只能骗后来的读取骗不了已经缓存的数据。4. 改 PEB 常见翻车点五条血泪排查记录4.1 进程崩溃 0xc0000374直接wcscpy覆盖堆内存现象调用wcscpy(pParams-CommandLine.Buffer, 新参数)之后进程过一会就崩退出码0xc0000374堆损坏。原因CommandLine 的 Buffer 指向一块堆内存长度按原始参数分配。新参数更长时wcscpy把数据写到了堆块边界之外破坏了堆元数据。这是 PEBFake 最经典的翻车姿势几乎每个初写者都会踩一次。解决写串之前先比较wcslen(新串) * 2 2和pUS-MaximumLength的大小。放不下就走HeapAlloc重新分配并替换 Buffer 指针绝不强制覆盖。4.2 改动完全没生效x64 上用了fs而不是gs现象用__readfsdword(0x30)拿 PEB 指针代码不崩但读出来的 ProcessParameters 地址全是错的改什么都不生效甚至读到无意义内存。原因x64 进程的 TEB 是从 GS 段寄存器寻址的fs:0x30在 64 位模式下指向的是一块无效或半兼容的区域不是真正的 PEB。程序编译成 x64 后TEB 里 PEB 指针的真实位置是gs:0x60。解决用#ifdef _WIN64区分平台宏x64 走__readgsqword(0x60)x86 走__readfsdword(0x30)。我后来所有涉及 PEB 的代码都会先打印一下取到的 PEB 地址和内核调试器里的结果比对确认没读错。4.3 字符串变乱码Length 和 MaximumLength 没有成对更新现象改完 PEB 后任务管理器能显示新路径但后面莫名其妙跟着上一份字符串的残留或者某些 API 直接返回错误。原因只改了 Buffer 内容和 Length没同步 MaximumLength。ntdll 内部处理 UNICODE_STRING 时会校验 Length 是否大于 MaximumLength一旦不一致轻则截断重则直接判定非法。解决三者必须成对更新。Length 新串字符数 * 2MaximumLength 新串字符数 * 2 2补结尾空字符。这个 2 最容易漏漏了之后调试器看到的字符串结尾会莫名多两个字节。4.4 32 位程序在 64 位系统上改不对位置WOW64 的 PEB32现象用 x86 编译的 PEBFake 在 64 位系统上运行路径伪装成功但命令行没变或者进程路径显示为C:\Windows\SysWOW64\...下的奇怪内容。原因WOW64 进程运行在 64 位系统上时系统为它单独维护了一份 32 位结构的 PEB32。这份 PEB32 里 ProcessParameters 的偏移和字段布局与原生 64 位 PEB 不同直接用同一个RTL_USER_PROCESS_PARAMETERS解析错位是必然的。解决工具场景直接编译为 x64 使用不要在 x86 上折腾 WOW64 的适配。如果必须支持 x86需要单独写一套 PEB32 结构体把 ProcessParameters 的偏移换成 32 位布局下的值。我的经验是为了一个工具同时支持两种架构调试成本远超收益。4.5 目标进程启动就被拦PEB 改得太晚遥测数据已经上报现象注入方式修改目标进程的 PEB改了之后看进程参数确实变了但终端的拦截告警里显示的仍然是原始路径。原因终端的 ETW 进程事件和内核回调在进程创建那一刻就已经把路径和参数上报了这些数据记录在事件日志和遥测历史里。你后来改 PEB改的只是当前快照改不了历史事件。另外目标进程的 CRT 可能已经把命令行缓存了改 PEB 并不会刷新那份缓存。解决认清 PEBFake 的能力边界——它对付的是“启动后读取快照”的检测逻辑不是“启动时上报遥测”的检测逻辑。改得越早越有效最佳时机是CreateProcess挂起后、目标主线程跑起来之前但即便如此也绕不过 ETW 已经发出的那一条进程事件。要做完整对抗得配合投递方式或注入时机一起考虑不能只靠 PEB。5. 现实对抗分析只靠 PEB 能挡到哪一层5.1 进程快照与 API 读取PEB 伪装最有效的战场终端防护检查进程最常见的手段是定时或触发式地枚举进程列表然后读取路径和命令行。这些信息从哪来有一部分走 WMI 查询有一部分直接读 PEB还有一部分通过GetCommandLineW这类 API。后面两种场景PEBFake 都能直接起作用因为 API 返回的就是你改过的内容。我在一次模拟攻防里做过对照把样本的路径改成C:\Program Files\Windows Defender\MsMpEng.exe同等的“标准路径”一部分采集逻辑确实放行了。原因在于这些采集逻辑只做了字符串比对没有校验该路径对应的进程是否存在也没有去查父进程是谁。PEB 伪装骗这类检查的成本极低见效很快。这也是为什么很多安全分析工具里进程路径和命令行只是参考项不能作为绝对信任的唯一依据。5.2 ETW 遥测与内核回调PEB 改不动启动历史但另一个现实是主流的终端防护早就不满足于查快照了。进程创建时ETW 的进程事件会把原始路径、原始命令行实时上报到驱动层存进审计日志。PEB 被修改后终端拿新快照和旧日志一比对立刻就能识别出“这不是系统自带的 svchost.exe是一个启动后篡改 PEB 的进程”反而更可疑。这个结论我一开始也不信直到在一次对抗中发现我改了 PEB 的样本在告警日志里原形毕露路径和参数就是进程创建时的原始值比我伪造的看起来还扎眼。从那以后我重构了思路PEB 伪装解决的是“被实时查看时展示什么”的问题而不是“从诞生起我是谁”的问题。只要 ETW 开着启动时的真相就无法通过改 PEB 抹掉。5.3 父子进程链与行为伪装之外的兜底还有一种检测不看路径只看行为链一个cmd.exe启动了powershell.exe然后这个 powershell 去访问一个外网地址这本身就是高危信号。PEB 把 powershell 的路径和参数改成svchost.exe也改变不了父进程是 cmd 的事实因为父进程 PID 存在系统内核的进程对象里不在 PEB 里。检测手段是否读取 PEBPEBFake 是否有效补充说明任务管理器及类似查看器是有效改完立即可见GetCommandLineW 类 API是有效后续读取全部返回新值沙箱的进程采集部分有条件取决于采集发生在启动阶段还是运行阶段ETW 进程事件否无效启动即上报历史不可改内核回调与父子进程链否无效行为面和 PEB 无关全内存特征扫描部分无效只解决路径伪装不解决内存内容所以 PEBFake 在完整对抗链条里只是其中一环。正确的用法是先把样本的行为做成不触发行为告警的状态再用 PEB 伪装修饰进程展示层两者配合才有效果。单改 PEB 指望骗过完整检测链就是把黑匣子当万灵丹最后大概率在父子进程链上翻车。6. 落地验证 PEBFake最后一公里请这样走代码写完了怎么确认它真的有用而不是自己骗自己我每次都会强制走一套三重验证第一步代码里先调PrintCurrentPebStrings打印改动前的 PEB 路径和参数再调FakeProcessParameters紧接着再打印一次。改动前后两组数据能对上说明 PEB 内部结构改对了这一关过不了就是代码问题别往别处想。第二步在任务管理器的“详细信息”页签里右键列头勾选“命令行”确认展示值和打印值一致。任务管理器走的是系统层采集能对上就说明不是你自己打印函数的幻觉。第三步用wmic process where processidpid get commandline这条指令再看一遍WMIC 走的查询链路和直接读 PEB 不同如果它返回的也是伪造后的参数说明改动穿透到了系统查询层。最后说一个我吃了亏之后养成的习惯改 PEB 的代码必须放在入口点的第一个函数调用里并且把改动前的快照打印留到调试完再删。有一次我把FakeProcessParameters放在某个库初始化之后执行结果那个库已经缓存了原始命令行后面所有判断都基于旧值排查了大半天才定位到是调用顺序的问题。从那以后我所有涉及进程参数的样本都强制走一遍“入口点第一行执行再验证 API 返回值”的流程这招替我挡掉了大部分无效伪装。希望帮到你。本文还有配套的精品资源点击获取