QuartzDesk License校验漏洞完整分析:一个“自包含证书“导致的签名绕过 📅 2026/8/25 5:16:17 事情是这样的最近我需要监控多台机器上的 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方法里核心代码是这样的SignaturesignatureSignature.getInstance(MD5withRSA);Stringstr1a.a(paramLicense);signature.initSign(paramPrivateKey);signature.update(str1.getBytes(StandardCharsets.UTF_8));byte[]arrayOfBytesignature.sign();Stringstrf.a(arrayOfByte);paramLicense.setSignature(str);嗯标准的 RSA 签名流程获取摘要 → 初始化私钥 → 签名 → 转 Base64 → 塞进 License 对象。但问题来了这个 JAR 包里不应该只有签名功能一定还有验签功能。验签的逻辑在哪搜一下Signature关键字果然找到了真正验签的地方。2.顺藤摸瓜找到真正的校验逻辑搜索Signature关键字后在一个匿名内部类b中找到了验签的核心代码。我把关键部分提取出来publicvoida(LicenseparamLicense)throwsLicenseException{// 1. 校验 XML 格式b(paramLicense);// 2. 从 License 中提取证书字符串StringstrparamLicense.getIssuer().getCertificate();CertificateFactorycertificateFactoryCertificateFactory.getInstance(X.509);ByteArrayInputStreambyteArrayInputStreamnewByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8));X509Certificatex509Certificate(X509Certificate)certificateFactory.generateCertificate(byteArrayInputStream);// 3. 用这个证书去验签a(paramLicense,x509Certificate);}privatevoida(LicenseparamLicense,CertificateparamCertificate)throwsLicenseException{byte[]arrayOfBytef.a(paramLicense.getSignature());SignaturesignatureSignature.getInstance(MD5withRSA);Stringstra.a(paramLicense);// 待签名数据signature.initVerify(paramCertificate.getPublicKey());signature.update(str.getBytes(StandardCharsets.UTF_8));booleanboolsignature.verify(arrayOfByte);if(!bool){thrownewLicenseException(License signature is invalid);}}看到这里我眉头一皱证书是从哪里来的StringstrparamLicense.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:complexTypenameIssuerxs:sequencexs:elementnamenametypexs:string/xs:elementnameemailtypexs:stringminOccurs0/xs:elementnamewebtypexs:stringminOccurs0/!-- ⚠️ 注意这里证书作为 XML 的一个普通元素 --xs:elementnamecertificatetypexs:string//xs:sequence/xs:complexType看到这一行我直接绷不住了。certificate作为 XML 的一个普通字符串元素没有任何特殊保护和name、email平起平坐。它存在的唯一用途就是在验签时被读出来验证同文件里的那个签名。那我们来整理一下这个设计的逻辑链条验证者拿到 License XML ↓ 从 XML 的 issuercertificate 节点读取公钥证书 ↓ 用这个证书去验证 XML 中 signature 节点的签名 ↓ 签名验证通过 → 许可证合法 ✅看出来问题在哪了吗验证签名的钥匙公钥证书是数据本身提供的。这意味着什么任何人都可以用自己的私钥对任意数据签名把自己的公钥证书塞进certificate节点把签名塞进signature节点官方程序用你的证书验证你的签名 → 通过 ✅签名机制退化成消息摘要数字签名失去了信任锚这个核心价值。为了进一步确认我找到了官方预置的证书文件/META-INF/ca/license/v1_0/ca.crt查看其信息keytool-printcert-v-fileca.crt所有者: CNQuartzDesk.com CA, OQuartzDesk 发布者: CNQuartzDesk.com CA, OQuartzDesk 序列号: 1 生效时间: Tue Feb 07 22:31:18 GMT08:00 2012 失效时间: Wed Dec 31 22:31:18 GMT08: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\-dnameCNQuartzDesk.com CA, OQuartzDesk\-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{ObjectFactoryfactorynewObjectFactory();Licenselicensefactory.createLicense();// -------- 1. 填写许可证基本信息 --------license.setSerialNumber(TE21-0120-NK1H-MH20);license.setIssueDate(getIssueDate());license.setType(LicenseType.PERPETUAL);// -------- 2. 填写被授权人信息 --------Licenseelicenseefactory.createLicensee();licensee.setName(admin);licensee.setEmail(admintoolsmith.pro);licensee.setWeb(https://toolsmith.pro);license.setLicensee(licensee);// -------- 3. 填写签发人信息⚠️ 关键证书放这里 --------Issuerissuerfactory.createIssuer();issuer.setName(CNQuartzDesk.com CA, OQuartzDesk);issuer.setEmail(salesquartzdesk.com);issuer.setWeb(https://www.quartzdesk.com);// ⚠️ 把导出的公钥证书作为字符串塞进 XMLissuer.setCertificate(readCertificateAsString(tomcat.crt));license.setIssuer(issuer);// -------- 4. 填写产品信息 --------Versionversionfactory.createVersion();version.setMajor(4);version.setMinor(3);Productproductfactory.createProduct();product.setId(ProductId.ID);product.setName(QuartzDesk Enterprise Edition);product.setEdition(ProductEdition.ENTERPRISE.getValue());product.setVersion(version);// ... FeatureSet 设置省略license.setProducts(products);// -------- 5. 加载私钥签名 --------KeyStorekeyStoreKeyStore.getInstance(jks);keyStore.load(newFileInputStream(tomcat.jks),123456.toCharArray());PrivateKeyprivateKey(PrivateKey)keyStore.getKey(tomcat,123456.toCharArray());// ⚠️ 用的是官方原始的 LicenseSigner 类没有改任何代码LicenseSignersignernewLicenseSigner(loadTrustedCertificates());licensesigner.sign(license,privateKey);// -------- 6. 输出许可证文件 --------LicenseWriterwriternewLicenseWriter();writer.write(license,newFile(license.key));System.out.println(✅ 许可证生成成功license.key);}}注意LicenseSigner是官方 JAR 包里原封不动的类我没有修改任何一行代码。③ 用官方校验逻辑验证写一个简单的LicenseManagerTest加载刚才生成的license.keypublicclassLicenseManagerTest{publicstaticvoidmain(String[]args)throwsException{// 加载我们自制的公钥证书就是刚才导出的 tomcat.crtCertificateFactorycfCertificateFactory.getInstance(X.509);X509Certificatecert(X509Certificate)cf.generateCertificate(newFileInputStream(tomcat.crt));SetX509CertificatetrustedCertsnewHashSet();trustedCerts.add(cert);// ⚠️ 用的是官方的 LicenseManagerImpl没有改任何代码ILicenseManagerLicenselmnewLicenseManagerImpl(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, admintoolsmith.pro, https://toolsmith.pro Issuer: CNQuartzDesk.com CA, OQuartzDesk, salesquartzdesk.com, https://www.quartzdesk.com Licensed Products: idQuartzDesk, nameQuartzDesk 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-----\nMIIDBTCCAe2gAwIBAgIIOmjA5dVKV9MwDQYJKoZIhvcNAQELBQAwMTETMBEGA1UE\n// ... 完整证书-----END CERTIFICATE-----;publicbooleanvalidate(Licenselicense){// 只用这个证书验签绝不从 XML 读取X509CertificatecertloadCertFromString(PUBLIC_CERT);returnverifySignature(license,cert);}}优点攻击者无法替换除非反编译改代码但这就属于破解范畴了不是数据驱动能解决的问题缺点更换证书需要重新发版方案二证书链校验推荐publicclassLicenseValidator{// 预置根证书自签名作为信任锚privatestaticfinalStringROOT_CERT-----BEGIN CERTIFICATE-----\n...;publicbooleanvalidate(Licenselicense){// 1. 从 XML 读取证书允许X509CertificatecertloadCertFromXml(license);// 2. 但必须要用预置的根证书验证这个证书是否可信X509CertificaterootloadRootCert();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 校验机制吗踩过什么坑欢迎在评论区聊聊大家一起避坑