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:
/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.phpofsite2.com(DB credentials, salts, API keys) - Write
index.phpofsite3.com(drop a backdoor or inject ClickFix script) - Modify
wp-content/themes/X/functions.phpon 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_optionsandwp-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?
# 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
// 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?
# 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/nullIf 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:
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_basediras a per-domain setting. - Plesk hosts: under Hosting Settings → PHP Settings → Additional configuration directives.
- Custom hosts: ask support to enable
open_basedirper 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:
# 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-fpmThen 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.
#!/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:
- 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.
- Snapshot every site's files and database for forensics.
- 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 awp-config.phpreinjection 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. - 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.
- Between sites, audit the shared directories:
~/tmp/,~/.cache/,~/cgi-bin/, anything outside the per-site directories where an attacker could stash a persistence script. - 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.
- Address the structural issue (one of the four fixes above). If you don't, you'll be doing this again.
- 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
- How to Remove WordPress Malware: The Practitioner's Playbook: the broader cleanup methodology. Cross-contamination doesn't change the per-site cleanup steps; it just means you do them on every site simultaneously.
- Why WordPress Malware Keeps Coming Back: Persistence Mechanisms: the file-system and database persistence layers. Each affected site needs the full persistence cleanup, plus you audit the shared account directories where the attacker may have stashed pivot scripts.
- How to Find the Original Entry Point in a WordPress Compromise: on a multi-site shared account, the entry point is sometimes on a site you haven't even noticed is compromised yet. Log analysis identifies which site was first.
- The Fake Cloudflare Verification Attack on WordPress (ClickFix): a common payload spread via cross-site contamination, once one site is compromised, the JavaScript injection often propagates across every site under the same account.
- How to Detect and Remove Fake WordPress Admin Users: fake admin accounts often appear simultaneously across multiple sites in a shared account.
- Why Wordfence Got Silently Disabled (and How to Stop It Happening Again): security plugins on every site in the account may all be disabled at once.
- How to Change a WordPress Password: in a cross-contamination cleanup, every admin password on every affected site has to be rotated.





