一机一码授权:软件试用期怎么防止改系统时间绕过(时间水位实现,C#)
试用期和到期控制是离线授权里最容易被戳破的一环,而且破法简单到让人无语:
把系统时间调回上个月。
这篇文章讲清楚为什么"读一次本机时间"一定会失效,以及我们最后用的方案:
时间水位(Time Vault)——记录这台机器上见过的最晚时间,校验时用 max(本机时间, 水位)。
附可跑的代码结构和一条"反证测试"。
一、天真的写法,以及它是怎么被绕过的
// 常见写法:装个文件记录首次运行,然后算天数
var firstRun = File.ReadAllText(trialFile);
var days = (DateTime.Now – DateTime.Parse(firstRun)).TotalDays;
if (days > 30) { 弹窗("试用已结束"); return; }
两个场景直接失效:
第二点还能靠多位置存储缓解,第一点只能靠不信任本机时钟来解决。
二、时间水位的核心思路
不要让"当前时间"决定一切,而是维护一份记录:
这台机器历史上见过的最晚时间(水位线)。
每次启动:
有效时间 = max(本机时间, 水位线)
如果 本机时间 > 水位线 → 把水位线抬到本机时间(时间只能前进)
回拨检测:本机时间 < 水位线 – 2 小时 → 记一次回拨
于是"把时间调回上个月"这件事毫无收益:有效时间不会跟着回去,过期就是过期。
而且客户照常能用——我们并不因为检测到回拨就停机,只是回拨不再能延长试用。
三、记录长什么样
就是一个带几个字段的文本(我们用的格式带自校验,防止被手改成看起来合法的内容):
format = MLV/1
product = UperDemo
highWater= 2026-09-17T14:22:31Z ← 见过的最大时间(UTC)
pendingJump= 2026-09-18T09:00:00Z ← 待确认的"向前大跳",见第四节
rollbacks= 3 ← 累计回拨次数(诊断用)
关键点:只用 UTC。本地时间会被时区、夏令时、手动改时区影响,
而 UTC 不受这些干扰,少一整类诡异 bug。
四、两个阈值:为什么是 2 小时和 30 天
守卫不能太敏感,否则会误伤正常客户。我们把规则定成三条:
| 本机时间比水位早,但差距 ≤ 2 小时 | 正常漂移 | 不计数、不处理(NTP 同步、手动校时都会这样) |
| 本机时间比水位早超过 2 小时 | 回拨 | rollbacks++,水位不降 |
| 本机时间比水位晚超过 30 天 | 可疑前跳 | 先不接受,记下 pendingJump,等下一次运行再确认 |
第三条是这个方案里最值得抄的一点。
客户主板电池没电、或者手滑把年份从 2026 设成 2062,如果你当场就把水位抬到 2062,
客户的授权就永久性地废了——他改回正确时间也救不回来,只能找你重新签发。
永久损坏一个客户的授权,代价比少赚一单大得多。
所以规则是:大幅前跳只在连续两次运行都观察到相近的时间(允许 1 天内差异)时才接受;
只看到一次就沿用旧水位、把这次记成 pendingJump。真跑了一个月?
第二次运行时间自然对得上,正常推进。
五、状态存哪里:四个位置 + 取"最晚的那份"
单点存储一定会被删。我们写到四个位置:
%ProgramData%\\<产品>\\clock.dat ← 所有用户共用,工厂电脑常有多账号
%LOCALAPPDATA%\\<产品>\\clock.dat
%APPDATA%\\<产品>\\clock.dat
HKCU\\Software\\<产品> (注册表值)
读的时候不是取第一个能读到的,而是全读一遍、应用"水位只前进"的合并规则:
TimeVaultRecord best = null;
foreach (var text in _store.ReadAll()) { // 四个位置全读
var record = TimeVaultRecord.TryDecode(text, _productId);
if (record == null) continue; // 解不开的副本直接忽略
if (best == null ||
record.HighWaterUtc > best.HighWaterUtc ||
(record.HighWaterUtc == best.HighWaterUtc && record.RollbackCount > best.RollbackCount)) {
best = record; // 取水位最高的那份
}
}
这么写以后,"删掉其中一个文件"没有任何收益——另外三个位置里水位最高的那份说了算。
需要注意的是 ProgramData 可能写不进去(非管理员 + 目录被加固),
所以写入失败要静默跳过、由其他位置兜底,绝对不能让激活流程因此报错。
六、一定要写一条"反证测试"
只测"守卫生效"是测不出什么的,因为关掉守卫时那条测试照样绿。
我们专门写了一条证明守卫必要的测试:
// 关掉时间守卫,构造一个"改过系统时间"的授权场景
var unguarded = new LicenseOptions("UperDemo") { UseTimeGuard = false };
var bypassed = LicenseRuntime.Initialize(unguarded);
// 断言:没有守卫时,这个场景**确实**会被绕过
Assert(bypassed.Status == LicenseStatus.Licensed);
这条测试的价值在于:它会在有人"顺手把守卫优化掉"的时候立刻失败。
一个能失败的测试,才是真的在保护东西。
七、边界:它挡得住谁
- 挡得住:改系统时间、改时区、删状态文件、重命名目录 —— 这些是最常见的"劝退级"绕过。
- 挡不住:会逆向、能改字节、能直接 patch 掉校验分支的人。
- 物理限制:把四个状态文件全部删掉、且从没在系统里跑过带守卫的版本,仍可能重置。
这是纯离线方案的物理限制,谁跟你说"离线授权绝对破解不了",那是话术,不是技术。
顺便说一句:检测到回拨时不要停机。停机伤的是正常客户(主板电池没电、改了时区),
而对真想绕的人毫无作用。把回拨计数记录下来、在界面上提示"检测到系统时间异常"就够了。
八、上线前建议实测这 5 条
第 5 条最容易被忽略,而它恰恰是工厂现场最常见的情况。
(文中这套流程的评估包我传到 CSDN 下载区了,免费,含 76 项自检和完整的"机器码 → 申请 → 签发 → 激活"演示:
https://download.csdn.net/download/jhjjbb/93466800 )
文中的代码是简化片段。完整实现我在自己的项目上跑通了一版,需要的话可以私信我。


