Pre-Auth Arbitrary File Upload in User Submitted Posts Plugin

Published 12 October 2023
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.

✌️ Our users are protected from this vulnerability. Are yours?

Web developers

Automatically mitigate vulnerabilities in real-time without changing code.

See pricing
Plugin developers

Identify vulnerabilities in your plugins and get recommendations for fixes.

Request audit
Hosting companies

Protect your users, improve server health and earn additional revenue.

Patchstack for hosts

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.

🤝 You can help us make the Internet a safer place

Plugin developer?

Streamline your disclosure process to fix vulnerabilities faster and comply with CRA.

Get started for free
Hosting company?

Protect your users too! Improve server health and earn added revenue with proactive security.

Patchstack for hosts
Security researcher?

Report vulnerabilities to our gamified bug bounty program to earn monthly cash rewards.

Learn more

The latest in Security Advisories

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