One kilobyte to kill a worker: the DTD attribute that crashes svg-sanitize
A 1009-byte SVG uploads cleanly, sanitizes "successfully" — and then the PHP worker dies in a heap of its own bookkeeping. Two calls to removeAttribute() on the same name: the first deletes the attacker's attribute, the second deletes a piece of the DTD itself, and ext/dom does not survive the difference.
Analysis of enshrined/svg-sanitize ≤ 0.22.0 (incl. WordPress Safe SVG ≤ 2.4.0). Fixed in 1.0.0. Published as GHSA-v383-3rw5-q8rf under coordinated disclosure. See also the sibling finding GHSA-9rjx-3jch-6vjf (stored XSS via DTD entity collision).
Summary
This is the second flaw we reported in enshrined/svg-sanitize's handling of document type declarations — same library, same root oversight, completely different outcome. Where GHSA-9rjx-3jch-6vjf turned a DTD entity into stored XSS, this one turns a DTD attribute declaration into a process crash: one crafted upload kills the PHP-FPM worker that serves it, and nginx answers the next visitor with HTTP 502.
The trigger costs the attacker nothing exotic — a <!ATTLIST> declaration and a matching attribute on the root element — and it works against every deployment of svg-sanitize ≤ 0.22.0: 45.2M Packagist downloads, the WordPress Safe SVG plugin (1M+ active installs, bundled ≤ 2.4.0), TYPO3 core since v9, Drupal modules, and 90+ dependents. No JavaScript fires, no data is read; the process simply ceases to exist mid-request.
The double-remove mechanism
The sanitizer's attribute pass, cleanAttributesOnWhitelist() (Sanitizer.php:303–330), runs two independent checks over each attribute. The whitelist check removes anything not explicitly allowed. The href safety check fires on any attribute whose name contains "href" — and stripos("badhref", "href") is true. Both checks call DOMElement::removeAttribute() on the same name. On an ordinary element attribute that is harmless. On ours, the first call has already deleted it — so the second call resolves to the only badhref left in the document: the XML_ATTRIBUTE_DECL node created from the DTD:
sanitize($dirty)
└─► loadXML() // DOCTYPE parsed → XML_ATTRIBUTE_DECL node created
└─► cleanAttributesOnWhitelist() // Sanitizer.php:303–330
├─► removeAttribute("badhref") // removes the explicit attribute — SAFE
├─► stripos("badhref", "href") // TRUE → href safety check kicks in
├─► getAttribute("badhref") // returns the DTD #FIXED default
│ // "javascript:alert(1)"
├─► isHrefSafeValue() // FALSE — it must go
└─► removeAttribute("badhref") // targets the XML_ATTRIBUTE_DECL node
└─► ext/dom type confusion → SIGABRT — the worker is gone
The fatal subtlety is getAttribute(): after the explicit attribute is gone, it happily returns the DTD's #FIXED default value — javascript:alert(1) — which of course fails isHrefSafeValue(). The sanitizer then "removes" an attribute that exists only as a declaration node, and ext/dom's internal type assumptions collapse. The process limps to the end of the request — the sanitized output is even produced — and then dies at shutdown: munmap_chunk(): invalid pointer, SIGABRT, exit code 134.
A second trigger path runs through cleanHrefAttributes(): its case-normalization of an attribute like HrEf performs the same remove-and-set dance against the DTD default, reaching the same crash without ever touching the whitelist check.
Proof of concept
The upload — 1009 bytes of perfectly legal XML:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
<!ATTLIST svg badhref CDATA #FIXED "javascript:alert(1)">
]>
<svg xmlns="http://www.w3.org/2000/svg" badhref="x" width="200" height="60">
<rect width="200" height="60" fill="#d33"/>
</svg>
The standalone reproducer:
<?php
use enshrined\svgSanitize\Sanitizer;
$dirty = file_get_contents('evil.svg'); // 1009 bytes
$clean = (new Sanitizer())->sanitize($dirty);
echo 'Sanitized: ' . strlen($clean) . " bytes\n";
Confirmed output — the sanitize "succeeds," then the process dies on its way out:
Sanitized: 157 bytes
munmap_chunk(): invalid pointer
[exit code 134 — SIGABRT]
# php-fpm log, same moment:
WARNING: [pool www] child 17 exited on signal 6 (SIGABRT)
# nginx → HTTP 502 Bad Gateway for the uploading client
The WordPress path
On WordPress the primitive is a media upload away. Reproduced on WordPress 6.9.4 + Safe SVG 2.4.0 with PHP 8.3.24-fpm: log in as Author, Media → Add New, upload the file — HTTP 502:
wp_handle_upload()
└─► safe_svg::check_for_svg() // safe-svg.php:176
└─► sanitize() // safe-svg.php:218
└─► Sanitizer::sanitize() // Sanitizer.php:193
└─► double removeAttribute() → worker killed → HTTP 502
One scoping detail matters for defenders: Safe SVG is only invoked through WordPress's own upload handlers. Form and file-upload plugins that move files with a bare move_uploaded_file() never call it — only code paths going through wp_handle_upload() or wp_handle_sideload() reach the vulnerable sanitizer. The REST API route (/wp/v2/media) does, and it takes the same Author role.
Impact
Full-site denial of service. PHP-FPM runs a bounded pool — pm.max_children = N. N crafted uploads kill all N workers. The master process respawns them, but each respawned worker dies again on the next upload: a modest request loop sustains the outage indefinitely at a cost of one kilobyte per kill. Every site on the same pool — not just the upload endpoint — is down for the duration.
Application-state corruption. SIGABRT does not run PHP's register_shutdown_function() callbacks — the hooks where frameworks persist what the request changed. The crash window opens after business logic ran but before cleanup, which produces failure modes worse than a clean 502:
- WooCommerce: the order completes but the coupon's
usage_countis never incremented — a single-use coupon becomes reusable indefinitely; - Inventory: stock is not decremented — overselling against physical goods;
- wp_cron starvation: scheduled cleanup (unpaid-order cancellation, cart expiry) silently stops running.
Attack surface by entry point:
| Entry point | Required access | Reach |
|---|---|---|
| WordPress Media upload | Author role | Safe SVG: 1M+ active installs |
WordPress REST API /wp/v2/media | Author role | same fleet, scriptable |
| Plugin-dependent public upload forms | varies — only wp_handle_upload()/wp_handle_sideload() callers | plugin-dependent |
| Custom PHP applications with an SVG endpoint | often unauthenticated — the strongest scenario | 45.2M Packagist downloads |
| TYPO3 backend (core since v9) / Drupal modules | editor account | 90+ dependents |
The official score is CVSS 3.1: 6.5 (Medium) — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H. On WordPress that is accurate: the PR:L gate is the Author role, and the impact is availability, not code execution — this crash hands over no control. The report also notes a 7.5 variant for custom applications whose sanitizing endpoints accept uploads without authentication (PR:N) — the same kilobyte, no account needed, against a library with 45.2M downloads. And the coupon/stock corruption above is a reminder that "just a crash" is only as harmless as the shutdown code it skips.
The fix
Patched upstream in 1.0.0. The suggested remediation is the same one that closes the sibling finding — do not let the DTD into the parser at all. Strip the DOCTYPE before loadXML():
$dirty = preg_replace('/<!DOCTYPE[^>]*(?:\[.*?\])?\s*>/si', '', $dirty); // no DOCTYPE → no XML_ATTRIBUTE_DECL → no double-remove target
One regex, applied pre-parse, removes the entire class: no attacker-defined attribute defaults, no declaration nodes for removeAttribute() to trip over — and as a bonus, no entity declarations for GHSA-9rjx-3jch-6vjf to ride on.
Disclosure timeline
- The crash was found by Denis Rostilov during an ExPatch audit of svg-sanitize's DTD handling and reproduced standalone (exit 134) and against WordPress 6.9.4 + Safe SVG 2.4.0 (HTTP 502).
- Reported privately to the maintainer via GitHub Security Advisories, with the PoC and the pre-parse DOCTYPE-strip fix.
- Fix shipped upstream in 1.0.0.
- GHSA-v383-3rw5-q8rf published; ExPatch Security Research / ExPatch-LLC credited as reporter. No CVE was assigned — the GHSA is the identifier.
- 2026-09-25 — this writeup.
References
- GHSA-v383-3rw5-q8rf — the official advisory.
- GHSA-9rjx-3jch-6vjf — our sibling writeup: stored XSS in the same library via DTD entity collision.
- enshrined/svg-sanitize on Packagist — 45.2M downloads.
- Safe SVG (WordPress plugin) — 1M+ active installs.