WordPress Simple:Press plugin fixed critical vulnerabilities.

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