EN

Anatomy of a webshell breach: from upload to root

On a shared VPS, four days passed between the webshell being uploaded and the actual attack. The entry point was an uploader that also ran PHP.

Close-up photograph of a metal padlock on a door, in a cool blue tone.
Photo: Kaffeebart (Unsplash)

We were handed a shared VPS running several separate Laravel applications side by side: a public marketing site, a few APIs, an admin panel with a file manager. The operator proudly noted that SSH was “only reachable over the VPN”. That was true, and precisely why it was misleading. The attacker never used a single SSH connection: they came in over public HTTPS the whole time, through the web application.

The case is instructive exactly because there is nothing exotic in it. Every step is a well-known, long-documented mistake, and every step could have been prevented by a line or two of configuration.

The entry point: a file uploader that also executed PHP

The admin application had a file uploader. Uploaded files landed in Laravel's usual place, under storage/app/public/files/…, which the public/storage symlink exposes to the outside. Validation checked the extension and the image header, but not the content.

The attacker uploaded a polyglot file: a valid JPEG header at the front, PHP code after it. An image to the browser, a program to PHP. That alone would not have been a problem: an uploaded .php file is only dangerous if the server actually runs it. Here it did, because the nginx vhost handed every .php to PHP-FPM, regardless of where it sat in the filesystem:

# The vulnerable pattern: ANY .php executes, including in the upload dir
location ~ \.php$ {
    fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
    include fastcgi_params;
}

In a Laravel application exactly one PHP file has any business running: the public/index.php front controller. Every other .php, especially one in an upload directory, is either a bug or an attack. So the correct pattern does not enumerate what to forbid; it names the single thing that is allowed:

# The fix: only the front controller may run, every other .php is denied
location = /index.php {
    fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
    include fastcgi_params;
}
location ~ \.php$ {
    deny all;
    return 403;
}

That one block rules out the entire attack class: a webshell can land anywhere it likes, and it will not start.

The four days when nothing seemed to happen

In the logs, four days passed between the upload and the actual attack. The webshell went up on day zero, and a Telegram bot fetched it once that day, the classic “I’ve marked it, I’ll come back later” pattern. Early on day four the attacker returned and began driving the shell with POST requests.

That gap matters. A breach is not a moment but a process, and the best chance to catch it is in exactly that quiet window. A simple alert on “a new .php file has appeared in an upload or asset directory” would have sat there unused for four days.

From deployer to root

The webshell ran as the PHP-FPM user; call it deployer. That account was not a sudoer, so a password would not have helped. Instead the attacker pulled down a few-megabyte toolkit through the shell and became root with a local kernel exploit.

As root, their first move was a hidden door: a new line in /etc/passwd with user and group ID 0, under a name that mimics a legitimate system account. To a casual glance such a line looks normal; in reality it is full root access.

The lesson here is twofold. First, the PHP-FPM user should run with as few privileges as possible, isolated. Second, and this is the uncomfortable part: after a root-level compromise, no amount of cleanup gives full certainty. A root attacker can modify binaries, forge timestamps, wipe logs. Cleanup limits the damage; the certain answer is a rebuild.

The backdoor that dialled outward

The most interesting piece of persistence was a gsocket (reverse-relay) backdoor, on two legs: an hourly user cron and a root systemd service, both running the binary under a name disguised as a harmless kernel process.

This is where the VPN comfort comes back around. gsocket does not open an inbound port that a firewall could block. Instead it builds a connection outward, through a relay network in the middle. That needs no inbound rule, no SSH, no open port. The “SSH only over the VPN” rule held the entire time, and it was entirely irrelevant, because the attacker never needed to come in: the server went out to them.

The practical takeaway: inbound filtering matters, but egress filtering matters at least as much. A production server rarely has any business making arbitrary outbound connections.

What the attacker was after: SEO spam

Not data theft, not ransomware. A few lines were prepended to the public site's index.php that served search-engine crawlers (Googlebot, Bingbot and friends, matched on the User-Agent) a different, spam payload from an external CDN, while real visitors got the normal site. This is “cloaking”, where the goal is to borrow the infected, well-reputed domain to rank someone else’s content.

There is an insidious aftereffect too: with the uploaded verification file, the attacker can register themselves as the domain owner in Google Search Console. Deleting the local file does not undo that: it has to be removed in Search Console separately.

What we took away

It was a chain of one-line mistakes, so the defence assembles line by line as well:

  • Only the front controller may run. The nginx index.php-only pattern rules out the whole webshell class, wherever the file ends up.
  • Uploads should not live under the webroot, or if they must, PHP execution there must be explicitly denied. Validate uploads by content, not just extension and header.
  • Run the PHP-FPM user with minimal privilege, so a webshell does not equal losing the whole machine.
  • Filter egress. Modern backdoors dial outward; an inbound-only firewall is false comfort.
  • Watch for change. A new .php in an upload directory, a new line in /etc/passwd, a new systemd unit: these are cheap, high-signal alerts.
  • Keep logs long enough. Here the system journal reached back three days, the web server log two weeks. For a breach that lurks for several days, that is the difference between being able to reconstruct the entry point and not.
  • Rebuild after a root compromise. It is the inconvenient but only truly reliable answer.

None of these is new. That is the whole point: the break-in needed no zero-day, only a few skipped defaults. The defence needs no more either: those few lines simply have to be written before they are needed.