TechEarl

Cross-Site Contamination on Shared WordPress Hosting (Why Cleaning One Site Isn't Enough)

If one site in a shared hosting account gets compromised, every other site under the same Linux user is at risk. Cleaning a single site without addressing the shared file system leaves the door open for reinfection from any sibling site. The structural problem and the realistic fixes, open_basedir, suEXEC, isolated users, and when to move hosts.

Ishan KarunaratneIshan Karunaratne⏱️ 18 min readUpdated
When multiple WordPress sites in one shared hosting account get infected at once, the cause is structural. open_basedir, suEXEC, isolation, and when to move hosts.

On shared WordPress hosting, the most painful realization for site owners is this: if one site in your hosting account is compromised, every other site in the same account is functionally compromised too, whether or not it shows symptoms yet. Cleaning one site without addressing the shared environment is sweeping the floor while the upstream tap stays open. The malware re-spreads from sibling sites within hours.

This article covers the structural reasons cross-site contamination happens, how to confirm whether your hosting account has the architecture that enables it, and the four realistic fixes, three of which require host cooperation and one of which is changing hosts entirely. I've worked this scenario more times than I can count, and the conversation always ends with the same recommendation: if you're running serious traffic on shared hosting, the lower-tier shared tier is the wrong product. The right fix is structural.

I'm writing this in 2017 originally and have updated it through every major shared-hosting product change since. The fundamental problem (one Linux user, multiple sites, shared file system) is older than WordPress itself and isn't going away.

Why one Linux user means one trust boundary

The simplest model of shared hosting: a hosting provider sells you an account. The account is one Linux user on a server shared with hundreds of other accounts. Inside your account, you can install as many WordPress sites as you want, in subdirectories like:

code
/home/yourname/public_html/site1.com/
/home/yourname/public_html/site2.com/
/home/yourname/public_html/blog.site3.com/

Every file in every site is owned by the same Linux user (yourname). Apache or Nginx runs as a separate user (commonly www-data or apache), but PHP runs as your user, via mechanisms like suPHP, mod_ruid2, or PHP-FPM with per-user pools.

That last detail is the whole story. PHP code running on site1.com is running as the user yourname. It can read, write, and execute every file owned by yourname, which includes the files of site2.com, site3.com, and any other site in the same account.

If site1.com is compromised and the attacker drops a PHP shell, that shell runs as yourname and can:

  • Read wp-config.php of site2.com (DB credentials, salts, API keys)
  • Write index.php of site3.com (drop a backdoor or inject ClickFix script)
  • Modify wp-content/themes/X/functions.php on every other site
  • Create admin users in every other site's database (using the read DB credentials)
  • Set up persistence in every other site's wp_options and wp-cron

There's nothing fancy about this. It's how Linux file permissions work. The malware doesn't need to escalate privileges; it already has the same privileges as the legitimate site.

How to confirm you're affected

Three quick checks. Run any of them on your server (over SSH; cPanel hosts usually provide SSH on a non-default port for accounts at certain tiers).

Check 1: Are all your sites owned by the same Linux user?

bash
# List the immediate subdirectories of your public_html (or wherever your sites live)
# and show ownership
ls -la ~/public_html/
# or
ls -la /home/$(whoami)/public_html/

If every site's directory is owned by the same user (which on most shared hosts is your cPanel username, your account username, or www-data), you have the shared-user architecture. That's the cross-contamination risk.

The contrast: properly isolated hosting shows each site owned by a different user (site1-user:site1-group, site2-user:site2-group, etc.). This is what dedicated/VPS hosting with cPanel "Account Isolation" gives you, or what managed WordPress hosts (WP Engine, Kinsta, Pressable) provide by default.

Check 2: Can PHP on site A read files from site B?

php
<?php
// drop this as test-cross-site.php in site1.com's directory
echo "<pre>";
echo "Current user (PHP process): " . posix_getpwuid(posix_geteuid())['name'] . "\n\n";

// Try to read site2's wp-config.php
$cross_path = '/home/yourname/public_html/site2.com/wp-config.php';
if (is_readable($cross_path)) {
    echo "DANGER: PHP on site1 can read site2's wp-config.php\n";
    echo "  -> Cross-site contamination is possible from this configuration.\n";
} else {
    echo "OK: PHP on site1 cannot read site2's wp-config.php\n";
    echo "  -> Either open_basedir, suEXEC, or different ownership is protecting you.\n";
}
?>

Browse to https://site1.com/test-cross-site.php. If you see "DANGER", you have the cross-contamination risk. Delete the file immediately after the test, leaving an unauthenticated script that confirms cross-site readability would be its own security problem if discovered.

Check 3: Are open_basedir, suEXEC, or per-site PHP-FPM pools in place?

bash
# Check whether PHP has open_basedir set per-site
php -i | grep -i open_basedir
# or, more reliably, check the actual loaded config:
php -i 2>/dev/null | grep -E "^\s*(open_basedir|disable_functions)" | head

# Check whether suEXEC / mod_ruid2 is loaded (Apache)
apachectl -M 2>/dev/null | grep -iE "suexec|ruid|itk"

# Check PHP-FPM pool config (modern hosts)
ls /etc/php/*/fpm/pool.d/ 2>/dev/null

If open_basedir is configured per-site and limits the include path to that site's directory, PHP code on site A cannot read files outside of site A's directory. If suEXEC or mod_ruid2 is in use, PHP runs as a per-site user (not a shared one), which physically prevents the cross-site reads.

If none of these protections are in place, your shared hosting is vulnerable to cross-site contamination as a structural property.

The four realistic fixes

Fix 1: Enable open_basedir per-site (host-dependent)

open_basedir is a PHP configuration directive that restricts which directories PHP can read, write, or include from. Setting it per-site in the host's PHP config blocks the cross-site reads at the language level.

Typical per-site open_basedir for site1.com:

code
open_basedir = /home/yourname/public_html/site1.com/:/tmp/:/var/lib/php/sessions/

The PHP process can read from the site's directory, the system temp directory, and the PHP session directory. It cannot read from any other site's directory.

How to enable depends on your host:

  • cPanel hosts: usually under "MultiPHP INI Editor" or "PHP Selector". Look for open_basedir as a per-domain setting.
  • Plesk hosts: under Hosting Settings → PHP Settings → Additional configuration directives.
  • Custom hosts: ask support to enable open_basedir per site in your PHP-FPM pool configurations.

Caveats: open_basedir has a long history of bypass tricks (notably via symlink race conditions and certain glob:// URL wrappers), so it's not a complete fix on its own. It's a meaningful defense layer; it's not airtight.

Fix 2: Enable suEXEC / mod_ruid2 / per-user PHP-FPM pools

This is the architectural fix: run each site's PHP as a different Linux user. Then standard Linux file permissions enforce the boundary; site A's PHP literally cannot read site B's files because the user account doesn't have permission.

The mechanisms:

  • suEXEC (Apache, older), runs CGI scripts as the file owner instead of the Apache user.
  • mod_ruid2 (Apache, more flexible), changes the Apache process's user/group on a per-virtualhost basis.
  • mod_itk (Apache), similar idea, simpler config.
  • PHP-FPM pools (modern, Nginx + Apache both), one PHP-FPM pool per site, each pool runs as a different system user. This is what serious shared hosts use.

In all four cases, file ownership has to be set per-site too: site1's files owned by site1-user, site2's owned by site2-user, etc. Standard 644 file permissions and 755 directory permissions then enforce the boundary.

How to enable: ask your host. Most quality cPanel hosts offer "Account Isolation" or "CageFS" as a paid add-on that delivers exactly this architecture. Hosts that don't offer it at any tier are saying their architecture is fundamentally shared; they're not the right product for sites with real traffic.

Fix 3: Use container-based or virtualized hosting

Modern managed WordPress hosts (Kinsta, WP Engine, Cloudways, Pressable, Pantheon, Flywheel) run each site in its own container or virtualized environment. The Linux user separation is enforced by the container runtime, not by Apache or PHP config. Cross-site contamination is structurally impossible.

The trade-off is cost, these hosts run $20-$100/site/month rather than the $5-$10/account-total of budget shared hosting. For one or two important sites it's the right answer; the cost of a single cleanup-after-cross-contamination usually exceeds a year of managed hosting.

Fix 4: Move to a VPS or dedicated server with per-site users

The DIY version of fix 3: run your sites on a $5-$20/month VPS (Digital Ocean, Vultr, Linode, Hetzner Cloud) and configure per-site users yourself. Each site gets:

  • A dedicated Linux user
  • An Nginx (or Apache) virtual host that proxies to the user's PHP-FPM pool
  • Per-site database user and database (in MySQL/MariaDB)
  • File permissions enforcing 750 directories, 640 files, owned by the per-site user

The labor is real. The result is the cross-site contamination problem disappears entirely.

A skeletal nginx + php-fpm per-site setup:

bash
# Create a dedicated user for site1
sudo adduser --system --group --shell /bin/bash site1
sudo mkdir -p /var/www/site1.com/public
sudo chown -R site1:site1 /var/www/site1.com
sudo chmod -R 750 /var/www/site1.com

# Create a PHP-FPM pool for site1 (Debian/Ubuntu path)
sudo tee /etc/php/8.3/fpm/pool.d/site1.conf <<EOF
[site1]
user = site1
group = site1
listen = /run/php/php-fpm-site1.sock
listen.owner = www-data
listen.group = www-data
pm = ondemand
pm.max_children = 10
php_admin_value[open_basedir] = /var/www/site1.com/:/tmp/
EOF

# Restart PHP-FPM
sudo systemctl restart php8.3-fpm

Then point each site's Nginx server block at its dedicated PHP-FPM socket. The architecture is identical to what high-end managed WordPress hosts provide, just built by hand.

A complete cross-site contamination check

Save as wp-cross-site-check.sh. Pass the path to ANY of your sites' WordPress directories; the script walks the parent and audits every sibling.

wp-cross-site-check.shAudits a shared hosting environment for cross-site contamination risk.Download
bash
#!/usr/bin/env bash
# wp-cross-site-check.sh, audit a shared hosting environment for cross-site contamination risk.
# Source: https://techearl.com/wordpress-shared-hosting-cross-site-contamination
# Site:   https://techearl.com/
# Reports the ownership, permissions, and PHP isolation status of every WordPress site
# in the same parent directory.
#
# Usage: ./wp-cross-site-check.sh /path/to/one-of-your-wordpress-sites

set -e
ONE_SITE="${1:-$PWD}"
PARENT=$(dirname "$ONE_SITE")
echo "========================================="
echo "  Cross-site contamination audit"
echo "  Parent directory: $PARENT"
echo "========================================="

# 1. Find every WordPress install in the parent
echo
echo "--- 1. WordPress sites in $PARENT ---"
find "$PARENT" -maxdepth 3 -name "wp-config.php" -type f 2>/dev/null | while read cfg; do
  site_dir=$(dirname "$cfg")
  owner=$(stat -c '%U:%G' "$cfg" 2>/dev/null || stat -f '%Su:%Sg' "$cfg")
  perms=$(stat -c '%a' "$cfg" 2>/dev/null || stat -f '%Lp' "$cfg")
  echo "  $site_dir"
  echo "    wp-config.php  perms=$perms  owner=$owner"
done

# 2. Are all the wp-config.php files owned by the same user?
echo
echo "--- 2. Ownership consistency check ---"
OWNERS=$(find "$PARENT" -maxdepth 3 -name "wp-config.php" -exec stat -c '%U' {} \; 2>/dev/null | sort -u)
NUM_OWNERS=$(echo "$OWNERS" | wc -l | tr -d ' ')
if [ "$NUM_OWNERS" = "1" ]; then
  echo "  WARNING: All wp-config.php files are owned by the same user: $OWNERS"
  echo "  -> Cross-site contamination is possible structurally."
else
  echo "  OK: Multiple owners detected, proper per-site isolation."
  echo "$OWNERS"
fi

# 3. PHP configuration
echo
echo "--- 3. PHP isolation configuration ---"
PHP_BIN=$(command -v php)
if [ -n "$PHP_BIN" ]; then
  OPENBASE=$($PHP_BIN -r 'echo ini_get("open_basedir");')
  if [ -n "$OPENBASE" ]; then
    echo "  open_basedir is set: $OPENBASE"
  else
    echo "  open_basedir is NOT set, PHP can read any file the running user owns."
  fi
  DISFNS=$($PHP_BIN -r 'echo ini_get("disable_functions");')
  echo "  disable_functions: ${DISFNS:-<none>}"
fi

# 4. Check whether Apache mod_suexec / mod_ruid2 / mod_itk is loaded
echo
echo "--- 4. Apache user-switching modules (if Apache) ---"
if command -v apachectl >/dev/null 2>&1; then
  apachectl -M 2>/dev/null | grep -iE "suexec|ruid|itk_module|mpm_itk" \
    || echo "  None loaded, Apache PHP runs as the shared web user."
else
  echo "  Apache not detected (Nginx or other)."
fi

# 5. Per-site PHP-FPM pool detection
echo
echo "--- 5. PHP-FPM pool configuration ---"
for pooldir in /etc/php/*/fpm/pool.d /etc/php-fpm.d; do
  if [ -d "$pooldir" ]; then
    echo "  $pooldir:"
    ls -1 "$pooldir"/*.conf 2>/dev/null | head -10 || echo "    (no pool configs found)"
  fi
done

# 6. Cross-read test: can we read wp-config.php from a sibling site?
echo
echo "--- 6. Practical cross-read test ---"
ME_CFG="$ONE_SITE/wp-config.php"
[ -f "$ME_CFG" ] || { echo "  Caller's wp-config not found at $ME_CFG; skipping."; exit 0; }
find "$PARENT" -maxdepth 3 -name "wp-config.php" -type f 2>/dev/null | while read sib; do
  if [ "$sib" != "$ME_CFG" ]; then
    if [ -r "$sib" ]; then
      echo "  READABLE from this shell: $sib"
    else
      echo "  NOT READABLE: $sib"
    fi
  fi
done

echo
echo "========================================="
echo "  If section 2 shows shared ownership AND"
echo "  section 6 shows readable siblings, you"
echo "  have the cross-contamination risk."
echo "========================================="

The shell-level cross-read in section 6 isn't exactly equivalent to a PHP-level cross-read (the shell runs as you, PHP may run as a different user), but if the shell can read sibling sites' files, the PHP-level read is also almost always possible. Use a real PHP test (the example from Check 2 above) for the authoritative answer.

Cleaning after cross-site contamination

Before the cleanup steps below, a hard truth worth stating up front: if the compromise is the above-doc-root variety (a process running in your hosting user's home directory rewriting WordPress files from RAM), and you are on shared hosting without SSH or full file-system access to your account, finding the malware from inside your account is essentially impossible. Every popular WordPress security plugin (Wordfence, Sucuri Security, iThemes Security Pro / Solid Security, Patchstack, MalCare, Jetpack Scan) operates inside the WordPress doc root and cannot see processes, dotfiles, or the home directory the malware actually lives in. SFTP-only access is similarly bounded; the cleaner can clean inside public_html/ and not above it. If the shared host itself has been compromised at a level above your account (the hypervisor, the shared filesystem, a sibling tenant escalating to the host's UID), the investigator does not have, and will never be given, the access required to find the malware from inside your hosting account. The realistic options in that scenario are: escalate to the host's security team with evidence (file-modification timestamps, reinjection logs, the persistence-check script output for what you can see), or move the site to hosting where you have shell access and can run the checks yourself. The deeper background is in section 11 of the persistence article.

If you confirm cross-contamination has happened (multiple sites in the same account compromised) and you have at least shell access to your hosting account, the cleanup has a strict order:

  1. Take ALL sites in the account offline simultaneously. Cleaning them one at a time means the cleaned ones get reinfected from the still-dirty ones within minutes.
  2. Snapshot every site's files and database for forensics.
  3. Kill the malware running above the WordPress directories first. The malware that drives shared-hosting cross-contamination is not inside any of your sites; it is sitting in the hosting user's home directory, one level up from public_html/, running as a process owned by your user. No site-level scanner reaches it. The same process reinfects every WordPress install under the account in parallel, which is why cross-site contamination in the wild almost always coincides with a wp-config.php reinjection campaign: same Linux user, same dotfiles (~/.bashrc, ~/.bash_profile, ~/.profile, ~/.zshrc), same stash location (~/.config/htop/), same process, every site hit at once. Killing the rogue process, cleaning the dotfiles, and deleting the stashed binary is step zero, before touching any individual site. Otherwise you can clean each site cleanly and they all get reinfected within seconds of coming back online. The full pattern is in section 11 of the persistence article and the gsyndication walkthrough.
  4. Pick one site at a time, in order of size (smallest first, they clean fastest and confirm your process). Run the full WordPress malware removal playbook including the persistence checks for each.
  5. Between sites, audit the shared directories: ~/tmp/, ~/.cache/, ~/cgi-bin/, anything outside the per-site directories where an attacker could stash a persistence script.
  6. Rotate every credential: every site's admin passwords, every site's database password, the hosting control panel password, the SFTP/SSH credentials. Stolen credentials from one site work against every other site in the account.
  7. Address the structural issue (one of the four fixes above). If you don't, you'll be doing this again.
  8. Bring sites back online one at a time, watching access logs for repeat probes.

Common mistakes

The patterns that make cross-site cleanups fail:

Cleaning one site fully, leaving the others "for later". The cleaned site gets reinfected from the dirty siblings within hours. All sites have to be offline simultaneously during cleanup.

Trusting that "this site is on the same account but in a different subdirectory so it's isolated". Subdirectories don't isolate anything. The Linux user is the same; the file system is the same. Subdirectory boundaries are visual organization, not security.

Assuming the hosting provider's "site isolation" feature is enabled. Many cPanel hosts advertise account isolation but only enable it on higher tiers, or require the customer to opt in via a setting. Run the test in this article to confirm what your account actually has.

Restoring all sites from backup simultaneously. If the backups predate the compromise, this works. If they don't (most hosting backups are 7-14 days deep), every backup is already infected and you've just reinstalled the malware on every site.

Forgetting that databases can also leak across sites. If all your databases share a single MySQL user with broad privileges (common on cheap shared hosts), the attacker who got into one site can write to every database. Each site should have its own database AND its own database user with privileges scoped to that one database only.

Cleaning the sites without changing the hosting tier or provider. If the cross-contamination happened once on this hosting product, it will happen again. The cleanup is necessary; changing the architecture is also necessary. Doing only one is half the job.

Frequently asked questions

See also

TagsWordPressSecurityShared HostingcPanelMulti-Siteopen_basedirsuEXECFile PermissionsHosting ArchitectureWordfenceSucuriAbove Doc RootReinfection
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Visitors see a fake Cloudflare verification on your WordPress site asking them to paste a command. That's ClickFix. Detection, removal, and persistence cleanup so it doesn't return.

The Fake Cloudflare Verification Attack on WordPress (ClickFix): What It Is and How to Remove It

Visitors to your WordPress site see a fake 'Cloudflare verification' page telling them to paste a command into Windows Run or Terminal. That's ClickFix, the social-engineering campaign that first appeared in early 2024 and exploded across compromised WordPress sites by autumn. What it does, where the injection lives in your site, and how to clean it without missing the persistence.