Vulnerability research
HFS: the login was unfinished, but the key was already giving clues
HFS once used the same ordinary random generator for its session-signing key and login identifiers. Researchers used repeated login outputs to reconstruct state and forge an administrative session. CVE-2026-61500 was first fixed in 3.2.1; upgrade to current stable 3.3.4 and retire untrusted old verification keys.

In this article
How does a random number returned during login lead all the way to code execution on a file-sharing server? In its September 30 HFS research, Horizon3.ai reconstructed the session-signing key from login identifiers returned by the server. An identifier intended to distinguish a handshake shared a recoverable random state with the key.
CVE-2026-61500 affects HFS from 3.0.0 up to, but excluding, 3.2.1. The repair shipped in 3.2.1 on July 13; the late-September publication explains the mechanism in detail. At publication, the stable release is already 3.3.4 and includes subsequent security fixes. The old code generated a long new key at every startup. The problem was that the state generating the key also produced publicly visible output.
1 Why the server trusts this cookie
HFS 3 uses Node.js and Koa. It keeps session data in a browser cookie, signs it on the server, and gives it to the client to store. When the browser returns the data and signature, the server checks the signature before recovering the session fields. Users can read cookies they receive. Security depends on whether they can change the contents and produce a new signature the server will accept.
HFS 3.2.0's session middleware enables signed: true and configures no external session store. Its locked koa-session 7.0.2 reads and decodes the cookie, then serializes and encodes session data when saving it. Base64 is a representation; anyone who knows the format can recover the fields.
The signing key must therefore remain secret. Startup code in HFS 3.2.0 first looks for the COOKIE_SIGN_KEYS environment variable. Without that setting, it calls randomId(30) and supplies the result to Koa. The defect lies in this default branch. A separately generated, strong custom key avoids that generation path; an easily guessed string supplied by an operator does not provide the same protection.
randomId() splits requests longer than ten characters. With an argument of 30, it makes three Math.random() calls, converts each result to base 36, takes characters from the fractional part, and concatenates them. Replacing lowercase l with uppercase L merely helps readability. More characters and additional formatting do not give an ordinary random generator the secrecy properties required of a key source.
// HFS 3.2.0: default signing key
const keys = process.env.COOKIE_SIGN_KEYS?.split(',')
|| [randomId(30)]
2 The random identifier leaves before password verification
The clue appears in HFS's first login step. It uses SRP, a protocol that authenticates the parties using password-related material: the server prepares handshake state, then the client submits a proof for the server to verify. To store that temporary handshake state, the application creates an identifier and sends it to the client.
loginSrp1() looks up the account, checks whether it can log in, checks allowed source networks, and lets plugins block the attempt. It then creates SRP server state and runs const sid = Math.random(). The identifier indexes that state in ongoingLogins, while { username, sid } is also written into the client session. A timer removes the temporary state after 60 seconds.
The client has not yet supplied its password proof. Proof verification and setLoggedIn() occur later in loginSrp2(). Knowing a usable account name and satisfying the first step's network and login conditions can therefore be enough to receive a cookie containing sid. Its signature prevents arbitrary edits; it does not conceal the random number.
Disabled or expired accounts, and accounts without an available login method, fail this check. Plugin-authenticated accounts take a different path, while plugin events and access controls can stop the request earlier. Account discovery in the published investigation also involved probing behavior repaired separately later. Continued access to these identifiers makes the later observation of random state possible.
The startup key and public identifier draw from the same JavaScript random state. Repeated observations can progressively constrain the possible states. An existing cookie signature then provides a way to check candidate keys and determine whether the reconstruction is correct.
3 Random-looking values can still reveal state
Math.random() returns a number between zero and one. Its sequence serves simulation, sampling and ordinary random choices, but the generator retains finite state: a deterministic rule advances that state, which determines later output. V8's own explanation explicitly warns against cryptographic use. Passing statistical randomness tests does not establish that a generator can safely protect a secret key.
The Node.js runtime family used by HFS illustrates the implementation details. V8 advances two 64-bit state fields with shifts and XOR operations. The V8 shipped in Node 24.0.0 fills a cache of values; that cache holds 64 entries. JavaScript consumes them through a decreasing index. The order visible to an observer therefore cannot simply be treated as the order in which the state-update loop generated them.
Recoverability also depends on the particular algorithm. Its combination of shifts and XOR operations is reversible: complete state allows a preceding state to be reconstructed. Each public floating-point value reveals only part of the state, but successive outputs constrain the same unknowns. A cryptographic generator can also be deterministic; its design requires recovery of secret state from visible output to be computationally infeasible. Math.random() offers no such guarantee.
Recovery still has to align intervening random calls, cache boundaries and the position of startup key generation. Once a candidate state satisfies the observations, HFS's base-36 conversion and substring rules produce a candidate key. Checking that against an existing cookie signature connects the reconstruction to the actual session key.
Horizon3's published demonstration uses 12 observations and discusses a 52-bit output model. It does not fully identify the HFS build and Node/V8 runtime used in that run. The Node 22.16.0 ToDouble() implementation inspected for this article constructs a 52-bit mantissa. The Node 24.0.0 implementation instead retains a 53-bit integer and scales it, requiring a corresponding change to the recovery model.
This explains why sample requirements vary between environments. Intervening random calls, restarts or requests reaching different worker processes can disrupt sequence alignment. The twelve observations belong to that particular demonstration; a different runtime or call sequence requires its own recovery model and sample assessment.

4 A forged signature reaches real account privileges
With the signing key, an attacker can alter client-held session data and produce a matching signature. HFS's prepareState() uses the session's username to find a server-side account and its stored permissions. Administrative access therefore requires the forged session to name a real, login-capable administrator account.
Session timestamps and IP fields need to be read in that context. The old code checks invalidation time and clears the session username when the IP changes. The same signature protects those fields. Once the key is compromised, the client-held material feeding the checks can also be reconstructed. Independently enforced server-side or network access restrictions retain their separate blocking role.
The administrative API has another gate. ctxAdminAccess() checks the management network restriction before using the account's administrative permission. accountCanLoginAdmin() requires a login-capable account with the admin property. admin_net can reject remote sources outside the allowed range. A complete code-execution chain therefore also depends on an appropriate administrative account and management reachability.
After that gate is passed, an existing server-extension feature supplies the final step. set_config passes changes into the configuration system, which invokes the relevant compilation handler. The server_code handler loads a script with new Function and supplies require. That code executes with the HFS process's authority. Its access to files and operating-system capabilities depends on the service identity and runtime environment.
No additional memory-corruption primitive is needed along this path. Session forgery delivers the attacker to a programmable feature intended for administrators. Once unauthorized administrative activity is established, the investigation needs to include server-code configuration, plugins and files accessible to the process. Changing a file-sharing password alone leaves prior server-side changes unexplained.
5 Replace the generator and retire the old key
The repair patch changes both uses. The default signing key comes from randomBytes(32), encoded as Base64url, while the login identifier becomes randomUUID(). The former obtains 32 bytes from Node's cryptographic random API. The latter stops exposing Math.random() state through this identifier. The ordinary randomId() helper remains available for uses that do not require secrecy or unpredictability.
const keys = process.env.COOKIE_SIGN_KEYS?.split(',')
|| [randomBytes(32).toString('base64url')]
const sid = randomUUID()
Generating the key from a cryptographic source separates it from the observable ordinary random sequence. Replacing the handshake identifier closes this particular output path. Other Math.random() calls should be reviewed according to what the value needs: a low chance of repetition, or unpredictability even after related outputs have been observed.
Version 3.2.1 marks the first repair. For a deployment now, use current stable 3.3.4, read the subsequent security changes, and check configuration and plugin compatibility against a trusted backup. The default key is generated at process startup. Updating and restarting replaces it with a newly generated cryptographic key, so old sessions should cease to validate. If the upgrade fails, restrict access while recovering on a compatible fixed release. The historical 3.2.1 floor covers this vulnerability, without guaranteeing protection against later issues.
Deployments using COOKIE_SIGN_KEYS need another check: a program update does not replace a secret stored in the environment. Replace a weak or potentially exposed key with an independently generated strong one and remove the old value. The locked dependency, Keygrip 1.1.0, signs with the first key but tries every key in the list when verifying. Prepending a new key while retaining an untrusted predecessor continues to accept signatures made with that predecessor. This is useful during routine gradual rotation; a compromise requires the old trust to expire.
Three checks directly follow from the mechanism: confirm the running release and key source, confirm that old sessions are rejected after key retirement, and complete a fresh normal login and file operation. An environment with anomalous administrative activity also needs review of server_code, plugins and file changes, with rebuilding from trusted software and inspected configuration where necessary. Updating removes this source of future session forgery. Previously executed code and its effects remain a separate investigation.
When reviewing tokens, password-reset links or session keys, look beyond length and the signing algorithm: who receives other outputs from the same random state? HFS kept the secret on the server and sent the clues out with login responses.