As part of our CVE monitoring, we came across GHSA-pcq9-mq6m-mvmp (CVE-2025-68402), an authentication bypass in FreshRSS, a self-hosted RSS aggregator. It is a good example of how over-engineering can hurt the security of an application.
A commit meant to strengthen the crypto ended up removing the need for a valid password.
It's worth noting upfront: this issue only affected the edge (development) branch and never made it into a stable release. But the pattern is instructive.
While this is a cool bug to study and understand, it would make a poor hacking lab since exploitation is trivial (just log in with any password). It would also be a difficult code review exercise since spotting it requires understanding the full interaction between the nonce length change and bcrypt's truncation behaviour, which involves a lot of context across multiple files.
Before diving in, it helps to understand what a bcrypt hash looks like. A bcrypt hash is a 60-character string with a specific structure:
$2y$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234
^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| | | |
| | 22-char salt 31-char hash output
| | (password-dependent)
| cost factor
|
algorithm id
The first 29 characters ($2y$10$ + the 22-character salt) are fixed for a given user and do not depend on the password. The remaining 31 characters are the actual hash output and are the only part that changes when you use a different password.
PHP's password_verify($input, $hash) function takes a plaintext input and a bcrypt hash, extracts the algorithm, cost, and salt from the hash, recomputes bcrypt on the input using those parameters, and checks whether the result matches.
Crucially, bcrypt silently truncates its input at 72 bytes. Anything beyond the 72nd byte is simply ignored. This is a well-known property of the algorithm, but it is easy to forget when composing bcrypt with other operations.
FreshRSS does not send the user's password to the server in plain text (even over HTTPS). Instead, it uses a challenge-response protocol with client-side bcrypt hashing. Here is how it works:
nonce (a random one-time value) and salt1 (the first 29 characters of the user's stored bcrypt hash, the algorithm, cost, and salt).// bcrypt hash of password (60 chars)
s = bcrypt.hashSync(password, salt1);
// bcrypt hash of nonce+s
c = bcrypt.hashSync(nonce + s, randomSalt);
s (as hash) and c (as challenge) to the server.password_verify($nonce . $hash, $challenge);
This recomputes bcrypt(nonce + hash) and checks it matches the challenge the client sent.
The nonce prevents replay attacks: even if an attacker captures the hash and challenge, they cannot reuse them because the next login will have a different nonce.
In October 2025, PR #8061 ("Strengthen some crypto") made several changes to improve the cryptographic primitives used by FreshRSS:
mt_rand() with random_bytes() for randomnessuniqid() with proper random byte generationsha1() with hash('sha256', ...) for nonce generationEach of these changes is individually reasonable. SHA-256 is stronger than SHA-1. random_bytes() is cryptographically secure while mt_rand() is not. These are textbook recommendations.
But there is a side effect. The nonce went from being a SHA-1 hex digest (40 characters) to a SHA-256 hex digest (64 characters).
// Before (40-char nonce):
-$this->view->nonce = sha1($salt . uniqid('' . mt_rand(), true));
// After (64-char nonce):
+$this->view->nonce = hash('sha256', FreshRSS_Context::systemConf()->salt . $user . random_bytes(32));
With the original 40-character nonce:
nonce (40 chars) + hash (60 chars) = 100 chars total
[---- nonce (40) ----][---- first 32 chars of hash ----]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Contains password-dependent data
Remember, the first 29 characters of a bcrypt hash are the algorithm, cost, and salt (not password-dependent), and the password-dependent output starts at character 30. With a 40-char nonce, bcrypt sees up to character 72, which is well past the password-dependent portion. Authentication works correctly.
With the new 64-character nonce:
nonce (64 chars) + hash (60 chars) = 124 chars total
[----------- nonce (64) -----------][8 chars]
^^^^^^^^
$2y$10$X
(just the bcrypt prefix + 1 salt char)
The 72-byte window now contains the 64-character nonce plus only 8 characters of the hash. Those 8 characters are $2y$10$ (the bcrypt algorithm identifier and cost parameter) plus one character of the salt. None of this depends on the user's password. It is the same regardless of what password was entered.
Result: password_verify() returns true for any password.
The fix in PR #8320 is simple: just swap the concatenation order:
// Server (PHP):
-return password_verify($nonce . $hash, $challenge);
+return password_verify($hash . $nonce, $challenge);
// Client (JavaScript):
-const c = bcrypt.hashSync(json.nonce + s, ...);
+const c = bcrypt.hashSync(s + json.nonce, ...);
Now the 60-character hash comes first. All 60 bytes fit within the 72-byte window, and the nonce fills the remaining 12 bytes. The password-dependent data is fully included in the bcrypt computation, and authentication works correctly again.
Understand the primitives you are composing. SHA-256 is stronger than SHA-1 in isolation. But "stronger" does not mean "safe to substitute" when the output feeds into another function with its own constraints. The 40-character SHA-1 nonce was not chosen because SHA-1 was considered secure. It just happened to be short enough that bcrypt's truncation did not matter. The upgrade to SHA-256 broke that assumption.
Over-engineering can hurt security. The challenge-response protocol with client-side bcrypt is already unusual. Most web applications just send the password over HTTPS and hash server-side. The added complexity created a subtle interaction between nonce length and bcrypt truncation that a simpler design would not have. More layers of crypto does not automatically mean more security.
Test authentication. The FreshRSS test suite has no tests for the authentication flow at all. The only password-related test checks validation rules (minimum length, non-empty), not actual credential verification. The complexity of the challenge-response protocol likely made it harder to test than a simple password check, but that is exactly the kind of code that needs tests the most. A single test case (log in with the wrong password and assert failure) would have caught this immediately.
Edge branches matter. This bug never reached a stable release, which is good. But it lived in the edge branch for about two months. Users tracking the development branch, often the most engaged members of the community, were running a version where any password worked. The edge/stable split limited the blast radius, but it is a reminder that development branches deserve security attention too.
If you enjoy this kind of vulnerability analysis, check out The CVE Archeologist's Field Guide, where I dig into real-world vulnerabilities and the patterns behind them.