The wp_options.siteurl hijack: how a one-row UPDATE redirects every visitor and how to spot it before Google does

One of the simplest, oldest, and still most effective WordPress compromises is a single SQL update. The attacker gets one query into your database — through any RCE, SQLi, or stolen-credential path — and runs:

UPDATE wp_options
   SET option_value = 'https://attacker-site.example'
 WHERE option_name IN ('siteurl', 'home');

From that moment, every visitor to your site gets redirected to the attacker’s domain. WordPress core uses siteurl and home to construct most internal URLs and to enforce the canonical redirect. Hit the homepage, you bounce to attacker-site. Click any link, it bounces. Even your wp-admin URL bounces — which means you can’t easily log in to revert it.

Two minutes of work for the attacker. Several hours of recovery for you, plus the SEO blast radius from Google flagging your domain as redirecting to malware. Worth understanding deeply.

Why it’s so effective

  • Exactly two rows in wp_options. No filesystem changes — your malware scanner sees nothing.
  • Survives plugin/theme updates. The settings live in the database; nothing in the file tree is suspicious.
  • The redirect happens at the WordPress layer (PHP code reads siteurl and emits Location: headers), so it’s invisible to your reverse proxy and to any WAF that doesn’t decrypt and analyze response headers.
  • Google sees it. Within a few crawl cycles, your domain is delisted or flagged as “this site may harm your computer” — which can take weeks to recover even after you fix the underlying issue.

Detection: the SQL audit query

The simplest detection is a periodic check that siteurl and home still match what they should:

#!/bin/bash
# /usr/local/sbin/wp-siteurl-check.sh
set -euo pipefail

declare -A EXPECTED=(
    ["ani2-com"]="https://animatedcreativity.com"
    ["pen-ylo-one"]="https://pen.ylo.one"
)

for site in "${!EXPECTED[@]}"; do
    cfg="/usr/local/lsws/$site/html/wp-config.php"
    [ -f "$cfg" ] || continue
    DBN=$(grep -oP "DB_NAME['\"], ?['\"]\K[^'\"]+" "$cfg")
    DBU=$(grep -oP "DB_USER['\"], ?['\"]\K[^'\"]+" "$cfg")
    DBP=$(grep -oP "DB_PASSWORD['\"], ?['\"]\K[^'\"]+" "$cfg")
    PFX=$(grep -oP '\$table_prefix\s*=\s*['"'"'"]\K[^'"'"'"]+' "$cfg")
    PFX=${PFX:-wp_}

    actual=$(mysql -u"$DBU" -p"$DBP" -h127.0.0.1 "$DBN" -BNe \
        "SELECT option_value FROM ${PFX}options
         WHERE option_name='siteurl' LIMIT 1;" 2>/dev/null)

    if [ "$actual" != "${EXPECTED[$site]}" ]; then
        echo "ALERT: $site siteurl is '$actual', expected '${EXPECTED[$site]}'"
    fi
done

Run from cron every 5 minutes. The window between attacker UPDATE and your alert is at most 5 minutes — much smaller than Google’s crawl interval, which is your real deadline.

Recovery when you’ve been hit

The catch-22: you need wp-admin to fix it, but wp-admin redirects to the attacker. There are three paths:

Path 1: WP-CLI (preferred)

wp --path=/usr/local/lsws/ani2-com/html --allow-root \
   option update siteurl 'https://animatedcreativity.com'

wp --path=/usr/local/lsws/ani2-com/html --allow-root \
   option update home 'https://animatedcreativity.com'

# Flush LSCache (or whatever cache plugin you use)
wp --path=/usr/local/lsws/ani2-com/html --allow-root \
   --skip-plugins --skip-themes rewrite flush
find /usr/local/lsws/ani2-com/html/wp-content/litespeed -type f -delete 2>/dev/null

Path 2: Direct SQL

mysql -u USER -p ani-com <<EOF
UPDATE wp_options SET option_value='https://animatedcreativity.com'
 WHERE option_name='siteurl';
UPDATE wp_options SET option_value='https://animatedcreativity.com'
 WHERE option_name='home';
EOF
# Then flush the cache as above

Path 3: WP_SITEURL constants in wp-config

Long-term mitigation: hard-code the URLs in wp-config.php:

define('WP_SITEURL', 'https://animatedcreativity.com');
define('WP_HOME',    'https://animatedcreativity.com');

These constants take precedence over the database row. The attacker’s SQL UPDATE still happens, but WordPress ignores it — because the constant wins. The DB still has the bad value (so the audit script still alerts, which is fine), but visitors don’t see the redirect.

This is the single best mitigation against the entire class of attack. Set it in wp-config.php on every WP install you own. The cost is that changing your siteurl now requires editing the file rather than the WP admin — which is fine, you change it about once per server-lifetime.

The other option_value rows worth watching

  • active_plugins — attackers append a malicious plugin path, getting their PHP loaded on every request. The serialised array makes this hard to detect with simple comparisons; you’d want to track the count and re-validate the path list.
  • template / stylesheet — switch the active theme to one the attacker uploaded.
  • users_can_register + default_role — set to “administrator” + open registration = walk-in admin accounts.
  • auth_key / auth_salt etc — actually these live in wp-config.php, not options. But attackers do steal them to forge cookies.

The siteurl/home audit catches the most common one. For full coverage, extend the script to assert all six of those values match a known-good config.

The Google delisting clock

Even after you fix the redirect, your search rank doesn’t bounce back instantly. Google needs to recrawl, see the redirect is gone, and lift the safe-browsing flag. That’s typically a few days. If you’ve been delisted, head to Google Search Console → Security Issues → “Request a review” and add a note explaining the fix. The review is usually within 24 hours, much faster than waiting for natural recrawl.

Once: I had a small client site hit by this. The fix took 10 minutes. The Google penalty took two weeks to fully lift. The audit script + WP_SITEURL constants would have prevented both. They take 5 minutes to set up. Do them today.

Cover photo: Max Laurell on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.