PAYATHON 2026

如何为 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 导出。界面上的按钮状态也应与权限一致。业务入口仍需自行检查,避免其他调用路径跳过限制。

这些检查能减少遗漏,也会扩大攻击者需要理解和修改的业务范围,但检查本身仍然可以被修改。如果所有入口最终都依赖一个可替换的布尔返回值,增加调用次数的作用就比较有限。

如果产品允许联网,让服务端参与必要的业务操作会更有效:

  1. 客户端提交身份凭据和操作请求。
  2. 服务端检查许可证状态、功能权限及配额。
  3. 服务端执行必要计算,或返回该操作确实需要的资源。

如果服务端只返回一个 licensed: true,客户端仍然容易替换这个结果。受保护操作需要依赖客户端无法自行完成的服务端能力,才能让攻击者在删除本地检查后仍无法恢复完整功能。服务端必须对每次操作执行相应的授权检查,不能信任客户端报告的许可状态。

第四步:针对关键代码做混淆

可以对验签调用、权限判断和受保护的业务路径做混淆。常见措施包括名称混淆、控制流变换和字符串保护,具体能力取决于所用工具。

这些措施各有限制:

  • 名称混淆能减少类名、方法名暴露的信息,不会消除程序行为。
  • 字符串加密能增加静态分析成本,运行时使用的明文仍可能被观察到。
  • 完整性校验可以检测部分修改,但本地校验逻辑本身也可能被修改。
  • 加密 JAR、使用自定义 ClassLoader 会增加提取步骤,执行所需的代码仍需在运行时恢复。
  • JNI 或本地编译会改变分析方式,也会增加部署和维护成本,无法保证不可绕过。

混淆后必须测试最终发布包。反射、序列化、依赖注入、JNI 和按名称加载资源等机制可能依赖原有名称,需要配置保留规则。映射文件应保存在受控的构建环境中,用于还原异常堆栈。

离线许可和发布时的注意事项

完全离线的程序无法及时获知许可证的撤销状态,本地时间也可能被回拨。设备绑定可以限制直接复制许可证,但需要处理换机、硬件变更和虚拟机迁移等情况。绑定判断仍然在可被修改的客户端中执行。

如果采用定期联网续期或短期授权凭据,需要明确断网宽限期。凭据有效期越长,离线使用越方便,撤销生效通常也越慢。

JAR 签名可以用于验证分发包的签名和完整性,不能替代业务许可授权。给包签名后,攻击者控制的环境仍可能运行修改后的版本。

发布前,应对自己拥有的构建做一次实际评估:检查是否存在单一授权开关,尝试修改许可证字段、替换验证结果,并检查混淆后的反射调用和异常处理。破解难度取决于程序结构、保护范围和分析者的投入,不宜承诺“至少需要几天才能破解”。

纯离线产品的合理目标是提高破解以及持续维护破解版本的成本。联网产品则可以通过保留在服务端的必要能力,建立更明确的授权边界。

备注:内容仅供参考。