news 2026/8/25 5:16:10

QuartzDesk License校验漏洞完整分析:一个“自包含证书“导致的签名绕过

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
QuartzDesk License校验漏洞完整分析:一个“自包含证书“导致的签名绕过

事情是这样的:最近我需要监控多台机器上的 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?一般这种工具类的命名不应该是LicenseValidatorLicenseVerifier吗?你一个做许可证校验的 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> 节点的签名 ↓ 签名验证通过 → 许可证合法 ✅

看出来问题在哪了吗?

验证签名的钥匙(公钥证书),是数据本身提供的。

这意味着什么?任何人都可以:

  1. 用自己的私钥对任意数据签名
  2. 把自己的公钥证书塞进<certificate>节点
  3. 把签名塞进<signature>节点
  4. 官方程序用你的证书验证你的签名 → 通过 ✅

签名机制退化成消息摘要,数字签名失去了"信任锚"这个核心价值。

为了进一步确认,我找到了官方预置的证书文件/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 校验机制吗?踩过什么坑?欢迎在评论区聊聊,大家一起避坑👇

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/25 5:14:09

DeepSeek 新工具怎么用?小白先过这三关

8 月 13 日&#xff0c;DeepSeek 把 Harness v0.1 开发者预览版放了出来&#xff0c;源码同时开放。先把这个拗口的名字扔一边&#xff1a;它不是新模型&#xff0c;也不是另一个聊天网页&#xff0c;而是一张 让 AI 在电脑文件夹里连续干活的工作台。 选好文件夹后&#xff0c…

作者头像 李华
网站建设 2026/8/25 5:13:33

MiniMaxH3本地部署指南:ComfyUI环境配置与低显存优化实战

最近在尝试将AI视频生成能力整合到本地工作流时&#xff0c;发现MiniMaxH3模型因其出色的图生视频效果备受关注。然而&#xff0c;从模型下载、环境配置到最终在ComfyUI中稳定运行&#xff0c;整个过程涉及多个环节&#xff0c;任何一个步骤出错都可能导致推理失败或显存爆炸。…

作者头像 李华
网站建设 2026/8/25 5:12:56

TencentOS AI增强版深度测评:从零体验AI运维副驾驶实战能力

1. 项目概述&#xff1a;当传统运维遇上AI副驾驶最近在服务器运维圈子里&#xff0c;TencentOS AI 增强版成了一个挺热的话题。作为一个常年和Linux命令行、监控告警、故障排查打交道的运维工程师&#xff0c;我第一次看到“一句话就能运维服务器”这个宣传时&#xff0c;心里是…

作者头像 李华
网站建设 2026/8/25 5:12:19

JavaScript短路运算与空值合并:原理、应用与面试解析

1. 为什么前端面试总爱问&&和||&#xff1f;2026年的前端面试依然在反复考察这两个基础运算符&#xff0c;原因很简单&#xff1a;它们看似简单&#xff0c;却能暴露候选人对JavaScript执行机制的理解深度。我见过太多工作3年的开发者还在用if (a && b)的固定模…

作者头像 李华
网站建设 2026/8/25 5:12:17

算法面试准备度评估:从刷题数量到能力边界的实战指南

这次我们来看一个所有准备软件工程师&#xff08;SDE&#xff09;面试的人都绕不开的核心问题&#xff1a;算法刷题到底要刷到什么程度&#xff0c;才敢去投简历、去面试&#xff1f;这不是一个关于“刷多少题”的简单数字问题&#xff0c;而是一个关于“能力边界”和“面试策略…

作者头像 李华
网站建设 2026/8/25 5:11:52

品牌全案方法论(下):全链路交付的九个零件

品牌全案方法论提到了全链路交付的九个核心环节、这些环节紧密相连无缝运营。通过精准的市场需求分析和目标受众识别&#xff0c;企业能够明确品牌定位&#xff0c;制定有效的传播策略。在实施过程中&#xff0c;核心是强化品牌与用户互动、通过实时反馈优化用户体验。这一方法…

作者头像 李华