Peter Davies

Ramblings

Hiding WordPress paths on Nginx

Most automated attacks on WordPress start by working out that the site is WordPress and which plugins it runs. They look for /wp-content/plugins/<name>/readme.txt, enumerate users through ?author=1, and request /wp-login.php. Plugins like WP Ghost (formerly Hide My WP) change those paths so the obvious requests return a 404. I set one up on a WordPress site served by Nginx recently, and the Nginx side needs more care than the Apache side.

Be clear what this is: security through obscurity. It reduces noise and wastes automated scanners' time. It doesn't fix anything.

What the plugin does

It works in two halves. Inside WordPress it rewrites the URLs in every page it outputs, so the HTML refers to the new paths. On the web server, rewrite rules map the new paths back onto the real files. Typical mappings:

/wp-admin/                     ->  /my-admin/
/wp-login.php                  ->  /my-login
/wp-admin/admin-ajax.php       ->  /ajax-call
/wp-content/                   ->  /core/
/wp-content/plugins/<name>/    ->  /core/modules/<hash>/
/wp-content/themes/<theme>/    ->  /core/views/<hash>/
/wp-content/uploads/           ->  /storage/
/wp-includes/                  ->  /lib/

Each plugin and theme folder gets a short hash in place of its name, so a scanner can't tell from the page source which plugins are installed.

On Apache the plugin writes these rules into .htaccess itself. Nginx has no .htaccess, so the plugin shows you the rules and you add them to the server block by hand, and reload. That's the source of most of the trouble: every time a plugin is added or removed, or you change a path in the plugin's settings, the rules change and you have to update Nginx.

The rewrite rules

A cut-down version of what gets generated, with placeholders for the custom names and hashes. It goes inside the server block, before the location blocks:

if (!-e $request_filename) {
    rewrite ^/([_0-9a-zA-Z-]+/)?ajax-call$ /wp-admin/admin-ajax.php last;
    rewrite ^/([_0-9a-zA-Z-]+/)?my-admin/(.*) /wp-admin/$2 last;
    rewrite ^/([_0-9a-zA-Z-]+/)?my-login$ /wp-login.php last;
    rewrite ^/([_0-9a-zA-Z-]+/)?core/modules/PLUGIN_HASH/(.*) /wp-content/plugins/plugin-name/$2 last;
    rewrite ^/([_0-9a-zA-Z-]+/)?core/modules/(.*) /wp-content/plugins/$2 last;
    rewrite ^/([_0-9a-zA-Z-]+/)?core/views/THEME_HASH/design.css$ /wp-content/themes/theme-name/style.css last;
    rewrite ^/([_0-9a-zA-Z-]+/)?core/views/THEME_HASH/(.*) /wp-content/themes/theme-name/$2 last;
    rewrite ^/([_0-9a-zA-Z-]+/)?core/views/(.*) /wp-content/themes/$2 last;
    rewrite ^/([_0-9a-zA-Z-]+/)?storage/(.*) /wp-content/uploads/$2 last;
    rewrite ^/([_0-9a-zA-Z-]+/)?core/(.*) /wp-content/$2 last;
    rewrite ^/([_0-9a-zA-Z-]+/)?lib/(.*) /wp-includes/$2 last;
}

A few things to understand about it:

if has a bad reputation in Nginx, but that's about if inside location blocks. At server level with only rewrite and return inside, it's safe.

The blocking rules

The second set of rules returns a 404 for the requests scanners use. Nginx has no "and" in an if, so the generated config builds up a string in a variable and tests the result. It's worth understanding because it's a useful pattern on its own:

set $cond "";
if ($http_cookie !~* "wordpress_logged_in_") { set $cond "cookie"; }
if ($args ~* "(^|&)author=\d+") { set $cond "${cond}+author"; }
if ($cond = "cookie+author") { return 404; }

If the visitor isn't logged in, $cond becomes cookie. If they're also asking for ?author=N, it becomes cookie+author, and only that exact value returns a 404. Logged-in editors still get author archives.

The same pattern hides direct access to the real folders from anyone who isn't logged in:

set $cond "";
if ($http_cookie !~* "wordpress_logged_in_") { set $cond "cookie"; }
if ($request_uri ~* ^/wp-content/?$) { set $cond "${cond}+deny"; }
if ($request_uri ~* ^/wp-includes/?$) { set $cond "${cond}+deny"; }
if ($request_uri ~* ^/wp-content/(plugins|themes)/[^\.]+\.(php|html?|txt|lock)) { set $cond "${cond}+deny"; }
if ($request_uri ~* ^/wp-admin/(install|upgrade)\.php) { set $cond "${cond}+deny"; }
if ($cond = "cookie+deny") { return 404; }

Note it only ever tests for cookie+deny. If two of the path tests match, $cond becomes cookie+deny+deny and the request goes through. The paths in this block don't overlap, so that can't happen here, but keep it in mind if you add your own lines.

And it blocks the usual fingerprinting tools by user agent, and the files that give the version away:

if ($http_user_agent ~* "(wpthemedetector|builtwith|isitwp|wappalyzer|whatcms)") { return 404; }

location ~ ^/(wp-config-sample\.php|readme\.html|readme\.txt|license\.txt|php\.ini|error_log|debug\.log) {
    deny all;
}

Things that went wrong

Allow-lists by IP. The generated rules let some addresses through the blocks regardless of cookies, typically your office. If the site sits behind a CDN or load balancer, $remote_addr is the proxy's address, and allow-listing that lets everyone through. Set up real_ip_header and set_real_ip_from first so $remote_addr is the visitor.

Headers. The plugin's Nginx output includes add_header lines for HSTS and other security headers. Nginx only inherits add_header into a location block that has no add_header of its own. If your PHP or static file locations set any header, the security headers silently disappear from those responses. Check with curl -I on a page, an image and a stylesheet.

Caching. A page cache that stored pages before the plugin was turned on keeps serving HTML with the old paths. Clear every cache layer after changing paths.

Locking yourself out. Changing the admin and login paths is the setting that matters most to get right. Write the new login URL down before saving, and keep SSH access to hand: renaming the plugin's folder over SSH disables it and restores the normal paths.

PHP in uploads. The generated rules only hide PHP in uploads from visitors who aren't logged in. PHP should never run from uploads at all, so add a hard rule regardless:

location ~* ^/wp-content/uploads/.*\.php$ {
    deny all;
}

What it doesn't protect against

WordPress is still easy to identify from the HTML structure, the REST API at /wp-json/ (or ?rest_route=/), feed formats, the shape of cookies and the hashes of core JavaScript files. A determined person will know it's WordPress in seconds. More importantly, a lot of automated exploitation doesn't bother fingerprinting first: it sends the exploit for a known plugin vulnerability to every site and sees what works. If the vulnerable plugin is installed, a renamed folder doesn't save it, because the request can go through the real path or the rewritten one equally well.

So use it as a layer on top of the things that do protect a site: keeping core, plugins and themes up to date, removing plugins you don't use, a WAF in front, multi-factor authentication on every admin account, DISALLOW_FILE_EDIT in wp-config.php, and file permissions that stop the web server writing to code. What you get from path hiding is quieter logs and fewer opportunistic probes, and that's still worth having.