The incident is over. Now let’s make the next one harder.
By SepedaTua — CrushEdge.com
Once the websites are back online, there is a temptation to declare victory.
I understand it.
After two days of downtime, the only thing you really want to see is:
HTTP 200
and a working WordPress homepage.
But restoration is only half the job.
The other half is making sure the server doesn’t immediately walk back into the same situation.
This is the hardening checklist I would use on a fresh Virtualmin server.
1. Start with the firewall
First, find out what is actually exposed:
ss -lntup
Then compare that with what the server actually needs.
For a typical web server, you may have something like:
22 SSH
80 HTTP
443 HTTPS
10000 Virtualmin/Webmin
You may have other legitimate services.
That’s fine.
The important thing is that every exposed port has a reason.
2. Don’t expose MySQL just because one application needs it
This is one of the things I would change after this incident.
Suppose I have:
MAIN VPS
MariaDB
│
└── WordPress
and temporarily create:
TEMP VPS
application
The easy solution is:
TEMP VPS ─────── Internet ───────► MAIN VPS:3306
I don’t like that.
Instead:
TEMP VPS
│
│ SSH tunnel
▼
MAIN VPS
│
▼
127.0.0.1:3306
Now MySQL doesn’t have to be exposed to the entire Internet.
3. SSH tunneling works surprisingly well
On the temporary VPS, you can create a tunnel like:
ssh -N \
-L 3307:127.0.0.1:3306 \
user@MAIN_SERVER
Then the application on the temporary VPS connects to:
127.0.0.1:3307
The traffic goes through SSH to:
MAIN_SERVER:3306
The application doesn’t need to know the real database port is private.
4. Give the temporary server its own SSH key
Don’t use your personal root password.
Create a dedicated key:
ssh-keygen -t ed25519 \
-f ~/.ssh/temp-db-tunnel
Put the public key on the main server.
Then remove that key when the temporary VPS is destroyed.
This is exactly the kind of thing that is easy to forget.
So I would document it:
Temporary VPS created:
2026-08-24
SSH key:
temp-db-tunnel
Purpose:
temporary database access
Remove when:
temporary VPS destroyed
5. Don’t give the application a database superuser
Suppose the application only needs one database:
app_database
Create a user specifically for it.
Conceptually:
CREATE USER 'app_user'@'localhost'
IDENTIFIED BY 'LONG_RANDOM_PASSWORD';
GRANT SELECT, INSERT, UPDATE, DELETE
ON app_database.*
TO 'app_user'@'localhost';
The exact privileges depend on the application.
Some applications genuinely need more.
But the principle is simple:
Give the application the privileges it actually needs.
Not:
GRANT ALL PRIVILEGES ON *.* ...
because it’s convenient.
6. If remote MySQL is absolutely necessary
Sometimes an SSH tunnel isn’t practical.
Then don’t use:
'app'@'%'
unless you genuinely have no better option.
Instead, use a dedicated account and restrict what it can do.
For example:
CREATE USER 'temporary_app'@'specific-source'
IDENTIFIED BY 'LONG_RANDOM_PASSWORD';
Then:
GRANT SELECT
ON specific_database.*
TO 'temporary_app'@'specific-source';
The exact Host restriction depends on your network architecture.
If the source IP is genuinely unpredictable, that’s another reason I prefer an SSH tunnel or VPN.
7. “But my temporary VPS doesn’t have a fixed IP”
Exactly.
That’s not a good reason to expose MySQL to everyone.
It’s a good reason to use a connection mechanism based on authentication rather than a permanent source IP.
For example:
temporary VPS
│
│ authenticated SSH
▼
main VPS
│
▼
MariaDB
The temporary VPS can come and go.
Its public IP doesn’t need to be permanent.
8. Remove old database accounts
After restoring, inspect:
SELECT User, Host
FROM mysql.user
ORDER BY User, Host;
Look for accounts you no longer recognize.
Then inspect privileges:
SHOW GRANTS FOR 'username'@'host';
Don’t delete an account simply because you don’t recognize the name.
First establish what it belongs to.
Virtualmin itself may create accounts you don’t remember manually creating.
9. WordPress deserves its own cleanup
For each WordPress site:
wp core verify-checksums \
--path=/home/customer/public_html
If the installation is a standard WordPress.org installation, this is a useful way to detect modified core files.
Then:
wp plugin list \
--path=/home/customer/public_html
and:
wp theme list \
--path=/home/customer/public_html
Remove anything unnecessary.
10. Don’t leave abandoned plugins installed
This is one of those boring things that comes back to bite you.
If you installed a plugin three years ago for a feature you don’t use anymore:
delete it.
Don’t just deactivate it.
An abandoned plugin can still contain vulnerable code.
11. Disable PHP execution where it isn’t needed
One particularly useful defense is preventing PHP execution in directories that should only contain uploads.
For WordPress, that commonly means:
wp-content/uploads/
The exact configuration depends on Apache/Nginx and your PHP handler.
But the concept is powerful:
Attacker uploads shell.php
↓
Web server sees PHP file
↓
PHP execution disabled here
↓
Shell doesn't execute
That’s much better than discovering the shell afterward.
12. Permissions should make sense
Check:
find /home/customer/public_html \
-type f \
-perm /022 \
-print
and:
find /home/customer/public_html \
-type d \
-perm /022 \
-print
Don’t blindly apply some magic:
chmod -R 755
command from a random blog post.
WordPress has files and directories with different requirements.
Understand the ownership model first.
13. Keep WordPress writable only where necessary
The web process needs to write some things.
For example:
uploads
cache
certain plugin-managed directories
But it shouldn’t need unrestricted write access to the entire application.
The less writable code you have, the smaller the attacker’s playground.
14. Protect wp-config.php
At minimum:
stat /home/customer/public_html/wp-config.php
Make sure it isn’t unnecessarily writable.
And make sure your web server isn’t accidentally serving it as plain text.
This sounds obvious.
It isn’t always obvious on a badly configured server.
15. Keep Virtualmin/Webmin off the open Internet if practical
Virtualmin is powerful.
That also means it’s an attractive target.
If you don’t need administration from everywhere, restrict access to your management interface.
For example:
Internet
│
X
Virtualmin
Admin
│
▼
VPN / trusted network
│
▼
Virtualmin
A management panel shouldn’t have to be publicly reachable just because the websites do.
16. Protect SSH
I generally prefer:
SSH keys
+
disabled password authentication
+
restricted root access
But do this carefully.
Before changing SSH:
- Keep your current session open.
- Open a second SSH session.
- Test the new authentication method.
- Only then close the original session.
Never test SSH hardening by locking yourself out of the only working connection.
Ask me how I know.
17. Keep the operating system updated
Check:
dnf update
or, depending on your distribution:
apt update
apt upgrade
Don’t blindly run upgrades on production systems without considering compatibility.
But don’t leave security updates indefinitely because:
“Everything works right now.”
That’s how old vulnerabilities become permanent residents.
18. Backups need to be isolated
If the attacker gets root access and your backup is mounted read-write on the same server, you may have a very bad afternoon.
Ideally:
SERVER
│
▼
BACKUP STORAGE
│
├── recent
├── weekly
└── historical
And at least some backups should not be directly writable from the compromised server.
19. Test restoration
A backup that has never been restored is a hypothesis.
A tested restore is a backup.
At least occasionally:
backup
↓
test environment
↓
restore
↓
verify
You don’t need to restore the entire production infrastructure every week.
But you should know that the backup actually works.
20. Build a clean baseline
After the server is clean, save:
ss -lntup > /root/baseline-ports.txt
systemctl list-unit-files \
--type=service \
> /root/baseline-services.txt
crontab -l \
> /root/baseline-root-cron.txt
And keep inventories of:
Virtualmin accounts
WordPress administrators
plugins
themes
MySQL users
SSH keys
Now you have something to compare against.
My ideal small VPS security model
I don’t need a $50,000 security stack for a small business server.
I’d rather have:
INTERNET
│
┌──────┴──────┐
│ │
HTTP HTTPS
│ │
└──────┬──────┘
│
Web/PHP
│
┌──────┴──────┐
│ │
Site A Site B
│ │
▼ ▼
DB-A DB-B
│ │
└──────┬──────┘
│
MariaDB
private network
Administration:
Admin PC
│
▼
SSH / VPN
│
▼
VPS
Temporary applications:
Temporary VPS
│
▼
SSH tunnel
│
▼
Private MariaDB
Backups:
VPS
│
▼
Separate backup storage
│
├── daily
├── weekly
└── historical
Nothing exotic.
Just fewer unnecessary doors.
The uncomfortable conclusion
The malware wasn’t the only problem.
The malware was simply the thing that made me look.
Once I started looking, I had to ask much bigger questions:
Who can access this server?
Which services are exposed?
Which database accounts can connect remotely?
Which files can PHP modify?
What happens if one WordPress site is compromised?
Can I restore quickly?
Can I prove what changed?
Those questions are much more important than knowing the name of a particular webshell.
Because attackers change their tools.
The principles don’t change nearly as quickly.
If I were setting up the server again tomorrow
My order would be:
1. Clean OS
2. Update OS
3. Firewall
4. SSH hardening
5. Virtualmin/Webmin access restriction
6. Separate accounts
7. Private MariaDB
8. Least-privilege DB users
9. WordPress hardening
10. Disable PHP where unnecessary
11. Backups
12. Restore test
13. Baseline
14. Monitoring
15. Only then start adding applications
That order matters.
Don’t install 30 things first and think about security afterward.
I’ve done enough DIY work to know that fixing a problem before everything is bolted together is usually much easier than fixing it afterward.
Servers aren’t very different from cars in that respect.
The goal isn’t an invincible server
There isn’t one.
The goal is a server where:
one mistake doesn’t become a complete disaster.
That’s what I would take away from this incident.
And honestly, that’s enough.
Because the next attacker may find a vulnerability I don’t know about yet.
But if I have:
isolation
+
least privilege
+
good backups
+
monitoring
+
tested recovery
then hopefully their “big win” becomes:
“I compromised one WordPress installation.”
instead of:
“I own the entire server.”
That difference is everything.
No Comments