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();
}
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);
}