简介:这是一份C#软件注册保护与注册机源码,面向需要为自有桌面软件增加授权验证功能的开发人员,用于解决软件被随意拷贝、盗用的问题。客户端基于设备硬件生成唯一序列号,配套注册机根据序列号和自定义有效期生成注册码,软件换机后需重新注册,超过有效期会自动识别失效;测试时可通过更换电脑或调整电脑时间,直观验证注册判断逻辑。资源合计112个文件,压缩包仅751KB,其中.cs源码约20个,并包含.sln/.csproj工程文件、config配置文件、exe可执行程序、pdb调试信息及resources资源文件等,可在Visual Studio 2015(.NET 4.0)环境中直接打开、编译与调试。目前已有232人学习。通过阅读源码,可以掌握设备指纹采集、序列号生成、注册码校验、有效期管理等完整实现思路;项目结构清晰,也便于将相关模块快速移植到自己的C#项目中,作为软件授权保护的基础方案,适合有一定C#基础、希望快速搭建授权系统的开发者参考。 用C#写过桌面软件的朋友,迟早都会遇到一个问题:软件写好了,往出一发,别人拿去随便复制,甚至被改了版权信息二次打包出售。我自己就吃过这个亏,早期做的一个小工具被人扒了界面换成别人的名字在论坛上发,当时根本没有注册机制,用户拿到就能用。后来下决心补上"检测是否注册"这套东西,顺带把注册码生成器也写了。这篇文章就把我做这套授权系统时的思路、踩过的坑、还有最终的完整源码结构拆开讲清楚,给正在做C#桌面应用又需要加授权控制的朋友一个能直接落地的参考方案。
1. 注册验证的核心思路:先搞清楚为什么不能用简单的bool标记
很多人第一次做注册功能,第一反应是"在程序里放一个bool变量,注册了就置true不就行了"。这种思路如果真用在商业软件里,稍微有点逆向经验的人用dnSpy打开你的程序集,找到那个判断条件,一行IL指令改掉就破解了。C#是托管代码,编译出来的是IL中间语言,比C++的机器码容易反编译得多,所以C#软件的注册保护,本质上是在跟"反编译+改逻辑"这件事对抗。
我做的这套方案,核心设计原则有四条:注册状态不落本地明文、注册码与机器硬件绑定、关键代码路径多层校验、生成端与验证端用同一套不可逆算法。说白了就是哪怕有人把注册相关的代码全反编译出来看,他也不知道从哪下手改,因为他改了本地的东西也伪造不出合法注册码。
这里要解释一个常见误解:很多人以为注册机是破解工具,其实在正规开发流程里,注册机就是开发者自己用来给正式用户生成注册码的授权管理工具,它跟软件本体是配套的。你要开发的不是"怎么绕过验证",而是"怎么生成一个只有你(或你的授权服务器)能生成、但所有客户端都能验证的注册码"。这个思路理顺了,下面的代码才看得明白。
2. 注册码生成与验证的算法选型:我为什么坚持用非对称签名
注册系统的核心是算法选型。市面上常见的做法有三类,我逐个对比过:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 对称加密序列号 | 用固定密钥加密用户信息得到注册码 | 生成验证都简单 | 密钥藏在客户端里,反编译能挖出来,然后就能自己造注册机 |
| 哈希散列拼接 | 把机器码+用户名+固定盐做MD5/SHA | 算法不可逆,成本低 | 容易被暴力碰撞分析,盐一旦被提取就能批量伪造 |
| 非对称签名 | 私钥生成签名,公钥验证签名 | 私钥不落地客户端,逆向拿到公钥也无法伪造注册码 | 实现稍复杂,需要处理签名格式 |
我最后选的是第三种,RSA非对称签名。原因很简单:签名验证只需要公钥,公钥本身是公开的,就算攻击者把客户端翻个底朝天,拿到的也只有公钥,而没有私钥的他无论如何也生产不出合法的注册码。这就像你开了一家店,门口挂着开锁师傅的验证电话(公钥),但开锁的模板(私钥)只在你手里,别人就算知道电话是多少,也打不开门。
注册机的角色在这套设计里就非常清晰了:它是持有私钥的那一方,专门用来给正版用户发布授权的工具。而客户端代码里永远只存在公钥。这样即使整个验证逻辑被反编译研究透彻,攻击者也做不出"注册机",因为他缺了最关键的私钥。这个架构把对抗关系直接拉开了一个层级。
具体到RSA密钥长度,我建议至少用2048位。1024位虽然也能跑,但近年硬件算力提升后已经被认为不够安全,签名长度也短不了多少,没必要省。生成签名时用SHA256做哈希,不要用老旧的SHA1。
3. 客户端注册检测的完整实现流程
下面这段是我实现的核心验证逻辑,分成三步:获取机器指纹、验证注册码签名、运行时多点检测。每一步都有坑,我会把代码和原因一起贴出来。
3.1 获取机器指纹的坑:CPU编号和MAC地址都不可靠
机器指纹是注册码绑定的基础,但这里的水很深。网上很多教程直接取CPU编号或者MAC地址,我实测下来都有问题:有些品牌的CPU编号是空值(老的AMD平台尤其明显),MAC地址在虚拟机和多网卡环境下不稳定,而且现在的Windows系统如果启用了随机硬件地址,每次连WiFi都可能变。
我最终用的是多种硬件信息拼接后做哈希,这样单一项变化不会导致整体失效:
using System; using System.Security.Cryptography; using System.Text; using Microsoft.Win32; public class MachineFingerprint { public static string GetFingerprint() { // 组合多个硬件标识,降低单一来源缺失导致指纹失效的概率 string baseInfo = string.Join("|", new string[] { GetCpuId(), // CPU GetMotherboardId(), // 主板 GetBiosId(), // BIOS GetDiskId() // 系统盘卷序列号 }); using (SHA256 sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(baseInfo)); return Convert.ToHexString(hash); // .NET 5+ 可用,旧版本用 BitConverter } } private static string GetCpuId() { try { using (RegistryKey key = Registry.LocalMachine.OpenSubKey( @"HARDWARE\DESCRIPTION\System\CentralProcessor\0")) { return key?.GetValue("ProcessorNameString")?.ToString() ?? "unknown"; } } catch { return "unknown"; } } private static string GetMotherboardId() { try { using (RegistryKey key = Registry.LocalMachine.OpenSubKey( @"HARDWARE\DESCRIPTION\System\BIOS")) { return key?.GetValue("BaseBoardProduct")?.ToString() ?? "unknown"; } } catch { return "unknown"; } } private static string GetBiosId() { try { using (RegistryKey key = Registry.LocalMachine.OpenSubKey( @"HARDWARE\DESCRIPTION\System\BIOS")) { return key?.GetValue("BIOSVersion")?.ToString() ?? "unknown"; } } catch { return "unknown"; } } private static string GetDiskId() { try { using (RegistryKey key = Registry.LocalMachine.OpenSubKey( @"SOFTWARE\Microsoft\Windows NT\CurrentVersion")) { return key?.GetValue("ProductId")?.ToString() ?? "unknown"; } } catch { return "unknown"; } } }注意我特意没直接用WMI(ManagementObjectSearcher),虽然它能拿到更标准的信息,但WMI在某些精简版系统上会被禁用,而且查询速度慢,首查可能要等几秒。用注册表读取速度快得多,兼容性也更好。这里取到的指纹不是给人看的,是给算法当输入数据的,所以用英文"unknown"做兜底完全没问题。
3.2 注册码验证:RSA签名校验的标准姿势
拿到指纹之后,注册码验证的核心就是一个签名校验过程。注册机端把"机器指纹+到期日期"拼成原文,用私钥做签名,生成的Base64字符串就是注册码。客户端拿到注册码后,用内置公钥验签,验证通过并且日期在有效期内,才判定为已注册。
using System; using System.Security.Cryptography; using System.Text; public class LicenseValidator { // 注意:这是公钥的XML字符串,发布时只包含公钥部分,绝不含私钥 private static readonly string PublicKeyXml = "<RSAKeyValue>...你的公钥XML...</RSAKeyValue>"; public static bool TryValidate(string machineFingerprint, string licenseCode, out DateTime expireDate) { expireDate = DateTime.MinValue; try { string[] parts = licenseCode.Split('|'); if (parts.Length != 2) return false; string rawData = parts[0]; string signature = parts[1]; byte[] signatureBytes = Convert.FromBase64String(signature); using (RSA rsa = RSA.Create()) { rsa.FromXmlString(PublicKeyXml); bool valid = rsa.VerifyData( Encoding.UTF8.GetBytes(rawData), signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); if (!valid) return false; } // rawData格式:machineFingerprint|yyyy-MM-dd string[] dataParts = rawData.Split('|'); if (dataParts.Length != 2) return false; if (dataParts[0] != machineFingerprint) return false; return DateTime.TryParseExact(dataParts[1], "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out expireDate); } catch { return false; } } }这段代码有几点要特别注意。第一,注册码格式设计成"原文|签名"两部分拼接,原文本身可读,方便排查授权问题,签名保证原文没被篡改。第二,验证签名时用的Padding是Pkcs1,很多人用OAEP加密但签名也用OAEP,会直接抛异常,这是RSA在加密和签名两种用法上的经典混淆点。第三,rawData里的机器指纹必须跟当前机器算出来的一致,否则就算签名合法,换了机器也跑不了。这是机器绑定的关键。
3.3 到期判断的隐蔽处理:别把过期时间放在一眼能看见的地方
注册码里带了到期日期,校验通过后程序就知道什么时候过期。但这里有个体验问题:如果每次启动都去校验RSA签名,一方面耗时(大概几十毫秒,感知不强),另一方面容易在调试时被下断点绕过。我的做法是缓存校验结果。
具体来说,校验通过后把"到期日期"和"当前指纹哈希"加密存到注册表或本地配置文件里。下次启动先读缓存,如果缓存里的记录有效且未过期,就不再走RSA验证。这样做的目的不是安全性,而是降低RSA验证的调用频率。真正的安全校验放在下面说的关键时间点。
public class LicenseCache { private static readonly string RegPath = @"SOFTWARE\YourSoftware\License"; private static readonly string ValueName = "AuthData"; public static bool TryReadCache(out DateTime expireDate, out string fpHash) { expireDate = DateTime.MinValue; fpHash = string.Empty; try { using (RegistryKey key = Registry.CurrentUser.OpenSubKey(RegPath)) { if (key == null) return false; string encrypted = key.GetValue(ValueName)?.ToString(); if (string.IsNullOrEmpty(encrypted)) return false; string decrypted = DPAPIHelper.Decrypt(encrypted); string[] parts = decrypted.Split('|'); if (parts.Length != 2) return false; expireDate = DateTime.Parse(parts[0]); fpHash = parts[1]; return true; } } catch { return false; } } public static void SaveCache(DateTime expireDate, string fpHash) { try { using (RegistryKey key = Registry.CurrentUser.CreateSubKey(RegPath)) { string plain = $"{expireDate:yyyy-MM-dd}|{fpHash}"; key.SetValue(ValueName, DPAPIHelper.Encrypt(plain)); } } catch { } } }DPAPI(ProtectedData)是Windows自带的加密机制,用当前用户凭据加密数据,即使别人拿到注册表里的值也解不开。这是我最推荐的方式,因为不涉及自己管理密钥的问题。用DPAPI加密缓存数据,既防止用户手动改注册表里的日期,又不需要额外引入加密库。不过要注意,DPAPI加密的数据跟Windows用户账户和机器绑定,用户换了账户登录就解不开了,所以判断的时候如果解密失败,直接让它走重新验证流程即可。
3.4 多点检测:真正让破解者头疼的部分
如果只在启动时校验一次,破解者只要找到那个校验函数,把返回值改掉就完事了。我后面学聪明了,把验证逻辑拆成多个检测点,散布在程序各个关键路径上:
- 启动时的主验证(负责加载注册信息)
- 核心功能模块调用前验证(比如导出、保存、批量处理等)
- 定时器周期验证(程序运行中每隔一段时间抽查一次)
- 窗口激活事件里验证(切换窗口时顺带检查)
每个检测点不直接调TryValidate,而是封装成一个静态方法,内部先看缓存是否还有效,失效才走完整RSA验证。这样破解者需要改的地方就从一个点变成了七八个点,而且每个点之间互相不知道对方的存在。
这里有个设计细节值得说一下:检测点的返回值不要直接返回true/false,我习惯返回一个枚举,比如LicenseStatus.Valid、LicenseStatus.Expired、LicenseStatus.Invalid、LicenseStatus.Trial,然后在调用方对不同的状态做不同的处理。这样做的好处是代码语义清晰,而且把"判断"和"执行"分离,调整逻辑时不用全局改代码。
4. 注册机源码:AuthTool生成授权码的工程实现
注册机这边的结构就简单多了,它的核心任务是:收集用户机器指纹、指定授权时长、用私钥生成签名、输出注册码。
using System; using System.Security.Cryptography; using System.Text; public class LicenseGenerator { private static readonly string PrivateKeyXml = "<RSAKeyValue>...你的私钥XML...</RSAKeyValue>"; public static string Generate(string machineFingerprint, DateTime expireDate) { string rawData = $"{machineFingerprint}|{expireDate:yyyy-MM-dd}"; using (RSA rsa = RSA.Create()) { rsa.FromXmlString(PrivateKeyXml); byte[] dataBytes = Encoding.UTF8.GetBytes(rawData); byte[] signature = rsa.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); string signatureBase64 = Convert.ToBase64String(signature); return $"{rawData}|{signatureBase64}"; } } }实际使用的注册机,我会做成一个WinForms小工具,界面上三个输入框:用户机器码、授权天数、授权备注,点生成后自动输出注册码文本和二维码。机器码可以由用户从软件"关于"窗口复制,也可以让用户发一个导出的指纹文件过来,注册机端直接读取。
这里必须强调一个容易踩的坑:私钥和公钥的XML格式不要搞混。RSA的ToXmlString(true)导出的包含私钥,ToXmlString(false)只包含公钥。发布软件时一定要检查PublicKeyXml里有没有 这样的私钥标签。我见过不止一个人把含私钥的完整XML编译进了客户端,结果一次更新包泄露后就全网都能造注册码了。这个错误是致命的,比代码写得烂还可怕。
另外,注册机生成的授权记录最好落一个日志,哪天授权的、授权给谁、机器码是什么、到期日是哪天,方便后续售后和排查。不用搞数据库,一个CSV文件足矣。有些开发者在注册机里加了到期前7天自动续期提醒的功能,也很实用,用户快到期时软件弹窗提示,注册机这边也会高亮显示即将到期的授权,整个授权流程就闭环了。
5. 常见的三种绕过方式与对应的加固策略
这套系统上线后,我陆续收到了几个"反馈",实际上是有人在尝试破解。我把遇到的绕过手段整理了一下,给后面的人提个醒。
第一种:改系统时间绕过到期。
这是最原始的招数。用户把系统时间调到授权日期之前,缓存里的日期判断就永远有效。应对方法有两个:一是记录上次运行时的系统时间,如果发现当前时间比上次还早,就判定为时间回拨;二是定期从网络校时服务获取真实时间做校准(这个看软件类型,纯内网工具就没有办法)。Registry里存一个LastRunTime字段,每次启动拿当前时间和它比较,回拨就重置注册状态,这招实测对大多数普通用户有效。
第二种:反编译修改IL指令绕过校验。
这是专业一点的玩法。C#程序用dnSpy打开,找到校验函数,右键编辑IL,把返回false改成返回true,保存后程序就"永远注册"了。对这种行为,我能做的有限,但有几个有效的拖延手段:强命名程序集(防止篡改后还能正常加载)、混淆器(让函数名变成a1b2c3),以及把关键的校验逻辑放到单独的DLL里,再对DLL做混淆和加壳。这些不能完全阻止破解,但能显著降低被破解的速度。
第三种:Hook系统API伪造机器码。
更高端的做法是Hook住取机器指纹的API,让它每次都返回注册时的指纹,从而骗过验证。这种攻击比较难防,因为指纹信息本身就是从系统来的。我的应对思路是除了启动时取一次指纹,在程序运行期间再随机取几次比对,一旦发现指纹不一致就走未注册流程。虽然这会让某些正常的硬件小变动(比如U盘插入导致卷序列号读取变化)触发误报,但权衡下来,防破解的优先级更高。
我还遇到过一个比较麻烦的情况:用户重装系统或者更换了硬盘,机器码变了,原来的注册码就失效了。这类用户通常不会破解,但体验很糟糕。我的注册机上面加了"重置授权"的功能:用户提供新机器码,注册机端读取原始授权记录,重新签发一条新注册码。这本质上是正常售后服务流程,但很多开发者没有意识到要提前做好这个运维准备。
6. 机器码兼容性:一个导致大量误判的隐藏陷阱
最后分享一个我调试了很久才发现的坑,这个问题如果不注意,会让很多正版用户被误判成未注册。
前面GetFingerprint方法里,我用了注册表里的ProcessorNameString、BaseBoardProduct、BIOSVersion来生成指纹。这些值在绝大多数情况下是稳定的,但有个例外:BIOS更新的情况。主板厂商偶尔会发布BIOS更新,更新后BIOSVersion会变,指纹就会变,注册码全部失效。
更隐蔽的是,Windows的注册表里这些值在有些机器上会包含不可见字符或者末尾空格。我记得排查过一个用户的问题,他的注册码时好时坏,后来发现是他Windows系统语言版本和开发机不同,注册表读取的编码有差异,导致同样的硬件读出来的字符串有细微区别,哈希结果完全不同。
解决办法有两个方向。一个是取硬件信息时做归一化处理,把所有内容Trim掉空格、转成小写、统一编码。另一个更稳妥的做法是不要直接对拼接字符串做哈希,而是把每项单独哈希后再拼接整体哈希。这样即使某一项因系统升级变化了,也可以用"多数匹配"的策略来判断机器是否还是同一台。
比较简单的实现是生成指纹时把每个硬件项的哈希值分别存下来,验证注册码时逐个比对,允许有一到两个不匹配。这样BIOS更新这类单点变化不会导致彻底失联。当然,这牺牲了一些严格性,对安全性要求极高的软件可以不这样妥协,但对大多数面向普通用户的工具类软件来说,体验更重要。
我在生产环境里实际跑的方案是:五项硬件信息,允许两项不匹配算合格。上线一年多,误报率大幅下降,真遇到三项以上不匹配的,基本可以确定是换了主板或整机,这时候让用户重新申请授权是合理的。
7. 最后再分享几条实操经验
做完这套系统,踩过的坑不少,有几条经验写在这里,算是给同样在搞C#授权机制的朋友提个醒。
第一,注册码长度不要太纠结。RSA 2048位签名转Base64后大约340个字符左右,加上原文会到400字符左右。很多开发者觉得太长,想压缩。别折腾了,直接提供一个"复制注册码"按钮,让用户复制粘贴,不要手动输入。注册码就是要长,越长越难被肉眼伪造。
第二,不要把验证逻辑全放在一个类里。我最早把所有验证写在一个LicenseManager类里,后来发现不利于反破解。改成上面说到的多点检测后,我把每个检测点的状态判断和维护分散到不同的静态类里,类之间只传递状态枚举,不知道彼此的存在。这样即使破解者找到了其中一个检测点,也看不到完整链路。
第三,上线前务必做一次"用户视角"测试。找一台全新的、没有开发环境的机器,用普通用户身份安装你的软件,然后再试一遍"没注册→申请注册→输入注册码→授权成功→运行核心功能"的完整流程。我第一次做的时候,就是因为忘记在安装包里打入公钥配置,导致所有用户收到注册码都验证失败,场面一度非常尴尬。
第四,试运行期一定要有。授权系统建议在新版本发布后留一个月的"试运行+灰度验证"期,观察有没有异常反馈,比如大量用户报注册失败、特定型号电脑全部无法通过指纹匹配等。没有问题再全量放开,这样可以避免授权系统本身成为软件口碑的短板。
本文还有配套的精品资源,点击获取