A scanner took down WordPress

A scanner took down WordPress

A WordPress website with fewer than one real visitor per day should not become unreachable. Mine did. Not often, not predictably, but every once in a while the website would stop responding. Strangely, SSH would stop responding too. At that point there was little left to do but reboot the AWS Lightsail instance from the console and hope it came back.

This blog post is a small post-mortem of that failure. The culprit was not a traffic spike from users, nor a full disk, nor CPU bursting. Instead, a scanner requested hundreds of non-existing paths, WordPress made those requests expensive, and Apache eventually filled its internal scoreboard with requests it could not finish.

Key takeaways:

  • A low-traffic WordPress website is still a public internet server. It will be scanned.
  • On a Bitnami WordPress stack, many non-existing URLs can be routed through WordPress instead of being rejected cheaply by Apache.
  • Apache’s scoreboard tracks worker slots. When slots stay occupied by slow or stuck requests, the server can become unable to accept useful work even when CPU usage looks fine.
  • Do not immediately increase Apache’s ServerLimit or MaxRequestWorkers on a tiny instance. First make bad requests fail cheaply.
  • Restrict SSH in the Lightsail firewall. Do not rely on the fact that nobody knows your server exists.

The symptom

My AWS Lightsail WordPress instance is not busy. It is a small Bitnami WordPress installation, receives almost no real visitors, and does not have meaningful peaks. Still, roughly once a month, the site would become unreachable.

The confusing part was that SSH also failed. When a WordPress plugin crashes, or PHP is slow, I expect the website to fail while SSH remains available. This failure felt lower in the stack. The only practical recovery was to reboot the instance from the Lightsail console.

After one of these reboots I checked the usual suspects:

basic-health-checks.sh
Copy
date uptime free -h df -h df -ih last -x | head -30

The output did not look suspicious. Disk space was fine. Inodes were fine. Memory was not exhausted after reboot. Swap existed. CPU metrics in Lightsail did not show a burst problem. This was already a useful finding: a server can be dead without being CPU-bound.

What the logs said

The previous boot told a more interesting story. The kernel logs contained many lines like this:

Copy
INFO: task httpd:2442469 blocked for more than 120 seconds. INFO: task httpd:2442611 blocked for more than 120 seconds. INFO: task httpd:2442625 blocked for more than 121 seconds.

Apache’s error log then repeated:

Copy
[mpm_event:error] AH03490: scoreboard is full, not at MaxRequestWorkers. Increase ServerLimit.

The access log showed one scanner hitting many paths that did not belong to my website:

Copy
GET /config/aws.yml HTTP/1.1 GET /jupyter/api/kernels HTTP/1.1 GET /grafana/api/alerting/ HTTP/1.1 GET /loki/ready HTTP/1.1 GET /prometheus/api/v1/query HTTP/1.1 GET /console/api/login HTTP/1.1 GET /phpinfo.php HTTP/1.1 GET /content/.env HTTP/1.1

Most of these requests returned 504, not a quick 404. That distinction matters. A 404 means Apache quickly answered: “that does not exist”. A 504 means something behind Apache did not respond in time. In this case, non-existing scanner paths were not cheap enough.

Why non-existing URLs were expensive

A static website can reject a non-existing file very quickly. If /config/aws.yml does not exist, the web server can simply return 404. No database. No PHP. No plugin system. No theme loading.

WordPress is different. WordPress is a dynamic content management system. A request is often not meant to map directly to a file. For example, /about, /events, and /members/john may all be virtual routes. They are not files called about, events, or john on disk. Apache therefore needs to pass many requests to WordPress so WordPress can decide what the URL means.

A typical WordPress rewrite rule looks conceptually like this:

conceptual-wordpress-rewrite.conf
Copy
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.php [L]

In words: if the requested thing is not an existing file and not an existing directory, pass it to index.php. That makes sense for WordPress permalinks. It is also exactly why scanner garbage can become expensive.

GET /config/aws.yml
Does this file exist?
No
Rewrite to /index.php
Late 404 or 504
Scanner
Apache
File system
PHP-FPM
WordPress
MariaDB

This is the core of the problem. The scanner did not need to find a real vulnerability to hurt the site. It only needed to send enough requests that were expensive to answer.

A small detour: what is Apache’s scoreboard?

The phrase “scoreboard is full” was new to me. It sounds like Apache is playing a game and losing. That is not a bad mental model.

Apache has multiple ways to handle simultaneous connections. My instance uses the event Multi-Processing Module (MPM). The event MPM is designed to avoid wasting a whole worker thread on idle keep-alive connections. Apache’s documentation describes this as an attempt to solve the “keep alive problem”: after a request, a client may keep the TCP connection open to reuse it, but dedicating an entire process or thread to a client that is merely waiting has obvious downsides.apache-event

To coordinate all these workers, Apache maintains an internal scoreboard. The scoreboard records which worker slots exist and what state they are in. A simplified version looks like this:

Apache scoreboard
yes
no
slot 1
serving /
slot 2
serving /wp-json
slot 3
waiting on PHP
slot 4
waiting on PHP
slot 5
stuck / old connection
slot 6
stuck / old connection
new request
free slot?
Apache cannot accept useful work

The important part is that a slot can remain occupied even when it is not doing useful CPU work. A worker can be waiting on PHP-FPM, MariaDB, disk I/O, a slow client, a graceful shutdown state, or some other blocking operation. That explains why the Lightsail CPU graph can look boring while the website is effectively dead.

The Apache error message was:

Copy
AH03490: scoreboard is full, not at MaxRequestWorkers. Increase ServerLimit.

The tempting response is to increase ServerLimit. I did not do that. Apache’s own MPM documentation warns that if ServerLimit and MaxRequestWorkers are set higher than the system can handle, Apache may fail to start or the system may become unstable.apache-mpm-common On a tiny Lightsail instance, accepting more expensive WordPress work is not necessarily a fix. It can simply let the failure become larger.

The better fix is to make bad requests cheap.

The first fix: restrict SSH

The first thing I changed was unrelated to WordPress. My Lightsail firewall had SSH open to any IPv4 address.

That is convenient, but it also means every internet scanner can talk to port 22. The logs showed exactly what you would expect: probes and failed attempts for names like root, admin, oracle, ubuntu, test, and user.

In Lightsail, restrict the SSH rule to your own IP address. If you use the browser-based SSH client in the AWS console, keep the Lightsail browser SSH option enabled. AWS explicitly supports restricting SSH to an IP range while still allowing browser-based SSH/RDP clients from the Lightsail console.lightsail-firewall

The result should look conceptually like this:

Copy
Application Protocol Port Restricted to SSH TCP 22 your.ip.address.here HTTP TCP 80 Any IPv4 address HTTPS TCP 443 Any IPv4 address

I would do this in the Lightsail firewall rather than with iptables inside the instance. If you make a mistake in the instance firewall, you can lock yourself out. If you make a mistake in the Lightsail firewall, you can still fix it from the AWS console.

The second fix: remove duplicate WordPress debug config

The Apache logs also contained many PHP warnings like this:

Copy
PHP Warning: Constant WP_DEBUG already defined in /bitnami/wordpress/wp-config.php

This was caused by two definitions in wp-config.php:

wp-config.php
Copy
define( 'WP_DEBUG', false ); define('WP_DEBUG', true);

This was not the main outage cause, but it created log noise on every WordPress request. On a small instance, useless logging is still useless work.

I checked it with:

find-wp-debug.sh
Copy
sudo grep -n "WP_DEBUG" /bitnami/wordpress/wp-config.php

Then I backed up the file and removed the duplicate true definition:

fix-wp-debug.sh
Copy
sudo cp -a /bitnami/wordpress/wp-config.php /bitnami/wordpress/wp-config.php.bak.$(date +%F-%H%M%S) sudo sed -i "/define('WP_DEBUG', true);/d" /bitnami/wordpress/wp-config.php sudo grep -n "WP_DEBUG" /bitnami/wordpress/wp-config.php

For production, the key line should be:

wp-config.php
Copy
define( 'WP_DEBUG', false );

The third fix: reject scanner paths before WordPress

The real fix was to stop obvious scanner paths before they reached PHP and WordPress.

On my Bitnami WordPress instance, the active virtual host files were:

Copy
/opt/bitnami/apache2/conf/vhosts/wordpress-vhost.conf /opt/bitnami/apache2/conf/vhosts/wordpress-https-vhost.conf

Bitnami’s Apache documentation explains that virtual host configuration is loaded from the vhosts directory under the Apache configuration tree.bitnami-apache-config In my instance, /opt/bitnami/apache2 and /opt/bitnami/apache pointed to the same target, but I still checked before editing:

check-bitnami-apache-paths.sh
Copy
readlink -f /opt/bitnami/apache readlink -f /opt/bitnami/apache2

Then I backed up both vhost files:

backup-vhosts.sh
Copy
sudo cp -a /opt/bitnami/apache2/conf/vhosts/wordpress-vhost.conf /opt/bitnami/apache2/conf/vhosts/wordpress-vhost.conf.bak.$(date +%F-%H%M%S) sudo cp -a /opt/bitnami/apache2/conf/vhosts/wordpress-https-vhost.conf /opt/bitnami/apache2/conf/vhosts/wordpress-https-vhost.conf.bak.$(date +%F-%H%M%S)

Inside the <Directory "/opt/bitnami/wordpress"> block, before the standard WordPress rewrite rules, I added this:

wordpress-vhost.conf
Copy
# Reject common scanner paths before WordPress/PHP handles them. RewriteCond %{REQUEST_URI} "(^|/)(\.env|\.git|phpinfo|config|configuration|manager|jupyter|grafana|prometheus|loki|jenkins|owa|console|containers|mgmt|portainer|server-status|wp-config)" [NC] RewriteRule ^ - [R=404,L]

The same block was added to the HTTPS vhost.

Apache mod_rewrite can run in server configuration or per-directory context, and a rewrite rule can lead to redirects, internal rewrites, proxying, or other request handling.apache-mod-rewrite In this case the rule is deliberately boring: if the URL looks like scanner garbage, return 404 and stop processing. The [R=404,L] flags mean: return HTTP status 404 and make this the last rewrite rule for that request.apache-rewrite-flags

Then I tested the configuration and restarted Apache:

test-and-restart-apache.sh
Copy
sudo /opt/bitnami/apache/bin/httpd -t sudo /opt/bitnami/ctlscript.sh restart apache

Finally, I verified the behavior locally:

verify-cheap-404s.sh
Copy
curl -I http://127.0.0.1/config/aws.yml curl -I http://127.0.0.1/.env curl -I http://127.0.0.1/

The first two returned fast Apache-level 404s. The homepage still returned 200.

That is exactly what I wanted:

Copy
/config/aws.yml -> 404 /.env -> 404 / -> 200

The scanner can still knock on the door. It just no longer gets invited into WordPress for every nonsense URL.

Why I did not only block the scanner IP

I also temporarily blocked the scanner IP with iptables:

temporary-scanner-block.sh
Copy
sudo iptables -I INPUT -s 173.212.230.238 -p tcp --dport 80 -j DROP sudo iptables -I INPUT -s 173.212.230.238 -p tcp --dport 443 -j DROP

This is useful as an emergency measure. It is not a durable fix. The next scanner may come from another IP address. Blocking one scanner is like removing one mosquito. Making bad requests cheap is like installing a screen.

What I would check during the next incident

If the instance is still reachable during a future incident, I would collect this before rebooting:

incident-snapshot.sh
Copy
date uptime free -h df -h df -ih ps -eo pid,ppid,stat,wchan:32,etime,cmd \ | egrep 'httpd|php-fpm|mariadb|mysql|journald|systemd' \ | head -200 sudo ss -antp | egrep ':80|:443|:22' | head -200 sudo dmesg -T | tail -200 sudo tail -200 /opt/bitnami/apache/logs/error_log sudo tail -200 /opt/bitnami/apache/logs/access_log

The STAT column is especially interesting. If many Apache processes are in D state, they are in uninterruptible sleep, usually waiting on kernel-level I/O. That would explain why they are not consuming CPU while still preventing useful progress.

The missing key points

There are a few lessons here that are easy to miss.

First, low human traffic does not mean low server exposure. A public IP is a public IP. It will be scanned.

Second, WordPress makes URL handling powerful by routing non-file requests through PHP. This is the same mechanism that gives you nice permalinks. It is also the mechanism that can make garbage URLs expensive.

Third, not every outage is visible in average CPU metrics. A server can be blocked, waiting, or wedged while the CPU graph remains calm. In this incident, the more useful signals were Apache’s scoreboard error, blocked httpd tasks, and access-log status codes.

Fourth, “increase ServerLimit” is not the first fix. It may be a fix for a correctly sized busy server. It is not automatically a fix for a small server that is drowning in slow garbage requests. On a small Lightsail instance, allowing more concurrent expensive PHP work can make the failure mode worse.

Fifth, security hardening and reliability hardening overlap. Restricting SSH, disabling noisy debug logs, and rejecting scanner paths are not just security measures. They reduce unnecessary work.

Conclusion

The interesting part of this incident is that nothing dramatic happened. No successful hack. No real traffic spike. No full disk. No obvious CPU exhaustion. Just a public WordPress server doing too much work for requests that should have been cheap.

The final architecture is almost the same as before, but with an important early exit:

GET /.env
yes
GET /
no
Scanner
Apache
Scanner path?
PHP-FPM
WordPress
MariaDB
Real visitor
404 from Apache

A small server does not need to be powerful if it is allowed to say “no” early.


  1. Apache HTTP Server documentation, event MPM: https://httpd.apache.org/docs/2.4/mod/event.html
  2. Apache HTTP Server documentation, MPM common directives, ServerLimit and MaxRequestWorkers: https://httpd.apache.org/docs/2.4/en/mod/mpm_common.html
  3. AWS Lightsail documentation, editing firewall rules and allowing browser-based SSH/RDP clients: https://docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-editing-firewall-rules.html
  4. Bitnami documentation, default Apache configuration for WordPress virtual machines: https://docs.bitnami.com/virtual-machine/apps/wordpress/get-started/understand-config/
  5. Apache HTTP Server documentation, mod_rewrite: https://httpd.apache.org/docs/current/mod/mod_rewrite.html
  6. Apache HTTP Server documentation, RewriteRule flags: https://httpd.apache.org/docs/2.4/rewrite/flags.html