Working out what you have

Your site sends visitors somewhere else

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.

bash
$ curl -sS -o /dev/null -w "%{http_code}  %{redirect_url}\n" https://example.com/
200

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.

bash
$ curl -sS -o /dev/null -w "%{http_code}  %{content_type}\n" \
    https://example.com/wp-includes/js/wp-embed.min.js
200  text/javascript

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.

bash
curl -s https://example.com/ | grep -i 'http-equiv="refresh"'

curl -s https://example.com/ | grep -oE '<script[^>]*src="[^"]+"' | sort -u

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.

What the checks said Layer Where the fix is
3xx on the home page and on a static .js file Server configuration Rewrite rules injected into .htaccess
3xx on pages, 200 on static files PHP running before output Below, plus reading eval and base64_decode in a PHP file
200, page renders, browser leaves anyway JavaScript or a meta refresh in the page Below
200, but links and assets point at another host siteurl and home Below
3xx to somewhere plausible, same for everyone A theme or plugin setting Below

Where each layer keeps its code

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.
bash
ls -la wp-content/mu-plugins/

grep -rlE --include="*.php" 'HTTP_REFERER|HTTP_USER_AGENT' \
  wp-content/themes/ wp-content/mu-plugins/

grep -rnE --include="*.php" "header *\( *['\"]Location" wp-content/

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

  1. 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
    define( 'WP_HOME', 'https://example.com' );
    define( 'WP_SITEURL', 'https://example.com' );
  2. 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.
  3. 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.

  4. 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.
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. 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%';"
  4. 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.
  5. 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.
  6. 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

  1. 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
  2. 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.
  3. 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.
  4. 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.
  5. A plugin you did not install is the payload. Remove the whole plugin. The settings row inside it is not worth reading.

If a removal breaks the site instead of fixing it, stop and read what to do when a cleanup broke the site before you change anything else.

Closing the way back in

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.

bash
wp cron event list --fields=hook,next_run_relative

find . -name "*.php" -newermt "-7 days" -printf "%T+ %p\n" | sort

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.

Next

Where the search stops being manual

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.