Halo Pro 许可证激活缺陷导致无效许可证仍可激活
背景
Halo 是一个开源 CMS: https://github.com/halo-dev/halo
做这个只是出于好奇, 折腾一下觉得有意思的东西, 就像我之前写过的这篇文章: 通过 Hook 添加 Dialect 访问 Halo HTML Template
早在 2025 年就想搞了 , 不过搁置了很长一段时间.
过了一年, 又想起来了, 于是在 2026 年 4 月写了个草稿. 现在终于写完发布.
免责声明
本文发布时, 问题已经被修复. 本文提到的方法现在已经无法使用.
我从未发布或传播过任何可以访问 Halo Pro 的破解程序, 也从未利用相关方法牟利.
本文仅用于技术分享.
请勿使用本文中的技术或代码绕过许可证验证, 未经授权访问付费功能, 请通过官方渠道购买 Halo Pro.
环境准备
工具
- IDEA
- Gradle
- Docker
字节码
为了方便分析, 可以先下载 JAR, 并将其中的类作为编译期依赖使用, 以便进行审计和调试.
由于 Halo 是 Spring Boot 应用, 其应用程序类位于 BOOT-INF/classes 下, 而不是 JAR 的根目录.
因此, 需要重新打包这个目录, 让其变成一个普通的 JAR, 这样才可以作为常规的编译期依赖使用. 在这个过程中也可以删除一些不需要的资源.
cd halo-pro/BOOT-INF/classes
jar cf ../../../halo-pro.jar .
如果使用 Gradle, 可以将重新打包后的 JAR 添加为 compileOnly 依赖:
compileOnly(fileTree("libs"))
现在就可以使用 Cmd + Left Click 跳转到类和方法的定义, 同时也可以进行断点调试.
Docker 镜像
Halo 官方提供了一个用于插件开发的开发工具, 可以配合 Halo 的 Docker 镜像使用. 不过, 该工具只支持社区版镜像, 例如:
halohub/halo:xxx
而 Halo Pro 的 Docker 镜像标签为:
halohub/halo-pro:xxx
由于标签格式与社区版不同, 开发工具无法识别 Halo Pro 镜像.
因此, 需要重新打一个标签:
docker tag halohub/halo-pro:2.x.x halohub/halo:2.x
docker rmi halohub/halo-pro:2.x.x
分析
Halo Pro
如果提供一个无效的许可证, 并且预期签名验证会失败, 会发生什么?
我本身拥有一个有效的许可证. 因此, 我知道许可证实际上是Base64 编码的 JSON.
这个许可证是带有签名的, 正常情况下是不可以篡改内容的.
但我决定改一下试试. 所以将它解码之后修改了一些字段, 例如过期时间.
将其用于激活后, 前端显示 Halo Pro 已经激活, 但同时提示了一个异常:
Reason: ReportLicenseError - invalid license code-signature verification failed: crypto/rsa: verification error. The next retry will be at ...

从前端上看 Halo Pro 已经处于激活状态.
我检查了一下付费功能, 发现这些功能竟然已经可以使用了.
这说明 Halo 的激活流程中可能存在问题.
于是, 我在控制台日志中搜索了这条异常信息, 并找到了对应的调用栈.
异常传递如下:
run.halo.app.license.LicenseServiceImpl
-> run.halo.app.license.ActivationReconciler
这两个类自然就是最优先分析的目标.
断点调试之后, 我找到了为什么在签名验证失败之后, 激活状态仍然保持为 active 的原因.
首先说明一下, Reconciler 是一个同步器, 会周期性地同步 Halo 的内部状态. ActivationReconciler 的职责就是同步激活状态. 其会使用 LicenseService 对许可证进行处理和验证, 根据验证结果更新激活状态.
许可证的激活流程大致如下:
导入 License
-> ActivationReconciler#reconcile
-> 从数据库获取 Activation
-> ActivationReconciler#resolveState
-> 检查 License 是否符合预期的数据结构
-> LicenseService#reportLicense
-> 验证 / 上报 License
-> 更新激活信息
signature verification failed 这个异常实际上并不是在最开始的验证阶段抛出的. 而是在 reportLicense 中抛出的, 因为真正的签名验证是在 reportLicense 中执行的.
而在异常发生之前, 激活状态就已经被设置成了 active. 当签名验证失败抛出异常时, 并没在异常处理的时候正确回滚这个状态.
因此, 即使签名验证失败, Halo 仍然会保持激活状态.
相关代码如下:
private Reconciler.Result resolveState(Activation activation) {
// skipped
if (licenseData.isOffline()) {
boolean valid = this.licenseService.validateOfflineLicenseData(licenseData);
if (!valid) {
// skipped
return null;
}
}
// skipped
if (productOpt.isEmpty()) {
// skipped
return null;
} else {
// skipped
status.setState(State.active); // 此时 Activation 状态已经被设置为 active
if (expiresAt != null && expiresAt.isBefore(this.clock.instant())) {
// skipped
return null;
} else {
// skipped
try {
// 发生异常的位置
LicenseReportResponse reportResponse =
(LicenseReportResponse) this.licenseService
.reportLicense(activationCode)
.block(Duration.ofMinutes(1L));
// skipped
return null;
} catch (Exception e) {
// 在这个异常处理中并没有正确的将状态设置为 unknow, 只在 retries 大于 15 时才设置, 其他情况直接返回重试或者 null
if (licenseData.isOffline()) {
log.debug("Skip failure if it is an offline license");
return null;
} else {
// skipped
int retries = status.getRetries();
if (retries >= 15) {
// skipped
status.setState(State.unknown);
return null;
} else {
// skipped
return Result.requeue(retryAfter);
}
}
}
}
}
// skipped
}
实际上激活没有失效是暂时的, 如果激活失败, 激活流程会不断重试. 当重试次数达到 15 次之后, 激活状态会被修改为 unknown.
那么, 怎么解决这个问题?
我注意到许可证存在一种特殊类型: offline license.
离线许可证的处理流程略有不同:
if (licenseData.isOffline()) {
log.debug("Skip failure if it is an offline license");
return null;
} else {
// skipped
if (retries >= 15) {
// skipped
status.setState(State.unknown);
return null;
} else {
// skipped
}
}
可以看到当许可证是离线许可证时是直忽略这个错误的, 只有在非离线许可证才设置 unknown.
所以用离线许可证激活后, 永远不会被设置成 unknown.
这里实际上并不是停止了重试, 相反, 重试会因为这样永远继续下去. 但是由于没有在许可证为离线许可证时设置
unknown, 所以会永远保持active.
因此, 只需要将许可证类型修改为 offline, 就可以避免被设置回 unknown.
补充
还可以有另一种方式, 就是将在 retries 到达 15 的时候 (可以直接将其设置为 15), 等待激活状态变为 unknown, 最后再将状态修改回 active.
此时, ActivationReconciler 将不再重试激活流程.
下面是 ActivationReconciler#reconcile 中的相关代码:
// skipped
Reconciler.Result result = Result.doNotRetry();
// skipped
if (result == null && status.getState() != null && status.getState() == State.active) {
result = new Reconciler.Result(true, Duration.ofDays(1L));
}
this.client.update(activation);
return result;
因为重试次数已经达到 15, 激活状态会被修改为 unknown, 并且 resolveState 会返回 null.
由于 resolveState 返回了 null, 并且状态已经被修改成了 unknown, 下面这个条件就不会成立:
result == null && status.getState() != null && status.getState() == State.active
ActivationReconciler#reconcile 中会根据这个表达式的值来决定要不要继续重试, 于是在任一情况满足的就不会再进行重试:
result != nullstatus.getState() != State.active
在重试达到 15 次的时候会设置 state 为unkonwn, 满足 status.getState() != State.active这个条件, 导致上述表达式不成立, 从而使reconcile返回Result#doNotRetry() 不再重试
流程如下:
retries 到达 15
|
v
Activation 状态变为 unknown
|
v
将 Activation 状态修改为 active
|
v
ActivationReconciler 返回 Result#doNotRetry()
|
v
Activation 保持 active
付费插件
Halo 还提供了一些付费插件.
在激活有效的 Halo Pro 许可证之后, 就可以使用这些插件.
不过, 用上面的方法是没办法启用付费插件而, 因为:
付费插件会执行自己的许可证验证.
为了分析插件的许可证验证机制, 需要先准备一个付费插件.
遗憾的是, 在没有有效许可证的情况下无法下载付费插件.
总结
问题根本原因是:
在许可证签名验证完成之前, 激活状态就已经被设置成了 active, 而当验证失败并抛出异常时, 并没有正确回滚该状态.
因此, 即使许可证签名无效, 激活状态仍然可能保持为 active.
重试机制又进一步引入了另外两个问题:
问题一
当重试次数达到 15 次之后, Reconciler 会停止重试.
这意味着, 一个无效的激活状态可以继续保留在数据库中, 并且不会再次验证的流程.
问题二
在验证失败的时候, 没有对离线许可证激活的情况回滚激活状态, 导致一直有效.
Comments