Blog

How to block your WordPress site from being scanned by WPScan with Nginx

Hello!

First and foremost, why would you want to block WPScan from probing your site? Well we all know that security through obscurity is a bad practice.

That said the risks of malicious activity on your site is undoubtedly heightened through many points of information disclosure that is freely available to parse and organize to make an accurate security risk assessment of your WordPress site.

This type of information is easily attainable through automated scanners like WPScan. Tools like this scan for version tags in readme files, file size fingerprints and meta tags to determine not only the version of WordPress you are running but the version of each of the plugins you have installed.

Why is information disclosure bad? Some would argue its not bad. Others would also point out that a 0-day WordPress core or plugin vulnerability could mean that minutes and hours of circumvention or lowered risk could mean the difference between a compromised site and a site that survives.

Purely as an exercise or proof of concept, I will be running through a bunch of changes you can make to your WordPress site as well as to your NGINX configuration in order to mitigate this information disclosure.

Changes to make in your WordPress functions.php to circumvent WPScan

First we want to go over some changes you can make in your WordPress theme’s functions.php to mitigate, circumvent or otherwise make-more-difficult for scanners like WPScan to enumerate and probe.

Remove unwanted headers

This is debatable and is something that WPScan only considers “interesting”:

functions.php

add_action('init', 'shift8_security_init', 1);
function shift8_security_init() {
    header_remove('X-Powered-By');
    header_remove('X-Pingback');
}

The above code snippet will indeed work, but is not ideal. If you are able to edit the nginx configuration file as well as your php.ini file, it is better to make the changes there at the web service / php level :

nginx.conf

server_tokens off;

php.ini

expose_php = Off

Disable your WordPress RSS feed

Having the RSS feed exposed is another way that WPScan can use to detect your WordPress version as well as other pertinent information such as authors.

Find the block of code below to add to your theme’s functions.php in order to disable the RSS feed (if you dont need it) :

add_action('do_feed', 'shift8_security_disable_feed', 1);
add_action('do_feed_rdf', 'shift8_security_disable_feed', 1);
add_action('do_feed_rss', 'shift8_security_disable_feed', 1);
add_action('do_feed_rss2', 'shift8_security_disable_feed', 1);
add_action('do_feed_atom', 'shift8_security_disable_feed', 1);
add_action('do_feed_rss2_comments', 'shift8_security_disable_feed', 1);
add_action('do_feed_atom_comments', 'shift8_security_disable_feed', 1);
add_filter('the_generator','shift8_security_remove_wp_version_rss');

function shift8_security_remove_wp_version_rss() {
    return '';
}

function shift8_security_disable_feed() {
    wp_die( __( 'No feed available, please visit the homepage!' ) );
}

All the code above is doing is adding an action hook for a handful of do_feed actions, triggering the shift8_security_disable_feed function. Within that function we are simply killing the process and returning a standard message directing the user to the main site url.

Disable WordPress emoji’s

Believe it or not the WordPress emoji’s are one of the vectors scanners like WPScan uses in order to enumerate version information. We want to remove things like the emoji script being injected into our website, within your theme’s functions.php :

// Disable emoji's
remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
remove_action( 'wp_print_styles', 'print_emoji_styles' );
remove_action( 'admin_print_styles', 'print_emoji_styles' ); 
remove_filter( 'the_content_feed', 'wp_staticize_emoji' );
remove_filter( 'comment_text_rss', 'wp_staticize_emoji' ); 
remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
add_filter( 'tiny_mce_plugins', 'disable_emojis_tinymce' );

Remove WordPress Meta generator tags

WordPress version information is disclosed in your WordPress meta generated tags in the section of your site. We want to remove that sensitive information from this section of the website, which WPScan looks for to parse sensitive info. Add the following to your theme’s functions.php :

remove_action( 'wp_head', 'wp_generator' );
remove_action( 'opml_head', 'the_generator' );

Remove version query parameters from all enqueued CSS and JS files

WPScan loves the &ver=5.0.0 type arguments that are appended to your CSS and JS files throughout. This is useful for caching systems and implementing this change could affect the quality of your cache. As long as you are aware of the effects or risks, there really shouldn’t be any other detrimental effects.

Add the following to your themes functions.php file :

add_filter( 'style_loader_src', 'shift8_security_remove_wp_ver_css_js', 10, 2);
add_filter( 'script_loader_src', 'shift8_security_remove_wp_ver_css_js', 10, 2);

function shift8_security_remove_wp_ver_css_js( $src ) {
    if ( strpos( $src, 'ver=' . get_bloginfo( 'version' ) ) )
        $src = remove_query_arg( 'ver', $src );
    return $src;
}

If you still want a version query string, the above code could be modified to take your current blog version and generate a hash or something alternative to a version string to append to the CSS/JS files. This way your caching system can still function in a similar way.

Modify your NGINX configuration to block WPScan

The NGINX configuration level is the most efficient way to circumvent automated scanning. This is because you are catching requests and attempts before they are processed by WordPress or PHP, thus improving the viability, efficiency and overall security of the methodology and implementation.

I will break down a subset of nginx configuration rules that you can implement in your site’s configuration in order to circumvent the plugin enumeration and WordPress core enumeration that scanners like WPScan implement.

Block WordPress XMLRPC with NGINX

This is the first thing I will demonstrate. We want to simply block any access to the xmlrpc.php file in the WordPress root folder. Accessing this file can allow an attacker to exhaust your server’s resources quite easily as well as potentially enumerate your WordPress authors and brute force your WordPress logins among other vectors.

location ^~ /xmlrpc.php {
    deny all;
    error_page 403 =404 / ;
}

Just so you know you can also accomplish the above (or a form of the above) in your theme’s functions.php file. This again is not ideal because by the time a request hits the code below, so much more is loaded which increases your server’s overhead (again increasing risk of DoS) and additional security vectors.

add_action('init', 'shift8_security_init', 1);

function shift8_security_init() {
    $current_url = rtrim($_SERVER['REQUEST_URI'], '/');
    // Disable XMLRPC
    add_filter('bloginfo_url', function($output, $property){
        return ($property == 'pingback_url') ? null : $output;
    }, 11, 2);
    add_filter( 'xmlrpc_enabled', '__return_false' );
    if ( strpos($current_url, '/xmlrpc.php') !== false ) {
        http_response_code(404);
        die();
    }
}

The code above is interesting. We are hooking into WordPress’ init and catching the request URI. If the URI matches xmlrpc.php, we block the request and return a 404.

Block WordPress Plugin Enumeration from WPScan

This is a blanket rule that you can inject into your nginx configuration to block the WPScan plugin enumeration. Basically how the enumeration works in WPScan is that they scan for readme files in each of the plugin sub folders in order to parse the version number. Its so simple!

Since there are many thousands of WordPress plugins in the directory, enumerating the plugin versions in a quick & easy way means that requesting for readme files in this way is the best way for WPScan to work.

Beyond enumerating the plugin version, WPScan checks to see if the plugin folder returns a 404 to actually confirm its existence. This is a bit more difficult to block, albeit probably impossible. Since WPScan can randomize the user agent, it is impossible to confirm if the request is legitimate or the result of a scanner.

So WPScan will find out that you have a plugin, but wont know what version it is, if you implement this in your nginx config :

location ~* ^/wp-content/plugins/.+\.(txt|log|md)$ {
    deny all;
    error_page 403 =404 / ;
}

Block access to install.php and upgrade.php

WPScan reads these two files, which are normally accessible by anyone, in order to extrapolate version query strings in the style and js files included on these pages. These files are not hooked into wordpress normally so the previous efforts at stripping the &ver=5.0.5 version query strings will not apply here. The only way to manipulate scans to these particular files is to block access to them outright.

Its possible you may ultimately need access to upgrade.php at certain junctures, at which point access can be released or delegated by IP based restrictions in your nginx config.

Blocking the files is pretty simple, and can be accomplished with the following nginx config directive :

location ^~ /wp-admin/install.php {
    deny all;
    error_page 403 =404 / ;
}

location ^~ /wp-admin/upgrade.php {
    deny all;
    error_page 403 =404 / ;
}

As with the previous nginx config above, we are sending a 404 not found error to anything trying to access those files.

Block wpscan from seeing your plugin versions

Obfuscate WPScan from enumerating your WordPress version by calculating md5sum values on key files

This is the last step in our proof of concept exercise, and what I would consider the most interesting. As a last ditch effort, if WPScan fails to determine the WordPress core version by the previous methods detailed above, they will try to calculate the md5sum value of certain key files that can be associated with each WordPress version in order to reverse engineer the version currently running on a site.

What does that mean? Simply put, WPScan will download several key static files in your wp-includes, wp-admin or even the root license.txt file in order to calculate the WordPress version running. An MD5sum value is associated with each WordPress version and WPScan can determine this by working backwards and calculating.

How on earth can we block this activity with nginx? Simple! We first need to make sure the ngx_http_sub_module or subs_filter is installed. Typically this is installed or packaged by default with most nginx implementations (i.e. if you installed it as a package in your OS).

The quickest way to check and make sure your nginx supports this module , you can run the following commands :

nginx -V

Look for –with-http_sub_module in the output. If you do, great!

This module will allow us to pattern match certain key files and inject random text in the files that is non-destructive / non-invasive and will most importantly alter the md5sum calculated on such files. All transparent to WPScan.

location ~* ^/(license.txt|wp-includes/(.*)/.+\.(js|css)|wp-admin/(.*)/.+\.(js|css))$ {
    sub_filter_types text/css text/javascript text/plain;
    sub_filter_once on;
    sub_filter ';' '; /* $msec */ ';
}

I’ll try to break everything down in the above config snippet, line by line. The first line we are pattern matching in order to catch certain requests. We certainly do not want to inject our random string in every file, that would be weird.

We want to catch /license.txt first and foremost. We then want to catch any JS or CSS file in the wp-includes folder as well as the wp-admin folder. WPScan looks for several key files that are basically unique in their md5sum per WordPress version.

Everything within the location directive is intended to do our bidding. The second line above is where we tell sub_filter to identify 3 different types of files : CSS, Javascript and plain text.

In the third line, we are telling sub_filter to only execute after the first match of the pattern. This ensures we are not injecting a huge amount of text for every match.

The fourth line we are telling sub_filter to search for the first occurrence of “;” (semi-colon), re-write it and add a commented variable after it. The variable is a built-in nginx variable that basically gives a millisecond timestamp. Every refresh of the page changes this timestamp which would make it even more difficult for a scanner to filter out. I should note that this default nginx module doesn’t allow regex pattern matching, which is why we have to match something like a semi-colon.

Thats it! If all goes well and you implement everything above, WPScan will not be able to determine your WordPress version nor will it be able to determine the versions of any of your plugins. Until they update their code to work around such efforts, this should work for everyone.

I hope this has been useful. Its important to remember that understanding how the scanners work in this way will help you to understand how to mitigate and protect your WordPress site.