资讯动态

深入解析PEB进程伪装:修改路径与命令行的完整实现

发布时间:2026/10/23 7:23:03 来源:尧图企业网站定制
简介这份精选源码包聚焦Windows系统底层编程中的进程伪装技术面向系统安全、逆向工程与进程调试方向的开发者通过修改目标进程PEB结构中的路径与命令行字段演示让进程呈现为另一程序的完整实现。压缩包为zip格式共15个文件主要包括3个cpp源文件与3个h头文件构成DisguiseProcess_Test测试工程的核心代码另附2个exe可执行程序、Visual Studio工程配置文件vcxproj、sln、filters、suo、md/txt说明文档以及license整体仅约370KB目录精简可直接打开工程阅读或编译验证。该资源已有225人浏览学习特别适合想要理解PEB内部机制、OpenProcess/WriteProcessMemory等API实际用法的入门与进阶读者。源码中的disguiseprocess_test示例清晰演示了从获取进程句柄、定位PEB地址到替换路径与命令行的全过程并包含内存保护恢复处理可帮助学习者在调试器开发、安全测试或恶意软件分析场景中复现技术细节同时理解其操作风险与合规边界。1. 修改指定进程PEB中的路径和命令行信息到底能改变什么一台服务器上多了一个svchost.exe路径指向 System32命令行是-k netsvcs但它是真身还是替身光看外表根本分辨不出来。Windows 下任务管理器、Process Explorer、wmic这类工具读“进程路径”和“命令行”绝大多数数据来自进程的 PEBProcess Environment Block进程环境块。PEB 是进程在用户态的“档案袋”路径和命令行就存放在其中的RTL_USER_PROCESS_PARAMETERS结构里。修改指定进程 PEB 中路径和命令行信息本质是让这份档案说谎程序本体不动只改内存中的两个UNICODE_STRING就能让一大批管理工具相信它看到的是另一个程序。文章后面我会逐步拆开这一过程讲清楚哪些偏移是核心、怎么写不崩、写完怎么验证。这套技术属于安全研究里的进程伪装最常见的用途是红蓝对抗中的身份隐藏、反沙箱测试以及 EDR/审计规则的自测。适合的读者不是“看看热闹”的人而是手上已经有进程句柄、知道 OpenProcess 和 ReadProcessMemory 基本用法的工程师。跟着本文走一遍你会拿到一套基于 Python ctypes 的最小实现不依赖额外第三方库也不要求编译环境。2. 两个偏移决定了进程的“户口本”PEB 与 RTL_USER_PROCESS_PARAMETERS 的精确定位开始动手前先要把目标结构弄清楚。PEB 是一个很大的结构体里面包含加载器数据、堆参数、模块列表、进程参数等。对伪装来说只需要关注两个东西PEB 里指向RTL_USER_PROCESS_PARAMETERS的指针以及该结构里ImagePathName映像路径和CommandLine命令行两个字段。搞清楚这两个偏移后面所有操作都是围绕它们展开的。2.1 在 Windows 上获取 PEB 地址与其猜地址不如用 NtQueryInformationProcess 查很多新手会尝试自己计算 PEB 地址比如在 64 位系统上硬编0x7FFE0000之类的值这在新版 Windows 上经常翻车。ASLR 会随机化进程地址空间硬编码地址属于赌博行为。更可靠的做法是调用NtQueryInformationProcess传入ProcessBasicInformation查询类让系统告诉你目标进程的 PEB 地址。import ctypes from ctypes import wintypes ntdll ctypes.WinDLL(ntdll, use_last_errorTrue) kernel32 ctypes.WinDLL(kernel32, use_last_errorTrue) PROCESS_ALL_ACCESS 0x1F0FFF class PROCESS_BASIC_INFORMATION(ctypes.Structure): _fields_ [ (Reserved1, ctypes.c_void_p), (PebBaseAddress, ctypes.c_void_p), (Reserved2, ctypes.c_void_p * 2), (UniqueProcessId, ctypes.c_void_p), (Reserved3, ctypes.c_void_p), ] def get_peb_address(h_process): pbi PROCESS_BASIC_INFORMATION() ret ntdll.NtQueryInformationProcess( h_process, 0, ctypes.byref(pbi), ctypes.sizeof(pbi), None) if ret ! 0: raise ctypes.WinError(ctypes.get_last_error()) return pbi.PebBaseAddressNtQueryInformationProcess是 ntdll 导出的系统调用包装第二个参数0表示ProcessBasicInformation返回的结构体里PebBaseAddress字段直接给出目标进程 PEB 的地址。这里没有依赖任何第三方库PROCESS_BASIC_INFORMATION的 64 位布局也按 Windows 官方定义对齐了。拿到这个地址后才能继续读ProcessParameters指针。2.2 RTL_USER_PROCESS_PARAMETERS路径与命令行藏在 0x60/0x70 两个偏移里PEB内部有一个ProcessParameters指针偏移量是0x20它指向RTL_USER_PROCESS_PARAMETERS。该结构体从0x00到0x60之间分布着大量字段当前目录、环境变量表、窗口标题、桌面名等多数伪装场景不需要碰。真正需要修改的是0x60处的ImagePathName和0x70处的CommandLine。偏移64位字段名类型说明PEB 0x20ProcessParameters指针指向 RTL_USER_PROCESS_PARAMETERS 0x60ImagePathNameUNICODE_STRING完整映像路径 0x70CommandLineUNICODE_STRING进程启动命令行UNICODE_STRING本身不是裸字符串它是个小结构体前两个字节是Length字符串字节数不含结尾 NUL接着两个字节是MaximumLength缓冲区容量字节数最后 8 字节是Buffer指针64 位下。结构体因为对齐原因实际占 16 字节。修改时如果只改Buffer指向的字符串内容而不更新Length系统按原长度截断就会出现“看起来啥都没变”的怪现象。class UNICODE_STRING(ctypes.Structure): _fields_ [ (Length, wintypes.USHORT), (MaximumLength, wintypes.USHORT), (Buffer, wintypes.LPVOID), ]这段结构体定义要和远程进程里的实际布局完全一致。写错一个字节大小后续读出来的Buffer就是错位的。64 位下该结构体 16 字节32 位下是 8 字节下面所有偏移我都按 64 位系统说明。2.3 跨进程修改必须先拿到的授权不是有句柄就能写打开目标进程之前先想清楚需要哪些权限。OpenProcess传PROCESS_ALL_ACCESS省事但权限太宽容易误触安全软件而且有些系统进程即使给了全权限也打不开。最小化授权是PROCESS_QUERY_INFORMATION读基本信息、PROCESS_VM_READ读内存、PROCESS_VM_WRITE写内存、PROCESS_VM_OPERATION分配内存。四者组合足够完成整个伪装操作。def open_target_process(pid): PROCESS_QUERY_INFORMATION 0x0400 PROCESS_VM_READ 0x0010 PROCESS_VM_WRITE 0x0020 PROCESS_VM_OPERATION 0x0008 handle kernel32.OpenProcess( PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION, False, pid) if not handle: raise ctypes.WinError(ctypes.get_last_error()) return handle选这四个权限而不是PROCESS_ALL_ACCESS目的是在功能完备的前提下减少触发监控规则的概率。部分 EDR 对进程打开权限的组合很敏感动不动就PROCESS_ALL_ACCESS的脚本容易被盯上。这里的逻辑是只申请完成操作所需的最小权限降低在环境中暴露的噪声。3. 不等长就没戏用 VirtualAllocEx 完成指针置换式伪装拿到 PEB 地址和进程句柄后接下来就是写入环节。伪装的内容通常是C:\\Windows\\System32\\svchost.exe这种长度与原路径不一致的字符串无法原地覆盖。常见做法是在目标进程内先分配一块内存写入伪造内容再把ProcessParameters里的两个UNICODE_STRING整体替换掉。3.1 在目标进程里开一块“干净内存”VirtualAllocEx 的参数怎么给分配内存需要VirtualAllocEx指定分配大小、内存类型和页面保护属性。分配大小为 4096 字节足够放下两个UNICODE_STRING结构体加两条宽字符串内存类型用MEM_COMMIT | MEM_RESERVE0x3000页面权限用PAGE_READWRITE0x04。def alloc_remote_memory(h_process, size4096): MEM_COMMIT 0x1000 MEM_RESERVE 0x2000 PAGE_READWRITE 0x04 remote_buf kernel32.VirtualAllocEx( h_process, None, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE) if not remote_buf: raise ctypes.WinError(ctypes.get_last_error()) return remote_buf调用后返回的remote_buf是目标进程内的绝对地址后面所有指针运算都基于它。注意这里不能用本地进程的地址WriteProcessMemory的远程写入参数必须指向目标进程的地址空间。分配后不再使用时要调用VirtualFreeEx释放否则目标进程退出前内存一直占用。3.2 伪造两个 UNICODE_STRING把新路径和新命令行摆进同一块缓冲布局方案前 32 字节放两个UNICODE_STRING结构体之后依次放路径宽字符串和命令行宽字符串。这样一次写入就能把两个结构体和字符串内容全部落到目标进程里后续只需要再写 32 字节到ProcessParameters对应偏移。PROCESS_PARAMETERS_IMAGE_PATH 0x60 PROCESS_PARAMETERS_COMMAND_LINE 0x70 path_new rC:\Windows\System32\svchost.exe cmd_new rC:\Windows\System32\svchost.exe -k netsvcs path_bytes path_new.encode(utf-16le) cmd_bytes cmd_new.encode(utf-16le) PATH_DATA_OFFSET 32 CMD_DATA_OFFSET PATH_DATA_OFFSET len(path_bytes) 2 us_path UNICODE_STRING() us_path.Length len(path_bytes) us_path.MaximumLength len(path_bytes) 2 us_path.Buffer remote_buf PATH_DATA_OFFSET us_cmd UNICODE_STRING() us_cmd.Length len(cmd_bytes) us_cmd.MaximumLength len(cmd_bytes) 2 us_cmd.Buffer remote_buf CMD_DATA_OFFSETLength存的是字节数不是字符数。中文路径会把这个细节放大一个中文字符在 UTF-16LE 下占 2 字节len()算出的字符数直接当字节数用会少一半导致Length MaximumLength后续读取直接报内存访问错误。统一用len(string.encode(utf-16le))计算不要手写。3.3 最后一步覆盖把偏移 0x60 开始的 32 字节一次替换成新结构先写字符串再写两个结构体最后一步一次性覆盖ProcessParameters的ImagePathName和CommandLine字段。两个UNICODE_STRING在 64 位下各占 16 字节连续存放偏移 0x60 到 0x80 正好 32 字节一次WriteProcessMemory就能同时替换两个字段。def write_bytes_to_process(h_process, addr, data): written ctypes.c_size_t(0) ok kernel32.WriteProcessMemory( h_process, addr, data, len(data), ctypes.byref(written)) if not ok: raise ctypes.WinError(ctypes.get_last_error()) write_bytes_to_process(h_process, remote_buf PATH_DATA_OFFSET, path_bytes b\x00\x00) write_bytes_to_process(h_process, remote_buf CMD_DATA_OFFSET, cmd_bytes b\x00\x00) payload ctypes.create_string_buffer(32) ctypes.memmove(payload, ctypes.addressof(us_path), 16) ctypes.memmove(ctypes.byref(payload, 16), ctypes.addressof(us_cmd), 16) params_addr read_remote_pointer(h_process, peb_addr 0x20) write_bytes_to_process(h_process, params_addr PROCESS_PARAMETERS_IMAGE_PATH, payload.raw)read_remote_pointer是一个读远程内存指针的辅助函数用ReadProcessMemory读 8 字节得到params_addr。最后一步覆盖是“提交动作”前面的分配和写入都可以反复重试但这一步一旦写完目标的身份立刻改变。建议把原始 32 字节先保存在本地文件里这就是你的后悔药。3.4 运行效果验证改完立刻用 wmic 查到的结果执行完上面四段代码目标进程的 PEB 已经被改写。验证方式不需要写代码直接用系统自带命令看wmic process where processidpid get commandline,executablepath正常输出中executablepath和commandline会显示为伪造的svchost.exe路径和-k netsvcs参数。如果输出还是旧值优先检查Length是否更新、Buffer指针是否指向目标进程地址空间。一个常见的低级错误是本地拿到的remote_buf地址直接被写入结构体没有换算成目标进程内的虚拟地址导致Buffer指向了无效区域。4. 从一次性脚本到稳定方案原地等长改写与进程创建早期伪装第 3 章的“先分配再整体替换”是最通用的方案但它要求目标进程运行中且句柄拥有PROCESS_VM_OPERATION。如果目标进程权限很严或者你希望最小化内存操作痕迹原地等长改写是另一条路线。两种方案各有适用边界选型时要看场景。4.1 原地等长改写适合路径长度刚好够用、不想分配内存的场景原地改写的思路是先读取ProcessParameters里的UNICODE_STRING拿到Buffer指针和MaximumLength然后直接把新内容写进原有缓冲区。好处是不需要VirtualAllocEx和VirtualFreeEx不会在目标进程里留下“多出一块内存”的痕迹坏处是长度受MaximumLength限制新字符串字节数含结尾 NUL必须不超过它。def overwrite_remote_ustring(h_process, params_addr, offset, new_value): us_local UNICODE_STRING() size ctypes.c_size_t(0) kernel32.ReadProcessMemory( h_process, params_addr offset, ctypes.byref(us_local), ctypes.sizeof(us_local), ctypes.byref(size)) new_bytes new_value.encode(utf-16le) if len(new_bytes) 2 us_local.MaximumLength: raise ValueError(f原地改写超长: {len(new_bytes) 2} {us_local.MaximumLength}) write_bytes_to_process(h_process, us_local.Buffer, new_bytes b\x00\x00) us_local.Length len(new_bytes) write_bytes_to_process(h_process, params_addr offset, ctypes.string_at(ctypes.addressof(us_local), 16))这里必须同步更新Length只写字符串内容不更新长度系统会认为字符串还是原来的长度读出来依然是旧身份。改写完成后再把结构体整体写回去保持Buffer不变、只改Length。从内存页的视角看目标进程整个生命周期里没有新增堆块审计特征更轻。4.2 在进程创建早期做伪装挂起进程 修改 PEB 恢复执行如果目标进程还没跑起来更优雅的路线是在CreateProcess时用CREATE_SUSPENDED让进程停在主线程执行之前此时修改 PEB再恢复执行。这样进程从一开始就用假身份运行很多运行时采集信息的 API 连真身都没见过。CREATE_SUSPENDED 0x4 si wintypes.STARTUPINFO() pi wintypes.PROCESS_INFORMATION() kernel32.CreateProcessW( None, target.exe --parameter original, None, None, False, CREATE_SUSPENDED, None, None, ctypes.byref(si), ctypes.byref(pi)) peb_addr get_peb_address(pi.hProcess) params_addr read_remote_pointer(pi.hProcess, peb_addr 0x20) write_bytes_to_process(pi.hProcess, params_addr 0x60, fake_params) ntdll.NtResumeProcess(pi.hProcess)挂起态修改有一个天然优势进程的主线程尚未开始执行不会出现某个线程刚好正在读CommandLine导致短时间不一致。等NtResumeProcess恢复后所有工具看到的就是已伪装的身份。这个方案也适用于“改名换姓”重启自身进程的场景先拉起一个挂起的副本改完再让它接管业务。4.3 把伪装逻辑封装成模块不要每次重写一坨散代码二次实战时你会发现裸写脚本很伤句柄忘关、原始值忘保存、偏移写错却报内存访问异常排查半小时才发现是位数不对。把这些逻辑抽成一个小工具类散弹枪变手术刀后续维护成本低很多。class PEBPatcher: def __init__(self, pid): self.h_process open_target_process(pid) self.peb get_peb_address(self.h_process) self.params read_remote_pointer(self.h_process, self.peb 0x20) self.snapshot None def snapshot_original(self): raw ctypes.create_string_buffer(32) kernel32.ReadProcessMemory( self.h_process, self.params 0x60, raw, 32, None) self.snapshot raw.raw def patch(self, new_path, new_cmd): self.snapshot_original() if self.snapshot is None else None # 分配远程内存 写入伪造结构 指针置换 # 具体逻辑复用上一章代码 def restore(self): if self.snapshot: write_bytes_to_process(self.h_process, self.params 0x60, self.snapshot)snapshot_original必须在patch之前调用否则没有后悔药。restore把原始 32 字节写回去相当于一键还原身份。注意还原时如果原地改写过Buffer指向的字符串内容这部分也要一并还原——只恢复结构体不恢复字符串等于半还原。5. 进程伪装避坑清单权限、WOW64 偏移、崩溃与审计残留这一章是实战场上最容易翻车的地方。每一条我都遇到过按“现象 → 原因 → 解决”的顺序写清楚方便你对照排查。5.1 OpenProcess 返回 5不是代码错了是你的令牌特权没开现象OpenProcess返回NULLGetLastError显示 5拒绝访问目标进程是系统服务或高完整性级别进程时尤其明显。原因当前进程令牌里没有启用SeDebugPrivilege。这个特权默认在管理员令牌里存在但被禁用普通用户令牌里根本没有。没有它即使申请了PROCESS_ALL_ACCESS打开高完整性目标也会被拒绝。解决先启用调试特权再打开目标进程。def enable_debug_privilege(): SE_PRIVILEGE_ENABLED 0x00000002 TOKEN_ADJUST_PRIVILEGES 0x0020 TOKEN_QUERY 0x0008 new_state (wintypes.LUID * 1)() luid wintypes.LUID() ntdll.RtlAdjustPrivilege(20, 1, 0, ctypes.byref(wintypes.BOOL()))RtlAdjustPrivilege(20, 1, 0, ...)中的20就是SeDebugPrivilege的编号1表示启用。如果这一行返回非零检查你的进程是否以管理员身份运行——普通用户进程没有权限基础后面所有步骤都走不通。5.2 32 位工具进程修改 64 位目标进程地址截断与结构大小错位现象脚本运行在 32 位 Python 解释器里读取 64 位目标进程的PebBaseAddress看起来总是一个小于 4GB 的地址后续读写全部失败。原因NtQueryInformationProcess在 32 位进程视角下返回的PebBaseAddress高 32 位被截断得到的地址不是真实地址另一个坑是 32 位视角下UNICODE_STRING结构体只有 8 字节而 64 位目标是 16 字节结构体定义错了偏移就全错了。解决工具自身要用 64 位编译版本确保ctypes.sizeof(UNICODE_STRING)输出 16。无法换 64 位环境时用IsWow64Process判断目标是否落在 Wow64 层再走 64 位辅助进程的代理通道常见做法是注入一个 64 位 dll 去完成实际读写。5.3 原地改写触发 0xC0000005字符串落到了只读映射页上现象WriteProcessMemory写入Buffer指向的字符串时返回失败错误码是 5 或 87强行忽略并继续后目标进程在某次系统调用时直接崩溃退出码0xC0000005访问违例。原因ImagePathName的原始字符串有时位于映像文件的只读映射区.rdata节。写这种页面会触发 Copy-on-Write 机制系统要为你复制一份私有副本但如果页面根本没有写权限标记写入请求直接被拒。更危险的做法是写入超过MaximumLength的长度破坏了后面堆上的数据。解决宁可优先用指针置换法不要原地改。如果必须原地改先对Buffer所在页调用VirtualProtectEx临时改为PAGE_EXECUTE_READWRITE0x40写完再恢复原保护属性。不要一边原地改写一边运行高频读取程序写入过程不是原子的有概率读到一半的新字符串。5.4 改完 Length 没同步更新为什么 wmic 显示的还是旧命令行现象脚本执行完没有报错但wmic查出来的commandline和改之前一模一样或显示出一段新字符串加乱码尾巴。原因结构体里的Length没有更新或者更新时用了字符数而不是字节数。UNICODE_STRING的Length表示的是字符串的字节数不包含结尾 NUL。如果旧字符串长 20 字节你写了 30 字节的新内容但Length依然停在 20系统只会读前 20 字节如果新内容比旧内容短Length不减就会把后面残留字节当作字符串的一部分显示成乱码尾巴。解决无论用哪种方案写完字符串后立刻从远程结构体读回Length和MaximumLength核对。判断标准很简单Length必须等于len(new_string.encode(utf-16le))MaximumLength必须大于等于Length 2Buffer地址必须落在已分配且可读的区域内。5.5 审计残留Sysmon 事件里记录的还是原始参数伪装只影响读 PEB 的那一侧现象伪装之后EDR 控制台和任务管理器里看到的是假身份但翻 Sysmon 的进程创建事件Event ID 1里面记录的Image和CommandLine还是原始值两边口径不一致反而暴露了“进程被改过”。原因Sysmon、ETW 以及部分安全产品在进程创建时通过内核回调采集数据这些数据在内核态已经固化和用户态的 PEB 没有关系。PEB 修改只能影响“运行时读取 PEB”的那些应用改变不了内核态的原始记录。从取证角度看这种不一致恰恰是最显眼的红旗一个进程的 PEB 身份和内核记录对不上基本可以断定被做过手脚。解决明确边界——PEB 伪装是“让后续读取者看到假身份”不是“让历史记录消失”。想要连事件日志也保持一致需要在进程创建早期挂起态完成修改让采集回调观察到的那一刻就已经是伪装后的样子但已经写入日志的旧记录无法通过改 PEB 抹掉只能靠清理日志或调整审计策略。认清这个边界比到处找“无痕”方案靠谱得多。6. 验证伪装是否生效两个命令加三种漏判场景确认目标进程伪装是否生效我一般先跑两个命令一个看命令行一个看可执行路径。wmic进程信息从 PEB 读取输出结果若是伪造值说明核心链路已经打通。wmic process where processidpid get commandline,executablepath /format:listGet-CimInstance Win32_Process -Filter ProcessId $pid | Format-List CommandLine, ExecutablePath脚本内部还可以加一道自检从ProcessParameters读回UNICODE_STRING解码后与预期值比对。def read_remote_ustring(h_process, addr): us UNICODE_STRING() kernel32.ReadProcessMemory( h_process, addr, ctypes.byref(us), ctypes.sizeof(us), None) buf ctypes.create_string_buffer(us.Length) kernel32.ReadProcessMemory( h_process, us.Buffer, buf, us.Length, None) return buf.raw.decode(utf-16le, errorsreplace)自检通过不代表万事大吉还有三种容易漏判的情况。第一任务管理器的“名称”列不会变因为进程名来自模块映像名称不走ProcessParameters改路径改命令行都影响不到它。第二Process Explorer 的“版本”列若显示原程序的版本信息那是读取了磁盘上的映像文件PEB 伪装覆盖不了需要配合文件替换才能闭环。第三用 ETW 或内核回调采集进程数据的检测工具拿到的还是创建瞬间的原始参数如果你的目标是规避这类检测就要回到第 4 章的挂起态修改方案。我的习惯是每次伪装修补完工先把原始UNICODE_STRING存成十六进制文件再写一遍自检脚本确认新值与预期一致、旧值能够还原最后才谈后续的业务接入。这个习惯救过我很多次尤其是连续调整路径长短的时候没有后悔药就只能重开目标进程。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑