What I Would Lock Down on a Fresh Virtualmin VPS

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:

  1. Keep your current session open.
  2. Open a second SSH session.
  3. Test the new authentication method.
  4. 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

Leave a Reply

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