WordPress ClickFix Reinfection Case Study: A Stolen Login, a Forgotten Admin, and a Spoofed Domain

img

A small law firm’s WordPress site had been professionally cleaned more than once, and the ClickFix malware kept coming back within days. There was no self-healing virus underneath this one though. Rather, it was just a person with a valid password walking back in through the front door. Here’s how I found the real root cause, locked the attacker out for good, and cleaned up the reputation damage without taking the site offline.


A Cleanup That Never Held

The message came through Upwork. A web developer who looks after the website for a small US law firm had a problem he couldn’t get rid of: the firm’s WordPress site kept getting infected with a fake “verify you are human” prompt that quietly poisoned their clipboard. The host’s malware team had cleaned it (more than once) but each time, within a few days, it came back.

By the time the developer reached me, the trouble had spread well past the website. Google had begun showing a red “dangerous site” warning to anyone who tried to visit. The firm’s own email address was reportedly turning up in clients’ inboxes sending messages nobody at the firm had written.

Before we started, the developer asked for two things: a 30-day guarantee that the site would remain free of reinfection, and a plan for what would happen if I could not find a persistence mechanism. Both were reasonable. I agreed that if the site was reinfected through the same or a related vector within 30 days of my sign-off, I would re-investigate and remediate it at no additional cost, provided the security changes stayed in place, nobody installed nulled software or reintroduced the problem, and I was notified promptly if anything looked wrong.

I also explained that failing to find an active backdoor wouldn’t leave us at a dead end, it would simply change the investigation. If nothing on disk or in the database was restoring the malware, I would pivot to the other ways an attacker could keep returning: stolen credentials, an exposed login, or a vulnerable component being exploited again. Either way, the job ended with credentials rotated, known exposures closed, the site confirmed clean, and a written account of what I found and did. The brief wasn’t merely to find a clever persistence mechanism but to identify the route back in and make the cleanup hold.

Anyway, I had SSH access to the server, a managed WordPress install on a Cloudways-hosted droplet, and the host’s own incident reports from the previous cleanups. That was a good place to start.


What ClickFix Actually Does

It helps to know what was being served because it shapes where you look.

ClickFix is a social-engineering payload, not a traditional drive-by. The injected code overlays a page with a convincing “verify you are human” or “fix this error” panel, the kind of thing people have been trained to click without thinking. The trick is in the instructions…it tells the visitor to press a keyboard shortcut and paste, and it has already loaded a malicious command onto their clipboard. The victim runs it themselves on their own machine which sidesteps most browser protections because the browser never delivered the malware. The site is only the lure.

That detail matters for an investigation. The malware isn’t sitting in a file being served to browsers in the usual sense. The injection is small, it lives where it can reach every page, and the real prize for the attacker is persistence: the ability to keep re-injecting after someone cleans it up. So persistence was what I went looking for.


Reading the Host’s Own Reports

The previous cleanups had been thorough on paper. The host’s reports described a genuinely nasty setup: a backdoor planted in the WordPress options table acting as a credential harvester and re-infector, dozens of obfuscated JavaScript payloads, and dozens of rogue plugin “stub” directories that according to the report regenerated every six hours. Their team had backed up the database, removed the malicious rows, deleted the rogue files, and verified the homepage rendered cleanly.

Two words in that report told me where the real problem probably was: “self-healing.” Something that regenerates every six hours isn’t a static infection…that implies a generator, a piece of code or a scheduled task that recreates the payload on a timer. If the cleanups kept removing the output but the generator survived, that alone would explain the cycle.

The reports also mentioned that all the evidence had been preserved under a folder in /tmp. That would have been gold but it was long gone, cleared out by a reboot weeks earlier so the live system and its logs were going to have to tell the story themselves.


The Site Wasn’t As Clean As Everyone Thought

The first thing I did was list what was actually on disk. The plugins directory held the usual, recognizable names, the theme’s companion plugins, a form plugin, a caching plugin, a security plugin. And then a cluster of directories that didn’t belong: names built from generic technical words with a short random suffix, things like site-render-booster-3c6a, ultra-query-engine-64d8, page-database-scanner-5676…eleven of them altogether. Every legitimate plugin had a normal name and everything with a four-character hex tail matched the exact naming convention the host’s report had attributed to the malware.

So the site wasn’t clean…it still had eleven rogue plugin directories sitting in it, except…

When I opened them each one held a single PHP file and every one of those files was empty so these were husks, and the site’s list of active plugins still contained an entry for one more rogue plugin whose folder had already been deleted, a dangling reference pointing at nothing.

That combination was the first real clue. The malicious code wasn’t in these files, it had lived in the database and the files were just markers left behind, either scaffolding the generator built before filling them or debris the cleanups had half-removed. The dangling active-plugin entry was worse than debris. Leaving it in place meant that if the attacker ever dropped that one folder back onto the server, WordPress would load it automatically with no activation step required. It was a hook waiting for a re-drop.


But the Generator Was Gone

If the site was self-healing, the generator had to be somewhere so I went looking for it properly.

WordPress core verified cleanly against the official checksums. The plugins that could be checked against the official repository verified too, and the premium ones that couldn’t be checked showed no injection signatures when I scanned them. The uploads folder, the theme, and the must-use plugins directory held no web shells, no eval of encoded input, none of the usual backdoor patterns. The database was clean of injected content: no malicious options, no rogue scheduled task rebuilding anything, no leftover application passwords, no active login sessions belonging to an intruder.

This was the part that reframed the whole case. The self-healing generator wasn’t there so whatever had been recreating those stubs every six hours had stopped, most likely broken by the last cleanup which had removed the loader along with the output. The husks were dated to a stretch in June and nothing newer had appeared.

So if the automated re-infector was dead, and the site was genuinely clean of active malware, why did the developer say it kept coming back? Something was still walking in. It just wasn’t a file.


The Users Table Had One Account Too Many

I pulled the list of administrators. There were the two I expected (the firm’s owner and the developer) and there was a third… an account named root with a placeholder email address registered earlier than either of the legitimate admins.

What made it interesting was that it had no role. In the normal Users screen and in the site’s public data, it didn’t register as an administrator at all. The reason was buried in how WordPress stores permissions. This site used a custom database table prefix (a common hardening step) and every legitimate account’s capabilities were filed under that custom prefix. The root account’s administrator capability was filed under the default wp_ prefix instead. WordPress reads roles from the current prefix so it simply didn’t see this account’s role. The account was invisible in every standard interface, including the public user-listing endpoint that had been quietly leaking the real admin usernames to the internet.

It could still log in, and it had; its saved session history showed logins from France and Indonesia on dates that matched nobody at a US law firm or its developer.

A hidden administrator account with a fake email, foreign logins, and a name like root looked exactly like a planted backdoor. I wrote it up that way.


A Wrong Turn, Corrected

I was partly wrong, and the client is the one who corrected me.

When I flagged the root account as an attacker-planted hidden admin, the firm’s side came back with context I didn’t have: that account had been created when the site was first built years earlier by the original developer, and had simply never been removed when the newer admin accounts were added.

That actually fit the evidence better than my first read. The odd wp_ prefix on its permissions wasn’t attacker trickery but rather the fingerprint of an account that predated the database-prefix hardening and never got migrated with the others. The “hidden” quality was an accident of history, not a disguise.

But the correction sharpened the finding rather than dissolving it. An old, forgotten administrator account with a stale password, unmonitored and unused, is one of the most reliable ways attackers establish a foothold. The foreign logins were still real and still unexplained by anyone legitimate. Whether or not it was where they first got in, it was a live key that the firm didn’t know it still had so it needed to go regardless. And the timeline with those overseas logins landing months before the reinfection wave the developer had called me about suggested this quiet, leftover account was the likely original point of entry.

I mention the misread deliberately. The honest version of this work includes the moments the first theory is wrong. The point of the investigation is that the evidence, and sometimes the client, keeps you honest.


The Logs Told the Whole Story

The account explained access and it was the server logs that explained the reinfection cycle completely.

I pulled every successful login the access logs still held and lined the timestamps up against the moments the rogue plugin folders had been created on disk and they matched one for one. Over roughly a week in June, each rogue plugin folder was created within seconds of a successful administrator login from a fresh IP address. A login, then a folder. A different login from a different address, then another folder. The single address that logged in repeatedly turned out to be the developer, doing legitimate work. Every one-time login lined up with an infection event, and every one came from a different datacenter or VPN address, a new one for each visit, which is how you dodge IP blocking.

Then I pulled one of those sessions in full, request by request, and watched the technique play out. Log in, try to open the security plugin’s settings, and get blocked. Then the ordinary WordPress workflow: open the “upload plugin” page, post a plugin zip file to the installer, and activate it. That was it, that was the whole attack…no exploit, no clever payload, no vulnerability; the attacker was logging in as an administrator with a valid password and using WordPress’s own built-in feature to upload a plugin, and that plugin re-injected the ClickFix code.

Every reinfection the firm had suffered was a person logging in and clicking upload.

There was one more detail in the logs that mattered. After the last host cleanup, one of those login attempts had started failing. A password had changed somewhere along the way and one of the attacker’s keys had stopped working which is why the site had been quiet in the weeks just before I arrived, but the failed attempts kept coming all through the following weeks from the same infrastructure. If any other harvested credential still worked or they guessed a new one the cycle would resume.

That’s why every previous cleanup failed. They removed the malware, which was the symptom, and never changed the credentials, which were the key. The intruder didn’t need a backdoor because the whole time he simply just had a login.


Changing the Locks

Once the mechanism was clear, the remediation was straightforward and I did it on the live site without an outage. Evidence first, then removal, then the part that actually mattered: invalidating every way back in.

Preserved evidence. Exported the hidden account and its metadata, archived the rogue folders, and saved the before-state, on the server and off it, before deleting anything.

Removed the artifacts. Deleted all eleven empty rogue plugin folders and purged the dangling active-plugin entry that was waiting to auto-load a re-dropped folder.

Deleted the forgotten account. Removed the root administrator entirely, reassigning its (nonexistent) content to the firm owner.

Changed the locks. Reset both legitimate administrators’ passwords, regenerated all of the site’s secret authentication keys, and destroyed every active session. The moment that ran, every stolen password and every saved login the attacker might have held stopped working at once.

Rotated the rest of the secrets. Coordinated a rotation of the database password with the host panel, and verified the site reconnected cleanly.

Closed the front-of-house exposures. Blocked the public endpoints that had been leaking administrator usernames, and added a set of modern browser security headers across the whole site, delivered through a single small must-use plugin so nothing in the existing configuration was disturbed. I verified the headers were present on cached pages, dynamic pages, and redirects, and confirmed the site still worked exactly as before.

The whole sequence is reversible in the sense that mattered to a working law firm: nothing about it could take the site down, and nothing about it changed what visitors saw. It only changed who could get in.


The Email Was Never the Site

That left the most alarming symptom to the firm’s owner: his address apparently emailing his own clients.

The site sends its transactional mail through a third-party service so I pulled that service’s complete sending logs across the entire incident window and read every message. All of it was legitimate: contact-form replies, system notifications, weekly summaries. There were no mass sends, spam, unusual recipients, and none of the bounce and complaint patterns you would see if the account were being used to blast a client list and so it was safe to say the sending key hadn’t been abused.

This meant the impersonation wasn’t coming from the website at all but was rather external spoofing. Anyone on the internet can forge a “From” address, and this domain had almost nothing stopping them: no SPF record at all and a DMARC policy set to take no action. Under those conditions, a forged message claiming to be from the firm sails straight through.

The fix lived entirely in DNS. I added a correct SPF record authorizing only the firm’s real mail providers, confirmed DKIM signing was in place for both the mailbox provider and the transactional service, and stood up DMARC. That combination tells receiving mail servers to reject anything that forges the domain. It also happens to matter for the site because the domain’s own reputation had taken damage and legitimate mail carrying links to it was at risk of being filtered.

I put the anti-spoofing policy into monitoring mode first, which is the responsible order. Before you tell the world’s mail servers to start rejecting anything that fails the check you want to see who is actually sending under the domain’s name so you don’t blindside something legitimate you had forgotten about. The reports came back clean and that cleared the way to tighten the policy from watching to actively rejecting.


Blocked on Reputation

Because the site had spent weeks flagged as a malware host, I checked the domain and the server against the major reputation and email blocklists, dozens of them.

The server’s own IP address came back essentially clean. The one or two lists it appeared on were policy and provider-level listings, the kind that flag entire hosting networks regardless of the individual site and they had no bearing on this case. The domain was a different story: it was listed on two significant blocklists, flagged specifically as a compromised and phishing-associated domain. That was expected as it was the reputation scar left by the infection and it’s exactly why clearing the malware is only half the job.

With the site genuinely clean and secured, those listings could finally be cleared. I submitted the delisting requests which the lists honor once a site is verified clean and no longer serving anything malicious, and started the review to lift the browser warning. Within days the domain dropped off the blocklists and came back clean.

Reputation recovers on a delay though and not every part of it moves at the same pace. Deliverability testing told the fuller story. Mail from the domain now passes every authentication check at the major providers, and both Gmail and Proton deliver it straight to the inbox. Microsoft (arguably the strictest of the large providers) still routed a test message to the junk folder even after the public blocklists had cleared because it keeps its own private reputation history and forgives a recently compromised domain slowly; nothing in a DNS record fixes that one, it heals with time and ordinary, legitimate sending. That’s the long tail of an incident like this: the malware comes out in an afternoon, and the reputation takes weeks to fully mend.


What Was Actually Underneath

The interesting thing about this case is what wasn’t there.

There was no ingenious self-healing virus, no cleverly hidden web shell. By the time I arrived the automated re-infector the earlier reports had described was already dead. The thing that kept a law firm’s website reinfected for weeks…that got it flagged as dangerous by Google and dragged its domain onto phishing blocklists…was a person with a valid administrator password and a forgotten account nobody had closed. The malware was downstream of that, it was the thing the attacker installed once he was inside, over and over, because getting inside cost him nothing.

That’s why the cleanups never held. You can’t clean your way out of a credential problem. As long as the key works, sweeping up the mess just resets the board for the next visit. The fix was changing the locks, removing the forgotten key, and closing the exposures that made the site easy to walk into in the first place.

The site is clean and verified, the intruder’s access is gone, the admin list is down to the two accounts that belong there, the secrets are rotated, the domain is off the blocklists, and its email is protected against being forged. The last of the reputation damage is healing on its own on the slow clock these things run on. This site had been cleaned before but this time, the reason it kept coming back is finally gone.


Stonegate Web Security remediates compromised WordPress sites without taking them offline, finding the root cause instead of sweeping the symptoms, and proving the fix holds. If your site has been “cleaned” before and the infection keeps returning, that repeating pattern is usually a sign that the real way in was never closed. That’s exactly the problem I specialize in.


Related Reading