这篇文章记录了我从发现端倪到完整复现漏洞的全过程。如果你从事 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?不应该是 LicenseValidator 或 LicenseVerifier 吗?
一个做许可证校验的 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 中的签名
↓
验证通过 → ✅
问题在哪?
验证签名,是为了确认"这份数据确实来自官方"。但现在验签用的公钥都可以被替换,那攻击者只需要:
- 用自己的私钥对任意数据签名
- 把自己的公钥证书塞进
<certificate> - 把签名塞进
<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
校验通过。
注意:LicenseSigner、LicenseManagerImpl 都是官方 JAR 包里的类,我没有改动任何一行源码。纯数据驱动。
8.这个设计错在哪?
简单画个对比:
✅ 正确做法:信任锚外置
markdown
┌─────────────────────────────┐
│ 应用程序(预置官方公钥证书) │ ← 信任锚来自外部,不可被数据篡改
└──────────────┬──────────────┘
│ 只用这个证书验签
▼
┌─────────────────────────────┐
│ License 数据 │
│ 业务数据 + 签名(官方私钥) │ ← 数据里没有证书
└─────────────────────────────┘
❌ QuartzDesk 的做法:信任锚自包含
markdown
┌─────────────────────────────┐
│ License 数据 │
│ 业务数据 + 证书 + 签名 │ ← 🔴 证书来自数据本身
└──────────────┬──────────────┘
│ 从数据里读证书
▼
┌─────────────────────────────┐
│ 应用程序(从 XML 读取证书) │ ← 任何人都能替换
└─────────────────────────────┘
三句话总结:
- 逻辑上:验签的目的是确认"数据来自官方"。但公钥都可以被替换,验证的就不是"来自官方",而是"数据自洽"。
- 工程上 :官方明明预置了
ca.crt却不用,让预置证书成了摆设。 - 密码学上 :信任锚(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 签名包裹攻击也算。评论区见👇