Multiple vulnerabilities in WordPress Woody Ad Snippets plugin lead to remote code execution.

An unauthenticated options import vulnerability combined with a stored XSS vulnerability can lead to remote code execution in the WordPress Woody Ad Snippets (90,000+ active installations).

Woody Ad Snippets is a plugin that allows administrators to insert any code, text, or ads by conditions in their blog: JS, CSS, HTML and even PHP code. It was prone in version 2.2.4 and below to two vulnerabilities that, when unintentionally triggered by the administrator in the back-end section of WordPress, would allow an attacker to run any PHP code in order to compromise the website and its database.

Unauthenticated options import

The first vulnerability can be found in the “insert-php/admin/includes/class.import.snippet.php” script:

/**
 * Register hooks
 */
public function registerHooks() {
   add_action( 'admin_init', array( $this, 'adminInit' ) );
}

It registers the adminInit function via the admin_init hook.
As we have seen in the Ocean Extra and Easy WP SMTP plugins vulnerability reports, this hook can be triggered by any unauthenticated user.
Line 63, it call processImportFiles:

/**
 * adminInit
 */
public function adminInit() {
   $this->processImportFiles();
}

The function is located line 173 and is used to import code snippets:

/**
 * Process the uploaded import files
 *
 * @uses import_snippets() to process the import file
 * @uses wp_redirect() to pass the import results to the page
 * @uses add_query_arg() to append the results to the current URI
 */
private function processImportFiles() {
   if ( ! isset( $_FILES['wbcr_inp_import_files'] ) || ! count( $_FILES['wbcr_inp_import_files'] ) || ! isset( $_FILES['wbcr_inp_import_files']['tmp_name'][0] ) || empty( $_FILES['wbcr_inp_import_files']['tmp_name'][0] ) ) {
         return;
      }

      $url = remove_query_arg( array( 'wbcr_inp_error', 'wbcr_inp_imported' ) );

      // Only ine files for free version
      if (
         ! WINP_Plugin::app()->get_api_object()->is_key()
         && count( $_FILES['wbcr_inp_import_files']['tmp_name'] ) > 1
      ) {
         $url = add_query_arg( array( 'wbcr_import_error' => true ), $url );
         wp_redirect( esc_url_raw( $url ) );
         exit;
      }

      $count      = 0;
      $uploads    = $_FILES['wbcr_inp_import_files'];
      $dup_action = WINP_Plugin::app()->request->post( 'duplicate_action', 'ignore', true );
      $error      = false;

      foreach ( $uploads['tmp_name'] as $i => $import_file ) {
         $ext       = pathinfo( $uploads['name'][ $i ] );
         $ext       = $ext['extension'];
         $mime_type = $uploads['type'][ $i ];

         if ( 'json' === $ext || 'application/json' === $mime_type ) {
            $result = $this->importSnippet( $import_file, $dup_action );
         } else {
            $result = apply_filters( 'wbcr/inp/import/snippet', false, $ext, $mime_type, $import_file, $dup_action );
         }

         if ( false === $result || - 1 === $result ) {
            $error = true;
         } else {
            $count += count( $result );
         }
      }

      $url = add_query_arg( $error ? array( 'wbcr_inp_error' => true ) : array( 'wbcr_inp_imported' => $count ), $url );
      wp_redirect( esc_url_raw( $url ) );
      exit;
}

It checks if there’s a $_FILES['wbcr_inp_import_files'] array that has at least one JSON encoded file and will call importSnippet to import its content. But because there’s no capability check, an unauthenticated user can import code snippets too, with a simple POST payload sent to the “/wp-admin/admin-post.php” script.

However, if we want to exploit this vulnerability to gain access to the website, things are going to be a bit more complicated than that:

As we can see in the above screenshot, our injected PHP snippet was inserted at the top of the list but it was not enabled. The function used to save the imported settings is saveSnippet in the “insert-php/admin/includes/class.import.snippet.php” script:

$snippet['id'] = wp_insert_post( $data );
		
$this->updateMeta( $snippet['id'], 'snippet_location', $snippet['location'] );
$this->updateMeta( $snippet['id'], 'snippet_type', $snippet['type'] );
$this->updateMeta( $snippet['id'], 'snippet_filters', $snippet['filters'] );
$this->updateMeta( $snippet['id'], 'changed_filters', $snippet['changed_filters'] );
$this->updateMeta( $snippet['id'], 'snippet_scope', $snippet['scope'] );
$this->updateMeta( $snippet['id'], 'snippet_description', $snippet['description'] );
$this->updateMeta( $snippet['id'], 'snippet_tags', $snippet['attributes'] );
$this->updateMeta( $snippet['id'], 'snippet_activate', 0 );

$this->updateTaxonomyTags( $snippet['id'], $snippet['tags'] );

return $snippet['id'];

The problem here is that the value of the snippet_activate custom field is always set to ‘0’, no matter what we do. Even if we tried to override an existing and active snippet by using the $_POST["duplicate_action"]=replace variable, it wouldn’t work.

Unauthenticated stored XSS

While trying to figure out how to activate the PHP snippet, I found exactly what I needed inside the “insert-php/admin/includes/class.snippets.viewtable.php” script. This is the code that will echo the description of the snippet (snippet_description) in the list of snippets, beside the “Status” button:

public function columnDescription( $post ) {
   echo WINP_Helper::getMetaOption( $post->ID, 'snippet_description' );
}

The description value is not sanitized, thus we have here a stored XSS that we can use to activate the snippet when the admin will visit the plugin page and trigger our PHP code.

Remote Code Execution

Changing the status of a snippet is done by sending a GET request with the following parameters:

?post_type=wbcr-snippets&post={POST_ID}&action=wbcr_inp_activate_snippet&_wpnonce={NONCE}  

Where {POST_ID} is the ID of our post/snippet and {NONCE} the security nonce used to protect against CSRF attacks.

Therefore, we can consider the following scenario:

Act I: JS code

  • When an admin will visit the plugin page, the JavaScript code will be triggered.
  • It will search the page for the post and _wpnonce values. They’re easy to find because our snippet is at the top of the list: the first values found will be ours.
  • It will immediately reload the page with the required parameters to activate the snippet.

Act II: PHP code

  • While reloading the page, the PHP code will be triggered.
  • It will drop a little foo.php backdoor in the WordPress root folder (a.k.a ABSPATH).
  • It will call the WordPress wp_delete_post function with our post ID, found in the $_GET['post'] variable, in order to delete our malicious snippet so that, after the page has loaded, the administrator will not see it in the list.

That may look complicated but in fact, it is a straightforward process that can be done with a 600-byte payload. I don’t know if it’s a bug or a feature but, oddly, the plugin will replace all < and > characters found in the imported PHP code with their HTML entities (&lt;, &gt;), which will throw a PHP fatal error when attempting to execute the code. However, that can be easily bypassed by using their corresponding hexadecimal values (\x3c, \x3e) instead.
Note that, for obvious reasons, I blurred the JS code in the picture:

Now, we have our RCE:

$ curl https://example.org/foo.php?x='echo php_uname();'  
Linux deb 4.19.0-2-amd64 #1 SMP Debian 4.19.16-1 (2019-01-17) x86_64

Timeline

The vulnerability was discovered and reported to the wordpress.org team on July 29, 2019 and a new version 2.2.5 was released on July 31.

Recommendations

Update as soon as possible if you have version 2.2.4 or below installed.
If you are using our web application firewall for WordPress, NinjaFirewall WP Edition (free) and NinjaFirewall WP+ Edition (premium), you are protected against this type of vulnerability.

Stay informed about the latest vulnerabilities in WordPress plugins and themes: @nintechnet