How a 95,000-IP Botnet Used tel: Links to Cripple a Client’s WordPress Site

I’ve been hosting and managing WordPress websites for over 20 years. Brute force attacks, XML-RPC abuse, spam injections, DDoS attempts… I’ve seen all of it. But last weekend I ran into something I’d never encountered before, and after searching around I couldn’t find a single article documenting this type of attack. So I’m writing one.

Something Was Wrong, But Nothing Looked Wrong

One of the WordPress sites on our cPanel server had been sluggish for about a week. It wasn’t just that site feeling it, either. The whole server was slower than usual, and other client sites that normally loaded instantly were dragging.

We went through the usual checklist. Cleared caches. Optimized the database. Checked for bloated error logs and reviewed plugin performance. Nothing jumped out. We ran a Wordfence scan, which kept failing due to memory exhaustion. We found and fixed a wp-cron misconfiguration that had been generating tens of thousands of unnecessary loopback requests. Load would drop for a bit and then climb right back up.

After hours of chasing our tail, we finally stopped guessing and looked at what was actually consuming resources in real time.

There It Was

Running ps aux --sort=-%cpu showed six PHP-FPM processes for this one site, each consuming 75-79% CPU. A single website was consuming the equivalent of nearly five full CPU cores.

We pulled the Apache access logs and the problem was staring right at us:

187.244.118.5 "GET /directory-page/categories/escape-room/tel:5551234567/tel:5559876543/tel:5555551234/tel:5558675309" 301
168.181.58.2 "GET /directory-page/categories/escape-room/tel:5551234567/tel:5553456789/tel:5557891234/tel:5554567890" 301
27.252.198.69 "GET /directory-page/categories/escape-room/tel:5551234567/tel:5552345678/tel:5558901234/tel:5556789012" 301

Hundreds of requests per second. All hitting the same URL pattern. All from different IPs. And all with fake user agents like Windows 98, Opera 8, MSIE 5.0, and Android 2.0. Nobody is running those browsers in 2026.

How the Attack Works

The site had a business directory page built with Elementor that listed local businesses with clickable phone numbers. Somewhere in the template, the tel: URIs were being rendered as relative links rather than proper <a href="tel:5551234567"> protocol links.

That distinction matters more than you’d think. When a tel: link is coded correctly, the browser handles it by opening the phone dialer. No request ever hits your server. But when it’s rendered as a relative path, a crawler treats it like a page on the site. A request to /directory-page/categories/escape-room/tel:5551234567 doesn’t return a 404 in WordPress. WordPress just loads the parent page, which contains more phone links, which generates more URL paths, which creates an infinite recursive crawl loop.

A botnet found this loop and went to town. Every request had a unique URL because the phone number combinations were randomized, which meant caching was completely useless. Every single request was a cache miss that spun up a fresh PHP process. With 95,000 unique IPs hammering the site, the server was trying to handle hundreds of concurrent PHP requests, each one rendering a full WordPress page with Elementor.

That’s what had been dragging down the server for a week.

Why This Was So Hard to Catch

This attack is different from what most WordPress admins are used to dealing with, and that’s what makes it dangerous.

With 95,000 unique source IPs, there’s nobody to block. Traditional firewall deny lists don’t help when the IPs are disposable and constantly rotating.

Because the phone number combinations are randomized, every URL is different. Page caching, the thing that normally saves WordPress sites from traffic spikes, does nothing here. Every request is a cache miss.

In this case, WordPress didn’t reject these URLs. When a request came in for /valid-page/garbage/more-garbage/, instead of returning a 404 it loaded the valid parent page. This behavior can vary depending on your permalink structure, theme, and plugins, but the result here was that every junk URL the botnet generated got a full 200 response with a complete PHP execution cycle.

Standard security monitoring won’t catch it. If you’re watching for failed logins, xmlrpc abuse, or malware signatures, this looks like normal page traffic. Wordfence didn’t flag a single request.

And on any server hosting multiple sites, one site under this attack slows down every other site on the machine. We spent hours optimizing the wrong things because we didn’t realize the traffic pattern was the actual problem.

The Fix

Immediate Mitigation

We needed to kill this fast. After trying several approaches that didn’t work (other redirect rules in the .htaccess were firing before our block could catch the requests), this is what finally did the job. Add it to the very top of your .htaccess, before any cache plugin or WordPress rewrite rules:

apache
# Block botnet tel: URI resource exhaustion attacks
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{THE_REQUEST} /tel: [NC]
RewriteRule ^ - [F]
</IfModule>

The important detail is using %{THE_REQUEST} instead of %{REQUEST_URI}. The former matches against the original raw request line before any other rewrites process it. We tried %{REQUEST_URI} first and it flat out didn’t work because trailing slash redirects and www normalization rules were issuing 301s before our block ever had a chance to evaluate.

Server load dropped to under 2 within minutes.

This Won’t Break Your Phone Links

If you’re wondering whether this rule will break legitimate tel: links on the front end, it won’t. When a visitor clicks <a href="tel:5551234567">Call Us</a>, the browser opens the phone dialer directly. No HTTP request goes to your server. The only time /tel: shows up in a URL path hitting your server is when a bot is crawling it like a page, and that’s exactly what we want to block.

Fixing the Root Cause

The real fix is finding where the template renders tel: URIs as relative links and correcting the HTML. Phone links should always be output as proper protocol links:

html
<a href="tel:5551234567">Call Us</a>

When they’re coded correctly, the browser handles them natively and they never generate server requests. This closes the infinite crawl loop permanently.

Nginx Alternative

If you’re running nginx as a reverse proxy or standalone, you can block this at the nginx level instead, which is even more efficient since it drops the connection before anything else processes:

nginx
location ~* /tel: {
    return 444;
}

The 444 response is an nginx-specific status code that simply closes the connection with zero response body. Extremely lightweight.

Long-Term Protection

Putting Cloudflare in front of the domain is probably the best single thing you can do going forward. Even their free tier with Bot Fight Mode would catch this traffic before it reaches your server. The fake user agents alone would trigger their bot detection.

Beyond that, get in the habit of periodically reviewing raw access logs for unusual patterns. Security plugins are great at detecting malware and brute force attacks, but they have blind spots. This type of resource exhaustion doesn’t trip any of the usual alarms.

Was This an AI-Driven Attack?

I wondered about this too, given how much AI is being used for offensive security these days. Based on what I observed, I don’t think so. The fake user agents are classic botnet signatures that have been in use for years. Windows 98, impossible OS and browser combinations, randomized version numbers. An AI-driven attack would more likely use realistic, modern user agent strings to blend in with legitimate traffic.

My best guess is that this is a traditional scraper botnet that stumbled into the infinite URL space created by the malformed tel: links and just kept spawning crawlers because it kept finding “new” pages. Whether the resource exhaustion was intentional or just a side effect of the botnet getting stuck in a crawl loop is hard to say, but the result was the same.

What I Took Away From This

The biggest lesson was embarrassingly simple: when a server is slow, look at what’s actually hitting it before you start optimizing things. We spent hours tweaking caches, databases, and cron jobs while the answer was sitting right there in the access logs.

WordPress’s permissive URL handling is also worth thinking about. The fact that it serves content for URLs with arbitrary path segments appended to valid pages creates real opportunities for this kind of resource exhaustion. It’s not a bug exactly, but it’s a behavior that can be exploited.

Security plugins have blind spots too. Wordfence is excellent at what it does, but it’s designed to detect malware, brute force attempts, and known vulnerability exploits. It’s not analyzing traffic patterns for resource exhaustion.

And finally, check your templates for relative protocol links. Any tel:, mailto:, or similar protocol URI that gets rendered as a relative path instead of a proper protocol link creates a potential infinite crawl loop that a bot can exploit.

If you’ve seen something similar or have questions about implementing these fixes, I’d like to hear about it. And if you’re a hosting provider or security researcher, I’m curious whether this attack pattern has shown up elsewhere. I couldn’t find any documentation when I went looking.