Pre-Auth Arbitrary File Upload in User Submitted Posts Plugin

Published 12 October 2023
Updated 11 December 2023
Rafie Muhammad
Security Researcher at Patchstack
Table of Contents

This blog post is about the User Submitted Posts plugin vulnerability. If you're a User Submitted Posts user, please update the plugin to at least version 20230914.

Patchstack Developer and Business users are protected from the vulnerability. You can also sign up for the Patchstack Community plan to be notified about vulnerabilities as soon as they become disclosed.

For plugin developers, we have security audit services and Threat Intelligence Feed API for hosting companies.

About the User Submitted Posts Plugin

The plugin User Submitted Posts (versions 20230902 and below, free version), which has over 20,000 active installations is known as the more popular user-generated content plugin in WordPress. This plugin is developed by Plugin Planet.

User Submitted Posts

This plugin enables visitors to submit posts from the front end of our site. It provides a front-end form that enables visitors to submit posts and upload images. We just need to add a shortcode to any Post, Page, or Widget. The post-submission form may include several custom field types.

The security vulnerability in User Submitted Posts

This plugin suffers from an unauthenticated arbitrary file upload vulnerability. This vulnerability allows any unauthenticated user to upload arbitrary files, including phtml files, that could lead to remote code execution. The described vulnerability was fixed in version 20230914 and assigned CVE-2023-45603.

Unauthenticated Arbitrary File Upload

The underlying vulnerable code exists in the usp_attach_images function :

function usp_attach_images($post_id, $newPost, $files, $file_count) {
	global $usp_options;
	
    do_action('usp_files_before', $files);
	$attach_ids = array();
	
	if ($files && $file_count > 0) {
		usp_include_deps();
		for ($i = 0; $i < $file_count; $i++) {	
			if (isset($files['tmp_name'][$i]) && !empty($files['tmp_name'][$i])) {	
				$file_local = file_get_contents($files['tmp_name'][$i]);	
				$tmp_name = $files['tmp_name'][$i];	
			} else {
				continue;
			}
			if (isset($files['name'][$i]) && !empty($files['name'][$i])) {
				$append = ($file_count > 1) ? '-'. $i : '';
				$file_name = sanitize_file_name(basename($files['name'][$i]));
				$parts = pathinfo($file_name);
				$ext = isset($parts['extension']) ? $parts['extension'] : null;
				$append = apply_filters('usp_filename_append', $append, $file_name, $ext);
				$filename = isset($parts['filename']) ? $parts['filename'] : usp_random_string();
				$file_name = isset($parts['filename']) ? $parts['filename'] . $append .'.'. $ext : $file_name;
				$file_name = apply_filters('usp_file_name', $file_name, $filename, $append, $ext);
			} else {
				continue;
			}
			
			$file_local = usp_maybe_rotate($tmp_name, $file_local);
			$file_path = defined('USP_UPLOAD_DIR') ? USP_UPLOAD_DIR : '/';
			$upload_dir = apply_filters('usp_upload_directory', wp_upload_dir());
			$wp_filetype = wp_check_filetype($file_name, null);
			
			if (wp_mkdir_p($upload_dir['path'])) {	
				$file = isset($upload_dir['path']) ? $upload_dir['path'] . $file_path . $file_name : null;
				$guid = isset($upload_dir['url'])  ? $upload_dir['url']  . $file_path . $file_name : null;	
			} else {	
				$file = isset($upload_dir['basedir']) ? $upload_dir['basedir'] . $file_path . $file_name : null;
				$guid = isset($upload_dir['baseurl']) ? $upload_dir['baseurl'] . $file_path . $file_name : null;	
			}
			
			$file = file_exists($file) ? usp_unique_filename($file) : $file;
			
			if (stripos($ext, 'php') === false) $bytes = file_put_contents($file, $file_local);

This function will be called from usp_createPublicSubmission function that handles the creation of posts for public submission. Let's view the call process on the usp_createPublicSubmission function :

function usp_createPublicSubmission($title, $files, $ip, $author, $url, $email, $tags, $captcha, $verify, $content, $category, $custom, $custom_2, $checkbox, $comments) {
	global $usp_options;
	
	$newPost = array('id' => null, 'error' => array());
	$author_data = usp_get_author($author);
	$author      = $author_data['author'];
	$author_id   = $author_data['author_id'];
	
	if (isset($author_data['error']) && !empty($author_data['error'])) {
		$newPost['error'][] = $author_data['error'];
		
	}
	
	$file_data  = usp_check_images($files, $newPost);
	$file_count = $file_data['file_count'];
	
	if (isset($file_data['error']) && !empty($file_data['error'])) {
		$newPost['error'] = array_unique(array_merge($file_data['error'], $newPost['error']));
		
	}
	
---------------------- CUTTED HERE ----------------------
	
	if ($post_id && !is_wp_error($post_id)) {
		$post = get_post($post_id);
		$post->post_status = $new_status;
		$post->comment_status = $comments;
		wp_update_post($post);
		wp_set_post_tags($post_id, apply_filters('usp_filter_tags', $tags), apply_filters('usp_append_tags', false));
		wp_set_post_categories($post_id, apply_filters('usp_filter_cats', $category), apply_filters('usp_append_cats', false));
		$newPost = usp_attach_images($post_id, $newPost, $files, $file_count);
---------------------- CUTTED HERE ----------------------

There is a lot going on in the usp_createPublicSubmission function, but we will focus on the handling of $files variables. This variable is simply an $_FILES input constructed on usp_checkForPublicSubmission which handles the parse request process :

function usp_checkForPublicSubmission() {
	global $usp_options;
	
	$is_submitted = (isset($_POST['usp-nonce']) && wp_verify_nonce($_POST['usp-nonce'], 'usp-nonce')) ? true : false;
	$is_allowed = apply_filters('usp_check_if_allowed', true);
	
	if ($is_submitted && $is_allowed) {
		$title = usp_get_submitted_title();
		$ip = usp_get_ip_address();
		$custom = usp_get_custom_field();
		$custom_2 = usp_get_custom_field_2();
		$checkbox = usp_get_custom_checkbox();
		$comments = usp_get_comment_status();
		$category = usp_get_submitted_category();
		$tags = usp_get_submitted_tags();
		$files = isset($_FILES['user-submitted-image']) ? $_FILES['user-submitted-image'] : array();
		
		$author   = isset($_POST['user-submitted-name'])     ? sanitize_text_field($_POST['user-submitted-name'])     : '';
		$url      = isset($_POST['user-submitted-url'])      ? esc_url($_POST['user-submitted-url'])                  : '';
		$email    = isset($_POST['user-submitted-email'])    ? sanitize_text_field($_POST['user-submitted-email'])    : '';
		$captcha  = isset($_POST['user-submitted-captcha'])  ? sanitize_text_field($_POST['user-submitted-captcha'])  : '';
		$verify   = isset($_POST['user-submitted-verify'])   ? sanitize_text_field($_POST['user-submitted-verify'])   : '';
		$content  = isset($_POST['user-submitted-content'])  ? usp_sanitize_content($_POST['user-submitted-content']) : '';
		
		$result = usp_createPublicSubmission($title, $files, $ip, $author, $url, $email, $tags, $captcha, $verify, $content, $category, $custom, $custom_2, $checkbox, $comments);
---------------------- CUTTED HERE ----------------------

Back to the usp_createPublicSubmission function. We could see that the usp_check_images will be used to check the $files variables and will assign an error condition if there is an error coming from usp_check_images function check. Let's examine the usp_check_images function :

function usp_check_images($files, $newPost) {
	global $usp_options;
	
	$error = array(); $file_count = 0;
	$name = isset($files['name'])     ? array_filter($files['name'])     : false;
	$temp = isset($files['tmp_name']) ? array_filter($files['tmp_name']) : false;
	$errr = isset($files['error'])    ? array_filter($files['error'])    : false;
	
	if ($usp_options['usp_images'] == 'show') {
		if (!empty($temp)) {	
			foreach ($temp as $key => $value) if (is_uploaded_file($value)) $file_count++;	
		}
		
		if (!empty($errr)) {
			foreach ($errr as $key => $value) {
				if (!empty($name) && $value > 0) {	
					error_log('WP Plugin USP: File error message '. $value .'. Info @ https://bit.ly/2uTJc4D', 0);
					$error[] = 'file-error';
				}
			}
		}
		
		if ($file_count < $usp_options['min-images']) $error[] = 'file-min';
		if ($file_count > $usp_options['max-images']) $error[] = 'file-max';
		
		for ($i = 0; $i < $file_count; $i++) {
			$image = @getimagesize($temp[$i]);
			if (false === $image) {
				$error[] = 'file-type';
				break;
			} else {
				if (isset($temp[$i]) && !exif_imagetype($temp[$i])) {
					$error[] = 'file-type';
					break;
				}
				
				if (isset($image[0]) && !usp_width_min($image[0])) {
					$error[] = 'width-min';
					break;
				}
				
				if (isset($image[0]) && !usp_width_max($image[0])) {
					$error[] = 'width-max';
					break;
				}
				
				if (isset($image[1]) && !usp_height_min($image[1])) {
					$error[] = 'height-min';
					break;
				}
				
				if (isset($image[1]) && !usp_height_max($image[1])) {
					$error[] = 'height-max';
					break;
				}
				
				if (isset($errr[$i]) && $errr[$i] > 0) {
					error_log('WP Plugin USP: File error message '. $errr[$i] .'. Info @ https://bit.ly/2uTJc4D', 0);
					$error[] = 'file-error';
					break;
				}
			}
		}
	}
	
	$file_data = array('error' => $error, 'file_count' => $file_count);
	return $file_data;
	
}

The main thing of the function process is to check the validity of the uploaded image file such as the EXIF type, height, and width. With this, we could only upload a valid image file.

Back to the usp_attach_images function, since there is only a filename check using this line if (stripos($ext, 'php') === false) $bytes = file_put_contents($file, $file_local); , we could bypass all the checks by uploading a valid image file with an added PHP script payload at the end of the file metadata and using phtml as the file extension.

PHTML file itself is a dynamic HTML and PHP file that could both execute HTML and PHP code. This file type itself could be used depending on the configuration of the web server.

However, there is one condition that could prevent us from achieving the trigger to load the uploaded PHTML file. Let's view the continuation from usp_attach_images function after the file_put_contents process :

---------------------- CUTTED HERE ----------------------
        $wp_filetype = wp_check_filetype($file_name, null);
        
        if (wp_mkdir_p($upload_dir['path'])) {
            $file = isset($upload_dir['path']) ? $upload_dir['path'] . $file_path . $file_name : null;
            $guid = isset($upload_dir['url'])  ? $upload_dir['url']  . $file_path . $file_name : null;
        } else {
            $file = isset($upload_dir['basedir']) ? $upload_dir['basedir'] . $file_path . $file_name : null;
            $guid = isset($upload_dir['baseurl']) ? $upload_dir['baseurl'] . $file_path . $file_name : null;
        }
        
        $file = file_exists($file) ? usp_unique_filename($file) : $file;
        if (stripos($ext, 'php') === false) $bytes = file_put_contents($file, $file_local);
        $file_type = isset($wp_filetype['type']) ? $wp_filetype['type'] : null;
        $params = apply_filters('wp_handle_upload', array('file' => $file, 'url' => $guid, 'type' => $file_type)); 
        
        $file      = isset($params['file']) ? $params['file'] : $file;
        $guid      = isset($params['url'])  ? $params['url']  : $guid;
        $file_type = isset($params['type']) ? $params['type'] : $file_type;
        
        $attachment = array(
            'post_mime_type' => $file_type,
            'post_name'      => $file_name,
            'post_title'     => $file_name,
            'post_status'    => 'inherit',
            'guid'           => $guid,
        );
        
        $attachment = apply_filters('usp_insert_attachment_data', $attachment);
        $attach_id = wp_insert_attachment($attachment, $file, $post_id);
        
        if (isset($usp_options['usp_featured_images']) && $usp_options['usp_featured_images']) {
            if (!has_post_thumbnail($post_id)) set_post_thumbnail($post_id, $attach_id);
        }
        
        $attach_data = wp_generate_attachment_metadata($attach_id, $file);
        wp_update_attachment_metadata($attach_id, $attach_data);
        
        if (!is_wp_error($attach_id) && wp_attachment_is_image($attach_id)) {
            $attach_ids[] = $attach_id;
            add_post_meta($post_id, 'user_submit_image', wp_get_attachment_url($attach_id));
        } else {
            wp_delete_attachment($attach_id);
            wp_delete_post($post_id, true);
            $newPost['error'][] = 'file-upload';
            unset($newPost['id']);
        }
---------------------- CUTTED HERE ----------------------

The function will check the file type with the built-in wp_check_filetype function. By default, WordPress will return a false value if the file type or name is not included in the get_allowed_mime_types function.

The value will be stored in the $wp_filetype variable and it will be used to create an attachment. The attachment then will be checked with wp_attachment_is_image function and if it's not a valid image file extension then the function will delete the uploaded file with the wp_delete_attachment function.

With this condition, we would need to perform a little bit of race condition technique so we could have time to visit the uploaded PHTML file. Since there are a couple of processes and checks between the file_put_contents and wp_delete_attachment function, we can craft a quite reliable proof of concept process to trigger the code execution.

Note that this vulnerability could be triggered if the Image Uploads field is enabled on the Form Fields setting of the plugin.

The patch

Since the main problem is allowing arbitrary file name extensions to be uploaded, the vendor decided to add a whitelist check before uploading the file to the server. The patch can be seen below :

User Submitted Posts

Conclusion on the User Submitted Posts plugin vulnerability

Always check every process of $_FILES parameter in the plugin or theme code. Make sure to apply a check on the filename and extension before uploading the file. One of the recommendations to prevent arbitrary file uploads would be applying a whitelist on allowed file extensions.

Timeline

14 September, 2023We found the vulnerability and reached out to the plugin vendor.
16 September, 2023User Submitted Posts plugin version 20230914 released to patch the reported issue.
10 October, 2023Added the vulnerabilities to the Patchstack vulnerability database.
12 October, 2023Security advisory article publicly released.

Help us make the Internet a safer place

Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.

The latest in Security Advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu