Essay

The Malware Asked Who Was Asking

A broken-link crawl, nine casino pages nobody could see, and a piece of malware that checked whether you were Google before deciding what to show you.

Lachy McGregor - 21/08/2026

I was not looking for malware. I was running a broken link crawl to quote a client on a website refresh, which is about the least glamorous job in this trade.

The crawl came back with 130 broken links on a site with 35 blog posts.

That number is not possible. A site that small cannot generate that many dead ends on its own. So I went looking for where the extra links were coming from, and I found nine pages about online casinos, published under an Australian business's domain, that had been live for five days.

The site had been professionally cleaned eleven months earlier. Every automated tool anyone had pointed at it since said it was fine. It was not fine, and the reason it was not fine is the most interesting thing I have worked on this year.

If you look after a WordPress site and would rather check it for this before reading on, the detection and removal guide is here: Is your WordPress site running this?

Two numbers that should have matched

Nobody spots hidden content by looking for hidden content. You spot it when two systems that should agree do not.

WordPress publishes its own list of posts through an API. It returned 35.

The sitemap that Google reads listed 45.

Counting posts per author in the admin panel accounted for exactly 35.

So nine posts existed, Google was being told about them, and the site's own management screens were not showing them to anybody. That gap is the entire investigation. Everything else came out of pulling on it.

The nine pages loaded fine in a browser. They used the site's real theme, carried its real branding, and had page titles ending in the business's name. They were not a defacement. Somebody had gone to a lot of trouble to make them look like they belonged.

Nobody was attacking this business

The instinct when you find this is that someone came for your client. Almost nothing about it was personal.

The business model is renting domain authority. This domain had been registered since around 2014, had real links pointing at it, and had a clean history, so Google trusted it. Casino affiliate pages hosted on a real Australian company rank far better than the same pages on a domain nobody has heard of. The operator gets ranking they could not buy. The owner gets a Google penalty and a reputation problem.

The code that ran it was a commercial product. It had a configuration. Options in it, the way ordinary software has options: which author IDs belong to which campaign, which kind of spam to publish, how many variants of hidden text to rotate through. There was even a setting spelled "cloacking" in the kit's own configuration schema, which tells you something about how many hands it has passed through.

It is built to be installed on thousands of sites. Not on this one.

It checked whether you were Google

This is the part I keep thinking about.

The malware injected a script that reported the visitor to a Russian web statistics service, deleted the page out of the browser, and loaded a remote script into the wreckage. Whoever owned that remote domain decided what the visitor actually saw, and could change it whenever they liked without touching the server.

But it did not do that to everybody.

Before deciding, it resolved the visitor's real IP address through the forwarding headers, then tested it against Google's officially published list of crawler addresses. Not a hardcoded list. It fetched the live one:

https://www.gstatic.com/ipranges/goog.txt

It cached that for an hour, and it did the range matching properly, converting both addresses to binary and comparing the leading bits rather than doing the sloppy string-prefix version these kits usually ship with.

If you were Google, it suppressed the payload and served you a clean, well-formed, indexable casino article.

If you were a person, it sent you somewhere else.

Sit with what that means for anyone checking the site. A scanner fetches the page and reads the HTML. It does not run the JavaScript. So whether it came from Google's address space and was handed the clean version, or came from anywhere else and was handed the version with the payload attached, what it read was a well-formed, plausible article wearing the site's own branding. The redirect only ever fired in a real browser, for a real person, who is the one audience that never files a report.

The malware was not hiding from scanners. It was giving them exactly the thing they were built to read.

Four security plugins were installed on that site. All four were deactivated. But even if every one of them had been running, they look for code that looks like malware, and this code did not. No obfuscation, no encoded blobs, every function named after a real WordPress function with a word bolted on. It read like theme code, because it was written to.

Hiding is a design discipline

The rest of it is built by somebody who has clearly done this many times.

Every page that was not one of the casino posts carried about 2.3 KB of concealed casino text injected into the page head. Roughly 226 words, three headings, eight links, rendered at 0.3 percent opacity so no human eye would ever catch it.

Which block of text appeared, and which technique concealed it, were both chosen by MD5 hash. The block came from the first two digits in the MD5 of that page's own URL, indexing one of a hundred stored variants. The concealment method came from the first digit of the MD5 of the site's hostname, indexing an array of ten options: opacity, off-screen positioning, zero height, and so on.

I verified it end to end. The MD5 of that hostname begins 6137. First digit 6. Entry six in the list of ten. That is exactly the one the live site was emitting.

Work out what that buys the attacker. Every page shows different hidden text, consistently, so it reads as organic rather than templated. And because the concealment method is keyed to the hostname, no two infected sites in the world share a CSS signature. You cannot take what you learned from one victim and grep for it on the next.

The same thinking runs all the way down. The malware's configuration was not stored in the file. It was read from the database under an option name computed at runtime from the site's own hostname:

get_option( md5( sha1( $_SERVER['HTTP_HOST'] ) ) )

That name is different on every infected site and is written down nowhere. A list of known-bad option names finds nothing.

The clean site that was never clean

The site had been cleaned in September 2025 by the agency that maintained it, and that was competent work. WordPress core was reinstalled, a malicious database entry was removed, Google was asked to re-crawl. Nothing wrong with any of it.

Here are the dates.

On 3 September the first casino posts appeared. On 4 September they were removed. On 5 September, the very next day, a second piece of code was planted. On 18 September the cleanup was reported complete. On 21 August 2026, eleven months later, I removed it.

The thing planted on the 5th was not a file. It was a row in the database, saved through a legitimate plugin whose entire purpose is storing and running snippets of custom code, and titled "Completely Disable Comments", which is a name lifted from that plugin's own list of examples.

Reinstalling WordPress does not touch it. Scanning for malware does not touch it. Comparing changed files does not touch it. Every one of those looks at files on a disk, and this was a record in a table.

What it did was watch every request. If the address contained any of about thirty gambling words, it turned a Not Found response into a valid one, replaced the page title with casino wording chosen from 105 built-in variants, overrode the instruction telling Google not to index the page, and injected the redirect.

Which means the nine casino pages were never the full extent of it. The site was manufacturing new ones on demand, for any address anyone cared to invent. That is why my original link crawl surfaced addresses that exist nowhere in the site's own records. I had been reading the output of a generator and thinking it was a list.

Stop scanning. Start comparing.

By the time I got server access I had a decent mental model of this operator, and I still nearly missed the rest of it, because I was doing what everyone does. Looking for things that looked wrong.

The thing that actually worked has no signature component at all.

WordPress is public software. Every file in it is byte-identical on every site in the world running that version. So the question is never "does this file look malicious." The question is "is this file supposed to exist":

grep 'wp_version =' wp-includes/version.php
curl -sSL -o wp.zip https://wordpress.org/wordpress-7.0.4.zip && unzip -q wp.zip
comm -23 <(cd site && find . -type f | sort) <(cd official && find . -type f | sort)

Ten minutes. 3,485 core files compared. Not one had been modified, which was not the answer I expected. But sixteen files existed that are not part of WordPress and never have been.

Twelve of them were one remote control shell split into twelve pieces. Six of those pieces hold a single variable each. The smallest is 27 bytes and its entire contents are a character set declaration. No scanner on earth flags a 27 byte file that says $qthrifeg = 'UTF-8';, and that is precisely why it is 27 bytes.

The other four were worse. A handful of lines each. They take a web address from the query string, fetch whatever program is at that address, and run it. No password. No payload stored anywhere, so nothing for a scanner to find and nothing in a backup. The shell is 70 KB of recognisable tooling. These are shorter than most copyright headers.

Then I applied the same kind of rule to the uploads folder, where no PHP file has any business existing at all, and found fourteen more belonging to a completely different toolkit. Different naming, different encoding, its own module system. It does not store its password either. It asks a remote server for the current one every time somebody tries to log in, so the operator can change access across every infected site at once and there is nothing local to attack.

Its developer comments are in Russian.

Neither of those two discoveries came from recognising anything. They came from asking a question that has a definite answer. Core files are fixed and public, so anything not upstream is an intruder. Uploads holds media, so anything executable is an intruder. Neither rule knows anything about this malware, this operator, or this year.

Signatures find what somebody has already seen. Structure finds what nobody has.

Two things worked, and nobody configured either of them

On 17 August the attackers copied the site's configuration file, the one holding the database password and the authentication keys, renamed it to .txt, and left it in a public folder ready to fetch over HTTP.

It returned 403. The hosting company blocks any request matching that filename regardless of extension.

They also could not run their uploads toolkit directly, because that host refuses every request for a PHP file in the uploads folder, whether the file exists or not.

Those are the only two preventive controls on the entire engagement that stopped anything. Four security plugins, all deactivated, contributed nothing. The things that worked were platform rules nobody on the project had chosen, configured, or knew were there.

I do not have a tidy lesson for that. It is just true, and it has changed how I think about where security actually lives in a stack.

The last thing

When I finished, I verified every removal by requesting each file and reading the status code, rather than trusting that the deletions had run.

Two backdoors were still live. I had written the delete commands, moved on to the next task, and never executed them. They had been sitting there for three hours while I updated documents describing the site as clean.

They returned 200 with an empty body. Which is correct behaviour for a file waiting on an instruction it has not been given. No error, no output, nothing to notice. Indistinguishable by eye from a file that is not there.

The only thing separating them from a deleted file was the status code, which no human ever sees. A path that never existed returned 404. These returned 200.

I had spent three days writing about malware that survives because everybody checks it the same way and it knows what that way looks like. Then I nearly shipped a clean bill of health on a site with two working backdoors on it, for exactly the same reason. I checked the way I always check, and the way I always check could not see them.

Verify by asking the machine. Not by remembering what you did.

If you think you have this on a site you look after, the detection and removal guide is here. It is the checklist version of everything above, with the queries and commands, tested on this infection.

Notes for readers: the site and the agency that maintained it are not named here on purpose. Nothing in this essay identifies either, and none of it is a criticism of the 2025 cleanup, which was competent work that failed for a reason nobody could reasonably have caught at the time. WordPress core file hashes can be compared against the official release for any version at wordpress.org. Google publishes its crawler IP ranges at gstatic.com/ipranges/goog.txt, which is a genuinely useful resource and also, as it turns out, a genuinely useful resource for the other side.