
1. 项目概述从“大海捞针”到“精准定位”在逆向分析、游戏外挂开发、安全研究甚至是某些特定的软件调试场景里我们经常会遇到一个经典问题如何在程序运行时那庞大且动态变化的内存空间中快速、准确地找到一小段我们已知的字节序列这个过程就是“内存特征码搜索”。它不像在硬盘上搜索文件内存是易失的、结构复杂的并且充满了各种不确定的地址偏移。想象一下你手里有一张模糊的藏宝图碎片特征码需要在瞬息万变的海洋进程内存里找到宝藏的确切位置这就是我们要解决的核心问题。用C/C来实现这个功能几乎是这个领域的“标准答案”。原因很简单效率和直接。这类操作需要直接与操作系统内存管理接口打交道进行底层的字节比对对性能要求极高。Python等脚本语言虽然也能通过ctypes或pymem实现但在处理动辄几个GB的内存空间、进行数百万次比对时原生C/C的速度优势是决定性的。无论是分析一个游戏的血量地址还是定位某个反作弊模块的钩子函数一个高效的特征码扫描器都是核心工具。我自己在写这类工具时最深的体会就是它远不止是简单的memcmp循环。你需要考虑内存页的权限不能去读没有读取权限的页否则程序会崩溃、考虑特征码中的通配符比如某些字节是可变的需要用??表示、考虑搜索的速度和内存占用的平衡。一个鲁棒的扫描器是技巧和工程实践的集合。接下来我就把自己在Windows平台下用C实现内存特征码搜索的完整思路、关键代码和踩过的坑系统地梳理一遍。2. 核心原理与方案设计为什么是ReadProcessMemory和通配符在动手写代码之前我们必须把几个核心问题想清楚我们有权读取谁的内存内存到底长什么样特征码又该如何表示这决定了我们整个程序的架构。2.1 内存访问的基石OpenProcess 与 ReadProcessMemory在Windows系统中每个进程都有自己独立的虚拟地址空间。你的扫描程序Scanner作为一个独立的进程不能直接通过指针去访问目标进程Target的内存比如int* hp (int*)0x12345678;这样做只会访问到自己进程的地址0x12345678或者引发访问违规。正确的姿势是使用Windows API。整个过程分为三步获取进程句柄通过OpenProcess函数传入目标进程的PID和所需的权限PROCESS_VM_READ和PROCESS_QUERY_INFORMATION是必须的得到一个代表该进程的句柄HANDLE。这个句柄就是你操作目标进程的“令牌”。枚举内存区域你不能盲目地从0x0读到0xFFFFFFFF。大部分地址区域是未保留或不可读的。需要用到VirtualQueryEx函数。它可以查询目标进程中某个地址所在的内存区域MEMORY_BASIC_INFORMATION的信息包括区域基址、大小、状态MEM_COMMIT、和保护属性PAGE_READONLY,PAGE_READWRITE等。我们只对已提交MEM_COMMIT且可读保护属性包含PAGE_READONLY或PAGE_READWRITE的区域感兴趣。读取内存内容对于每一个可读的内存区域我们使用ReadProcessMemory函数传入进程句柄、目标地址、本地缓冲区地址和要读取的大小将目标进程的内存数据“复制”到我们自己的程序缓冲区中然后才能进行比对。注意OpenProcess可能需要管理员权限特别是对于某些受保护的系统进程或使用了某些驱动保护的进程。如果你的扫描器需要应对高强度的游戏可能需要考虑提权或使用其他内核模式的方法但这超出了本文基础工具的范围。2.2 特征码的表示法从字节数组到模式串特征码本质上就是一个字节序列。最直接的表示方法就是一个unsigned char数组。但现实中我们往往需要模糊匹配。例如我们想找一个函数调用指令E8 ?? ?? ?? ??其中E8是call的操作码后面的四个字节是相对偏移地址每次程序加载这个偏移值都可能不同我们需要将其视为通配符。因此业界常用的表示法是“IDA风格”的特征码字符串例如55 8B EC 83 EC 20 A1 ?? ?? ?? ?? 85 C0。 这里两位十六进制数表示一个确定的字节??表示一个通配符任何字节都匹配。同时我们还需要一个等长的布尔数组或称为掩码Mask来标识哪些位置是确定的true哪些是通配的false。在代码中我们通常会设计一个Pattern结构体或类包含两个向量std::vectorunsigned char bytes;// 存放确定字节的值通配符位置可以填0或任意值。std::vectorbool mask;// 存放掩码true表示该位置需匹配false表示通配。解析特征码字符串的函数就是负责把55 8B EC ?? ??这样的字符串转换成上述的bytes和mask向量。2.3 搜索算法选择暴力扫描与优化权衡最直观的算法是暴力扫描Brute-Force在每一个可读内存区域的缓冲区里从第一个字节开始尝试将特征码对齐到每一个可能的位置然后逐字节比对考虑掩码。假设内存区域大小为N特征码长度为M那么时间复杂度是O(N*M)。在M较小通常几十字节而N很大几MB到几GB的情况下这仍然是主流且实现简单的方法。有没有更快的算法有例如Boyer-Moore或Knuth-Morris-Pratt (KMP) 这类字符串搜索算法。但它们主要针对精确匹配优化处理通配符会变得复杂。对于带有通配符的模式一种常见的优化是“分段匹配”或“特征点匹配”。即不从模式串的第一个字节开始比而是选择模式串中连续几个确定字节称为“特征点”或“锚点”先进行快速定位然后再进行全量比对。这可以跳过大量明显不匹配的位置。然而在内存扫描这个特定场景下由于我们还需要频繁调用ReadProcessMemory这是一个相对耗时的系统调用I/O开销往往比CPU比对开销更大。因此优化的首要方向是减少不必要的内存读取次数而不是单纯优化比对算法本身。我们的设计思路是一次读取一个合理大小的内存块例如4096字节的页面大小或更大然后在这个块内进行暴力扫描。这样可以最大化每次系统调用的数据吞吐量。3. 关键模块实现与代码拆解理论说完了我们上代码。我将整个扫描器分为几个核心模块并会解释关键代码段。3.1 进程操作模块获取内存地图首先我们需要一个类来管理目标进程。它负责打开进程、遍历内存区域。#include windows.h #include vector #include string class ProcessMemoryScanner { public: ProcessMemoryScanner(DWORD pid) : m_processId(pid), m_hProcess(nullptr) {} ~ProcessMemoryScanner() { if (m_hProcess) CloseHandle(m_hProcess); } bool Open() { m_hProcess OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, m_processId); return m_hProcess ! nullptr; } struct MemoryRegion { uintptr_t baseAddress; size_t regionSize; DWORD protect; }; std::vectorMemoryRegion GetReadableRegions() { std::vectorMemoryRegion regions; SYSTEM_INFO sysInfo; GetSystemInfo(sysInfo); uintptr_t minAddr (uintptr_t)sysInfo.lpMinimumApplicationAddress; uintptr_t maxAddr (uintptr_t)sysInfo.lpMaximumApplicationAddress; MEMORY_BASIC_INFORMATION mbi; for (uintptr_t addr minAddr; addr maxAddr; addr mbi.RegionSize) { if (VirtualQueryEx(m_hProcess, (LPCVOID)addr, mbi, sizeof(mbi)) 0) { break; // 查询失败可能进程已结束 } // 只关心已提交的、可读的内存页 bool isCommitted (mbi.State MEM_COMMIT); bool isReadable (mbi.Protect PAGE_READONLY) || (mbi.Protect PAGE_READWRITE) || (mbi.Protect PAGE_EXECUTE_READ) || (mbi.Protect PAGE_EXECUTE_READWRITE); // 注意排除一些特殊的保护属性如PAGE_GUARD bool isGuard (mbi.Protect PAGE_GUARD); if (isCommitted isReadable !isGuard) { regions.push_back({ (uintptr_t)mbi.BaseAddress, (size_t)mbi.RegionSize, mbi.Protect }); } // 防止死循环 if (mbi.RegionSize 0) { break; } } return regions; } HANDLE GetHandle() const { return m_hProcess; } private: DWORD m_processId; HANDLE m_hProcess; };实操心得VirtualQueryEx的循环中用addr mbi.RegionSize来推进地址是关键。直接加一个固定值如sysInfo.dwPageSize会慢得多因为一个内存区域Region通常包含很多个内存页Page。另外一定要检查mbi.RegionSize是否为0这在某些边缘情况下可能导致死循环。3.2 特征码解析模块从字符串到模式接下来我们需要将AA BB CC ?? DD ?? EE这样的字符串解析成模式。#include sstream #include iomanip #include cctype struct Pattern { std::vectorunsigned char bytes; std::vectorbool mask; // true 需要匹配 false 通配符 size_t length() const { return bytes.size(); } }; class PatternParser { public: static Pattern FromString(const std::string patternStr) { Pattern pattern; std::istringstream iss(patternStr); std::string token; while (iss token) { if (token ?? || token ?) { // 通配符 pattern.bytes.push_back(0x00); // 值不重要可填任意 pattern.mask.push_back(false); } else { // 尝试解析为十六进制字节 try { // 使用stoul将16进制字符串转换为整数 unsigned long byteVal std::stoul(token, nullptr, 16); if (byteVal 0xFF) { // 处理错误输入可能不是有效的字节 throw std::invalid_argument(Token exceeds byte value: token); } pattern.bytes.push_back(static_castunsigned char(byteVal)); pattern.mask.push_back(true); } catch (const std::exception) { // 解析失败按通配符处理或者抛出异常。这里我们选择按通配符处理增强容错。 pattern.bytes.push_back(0x00); pattern.mask.push_back(false); } } } // 确保两个向量长度一致 if (pattern.bytes.size() ! pattern.mask.size()) { // 理论上不会发生这里做安全保护 pattern.mask.resize(pattern.bytes.size(), true); } return pattern; } };注意事项这里做了一个容错处理当解析十六进制失败时将其当作通配符。在实际严谨的工具中你可能希望直接抛出异常让调用者知道特征码格式有误。另外特征码字符串中的分隔符不一定是空格也可能是-或其他可以根据需要调整解析逻辑。3.3 内存块扫描模块核心比对逻辑这是最核心的部分负责在一个本地缓冲区即从目标进程读取出来的一块内存中搜索特征码。#include algorithm class MemoryBlockScanner { public: static std::vectoruintptr_t ScanBlock( const unsigned char* blockData, size_t blockSize, const Pattern pattern, uintptr_t blockBaseOffset) // 这个块在目标进程内存中的起始地址 { std::vectoruintptr_t results; size_t patternLen pattern.length(); if (patternLen 0 || blockSize patternLen) { return results; } // 暴力扫描遍历块内每一个可能的起始位置 for (size_t i 0; i blockSize - patternLen; i) { bool match true; for (size_t j 0; j patternLen; j) { // 如果该位置有掩码需要匹配且字节不相等则匹配失败 if (pattern.mask[j] blockData[i j] ! pattern.bytes[j]) { match false; break; } // 如果掩码为false通配符则跳过比对 } if (match) { // 计算在目标进程中的绝对地址块基址偏移 块内偏移 results.push_back(blockBaseOffset i); } } return results; } // 一个简单的优化版本使用特征点Signature Point先进行快速过滤 static std::vectoruintptr_t ScanBlockOptimized( const unsigned char* blockData, size_t blockSize, const Pattern pattern, uintptr_t blockBaseOffset) { std::vectoruintptr_t results; size_t patternLen pattern.length(); if (patternLen 3) { // 特征点优化对短模式效果不大回退到暴力扫描 return ScanBlock(blockData, blockSize, pattern, blockBaseOffset); } // 选择模式串中第一个确定的字节作为“特征点” size_t anchorIndex 0; for (; anchorIndex patternLen; anchorIndex) { if (pattern.mask[anchorIndex]) { break; } } if (anchorIndex patternLen) { // 整个模式都是通配符返回整个区域的所有位置通常无意义这里返回空。 return results; } unsigned char anchorByte pattern.bytes[anchorIndex]; // 第一步在块中快速定位所有特征字节出现的位置 for (size_t i 0; i blockSize - patternLen; i) { if (blockData[i anchorIndex] anchorByte) { // 初步匹配进行全量验证 bool match true; for (size_t j 0; j patternLen; j) { if (pattern.mask[j] blockData[i j] ! pattern.bytes[j]) { match false; break; } } if (match) { results.push_back(blockBaseOffset i); } } } return results; } };踩坑记录在ScanBlock函数的循环条件i blockSize - patternLen中一定要用而不是。因为如果块大小正好等于模式长度i应该可以从0开始此时blockSize - patternLen 0用会导致一次都不循环。这是边界条件的一个经典错误。3.4 主控与调度模块串联一切最后我们需要一个主函数或管理器来协调以上所有模块。它的工作流程是打开目标进程。获取所有可读内存区域列表。解析用户输入的特征码。遍历每个内存区域分块读取内存。对每个内存块调用扫描函数。收集并返回所有匹配的地址。class SignatureScanner { public: SignatureScanner(DWORD pid) : m_process(pid) {} std::vectoruintptr_t Scan(const std::string signatureStr) { std::vectoruintptr_t allResults; if (!m_process.Open()) { std::cerr Failed to open process. Error: GetLastError() std::endl; return allResults; } Pattern pattern PatternParser::FromString(signatureStr); if (pattern.length() 0) { std::cerr Invalid or empty pattern. std::endl; return allResults; } auto regions m_process.GetReadableRegions(); std::cout Found regions.size() readable memory regions. std::endl; const size_t READ_BLOCK_SIZE 4096 * 4; // 一次读取16KB平衡I/O次数和内存占用 std::vectorunsigned char buffer(READ_BLOCK_SIZE); for (const auto region : regions) { size_t regionSize region.regionSize; uintptr_t currentAddr region.baseAddress; size_t totalRead 0; while (totalRead regionSize) { size_t bytesToRead std::min(READ_BLOCK_SIZE, regionSize - totalRead); SIZE_T bytesRead 0; if (ReadProcessMemory(m_process.GetHandle(), (LPCVOID)currentAddr, buffer.data(), bytesToRead, bytesRead) bytesRead 0) { // 成功读取扫描这个缓冲区 auto blockResults MemoryBlockScanner::ScanBlockOptimized( buffer.data(), bytesRead, pattern, currentAddr // 传入当前块在目标进程中的基址 ); // 将本次扫描结果合并到总结果中 allResults.insert(allResults.end(), blockResults.begin(), blockResults.end()); } else { // 读取失败可能是遇到了PAGE_GUARD或在扫描过程中权限变化跳过这个块 DWORD err GetLastError(); if (err ! ERROR_PARTIAL_COPY) { // 部分拷贝错误在扫描边界时常见可忽略 // 记录或处理其他错误 } } currentAddr bytesToRead; totalRead bytesToRead; } } return allResults; } private: ProcessMemoryScanner m_process; };4. 性能优化与高级技巧基础的扫描器已经能工作了但在实战中尤其是面对大型游戏进程我们还需要考虑性能和实用性。4.1 分块读取的策略与缓冲区管理我上面代码中使用了固定大小的块16KB来读取。这是一个折中方案。太小的块如4KB会导致ReadProcessMemory调用次数过多系统调用开销巨大。太大的块如1MB则可能一次性分配大量内存并且如果区域尾部剩余空间不足处理起来麻烦。16KB-64KB是一个经验上的甜点区间。更高级的策略是动态分块根据内存区域的大小来决定。对于非常大的区域100MB可以使用更大的块如256KB对于小区域则用小块。甚至可以预估扫描时间实现一个简单的进度提示。4.2 多线程并行扫描内存区域之间通常是独立的这为并行化提供了天然条件。我们可以将内存区域列表分成若干份交给多个工作线程同时扫描。主线程负责收集结果。需要注意的线程安全问题ReadProcessMemory是线程安全的可以多个线程同时对同一个进程句柄进行读取。结果收集需要使用互斥锁std::mutex保护共享的std::vectoruintptr_t。线程间的任务分配要均匀避免某个线程分到几个巨大的区域而其他线程早早结束。一个简单的线程池模型可以显著提升在多核CPU上的扫描速度对于扫描数GB的内存速度提升可能是几倍的。4.3 特征码的优化与“唯一性”编写一个好的特征码本身也是一门学问。目标是在内存中唯一地标识出目标数据或代码同时稳定在不同版本或环境下不变。选择稳定的字节尽量选择代码段.text中的指令操作码部分而不是地址偏移或立即数。例如函数开头的push ebp; mov ebp, esp(55 8B EC) 就比一个call指令后面的偏移地址要稳定。足够的长度太短的特征码如90 90两个NOP可能会在内存中匹配到成千上万次。通常建议特征码长度在8-20个字节之间并包含至少4-5个确定的字节。使用通配符对于绝对会变化的地址、偏移果断使用??。对于可能因编译器优化而改变的寄存器操作如mov eax, [ecx4]和mov edx, [ecx4]如果上下文允许也可以考虑将寄存器字段设为通配。验证结果扫描到地址后不要直接使用。最好能根据该地址附近的指令或数据结构进行二次验证。例如如果你扫描的是一个函数开头可以反汇编附近的代码看看是否符合函数的一般结构有ret指令等。4.4 处理地址随机化ASLR与重定位现代操作系统和编译器普遍使用地址空间布局随机化ASLR。这意味着每次程序启动模块如exe、dll加载的基地址都会变化。你的特征码如果直接包含硬编码的绝对地址肯定会失效。解决方案是使用相对偏移。例如你的特征码定位到模块中的一个特定指令然后通过这条指令与目标数据之间的固定偏移来计算目标数据的地址。在代码中这通常意味着扫描特征码得到一个地址A。获取特征码所在模块的基地址ModuleBase。计算特征码在模块内的相对偏移RVA A - ModuleBase。在下次程序运行时先获取模块新的基地址ModuleBaseNew。目标地址A_new ModuleBaseNew RVA。获取模块基地址可以用EnumProcessModules等API。这要求你的特征码必须落在某个已知模块的代码/数据段内。5. 实战调试与常见问题排查即使代码逻辑正确在实际运行中也会遇到各种问题。这里记录几个我踩过的坑和解决方法。5.1 扫描结果为空或地址错误这是最常见的问题。排查步骤如下确认进程ID和权限用任务管理器或Process Explorer确认目标进程PID是否正确。并以管理员身份运行你的扫描器。检查特征码格式确保特征码字符串没有多余空格十六进制字母大小写正确解析器应不区分大小写通配符??使用正确。最好先在静态分析工具如IDA、x64dbg中验证你的特征码在目标进程内存中确实存在。验证内存区域枚举在代码中打印出枚举到的所有可读内存区域的基址和大小看看是否包含了目标模块所在的区域通常是.text代码段和.rdata只读数据段。如果目标模块是DLL确保你扫描的是目标进程的内存而不是扫描器自身。检查读取失败在ReadProcessMemory失败时打印GetLastError()的错误码。常见的ERROR_PARTIAL_COPY299通常发生在扫描到区域边界时可以安全跳过。其他错误可能意味着权限不足或地址无效。缩小搜索范围如果扫描整个进程太慢或干扰太多可以尝试只扫描特定模块。先获取模块的基址和大小然后只在这个地址范围内进行扫描能极大提升精度和速度。5.2 程序崩溃访问违规如果你的扫描器自身崩溃问题可能出在缓冲区溢出在ScanBlock函数中确保循环边界i blockSize - patternLen计算正确且访问blockData[ij]时j不会越界。这是最可能的原因。空指针解引用检查ReadProcessMemory读取成功后buffer.data()是否有效。确保buffer向量在读取前已经resize或声明了足够大小。多线程数据竞争如果使用了多线程确保每个线程使用自己独立的缓冲区或者对共享缓冲区的访问有严格的锁保护。5.3 性能瓶颈分析如果扫描速度慢得无法接受使用性能分析工具用VS的性能探测器或简单的计时函数找出是VirtualQueryEx/ReadProcessMemory的I/O耗时多还是内存比对的CPU耗时多。调整块大小如前所述增大READ_BLOCK_SIZE可以减少系统调用次数但会增加单次分配的内存和单次比对的数据量。需要找到一个平衡点。启用编译器优化确保在Release模式下编译并开启最大优化/O2或/Ox。特别是内层比对循环优化后速度差异巨大。考虑算法优化对于超长的特征码50字节可以尝试实现更复杂的多字节特征点匹配。但对于常见长度暴力扫描经过编译器优化后已经足够快瓶颈通常在I/O。5.4 特征码失效的应对游戏或软件更新后特征码很可能失效。应对策略使用更稳定的特征码如前所述选择函数序言、固定的字符串引用等作为特征。多层特征码准备多个特征码第一个定位到一个大的代码块然后在这个代码块范围内用第二个、第三个更精确的特征码进行二次、三次扫描。指针遍历Pointer Scanning这是更高级的技术。不直接扫描目标值而是扫描指向目标值的指针链。通过多次ReadProcessMemory读取指针逐级追踪到最终地址。这种方法抗更新能力更强但实现也更复杂。最后我想说的是内存特征码搜索是一个实践性极强的领域。理论代码只是骨架真正的稳定性、效率和实用性来自于对目标程序内存布局的深刻理解以及大量调试经验的积累。我建议从简单的、自己写的小程序开始练习扫描逐步过渡到记事本、计算器最后再挑战复杂的游戏或应用。每解决一个崩溃每优化一次速度你对内存和系统的理解就会加深一层。这个工具本身也会成为你探索软件内部世界的强大手电筒。