Your site shows pharmacy spam to Google and not to you
Search your own domain and the results advertise Viagra, Cialis or Tramadol under your page titles. Open the same pages in your browser and everything looks right. That gap is the infection working as designed, and one curl command reproduces it.
Checked against a live WordPress install on
.
What you are actually looking at
The symptom arrives from outside. A customer mentions it, Search Console
posts a security issue, your host opens a ticket, or you search
site:example.com and find titles you never wrote. The drug
names vary and the shape does not: your domain, your URLs, someone else's
pharmacy.
This family comes in two shapes and a site can carry both.
Doorway pages. New URLs that were never part of your site, each
stuffed with long-tail drug keywords, each pushing search traffic on to
a shop selling counterfeit prescription medicine. Sucuri found this
specific doorway malware on 5.04% of the compromised sites its team
remediated in 2023.
Injected content on pages you did write. Your titles, your meta
descriptions and your post bodies pick up keywords and links. The links
are pushed out of sight with CSS rather than deleted, so the page reads
normally to a visitor and carries fifty pharmacy links to a crawler.
Sucuri's remote scanner flagged hidden content of this kind on 114,318
sites in 2024.
Both shapes rely on cloaking. The injected code inspects the request
before it answers: the User-Agent header, the
Referer header, sometimes the client IP address, and often
whether a WordPress login cookie came with the request. Fail those tests
and you get your real page. Pass them and you get the spam.
This is why looking at your own site proves nothing. You are logged in,
your browser announces itself as a browser, and you arrived by typing the
address rather than by clicking a search result. Google documents the
same trap from the other side: the owner "might be shown an empty or HTTP
404 page which would lead the webmaster to believe the hack is no longer
present", while search engines are still served the spam.
Nobody is attacking your visitors here. The payload is keywords and
links aimed at a crawler. Your search rankings and your domain's
reputation are what this costs you.
Prove the cloaking in one command
Ask your own server for the page while pretending to be a crawler, then
ask for it again as a browser, and compare. Everything you need is curl.
bash
# What a browser gets.
curl -s https://example.com/ > browser.html
# What a crawler gets.
curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://example.com/ > crawler.html
wc -c browser.html crawler.html
diff browser.html crawler.html | head -40
Two files of the same size and an empty diff mean this page is not
cloaking on the user agent. Different sizes mean read the diff. Injected
spam usually arrives as one long block near the closing
</body> tag or immediately after the opening
<body>, and it will contain a domain you do not
recognise.
Some payloads key on the referrer instead, so that a visitor arriving
from a search result is treated differently from one arriving directly.
Add the header and repeat.
Run both against a handful of URLs rather than the home page alone.
Injections often sit on posts and leave the front page untouched, and
Google's advice on this family is to check the home page as well, because
it is a common target for added text and links.
What Google is holding
The curl test tells you what your server does today. A
site: search tells you what Google has already indexed.
Search for site:example.com on its own first, then narrow it
with spam terms, which is the technique Google recommends: append a term
to the query, as in site:example.com viagra, and try several.
Then fetch one of the spam URLs with the URL Inspection tool in Search
Console and use "Test live URL". That crawls the page from Google's own
infrastructure and shows you the HTML it received, which is the one check
a spoofed user agent cannot beat. It replaced the tool older guides call
Fetch as Google.
The cache: operator is gone. Google retired cached links on 1
February 2024 and the operator stopped working later that year, so any
guide telling you to run cache:example.com predates that
change. The Wayback Machine at web.archive.org is the nearest
replacement, with the caveat that it crawls under its own user agent and
may never have been served the spam at all.
Check
What you send
What a positive result tells you
Browser, logged out, private window
A normal user agent, no referrer, no login cookie
The spam is not gated at all, which is rarer and easier
curl -A with a Googlebot string
The user agent header only
The payload branches on the user agent
curl -e with a Google referrer
The referrer header only
The payload branches on where the visitor came from
site: search plus a drug term
Nothing, you are reading Google's index
Google has already indexed spam under your domain
URL Inspection, Test live URL
A real crawl from Google's own address ranges
The spam survives an IP check, which curl cannot fake
A negative curl result does not clear you. Google warns that the user
agent string can be spoofed, and the better payloads know it: some verify
the request against known crawler address ranges before they answer, so
your fake Googlebot gets your real page back. If the
site: search shows spam and every curl comes back clean, go
straight to the live test in Search Console.
Where the payload lives
A working pharma hack has three parts, and they are rarely in the same
place: the decision (who gets the spam), the content (the keywords and
links), and the persistence (whatever puts it all back). Read the
sections below as a list of places to look, not as a sequence.
PHP that branches on the request
This is the decision. It reads
$_SERVER['HTTP_USER_AGENT'] or
$_SERVER['HTTP_REFERER'], matches it against a list of
crawler names, and takes a different path when it matches. A sample
Sucuri published in January 2026 checked for Googlebot, Bingbot and
Baiduspider, pulled the spam body from a remote server over cURL, paused
for a random interval so the timing looked ordinary, echoed the spam and
called exit so the real page never rendered.
Where it sits, in the order worth checking:
The active theme.functions.php and
header.php above everything else, because both run on
every public request. Sucuri's 2016 case had one line added to
header.php that pulled in a file called
nav.php from the same theme directory.
wp-content/mu-plugins/. Must-use plugins load
automatically, appear in no plugin list a normal user reads, and cannot
be deactivated from the admin. Sucuri found spam and redirect payloads
at wp-content/mu-plugins/redirect.php,
wp-content/mu-plugins/index.php and
wp-content/mu-plugins/custom-js-loader.php in March 2025.
A stock WordPress install has no mu-plugins directory at
all, so its existence is worth a look on its own.
Core files that run on every page. The hidden-link injection
Sucuri analysed in 2020 was written into
wp-includes/general-template.php, which is included on
every request and which nobody opens.
A separate file with a name that means nothing. Sucuri's 2023
remediation notes give dehjgodb.php and
kbeheloh.php as typical. Google's write-up describes the
opposite trick, a file named to sit next to a real one:
wp-cache.php beside the legitimate
wp_cache.php.
Grep is how you find these, and the grep is noisier than most guides
admit. On a clean WordPress 7.0.4 install with nine plugins, searching
the whole of wp-content for
HTTP_USER_AGENT returned nine legitimate files, Akismet
among them. The same search across
wp-content/themes/ with four themes present returned
nothing. So start narrow.
This is where the content half increasingly lives, because a row in a
database table is invisible to anything that walks the filesystem.
Sucuri's 2024 trends report puts it plainly: "attackers are increasingly
storing their payloads within database options".
Two patterns, and they need different searches.
A row that belongs to a real plugin. The DNS TXT campaign
Sucuri tracked through 2024 wrote its PHP into
wp_options under
option_name = 'wpcode_snippets', which is a legitimate
option belonging to the WPCode plugin. The option name looks correct
because it is correct. The value is not.
A row invented for the purpose. The 2010 generation of this hack
used class_generic_support,
widget_generic_support, wp_check_hash,
fwp, ftp_credentials and a set of
rss_ names followed by a 32-character hash. Treat those as
history rather than as a checklist. They are useful as a shape,
an option whose name sounds plausible and which nothing in your install
reads, and useless as literal strings to search for in 2026.
wp_ is the default table prefix and yours may differ. Read
$table_prefix in wp-config.php before you run
any SQL from this page or anywhere else.
Spam written into wp_posts
Doorway pages are often just posts: real rows in wp_posts,
created by whatever account the attacker took over or made. Injected
links inside your own posts are the other half, and those are hidden with
CSS rather than removed from the flow. The injection Sucuri documented in
2020 used position: absolute; bottom: 0px; left: -11055px,
pushing the block off the left of the viewport. The 2024 report describes
the same idea more broadly: divs positioned off-screen with negative
values, or containers with zero height and hidden overflow.
Rewrite rules in .htaccess
On Apache, the routing half often sits in
.htaccess. Google published this example of a file altered
by an injection campaign, and the comments are Google's own:
apache
<IfModule mod_rewrite.c>
RewriteEngine On
#Visitors that visit your site from Google will be redirected
RewriteCond %{HTTP_REFERER} google\.com
#Visitors are redirected to a malicious PHP file called happypuppy.php
RewriteRule (.*pf.*) /happypuppy.php?q=$1 [L]
</IfModule>
Read every .htaccess on the site rather than the one in the
root, because these campaigns write them into subdirectories in bulk.
Telling an injected block from the one WordPress writes itself is a job
of its own, covered in
rewrite rules injected into .htaccess.
If your searches keep turning up Japanese titles rather than drug names,
you have the neighbouring campaign and a different set of markers:
Japanese text showing in your Google results
covers the doorway generator and the sitemap it publishes.
Removing it, files then database
Two jobs, in this order, and finishing one is not finishing. The file
work removes the code that decides and the code that rebuilds. The
database work removes the spam itself. A site where you did only the
files keeps serving spam from a row nobody deleted, and a site where you
did only the database is repopulated by the next request.
Keep the crawler fetch you captured. The
crawler.html file from the confirmation step is your
search key. Pull one distinctive string out of it: the spam domain, an
unusual class name on the hidden div, or a phrase from a spam heading.
A hit in a PHP file is your injector. No hit anywhere means the string
arrives from the database or from a remote server, so continue at
step 7.
Find what loads the file before you delete it. Search for the
file's own name across the tree. The 2016 case is the reason this step
exists: deleting the visible doorway did nothing because a second file
in the theme rebuilt it on the next request. Delete the loader first,
or delete both in the same minute.
Read the injected block. Do not run it. If it is encoded, decode
it into a file and open the file in an editor. Piping decoded PHP into
php to see what it does is how a cleanup becomes a second
incident.
Cut it out, or replace the whole file. A file that is nothing
but the injection gets deleted. A legitimate file with a block bolted
on gets the block removed and nothing else touched. Where the file
belongs to core, a plugin or a theme from WordPress.org, downloading
the published copy of your exact version is safer than editing by hand:
restoring modified core, plugin and theme files
covers where each copy comes from.
Check mu-plugins and drop-ins.bash
ls -la wp-content/mu-plugins/
wp plugin list --status=dropin --fields=name,title
A stock install has neither. Anything in either place loads before your
plugins do and is worth reading in full.
Move to the database. Read your prefix first. Every query below
assumes wp_. Change it to whatever
$table_prefix says.
List the largest autoloaded options. An encoded payload is
usually large and usually autoloaded, because it has to be there on
every request.
bash
wp option list --autoload=on --exclude='_transient_*' \
--fields=option_name,size_bytes --format=csv | tail -n +2 | sort -t, -k2 -nr | head -20
The --no-transients flag is ignored on WP-CLI 2.12.0, which
is why the command uses --exclude instead. Read the top
twenty names. You should recognise every one, or be able to trace it to
a plugin you installed.
Search options and posts for drug terms, with word boundaries.bash
wp db query 'SELECT option_id, option_name FROM wp_options
WHERE option_value REGEXP "\\b(viagra|cialis|levitra|tramadol|xanax)\\b";'
wp db query 'SELECT ID, post_status, post_title FROM wp_posts
WHERE post_content REGEXP "\\b(viagra|cialis|levitra|tramadol|xanax)\\b";'
Boundaries matter more than they look. A plain
LIKE '%cialis%' matched a cached WordPress.org news feed on
a clean install, because the word specialised contains the
letters c-i-a-l-i-s. On MySQL 8 the boundary marker is
\b, doubled in the commands above because MySQL parses
the string literal before the regex engine sees it and eats one
backslash of its own. That doubling is MySQL's, not the shell's. The
single quotes around the SQL mean bash consumes nothing, and in a
client with no shell at all, such as phpMyAdmin, you still type
\\b. The older MySQL 5.7 spelling
[[:<:]] fails with ERROR 3685.
Read the difference between no output and no match. On WP-CLI
2.12.0 against MySQL 8, wp db query prints nothing at all
when nothing matches. The column header appears only when there is at
least one row. Silence is a clean result.
Hunt the hidden markup separately. Spam that carries no drug
word still has to hide itself, so search for the hiding instead.
bash
wp db query 'SELECT ID, post_title FROM wp_posts
WHERE post_content REGEXP "position: *absolute.*left: *-[0-9]{3,}";'
wp db query 'SELECT ID, post_title FROM wp_posts
WHERE post_content REGEXP "(height: *0|text-indent: *-[0-9]{4,}|display: *none)";'
The second query can return legitimate posts too, because
display: none is ordinary in page-builder output. Open
what it finds rather than deleting on the strength of a match.
Read a suspicious option before you delete it.bash
wp option get suspicious_option_name > option-dump.txt
wp option delete suspicious_option_name
Keep the dump. If the site misbehaves afterwards you will want to know
what was in the row, and if you report the incident anywhere the dump
is the evidence.
Edit spam out of posts by hand. A blind find-and-replace across
the database will happily rewrite a legitimate post that mentions the
same string. If you use one, run it dry first and read the count.
bash
It ends with a line reading "Success: N replacements to be made." That
number is the number of rows you are about to change.
Delete the doorway posts rather than unpublishing them. A post
moved to draft is still a row, and step 5 is what stopped anything
republishing it. Empty the trash afterwards.
Re-run the confirmation. Fetch the page as a browser and as a
crawler again. Byte-identical output is the pass condition. If a page
cache or an object cache sits in front of WordPress, flush it first, or
you are grading yesterday's answer.
Closing the way in
Everything above removed the payload. None of it removed the reason the
payload could be written, and this family reinfects fast because the
spam is the business model rather than the goal. Two entry points account
for most of it.
An outdated plugin or theme. Sucuri found that 13.97% of
the sites it cleaned in 2023 still had at least one plugin or theme with
a known vulnerability at the moment of remediation. Update everything,
and delete what you do not use rather than leaving it deactivated,
because a deactivated plugin's files are still reachable over HTTP.
An account the attacker controls. 55.2% of the sites
Sucuri found database malware on had at least one malicious administrator
account. Rotate every administrator password, rotate the database
password and the FTP or SSH credentials, and read the user list rather
than trusting it looks familiar.
bash
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
wp option get users_can_register
wp option get default_role
wp cron event list --fields=hook,next_run_relative
Read the cron list carefully. Reinjection is often scheduled: a hook with
a name that matches no plugin you have installed, running every few
hours, is the thing that will put the spam back next Tuesday. If it does
come back and you cannot see why,
the reinfection guide
works through the places a persistence mechanism hides.
Two changes worth making while you are in there. Add
define( 'DISALLOW_FILE_EDIT', true ); to
wp-config.php, which removes the theme and plugin editors
from the admin and takes away the easiest way to write PHP through a
stolen login. And stop PHP executing inside
wp-content/uploads, which is where droppers land after a
file-upload flaw.
Getting the results back
Cleaning the site does not clean the search results. Google keeps the
title and description it last crawled until it crawls again, and a site
of any size takes days rather than minutes to work through. Once your
crawler fetch matches your browser fetch, open Search Console, read the
Security Issues report, and request a review there. Then check with a
site: search a week later instead of assuming it worked.
Let the spam URLs return 404 or 410 once they are gone. Redirecting
them all to your home page hands Google a page that resolves, which is
the opposite of what you want it to record.
Questions
Why does my site look clean in my browser but sell drugs in Google?
Because the injected code decides what to serve per request. It reads the user agent, the referrer, sometimes the visitor's IP address, and whether a WordPress login cookie is present. A logged-in owner in Chrome fails every one of those tests, so the code returns your real page. Google's crawler passes, so it gets the spam. Google calls this cloaking and says outright that the site owner may be shown an empty or 404 page while the crawler is still fed spam.
Can I still use cache:example.com to see what Google indexed?
No. Google retired the cached link in February 2024 and the cache: operator no longer works. Guides that tell you to use it were written before that. The two live replacements are the URL Inspection tool in Search Console, which will fetch the page from Google's own crawler, and a site: search, which shows you the titles and descriptions Google is currently holding. The Wayback Machine can show you what was served on a past date, though it crawls with its own user agent and may never have been shown the spam.
I deleted the spam file and it came back within minutes. Why?
You removed the doorway and left the thing that writes it. Sucuri documented exactly this in 2016: the visible file was wp-page.php in the site root, and a second file called nav.php sat in the active theme, was pulled in from header.php on every request, and recreated wp-page.php whether or not it existed. Find what includes the file before you delete the file.
Do I have to touch the database, or is cleaning the files enough?
You usually have to do both. Sucuri's 2023 remediation data puts the bulk of database malware in wp_options and wp_posts, over 70% of everything they removed from databases, and 38.3% of compromised databases they cleaned contained SEO spam, mostly concealed links for counterfeit drugs and gambling. A file-only cleanup on one of those sites removes the machinery and leaves the spam text sitting in a row.
Is searching the database for the word cialis good enough?
No, and it produces false hits in both directions. On a clean install, a LIKE '%cialis%' query matched a cached WordPress.org news feed, because the word specialised contains the letters c-i-a-l-i-s. Use a word-boundary regular expression instead. In the other direction, a lot of injected spam carries no drug word at all in the row you are searching, because the row holds an encoded blob or a remote URL that the spam text arrives from.
How long until Google stops showing the spam?
Longer than the cleanup takes. Recrawling a site of any size runs into days, and pages Google has not revisited keep their old title and description in the results. Ask for a review in Search Console once the crawler fetch matches the browser fetch, and check back with a site: search rather than assuming.
Half of what you just did was file work: find the PHP that decides who
sees the spam, read it without running it, and cut it out of a file that
still has a job to do. Segurium does that half. Every file is
fingerprinted and checked, and a file carrying an injection has the
injected bytes removed and keeps working, so a theme header that also
contains your own edits survives the cleanup instead of being replaced
wholesale. Detection is identical on the free and paid tiers, and only
the number of cleanups differs.
Finish the pass yourself. Run the option and post queries above once the
file cleanup is done, then go back to the site: search and
the live test in Search Console. A doorway page stored as a post lives at
its own URL and survives a clean file tree, and those two are what
surface it.