A file server signed its session cookies with a key derived from Math.random(), and handed out raw outputs of that same generator to anyone who started a login. An attacker who starts twelve logins can reconstruct the signing key from those outputs, forge an administrator cookie, and run code on the host. That is CVE-2026-61500 in Rejetto HFS, and per VulnCheck's canaries it went from researcher writeup to exploitation in about 24 hours this week.
I was researching the disclosure for this post and the part that grabbed me is not that Math.random() is insecure. Everyone knows that. The part worth studying is that the bug only works because of two mistakes stacked together, and the second one is a pattern I see constantly in code review. Here is the chain, the fix, and the two checks I would now run on any Node.js service I inherit.
How do you sign cookies with Math.random in the first place?
HFS 3.x, a TypeScript rewrite of the old Rejetto server built on Koa, falls back to a default key when COOKIE_SIGN_KEYS is unset. Per the Horizon3 writeup, that default is randomId(30), built from Math.random().toString(36) and consuming three consecutive Math.random() outputs at process start. Koa's keygrip middleware then signs every session cookie with that key.
So the entire security of every session on the box rests on V8's PRNG being unpredictable. Here is the shape of the problem in the most direct terms I can write it:
// The HFS 3.x default, per the Horizon3 analysis (simplified shape, not verbatim source)
// index.ts: keys = [randomId(30)] -> consumes 3 Math.random() calls at startup
// cross.ts: randomId is Math.random().toString(36).substring(2,12) chained
// V8's Math.random() is xorshift128+:
// - two 64-bit integers of internal state
// - fully reversible: given consecutive outputs, you can run the state
// BACKWARD to reconstruct earlier outputs you never observed
// - NOT a cryptographic generator, by design
const sid = Math.random(); // api.auth.ts:78, the leak, next section
const signingKey = randomId(30); // the key this sid lets you recover
The code comment in HFS even says the startup randomness gives "some extra security." That instinct is right in principle and wrong in the implementation: the value is only "extra security" if the generator is unpredictable and unobservable. V8's is neither, once you can watch it.
Where did the attacker observe the PRNG stream?
This is the second mistake, and in my opinion the more instructive one. The signing key being weak is not exploitable on its own. The attacker also needs observations from the same PRNG stream, taken after the key was generated. HFS provided them.
During the first step of the login handshake, the unauthenticated loginSrp1 handler sets const sid = Math.random(). Sessions are cookie-stored, and koa-session cookies are base64-encoded JSON that is signed but not encrypted. So the server hands the client the exact 52-bit double the PRNG just produced, wrapped in its own cookie. Reaching that endpoint requires only a valid login-enabled username, not a password.
Unauthenticated request sequence (per Horizon3 and the public PoC):
1. Confirm an admin account exists (user enumeration, also disclosed)
2. Call loginSrp1 ~6-12 times, collect the leaked doubles
from your own Set-Cookie headers
3. Feed consecutive doubles to Z3 as constraints
4. Z3 recovers the xorshift128+ internal state
5. Step the state BACKWARD past your observations to the 3 outputs
consumed by randomId(30) at process start
6. Rebuild the signing key, verify against the HMAC of a cookie
the server legitimately issued to you
7. Forge {username: "admin", ts: now, allow_session_ip_change: true}
8. The admin API's server_code feature executes your JavaScript. RCE.
Two details make this clean. The search space is tiny: grepping the codebase shows the only server-side Math.random() consumers are the key (three calls) and the sid. And verification is free: the attacker already holds a cookie plus its valid HMAC signature, so every candidate key can be checked offline. Horizon3's analysis says roughly 3 to 5 consecutive doubles suffice; the public proof of concept uses 6 requests and 5 values; the Horizon3 chain description says 12. I am quoting the range rather than a single number because the sources disagree and I have not run any of them.
The forged cookie includes allow_session_ip_change: true, which the attacker signs themselves, so the session IP binding does not stop the forgery.
Why did the exploit show up only 24 hours after the writeup?
The timeline is the uncomfortable part. HFS 3.2.1 shipped with this fix on July 13, alongside fixes for five more CVEs in the same release. Nothing happened for eleven weeks. Then a third-party PoC repository appeared on GitHub on September 26, Horizon3 published the full writeup on September 30, and VulnCheck's canaries started detecting exploitation on the evening of October 1, 80 days after the patch. By October 3 The Register was reporting a China-hosted IP targeting vulnerable hosts in the US and Japan, plus four hits from two US addresses that look like proxies.
VulnCheck lists the CVE in its own KEV catalog. As of the checks I ran, it was not on CISA's KEV catalog yet, so quote "VulnCheck KEV" if you cite the exploitation status. Reporting calls the activity "China-nexus" with low confidence, and no group is named.
I want to be careful here: nobody has shown which material the attacker used. The exploitation timing correlates with the public writeup and PoC, not the model that found the bug. But the correlation is strong enough that the disclosure question writes itself, and it is the debate at the end of this post.
What does an AI-written exploit change?
The finder is the strangest part of the story. Zach Hanley of Horizon3 found this with Anthropic's Mythos model under Project Glasswing, a program that gives vetted security partners access to the model. Per the writeup, Mythos did not just flag the weak PRNG. It simultaneously found the separate code path that leaks the same PRNG stream, recognized the two as a chain, determined the leak produced exactly enough observations, wrote the Z3 model, and produced a working exploit. Hanley's stated reasons humans skip bugs like this are math expertise and economics, and his conclusion is that the model removes both.
Honest framing on this: Horizon3 sells an autonomous pentesting product, so the capability claims are the vendor's own. What I can check independently, via the fix commit and the NVD record, is the mechanism, and it holds. I traced the earlier one-shot agent attack in my GitHub issue hijack piece and this is a different species: that one abused an agent as the payload, this one uses a model as the researcher. Both push the same question: the cost of finding and weaponizing certain bug classes just dropped, and my mental model of "too obscure to exploit" did not survive the week.
Is the fix actually good?
The fix commit is short and I think it is the right shape. The signing key becomes randomBytes(32).toString('base64url') and the sid becomes randomUUID(). Both come from Node's cryptographic generator, so no amount of observed output reconstructs them. The same release also made the login handler answer unknown usernames the same way it answers wrong passwords, killing the enumeration oracle. If you run HFS anywhere: 3.2.1 minimum, prefer the current release, and rotate the signing key when you restart because a new key invalidates anything forged against the old one.
How do I audit my own code for this pattern?
Two checks. The first is a grep I now run on any Node.js service I inherit, because the two-mistakes pattern travels:
# Audit 1: Math.random in anything security-shaped
grep -rn "Math.random" --include="*.js" --include="*.ts" . \
| grep -vE "test|spec|fixture|\.min\."
# Audit 2: does any of it reach a cookie, token, or response?
# for each hit, trace where the value lands before trusting the grep
Every hit needs a question asked of it: what consumes this value? A shuffling a UI list is fine. A session id, a reset token, an API key, a signing key, a CSRF nonce, or anything else an attacker can observe or forge with is not. The fix is crypto.randomBytes() or crypto.randomUUID(), both from the cryptographic generator.
Does a strong secret protect you if the entropy is shared?
The second check is the leak-side of the pattern, and it is subtler: it is not enough that your secret is strong. Ask whether anything else shares your entropy source. If your signing key comes from one PRNG and any observable value comes from the same PRNG, the strength of the key is measured by the observability of the leak, not the size of the key. I traced a cousin of this failure class in the vm2 sandbox escape I covered earlier this month, where an allowlisted module authorized its neighbors through a string prefix: the control was sound against the thing it named and silent about the thing it leaked sideways.
Should Math.random in a security role be a build failure?
Here is where I will probably lose arguments. There is a real position that says Math.random() is fine in a security role as long as the stream is unobservable, and plenty of code ships that way without incident. HFS proves the risk though: "unobservable" is a property that one careless handler can revoke without touching the key at all. The signing key was unchanged; a login sid turned the whole thing into an exploit.
So my position: Math.random() in a security role should fail CI by default, with an explicit allowlist comment where it is genuinely safe. Linters flag worse things on weaker evidence every day. The counterargument is that this creates noise and trains people to ignore lint warnings, and that a leak audit is the real control. Both are true. But the leak audit requires a human reading every handler, forever, and humans are exactly the resource the Horizon3 argument says is about to become irrelevant.
And the second debate, the disclosure one: VulnCheck saw exploitation begin roughly one day after a detailed writeup and five days after a public PoC landed, on a flaw patched eleven weeks earlier. Should researchers keep publishing full chains with working code, knowing the correlation? Horizon3's whole model assumes yes, sunlight makes the ecosystem safer, and the patch predates the exposure by 80 days so the argument is not crazy. But the canary data does not show the attacks waiting for the patch to be installed, only for the recipe to be public. I genuinely have not settled this one.
The version check takes thirty seconds and is the only action that ends the argument for your own servers. Everything above came from the Horizon3 writeup, the NVD record, VulnCheck's advisory, SXZ's timeline analysis, and The Register; I have not reproduced any exploit and neither should you, outside a loopback lab.
If this was useful, I write one of these deep-dives most weeks. Two related pieces: what I still learn by hand while agents write the rest, and the time my own agent reported an attack that never happened. The through-line in all three is the same: trust the artifact you can verify, not the story attached to it.
Top comments (0)