August 21, 2026
CVE-2026-77427: How a Fail-Open Default Key Turns One File Read Into Total Secret Recovery in…
Here’s a finding that, described in one sentence, sounds like a non-issue: the application has a hard-coded encryption key, and the key is…

By Xanlar Agamalizade
8 min read
Here's a finding that, described in one sentence, sounds like a non-issue: the application has a hard-coded encryption key, and the key is published in its own open-source tree. A reviewer's reflex is "so what — if the key is public, it isn't a secret, and if it isn't a secret, there's nothing to steal." That reflex is exactly why bugs like this survive in mature codebases. The interesting work isn't proving the key is hard-coded — it takes ten seconds to grep for it. The interesting work is answering the two questions that decide whether it's a vulnerability or a shrug: what does that key protect, and under what conditions does the application actually fall back to it?
This is CVE-2026–77427 in FileRise, a self-hosted file manager. It's scored 5.9 Medium — deliberately, and I'll spend a good part of this article defending that number in both directions, because the discipline of not inflating a chained finding (and not deflating it either) is the part worth reading. Fixed in v3.22.0.
What the key actually is
FileRise encrypts several categories of stored secret at rest with AES-256-CBC. The encryption key is resolved once at bootstrap, in config/config.php, by a function called fr_resolve_persistent_tokens_key(), and stashed into $GLOBALS['encryptionKey']. Everything that needs to encrypt or decrypt a secret pulls from that one global.
So the first thing I map on a finding like this isn't the key — it's the consumers of the key, because the blast radius is defined by them, not by the string. In FileRise, one key resolution feeds three distinct sinks:
src/FileRise/Domain/UserModel.php:895— encrypts each user's TOTP 2FA seed, stored inusers.txtasusername:bcryptHash:role:encryptedTotpSecret.src/FileRise/Domain/AdminModel.php:706,948— encryptsadminConfig.json, which holds the OIDC client secret and the application'sjwtSecret.src/FileRise/Storage/SourcesConfig.php:91-101— encrypts stored Pro source credentials: S3, SFTP, and SMB connection secrets for every configured storage backend.
All three call the same encryptData()/decryptData() pair (config.php:187-204) against the same $GLOBALS['encryptionKey']. That's the structural fact that reframes the whole finding: this isn't a "2FA key" or a "config key." It's a single point of confidentiality for three unrelated classes of secret, two of which reach outside FileRise entirely — JWTs that other components trust, and standing credentials to external storage systems.
The bug: a fail-open default, in the one install mode that isn't protected
Now the second question — under what conditions does the app use a weak key? Here's fr_resolve_persistent_tokens_key(), condensed to the branch that matters (config/config.php:211-291):
function fr_resolve_persistent_tokens_key(): array {
$defaultKey = 'default_please_change_this_key';
$source = 'legacy_default';
$key = $defaultKey;
if ($envKey !== '' && /* valid */) { $key = $envKey; $source = 'env'; }
elseif ($fileKey !== '') { $key = $fileKey; $source = 'file'; }
// else: silently keep $defaultKey
if ($needsAttention) {
error_log('WARNING: ' . $warning); // <-- the entire consequence of misconfiguration
}
// ... returns the default key and lets the app boot normally
}function fr_resolve_persistent_tokens_key(): array {
$defaultKey = 'default_please_change_this_key';
$source = 'legacy_default';
$key = $defaultKey;
if ($envKey !== '' && /* valid */) { $key = $envKey; $source = 'env'; }
elseif ($fileKey !== '') { $key = $fileKey; $source = 'file'; }
// else: silently keep $defaultKey
if ($needsAttention) {
error_log('WARNING: ' . $warning); // <-- the entire consequence of misconfiguration
}
// ... returns the default key and lets the app boot normally
}If neither the PERSISTENT_TOKENS_KEY environment variable nor a metadata/persistent_tokens.key file is present, the function returns the published string 'default_please_change_this_key' and the application boots and runs completely normally. The only trace of the problem is an error_log() line and an admin-panel banner that's visible only after someone has already logged in as admin. This is a textbook fail-open default: the insecure state is the running state.
The part that turns this from a theoretical footgun into a real exposure gap is where the fallback is reachable. FileRise already solved this — for Docker. The container entrypoint start.sh:78-109 auto-generates and persists a unique random key on first boot (added in v3.9.0). So Docker installs are fine in practice.
But the project also officially supports a "Manual install (PHP web server)" path — bare PHP-FPM, documented in the README — and nothing in the PHP bootstrap does what start.sh does. There is no code-level generate-and-persist step in config/config.php or anywhere in the request path. The manual installer's only protection is a line in the README's "first-run security checklist" asking them to set the key by hand. Miss that optional step — as a fresh or hurried manual install easily does — and every secret above is encrypted under a key that ships in the source tree.
This is a pattern I've learned to look for specifically: a remediation that lives in one deployment mode's tooling instead of in the application's own logic. The fix was real; it just wasn't where every install path could inherit it.
The chain, and the honest accounting of it
Here's where a lot of write-ups go wrong in the exciting direction, so let me be deliberate. Knowing the key, by itself, buys an attacker nothing. The ciphertext lives in files inside the install — users.txt, adminConfig.json, the sources config — and reading a stored secret requires the attacker to first obtain that ciphertext. So this is a chained / prerequisite finding: it is only exploitable when a second, independent primitive hands over the files. In practice that second primitive is common enough to matter — a misconfigured vhost serving USERS_DIR/META_DIR directly, a backup archive left in a web-reachable path, an LFI elsewhere in the stack, or local/insider access — but it is a genuine precondition, not a given.
That precondition is the entire reason the score is what it is, and it's worth walking the CVSS metric by metric because each choice is a decision about honesty:
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N → 5.9, Medium.
- AC:H — this is the crux. "High attack complexity" here encodes "depends on conditions beyond the attacker's control": a separate disclosure vector must already exist to surrender the ciphertext. This is precisely what stops the finding from being scored as a trivially-remote secret leak. Without that second condition, knowing the key yields nothing, and the metric has to say so.
- PR:N — knowing the key requires no FileRise authentication; it's in the published source. (Whether the prerequisite read-primitive needs privilege is out of scope for this finding — the convention for a chained vuln is to score the realized impact assuming the prerequisite is met, not to re-litigate the prerequisite.)
- C:H — once chained, the recovery is broad and high-value: TOTP seeds, the OIDC client secret, the
jwtSecret, and S3/SFTP/SMB credentials. - I:N — and this is where I refused to inflate. Only decryption (read) was confirmed. The adjacent, tempting angle — using key-knowledge to forge a remember-me token or a JWT — I investigated and could not confirm from key-knowledge alone (the remember-me store is HMAC-keyed and would need an additional file-write primitive I didn't establish). So integrity stays
N. I don't get to score a forgery I didn't prove. - S:U / A:N — no scope change (no container/OS escape demonstrated), no availability impact.
That's the whole game with a finding like this. Score it as a standalone remote bug and you've lied upward (it needs a prerequisite). Wave it away because "the key is public" and you've lied downward (it concentrates three secret classes behind one string, and the prerequisite is common). 5.9 is the number that tells the truth: real, conditional, and worth fixing.
Proof of concept — disarmed, against the project's own code
I don't like PoCs that "simulate" the vulnerable logic in a paraphrase, because paraphrases hide bugs. FileRise ships test-isolation environment variables (FR_TEST_UPLOAD_DIR / FR_TEST_USERS_DIR / FR_TEST_META_DIR), so I used them to stand up a fresh manual install with no key configured and drive the real config/config.php — the actual resolver, the actual cipher — with no live instance touched and no network calls:
putenv('FR_TEST_UPLOAD_DIR=' . $uploadDir);
putenv('FR_TEST_USERS_DIR=' . $usersDir);
putenv('FR_TEST_META_DIR=' . $metaDir);
require '/root/FileRise/config/config.php';
// 1) confirm the real app resolves to the legacy default:
fr_get_persistent_tokens_key_status();
// => source = legacy_default, usesLegacyDefault = true, key = 'default_please_change_this_key'
// 2) simulate UserModel::setupTOTP() writing a 2FA secret on this install:
$encryptedSecret = encryptData('JBSWY3DPEHPK3PXP', $GLOBALS['encryptionKey']);
// 3) attacker, who has never run this app, knows only the public default string:
$recovered = decryptData($encryptedSecret, 'default_please_change_this_key');
// => 'JBSWY3DPEHPK3PXP' (exact match — full recovery)putenv('FR_TEST_UPLOAD_DIR=' . $uploadDir);
putenv('FR_TEST_USERS_DIR=' . $usersDir);
putenv('FR_TEST_META_DIR=' . $metaDir);
require '/root/FileRise/config/config.php';
// 1) confirm the real app resolves to the legacy default:
fr_get_persistent_tokens_key_status();
// => source = legacy_default, usesLegacyDefault = true, key = 'default_please_change_this_key'
// 2) simulate UserModel::setupTOTP() writing a 2FA secret on this install:
$encryptedSecret = encryptData('JBSWY3DPEHPK3PXP', $GLOBALS['encryptionKey']);
// 3) attacker, who has never run this app, knows only the public default string:
$recovered = decryptData($encryptedSecret, 'default_please_change_this_key');
// => 'JBSWY3DPEHPK3PXP' (exact match — full recovery)Full TOTP-seed recovery using nothing but a string from the source tree. adminConfig.json and the Pro sources credentials I verified by code trace rather than separate execution — they call the identical encryptData()/decryptData() pair against the identical key, so the PoC above is directly representative of their decryption. PHP 8.4.22 CLI, isolated temp dirs, no live target. Disarmed, reproducible, and scoped to exactly what I claimed.
The lineage: an incomplete remediation, not a fresh oversight
One habit that consistently pays off — the same one behind my OpenNMS JEXL work — is to read the fix that was added for a previous bug, because residual risk lives in the delta between "closed the reported path" and "closed the class." FileRise had already been here. The prior advisory GHSA-f4xx-57cv-mg3x (CVE-2026–33072, v3.9.0) addressed this very key: it added the Docker auto-generation and an admin-triggered rotation workflow. The maintainer clearly understood the risk — the code even sets usesLegacyDefault / usesPublishedPlaceholder flags and raises a banner.
What that remediation didn't do was make the fallback unreachable for manual installs or make the app refuse to boot on it. So this finding isn't "they never thought about it"; it's "the fix landed in the Docker entrypoint and the admin UI, but not in the bootstrap that every install mode runs." That's a more useful thing to hand a maintainer than a bare bug report, and it's why I framed the disclosure as a scoping question — is the residual manual-install fallback an accepted backward-compat tradeoff, or an oversight in the 3.9.0 remediation? — rather than asserting a verdict. The maintainer confirmed it was the latter and shipped a fix.
Scope discipline: what I checked and ruled out
A senior report is defined as much by its negatives as its positives, so these were explicitly investigated and closed rather than left as vague "possible" hand-waving:
- Remember-me token forgery from key-knowledge alone — investigated, Unconfirmed. The store is HMAC-keyed and forgery would require an additional file-write primitive I did not establish. Hence
I:Nabove, not a hopefulI:L. public/api/pro/**authorization sweep — traced fully, no gap found.- OnlyOffice callback-secret exposure via
config.php/status.php— the fix from CVE-2026-33330 was confirmed intact.
Ruling things out on the record is not padding; it's what tells the maintainer (and the next reader) that the boundary of the claim was drawn deliberately.
The fix
v3.22.0 does the right thing, and notably does both halves of a proper fail-closed remediation:
- Auto-generate by default, everywhere. Pristine manual installs now generate a cryptographically random 32-byte key and persist it to
metadata/persistent_tokens.key— the same protection Docker already had, now in the path every install mode runs. - Fail closed when it can't. A pristine Docker install now stops with an actionable error if its generated key can't be persisted, instead of running on an ephemeral key that would silently vanish on restart.
- Existing env-provided/persisted keys are left untouched, and installs with existing key-dependent state stay on the legacy path rather than being auto-rotated — because blindly re-keying would render already-encrypted secrets and signed sessions unreadable. (That restraint is correct: the safe migration is admin-driven rotation with re-encryption, which the panel already offers.)
If you run a manual/non-Docker FileRise between v3.9.0 and v3.21.0, update to v3.22.0 — and if you've been on the legacy key, treat the OIDC client secret, jwtSecret, any stored S3/SFTP/SMB credentials, and users' TOTP seeds as potentially exposed: rotate them, don't just re-key.
Takeaway
The engineering lesson is fail-closed for security-critical defaults. A key that simultaneously protects 2FA seeds, JWT/OIDC secrets, and external storage credentials is exactly the kind of thing an application should refuse to start without — a log line and a post-login banner are not a control, because the window in which they're ignored is the window in which the secrets are written under the weak key.
The tradecraft lesson is the one I'd most want a junior to take: the value in this finding was never "the key is hard-coded." It was in the two follow-up questions — what does the key protect (three secret classes, two reaching outside the app) and when does the app fall back to it (the one supported install mode the previous fix didn't cover) — and then in scoring the result honestly: AC:H for the prerequisite it genuinely needs, I:N for the forgery I couldn't prove. Anyone can find the string. The professional part is refusing to oversell it and refusing to dismiss it — landing on the number, and the narrative, that a maintainer can actually act on.
CVE-2026–77427. Fixed in FileRise v3.22.0.
Xanlar Agamalizade LinkedIn · GitHub · xanlaragamalizade.com