Shared Hosting and Cross-Site Contamination: When the Infection Jumps from One Site to Another

Same system user, shared PHP pools, forgotten staging copies: why one infected site contaminates the others on the account, and how to truly isolate them.

Four website windows: three inside the same “shared user” box, with red arrows from the first, infected one to the other two; a blue barrier with a shield stops the infection in front of the fourth, in the “dedicated user” box

An agency runs about twenty of its clients’ WordPress sites on a single hosting account. One of them, an old brochure site nobody has updated in years, is compromised through an abandoned plugin. The site is carefully cleaned, but a week later the malware shows up on three different sites, then on five more. Every time one is fixed, another gets sick. It isn’t bad luck, and it isn’t a new attack every day: it’s cross-site contamination, and it comes from how the account and the server are organized.

On a shared account the perimeter of the infection is not the site: it’s everything the site’s system user can read and write.

In this article we look at why an infection jumps from one site to another, which configurations make it possible (a single system user, shared PHP pools, overly broad permissions, forgotten installations) and how to plan a cleanup that doesn’t leave the door open from the site next door.

One system user, many sites

On Linux every process runs with the identity of a user, and file permissions say what that user can do. When PHP runs WordPress, it does so as a specific user: it can be www-data or apache in the simplest setups, or the hosting account’s user when the server uses PHP-FPM, suEXEC or LiteSpeed with one process per account. Any PHP code run by that site, including a webshell’s, has exactly the same rights.

If ten sites belong to the same user, a webshell uploaded to any one of them can read and modify the files of all ten. No further vulnerability is needed, no privilege escalation: to the operating system they’re simply files with the same owner. Many WordPress malware kits know this well and, as soon as they run, look for every wp-config.php file reachable from the home folder, read each site’s database credentials and inject code into the index.php files, themes and plugins of every installation they find.

The same holds, on a larger scale, for badly configured servers where every site of every client runs as www-data: there the perimeter isn’t the account, it’s the entire machine. It’s a setup you still run into on hand-built VPSs, where Apache with mod_php or a single PHP-FPM pool serves dozens of virtual hosts.

Two-column diagram. On the left, four WordPress sites under the same home folder, served by a single PHP-FPM pool with the same system user: a webshell in the first site reaches the files and wp-config.php of all the others with red arrows. On the right, the same four sites with one user, one PHP-FPM pool and one open_basedir each: the red arrow leaving the compromised site stops against the boundary of its user.
With a single system user, one site’s webshell writes into everyone’s files; with one user and one pool per site, the infection stays contained.

How cPanel and Plesk accounts are built

In the most common hosting panels the boundary between system users doesn’t necessarily coincide with the boundary between sites, and it’s worth knowing where it falls.

In cPanel each account corresponds to a system user with its own home, usually /home/username/. The main domain lives in public_html, but the additional domains (addon domains) and subdomains created from the same account also live under that home, often in subfolders of public_html itself, and they all run as the same user. A cPanel account with fifteen addon domains is, from a security standpoint, a single site with fifteen entrances.

In Plesk the unit of isolation is the subscription: each subscription has its own system user, while the domains and subdomains added inside the same subscription share that user. Splitting sites into separate subscriptions is therefore the way to get different users. On both panels, server choices also matter: with PHP-FPM each account normally has its own pool, while older handlers or custom configurations can make everything run as the web server’s user.

The first step of any shared hosting analysis is therefore to draw the map: which sites exist, under which user, with which PHP process. A useful command to see which users the PHP-FPM processes run as:

# PHP-FPM processes grouped by user and by pool
ps -eo user:20,args | grep '[p]hp-fpm: pool' | sort | uniq -c

# Pools defined in the configuration (typical path on Debian and Ubuntu)
grep -H -E '^\[|^user|^listen ' /etc/php/*/fpm/pool.d/*.conf

Forgotten installations

In the cross-contamination cases we analyze, the entry point is very often a site the owner didn’t remember having. The forms are always the same: a staging copy created years ago in public_html/staging or public_html/test, the previous version of the site renamed to old or vecchio after a redesign, a test installation left half-finished, a .zip archive of the site containing a readable wp-config.php, an expired domain whose WordPress is still on disk.

These copies have three characteristics that make them ideal for an attack: nobody updates them, they often contain plugins that have since become vulnerable, and if they’re reachable from the web even just through the main domain’s path, they can be scanned like any other site. Once inside, the attacker is in the same home as the well-maintained, up-to-date sites.

Folder tree of a hosting account under /home/cliente: public_html with the updated main site, two addon domains, a staging folder with WordPress 5.8, an old folder with the previous version of the site, a sito-backup.zip archive and an expired domain. The forgotten items are highlighted in red with the label typical entry point, while a box indicates that all the folders belong to the same system user.
The map of a shared account: forgotten copies sit next to the well-maintained sites, with the same owner.

To take inventory you can’t trust the list of domains in the panel: you have to look for installations on disk. The following commands find every WordPress present in a home, the archives that might contain credentials and the core version of each installation:

# All WordPress installations under the account's home
find /home/cliente -name wp-config.php -not -path '*/node_modules/*' 2>/dev/null

# Core version of each one
find /home/cliente -path '*/wp-includes/version.php' -exec grep -H '^\$wp_version' {} \;

# Forgotten archives and dumps in public folders
find /home/cliente -type f \( -name '*.zip' -o -name '*.tar.gz' -o -name '*.sql' -o -name '*.sql.gz' \) \
  -path '*public_html*' -printf '%TY-%Tm-%Td %10s %p\n' | sort

Permissions, owners and open_basedir

When sites belong to different users, the separation holds only if permissions respect it. A home with 755 permissions lets all of the server’s users list its contents; a wp-config.php with 644 permissions is readable by anyone who can reach it, and with it the database credentials. The basic rules are simple: homes at 711 or 750 (depending on how the web server accesses the files), folders at 755, files at 644 and wp-config.php at 600 or 640, always with the correct owner. We covered this in detail in the article on hardening wp-config and permissions.

A file with 777 permissions or a folder writable by everyone cancels any separation between users. Finding them is quick:

# Files and folders writable by anyone
find /home -xdev \( -type f -o -type d \) -perm -0002 -not -type l 2>/dev/null

# Files inside a home that don't belong to its user
find /home/cliente -not -user cliente -printf '%u %p\n' 2>/dev/null | head -50

# wp-config.php files readable by the rest of the world
find /home -name wp-config.php -perm -0004 2>/dev/null

Then there’s open_basedir, the PHP directive that limits the paths PHP’s file functions can access. Set for each site to its own folder, it keeps a script from opening another site’s files with fopen() or include. It’s useful, but you need to understand its limit: it applies only to PHP functions. A command launched with exec(), system() or proc_open() is an external process and completely ignores open_basedir. On its own it is therefore not a security boundary: it’s an extra net that only really works alongside separate users and, where possible, disable_functions on the execution functions.

One last detail that’s often overlooked is shared folders: /tmp and the PHP sessions folder. If all the sites write there, a compromised site can read the others’ session files or leave payloads there to be included. Each pool should have its own upload_tmp_dir and session.save_path, inside the site’s home.

One PHP-FPM pool per site

On servers we manage directly, or on a VPS, the structural solution is to give each site its own system user and PHP-FPM pool. The web server (NGINX, Apache with mod_proxy_fcgi or LiteSpeed) passes PHP requests to the right pool’s socket, and each pool runs with its own user and its own restrictions. An example pool for one site:

; /etc/php/8.3/fpm/pool.d/sito1.conf
[sito1]
user = sito1
group = sito1

listen = /run/php/php8.3-fpm-sito1.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 30s

php_admin_value[open_basedir] = /home/sito1/:/usr/share/php/
php_admin_value[upload_tmp_dir] = /home/sito1/tmp
php_admin_value[session.save_path] = /home/sito1/sessions
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_admin_flag[allow_url_include] = off

Directives set with php_admin_value can’t be changed by the site through ini_set() or a .user.ini file, which is what makes them reliable. The disable_functions list needs adapting: some backup or image optimization plugins use execution functions and should be tested in staging first. On the NGINX side, the virtual host block points to the pool’s socket:

location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm-sito1.sock;
}

With Apache, besides the pool, it’s worth replacing Options FollowSymLinks with SymLinksIfOwnerMatch: a classic attack in shared environments is to create, from the compromised site, a symbolic link to another user’s wp-config.php and have the web server serve it as text. On commercial shared hosting these problems are often solved by solutions like CloudLinux with CageFS, which gives each user a virtualized file system; still, it’s worth checking what your own provider offers instead of taking it for granted.

Why cleaning one site at a time doesn’t work

Back to the agency from the beginning. The problem wasn’t the quality of the cleanup on individual sites, but the order of operations. While the first site was being cleaned, the others, still infected, had write access to the same files: a single request to one of their backdoors, or a scheduled task, was enough to reinfect the one just fixed. Every partial intervention was bound to last only a few hours.

There are also elements that belong to the user and not the site, so no cleanup of an individual WordPress touches them: the system user’s crontab, PHP processes left in memory even after the file that started them was deleted, the SSH keys in ~/.ssh/authorized_keys, modified .bashrc or .profile files, and the .htaccess and .user.ini files in parent folders, which apply to all the sites below. An auto_prepend_file set in a .user.ini at the root of public_html, for example, makes a PHP file run before every script of every site under that folder.

# PHP and .htaccess directives that apply to multiple sites
find /home/cliente -maxdepth 3 \( -name '.user.ini' -o -name 'php.ini' -o -name '.htaccess' \) \
  -exec grep -H -i -E 'auto_prepend_file|auto_append_file|php_value' {} \;

# PHP files recently modified across the whole home, not just one site
find /home/cliente -name '*.php' -newermt '2026-08-01' -printf '%TY-%Tm-%Td %TH:%TM %p\n' | sort

# Core integrity of every installation found
for cfg in $(find /home/cliente -name wp-config.php); do
  d=$(dirname "$cfg"); echo "== $d"
  sudo -u cliente wp --path="$d" core verify-checksums --quiet || true
done

Databases deserve attention too: if all the sites use the same MySQL user, whoever has read one site’s credentials can write into the others’ databases, for example by creating hidden administrators or inserting payloads into options. Each site should have its own database user with privileges limited to its own schema.

A six-step isolation plan

Cleaning up a shared account should be treated as a single intervention across the user’s entire perimeter, not as a series of interventions on individual sites. The approach we follow is this:

  1. Inventory. All installations on disk, all databases, all system users and pools involved, the domains pointing to the account. Copies that are no longer needed are archived outside the public folder and then deleted.
  2. Freeze. The common persistence points are stopped: the user’s crontab, running processes, unrecognized SSH keys. Where possible the account is put into maintenance mode, so no backdoor can be called during the work.
  3. Simultaneous cleanup. All of the same user’s sites are cleaned in the same work window: core and plugins replaced with original copies, foreign files removed, databases checked, before reopening any site.
  4. Separation. One system user, one PHP-FPM pool and one database user per site, with their own open_basedir, temporary folders and sessions; on cPanel and Plesk, separate accounts or subscriptions.
  5. Credentials. All passwords are changed after the cleanup, not before: database, WordPress administrators, FTP and SFTP, hosting panel; the salts in each wp-config.php are regenerated.
  6. Monitoring. File integrity and log checks for the following weeks, because a reinfection, if it comes, comes in the first few days.
Sequence of six steps linked by arrows: inventory of installations, freezing common persistence, simultaneous cleanup of all the user's sites, separation into distinct users and PHP-FPM pools, changing credentials, monitoring. Below, a red bar shows the wrong approach, one site at a time, with an arrow looping back and the label reinfection from the site next door.
The cleanup plan for a shared account: first stop everything and clean together, then separate, and only at the end change the credentials.

Order matters. Changing passwords before removing the backdoors is pointless, because the attacker doesn’t need them to get back in; separating users before cleaning means distributing infected files into new accounts; cleaning without having stopped the cron means working while someone puts everything back the way it was.

When you can’t change the hosting

You don’t always control the server. On cheap shared hosting there’s a single user and you can’t create pools; in that case you have to work on the attack surface: keep the number of sites in the same account to a minimum, eliminate every non-essential installation, block PHP execution in upload folders, keep everything that remains updated and remove abandoned plugins. If the sites belong to different clients, the most sensible choice is to move them to separate accounts: the cost of one more account is almost always lower than that of a chain reinfection.

When we step in on an infection, the first question we ask ourselves is exactly this: how many sites share the same user? We work at the system level, not just the application level, so we check the crontab, processes, permissions and PHP configuration of the entire account, in addition to the WordPress files. The guaranteed cleanup in 6, 12 or 24 hours applies to each WordPress installation, and on a shared account it includes analysis of the shared elements that could reinfect it. If you manage several sites on the same hosting and one of them has been compromised, contact us before you start cleaning them one by one.

THE NEXT STEP

Is your site
infected?

Every hour counts: the malware keeps hitting visitors and customers, and Google, your host and the blacklists won’t wait. Choose a plan, pay online and share your access: the cleanup starts right away, at a fixed price and with guaranteed times.

Request a cleanup

Takes you to the plans: cleanup within 6, 12 or 24 hours of receiving access.

MANAGED SERVER S.R.L.info@malwarecleanup.com +39 02 5656 9681

Monday – Friday · 9:30am – 7:30pm Italian time (CET)

Prefer an online form?
Contact us at managedserver.it ↗