简介:这套基于 C# 的万能程序补丁工具源代码,面向需要调试或修正二进制程序的开发者,通过搜索 EXE 或 DLL 中的特征代码并替换,实现目标程序的补丁修改。压缩包共 19 个文件,约 323KB,包含 8 个 cs 源码文件、3 个 resx 窗体资源、sln 解决方案与 csproj 工程文件,以及 config.prg 配置示例、程序图标和可执行文件等,结构完整,可在 Visual Studio 中直接打开编译。配置文件中可指定目标文件名与版本,其后按两行一组给出原始码与补丁码;项目强调特征码必须唯一,且十六进制码之间需保留且仅保留一个空格,便于读者理解补丁匹配规则。已有 644 人学习,适合想掌握特征码扫描、二进制替换和 WinForms 工具开发的中级 C# 开发者参考,也可在其基础上改造成自己的调试辅助工具。
1. 万能补丁工具到底补的是什么:一套 C# 源码里的进程注入与内存改写
很多人第一次听到“万能程序补丁工具”,会以为它能给任何 exe 打补丁,实际上它解决的是一类很具体的问题:目标程序已经编译成二进制,手上没有它的源码,但需要在运行时或文件层面改掉某条指令、某个常量、某个跳转判断。这套 C# 源代码把完整解决方案拆成了几个可替换的模块:PE 文件结构解析、特征码扫描定位、内存写入与保护属性调整、补丁配置的序列化。它适合手上有一批老系统要维护、又不想每次重编译的从业者,也适合想搞明白“补丁”背后到底发生了什么的 C# 开发者。读完你至少能判断:一个补丁请求到了你手上,是该走文件静态改写,还是走进程内存动态改写。
2. 补丁原理与工程结构:先搞清静态改写和动态注入的边界
2.1 两种打补丁的路径,选错了后面全是白工
补丁这件事本质只有两条路:改文件,或者改进程内存。静态改文件是直接对磁盘上的 PE 文件下手,把它复制一份、修改字节、写回,下次启动生效。它最大的好处是持久,关掉工具补丁还在;坏处是目标文件一旦被校验(哈希、数字签名),改一个字节就会暴露。动态改内存是目标进程跑起来之后,通过 OpenProcess 拿到句柄,VirtualProtectEx 改掉页保护属性,再 WriteProcessMemory 把新指令写进去。它不碰磁盘文件,天然躲开文件级哈希校验,但进程一重启补丁就消失,需要工具常驻或每次手动挂载。
我一般的判断顺序是:目标文件有没有签名校验或完整性自检?有,优先动态。补丁是否需要跨重启保持?需要,优先静态,或者静态加动态双保险。目标程序是不是 .NET 托管程序?托管程序里 IL 代码被打包在程序集里,直接改机器码不一定命中,得先看是改 IL 还是改 JIT 之后的本机代码,这套源码对托管程序的处理需要额外留意。
源代码的工程结构大致分四层,理解了分层,后面照抄代码才有落脚点。第一层是 PE 解析层,读 DOS 头、NT 头、节表,算出镜像基址和每个节的虚拟地址与文件偏移对应关系;第二层是扫描定位层,用特征码(一段唯一字节序列)在内存或文件缓冲区里找到目标位置;第三层是写入层,封装静态写文件和动态写内存两套 API;第四层是配置层,把“特征码、偏移、替换字节、是否校验”这些信息序列化成可复用的补丁描述文件。
一个常见误区是把特征码写死成“偏移量”。偏移量在同一个版本里能用,程序一升级、编译器一换优化选项,偏移就漂了。特征码虽然也有失效风险,但至少跟着代码走,可维护性高一个档次。这就是为什么这套源码里定位模块要单独抽出来。
2.2 用 C# 读 PE 结构:把 RVA 换算成文件偏移
静态改文件绕不开一件事:你在反汇编里看到的是 RVA(相对虚拟地址),但你要改的是文件里的字节,需要把 RVA 换算成 FileOffset。这个换算全靠节表。下面这段代码是 PE 解析的最小可用版本,先拿它把节表读出来。
// 读取 PE 文件节表,建立 RVA -> FileOffset 的映射 public static List<Section> ReadSections(string filePath) { var sections = new List<Section>(); using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read); using var br = new BinaryReader(fs); fs.Seek(0x3C, SeekOrigin.Begin); // e_lfanew 在 DOS 头 0x3C 处 int peOffset = br.ReadInt32(); fs.Seek(peOffset + 4, SeekOrigin.Begin); // 跳过 "PE\0\0" 签名 ushort machine = br.ReadUInt16(); // 机器类型 ushort sectionCount = br.ReadUInt16(); // 节数量 fs.Seek(peOffset + 4 + 20, SeekOrigin.Begin); // 跳过文件头剩余部分 ushort optMagic = br.ReadUInt16(); // 0x10B = PE32, 0x20B = PE32+ bool is64 = optMagic == 0x20B; int optHeaderSize = is64 ? 240 : 224; // 可选头长度,决定节表起始位置 fs.Seek(peOffset + 4 + 20 + optHeaderSize, SeekOrigin.Begin); for (int i = 0; i < sectionCount; i++) { byte[] nameBuf = br.ReadBytes(8); string name = Encoding.ASCII.GetString(nameBuf).TrimEnd('\0'); uint virtualSize = br.ReadUInt32(); // 内存中实际大小 uint virtualAddr = br.ReadUInt32(); // RVA 起始 uint rawSize = br.ReadUInt32(); // 文件中占用大小 uint rawPtr = br.ReadUInt32(); // 文件偏移起始 br.ReadBytes(12); // 重定位、行号等信息跳过 br.ReadUInt32(); // 节属性,这里不需要 sections.Add(new Section(name, virtualAddr, virtualSize, rawPtr, rawSize)); } return sections; } // RVA 换算文件偏移:落在哪个节就减去该节 RVA,再加该节文件偏移 public static long RvaToFileOffset(List<Section> sections, uint rva) { foreach (var s in sections) { uint end = s.VirtualAddress + Math.Max(s.VirtualSize, s.RawSize); if (rva >= s.VirtualAddress && rva < end) return rva - s.VirtualAddress + s.RawPointer; } throw new ArgumentException($"RVA 0x{rva:X} 不属于任何节"); }这段代码的关键点有三个。第一,e_lfanew固定在 DOS 头偏移 0x3C,读它拿到 PE 头位置,这是所有 PE 解析的起点。第二,可选头长度随 32 位和 64 位不同,PE32 是 224 字节、PE32+ 是 240 字节,算错节表整段错位,节名会读成乱码,这是新手最常见的翻车点。第三,节表里VirtualSize在内存里生效,RawSize在文件里生效,两者可能不等,换算时要取较大值当边界,否则边界附近的 RVA 会找不到节。参数上,virtualAddr和rawPtr是最要紧的两个字段,前者是内存基准、后者是文件基准,后面所有换算都靠这两个。
2.3 特征码扫描:从一串十六进制到唯一命中位置
有了换算能力,下一步是找到“要改哪里”。特征码是工程上最稳的做法:从目标位置前后截取一段在本版本里唯一的字节序列,扫描时先全量匹配这段序列,再按固定偏移退回到要改的位置。下面是一个带通配符支持的内存扫描实现,通配符用??表示,用来跳过会随版本变化的地址字节。
public static long ScanPattern(byte[] data, byte[] pattern, byte[] mask, long start = 0) { // mask[i] == 0 表示该字节通配,== 1 表示必须精确匹配 for (long i = start; i <= data.Length - pattern.Length; i++) { bool hit = true; for (int j = 0; j < pattern.Length; j++) { if (mask[j] == 1 && data[i + j] != pattern[j]) { hit = false; break; } } if (hit) return i; } return -1; // -1 表示未命中,调用方必须显式处理 } // 把 "E8 ?? ?? ?? ?? 55 8B" 这样的字符串转成 pattern + mask public static (byte[] pattern, byte[] mask) ParsePattern(string text) { var parts = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); var pattern = new byte[parts.Length]; var mask = new byte[parts.Length]; for (int i = 0; i < parts.Length; i++) { if (parts[i] == "??") { pattern[i] = 0; mask[i] = 0; } else { pattern[i] = Convert.ToByte(parts[i], 16); mask[i] = 1; } } return (pattern, mask); }扫描逻辑本身不复杂,真正决定成败的是特征码怎么选,这部分是血泪经验。第一,特征码长度建议不少于 12 字节,太短容易多命中,短于 8 字节在大型程序里几乎必然重复。第二,尽量把会变化的部分设成通配符,典型是 call 指令后面的相对偏移(E8 后面 4 字节)、全局变量地址(C7 45 后面的 imm32),这些地址每次编译都会变,写死就等于把补丁锁死在一个版本上。第三,扫描结果必须判空,未命中时返回 -1,调用方要能给出“特征码失效,请更新补丁描述”的明确提示,而不是拿着 -1 去写内存,那样必然写坏别的位置。
3. 动态注入与内存改写:句柄、权限和写入顺序一个都不能错
3.1 拿到进程句柄:OpenProcess 的权限位选择
动态补丁的第一步是拿到目标进程的操作句柄,这里最容易出错的是权限位给少了,后面 VirtualProtectEx 直接返回 false,而错误码往往不直观。下面这段是常用的句柄获取封装。
[DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr OpenProcess(uint access, bool inherit, int pid); [DllImport("kernel32.dll", SetLastError = true)] static extern bool CloseHandle(IntPtr handle); const uint PROCESS_VM_OPERATION = 0x0008; // 允许 VirtualProtectEx const uint PROCESS_VM_WRITE = 0x0020; // 允许 WriteProcessMemory const uint PROCESS_VM_READ = 0x0010; // 允许 ReadProcessMemory const uint PROCESS_QUERY_INFORMATION = 0x0400; public static IntPtr OpenTarget(int pid, out string error) { error = null; IntPtr h = OpenProcess( PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid); if (h == IntPtr.Zero) error = $"OpenProcess 失败,错误码 {Marshal.GetLastWin32Error()}"; return h; }权限位这块,我的习惯是把VM_OPERATION | VM_WRITE | VM_READ三个一起给,缺VM_OPERATION会导致改页保护失败,缺VM_READ会导致扫描阶段读不到内存。PROCESS_QUERY_INFORMATION用于查模块基址,也一并带上。所有非托管句柄用完必须CloseHandle,这一点在长时间运行的补丁工具里尤其重要,句柄泄漏积累到一定数量后,再 OpenProcess 就会持续失败,且错误码不指向句柄耗尽,排查起来很费时间。进程位数也要对齐:32 位工具打不开 64 位进程的部分操作,位数不匹配时优先怀疑工具本身编译目标。
3.2 改页保护再写内存:VirtualProtectEx 的时机
目标代码段默认是只读可执行的,直接 WriteProcessMemory 会失败。正确顺序是:先 VirtualProtectEx 把目标页改成可读写,写入完成后恢复原来的保护属性。恢复这一步很多人省掉,短期看不出问题,长期会让目标程序在某些安全检查里表现异常。
[DllImport("kernel32.dll", SetLastError = true)] static extern bool VirtualProtectEx(IntPtr hProcess, IntPtr addr, UIntPtr size, uint newProtect, out uint oldProtect); [DllImport("kernel32.dll", SetLastError = true)] static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr addr, byte[] buffer, UIntPtr size, out UIntPtr written); const uint PAGE_EXECUTE_READWRITE = 0x40; public static bool PatchMemory(IntPtr hProcess, IntPtr target, byte[] patch, out string error) { error = null; if (!VirtualProtectEx(hProcess, target, (UIntPtr)patch.Length, PAGE_EXECUTE_READWRITE, out uint oldProtect)) { error = $"改页保护失败,错误码 {Marshal.GetLastWin32Error()}"; return false; } try { if (!WriteProcessMemory(hProcess, target, patch, (UIntPtr)patch.Length, out UIntPtr written) || written.ToUInt64() != (ulong)patch.Length) { error = $"写入失败或不完整,错误码 {Marshal.GetLastWin32Error()}"; return false; } return true; } finally { // 无论成败都恢复原保护属性,避免目标进程状态异常 VirtualProtectEx(hProcess, target, (UIntPtr)patch.Length, oldProtect, out _); } }参数上有两个细节值得单独说。oldProtect是出参,调用前不需要赋值,调用后拿到的是该页原来的保护属性,恢复时必须用它而不是硬编码成PAGE_EXECUTE_READ,因为目标页原本可能是可写数据页。written也要校验,WriteProcessMemory 返回 true 但写入字节数不足的情况虽然少见,一旦发生就是半个补丁,比完全失败更隐蔽。另外补丁长度可能跨页,跨页时 VirtualProtectEx 会自动处理范围内所有页,这点不用手动拆。
3.3 补丁描述文件:把特征码和替换字节做成可维护配置
工具做到能用之后,真正的维护成本在补丁描述的管理上。硬编码在代码里的特征码,目标程序一升级就得重新编译工具,这不可持续。常见做法是把补丁描述做成 JSON,工具启动时加载,匹配失败时提示用户更新描述文件。字段设计上至少要有:名称、目标模块、特征码、特征码到修改点的偏移、原始字节(用于校验是否已被改过)、替换字节。
public class PatchEntry { public string Name { get; set; } // 补丁名,用于日志和界面 public string Module { get; set; } // 目标模块名,如主程序 exe public string Pattern { get; set; } // 特征码,支持 ?? 通配 public int OffsetFromPattern { get; set; }// 从命中点往后偏移多少字节开始改 public string OriginalBytes { get; set; } // 原始字节,改之前先比对 public string NewBytes { get; set; } // 替换字节 public bool VerifyOriginal { get; set; } // 是否强制校验原始字节 }VerifyOriginal这个开关是我建议一定要留的。它的作用是写入前先读一遍目标位置,确认当前字节等于OriginalBytes,不等就说明要么补丁已经打过了,要么目标版本不对,两种情况都不应该继续写。这个字段看起来多余,实际能挡掉大量“重复打补丁把指令改成垃圾”的事故。Module字段也不能省,大型程序往往加载多个模块,特征码在不同模块里可能都命中,限定模块能显著降低误命中率。
4. 避坑与排查:补丁工具最常见的五类翻车
4.1 现象:补丁显示成功,程序行为没变
原因通常有三种。一是改错了位置,特征码命中了相似代码而不是目标代码,特征码设计得太短或通配符太多就会这样;二是静态改了文件,但程序实际加载的是另一份副本(比如缓存目录或更新后重新释放的文件);三是目标程序是托管程序,改了磁盘上的机器码,但 JIT 在运行时重新生成了代码,改动未生效。解决路径是:先确认命中位置的反汇编上下文与预期一致,再确认程序实际加载路径,托管程序改走 IL 层或内存层。
4.2 现象:OpenProcess 返回 0,错误码 5
错误码 5 是拒绝访问,最常见原因是权限位给少了,其次是目标进程以更高完整性级别运行。解决方式是先补齐VM_OPERATION | VM_WRITE | VM_READ,仍失败则确认工具与目标进程的完整性级别是否一致。还有一种容易被忽略的情况:进程刚启动还没完全初始化,句柄拿得到但模块列表还不完整,此时扫描必然失败。我的习惯是拿到句柄后轮询等待目标模块基址非零再开始扫描,而不是拿到句柄就立刻动手。
4.3 现象:WriteProcessMemory 成功但目标进程崩溃
这基本可以判定为写入内容破坏了指令完整性。典型场景是只改了一个字节,把一条多字节指令截断成两条非法指令;或者改的位置不在指令边界上,CPU 取指时解码出错。解决方式是写入前确认目标偏移位于指令起始位置,并且替换字节长度与原指令长度完全一致,长度不一致时用 NOP 填充剩余字节,绝不能少写或多写。
4.4 现象:特征码在旧版本命中,新版本扫描返回 -1
这是补丁维护里最高频的问题,原因就是特征码里包含了随版本变化的地址或偏移。解决方式是把特征码里的相对地址段改成通配符,只保留指令操作码和稳定的寄存器编码部分。如果通配后仍然多命中,说明这段代码本身不唯一,需要往前或往后扩大特征码范围,把邻近的唯一常量或字符串纳入进来。
4.5 现象:补丁打完后程序启动报文件损坏
静态改文件最容易踩这个坑,原因是 PE 文件里有校验和字段,部分程序启动时会比对。解决方式是在静态改写完成后重新计算并回写 PE 校验和,或者干脆改走内存动态方案绕开文件校验。另外改写前务必备份原文件,且备份要在同一次操作内完成,不要依赖用户手动备份,后悔药得自己准备好。
5. 进阶:把补丁做成可验证的流程,而不是一次性脚本
补丁工具写到能跑通只是及格线,真正决定它能不能长期用的是可验证性。我后来把整个流程固定成四步,每步都有明确的通过条件,任何一步不过就中止,不允许“先打上再说”。
第一步是版本识别。不靠文件名,靠目标模块里一段稳定字符串或版本资源的哈希值,识别出这是哪个版本,再去匹配对应的补丁描述集合。版本对不上直接拒绝执行,这是最省事的一道闸。第二步是预演。所有特征码先在内存里扫一遍,把命中数量和命中地址全部打印出来,命中数量不等于 1 的一律不写。这一步能挡掉绝大多数误命中,成本几乎为零。第三步才是写入,写入后立刻回读目标位置,比对是否等于预期的新字节,不等就报错。第四步是结果校验,动态补丁校验进程是否仍在运行且行为符合预期,静态补丁校验输出文件的 PE 结构是否仍然合法。
把这四步固化成代码骨架,核心就是一个带返回值的结果对象,每步返回Success、Message、Detail三个字段,任一步Success为 false 就短路。下面这段是流程编排的示意,重点是短路和日志,不是具体实现。
public class PatchResult { public bool Success { get; set; } public string Message { get; set; } // 面向用户的结论 public string Detail { get; set; } // 面向排查的细节,如命中地址 } public static PatchResult RunPatch(PatchContext ctx) { // 第一步:版本识别,不匹配直接退出 var ver = IdentifyVersion(ctx); if (!ver.Success) return ver; // 第二步:预演扫描,命中数必须严格等于 1 var hits = ScanAll(ctx, ver.Detail); if (hits.Count != 1) return new PatchResult { Success = false, Message = $"特征码命中 {hits.Count} 处,已中止", Detail = string.Join(",", hits.Select(h => $"0x{h:X}")) }; // 第三步:写入并回读校验 var write = WriteAndVerify(ctx, hits[0]); if (!write.Success) return write; // 第四步:结果校验,静态看 PE 合法性,动态看进程存活 return Validate(ctx, write.Detail); }这段骨架的价值不在代码本身,而在它强制你把“命中数量必须为 1”“写入后必须回读”“失败必须短路”这三条变成不可绕过的规则。参数上,PatchContext里应该带上目标路径或进程 ID、补丁描述集合、是否静态模式、日志输出回调,这几项齐了,同一套编排能同时服务静态和动态两种模式。
还有一个容易被低估的技巧:把每次补丁的命中地址、原始字节、写入字节全部落一条日志。目标程序升级导致特征码失效时,这条日志就是唯一的线索,能让你快速看出是新版本里这段代码被优化掉了,还是前面的指令长度变了导致偏移漂移。从那以后我每次做补丁工具,都会在写入前强制走一遍预演扫描加日志落盘,宁可多花两秒,也不接受“打上了但不知道打在哪”。希望这套结构帮到你,少走几段我踩过的弯路。
本文还有配套的精品资源,点击获取