Part 5 — The IOC Checklist I Wish I Had Before the Incident

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

Leave a Reply

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