WordPress File Upload RCE Part1
Introduction
In this post, I’ll walk through the discovery and analysis of CVE-2024-9939, a high impact vulnerability (CVSS 3.1 Score: 7.5) and CVE-2024-11635, a critical impact vulnerability (CVSS 3.1 Score: 9.8) affecting the WordPress File Upload Plugin. This vulnerability allows unauthenticated attackers to perform Arbitrary File Read and Remote Code Execution attacks.
Background
During an offsite security assessment, I noticed the WordPress File Upload Plugin installed on a customer’s WordPress instance. As part of my standard methodology, I downloaded version 4.24.11 of the plugin for local analysis, which led to the discovery of two severe security flaws;
- Unauthenticated Arbitrary File Read (CVE-2024-9939).
- Unauthenticated Remote Code Execution vulnerability (CVE-2024-11635).
Affected Versions
- Plugin: WordPress File Upload
- Version: ≤ 4.24.12
Initial Analysis - CVE-2024-9939
My vulnerability discovery process began by identifying potential entry points. I discovered that the wfu_file_downloader.php
had no authentication checks in place and it also allowed direct access.
//wp-file-upload.4.24.12/wfu_file_downloader.php
<?php
if ( !defined("ABSWPFILEUPLOAD_DIR") ) DEFINE("ABSWPFILEUPLOAD_DIR", dirname(__FILE__).'/');
if ( !defined("WFU_AUTOLOADER_PHP50600") ) DEFINE("WFU_AUTOLOADER_PHP50600", 'vendor/modules/php5.6/autoload.php');
include_once( ABSWPFILEUPLOAD_DIR.'lib/wfu_functions.php' );
include_once( ABSWPFILEUPLOAD_DIR.'lib/wfu_security.php' );
$handler = (isset($_POST['handler']) ? $_POST['handler'] : (isset($_GET['handler']) ? $_GET['handler'] : '-1'));
$session_legacy = (isset($_POST['session_legacy']) ? $_POST['session_legacy'] : (isset($_GET['session_legacy']) ? $_GET['session_legacy'] : ''));
$dboption_base = (isset($_POST['dboption_base']) ? $_POST['dboption_base'] : (isset($_GET['dboption_base']) ? $_GET['dboption_base'] : '-1'));
$dboption_useold = (isset($_POST['dboption_useold']) ? $_POST['dboption_useold'] : (isset($_GET['dboption_useold']) ? $_GET['dboption_useold'] : ''));
$wfu_cookie = (isset($_POST['wfu_cookie']) ? $_POST['wfu_cookie'] : (isset($_GET['wfu_cookie']) ? $_GET['wfu_cookie'] : ''));
if ( $handler == '-1' || $session_legacy == '' || $dboption_base == '-1' || $dboption_useold == '' || $wfu_cookie == '' ) die();
else {
$GLOBALS["wfu_user_state_handler"] = wfu_sanitize_code($handler);
$GLOBALS["WFU_GLOBALS"]["WFU_US_SESSION_LEGACY"] = array( "", "", "", ( $session_legacy == '1' ? 'true' : 'false' ), "", true );
$GLOBALS["WFU_GLOBALS"]["WFU_US_DBOPTION_BASE"] = array( "", "", "", wfu_sanitize_code($dboption_base), "", true );
$GLOBALS["WFU_GLOBALS"]["WFU_US_DBOPTION_USEOLD"] = array( "", "", "", ( $dboption_useold == '1' ? 'true' : 'false' ), "", true );
if ( !defined("WPFILEUPLOAD_COOKIE") ) DEFINE("WPFILEUPLOAD_COOKIE", wfu_sanitize_tag($wfu_cookie));
wfu_download_file();
}
...
function wfu_download_file() {
global $wfu_user_state_handler;
$file_code = (isset($_POST['file']) ? $_POST['file'] : (isset($_GET['file']) ? $_GET['file'] : ''));
$ticket = (isset($_POST['ticket']) ? $_POST['ticket'] : (isset($_GET['ticket']) ? $_GET['ticket'] : ''));
if ( $file_code == '' || $ticket == '' ) die();
wfu_initialize_user_state();
$ticket = wfu_sanitize_code($ticket);
$file_code = wfu_sanitize_code($file_code);
//if download ticket does not exist or is expired die
if ( !WFU_USVAR_exists_downloader('wfu_download_ticket_'.$ticket) || time() > WFU_USVAR_downloader('wfu_download_ticket_'.$ticket) ) {
WFU_USVAR_unset_downloader('wfu_download_ticket_'.$ticket);
WFU_USVAR_unset_downloader('wfu_storage_'.$file_code);
wfu_update_download_status($ticket, 'failed');
die();
}
//destroy ticket so it cannot be used again
...
elseif ( substr($file_code, 0, 8) == "debuglog" ) {
$file_code = substr($file_code, 8);
//$filepath = wfu_get_filepath_from_safe($file_code);
$filepath = WFU_USVAR_downloader('wfu_storage_'.$file_code);
$disposition_name = wfu_basename($filepath);
$delete_file = false;
}
...
//destroy file code as it is no longer needed
WFU_USVAR_unset_downloader('wfu_storage_'.$file_code);
//check that file exists
if ( !wfu_file_exists_for_downloader($filepath) ) {
wfu_update_download_status($ticket, 'failed');
die('<script language="javascript">alert("'.( WFU_USVAR_exists_downloader('wfu_browser_downloadfile_notexist') ? WFU_USVAR_downloader('wfu_browser_downloadfile_notexist') : 'File does not exist!' ).'");</script>');
}
$open_session = false;
@set_time_limit(0); // disable the time limit for this script
$fsize = wfu_filesize_for_downloader($filepath);
if ( $fd = wfu_fopen_for_downloader($filepath, "rb") ) {
$open_session = ( ( $wfu_user_state_handler == "session" || $wfu_user_state_handler == "" ) && ( function_exists("session_status") ? ( PHP_SESSION_ACTIVE !== session_status() ) : ( empty(session_id()) ) ) );
if ( $open_session ) session_start();
header('Content-Type: application/octet-stream');
header("Content-Disposition: attachment; filename=\"".$disposition_name."\"");
header('Content-Transfer-Encoding: binary');
header('Connection: Keep-Alive');
header('Expires: 0');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header("Content-length: $fsize");
$failed = false;
while( !feof($fd) ) {
$buffer = @fread($fd, 1024*8);
echo $buffer;
ob_flush();
flush();
if ( connection_status() != 0 ) {
$failed = true;
break;
}
}
fclose ($fd);
}
...
}
...
function wfu_update_download_status($ticket, $new_status) {
require_once WFU_USVAR_downloader('wfu_ABSPATH').'wp-load.php';
WFU_USVAR_store('wfu_download_status_'.$ticket, $new_status);
}
...
function WFU_USVAR_exists_downloader($var) {
global $wfu_user_state_handler;
if ( $wfu_user_state_handler == "dboption" && WFU_VAR("WFU_US_DBOPTION_BASE") == "cookies" ) return isset($_COOKIE[$var]);
else return WFU_USVAR_exists_session($var);
}
...
function WFU_USVAR_downloader($var) {
global $wfu_user_state_handler;
if ( $wfu_user_state_handler == "dboption" && WFU_VAR("WFU_US_DBOPTION_BASE") == "cookies" ) return $_COOKIE[$var];
else return WFU_USVAR_session($var);
}
...
function wfu_file_exists_for_downloader($filepath) {
if ( substr($filepath, 0, 7) != "sftp://" ) return file_exists($filepath);
...
}
function wfu_filesize_for_downloader($filepath) {
if ( substr($filepath, 0, 7) != "sftp://" ) return filesize($filepath);
...
}
...
function wfu_fopen_for_downloader($filepath, $mode) {
if ( substr($filepath, 0, 7) != "sftp://" ) return @fopen($filepath, $mode);
...
}
...
function WFU_USVAR_unset_downloader($var) {
global $wfu_user_state_handler;
if ( $wfu_user_state_handler == "session" || $wfu_user_state_handler == "" ) WFU_USVAR_unset_session($var);
}
...
//wp-file-upload.4.24.12/lib/wfu_functions.php
function wfu_sanitize_code($code) {
return preg_replace("/[^A-Za-z0-9]/", "", $code);
}
...
function wfu_basename($path) {
if ( !$path || $path == "" ) return "";
return preg_replace('/.*(\\\\|\\/)/', '', $path);
}
...
function wfu_initialize_user_state() {
$a = func_get_args(); $a = WFU_FUNCTION_HOOK(__FUNCTION__, $a, $out); if (isset($out['vars'])) foreach($out['vars'] as $p => $v) $$p = $v; switch($a) { case 'R': return $out['output']; break; case 'D': die($out['output']); }
global $wfu_user_state_handler;
if ( $wfu_user_state_handler == "dboption" && WFU_VAR("WFU_US_DBOPTION_BASE") == "cookies" ) {
if ( wfu_get_session_cookie() == "" ) wfu_set_session_cookie();
}
elseif ( WFU_VAR("WFU_US_SESSION_LEGACY") == "true" && !headers_sent() && ( function_exists("session_status") ? ( PHP_SESSION_ACTIVE !== session_status() ) : ( session_id() == "" ) ) ) { session_start(); }
}
Let’s take a closer look at wfu_file_downloader.php
. The code starts off by checking whether the required parameters are sent, the first issue is we can set the parameters via Cookies if we set our dboption
HTTP GET/POST parameter to cookies
.
If we examine the wfu_download_file
function we see that the only authorisation check in place was to see whether the ticket has expired, the problem with this is it compared the current time to whatever time we sent in the wfu_download_ticket_
Cookie, therefore we can easily bypass this check.
Once we have bypassed this check, we can read any file on the filesystem.
Initial Analysis - CVE-2024-11635
A few weeks had gone by, so I decided to take a second look at the wfu_file_downloader.php
.
So let’s revisit the below code snippet:
//wp-file-upload.4.24.12/wfu_file_downloader.php
function wfu_download_file() {
global $wfu_user_state_handler;
$file_code = (isset($_POST['file']) ? $_POST['file'] : (isset($_GET['file']) ? $_GET['file'] : ''));
$ticket = (isset($_POST['ticket']) ? $_POST['ticket'] : (isset($_GET['ticket']) ? $_GET['ticket'] : ''));
if ( $file_code == '' || $ticket == '' ) die();
wfu_initialize_user_state();
$ticket = wfu_sanitize_code($ticket);
$file_code = wfu_sanitize_code($file_code);
//if download ticket does not exist or is expired die
if ( !WFU_USVAR_exists_downloader('wfu_download_ticket_'.$ticket) || time() > WFU_USVAR_downloader('wfu_download_ticket_'.$ticket) ) {
WFU_USVAR_unset_downloader('wfu_download_ticket_'.$ticket);
WFU_USVAR_unset_downloader('wfu_storage_'.$file_code);
wfu_update_download_status($ticket, 'failed');
die();
}
...
}
...
function wfu_update_download_status($ticket, $new_status) {
require_once WFU_USVAR_downloader('wfu_ABSPATH').'wp-load.php';
WFU_USVAR_store('wfu_download_status_'.$ticket, $new_status);
}
...
function WFU_USVAR_exists_downloader($var) {
global $wfu_user_state_handler;
if ( $wfu_user_state_handler == "dboption" && WFU_VAR("WFU_US_DBOPTION_BASE") == "cookies" ) return isset($_COOKIE[$var]);
else return WFU_USVAR_exists_session($var);
}
...
function WFU_USVAR_downloader($var) {
global $wfu_user_state_handler;
if ( $wfu_user_state_handler == "dboption" && WFU_VAR("WFU_US_DBOPTION_BASE") == "cookies" ) return $_COOKIE[$var];
else return WFU_USVAR_session($var);
}
I noticed that during the execution of the wfu_download_file
function, if the wfu_download_ticket_
Cookie doesn’t exist or the ticket is expired the wfu_update_download_status
function is called.
I examined the wfu_update_download_status
function and discovered the wfu_ABSPATH
Cookie is being used without any santization or validation in a require_once
statement.
With this in mind, we can perform Remote File Inclusion (RFI) or Local File Inclusion (LFI) attacks, granting us Remote Code Execution.
To gain reliable code execution, I utilized the php filter chain attack vector.
Patch Analysis
After reporting this vulnerability, the vendor addressed it by completely eliminating the use of inputs via Cookies. In a follow-up blog post next week, I’ll examine how this patch introduced a new vulnerability CVE-2024-11613 — subscribe to my newsletter to get notified when it drops!
Remediation and Disclosure
- Reported first vulnerability to vendor on [14/10/2024]
- CVE assigned: CVE-2024-9939 [14/10/2024]
- Both vulnerabilities fixed in version 4.24.14 [14/11/2024]
- Reported second vulnerability to vendor on [20/11/2024]
- CVE assigned: CVE-2024-11635 [22/10/2024]
- Public Disclosure [07/01/2025]
Conclusion
This was a fun vulnerability to discover!
It reminded me of the importance of revisiting pieces of code to discover further vulnerabilities in them.
Thanks for Reading!