In mass infection scenarios, our Malware Research team often looks for attack vectors to find patterns and other similarities among compromised websites. The identification of these patterns allows us to deploy better and faster solutions to our customers, minimizing impacts from massive attacks.
Recently during a routine investigation, we found a number of vulnerabilities in 123contactform-for-wordpress WordPress Plugin Version <= 1.5.6. These critical vulnerabilities allow attackers to arbitrarily create posts and inject malicious files to the website without any form of authentication.
With over 3k+ installations, the 123contactform-for-wordpress plugin was designed to help site owners add web forms, surveys, quizzes, or polls from a 123FormBuilder account to their WordPress website or blog.
Validation Bypass via Plugin Verification
Let’s start with analysis of the plugin verification process. In the script 123contactform-for-wordpress.php, the plugin registers the cfp-connect action to load the cfp_connect() function via the AJAX API:
109 add_action( 'wp_ajax_cfp-connect', 'cfp_connect' ); 110 add_action( 'wp_ajax_nopriv_cfp-connect', 'cfp_connect' ); 111 function cfp_connect() { 112 $cfp_pub_key = $_POST["pk"]; 113 $message = $_POST["message"]; 114 $signature = base64_decode(str_replace(" ", "+", $_POST["signature"])); 115 if(!isset($cfp_pub_key) || $cfp_pub_key=="") { echo cfp_message("Key is not sent",0);exit(); } // Key is not sent 116 $verify = openssl_verify($message, $signature, base64_decode($cfp_pub_key), OPENSSL_ALGO_SHA1); 117 if ($verify == 1) { 118 if(!get_option("123cf_post_public_key")) { 119 add_option("123cf_post_public_key",$cfp_pub_key); 120 } else { 121 update_option("123cf_post_public_key",$cfp_pub_key); 122 } 123 echo cfp_message("WordPress connected",1);exit(); 124 } elseif ($verify == 0) { 125 echo cfp_message("Signature not verified",0);exit(); 126 } else { 127 echo cfp_message("error: " . openssl_error_string(), 0); 128 exit(); 129 } 130 exit(); 131 }
This function performs a signature verification using the openssl_verify(), then checks its results: 1 if the signature is correct or 0 if it is incorrect.
If the verification succeeds, the script checks if the option_name 123cf_post_public_key exists in the database. From there, it either adds the value of the $cfp_pub_key variable into the option_value field or updates its value if the option_name doesn’t exist.
Initially there’s nothing wrong with this procedure, but since all the fields checked by the openssl_verify() are received via $_POST requests, attackers can simply craft these values ($message, $signature, $cf_pub_key) to bypass the validation mechanisms and inject their own public_key into the database.
Arbitrary Post Creation
A few lines down, another action cfp-new-post is registered to load the cfp_new_post() function via the AJAX API:
167 add_action( 'wp_ajax_cfp-new-post', 'cfp_new_post' ); 168 add_action( 'wp_ajax_nopriv_cfp-new-post', 'cfp_new_post' ); 169 function cfp_new_post() { 170 if(!cfp_authenticate()) { echo cfp_message("There was an error while trying to authenticate with wordpress",0); exit(); } ...
Before we describe the issue, there’s an interesting call to the function cfp_authenticate() in line 170 that once again attempts to perform the signature validation via openssl_verify().
All the variables are received via $_POST requests, therefore we still have control of the execution flow and the result of the $verify variable.
287 function cfp_authenticate() { 288 if(!get_option( "123cf_post_public_key")) { return false; } 289 $cfp_pub_key = get_option( "123cf_post_public_key"); 290 $message = $_POST["message"]; 291 $signature = base64_decode(str_replace(" ", "+", $_POST["signature"])); 292 $verify = openssl_verify($message, $signature, base64_decode($cfp_pub_key), OPENSSL_ALGO_SHA1); 293 return $verify; 294 }
Continuing onto the cfp_new_post() function, the code assigns all the fields related to the post entries for WordPress to different variables within the $new_post array, including post_author, post_title, and post_content, among others.
These variables are then inserted into the posts section via the WordPress function wp_insert_post().
171 $post_title = strip_tags(rawurldecode($_POST["post_title"])); 172 $post_title = preg_replace("/ /",' ',$post_title); 173 $post_title = stripslashes($post_title); 174 $post_content = rawurldecode($_POST["post_content"]); 175 $post_content = stripslashes($post_content); 176 $post_status = $_POST["post_status"]; 177 $post_category = urldecode($_POST["post_category"]); 178 $post_author = $_POST["post_author"]; 179 $post_format = $_POST["post_format"]; 180 $comments = $_POST["comment_status"]; 181 $comments == "1" ? $comment_status = "open" : $comment_status = "closed"; 182 $post_excerpt = rawurldecode($_POST["post_excerpt"]); 183 $post_excerpt = preg_replace("/ /",' ',$post_excerpt); 184 $post_excerpt = stripslashes($post_excerpt); 185 $post_tags = explode(",",rawurldecode($_POST["post_tags"])); 186 $post_image = str_replace(" ", "+",$_POST["post_image"]); 187 $post_image_name = $_POST["post_image_name"]; … 205 $new_post = array( 206 'post_author' => $post_author, 207 'post_title' => $post_title, 208 'post_content' => $post_content, 209 'post_status' => $post_status, 210 'comment_status' => $comment_status, 211 'post_excerpt' => $post_excerpt, 212 'post_category' => $cat_id_arr 213 ); 214 $post_id = wp_insert_post( $new_post ); 215 if($post_id) { 216 foreach($custom_fields_values as $meta_key=>$meta_value) { 217 add_post_meta($post_id,str_replace("|***|"," ",$meta_key), $meta_value); 218 } 219 set_post_format($post_id, $post_format); 220 wp_set_post_tags($post_id, $post_tags); 221 if(isset($post_image)) { 222 cfp_upload_image($post_id,$post_image,$post_image_name); 223 } 224 echo cfp_message("New post created",1); exit(); 225 } 226 echo cfp_message("There was an error while trying to create new post",0); exit(); 227 }
Arbitrary File Upload
If you happened to look at the function cfp_upload_image() on line 222 and thought “There may be something here,” you are right!
This function is responsible for uploading “images” to the server. Although there’s an attempt to set the filename ($filename) to image.png (line 137), attackers can inject any value into the variable $post_image_name (line 187) and name the file any way they want. They can also manipulate the content injected to the file by using the variable $post_image (line 186)
To reduce the risks of filename truncation, the developers added an interesting way to save the file to the server by using md5() to calculate the hash of the $filename and microtime(), which returns the current Unix timestamp with microseconds.
132 function cfp_upload_image($post_id,$post_image, $post_image_name = null) { 133 $upload_dir=wp_upload_dir(); 134 $upload_path=str_replace( '/', DIRECTORY_SEPARATOR, $upload_dir['path'] ) . DIRECTORY_SEPARATOR; 135 $decoded_img=base64_decode($post_image); 136 if(!$post_image_name) { $filename='image.png'; } else { $filename=$post_image_name; }; 137 $hashed_filename=md5( $filename . microtime() ) . '_' . $filename; 138 $image_upload=file_put_contents( $upload_path . $hashed_filename, $decoded_img ); ... 145 $file = array(); 146 $file['error'] = ''; 147 $file['tmp_name'] = $upload_path . $hashed_filename; 148 $file['name'] = $hashed_filename; 149 $file['type'] = 'image/jpg'; 150 $file['size'] = filesize( $upload_path . $hashed_filename ); 151 $file_return = wp_handle_sideload( $file, array( 'test_form' => false ) ); 152 $file_url = $file_return["file"]; 153 $filetype = wp_check_filetype( basename( $file_url ), null ); 154 $attachment = array( 155 'guid' => $upload_dir['url'] . '/' . basename( $file_url ), 156 'post_mime_type' => $filetype['type'], 157 'post_title' => preg_replace( '/\.[^.]+$/', '', basename( $file_url ) ), 158 'post_content' => '', 159 'post_status' => 'inherit' 160 ); 161 $attach_id = wp_insert_attachment( $attachment, $file_url, $post_id ); 162 require_once( ABSPATH . 'wp-admin/includes/image.php' ); 163 $attach_data = wp_generate_attachment_metadata( $attach_id, $file_url ); 164 wp_update_attachment_metadata( $attach_id, $attach_data ); 165 update_post_meta( $post_id, '_thumbnail_id', $attach_id ); 166 }
The filename is very hard to guess due to this implementation, but if the Directory Listing option within the web server is not properly set, attackers can easily browse the uploads directory to get the filename and execute its content.
Sidetrack Bonus (Guessing the Backdoor’s Filename)
If attackers are determined, which they usually are, they can make a wild guess and a few million requests to reach the desired result. To understand how that works, we need to dive a bit more into the microtime() function.
As defined in PHP’s manual entry, by default, microtime() returns a string in the form “msec sec“, where sec is the number of seconds since the Unix epoch (0:00:00 January 1,1970 GMT) and msec measures microseconds that have elapsed since sec — also expressed in seconds.
In practical terms, this is the result of the function being executed:
$ php -r "echo microtime();" 0.01101800 1588386363
Where “0.01101800” represents the microseconds and the second piece “1588386363” the Unix epoch:
$ date --date @1588386363 Fri May 1 23:26:03 -03 2020
Since attackers control the $filename and can fetch the microtime() from the request at the time of injection, the only piece left to guess is the microseconds. During our tests, we estimated an average of 2 to 4 million requests to get all the variables correctly.
Conclusion
This analysis clearly demonstrates how attackers are able to leverage software vulnerabilities in 123contactform-for-wordpress to arbitrarily create posts and inject malicious files to the website without authentication.
Unfortunately, all of these vulnerabilities are rectifiable and the direct result of missing capability checks, improper user validation, and permission issues within the WordPress ecosystem. There are plenty of methods and mechanisms to prevent these arbitrary injections and plugin developers should take advantage of them to ensure that users are protected from known software vulnerabilities.
In this particular case, the plugin owners didn’t provide a patch to fix these vulnerabilities. Instead, they took the plugin down from the WordPress Plugin repository. To mitigate risk and protect your environment, we strongly encourage website owners to uninstall the plugin and find an alternative solution from a reputable source.