一个商业软件的License校验,居然把验签的证书放在了License文件里

这篇文章记录了我从发现端倪到完整复现漏洞的全过程。如果你从事 Java 开发、涉及软件授权机制设计、或对密码学的工程落地感兴趣,这篇文章可能会给你一些启发。

1.先给你看结果

我用 10 分钟,生成了一个QuartzDesk官方程序认账的许可证:

yaml 复制代码
Serial Number: TE21-0120-NK1H-MH20
Issue Date: 2026-08-20
Type: PERPETUAL
Expiry Date: n/a
Licensee: admin, admin@toolsmith.pro, https://toolsmith.pro
Issuer: CN=QuartzDesk.com CA, O=QuartzDesk
Licensed Products:
  id=QuartzDesk, name=QuartzDesk Enterprise Edition

校验通过。

我没有改官方任何一行代码,没有 patch 任何 .class 文件,没有用任何破解工具。

我只是按照官方设计的"规则",生成了一份数据,它就认了。

那问题出在哪?

许可证文件里,带着用于验证它自己的证书。

下面我带你走一遍完整的发现和复现过程。

2.背景介绍

最近需要监控多台机器上的 Quartz Job,搜到了 QuartzDesk :一个针对 Quartz Scheduler 的企业级监控平台,功能挺强,无侵入集成。

但它的许可证校验机制......我反编译看了一眼,直接愣住了。

先说下背景知识,就两句:

  • Quartz:Java 生态里最流行的开源作业调度库,没有之一。
  • QuartzDesk:Quartz 的商业监控平台,License 校验封装在独立的 JAR 包里。

好,背景结束,开始干活。

3.第一眼就不对劲

下载 quartzdesk-web-6.0.2.war 解压,在 WEB-INF/lib 下找到 quartzdesk-license-10.0.1.jar

用 JD-GUI 打开,类列表里赫然躺着一个类:

java 复制代码
public class LicenseSigner {
    public License sign(License paramLicense, PrivateKey paramPrivateKey) {
        // 签名逻辑
    }
}

等等? LicenseSigner?不应该是 LicenseValidatorLicenseVerifier 吗?

一个做许可证校验的 JAR 包,核心类不叫"校验器"叫"签名器"。

名字和职责对不上,直觉告诉我有故事。

它的签名逻辑倒是标准流程:

java 复制代码
Signature signature = Signature.getInstance("MD5withRSA");
String str1 = a.a(paramLicense);
signature.initSign(paramPrivateKey);
signature.update(str1.getBytes(StandardCharsets.UTF_8));
byte[] arrayOfByte = signature.sign();
String str = f.a(arrayOfByte);
paramLicense.setSignature(str);

标准的 RSA 签名:摘要 → 私钥签名 → Base64 编码 → 塞进 License。

但"验签"在哪里?

Signature 关键字,找到了一个匿名内部类 b

4.看到这行代码,我愣住了

校验类的核心逻辑(我把关键部分精简出来):

java 复制代码
public void a(License paramLicense) throws LicenseException {
    // 从 License 对象中直接提取证书字符串
    String str = paramLicense.getIssuer().getCertificate();  // 🔴 问题在这
    CertificateFactory cf = CertificateFactory.getInstance("X.509");
    X509Certificate cert = (X509Certificate) cf.generateCertificate(
        new ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8))
    );
    
    // 用这个从 XML 里读出来的证书去验签
    a(paramLicense, cert);
}

private void a(License paramLicense, Certificate paramCertificate) {
    Signature signature = Signature.getInstance("MD5withRSA");
    signature.initVerify(paramCertificate.getPublicKey());  // 🔴 证书来自 XML
    signature.update(getLicenseData(paramLicense));
    boolean valid = signature.verify(decodeSignature(paramLicense));
    
    if (!valid) {
        throw new LicenseException("License signature is invalid");
    }
}

看到 paramLicense.getIssuer().getCertificate() 这行,我愣住了。

验签用的公钥证书,是从 License 数据本身里读的?

也就是说:

复制代码
License XML 文件
├── 业务数据(序列号、有效期、产品信息...)
├── 签名(Base64)
└── 证书(PEM)  ← 🔴 用来验证上面那个签名的钥匙

验签的钥匙,和被验签的数据,在同一个文件里。

是不是感觉哪里不对?接着往下看,更离谱的来了。

5.官方居然预置了证书,但没用

为了确认,我去翻了 META-INF/xsd/license/v1_0/License.xsd

找到 <Issuer> 类型定义:

xml 复制代码
<xs:complexType name="Issuer">
  <xs:sequence>
    <xs:element name="name" type="xs:string"/>
    <xs:element name="email" type="xs:string" minOccurs="0"/>
    <xs:element name="web" type="xs:string" minOccurs="0"/>
    <!-- 🔴 证书作为 XML 的普通字符串元素,和 name、email 平起平坐 -->
    <xs:element name="certificate" type="xs:string"/>
  </xs:sequence>
</xs:complexType>

<certificate> 就是一个普通字符串,和 <name><email> 没有任何区别。

验证签名的公钥证书,在 XML 里就是一个普通字段,谁都能填。

我有点不敢相信,继续在 JAR 里找,发现了 /META-INF/ca/license/v1_0/ca.crt

bash 复制代码
keytool -printcert -v -file ca.crt
ini 复制代码
所有者: CN=QuartzDesk.com CA, O=QuartzDesk
发布者: CN=QuartzDesk.com CA, O=QuartzDesk
签名算法名称: SHA1withRSA(禁用)  ⚠️

他们明明预置了一个证书文件!

说明官方知道"需要预置证书"这件事。但校验代码却从 XML 读证书,让预置证书成了摆设。

6.为什么说这是个致命设计?

逻辑链条非常简单:

markdown 复制代码
验证者拿到 License XML
    ↓
从 XML 的 <certificate> 节点读公钥证书
    ↓
用这个证书验证 XML 中的签名
    ↓
验证通过 → ✅

问题在哪?

验证签名,是为了确认"这份数据确实来自官方"。但现在验签用的公钥都可以被替换,那攻击者只需要:

  1. 用自己的私钥对任意数据签名
  2. 把自己的公钥证书塞进 <certificate>
  3. 把签名塞进 <signature>

官方程序拿你的证书验你的签名,当然能通过。

数字签名有两个核心价值:

  • 真实性(数据确实来自持有私钥的人)
  • 不可否认性(签名者无法抵赖)

当验证公钥都可以被数据自己提供时,这两个价值直接归零。签名退化成消息摘要,任何人都能做一份"看起来自洽"的数据。

7.接下来就是见证奇迹的时刻

理论分析完了,实战开始。

前置说明:以下操作不修改官方 JAR 包的任何一个文件。

① 生成密钥和证书

bash 复制代码
# 生成密钥库
keytool -genkeypair -alias tomcat -keyalg RSA -keysize 2048 \
  -dname "CN=QuartzDesk.com CA, O=QuartzDesk" \
  -keypass 123456 -validity 3650 \
  -storetype jks -keystore tomcat.jks -storepass 123456

# 导出公钥证书(PEM 格式)
keytool -exportcert -rfc -alias tomcat \
  -file tomcat.crt \
  -storetype jks -keystore tomcat.jks -storepass 123456

② 生成许可证(核心代码)

这里用了官方的 LicenseSigner 类,没有改一行源码

java 复制代码
public class LicenseGenerator {
    public static void main(String[] args) throws Exception {
        ObjectFactory factory = new ObjectFactory();
        License license = factory.createLicense();
        
        // 基本信息
        license.setSerialNumber("TE21-0120-NK1H-MH20");
        license.setType(LicenseType.PERPETUAL);
        
        // 被授权人
        Licensee licensee = factory.createLicensee();
        licensee.setName("admin");
        licensee.setEmail("admin@toolsmith.pro");
        license.setLicensee(licensee);
        
        // 🔴 关键:把我们的公钥证书塞进 XML
        Issuer issuer = factory.createIssuer();
        issuer.setName("CN=QuartzDesk.com CA, O=QuartzDesk");
        issuer.setCertificate(readCertAsString("tomcat.crt"));  // 🔴 自包含!
        license.setIssuer(issuer);
        
        // 产品信息
        Product product = factory.createProduct();
        product.setId(ProductId.ID);
        product.setName("QuartzDesk Enterprise Edition");
        // ... 省略版本、FeatureSet 设置
        
        // 加载私钥,签名(用的是官方原始 LicenseSigner)
        PrivateKey privateKey = loadPrivateKey("tomcat.jks");
        LicenseSigner signer = new LicenseSigner(loadTrustedCerts());
        license = signer.sign(license, privateKey);  // 🔴 官方类,原封不动
        
        // 输出
        new LicenseWriter().write(license, new File("license.key"));
    }
}

③ 用官方的校验器加载

java 复制代码
public class LicenseManagerTest {
    public static void main(String[] args) throws Exception {
        X509Certificate cert = loadCert("tomcat.crt");
        Set<X509Certificate> trusted = new HashSet<>();
        trusted.add(cert);
        
        // 🔴 用的是官方的 LicenseManagerImpl,原封不动
        ILicenseManager<License> lm = new LicenseManagerImpl(
            new FileInputStream("license.key"),
            trusted,
            true
        );
        
        System.out.println(lm.getPrintableLicenceInfo());
    }
}

控制台输出:

text 复制代码
Serial Number: TE21-0120-NK1H-MH20
Issue Date: 2026-08-20
Type: PERPETUAL
Licensee: admin, admin@toolsmith.pro, https://toolsmith.pro
Issuer: CN=QuartzDesk.com CA, O=QuartzDesk
Licensed Products:
  id=QuartzDesk, name=QuartzDesk Enterprise Edition

校验通过。

注意:LicenseSignerLicenseManagerImpl 都是官方 JAR 包里的类,我没有改动任何一行源码。纯数据驱动。

8.这个设计错在哪?

简单画个对比:

✅ 正确做法:信任锚外置

markdown 复制代码
┌─────────────────────────────┐
│  应用程序(预置官方公钥证书)    │ ← 信任锚来自外部,不可被数据篡改
└──────────────┬──────────────┘
               │ 只用这个证书验签
               ▼
┌─────────────────────────────┐
│      License 数据            │
│  业务数据 + 签名(官方私钥)    │ ← 数据里没有证书
└─────────────────────────────┘

❌ QuartzDesk 的做法:信任锚自包含

markdown 复制代码
┌─────────────────────────────┐
│      License 数据            │
│  业务数据 + 证书 + 签名        │ ← 🔴 证书来自数据本身
└──────────────┬──────────────┘
               │ 从数据里读证书
               ▼
┌─────────────────────────────┐
│  应用程序(从 XML 读取证书)    │ ← 任何人都能替换
└─────────────────────────────┘

三句话总结:

  1. 逻辑上:验签的目的是确认"数据来自官方"。但公钥都可以被替换,验证的就不是"来自官方",而是"数据自洽"。
  2. 工程上 :官方明明预置了 ca.crt 却不用,让预置证书成了摆设。
  3. 密码学上 :信任锚(Trust Anchor)必须独立于被验证对象之外,这是数字签名能成立的前提条件。这不是"灵活设计"的问题,是必要条件。

9.如果我来设计

三条铁律:

铁律 说明
信任锚外置 验签公钥来自代码硬编码或安全分发,绝不从 License 读取
证书链校验 预置根证书,License 带的证书必须被根证书验证
算法现代化 至少 SHA256withRSA,别用 MD5 和 SHA1

推荐实现(证书链校验):

java 复制代码
public class LicenseValidator {
    // 预置根证书------这是信任锚,写死在代码里
    private static final String ROOT_CERT = 
        "-----BEGIN CERTIFICATE-----\n...";
    
    public boolean validate(License license) {
        // 1. 从 XML 读取证书(允许)
        X509Certificate cert = loadCertFromXml(license);
        
        // 2. 但必须用预置根证书验证它是否可信
        X509Certificate root = loadRootCert();
        cert.verify(root.getPublicKey());  // 🔴 必须通过
        
        // 3. 再用这个(已验证可信的)证书验签
        return verifySignature(license, cert);
    }
}

10.说几句掏心窝的话

复盘完整条链路:

markdown 复制代码
看到 LicenseSigner(名字不对劲)
    ↓
找到校验类(从 XML 读证书)
    ↓
查看 XSD(确认 certificate 元素存在)
    ↓
发现预置的 ca.crt(官方知道该怎么做,但没用上)
    ↓
自签证书 → 构造 XML → 签名 → 校验通过 ✅

三句话,写给三类人:

给开发者: 密码学不是业务逻辑,没有"先跑起来再说"。MD5withRSA 配自包含证书,约等于纸糊保险柜。

给架构师: 判断 License 设计是否合格,问自己一个问题:如果把验签用的公钥换成攻击者的,系统能发现吗? 不能?那重做。

给所有人: 安全不是功能,是属性。功能错了会报错,属性错了没人告诉你,直到出事那天。

信任锚必须从外部注入,绝不能来自被验证对象本身。

这条规则没有例外,没有变通,没有"看场景灵活处理"。

写在最后

我把这个案例分享出来,不是嘲讽谁。

是希望更多人能意识到:一个看似"灵活"的设计,可能在安全层面埋下致命隐患。

设计授权系统时,多问自己一句:

"我这个设计,能不能被纯数据层面的方式绕过?"

如果答案是"能",那就重新画图。

聊两句:你遇到过类似的自引用式安全设计吗?JWT 的 none 算法攻击、XML 签名包裹攻击也算。评论区见👇

相关推荐
wuminyu1 小时前
JDK21 FFM异步读取MSG_ZEROCOPY错误队列揭秘
java·linux·c语言·jvm·c++
存在morning1 小时前
【PySpark 学习笔记 三】DataFrame API 入门
android·java·数据库
AC赳赳老秦1 小时前
企业标签体系搭建:基于 OpenClaw 采集的多维度公开数据,构建企业画像标签库
java·运维·服务器·python·信息可视化·deepseek·openclaw
智嵌研习社1 小时前
Ollama 本地大模型完全配置指南:Modelfile 参数与系统环境变量深度解析
java·开发语言
Crawl1 小时前
5.登录与分页功能分析
java·后端
今天AI了吗2 小时前
AI Agent 在数据分析领域的落地判断:哪些场景真的需要 Agent
java·数据库·人工智能·python·sql·数据分析·copilot
SimonKing2 小时前
SwitchHosts V5大改版,我发现了这些惊喜和坑
java·后端·程序员
山甫aa2 小时前
日志技术 Logback + Slf4j —— 从零开始的 Web 后端学习
java·后端·学习·web·logback
韶博雅2 小时前
开启补充日志
java·开发语言·sql