C# EXE加密器源码解析:用AES和自解压壳保护你的程序

发布时间:2026/10/20 18:02:32
C# EXE加密器源码解析:用AES和自解压壳保护你的程序
简介这是一份采用C#编写的EXE文件加密器完整源码适合有基础Windows窗体开发经验的程序员学习软件授权与文件保护思路。资源围绕“加密设置 高级限制”两条主线可指定被保护EXE并设置密码同时按运行时间或次数约束启动完整演示了从配置界面到加密生效的闭环流程。压缩包共54个文件以cs源码、resx界面资源、exe可执行程序、sln/csproj工程文件为主并附doc使用说明整体仅135KB代码量精炼便于逐模块阅读。内容包含FormalityEncryet与DispelFormality两个项目前者实现加密配置后者作为被加密示例程序可对照观察加密前后运行限制效果。已有268人学习下载对想了解C#程序授权控制、文件加密或试用期限制的开发者具有一定参考价值。1. 为什么这个 C# EXE 加密器值得拆开看加密一个程序只要两百行代码你下载过不少带密码的小工具双击先提示输入口令输对了才弹主界面。这种体验大多不是原工程做的而是外面套了一层壳。这份 C# 源码做的正是这件事用 WinForms 写加密界面把目标 EXE 读进来做 AES 加密再把密文拼到一个自解压壳的尾部壳启动后用相同密钥解密在临时目录把原程序拉起来全程约两百行可读代码。它的定位很实在防的不是逆向高手而是防你拷贝一个 EXE 就能改图标、改资源、改逻辑。适合想拿加密 API 练手的开发者也适合发内部分发包之前需要加一道包装的工程负责人。2. 架构设计加密器、自解压壳和公共协议的配合方式2.1 解决方案里三个工程怎么分工常见做法是把项目拆成两个可执行工程加一个公共库道理和大多数客户端工具一样加密端和运行端不共享状态唯一共享的东西是“文件协议”。如果协议定义只在某一端另一头跟着自己的理解去解析等你真加密出一个文件才发现壳解不开那可是最冤的翻车。这个源码的工程结构大概是EXEGuard.sln ├── src/ │ ├── EncryptorUI/ // WinForms 加密器界面 │ ├── RuntimeShell/ // 运行时自解压壳工程 │ └── ProtocolShared/ // 魔数、头结构、KDF 等公共常量 └── docs/ // 协议说明文档三个工程各干各的EncryptorUI 负责选文件、输密码、生成壳RuntimeShell 是最终给用户双击的那个文件负责自定位、解密、拉起目标进程ProtocolShared 放两端的公共约定比如魔数字符串、版本号、PBKDF2 派生函数。这样划分之后你想换个界面只管改 UI 工程想换加密算法只动 Shell 和 Shared。动手创建工程时可以用这几条命令快速搭出骨架dotnet new sln -n EXEGuard dotnet new winforms -n EncryptorUI -o src/EncryptorUI dotnet new console -n RuntimeShell -o src/RuntimeShell dotnet new classlib -n ProtocolShared -o src/ProtocolSharedRuntimeShell 默认是控制台工程直接运行会闪一个黑框需要把输出类型改成 WinExePropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet8.0/TargetFramework /PropertyGroup目标框架按你本机装的 SDK 来net6.0 或 net8.0 都行源码层面没有用到太新的语言特性老版本也能编译过。2.2 加密文件格式尾部协议的字段布局壳和加密器能配合靠的是一个任何人都能解析的二进制布局。我习惯把它叫“尾部协议”因为输出壳本身是一个正常的 PE 文件密文不是塞在资源段里而是追加到文件末尾。加密器读完目标 EXE 后先写一个固定大小的元数据块再把密文跟着写进去。文件头字段如下字段大小字节说明魔数 EXGUARD18让壳在文件尾部快速定位载荷版本号4当前为 1给协议改动留余地盐 Salt16PBKDF2 随机盐IV16AES 初始化向量加密前数据长度8解密后应得的数据长度原始文件名长度4原 EXE 文件名 UTF-8 字节数原始文件名变长用于临时文件还原加密载荷变长AES-CBC 密文之所以保存原始文件名是因为不少 EXE 在运行时会通过自身路径或程序集名来定位配置文件、读取同目录资源。解密后的临时文件尽量沿用原文件名能解决相当一部分“加密完打不开”的怪问题。加密前数据长度和文件名都以明确长度写入是因为载荷后面不再有终止标记解析器必须知道在哪精确停住。2.3 密码在两条链路里怎么流转加密器拿到用户输入的密码用随机盐跑 PBKDF2 生成 256 位密钥盐是随机数IV 也是随机数密钥本身不会落盘只有盐和 IV 会写进文件头。RuntimeShell 启动时同样让使用者输入同一串密码再用文件头里的盐做一次完全相同的派生得到同一个密钥。这就是整个壳能解密的根基两端不约而同跑同一个 KDF而不是把密钥写死在壳里。直接把密钥硬编码进壳确实省事但你要明白一点壳是 PE 文件能被反编译工具扫出字符串常量硬编码等于给懂行的人留后门。这个项目采用提示输入或命令行传参的方式两端都要提供密码代价是每次运行多敲一次。需要开机自启或无人值守的场景就得考虑在壳外面接一个配置文件或参数缓存逻辑第六章会聊到这个改造。3. 核心加密模块拆解从密码到密文文件的三个函数3.1 PBKDF2 密钥派生Rfc2898DeriveBytes 参数怎么选加密的第一步不是直接拿密码当密钥而是先把密码变成足够随机的 256 位密钥。源码里用了 .NET 自带的Rfc2898DeriveBytes也就是 PBKDF2 标准实现private static byte[] DeriveKey(string password, byte[] salt) { using var kdf new Rfc2898DeriveBytes( password, salt, 600_000, HashAlgorithmName.SHA256); return kdf.GetBytes(32); // AES-256 需要 32 字节密钥 }几个参数值得解释。第一个是密码原文第二个是 16 字节随机盐第三个迭代次数设成 60 万次最后指定 HMAC-SHA256。迭代次数决定暴力破解的成本普通机器跑一次大约 100 到 300 毫秒用户几乎无感但攻击者想逐条猜测密码每猜一次都要付出同样代价整体压力会大很多。需要注意早期 .NET 版本的Rfc2898DeriveBytes默认用 SHA1显式传HashAlgorithmName.SHA256是更稳妥的做法不然算法强度会悄悄降档。两端代码库必须保持相同参数盐和迭代次数一样派生出来的密钥才一致。3.2 加密主流程读入、压缩、加密、写头加密器把所有步骤收敛成一个函数输入是目标 EXE 路径和密码输出是一段完整的“加密载荷块”等会儿会追加到壳模板后面public static byte[] BuildPayloadBlock(string inputExe, string password, bool compress) { byte[] raw File.ReadAllBytes(inputExe); if (compress) raw DeflateCompress(raw); byte[] salt RandomNumberGenerator.GetBytes(16); byte[] iv RandomNumberGenerator.GetBytes(16); byte[] key DeriveKey(password, salt); string originalName Path.GetFileName(inputExe); byte[] nameBytes Encoding.UTF8.GetBytes(originalName); using var block new MemoryStream(); using (var bw new BinaryWriter(block, Encoding.UTF8, leaveOpen: true)) { bw.Write(EXGUARD1u8.ToArray()); // 魔数固定 8 字节 bw.Write(1); // 协议版本 bw.Write(salt); // 16 字节盐 bw.Write(iv); // 16 字节 IV bw.Write((long)raw.Length); // 加密前数据长度 bw.Write(nameBytes.Length); // 原文件名长度 bw.Write(nameBytes); // 原文件名 bw.Flush(); // 先落盘头确保顺序正确 } using (var aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using var crypto new CryptoStream(block, aes.CreateEncryptor(), CryptoStreamMode.Write); crypto.Write(raw, 0, raw.Length); } return block.ToArray(); }整个流程的顺序很重要先写头部元数据再写密文。File.ReadAllBytes一次性把整个 EXE 读进内存对绝大多数桌面程序没问题如果哪天要加密上百兆的安装包就得改成流式分块读避免内存峰值过高。RandomNumberGenerator.GetBytes是 .NET 推荐的安全随机数来源比new Random()可靠得多。DeflateCompress内部用DeflateStream做无损压缩private static byte[] DeflateCompress(byte[] input) { using var ms new MemoryStream(); using (var ds new DeflateStream(ms, CompressionLevel.Optimal, leaveOpen: true)) { ds.Write(input, 0, input.Length); } return ms.ToArray(); }压缩对体积有明显帮助普通 .NET 程序能压掉三分之一甚至一半。但代价是壳运行时要先解压多花几百毫秒而且压缩流特征明显更容易触发杀软启发式扫描。所以源码把这个行为做成布尔参数而不是写死发布时按场景取舍。3.3 壳文件装配模板 EXE 尾部追加载荷的代价和好处加密载荷块生成之后跟壳模板合并就非常简单public static void AssembleShell(string outputPath, string templatePath, byte[] payloadBlock) { File.Copy(templatePath, outputPath, overwrite: true); using var fs new FileStream(outputPath, FileMode.Append, FileAccess.Write); fs.Write(payloadBlock, 0, payloadBlock.Length); }先把预编译好的 RuntimeShell.exe 复制为目标文件再把载荷块追加到文件尾。这在 PE 格式里完全合法Windows 加载器只解析到最后一个节表结束的位置尾部多余数据不会影响运行。好处很直接壳模板可以单独重新编译升级加密数据不用跟着改两头开发互不阻塞。代价主要出现在数字签名场景。如果壳模板本身带签名追加数据会让签名校验失败因为签名计算的是整个文件的哈希。这种情况只能把载荷放进 PE 资源段或者在签名之后再做追加并做好白名单签名策略。源码里走简单路线适合绝大多数内部分发场景。4. 运行时解包模块从双击到启动的完整链路4.1 自定位在自身字节里找魔数RuntimeShell 启动后第一个任务不是解密而是先搞清楚“我的密文在哪”。因为密文是追加在文件尾部的壳程序会把自己整个读进内存然后从末尾往前找魔数private static int FindPayloadIndex(byte[] fileBytes) { byte[] magicBytes Encoding.ASCII.GetBytes(EXGUARD1); for (int i fileBytes.Length - magicBytes.Length; i 0; i--) { bool matched true; for (int j 0; j magicBytes.Length; j) { if (fileBytes[i j] ! magicBytes[j]) { matched false; break; } } if (matched) return i; } return -1; }从后往前扫有两个原因。第一载荷追加在尾部理论上最后一次出现的魔数就是真正入口第二壳本身的 PE 数据或字符串池里也可能碰巧存在“EXGUARD1”这 8 个字节如果从头开始找很容易定位到错误位置。找到索引后后面所有的头字段解析都基于这个偏移量开始索引为 -1 就说明这文件根本没加过壳。4.2 解析头字段并解密魔数定位之后按第二章的表逐个字段拆解。这里不用BinaryReader而是直接用偏移量读取逻辑更直白也方便新手对着字段表一行行核对private static (byte[] data, string originalName) DecryptPayload( byte[] fileBytes, int start, string password) { int offset start 8; // 跳过魔数 byte[] salt fileBytes.AsSpan(offset, 16).ToArray(); offset 16; byte[] iv fileBytes.AsSpan(offset, 16).ToArray(); offset 16; long rawLen BitConverter.ToInt64(fileBytes, offset); offset 8; int nameLen BitConverter.ToInt32(fileBytes, offset); offset 4; string originalName Encoding.UTF8.GetString(fileBytes, offset, nameLen); offset nameLen; int cipherLen fileBytes.Length - offset; byte[] key DeriveKey(password, salt); using var aes Aes.Create(); aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using var outMs new MemoryStream(); using (var crypto new CryptoStream(outMs, aes.CreateDecryptor(), CryptoStreamMode.Write)) { crypto.Write(fileBytes, offset, cipherLen); } byte[] plain outMs.ToArray(); if (plain.Length ! rawLen) throw new InvalidDataException(解密长度和头字段不一致密码可能错误); return (plain, originalName); }每一步偏移都对应头区的一个字段拿到盐和 IV 后重新派生密钥。密码错误时PKCS7 填充会在解密阶段抛CryptographicException或者解密结果长度对不上rawLen此时弹一个友好提示比直接静默崩溃强得多。4.3 解密到临时目录拉起目标进程再想尽办法清理拿到明文数据后壳的工作就是把它写进临时目录然后用Process.Start启动private static int RunCleartext(byte[] programBytes, string originalName, string[] forwardArgs) { string dir Path.Combine(Path.GetTempPath(), EXEGuardCache); Directory.CreateDirectory(dir); string ext Path.GetExtension(originalName).ToLowerInvariant(); string tempName ${Path.GetFileNameWithoutExtension(originalName)}_{Guid.NewGuid():N}{ext}; string tempPath Path.Combine(dir, tempName); File.WriteAllBytes(tempPath, programBytes); File.SetAttributes(tempPath, FileAttributes.Temporary); var psi new ProcessStartInfo { FileName tempPath, WorkingDirectory dir, UseShellExecute true }; foreach (string arg in forwardArgs) psi.ArgumentList.Add(arg); using Process? proc Process.Start(psi); proc?.WaitForExit(); TryDelete(tempPath); return proc?.ExitCode ?? 1; }文件名前面加一段 GUID是为了避免多个实例同时启动互相覆盖。FileAttributes.Temporary是告诉系统这个文件很快会被删磁盘缓存可以优先回收。UseShellExecute true能避免额外弹出一个控制台窗口但代价是不能重定向标准输出如果目标程序是命令行工具且需要捕获输出就得改成UseShellExecute false并手动接管输出流。清理临时文件看着简单实际容易翻车private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch (IOException) { // 子进程还没完全退出文件被占用留到下次重启清理 } catch (UnauthorizedAccessException) { // 权限不足记录日志后跳过 } }如果目标程序启动了子进程就立即退出母进程可能还握着文件句柄立刻删除就会失败。更可靠的做法是调用MoveFileEx并传入延迟删除标志让系统在文件不再被占用时自动清理不过那部分代码会增加不少复杂度源码这块用 TryDelete 加静默容错已经够入门用。5. 避坑排查加壳项目里最常见的四个坑5.1 坑一输出壳被杀毒软件直接干掉现象加密器生成壳之后安全软件静默删除或隔离了这个文件你自己的开发机一运行就报毒。原因尾部追加密文的 PE 结构本身比较少见加上压缩流特征和缺数字签名很容易触发启发式扫描。更糟的是壳模板是固定工程编译出来的杀软库收录一个样本后面所有用同模板生成的壳都可能被连坐。解决先关掉压缩选项试试有时光是 Deflate 流特征就能让扫描引擎警觉。然后给壳模板补数字签名签上名后误报率会明显下降。最稳妥的是把载荷从文件尾挪到 PE 资源段结构更常规但解析代码要多写一组 Win32 资源 API。发布前用在线扫描做一次抽样确认没有误报再分发给用户。5.2 坑二双击后没有任何反应现象加密后的壳运行后既不弹主界面也不报错任务管理器里一闪而过看起来像黑匣子。原因最常见的是异常被静默吞掉。壳作为 WinExe 工程入口异常没有控制台输出如果FindPayloadIndex返回 -1或者解密抛异常进程直接退出。另一个常见原因是临时目录权限不足Directory.CreateDirectory没问题但File.WriteAllBytes被拦截。解决给壳加一个/debug命令行参数开启后把偏移索引、解密长度、临时文件路径写到日志文件。我在调试阶段就是这么干的定位问题的时间从半小时缩短到两分钟。另外在入口包一层 try-catch捕获后弹MessageBox显示具体异常信息发布时再关掉。5.3 坑三目标程序有自校验解密后无法运行现象明文 EXE 单独运行没问题但通过壳解密后一启动就崩溃或者闪退回桌面。原因不少程序会在启动时计算自身文件的 CRC、大小或版本信息跟编译期写入的基准值比对。壳在临时目录释放出的文件字节内容就是原始 EXE 本身可文件路径变了程序一查Assembly.GetExecutingAssembly().Location发现不是预期路径就认为被篡改。解决尽量沿用原始文件名释放不要随手改成tmp1234.exe能骗过一部分只查文件名的程序。但如果程序做了字节级自校验这套壳就无能为力因为最终运行的一定是明文原文件。遇到这种情况老老实实放弃加壳或者考虑改用虚拟化保护方案。5.4 坑四运行壳提示找不到 DLL 或配置文件现象目标程序明明和一堆 DLL、配置文件放在同一个目录单独双击运行正常套壳后却提示找不到某个模块。原因壳把解密后的文件放进临时目录工作目录也切到了临时目录原程序里所有相对路径的读取全部失效。这是最典型的血泪经验问题不在加密而在运行时环境被整体搬家。解决启动子进程前把ProcessStartInfo.WorkingDirectory设置为壳所在的原始目录并额外设置环境变量。如果目标程序依赖同目录资源也可以在释放明文后把原目录里的附属文件一并复制到临时目录但那样启动成本更高。我的习惯是优先还原工作目录只在确认程序必须和附属文件挨着时才做整目录复制。6. 二次开发指南把加密壳改成你自己的发布工具6.1 增加命令行参数透传现在的壳已经能把多余参数转发给目标程序但密码还不支持自动化。你可以加一组约定/pass后跟密码--之后的参数原样透传。解析逻辑写在一个方法里private static (string password, string[] forward) ParseArguments(string[] args) { string password string.Empty; Liststring forward new(); for (int i 0; i args.Length; i) { if (args[i] /pass i 1 args.Length) { password args[i]; } else if (args[i] --) { forward.AddRange(args.Skip(i 1)); break; } else { forward.Add(args[i]); } } return (password, forward.ToArray()); }这样远程调用或计划任务启动时可以在命令行里一次性带密码和业务参数不用每次手工输入适合无人值守场景。6.2 把 AES-CBC 升级成 AES-GCMCBC 模式加 PKCS7 填充在主流的 .NET 加密场景里没什么大毛病但如果你想把安全级别再推一步可以换AesGcm。GCM 是认证加密解密时能同时确认数据有没有被篡改错误提示也远比“填充无效”清晰。核心替换思路是byte[] nonce RandomNumberGenerator.GetBytes(12); // GCM 推荐 12 字节 nonce byte[] ciphertext new byte[plainBytes.Length]; byte[] tag new byte[16]; // 认证标签 using var gcm new AesGcm(key, tagSizeInBytes: 16); gcm.Encrypt(nonce, plainBytes, ciphertext, tag);头区里原来存 IV 的位置改成存 nonce密文末尾多追加 16 字节 tag。解密时先切出 tag再调用gcm.Decrypt(nonce, ciphertext, tag, plainBytes)。需要提醒的是不同 .NET 版本的AesGcm构造器重载有差异net6 和 net8 的写法不完全一样升级时先看一眼目标框架的 API 签名免得踩版本坑。6.3 每次改壳的验证三连改完壳代码后我一般强制自己走一遍固定流程先用加密器生成一个测试壳再双击运行确认能拉起目标程序最后上传在线扫描确认没误报。这三步缺一不可缺了第一步可能直接拿到一个加了密但解不开的文件缺了第三步就等着用户反馈“文件被安全软件删了”。从那以后我每次改完壳都至少跑一遍这三连宁可多花十分钟也不愿发完包再处理抱怨。希望帮到你。本文还有配套的精品资源点击获取