Five separate layers can produce this exact symptom, and the fix is different in every one of them. Work out which layer you have before you open a single file. It takes about ten minutes and half a dozen requests.
Checked against a live WordPress install on
.
What a redirect hack looks like
Someone tells you the site sent them to a page selling something you have
never sold. You open the site and it is fine. You clear your browser
cache, open it again, and it is still fine. Meanwhile the reports keep
arriving, your search traffic is falling, and your host has started
forwarding complaints.
That gap between what you see and what your visitors see is the design
working. Most published samples of this malware check the visitor before
they fire, and the check that appears most often is whether the visitor
is logged in. The malicious must-use plugin Sucuri documented
in March 2025 skipped administrators and search engine crawlers. The fake
plugin they analysed in June 2025 skipped logged-in users and waited four
to five seconds before moving anyone else. You are the visitor guaranteed
not to see it.
Three other conditions turn up constantly, and any of them explains a
redirect you cannot reproduce.
Where the visitor came from. The code reads
$_SERVER['HTTP_REFERER'] and fires only for arrivals from a
search engine. Type the address yourself and nothing happens.
What the visitor is using. The code reads
$_SERVER['HTTP_USER_AGENT'] and fires only on phones, or
fires for everyone except crawlers, which keeps the site indexed while
it monetises the traffic.
Whether the visitor has been here before. The functions.php
sample Sucuri published in January 2025 gated itself on a cookie,
if(isset($_COOKIE['MkQQ'])), so a returning visitor was
left alone and the redirect looked intermittent to anyone testing it.
How long the visitor waits. A four-second delay means the page
renders, you watch it render, and your reader is gone before they
finish the second paragraph.
None of that changes the removal. It changes the testing, and testing
wrong is how people spend a day concluding the site is clean.
Ten minutes to narrow it to one layer
Five layers can redirect a WordPress visitor: the server configuration,
PHP that runs before any output, JavaScript inside the delivered page, the
siteurl and home options in the database, and a
setting in a theme or a plugin. Every one of them is cheap to test from a
command line. Run these in order and stop at the first one that answers.
1. Ask the server and read what comes back
Use a GET request that throws away the body. Do not use -I,
because a HEAD request is not the request the malware is waiting for and
some samples ignore it.
A 200 with nothing after it means the server handed over your
page. Whatever moves the visitor is inside that page, so it is JavaScript
or a meta refresh. A 301 or 302 followed by a
host you do not recognise means the server itself is doing it, and the
HTML never gets built at all.
On a clean install this printed 200 and an empty second
field. Checked on WordPress 7.0.4 and Apache 2.4.67, 18 August 2026.
2. Change one thing about who is asking
Run the same request three more times, changing exactly one condition
each time. None of these carry a login cookie, so all four are already
testing as a logged-out visitor.
bash
# arriving from a search engine
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" \
-e "https://www.google.com/" https://example.com/
# on a phone
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" \
-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" \
https://example.com/
# as a crawler
curl -sS -o /dev/null -w "%{http_code} %{redirect_url}\n" \
-A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://example.com/
Whichever run differs from the others has told you the condition the code
checks, and that narrows where the code can live. A referrer check has to
run somewhere that can read a request header, which rules out injected
JavaScript on its own. A difference that shows up only for the crawler
user agent is cloaking, which is how a site earns a Google warning while
every visit you make to it looks fine.
3. Separate the server configuration from PHP
If step one gave you a redirect, ask for a file that Apache serves without
running PHP. Any static asset does. A rewrite rule in
.htaccess catches those too, and PHP cannot, because PHP
never runs.
A redirect on that URL puts you in the server configuration layer. A clean
200 on it while the home page redirects puts you in the PHP
layer. Both of those paths returned 200 on a clean install
when this was checked, along with /readme.html.
4. Look at the page you were given
If step one gave you a 200, the redirect is in the HTML. Two
things do it, and one command each finds them.
Read the second list against what your site is supposed to load. A
container you never created is the tell. Sucuri traced a July 2025
campaign to a Google Tag Manager loader,
googletagmanager.com/gtm.js?id=GTM-PL2J2GLH, which reads as
legitimate in a page source and was shared across more than 200
compromised sites. Turning JavaScript off in your browser and reloading
settles the rest: if the redirect stops, it is JavaScript.
5. Read the two options that rewrite every URL on the site
Read the raw database row. Do not use wp option get for this
one, and the reason is worth knowing.
bash
$ wp db query "SELECT option_name, option_value FROM wp_options \
WHERE option_name IN ('siteurl','home');"
option_name option_value
home https://example.com
siteurl https://example.com
WordPress attaches _config_wp_siteurl to the
option_siteurl filter and _config_wp_home to
option_home, in
wp-includes/default-filters.php. When
WP_SITEURL or WP_HOME is defined in
wp-config.php, those functions return the constant and
discard whatever was passed in. So wp option get siteurl
shows you the constant and a poisoned row sits behind it, invisible and
waiting for the day somebody removes the constant.
Read the two values as bytes rather than as URLs. The pattern Sucuri
documented in January 2020 left the domain intact and appended a script
tag to the end of the value, so every page on the site loaded the
attacker's JavaScript and the option still looked broadly right at a
glance. Balada, which has been running since 2017 and is still using
SiteURL injection, is the same idea at scale.
Your table prefix may not be wp_. Read
$table_prefix in wp-config.php and use what is
there. A query against the wrong prefix returns nothing and reads exactly
like a clean result.
6. What is left is a setting
A redirect that survives all five checks, points somewhere plausible, and
fires for everyone is usually a plugin or a theme doing what it was
configured to do. That configuration may have been written by an attacker
rather than by you, which is the fifth layer and the last section below.
You know the layer. Now you need the file or the row.
Server configuration
On Apache this is .htaccess, and there is never only one.
Every directory can hold its own, and the injected one is often not the
one in the web root. The install this page was checked against carries
eight, every one of them belonging to a plugin or to a subdirectory of
wp-content/uploads, and no root file at all.
bash
find . -name ".htaccess"
Injected rules read the request and decide. A
RewriteCond on %{HTTP_REFERER} matching a search
engine, or on %{HTTP_USER_AGENT} matching phone browsers, is
the giveaway, because WordPress itself never writes one. Telling the
WordPress block apart from an injected one, and stopping the file being
rewritten again an hour later, is the whole subject of
the .htaccess page. On
nginx there is no per-directory file: the rules live in the server
configuration and only your host can change them, which makes this layer
rare on nginx and worth ruling out first if you are on Apache.
PHP that runs before output
Four locations carry almost all of it. Check them in this order, because
the order runs from the place people never look to the place they always
do.
wp-content/mu-plugins/. Must-use plugins load on
every request and never appear on the Plugins screen, so nothing in the
admin will ever show you one. Sucuri named three files there in March
2025: redirect.php, which sent visitors to a fake browser
update at updatesnow[.]net while skipping bots and
administrators, index.php, a webshell that pulled PHP from
a GitHub raw URL and ran it through eval, and
custom-js-loader.php. ThaiCERT reported the same directory
again in July 2025 holding wp-index.php, which fetched its
payload from a URL obfuscated with ROT13, alongside a second file called
wp-bot-protect.php.
The active theme's functions.php. The January 2025
sample lived here: a cookie gate, strings written as hex escapes such as
\x68\x74\x74\x70s so a search for http misses
them, a rawurldecode() call on a percent-encoded payload,
and a user-agent regular expression that dropped crawlers.
A plugin that exists only to do this. Sucuri's June 2025 write-up
covers a single file,
wp-content/plugins/wordpress-player.php, whose author field
read "WordPress Core". It hooked wp_footer, skipped
logged-in users, and moved everyone else after four to five seconds.
index.php, wp-config.php and
header.php. The classic top-of-file injection. The
first line of an infected index.php is the thing to read,
because the real one opens with a docblock about loading
wp-blog-header.php and nothing else.
When you find the block, read it rather than running it. Hex escapes,
rawurldecode, str_rot13 and
base64_decode are all reversible at a shell prompt without
the code ever executing, and
the page on reading obfuscated PHP
walks through doing that safely and cutting the block out without breaking
the file it was pasted into.
JavaScript in the delivered page
This layer splits again, and the split decides the removal. The script is
either written into a file, in which case it is really the PHP layer
wearing a different hat, or it is stored in the database and printed by
plugin code that is doing its job.
Published database locations, all named by vendors: the
wp_options row ihaf_insert_body, which belongs to
Insert Headers and Footers and now WPCode, and which held the Tag Manager
loader in the July 2025 campaign; custom HTML widgets, where the Sign1
campaign kept its script; wp_posts.post_content, injected in
bulk across every post; and the theme modifications row, named after your
active theme and reading theme_mods_twentytwentyfour on a
default install, which renders on every page of the site.
An injected script that does not move the visitor anywhere is a different
infection with the same delivery. If the page loads normally but the
machine gets hot, read
a cryptominer is running on your site,
which hunts the same injection points for a script that mines instead of
redirects.
Obfuscation in this layer is shallow and consistent.
String.fromCharCode assembles the destination one character
code at a time so a search for the domain finds nothing.
location.replace is preferred over
location.href because it leaves no back-button entry, which is
why a visitor cannot get back to your page after landing on the spam one.
The siteurl and home options
WordPress documents six ways to set them, which is six places a value can
have come from when you are working out who changed it: the
WP_HOME constant, the WP_SITEURL constant, the
RELOCATE constant, the WordPress Address and Site Address
fields under Settings then General, a direct UPDATE on
wp_options, and an update_option() call in a
theme's functions.php. The last two leave no trace in the
admin.
A theme or plugin setting
Redirect managers, coming-soon modes, SSL plugins and header injection
plugins all redirect people on purpose, and an attacker with a way to
write settings can use yours instead of installing anything. Wordfence
documented the cleanest example in August 2019: the Bulk Uploader add-on
for Simple 301 Redirects listened for a POST parameter named
submit_bulk_301 without checking who sent it, so an
unauthenticated request wrote redirect rules straight into a legitimate
plugin's own configuration. Nothing on the file system changed and no
scanner reading files would have seen it.
Removing the three layers with no page of their own
Two of the five layers are covered end to end elsewhere. These three are
not, so here they are.
Poisoned siteurl or home
Take the site back first, before you investigate anything. Add
two lines to wp-config.php, above the line that says to
stop editing. They take effect on the next request and they hold even
with the poisoned row still in the database.
php
Expect the admin fields to go grey.wp-admin/options-general.php disables the WordPress Address
and Site Address inputs whenever those constants are defined. That is
the constants working, not a second problem.
Write the stored row back to the truth. The constants mask the
row; they do not repair it. Write the row with SQL, not with
wp option update. The same
_config_wp_siteurl filter that hides the poisoned value
from wp option get also makes the update look like a
no-change: update_option() compares your new value against
what the filter returned, finds them equal, and returns false without
writing anything. WP-CLI prints
Success: Value passed for 'siteurl' is unchanged. and the
poisoned row survives.
bash
wp db query "UPDATE wp_options SET option_value = 'https://example.com' WHERE option_name IN ('siteurl','home');"
wp db query "SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl','home');"
The wp_ prefix is a default and yours may differ. Read
$table_prefix in wp-config.php first. If
you would rather use wp option update, comment the two
constants out, run it, read the row back, then put them back.
Find out what else carries the attacker's domain. Run the
replacement as a dry run first and read the table list it prints. A
real dry run ends with a count.
bash
$ wp search-replace "https://spam.example" "https://example.com" \
--dry-run --all-tables
...
Success: 0 replacements to be made.
Remove the constants once the row is right, then re-check.
Keeping them is a fair choice, and it means the stored value is now
hidden from every tool that reads it through WordPress, including from
the next person who looks.
Watch the row for an hour. A value that comes back means code is
writing it, and you are in the PHP layer after all. Look at
wp-content/mu-plugins/ and the active theme's
functions.php first.
JavaScript stored in the database
Copy a unique fragment out of the page source. Twenty characters
of the injected block that will not appear anywhere else: part of the
container id, part of the hostname, part of a variable name. That
fragment is what you search for.
Search the options table. One query, and read the option names it
returns before you touch any of them.
bash
wp db query "SELECT option_name FROM wp_options \
WHERE option_value LIKE '%googletagmanager.com/gtm.js%';"
A query that matches nothing prints nothing at all on MySQL 8.0, so an
empty screen is a clean result rather than a broken command.
Search posts and pages. Injections into post content arrive in
bulk, so expect either no rows or hundreds.
bash
wp db query "SELECT ID, post_title FROM wp_posts \
WHERE post_content LIKE '%<script%';"
Edit the value, do not delete the row. A widget option holds all
your widgets, and the theme modifications row holds your entire theme
configuration. Deleting either to remove one script costs you the
site's appearance. Open the setting in the admin, remove the injected
block, save.
Check the plugin that printed it. If the script came from a
header injection plugin, the attacker needed a way to write that
setting. Update the plugin, and if you do not use it, remove it.
Clear every cache, then request the page again. Page cache,
object cache, and whatever your host runs in front of the site. Add a
query string to the URL so you know the response you are reading is
fresh.
A redirect written into a plugin's settings
List everything that loads. Three commands, because WordPress
has three categories of plugin and the admin screen shows one of them.
bash
wp plugin list --fields=name,status,version,update
wp plugin list --status=must-use --fields=name,title
wp plugin list --status=dropin --fields=name,title
Open every redirect rule you have. Redirect managers, SEO plugins
with a redirect module, coming-soon and maintenance modes, and any SSL
plugin. Read the destination of each rule. A rule pointing off your
domain is the one you came for.
Read the header and footer injection fields. If you run WPCode or
anything like it, the global header, body and footer boxes render on
every page of the site and show up in no file on disk.
Remove the rule, then update the plugin that held it. Deleting
the rule removes today's redirect. The version that let an unauthorised
request write it is still installed, and it will be written again.
A plugin you did not install is the payload. Remove the whole
plugin. The settings row inside it is not worth reading.
Every layer above is a payload. Something wrote it, and that something is
still there until you deal with it. A redirect that comes back within days
is the normal outcome of removing the payload and leaving the way in open.
Four questions. Work through them in this order.
What could write to your files? An outdated plugin with a file
upload flaw, a stolen FTP or hosting password, or a nulled theme.
Update everything, then look at what PHP can be reached directly.
Sucuri's mu-plugins cases and ThaiCERT's both ended with the attacker
able to write into a directory WordPress loads automatically. Whatever
handed them that write access is the thing to close.
Who can log in? The ThaiCERT case created an administrator called
officialwp and could reset the passwords of accounts named
admin, root and wpsupport. List
your administrators, remove the ones you cannot account for, and reset
every remaining password.
bash
wp user list --role=administrator \
--fields=ID,user_login,user_email,user_registered
Are the keys still yours? Rotate the salts in
wp-config.php. Every existing session is invalidated, which
includes any session the attacker is holding. Rotate the database
password and the hosting account password at the same time.
Can anything still run PHP where it should not? Uploads is the
directory that matters. A PHP file that can be requested directly under
wp-content/uploads is a way back in regardless of how the
first one arrived.
Then check the files nobody thinks to check. A scheduled event is the
quietest way to keep an infection alive, because it needs no visitor and
leaves no request in the access log to trace.
If you have been here before, the entry point is the thing you have not
found yet, and
the page on reinfection
covers that ground. Once the site is clean, compare your core, plugin and
theme files against the published originals with
the restore procedure,
which will surface anything the removal above walked past.
Questions
Why do visitors get redirected when I do not?
Because the code checks who you are before it fires. Skipping logged-in users is the most common evasion in this family, and it appears in most published samples: the malicious mu-plugin Sucuri documented in March 2025 skips administrators and search engine bots, and the fake wordpress-player.php plugin they analysed in June 2025 skips logged-in users too. Others check the referring page and only redirect arrivals from Google, or check the user agent and only redirect phones. Log out, open a private window, and request the site with a phone user agent before you conclude anything.
I fixed the siteurl option and it changed back within the hour.
Something on the site is writing it. A poisoned option is a symptom of code that still runs, so putting the value back does not remove anything. Define WP_HOME and WP_SITEURL in wp-config.php, which makes the stored row irrelevant while you work, then go and find the code. A scheduled task, a must-use plugin, or a line in the active theme's functions.php are the three usual writers.
Google says my site redirects and I cannot reproduce it.
Request the site with Googlebot's user agent. Cloaked redirects serve one thing to crawlers and another to you, and that is the whole point of the design. If a crawler user agent reproduces it and your browser does not, you have confirmed a conditional redirect and you have also confirmed that clearing your own browser cache was never going to help.
Do I need to reinstall WordPress?
Not for this. A redirect lives in one of five places, all of which you can read, and reinstalling core replaces none of them: not .htaccess, not wp-config.php, not your theme, not your plugins, not the database. Reinstalling core is worth doing after you have found the injection, since it settles whether anything else in core was touched, but do not expect it to remove the redirect.
Will clearing the cache fix it?
Clearing a cache removes a stored copy of an infected page, which is worth doing after the removal and does nothing before it. If the redirect comes back on the next page build, the code that built it is still there. Test with a cache-busting query string on the URL so you know you are looking at a fresh response rather than a stored one.
The redirect points at a page on my own domain. Is that still a hack?
Usually not. WordPress redirects to the canonical URL of a page all day, and so do SSL plugins, redirect managers and coming-soon modes. Read the destination before you treat it as an infection. An off-site host you do not recognise is the signal. Your own domain with a different path is a setting somewhere, and the fifth layer of this page is where to look for it.
The file side of this page is the part Segurium handles. It fingerprints
every file and looks the fingerprint up before reading anything, so about
94% of the files on a site settle without their contents being examined
by anything. When it does find an injection it removes the injected block
and writes the cured file back, so a theme whose
functions.php was used as the host keeps working once the
redirect is gone. It walks the file system rather than the Plugins
screen, so whether a file is visible in the WordPress admin has no
bearing on whether it gets read.
It also covers the way back in: two-factor authentication and
brute-force protection on the login, IP, CIDR and country rules in the
firewall, and an integrity check that tells you when a core, plugin or
theme file stops matching the published copy. Run the option queries
above once the file work is done, then request the site again the way you
reproduced the redirect in the first place: logged out, with a phone user
agent, and with Googlebot's.