news 2026/10/16 10:22:56

C++实现进程伪装:修改PEB与Ldr让进程改名换姓

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++实现进程伪装:修改PEB与Ldr让进程改名换姓

简介:资源是一份C++编写的进程伪装工具PEBFake,面向安全研究、逆向工程和Windows底层开发人员,核心围绕进程环境块(PEB)展开;工具通过修改PEB中的ImagePath、ProcessName等关键字段,隐藏或篡改进程路径、参数与名称,可帮助理解恶意软件规避检测的常见手法。压缩包共3个文件,包括可编译的cpp源码、带实现解析的md参考文档和演示效果的png示意图,整体仅52KB,轻量且信息完整;已有764人浏览学习,适合具备一定C++与WinAPI基础、对进程内部机制感兴趣的开发者。源码实现涉及获取目标进程句柄、通过NtQueryInformationProcess读取PEB地址、使用VirtualProtect修改内存保护后写入新路径,并讨论了进程信息持久化伪装与异常恢复的思路。md文档还标注了此类非标准技术可能触发安全软件告警,以及操作不当可能导致系统不稳定或法律风险,提醒在合法合规范围内研究;png效果图直观展示了伪装前后的进程信息对比,便于快速验证源码效果。

1. PEBFake是什么:改一个结构体就能让进程“改名换姓”

调试一个遗留的32位老程序时,我发现它启动时会对自身路径做hash校验,换目录就退出。用PEBFake改了一下PEB(进程环境块)里的ImagePathName和CommandLine,程序完全没察觉路径变了,该跑跑。PEBFake是一个用C++实现的进程信息伪装工具,核心原理是直接修改当前进程的PEB字段,伪造进程路径、命令行参数等信息,让API调用方(包括调试器、日志模块、路径自校验代码)读到的是假数据。它适合做软件兼容性测试、多开冲突排查、安全分析和脱壳练习,新手能通过它理解Windows进程初始化的底层流程,熟手能直接把它集成进自己的调试工具链。

2. PEB与Ldr:进程路径到底存在哪,为什么只改一处会失效

很多初次接触的人以为进程路径是内核里的一个固定字段,改个环境变量就能生效。真正在用户态决定这一切的,是PEB里的几块缓冲区和链表。创建进程时,系统会把命令行、工作目录、环境变量填到一个RTL_USER_PROCESS_PARAMETERS结构里,然后挂到PEB上;同时每个被映射的DLL也会在Ldr链表中登记一条记录,保存它的完整路径。二者互为参照,却不完全一致,这也是PEBFake要同时处理两个地方的原因。

2.1 PEB的未文档化结构:从TEB找到ProcessParameters

PEB不是官方文档化的结构,但Windows自身依赖它,.NET、Rust、Go运行时也直接读它。每个线程的TEB(Thread Environment Block)指向当前进程的PEB,在x64上通过gs:[0x60]获取,在x86上通过fs:[0x30]。得到PEB指针后,ProcessParameters指向的RTL_USER_PROCESS_PARAMETERS包含ImagePathName、CommandLine、CurrentDirectory、Environment等关键字段。比如GetCommandLineW内部会走到RtlGetCommandLine,后者只是把PEB里的CommandLine缓冲区交给调用者,所以只要改了这个缓冲区的字符和长度,返回值就会变化。

这里最容易被忽略的是UNICODE_STRING的结构:它由Buffer指针、Length字节长度、MaximumLength缓冲容量组成。为什么用字节而不是字符数?因为内核里很多路径处理是按字节操作的,方便尾部截断和拼接,也导致后续代码里必须严格区分字符和字节,否则就会翻车。

2.2 Ldr模块链表:另一个藏着路径的地方

进程启动后,PEB->Ldr指向PEB_LDR_DATA,里面有三个双向链表:InLoadOrderModuleList、InMemoryOrderModuleList、InInitializationOrderModuleList。每个节点都是LDR_DATA_TABLE_ENTRY,包含DllBase、FullDllName、BaseDllName。GetModuleFileNameW就是通过遍历InMemoryOrderModuleList,比对DllBase,然后返回对应的FullDllName。

所以如果只改ProcessParameters的ImagePathName而不改Ldr链表的FullDllName,会出现一个诡异现象:GetCommandLineW返回假路径,而GetModuleFileNameW仍然返回真路径。你在自己写的代码里同时读这两个值,会立即发现矛盾。实际上很多校验逻辑正是故意同时判断这两处,只看一个地方反而能通过。

2.3 为什么不建议只改字符串:长度、完整性和一致性

第一个坑是长度。UNICODE_STRING.Length是按字节算的,MaximumLength必须容纳结尾的null。如果只改Buffer内容,不更新Length,有些API会多读或少读;如果新字符串更长,则会越界写到相邻内存。第二个坑是完整性。除了ImagePathName,CurrentDirectory、WindowTitle、Environment也可能被程序读取,需要根据场景决定是否一起伪装。第三个坑是一致性。多线程程序可能有一个线程正在读取ProcessParameters的同一段内存,另一个线程改到一半,读线程就会拿到一半旧一半新的数据,引发莫名其妙的崩溃。

所以我在工程里倾向于把修改操作分成三步:先把其他线程挂起,再改内存,最后恢复。如果你只是给自己写的玩具程序做演示,可以忽略挂起;但要做注入或处理复杂宿主进程,挂起是必须的,否则就是拿运气赌稳定性。

3. C++实现:完整读写PEB与Ldr的代码与参数说明

这一章直接进入工程实现。PEBFake工程里提供的示例代码按三个模块组织:获取PEB、修改ProcessParameters、同步Ldr链表。下面按顺序拆开讲。

3.1 获取PEB地址并初始化访问权限

获取PEB指针是第一步。推荐用编译器内联读取TEB偏移,而不是手工计算地址。

#include <windows.h> #include <winternl.h> // 获取当前线程的PEB指针。x64和x86的TEB偏移不同。 PEB* GetCurrentPeb() { #ifdef _WIN64 // x64下,TEB的0x60偏移保存PEB指针 return (PEB*)__readgsqword(0x60); #else // x86下,TEB的0x30偏移保存PEB指针 return (PEB*)__readfsdword(0x30); #endif }

这里有两个细节值得说明。第一,__readgsqword和__readfsdword是MSVC专有内联,换成GCC/Clang时要用_readgsbase_u64之类的内建函数,或者写一小段内联汇编。第二,TEB偏移在64位和32位下是不同的,如果你在64位程序里用x86的fs偏移去读,拿到的不会是PEB,而是一个无效地址。后面避坑章还会专门提Wow64场景。

3.2 修改ProcessParameters中的ImagePathName和CommandLine

拿到PEB之后,ProcessParameters就挂在它上面。修改时优先采用“原地替换”策略,因为Buffer指针不变化,依赖这个指针的模块缓存不会被破坏;只有新字符串比原缓冲区更长时才分配新缓冲区。

#include <string> // 原地替换UNICODE_STRING,要求新值长度不超过原缓冲区容量 static bool ReplaceUnicodeStringInPlace(UNICODE_STRING* target, const std::wstring& value) { // MaximumLength按字节算,包含结尾null,所以这里判断整串是否放得下 if ((value.size() + 1) * sizeof(wchar_t) > target->MaximumLength) return false; DWORD oldProtect; // ProcessParameters所在页面可能是只读的,先解除保护 if (!VirtualProtect(target->Buffer, target->MaximumLength, PAGE_READWRITE, &oldProtect)) return false; // 清掉旧内容,避免残留路径片段造成“半新半旧”的脏数据 ZeroMemory(target->Buffer, target->MaximumLength); // wcscpy_s自动补结尾null,第三个参数是字符数,不是字节数 wcscpy_s(target->Buffer, target->MaximumLength / sizeof(wchar_t), value.c_str()); // Length是字符串字节数,不包括结尾null target->Length = (USHORT)(value.size() * sizeof(wchar_t)); // MaximumLength保持不变,仍表示缓冲区总容量 // 还原页面保护属性,注意是还原,不是改成只读 VirtualProtect(target->Buffer, target->MaximumLength, oldProtect, &oldProtect); return true; }

逻辑说明:整个函数的核心是控制两个尺寸边界。wcscpy_s要求的是字符数组容量,所以target->MaximumLength要除以sizeof(wchar_t)。而Length赋的是字节数,如果少了这一步,即使Buffer内容已经改掉,很多API也会认为字符串是空的。这是最常见的翻车点之一。

当新路径比旧缓冲区还长时,原地替换就不能用了。这时分配一块新缓冲区,然后替换Buffer指针:

// 分配新缓冲区并替换UNICODE_STRING指针,注意不释放旧Buffer static bool ReplaceUnicodeStringAlloc(UNICODE_STRING* target, const std::wstring& value) { size_t bytes = (value.size() + 1) * sizeof(wchar_t); wchar_t* newBuf = (wchar_t*)VirtualAlloc(NULL, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!newBuf) return false; wcscpy_s(newBuf, value.size() + 1, value.c_str()); // 不释放旧Buffer:可能有其他模块仍缓存着这个地址,释放会踩空 target->Buffer = newBuf; target->Length = (USHORT)(value.size() * sizeof(wchar_t)); target->MaximumLength = (USHORT)bytes; return true; }

这里故意不释放旧Buffer,是经过几次崩溃换来的经验。旧Buffer属于进程堆块,进程内可能有模块早就保存了它的指针,一旦释放就会成为悬挂指针,后续写操作会触发访问违规。如果只在当前作用域内临时使用,可以留着旧块不处理;最终恢复现场时把旧Buffer换回来,再释放新块即可。

3.3 同步修改Ldr模块链表中的FullDllName

ProcessParameters改完后,Ldr链表也要同步。链表的遍历需要借助CONTAINING_RECORD,避免手工计算偏移。

// 修改指定模块的FullDllName,module传NULL表示主程序模块 void FakeModulePath(HMODULE module, const std::wstring& fakePath) { PEB* peb = GetCurrentPeb(); if (!peb || !peb->Ldr) return; // 使用InMemoryOrderModuleList,它在x64下所在的节点偏移更稳定 LIST_ENTRY* head = &peb->Ldr->InMemoryOrderModuleList; for (LIST_ENTRY* entry = head->Flink; entry != head; entry = entry->Flink) { // CONTAINING_RECORD根据节点字段反推出LDR_DATA_TABLE_ENTRY头部 auto* ldrEntry = CONTAINING_RECORD(entry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks); if (ldrEntry->DllBase != module) continue; // FullDllName就是模块完整路径,原地替换即可 if (!ReplaceUnicodeStringInPlace(&ldrEntry->FullDllName, fakePath)) { // 模块路径缓冲区通常较小,原地失败就放弃,不强行分配 break; } break; } }

参数说明:module用GetModuleHandle(NULL)就能拿到主程序基址。很多人误传GetModuleHandle(NULL)返回的HMODULE值,实际上它和exe的基址一致,没问题。但要注意有些模块是延迟加载或动态加载的,枚举时机必须发生在LoadLibrary之后,否则链表里还没这条记录。

另外一个细节是:InMemoryOrderModuleList在LDR_DATA_TABLE_ENTRY中的偏移和InLoadOrderModuleList不同。如果用错,CONTAINING_RECORD会算出错误的首地址,轻则返回值牛头不对马嘴,重则写入时改坏链表节点。用InMemoryOrder而不是InLoadOrder可以避开大部分位宽差异问题。

4. 验证与调试:确认伪装生效并识别缓存带来的偏差

改完以后,最想知道的是“到底生效没有”。这一章讲怎么验证,以及验证时要注意哪些缓存和间接层。

4.1 验证方法:GetCommandLine与GetModuleFileName两种情况

修改完成后,打印两条最关键的信息:

// 在调用SetProcessParameters之后执行 wprintf(L"GetCommandLine -> %s\n", GetCommandLineW()); wchar_t modulePath[MAX_PATH]; GetModuleFileNameW(NULL, modulePath, MAX_PATH); wprintf(L"GetModuleFileName -> %s\n", modulePath);

如果你只改了ProcessParameters,没有改Ldr,那么第一行会变成假值,第二行还是真路径。如果两个都改了,第二行也变。这个对照能帮你确认自己到底改了哪层。注意不要用C运行库的__argv或_pgmptr去验证,因为C运行时在进程启动时就把命令行拷贝到自己静态缓冲区里了,PEB改了它也不清楚。

4.2 用调试器观察PEB内存,判断修改是否真正写入

在Visual Studio里把GetCurrentPeb()的返回值加入监视窗口,展开ProcessParameters->CommandLine.Buffer,可以直接看到内存中实际的字符。也可以打开“内存”窗口,输入peb->ProcessParameters->CommandLine.Buffer,地址会直接跳到目标缓冲区,观察字节序列是否和预期一致。

这里有个容易误判的点:调试器本身读到的也是用户态数据,也就是说它看到的是我们改完后的假值,不是“真实值”。如果修改过程有Bug,比如改完立刻被覆盖,调试器看到的就会是覆盖后的旧值。所以可以用内存写入断点,在写入假路径的那条指令上打断,观察函数调用前后的变化。

4.3 恢复现场:记住备份与进程退出前的还原

修改内存不是一锤子买卖,尤其当目标进程还要继续运行时,必须能在适当时候恢复。最简单的做法是在修改前保存旧的UNICODE_STRING,修改后需要还原时再写回去。

typedef struct _PEB_BACKUP { UNICODE_STRING ImagePathName; UNICODE_STRING CommandLine; } PEB_BACKUP; void SaveBackup(PEB_BACKUP* backup) { PEB* peb = GetCurrentPeb(); if (!peb || !peb->ProcessParameters) return; backup->ImagePathName = peb->ProcessParameters->ImagePathName; backup->CommandLine = peb->ProcessParameters->CommandLine; } void RestoreBackup(PEB_BACKUP* backup) { PEB* peb = GetCurrentPeb(); if (!peb || !peb->ProcessParameters) return; // 用旧内容原地替换,注意旧缓冲区可能已不在原来地址,需重新判断 ReplaceUnicodeStringInPlace(&peb->ProcessParameters->ImagePathName, std::wstring(backup->ImagePathName.Buffer)); peb->ProcessParameters->ImagePathName.Length = backup->ImagePathName.Length; // CommandLine同理 }

这个备份是针对整个UNICODE_STRING的,包括Buffer指针、Length和MaximumLength。如果修改时用了ReplaceUnicodeStringAlloc,恢复时还要先把Buffer换回旧指针,再释放临时分配的新块。否则进程退出时会多一次堆块释放,可能报错。工程里的片段简化了这步,实际集成时推荐写一个小的RAII类来管理,后面进阶章会给出封装思路。

5. 避坑与常见问题:五个让PEBFake翻车的细节

这一章记录我用这个思路改造程序时踩过的五个具体问题,每条都是“现象→原因→解决”的结构。

5.1 现象:修改后程序崩溃,调用栈停在VirtualProtect

原因:VirtualProtect要求传入的地址必须按页边界对齐。而UNICODE_STRING.Buffer是堆分配的,起始地址通常不在一页的起始位置。直接按MaximumLength修改保护属性,Windows会检查这段地址是否属于同一页,一旦跨越页边界,保护规格就会失效。解决的常见做法是先调VirtualQuery,把它返回的RegionBase和RegionSize作为保护范围,而不是直接用Buffer和MaximumLength。

MEMORY_BASIC_INFORMATION mbi; VirtualQuery(target->Buffer, &mbi, sizeof(mbi)); DWORD oldProtect; VirtualProtect(mbi.BaseAddress, mbi.RegionSize, PAGE_READWRITE, &oldProtect); // 修改完成后再用同样的RegionSize恢复

5.2 现象:任务管理器里路径没变,但API已经返回新路径

原因:任务管理器通过NtQueryInformationProcess查询的是内核EPROCESS里的ProcessImageFileName,这个值不读PEB,直接由内核维护。PEBFake再怎么做也骗不过内核。解决:认清工具的边界,它能影响的是用户态通过PEB/Ldr读取路径的API(GetCommandLineW、GetModuleFileNameW、许多运行时库),影响不了系统进程管理器的显示。如果为了调试,用Process Explorer这类也能读取用户态PEB的工具来观察反而更有说服力。

5.3 现象:修改CommandLine后,GetCommandLineW仍返回旧值

原因:有几种常见情况。一是C运行库的argv在启动时就拷贝过了,GetCommandLineW虽然是实时读PEB,但很多高层的字符串处理库已经把旧值缓存到自己的全局变量里。二是系统上存在安全软件挂接NtQueryInformationProcess或RtlGetCommandLine,在返回前把Buffer覆盖回真实值。解决:先用调试器观察GetCommandLineW返回的地址是不是当前Buffer,排除缓存;再看是不是有钩子,直接看调用的源头而不是相信返回值。

5.4 现象:在64位系统上操作32位进程,PEB地址读取不对

原因:32位进程运行在Wow64环境下,它的TEB是32位的,偏移0x30指向32位PEB。如果写了一个64位程序,用gs:[0x60]去读目标进程的TEB,拿到的是64位原生TEB,再往后推的PEB就和目标进程的4GB地址空间完全对不上。解决:最简单的办法是让修改代码和目标进程位数一致,比如写个32位DLL注入后跑。如果必须从64位进程跨架构修改,要用NtWow64ReadVirtualMemory64读取WOW64的TEB字段,这不是几百行能讲完的事。

5.5 现象:修改Ldr链表后,LoadLibrary加载失败

原因:链表节点的Flink/Blink被误改了。常见于遍历时用了错误的链表偏移,或者在不该写的地方写了数据。比如有人为了拿节点指针,直接对entry做数值加减,而不是用CONTAINING_RECORD,在x64下一次就把节点搞坏。解决:写死规矩——链表遍历只用CONTAINING_RECORD,绝不手工算偏移。修改前把整个LDR_DATA_TABLE_ENTRY内存dum一份出来,对比修改前后的差异,确认只动了FullDllName和Length,其他字节一律不许变。

6. 进阶:把PEBFake封装成自动恢复的RAII组件

如果你只是在自己的实验程序里调用修改函数,忘记恢复问题不大,进程退出就自动清理了。但要做注入到宿主进程的调试工具,没有恢复逻辑就等于给宿主埋雷。我习惯用一个RAII类把修改和恢复绑在一起,作用域离开时自动还原。

class PebFakeScope { public: PebFakeScope(const std::wstring& fakePath, const std::wstring& fakeCmd) { PEB* peb = GetCurrentPeb(); if (!peb || !peb->ProcessParameters) { ok_ = false; return; } backup_.ImagePathName = peb->ProcessParameters->ImagePathName; backup_.CommandLine = peb->ProcessParameters->CommandLine; ok_ = SetProcessParameters(fakePath, fakeCmd); if (ok_) { FakeModulePath(GetModuleHandle(NULL), fakePath); } } ~PebFakeScope() { if (!ok_) return; PEB* peb = GetCurrentPeb(); ReplaceUnicodeStringInPlace(&peb->ProcessParameters->ImagePathName, std::wstring(backup_.ImagePathName.Buffer)); peb->ProcessParameters->ImagePathName.Length = backup_.ImagePathName.Length; // CommandLine同理,这里省略重复代码 } explicit operator bool() const { return ok_; } private: bool ok_ = false; PEB_BACKUP backup_; };

这样封装后,在函数入口直接声明一个PebFakeScope fake(L"C:\\Tools\\fake.exe", L"/silent");,退出函数时无论走正常分支还是异常分支,析构函数都会把旧路径和旧参数还原。工程里还建议在构造函数里先挂起所有线程,改完再恢复,并把挂起线程的句柄保存下来,避免析构时线程处于半挂起状态。

另一个值得尝试的方向是:在DLL_PROCESS_ATTACH的早期调用这个组件。宿主程序刚加载DLL时,很多模块还没来得及缓存路径,此时修改覆盖面更大。但要注意DLL的主入口里不要直接写复杂操作,否则容易引起加载器死锁,最好用一个小线程延迟执行。

从那以后,我每次做进程信息读取都会先自检一下PEB里的CommandLine和Ldr的FullDllName是否一致,看到不一致就立刻知道有地方动过手脚。这份PEBFake工程里把上面的代码拼成了一个可直接编译的示例,配合调试器跑一遍比读十遍资料都有用。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 10:22:33

HBuilderX编辑器打包成APP

HBuilderX编辑器打包成APPHBuilderX编辑器打包成APPHBuilderX打包IOS全过程HBuilderX编辑器打包成APP 作者&#xff1a;梦想艳雪 点我跳转链接&#xff1a;https://www.jianshu.com/p/495a87607414 来源&#xff1a;简书 著作权归作者所有。商业转载请联系作者获得授权&#x…

作者头像 李华
网站建设 2026/10/16 10:20:55

Linux连接跟踪表与conntrack命令实战:原理、排障与性能调优

1. 连接跟踪表到底在跟踪什么很多刚接触Linux网络运维的朋友&#xff0c;第一次听到“连接跟踪表”这个词&#xff0c;脑子里浮现的往往是“这不就是个看网络连接的命令吗&#xff0c;跟netstat差不多吧”。我一开始也这么想&#xff0c;直到有一次线上环境出现大量丢包&#x…

作者头像 李华
网站建设 2026/10/16 10:20:08

Flink on YARN HDFS文件系统类加载失败根因与修复

1. 问题现场还原&#xff1a;Flink on YARN 提交时突然报错“could not find a file system implementation for scheme ‘hdfs’” 我第一次在某公司大数据平台部署 Flink 1.16 作业时&#xff0c;就卡在这个报错上。当时用的是标准的 flink run -m yarn-cluster 命令&#…

作者头像 李华
网站建设 2026/10/16 10:18:43

JSP购物车案例实战:深入理解Session会话机制与避坑指南

简介&#xff1a;这份购物车案例【简单版】压缩包面向Java Web初学者与课程设计者&#xff0c;提供一套基于JSP、Servlet、Tomcat与数据库的入门级电商购物车实现方案&#xff0c;帮助理解动态网页与服务器端状态管理的完整链路。包内共62个文件&#xff0c;以26个class与13个j…

作者头像 李华
网站建设 2026/10/16 10:18:38

Linux指定源端口打流全攻略:hping3/iperf3/Scapy等工具详解

1. 为什么打流时要把源端口“钉死” 做网络测试的兄弟应该都有过这种经历&#xff1a;在测试环境里用压测工具打流&#xff0c;明明只是想验证防火墙策略&#xff0c;结果发现源端口一直在变&#xff0c;抓包抓得到一堆随机端口&#xff0c;规则匹配不上&#xff0c;NAT 会话也…

作者头像 李华
网站建设 2026/10/16 10:18:03

Java大数据医疗实战:临床路径优化与资源调度全解析

1. 从临床路径到数据驱动&#xff1a;这个方向到底在解决什么问题 做Java后端的朋友&#xff0c;这两年多少都感受到了医疗信息化的风向变化。以前我们聊HIS系统、电子病历&#xff0c;核心是把线下流程搬到线上&#xff0c;解决“无纸化”的问题。但现在的需求已经变了&#x…

作者头像 李华