CVE-2026-61500: Forging an HFS Admin Session Without Logging In
CVSS 3.1: 9.8 (Critical) / CVSS 4.0: 9.3 / CWE-338 (Use of a cryptographically weak PRNG)
Rejetto HFS (HTTP File Server) versions 3.0.0 through 3.2.0 ship a bug that lets an unauthenticated attacker forge an administrator session and, from there, execute arbitrary code on the server through HFS's own server_code configuration feature.
It was found by Zach Hanley at Horizon3.ai in September 2026 while using Claude for the analysis, and real-world exploitation started on October 1.
Root cause: Math.random() used where crypto belongs
HFS runs on Koa, and it used plain JavaScript Math.random() for two things that are supposed to be secret: the cookie-signing key and a login-handshake identifier.
The vulnerable code looked like this.
// src/index.ts (vulnerable)
import { randomId } from './misc'
const keys = process.env.COOKIE_SIGN_KEYS?.split(',') || [randomId(30)] // generated once at startup, signs every session cookie
export const app = new Koa({ keys })
// src/cross.ts (vulnerable)
export function randomId(len = 10): string {
if (len > 10) return randomId(10) + randomId(len - 10)
return Math.random().toString(36).slice(2, 2 + len)
}
// src/api.auth.ts (vulnerable)
const { srpServer, ...rest } = await srpServerStep1(account)
const sid = Math.random() // login handshake identifier
ongoingLogins[sid] = srpServer
ctx.session.loggingIn = { username, sid }
Here's the chain in plain terms:
randomId(30)callsMath.random()three times and concatenates the results as base-36 strings. This runs once at server startup and becomes the secret key used to sign every session cookie afterward.loginSrp1- the first step of login, callable by anyone, no auth required - generates a freshMath.random()value on every single call and ships it back to the client assid, sitting in plain sight inside the session cookie.
The problem is that both values come from the same PRNG.
V8's Math.random() isn't cryptographically secure - it's xorshift128+, a fully deterministic algorithm. Recover its 128-bit internal state and you can compute every value it has ever produced or ever will produce.
Walking the attack chain
Steps 1-2: Harvest PRNG outputs
The attacker sends six unauthenticated loginSrp1 requests for a known account (e.g. admin). Each response's cookie carries the current sid, which is a raw Math.random() output at that exact moment.
The server is handing out fragments of its own "secret" PRNG state on every request.
Step 3: Reverse the PRNG state
V8's Math.random() exposes only the 53-bit mantissa of a 64-bit double; the remaining 11 bits are dropped during rounding.
The attacker takes five consecutive observed values, recovers their 53 visible bits each, brute-forces the missing 11 bits (only 2,048 combinations - trivial), and reverses V8's xorshift128+ recurrence until it finds the single internal state consistent with every observation.
Once that state is known, every past and future Math.random() output becomes predictable.
Step 4: Recover the cookie-signing key
Rewinding that same PRNG state back to server startup reproduces the exact randomId(30) call that produced the cookie-signing key.
Because V8 rounds strings to their shortest representation, a few candidate keys can come out of this - the attacker disambiguates by checking which one matches the HMAC on an actual cookie the server sent, which also pins down the server's startup offset.
Step 5: Forge the admin session
With the signing key in hand, the attacker builds a session object containing { username: "admin" } and signs it the same way Koa does.
Handing that forged cookie to an admin-only endpoint like get_config gets a clean HTTP 200 back - no login ever took place.
Step 6: RCE via server_code
HFS officially lets admins register a server_code JavaScript snippet that the server executes. With the forged session, the attacker calls set_config to install their own payload, and it runs immediately with the server process's privileges.
In the validated environment, running id through this path returned uid=0(root).
Note what the attacker never touches directly: the filesystem, process memory, or environment variables. The entire chain is a black-box reconstruction of secret internal state from values the server itself returned over HTTP.
The fix: real randomness
HFS 3.2.1 replaces both weak spots with Node's cryptographically secure primitives.
// src/index.ts (patched)
import { randomBytes } from 'node:crypto'
const keys = process.env.COOKIE_SIGN_KEYS?.split(',') || [randomBytes(32).toString('base64url')] // 256 bits from the OS CSPRNG
export const app = new Koa({ keys })
// src/api.auth.ts (patched)
import { randomUUID } from 'node:crypto'
const sid = randomUUID() // no longer leaks Math.random() output
ongoingLogins[sid] = srpServer
randomBytes(32) pulls 256 bits straight from the OS-level CSPRNG, so observing the output gives an attacker nothing to reverse.
sid is now a randomUUID(), so it no longer hands out a clue to the PRNG's internal state at all.
The lesson generalizes cleanly: any value with a security role - including a session identifier that looks like throwaway metadata - needs a CSPRNG, and nothing a PRNG produces should ever be exposed to a client.
Comments
No comments yet. Start the discussion.