给一个 Java 工具做了套 licence,要解决两个现实问题:一份 licence 不被全公司共用、不被改系统时间无限续期。先说清楚一点,客户端 licence 本质是抬高破解成本,不是绝对防得住——私钥不泄露的前提下,能做到”普通用户懒得搞”的程度,真遇上专业逆向一样会破。

licence 本体是一张 JWT,RSA 私钥签发,客户端内嵌公钥验签。下面几个是做的时候要注意的点。

时间回拨

JWT 的过期判断(exp)依赖系统时钟,所以最简单的破解就是把系统时间往回调,过期就判不出来了。

办法是本地存一个单调不降的时间记录:每次校验通过,把当前时间写进 ~/.myapp/.lastseen;下次校验前先比一下,nowlastseen 早超过一个容差就拒掉:

long lastSeen = Long.parseLong(readLastSeen());
long now = System.currentTimeMillis();
// 留 5 分钟容差吸收 NTP 微调、时区抖动
return now >= lastSeen - CLOCK_ROLLBACK_TOLERANCE_MS;

写入取 Math.max(now, 已记录值),保证记录值只涨不跌。容差给 5 分钟,吸收 NTP 自动校时和跨时区出差这种正常抖动。

顺序上要先 check 再 record,先写后查的话回拨那一刻反而把回拨时间写进去了(max 逻辑兜得住,但顺序对了更清晰)。另外校验通过才写,没通过别写,免得帮攻击者刷新基线。

局限也明显:.lastseen 就是个普通文件,删掉就重置了,防的是随手调时间,防不住刻意破坏。要再收紧得靠联网校时或在线续期。

指纹覆盖的后门

机器指纹是把 os、user、hostname、MAC 集合、硬件序列号这些拼一起做 SHA-256,塞进 JWT 的 aud 字段做机器绑定。为了 CI 方便,我留了个后门:设环境变量 MYAPP_MACHINE_FINGERPRINT 就能直接覆盖指纹,CI 里固定一个值,省得每台机器都签。

但这个后门有问题:攻击者拿到任意一份真实 licence(自己买一份就行),Base64 解开 JWT 的 payload 就能读到 aud,那就是目标机器指纹的 SHA-256,然后 export MYAPP_MACHINE_FINGERPRINT=<那个值>,任意机器都能过校验。机器绑定就失效了,一份 licence 全网通用。

修法是加双开关:光设指纹 env 不够,还得同时设 MYAPP_ALLOW_FP_OVERRIDE=true 才允许覆盖,默认拒绝:

String envOverride = System.getenv("MYAPP_MACHINE_FINGERPRINT");
boolean allowOverride = Boolean.parseBoolean(System.getenv("MYAPP_ALLOW_FP_OVERRIDE"));
if (envOverride != null && !envOverride.isBlank() && allowOverride) {
return envOverride.trim().toLowerCase();
}
return computeRawFingerprint();

平时 ALLOW_FP_OVERRIDE 没人设,那个覆盖 env 就是个摆设;CI 里两个一起设,照样方便。

原则是:凡是能从外部(env、配置文件、JVM 参数)覆盖安全相关判断的开关,都得看这个值攻击者拿不拿得到。JWT 的 payload 是明文 base64,谁都能解,把指纹放 aud 等于公开了”目标指纹长这样”,一旦有覆盖通道就能被投毒。

MAC 老漂

机器指纹里最不稳的是 MAC。一开始遍历网卡、拿第一个物理网卡的 MAC,但 NetworkInterface.getNetworkInterfaces() 的枚举顺序不稳——插拔一次网卡、开关蓝牙、连 VPN,顺序都可能变,”第一个”就漂走了,指纹跟着变,用户就被误拒。

稳住的办法:把所有物理网卡的 MAC 都收上来,滤掉蓝牙、VPN、虚拟网卡这些易变的,剩下的排个序再拼:

List<String> macs = new ArrayList<>();
while (interfaces.hasMoreElements()) {
NetworkInterface ni = interfaces.nextElement();
if (ni.isLoopback() || ni.isVirtual() || !ni.isUp()) continue;
if (ni.getHardwareAddress() == null) continue;
String name = ni.getDisplayName().toLowerCase();
if (isUnstableInterface(name)) continue; // bluetooth/vpn/vmware/docker/wsl/...
macs.add(bytesToHex(ni.getHardwareAddress()));
}
Collections.sort(macs); // 消除枚举顺序影响
return String.join(",", macs);

关键是 Collections.sort——只要”稳定网卡集合”没变,不管枚举顺序怎么变,拼出来都一样。黑名单里 bluetooth、pan、vpn、vmware、docker、veth、bridge、wsl、tap 这些都排除,都是会随时增删、不该进指纹的。

指纹的多维特征都做了”缺失用空串占位”,某一项取不到(比如某些 Linux 读不到硬件序列号)不会让整个指纹崩掉,只是那一项贡献为空。要的是常规变更不漂、硬件大改才变。

Gradle 帮倒忙

这个 licence 挂在 Gradle/Maven 构建插件里,每次构建前跑一遍校验。但 Gradle 有增量构建,判断任务要不要重跑看的是输入输出有没有变;而 licence 过期这件事,文件内容一个字没改,只是时间过去了,Gradle 从输入快照里看不出来,直接 UP-TO-DATE 跳过校验。结果就是 licence 过期了,构建照常跑。

修法一行,让这个任务永远别缓存:

public CheckLicenceTask() {
getOutputs().upToDateWhen(task -> false);
}

licence 是时间敏感的,Gradle 的缓存判断是内容敏感的,这俩语义对不上。构建期做时间相关的校验(licence、证书、token 过期),都得显式关掉增量缓存。

这几个点串起来其实就一条:客户端 licence 防的是顺手白嫖,不是专业破解。时间回拨堵上、后门堵上、指纹漂移稳住、构建缓存关对,门槛到这层就够了;再往上要不要联网激活、私钥挪不挪进 KMS,看想防到什么程度。