Halo Pro License Activation Flaw Allows Invalid Licenses to Remain Active
Background
Halo is an open-source CMS: https://github.com/halo-dev/halo
I did this simply out of curiosity, just to tinker with something I found interesting, much like what I did in this previous post: Access Halo HTML Template by Adding Dialect by Hooking
I first came up with this idea in 2025, but I shelved it for a long time.
A year later, I remembered it again and wrote a draft in April 2026. Now it is finally finished and published.
Disclaimer
By the time this post was published, the issue had already been fixed. The methods described in this post no longer work.
I have never published or distributed any cracked program that provides access to Halo Pro, nor have I ever used these methods for profit.
This post is intended solely for technical sharing.
Do not use the techniques or code in this post to bypass license verification or access paid features without authorization. Please purchase Halo Pro through the official channels.
Environment Preparation
Tools
- IDEA
- Gradle
- Docker
Bytecode
For convenience, download the JAR and use its classes as a compile-time dependency for auditing and debugging.
Because Halo is a Spring Boot application, its application classes are located under BOOT-INF/classes rather than at the root of the JAR.
Therefore, this directory needs to be repackaged into a regular JAR before it can be used as a normal compile-time dependency. Unnecessary resources can also be removed during this process.
cd halo-pro/BOOT-INF/classes
jar cf ../../../halo-pro.jar .
If using Gradle, add the repackaged JAR as a compileOnly dependency:
compileOnly(fileTree("libs"))
Now, Cmd + Left Click can be used to navigate to class and method definitions, and breakpoint debugging is also available.
Docker Image
Halo officially provides a development tool for plugin development that works with Halo's Docker image. However, this tool only supports the community edition image, for example:
halohub/halo:xxx
The Halo Pro Docker image uses a different tag:
halohub/halo-pro:xxx
Because the tag format differs from that of the community edition, the development tool cannot recognize the Halo Pro image.
Therefore, the image needs to be retagged:
docker tag halohub/halo-pro:2.x.x halohub/halo:2.x
docker rmi halohub/halo-pro:2.x.x
Analysis
Halo Pro
What happens if an invalid license is provided and signature verification is expected to fail?
I already have a valid license. Therefore, I knew that the license was actually Base64-encoded JSON.
The license is signed, so under normal circumstances its contents cannot be modified without invalidating the signature.
But I decided to modify it and see what would happen. I decoded the license and changed several fields, such as the expiration time.
After using the modified license for activation, the frontend showed that Halo Pro had been activated, but it also reported the following exception:
Reason: ReportLicenseError - invalid license code-signature verification failed: crypto/rsa: verification error. The next retry will be at ...
From the frontend, Halo Pro appeared to be activated.
I checked the paid features and found that they were already available.
This suggested that there might be a problem in Halo's activation flow.
I searched for the exception message in the console logs and found the corresponding stack trace.
The exception propagated through the following classes:
run.halo.app.license.LicenseServiceImpl
-> run.halo.app.license.ActivationReconciler
Naturally, these two classes became the first targets for analysis.
After stepping through the code with the debugger, I found out why the activation state remained active even after signature verification had failed.
First, Reconciler is a synchronizer that periodically synchronizes Halo's internal state. ActivationReconciler is responsible for synchronizing the activation state. It uses LicenseService to process and verify the license, then updates the activation state based on the verification result.
The license activation flow is roughly as follows:
Import License
-> ActivationReconciler#reconcile
-> Fetch Activation from database
-> ActivationReconciler#resolveState
-> Check whether the License matches the expected data structure
-> LicenseService#reportLicense
-> Verify / report License
-> Update activation information
The signature verification failed exception is not actually thrown during the initial validation stage. Instead, it is thrown inside reportLicense, because that is where the actual signature verification is performed.
However, before the exception occurs, the activation state has already been set to active. When signature verification fails and throws an exception, the exception handler does not correctly roll back this state.
As a result, Halo remains activated even though signature verification has failed.
The relevant code is shown below:
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 state has already been set to active
if (expiresAt != null && expiresAt.isBefore(this.clock.instant())) {
// skipped
return null;
} else {
// skipped
try {
// The exception occurs here
LicenseReportResponse reportResponse =
(LicenseReportResponse) this.licenseService
.reportLicense(activationCode)
.block(Duration.ofMinutes(1L));
// skipped
return null;
} catch (Exception e) {
// This exception handler does not correctly set the state to unknown.
// It only does so when retries reaches 15. Otherwise, it directly
// returns either a retry result or 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
}
In fact, the activation remaining valid is only temporary. If activation fails, the activation flow keeps retrying. Once the retry count reaches 15, the activation state is changed to unknown.
So how can this be handled?
I noticed that the license has a special type: offline license.
The handling of offline licenses is slightly different:
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
}
}
As shown above, when the license is an offline license, the error is simply ignored. The state is only changed to unknownfor non-offline licenses.
Therefore, after activating with an offline license, the state will never be changed to unknown.
This does not actually stop the retries. On the contrary, the retries continue indefinitely. However, because the state is never changed to
unknownfor an offline license, it remainsactive.
Therefore, changing the license type to offline is enough to prevent the activation state from being changed back to unknown.
Additional
There is another approach. Let retries reach 15 (or simply set it to 15), wait for the activation state to become unknown, and then change the state back to active.
At this point, ActivationReconciler will no longer retry the activation process.
The relevant code in ActivationReconciler#reconcile is shown below:
// 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;
Because the retry count has already reached 15, the activation state is changed to unknown, and resolveState returns null.
Since resolveState returns null and the state has already been changed to unknown, the following condition evaluates to false:
result == null && status.getState() != null && status.getState() == State.active
ActivationReconciler#reconcile uses the result of this expression to determine whether another retry should be scheduled. A retry will therefore not be scheduled when either of the following conditions is true:
result != nullstatus.getState() != State.active
When the retry count reaches 15, the state is set to unknown, satisfying status.getState() != State.active. This makes the expression above evaluate to false, causing reconcile to return Result#doNotRetry() and stop retrying.
The flow is as follows:
retries reaches 15
|
v
Activation state becomes unknown
|
v
Change Activation state to active
|
v
ActivationReconciler returns Result#doNotRetry()
|
v
Activation remains active
Paid Plugins
Halo also provides paid plugins.
They can be used after activating a valid Halo Pro license.
However, the methods described above cannot be used to enable paid plugins, because:
Paid plugins perform their own license verification.
To analyze the plugin licensing mechanism, a paid plugin needs to be prepared first.
Unfortunately, paid plugins cannot be downloaded without a valid license.
Conclusion
The root cause is:
The activation state is set to active before license signature verification completes, and the state is not correctly rolled back when verification fails and throws an exception.
As a result, the activation state may remain active even when the license signature is invalid.
The retry mechanism introduces two additional problems:
Problem 1
Once the retry count reaches 15, the Reconciler stops retrying.
This means that an invalid activation state can remain in the database without triggering the verification process again.
Problem 2
When verification fails for an offline license, the activation state is not rolled back, causing it to remain activeindefinitely.
Comments