How to Fix the Japanese Keyword Hack and Pharma Spam Pages in Google
Google shows Japanese, pharmacy or casino pages under your domain that you never wrote. Find the cloaked spam, remove it at the source, then get the junk URLs dropped from Google.
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.
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.
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:
<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.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..htaccess rewrite rules that test the referrer or user agent.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.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.
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.
Browser redirects only fire when JavaScript runs, so a real browser test matters:
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.
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.
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.
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.
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.
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.
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.
If the site’s files and database are clean but redirects continue, look upstream:
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.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.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.
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:
location: line, with the UTC time you ran it.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.
Did this fix it?
Still stuck? Start again from what you can see in the Emergency Room.
Still stuck?
Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.
Get the free checklistsKeep reading
Search the field manual
Or jump straight to a symptom
Press / to search from any page, Esc to close