资讯动态

C# EXE内存加密壳源码:原理、实现与避坑指南

发布时间:2026/10/20 21:05:40 来源:尧图企业网站定制
简介一套基于C#的EXE文件加密器完整源代码面向需要实现可执行文件加密与运行限制的Windows开发者。该工具支持先设置加密密码并选择目标EXE再通过高级设置指定加密程序的运行时间或次数点击加密后即可生效可有效保护程序不被随意分发。压缩包共54个文件大小约135KB以17个cs源码为主同时包含6个exe、resx资源、txt说明与doc使用手册其中FormalityEncryet为加密器主项目DispelFormality为配套演示程序。下载后可直接编译并运行两个项目也能对照源码学习密码校验、运行时限/次数判定、文件读取改写等核心逻辑适合有一定C#基础的开发者参考、调试或二次扩展。目前已有268人学习下载。1. EXE 文件加密器一份用 C# 写内存加密壳的源码包能解决什么如果你是 C# 开发者应该经历过这种尴尬自己写的工具分发出去被人用 ILSpy 或 dnSpy 直接打开所有业务逻辑像没穿衣服一样摊在面前。EXE 文件加密器这个 C# 源码包解决的就是这个问题——它不修改原始程序的业务逻辑而是把托管 EXE 整个加密成密文嵌入另一个宿主程序运行时在内存里解密再反射加载。适用于分发内部工具、共享 Demo、防普通网友逆向等场景。它不是商用的加壳软件定位是“花一天时间就能读完源码、改成自己形状”的学习与实用型加密器。下面我从原理、源码、实操到踩坑逐一拆开。2. 加密托管 EXE 的原理与选型为什么要绕开直接改文件字节2.1 从 PE 结构到 CLR 元数据加密器要操作的到底是什么一个用 C# 编译出来的 .exe本质是一个 PE 文件。PE30 头里有 CLR 目录指向托管元数据和方法体 IL。IL 是明文存在的所以 dnSpy 能通过解析元数据表把方法体还原成近似源码的 IL 或伪 C#。要防这一步思路有两条一种是改方法体把 IL 加密运行时再解密回内存这就是动态加壳工具做的另一种简单粗暴把整个程序集当作一个普通二进制文件加密起来再用一个宿主程序去加载它。这份源码走的是第二条路因为它不碰 PE 布局细节只要能正常加载程序集的 API 就能完成开发成本低很多。不过第二条路有个前提目标程序集必须是纯托管的。如果 exe 里混了非托管代码或原生 C DLL这个方案就失效了因为 Assembly.Load 只认 CLR 程序集。所以这份源码最合适的使用对象是 C# 生成的纯托管 EXE以及由它引用的托管 DLL。对于 C 写的原生 EXE虽然理论上可以在内存里解析 PE、做重定位、构造进程入口但那个工作量已经是一个小型 PE Loader 了不是这个源码包要负责的事。从操作对象看加密器要处理的就是“原始 exe 的所有字节”。用 File.ReadAllBytes 读进来AES 加密再把密文作为资源写进宿主。加载时宿主从资源里读密文解密成原始字节然后调用 Assembly.Load(byte[]) 把程序集加载到当前运行时。这个 API 会完全在内存里解析元数据不会主动在磁盘上释放原文件所以解密后的明文不会天然落盘。当然CLR 运行时仍然会把程序集映射到内存工作集后面会提到怎么验证“内存里有没有明文”。2.2 AES 还是 XOR轻量加壳场景下的加密算法选型选加密算法是个权衡问题。XOR 加密写出来就几行for (int i 0; i data.Length; i) data[i] ^ key[i % key.Length];简单、极快但它对已知明文攻击没有防御。只要用户手里拿到宿主和密文再知道密文开头几个字节是 MZ 头密钥立刻能被推出来。所以我这个源码包里用的是 AES-CBC密钥 32 字节IV 16 字节不用“拿一段字符串直接当 key”的偷懒写法。看一下核心加密函数private static byte[] AesEncrypt(byte[] plain, byte[] key, byte[] iv) { using var aes Aes.Create(); aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using var encryptor aes.CreateEncryptor(); using var ms new MemoryStream(); using var cs new CryptoStream(ms, encryptor, CryptoStreamMode.Write); cs.Write(plain, 0, plain.Length); cs.FlushFinalBlock(); return ms.ToArray(); }逻辑说明Aes.Create() 返回一个默认的 AES 实现CBC 需要设置 IV。CryptoStream 把明文流式加密FlushFinalBlock 会补满最后一个块并输出最终密文。解密就是 CreateDecryptor 对称操作代码几乎一模一样只是把 CreateEncryptor 换成 CreateDecryptor。参数说明key 必须恰好 32 字节对应 AES-256iv 必须恰好 16 字节CBC 模式下的块大小。源码里这两个参数由加密端命令行传入宿主端则写在配置类里。因为宿主本身不会被再次加密所以它只承担“搬运工”的角色密钥安全要靠在部署环节控制。实际使用中我会让 key 尽量随机不要所有人用同一套默认值。既然是选型顺便说一句为什么不选 GCM。CBC 需要手动处理填充和对齐但代码直白GCM 需要额外管理 nonce 和 tag对命令行工具来说参数更多加密一个文件也显示不出性能优势。这份源码保持 CBC主要是让别人读源码时能更快看清数据流。如果只是保护自己分发的小工具CBC 已经够用。2.3 运行时装配Assembly.Load 与入口点反射调用解密出来是原始字节怎么执行它Assembly.Load(byte[]) 是核心。它会从托管映像创建一个程序集对象但不会立刻执行。需要找到入口点反射调用。入口点的定义在原始程序集里以 entry point token 标记在 CLR 头中Assembly.EntryPoint 属性可以直接拿到 MethodInfo。这个过程可以写成这样var rawAssembly AesDecrypt(GetCipherBytes(), key, iv); using var loaded Assembly.Load(rawAssembly); var entry loaded.EntryPoint; var args Environment.GetCommandLineArgs()?.Skip(1).ToArray() ?? Array.Emptystring(); object result null; if (entry.GetParameters().Length 0) result entry.Invoke(null, new object[] { args }); else result entry.Invoke(null, null); if (result is int exitCode) Environment.ExitCode exitCode;逻辑说明Assembly.Load 把解密后的字节数组加载进当前上下文EntryPoint 返回入口方法判断参数个数决定是否传 argsWinForms 和 WPF 的入口通常带 STAThreadInvoke 会同步执行并在该方法返回后退出。这里还有一个细节原始 exe 的 Main 可能是 async 的Invoke 拿到的是 Task 而不是 int后面避坑章节会单独讲。参数说明Environment.GetCommandLineArgs() 第一位是当前宿主 exe 路径所以 Skip(1) 去掉剩下的传给原 Main。如果原程序期待string[] args这里传的数组正好对得上。如果入口方法是无参的Invoke 传 null 就行。需要留意的是Invoke 的第二个参数 null 和空数组等效但建议显式写 null避免误导后来人。不过这里藏着一个关键依赖问题原始程序引用了一些托管 DLL这些 DLL 不会因为你加密了主 exe 就自动跟过来。CLR 在加载依赖时默认按宿主进程的探测路径找找不到就抛 FileNotFoundException。源码包里对这种情况的处理是把依赖 DLL 也读入密文资源然后在宿主启动时注册 AppDomain.CurrentDomain.AssemblyResolve 事件等到 CLR 解析不出依赖时从资源里解密加载。这个事件处理函数是这类内存型加载器的生命线下面源码章节会看到它的具体写法。3. 源码结构拆解加密端与宿主端的分工和关键实现3.1 加密端流程读取、加密、资源嵌入这个源码包分成两个可执行项目和一个小型公共类库。公共类库放 AES 加解密和资源对象序列化加密端负责接收要加密的 exe 和附加依赖最终生成一个“被加密后的发布 EXE”宿主端就是生成的发布 EXE运行后自动解密并加载原始程序。加密端的主流程可以抽象成三步读取目标文件AES 加密把密文打包成一个资源文件并嵌入新的宿主程序。嵌入方式我采用“宿主模板加替换”的做法项目里先有一个宿主模板 exe通过一个小的 Installer API 把名为Config.bin的资源替换为新内容。资源名称怎么定是这份源码约定好的契约如果你要改资源名两端必须同步改。加密端命令行处理片段static int Main(string[] args) { var input GetArg(args, --input); var output GetArg(args, --output); var key GetArg(args, --key); var iv GetArg(args, --iv); if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(output) || string.IsNullOrEmpty(key)) { Console.WriteLine(usage: ExePacker.exe --input exe --output packed.exe --key 32byte hex [--iv 16byte hex]); return 2; } return EncryptAndRebuild(input, output, HexToBytes(key), HexToBytes(iv)); }逻辑说明这里用 GetArg 取出命令行参数key 允许传 64 个十六进制字符iv 允许 32 个缺参数就打印 usage。EncryptAndRebuild 内部会调用 File.ReadAllBytes、AesEncrypt然后把密文写进临时资源目录再调用宿主项目的 Installer 完成资源替换。参数说明--key 和 --iv 都是十六进制字符串代码里 HexToBytes 会把“00112233”这样的字符串转成字节数组。这里不推荐直接用明文口令因为 AES 需要确定长度的二进制密钥口令字转字节容易产生长度不可控和安全强度不足的问题。实际工程里我会先用一个随机工具生成密钥再写进部署配置。加密端另外还有一个--include参数用来指定要一起打包的依赖 DLLExePacker.exe --input app.exe --output packed.exe --key ... --iv ... --include helper.dll,lib/config.dll这个参数会遍历逗号分隔的路径把每个文件加密后追加到同一个密文包宿主端通过清单识别文件名和长度。这样做的主要原因是原始 exe 引用的第三方库如果不一起放进去加载几乎必然失败而把外部 DLL 交给用户自己放又会留下第三方明文程序集。打包进去之后依赖 DLL 也在内存里被解密加载主程序集的反编译难度就大大提高了。3.2 宿主端加载器解密与程序集绑定宿主端的核心是PackedHost.Program.Main它做的事按顺序是从当前自身程序集的资源流读取密文使用同样的 AES 密钥解密注册 AssemblyResolve加载主程序集反射调用入口点。我们先看资源读取private static byte[] ReadResource(string resourceName) { var asm Assembly.GetExecutingAssembly(); using var stream asm.GetManifestResourceStream(resourceName); if (stream null) throw new InvalidOperationException($resource {resourceName} not found); using var ms new MemoryStream(); stream.CopyTo(ms); return ms.ToArray(); }逻辑说明GetManifestResourceStream 返回嵌入资源的读取流对宿主自身有效。资源名是加密端写入时约定的比如PackedHost.Config.cipher。如果找不到资源说明发布的产物没有正确替换资源多半是加密端和宿主端的资源名大小写不一致。资源名是强命名的完整名称大小写敏感这一点很多人忽略。依赖解析的完整片段会用到 AssemblyResolveprivate static Assembly ResolveDependency(object sender, ResolveEventArgs args) { var name new AssemblyName(args.Name).Name; if (dependencyFiles.TryGetValue(name, out var dllBytes)) { var decrypted AesDecrypt(dllBytes, key, iv); return Assembly.Load(decrypted); } return null; }逻辑说明CLR 在遇到解析不了的程序集时触发这个事件参数 args.Name 是“完整名”而不是短名这里用 AssemblyName(…).Name 只取主文件名方便查表。dependencyFiles 是从密文包里拆出来的一个 Dictionarystring, byte[]键是原始文件名去掉扩展名。如果事件处理不返回程序集CLR 才会抛 FileNotFoundException。很多内存加载器翻车就是漏了return null事件返回 null 是允许的但必须显式写出来否则方法返回空程序集会被继续追问。参数说明key 和 iv 在这里直接使用宿主启动时从配置类读出的字段。如果原始依赖很多事件里每次都解密一次性能影响不大但要注意内存回收。我们通常把解密后的 Assembly 缓存起来用 ConcurrentDictionary 存一下避免同一个程序集被 Load 两次尤其是多个依赖相互引用时。3.3 入口点定位处理 token 与参数Assembly.EntryPoint 已经够用但实际里有边缘情况。比如原始程序是 WinFormsMain 方法标记了 STAThreadAttribute在宿主里 Invoke 之前你最好显式设置宿主线程的 ApartmentState。又比如原始程序里用了环境变量、当前目录这些都会沿袭宿主进程的上下文需要注意。入口点调用的代码方面有一个比较靠谱的写法是直接看入口方法参数public static object InvokeEntry(Assembly asm) { var entry asm.EntryPoint; var ps entry.GetParameters(); var args GetPassthroughArgs(ps); return entry.Invoke(null, args); }参数说明GetPassthroughArgs 根据入口方法的参数个数返回对象数组——无参返回 null一个 string[] 参数返回命令行参数数组两个参数的情况在托管入口里极其少见基本遇不到。Invoke 的第一个参数是实例对象Main 是静态方法所以 null 是标准做法。入口点 token 和参数类型都确定了剩下就是等待方法返回。如果入口方法直接调用 Environment.Exit宿主代码后面的清理逻辑不会执行。这点要格外小心一旦原程序调用了 Environment.Exit反射调用就会终止当前进程你写在 Invoke 之后的任何代码都跑不到。所以宿主里不需要对入口返回后的清理做太多幻想启动前完事。4. 把源码跑通加密一个 Demo EXE 的完整步骤4.1 准备测试程序与项目引用先做一个最简单的 Demo。用 .NET Framework 的 csc 命令编译一个纯托管 exe这样它能被 Assembly.Load 直接加载不会遇到 .NET Core AppHost 那种“exe 只是引导程序”的问题。using System; class Program { static int Main(string[] args) { Console.WriteLine(hello from demo, arg count args.Length); for (int i 0; i args.Length; i) { Console.WriteLine(i : args[i]); } return 42; } }编译命令csc /target:exe /out:Demo.exe Demo.cs逻辑说明Demo.cs 里只有一个 Main返回 int方便后面验证退出码。csc 模式下生成的 Demo.exe 是一个标准托管程序集用 dnSpy 可以直接看到源码级结构。这个文件就是我们要加密的目标。参数说明/target:exe 表示生成 Win32 控制台可执行文件/out 指定输出路径。如果你本机装了 Visual Studio 或 .NET Framework SDK直接在开发者命令行里跑这条就行。没有 csc 也可以用代码编辑器配合 dotnet 工程发布一个 net48 的 exe但 csc 是最简路径。4.2 在命令行模式下加密目标 EXE把源码包里的加密端编译成 ExePacker.exe然后执行ExePacker.exe --input Demo.exe --output DemoPack.exe --key 0123456789abcdef0123456789abcdef --iv 00112233445566778899aabbccddeeff运行后应看到一段输出说明加密完成。生成的 DemoPack.exe 就是最终的发布文件。这时候你可以用 dnSpy 打开 DemoPack.exe会发现它只有一个宿主类根本看不到原始的 Main 方法。原始代码全部被藏进资源里而且资源内容是密文。接下来运行加密后的 exeDemoPack.exe hello world正常情况会输出hello from demo, arg count2 0: hello 1: world逻辑说明宿主启动后读取自身资源解密得到 Demo.exe 的字节再 Assembly.Load 并 Invoke 入口。因为原 Main 接受 string[] 参数所以这里传入了宿主进程的命令行参数旧的调参方式完全被保留。返回值 42 会作为宿主进程的退出码。参数说明--key 是 64 个十六进制字符对应 32 字节密钥--iv 是 32 个十六进制字符对应 16 字节 IV。命令行里直接写密钥方便测试正式部署时建议改成从环境变量或配置文件读取避免出现在命令历史里。4.3 验证加密产物IL 可见性与运行行为验证分两步静态看 IL动态看运行行为。静态验证最简单的方式就是用 IL 工具打开 DemoPack.exe搜索“hello from demo”这类字符串。如果搜不到原始字符串说明密文资源没被直接解析如果搜得到说明资源替换失败或者用了 Base64 字符串存储。要注意的是宿主自身也可能包含类似的反射代码但不会包含原始业务字符串。这一步只是低级验证不等于绝对安全因为内存里动态解密后字符串仍然会出现。动态验证是跑一遍原始功能确认输出和退出码一致。我这里会用脚本批量跑几组参数DemoPack.exe DemoPack.exe a b c DemoPack.exe --help每次进程退出码都应符合预期。如果原程序是 GUI就用一个自动化脚本启动进程后等待窗口句柄或退出。这种验证能捕捉大部分加载失败、参数错误和程序集依赖问题。如果验证过程中出现“找不到程序集”或“无法加载”不要急着改业务代码先检查你有没有把引用的 DLL 一起加密进去。这个源码包默认只加密--input指定的 exe所有额外依赖都必须通过--include显式带入。依赖 DLL 没带是最常见的启动失败原因。5. 避坑与排查五条真实翻车记录建议逐条对照5.1 现象加密后弹“未能加载文件或程序集”这个错误几乎每个人都遇到过。具体表现是加密后的宿主能启动但刚进 Assembly.Load 或依赖程序集访问时CLR 抛 FileNotFoundException。原因最常见的有两个。第一个是最开始把 .NET Core 的 AppHost exe 当作托管程序集拿来加密。AppHost.exe 只是引导器真正代码在 dll 里Assembly.Load 根本不敢加载它。第二个是真托管 exe但原始程序引用了第三方 DLL你只加密了主 exe没有把这些 DLL 一起打包。解决先确认目标 exe 能被 dnSpy 直接打开如果打不开说明它不是托管程序集改用同一目录下的 dll 作为加密目标。然后清点原始程序集引用的所有依赖用--include把它们全部加密进密文包。宿主端的 AssemblyResolve 事件里必须有对应名单否则事件返回 nullCLR 继续走默认探测路径照样找不到。5.2 现象加密后程序运行第一次正常第二次闪退这类问题比较隐蔽。第一次运行正常是因为同一个进程内能加载依赖第二次闪退常见原因是宿主每次启动都从资源里重新解密并 Assembly.Load而某些运行时把同一个标识的程序集卸载或覆盖后出现了内部状态冲突。还有一个常见来源入口方法返回类型是 Task。你在宿主里拿到 Task 后没有等待直接让 Main 返回进程在后台 Task 还没跑完就被杀掉了。第一次正常大概率是巧合后续被回收就闪退。解决把入口调用改成对 Task 容错。var invokeResult entry.Invoke(null, new object[] { args }); if (invokeResult is Task t) { t.GetAwaiter().GetResult(); }逻辑说明如果 Main 是 async TaskinvokeResult 会是一个 Task 对象GetAwaiter().GetResult() 会阻塞当前线程直到异步方法跑完。这样能防止宿主主线程提前退出。如果返回值是 Task 则还要取 Result 再映射退出码。5.3 现象杀毒软件把加密后的宿主 exe 标记为可疑这大概是所有加壳工具都会撞上的。行为太像已知的“内存加载器”程序从资源中解密一段字节、在内存中创建程序集、然后反射调用。杀软不一定判定为病毒但行为特征足以触发启发式拦截。原因在于这种加密方式本质上就是让杀软眼里的程序“在内存里跑不明代码”。如果你给内部工具做数字签名信任度会有所提升但并不能根除误报。如果是公网分发误报概率会明显偏高。解决第一宿主项目用 Release 编译去掉调试符号避免出现怪异节区第二给产物做代码签名虽然不能消除行为检测但能从信誉维度加分第三不要把这种内存加载器用于对抗恶意软件检测它本来就不是干这个的。内部工具或者给特定网络的机器用是最舒服的。5.4 现象入口方法 Invoke 抛 MethodAccessExceptionMethodAccessException 通常意味着调用方没有权限访问目标方法。但托管 Main 通常是 public static不应该有这个权限问题。实际遇到时十有八九是原始程序集有强名称签名而宿主没有加载到同样的签名上下文。解决最快捷的办法是去掉原始程序集的强名称签名重新编译目标 exe。如果你不能改程序集那就在宿主加载原始程序集时尝试用Assembly.Load(rawBytes)之外的方式或者通过自定义 AssemblyLoadContext 来绕过签名验证。大多数场景下改原始程序集更简单。5.5 现象加密后的 exe 体积异常膨胀如果加密产物比原文件大了一倍不止先怀疑资源存储格式。源码里如果为了调试好看把密文写成 Base64 字符串再当作资源编译体积会膨胀至少 33%而且运行时还要多一次 Base64 解码性能也亏。解决确认加密端写入宿主资源时使用的是二进制流而不是字符串。资源在 .NET 项目里可以以 .binary 形式嵌入Build Action 设为 Embedded Resource。检查方式很简单用 zip 工具打开宿主 exe查看 Resources 段里的密文资源应该是接近原文件大小的随机字节。如果看到一堆 ASCII 字符说明踩了这个坑。还有一个体积膨胀原因是宿主模板引用了大量程序集比如误引用了一整套 UI 框架。实际发布时可以把宿主模板做成独立的精简项目只引用基础类库这样最终产物体积会更接近原文件加密钥开销。6. 进阶技巧把密钥藏起来让内存 dump 也难破前面的基础版把密钥直接写在宿主的配置类里用 strings 一扫就能看到十六进制串这肯定不够。进阶做法是把密钥拆成三份一份写在宿主的资源文件里一份放在环境变量里还有一份由运行时从机器指纹派生。启动时三份叠加组合成真正的 32 字节密钥。拆分的思路是资源里存前 16 字节环境变量里存中间 8 字节机器指纹派生最后 8 字节。代码里大致是这样var part1 ReadResource(KeyPart1.bin); var part2 Environment.GetEnvironmentVariable(DEMO_PACK_KEY); var part3 DeriveMachinePart(); byte[] key new byte[32]; Array.Copy(part1, 0, key, 0, 16); Array.Copy(part2, 0, key, 16, 8); Array.Copy(part3, 0, key, 24, 8);逻辑说明这样即便有人 dump 出宿主资源也只拿到一半密钥机器指纹部分每台机器都不同换机器后需要重新生成带机器指纹的配置所以它不适合公共分发但很适合企业内部固定终端。环境变量部分则方便你在部署时按批次更换不需要重新编译宿主。参数说明part2 从环境变量取出的是字节还是字符串要约定好我一般会取十六进制字符串再 HexToBytes这样环境变量可读性好一点。环境变量缺失时程序要报错退出不要悄悄降级成弱密钥否则前功尽弃。内存 dump 层面还有一个值得做的验证加密后的宿主运行时原始程序集已经被解密到内存所以用调试器附加进程扫一下堆内存还是可能找到明文。要真正提高抗 dump 能力可以在解密加载后把原始字节数组清掉减少明文在托管堆里的生命周期var raw AesDecrypt(cipher, key, iv); var asm Assembly.Load(raw); Array.Clear(raw, 0, raw.Length);逻辑说明Assembly.Load 完成之后原始字节数组已经不需要了Array.Clear 把托管堆里的数组内容打成零避免 GC 不回收时保留明文。这一步并不能杜绝所有 dump高级工具照样从程序集映像里拷贝已经 JIT 过的代码但至少能让随手一搜 strings 找不到明文。我最后一次用这套流程是去年发布一个给内部运营用的报表工具当时把密钥明文写在常量里结果被人用字符串扫描直接还原了原始 exe。从那以后我每次加密都会强制走一遍“密钥随机化 机器指纹 运行后清空明文”的习惯。希望这份源码和踩坑记录能帮你在加密路上少走几段冤枉路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑