Part 5 of a real-world WordPress VPS compromise investigation
By SepedaTua — CrushEdge.com
By now, the server was restored.
That was the part I cared about most.
But I didn’t want this whole incident to end with:
“Well, I got hacked, restored a backup, and hopefully everything is fine.”
That’s not very useful to anyone else.
So I took the things I actually found during the investigation and turned them into a practical checklist.
Not a fancy commercial malware scanner.
Just Linux commands, find, grep, file, and a bit of common sense.
If you run WordPress on a VPS, you can do quite a lot with those tools.
First: understand what an IOC is
IOC means:
Indicator of Compromise.
It’s simply something that gives you a reason to investigate.
Examples from this incident included:
bf6f03.php
wp-env-setup.php
wp-helper.php
wp-worker
and PHP code involving:
eval()
base64_decode()
str_rot13()
shell_exec()
exec()
There were also PHP files hiding behind image extensions.
None of these indicators should be treated as a magic “infected = yes” switch.
They’re clues.
The trick is connecting the clues.
IOC #1 — Unexpected PHP files
The first thing I’d check on a WordPress account is what PHP files exist.
For example:
find /home/customer01/public_html \
-type f \
\( -name '*.php' -o -name '*.phtml' -o -name '*.php5' -o -name '*.php7' \) \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %s | %p\n' \
2>/dev/null |
sort
This produces a timestamped list.
I don’t recommend manually reading thousands of lines.
Instead, redirect it:
find /home/customer01/public_html \
-type f \
\( -name '*.php' -o -name '*.phtml' -o -name '*.php5' -o -name '*.php7' \) \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %s | %p\n' \
2>/dev/null |
sort \
> /root/customer01-php-files.txt
Now you can inspect the file comfortably.
less /root/customer01-php-files.txt
Why timestamps are useful
Suppose WordPress normally has:
wp-login.php
wp-settings.php
wp-load.php
with old timestamps.
Then suddenly you find:
2026-08-19 06:35:40 | 243589 | wp-env-setup.php
That deserves attention.
Especially if:
- the file wasn’t in your backup
- the filename isn’t part of WordPress
- the contents are encoded
- it executes PHP dynamically
That’s much stronger than simply saying:
“I don’t recognize this filename.”
IOC #2 — PHP that reads user-controlled input and executes it
This is one of the patterns I care about most.
For example:
grep -RIlE \
'\$_(GET|POST|REQUEST|COOKIE)\[[^]]+\].*(eval|assert|system|exec|shell_exec|passthru|popen|proc_open)|\
(eval|assert|system|exec|shell_exec|passthru|popen|proc_open)\s*\([^)]*\$_(GET|POST|REQUEST|COOKIE)' \
/home/customer01/public_html \
--include='*.php' \
--include='*.phtml' \
--include='*.php5' \
--include='*.php7' \
2>/dev/null
If this returns something, inspect the file.
Don’t automatically delete it.
IOC #3 — Encoded PHP
Another useful search:
grep -RInE \
'base64_decode\s*\(|gzinflate\s*\(|gzdecode\s*\(|str_rot13\s*\(|eval\s*\(' \
/home/customer01/public_html \
--include='*.php' \
--include='*.phtml' \
--include='*.php5' \
--include='*.php7' \
2>/dev/null
Again, this will produce false positives.
WordPress plugins can legitimately use some of these functions.
So I normally inspect the surrounding code.
A file containing:
$decoded = base64_decode($data);
is not automatically malware.
A file containing:
$payload = base64_decode($huge_obfuscated_string);
eval($payload);
is a very different story.
IOC #4 — Image files that aren’t images
This was one of the most useful checks from my investigation.
Run:
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
This is particularly useful because it doesn’t care what the filename claims to be.
The file itself tells us what it actually looks like.
A suspicious result
Something like:
/home/customer01/public_html/wp-content/uploads/foo.jpg
-> PHP script, ASCII text
is worth investigating immediately.
Something like:
/home/customer01/public_html/wp-content/plugins/some-library/docs/example.png
-> ASCII text
might be perfectly legitimate.
Location and context matter.
IOC #5 — Weird directory structures
During the investigation I found paths containing repeated directory names:
images/images/images/images/
That doesn’t automatically mean malware.
But it becomes interesting when combined with:
random filename
+
image extension
+
PHP content
So this is a useful search:
find /home/customer01/public_html \
-type d \
-printf '%p\n' |
grep -E '(/[^/]+){0,}/([^/]+)/\2/'
If your grep behaves differently, don’t worry.
The simpler method is often better:
find /home/customer01/public_html -type d |
grep -E 'images/.*/images|uploads/.*/uploads'
The point isn’t to find a specific attack.
It’s to identify unusual nesting worth looking at.
IOC #6 — Executables inside web directories
This one is important.
PHP malware is bad.
A native executable inside a WordPress directory is a much bigger red flag.
Start with:
find /home/customer01/public_html \
-type f \
-executable \
-printf '%m %u:%g %s %p\n' \
2>/dev/null
Then inspect suspicious results:
file /path/to/suspicious-file
If you get:
ELF 64-bit LSB pie executable
and you weren’t expecting a binary there, stop and investigate.
Search for ELF files even if they’re not executable
Sometimes permissions aren’t what you’d expect.
So I also like:
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
This catches ELF binaries regardless of their filename.
IOC #7 — Mining indicators
If you suspect cryptomining, search for obvious strings:
grep -RInE \
'xmrig|stratum\+tcp|stratum\+ssl|pool\.supportxmr|cryptonight|randomx|monero|wallet' \
/home \
2>/dev/null
Again, this isn’t proof by itself.
A documentation file could mention XMRig.
A security article could mention a mining pool.
But if you find:
PHP launcher
+
native ELF binary
+
stratum URL
+
wallet
+
CPU detection
then the picture is much clearer.
IOC #8 — Processes
If the machine is live and you suspect a miner:
ps auxww
Then:
ps auxww | grep -Ei \
'xmrig|miner|worker|stratum|crypto|kworker'
Be careful with names.
A process called kworker can be legitimate.
Don’t kill something simply because its name contains a suspicious word.
Look at the complete command line and executable path.
For example:
ps -eo pid,user,%cpu,%mem,lstart,args --sort=-%cpu | head -30
This is much more useful.
IOC #9 — Cron persistence
Attackers love scheduled tasks because they’re boring.
Check the obvious places:
crontab -l
Then check system cron:
ls -lah /etc/cron.d/
ls -lah /etc/cron.daily/
ls -lah /etc/cron.hourly/
ls -lah /etc/cron.weekly/
ls -lah /etc/cron.monthly/
And:
grep -RInE \
'curl|wget|php|bash|sh|python|perl|nc|socat' \
/etc/cron* \
/var/spool/cron \
2>/dev/null
Don’t automatically assume every match is malicious.
The question is:
Does this scheduled task belong here?
IOC #10 — systemd persistence
Check services:
systemctl list-unit-files --type=service
Then recently created or suspicious units:
find /etc/systemd/system \
-type f \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %p\n' \
2>/dev/null |
sort
Search for obvious execution:
grep -RInE \
'ExecStart=.*(curl|wget|bash|sh|php|python|perl|nc)' \
/etc/systemd/system \
2>/dev/null
Again, context.
Some legitimate services execute scripts.
IOC #11 — SSH keys
This is one of the checks I would absolutely perform after a serious compromise.
find /home /root \
-path '*/.ssh/authorized_keys' \
-type f \
-print \
2>/dev/null
Then inspect them:
cat /home/customer01/.ssh/authorized_keys
and:
cat /root/.ssh/authorized_keys
Look for keys you don’t recognize.
An attacker who gets SSH access may not need a PHP webshell anymore.
IOC #12 — WordPress administrator accounts
A compromised WordPress account can be just as useful as a webshell.
For each WordPress database, check administrator-level users.
Using WP-CLI where available:
wp user list \
--role=administrator \
--path=/home/customer01/public_html
Look for:
- accounts you don’t recognize
- strange usernames
- newly created administrators
- changed email addresses
- unexpected application passwords
Don’t delete an account simply because the username looks strange.
Verify it first.
IOC #13 — Plugins and themes
Check what is installed:
wp plugin list \
--path=/home/customer01/public_html
and:
wp theme list \
--path=/home/customer01/public_html
Then compare versions against known-good sources.
More importantly, look for:
plugin you don't remember installing
theme you don't recognize
plugin directory with strange random name
plugin containing executable PHP outside expected files
One of the suspicious artifacts in this incident lived under a plugin directory.
That made the plugin tree especially interesting.
IOC #14 — PHP files with impossible names
Another quick search:
find /home/customer01/public_html \
-type f \
-name '*.php' \
-printf '%p\n' |
grep -Ei \
'/[A-Za-z0-9]{6,}\.php$'
This can identify random-looking PHP filenames.
But again:
random filename ≠ malware.
Some applications legitimately generate random names.
It’s an indicator worth inspecting, not a verdict.
IOC #15 — Files outside normal WordPress locations
I also like looking at PHP files at unusual depths.
For example:
find /home/customer01/public_html \
-type f \
-name '*.php' \
-printf '%d %p\n' \
2>/dev/null |
sort -n
Very deeply nested PHP files aren’t necessarily malicious.
But something like:
wp-content/uploads/2026/08/abc123.php
deserves more attention than:
wp-admin/admin.php
because WordPress normally shouldn’t be executing arbitrary PHP uploaded into an uploads directory.
IOC #16 — PHP inside uploads
This deserves its own check:
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
If your site legitimately uses PHP there, fine.
Most WordPress installations don’t need PHP files in uploads.
That makes this one a particularly useful check.
IOC #17 — File modification bursts
If you know approximately when the compromise happened:
find /home/customer01/public_html \
-type f \
-newermt '2026-08-19 03:00:00 UTC' \
! -newermt '2026-08-19 05:00:00 UTC' \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %s | %p\n' \
2>/dev/null |
sort
This can reveal a cluster of files changed together.
A sudden burst can be interesting.
But remember:
WordPress updates can also modify hundreds of files at once.
So compare the burst with known maintenance activity.
IOC #18 — Compare against your backup
This is one of the strongest techniques available if you have a known-good backup.
Suppose your backup is:
/mnt/backup/full-2026-08-16/customer01.com.tar.gz
You can search its contents without extracting it:
tar -tzf \
/mnt/backup/full-2026-08-16/customer01.com.tar.gz |
grep -E \
'bf6f03\.php|wp-env-setup\.php|wp-helper\.php|wp-worker'
If nothing is returned:
NOT FOUND
that’s useful historical evidence.
It tells you those artifacts weren’t present in that backup.
Don’t make this mistake with Virtualmin backups
This was one of the confusing parts of my investigation.
A Virtualmin backup isn’t necessarily structured the way you might expect from a manually created:
tar -czf website.tar.gz public_html/
archive.
The Virtualmin backup contains account-level data and can have its own directory structure.
So don’t assume:
/home/customer01/public_html/
will literally appear inside the archive.
First inspect:
tar -tzf /path/to/backup.tar.gz | head -50
Then determine the archive’s actual structure.
That saved me from drawing the wrong conclusion from an empty grep.
IOC #19 — Database users
Because this incident involved remote MySQL access, I also wanted to know which accounts could connect remotely.
From MySQL:
SELECT User, Host
FROM mysql.user
ORDER BY User, Host;
Pay particular attention to:
'%'
For example:
appuser %
means the account can potentially authenticate from any host, subject to the server’s network exposure and authentication configuration.
That’s very different from:
appuser 10.50.0.%
or a specific controlled host.
IOC #20 — Check MySQL network exposure
First:
ss -lntp | grep ':3306'
You might see:
0.0.0.0:3306
or:
:::3306
That means MySQL is listening on network interfaces rather than only localhost.
Again, that doesn’t automatically mean Internet exposure.
Check the firewall too.
For example:
iptables -S
or, if using nftables:
nft list ruleset
If you’re using UFW:
ufw status verbose
The goal is to understand the entire path:
Internet
↓
firewall
↓
server interface
↓
MySQL
↓
MySQL account
Don’t forget the server itself
The compromise wasn’t just about WordPress.
So I would also run:
ss -lntup
This gives you a useful inventory of listening services.
Ask yourself:
“Do I recognize every Internet-facing service?”
If you see:
22
80
443
10000
3306
you should know why each one exists.
If you see something unexpected:
4444
5555
8080
9001
don’t panic.
Investigate it.
The most important command isn’t a command
It’s this question:
“Should this exist?”
For every suspicious file:
Should this file exist?
For every service:
Should this service be listening?
For every user:
Should this user exist?
For every cron job:
Should this job run?
For every database account:
Should this account connect from that host?
This simple question catches a surprising amount of bad stuff.
What I would NOT scan for blindly
There are plenty of commands online that basically say:
grep -R "eval"
and then print scary-looking results.
I don’t like that approach.
It creates noise.
A WordPress server contains:
- third-party libraries
- minified JavaScript
- documentation
- generated code
- plugins
- themes
- compatibility code
Some of it will look suspicious if you search for the right keywords.
The goal isn’t to produce the longest malware report.
The goal is to find things that don’t belong.
My practical triage order
If I had to investigate another compromised WordPress VPS tonight, I’d do it in this order:
Step 1 — Look for known IOCs
grep -RInE 'known-string-1|known-string-2' /home 2>/dev/null
Step 2 — Check suspicious PHP
find /home -type f -name '*.php'
Step 3 — Check fake images
file suspicious-image.jpg
Step 4 — Check executables
find /home -type f -executable
Step 5 — Check persistence
crontab -l
systemctl list-unit-files --type=service
Step 6 — Check SSH
find /home /root -path '*/.ssh/authorized_keys'
Step 7 — Check WordPress administrators
wp user list --role=administrator
Step 8 — Check MySQL
SELECT User, Host FROM mysql.user;
Step 9 — Check listening ports
ss -lntup
Step 10 — Compare against backup
This is the part that gives the investigation historical context.
The commands I would save in a script
After doing all this manually, I eventually realized that the useful part could be made repeatable.
I don’t want to sit at 2 AM typing twenty commands into a terminal while wearing glasses that are probably already covered in fingerprints.
So I would put the non-destructive checks into a script.
Something like:
#!/bin/bash
ROOT="$1"
if [ -z "$ROOT" ]; then
echo "Usage: $0 /path/to/public_html"
exit 1
fi
echo "===== PHP FILES ====="
find "$ROOT" -type f \
\( -name '*.php' -o -name '*.phtml' -o -name '*.php5' -o -name '*.php7' \) \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %s | %p\n' \
2>/dev/null |
sort
echo
echo "===== SUSPICIOUS PHP PATTERNS ====="
grep -RInE \
'base64_decode\s*\(|gzinflate\s*\(|gzdecode\s*\(|str_rot13\s*\(|eval\s*\(|shell_exec\s*\(|passthru\s*\(|proc_open\s*\(|popen\s*\(' \
"$ROOT" \
--include='*.php' \
--include='*.phtml' \
--include='*.php5' \
--include='*.php7' \
2>/dev/null
echo
echo "===== DISGUISED IMAGE FILES ====="
find "$ROOT" -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
type=$(file -b "$f")
case "$type" in
*"PHP script"*|*"HTML document"*|*"ASCII text"*|*"Unicode text"*)
echo "$f | $type"
;;
esac
done
echo
echo "===== EXECUTABLE FILES ====="
find "$ROOT" \
-type f \
-executable \
-printf '%m %u:%g %s %p\n' \
2>/dev/null
I’d save it somewhere outside the website tree.
For example:
/root/wp-ioc-scan.sh
Then:
chmod 700 /root/wp-ioc-scan.sh
and run:
/root/wp-ioc-scan.sh /home/customer01/public_html
The important thing is that this script is read-only.
It doesn’t delete anything.
That’s intentional.
One last lesson from this incident
The attacker didn’t need to defeat every security mechanism on the server.
They only needed one useful weakness.
After that, they could build additional access.
That means security isn’t about finding one magical setting.
It’s about reducing the number of ways an attacker can move from:
"I found a weakness"
to:
"I own the server."
Things like:
- least privilege
- limited network exposure
- current software
- good backups
- MFA
- separate accounts
- restricted database access
- monitoring
- sensible permissions
all make that journey harder.
The most valuable thing I got from the incident
It wasn’t the malware samples.
It wasn’t the logs.
It wasn’t even the backup.
It was the timeline.
Once I put everything together, I could separate:
normal Internet noise
from:
suspicious activity
from:
confirmed malicious artifacts
That made the recovery decision much easier.
And that’s probably the biggest lesson I would give another sysadmin:
Don’t investigate a compromise by staring at individual files. Build a timeline and connect the evidence.
A weird .png might be nothing.
A weird .png that contains PHP, appeared during the incident window, wasn’t present in the previous backup, sits inside a strange directory, and is related to a webshell?
That’s a very different story.
Coming next
Part 6 — What I Would Change on This VPS After the Restore
This is the final practical part: the hardening work after recovery.
I’ll cover the things I would actually change—not a 200-item security checklist that nobody will finish—including:
- WordPress and plugin hardening
- PHP execution restrictions
- upload-directory protection
- Virtualmin account isolation
- SSH
- MySQL
- firewall rules
- temporary VPS access
- backups
- logging
- and a simple “before you go to bed” server checklist.
No Comments