Security, Sysadmin, WordPress

Get WordPress off MySQL root: per-site users in one Python loop

A single key resting in a locker door, symbolizing per-site database credentials with no shared master key (photo: Jakub Zerdzicki / Pexels)

If you run more than one WordPress site on a single server and every wp-config.php has DB_USER = 'root', your eight sites are effectively one site as far as a compromise is concerned. One vulnerable plugin on any of them gets the attacker shell access as the nobody PHP user, and from there cat /usr/local/lsws/*/html/wp-config.php hands over every database. There’s nothing to pivot — the credentials are already shared.

Here’s a 30-line Python loop that gives every site its own MySQL user with grants only on its own database, rotates the wp-config to use those credentials, and tests connectivity before moving on. After this runs, a compromise of one site can no longer dump any other site’s database.

What “shared root” actually buys an attacker

Three concrete things, in order of nastiness:

  1. Read every other site’s user table — usernames, hashed passwords, emails, billing data if WooCommerce is involved.
  2. Insert a backdoor admin account in any site, not just the one they got into. Now there are N footholds for the price of one.
  3. Drop tables, modify wp_options.siteurl, plant SEO-spam in wp_posts across the entire estate. With root, even information_schema lookups are free.

None of this is exotic — it’s the natural consequence of giving one credential the keys to all houses. Per-site users don’t make compromise impossible; they make compromise contained.

The plan

  • For each site: generate a strong random password.
  • CREATE USER with that password, bound to 127.0.0.1 (not %).
  • GRANT ALL ON `dbname`.* — one database, no global privileges.
  • Test the new credentials work before touching wp-config.php.
  • Update DB_USER and DB_PASSWORD in wp-config.php.
  • Save the new credentials to a root-owned secrets file so you don’t lose them.

The order matters: create the user and verify it works before swapping the wp-config. If anything fails mid-loop, the site keeps connecting with the old root credentials and you can fix forward.

The script

#!/usr/bin/env python3
import os, re, secrets, string, subprocess, datetime

ROOT_PW = "your-current-root-password"
SECRETS = "/root/mysql-per-site-users.txt"

# (wp_path, db_name, mysql_user_to_create)
SITES = [
    ("/usr/local/lsws/site1/html", "site1_db", "wp_site1"),
    ("/usr/local/lsws/site2/html", "site2_db", "wp_site2"),
    # ...add the rest
]

ALPHA = string.ascii_letters + string.digits  # alnum only — no escape headaches in wp-config

def gen_pw(n=32): return "".join(secrets.choice(ALPHA) for _ in range(n))

def sql(stmt, db=None):
    cmd = ["mysql", "-uroot", f"-p{ROOT_PW}", "-h127.0.0.1"]
    if db: cmd.append(db)
    return subprocess.run(cmd + ["-e", stmt], capture_output=True, text=True)

def update_wp_config(path, user, pw):
    backup = path + ".bak.pre-mysql-rotate"
    if not os.path.exists(backup):
        subprocess.run(["cp", "-p", path, backup], check=True)
    with open(path) as f: src = f.read()
    src = re.sub(r"(define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"])[^'\"]+(['\"]\s*\)\s*;)",
                 rf"\g<1>{user}\g<2>", src)
    src = re.sub(r"(define\s*\(\s*['\"]DB_PASSWORD['\"]\s*,\s*['\"])[^'\"]*(['\"]\s*\)\s*;)",
                 rf"\g<1>{pw}\g<2>", src)
    with open(path, "w") as f: f.write(src)

with open(SECRETS, "w") as out:
    out.write(f"# Per-site MySQL users — generated {datetime.datetime.utcnow().isoformat()}Z\n\n")
    for path, db, user in SITES:
        cfg = os.path.join(path, "wp-config.php")
        if not os.path.exists(cfg):
            print(f"SKIP {db}: wp-config missing"); continue
        pw = gen_pw()
        sql(f"DROP USER IF EXISTS '{user}'@'127.0.0.1';")
        if sql(f"CREATE USER '{user}'@'127.0.0.1' IDENTIFIED BY '{pw}';").returncode:
            print(f"FAIL create {user}"); continue
        if sql(f"GRANT ALL PRIVILEGES ON `{db}`.* TO '{user}'@'127.0.0.1'; FLUSH PRIVILEGES;").returncode:
            print(f"FAIL grant {user}"); continue
        # Verify the new credentials actually connect to the target DB
        test = subprocess.run(["mysql", f"-u{user}", f"-p{pw}", "-h127.0.0.1", db, "-e", "SELECT 1;"],
                              capture_output=True, text=True)
        if test.returncode:
            print(f"FAIL test connect {user}: {test.stderr}"); continue
        update_wp_config(cfg, user, pw)
        out.write(f"{db:30s}  user={user:18s}  pass={pw}\n")
        print(f"  ✓ {db}: rotated to {user}")

os.chmod(SECRETS, 0o600)
print(f"\nSaved credentials to {SECRETS} (chmod 600).")

Three subtle bits worth highlighting:

  • Alphanumeric-only passwords. WordPress’s wp-config.php stores the password inside define( 'DB_PASSWORD', '...' );. Special characters need PHP escaping. By restricting to [A-Za-z0-9] we avoid the entire class of “single-quote in password breaks the file” bugs. With a 32-character alphanumeric password the keyspace is still 62^32 — plenty.
  • '127.0.0.1', not 'localhost'. MySQL treats localhost as a Unix-socket connection and 127.0.0.1 as TCP — they’re different “hosts” from the auth system’s perspective. WordPress’s DB_HOST on most setups is 127.0.0.1, so the user has to be created with that exact host. Mismatch = “Access denied for user”.
  • Test connection before swapping wp-config. If the user gets created but somehow can’t actually connect (firewall, wrong host binding, SQL mode issue), the script bails out without touching wp-config — the site keeps working with the old root creds and you can investigate.

After it runs

Hit each site’s homepage to confirm 200 OK with new credentials, then rotate the MySQL root password itself. Even though no site is using root anymore, you’d been spreading that password across files for years — assume it’s leaked and pick a fresh one:

NEW_ROOT=$(python3 -c 'import secrets,string;print("".join(secrets.choice(string.ascii_letters+string.digits) for _ in range(40)))')
mysql -uroot -p"$OLD_ROOT" -h127.0.0.1 -e "
  ALTER USER 'root'@'localhost' IDENTIFIED BY '$NEW_ROOT';
  FLUSH PRIVILEGES;"
echo "$NEW_ROOT" > /root/.mysql-root-password
chmod 600 /root/.mysql-root-password

And update any helper scripts — ~/.my.cnf, backup cron jobs, monitoring tools — that still hardcoded the old root password. grep -rl 'OLD_PASSWORD' /root /etc /usr/local 2>/dev/null finds them.

Rollback

The script writes wp-config.php.bak.pre-mysql-rotate next to each config. To revert any single site:

cp /usr/local/lsws/site1/html/wp-config.php.bak.pre-mysql-rotate \
   /usr/local/lsws/site1/html/wp-config.php

The created user can stay (it’s harmless when nothing references it) or get dropped with DROP USER 'wp_site1'@'127.0.0.1';.

30 lines of Python, applied once, removes one of the most underrated lateral-movement risks in shared-hosting WordPress setups. If you’re going to do one piece of database hygiene this quarter, do this one.

Leave a Reply

Your email address will not be published. Required fields are marked *

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