Let's talk
security

One shared process pool is how a single compromised site reaches all the others

Several sites on one server, all running through one application process pool, all executing as the same system user. One of those sites was compromised. Every other site on the box was compromised within the same window, and there was nothing clever about how. The attacker did not need a second exploit. Once code ran as the shared user, every other site’s directory tree was already writable by the process it had landed in.

That is not a lateral movement technique. It is just file permissions doing exactly what they were configured to do.

The shape of the mistake

The default configuration for a general-purpose web stack runs the application interpreter as one service-level user for everything on the machine. It is the path of least resistance: one pool to configure, one user to own the files, one set of permissions to reason about. If you never think about it, you get it.

The consequence only becomes visible under compromise. Ask the question directly — if arbitrary code executes inside this process, what can it read and what can it write? — and the answer on a shared pool is: every site on the host. Configuration files, which contain database credentials. Content directories, which are writable so the application can accept uploads. Which means every database on the host too, since the credentials to reach them are sitting in files the process can read.

The number of sites on the machine is therefore also the multiplier on any single vulnerability. An outdated extension on the least important site — a demo, a parked domain, something nobody has logged into for a year — is a full compromise of the most important one. The neglected site is always the one that falls, because neglect is what makes it fall, and it is precisely the site nobody thought was worth hardening.

What we changed

Each site now runs on its own process pool, executing as its own dedicated system user, with the interpreter’s path confinement set to that site’s directory tree and nothing else.

Three properties come out of that, and it is worth separating them because they fail independently.

Separate users. Site A’s files are not owned by, and not writable by, the user that site B’s code executes as. This is the one that actually contains the breach. Standard permission bits do the work; nothing exotic is required.

Separate pools. Each site gets its own set of worker processes. A site that hangs on a slow database query exhausts its own workers and returns errors on its own domain. On a shared pool it consumes the workers for everything, and the failure presents as “the server is down” rather than “one site is broken”. We had seen that symptom before and misread it as a capacity problem. It was an isolation problem wearing a capacity problem’s clothes.

Path confinement. The interpreter refuses to open files outside a configured list of directories. This is the belt-and-braces layer for the case where permissions have been set wrong somewhere — and on a machine with any history, permissions have been set wrong somewhere. It also blocks the common reconnaissance step of reading system files to enumerate what else is on the host.

The cost is real but bounded: more configuration files, more users, more memory because pools do not share worker capacity, and a deployment process that has to get file ownership right per site. Set against the cost of restoring an entire estate, that is not a difficult trade.

The configuration that was doing nothing at all

The other half of this story is worth telling separately, because it is a more general failure.

The sites carried the usual application-level protections — extensions that write directory-level access rules to block direct execution of files in upload paths, deny access to configuration and backup files, and refuse requests for dotfiles. Those protections were configured. Their control panels showed them as active. They were doing absolutely nothing, because the web server on this machine does not read per-directory access files at all. That mechanism belongs to a different server, and this was not that server.

So the entire protective layer was decorative. Not weak, not misconfigured — inert. The rules were written to disk correctly and never consulted by anything.

That is how 539 MB of site backups sat publicly downloadable without anyone noticing. The plugin that created them wrote a rule denying access to the archive directory. The rule was never read. Anyone who guessed the path had a full copy of the site and its database.

The lesson generalises beyond web servers: a security control you have not observed refusing something is not a control, it is an intention. Configuring it is not the test. Watching it deny a request you deliberately made is the test. We now verify by hand — request the file that should be forbidden, confirm the response is a denial, and confirm the denial came from the layer that was supposed to produce it. If the request succeeds, the control does not exist, whatever its status page claims.

The general protections now live in a single server-level configuration fragment included by every site: no interpreter execution anywhere under upload directories, no serving of configuration or archive file extensions, no dotfiles. One file, in the layer that actually processes requests, included everywhere. Nothing depends on an application plugin to enforce anything at the transport level, because an application plugin cannot.

Ordering, and the rule that never fired

One more variant of the same failure, from the same hardening pass. Access rules for the remote shell service on this platform are assembled from a directory of fragments, and the service applies the first matching directive it encounters rather than the last. The fragments are read in lexical filename order. A hardening file placed after a vendor-supplied default file — later in the alphabet — is read second and therefore loses every conflict silently.

There is no error. There is no warning. The configuration is valid. It simply never applies, because something earlier already answered the question.

We renamed the hardening fragment so it sorts first. That is the entire fix, and it is the kind of fix that only exists because somebody read the documentation on precedence rather than assuming the obvious. Assumed precedence is one of the most reliable sources of security controls that are present in the repository and absent in reality.

The principle

Isolation is not a hardening measure you apply to important systems. It is a decision about how much a single failure costs, made in advance, usually by default, usually without anyone noticing they made it.

Two questions are worth asking of any shared environment, and neither requires a security background to answer:

  1. If arbitrary code runs in this process, what else can it read and write? Answer it concretely, by listing directories, not by reasoning about likelihood.
  2. Which of my protective controls have I actually watched deny a request? Everything else is assumption.

The credentials, the patching, the scanning — all of that reduces the probability of a compromise. Isolation reduces the cost of one. You need both, but only one of them still helps you on the day you find out the other did not work.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch