toolsmith
发布于 2026-08-21 / 26 阅读
0

License校验的反模式:从QuartzDesk漏洞看“自包含签名”为何不可取

最近在监控多台服务器上的 Quartz 调度任务时,我在 Google 上搜到了一个叫 QuartzDesk 的商业解决方案。下载试用后,功能确实强大:但随之而来的一个发现,却让我有些意外。

QuartzDesk 是一个企业级的 Quartz Scheduler 管理与监控平台,号称“无侵入集成”,开发者无需修改应用程序代码即可接入。它分别面向开发、测试、运维三类角色提供作业调试、执行历史追溯和集群监控能力。从产品设计上看,它是一款成熟的企业软件。

然而,当我随手解压它的 WAR 包、看了一眼其中的许可证校验模块后,却意识到:这款功能强大的产品,其核心安全防线的License 保护机制却存在着一个致命的、教科书级别的设计错误

这个错误与代码质量无关,甚至与算法强度无关。它关乎的是数字签名应用中一条最基本、却最容易被忽视的规则:验证签名所用的信任锚(Trust Anchor),绝对不能来自被验证的数据本身。

以下是我对该漏洞的完整分析过程与复现验证。

一、初探:从类名到代码的意外发现

从 QuartzDesk 官网下载 quartzdesk-web-6.0.2.war,解压后在 WEB-INF/lib 目录下找到 quartzdesk-license-10.0.1.jar,这是许可证校验的核心模块。

用 JD-GUI 打开这个 JAR 包,第一眼就看到一个名为 LicenseSigner 的类。这里已经出现了一个值得留意的信号:在 License 校验场景中,常见的类名通常是 LicenseValidatorLicenseVerifier,而 Signer 暗示的是签名生成职责。一个负责验证 License 合法性的模块,为什么会出现签名工具?这种职责混淆,往往是设计问题的前兆。

打开 LicenseSigner 类,代码出乎意料地简洁:

public License sign(License paramLicense, PrivateKey paramPrivateKey) throws LicenseException {
    Signature signature = Signature.getInstance("MD5withRSA");
    String data = a.a(paramLicense);        // 将 License 对象转为待签名字符串
    signature.initSign(paramPrivateKey);
    signature.update(data.getBytes(StandardCharsets.UTF_8));
    byte[] signed = signature.sign();
    String signatureStr = f.a(signed);      // Base64 编码
    paramLicense.setSignature(signatureStr);
    // 关键:此处调用了验证器,将刚刚签名的 License 再次校验
    b verifier = new b(this.a);
    verifier.a(paramLicense);
    return paramLicense;
}

这段代码的逻辑很直接:用 RSA 私钥对 License 数据签名,然后将签名附回到 License 对象中。但注意最后两行:它在签名完成后立即调用了验证器 b 对结果进行校验。这说明 b 才是真正的验证入口。

于是,我搜索 Signature 关键字的调用位置,定位到了这个内部验证器 b。其核心验证逻辑如下:

private void a(License paramLicense, Certificate paramCertificate) throws LicenseException {
    byte[] signatureBytes = f.a(paramLicense.getSignature());   // Base64 解码
    Signature sig = Signature.getInstance("MD5withRSA");
    String data = a.a(paramLicense);                             // 同款序列化
    sig.initVerify(paramCertificate.getPublicKey());
    sig.update(data.getBytes(StandardCharsets.UTF_8));
    boolean valid = sig.verify(signatureBytes);
    if (!valid) {
        throw new LicenseException("License signature is invalid.");
    }
}

验证逻辑本身没有语法错误,算法(MD5withRSA)虽然陈旧但并非不可用。问题在于:这个 paramCertificate 是从哪里来的?

顺着调用链追溯,发现验证器 b 在验证前,会先从 License XML 中读取一个名为 <certificate> 的元素,将其解析为 X509Certificate 对象,然后传入上述验证方法。也就是说,验证签名所用的公钥证书,是从待验证的 License 文件中读取的

这相当于什么呢?相当于你收到一张钞票,然后用钞票上自己印的水印图案来鉴别这张钞票的真伪。

二、漏洞本质:“自包含证书”的致命设计

要理解这个问题的严重性,我们先回顾一下数字签名的基本逻辑。

数字签名解决两个问题:完整性(数据未被篡改)和来源认证(数据确实来自声称的发送方)。前者靠摘要算法保证,后者靠公钥基础设施(PKI) 保证:验证方必须事先持有或通过可信渠道获取签名方的公钥证书,然后用这把“信任锚”去校验签名。

这是流程的常识:信任锚是事前配置的,不是事中获取的。

但 QuartzDesk 的设计恰恰违背了这一原则。根据 XSD 定义,许可证 XML 中的 <Issuer> 元素包含一个 <certificate> 子元素:

<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"/>
    <xs:element name="certificate" type="xs:string"/>   <!-- 证书内嵌于许可证数据中 -->
  </xs:sequence>
</xs:complexType>

程序在验证许可证时,执行路径如下:

  1. 加载许可证 XML 文件
  2. 解析 XML,提取 <certificate> 中的公钥证书
  3. 使用该证书初始化 Signature.verify()
  4. 用同一份 XML 数据中的签名进行比对

问题的本质: 验证过程使用了“数据自身携带的证书”去验证“数据自身的签名”。攻击者只需要做三件事:

  • 生成一对全新的 RSA 密钥
  • 用自制私钥对许可证内容签名
  • 将自制公钥证书填入 <certificate> 元素

从程序视角看,XML 格式正确、签名校验通过、证书解析成功,这一切都是“合法”的。此时,数字签名退化为消息摘要:它只能证明数据在传输过程中未被篡改,却完全失去了来源认证的能力。

值得一提的细节是,QuartzDesk 的安装包中其实已经附带了一个预置的 CA 证书(/META-INF/ca/license/v1_0/ca.crt),这是一个典型的信任锚,它本应被硬编码为唯一合法的验证凭证。但最终,程序却放弃了使用这个信任锚,转而采用 XML 中用户可控的证书作为验证依据。

这并非算法强度不够,而是信任模型的设计错误。用一个通俗的类比来收束这段分析:

你在银行办业务,柜员要求你出示身份证。你递上一张身份证,柜员看了一眼说:“行,这张身份证上的照片跟你本人挺像的,我信了。”柜员没有去公安系统核对,而是让你提供的身份证自己证明自己。QuartzDesk 的校验逻辑,就是这位柜员。

三、攻击复现:一条龙生成有效许可证

理论分析已经明确:漏洞的核心在于“证书自包含”。接下来,我用一个最小化的复现过程来验证这个结论,整个过程无需修改 QuartzDesk 的任何原生文件

第一步:生成自制密钥库与证书

# 生成密钥对(别名 tomcat,RSA-2048)
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 -keystore tomcat.jks -storepass 123456

第二步:构造许可证并签名

基于 JAXB 生成的 XML 结构,构造 License 对象,其中最关键的一步是:将自制证书(tomcat.crt)的 PEM 内容填入 <Issuer><certificate> 元素。

Issuer issuer = objectFactory.createIssuer();
issuer.setName("CN=QuartzDesk.com CA, O=QuartzDesk");
issuer.setCertificate(
    "-----BEGIN CERTIFICATE-----\n" +
    "MIIDBTCCAe2gAwIBAgIIOmjA5dVKV9MwDQYJKoZIhvcNAQEMBQAwMTETMBEGA1UE\n" +
    // ... 省略证书完整内容 ...
    "-----END CERTIFICATE-----"
);
license.setIssuer(issuer);

随后,加载自制密钥库中的私钥,调用 LicenseSigner.sign() 完成签名:

KeyStore keyStore = KeyStore.getInstance("jks");
keyStore.load(..., "123456".toCharArray());
PrivateKey privateKey = (PrivateKey) keyStore.getKey("tomcat", "123456".toCharArray());

LicenseSigner signer = new LicenseSigner(trustedCerts);
License signedLicense = signer.sign(license, privateKey);

// 输出 license.key 文件
new LicenseWriter().write(signedLicense, new File("license.key"));

第三步:验证许可证

编写验证程序,加载自制证书作为信任锚集合,读取刚才生成的 license.key

Set<X509Certificate> trustSet = new HashSet<>();
trustSet.add(loadCertificate("tomcat.crt"));

ILicenseManager<License> lm = new LicenseManagerImpl(
    LicenseManagerTest.class.getResourceAsStream("license.key"),
    trustSet,
    true
);

System.out.println(lm.getPrintableLicenceInfo());

控制台输出:

Serial Number: TE21-0120-NK1H-MH20
Issue Date: 2026-08-20
Type: PERPETUAL
Expiry Date: n/a
Licensee: admin, [email protected], https://toolsmith.pro
Issuer: CN=QuartzDesk.com CA, O=QuartzDesk, [email protected], https://www.quartzdesk.com
Licensed Products:
  id=QuartzDesk, name=QuartzDesk Enterprise Edition

校验通过。

从自制密钥对到生成被产品完全认可的“永久企业版”许可证,全程无需触碰 QuartzDesk 的 class 文件、配置文件或证书库。整个攻击面仅由一个 XML 文件驱动,这正是“自包含证书”设计带来的直接后果。攻击者只要有基本的 Java 开发环境,就能在几分钟内完成上述操作。

广告时间:虽然现在都爱用codex写代码,但是看代码及调试还是力荐JetBrains全家桶IDEs(特别是调试与定位)。想要有意识的训练看代码和调试的洞察力,还真不能全包AI。

四、教训与启示:密码学应用没有“灵活”的余地

QuartzDesk 的案例并非孤例。在软件授权、JWT 认证、XML 数字签名等场景中,“让数据自己证明自己”的思维陷阱反复出现。其根源往往不是开发者缺乏密码学知识,而是在“灵活性”与“安全性”的权衡中,错误地将安全底线当成了可以妥协的功能选项。

以下是三条可以从这个案例中直接提炼的设计原则:

原则一:信任锚必须与数据分离

验证签名所使用的公钥证书(或证书指纹),必须在代码中硬编码、或在部署环境中通过安全渠道预置。任何从待验证数据中读取信任锚的行为,都是对数字签名机制的破坏。

正确做法:

// 硬编码或从安全存储加载,而非从 license.xml 读取
X509Certificate trustedCert = loadFromTrustStore("/META-INF/ca/trusted.crt");
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(trustedCert.getPublicKey());
sig.update(licenseData.getBytes());
boolean valid = sig.verify(signatureBytes);

错误做法(QuartzDesk 的实现):

// 从 XML 中提取证书 —— 数据驱动信任
String certPem = license.getIssuer().getCertificate();
X509Certificate cert = parseCertificate(certPem);
sig.initVerify(cert.getPublicKey());  // 信任锚来自待验证数据本身

原则二:证书链校验不可省略

即使证书来自可信存储,也必须执行完整的证书链验证:有效期检查、吊销状态检查(CRL/OCSP)、密钥用法约束等。QuartzDesk 虽然对内置 CA 证书做了部分校验,但由于信任锚的获取环节已被攻破,后续校验全部形同虚设。安全链条的强度,取决于最薄弱的环节,而非最后一道关卡。

原则三:安全机制不应为“便利性”提供后门

许多产品在 License 模块中嵌入“调试模式”或“万能证书”,以便销售或技术支持人员快速生成试用许可。QuartzDesk 的设计缺陷或许也源于类似动机:通过将证书内嵌于 XML,可以免去客户导入证书的步骤,降低部署门槛。但这类“便利性”一旦被攻击者发现,便成为穿透整个安全体系的突破口。可配置的安全性,往往等于可绕过的安全性。

回顾这个案例,最值得反思的不是代码本身的质量,而是设计阶段的一个选择失误:作者已经正确地准备了 CA 证书(ca.crt),却在验证逻辑中放弃了使用它。这种“准备了钥匙却把锁装在了自己身上”的矛盾,恰恰说明安全设计不是“有就行”,而是“必须放在正确的位置”。

结语

回到开篇的问题:为什么一个成熟的企业级产品,会在 License 保护上犯如此基础的错误?

答案不在于算法强度,也不在于代码质量,而在于对密码学应用规则的认知:数字签名的信任链必须从可信源头单向延伸,不能在验证路径中自我循环。

QuartzDesk 的开发者已经做了分内之事:准备了 CA 证书、实现了 RSA 签名、设计了 XML 格式约束。但一个看似“便利”的决策,让证书随许可证一同分发却让所有这些努力形同虚设。这是一种典型的“木桶效应”:安全体系的整体强度,被最薄弱的环节所决定,而最薄弱的环节,往往是设计阶段被轻视的那条原则。

“这不是死板,而是信息安全角度必不可少的固定动作。”

密码学应用的历史上,因信任锚配置不当而导致系统被攻破的案例不胜枚举。每一个案例都在重复同一个教训:安全规范不是可选的“最佳实践”,而是必须恪守的“底线规则”。 灵活性、可扩展性、部署便利性,都可以在产品迭代中逐步优化,唯独信任模型一旦出现设计偏差,整个安全体系便失去了根基。

希望这篇分析,能让更多开发者意识到:在涉及密码学和数字签名的场景中,严格遵守规范不是死板,而是对系统安全最基本的尊重。