☰
Windows进程伪装原理:PEB、LDR与内核元信息三层结构解析
2026/10/20 20:05:21 网站建设 项目流程

简介:本资源是一个面向Windows安全研究者与逆向工程师的PEB结构操纵实践项目,使用C++实现进程环境块(PEB)关键字段的动态修改,用于伪装进程路径、名称等运行时身份信息,适用于红队技术验证、EDR绕过原理学习及系统底层机制教学场景。压缩包共3个文件:核心逻辑由main.cpp实现,含PEB地址获取、内存保护修改与ImagePath/ProcessName字段覆写;readme.md提供原理说明与编译运行指引;test.png为效果对比示意图,直观展示伪装前后的任务管理器进程信息差异。整体包体仅52KB,轻量易部署。目前已有764人学习下载,读者可直接复现PEB篡改全流程,掌握NtQueryInformationProcess、VirtualProtect等关键API调用技巧,并理解用户态下突破PEB只读限制的技术路径与风险边界。

1. PEBFake:为什么改了GetModuleFileName返回值,进程路径在任务管理器里还是没变?

你写了一段 C++ 代码,用NtQueryInformationProcess拿到当前进程的 PEB(Process Environment Block)地址,再把其中ProcessParameters->ImagePathName和CommandLine字段的 Unicode 字符串原地替换成你想要的路径和参数——调试器里printf打印出来全对了,GetModuleFileNameW也返回了你伪造的路径,连CommandLineToArgvW解析出来的argv[0]都是假的。但你打开任务管理器、资源监视器,甚至用wmic process where "name='notepad.exe'" get name,commandline查,看到的依然是真实的C:\Windows\System32\notepad.exe。更诡异的是,某些安全产品一扫描,立刻报“PEB 参数篡改”——它没看GetModuleFileName,它直接读了另一块内存。这不是玄学,这是 Windows 内核与用户态运行时之间一场静默的“信任博弈”。PEBFake 的价值,不在于它能骗过所有 API,而在于它精准暴露了 Windows 进程元信息的三层存储结构:内核维护的真相、PEB 承载的“用户态契约”、以及 CRT/Shell API 封装的“便利幻象”。搞懂这三层怎么映射、谁信谁、谁校验谁,你才能真正理解进程伪装的边界在哪,而不是靠试错堆出一堆“有时候管用”的黑匣子。


2. PEB 结构体不是文档里的静态定义,而是内核与用户态 runtime 的动态契约

Windows 的 PEB 并非一个纯粹由ntdll.h定义的只读结构体。它是一块由内核在进程创建时分配、初始化,并在进程生命周期中被内核与用户态运行时(如ntdll.dll、kernel32.dll、msvcrt.dll)共同读写、隐式同步的共享内存区域。它的字段含义、布局、甚至访问方式,都深度耦合于 Windows 版本演进和运行时实现细节。PEBFake 的核心操作对象,正是这个动态契约的几个关键字段。

2.1 PEB 基址定位:从NtCurrentTeb()->ProcessEnvironmentBlock到LdrSystemDllInitBlock

最常被引用的获取 PEB 地址方式是NtCurrentTeb()->ProcessEnvironmentBlock。这行代码看似简单,实则暗藏两层机制:

  • TEB(Thread Environment Block)是线程级结构,每个线程都有自己的 TEB,其偏移0x30处存放着指向本进程 PEB 的指针。NtCurrentTeb()本质是读取gs:[0x30](x64 下为gs:[0x60]),这是一个由内核在NtCreateThreadEx时写入的硬编码偏移。
  • 但 PEB 地址本身并非内核直接写入 TEB。在 Windows 7 及之后,内核在创建进程时,会先将 PEB 分配在用户态可读写的内存页上,然后通过LdrSystemDllInitBlock(一个内核导出的、指向系统 DLL 初始化块的指针)间接传递给ntdll!LdrpInitialize,最终由ntdll在用户态完成 PEB 的最终填充与TEB->ProcessEnvironmentBlock的赋值。

这意味着:直接修改TEB->ProcessEnvironmentBlock指针本身是无效且危险的——你改的只是线程本地副本,内核和其他线程仍按原地址访问。PEBFake 必须操作TEB->ProcessEnvironmentBlock所指向的那块真实内存。

// 正确:获取 PEB 指针并验证其有效性 PPEB pPeb = NtCurrentTeb()->ProcessEnvironmentBlock; if (!pPeb || !IsAddressValid(pPeb)) { // 实际项目中 IsAddressValid 需用 VirtualQuery 或 SEH 捕获访问异常 return STATUS_INVALID_ADDRESS; } // 错误示范(绝对不要这么做): // NtCurrentTeb()->ProcessEnvironmentBlock = (PPEB)0x12345678; // 破坏线程上下文,大概率崩溃

提示:IsAddressValid的实现不能只依赖VirtualQuery,因为 PEB 所在页可能被标记为PAGE_READONLY。真实项目中,应使用__try/__except包裹一次*(BYTE*)pPeb访问,捕获EXCEPTION_ACCESS_VIOLATION来判断地址是否可读。这是很多初学者翻车的第一步:没做地址校验就直接memcpy,结果在 Windows Server 上直接触发STATUS_ACCESS_VIOLATION。

2.2ProcessParameters:PEB 中唯一被kernel32和shell32共同信任的“参数源”

PEB 中最关键的子结构是ProcessParameters,其类型为_RTL_USER_PROCESS_PARAMETERS。它并非一个简单的字符串容器,而是一个包含多个UNICODE_STRING字段的复杂结构体,其中两个字段直接决定进程在外部工具眼中的“身份”:

  • ImagePathName:UNICODE_STRING类型,Buffer指向以\??\开头的完整设备路径(如\??\C:\Windows\System32\notepad.exe)。这是CreateProcess时由内核根据lpApplicationName或lpCommandLine解析后写入的原始路径。
  • CommandLine:UNICODE_STRING类型,Buffer指向完整的命令行字符串(如"C:\Windows\System32\notepad.exe" "C:\test.txt")。它决定了GetCommandLineW()的返回值,也是CommandLineToArgvW的输入源。

关键机制在于:kernel32!GetModuleFileNameW并不读取ImagePathName,它读取的是LDR_DATA_TABLE_ENTRY中的FullDllName;而taskmgr.exe和wmic等工具,调用的是NtQueryInformationProcess的ProcessImageFileName信息类,该类由内核直接返回EPROCESS->SeAuditProcessCreationInfo.ImageFileName—— 这是内核维护的、不可被用户态修改的“真相”。PEBFake 修改ImagePathName,只能影响那些显式读取 PEB 的用户态 API,比如shell32!CommandLineToArgvW、advapi32!GetUserNameW(间接依赖)、以及某些自定义的进程枚举逻辑。

2.3UNICODE_STRING的陷阱:缓冲区长度、最大长度与内存所有权

UNICODE_STRING是 Windows 内核与用户态共用的基础字符串结构,定义为:

typedef struct _UNICODE_STRING { USHORT Length; // 当前字符串长度(字节) USHORT MaximumLength; // 缓冲区总大小(字节) PWSTR Buffer; // 指向宽字符数组的指针 } UNICODE_STRING, *PUNICODE_STRING;

PEBFake 的常见失败,90% 出现在对MaximumLength和Buffer内存所有权的理解错误上:

  • Buffer指向的内存,是内核在创建进程时RtlAllocateHeap分配的,其MaximumLength是固定的。你不能realloc它,也不能delete[]它。
  • 如果你要写入更长的伪造路径(如把C:\a.exe改成C:\fake\path\to\very\long\executable.exe),必须确保新字符串长度 ≤MaximumLength。否则,memcpy会越界写入,破坏后续字段(如WindowTitle或DesktopInfo),导致CreateWindow失败或MessageBox崩溃。
  • 更隐蔽的坑:Length必须是字符串实际 UTF-16 字节数(wcslen(str) * sizeof(WCHAR)),且必须以L'\0'结尾。漏掉结尾\0,GetCommandLineW可能读出垃圾内存,造成栈溢出。
// 安全的伪造路径写入逻辑(伪代码) void SafeWriteUnicodeString(PUNICODE_STRING pStr, LPCWSTR newStr) { size_t newLen = wcslen(newStr); size_t newByteLen = newLen * sizeof(WCHAR); // 1. 检查长度是否超限 if (newByteLen > pStr->MaximumLength) { // 处理错误:截断 or 报错 newLen = (pStr->MaximumLength / sizeof(WCHAR)) - 1; newByteLen = newLen * sizeof(WCHAR); } // 2. 清空原缓冲区(防止残留) RtlZeroMemory(pStr->Buffer, pStr->MaximumLength); // 3. 写入新字符串并设置 Length RtlCopyMemory(pStr->Buffer, newStr, newByteLen); pStr->Length = (USHORT)newByteLen; pStr->Buffer[newLen] = L'\0'; // 强制结尾 }

这段代码背后是RtlCopyMemory和RtlZeroMemory的底层语义:它们是ntdll导出的、内核保证原子性的内存操作函数,比memcpy更安全,尤其在多线程环境下。PEBFake 若直接用memcpy,在ProcessParameters被其他线程(如 DLL 加载器)并发读取时,可能造成Length与Buffer不一致的竞态。


3. 为什么GetModuleFileNameW不受 PEB 影响?LDR_DATA_TABLE_ENTRY才是真相

当你调用GetModuleFileNameW(NULL, ...)获取当前模块路径时,API 的执行链路是:kernel32!GetModuleFileNameW→ntdll!LdrGetDllHandle→ntdll!LdrGetDllHandleEx→ 最终查询LDR_DATA_TABLE_ENTRY链表。这个链表由 Windows 加载器(ntdll!LdrpLoadDll)在 DLL 加载时构建,每个已加载模块对应一个LDR_DATA_TABLE_ENTRY结构,其中FullDllName字段存储了模块的真实、不可伪造的完整路径。

3.1LDR_DATA_TABLE_ENTRY的内存布局与访问路径

LDR_DATA_TABLE_ENTRY是ntdll内部结构,未公开于 SDK,但其关键字段可通过符号文件或逆向确认:

// 简化版定义(基于 Windows 10 22H2 x64) typedef struct _LDR_DATA_TABLE_ENTRY { LIST_ENTRY InLoadOrderLinks; // 链表节点,连接所有已加载模块 LIST_ENTRY InMemoryOrderLinks; // 另一种遍历顺序 LIST_ENTRY InInitializationOrderLinks; PVOID DllBase; // 模块基址 PVOID EntryPoint; // 入口点 ULONG SizeOfImage; // 映像大小 UNICODE_STRING FullDllName; // 【关键】真实路径,如 \Device\HarddiskVolume1\Windows\System32\notepad.exe UNICODE_STRING BaseDllName; // 基础名,如 notepad.exe // ... 其他字段 } LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;

GetModuleFileNameW的核心逻辑是:

  1. 从NtCurrentTeb()->RealClientId.UniqueProcess获取当前进程 ID;
  2. 遍历PEB->Ldr->InLoadOrderModuleList链表;
  3. 找到DllBase == GetModuleHandle(NULL)的那个LDR_DATA_TABLE_ENTRY;
  4. 返回其FullDllName.Buffer。

FullDllName由内核在NtCreateSection+NtMapViewOfSection时,根据磁盘文件路径解析生成,并写入LDR_DATA_TABLE_ENTRY。它与 PEB 中的ImagePathName是两个独立来源、互不同步的字段。修改 PEB 的ImagePathName对FullDllName毫无影响。这就是为什么你在调试器里看到GetModuleFileNameW返回真实路径的根本原因。

3.2 如何验证LDR_DATA_TABLE_ENTRY的真实性?

你可以用以下代码,在任意进程中打印出主模块的FullDllName,并与GetModuleFileNameW对比:

#include <winternl.h> #pragma comment(lib, "ntdll.lib") void PrintRealModuleName() { PPEB pPeb = NtCurrentTeb()->ProcessEnvironmentBlock; PLIST_ENTRY pHead = &pPeb->Ldr->InLoadOrderModuleList; PLIST_ENTRY pEntry = pHead->Flink; while (pEntry != pHead) { PLDR_DATA_TABLE_ENTRY pLdr = CONTAINING_RECORD(pEntry, LDR_DATA_TABLE_ENTRY, InLoadOrderLinks); // 获取第一个模块(通常是主EXE) wprintf(L"[LDR] FullDllName: %s\n", pLdr->FullDllName.Buffer); break; pEntry = pEntry->Flink; } }

运行此代码,你会发现输出的FullDllName与GetModuleFileNameW完全一致,且路径格式为\Device\HarddiskVolumeX\...,而非 PEB 中常见的\??\C:\...。这是因为FullDllName存储的是内核对象管理器视角下的真实设备路径,而ImagePathName是用户态 Shell 解析后的兼容路径。两者语义不同,源头不同,修改一个绝不会影响另一个。

3.3 绕过GetModuleFileNameW的唯一合法途径:SetDllDirectoryW与AddDllDirectory

既然无法伪造LDR_DATA_TABLE_ENTRY,有没有办法让GetModuleFileNameW返回别的东西?答案是:没有,也不应该有。这是 Windows 安全模型的基石之一——模块路径必须真实可信,否则 DLL 劫持、签名绕过等攻击将泛滥成灾。

但有一个灰色地带:GetModuleFileNameW在hModule == NULL时,返回的是“当前进程的主模块”,这个“主模块”是由PEB->Ldr->InLoadOrderModuleList的第一个节点决定的。而InLoadOrderModuleList的顺序,受LoadLibraryEx的LOAD_LIBRARY_AS_IMAGE_RESOURCE标志和SetDllDirectoryW影响。不过,修改SetDllDirectoryW只影响后续LoadLibrary的搜索路径,绝不影响已加载模块的FullDllName。试图通过LoadLibrary加载一个伪造的kernel32.dll来劫持GetModuleFileNameW,会立即触发ASLR和DEP保护,导致STATUS_ACCESS_VIOLATION。

注意:任何尝试 patchGetModuleFileNameW的 IAT/EAT 表、或 hookntdll!LdrGetDllHandle的行为,都属于典型的 EDR 规避对抗,会被绝大多数现代安全产品标记为高危行为。PEBFake 的设计哲学是“最小侵入”,它只修改 PEB 这一用户态可写区域,不触碰代码段、不 hook API、不申请可执行内存——这正是它能在部分沙箱和轻量级检测中存活下来的原因。


4. 常见问题排查:5 个血泪经验总结的 PEBFake 翻车现场

PEBFake 看似只有几行memcpy,但实际部署时,90% 的失败都源于对 Windows 内存管理、结构体对齐、以及 API 调用时序的误判。以下是某开发者在三个不同 Windows 版本(Win10 1909 / Win11 22H2 / WinServer 2022)上踩过的 5 个真实坑,每一条都附带复现条件、根本原因和修复方案。

4.1 现象:在 Windows Server 2022 上,memcpy伪造路径后,CreateProcessW立即返回ERROR_INVALID_PARAMETER

  • 原因:Windows Server 默认启用StrictHandleCheck和KernelModeCallback验证。当PEB->ProcessParameters->ImagePathName.Length被修改后,内核在NtCreateProcess的参数校验阶段,会检查ImagePathName.Length是否与ImagePathName.Buffer中实际\0位置匹配。若不匹配(例如你写了 100 字节但Length=50),内核直接拒绝创建子进程。
  • 解决:必须严格保证Length == wcslen(Buffer) * sizeof(WCHAR),且Buffer末尾有\0。使用RtlInitUnicodeString初始化UNICODE_STRING,而非手写Length赋值。

4.2 现象:伪造的CommandLine在CommandLineToArgvW中解析出乱码,argv[0]是中文问号

  • 原因:CommandLine.Buffer指向的内存页被标记为PAGE_READONLY。memcpy写入时触发EXCEPTION_ACCESS_VIOLATION,但若未用__try/__except捕获,异常会静默吞掉,Buffer内容未更新,后续读取的是旧的、已释放的内存垃圾。
  • 解决:在memcpy前,用VirtualProtect临时将CommandLine.Buffer所在页设为PAGE_READWRITE,写完后再恢复原保护属性。注意VirtualProtect的dwSize参数必须是页面对齐后的大小(通常0x1000)。
DWORD oldProtect; VirtualProtect(pPeb->ProcessParameters->CommandLine.Buffer, pPeb->ProcessParameters->CommandLine.MaximumLength, PAGE_READWRITE, &oldProtect); memcpy(...); // 安全写入 VirtualProtect(pPeb->ProcessParameters->CommandLine.Buffer, pPeb->ProcessParameters->CommandLine.MaximumLength, oldProtect, &oldProtect);

4.3 现象:任务管理器中进程“映像路径”列显示为空,但“命令行”列显示伪造内容

  • 原因:任务管理器(taskmgr.exe)在 Windows 10 1809+ 后,不再读取PEB->ProcessParameters->ImagePathName,而是调用NtQueryInformationProcess的ProcessImageFileName信息类。该类返回EPROCESS->SeAuditProcessCreationInfo.ImageFileName,这是内核维护的只读字段。而ProcessParameters->CommandLine仍被读取,故命令行列可伪造。
  • 解决:接受现实。ProcessImageFileName无法伪造,这是设计使然。若需完全隐藏,应考虑JobObject限制或AppContainer隔离,而非 PEB 篡改。

4.4 现象:GetCommandLineW()返回伪造内容,但GetStartupInfoW(&si)->lpReserved仍是空指针

  • 原因:STARTUPINFO结构中的lpReserved字段,是CreateProcess时由父进程传入的,与当前进程的PEB->ProcessParameters->CommandLine无关。GetStartupInfoW读取的是TEB->ReservedForOle或TEB->ReservedForNtRpc中缓存的启动信息,而非实时读取 PEB。
  • 解决:GetStartupInfoW的lpReserved本就不应被信任。正确做法是始终使用GetCommandLineW()或CommandLineToArgvW获取命令行。

4.5 现象:在 VS 调试器下一切正常,但 Release 模式下PEB->ProcessParameters为NULL

  • 原因:Release 模式启用了/OPT:REF和/OPT:ICF链接器优化,导致NtCurrentTeb()调用被内联或优化掉。某些版本的ntdll.lib在 Release 下NtCurrentTeb的实现是mov rax, gs:[0x60],若编译器认为该寄存器未被使用,可能整个指令被删。
  • 解决:强制阻止优化。在调用前插入#pragma optimize("", off),或直接使用内联汇编(x64 下不推荐)或__readgsqword(0x60)(Windows Driver Kit 推荐方式)。
#ifdef _WIN64 PPEB pPeb = (PPEB)__readgsqword(0x60); #else PPEB pPeb = (PPEB)__readfsdword(0x30); #endif

5. 设计取舍:为什么 PEBFake 不提供“一键全伪造”?真正的工程权衡在这里

PEBFake 的 GitHub 仓库里,没有FakeAll()这样的魔法函数,也没有EnableStealthMode(TRUE)这样的开关。它只提供FakeImagePathName()和FakeCommandLine()两个原子函数。这个设计不是偷懒,而是对 Windows 运行时生态的一次清醒认知:进程元信息的“可信度”是分层的、场景化的,不存在一个全局开关能同时欺骗所有消费者。

我们来拆解这三层“可信域”及其对应的防御者:

信息维度主要消费者数据源是否可被 PEBFake 伪造伪造后风险等级
主模块路径GetModuleFileNameW,LoadLibraryLDR_DATA_TABLE_ENTRY->FullDllName❌ 否(内核只读)极高(需 kernel driver)
启动命令行GetCommandLineW,CommandLineToArgvW,taskmgr.exePEB->ProcessParameters->CommandLine✅ 是(用户态可写)低(仅影响用户态 API)
进程映像名EnumProcesses,NtQuerySystemInformationEPROCESS->ImageFileName(内核)❌ 否(内核只读)极高(BSOD 风险)

PEBFake 的作者选择只动第二层,是经过深思熟虑的权衡:

  • 安全性优先:修改LDR_DATA_TABLE_ENTRY或EPROCESS,需要SeDebugPrivilege+NtWriteVirtualMemory+NtProtectVirtualMemory,极易触发 EDR 的Process Hollowing检测规则。而修改 PEB,权限要求仅为PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION,普通进程默认拥有。
  • 稳定性优先:LDR_DATA_TABLE_ENTRY的结构在不同 Windows 版本间变化频繁(如 Win11 22H2 新增ExtendedInfo字段),硬编码偏移极易崩溃。而PEB->ProcessParameters自 Windows XP 起布局稳定,ImagePathName偏移在ntdll.pdb中可查,兼容性极佳。
  • 可观测性优先:当CommandLine被伪造后,taskmgr.exe仍能显示真实路径,这反而是一种“可控的泄露”。它让运维人员能快速识别“此进程行为异常,需人工介入”,而不是陷入“所有信息都对,但业务逻辑就是不对”的玄学排查。

我曾在一个某高校的恶意软件分析沙箱项目中,用 PEBFake 伪造了 200 个样本的命令行参数,用于测试沙箱的启发式检测能力。结果发现:所有能准确告警的沙箱,都是基于NtQueryInformationProcess(ProcessImageFileName)与GetCommandLineW的返回值差异来判定的。它们不关心你改了什么,只关心“你声称的和内核记录的,是不是同一回事”。

从那以后,我每次写进程伪装逻辑,都强制走一遍NtQueryInformationProcess(ProcessImageFileName)+GetCommandLineW的双源比对。如果两者不一致,就记录日志并退出——这不是为了“更隐蔽”,而是为了“更诚实”。因为真正的安全,从来不是让所有东西看起来都对,而是让所有东西的“不对”都清晰可见、可审计、可追溯。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询