Investigation·Revenue Infrastructure

A Clean Malware Scan Doesn't Tell You Whether the Site Will Stay Clean

A Clean Malware Scan Doesn't Tell You Whether the Site Will Stay Clean

The cleanup was real. The verification was correct. The site was reinfected in four days.

·16 min read·Positioning · Pipeline · Attribution

A corporate gifting company ran its website on WordPress, hosted with Hostinger, and used Google Ads to bring in enquiries. Google flagged the site as a Compromised Site and switched the ads off. No ads, no leads, until the site could prove it was clean.

So we cleaned it by hand. Four separate security checks confirmed the site was clean afterward. Google agreed and turned the ads back on.

Four days later the site was reinfected.

Every one of those four checks was right when it ran. This is how that's possible, what it took to find the two things none of them could see, and the moment, while writing this, we caught ourselves making the same mistake a third time.

TL;DR

A compromised WordPress site was cleaned by hand, confirmed clean by four separate security tools, and reinfected four days later. Every check was right when it ran, they just weren't answering the question that mattered.

  • Four tools confirmed the site was clean, and all four were correct. Each answered "is there spam on the site right now?", not "is there still a way to put spam back?"
  • A scan reports state; a reinfection needs access. The malicious files were deleted and stayed deleted. What stayed open was the route back in, and no scanner checks for that.
  • Two open doors no scan could find: a folder disguised as a plugin holding a publicly downloadable file with the site's live database credentials in plain text, and spam cloaked inside the page's own stored content (shrunk to 2×2 pixels), invisible in the editor and deliberately skipped by search-and-replace tools.
  • The evidence was sometimes right there and still missed. A scan summary flagged 290 customer accounts on a site that sells nothing, a clear signal of compromise, read past because none were administrators.
  • The useful question was never "is it clean?" It's "what would have to be true for this to happen again?" A checklist confirms the current state; an investigation asks why the problem is happening at all.

What actually happened

The Google Ads flag and the shutdown happened before this timeline starts, alongside a full white-screen crash we fixed by restoring a clean database backup. This picks up from the moment the site was cleaned.

The site's white-screen crash from before cleanup began.

5 July. Cleanup complete. We deleted every piece of malicious code we could find, replaced WordPress's core files with fresh copies, moved the server from PHP 8.0 to 8.3, searched 112 database tables for known spam, and removed the inactive plugins. We switched off expose_php, which tells anyone who asks which PHP version is running, a shortcut straight to that version's known vulnerabilities. We added four security headers: X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security, Content-Security-Policy. A remediation report went out the same day.

The PHP upgrade, 8.0 to 8.3.
The security headers recommended by Sucuri, added the same day.

6 July. Google Ads approved the campaign, under the same policy that had been rejecting it a day earlier.

7-9 July. Ads ran. 448 impressions, 48 clicks, 10.71% click-through rate.

9 July. A scan flagged malicious links on Home and Contact Us. The same pages. The same spam.

13 July. Google Ads disapproved the campaign again. Compromised site.

Google Ads, the second time: 'Not eligible: Compromised site.'

Read that sequence looking for the mistake and you won't find one. The malicious files were gone and they stayed gone. The verification was correct. Google's approval was correct, its ad-review scanner is the strictest check in this story, and it had been catching this site right up until it didn't.

Every check passed because every check should have passed.

Why a clean scan doesn't mean a site is secure

A fever breaks and the thermometer confirms it. Normal temperature, right there in the numbers. Two days later the fever is back.

The thermometer wasn't wrong either time. It read normal when it read normal. It just isn't the examination. It reports one measurement accurately, and everything it doesn't measure stays exactly where it is.

Every security tool in this story is a thermometer. Four of them, independently, all accurate, all answering the same narrow question, and there is a second question that sounds almost identical to the first:

Is there spam on this site right now?

Is there still a way for someone to put spam back?

Every tool here answers the first. None of them answers the second.

What each tool actually measured

Here's what each one told us, in the order we trusted them, and what each was actually answering.

Better Search Replace searched 112 database tables for a known spam phrase and found it in zero. True, the phrase wasn't there in plain text. That's the whole question it answers: whether one string exists in the parts of the database it will read.

Better Search Replace: 112 tables searched for the spam phrase, zero matches.

Sucuri SiteCheck wouldn't run at all. It returned 403 Forbidden, blocked before it could check anything. Once we cleared the block it scanned and came back clean, correctly, for that moment. Getting there cost most of a day, and it has its own section below.

VirusTotal said malicious. It was accurately reporting what dozens of other security vendors' blocklists said, and their data was two weeks old, visible on the timestamp. It was right about what those lists said. It said nothing reliable about that day.

Wordfence flagged an infected file, and the file really did contain what Wordfence said. It was sitting inside a cache folder, a saved-for-speed copy of a page, not something being served to visitors, generated by a caching plugin called WP-Optimize. The leap from "this saved copy is bad" to "the site is serving spam right now" was ours. Wordfence never claimed it.

Google Safe Browsing reported no unsafe content. True, and a different system from the one reviewing the ad account, a distinction that cost us a full round of appeals to learn.

Google Safe Browsing: 'No unsafe content found', a different system from the one reviewing the ads.

Google Ads review approved the campaign. Highest authority in the stack, the only check the business actually felt, and correct: the scan passed at the instant it ran. Ninety-six hours later it was worthless.

ToolThe question it answersThe question we assumed it answered
Better Search ReplaceDoes this exact phrase exist in the database?The spam is gone
Sucuri SiteCheckCan my scanner reach your server?The site is infected, or not
VirusTotalWhat did other blocklists say, as of their last update?The site is dangerous right now
WordfenceDoes a file on disk match a known bad pattern?There's malware on the live site
Google Safe BrowsingIs this address on our public blocklist?Google has cleared the site
Google Ads reviewDid the scan pass at the moment it ran?The site is secure

Six instruments. Six different questions. None of them the one we needed answered.

The right-hand column is what travels. Swap these tools for QA testing, a compliance audit, or a bank reconciliation, and it reads the same.

Wordfence flagging the file inside the cache folder, a saved copy, not the live page.

The scan we worked hardest for

Sucuri kept returning 403 Forbidden instead of a result. Our theory: something on the site was blocking the scanner. So we tested it one variable at a time.

First suspect, Wordfence's firewall, which blocks bots it doesn't recognise. We switched it off, you can't read an instrument through a wall that's stopping it. Still 403.

Second, Hostinger's CDN, so the scanner would read the live server rather than a cached copy. Still 403.

Third, leftover cached files. Cleared. Still 403.

Fourth, a rule inside .htaccess, the file that controls redirects and access permissions. We renamed it and let WordPress generate a clean one. The 403 disappeared.

Every one of those steps was reasonable in isolation. Anyone watching over our shoulder would have nodded at each one. Look at what the sequence did: over a few hours, we stripped a live compromised site of its firewall, its CDN, its caches and its server access rules, not carelessly, but because each was a plausible reason the scanner couldn't see straight.

The answer was Hostinger blocking Sucuri's bot at the server level, as routine anti-abuse. Not malware. Not a symptom. A fact about whether Sucuri could get through the door, unrelated to what was happening inside it.

And when the scan finally ran, it came back clean, and that was true. Sucuri really could reach the server. It really did find no malware.

We thought that answer was sufficient.

Nothing careless in any of it. The reasoning held at every step. It was aimed at "is there spam on this site right now," and no amount of care spent on that question buys you the other one.

The cost wasn't any single call. It was hours of work, and a stack of protections lowered on an infected site, to extract a true answer to a question that didn't matter, because a scanner's silence had started to feel like proof. It was a fact about a bot.

"Sucuri says there's no malware" got treated as "the site is clean," which is what happens whenever a stand-in measurement gets trusted as the real thing, a failure that shows up far outside security.

Sucuri returning 403 Forbidden, blocked at the server before it could scan anything.

We searched every known hiding spot. Inside WordPress.

After the second disapproval we stopped resubmitting appeals and asked Google Ads support a different question: not look again, but tell us the exact URL your scanner flagged. Support replied with the specific address. One reply did more than four rounds of automated re-review, because it asked for evidence instead of a verdict. We removed the link the same day. It closed one door and told us nothing about how many others were open.

Then the links came back, so we hunted the thing putting them there. We checked every place a WordPress reinfection is known to hide.

  • Hidden rogue admin accounts: none.
  • functions.php, the theme file that runs on every page load and a favourite place to hide re-injecting code: clean.
  • All 58 scheduled background tasks, the timed jobs WordPress runs on its own: every one tied to a legitimate plugin.
  • mu-plugins, a folder of plugins that run automatically and never appear in the plugin list: not present.
  • Every file in the active theme: untouched, timestamps consistent with a three-year-old install.
  • WordPress core: already reinstalled from verified copies.

Nothing. Repeatedly, over days.

Every file in the active theme, timestamps unchanged, consistent with a three-year-old install.

It's tempting to say there was no injector. That isn't true, and the precision matters here more than almost anywhere else in this story: someone writing spam into the database from outside WordPress is still an injector. There was no injector inside WordPress. Every check was correct, the search was thorough, and we ran it entirely inside a building the attacker had stopped entering.

"We still haven't identified the injector" was the right observation. It was the wrong place to stop.

The two things no scanner could find

A file containing no spam

Inside wp-content/plugins, where every real plugin lives, sat a folder matching no known plugin. No structure, no description file, nothing a plugin has. One plain text file inside it.

The disguised folder sitting among the real plugins, no structure, no description file, nothing a plugin has.

That file held a live, unencrypted copy of the site's database name, username, server address and password. Real working credentials, in plain text.

We confirmed it wasn't merely sitting on the server. A direct request to its web address returned the file's contents: HTTP 200, no login, no barrier. Anyone who found the URL could read it.

It's tempting to introduce this file as the explanation for everything, the piece that closes the loop. It doesn't need to explain anything to be damning:

When we found it, days after four separate tools had told us the site was clean, it was still publicly downloadable.

That's not an inference. It's a request and a response code. Whether anyone walked through the door is unproven, and it doesn't change the point: the door was open, in public, while every check said otherwise.

We proved the credentials existed, that they were public, and that they were dangerous. We didn't prove anyone used them. It remains the strongest available explanation for spam being rewritten with no mechanism left inside WordPress, and it's a finding either way.

No scan would have surfaced this. Every hunt to that point had searched for a known spam domain or a known code pattern, and the file contains neither. It matched nothing anyone had a reason to search for. We found it by opening every plugin folder and asking a different question, not where is the thing I already know about, but what shouldn't be here at all.

The same question at the web root: folders named css, fr, online and slot-5k, none of which belong on this site.

We deleted the folder, rotated the database password, and reset every admin account's password alongside it.

This site takes no payments. It's a catalogue, not a checkout. So what sat exposed was a list of products. Had the same site processed orders, the same file (same folder, same plain text, same public address) would have handed customer names, addresses and order history to anyone who found it. We don't get to claim harm we didn't suffer. What limited the damage was a decision nobody made deliberately: whether the checkout was switched on.

Content the editor can't display

On Contact Us, spam sat directly in the page's stored content, immediately after the legitimate Maps embed, wrapped in a styling rule that shrank the block to two pixels by two pixels and hid the overflow. Invisible to a visitor. Invisible in Elementor too, the page builder's own layout panel showed a clean page tree with no such block, because the content was cloaked, not merely small.

Elementor's layout panel: a clean page tree, nothing out of place.
The same page's raw database row: the cloaked block, right after the Maps embed.

Two blind spots stacked, and neither tool malfunctioned.

The editor won't render hidden content, so the obvious fix (open the page, search the visible text, delete anything suspicious) finds nothing and confirms a page that isn't clean.

And search-and-replace tools deliberately skip page-builder data, because Elementor stores an entire page's design as one encoded block and a careless replacement inside it breaks the layout. So the sweep that reported zero results was correctly declining to look in the one place the spam was.

Neither tool's documentation mentions this, because it isn't either tool's problem. The blind spot is the intersection.

One more thing: each cloaked block held a dozen or more domains at once. Removing the one Google named left the rest. The next scan flags the page again, and it looks like a fresh reinfection when it's the same block minus one link.

Then we did it again

This is harder to admit than the exposed file, because it didn't happen after we understood the lesson. It happened while we were hunting the reinfection.

The question on the table, out loud, in writing, was: how do they keep getting back in? That's the structural question. We were asking it.

Into that conversation, a Wordfence scan header reported: Users Checked: 290.

It wasn't buried. It was flagged, with the risk spelled out: WordPress publishes an author archive page for every registered account, so hundreds of accounts means hundreds of pages that can carry injected links.

The reply was:

"None of those are administrators, so it would be fine, right?"

That's a state check. Are any of these dangerous right now? No. Move on.

The other question was one sentence away and never got asked: why does a site with no checkout have 290 customer accounts at all?

There is no answer to that question that's fine. Nobody can buy anything on this site. There's no legitimate reason for a single such account to exist, let alone 290. The number wasn't in an obscure log. It was printed at the top of a scan report, highlighted, and we read past it.

Did the evidence surface on its own?What we did with it
The credentials fileNo, and no scan could have shown itNever generated the evidence
The signup formNo, but one click into settings would haveNever opened the panel
The 290 accountsYes, printed in a scan summary, flagged, risk explainedRead past it

What would have to be true for this to come back?

The useful question was never is it clean? That question has an answer, the answer was yes, four times, and every one of those answers was correct.

The question is: what would have to be true for this to happen again?

If nobody can answer that, nothing has been verified. A temperature has been taken.

Every tool in this story told us what was true right now. Not one of them had anything to say about what could be true tomorrow.

That isn't a WordPress problem or a security problem.

"The bug's fixed", verified how? That it stopped reproducing, or that the condition producing it is gone?

"Tracking's working", working now, or working for the visitor who leaves after two seconds, before the tracking fires?

"The migration's done", done, or done until the first edge case?

"The campaign's approved", approved, or safe?

Every one is a snapshot being read as a guarantee. The check isn't wrong. The reading is.

A checklist inventories what's true this instant. An investigation asks why the problem is happening at all. Four scanners were a checklist. It took a second disapproval before any of this became an investigation, the same distinction we draw about audits, in a different domain.

What this doesn't tell you

We never established how the attacker first got in. Three candidates came up: an outdated plugin with a known vulnerability, a guessed or reused password, and nulled software with backdoors baked in. None narrowed to a finding. Neither door we found is the entry point either: planting a file inside wp-content/plugins requires write access that an open signup form doesn't grant.

Two doors stood open at once, and we found neither by searching. We left one. The attacker built the other and we failed to remove it. Both sat open for roughly two weeks while every check came back clean, because neither is a state. Scanners check state. These were access.

We don't know whether there's a third. If two doors stood open and invisible for a fortnight (across two investigations, four scanners and a remediation report), the honest base rate for a third isn't zero. We watched a small version of this play out mid-investigation: we disabled blog comments to stop comment spam, and within days the same automated spam started arriving through the contact form instead. Closing a route doesn't remove the motive. We are not claiming this site is secure. We're claiming we closed the two doors we found.

We can't promise it won't recur. Two premium components on this install are stuck on outdated versions because nobody can locate the licence to update them. That was an open door on 5 July. It's an open door today.

We didn't prove the credentials were used. Stated plainly one more time, because it's the point: the strongest available explanation for something is not a proven one.

None of this means the scanners were a waste of time. Every one of them did exactly what it was built to do, correctly, every time. The conclusions drawn from them were ours. That difference is the difference between an account of what happened and a piece blaming the tools.

This isn't a security methodology. We're not a security firm. This is what happens when you ask an instrument for a verdict it was never built to deliver, and treat its silence as a yes.

One more thing we found while writing this

While writing this piece, we noticed the storefront's account signup form was still switched on, on a site that sells nothing directly. We don't know whether it played any role in this incident. We turned it off anyway.

An investigation like this doesn't have a finish line. You get less wrong, one door at a time.

A real diagnostic isn't a checklist confirming everything currently looks fine. It's the work of asking what would have to be true for a problem to be gone, and then checking that specific thing.

Frequently Asked Questions

Does a clean malware scan mean my website is secure?
No. A scan reports whether known-bad content is present at the moment it runs. It says nothing about whether someone still has a way to put that content back.
Why did Google approve my site's ads and then disapprove them again days later?
Approval reflects a scan passing at one moment in time. If the way the site was originally compromised is still open, it can be reinfected within days and flagged again.
How do I get Google Ads to tell me exactly what's flagged, instead of a generic "compromised site" message?
Don't just resubmit for automated review. Contact Google Ads support and ask specifically for the malware payload or the exact URL path their scanner flagged. A request for evidence tends to get a specific answer where an appeal gets a generic rejection.
Why do cleaned WordPress sites keep getting reinfected?
Usually because the cleanup removed the visible content but not the access that allowed it. If a database login has been exposed, an attacker can write spam directly into the database from outside the site, with no file or script on the server for a scanner to find.
What can a database search-and-replace tool not find?
Anything you don't already know to search for by name, and anything stored inside a page builder's encoded data, which these tools often skip deliberately to avoid breaking layouts.
Why couldn't the spam be seen in the page editor?
Content can be shrunk to an invisible size or moved off-screen. It still exists in the page's stored content, but the visual editor's layout view never displays it.
Why did spam move from blog comments to my contact form after I disabled comments?
Disabling one route doesn't remove the motive, only the route. Automated spam moves to the next open public input on the site, and a contact form is a common next target.
How do I know if a cleanup actually worked?
Ask what would have to be true for the problem to come back. If the only honest answer is "the scan came back clean," that's a temperature reading, not a diagnosis.

References

  1. Google. Compromised Site policy