Part 4 — The Log Lines That Weren’t There

Part 4 of a real-world WordPress VPS compromise investigation

By SepedaTua — CrushEdge.com


By this point I had enough evidence to say:

The server had been compromised.

I had malicious PHP.

I had disguised image files.

I had a PHP-based file manager.

I had an encoded payload.

I had a native executable associated with cryptomining.

I had suspicious database activity.

But I still had one annoying problem.

I couldn’t simply point at one line in the Apache access log and say:

“There. That’s exactly how the attacker got in.”

In fact, some of the most interesting periods contained no useful HTTP request at all.

That sounds contradictory.

It isn’t.


The first thing I checked was the obvious stuff

I searched the access log for requests involving:

wp-admin
wp-login
admin-ajax
xmlrpc
upload
install
plugin
filester
REST API
PHP

For example:

grep -Ei \
'wp-admin|wp-login|admin-ajax|xmlrpc|upload|install|filester|plugin|php|rest|ajax' \
/home/customer01/logs/access_log

There were plenty of ordinary WordPress requests.

There were also plenty of scanners.

That’s normal on an Internet-facing WordPress server.

The Internet is basically one giant doorbell and automated scanners have no problem ringing it all day.


Then I looked at the suspicious time window

I had filesystem evidence suggesting that some malicious files appeared around a particular period.

So naturally I searched the access log around that time.

Something like:

awk '
/19\/Aug\/2026:03:4[0-9]:/ ||
/19\/Aug\/2026:03:5[0-9]:/
' /home/customer01/logs/access_log

Then I narrowed it down:

awk '
/19\/Aug\/2026:03:4[0-9]:/ ||
/19\/Aug\/2026:03:5[0-9]:/
' /home/customer01/logs/access_log |
grep -Ei \
'POST|wp-admin|wp-login|admin-ajax|xmlrpc|upload|install|filester|plugin|php|rest|ajax'

And I got:

0 results

That was interesting.

But it wasn’t proof of anything by itself.


This is where forensic thinking matters

There are two completely different statements:

Statement A

“There was no malicious request.”

Statement B

“There was no malicious request in this particular access log during this particular time window.”

I can support Statement B.

I cannot automatically support Statement A.

That’s a very important distinction.


Why might the request be missing?

There are several possibilities.

For example:

1. The relevant log wasn’t retained

Logs rotate.

They get compressed.

They get deleted.

They get overwritten.

They can also be stored somewhere different from where you’re looking.


2. The attacker used another service

Maybe the initial compromise wasn’t through Apache at all.

For example:

SSH
FTP/SFTP
PHP-FPM
another virtual host
another application
database
control panel

A WordPress access log can’t tell you what happened through SSH.


3. There was a proxy in front

In my case, some web traffic passed through a proxy/CDN layer.

That means the IP appearing in the Apache log isn’t necessarily the attacker’s actual public IP.

This is another reason I don’t like making conclusions from one log source.


4. The attacker already had credentials

If somebody logs into WordPress using valid credentials, the HTTP request can look completely ordinary.

There’s no magical:

I AM THE ATTACKER

header.

It might just look like a normal login.


5. The compromise happened earlier

This one is particularly important.

Suppose:

August 15
    attacker gets access

August 17
    backup created

August 19
    malicious file appears

The file’s timestamp doesn’t necessarily mean the attacker first entered on August 19.

They could have had access for days.

The malicious file could have been created later.


This is why the timeline became messy

At first I wanted something beautiful:

03:45 attacker logs in
03:46 uploads shell
03:47 executes shell
03:48 installs miner
03:49 done

Real incidents rarely cooperate that nicely.

What I actually had was closer to:

             ┌───────────────┐
             │ Earlier access│
             └───────┬───────┘
                     │
                     ▼
             unknown activity
                     │
                     ▼
          malicious files appear
                     │
                     ▼
               webshell
                     │
                     ▼
            payload / miner
                     │
                     ▼
             persistence

Some events were strongly supported.

Others were only possible.

I had to keep those categories separate.


Filesystem timestamps helped—but only as clues

One of the most useful commands was:

find /home/customer01/public_html \
-type f \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %s | %p\n' \
2>/dev/null |
sort

This gave me a huge list.

So I narrowed it down to a time range:

find /home/customer01/public_html \
-type f \
-newermt '2026-08-22 16:50:00 UTC' \
! -newermt '2026-08-22 16:58:37 UTC' \
-printf '%TY-%Tm-%Td %TH:%TM:%TS | %s | %p\n' \
2>/dev/null |
sort

Sometimes the result was empty.

That was also useful.

An empty result means:

No files matching the filesystem criteria were found in that period.

It doesn’t mean:

Nothing happened.

Again, context matters.


One of my favorite discoveries was actually a negative result

I had previously found:

bf6f03.php

After restoration and cleanup:

test ! -e /home/customer01/public_html/bf6f03.php \
  && echo "OK: bf6f03.php removed"

Result:

OK: bf6f03.php removed

Then I ran a stronger pattern search for obvious webshell behavior.

The command produced no output.

That was good.

But again, it didn’t prove that every possible malicious technique was absent.

It meant that those particular indicators were no longer present.

This distinction sounds pedantic until you’re responsible for a server.

Then it becomes extremely important.


The weird image files were a better IOC

One of the strongest practical techniques I found was checking whether files with image extensions were actually images.

For example:

file suspicious.png

A legitimate image might return:

PNG image data

A malicious fake image might return:

PHP script, ASCII text

That’s a huge difference.


I expanded that check across the whole server

This command became useful:

find /home -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"*|*"ASCII text"*|*"HTML document"*|*"XML document"*)
            echo "$f"
            echo "  -> $type"
            ;;
    esac
done

The output was surprisingly revealing.

There were files like:

/home/customer02/.../something.jpeg
    -> PHP script

and:

/home/customer02/.../something.gif
    -> PHP script

That gave me a very useful IOC category:

Image extension + non-image content.


But some “ASCII image” files were legitimate

This was another trap.

The command also found files inside libraries such as:

jpgraph/docs/chunkhtml/images/

Some of those were reported as ASCII text.

That doesn’t automatically mean they’re malicious.

Documentation examples and generated assets can legitimately be unusual.

So once again:

file says ASCII text

does not equal:

MALWARE

You still need to inspect the file and its location.

The suspicious ones were suspicious because several indicators lined up:

  • strange directory
  • strange filename
  • wrong file type
  • PHP content
  • suspicious creation period
  • association with other malicious artifacts

That’s much stronger.


Cloudflare added another layer of confusion

Some of the access-log addresses belonged to Cloudflare.

That matters because the web server may see a Cloudflare edge IP rather than the visitor’s actual IP unless the real client IP is correctly restored through the proxy headers.

So an Apache line like:

172.x.x.x

doesn’t necessarily mean:

“The attacker was physically using this IP.”

It may mean:

“This Cloudflare edge handled the request.”

For an incident report, I therefore prefer language such as:

“The request reached the server through Cloudflare from edge address X.”

rather than:

“Attacker IP X.”

That small wording difference prevents a lot of bad forensic conclusions.


Some scanners were extremely noisy

During the investigation I also found requests from automated scanners.

For example, one user agent identified itself as:

l9scan/...

and referenced:

leakix.net

Those scanners were probing things like:

/graphql
/api
/config.json
/info.php
/telescope/requests
/actuator/env
/trace.axd
/@vite/env
/.vscode/sftp.json

Most returned:

404

That’s useful evidence of Internet background noise.

But it is not evidence that those scanners compromised the server.

They were probing.

The server returned 404.

That’s a very different situation.


This distinction saved me from blaming the wrong attacker

If I had looked only at the logs, I could have written a dramatic story:

“A scanner attacked the server!”

But the evidence doesn’t justify that.

The logs showed automated scanning activity.

They did not establish that those scanners caused the compromise.

That’s exactly the kind of mistake I wanted to avoid in this article.


What the logs did prove

The logs were still extremely valuable.

They showed that the server was constantly being probed.

They showed WordPress login activity.

They showed XML-RPC traffic.

They showed automated scanners.

They showed requests returning 404.

They showed normal WordPress traffic.

And importantly, they helped establish what wasn’t visible in the relevant time windows.

That last part is underrated.


Absence of evidence isn’t evidence of absence

This old saying is particularly appropriate for server logs.

Suppose I search:

grep 'POST /wp-admin/install.php?step=2' access_log

and get nothing.

I can conclude:

That exact request isn’t present in this log.

I cannot conclude:

The attacker never used WordPress installation.

Maybe:

  • another log contains it
  • the request happened before rotation
  • the attack used another endpoint
  • the attacker used another account
  • the attacker didn’t need that endpoint
  • the request came through another service

This is why I became increasingly conservative about the wording in my notes.


Eventually I stopped trying to identify the exact first request

This may sound like giving up.

It wasn’t.

There is a point where an investigation has enough evidence to answer the operational question:

Can I trust this installation?

In my case, the answer was no.

I had confirmed malicious files and execution behavior.

I didn’t need to know the exact HTTP request that created the first shell before I could decide to restore from backup.

That distinction saved a lot of time.


The recovery decision

At that point my options were:

Option A — Clean the existing filesystem

find malware
   ↓
delete malware
   ↓
search again
   ↓
repeat

Option B — Restore from a known-good backup

wipe account
   ↓
restore backup
   ↓
verify
   ↓
harden

For a compromised multi-site VPS, I preferred Option B.

Not because restoring is magical.

Because it gives me a much cleaner starting point.


I intentionally did not try to “save” every suspicious file

This was an important change in mindset.

Earlier I was worried about preserving files.

Once I had captured the evidence I needed, that priority changed.

The objective became:

Get the production environment back from a trusted baseline.

I didn’t need to rescue every weird PHP file.

I didn’t need to preserve the attacker’s webshell in its original location.

I needed the websites.

And I needed to know what had been compromised.

Those are different goals.


The restore was deliberately boring

That’s exactly what I wanted.

I used the existing Virtualmin backup process rather than trying to manually reconstruct the websites.

The important thing was:

known backup
   ↓
fresh account contents
   ↓
known malicious files absent
   ↓
verification

I wasn’t trying to repair a Frankenstein filesystem.

I wanted a clean filesystem from the historical snapshot.


Then I checked the restored server

One of the first checks was the known webshell:

test ! -e /home/customer01/public_html/bf6f03.php \
  && echo "OK: bf6f03.php removed"

Then the stronger PHP webshell pattern:

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

No output.

Good.

Then the disguised-image check.

Again, no suspicious output.

Better.


But I wasn’t ready to declare victory

This is probably the most important sentence in the entire series.

A clean scan is not the same thing as a clean server.

The restore gave me a known historical filesystem.

The scans gave me some confidence that the known indicators were gone.

But I still had to address:

  • credentials
  • MySQL exposure
  • WordPress accounts
  • SSH access
  • scheduled tasks
  • system services
  • plugins and themes
  • operating-system updates
  • permissions
  • logging
  • monitoring

Only after those were addressed did I feel comfortable bringing the server back into normal operation.


The incident changed how I think about backups

Before this incident, I mostly thought about backups in terms of:

“Can I recover my website?”

After the incident, I started thinking:

“Can I recover my website to a point before the attacker was there?”

Those are not the same question.

A backup is not only a disaster-recovery tool.

For security incidents, it can also be a historical reference point.

That’s incredibly valuable.


My practical rules after this incident

If I had to reduce everything from this investigation to a short checklist, it would be:

1. Don’t immediately delete suspicious files

Preserve evidence first if you may need to understand the compromise.

2. Don’t trust file extensions

Run:

file suspicious-file

3. Don’t assume wp-* means WordPress

Read the code.

4. Search the entire account, not just wp-content

Attackers like hiding in places that look legitimate.

5. Don’t trust one log

Correlate:

access logs
error logs
filesystem timestamps
database
system logs

6. Don’t confuse scanners with successful compromise

A 404 scanner request is still just a scanner request.

7. Don’t assume a missing log entry proves nothing happened

Logs have limitations.

8. If the host was seriously compromised, restore rather than endlessly cleaning

Especially when you have a trustworthy backup.

9. Rotate credentials

Assume secrets stored on the compromised machine may have been exposed.

10. Don’t expose MySQL globally just because a temporary VPS has a changing IP

Use VPN, SSH tunneling, private networking, or controlled temporary firewall rules.


And finally, bring the server back slowly

After two days offline, the temptation was obvious:

START APACHE
START MARIADB
LET'S GO

I resisted that temptation.

Instead:

restore
  ↓
verify filesystem
  ↓
verify database
  ↓
harden credentials
  ↓
restrict network access
  ↓
start MariaDB
  ↓
test
  ↓
start Apache
  ↓
watch logs

A few extra minutes here are worth considerably more than another two-day incident.


The final question

At the end of the investigation, I had a reasonably clean restored environment.

But there was still one thing I wanted to understand:

What exactly did the attacker leave behind, and what can another WordPress administrator look for before discovering the same problem the hard way?

That became the most useful part of the investigation.

Because the real value of documenting an incident isn’t proving that I got hacked.

It’s giving the next person a checklist they can run before their server becomes a crypto-mining machine.


Coming next

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

We’ll turn the investigation into a practical set of commands for finding:

  • PHP webshells
  • fake image files
  • suspicious WordPress files
  • encoded PHP payloads
  • unexpected executables
  • cryptominers
  • cron persistence
  • systemd persistence
  • SSH keys
  • suspicious WordPress administrators
  • suspicious database users
  • exposed MySQL
  • and the indicators that are worth investigating versus the ones that are just normal Internet noise.

No Comments

Leave a Reply

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