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.

A mechanical random wheel, output strip and session stamp on warm paper, connecting public output with a signing secret.
In this article

How does a random number returned during login lead all the way to code execution on a file-sharing server? Horizon3.ai's September 30 HFS research connects that path. It begins by observing the login identifiers the server sends to a client. An identifier intended to distinguish a handshake exposes information about the random state also used to create the session-signing 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. Revisiting the flaw helps explain a common failure: a new key at every startup can look long and random while still failing to keep a secret.

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.

The dependency path makes this explicit. 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 key stays on the server. Where does a client obtain clues about it? The first login step supplies them. HFS's SRP login uses two exchanges: the server prepares handshake state, then the client submits a proof that the server verifies before completing authentication. SRP lets parties authenticate using password-related material. The relevant mistake here is in the surrounding code that stores the handshake.

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.

Two apparently unrelated uses are now connected. The startup signing key and the public first-step identifier draw from the same JavaScript random state. One observation reveals limited information; repeated observations can progressively constrain the possible internal states. The researchers then used an existing cookie signature to check candidate keys and determine whether their reconstruction was 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.

The twelve observations belong to that demonstration's environment. Intervening random calls, restarts or requests reaching different worker processes can change sequence alignment; reliability and sample requirements also depend on the runtime. The remaining analysis follows the source path from a compromised key to account privileges. This article did not execute state recovery or session forgery.

A shared random state supplies both the startup signing key and public login identifiers; repeated outputs constrain the state, and the fix uses cryptographic random APIs for both purposes.
Figure 1: Arrows show information and call relationships. Runtime and sequence-alignment conditions remain between observing output, recovering state and checking a key. A single identifier does not directly reveal every deployment's key.

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. The account's permissions still reside on the server. The attacker needs to impersonate an existing, login-capable account with the desired rights; an invented name does not create an administrator.

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()

The edits sever different connections. Correct key generation means that observations of ordinary random values elsewhere no longer reveal a new signing key through this shared-state path. Replacing the handshake identifier also removes the public output at this location. A blanket instruction to replace every Math.random() call would obscure that distinction. Reviewers should establish what each value needs: a low chance of repetition, or unpredictability even after other 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.

The instructive failure in HFS is the interaction between two locally plausible choices: generate a secret at startup, and assign random identifiers during login. Putting both on one reconstructible state lets the public identifiers reveal clues about the secret. When reviewing tokens, password-reset links or session keys, length and signature algorithms are only part of the inquiry. Follow the random source one step further: who receives the other outputs of the same state?