Skip to content
  • FG-23
  • 14 min read
  • Last checked November 8, 2026

WordPress Site Redirecting Visitors to a Spam or Scam Site? How to Find and Remove It

Visitors land on a scam, dating or fake prize site, often only on mobile, only from Google or only once. Reproduce the conditional redirect, then remove it from the database, files or CDN.

Run the health check

A customer tells you that your site sent them to a fake prize page, a dating site, a “your phone is infected” scam or a page asking them to allow notifications. You open the site and everything is normal. You try again on your phone and nothing happens. The customer is not imagining it: malicious redirects are usually conditional, firing only for mobile visitors, only for people who arrive from a search engine, only for visitors who are not logged in, or only once per visitor so the site owner never catches it twice.

This guide shows you how to reproduce the redirect, then hunt down the code in the four places it hides: the database, theme and JavaScript files, server rules, and outside services such as a CDN or a third-party script. If the problem is a loop (ERR_TOO_MANY_REDIRECTS) rather than a trip to someone else’s site, see FG-03 instead. For general hack containment, start with FG-05.

What is happening

An attacker adds a small piece of code that decides, for every visitor, whether to send them away. The decision happens either on the server, before the page is sent, or in the visitor’s browser, after the page loads. Server redirects show up as an HTTP 301 or 302 response with a Location header. Browser redirects are JavaScript that changes window.location or injects a script from the attacker’s domain, which then does the redirecting; these return a normal 200 page and only misbehave once the script runs.

Condition What the code checks Why attackers use it
Mobile only User agent contains Android, iPhone, Mobile Mobile traffic converts well for scams, and owners test on desktops
Search visitors only Referrer is google, bing, yahoo or similar Owners type the address or use bookmarks
Once only A cookie or browser storage flag set on first redirect, or the visitor’s IP logged on the server The second visit looks clean, so reports seem unreliable
Not logged in Absence of the WordPress wordpress_logged_in_ cookie Administrators never see it
Not a crawler User agent is not Googlebot Delays detection by Google Safe Browsing

The code itself usually lives in one of these places:

  • Database: a <script> tag appended to post content, stored in widget text, saved in a theme option or a “header and footer scripts” plugin setting, or the siteurl and home options changed outright. Several large campaigns against WordPress have used vulnerable plugins to write obfuscated JavaScript straight into options and posts.
  • Files: PHP added to the theme’s header.php or functions.php, or JavaScript appended to the end of a legitimate .js file such as a theme script or a copy of jQuery.
  • Server rules: .htaccess rewrite rules that test the referrer or user agent.
  • Outside the server: a CDN rule or edge script, a tag manager container, or a third-party widget whose own server was compromised.

Before you start

  • Collect the report: the time (with time zone), device, browser, how the visitor reached you (Google search, social link, typed address), and a screenshot showing the final URL. The final domain is valuable: you will search for it.
  • Access: SSH or SFTP, WordPress admin, the hosting panel, and your DNS and CDN accounts.
  • Backup: export the database and copy the files before changing anything. You will want to compare later.
  • Where commands run: curl runs on your own computer (macOS and Linux have it; on Windows use curl.exe in PowerShell). wp, grep and find run in an SSH terminal on the server, in the site folder that holds wp-config.php. Some hosts name the command wp-cli. Words in CAPITALS are placeholders explained under each command.
  • Do not visit the scam domain on a computer you care about. You only need its name.

Step 1: Reproduce the redirect

With curl, posing as a mobile visitor from Google

On your computer, request a page as an iPhone user who clicked a Google result:

curl -s -o /dev/null -D - -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" -e "https://www.google.com/" "https://example.com/PAGE_PATH"

Replace example.com with your domain and PAGE_PATH with the page the visitor landed on (leave it empty for the homepage). -s hides progress output, -o /dev/null throws away the page body, -D - prints the response headers to the screen, -A sets the user agent string to a real iPhone Safari one, and -e sets the referrer, telling the server you came from Google.

Good output starts with HTTP/2 200 (or HTTP/1.1 200 OK) and has no location: line. Bad output is a 301 or 302 status with a location: header pointing to a domain that is not yours. If you see that, the redirect is server side: focus on Step 3 (files and .htaccess). Run it again without -e and with a desktop user agent; if those return 200, you have confirmed the condition.

Look for scripts in the page itself

A JavaScript redirect returns a normal 200, so look at the HTML instead:

curl -s -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" -e "https://www.google.com/" "https://example.com/" | grep -oiE "<script[^>]*src=[^>]*>" | sort -u

Replace example.com with your domain. The | passes the page to grep, where -o prints only the matching part, -i ignores case and -E enables the pattern, which matches every external script tag. sort -u removes duplicates. Good output is a list of scripts from your own domain and services you recognize (analytics, fonts, your CDN). Bad output is a script from a domain you have never heard of, often a recently registered name, or a long jumble of letters. Also save the full page (-o page.html instead of the pipe) and search it for atob(, eval(, fromCharCode and document.write, which inline injected scripts use to hide their target.

In a clean browser

Browser redirects only fire when JavaScript runs, so a real browser test matters:

  1. Open a new private or guest window, so there are no cookies from earlier visits and no login cookie.
  2. Open DevTools (F12, or Cmd+Option+I on a Mac), go to the Network tab and check Preserve log, so requests survive the page change.
  3. Turn on device emulation (the phone and tablet icon) and pick a phone.
  4. Go to Google, search for your site, and click your result.

If it redirects, look at the first request to the foreign domain in the Network list. A 301 or 302 in the Status column is a server redirect. If the Type column says document with an Initiator pointing to a script, click the initiator: DevTools shows the exact file and line that triggered it, which is often your answer.

If nothing happens, the code may be remembering your IP address or skipping your network. Try a phone on mobile data with Wi-Fi off, or a different network. Repeated failure to reproduce from one IP while visitors keep reporting it is itself a clue to IP-based “once only” logic.

Step 2: Search the database

The database is the most common home for injected redirect scripts on WordPress today, and the hardest to see from the file system. Start with the domain from the visitor’s screenshot:

wp db search "SCAM_DOMAIN" --all-tables --stats

Replace SCAM_DOMAIN with the domain the visitor ended up on, without https://. --all-tables searches every table in the database, including ones plugins created, rather than only core WordPress tables. --stats prints a summary of matches per table and column. Output lists each matching table and column (for example wp_options:option_value), then the row ID and the matched text with some characters of context either side. No output means the domain is not stored in plain text, which is common because attackers encode it.

So search for the encoding tricks too:

wp db search "atob\(|eval\(|fromCharCode|document\.write" --regex --all-tables --stats
wp db search "<script" --stats --before_context=20 --after_context=120

Type the first line as shown. --regex treats the search as a regular expression, so | means “or” and \( matches a literal bracket. atob decodes base64 text, fromCharCode builds text from numbers, and eval runs text as code; all three are how injected JavaScript hides its destination. The second line finds every script tag in WordPress tables. --before_context and --after_context set how many characters to show either side of the match, so you can read where the script points.

Expect legitimate matches: embedded forms, video players, analytics snippets you added, page builder data. What you are looking for is a script you did not add, especially one pointing to an unfamiliar domain or containing a long encoded string, often appended after the end of normal post content. Note each table, column and row ID.

Check the options that load on every page

First the two settings that redirect the whole site:

wp option get siteurl
wp option get home

Type them as shown. Both should print your own address, for example https://example.com. Anything else means the attacker rewrote them; set them back with wp option update siteurl https://example.com and the same for home, replacing example.com with your real address. If wp-config.php defines WP_HOME or WP_SITEURL, those override the database, so check that file too.

Then look at the other options that commonly carry injected scripts:

wp db query "SELECT option_name, LENGTH(option_value) AS bytes FROM wp_options WHERE option_value LIKE '%<script%' OR option_value LIKE '%fromCharCode%' OR option_value LIKE '%atob(%';"

Run wp db prefix first and replace wp_ in wp_options if yours differs. The query lists every option containing a script tag or one of the two decoding functions, with its size in bytes. Common legitimate names include those of analytics and “insert headers and footers” style plugins. Suspicious ones: widget_text or widget_block (widget contents), theme_mods_THEME_NAME (theme customizer settings, which some themes use for custom header code), and any option with a random-looking name. Open one with wp option get OPTION_NAME, replacing OPTION_NAME with a value from the list.

Remove database injections safely

Many options and some post meta are stored as PHP serialized data, which records the length of every string. Editing them with raw SQL breaks the length counts and can wipe the whole setting. Prefer, in this order: delete the script in the WordPress screen that owns it (the widget, the plugin’s header scripts box, the post editor’s code view); or use WP-CLI’s search-replace, which understands serialized data:

wp search-replace "EXACT_INJECTED_STRING" "" --all-tables --dry-run

Replace EXACT_INJECTED_STRING with the full injected snippet copied from the search output, from its opening <script to its closing </script>. The empty "" is the replacement, meaning delete it. --all-tables covers plugin tables, and --dry-run reports how many replacements it would make in each table without changing anything. Check that the counts match what you found, then run the same command without --dry-run. If the snippet contains quotes, wrap it in single quotes instead of double quotes. Good output ends with Success: Made N replacements. If the same script appears with small variations, add --regex and a careful pattern, and test with --dry-run first.

Step 3: Check the theme and JavaScript files

PHP templates

Attackers like header.php, footer.php and functions.php in the active theme because they run on every page. Find which theme is active with wp theme list --status=active, then check its files:

grep -nE "HTTP_REFERER|HTTP_USER_AGENT|wp_is_mobile|base64_decode|<script" wp-content/themes/THEME_SLUG/header.php wp-content/themes/THEME_SLUG/footer.php wp-content/themes/THEME_SLUG/functions.php

Replace THEME_SLUG with the theme’s folder name from the name column of wp theme list. If you use a child theme, check both the child and the parent. -n prints line numbers and -E enables the pattern. The pattern catches code that reads the referrer or user agent, WordPress’s own mobile check, base64 decoding and raw script tags. A theme you bought may legitimately use some of these, so read each matching line. Code at the very top of a file before the normal opening comment, or a single very long line, is a strong sign. The cleanest fix is to reinstall the theme from its original source and reapply only the customizations you can account for.

JavaScript files

Injected JavaScript is often appended to the end of an existing file so the site keeps working. Plugin and core files can be checked against official copies with wp core verify-checksums and wp plugin verify-checksums --all, which compare every file, JavaScript included. Theme and premium plugin files cannot, so look for recent changes:

find wp-content -name "*.js" -newer wp-includes/version.php -not -path "*/cache/*" -exec ls -l {} \;

Type it as shown. -newer wp-includes/version.php lists JavaScript files modified after your last WordPress core update, a handy reference date, and -not -path skips cache folders. Expect files from plugins and themes you updated since then. For any file that looks wrong, open it and jump to the end, or run tail -c 600 FILE_PATH to print its last 600 characters, replacing FILE_PATH with the path from the list. A block of encoded code after the file’s normal ending, often after a closing comment, is the injection.

.htaccess rules

Search every .htaccess for conditions on the referrer or user agent:

find . -name ".htaccess" -exec grep -HniE "HTTP_REFERER|HTTP_USER_AGENT|RewriteRule.*https?://" {} \;

Type it as shown. -H prints the file name, -n the line number, -i ignores case. The pattern catches rewrite conditions on the referrer or user agent and rewrite rules that send visitors to a full external URL. A security or caching plugin may add legitimate user agent rules (for example blocking bad bots), but a rule that sends visitors from Google or on mobile to another domain is malicious. Restore the standard WordPress block and delete the rest you cannot account for.

Step 4: Check outside the server

If the site’s files and database are clean but redirects continue, look upstream:

  • CDN rules: in Cloudflare, check Rules (Redirect Rules, Page Rules and any Transform Rules) and Workers routes for anything you did not create, and review the account’s members and audit log. Other CDNs have equivalent edge rule and audit screens.
  • DNS: if every visitor, even desktop users, land elsewhere, run dig +short example.com A on your computer (replace example.com with your domain) and confirm the address belongs to your host or CDN. A changed record means your DNS or registrar account was compromised.
  • Third-party scripts: chat widgets, review badges, ad networks and tag manager containers load code you do not control. In the DevTools Initiator trace from Step 1, a redirect that starts inside a third-party script points there. Remove the tag and contact the vendor. In Google Tag Manager, check the container’s version history for tags you did not publish.
  • Plugins: occasionally a plugin itself ships malicious code after a change of ownership or a compromised developer account. If wp plugin verify-checksums passes but the redirect follows one plugin when you deactivate them one at a time on a staging copy, check that plugin’s support forum and public vulnerability databases.
  • Browser extensions and adware on the visitor’s own device can cause redirects on every site they visit. If only one person reports it, and other sites do the same for them, the problem is on their device.

Advanced: decoding an obfuscated redirect without running it

To know what to search for, you sometimes need to see the destination hidden inside the injected code. Never paste suspicious code into your browser console to “see what it does”. Decode it as plain text instead.

For a base64 string passed to atob, copy the text between the quotes and decode it on your computer:

echo "BASE64_TEXT" | base64 --decode

Replace BASE64_TEXT with the encoded string. --decode converts base64 back to readable text (on older macOS versions use -D). Good output is readable text, usually a URL or more JavaScript. If it prints garbage, the string has another layer, often a second base64 or a character shift.

For String.fromCharCode(...), the numbers are character codes. Decode them with Python on your computer, which only prints text and runs nothing from the sample:

python3 -c "print(''.join(chr(n) for n in [NUMBER_LIST]))"

Replace NUMBER_LIST with the comma-separated numbers inside the brackets of fromCharCode. The output is the hidden text, typically a domain or a script URL. Search the database and files for that decoded domain and for distinctive fragments of the encoded string, because the same campaign often writes the same code into several places at once. Record each decoded domain for your host and for the review request in FG-24.

If you find code that loads a script from a remote domain, that remote script can change at any time, so the redirect destination you see today may differ from what visitors saw last week. Remove the loader; do not try to “fix” the destination.

When to contact your host

Open a ticket if the redirect is server side but you cannot find the rule, if it comes back after cleanup, or if you need access logs to see when it started. Send:

  • The exact curl command and the full response headers showing the location: line, with the UTC time you ran it.
  • The visitor’s report: time, device, browser, referrer, and the final domain.
  • The tables, row IDs and file paths where you found injected code, and what you removed.
  • Whether your site sits behind a CDN, and any Cloudflare ray ID from the response headers (cf-ray).

If Google or a browser has already started warning visitors, continue with FG-24. If the redirect returns after a clean removal, follow FG-26 to find the persistence mechanism.

Still stuck?

Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.

Get the free checklists

Keep reading

All field guides

Search the field manual

What is going wrong?

Press / to search from any page, Esc to close