Critical vulnerability in WordPress AdSanity plugin.

WordPress AdSanity plugin is prone to a critical vulnerability affecting version 1.8.1 and below that could allow a low-privilege user to perform arbitrary file upload, remote code execution and stored cross-site scripting attacks.

Broken Access Control

CVSS v3.1: 9.9 (Critical)

When creating an ad, the plugin allows the upload of a ZIP file. That process is managed inside the “adsanity/views/html5-upload.php” script, by the adsanity_html5_upload AJAX action that loads the ajax_upload function:

/**
 * Handles the zip file upload via ajax
 */
public static function ajax_upload() {

   // Check the nonce
   if ( ! array_key_exists( 'security', $_POST ) ||
       ! wp_verify_nonce( $_POST['security'], 'adsanity-html5-upload' ) ) {
      wp_send_json_error( 'Security' );
      return;
   }

   // Check that all of the parts are here
   if ( ! array_key_exists( 'file_upload', $_FILES ) ||
       ! array_key_exists( 'tmp_name', $_FILES['file_upload'] ) ||
       ! array_key_exists( 'type', $_FILES['file_upload'] ) ||
       ! array_key_exists( 'ad_id', $_POST ) ) {
      wp_send_json_error( 'Request is missing parts' );
      return;
   }

   // Possible mime type for a zip file
   $allowed_mime_types = array(
      'application/zip',
      'application/octet-stream',
      'application/x-zip-compressed',
      'multipart/x-zip',
   );

   if ( ! in_array( $_FILES['file_upload']['type'], $allowed_mime_types ) ) {
      wp_send_json_error( [
         'message' => esc_js( __( 'Invalid filetype.', 'adsanity' ) ),
      ], 400 );
      return;
   }

   $upload_dir = wp_upload_dir();
   $upload_base_dir = $upload_dir['basedir'];
   $adsanity_dir = trailingslashit( $upload_base_dir ) . 'adsanity/';
   $ad_id = $_POST['ad_id'];
   $ad_dir = $adsanity_dir . $ad_id;

   // Create the ad src for use in meta
   $ad_src = trailingslashit( trailingslashit( $upload_dir['baseurl'] ) . 'adsanity/' . $ad_id );

   // Get the path to this ad
   $ad_path = trailingslashit( trailingslashit( $upload_dir['basedir'] ) . 'adsanity/' . $ad_id );

   /**
    * Initialize WP_Filesystem
    * Used for unzip_file and rmdir
    */
   global $wp_filesystem;
   WP_Filesystem();

   // Check to see if there has already been an upload to this folder
   if ( is_dir( $ad_path ) ) {
      // Remove the folder
      $wp_filesystem->rmdir( $ad_path, true );
   }

   // Unzip the files
   $unzipped = unzip_file( $_FILES['file_upload']['tmp_name'], $ad_dir );

   if ( ! $unzipped ) {
      wp_send_json_error( 'File upload error', 500 );
      return;
   }

   // Scan the directory for this ad
   $scanned = scandir( $ad_path );

   /**
    * Check to see if this only contains a directory
    * HTML5 ads can be uploaded as a zip file
    * of only files, or a zipped folder
    */
   while (
      3 === count( $scanned ) &&
      is_dir( $ad_path . trailingslashit( $scanned[2] ) ) &&
      ! in_array( 'index.html', $scanned )
   ) {
      // Update the path and src with the new inner folder added
      $ad_path .= trailingslashit( $scanned[2] );
      $ad_src .= trailingslashit( $scanned[2] );
      // Re-scan
      $scanned = scandir( $ad_path );
   }

   // Check for the existence of an index.html file
   if ( ! in_array( 'index.html', $scanned ) ) {
      wp_send_json_error( [
         'message' => esc_js( __( 'Zip file must contain an index.html file.', 'adsanity' ) ),
      ], 400 );
   }

   update_post_meta( $ad_id, 'ad_src', $ad_src );

   wp_send_json_success( array( 'src' => $ad_src ) );

}

That function is used to upload and extract the content of a ZIP archive into the “wp-content/uploads/adsanity/{post_id}/” folder. It only has a security nonce, accessible to any user with Contributor or above privileges, and a simple check to ensure there’s an index.html file inside the archive. After the upload, the function will return the full path to the folder where the files were extracted:

{"success":true,"data":{"src":"http:\/\/example.com\/wp-content\/uploads\/adsanity\/407\/"}}

A low-privilege user such as a Contributor can upload any file into that folder:

If the blog has a .htaccess file to prevent PHP code execution inside the /uploads/ folder, the attacker can easily override that protection by uploading another .htaccess.

In the ads management section, the plugin injects inside a metabox an iframe that points to the directory where the files were extracted:

<?php
// Get ad src
global $post;
$ad_src = get_post_meta( $post->ID, 'ad_src', true );
if ( $ad_src ) { ?>
   <iframe src="<?php echo esc_attr( trailingslashit( $ad_src ) ); ?>" scrolling="no" frameborder="0"></iframe>
<?php } ?>

The attacker can add an index.php script inside the ZIP archive. Its code will be loaded by the iframe instead of the index.html file, and executed inside the metabox each time a user accesses the ads manager in the backend:

Additionally, the attacker can upload files with JavaScript code too, which could be used to target the administrator reviewing the post:

Timeline

The issue was reported to the developer on January 13, 2022, and a new version 1.8.2 was released on January 14, 2022. Note that the new version doesn’t allow Contributor users to upload files but still allow Author+ users to do so, therefore if you have Author users registered on your blog, you may exercise extreme caution. Note also that if you’re using our web application firewall for WordPress, NinjaFirewall WP Edition (free) and NinjaFirewall WP+ Edition (premium), only the Admin (single site) and Superadmin (multisite) will be allowed to access the upload function, any other user will be blocked.

Stay informed about the latest vulnerabilities