Epilogue to the WordPress VPS compromise investigation
By SepedaTua — CrushEdge.com
After writing all of this down, I realized there was one more useful question.
Not:
“What did the attacker do?”
But:
“What would I do differently if I could go back to the day before the incident?”
That is probably more useful than another list of malware strings.
Because the investigation showed me something uncomfortable:
Most of the important improvements are boring.
There is no single command that magically makes a WordPress VPS secure.
It’s a collection of small decisions.
I would make the blast radius smaller
This is the biggest lesson.
Imagine the server has:
10 WordPress sites
5 applications
MariaDB
Virtualmin
SSH
If one WordPress site gets compromised and PHP can freely access everything available to that account, the attacker has a starting point.
The question becomes:
How much more can they reach from there?
That’s the blast radius.
I want this:
Site A compromised
│
└──> Site A damage
Not this:
Site A compromised
│
├──> Site B
├──> Site C
├──> database
├──> SSH credentials
└──> entire VPS
Separate things that don’t need to trust each other
This applies especially to multi-site VPS hosting.
If several unrelated websites live on the same machine, I want each Virtualmin account to have as little access to the others as possible.
A compromised:
customer01
shouldn’t automatically be able to read:
customer02
customer03
customer04
That’s basic compartmentalization.
The database deserves the same treatment
A database account shouldn’t automatically have access to every database.
For example:
shop_app
↓
shop_database
is preferable to:
shop_app
↓
ALL DATABASES
If the application only needs:
SELECT
INSERT
UPDATE
DELETE
give it those.
Not:
ALL PRIVILEGES
just because it’s easier.
I would stop treating % as the normal solution
This was one of the clearest lessons from the MySQL side of the incident.
When you need temporary remote access, the easy answer is:
'app'@'%'
But there are better choices.
My preferred order would be:
- SSH tunnel
- VPN/private network
- Temporary source restriction
- Direct public MySQL access only when absolutely necessary
The first option is particularly attractive for disposable VPS instances.
Temporary infrastructure should leave no permanent doors
This is a rule I want to remember.
If I create:
temporary-vps-01
for a job that lasts six hours, I don’t want six months of leftovers.
When the job ends:
SSH key
database user
firewall rule
API token
temporary account
should all be considered for removal.
Then destroy the VPS.
Clean slate.
I would also document why each unusual service exists
A surprisingly useful habit is keeping a small inventory.
Something like:
22 SSH administration
80 HTTP websites
443 HTTPS websites
10000 Webmin Virtualmin management
If six months later I see:
4444
I immediately know:
“That isn’t on my list.”
Without an inventory, you have to remember what you installed months ago.
My memory is good.
It’s not that good.
I would make malware detection less dependent on filenames
This incident made this painfully obvious.
An attacker doesn’t need to call their file:
malware.php
They can call it:
logo.png
So my mental model is now:
filename
↓
claim
while:
file
↓
actual content
is evidence.
That’s why:
file suspicious.png
is such a useful little command.
I would keep a baseline
This is something I wish I had done earlier.
After a clean restoration, generate inventories.
For example:
find /home \
-type f \
-printf '%u:%g %m %s %p\n' \
2>/dev/null |
sort > /root/files-baseline.txt
And:
ss -lntup > /root/listening-ports-baseline.txt
And:
systemctl list-unit-files --type=service \
> /root/services-baseline.txt
And:
crontab -l > /root/root-crontab-baseline.txt
Now, six months later, you have something to compare against.
A baseline doesn’t need to be fancy
You don’t need a commercial SIEM.
For a small VPS, even:
files-baseline.txt
services-baseline.txt
ports-baseline.txt
cron-baseline.txt
wordpress-plugins.txt
wordpress-users.txt
mysql-users.txt
stored somewhere safe can be extremely useful.
The important word is:
baseline.
You need something known-good to compare with.
Hashes can make the baseline even better
For important files:
sha256sum \
/home/customer01/public_html/wp-login.php \
/home/customer01/public_html/wp-settings.php \
/home/customer01/public_html/wp-config.php
You can save those hashes.
Later:
sha256sum -c baseline.sha256
can tell you whether they changed.
This is particularly useful for WordPress core.
But don’t blindly hash everything forever
You could hash the entire website:
sha256sum every-file
but then WordPress uploads and cache files constantly change.
That creates noise.
Instead, concentrate on things that should be relatively stable:
wp-admin/
wp-includes/
important configuration
custom application code
The goal is useful detection, not a wall of alerts.
I would also separate evidence from cleanup
This was probably the most important operational lesson.
During the incident:
Evidence
↓
Preserve
↓
Understand
↓
Contain
↓
Restore
Don’t start with:
rm -rf
unless you’re absolutely sure you no longer need the evidence.
In my case, once I had captured the relevant evidence and decided to restore from backup, the priority changed.
At that point:
clean recovery
became more important than:
preserve every suspicious file
The backup itself became part of the investigation
This was an unexpected benefit.
I initially thought of the August 16 backup as:
“The thing that will get my websites back.”
It turned out to be much more.
It became a historical snapshot.
For example:
Backup
│
├── file exists
├── file absent
└── directory structure
That lets you establish things like:
“This particular suspicious file was not present in the known-good backup.”
That’s much stronger than:
“I don’t remember creating this file.”
I would keep more than one historical backup
If the attacker had been present for several days, the latest backup could already contain malicious files.
That’s why I like having:
daily
weekly
monthly
historical points where practical.
You don’t necessarily need every backup forever.
But you need enough history to go back before an unknown compromise.
One backup is not enough
This deserves repeating.
If you have:
backup-2026-08-23
and the attacker entered:
2026-08-20
your backup may already be contaminated.
But if you have:
backup-2026-08-23
backup-2026-08-16
backup-2026-08-09
you have options.
Historical backups turn an incident from:
“We’re screwed.”
into:
“Let’s determine which restore point is clean.”
That’s a huge difference.
I would monitor changes to web directories
One of the simplest useful alerts would be:
“A new PHP file appeared in an uploads directory.”
That’s much more actionable than:
“Something suspicious happened somewhere.”
For example, a simple periodic check can report:
New PHP files
Modified WordPress core
New executable files
New files pretending to be images
That gives you a much smaller signal-to-noise ratio.
I wouldn’t try to automatically delete malware
This is another lesson from the investigation.
Automatic deletion sounds great until it deletes:
legitimate plugin
custom application
database migration
because some malware scanner thought it looked strange.
I’d rather have:
ALERT:
Suspicious PHP file detected:
/path/to/file.php
than:
DELETE:
/path/to/file.php
during normal monitoring.
Contain first.
Understand second.
Delete third.
I would make one emergency checklist
When you’re tired, you don’t want to remember everything.
So I’d keep this somewhere outside the VPS:
WORDPRESS VPS INCIDENT
[ ] Stop unnecessary public services
[ ] Preserve logs
[ ] Preserve suspicious files if evidence is needed
[ ] Record current time
[ ] Record hostname/IP
[ ] Snapshot if possible
[ ] Check running processes
[ ] Check listening ports
[ ] Check cron
[ ] Check systemd
[ ] Check SSH keys
[ ] Check WordPress administrators
[ ] Check database users
[ ] Identify known-good backup
[ ] Restore
[ ] Rotate credentials
[ ] Restrict firewall
[ ] Restrict MySQL
[ ] Verify WordPress
[ ] Monitor logs
[ ] Monitor CPU/network
It doesn’t need to be sophisticated.
It needs to be available when your brain isn’t.
And I would keep the investigation notes
Don’t rely on memory.
During the incident, write:
2026-08-24 01:20
Found suspicious PHP file.
2026-08-24 01:35
Found fake image containing PHP.
2026-08-24 02:10
Confirmed suspicious executable.
2026-08-24 02:45
Selected backup from August 16.
2026-08-24 03:30
Restoration completed.
Even rough notes are incredibly useful.
Three months later you won’t remember why you decided something.
The notes will.
What I learned about “perfect” investigations
I initially wanted certainty.
I wanted to know:
who
when
how
which vulnerability
which request
which command
Sometimes you simply can’t establish all of that.
Logs may be missing.
Timestamps may be misleading.
Credentials may have been reused.
An attacker may have entered through one service and deployed files through another.
The correct response isn’t to invent certainty.
It’s to label evidence honestly.
For example:
Confirmed
This file contained executable PHP code.
Strongly indicated
These files were likely deployed as part of the same compromise.
Possible
The attacker may have used this access path.
Unknown
The original entry point could not be established from the available logs.
That’s good incident reporting.
I also learned not to overreact to Internet noise
A public WordPress server will see garbage.
Constantly.
You’ll see:
/wp-login.php
/xmlrpc.php
/wp-admin/
/.env
/.git/config
/config.php
/phpinfo.php
You may see scanners looking for:
Laravel
Spring
GraphQL
Docker
Jenkins
WordPress
Drupal
Magento
That doesn’t mean you’ve been compromised.
A 404 is often just a 404.
The important question is:
What happened after the probe?
Did the request succeed?
Did a file appear?
Did a new process start?
Did privileges change?
Did the attacker establish persistence?
That’s where the investigation becomes meaningful.
The attacker doesn’t get the final word
This may sound obvious, but after spending hours looking at malicious files, it’s easy to start thinking of the attacker as having complete control.
They don’t necessarily.
A compromise is a sequence of opportunities.
If they get:
web access
but cannot:
execute PHP
that’s useful.
If they execute PHP but cannot:
write outside uploads
that’s useful.
If they get database credentials but:
3306 isn't reachable
that’s useful.
If they compromise one site but:
other Virtualmin accounts are isolated
that’s useful.
Security is often about making every next step harder.
The final architecture I want
If I had to draw the ideal small-business VPS after this incident, it would look roughly like this:
INTERNET
│
┌─────────┴─────────┐
│ │
80 443
│ │
└─────────┬─────────┘
│
Web
│
PHP-FPM
│
WordPress
│
▼
MariaDB
localhost
Administration:
ADMIN LAPTOP
│
│ SSH
▼
VPS
Temporary application:
TEMPORARY CLOUD VPS
│
│ SSH tunnel
▼
MAIN VPS
│
▼
MariaDB localhost
And backups:
VPS
│
▼
separate storage
│
┌─────────┴─────────┐
│ │
recent historical
backup backup
It’s not fancy.
That’s the point.
If I had to give one piece of advice
It would be this:
Don’t wait until after a compromise to decide what “normal” looks like.
Know beforehand:
- which users exist
- which websites exist
- which ports are open
- which services run
- which database accounts exist
- which WordPress administrators exist
- which plugins are installed
- where PHP is supposed to execute
- where backups live
Then an incident becomes much easier to recognize.
Instead of:
“Something feels wrong.”
you can say:
“This changed.”
And that’s a much better place to start.
Closing the incident
Eventually, the server stopped being an incident-response project and became a normal server again.
That’s the point.
Not endless forensic investigation.
Not endlessly staring at logs.
Not living in fear of the next POST /xmlrpc.php.
The goal is to:
Understand
↓
Recover
↓
Harden
↓
Monitor
↓
Move on
I still have four kids, a wife, work to do, websites to maintain, and probably some car problem waiting for me in the garage.
I can’t spend every night hunting malware.
So the system needs to be designed to make the next incident:
smaller, easier to detect, and easier to recover from.
That’s the real lesson I took away from this one.
The complete series
If you’ve followed the whole investigation, the progression was:
Part 1 — Something Was Wrong
How the first suspicious files appeared.
Part 2 — The Files That Pretended to Be Images
Why .png, .jpg, and .gif files aren’t necessarily images.
Part 3 — The Webshell and the Encoded Payload
How the malicious PHP behaved and why eval(base64_decode(...)) was such a strong indicator.
Part 4 — The Log Lines That Weren’t There
Why the exact initial entry point couldn’t always be proven from Apache logs.
Part 5 — The IOC Checklist I Wish I Had Before the Incident
Practical commands for finding suspicious PHP, fake images, executables, persistence, WordPress accounts, and exposed services.
Part 6 — What I Changed After the Restore
How I approached recovery, MySQL access, SSH tunneling, firewall rules, permissions, backups, and hardening.
Part 7 — What I Would Do Differently Next Time
The lessons that matter more than the malware itself.
And if there is one command from this entire series that I hope you remember, it’s not a malware scanner.
It’s:
file suspicious-file
Because sometimes the file tells you exactly what it is.
Even when the filename is lying.
No Comments