What Happens When You Stop Looking: A Month of Downtime, Leaked Credentials, and a Backdoor on Sites I Forgot About
This is not a post about a clever attack. Nobody targeted me. Nothing sophisticated happened.
I stopped looking at a set of websites, and they quietly fell apart. When I finally looked, the damage had been compounding for weeks.
I'm writing it down because it is embarrassing, because I think it is common, and because the fix turned out to be mostly habits, not tools.
The setup I thought was "fine"
Over a few years I accumulated a small fleet: roughly ten budget VPSs, managed through a server control panel, with DNS on Cloudflare. They hosted about a dozen WordPress sites. Some were mine. Some belonged to side projects and small clients. One was a MainWP dashboard that manages other WordPress sites.
Each site was set up properly on day one. SSL, a firewall, Cloudflare in front, a monitor in an uptime service.
Then I moved on to other work, and I treated "it was set up properly" as if it meant "it is still fine."
It did not.
How it unraveled
1. A new IP address and a month of silence
My VPS provider migrated one of the servers, and it came back with a new public IP. If there was a notice, I missed it.
Every DNS record for the sites on that server still pointed at the old address. Cloudflare dutifully tried to reach an IP that no longer existed and served visitors a 522 Connection timed out page.
When I finally pulled Cloudflare's analytics, two of those sites had zero successful responses for 29 consecutive days. Twenty-nine days is the whole per-hostname window Cloudflare kept, so the real outage was almost certainly longer. I simply cannot see further back.
A third site on the same server was down on 28 of those days.
2. The monitoring existed. It was paused.
I did have an uptime monitoring account. When I opened it during the cleanup, I found:
- the monitor for the affected server was paused
- the monitor for the MainWP dashboard was paused
- two of the sites on that server had no monitor at all
- most of the other site monitors were paused too
The monitors that were still running mostly checked servers, not websites. A server can answer a ping perfectly well while every site on it is unreachable because DNS points somewhere else. Those checks stayed green for the whole outage.
A paused monitor is worse than no monitor. It gives you the feeling of coverage without any of the coverage.
3. Fixing DNS exposed something much worse
I pointed DNS at the new IP and reissued the TLS certificates. Pages loaded again, and I thought the job was done.
Later that day I opened the MainWP admin and the browser printed raw PHP source code.
It was not just the admin page. On all three WordPress sites on that server, the web server was handing out .php files as plain text. That included wp-config.php, which contains the database name, user, password, and WordPress secret keys.
Cloudflare was in front of the sites and some requests got blocked. But the origin server was reachable directly by IP, and anyone asking the server directly got the files.
The root cause was mundane. The provider's migration had restored the server without one Apache module enabled: proxy_fcgi, the piece that hands .php requests to PHP-FPM. Without it, Apache does not error out. It happily serves the file as text. A fourth app on the same server used an older PHP handler, so it kept working and made the whole server look healthy from the control panel's point of view.
The control panel's own "check and repair server configuration" tool reported that everything was fine.
4. The MainWP question
A MainWP dashboard holds the keys to every child site it manages, so seeing its source in a browser was the moment my stomach dropped.
What limited the damage:
- MySQL on that server was not reachable from the internet, so the leaked database password could not be used remotely
- MainWP stores child site connection keys in the database, not in the leaked files
I rotated everything anyway. When a config file has been public, you do not get to guess about whether anyone read it.
5. A backdoor, sitting behind a maintenance page
While moving the sites off a second, older host, I cloned a WordPress site that had been sitting behind a maintenance page for a long time.
In its web root was a randomly named file along the lines of wp-XXXXXX.php, about 66 KB, that answered requests from the internet. Next to it was a file with a .jpg extension that was not an image at all. It started with <?php and a base64 decode.
The maintenance page did not protect anything. It was a rewrite rule with exceptions, and the backdoor answered right through it.
6. DNS pointing at other people's servers
The same audit turned up subdomains pointing at cloud IPs I no longer owned. One of them now belonged to an unrelated company and served their login page on my subdomain.
That is a textbook subdomain takeover risk. It sat there because I deleted cloud instances and never deleted the DNS records that pointed at them.
7. A server nobody touched for a year
The activity log of one server had no entries for twelve months. It was still serving a site and a public phpMyAdmin installation.
What I did, in order
The order mattered more than any individual step.
- Contain. I blocked the affected hostname at Cloudflare and disabled the three apps in the control panel so the origin stopped serving anything, including to direct-IP requests. I quarantined the backdoor and the disguised PHP file outside the web root instead of deleting them, and disabled the old copy on the previous host.
- Find the root cause before re-enabling anything. I compared the working app's Apache config with the broken ones and listed the loaded modules.
proxy_fcgiwas missing. - Fix it, and prove the fix without exposing anything. After enabling the module, requests to the still-disabled apps returned PHP-FPM's own "File not found" instead of source code. That confirmed PHP was handling
.phpfiles again before any site went live. - Rotate before going live. New random database passwords for each site, set in MySQL and in
wp-config.php, and fresh WordPress salts, which also logs every user out. - Verify integrity.
wp core verify-checksumsandwp plugin verify-checksums --allon every affected site. Core files matched. Every plugin from wordpress.org matched. Premium plugins cannot be verified this way, so they get a manual review. - Patch what I had been ignoring. A vulnerability scanner had emailed me about an unauthenticated stored XSS in an SMTP plugin on the MainWP dashboard. It went from 2.2.90 to 2.4.1.
- Clean up leftovers. A one-time login helper file the control panel had created, which never ran because PHP was broken, and was therefore sitting there readable.
- Only then re-enable, remove the emergency block, and re-check every path directly against the origin.
The structural change: stop putting IPs in app DNS
The first failure, a month of downtime, happened because every app hostname was an A record with a hard-coded server IP. One IP change meant hunting down records across several Cloudflare zones and two accounts.
Now each server gets exactly one DNS-only A record on a separate, neutral infrastructure domain, something like node01.infra-domain.com. Every app hostname is a CNAME to its server's record, with the proxy setting unchanged. Cloudflare flattens CNAMEs at the apex, so this works for root domains too.
The next time a provider changes an IP, I change one record.
It also made the audit easier. Once everything pointed at named nodes, the leftovers pointing at raw IPs stood out immediately.
What I actually learned
Monitor what users hit, not what you run. A server ping tells you the machine is on. It says nothing about DNS, TLS, PHP, or whether the page renders. I now monitor each site's real URL, with a keyword check so a blank page or an error page counts as down.
A paused monitor is a lie you tell yourself. Audit the monitoring itself. Once a month, list every monitor and every site, and make sure the two lists match and nothing is paused without a written reason.
Every site needs an owner and a state. Maintained, archived, or deleted. "Abandoned but still online" is the most dangerous state a website can be in. It has all the attack surface of a live site and none of the attention.
A maintenance page is not a security boundary. If a site is not being maintained, take it offline properly, or archive it as static files.
Treat provider maintenance as a change you have to verify. After any migration, restore, or IP change, run the same short checklist every time: DNS resolves to the new IP, TLS is valid, PHP executes, wp-config.php returns an empty response rather than text, and the pages render.
Do not trust a control panel's "all green." Its health check did not test the one thing that mattered, whether PHP actually ran for each app.
Hide the origin. Cloudflare in front does not help if the server answers everyone directly. Restrict ports 80 and 443 to Cloudflare's IP ranges, or use a tunnel so the origin has no public web ports at all.
Delete DNS when you delete infrastructure. Every record should point at something you still control. Dangling records are free subdomains for someone else.
Backups only count if they live somewhere else and you have restored one. I had neither. That is next on the list.
Act on security emails. The vulnerability alert arrived, and I read it. Reading is not patching.
The checklist I now run
Monthly:
- [ ] Every site has an active, keyword-based URL monitor, and none are paused
- [ ] Every DNS record points at a node I own
- [ ] WordPress core, plugins, and themes are updated, and vulnerability alerts are cleared
- [ ] Off-server backups exist, and one was test-restored this quarter
- [ ] Every site is either maintained, archived, or deleted
After any provider change:
- [ ] Node DNS record updated, apps resolve through it
- [ ] TLS valid on every hostname
- [ ] PHP executes, and config files are not served as text, tested directly against the origin
- [ ] Monitors show green for the right reason
Closing
None of this required an attacker to be clever. It only required me to stop looking.
The honest summary is that I built things, felt responsible for them while they were new, and then let them run on inertia. Inertia kept them online right up until the first small change. After that, every missing habit made the next problem worse.
If you have a VPS, a WordPress install, or a client site that you have not logged into this year, go look at it today. Check that the monitor is on, that DNS points at something you control, and that wp-config.php does not open in a browser.
It takes ten minutes. It took me a month of downtime and one very bad afternoon to learn that.
Related Articles
Headless Theme: the one thing WordPress needs when you only want the backend
WordPress insists on an active theme, even when your site is just a content backend. Headless Theme is a small, free theme that fills that gap and sends visitors to your real frontend.
Git Authentication with GitHub CLI: The Easy and Secure Way
Tired of dealing with SSH keys or personal access tokens? Here's how to authenticate with GitHub using your browser and never worry about credentials again.