DEV Community

Shirajul Islam Shakur
Shirajul Islam Shakur

Posted on Originally published at shakurshirajul.com AI-assisted

My Site Was Compromised in 48 Hours. I Thought It Was a CSS Bug.

I opened my laptop convinced I had a CSS bug. Shop links on my WooCommerce site were resolving to empty search URLs instead of product pages. Classic front-end problem a broken template path, probably something I had changed the night before.

I debugged my own code for a while before I thought to check the user list. There was an admin account on the site that I had not created.

This was a dummy store a throwaway project I had spun up to test a build, with no real customers and nothing of value on it. That detail turns out to matter, and not in the way I assumed.

The mistake

My admin password was identical to my username. Four characters. I set it while getting the environment running and told myself I would change it before anyone found the site.

Nobody found the site. Scanners found it. New WordPress installations get probed within hours of becoming reachable, and username-equals-password sits in the first few hundred guesses of any credential wordlist. There was no window between "deployed" and "targeted" for me to fix it in.

By hour 48 the access logs showed over 500 login attempts from roughly 20 IP addresses, deliberately spread thin so no single address tripped a rate limit. None of it was aimed at me specifically. It was a scanner working through a list, and my site was on it.

Seven seconds

Two timestamps from the log, adjacent:

  • 01:51:15 - a login succeeds.
  • 01:51:22 - a second administrator account exists on the site.

Seven seconds. No human logs in, navigates to the users screen, fills out a form, and submits it in seven seconds. That was a script, and it was running the moment valid credentials were available.

The detail that actually taught me something

The IP that logged in successfully appears only about twenty times in my entire log. It never brute-forced anything. It arrived already knowing the password.

That is the part worth sitting with. One operation cracks credentials at scale; working logins get passed along to other operators. The machine that broke in was not the machine that guessed. My site had become a line item on somebody's list.

By that evening, three separate parties had logged in as administrator. The second one tried to create its own backdoor account and failed the first had already taken that step.

Why a bug report is what saved me

The intruders deactivated seven plugins, which is standard practice: anything that logs, scans, or blocks gets turned off first. WooCommerce happened to be among them.

My theme wraps every WooCommerce call in a function_exists() guard. I wrote those guards for robustness, so a missing plugin would not produce a fatal error on a live page. With WooCommerce deactivated, the theme did exactly what I designed it to do: instead of crashing, it degraded quietly. Shop links fell back to empty search URLs.

That fallback is the only reason I noticed. Defensive code I wrote for reliability is what converted a silent compromise into a visible symptom. Had the theme crashed outright I would have found it faster. Had it handled the absence perfectly, I might not have found it for weeks.

What contained the damage

Administrator access to WordPress means arbitrary code execution the plugin editor alone is enough. So the question was never whether they could run code. It was what that code could reach.

It ran as an unprivileged user inside a container. The database lived in a separate container with its port never exposed to the host. Getting from where they were to the host itself would have required a second, unrelated exploit.

The result: container compromised, host clean. Recovery became rebuild from a clean image and rotate every credential rather than disinfect a server and hope you found everything. That distinction is the difference between an afternoon and a week.

I want to be honest about this: I did not containerize for security. I did it because it made local development tidier. An architectural decision made months earlier for convenience is what turned a disaster into an inconvenience.

What I got wrong about "pre-launch"

The belief that did the damage was not really the weak password. It was the idea that a site nobody knows about is a site nobody will attack that "before launch" is a period during which normal rules are suspended.

There is no such period. The moment a host resolves and a port answers, you are in production as far as the internet is concerned. Obscurity was never protecting me; I just had not been found yet.

The baseline I should have had before deploying

None of this is advanced. All of it takes less time than the recovery did.

  • A generated password, stored in a manager. Not a memorable one. Not one you plan to change later.
  • Two-factor authentication on every admin account, from the first deploy rather than the first launch.
  • Rate limiting on the login endpoint. 500 attempts should never have been possible. Lock out after a handful of failures per IP and per username.
  • Disable the plugin and theme editors. One line in wp-config.php: define('DISALLOW_FILE_EDIT', true); - it removes the most direct path from admin access to code execution.
  • Block xmlrpc.php unless something you run genuinely needs it. It permits hundreds of credential guesses in a single request, which makes per-request rate limiting nearly useless.
  • fail2ban, or equivalent, watching the auth log and banning at the firewall rather than in PHP.
  • Access logging turned on and actually retained. Without logs I would have had no idea how many parties were involved, or that credential resale had happened at all.
  • Least privilege in the runtime. Unprivileged user, isolated database, no unnecessary ports. This is the layer that worked.

A forensic detail worth knowing

WordPress does not record an IP address when a user account is created. It does store the exact registration timestamp.

Take that timestamp, search your web server access logs for the matching second, and the request that created the account is sitting there with its source IP attached. That one join is how I established the sequence, who got in first, who arrived later, and which address had never attempted a password at all.

What I actually took away

My authentication layer failed completely. Not partially completely. The thing that limited the damage was a layer I had put in place for entirely unrelated reasons.

That is the argument for defense in depth, and I did not really understand it until one layer failed and a different one held. You are not building a wall. You are arranging things so that the failure of any single control is survivable, because eventually one of them will fail most likely the one you were confident about.


Originally published at shakurshirajul.com.

Top comments (0)