Part 8 — The Practical WordPress VPS Incident-Response Playbook

What I would keep on the server owner’s desk after going through all of this

By SepedaTua — CrushEdge.com


After going through this incident, I wanted one thing left behind:

a short playbook I could actually use next time.

Not 50 pages of theory.

Not a giant enterprise security framework.

Just:

“I think my WordPress VPS has been hacked. What do I do now?”

So this is the version I would save.


Phase 1 — Don’t panic and don’t start deleting

The first instinct is usually:

rm suspicious-file.php

Don’t.

Not yet.

If this is a real compromise, those files may be useful evidence.

Instead, record:

date -u
hostname
uptime

Then preserve suspicious files somewhere outside the web root if possible.

For example:

mkdir -p /root/incident-evidence
cp -a /path/to/suspicious.php /root/incident-evidence/
sha256sum /root/incident-evidence/suspicious.php

The SHA-256 hash gives you a simple identity for the evidence.


Phase 2 — Find out what is actually running

Start with:

ps auxww

Then look at the busiest processes:

ps -eo pid,user,%cpu,%mem,etime,args \
--sort=-%cpu |
head -30

If you find an unfamiliar process consuming huge CPU, investigate it.

Don’t immediately kill it if you’re still collecting evidence.


Phase 3 — Check listening services

Run:

ss -lntup

Make a list.

For every listening port ask:

“Why is this here?”

If the answer is:

“I don’t know.”

that’s your next investigation target.


Phase 4 — Check persistence

Cron

crontab -l

Then:

find /var/spool/cron /etc/cron.d \
-type f \
-print \
2>/dev/null

Search:

grep -RInE \
'curl|wget|bash|sh|php|python|perl|nc|socat' \
/var/spool/cron /etc/cron* \
2>/dev/null

systemd

find /etc/systemd/system \
-type f \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %p\n' \
2>/dev/null |
sort

And:

systemctl list-unit-files --type=service

Phase 5 — Check SSH

Find authorized keys:

find /home /root \
-path '*/.ssh/authorized_keys' \
-type f \
-print \
2>/dev/null

Inspect them.

Then check SSH configuration:

sshd -T | grep -Ei \
'permitrootlogin|passwordauthentication|pubkeyauthentication'

Don’t change SSH configuration while disconnected from the machine.

Keep your existing session open and test a second session first.


Phase 6 — Check the websites

For each website:

find /home/customer01/public_html \
-type f \
-newermt '2026-08-01' \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %s | %p\n' \
2>/dev/null |
sort

Adjust the date to the suspected incident window.

You’re looking for unusual bursts and files that don’t belong.


Phase 7 — Search PHP

Start with:

grep -RInE \
'base64_decode\s*\(|gzinflate\s*\(|gzdecode\s*\(|str_rot13\s*\(|eval\s*\(|shell_exec\s*\(|passthru\s*\(|proc_open\s*\(|popen\s*\(' \
/home/customer01/public_html \
--include='*.php' \
--include='*.phtml' \
--include='*.php5' \
--include='*.php7' \
2>/dev/null

Then investigate the results.

Don’t blindly delete everything.


Phase 8 — Check fake images

This was one of my favorite checks from the investigation:

find /home/customer01/public_html -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \
   -o -iname '*.gif' -o -iname '*.webp' -o -iname '*.ico' \
   -o -iname '*.bmp' -o -iname '*.avif' \) \
-print0 2>/dev/null |
while IFS= read -r -d '' f; do
    t=$(file -b "$f")

    case "$t" in
        *"PHP script"*|*"HTML document"*|*"ASCII text"*|*"Unicode text"*)
            printf '%s | %s\n' "$f" "$t"
            ;;
    esac
done

A result doesn’t automatically mean malware.

But it gives you something worth investigating.


Phase 9 — Check PHP inside uploads

find /home/customer01/public_html/wp-content/uploads \
-type f \
\( -name '*.php' -o -name '*.phtml' -o -name '*.php5' -o -name '*.php7' \) \
-print \
2>/dev/null

Unexpected PHP in uploads is a serious red flag on a typical WordPress installation.


Phase 10 — Check executables

find /home \
-type f \
-executable \
-printf '%m %u:%g %s %p\n' \
2>/dev/null

And specifically look for ELF binaries:

find /home/customer01/public_html \
-type f \
-print0 2>/dev/null |
while IFS= read -r -d '' f; do
    t=$(file -b "$f")

    case "$t" in
        *"ELF"*)
            printf '%s | %s\n' "$f" "$t"
            ;;
    esac
done

A random ELF binary inside a WordPress installation deserves immediate attention.


Phase 11 — Check for miners

Search for obvious indicators:

grep -RInE \
'xmrig|stratum\+tcp|stratum\+ssl|cryptonight|randomx|monero|wallet' \
/home \
2>/dev/null

Then inspect suspicious processes:

ps -eo pid,user,%cpu,%mem,etime,args \
--sort=-%cpu |
head -30

And network connections:

ss -tpn

Phase 12 — Check WordPress administrators

If WP-CLI is available:

wp user list \
--role=administrator \
--path=/home/customer01/public_html

Look for accounts that shouldn’t exist.

Also check application passwords and other authentication mechanisms where applicable.


Phase 13 — Check plugins and themes

wp plugin list \
--path=/home/customer01/public_html
wp theme list \
--path=/home/customer01/public_html

Remove software that isn’t needed.

Update software that is needed.

And if a plugin has a history of vulnerabilities, don’t keep it simply because:

“I’ve always used it.”


Phase 14 — Check MySQL

Inside MariaDB/MySQL:

SELECT User, Host
FROM mysql.user
ORDER BY User, Host;

Then inspect remote accounts:

SHOW GRANTS FOR 'some_user'@'%';

Pay special attention to:

%

and administrative privileges.


Phase 15 — Close unnecessary MySQL exposure

Check:

ss -lntp | grep ':3306'

If MariaDB is listening publicly, ask:

“Does it really need to?”

If not, keep it private.

For temporary remote applications, my preferred approach is:

Temporary VPS
      │
      │ SSH tunnel
      ▼
Main VPS
      │
      ▼
127.0.0.1:3306

rather than:

Internet
   │
   ▼
0.0.0.0:3306

Phase 16 — Rotate credentials

After a confirmed compromise, I would rotate:

root password
SSH keys
Virtualmin credentials
WordPress administrators
database passwords
API tokens
SMTP credentials
cloud/API credentials
backup credentials

Especially anything that was stored on the compromised server.


Phase 17 — Decide whether to clean or restore

This is the decision point.

If you have:

known-good backup
+
confirmed compromise

I generally prefer:

wipe
↓
restore
↓
verify
↓
harden

rather than:

delete malware #1
↓
search
↓
delete malware #2
↓
search
↓
discover malware #3
↓
repeat forever

Cleaning can be appropriate.

But restoration gives you a much clearer baseline when a trustworthy backup exists.


Phase 18 — Don’t restore blindly

Before selecting a backup, ask:

“When do I know the server was clean?”

If the answer is:

“I’m not sure.”

then compare multiple historical backups.

For example:

August 23
August 22
August 21
August 20
August 19
August 18
August 17
August 16

You may discover that suspicious files first appear between two restore points.

That’s valuable information.


Phase 19 — After restoration, scan again

Don’t assume:

“Backup = clean.”

Run the same checks again.

For example:

find /home/customer01/public_html \
-type f \
-name '*.php' \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %s | %p\n' \
2>/dev/null |
sort

Then:

find /home/customer01/public_html \
-type f \
-executable \
-print \
2>/dev/null

Then the fake-image scan.

Then the webshell pattern scan.

Then check:

ss -lntup

and:

systemctl list-unit-files --type=service

Phase 20 — Monitor after going live

This is where I would spend the next few hours.

Watch:

tail -f /var/log/...

depending on your distribution and Virtualmin logging setup.

Watch CPU:

top

Watch connections:

ss -tpn

And watch for unexpected filesystem changes.

The first few hours after restoration are important.


The final “normal server” test

Before I consider the incident closed, I want to be able to answer yes to these:

[✓] I know which websites are installed.
[✓] I know which users own them.
[✓] I know which WordPress administrators exist.
[✓] I know which plugins are installed.
[✓] I know which services are running.
[✓] I know which ports are exposed.
[✓] I know which MySQL users exist.
[✓] I know which MySQL users can connect remotely.
[✓] I know which SSH keys are authorized.
[✓] I know which cron jobs exist.
[✓] I know where my backups are.
[✓] I have tested a restore.

If I can’t answer one of those, that’s the next thing I investigate.


And that’s where I would officially close the incident

Not when:

Apache starts

Not when:

WordPress homepage loads

And not when:

malware scanner says "clean"

I’d close it when:

known-good restore
+
credential rotation
+
reduced attack surface
+
verified accounts
+
verified services
+
verified backups
+
monitoring

are all in place.

That’s a much more meaningful definition of “recovered.”


One final note

I originally started this investigation because some files looked wrong.

A few fake images.

A suspicious PHP file.

Some encoded code.

A webshell.

Then the investigation expanded into logs, timestamps, backups, MySQL, persistence, and network exposure.

That’s normal.

A real compromise rarely stays neatly inside one folder.

But you also don’t need to become a professional forensic investigator to respond intelligently.

Start with:

file
find
grep
stat
ps
ss

Add good backups and sensible isolation.

Those boring tools can take you surprisingly far.

And hopefully, the next time I see something like:

something-random.png

I won’t have to spend two sleepless nights wondering whether it is actually an image.

I’ll just run:

file something-random.png

and let the file answer for itself.

No Comments

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.