If you’re cleaning up a WordPress compromise and the site has Wordfence installed, you have more forensic data than you think. Even on the free plan, Wordfence quietly logs every blocked request, every plugin-vulnerability advisory, every flagged file, and every scan result into a handful of database tables that nobody ever opens. Those tables are gold for reconstructing what happened — when the breach started, what the entry vector probably was, which sites the attacker also tried, and whether any of the cleanup you’re doing is actually finished.
Here’s how to mine them.
The two tables that matter
wp_wfissues— every flagged finding from a Wordfence scan: out-of-date core, vulnerable plugin, modified core file, malicious file detected, abandoned plugin warning. Each row has aseverity, atype, ashortMsg, and a serializeddatablob with the full context. Most importantly: a Unixtimecolumn.wp_wfhits— every request Wordfence’s firewall layer either blocked or noted. Includes the IP, the URL, the user-agent, the action (“blocked:wfsn”, “loginFailValidUsername”, “loginOK”), and actimemicrosecond timestamp.
Both tables retain data for months by default — Wordfence rotates them based on row count, not age, so on a low-traffic site you may have data going back a year or more.
First query: when did the trouble start?
SELECT FROM_UNIXTIME(time) AS ts, severity, type, shortMsg
FROM wp_wfissues
WHERE shortMsg IS NOT NULL
ORDER BY time ASC
LIMIT 30;This is your earliest-known-bad-day timeline. The first wfPluginVulnerable or knownfile or file entries tell you when Wordfence first noticed something off. In compromise after compromise I’ve worked, the very first row in this list is dated within hours or days of the actual breach. Wordfence saw it; it logged it; nobody looked at the dashboard.
Look especially for entries with severity = 75 or higher and types like:
wfUpgrade— “Your WordPress version is out of date.” If this is dated within days of your earliestfileentries, that’s almost certainly the entry vector.wfPluginVulnerable— a known-CVE plugin you had installed at the time. The serializeddatafield contains the plugin name and version. That’s your second most likely entry vector.file— Wordfence flagged a specific file as suspicious. The earliest of these is when the file landed.knownfile— a WordPress core file was modified. Earliest such entry is when the modification happened, which is usually after the attacker got admin access.
Second query: the brute-force lead-up
SELECT FROM_UNIXTIME(ctime) AS ts,
INET6_NTOA(IP) AS ip,
SUBSTRING(URL, 1, 80) AS url,
action,
actionDescription
FROM wp_wfhits
WHERE URL LIKE '%xmlrpc%' OR URL LIKE '%wp-login%'
ORDER BY ctime ASC
LIMIT 50;If your earliest wfissues row is dated, say, May 6, run this query for the surrounding 48 hours. You’ll see the brute-force lead-up: hundreds or thousands of blocked:wfsn entries, then probably a single loginOK entry that’s the moment the attacker actually authenticated. The IP on that loginOK row is the one to investigate further.
Third query: what else did they touch?
SELECT INET6_NTOA(IP) AS ip,
COUNT(*) AS hits,
MIN(FROM_UNIXTIME(ctime)) AS first,
MAX(FROM_UNIXTIME(ctime)) AS last,
GROUP_CONCAT(DISTINCT action SEPARATOR ', ') AS actions
FROM wp_wfhits
WHERE INET6_NTOA(IP) = '198.51.100.42'
GROUP BY IP;Replace the IP with the one from the loginOK row. This shows everything that IP did. The actions column tells you whether the IP was just brute-forcing wp-login or also poking at xmlrpc, file-managers, REST endpoints, or vulnerable plugins. Often you find the same IP in your other sites’ logs too — confirming this was a multi-site campaign.
Fourth query: ongoing post-compromise activity
SELECT FROM_UNIXTIME(ctime) AS ts,
INET6_NTOA(IP) AS ip,
SUBSTRING(URL, 1, 120) AS url
FROM wp_wfhits
WHERE action = 'loginOK'
OR URL REGEXP '/wp-admin\\.php|wp-content/uploads/.*\\.php'
ORDER BY ctime DESC
LIMIT 30;This catches a few things at once: every successful login (so you can confirm only legitimate IPs have been logging in lately), and any access to suspicious paths like wp-admin.php at the webroot (a known dropper name) or PHP files inside uploads/ (a planted webshell). After you’ve cleaned up, run this query weekly. Nothing should show up; if something does, the cleanup wasn’t complete.
A note on retention
If you’re starting a forensic dig and you don’t see anything earlier than a few weeks ago, check whether someone (you?) recently bumped the Wordfence “Maximum number of records” setting down. The default keeps the table small for performance reasons, which means high-traffic sites lose visibility fastest. Bumping the cap to 10,000 rows or higher costs almost nothing on a database with billions of rows in wp_posts and gives you actual forensic depth.
Also: wp_wfconfig stores the current rule set, the IP allowlist, and the last-scan timestamp. Look there if you want to confirm Wordfence was actually running scans during the period of interest, not paused or misconfigured.
When the data isn’t there
If the attacker had admin access, they may have wiped these tables themselves to cover tracks. SELECT MIN(time), COUNT(*) FROM wp_wfissues tells you in one query whether you have continuous history or a suspicious gap. A 6-month-old install with 5 rows in wfissues is showing you a tampered table, not a clean site.
For sites with no Wordfence at all, your forensic data is the LSWS access logs (/usr/local/lsws/<site>/logs/access.log*). They’re less structured but they don’t lie. Worth a separate post.
The point: you almost certainly have more breach-timeline data than you think. Wordfence’s UI surfaces the latest 100 or so issues; the database has thousands more, and the SQL above turns “we got hacked sometime last summer” into a precise hour and an IP address. That precision is what lets you actually fix what went wrong, instead of guessing.
