Working out what you have

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.

bash
curl -s -e "https://www.google.com/" https://example.com/ > from-google.html
wc -c browser.html from-google.html

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.

bash
grep -rn --include="*.php" "HTTP_USER_AGENT" wp-content/themes/ wp-content/mu-plugins/
grep -rn --include="*.php" "HTTP_REFERER"   wp-content/themes/ wp-content/mu-plugins/
grep -rlE --include="*.php" "eval\(|gzinflate|str_rot13|assert\(" wp-content/
find . -name "*.php" -newermt "-30 days" -printf "%T+ %p\n" | sort

Encoded blobs in wp_options

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.

  1. 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.
  2. Search the filesystem for that string.
    bash
    grep -rn --include="*.php" --include="*.html" "spam-domain-you-found" .
    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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. Move to the database. Read your prefix first. Every query below assumes wp_. Change it to whatever $table_prefix says.
  8. 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.
  9. 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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
    wp search-replace "spam-domain-you-found" "" --dry-run --all-tables
    It ends with a line reading "Success: N replacements to be made." That number is the number of rows you are about to change.
  14. 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.
  15. 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.

Next

Where the file half stops being manual

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.