C++进程内存搜索实战:特征码扫描与模块偏移定位 简介这是一份面向C逆向分析与游戏辅助开发方向的进程内存搜索工具源码核心解决在目标进程中快速定位特征码、查找Call与关键地址的问题适合具备一定C与Windows编程基础、希望深入理解内存扫描原理的开发者参考。资源包共27个文件约8.45MB包含cpp与h源码文件、sln与vcxproj工程配置、pdb与ilk调试符号、obj与pch编译中间产物以及exe可执行程序、db与idb调试数据库等覆盖从源码到可运行程序的完整工程结构便于直接编译调试与二次修改。目前已有4416人学习下载热度较高。读者可从中获取特征码匹配与内存遍历的实现思路、工程组织方式及调试排错参考对研究进程内存搜索、定位Call与地址定位的开发者具有实用价值。1. 从一次崩溃现场说起C 进程内存搜索到底在搜什么线上环境跑着一个 C 写的采集程序运行到第三天突然内存占用从 200MB 涨到 1.8GB日志里没有任何异常重启后又能撑三天。这种问题用常规日志排查基本是黑匣子——代码里没有泄漏点第三方库也换过版本最后是靠直接读进程内存、按特征码定位到一块反复增长的堆区才收口的。这就是 C 进程内存搜索的典型场景不依赖源码、不依赖调试符号直接对目标进程的虚拟地址空间做扫描用一段字节序列特征码把关键地址揪出来。它解决的问题很具体找 Call、找全局变量、找对象基址、找动态生成的函数入口。适合两类人——做逆向和协议分析的从业者以及需要在不重启进程的前提下定位内存异常的 C 工程师。核心动作就三步拿到目标进程句柄、遍历可读内存区域、按特征码匹配。听起来简单但真正落地时权限、内存分页、特征码唯一性、扫描性能这四件事会轮流教你做人。下面按我实际拆过的流程从原理到代码一步步来。2. 进程内存搜索的底层机制从 OpenProcess 到特征码匹配2.1 为什么不能直接读别人的内存现代操作系统对进程做了地址空间隔离一个进程默认看不到另一个进程的内存。要跨进程读取必须让系统内核帮你做一次地址翻译。在 Windows 上这个入口是OpenProcess拿到句柄再用ReadProcessMemory逐段读取在 Linux 上则是ptrace附加后通过/proc/pid/mem读取。两者本质一样都是请求内核把目标进程的虚拟地址映射到当前进程的缓冲区。这里有个容易被忽略的点你读到的地址是目标进程的虚拟地址不是物理地址。目标进程里一个0x7FF6A000的地址在你自己的进程里可能完全无效。所以扫描时必须先枚举目标进程的内存区域拿到每一段的基址和大小再逐段读。Windows 用VirtualQueryEx枚举Linux 解析/proc/pid/maps。跳过这一步直接按固定地址读十有八九返回错误码 299部分读取或者直接崩。2.2 特征码为什么能定位到唯一地址特征码signature本质是一段字节模式比如48 8B 05 ?? ?? ?? ?? 48 85 C0其中??是通配符代表会随编译或运行变化的字节比如相对偏移、随机基址。它的可靠性来自一个事实编译器生成的机器码里指令序列的排列组合在特定函数内是稳定的而通配符把那些不稳定的部分屏蔽掉。但特征码不是越长越好。太长会拖慢扫描太短会命中多个地址。我一般控制在 8 到 16 字节通配符不超过 30%。如果扫出来一堆结果说明特征码选得不够独特需要回到反汇编里找更靠前的指令序列或者加上前后文约束。常见做法是先用调试器在目标函数下断点把断点附近的十几条指令字节抄下来再去掉那些明显是地址或偏移的字节换成??。2.3 扫描性能的关键分块读取与首字节过滤直接对几百 MB 的内存逐字节比对速度会慢到无法接受。两个优化必须做第一按内存页通常 4KB分块读取而不是一次性读整段避免大块分配和缺页中断第二先用特征码的第一个非通配字节做快速过滤只有首字节匹配的位置才进入完整比对。这个技巧能把无效比对减少 90% 以上。下面是一段 Windows 下的核心扫描逻辑用 C 写去掉了平台相关的错误处理保留主干// 逐段扫描目标进程内存匹配特征码 // hProcess: OpenProcess 拿到的句柄 // pattern: 特征码字节数组wildcard 用 0x00 占位并配合 mask // mask: 与 pattern 等长1 表示精确匹配0 表示通配 std::vectoruintptr_t ScanMemory(HANDLE hProcess, const std::vectoruint8_t pattern, const std::vectoruint8_t mask) { std::vectoruintptr_t hits; MEMORY_BASIC_INFORMATION mbi; uintptr_t addr 0; // 从地址 0 开始枚举所有内存区域 while (VirtualQueryEx(hProcess, (LPCVOID)addr, mbi, sizeof(mbi))) { // 只扫描已提交且可读的区域跳过保留区和 Guard 页 if (mbi.State MEM_COMMIT (mbi.Protect (PAGE_READONLY | PAGE_READWRITE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE)) !(mbi.Protect PAGE_GUARD)) { std::vectoruint8_t buffer(mbi.RegionSize); SIZE_T bytesRead 0; // 分块读取失败就跳过这一段 if (ReadProcessMemory(hProcess, mbi.BaseAddress, buffer.data(), mbi.RegionSize, bytesRead)) { // 首字节过滤 完整比对 for (size_t i 0; i pattern.size() bytesRead; i) { if (buffer[i] ! pattern[0]) continue; // 快速跳过 bool matched true; for (size_t j 1; j pattern.size(); j) { if (mask[j] buffer[i j] ! pattern[j]) { matched false; break; } } if (matched) { hits.push_back((uintptr_t)mbi.BaseAddress i); } } } } addr (uintptr_t)mbi.BaseAddress mbi.RegionSize; } return hits; }逻辑说明VirtualQueryEx每次返回一段连续内存的属性循环推进直到地址溢出。mbi.State MEM_COMMIT过滤掉未提交的保留区PAGE_GUARD是栈保护页读了会触发异常必须排除。ReadProcessMemory的bytesRead可能小于请求大小所以比对边界用bytesRead而不是RegionSize。参数说明pattern和mask等长mask[j] 1表示该位精确匹配0表示通配。首字节过滤用pattern[0]所以特征码第一个字节不能是通配符这是选码时的硬约束。hits返回的是目标进程的虚拟地址后续用ReadProcessMemory读这些地址就能拿到实际数据。2.4 权限与架构两个绕不开的前置条件第一权限。OpenProcess需要PROCESS_VM_READ和PROCESS_QUERY_INFORMATION权限。如果目标进程是系统服务或者以更高完整性级别运行普通用户权限会直接失败返回ERROR_ACCESS_DENIED。常见做法是以管理员身份运行扫描器或者用调试权限SeDebugPrivilege提权。第二架构。32 位扫描器读不了 64 位进程的内存反过来 64 位扫描器读 32 位进程虽然可以但地址空间布局不同特征码里的相对偏移会失效。所以扫描器和目标进程的位数最好一致这是血泪经验。3. 从零写一个可用的内存扫描器代码结构与参数调优3.1 工程骨架四个模块各管一件事一个能用的扫描器不需要多复杂但职责要分清。我一般拆成四块进程管理打开、枚举、关闭句柄、内存枚举VirtualQueryEx循环、扫描核心上一章的ScanMemory、结果处理去重、排序、读取命中地址的实际值。这四块分开写的好处是换平台时只需要改前两块扫描逻辑可以复用。进程管理里有个细节OpenProcess拿到的句柄用完必须CloseHandle否则句柄泄漏扫描几十次后系统会拒绝新的打开请求。我见过有人循环扫描忘了关句柄跑到第 200 次直接卡死排查了半天才发现是句柄耗尽。3.2 特征码的构造与验证构造特征码的流程我固定成三步。第一步用调试器在目标函数入口下断点触发后把当前指令地址附近的 32 字节抄下来。第二步把其中明显是地址或偏移的字节替换成??比如E8 ?? ?? ?? ??这种 call 指令后面的相对偏移。第三步用扫描器扫一遍看命中数量。如果命中超过 3 个说明特征码不够独特回到第一步往前多抄几条指令。验证特征码是否可靠有个简单办法重启目标进程再扫一次。如果两次命中的地址数量一致且相对模块基址的偏移一致说明特征码稳定。如果偏移变了说明特征码里混入了会变的字节需要继续加通配符。这个验证步骤很多人跳过结果上线后地址漂移找 Call 找到错误的函数上。3.3 扫描参数怎么调区域过滤与线程数扫描速度主要受三个参数影响扫描区域范围、单次读取块大小、是否多线程。区域范围上我一般只扫MEM_PRIVATE和MEM_IMAGE两类跳过MEM_MAPPED映射文件通常不是目标。块大小设成 4KB 的整数倍和系统页大小对齐减少跨页读取的开销。多线程方面按内存区域分段并行扫描能提速 3 到 5 倍但要注意每个线程独立打开句柄或者做好句柄共享的同步否则会踩到句柄竞争的坑。下面是一个区域过滤的配置示例用结构体把参数集中管理// 扫描配置集中管理过滤条件和性能参数 struct ScanConfig { // 只扫描这些类型的内存区域 DWORD allowedTypes[2] { MEM_PRIVATE, MEM_IMAGE }; // 单次读取块大小4KB 对齐 size_t chunkSize 0x1000; // 是否启用多线程分段扫描 bool multiThread true; // 最大命中数超过就停止防止内存爆炸 size_t maxHits 10000; }; // 判断某段内存是否在扫描范围内 bool ShouldScan(const MEMORY_BASIC_INFORMATION mbi, const ScanConfig cfg) { if (mbi.State ! MEM_COMMIT) return false; if (mbi.Protect PAGE_GUARD) return false; if (!(mbi.Protect (PAGE_READONLY | PAGE_READWRITE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE))) return false; for (DWORD t : cfg.allowedTypes) { if (mbi.Type t) return true; } return false; }逻辑说明ShouldScan把上一章散落在循环里的判断抽成独立函数方便单测和调整。allowedTypes默认只留私有内存和镜像内存映射文件被排除。maxHits是保险丝特征码选得太短时可能命中几万个地址不设上限会把内存吃光。参数说明chunkSize设 0x1000 是页大小设太大比如 1MB会增加单次分配的延迟设太小比如 256 字节会增加系统调用次数。multiThread在扫描大进程比如 2GB 以上时收益明显小进程反而因为线程调度开销变慢建议按目标进程大小动态决定。3.4 命中结果的处理去重与二次验证扫出来的地址列表不能直接用。同一块内存可能被多个区域覆盖或者特征码在相邻位置重复命中。我一般做两步处理先按地址排序去重再对每个命中地址做二次验证——读取该地址前后各 16 字节确认上下文符合预期。二次验证能过滤掉那些“碰巧匹配”的假阳性尤其是特征码较短的时候。二次验证的判据可以很简单命中地址的前 4 字节是不是一条合法指令的开头或者命中地址是否落在某个已知模块的地址范围内。这些判据不需要精确只要能排除明显不合理的命中就行。做完这两步剩下的地址基本就是真正要找的 Call 或者变量了。4. 避坑与排查内存搜索里最容易翻车的五件事4.1 扫描结果为空但特征码明明是对的现象调试器里能看到目标指令抄下来的特征码扫出来零命中。原因通常是扫描器位数和目标进程不一致或者目标进程的内存区域被PAGE_GUARD保护被过滤掉了。解决先确认扫描器和目标进程位数一致再把PAGE_GUARD的过滤条件临时去掉试一次。如果去掉后能扫到说明目标区域确实有保护页需要在读取前用VirtualProtectEx临时改权限。4.2 命中地址每次重启都变现象这次扫到0x7FF6A12340重启进程后变成0x7FF6B45670。原因是特征码里混入了绝对地址或相对偏移这些字节随基址变化。解决把命中地址减去目标模块的基址看偏移是否稳定。如果偏移稳定说明特征码本身没问题只是基址变了用模块基址加偏移的方式记录即可。如果偏移也变说明特征码需要继续加通配符。4.3 扫描到一半进程崩溃现象扫描器运行中目标进程直接退出。原因是ReadProcessMemory读到了不可读的页触发了目标进程的访问违例。虽然ReadProcessMemory本身会返回失败而不是让目标崩溃但如果目标进程正好在扫描期间释放了那块内存就会踩到竞态。解决扫描前先挂起目标进程的所有线程SuspendThread扫完再恢复。这个操作有风险挂起时间过长会导致目标进程假死所以扫描要快或者分多次小范围扫。4.4 特征码命中了几百个地址现象一个 8 字节的特征码扫出 300 多个命中。原因是特征码太短或者通配符太多失去了唯一性。解决回到反汇编把特征码延长到 12 到 16 字节减少通配符比例。如果延长后还是多命中说明这段指令在多个函数里重复出现需要换一个更独特的指令序列比如函数入口的push序列或者特有的常量加载指令。4.5 多线程扫描结果不一致现象单线程扫出 5 个命中多线程扫出 3 个或者 7 个。原因是多个线程共享了同一个结果容器没有加锁导致写入丢失或重复。解决每个线程用独立的结果容器扫描结束后合并去重。或者用线程安全的队列但队列的锁开销在高速扫描时反而拖慢速度。我一般用前者合并时按地址排序相邻重复的直接去掉。5. 进阶技巧用模块基址加偏移做稳定定位5.1 为什么绝对地址不可靠绝对地址依赖进程的加载基址而现代系统开启了地址空间布局随机化ASLR每次启动基址都不同。所以任何直接记录绝对地址的做法都是一次性的重启就失效。稳定的做法是记录“模块名 偏移”模块基址在运行时通过EnumProcessModules或者遍历 PEB 拿到加上偏移就是实际地址。这个思路在找 Call 时尤其重要因为 Call 的目标地址往往在某个模块内部用模块加偏移能跨重启复用。5.2 从特征码到偏移的转换拿到特征码命中的绝对地址后减去它所在模块的基址就得到偏移。下面这段代码演示如何枚举模块并计算偏移// 枚举目标进程的模块找到命中地址所属模块并计算偏移 // hProcess: 目标进程句柄 // hitAddr: 特征码命中的绝对地址 struct ModuleInfo { std::string name; uintptr_t base; size_t size; }; ModuleInfo LocateModule(HANDLE hProcess, uintptr_t hitAddr) { HMODULE modules[1024]; DWORD needed 0; // 枚举所有模块句柄 if (!EnumProcessModules(hProcess, modules, sizeof(modules), needed)) { return {}; } size_t count needed / sizeof(HMODULE); for (size_t i 0; i count; i) { MODULEINFO mi; if (!GetModuleInformation(hProcess, modules[i], mi, sizeof(mi))) continue; uintptr_t base (uintptr_t)mi.lpBaseOfDll; // 判断命中地址是否落在该模块范围内 if (hitAddr base hitAddr base mi.SizeOfImage) { char name[MAX_PATH] {0}; GetModuleBaseNameA(hProcess, modules[i], name, MAX_PATH); return { name, base, mi.SizeOfImage }; } } return {}; }逻辑说明EnumProcessModules返回目标进程加载的所有模块句柄GetModuleInformation拿到每个模块的基址和大小。遍历判断命中地址落在哪个模块的[base, base SizeOfImage)区间内返回模块名和基址。调用方用hitAddr - base就得到偏移。参数说明modules数组大小 1024 是经验值一般进程加载的模块不超过这个数如果不够needed会返回实际需要的字节数可以据此扩容。GetModuleBaseNameA拿模块文件名用于记录和跨版本比对。5.3 一个我常用的验证习惯每次定位到关键地址后我会做一次“重启验证”把目标进程关掉重新启动用记录的模块名加偏移重新计算地址再读一次内存确认读到的数据和上次一致。这个习惯帮我挡掉过好几次特征码漂移的问题。具体做法是把模块名、偏移、期望的字节值写成一个校验表每次启动后自动跑一遍任何一项对不上就报警。// 校验表记录模块名、偏移、期望字节启动后自动验证 struct VerifyEntry { std::string module; uintptr_t offset; std::vectoruint8_t expected; // 期望读到的字节 }; bool VerifyAll(HANDLE hProcess, const std::vectorVerifyEntry entries) { for (const auto e : entries) { // 重新计算实际地址 uintptr_t base GetModuleBaseByName(hProcess, e.module); if (base 0) return false; uintptr_t addr base e.offset; std::vectoruint8_t buf(e.expected.size()); SIZE_T read 0; if (!ReadProcessMemory(hProcess, (LPCVOID)addr, buf.data(), buf.size(), read)) return false; if (read ! buf.size() || buf ! e.expected) return false; } return true; }逻辑说明VerifyAll遍历校验表对每一项重新计算地址并读取内存比对期望字节。任何一项失败就返回 false调用方据此决定是否重新扫描。GetModuleBaseByName是上一节LocateModule的反向操作按模块名找基址实现略。参数说明expected建议存 4 到 8 字节太短容易误判太长会受正常数据变化影响。如果目标数据本身会变比如计数器就不要放进校验表只校验那些稳定的指令字节。从那以后我每次做内存定位都会强制走一遍“特征码扫描 → 模块偏移转换 → 重启校验”这三步少一步都不敢把地址写进生产代码。希望帮到你。本文还有配套的精品资源点击获取