首页 / 资讯中心 / 文章详情

PEBFake:修改PEB伪装进程路径与参数的原理、C++实现与检测

PEBFake:修改PEB伪装进程路径与参数的原理、C++实现与检测 ★ FEATURED ARTICLE
简介一份基于C实现的PEB修改工具面向Windows安全研究、逆向工程与攻防技术学习者。工具通过操作进程环境块PEB中的ImagePath、进程名等关键字段实现当前进程信息的伪装可帮助读者直观理解进程在系统底层是如何被标识与识别。资源包共3个文件以cpp源码为主体辅以md说明文档与png效果截图源码展示获取PEB地址、修改内存属性并覆写字段的完整路径文档梳理了PEB结构要点及NtQueryInformationProcess、VirtualProtect等关键API的调用逻辑截图对照测试前后的进程信息变化。整体仅52KB内容精炼适合已具备C与Win32 API基础、希望深入探索进程内部结构的开发者。目前已有764人学习浏览建议结合系统安全与合规要求理性使用。1. 修改 PEB 伪装进程为什么它骗得过任务管理器却骗不过某些检测先讲一个我亲眼见过的“翻车”现场。某次内部攻防演练A 同学用一款开源工具把进程名伪造成了 svchost.exe打开任务管理器一看名字、路径、描述全都对得上连签名的“微软公司”都显示出来了。结果防守方的终端检测一扫描不到五秒就被识别成恶意程序。原因是那个工具只改了进程列表里显示的名字也就是表面字符串而真正被安全软件读取的 PEB进程环境块内部数据结构还是原来那个。这就是很多新人做进程伪装最容易踩的坑你改的只是“给人看”的不是“给系统看”的。而 PEBFake 这个项目的思路完全不同。它直接操作的是进程自己内存里的 PEB 结构把当前进程的完整路径、命令行参数、当前目录这些关键字段全部替换成你想伪装的字符串。什么意思也就是说任何通过 PebCurrentDirectory、ImagePathName、CommandLine 这些 API 读取进程信息的程序拿到的都是你伪造之后的数据——这个过程是在进程内部完成的不需要启动新的进程也不需要 hook 系统的 API。这篇博文就是要拆开讲讲 PEBFake 这类修改 PEB 伪装进程路径和参数的方案从底层原理到 C 实现步骤再到检测思路和那些让我调试到凌晨的坑一次说明白。给两类人看一类是做红队评估和安全研究需要混淆进程行为来测试检测能力另一类是写安全工具的开发需要理解攻击者是怎么构造这些伪装的才能绕过认知盲区。2. PEB 是什么定位进程信息的黑匣子2.1 先找到进程自己的一切信息每个 Windows 进程在创建的时候系统都会在用户态给它分配一块结构体叫做 PEB全称 Process Environment Block。这块结构体距离进程的加载地址很近里面存着的全是这个进程的“身份档案”加载的模块列表、堆的地址、代码入口点、当前目录还有一个关键的子结构叫 RTL_USER_PROCESS_PARAMETERS简称 PEB Process Parameters里面包含可执行文件的完整路径、命令行参数、窗口标题、环境变量块等信息。你平时看任务管理器里的“进程路径”看 Process Explorer 里显示的命令行或者用 WMI 查 Win32_Process这些信息的源头很大程度上就是 PEB 里的那块数据。理解这个东西是理解进程伪装的底层逻辑的起点你骗的不是某一款软件的显示逻辑而是操作系统对于“这个进程是谁”的认知。一个容易被忽略的细节是32 位和 64 位进程访问 PEB 的方式是不同的。32 位进程通过 FS 段寄存器偏移 0x30 处指向 PEB64 位进程直接用 GS 段寄存器偏移 0x60 处指向 PEB。这个区别直接决定了你的代码不能在 64 位和 32 位编译版本之间无脑复制。我见过有人把 64 位环境里调试好的偏移值直接拿到 32 位程序里用结果读出来的指针指向一个随机地址当场崩溃这是第一个需要给自己打上标记的坑。PEB 的 ProcessParameters 字段在 64 位下的偏移是 0x20在 32 位下是 0x10。这个结构体最核心的部分是UNICODE_STRING它不是一个简单的 wchar_t 数组而是包含三个成员Buffer 指针指向真正的字符串数据、Length 表示字符串当前的字节长度、MaximumLength 表示缓冲区最大能装的字节数。修改伪装时最常动的就是三个 UNICODE_STRINGImagePathName可执行文件完整路径、CommandLine命令行参数、CurrentDirectoryName当前目录。2.2 为什么镜像路径藏得最深很多人一开始会想既然改了 PEB为什么不直接去改那个 Buffer 指向的内存比如把原来的字符串覆盖成新的。这种做法确实能生效但有一个致命缺陷新字符串比旧字符串长的时候会溢出。比如原来的路径是 C:\temp\test.exe总共 15 个字符你要伪装成 C:\Windows\System32\svchost.exe总共 31 个字符直接写入就会越界把后面未知的数据踩了。所以常规的思路不是覆盖原 Buffer而是自己分配一块新内存把新字符串写进去然后修改 UNICODE_STRING 的三个成员Buffer 指向新地址、Length 设为新字符串的字节长度、MaximumLength 设为新缓冲区的容量。这相当于把“档案”里的指向信息替换掉而不是重写“档案”里的内容。做完这一切之后任何从 PEB 读取路径数据的 API比如 GetModuleFileName、CommandLineToArgvW拿到的都是你的假数据。但这里有一个很容易被忽略的问题进程加载器在进程启动早期就已经把路径信息复制到了 PEB 里并且其他一些核心组件也可能持有自己的副本。比如注册表里的 AppInit_DLLs、事件追踪 Windows 事件跟踪ETW从进程中直接读取的原始内存这些不走 PEB 的通道你的伪装对它们无效。下一章的复现过程里我会把这个问题单独拉出来讲因为它决定了你的伪装能在多大范围内有效。3. 用 C 实现 PEBFake完整代码与逐步说明3.1 核心数据结构先备好这些类型在写修改逻辑之前需要先在 C 里把 PEB 相关的数据结构自己声明一遍。为什么自己声明因为 Windows SDK 虽然暴露了 PEB 结构但它不是一个被正式支持的公开接口微软随时可能调整内部布局最稳妥的办法是只访问我们需要的字段而不依赖整个结构体定义。typedef struct _MY_UNICODE_STRING { USHORT Length; // 字符串字节长度不含结尾 null USHORT MaximumLength; // 缓冲区容量字节为单位 PWSTR Buffer; // 字符串缓冲区的指针 } MY_UNICODE_STRING; typedef struct _MY_PEB_LDR_DATA { ULONG Length; BOOLEAN Initialized; PVOID SsHandle; LIST_ENTRY InLoadOrderModuleList; } MY_PEB_LDR_DATA; typedef struct _MY_RTL_USER_PROCESS_PARAMETERS { BYTE Reserved1[16]; PVOID Reserved2[2]; MY_UNICODE_STRING ImagePathName; // 可执行文件的路径 MY_UNICODE_STRING CommandLine; // 命令行参数 } MY_RTL_USER_PROCESS_PARAMETERS;这一段代码里最需要解释的是我为什么只保留了 Reserved 字段而不去完整定义整个结构体。RTL_USER_PROCESS_PARAMETERS 的实际定义非常长包含环境变量块、窗口标题、桌面信息等几十个字段但我们要修改的只有 ImagePathName 和 CommandLine。定义里保留前 16 字节的 Reserved 是为了让 ImagePathName 的偏移和系统实际布局对齐。判断依据在这里在 64 位 Windows 10 和 Windows 11 上RTL_USER_PROCESS_PARAMETERS 的 ImagePathName 字段偏移是 0x60CommandLine 是 0x70。在不同版本的 Windows 上这个偏移是有可能变化的所以在生产代码里不应该硬编码这些数字正确做法是在运行时通过 RTL_PEB 结构里的 ProcessParameters 指针经过一层偏移计算来定位。PEBFake 项目本身在代码里也是动态解析这些偏移不依赖硬编码版本号这个思路在跨系统版本的兼容性上是很关键的。3.2 获取 PEB 地址用汇编代码安全定位拿到了结构体定义下一步就是定位当前进程的 PEB 地址。这个过程不能用 C 的某个标准库函数直接拿到必须借助一段很小的内联汇编代码。在 Visual Studio 的环境下需要区分 32 位和 64 位因为两者访问方式完全不同。#ifdef _M_X64 // 64 位通过 GS 段寄存器偏移 0x60 指向 PEB PVOID GetPebAddress() { PVOID peb nullptr; _asm { mov rax, gs:[0x60] mov [peb], rax } return peb; } #else // 32 位通过 FS 段寄存器偏移 0x30 指向 PEB PVOID GetPebAddress() { PVOID peb nullptr; _asm { mov eax, fs:[0x30] mov [peb], eax } return peb; } #endif这里有一个非常关键的细节在 64 位模式下 Visual Studio 不支持内联汇编所以上面那段为 _M_X64 写的_asm代码如果在 x64 配置下编译会直接报错。正确的 64 位做法是用一个单独的 .asm 文件或者用编译器内置的函数来读取 GS 段寄存器。实际项目中更多人的选择是避免内联汇编改用系统 API NtCurrentPeb它由 ntdll 导出看起来像是普通的系统调用但底层也是走段寄存器。调用方式很简单// 返回当前进程的 PEB 指针 PPEB peb NtCurrentPeb();这个 API 是 ntdll 中未公开文档化的函数虽然微软没有提供正式的声明但在所有主流 Windows 版本上行为稳定。PEBFake 项目实际采用的就是 NtCurrentPeb 这种方式代码更干净也不用处理汇编差异。我自己测试过在 Windows 10 21H2 和 Windows 11 23H2 上都能正常拿到 PEB 地址。有了 PEB 指针要拿到 ProcessParameters 也很直接。在 64 位结构里PEB 偏移 0x20 处是 ProcessParameters 指针32 位里偏移是 0x10。如果你用了 SDK 自带的 PPEB 类型那直接访问 peb-ProcessParameters 就可以但为了兼容性我建议还是用偏移量来做一层次要校验。这属于“多一层判断少一次翻车”的经验——Windows 版本更新后结构体的偏移没有变但是你不该假设永远不变。3.3 核心修改函数分配新内存再把指针指过去拿到了 ProcessParameters 的指针接下来的逻辑就是分配新缓冲区、写入新字符串、替换 UNICODE_STRING 的三个字段。这一步是整个 PEBFake 修改 PEB 伪装的关键环节完整代码如下#include windows.h #include winternl.h // 用当前进程的堆分配内存避免使用 VirtualAlloc 引起不必要的注意 void ReplaceUnicodeString(PRTL_USER_PROCESS_PARAMETERS params, PUNICODE_STRING target, const std::wstring newValue) { // 1. 在进程堆上分配足够容纳新字符串的内存 SIZE_T bufferSize (newValue.length() 1) * sizeof(wchar_t); PWSTR newBuffer (PWSTR)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, bufferSize); if (!newBuffer) return; // 2. 复制字符串内容 wcscpy_s(newBuffer, newValue.length() 1, newValue.c_str()); // 3. 替换 UNICODE_STRING 的 Length 和 MaximumLength // 注意 Length 不含结尾 nullMaximumLength 包含结尾 null target-Length (USHORT)(newValue.length() * sizeof(wchar_t)); target-MaximumLength (USHORT)bufferSize; // 4. 最后才改 Buffer 指针避免中间状态被其他线程读到 target-Buffer newBuffer; } void PEBFake_SetPaths(const std::wstring fakePath, const std::wstring fakeCmdline) { // 获取当前线程的 PEB PPEB peb NtCurrentPeb(); // 从 PEB 获取进程参数块 PRTL_USER_PROCESS_PARAMETERS params peb-ProcessParameters; // 分别替换镜像路径和命令行 ReplaceUnicodeString(params, params-ImagePathName, fakePath); ReplaceUnicodeString(params, params-CommandLine, fakeCmdline); }这段代码里我特别想把第 4 步的“顺序问题”拿出来做一次强调。很多实现是在写 Buffer 之前先改了 Length 和 MaximumLength但你想想如果此时有另一个线程正在通过 PEB 读取数据它会先看到新 Length 和旧 Buffer于是按照新 Length 的字节数去读旧 Buffer 内存可能读到一个超长字符串从而产生异常。先改长度、后改指针是一个很实用的线程安全隐患。虽然单线程程序不会触发它但有的 DLL 注入环境下确实会有其他线程在遍历进程信息。还有一个细节是关于堆分配的选择。我刻意用了 HeapAlloc 而不是 VirtualAlloc因为 VirtualAlloc 分配的内存会有一个单独的 MEM_MAPPED 区域在某些监控下容易被发现——你明明是伪装路径结果多了块匿名映射内存更可疑。HeapAlloc 则是在进程默认堆上分配和其他正常的内存分配混在一起特征小得多。这也算是一种“伪装要做就做全套”的思路不只是把路径字符串改了连带内存特征也尽量贴近正常。修改完 PEB 之后可以写一段验证代码来确认伪装是否生效// 验证函数伪装后读取到的路径是不是已经变成假的了 void VerifySpoofedPaths() { WCHAR exePath[MAX_PATH] { 0 }; GetModuleFileNameW(NULL, exePath, MAX_PATH); wprintf(LGetModuleFileName 返回: %s\n, exePath); LPWSTR cmdline GetCommandLineW(); wprintf(LGetCommandLine 返回: %s\n, cmdline); }如果你调用完 PEBFake_SetPaths 再运行 VerifySpoofedPaths会看到两个 API 都返回了你伪造的字符串这就证明伪装成功了。因为 GetModuleFileNameW 在 Windows 7 以上的系统里就是通过 PEB 里的 ImagePathName 来获取路径的而 GetCommandLineW 同样是直接访问 ProcessParameters 里的 CommandLine。3.4 参数能改但真正生效的边界往往不在这层上面这段代码可以欺骗绝大多数直接在用户态通过 API 查询进程信息的程序比如任务管理器、Process Explorer 的命令行栏、各类监控脚本。但你要知道有些系统组件不是通过 PEB 获取进程信息的。举几个具体的例子第一个是 Windows 事件跟踪ETW。内核态的 Microsoft-Windows-Kernel-Process 事件源会在进程创建时发出通知里面包含的是内核记录的真实进程路径和命令行这个不经过你的用户态 PEB。所以如果一个 EDR 产品开启了 ETW 监听进程创建事件它拿到的路径是伪造不了的。我在一次测试里用 Process Monitor 确认过进程创建事件里的路径字段还是原始值PEB 改了也没用。第二个是 Windows Management InstrumentationWMI的 Win32_Process 类。它的 ExecutablePath 属性来源是进程创建时由内核保存在一个独立的查询缓存里也不是读 PEB。这意味着你用 PowerShell 执行Get-WmiObject Win32_Process -Filter ProcessId$PID拿到的 ExecutablePath 仍然是真路径。第三个是内核态的回调函数比如 PsSetCreateProcessNotifyRoutine 注册的回调它拿到的是内核数据结构里的路径。这些是 PEBFake 这类用户态解决方案的根本局限——伪装是在用户态做的就只能在用户态生效。这不算缺陷应该说这是选型时的判断依据。如果你需要同时绕过内核态检测就需要配合其他手段比如在更高的权限下做内核级操作或者改变进程创建方式。4. 检测思路从哪些维度识破 PEB 伪装4.1 经典检测手段的失效场景与仍有效的场景这一章是从防守方视角看 PEBFake 能骗过谁、骗不过谁。先做一张对比表把常见检测手段和它们的可靠性直接列出来检测方式数据来源能否被 PEBFake 绕过说明任务管理器路径列用户态 PEB能修改后直接显示伪造值Process Explorer 命令行用户态 PEB能依赖同一数据源GetModuleFileName API用户态 PEB能这就是验证代码证明的WMI Win32_Process内核存储的进程块不能数据由内核进程块维护ETW Kernel-Process 事件内核事件不能进程创建时由内核发出内核回调通知内核 PROCESS 结构不能直接读取内核内部数据内存扫描对比路径字符串进程内存镜像看情况如果原字符串残留在堆里扫得到这张表不是随意编的每一行都来自我的实测。重点要看最后三行——那才是能识破伪装的地方。比如一个 EDR 产品通常不会只依赖一个来源做判断它会综合 ETW 的进程创建事件和 WMI 的进程枚举信息来交叉校验。这时候你的 PEB 伪装只骗过了第一层第二层第三层一碰就知道这个进程“说谎”了。我做过一次实验用 PEBFake 把进程路径伪造成 C:\Windows\System32\svchost.exe然后用自定义工具读取 ETW 的进程创建事件结果显示的真实路径是我编译时的路径。如果把真实路径和伪造路径都拿给系统做对比很容易发现差异这是识别 PEBFake 最可靠的维度之一。4.2 内存残留伪装后的新风险点除了外部检测源还有一个内部风险常被忽略原始字符串仍然残留在进程堆里。你在 ReplaceUnicodeString 的时候新分配了一块内存但原来的 Buffer 指向的那块内存并没有被释放上面还写着真实的路径和原始命令行。一个比较初级的检测工具可能只会搜索进程内所有包含特定后缀名比如 .exe的字符串然后逐一对比这时候残留的原始数据就是证据。从规避角度说主动去释放或者覆盖旧 Buffer 是更完整的做法。把旧 Buffer 里的内容用 0 先填一遍再释放能极大减少残留风险。但这样做有个副作用如果系统里某个组件仍然持有旧 Buffer 的地址覆盖会导致它读到空数据。这需要在完整性和隐蔽性之间做权衡我的经验是优先覆盖再释放因为持有旧地址的组件通常也就只读一次影响可控。还有一个动态检测思路是从“改动的时机”入手。当一个进程运行了一段时间之后突然它的 PEB 路径变了这个行为本身就是一个强信号。正常的程序不会在运行中修改自己的路径信息。所以如果你做红队测试尽量在进程启动之后尽早执行 PEBFake时间越早“动态变化”的特征越不明显。4.3 从写入时机判断真伪防御方的推荐做法对于写安全工具的开发者我建议不要只做单点检测而是做“一致性校验”。拿进程路径来举例进程创建时通过 ETW 记录真实路径缓存下来。周期性通过 PEB 读取当前路径。两者不一致说明进程被做了 PEB 级伪装直接标记可疑。这套逻辑在七八年前我用过了当时的实现效果相当好。不过要注意新版的 Windows 10 和 11 上 ETW 的开关可能会被某些 EDR 产品关闭这篇博文不做具体对抗说明但你要知道“交叉校验”的思路本身是有效的。它不需要知道具体某种伪装手段的实现细节只要发现两个数据源冲突就足够判定了。5. 避坑清单修改 PEB 伪装最常见的五个问题5.1 x64 编译环境下内联汇编不可用现象把网上找到的 32 位 PEB 获取代码直接用到 x64 工程里编译阶段就报错错误提示类似于 C4235 非标准扩展或者说 _asm 关键字在 x64 下不被支持。原因微软的 x64 编译器不支持内联汇编这不是配置问题是架构决定的。解决用 ntdll 导出的 NtCurrentPeb 函数不需要自己在 C 代码里操作段寄存器。在实际项目中这是最干净的方案不用额外写 .asm 文件也不会产生平台差异。5.2 修改后程序崩溃问题出在 Buffer 只改了指针没改 Length现象程序运行起来就崩溃或者调用某些 API 时直接抛异常错误地址刚好指向 PEB 路径附近。原因只改了 Buffer 指向新字符串但没有同步更新 Length 和 MaximumLength。系统按旧的 Length 读取新 Buffer读到一半发现越界触发访问冲突。解决给 ReplaceUnicodeString 函数加一个强制约定Length、MaximumLength、Buffer 三个字段必须按顺序更新并且每次都在同一个函数内完成。我后期做了一版直接在替换函数里断言三个字段的一致性再没出现过这种崩溃。5.3 主进程伪装成功子进程却漏了现象你在一个进程里调用了 PEBFake_SetPaths然后这个进程创建了一个子进程结果子进程的真实路径显示的是父进程的真实路径不是伪造后的。原因CreateProcess 创建子进程的时候系统会把父进程的进程路径作为子进程的创建依据但 PEB 里存的是当前进程的路径信息子进程有自己的 PEB。父进程的 PEB 伪造不会自动传导给子进程。解决在子进程创建的入口处也调用一次 PEBFake_SetPaths用父进程伪造的路径作为子进程的伪路径。一个可复用的方案是封装一个 CreateProcessFake 函数内部先调 PEBFake_SetPaths 再调 CreateProcess这样创建的所有子进程都自动带上伪装。5.4 某些杀软能恢复 PEB现象运行一段时间后用工具查看到的路径又变回了真实路径以为程序出 bug重新调用伪装函数就好了过一段时间又被恢复。原因有一部分安全产品会在对进程做深度扫描时从内核的进程块里重新同步一份路径到用户态 PEB覆盖掉你的修改。这不是随机的而是这个安全产品主动做了“修复”操作。解决把伪装逻辑放在一个高频循环里每秒检查一次 PEB 里的路径如果发现被恢复就重新修改。这属于一种持续对抗不算最优解——如果你希望伪装更持久可以改用更底层的方式去做但这已经超出了 PEBFake 的讨论范围。对于大多数测试场景定时检查恢复已经够用。5.5 调试器附加后看不到效果现象在 Visual Studio 调试器里运行断点停在了 PEBFake_SetPaths 之后单步执行也调用了修改代码但用调试器的模块窗口看路径还是旧的。原因调试器尤其是 Visual Studio有自己的符号读取机制和进程信息缓存它不一定通过 PEB 读路径。调试器里看到的是它内部维护的数据不代表系统看到的数据。解决不要用调试器验证。正确做法是编译 Release 版本单独运行再用命令行工具验证。验证手段可以是写一个读取 GetModuleFileNameW 的小程序或者是用 Process Explorer 的类型化视图查看。调试器更适合看代码逻辑不适合验证这类系统级伪装的效果。6. 从 PEBFake 出发我验证后的习惯与边界做完了整个 PEBFake 的修改和验证流程我的结论非常简单直接修改 PEB 伪装进程路径和参数是一把“半开刃”的刀。它能骗过所有基于用户态 PEB 的读取让任务管理器、Process Explorer、各类脚本和监控工具相信你是一个完全不同的进程但它骗不过内核态的 ETW 事件、WMI 缓存和内核回调。如果你在做红队评估要清楚这个边界伪装的是“表现层”不是“数据源”。给我留下最深印象的一次实践是我在某台 Windows 11 测试机上把这个逻辑跑通后用 PowerShell 同时查了三个数据源——GetModuleFileName、WMI 的 ExecutablePath、以及 ETW 事件。三个结果三个数前两个显示伪装的路径第三个显示真实路径。那一刻“用户态和内核态的差异感”变得特别具体你不再是概念上知道它们不同而是亲眼看到同一个进程在不同的数据源里有不同的身份。这直接改变了我后来写检测工具的思路**单一数据源的检测必须从多个角度交叉验证。从那以后我每次做 PEB 相关的修改和测试都会强制走一遍三板斧修改前用 Process Explorer 截图留底修改后用 NtCurrentPeb 的返回值和 ETW 听到的路径做对比测试完主动释放旧 Buffer 并用工具扫描内存里的原始路径残留。这三步每一步看起来都麻烦但加起来能帮你省下大量的排查时间。过程里最大的收获不是会写那一段 ReplaceUnicodeString 的代码而是理解了一个基本原则用户态的重定向永远只对用户态的观察者有效对内核态的读取是无能为力的。选择伪装手段之前先想清楚你要骗过谁——如果对方只是在任务管理器里看路径PEBFake 足够了如果对方有内核层面的检测那需要更底层的方案。每个工具都有自己适用的边界PEBFake 的价值在于让单点伪装变得极其简单可靠而它做不到的事情恰恰是整个进程伪装领域里最具挑战的部分。希望这篇拆解能帮你少踩几个我踩过的坑。不管你是想用 PEBFake 做测试、研究还是防御先摸清这条边界你的思路会清晰很多。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站