Prevent WPScan from scanning

Homepage:

http://wpscan.org/

Description:

Below I will show few methods which can prevent WPScan scan.

1. Change wp-content dir to facebook.com/something or twitter.com/something

First, WPScan is trying to find wp-content dir.

File: wp_target.rb

def wp_content_dir
  unless @wp_content_dir
    index_body = Browser.get(@uri.to_s).body
    uri_path = @uri.path # Only use the path because domain can be text or an IP

    if index_body[/\/wp-content\/(?:themes|plugins)\//i] || default_wp_content_dir_exists?
      @wp_content_dir = 'wp-content'
    else
      domains_excluded = '(?:www\.)?(facebook|twitter)\.com'
      @wp_content_dir  = index_body[/(?:href|src)\s*=\s*(?:"|').+#{Regexp.escape(uri_path)}((?!#{domains_excluded})[^"']+)\/(?:themes|plugins)\/.*(?:"|')/i, 1]
    end
  end

  @wp_content_dir
end

If /wp-content/themes/ or /wp-content/plugins/ string is found inside site content, default dir is used.

In other cases it tries to use more complex regexp. This one search for /themes/ or /plugins/ and if found something, use string before as wp-content dir.

But, there is one exception. If inside url string facebook.com or twitter.com exists, it’s omitted.

So, if we change wp-content dir to facebook.com/another_dir WPScan will try to check only another_dir.

We can change this dir using wp-config.php - see WP_CONTENT_DIR and WP_CONTENT_URL.

Also we need to move folders content using FTP.

define( 'WP_CONTENT_DIR', dirname(__FILE__) . '/facebook.com/another_dir' );
define( 'WP_CONTENT_URL', 'http://szurek.pl/facebook.com/another_dir' );

Right now we will get:

ruby c:\wpscan\wpscan.rb --url http://szurek.pl
_______________________________________________________________
        __          _______   _____
        \ \        / /  __ \ / ____|
         \ \  /\  / /| |__) | (___   ___  __ _ _ __
          \ \/  \/ / |  ___/ \___ \ / __|/ _` | '_ \
           \  /\  /  | |     ____) | (__| (_| | | | |
            \/  \/   |_|    |_____/ \___|\__,_|_| |_|

        WordPress Security Scanner by the WPScan Team
                       Version 2.9
          Sponsored by Sucuri - https://sucuri.net
   @_WPScan_, @ethicalhack3r, @erwan_lr, pvdl, @_FireFart_
_______________________________________________________________

The plugins directory 'another_dir/plugins' does not exist.
You can specify one per command line option (don't forget to include the wp-content directory if needed)
[?] Continue? [Y]es [N]o, default: [N]

2. Disable robots.txt

robots.txt file is used to obtain usefull information.

File: robots_txt.rb

# Parse robots.txt
# @return [ Array ] URLs generated from robots.txt
def parse_robots_txt
  return unless has_robots?

  return_object = []
  response = Browser.get(robots_url.to_s)
  body = response.body
  # Get all allow and disallow urls
  entries = body.scan(/^(?:dis)?allow:\s*(.*)$/i)
  if entries
    entries.flatten!
    entries.compact.sort!
    entries.uniq!
    wordpress_path = @uri.path
    RobotsTxt.known_dirs.each do |d|
      entries.delete(d)
      # also delete when wordpress is installed in subdir
      dir_with_subdir = "#{wordpress_path}/#{d}".gsub(/\/+/, '/')
      entries.delete(dir_with_subdir)
    end

    entries.each do |d|
      begin
        temp = @uri.clone
        temp.path = d.strip
      rescue URI::Error
        temp = d.strip
      end
      return_object << temp.to_s
    end
  end
  return_object
end

We can disable generating this file hooking do_robots.

add_action('do_robots', 'hook_robots', 1);
function hook_robots() {
	// Return 404
	status_header(404);
	// End script
	die();
}

3. Remove readme.html

readme.html file is used to obtain WordPress version info.

We can disable access to this file using .htaccess rule:

RewriteRule ^readme\.html$ - [R=404,L,NC]

4. Prevent Full Path Disclosure

When server is badly configured it’s possible to obtain path information displaying wp-includes/rss-functions.php file.

Because this file is deprecated, we can disable access to it.

RewriteRule ^wp-includes/rss-functions\.php$ - [R=404,L,NC]

5. Detect wp-config.php enumeration

WPScan contains list of files, which can by created by some text editors (like VIM) when wp-config.php file is opened and then upload to server by mistake.

File: wp_config_backup.rb

def self.config_backup_files
  %w{
    wp-config.php~ #wp-config.php# wp-config.php.save .wp-config.php.swp wp-config.php.swp wp-config.php.swo
    wp-config.php_bak wp-config.bak wp-config.php.bak wp-config.save wp-config.old wp-config.php.old
    wp-config.php.orig wp-config.orig wp-config.php.original wp-config.original wp-config.txt
  } # thanks to Feross.org for these
end

We can assume that it’s very unlikely that normal user will visit wp-config.php.save or .wp-config.php.swp by mistake.

So we can detect this requests and temporary block user.

# Detect that someone is accessing those strange files
RewriteRule ^wp-config\.php\.save$ index.php?wp_config_enumeration=1 [L]
RewriteRule ^\.wp-config\.php\.swp$ index.php?wp_config_enumeration=1 [L]
RewriteRule ^wp-config\.php\.swp$ index.php?wp_config_enumeration=1 [L]'
$transient_name = 'wce_block_'.$_SERVER['REMOTE_ADDR'];

$transient_value = get_transient($transient_name);

if ($transient_value !== false) {
  die('BANNED!');
}

if (isset($_GET['wp_config_enumeration'])) {
  set_transient($transient_name, 1, DAY_IN_SECONDS);
  die('BANNED!');
}

6. Detect User Agent

By default WPScan v2.9 (http://wpscan.org) string is used as User Agent.

So we can easily detect this.

if (!empty($_SERVER['HTTP_USER_AGENT']) && preg_match('/WPScan/i', $_SERVER['HTTP_USER_AGENT'])) {
	die('Wrong user agent');
}

7. Remove strange XML-RPC server info

By default when you open xmlrpc.php it displays XML-RPC server accepts POST requests only text.

WPScan is trying to find this string in order to check if xmlrpc functionality exist.

File: web_site.rb

def has_xml_rpc?
	response = Browser.get_and_follow_location(xml_rpc_url)
	response.body =~ %r{XML-RPC server accepts POST requests only}i
end

We can prevent this hooking wp_xmlrpc_server_class and creating own xmlrpc class on $_GET request.

function add_fake_xmlrpc() {
	// We don't want to display die('XML-RPC server accepts POST requests only.'); on $_GET
	if (!empty($_POST)) {
		return 'wp_xmlrpc_server';
	} else {
		return 'fake_xmlrpc';
	}
}

add_filter('wp_xmlrpc_server_class', 'add_fake_xmlrpc');

class fake_xmlrpc {
	function serve_request() {
		// Its fake ;)
		die();
	}
}

8. Remove generator info

We can remove <meta name="generator" content="WordPress" /> from source code.

remove_action('wp_head', 'wp_generator');
add_filter('the_generator', 'remove_generator');
function remove_generator() {
	// Return nothing
    return '';
}

9. Prevent advanced fingerprinting

Inside wp_versions.xml WPScan stores list of files which can be used to detect WordPress version based on file hash.

We should modify each of files there. Right now we will focus on version 4.4.

It can be detected using:

readme.html
wp-includes/js/tinymce/wp-tinymce.js.gz

First one is disabled using .htaccess.

Second one is gzipped JavaScript. So we need to unpack it, add random comment and then pack again.

RewriteRule ^wp-includes/js/tinymce/wp-tinymce\.js\.gz$ index.php?advanced_fingerprinting=1 [L]
if (isset($_GET['advanced_fingerprinting'])) {
	switch ($_GET['advanced_fingerprinting']) {
		case '1':
			// Unpack file
			$file = gzopen(ABSPATH.'wp-includes/js/tinymce/wp-tinymce.js.gz', 'rb');
			// Add comment
			$out = '// '.uniqid(true)."\n";
			while(!gzeof($file)) {
				$out .= gzread($file, 4096);
			}

			// Pack again
			header('Content-type: application/x-gzip');
			echo gzencode($out);
		break;

		default:
			status_header(404);
	}

	die();
}

10. Remove version number from stylesheet

WordPress adds version number inside stylesheet link, for example:

<link rel='stylesheet' id='twentyfifteen-style-css'  href='http://szurek.pl/wp-content/themes/twentyfifteen/style.css?ver=4.3.1' type='text/css' media='all' />

File: findable.rb

def find_from_stylesheets_numbers(target_uri)
  wp_versions = WpVersion.all
  found       = {}
  pattern     = /\bver=([0-9\.]+)/i

  Nokogiri::HTML(Browser.get(target_uri.to_s).body).css('link,script').each do |tag|
    %w(href src).each do |attribute|
      attr_value = tag.attribute(attribute).to_s

      next if attr_value.nil? || attr_value.empty?

      uri = Addressable::URI.parse(attr_value)
      next unless uri.query && uri.query.match(pattern)

      version = Regexp.last_match[1].to_s

      found[version] ||= 0
      found[version] += 1
    end
  end

  found.delete_if { |v, _| !wp_versions.include?(v) }

  best_guess = found.sort_by(&:last).last
  # best_guess[0]: version number, [1] numbers of occurences
  best_guess && best_guess[1] > 1 ? best_guess[0] : nil
end

We can change this using global $wp_version.

add_action('init', 'init');
function init() {
	global $wp_version;
	$wp_version = 'some_strange_number';
}

11. Stop plugin enumeration

WPScan can enumerate installed plugins and then display info about known vulnerabilities inside them.

We can trick this scan so it will be think that we have every plugin installed.

Because of that output will be very long and useless.

# Match any dir inside wp-content
RewriteRule ^(.*)wp-content/plugins/(.*)$ index.php?plugin_enumeration=1 [L]',
if (isset($_GET['plugin_enumeration'])) {
	// Display something random
	die('<!-- ' .uniqid() .'-->');
}

12. Prevent username enumeration

In WordPress when you visit ?author=1 you will be redirected to author/your_username/.

Using this, WPScan can obtain usernames very easily.

So we can block any $_GET['author'] requests.

if (!is_admin() && isset($_REQUEST['author'])) {
	status_header(404);
	die();
}

Proof of Concept:

Below you can find example WordPress plugin which uses techniques described above.

Warning !

This code is not heavily tested and should not be used on any production website.

It only should give you idea how this kind of protection can be implemented.

You can also download this file from here.

<?php
/*
Plugin Name: antywpscan 
Description: Prevent WPScan scanning
Version: 1.0
Author: Kacper Szurek
Author URI: http://www.security.szurek.pl
*/

if (isset($_GET['advanced_fingerprinting'])) {
  switch ($_GET['advanced_fingerprinting']) {
    case '1':
      $file = gzopen(ABSPATH.'wp-includes/js/tinymce/wp-tinymce.js.gz', 'rb');
      $out = '// '.uniqid(true)."\n";
      while(!gzeof($file)) {
        $out .= gzread($file, 4096);
      }
      
      header('Content-type: application/x-gzip');
      echo gzencode($out);
    break;

    default:
      status_header(404);
  }

  die();
}

if (isset($_GET['plugin_enumeration'])) {
  // Display something random
  die('<!-- ' .uniqid() .'-->');
}

if (!is_admin() && isset($_REQUEST['author'])) {
  status_header(404);
  die();
}

add_action('init', 'anty_wpscan_init');
function anty_wpscan_init() {
  global $wp_version;

  $transient_name = 'wce_block_'.$_SERVER['REMOTE_ADDR'];
  
  $transient_value = get_transient($transient_name);
  
  if ($transient_value !== false) {
    die('BANNED!');
  }

  if (isset($_GET['wp_config_enumeration'])) {
    set_transient($transient_name, 1, DAY_IN_SECONDS);
    die('BANNED!');
  }

  // @user_agent = "WPScan v#{WPSCAN_VERSION} (http://wpscan.org)"
  if (!empty($_SERVER['HTTP_USER_AGENT']) && preg_match('/WPScan/i', $_SERVER['HTTP_USER_AGENT'])) {
    die('Wrong user agent');
  }

  // WordPress version identified from stylesheets numbers
  $wp_version = '0001';
}

add_action('do_robots', 'anty_wpscan_do_robots', 1);
function anty_wpscan_do_robots() {
  // Return 404 on robots.txt
  status_header(404);
  die();
}

function add_fake_xmlrpc() {
  // We don't want to display die('XML-RPC server accepts POST requests only.'); on $_GET
  if (!empty($_POST)) {
    return 'wp_xmlrpc_server';
  } else {
    return 'fake_xmlrpc';
  }
}

add_filter('wp_xmlrpc_server_class', 'anty_wpscan_fake_xmlrpc');
class anty_wpscan_fake_xmlrpc {
  function serve_request() {
    // Its fake ;)
    die();
  }
}

// Remove <meta name="generator" content="WordPress" />
remove_action('wp_head', 'wp_generator');
add_filter('the_generator', 'anty_wpscan_remove_generator');
function anty_wpscan_remove_generator() {
    return '';
}

register_activation_hook( __FILE__, 'anty_wpscan_activation');
function anty_wpscan_activation() {
  add_filter('rewrite_rules', 'anty_wpscan_rewrite_rules_filter');
  function anty_wpscan_rewrite_rules_filter($rules){

    $exploded = explode("\n", $rules);

    $my_rules = array('RewriteRule ^readme\.html$ - [R=404,L,NC]', // Disable access to readme.html
      'RewriteRule ^readme\.txt$ - [R=404,L,NC]', // Disable access to readme.txt
      'RewriteRule ^changelog\.txt$ - [R=404,L,NC]', // Disable access to changelog.txt
      'RewriteRule ^wp-includes/rss-functions\.php$ - [R=404,L,NC]', // Disable Full Path Disclosure         
      'RewriteRule ^wp-includes/js/tinymce/wp-tinymce\.js\.gz$ index.php?advanced_fingerprinting=1 [L]', // prevent advanced fingerprinting
      'RewriteRule ^(.*)wp-content/plugins/(.*)/readme\.txt$ - [R=404,L]', // Not display plugins readmes
      'RewriteCond %{REQUEST_FILENAME} !-f',
      'RewriteRule ^(.*)wp-content/plugins/(.*)$ index.php?plugin_enumeration=1 [L]', // Always display something when visit plugin dir
      'RewriteRule ^wp-config\.php\.save$ index.php?wp_config_enumeration=1 [L]', // wp-config enumeration
      'RewriteRule ^\.wp-config\.php\.swp$ index.php?wp_config_enumeration=1 [L]',
      'RewriteRule ^wp-config\.php\.swp$ index.php?wp_config_enumeration=1 [L]'
      );

    array_splice( $exploded, 3, 0, $my_rules );

    $rules = implode("\n", $exploded);

      return $rules;
  }

  // Need for save_mod_rewrite_rules
  flush_rewrite_rules(true);
}

register_deactivation_hook( __FILE__, 'anty_wpscan_deactivation');
function anty_wpscan_deactivation() {
  flush_rewrite_rules(true);
}

Timeline: