WordPress File Upload RCE Part2
Introduction
In my previous post, I detailed the discovery of CVE-2024-9939 and CVE-2024-11635, high-impact vulnerabilities affecting the WordPress File Upload plugin. As promised, this follow-up examines how the vendor’s patch inadvertently introduced a new critical vulnerability (CVSS 3.1 Score: 9.8): CVE-2024-11613.
Background
After reporting the initial vulnerabilities, the WordPress File Upload plugin was updated to version 4.24.14, which removed the use of cookie-based inputs. However, during my post-patch analysis, I discovered that this fix created a new attack vector with equally severe implications.
I also discovered a new trick with implication’s beyond the scope of the WordPress File Upload plugin.
Affected Versions
- Plugin: WordPress File Upload
- Version: 4.24.14 - 4.24.15
Patch Analysis - (CVE-2024-9939 & CVE-2024-11635)
The initial fix focused on eliminating the cookie-based input vector that allowed the previous exploits. Let’s examine how these changes were implemented:
//wp-file-upload.4.24.15/wfu_file_downloader.php
<?php
$downloader_data = wfu_read_downloader_data();
$GLOBALS["wfu_downloader_data"] = $downloader_data;
...
wfu_download_file();
...
function wfu_read_downloader_data() {
// read the temp file name from URL param
$source = (isset($_POST['source']) ? $_POST['source'] : (isset($_GET['source']) ? $_GET['source'] : ''));
if ( $source === '' ) die();
$filepath = sys_get_temp_dir();
if ( substr($filepath, -1) != '/' ) $filepath .= '/';
$filepath .= $source;
if ( !file_exists($filepath) ) die();
// read and decode the data from the file
$dataenc = @file_get_contents($filepath);
// remove the file immediately so that it cannot be reused
@unlink($filepath);
if ( $dataenc === false ) die();
$data = json_decode($dataenc, true);
if ( $data === null ) die();
// validate type
$type = ( array_key_exists('type', $data) ? $data['type'] : '' );
if ( !in_array( $type, array( 'normal', 'exportdata', 'debuglog' ) ) ) die();
// validate ticket
$ticket = ( array_key_exists('ticket', $data) ? wfu_sanitize_code_downloader($data['ticket']) : '' );
if ( empty($ticket) || $ticket !== $data['ticket'] ) die();
// validate file
$filepath = ( array_key_exists('filepath', $data) ? wfu_sanitize_url_downloader($data['filepath']) : '' );
if ( empty($filepath) || $filepath !== $data['filepath'] ) die();
// validate user state handler
$handler = ( array_key_exists('handler', $data) ? $data['handler'] : null );
if ( $handler === null || !in_array( $handler, array( 'session', 'dboption', '' ) ) ) die();
// validate expire
$expire = ( array_key_exists('expire', $data) ? intval($data['expire']) : 0 );
if ( $expire <= 0 ) die();
// validate wfu_ABSPATH
$wfu_ABSPATH = ( array_key_exists('wfu_ABSPATH', $data) ? wfu_sanitize_url_downloader($data['wfu_ABSPATH']) : '' );
if ( empty($wfu_ABSPATH) || $wfu_ABSPATH !== $data['wfu_ABSPATH'] ) die();
// validate error messages
$notexist = ( array_key_exists('wfu_browser_downloadfile_notexist', $data) ? strip_tags($data['wfu_browser_downloadfile_notexist']) : '' );
$failed = ( array_key_exists('wfu_browser_downloadfile_failed', $data) ? strip_tags($data['wfu_browser_downloadfile_failed']) : '' );
if ( empty($notexist) || empty($failed) || $notexist !== $data['wfu_browser_downloadfile_notexist'] || $failed !== $data['wfu_browser_downloadfile_failed'] ) die();
return $data;
}
...
function wfu_download_file() {
global $wfu_downloader_data;
extract($wfu_downloader_data);
...
}
...
function wfu_sanitize_code_downloader($code) {
return preg_replace("/[^A-Za-z0-9]/", "", $code);
}
...
function wfu_sanitize_url_downloader($url) {
return filter_var(strip_tags($url), FILTER_SANITIZE_URL);
}
Ultimately the developers decided to read the inputs from a temporary file, this file can only be generated by an admin.
When the file is referenced by the source HTTP GET/POST parameter, if the file exists, its content would be read and the file would be deleted.
The New Vulnerability (CVE-2024-11613)
While eliminating the cookie-based input was the right approach, the replacement mechanism introduced a new vulnerability.
//wp-file-upload.4.24.15/wfu_file_downloader.php
<?php
$downloader_data = wfu_read_downloader_data();
$GLOBALS["wfu_downloader_data"] = $downloader_data;
...
wfu_download_file();
...
function wfu_read_downloader_data() {
// read the temp file name from URL param
$source = (isset($_POST['source']) ? $_POST['source'] : (isset($_GET['source']) ? $_GET['source'] : ''));
if ( $source === '' ) die();
$filepath = sys_get_temp_dir();
if ( substr($filepath, -1) != '/' ) $filepath .= '/';
$filepath .= $source;
if ( !file_exists($filepath) ) die();
// read and decode the data from the file
$dataenc = @file_get_contents($filepath);
// remove the file immediately so that it cannot be reused
@unlink($filepath);
...
}
...
function wfu_download_file() {
global $wfu_downloader_data;
extract($wfu_downloader_data);
//if download ticket does not exist or is expired die
if ( time() > $expire ) {
wfu_update_download_status('failed');
die();
}
...
}
...
function wfu_update_download_status($new_status) {
global $wfu_downloader_data;
require_once $wfu_downloader_data['wfu_ABSPATH'].'wp-load.php';
WFU_USVAR_store('wfu_download_status_'.$wfu_downloader_data['ticket'], $new_status);
}
The first vulnerability occurs because there was no sanitization in place of the source HTTP GET/POST parameter, using this we can traverse out of the temp directory, this introduced an arbitrary file delete vulnerability.
The rest of the code relatively remained unchanged, the key change is that inputs must come from a temporary file on the filesystem, with this in mind we could exploit CVE-2024-9939 and CVE-2024-11635.
The only hurdle we have to cross, is to find a way to upload our payload, then it occured to me the whole point of this plugins existence according to the developer is:
“With this plugin you or other users can upload files to your site from any page, post or sidebar easily and securely.”
The function that allowed us to upload files as an unauthenticated user is the wfu_ajax_action AJAX hook.
So at this point in time we have a clear cut plan on how we gain RCE:
- Craft a JSON file payload with the php filter chain attack vector.
- Use the wfu_ajax_action AJAX to upload our payload.
- Use the path traversal to load our payload from the
wp-content/uploads/
directory.
Unfortunately this plan failed in step 2, the wfu_ajax_action AJAX hook, had a very rigourous checklist in place for files being uploaded, this checks where implemented in the wfu_process_files function, the checklist was:
- Check if a DOS attack was happening.
- Filename check to reject files with dangerous extensions.
- File contents check to reject files with PHP tags.
- File size check to reject files larger than set limit.
- Final check to see if the mimetype of the file matches that set in the content-type header, this also rejects mimetype not in the default wordpress whitelist.
The final check is handled by the wfu_wp_check_filetype_and_ext function, which calls the in built wp_check_filetype_and_ext WordPress function. This function only accepts files with mimetypes in the default wordpress whitelist.
To know the mimetype of the uploaded file the wp_check_filetype_and_ext function utilizes the finfo_file php function.
The problem we run into is application/json
is not on the default whitelist.
At this junction, my objective was to find a way to masquerade my payload to match a file in the whitelist.
So, I put pen to paper and came up with a list of ideas on how to tackle this:
- Masquerade the payload as a csv or tsv file.
- Exotic characters, i.e non ascii characters.
- Encode part of the file as utf-8 and other parts as utf-16 or some other variant.
- Create a very complex JSON payload, i.e with multiple keys.
- Ask for help in community.
Well, all these ideas failed for one reason or the other, some of them bypassed the uploader checks, but in the end the json_decode function was very strict and only accepted valid JSON.
So I retired for the night.
Fastforward to the next morning, the idea came to me, what if I create valid junk to go with the JSON payload, just the right amount of valid junk to masquerade the payload as a txt file.
Well this worked, basically the valid junk consists of:
- white spaces.
- tabs.
- new lines.
So we generate a very big file that sandwiches the JSON payload in the middle of our junk.
The best part a file as small as 2.1 mb is satisfactory for this, we can generate this file using the below code:
import random
import sys
def create_large_junk_json_file(json_data, output_file, min_size_mb, max_size_mb):
"""
Create a JSON file with random junk data around the JSON content.
Args:
json_data (str): The actual JSON content to embed.
output_file (str): The output file path.
min_size_mb (float): Minimum size of the junk data in megabytes.
max_size_mb (float): Maximum size of the junk data in megabytes.
"""
# Determine the size of the junk data in bytes
size_in_bytes = random.randint(int(min_size_mb * 1024 * 1024), int(max_size_mb * 1024 * 1024))
# Generate random junk: mix of spaces, tabs, and newlines
junk_chars = [" ", "\t", "\n"]
junk_data = ''.join(random.choice(junk_chars) for _ in xrange(size_in_bytes))
# Construct the final file content
content = junk_data + json_data + junk_data
# Write the large junk-filled JSON file
with open(output_file, 'w') as f:
f.write(content)
print("Generated file with random junk size of {:.2f} MB: {}".format(size_in_bytes / 1024.0 / 1024.0, output_file))
json_data = '{"foo": 1}'
# Generate a file with 1.1 MB to 1.5 MB of random junk
create_large_junk_json_file(json_data, 'large_junk.txt', 1.1, 1.5)
Proof of Concept
Armed with the mimetype bypass exploit, we revist our original plan on how we gain RCE:
- Craft a JSON file payload with the php filter chain attack vector.
- Use the wfu_ajax_action AJAX to upload our payload.
- Use the path traversal to load our payload from the
wp-content/uploads/
directory.
Patch Analysis - (CVE-2024-11613)
After reporting this vulnerability, the vendor addressed it by implementing sanitization of source HTTP GET/POST parameter, this was enough to mitigate the path traversal:
--- wp-file-upload/trunk/wfu_file_downloader.php (revision 3188857)
+++ wp-file-upload/trunk/wfu_file_downloader.php (revision 3217005)
@@ -24,4 +24,6 @@
$source = (isset($_POST['source']) ? $_POST['source'] : (isset($_GET['source']) ? $_GET['source'] : ''));
if ( $source === '' ) die();
+ // sanitize source file to avoid directory traversals through it
+ $source = preg_replace("/[^A-Za-z0-9]/", "", $source);
$filepath = sys_get_temp_dir();
if ( substr($filepath, -1) != '/' ) $filepath .= '/';
Remediation and Disclosure
- Reported to vendor [17/11/2024]
- CVE assigned: CVE-2024-11613 [21/11/2024]
- Fixed in version 4.25.0 [04/01/2024]
- Public Disclosure [07/01/2025]
Conclusion
This vulnerability was a nice one, that well and truely tested me, it was fun.
I learned new things and uncovered a new attack vector during this research.
I find the patch satisfactory for now.
Thanks for Reading! If you found this analysis valuable, consider subscribing to my newsletter for more security insights.