事情是这样的:最近我需要监控多台机器上的 Quartz Job,搜到了一个叫QuartzDesk的企业级监控平台。下载体验后,功能确实强大,但它的许可证保护机制……怎么说呢,形同虚设。
问题出在哪?出在一个非常低级的设计错误上 :许可证文件里自带了用于验证它的证书。
这篇文章,我就带你完整复盘一下这个漏洞的发现过程和攻击复现。希望能让更多开发者意识到:密码学和数字签名不是"能跑就行"的业务逻辑,每一步都有必须遵守的铁律。
放心,全程有图有码有真相,你可以跟着复现。
1.第一现场:反编译发现端倪
先说下背景,QuartzDesk 的许可证校验逻辑封装在一个独立的 JAR 包里。
从官网下载quartzdesk-web-6.0.2.war后解压,在WEB-INF/lib目录下找到了quartzdesk-license-10.0.1.jar。掏出JD-GUI反编译,直接浏览类列表。
好家伙,我看到了什么?
publicclassLicenseSigner{// ...publicLicensesign(LicenseparamLicense,PrivateKeyparamPrivateKey)throwsLicenseException{// 签名逻辑}}等等,LicenseSigner?一般这种工具类的命名不应该是LicenseValidator或LicenseVerifier吗?你一个做许可证校验的 JAR 包,核心类叫 “Signer” ?
名字和职责对不上,直觉告诉我这里有故事。
带着疑惑往下翻,这个类的sign方法里,核心代码是这样的:
Signaturesignature=Signature.getInstance("MD5withRSA");Stringstr1=a.a(paramLicense);signature.initSign(paramPrivateKey);signature.update(str1.getBytes(StandardCharsets.UTF_8));byte[]arrayOfByte=signature.sign();Stringstr=f.a(arrayOfByte);paramLicense.setSignature(str);嗯,标准的 RSA 签名流程:获取摘要 → 初始化私钥 → 签名 → 转 Base64 → 塞进 License 对象。
但问题来了:这个 JAR 包里不应该只有"签名"功能,一定还有"验签"功能。验签的逻辑在哪?
搜一下Signature关键字,果然找到了真正验签的地方。
2.顺藤摸瓜:找到真正的校验逻辑
搜索Signature关键字后,在一个匿名内部类b中找到了验签的核心代码。我把关键部分提取出来:
publicvoida(LicenseparamLicense)throwsLicenseException{// 1. 校验 XML 格式b(paramLicense);// 2. 从 License 中提取证书字符串Stringstr=paramLicense.getIssuer().getCertificate();CertificateFactorycertificateFactory=CertificateFactory.getInstance("X.509");ByteArrayInputStreambyteArrayInputStream=newByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8));X509Certificatex509Certificate=(X509Certificate)certificateFactory.generateCertificate(byteArrayInputStream);// 3. 用这个证书去验签a(paramLicense,x509Certificate);}privatevoida(LicenseparamLicense,CertificateparamCertificate)throwsLicenseException{byte[]arrayOfByte=f.a(paramLicense.getSignature());Signaturesignature=Signature.getInstance("MD5withRSA");Stringstr=a.a(paramLicense);// 待签名数据signature.initVerify(paramCertificate.getPublicKey());signature.update(str.getBytes(StandardCharsets.UTF_8));booleanbool=signature.verify(arrayOfByte);if(!bool){thrownewLicenseException("License signature is invalid");}}看到这里,我眉头一皱:证书是从哪里来的?
Stringstr=paramLicense.getIssuer().getCertificate();paramLicense是解析 XML 后得到的 Java 对象,getIssuer().getCertificate()返回的是一个字符串 :证书是从 License 文件里读出来的。
也就是说,校验流程是这样的:
License XML 文件 ├── 业务数据(序列号、有效期、产品信息...) ├── 签名(Base64 字符串) └── 证书(PEM 格式公钥证书) ← 用于验证上面那个签名验签用的公钥证书,居然和被验签的数据在同一个文件里。
为了进一步确认,我需要找到 License 的 XML Schema 定义,看<certificate>元素是不是真的在 XSD 里定义了。
3.核心漏洞:证书竟然在 XML 里?
为了确认这个猜测,我去翻了META-INF/xsd/license/v1_0/License.xsd文件。同目录下还有个xjc-bindings.xjb,这是典型的 JAXB 映射配置,说明官方就是用这套 XSD 生成 Java Bean 和 XML 结构的。
打开 XSD,找到<Issuer>类型定义:
<xs:complexTypename="Issuer"><xs:sequence><xs:elementname="name"type="xs:string"/><xs:elementname="email"type="xs:string"minOccurs="0"/><xs:elementname="web"type="xs:string"minOccurs="0"/><!-- ⚠️ 注意这里:证书作为 XML 的一个普通元素! --><xs:elementname="certificate"type="xs:string"/></xs:sequence></xs:complexType>看到这一行,我直接绷不住了。
<certificate>作为 XML 的一个普通字符串元素,没有任何特殊保护,和<name>、<email>平起平坐。它存在的唯一用途,就是在验签时被读出来,验证同文件里的那个签名。
那我们来整理一下这个设计的逻辑链条:
验证者拿到 License XML ↓ 从 XML 的 <issuer><certificate> 节点读取公钥证书 ↓ 用这个证书去验证 XML 中 <signature> 节点的签名 ↓ 签名验证通过 → 许可证合法 ✅看出来问题在哪了吗?
验证签名的钥匙(公钥证书),是数据本身提供的。
这意味着什么?任何人都可以:
- 用自己的私钥对任意数据签名
- 把自己的公钥证书塞进
<certificate>节点 - 把签名塞进
<signature>节点 - 官方程序用你的证书验证你的签名 → 通过 ✅
签名机制退化成消息摘要,数字签名失去了"信任锚"这个核心价值。
为了进一步确认,我找到了官方预置的证书文件/META-INF/ca/license/v1_0/ca.crt,查看其信息:
keytool-printcert-v-fileca.crt所有者: CN=QuartzDesk.com CA, O=QuartzDesk 发布者: CN=QuartzDesk.com CA, O=QuartzDesk 序列号: 1 生效时间: Tue Feb 07 22:31:18 GMT+08:00 2012 失效时间: Wed Dec 31 22:31:18 GMT+08:00 2036 证书指纹: SHA1: 01:B4:58:9C:41:55:86:37:6B:9F:FF:27:DE:FF:8E:BE:5B:62:6E:1E SHA256: 14:9F:50:AB:EC:2B:20:2D:D4:EE:E1:8C:0B:74:C0:59:87:47:68:2A:82:41:57:EE:45:BD:AE:EC:21:25:51:CC 签名算法名称: SHA1withRSA(禁用) ⚠️这是个典型的自签名证书,且签名算法还是已被业界弃用的 SHA1withRSA。但这些都不是最致命的问题 :最致命的问题是,官方明明预置了证书文件,却在校验时选择信任 XML 里自带的那一份。
既然知道了漏洞原理,那下面就是见证奇迹的时刻:从零生成一个官方程序认账的许可证。
4.攻击复现:从零生成有效许可证
理论分析完了,下面是实战环节。我会带你完整走一遍从零生成有效许可证的流程。
前置说明:以下操作不需要修改官方 WAR 包的任何一个文件,纯数据驱动。
① 生成自签名密钥库
# 生成密钥库(有效期 10 年)keytool-genkeypair-aliastomcat-keyalgRSA-keysize2048\-dname"CN=QuartzDesk.com CA, O=QuartzDesk"\-keypass123456-validity3650\-storetypejks-keystoretomcat.jks-storepass123456# 导出 PEM 格式的公钥证书keytool-exportcert-rfc-aliastomcat\-filetomcat.crt\-storetypejks-keystoretomcat.jks-storepass123456执行完后得到两个文件:tomcat.jks(私钥库)和tomcat.crt(公钥证书,PEM 格式)。
② 编写 LicenseGenerator
这是核心代码,用 JAXB 构造 License XML 结构,然后用私钥签名,最后将证书和签名一并写入 XML。
publicclassLicenseGenerator{publicstaticvoidmain(String[]args)throwsException{ObjectFactoryfactory=newObjectFactory();Licenselicense=factory.createLicense();// -------- 1. 填写许可证基本信息 --------license.setSerialNumber("TE21-0120-NK1H-MH20");license.setIssueDate(getIssueDate());license.setType(LicenseType.PERPETUAL);// -------- 2. 填写被授权人信息 --------Licenseelicensee=factory.createLicensee();licensee.setName("admin");licensee.setEmail("admin@toolsmith.pro");licensee.setWeb("https://toolsmith.pro");license.setLicensee(licensee);// -------- 3. 填写签发人信息(⚠️ 关键:证书放这里!) --------Issuerissuer=factory.createIssuer();issuer.setName("CN=QuartzDesk.com CA, O=QuartzDesk");issuer.setEmail("sales@quartzdesk.com");issuer.setWeb("https://www.quartzdesk.com");// ⚠️ 把导出的公钥证书作为字符串塞进 XMLissuer.setCertificate(readCertificateAsString("tomcat.crt"));license.setIssuer(issuer);// -------- 4. 填写产品信息 --------Versionversion=factory.createVersion();version.setMajor(4);version.setMinor(3);Productproduct=factory.createProduct();product.setId(ProductId.ID);product.setName("QuartzDesk Enterprise Edition");product.setEdition(ProductEdition.ENTERPRISE.getValue());product.setVersion(version);// ... FeatureSet 设置省略license.setProducts(products);// -------- 5. 加载私钥,签名 --------KeyStorekeyStore=KeyStore.getInstance("jks");keyStore.load(newFileInputStream("tomcat.jks"),"123456".toCharArray());PrivateKeyprivateKey=(PrivateKey)keyStore.getKey("tomcat","123456".toCharArray());// ⚠️ 用的是官方原始的 LicenseSigner 类!没有改任何代码!LicenseSignersigner=newLicenseSigner(loadTrustedCertificates());license=signer.sign(license,privateKey);// -------- 6. 输出许可证文件 --------LicenseWriterwriter=newLicenseWriter();writer.write(license,newFile("license.key"));System.out.println("✅ 许可证生成成功:license.key");}}注意:LicenseSigner是官方 JAR 包里原封不动的类,我没有修改任何一行代码。
③ 用官方校验逻辑验证
写一个简单的LicenseManagerTest,加载刚才生成的license.key:
publicclassLicenseManagerTest{publicstaticvoidmain(String[]args)throwsException{// 加载我们自制的公钥证书(就是刚才导出的 tomcat.crt)CertificateFactorycf=CertificateFactory.getInstance("X.509");X509Certificatecert=(X509Certificate)cf.generateCertificate(newFileInputStream("tomcat.crt"));Set<X509Certificate>trustedCerts=newHashSet<>();trustedCerts.add(cert);// ⚠️ 用的是官方的 LicenseManagerImpl!没有改任何代码!ILicenseManager<License>lm=newLicenseManagerImpl(newFileInputStream("license.key"),trustedCerts,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, admin@toolsmith.pro, https://toolsmith.pro Issuer: CN=QuartzDesk.com CA, O=QuartzDesk, sales@quartzdesk.com, https://www.quartzdesk.com Licensed Products: id=QuartzDesk, name=QuartzDesk Enterprise Edition校验通过,许可证合法。
整个过程,我没有修改官方的任何一个.class文件,没有破解,没有 patch,没有 hook。仅仅是按照官方的"规则"生成了一份数据,它就认了。
5.为什么说这是个低级错误?
复现完成,我们来复盘一下这个设计的根本问题。
正确 vs 错误:一张图说清楚
✅ 正确的数字签名校验流程
┌─────────────────────────────────────────────────────────────┐ │ 可信第三方 │ │ (CA 机构 / 软件厂商官方预置的根证书 / 安全分发渠道) │ └──────────────────────┬──────────────────────────────────────┘ │ 预置信任锚(不可被数据篡改) ▼ ┌─────────────────────────────────────────────────────────────┐ │ 应用程序(硬编码或安全存储) │ │ 持有官方公钥证书 / CA 证书链 │ └──────────────────────┬──────────────────────────────────────┘ │ 只信任这个证书 ▼ ┌─────────────────────────────────────────────────────────────┐ │ License 数据(外部文件) │ │ ┌─────────────┬─────────────────────────┐ │ │ │ 业务数据 │ 签名(由官方私钥生成) │ │ │ └─────────────┴─────────────────────────┘ │ │ ⚠️ 数据里没有证书! │ └─────────────────────────────────────────────────────────────┘信任锚(CA 证书)外置→ 攻击者无法篡改 → 签名验证有意义。
❌ QuartzDesk 的错误做法
┌─────────────────────────────────────────────────────────────┐ │ License 数据(外部文件) │ │ ┌─────────────┬─────────────────┬──────────────────────┐ │ │ │ 业务数据 │ 公钥证书(PEM) │ 签名(任意私钥生成) │ │ │ └─────────────┴─────────────────┴──────────────────────┘ │ │ │ ▲ │ │ │ 读取证书 │ 用证书验签 │ │ ▼ │ │ │ ┌─────────────────────────────────────┐ │ │ │ 应用程序(校验逻辑) │ │ │ │ "证书从 XML 里读,然后验签" │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘信任锚自包含在数据中→ 攻击者可以替换 → 签名验证形同虚设。
这个设计错在哪?三个层次
第一层:逻辑错误
验签的目的是"确认这份数据确实来自官方"。但如果你用于验签的公钥都来自数据本身,那攻击者可以完全替换掉公钥,我用我的私钥签名,附上我的公钥,官方程序拿我的公钥验我的签名,当然能通过。这验证的不是"来自官方",而是"数据自洽"。
第二层:工程错误
官方明明在/META-INF/ca/license/v1_0/ca.crt预置了证书,说明他们知道"需要预置证书"这件事。但校验代码却优先从 XML 读取证书,让预置证书成了摆设。做了 A 却用 B,还不如不做。
第三层:密码学基础错误
数字签名的核心价值是“可信第三方 + 不可否认性”。可信第三方(信任锚)必须独立于被签名的数据之外,否则信任链条从根上就断了。这不是"灵活设计"的问题,是数字签名能成立的必要条件。
一句话总结
信任锚必须从外部可信源注入,绝不能来自被验证对象本身。
6.如果我来设计,会怎么做?
说完了错误示范,再说说正确做法。如果让我来设计这个许可证校验机制,我会这样做:
设计原则(三条铁律)
| 铁律 | 说明 |
|---|---|
| 信任锚外置 | 验签用的公钥证书必须来自代码内部或安全分发渠道,绝不能从 License 文件读取 |
| 证书链校验 | 预置根证书,License 携带的证书必须能被根证书验证(而非直接信任) |
| 算法现代化 | 至少使用 SHA256withRSA,弃用 MD5 和 SHA1 |
具体实现方案
方案一:证书硬编码(最简单)
publicclassLicenseValidator{// ✅ 证书直接写在代码里,随 JAR 发布privatestaticfinalStringPUBLIC_CERT="-----BEGIN CERTIFICATE-----\n"+"MIIDBTCCAe2gAwIBAgIIOmjA5dVKV9MwDQYJKoZIhvcNAQELBQAwMTETMBEGA1UE\n"+// ... 完整证书"-----END CERTIFICATE-----";publicbooleanvalidate(Licenselicense){// 只用这个证书验签,绝不从 XML 读取X509Certificatecert=loadCertFromString(PUBLIC_CERT);returnverifySignature(license,cert);}}优点:攻击者无法替换,除非反编译改代码(但这就属于破解范畴了,不是"数据驱动"能解决的问题)
缺点:更换证书需要重新发版
方案二:证书链校验(推荐)
publicclassLicenseValidator{// 预置根证书(自签名,作为信任锚)privatestaticfinalStringROOT_CERT="-----BEGIN CERTIFICATE-----\n...";publicbooleanvalidate(Licenselicense){// 1. 从 XML 读取证书(允许)X509Certificatecert=loadCertFromXml(license);// 2. 但必须要用预置的根证书验证这个证书是否可信X509Certificateroot=loadRootCert();cert.verify(root.getPublicKey());// ⚠️ 必须通过根证书校验// 3. 再用这个(已验证可信的)证书验签returnverifySignature(license,cert);}}优点:允许 License 携带子证书,但子证书必须由官方的根证书签发,攻击者无法伪造
缺点:需要管理证书签发流程
方案三:非对称加密 + 对称加密结合
- License 文件用 AES 加密,密钥由 RSA 公钥加密后附在文件中
- 只有拥有私钥的官方才能生成合法 License
- 校验时用预置公钥解出 AES 密钥,再解密 License
这种方式更复杂,但安全性更高。
补充一句
以上方案都不算"绝对安全":只要有足够的时间和技术手段,任何客户端保护机制都能被破解。但攻破有成本和门槛。
QuartzDesk 的问题不在于"能被破解",而在于破解成本几乎为零。它把门槛从"逆向工程"降低到了"读 XML 规范然后写 100 行 Java 代码"。这二者有本质区别。
好的安全设计,是让攻击成本 > 攻击收益。而不是把门敞开,然后祈祷没人进来。
7.写在最后
复盘一下这次发现的完整链条:
反编译看到 LicenseSigner(名字不对劲) ↓ 找到校验类 b(从 XML 读证书) ↓ 查看 License.xsd(确认 <certificate> 元素存在) ↓ 找到预置的 ca.crt(官方知道要预置证书,但没用上) ↓ 生成自签名证书 → 构造 XML → 签名 → 校验通过 ✅整个过程,我没有修改一行官方代码,没有patch任何.class文件,没有使用任何破解工具。仅仅是按照官方设计的"规则"生成了一份数据,它就认了。
我想说的三句话
第一句:给开发者
密码学和数字签名不是"能跑就行"的业务代码。MD5withRSA 配自包含证书,约等于用纸糊了个保险柜。安全领域的"死板"规则,背后都是血的教训换来的,请尊重它们。
第二句:给架构师
做 License 校验时,请记住这个简单的判断标准:如果把验签用的公钥换成攻击者的,你的系统还能识别出 License 是伪造的吗?如果答案是"能",你的设计是对的。如果答案是"不能",这篇文章你白看了。
第三句:给所有人
安全不是功能,是属性。功能错了会报错,属性错了没人告诉你,直到出事的那一天。
后记
我把这个案例分享出来,不是要嘲讽谁,而是希望更多开发者能从中看到一个看似"灵活"的设计,如何在安全层面埋下致命隐患。
商业软件也好,开源项目也罢,涉及授权的代码都值得多花 10 分钟想一想:
“我这个设计,能不能被别人用纯数据层面的方式绕过?”
如果答案是"能",那就重新画图。
你在工作中见过哪些"自引用"式的安全设计翻车?或者,你设计过类似的 License 校验机制吗?踩过什么坑?欢迎在评论区聊聊,大家一起避坑👇