Critical Vulnerabilities in 123contactform-for-wordpress WordPress Plugin

WordPress Vulnerability

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("/&nbsp;/",' ',$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("/&nbsp;/",' ',$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.

Improperly set directory listing

Browsing uploads directory

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.

You May Also Like