ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++特征码极速定位:Windows进程内存搜索实战

C++特征码极速定位:Windows进程内存搜索实战 简介面向C逆向与内存分析学习者这套进程内存搜索方案以特征码快速定位指定内存区域辅助追踪Call地址与关键数据地址适用于游戏逆向、软件调试和外挂原理研究等场景。代码基于Visual Studio工程构建并保留完整项目结构与编译输出适合有一定C基础、希望在动态调试与内存分析方向动手实操的读者。包内共27个文件整体8.45MB包含C源代码、头文件、VS工程配置文件以及编译生成的exe可执行程序、pdb调试符号和ilk增量链接文件同时保留obj、tlog等编译中间输出和ReadMe说明文档便于查看编译流程与项目设置。工程可直接编译运行也可打开源码研究特征码匹配、内存遍历与读写流程。资源已有4415人学习下载。通过这套工程读者既能获得可直接运行的搜索工具也能学习从特征码构造、区域扫描到权限处理、地址返回的完整实现细节为后续定位Call地址或编写内存辅助模块打下扎实基础。 搞Windows底层调试和逆向分析的老哥们应该都经历过这种折磨程序表面功能清清楚楚但你想找的那个关键函数死活定位不到或者盯着内存变来变去数据倒是敏感可它属于哪段逻辑完全没头绪。我自己的解决办法就是靠C写一套进程内存搜索工具用特征码做极速定位然后顺着命中点一路拆到Call调用、拆到目标地址。这篇文章把这套思路完整展开从原理讲到代码实现再把常见的坑挨个点一遍适合刚开始接触逆向、想自己造调试轮子、或者准备深入Windows底层开发的朋友参考。1. 先想清楚为什么“特征码极速定位”是刚需1.1 数值搜内存解决不了的问题大部分新手接触的第一种搜索方式是Cheat Engine式的“数值扫描”比如搜血量、金币、坐标这类动态变化的数据。这个思路直观但遇到两类情况就立刻抓瞎。第一类是数据本身不直接暴露程序可能把血量做了偏移存储的是当前生命值乘以某个系数、包了一层结构体、甚至用指针链表层层跳转。这时候数值搜索要么搜出一堆不相关的内存要么干脆搜不到目标。第二类更关键你想定位的压根不是“数据”而是“代码”。比如点击某个按钮后程序内部调用了注册函数或者某个网络包的构造逻辑在哪个函数里。数据是内存里的内容代码也是内存里的内容——数值搜索只能搜内容匹配但你真正想找的是“这条指令在哪个地址”。这时候就得靠特征码扫描直接在可执行内存区域里搜索一串代表机器码的字节序列一步到位定位到对应指令。1.2 特征码的本质给机器码做指纹任何函数编译成二进制后都会对应一段固定的字节序列。比如最经典的函数开头55 push rbp 48 89 E5 mov rbp, rsp对应的机器码就是55 48 89 E5。只要这段指令在程序里出现你拿这串字节去内存里搜就能找到函数入口。更实用的是特征码允许带通配符。因为同一段代码在不同版本、不同编译参数下有些字节会变化——比如立即数、偏移量、寄存器编号。举例FF 25 ?? ?? ?? ?? jmp qword ptr [rip 0x????????]这通常是导入函数跳转表的写法问号就是“这一字节不管多少都行”的通配符。这样即使目标程序每次启动的绝对地址都不一样——ASLR导致的基址随机化——你依然能靠这段模式稳定命中。1.3 找Call是一条完整的证据链特征码定位最常用的三个场景定位函数入口、定位关键Call指令、定位全局对象地址。这三个场景往往是串起来的先通过特征码找到某个深层函数的入口然后想搞清楚谁调用了它再回到调用点看参数从哪里来最终还原出整个业务链路。这比单纯搜数值高一个维度——你搜的不是“数据长什么样”而是“代码长什么样”。只要目标程序的机器码是稳定的特征码定位就是一条可靠的证据链可以反复使用不依赖程序运行时的具体数据变化。2. 动手前的准备选型与权限2.1 为什么用C而不是C#/Python进程内存搜索本质上是高频调用Windows APIOpenProcess、ReadProcessMemory、VirtualQueryEx。这些API本身是C接口C用起来零成本。更重要的是性能扫描一个几十MB甚至几百MB的进程Python逐字节比对能把你等哭C配合多线程和SIMD指令把扫描时间压缩到几百毫秒体验完全不在一个量级。C还不容易被托管运行时干扰。调试目标程序本身就很敏感你的工具进程越轻、越贴近系统底层越容易保持稳定。这也是逆向和调试圈子里C常年占据主力位置的原因。2.2 权限与进程对齐进程内存搜索最基础的前置条件是权限。要读取目标进程必须用OpenProcess打开它并指定PROCESS_QUERY_INFORMATION | PROCESS_VM_READ。如果目标进程以管理员权限运行你的工具也必须提权到管理员如果目标进程是系统服务还得考虑SYSTEM权限。这里有一个特别容易踩的坑位数对齐。64位工具读32位进程的内存没问题但32位工具读64位进程的内存OpenProcess能成功ReadProcessMemory却大概率失败因为32位进程的地址空间根本装不下64位的内存地址。所以工具建议直接编译成x64一次性省掉这类兼容性烦恼。另外如果遇到有反调试保护的目标常规API读取会被拦截那已经不是本文讨论的普通场景了需要专门的驱动级方案。2.3 内存可见性哪些区域能搜到用VirtualQueryEx枚举进程内存时你会拿到一组MEMORY_BASIC_INFORMATION结构里面包含State、Protect、Type三个关键字段。只有State MEM_COMMIT的页面才是已提交内存MEM_RESERVE和MEM_FREE都不用扫。Protect必须包含可读权限比如PAGE_READWRITE、PAGE_EXECUTE_READ、PAGE_EXECUTE_READWRITE。PAGE_NOACCESS、PAGE_GUARD这些页面要么读不了要么一读就触发异常。Type决定了这块内存的性质MEM_IMAGE是模块映像代码段、数据段MEM_PRIVATE是私有堆栈内存MEM_MAPPED常见于映射文件。搜特征码定位代码时一般优先扫MEM_IMAGE范围小、速度快、命中率也高。很多新手一上来就全内存扫描结果又慢又乱。先按内存类型过滤扫描效率立刻提升一个档次。3. 核心实现C进程内存搜索与特征码匹配3.1 拿句柄、拿模块基址第一步是打开目标进程并拿到模块信息。我的工具一般用进程名匹配再枚举模块找到主模块基址这样后续扫描就能从模块基址开始而不是从0地址扫整片内存。#include windows.h #include psapi.h #include tlhelp32.h #include vector #include string #include sstream DWORD GetProcessIdByName(const std::wstring processName) { HANDLE snapshot CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot INVALID_HANDLE_VALUE) return 0; PROCESSENTRY32W pe { sizeof(pe) }; if (Process32FirstW(snapshot, pe)) { do { if (_wcsicmp(pe.szExeFile, processName.c_str()) 0) { CloseHandle(snapshot); return pe.th32ProcessID; } } while (Process32NextW(snapshot, pe)); } CloseHandle(snapshot); return 0; } uintptr_t GetModuleBase(HANDLE hProcess, const std::wstring moduleName) { HMODULE mods[1024]; DWORD needed 0; if (!EnumProcessModulesEx(hProcess, mods, sizeof(mods), needed, LIST_MODULES_ALL)) return 0; for (DWORD i 0; i needed / sizeof(HMODULE); i) { wchar_t modPath[MAX_PATH]; if (GetModuleFileNameExW(hProcess, mods[i], modPath, MAX_PATH)) { std::wstring name(modPath); size_t pos name.find_last_of(L\\); std::wstring shortName (pos ! std::wstring::npos) ? name.substr(pos 1) : name; if (_wcsicmp(shortName.c_str(), moduleName.c_str()) 0) { MODULEINFO info { 0 }; if (GetModuleInformation(hProcess, mods[i], info, sizeof(info))) { return (uintptr_t)info.lpBaseOfDll; } } } } return 0; }注意EnumProcessModulesEx的最后一个参数必须传LIST_MODULES_ALL否则在64位系统上枚举64位进程时只能拿到32位模块列表主模块就可能漏掉。3.2 特征码解析支持通配符我用字符串来描述特征码用??代表通配符。这样调试时从反汇编窗口复制字节串直接粘到工具里就能用。struct PatternByte { uint8_t value; bool wildcard; }; std::vectorPatternByte ParsePattern(const std::string pattern) { std::vectorPatternByte bytes; std::istringstream iss(pattern); std::string token; while (iss token) { PatternByte pb; if (token ? || token ??) { pb.wildcard true; pb.value 0; } else { pb.wildcard false; pb.value static_castuint8_t(std::stoul(token, nullptr, 16)); } bytes.push_back(pb); } return bytes; }这里有个细节通配符统一用??但解析时对单个?也做了兼容因为x64dbg和CE复制出来的格式不完全一样能多兼容一种格式就少一次手工替换。3.3 内存扫描主循环扫描主循环有两层外层用VirtualQueryEx遍历内存区域内层按块读取并做模式匹配。bool ComparePattern(const uint8_t* data, size_t dataLen, const std::vectorPatternByte pattern, size_t offset) { if (dataLen pattern.size()) return false; for (size_t i 0; i dataLen - pattern.size(); i) { size_t j 0; for (; j pattern.size(); j) { if (!pattern[j].wildcard data[i j] ! pattern[j].value) break; } if (j pattern.size()) { offset i; return true; } } return false; } std::vectoruintptr_t ScanRegion(HANDLE hProcess, uintptr_t start, size_t size, const std::vectorPatternByte pattern) { std::vectoruintptr_t results; std::vectoruint8_t buffer(size); SIZE_T bytesRead 0; if (!ReadProcessMemory(hProcess, (LPCVOID)start, buffer.data(), size, bytesRead)) return results; size_t offset 0; size_t searchLen bytesRead; while (searchLen pattern.size()) { size_t matchOffset 0; if (ComparePattern(buffer.data() (bytesRead - searchLen), searchLen, pattern, matchOffset)) { results.push_back(start (bytesRead - searchLen) matchOffset); searchLen - matchOffset 1; // 向后移动1字节继续搜避免漏掉重叠匹配 } else { break; } } return results; }这一段有两个容易忽略的点。第一ReadProcessMemory返回的bytesRead可能小于请求大小必须用实际读取的字节数做计算否则会把未初始化的缓冲区垃圾数据当成目标内容。第二匹配成功后不能直接跳到matchOffset pattern.size()而要只移动1字节因为两个特征码命中点可能存在重叠。虽然实际情况中少见但严谨点没坏处。外层遍历内存区域时我只对MEM_COMMIT且Protect可读、且Type为MEM_IMAGE或MEM_PRIVATE的区域发起扫描uintptr_t address 0; MEMORY_BASIC_INFORMATION mbi { 0 }; while (VirtualQueryEx(hProcess, (LPCVOID)address, mbi, sizeof(mbi)) ! 0) { if (mbi.State MEM_COMMIT mbi.Type MEM_IMAGE (mbi.Protect 0xFF) ! PAGE_NOACCESS !(mbi.Protect PAGE_GUARD)) { auto hits ScanRegion(hProcess, address, (size_t)mbi.RegionSize, pattern); results.insert(results.end(), hits.begin(), hits.end()); } address mbi.RegionSize; }(mbi.Protect 0xFF)是为了去掉PAGE_GUARD这类修饰标志只看基础保护属性。3.4 性能优化三板斧特征码扫描最大的痛点是速度。实测中直接整块读取再逐字节比对一个几百MB的目标进程能跑到几十秒完全没有“极速”体验。我自己优化靠三板斧。第一板斧按页过滤。先遍历内存区域把不可能命中代码特征码的堆区、栈区提前排除。比如定位代码时只扫MEM_IMAGE这一步能过滤掉一半以上的内存。第二板斧先定位“锚点字节”。ComparePattern里的双层循环是纯C逐字节比对慢在对每个偏移都从头比一遍。更好的做法是从特征码里挑一个非通配符的“锚点字节”用memchr快速找到候选位置只对候选位置做完整比对。实测这个优化能让扫描速度提升一个数量级。第三板斧多线程分区扫描。把需要扫描的内存区域按地址切分成N段每段丢给一个线程处理。需要注意线程安全每个线程只写自己的结果集合最后统一汇总。std::vectoruintptr_t results; std::mutex mutex; // 简单划分区域从start开始每步stepSize交给一个线程 const size_t stepSize 64 * 1024 * 1024; // 64MB std::vectorstd::thread threads; for (uintptr_t cur start; cur start totalSize; cur stepSize) { uintptr_t regionStart cur; size_t regionSize min(stepSize, (size_t)(start totalSize - cur)); threads.emplace_back([, regionStart, regionSize]() { auto hits ScanRegion(hProcess, regionStart, regionSize, pattern); std::lock_guardstd::mutex lock(mutex); results.insert(results.end(), hits.begin(), hits.end()); }); } for (auto t : threads) t.join();这里有一个隐藏问题ReadProcessMemory的整块读取是连续的跨过两个不同属性的内存区域时可能失败。所以严格来说单块读取的区域边界应该由VirtualQueryEx决定而不是我这样粗暴等分。上面的代码为了演示简单用了等分实际工具里我会先用VirtualQueryEx把可扫区域整理成一个区间列表再对这些区间做多线程分配。4. 实战定位Call的完整流程4.1 特征码命中后如何解析相对地址找到特征码命中地址只是第一步。很多时候我们定位到的是某个call指令本身要把它的目标地址解析出来才算真正找到关键调用点。x86/x64里E8 xx xx xx xx是相对近调用call rel32E9是相对跳转。指令本身5字节后面4字节是相对偏移偏移是相对于下一条指令地址的。所以解析公式为callTarget callAddr 5 *(int32_t*)(callAddr 1)对应代码uintptr_t ResolveCallTarget(HANDLE hProcess, uintptr_t callAddr) { uint8_t buffer[5] { 0 }; SIZE_T bytesRead 0; if (!ReadProcessMemory(hProcess, (LPCVOID)callAddr, buffer, 5, bytesRead)) return 0; if (bytesRead 5) return 0; if (buffer[0] 0xE8 || buffer[0] 0xE9) { int32_t rel *(int32_t*)(buffer 1); return callAddr 5 static_castint64_t(rel); } return 0; }这里用static_castint64_t(rel)再做加法是为了防止32位偏移相加时发生符号扩展错误尤其在高地址场景下容易出问题。4.2 从函数入口反查调用来源有时候你已经定位到了某个函数的入口但更想知道“谁在调用它”。方法也很朴素扫描目标模块整个代码段找出所有E8指令逐个解析目标地址凡是等于函数入口的就是潜在调用点。这一步的瓶颈是扫描范围大命中点又少。优化思路和前面一样先过滤内存区域只扫MEM_IMAGE的可执行区域再用memchr找0xE8字节命中后再读取5字节解析地址。实测一个50MB左右的模块全量找完所有调用点也不过一两秒。找到调用点之后再结合调试器查看调用点附近的指令就能看出参数是怎么传递的。比如调用前有没有mov rcx, ...、mov rdx, ...这些寄存器操作往往直接暴露了业务上下文。4.3 扫出一堆结果怎么办特征码太短或者通配符太多经常一次命中几十上百个地址。这时候不要急着逐个排查先用三个手段筛选。第一加长特征码。从命中点往前和往后多取一些指令把特征码从10字节拉长到20字节以上唯一性会大幅提升。第二验证指令边界。特征码必须从一条指令的起始字节开始匹配如果从指令中间开始即使字节匹配反汇编后也是错位的。最稳妥的做法是在x64dbg里对着反汇编窗口提取特征码确保每个字节组都是一条完整指令。第三结合上下文验证。命中地址前后几字节往往有规律比如函数开头通常是push rbp; mov rbp, rsp或者sub rsp, xx。如果命中地址根本不在函数边界上直接丢掉。5. 踩坑记录与排查速查表5.1 权限与兼容性OpenProcess返回NULL是最常见的失败。先确认目标进程权限级别再确认你是用管理员运行的。如果目标进程有多个实例还要确认进程ID没拿错。我自己排查过最诡异的一次目标进程明明存在OpenProcess却一直拒绝最后发现是杀毒软件拦截了跨进程访问把工具加入白名单就好了。位数兼容性我也再强调一次工具按x64编译是底线。很多朋友用Visual Studio默认的x86编译结果一读64位目标进程就失败换x64编译立刻正常。5.2 扫描盲区扫描结果为空不一定是你特征码错了也可能是内存根本没扫到。VirtualQueryEx返回的Protect里如果带PAGE_GUARD读取就会触发异常某些驱动保护的目标进程甚至会让ReadProcessMemory读取指定区域时直接失败。这种情况下先把扫描范围打印出来看哪些区域被跳过了再做针对性处理。还有一种盲区是特征码本身跨页了。如果特征码正好落在256MB扫描块的最末尾后半截在下一块区域里单块扫描就会漏掉。这也是为什么多线程分区不能用粗糙的等分必须先用VirtualQueryEx把连续可读区域整理好再切分。5.3 特征码选取的玄学特征码不是越长越好也不是随便抓一段就行。我自己总结了几条经验优先选函数头部指令因为函数开头通常是最稳定的受版本迭代影响小。特征码里尽量不要包含立即数和偏移量比如48 8B 05 xx xx xx xx这种带位移的指令一旦前后代码变化位移就会变。除非你把这些位置通配掉。一条特征码里通配符不要超过4个否则唯一性无法保证。选完后先在目标模块里跑一次看命中数量。如果超过10个就加长或调整位置。我平时在x64dbg里调试时看到一段可疑指令会直接复制原始字节作为特征码再用自己的工具扫描验证唯一性。整个过程快的话半分钟就能完成比反复手动看内存高效得多。最后分享一个我自己常用的调试小经验整个工具跑通之后我习惯在扫描前先做一个“自检”拿目标模块里已知的一条绝对地址当作特征码扫描一次确认能稳定命中然后再去搜未知目标。这样做的好处是一旦扫描逻辑有问题比如位数不对、内存类型过滤错了立刻就能暴露而不是等搜半天搜不到目标时才怀疑工具本身。这个流程虽然多花十秒钟但能省掉大量反复编译调试的时间。如果你刚开始写自己的内存搜索工具强烈建议先拿一个自己写的测试程序验证整个流程跑通之后再拿去分析真实目标心里会踏实很多。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进