A WordPress malware cleanup that doesn't identify the original entry point is temporary. The attacker walked in through a door, and if you close the back rooms (the backdoors, the fake admins, the persistence) but leave the front door propped open, they walk back in through the same door the next day, the next week, or whenever they get back to your name on their target list.
This article is the forensic step that has to come before cleanup: working through the server access logs, error logs, and file modification times to identify exactly which plugin CVE, which credential, or which upload path the attacker used. Without this step, every cleanup is provisional. With it, you can close the specific vulnerability and the cleanup actually holds.
I've used this same methodology since the 2017-era brute-force and REST-API-exploitation waves. The specific plugin CVEs change every year; the log patterns and the order of operations have stayed identical. This works on Apache and Nginx logs, on shared hosting where you only have cPanel-exported access logs, and on managed hosting where the logs come from the host's dashboard.
Set your site values
Set your WordPress install root, the path to your merged access log, and the approximate date the symptoms appeared once, and every find, grep, and awk example below picks them up. The symptom date is in Apache log format (DD/Mon/YYYY); leave it at the default if you don't yet know the exact day, and update it once Step 1 narrows the window.
Set the WordPress install root, the path to your concatenated access log, and the approximate symptom date (Apache log format). Every find, grep, and awk example below substitutes your values automatically.
What you need before starting
Three things, in roughly this order:
- At least 14-30 days of access logs, preferably more. Compromises often go unnoticed for weeks, and the entry can be older than you think. If your host only retains 7 days, request a longer window now and treat anything older as a known unknown.
- A rough date for "when did the symptoms start?" Even an approximate date narrows the search dramatically. Search Console flagged the site Tuesday → attacker was in by Monday at the latest → look at access logs from the previous 7-14 days.
- Access to the WordPress error log (
wp-content/debug.logifWP_DEBUG_LOGis on; otherwise the PHP error log via your hosting panel) and to file modification times in the WordPress directory.
If your host gives you access logs in cPanel-exported gzipped daily files, decompress and concatenate them into one file:
mkdir -p :log_dir && cd :log_dir
zcat /path/to/cpanel-logs/*.gz > access.log
# Sort by time (cPanel files are sometimes out of order)
sort -k 4 access.log > access-sorted.logOn managed hosts, download the daily log files and cat them together. Sort if the host doesn't deliver them in time order.
The order of operations
Don't grep randomly. The investigation has a logical order. Work through these sections top-to-bottom; each narrows the search for the next.
Step 1: Establish a timeline from file modification times
Before touching the logs, ask the file system: when did things start changing?
# 30 most recently modified PHP files in the WordPress directory
find :wp_root -type f -name '*.php' -printf '%T+ %p\n' 2>/dev/null \
| sort -r | head -30
# Same but for any file type (JS, .htaccess, images that might be PHP shells with the wrong extension)
find :wp_root -type f -printf '%T+ %p\n' 2>/dev/null \
| sort -r | head -50
# macOS BSD `find` doesn't support -printf; use `stat` instead
find :wp_root -type f -name '*.php' -exec stat -f '%Sm %N' -t '%Y-%m-%d %H:%M:%S' {} \; \
2>/dev/null | sort -r | head -30You're looking for a cluster: a date where multiple files were modified within a short window, none of which you remember editing. That cluster is the moment of compromise. Often it's a backdoor file with an unfamiliar name, a modified index.php, a modified .htaccess, and a modified wp-config.php, all within the same hour.
Note the time of the earliest file in the cluster. That's your compromise window start. The access-log grep starts there or shortly before.
Step 2: Look at the requests right before the compromise window
Now look at access-log requests in the window from a few hours before the earliest modified file to the timestamp itself. The attacker's exploit is in that window.
# Set the symptom date in the panel above (Apache log format, e.g. 15/Jul/2025)
# This pulls every request to PHP files from the compromise day
grep -E ':symptom_date.*\.php' :access_log \
| awk '{print $1, $4, $6, $7, $9}' | head -100The awk pattern strips the access log down to: IP, timestamp, method, URL, response code. Scan visually for anything unusual: POST requests to plugin endpoints, requests to PHP files inside wp-content/uploads/, requests with very long query strings (often SQL injection probes), repeated authentication attempts.
Step 3: Brute force and authentication attack signatures
The lowest-effort entry vectors are still the most common.
XML-RPC brute force: a flood of POST requests to /xmlrpc.php, often from many different IPs. WordPress's XML-RPC endpoint accepts authentication and is rate-limited weakly, so a botnet can try thousands of credential combinations per minute.
# Count POST requests to xmlrpc.php by IP, sorted by request count
grep -E 'POST .*/xmlrpc\.php' :access_log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20Top IPs with hundreds or thousands of requests within a short window are credential-stuffing the endpoint.
wp-login.php brute force: the same pattern but against the login form.
grep -E 'POST .*/wp-login\.php' :access_log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20For both: if you see a single IP making thousands of POST requests in a few hours, that's the credential attack. If a successful 200 response follows those POSTs (different from the typical 200 of a failed login page render, check by manually inspecting), the attacker found a valid credential pair.
Step 4: Plugin endpoint exploitation
The most common 2024-2025 entry vector. Look for POST or GET requests to plugin admin-ajax endpoints, REST API routes, and plugin file-upload endpoints, especially with response codes in the 200 (success) range:
# admin-ajax.php with non-trivial query strings (often plugin AJAX exploits)
grep -E 'admin-ajax\.php\?action=' :access_log \
| awk '{print $1, $4, $7, $9}' | head -50
# WordPress REST API
grep -E '/wp-json/[^/]+/' :access_log \
| awk '{print $1, $4, $6, $7, $9}' | head -50
# Direct plugin path requests (the attacker hits the plugin file directly)
grep -E '/wp-content/plugins/[^/]+/.*\.php' :access_log \
| awk '{print $1, $4, $6, $7, $9}' | head -50Cross-reference any plugin name you see in the URLs against the Patchstack database or Wordfence's threat intel feed for known CVEs in the version you had installed at the time. A match on one of these is your entry point.
Notable 2024-2025 plugin entry vectors I've identified in cleanups:
| CVE | Plugin | URL pattern that exploited it | Fixed in |
|---|---|---|---|
| CVE-2024-28000 | LiteSpeed Cache | POST /wp-admin/admin-ajax.php?action=... and direct plugin endpoints | 6.4.1 (Aug 2024) |
| CVE-2024-2879 | LayerSlider | GET /wp-admin/admin-ajax.php?action=ls_get_popup_markup&id=... (SQLi in id) | 7.10.1 (March 2024) |
| CVE-2023-40000 | LiteSpeed Cache | Stored XSS via crawler; visible as unusual ?ver= query strings | 5.7.0.1 (Oct 2023) |
| CVE-2024-4358 | Telerik Report Server (non-WP but commonly co-hosted) | POST /Token with unusual content-type | Patched 2024 |
| CVE-2024-25600 | Bricks Builder | POST /wp-json/bricks/v1/... with PHP code in payload | 1.9.6.1 (Feb 2024) |
The grep pattern that surfaces the LayerSlider exploitation:
grep -E 'ls_get_popup_markup' :access_logThe presence of a 200 response on requests matching the known-CVE patterns above is high-confidence evidence of the entry vector.
Step 5: PHP shell access patterns
If the attacker uploaded a PHP shell (via a file-upload vulnerability, a stolen FTP credential, or hands-on admin access), the next step is them retrieving the shell repeatedly to issue commands. Shell access is highly distinctive in the logs.
# PHP files served from wp-content/uploads, this directory should NEVER serve PHP
grep -E 'GET|POST .*/wp-content/uploads/.*\.php' :access_log \
| awk '{print $1, $4, $6, $7, $9}' | head -30
# Direct requests to files with shell-like names
grep -aiE 'GET|POST.*/(wp-conf|wp-tmp|class-wp-cache|index2|radio|shell|c99|r57|wso|filesman)\.php' \
:access_log | head -30
# Suspicious query strings (shells often take commands via GET params)
grep -aE '\?(cmd|exec|do|action|c)=' :access_log | head -30The same IP making repeated GETs to a PHP file in wp-content/uploads/ is the operator working through their shell. The earliest such request tells you when the shell was first established. Look at the requests immediately before that for the upload that placed the shell.
Step 6: When the entry isn't in the access log
Three cases that don't show in the HTTP access log:
Compromised SFTP/SSH credentials: the attacker logged in via SFTP and wrote files directly. The access log shows nothing because no HTTP request happened.
# Check SSH authentication log (RHEL uses /var/log/secure instead of /var/log/auth.log)
sudo grep -E 'Accepted|Failed' :auth_log /var/log/secure 2>/dev/null \
| tail -100
# On hosts with FTP, check vsftpd / pure-ftpd / proftpd logs
sudo tail -200 /var/log/vsftpd.log /var/log/pure-ftpd.log 2>/dev/nullA successful SSH login from an unfamiliar IP, especially one not in your office or VPN range, at a time you weren't working, is the SFTP/SSH entry. Rotate the credentials immediately.
Database-level attack via leaked credentials: if your database server is exposed (rare on shared hosting; possible on misconfigured VPS), an attacker can mysql in directly using leaked credentials. No HTTP request, no SSH log.
# Check MySQL general query log if it's enabled
sudo tail -200 :{mysql_log_dir}/general.log 2>/dev/null
# Or the MySQL error log for unusual auth attempts
sudo grep -E 'Access denied' :{mysql_log_dir}/error.log 2>/dev/null | headIf you find direct database connections from unexpected source IPs, that's the entry.
Supply-chain compromise via plugin update: a legitimate plugin's WordPress.org account was compromised and an update was pushed that contained malicious code. The site auto-updated and inherited the compromise. No anomalous HTTP request, the malicious code arrived through the legitimate update API.
The detection here is matching the file-modification cluster (from step 1) against the times of plugin updates. If the cluster happens to match the exact moment one of your plugins auto-updated, check Patchstack and the plugin's GitHub or WordPress.org review history for any reported supply-chain incident. The list is curated by Patchstack's vulnerability database and tracked by Sucuri and Wordfence blogs as incidents emerge.
A complete forensic script
Save as wp-entry-forensics.sh, chmod +x, run with the path to your WordPress installation and the path to your concatenated access log.
#!/usr/bin/env bash
# wp-entry-forensics.sh, methodical access-log forensics for finding the entry vector
# of a WordPress compromise. Reports only; does NOT modify anything.
# Source: https://techearl.com/wordpress-entry-point-log-forensics
# Site: https://techearl.com/
#
# Usage:
# ./wp-entry-forensics.sh /path/to/wordpress /path/to/access.log [YYYY-MM-DD-of-symptoms]
set -e
WP_ROOT="${1:?Usage: $0 <wp-root> <access-log> [symptom-date]}"
LOG="${2:?Usage: $0 <wp-root> <access-log> [symptom-date]}"
SYMPTOM_DATE="${3:-}"
echo "========================================="
echo " WordPress Entry-Point Forensics"
echo " WP root: $WP_ROOT"
echo " Log: $LOG"
echo " Symptom date: ${SYMPTOM_DATE:-<not specified>}"
echo "========================================="
# 1. File modification cluster
echo
echo "--- 1. 20 most-recently-modified PHP files in WordPress directory ---"
if [ "$(uname)" = "Darwin" ]; then
find "$WP_ROOT" -type f -name '*.php' \
-exec stat -f '%Sm %N' -t '%Y-%m-%d %H:%M:%S' {} \; 2>/dev/null \
| sort -r | head -20
else
find "$WP_ROOT" -type f -name '*.php' -printf '%T+ %p\n' 2>/dev/null \
| sort -r | head -20
fi
# 2. XML-RPC brute force
echo
echo "--- 2. Top XML-RPC POST sources (credential stuffing target) ---"
grep -E 'POST .*/xmlrpc\.php' "$LOG" 2>/dev/null \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -10
# 3. wp-login.php brute force
echo
echo "--- 3. Top wp-login.php POST sources ---"
grep -E 'POST .*/wp-login\.php' "$LOG" 2>/dev/null \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -10
# 4. PHP files served from wp-content/uploads (red flag)
echo
echo "--- 4. PHP files in wp-content/uploads/ ---"
grep -E 'GET|POST .*/wp-content/uploads/.*\.php' "$LOG" 2>/dev/null \
| awk '{print $1, $4, $6, $7, $9}' | head -20
# 5. Suspicious admin-ajax.php actions (often plugin AJAX exploits)
echo
echo "--- 5. admin-ajax.php request volume by action ---"
grep -aE 'admin-ajax\.php\?action=' "$LOG" 2>/dev/null \
| sed -E 's/.*action=([^& "]+).*/\1/' | sort | uniq -c | sort -rn | head -20
# 6. Known-CVE plugin endpoint probes
echo
echo "--- 6. Known plugin-CVE entry patterns ---"
echo " LayerSlider CVE-2024-2879 (ls_get_popup_markup):"
grep -c 'ls_get_popup_markup' "$LOG" 2>/dev/null || echo " 0"
echo " Bricks Builder CVE-2024-25600 (/bricks/v1/render_element):"
grep -c '/bricks/v1' "$LOG" 2>/dev/null || echo " 0"
echo " LiteSpeed-related requests:"
grep -cE '/litespeed-cache/|action=litespeed' "$LOG" 2>/dev/null || echo " 0"
# 7. REST API exploitation attempts
echo
echo "--- 7. REST API namespaces accessed (unfamiliar ones are suspicious) ---"
grep -aoE '/wp-json/[^ "/]+' "$LOG" 2>/dev/null \
| sort | uniq -c | sort -rn | head -20
# 8. Server access logs (SSH/FTP) if accessible
echo
echo "--- 8. SSH/FTP authentication ---"
sudo grep -aE 'sshd.*Accepted' /var/log/auth.log /var/log/secure 2>/dev/null \
| tail -10 || echo " (auth log not readable; skip on shared hosting)"
echo
echo "========================================="
echo " Cross-reference section 1 (file mod"
echo " times) against sections 2-7 (log)."
echo " The IP / endpoint that appears just"
echo " before the modification cluster is"
echo " your entry vector."
echo "========================================="The script reads only; nothing is deleted or modified. Its job is to surface candidates; the analyst still has to read the output and identify which one is the entry.
Putting it together: a real-world example
A site I cleaned in late 2024:
Symptom: visitors reported a "Cloudflare verification" page on the homepage. Site owner noticed Friday morning.
Step 1, file modification cluster: 14 PHP files modified between 03:12 and 03:18 UTC on Wednesday (two days earlier). Files included wp-config.php, two files in wp-content/themes/active-theme/, a new file at wp-content/uploads/2024/10/cache.php, and modifications to wp-content/plugins/litespeed-cache/litespeed-cache.php.
Step 2, request window: the access log around 03:00-03:18 UTC Wednesday showed normal traffic plus one IP (193.41.X.X) making POST requests to plugin endpoints.
Step 3, known CVE check: the 193.41.X.X requests at 03:10 UTC went to POST /wp-admin/admin-ajax.php?action=... with payloads matching the LiteSpeed Cache CVE-2024-28000 signature. The site was running LiteSpeed Cache 6.3.0.1, vulnerable, fixed in 6.4.1 released August 2024.
Step 4, confirm the chain: at 03:11 UTC the same IP made a POST /wp-admin/users.php (creating an admin), at 03:13 UTC a POST /wp-admin/admin-ajax.php?action=upload-attachment (uploading the PHP file that became cache.php), at 03:14 UTC a request to that uploaded file with command parameters. The chain was end-to-end visible.
The cleanup: update LiteSpeed Cache to current (fixes the entry). Run the full malware playbook to clean files, database, persistence. Rotate credentials. The site has been clean since.
Without step 1's modification cluster pointing at Wednesday at 03:00 UTC, the symptom-day log (Friday) would have shown nothing unusual. The compromise was 48 hours old by the time symptoms appeared; the smoking gun was in Wednesday's logs.
This is why log forensics has to come first, and why an arbitrary "the last 24 hours of logs" isn't enough. Walk backwards from the file system, not forwards from "today".
Common mistakes
The patterns that make log forensics miss the entry:
Starting from "today" instead of from the file modification cluster. If the compromise is 7 days old by the time symptoms appeared, today's logs show only the attacker's ongoing access, not their initial entry. Always start from when files were modified.
Trusting that bot-driven requests are "just noise" without checking the response codes. Most XML-RPC and wp-login POSTs return 401 or 403. If a request to either endpoint returned 200, that's a successful authentication and worth investigating. Filter logs by response code, not just URL pattern.
Missing the supply-chain case. If the file modification cluster aligns exactly with one of your installed plugins' auto-update timestamps, the entry might be the update itself, not an HTTP request. Cross-check the plugin's recent version history on WordPress.org for any reported incidents.
Ignoring the error log. PHP fatal errors during exploitation attempts often log to error_log, wp-content/debug.log, or your hosting panel's error log. A series of PHP Fatal error: Uncaught Error: Call to undefined function ... entries can pinpoint exactly what an attacker tried before succeeding.
Not preserving the logs. As soon as you confirm a compromise, copy the entire access log and error log off the server to your local machine. Many hosts rotate logs daily and delete old ones within 7-30 days. The 14-day-old entry log entry may not exist by the time you go looking for it.
Closing the entry vector without doing the cleanup. Finding the entry is half the work. If the plugin CVE was the entry and you patch it, the attacker who exploited it is still inside via the persistence they planted. Both have to happen, close the entry AND clean the persistence (covered here).
Looking only at HTTP logs when the malware is running above the WordPress directory. If the access log shows no suspicious activity but wp-config.php keeps reinjecting itself within minutes of cleanup, the entry was a previous compromise and the current reinfection is not being driven by an inbound HTTP request at all. The malware is sitting one directory up from your WordPress install, running as a process owned by your hosting user, somewhere no WordPress scanner reaches. You are cleaning WordPress but nothing about the cleanup touches where the malware actually lives. The forensic step shifts from log analysis to process listing: ps -u <web-user> -o pid,etime,comm,args, then hunt for the binary stashed in the user's home directory (commonly under ~/.config/htop/). The signal is a process whose comm looks like a kernel thread ([watchdogd], [kthreadd]) but is owned by a non-root user; that is exec -a spoofing argv[0] to hide a payload binary in plain sight. The persistence article's section 11 and the gsyndication walkthrough cover the detection and cleanup for this case in full.
Frequently asked questions
See also
- How to Remove WordPress Malware: The Practitioner's Playbook: the broader cleanup methodology. Entry-point forensics is step 3 of seven; this article is the deep dive on it.
- Why WordPress Malware Keeps Coming Back: Persistence Mechanisms: once you've identified the entry, the next question is what persistence the attacker installed. The two articles together cover the full "how did they get in, where are they hiding" picture.
- How to Detect and Remove Fake WordPress Admin Users: the symptom side of credential and plugin-CVE compromises. If your entry-point forensics points at a credential issue, this is the cleanup.
- The Fake Cloudflare Verification Attack on WordPress (ClickFix): a common 2024-2025 payload. Identifying the entry vector via log forensics is mandatory for ClickFix cleanups specifically because the visible payload is the second stage, not the first.
- Why Wordfence Got Silently Disabled (and How to Stop It Happening Again): if your security plugin was the first thing the attacker disabled, the log forensics has to use a window that predates the disablement.
- Cross-Site Contamination on Shared WordPress Hosting: on a multi-site shared account, the entry point may be on a sibling site you haven't yet identified as compromised. Pull access logs for every site in the account, not just the one with visible symptoms.
External references: Wordfence's threat intelligence feed and Patchstack's vulnerability database are the two best-maintained trackers of WordPress plugin CVEs. The Sucuri research blog covers specific incident-response cases that are useful for pattern matching against real-world investigations.





