简介: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 偏移0x20(x64)的位置,里面字段很多,但我们真正需要动的只有下面这几个:
| 字段 | 类型 | 相对 ProcessParameters 的偏移(x64) | 说明 |
|---|---|---|---|
| ImagePathName | UNICODE_STRING | 0x60 | 进程可执行文件的完整路径 |
| CommandLine | UNICODE_STRING | 0x70 | 完整的命令行参数 |
| WindowTitle | UNICODE_STRING | 0x80 | 窗口标题,部分检测会看 |
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 取 PEB:x64 与 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 编译和使用的注意事项
编译时记住两点:一是把平台选成 x64,32 位进程在 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 = 新串字符数 * 2,MaximumLength = 新串字符数 * 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 里。
| 检测手段 | 是否读取 PEB | PEBFake 是否有效 | 补充说明 |
|---|---|---|---|
| 任务管理器及类似查看器 | 是 | 有效 | 改完立即可见 |
| GetCommandLineW 类 API | 是 | 有效 | 后续读取全部返回新值 |
| 沙箱的进程采集 | 部分 | 有条件 | 取决于采集发生在启动阶段还是运行阶段 |
| ETW 进程事件 | 否 | 无效 | 启动即上报,历史不可改 |
| 内核回调与父子进程链 | 否 | 无效 | 行为面,和 PEB 无关 |
| 全内存特征扫描 | 部分 | 无效 | 只解决路径伪装,不解决内存内容 |
所以 PEBFake 在完整对抗链条里只是其中一环。正确的用法是:先把样本的行为做成不触发行为告警的状态,再用 PEB 伪装修饰进程展示层,两者配合才有效果。单改 PEB 指望骗过完整检测链,就是把黑匣子当万灵丹,最后大概率在父子进程链上翻车。
6. 落地验证 PEBFake:最后一公里请这样走
代码写完了,怎么确认它真的有用,而不是自己骗自己?我每次都会强制走一套三重验证:
第一步,代码里先调PrintCurrentPebStrings打印改动前的 PEB 路径和参数,再调FakeProcessParameters,紧接着再打印一次。改动前后两组数据能对上,说明 PEB 内部结构改对了,这一关过不了就是代码问题,别往别处想。第二步,在任务管理器的“详细信息”页签里,右键列头勾选“命令行”,确认展示值和打印值一致。任务管理器走的是系统层采集,能对上就说明不是你自己打印函数的幻觉。第三步,用wmic process where processid=<pid> get commandline这条指令再看一遍,WMIC 走的查询链路和直接读 PEB 不同,如果它返回的也是伪造后的参数,说明改动穿透到了系统查询层。
最后说一个我吃了亏之后养成的习惯:改 PEB 的代码,必须放在入口点的第一个函数调用里,并且把改动前的快照打印留到调试完再删。有一次我把FakeProcessParameters放在某个库初始化之后执行,结果那个库已经缓存了原始命令行,后面所有判断都基于旧值,排查了大半天才定位到是调用顺序的问题。从那以后,我所有涉及进程参数的样本,都强制走一遍“入口点第一行执行,再验证 API 返回值”的流程,这招替我挡掉了大部分无效伪装。希望帮到你。
本文还有配套的精品资源,点击获取