The WordPress Simple:Press plugin (600+ active installations) fixed a broken access control vulnerability affecting version 6.6.0 and below that could lead to unauthenticated arbitrary file upload and remote code execution.
Unauthenticated arbitrary file upload
The plugin registers several AJAX actions in the “sp-startup/admin/spa-admin-ajax-actions.php” script. However, those hooks will only be loaded if the HTTP_REFERER
of the HTTP request includes the path to the blog’s admin URL, e.g., http://example.com/wp-admin/
, as we can see in the “sp-startup/core/sp-core-ajax.php” script:
function sp_is_frontend_ajax() { $ajax = false; if (wp_doing_ajax()) { $filename = isset($_SERVER['SCRIPT_FILENAME']) ? $_SERVER['SCRIPT_FILENAME'] : ''; $ref = isset($_SERVER['HTTP_REFERER']) ? wp_unslash($_SERVER['HTTP_REFERER']) : ''; if (((strpos($ref, admin_url()) === false) && (basename($filename) === 'admin-ajax.php'))) { $ajax = true; } } return $ajax; }
The uploader
action, used by a logged-in administrator to upload custom icons, is accessible to all users, authenticated or not:
function spa_ajax_uploader() { require SP_PLUGIN_DIR.'/admin/resources/jscript/ajaxupload/sf-uploader.php'; } add_action('wp_ajax_uploader', 'spa_ajax_uploader'); add_action('wp_ajax_nopriv_uploader', 'spa_ajax_uploader');
It loads the “admin/resources/jscript/ajaxupload/sf-uploader.php” script that will handle the upload:
<?php /* Simple:Press Image Uploader Script $LastChangedDate: 2010-03-26 16:38:27 -0700 (Fri, 26 Mar 2010) $ $Rev: 3818 $ */ if (preg_match('#'.basename(__FILE__).'#', $_SERVER['PHP_SELF'])) die('Access denied - you cannot directly call this file'); // ========= NEEDS TO BE TURNED OFF UNTIL ALL PLUGIN ADMIN FORMS ARE CHANGED TO USE THE NEW NONCE CODE //if (!sp_nonce('uploader')) die(); // =================================================================================================== # workaround function for php installs without exif. leave original function since this is slower. if (!function_exists('exif_imagetype')) { function exif_imagetype($filename) { if ((list($width, $height, $type, $attr) = @getimagesize(str_replace(' ', '%20', $filename))) !== falseprepend) return $type; return false; } } $uploaddir = SP()->filters->str($_POST['saveloc']); # Clean up file name just in case $uploadfile = $uploaddir.SP()->saveFilters->filename(basename($_FILES['uploadfile']['name'])); # check image file mimetype $mimetype = 0; $mimetype = exif_imagetype($_FILES['uploadfile']['tmp_name']); if (empty($mimetype) || $mimetype == 0 || $mimetype > 3) { echo 'invalid'; die(); } # check for existence if (file_exists($uploadfile)) { echo 'exists'; die(); } # check file size against limit if provided if (isset($_POST['size'])) { if ($_FILES['uploadfile']['size'] > $_POST['size']) { echo 'size'; die(); } } # try uploading the file over if (move_uploaded_file($_FILES['uploadfile']['tmp_name'], $uploadfile)) { @chmod("$uploadfile", 0644); echo "success"; } else { # WARNING! DO NOT USE "FALSE" STRING AS A RESPONSE! # Otherwise onSubmit event will not be fired echo "error"; } die();
The function lacks a capability check and a security nonce. It only tries to verify the type of the file with the exif_imagetype
function. I explained in a previous post that PHP developers should never rely on functions such as exif_imagetype
or getimagesize
for security checks because they can easily be bypassed. In the above example, an unauthenticated attacker could prepend the GIF
magic bytes to a PHP script, upload it and bypass the verification. Unless the path to the destination folder is assigned to the $_POST['saveloc']
variable, the file will be uploaded into /wp-admin/
, the current directory:
Authenticated arbitrary file upload
Still in the “sp-startup/admin/spa-admin-ajax-actions.php” script, the spa_ajax_iconset_uploader
function is loaded via the iconset_uploader
AJAX action and accessible to authenticated users only (Simple:Press being a forum, all members can access it). It is used by the administrator to upload their own custom iconsets in a ZIP archive.
/** * load iconset uploader script */ function spa_ajax_iconset_uploader() { require SP_PLUGIN_DIR.'/admin/resources/jscript/ajaxupload/sf-iconset_uploader.php'; } add_action('wp_ajax_iconset_uploader', 'spa_ajax_iconset_uploader');
The upload and extraction of the ZIP file is handled by the “admin/resources/jscript/ajaxupload/sf-iconset_uploader.php” script:
<?php /* Simple:Press Iconset Uploader Script */ if (preg_match('#'.basename(__FILE__).'#', $_SERVER['PHP_SELF'])) die('Access denied - you cannot directly call this file'); // ========= NEEDS TO BE TURNED OFF UNTIL ALL PLUGIN ADMIN FORMS ARE CHANGED TO USE THE NEW NONCE CODE //if (!sp_nonce('uploader')) die(); // =================================================================================================== require_once SP_PLUGIN_DIR.'/admin/library/spa-iconsets.php'; $sfconfig = SP()->options->get('sfconfig'); $iconsets_base_dir = SP_STORE_DIR . '/' . $sfconfig['iconsets'] . '/'; $upload_dir = $iconsets_base_dir . '__uploads/'; if ( !file_exists($upload_dir) ) { @mkdir($upload_dir, 0775); } # Clean up file name just in case $filename = strtolower ( SP()->saveFilters->filename( basename( $_FILES['uploadfile']['name'] ) ) ); $uploadfile = $upload_dir. $filename; $filename_info = pathinfo( $filename ); $iconset_id = $filename_info['filename']; $extract_to = $iconsets_base_dir . $filename_info['filename']; # check for existence if ( file_exists( $uploadfile ) || file_exists( $extract_to ) ) { echo 'exists'; die(); } # try uploading the file over if ( move_uploaded_file( $_FILES['uploadfile']['tmp_name'], $uploadfile ) ) { @chmod( $uploadfile, 0644 ); # Now try and unzip it require_once ABSPATH.'wp-admin/includes/class-pclzip.php'; $zipfile = $uploadfile; $zipfile = str_replace('\\', '/', $zipfile); # sanitize for Win32 installs $zipfile = preg_replace('|/+|', '/', $zipfile); # remove any duplicate slash $extract_to = str_replace('\\', '/', $extract_to); # sanitize for Win32 installs $extract_to = preg_replace('|/+|', '/', $extract_to); # remove any duplicate slash $archive = new PclZip($zipfile); $archive->extract($extract_to); if ($archive->error_code == 0) { $response = spa_add_iconset( array( 'id' => $iconset_id, 'active' => true ) ); if( is_wp_error( $response) ) { echo 'error'; die(); } $successExtract1 = true; } else { echo "error"; die(); } # Lets try and remove the zip as it seems to have worked @unlink($zipfile); echo "success"; } else { # WARNING! DO NOT USE "FALSE" STRING AS A RESPONSE! # Otherwise onSubmit event will not be fired echo "error"; } die();
Here too, there’s no capability check or security nonce, allowing any authenticated user to upload one or more PHP scripts packed into a ZIP file and, despite the error returned by the spa_add_iconset
function when checking the files, the content of the archive will be extracted anyway and accessible at http://example.com/wp-content/sp-resources/forum-iconsets/{name_of_zip_file}/{name_of_php_script}
.
Timeline
The vulnerabilities were reported to the authors on September 18th, 2020 and a new version 6.6.1 was quickly released on the same day.
Recommendations
Upgrade immediately if you have version 6.6.0 or below. If you are using our web application firewall for WordPress, NinjaFirewall WP Edition (free) and NinjaFirewall WP+ Edition (premium), you are protected against this vulnerability.
Stay informed about the latest vulnerabilities
- Running WordPress? You can get email notifications about vulnerabilities in the plugins or themes installed on your blog.
- On Twitter: @nintechnet