Part 1 of a real-world WordPress VPS compromise investigation
By SepedaTua — CrushEdge.com
My server had been compromised.
I didn’t know how badly at first.
I had several websites running on the same VPS, and I was already seeing enough strange files and behavior that I knew this wasn’t something I wanted to “fix quickly” and hope for the best.
So I did something that feels slightly unnatural when you’re used to fixing servers:
I stopped trying to fix it.
Apache was turned off.
MariaDB was turned off.
The server had been offline for roughly two days.
That was inconvenient, because of course people still wanted the websites back. I wanted them back too.
But at that point, getting the websites online was not my first priority.
I wanted to know:
What happened?
And more importantly:
How much of the server should I trust anymore?
This wasn’t just one WordPress site
The VPS wasn’t hosting a single website.
It was a typical small multi-purpose server: several Virtualmin accounts, WordPress installations, staging sites, and some PHP applications.
For this article, I’ll call the server:
web01.example-lab.net
and I’ll use fictional account names such as:
customer01
customer02
customer03
The real server names, domains, usernames, IP addresses, database names and other infrastructure details are deliberately not shown.
The attacker-side indicators are a different story. Those are useful forensic information, so I’ll preserve them where the evidence actually contains them.
My first mistake would have been deleting everything
When you find a suspicious PHP file, the easiest thing in the world is:
rm suspicious.php
Problem solved, right?
Not necessarily.
If the server has actually been compromised, that file might tell you:
- how the attacker got in
- when they got in
- what they were trying to do
- whether they had persistence
- what other files they created
- what commands they executed
- where they connected
- whether they installed something else
Delete it too early and you may delete some of your best evidence.
So I changed my approach.
Instead of:
find → delete → restart
I wanted:
preserve
↓
inspect
↓
understand
↓
quarantine
↓
restore
↓
verify
That’s a much slower approach.
It is also much safer.
The backup was suddenly more important than usual
Fortunately, I had backups stored on cloud.
That turned out to be extremely useful.
There was a full Virtualmin backup from before the incident and differential backups around the same period.
This changed the investigation completely.
A suspicious file on the current filesystem tells me:
“This file exists now.”
A backup can tell me:
“This file did not exist at that earlier point in time.”
That difference is incredibly useful.
For example, one of the files that eventually became important was:
bf6f03.php
It wasn’t present in the earlier backup.
That gave me something much stronger than simply saying:
“The filename looks suspicious.”
I had a historical baseline.
Don’t confuse a backup with proof that everything is clean
There is an important limitation here.
A backup is only useful as a known-good reference if you have reason to trust it.
If the attacker had already compromised the server before the backup was created, restoring that backup could simply restore the compromise.
That’s why I didn’t blindly assume:
“August 17 = clean.”
Instead, I treated the backup as a historical snapshot and compared the evidence against it.
That distinction matters.
Then I started looking at timestamps
One of my first useful tools was simply:
find /home/customer01/public_html \
-type f \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %s | %p\n' \
2>/dev/null |
sort
This produces something like:
2026-08-19 04:01:12 | 18432 | /home/customer01/public_html/index.php
2026-08-19 04:03:41 | 5237 | /home/customer01/public_html/bf6f03.php
2026-08-19 04:04:02 | 9123 | /home/customer01/public_html/wp-content/...
The exact output obviously depends on the filesystem.
The point isn’t that timestamps magically tell you who hacked the server.
They don’t.
They give you a timeline to investigate.
A timestamp isn’t an alibi
This is another thing I learned during the investigation.
Filesystem timestamps can be useful, but they aren’t absolute truth.
Files can be:
- copied
- restored
- extracted from archives
- modified
- touched
- moved
- created by automated processes
So I don’t want to look at:
04:03:41
and announce:
“The attacker uploaded this at exactly 04:03:41.”
That’s too strong.
Instead:
“The file’s filesystem timestamp places it in this period.”
Then I compare that with:
- web access logs
- PHP logs
- database timestamps
- backup contents
- other file timestamps
- process information
- attacker artifacts
The more independent evidence lines up, the stronger the conclusion becomes.
Then I found the first webshell
One of the files that eventually became a confirmed malicious artifact was:
bf6f03.php
It was not part of the site’s legitimate application.
After further investigation, I removed it from the restored environment.
The final check was deliberately simple:
test ! -e /home/customer01/public_html/bf6f03.php \
&& echo "OK: bf6f03.php removed"
The result:
OK: bf6f03.php removed
But this is important:
Removing bf6f03.php did not prove that the server was clean.
It only proved that bf6f03.php was gone.
This sounds obvious, but it’s an easy mistake to make during a stressful cleanup.
Searching for malware isn’t as simple as searching for eval()
A natural next step is something like:
grep -RInE \
'base64_decode|eval\s*\(|gzinflate|str_rot13|assert\s*\(|shell_exec|passthru|proc_open|popen|system\s*\(|exec\s*\(' \
/home/customer01/public_html \
--include='*.php' \
2>/dev/null
This is useful.
It is also noisy.
WordPress and its plugins contain plenty of code that can legitimately match some of those functions.
So I learned to treat grep output as:
“Interesting. Investigate this.”
not:
“Malware found.”
That’s a very important distinction.
The more interesting files didn’t even look like PHP
This was one of my favorite findings.
Some suspicious files had names ending in:
.jpg
.jpeg
.png
.gif
.ico
.bmp
That normally wouldn’t make me suspicious.
Images are everywhere in WordPress.
But filenames don’t determine what a file actually contains.
So I used file.
For example:
file suspicious.jpg
If it returns:
PHP script
instead of:
JPEG image data
then we have a problem.
I eventually found whole directories of fake images
Some paths looked like this:
/home/customer02/public_html/
images/
images/
images/
images/
something.jpeg
And some of the files were reported by file as PHP scripts.
That is a very different situation from an ordinary image upload.
The attacker was using filenames and directory structures that looked harmless enough at first glance.
This is why one of my favorite little commands during the investigation became:
find /home/customer02/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
The important part is not the command itself.
It’s the principle:
Don’t trust the extension. Check the actual file type.
Then things got much uglier
Eventually I found files that tried very hard to look like WordPress files.
Names such as:
wp-env-setup.php
wp-helper.php
wp-loader.php
sound plausible.
An attacker doesn’t have to call a file:
evil-hacker-shell.php
They can call it something boring.
That’s much more likely to survive a quick visual inspection.
One particularly interesting file was:
wp-env-setup.php
It was enormous for what its name suggested:
243,589 bytes
16,235 lines
And it wasn’t normal WordPress code.
The beginning contained binary-looking data followed by PHP and a huge encoded string.
Near the end was the important part:
$decoded = $c($z);
eval("?>".$decoded);
At that point there wasn’t much ambiguity left.
The file was deliberately constructing an encoded payload and executing the decoded result.
This is where I stopped thinking “WordPress problem”
That was an important mental shift.
Initially, I was investigating a WordPress compromise.
Then I found:
- arbitrary file writing
- suspicious PHP execution
- encoded payloads
- shell commands
- downloadable files
- executable binaries
- mining-pool references
- database modifications
That isn’t simply:
“Someone uploaded a bad plugin.”
The application was being used as a way into the underlying server.
And that meant I had to stop looking at individual websites and start looking at the host.
One compromised account changes the question
The question is no longer:
“Is this WordPress site infected?”
It becomes:
“What else on this server could the attacker have reached?”
That’s a much more uncomfortable question.
It also happens to be the right one.
On a multi-account VPS, websites share the same underlying operating system, network, storage, PHP infrastructure, database server, and administrative environment.
So I started hunting across accounts.
That’s when the investigation found the next layer.
Coming next
In Part 2, I’ll go through the fake WordPress files, the file-manager activity, and the PHP code that eventually led to something much worse:
a native Linux executable running as a cryptominer.
And this is where the incident stopped looking like a collection of strange PHP files and started looking like a complete persistence chain.
No Comments