简介:面向C++开发者与逆向爱好者的进程内存搜索工具,基于Visual Studio工程实现,可对指定进程执行内存扫描,通过特征码极速定位目标地址,辅助分析函数调用关系与关键Call地址。包内含27个文件,以cpp、h源码为核心,附带sln工程文件、vcxproj配置、pdb调试符号以及可直接运行的exe,同时包含tlog、obj、pch等VS编译中间产物,整体压缩包8.45MB,便于按需调试或二次修改。配套源码覆盖进程句柄获取、内存区域遍历、特征码匹配等关键步骤,主逻辑集中在SearchMem.cpp中,可帮助初学者理解内存搜索的实现套路,也为逆向排查与游戏辅助开发提供参考。已有4415人学习下载,适合具备一定C++基础、希望快速搭建内存搜索测试环境或研究特征码定位原理的开发者,阅读时可结合工程文件和调试符号对照验证。
1. 思路拆解:为什么需要自己写一个特征码定位工具
先聊一个玩逆向的朋友都遇到过的场景。你在调试某个程序,好不容易定位到一个关键函数,记录下它的地址,结果程序一重启,这个地址就变了。或者你找到了一个关键Call,想写个工具去调用它,但地址飘忽不定,手动在 CE 里翻来翻去效率太低。这时候你需要的,就是一套能快速定位“特征码”的自动化方案。
所谓特征码,就是一段唯一的字节序列。比如8B 4D F8 83 C1 01 89 4D F8这种机器码片段,它在进程中往往只出现一次或极少数几次。我们通过扫描进程内存,找到这段字节出现的地址,就等于拿到了这个函数或Call的精确位置。这正是标题里“特征码极速定位,方便找Call找地址”的核心逻辑。
我早期做 C++ 内存搜索工具时,第一版用的就是暴力遍历。OpenProcess 拿到进程句柄,VirtualQueryEx 遍历内存区块,ReadProcessMemory 整块读出来,然后逐字节匹配。这个方案原理上没问题,但实际跑起来很折磨——一个几百 MB 的进程,全量扫描可能要好几秒甚至十几秒,特征码选得差一点还会误匹配。后来我仔细优化了扫描算法和内存读取策略,速度提升了十倍不止。
这篇文章我会完整讲清楚这个工具的设计思路和实现细节,包含内存枚举、特征码匹配、Call 地址定位,以及我在实际调试中踩过的坑。适合刚接触逆向、想自己动手写扫描器的人,也适合已经在用 CE 但希望把定位逻辑集成到自己的 C++ 工具里的朋友。整个方案偏工程实践,代码可以直接复用。
1.1 方案选型:外挂式扫描还是注入式扫描
写内存扫描器,第一步要想清楚是“从外部读”还是“进内部读”。
外部读取就是标准的 ReadProcessMemory 方案。我们的扫描器作为独立进程运行,通过 OpenProcess 打开目标进程,读取它的内存空间。优点是实现简单、崩溃风险低、不影响目标进程运行。缺点是每次读取都涉及一次内核态切换,频繁调用时性能开销较大。
内部读取则是把扫描代码以 DLL 的形式注入目标进程,直接在进程内访问内存,速度极快,还能绕过部分外部读写的限制。但注入本身就是一个高风险操作,容易触发安全软件告警,也容易写崩目标进程。
从实用角度讲,特征码定位这种场景,外部读取已经完全够用。很多知名的调试辅助工具,本质就是一个增强版的外部读取器。所以我下面的实现方案默认走外部读取路线,仅在有特殊需求时才建议考虑注入式。
1.2 特征码匹配:从“必须全等”到“支持通配符”
特征码扫描最核心的问题不是匹配本身,而是特征码怎么写。
如果每次都用完整的字节序列做精确匹配,比如8B 4D F8 83 C1 01,那确实简单,但实际逆向中很少有这么理想的情况。因为很多指令里含有动态地址,比如call [rax+0x10]这类的指令,它的编码中包含了寄存器偏移,但这个偏移在不同版本的程序里可能不同。如果你把整个字节序列都写死,换个版本就扫不到了。
所以真正的特征码必须支持通配符。常见的写法是用??表示“任意字节”,例如:
8B 4D F8 83 C1 01 89 4D F8 ?? ?? 74 05其中?? ??表示这两个字节可以匹配任何值。这种模式的匹配逻辑本质上是“模糊匹配”,匹配时要跳过通配符对应的字节不做比较。
实现上很简单,把特征码字符串解析成一个结构体数组,每个元素包含字节值和是否通配的标志,然后逐字节比较即可。这一步虽然基础,但它是整个扫描器的灵魂,后面的算法优化都建立在这个数据结构之上。
2. 内存枚举原理与核心 API 的使用细节
特征码扫描器外表看是个匹配问题,本质是个“读内存”的问题。你匹配算法再快,如果内存读取策略不合理,性能一样上不去。Windows 下进程内存读取的标准姿势是三个 API 的组合:OpenProcess、VirtualQueryEx、ReadProcessMemory。
2.1 进程句柄与权限分配
OpenProcess 是这场操作的门票。扫描目标进程前,必须拿到一个具备PROCESS_QUERY_INFORMATION和PROCESS_VM_READ权限的句柄。常见的坑有两个。
第一,权限不足。很多系统进程受保护,普通权限下 OpenProcess 直接返回失败。这时候要么以管理员权限运行我们的工具,要么找其他途径。在写普通应用层的扫描器时,管理员权限基本是底线。
第二,句柄泄漏。每 OpenProcess 一次就对应一个内核对象,用完不 CloseHandle,长期运行内存会悄悄上涨。我在工具里习惯用 RAII 封装句柄,或者在一个扫描会话中重复使用同一个句柄,而不是每次扫描都重新打开进程。
2.2 VirtualQueryEx 遍历内存区块
拿到句柄后,遍历整个地址空间要靠 VirtualQueryEx。这个 API 接受一个地址,返回该地址所在内存区域的信息,包括区域起始地址、大小、状态和保护属性。我们需要的,就是区域状态为MEM_COMMIT、保护属性可读的那些块。
需要注意,不要扫描所有区块。像MEM_FREE的区域根本不能读,直接跳过。MEM_IMAGE一般是模块映射区,包含 EXE 和 DLL 的代码段,这是找 Call 和找函数地址时最常命中的区域。MEM_MAP则可能是数据文件映射或共享内存。
另外,保护属性是PAGE_NOACCESS或PAGE_GUARD的区域要跳过,否则 ReadProcessMemory 必然失败。但也别直接放弃整个区域,因为一个区块中可能只有某个页不可读,其他页可读。稳妥的做法是:先查询整块区域属性,如果不可读就跳过;如果可读,则按页(4KB)为单位进一步细分,逐页尝试读取,这样能最大化扫描覆盖率。
我见过不少新手写的扫描器,扫描结果不全,原因就是这里偷懒了,一个区块整体跳过导致漏掉大量指令区域。
2.3 ReadProcessMemory 的缓冲区策略
ReadProcessMemory 读内存时,缓冲区大小直接决定扫描速度。原则是“能读多大读多大”,尽量减少 API 调用次数。
每次 API 调用都有固定的内核态开销,假设一次调用耗时 1 微秒,如果你每 4KB 读一次,读一个 500MB 的进程需要 12.5 万次调用,光 API 开销就几百毫秒。但如果你按 1MB 的缓冲区读,调用次数直接降到 500 次,开销几乎可以忽略。
当然,缓冲区也不能无限大。实践中我常用的值是 1MB 到 4MB,既能保证效率,又不会因为分配超大缓冲区导致内存抖动。每次读取的大小还要根据 VirtualQueryEx 返回的区域大小动态调整,避免跨区域读取导致部分页不可读而失败。
提示:ReadProcessMemory 并不保证读取完整的请求字节数。如果目标区域内有不可读的页,它会返回失败,实际读到的字节数通过 lpNumberOfBytesRead 返回。代码里必须判断这个值,否则你处理的数据里可能混入垃圾内容。
3. 实操:完整实现一个 C++ 特征码扫描器
直接看代码。我提供的实现包含三个模块:特征码解析、内存扫描、调用地址定位。整个工程基于 Win32 API,不依赖第三方库,VS2019 及以上版本都能直接编译。
3.1 特征码解析模块
特征码字符串形如"8B 4D F8 83 C1 01 ?? 89",第一步是把它解析成匹配单元。
struct PatternByte { BYTE value; // 字节值,仅当 wildcard 为 false 时有效 bool wildcard; // 是否为通配符 }; std::vector<PatternByte> ParsePattern(const std::string& pattern) { std::vector<PatternByte> result; std::stringstream ss(pattern); std::string token; while (ss >> token) { PatternByte pb; if (token == "??" || token == "?") { pb.wildcard = true; pb.value = 0; } else { pb.wildcard = false; pb.value = static_cast<BYTE>(std::stoul(token, nullptr, 16)); } result.push_back(pb); } return result; }这一步没什么技术含量,但要注意字符串容错。真实情况中,特征码可能来自 CE 复制出来,也可能来自 IDA 的字节面板,格式五花八门。有的带空格,有的带\x前缀,有的用?而不是??。解析函数最好多兼容几种,否则使用时就得多一步手动清洗。
3.2 内存扫描模块
扫描模块的核心是ScanRegion函数,负责在给定的内存区域内寻找特征码。
std::vector<uintptr_t> ScanRegion(HANDLE hProcess, uintptr_t startAddr, SIZE_T regionSize, const std::vector<PatternByte>& pattern) { std::vector<uintptr_t> results; const SIZE_T bufferSize = 1024 * 1024; // 1MB 缓冲区 std::vector<BYTE> buffer(bufferSize); SIZE_T bytesRead = 0; for (SIZE_T offset = 0; offset < regionSize; offset += (bufferSize - pattern.size() + 1)) { SIZE_T toRead = std::min(bufferSize, regionSize - offset); if (!ReadProcessMemory(hProcess, (LPCVOID)(startAddr + offset), buffer.data(), toRead, &bytesRead)) { // 部分区域不可读,尝试按页细分读取 for (SIZE_T pageOffset = 0; pageOffset < toRead; pageOffset += 4096) { SIZE_T pageRead = 0; if (ReadProcessMemory(hProcess, (LPCVOID)(startAddr + offset + pageOffset), buffer.data() + pageOffset, 4096, &pageRead)) { bytesRead = pageOffset + pageRead; } } } if (bytesRead == 0) continue; // 在缓冲区中搜索特征码 for (SIZE_T i = 0; i <= bytesRead - pattern.size(); ++i) { bool match = true; for (SIZE_T j = 0; j < pattern.size(); ++j) { if (!pattern[j].wildcard && buffer[i + j] != pattern[j].value) { match = false; break; } } if (match) { results.push_back(startAddr + offset + i); } } } return results; }注意外层循环的步长,不是bufferSize,而是bufferSize - pattern.size() + 1。这是为了保证特征码不会横跨两次读取的边界。如果特征码正好分布在缓冲区尾部和新读取数据的开头,步长设置不对就会漏掉它。这个细节我第一次写的时候没注意,排查了很久才发现问题。
3.3 全进程扫描和调用地址定位
有了区域扫描,全进程扫描就是组合 VirtualQueryEx 遍历所有可读区域。
std::vector<uintptr_t> ScanEntireProcess(DWORD pid, const std::string& patternStr) { std::vector<uintptr_t> results; auto pattern = ParsePattern(patternStr); if (pattern.empty()) return results; HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid); if (!hProcess) return results; uintptr_t addr = 0; MEMORY_BASIC_INFORMATION mbi; while (VirtualQueryEx(hProcess, (LPCVOID)addr, &mbi, sizeof(mbi)) == sizeof(mbi)) { if (mbi.State == MEM_COMMIT && mbi.Protect != PAGE_NOACCESS && !(mbi.Protect & PAGE_GUARD)) { uintptr_t regionStart = (uintptr_t)mbi.BaseAddress; SIZE_T regionSize = mbi.RegionSize; auto regionResults = ScanRegion(hProcess, regionStart, regionSize, pattern); results.insert(results.end(), regionResults.begin(), regionResults.end()); } addr = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; } CloseHandle(hProcess); return results; }这段代码直接遍历整个用户态地址空间,从0x00000000到0x7FFFFFFF(64 位进程要处理到更高地址)。每个MEM_COMMIT且可读的区域都交给ScanRegion。
如果你的目标是定位一个 Call,拿到了特征码地址之后,还需要进一步处理。通常特征码位置并不是 Call 指令的起始地址,而是 Call 附近几字节的指令。你需要往地址的低地址方向回溯,找到E8(近Call)或FF 15(间接Call)这样的指令字节,然后计算相对偏移。我一般在扫描完成后写一个FindCallStart辅助函数,从命中地址往前搜索最多 32 字节,找到指令边界。
3.4 参数选择:缓冲区大小和扫描超时
缓冲区大小的取值,我实测下来 1MB 是性价比最高的。太小了 API 调用频繁,太大了内存分配有压力且对性能提升不明显。但如果目标进程特别大,可以适当调大到 4MB。
扫描超时很有必要加一个。因为没有超时保护的话,扫描一个大型进程时如果特征码匹配了上万次(比如一个太短的特征码),结果列表会膨胀,处理起来容易卡死。我习惯的做法是:记录扫描开始时间,超过 10 秒就自动停止,把已经找到的结果返回。用GetTickCount64()实现就行,不引入额外依赖。
4. 实战经验:扫不到、误匹配、定位不准怎么处理
代码写完了,真正难的是使用阶段。我把这几年用这类工具遇到的问题整理了一下,做个速查表。
| 现象 | 最常见原因 | 解决思路 |
|---|---|---|
| 扫描结果为空 | 权限不足,OpenProcess 失败 | 以管理员身份运行工具 |
| 扫描结果为空 | 特征码选取了不可读区域 | 检查特征码是否包含数据段内容 |
| 扫描结果为空 | 特征码含通配符位置写错 | 回到 CE/IDA 重新核对特征码 |
| 扫描到多个地址 | 特征码太短或太常见 | 加长特征码,或选包含独有立即数的位置 |
| 定位到的地址对不上 | 特征码可能是数据而非代码 | 确认命中区域是否为 MEM_IMAGE |
| 扫描速度太慢 | 缓冲区太小 | 调到 1MB 以上,按区域扫描 |
| 目标进程中符号变更 | 特征码失效 | 重新分析,生成新的特征码 |
4.1 为什么有时扫描速度比 CE 慢
Cheat Engine 扫描大量内存时,用的也是 ReadProcessMemory 方案,但它有专门的驱动,可以批量读取和内核态加速。纯应用层 C++ 和它比速度是不公平的。
提升应用层扫描速度的可行思路有两条。第一条是开多线程,把地址空间按区域切分,每个线程扫一段,最后汇总。第二条是用 SIMD 指令优化匹配逻辑,_mm_cmpeq_epi8这类指令一次能比较 16 个字节,匹配速度能翻几倍。不过匹配逻辑本身不是瓶颈,内存读取才是,所以优先优化读取策略更实际。
4.2 特征码怎么选才能避免误匹配
选特征码是个经验活。核心原则是“在关键指令中找独有性”。
看一段反汇编时,优先选择那些包含立即数或寄存器相对偏移的指令。比如83 C1 01(add ecx, 1)这种太常见,一个进程里可能出现几千次。而C7 86 00 02 00 00 64 00 00 00(mov dword ptr [rsi+0x200], 0x64)这种带明显偏移和立即数的序列,独有性就高得多。
我选特征码的标准做法是:先在 IDA 里定位关键函数,从头开始取 16 到 32 个字节,去掉所有含相对地址的指令(如 call rel32、jmp rel32),保留 opcode 和寄存器编码,不够长就往后继续取。只要特征码长度超过 10 字节,误匹配概率就已经很低了。
4.3 怎么验证命中的地址一定是目标位置
验证是最后一步,也是最容易忽略的一步。很多新手扫到地址就直接用,结果程序一更新就崩溃。
最可靠的验证方式是二次定位。扫描结果出来之后,在命中地址处读回 16 字节,和 IDA 里的原始字节做一次人工核对。如果 IDA 里显示的是8B 4D F8 83 C1 01,扫描命中的地址读出来也必须是这个序列,否则就是匹配逻辑有问题。
另一个实用技巧是动态验证。如果定位的是函数起始点,可以在该地址设置一个硬件断点,然后让目标程序跑起来。能正常命中断点,说明地址真实有效。
提示:写特征码扫描器本身是纯粹的技术活,但我还是想多说一句。这类工具最合适的应用场景是学习逆向、调试自己的程序、分析恶意样本、做安全研究。请把它用在合法合规的用途上。
4.4 从扫描器到完整调试工具的扩展思路
扫描器实现之后,很多朋友会问后续还能做什么。我自己的路线是往三个方向扩展。
第一是增加模块过滤。只扫描指定的 DLL 或 EXE 模块,能大幅缩小扫描范围。实现上先用EnumProcessModulesEx拿模块基址和大小,再把这些信息传给扫描器。
第二是增加热键和命令行参数。扫描操作封装成库,暴露一个简单的接口,外部程序通过命令行或热键触发扫描,这是做成工具的第一步。
第三是集成反汇编引擎。命中特征码之后,用 Capstone 或 BeaEngine 反汇编命中地址附近的指令,自动识别 Call 指令并计算目标地址,整个过程全自动。这一步做完,基本上就是一个功能完整的“找 Call 神器”了。
我在实际项目中,把这三个方向都做了。模块过滤让扫描时间降低到原来的五分之一,反汇编引擎结合特征码定位,基本实现了“一个特征码输入,Call 地址输出”的自动化流程。这套东西配合调试器使用,效率比纯手工在 CE 里翻高太多了。
本文还有配套的精品资源,点击获取