Part 2 of a real-world WordPress VPS compromise investigation
By SepedaTua — CrushEdge.com
In Part 1, I stopped the server and started looking at the filesystem instead of immediately trying to get the websites back online.
The first confirmed webshell was bf6f03.php.
Then I found something more interesting.
The attacker had apparently decided that naming a file shell.php was too obvious.
So they started giving the files names that looked like they belonged to WordPress.
That turned out to be a much better clue than the filenames themselves.
wp- does not mean WordPress
If you’ve managed WordPress for a while, you get used to seeing files like:
wp-config.php
wp-login.php
wp-settings.php
wp-load.php
So when you see:
wp-helper.php
wp-loader.php
wp-env-setup.php
your brain can easily categorize them as “probably WordPress.”
Mine almost did.
Then there was this one:
wp-includes/rest-api/class-wp-rest-advanched.php
The location looked convincing.
The filename even looked convincing.
Except for one small problem:
advanched
That’s not how WordPress names the class.
And when I opened the file, things got considerably worse.
It wasn’t a WordPress REST API class
The file contained functionality for manipulating files on the server.
Among other things, it handled:
- authentication
- file uploads
- chunked uploads
- file editing
- renaming
- chmod
- deletion
There was even a password embedded in the script:
$password = 'admin';
And the page identified itself as:
Mr.X private File Manager V.2
It also contained an attacker Telegram identifier.
At that point, I wasn’t looking at a modified WordPress core file.
I was looking at a web-based file manager/webshell disguised as WordPress code.
That distinction matters.
A normal WordPress REST API class has a very specific purpose.
This thing could manipulate the filesystem.
The dangerous part wasn’t the password
Seeing:
$password = 'admin';
is obviously bad.
But the password itself wasn’t the most important finding.
The functionality was.
The script could perform operations such as:
upload
write
edit
rename
chmod
delete
That means an attacker who could reach the script potentially had a convenient interface for modifying the website’s filesystem.
And once an attacker can write PHP into a web-accessible directory, things get interesting very quickly.
Then I found wp-env-setup.php
This file was particularly strange.
The filename sounds harmless enough.
The contents were anything but.
Its size was around:
243 KB
and it contained more than:
16,000 lines
of code.
The file began with bytes that looked like image data before PHP appeared.
Then the PHP constructed a very large encoded string piece by piece:
$z = "";
$z .= "UEQ5";
$z .= "d2FI";
$z .= "QUtD";
$z .= "bWxt";
$z .= "SUNo";
...
This isn’t how you normally write WordPress configuration or environment setup code.
The important part came near the end:
$decoded = $c($z);
eval("?>".$decoded);
So the file essentially did:
large encoded payload
↓
decode
↓
execute
That’s a very useful pattern to remember when hunting compromised PHP.
Don’t just search for eval()
At this point you might be tempted to run:
grep -RIn "eval(" /home
and call it a day.
Don’t.
You’ll get a pile of results.
Some will be legitimate.
Some will be generated code.
Some will be libraries.
Some will be malicious.
The interesting question isn’t:
“Does this file contain
eval()?”
It’s:
“Why is this file using
eval(), what is it decoding, and where did the file come from?”
Context matters.
In this case, the surrounding behavior made the answer pretty clear.
I preserved the file before removing it
This was one of the more important operational decisions.
Before removing a confirmed malicious file, I kept an evidence copy and recorded its SHA-256 hash.
For example:
cp -a /path/to/wp-env-setup.php \
/root/evidence/wp-env-setup.php
sha256sum /root/evidence/wp-env-setup.php
Why bother with the hash?
Because later I can say:
“This is the exact file I investigated.”
If the file is accidentally modified later, the hash changes.
It’s a simple way to keep track of evidence without needing a fancy forensic platform.
Then there was wp-helper.php
This one changed the investigation again.
The filename was boring.
The code wasn’t.
The script checked whether a process called:
wp-worker
was running.
It used commands such as:
ps
exec()
shell_exec()
and gathered CPU information using commands including:
nproc
lscpu
It also contained a mining-pool reference:
pool.supportxmr.com:3333
and parameters for a wallet and worker process.
That was enough to move this from:
“Someone installed a webshell.”
to:
“Someone appears to be using the server for cryptomining.”
The PHP wasn’t the miner
This distinction is important.
wp-helper.php wasn’t necessarily doing all the mining itself.
It was acting more like a controller.
Conceptually:
wp-helper.php
│
┌─────────┴─────────┐
│ │
check process collect CPU info
│
▼
wp-worker
│
▼
mining pool
That is a much more useful way to think about malicious PHP.
The PHP script can be the launcher.
The actual payload can be a completely different file.
Then I found wp-worker
This was the point where I stopped thinking of this as a WordPress-only incident.
wp-worker was not another PHP script.
It was a native executable.
The file command identified it as a 64-bit x86-64 ELF executable.
In other words:
PHP
↓
shell command
↓
Linux executable
Now we’re talking about the underlying operating system.
Why this matters
Suppose an attacker only manages to modify:
wp-content/something.php
That’s already bad.
But suppose that PHP can execute:
shell_exec()
and launch an executable.
Now the boundary has changed.
The attacker isn’t merely modifying WordPress anymore.
They are using WordPress/PHP as a gateway into the Linux environment.
That is why I became much more interested in things such as:
ps aux
and:
find /home -type f
rather than limiting the investigation to WordPress directories.
The miner also explained some of the weird code
Some of the PHP looked unnecessarily complicated.
Then I noticed code collecting information about:
CPU model
number of processors
That suddenly made sense.
If you’re running a cryptominer, CPU information is useful.
The attacker doesn’t care about your fancy WordPress setup.
They care about how much CPU they can steal.
That’s a rather rude way of looking at somebody else’s server.
There was another clue: the mining pool
The code referenced:
pool.supportxmr.com:3333
This was one of the strongest indicators that wp-worker was related to cryptocurrency mining.
It’s also a good example of why strings inside malware can be useful IOC material.
A search such as:
grep -RInE \
'supportxmr|xmrig|wallet|worker' \
/home \
2>/dev/null
can sometimes find related artifacts.
Again, though:
a string match is an indicator, not automatically proof.
A legitimate application could theoretically mention a mining pool.
You need the surrounding context.
Here, we had the surrounding context.
Then I started finding the same tricks elsewhere
Once I knew what I was looking for, the filesystem started making more sense.
There were files with:
- fake WordPress names
- suspicious random names
- image extensions containing PHP
- deeply nested directories
- executable-looking payloads
- encoded PHP
- shell-command execution
This is why I keep saying that malware hunting isn’t one command.
It’s a process of connecting small clues.
One suspicious filename by itself might mean nothing.
Ten independent indicators pointing in the same direction are a different story.
The image files were particularly clever
Some of the malicious files were hidden among normal-looking WordPress assets.
For example:
wp-includes/images/...
wp-admin/css/...
wp-content/plugins/...
wp-content/themes/...
And some ended with extensions such as:
.png
.gif
.jpg
.ico
.bmp
But file identified them as PHP.
This is a simple trick, but it works surprisingly well against manual inspection.
If I open:
wp-includes/images/logo.png
in a file browser, I probably won’t think twice.
If I run:
file logo.png
and get:
PHP script, ASCII text
the story changes immediately.
Why attackers like WordPress directories
There is another practical reason for this technique.
WordPress contains a huge number of files.
An administrator looking at:
wp-admin/
wp-includes/
wp-content/
doesn’t necessarily know what every file should look like.
An attacker can take advantage of that noise.
A random PHP file in the root directory might stand out.
A random .png several directories deep is easier to overlook.
That’s why checking file type versus file extension is such a useful hunting technique.
Then came the database
At this point I was already convinced that I shouldn’t treat this as a single-site problem.
So I started looking at the database side too.
That produced another interesting set of artifacts.
There were unexpected database tables created around the suspicious period.
Some contained attacker-oriented messages and contact information, including text equivalent to:
CONTACT FOR DATABASE
AND VULNERABILITY
There were also attacker contact identifiers.
That was significant because it gave me another independent evidence source.
I wasn’t relying only on filesystem artifacts anymore.
I now had:
HTTP activity
+
filesystem artifacts
+
database artifacts
When several independent sources point toward the same incident window, confidence goes up considerably.
But did the attacker enter through MySQL?
Not necessarily.
This is where I had to resist the temptation to connect every suspicious thing together.
The server also had a MySQL configuration that allowed broad network listening:
bind-address = 0.0.0.0
There were also database accounts permitting connections from external hosts.
That’s dangerous.
But I don’t have enough evidence to say:
“The attacker entered through MySQL.”
So I won’t claim that.
The accurate statement is:
The MySQL configuration created unnecessary attack surface and became one of the things I needed to fix during recovery.
That’s a much more useful conclusion anyway.
The bigger picture was becoming clear
At this point, my mental model of the incident looked something like this:
Internet
│
▼
WordPress site
│
▼
Initial compromise
│
▼
PHP webshell
│
┌─────────┴─────────┐
▼ ▼
File operations PHP execution
│
▼
shell commands
│
▼
wp-worker
│
▼
cryptomining
And separately:
compromised environment
│
┌────────┴────────┐
▼ ▼
filesystem database
│ │
▼ ▼
fake WP files suspicious tables
disguised images attacker messages
The exact relationships between every artifact were not all proven.
But there was more than enough evidence to justify treating the host as compromised.
This is where “just clean WordPress” stopped making sense
I could have spent hours doing this:
delete file
scan
delete another file
scan again
restart PHP
restart Apache
scan again
And maybe I would eventually have removed most of the obvious malware.
But how would I know I didn’t miss something?
That was the problem.
Once an attacker has had enough access to:
- write PHP
- execute shell commands
- deploy an executable
- modify databases
- create persistence
I don’t want to spend my night playing whack-a-mole with suspicious files.
I have four kids.
There are better uses for the remaining hours of my day.
The backup became the answer
Fortunately, I had another option.
I had a backup from before the known compromise.
So the recovery strategy became:
Don't trust the current filesystem
↓
Use the historical backup
↓
Restore the accounts
↓
Remove known malicious artifacts
↓
Search for the known indicators
↓
Fix the MySQL exposure
↓
Bring services back carefully
This is where the investigation moved from forensics to recovery.
And there was one more problem I needed to solve before I was comfortable bringing the server back:
MySQL had been deliberately exposed for legitimate remote access.
That part deserves its own article because the original requirement was reasonable—but the way I had implemented it was much broader than it needed to be.
Coming next
Part 3 — I Needed Remote MySQL Access, So I Made It Too Easy
I’ll explain the MySQL configuration I found, why a temporary cloud VPS made normal IP allowlisting inconvenient, why user@'%' is a bad shortcut, and the safer alternatives I should have used instead.
No Comments