如何为 Java 文件实现难以绕过的 licensing 和 obfuscation?
本地许可保护可以提高绕过成本,但无法让运行在用户电脑上的 Java 程序做到“不可绕过”。只要对方能修改程序和运行环境,本地许可检查就可能被替换或删除。混淆能增加分析难度,却无法保证破解需要多长时间。
比较实际的做法是用数字签名保护许可证,在受保护功能中检查授权,再对关键代码做混淆。如果业务允许联网,可以把必要的计算或资源保留在服务端。
为什么把 licensing 逻辑藏深还不够
下面这种设计很容易成为集中修改的目标:
if (!LicenseManager.isLicensed()) {
throw new SecurityException("License required");
}
即使给方法改名并做混淆,攻击者仍可能修改返回值、调用处的条件分支,或者直接跳过异常。
把同一个判断复制到很多地方,会增加需要修改的位置,但攻击者也可能通过修改底层方法,统一绕过这些检查。设计许可保护时,需要分别考虑两个问题:
- 如何防止别人自行生成或修改许可证。
- 如何增加别人修改程序、跳过许可证检查的成本。
数字签名主要解决前一个问题,后一个问题则取决于功能设计、混淆和服务端授权。
第一步:用数字签名签发许可证
许可证可以包含以下业务字段:
{
"licenseId": "lic-123",
"productId": "desktop-pro",
"customerId": "customer-123",
"expiresAt": 1893456000,
"features": ["export.pdf"]
}
这里约定 expiresAt 使用 Unix 时间戳,单位为秒。
签发端用私钥对许可证载荷的原始字节签名。客户端只保存公钥,用它验证签名,再检查产品、有效期和功能权限。
私钥必须留在签发端。如果把用于签发许可证的共享密钥放进 JAR,即使经过加密或混淆,也可能被提取出来,用于制作新的许可证。公钥可以公开,但客户端必须使用预先信任的公钥,不能直接信任许可证附带的公钥。
数字签名能让正常执行验证的程序发现许可证被修改或伪造。它不会隐藏许可证内容,也无法阻止攻击者删除客户端的验签逻辑。
第二步:实现验签,并单独检查授权字段
以下代码使用 Java 标准 API 完成 RSA 验签。签发端需使用对应的 SHA256withRSA 算法,输入的公钥采用 X.509 SubjectPublicKeyInfo 的 DER 编码。
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.spec.X509EncodedKeySpec;
public final class LicenseVerifier {
private final PublicKey trustedPublicKey;
public LicenseVerifier(byte[] trustedPublicKeyDer)
throws GeneralSecurityException {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
this.trustedPublicKey = keyFactory.generatePublic(
new X509EncodedKeySpec(trustedPublicKeyDer)
);
}
public byte[] verify(byte[] payload, byte[] signatureBytes)
throws GeneralSecurityException {
byte[] verifiedPayload = payload.clone();
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(trustedPublicKey);
verifier.update(verifiedPayload);
if (!verifier.verify(signatureBytes)) {
throw new SignatureException("Invalid license signature");
}
return verifiedPayload;
}
}
trustedPublicKeyDer 应来自应用预置信任的公钥。如果许可证文件以 Base64 存储载荷和签名,需要先解码,再调用 verify。
验签必须使用签发时的原始载荷字节。不要先解析 JSON,再重新序列化后验签,因为空格、字段顺序或转义方式的变化都可能改变字节内容。
验签成功后,还要检查业务字段:
productId是否对应当前产品。expiresAt是否有效,是否已经过期。features是否包含当前操作需要的权限。- 如果有设备绑定,当前设备是否符合绑定条件。
解析时,应拒绝缺失必要字段或字段值不符合约定的许可证,并明确如何处理重复的 JSON 字段。签名有效,并不代表所有功能都已获得授权。
这段代码只包含验签核心,不包含许可证解析、续期、撤销或设备管理。
第三步:让授权检查对应实际功能
如果只在启动时检查一次许可证,之后都读取全局 licensed = true,就会留下明显的集中绕过点。
可以在导出、批处理等受保护操作的业务入口检查相应权限。例如,只有许可证包含 export.pdf 权限时,才允许执行 PDF 导出。界面上的按钮状态也应与权限一致。业务入口仍需自行检查,避免其他调用路径跳过限制。
这些检查能减少遗漏,也会扩大攻击者需要理解和修改的业务范围,但检查本身仍然可以被修改。如果所有入口最终都依赖一个可替换的布尔返回值,增加调用次数的作用就比较有限。
如果产品允许联网,让服务端参与必要的业务操作会更有效:
- 客户端提交身份凭据和操作请求。
- 服务端检查许可证状态、功能权限及配额。
- 服务端执行必要计算,或返回该操作确实需要的资源。
如果服务端只返回一个 licensed: true,客户端仍然容易替换这个结果。受保护操作需要依赖客户端无法自行完成的服务端能力,才能让攻击者在删除本地检查后仍无法恢复完整功能。服务端必须对每次操作执行相应的授权检查,不能信任客户端报告的许可状态。
第四步:针对关键代码做混淆
可以对验签调用、权限判断和受保护的业务路径做混淆。常见措施包括名称混淆、控制流变换和字符串保护,具体能力取决于所用工具。
这些措施各有限制:
- 名称混淆能减少类名、方法名暴露的信息,不会消除程序行为。
- 字符串加密能增加静态分析成本,运行时使用的明文仍可能被观察到。
- 完整性校验可以检测部分修改,但本地校验逻辑本身也可能被修改。
- 加密 JAR、使用自定义
ClassLoader会增加提取步骤,执行所需的代码仍需在运行时恢复。 - JNI 或本地编译会改变分析方式,也会增加部署和维护成本,无法保证不可绕过。
混淆后必须测试最终发布包。反射、序列化、依赖注入、JNI 和按名称加载资源等机制可能依赖原有名称,需要配置保留规则。映射文件应保存在受控的构建环境中,用于还原异常堆栈。
离线许可和发布时的注意事项
完全离线的程序无法及时获知许可证的撤销状态,本地时间也可能被回拨。设备绑定可以限制直接复制许可证,但需要处理换机、硬件变更和虚拟机迁移等情况。绑定判断仍然在可被修改的客户端中执行。
如果采用定期联网续期或短期授权凭据,需要明确断网宽限期。凭据有效期越长,离线使用越方便,撤销生效通常也越慢。
JAR 签名可以用于验证分发包的签名和完整性,不能替代业务许可授权。给包签名后,攻击者控制的环境仍可能运行修改后的版本。
发布前,应对自己拥有的构建做一次实际评估:检查是否存在单一授权开关,尝试修改许可证字段、替换验证结果,并检查混淆后的反射调用和异常处理。破解难度取决于程序结构、保护范围和分析者的投入,不宜承诺“至少需要几天才能破解”。
纯离线产品的合理目标是提高破解以及持续维护破解版本的成本。联网产品则可以通过保留在服务端的必要能力,建立更明确的授权边界。
备注:内容仅供参考。
