WordPress hacked: a step-by-step incident response that doesn’t make it worse

Was this helpful?

Your WordPress site is acting weird. Traffic tanked. Search Console screams. Customers email screenshots of casino links you definitely didn’t publish. Someone on Slack says “just delete the site and restore from backup.” Someone else says “install a security plugin.” Both are trying to help. Both can make it worse.

When WordPress gets hacked, the technical problem is solvable. The operational problem is harder: don’t destroy evidence, don’t spread the infection, don’t lie to yourself with a “green” scan, and don’t restore a compromised backup into production with confidence you didn’t earn.

Ground rules: stabilize before you “fix”

Incident response is not a vibe. It’s a sequence. You can go fast, but you can’t skip steps without paying later—usually at 2 a.m. with a CFO on the call.

Here are the rules that keep you out of self-inflicted disaster:

  • Don’t log in through wp-admin to “see what’s going on” if you suspect credential theft. Use server-side access first.
  • Don’t run “cleanup” tools first. They mutate evidence and can delete the only clue about how the attacker got in.
  • Don’t restore over the top of a running compromised host. If the host is owned, your “fresh” WordPress is just a new tenant.
  • Contain first, then investigate. Keep business impact in mind, but contain the blast radius before you start poking the bear.
  • Decide your objective: is this a marketing site you can take offline, or a revenue-critical checkout path? Your containment choices differ.

One quote worth remembering (paraphrased idea): Gene Kranz, NASA flight director, pushed “tough and competent” as an operating standard—no excuses, no panic, just disciplined action.

Fast diagnosis playbook (first/second/third)

This is the “walk in, don’t trip over your own feet” playbook. The goal is to identify the bottleneck: is this a WordPress-level compromise, a host-level compromise, a supply-chain compromise (plugin/theme), or simply a traffic/SEO aftermath?

First: confirm the impact and containment priority (5–10 minutes)

  • Is the site serving malicious content right now? If yes: contain at the edge (WAF/CDN) or at the web server (maintenance mode / deny rules).
  • Is data at risk? If the site has customer accounts, checkout, or admin logins: assume credential theft until proven otherwise.
  • Is the host shared? Shared hosting or multi-tenant VM means lateral movement is plausible. Contain more aggressively.

Second: identify the compromise layer (10–20 minutes)

  • WordPress-only signs: injected JS in posts, rogue admin users, modified theme files, backdoored plugins, weird cron events.
  • Host signs: unknown processes, SSH keys you don’t recognize, rootkit-ish behavior, suspicious outbound connections.
  • Supply-chain signs: a plugin/theme update around the first detection, or a “nulled” theme in the history. (Nulled is a fancy word for “pirated with a side of malware.”)

Third: pick the fastest safe path

  • If host compromise is likely: rebuild the host. Don’t negotiate with it.
  • If WordPress compromise is likely: move the site to a clean host, restore from known-good backup, then selectively migrate content.
  • If you lack good backups: do forensics and surgical cleanup, but plan a rebuild anyway.

Nine facts and a little history (that change decisions)

  1. WordPress started in 2003 as a fork of b2/cafelog. Its success made it a target, because attackers like ROI.
  2. The “TimThumb” era (early 2010s) taught the ecosystem a painful lesson: one popular component used everywhere becomes a mass-exploitation multiplier.
  3. XML-RPC (introduced for remote publishing) has been repeatedly abused for brute force and amplification patterns. Many sites don’t need it.
  4. wp-cron.php isn’t a real cron; it’s triggered by web requests. Under load or attack, it can become a self-inflicted DoS or a persistence channel.
  5. File integrity is a superpower: the WordPress core is deterministic. If you can’t quickly tell what changed, you’re operating blind.
  6. Most compromises aren’t “zero-days”; they’re outdated plugins/themes, reused passwords, leaked credentials, or writable files.
  7. SEO spam is often “conditional”: attackers serve clean pages to admins and malicious pages to search bots or specific user agents to delay detection.
  8. Backdoors love boring places: mu-plugins, must-use directories, cache directories, uploads, and “temporary” folders are common hideouts because people don’t diff them.
  9. A clean scan doesn’t prove cleanliness: many scanners are signature-based. Attackers can be more creative than a regex.

Phase 1: containment without collateral damage

Containment is about stopping harm. Not proving root cause. Not doing a perfect cleanup. Stop the bleeding.

Contain at the edge if you can

If you have a CDN/WAF, flip the site into a maintenance page, or block suspicious paths. This is fast, reversible, and doesn’t mutate the compromised host.

  • Block access to /wp-admin and /wp-login.php except from known IPs.
  • Rate-limit or block requests with exploit patterns hitting known vulnerable endpoints.
  • Temporarily disable XML-RPC if you don’t need it.

Contain at the server when the edge isn’t available

If you’re on a single VM without a WAF, you can still contain. Do it in a way that keeps logs and doesn’t nuke your filesystem.

Short joke #1: If your first reaction is “chmod -R 777 so it works,” congratulations—you’ve invented a malware subscription service.

Phase 2: preserve evidence (cheap insurance)

Evidence preservation is not about cosplay forensics. It’s about answering two questions later: “How did they get in?” and “Are we actually clean?” Without evidence, you will guess. Guessing is expensive.

Preserve:

  • Web server logs (access and error).
  • PHP-FPM logs if present.
  • WordPress directory tree (or at least hashes + metadata).
  • Database snapshot.
  • System auth logs (/var/log/auth.log or equivalent).
  • List of processes and network connections.

Phase 3: triage with commands (what it means, what you decide)

These tasks are designed to be runnable on a typical Linux host (Debian/Ubuntu-ish). Adjust paths for your distro and web stack. Each task includes: a command, what typical output means, and the decision you make.

Task 1: Identify the web root and ownership drift

cr0x@server:~$ ps aux | egrep 'nginx|apache2|httpd|php-fpm' | head
root       812  0.0  0.3  56400  7420 ?        Ss   Dec26   0:02 nginx: master process /usr/sbin/nginx
www-data   913  0.0  0.4  59824 10240 ?        S    Dec26   0:11 nginx: worker process
root      1021  0.0  0.6 225320 13240 ?        Ss   Dec26   0:05 php-fpm: master process (/etc/php/8.2/fpm/php-fpm.conf)
www-data  1055  0.0  0.7 225320 15400 ?        S    Dec26   0:40 php-fpm: pool www

What it means: Your web/PHP workers run as www-data. If WordPress files are owned by www-data, the web process can modify them, which is convenient for updates and extremely convenient for attackers.

Decision: If the site is compromised and owned by the web user, treat file integrity as untrusted. Plan to rebuild from known-good sources, not “clean in place.”

Task 2: Freeze the filesystem view (quick inventory of recent changes)

cr0x@server:~$ sudo find /var/www/example -type f -mtime -3 -printf '%TY-%Tm-%Td %TT %u %g %p\n' | head
2025-12-26 09:12:44.000000000 www-data www-data /var/www/example/wp-content/uploads/2025/12/cache.php
2025-12-26 09:12:50.000000000 www-data www-data /var/www/example/wp-includes/version.php
2025-12-26 09:13:02.000000000 www-data www-data /var/www/example/wp-content/plugins/hello/hello.php

What it means: Core files like wp-includes/version.php shouldn’t be modified by uploads activity. cache.php inside uploads is a classic “PHP in uploads” red flag.

Decision: If you see core file modifications outside planned updates, escalate to “core tampering.” That usually means full codebase replacement.

Task 3: Check for PHP execution in uploads (configuration reality check)

cr0x@server:~$ grep -R --line-number -E 'location\s+~\s+\*?\s*\^?/wp-content/uploads|deny\s+all|php' /etc/nginx/sites-enabled/* 2>/dev/null
/etc/nginx/sites-enabled/example.conf:42:location ~* ^/wp-content/uploads/.*\.php$ { deny all; }

What it means: This server has a rule denying PHP in uploads. If you don’t see this (or equivalent), attackers can drop a “.php” backdoor in uploads and execute it.

Decision: If missing, add it during recovery. If present but you still see uploads backdoors, look for alternative execution paths (e.g., .phtml, misconfigured handlers, or direct PHP-FPM routing).

Task 4: Inspect web access logs for exploit spikes and suspicious endpoints

cr0x@server:~$ sudo awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
  4123 /wp-login.php
  1988 /xmlrpc.php
   744 /wp-admin/admin-ajax.php
   321 /wp-content/uploads/2025/12/cache.php
   275 /wp-json/wp/v2/users

What it means: High volume to wp-login.php and xmlrpc.php suggests credential attacks. Requests to an uploads PHP file indicate execution attempts or successful usage.

Decision: If you see direct hits to a suspicious file, prioritize isolating and preserving that file, then search for siblings and persistence mechanisms.

Task 5: Correlate suspicious requests with response codes

cr0x@server:~$ sudo grep 'cache.php' /var/log/nginx/access.log | tail -5
203.0.113.44 - - [26/Dec/2025:09:12:56 +0000] "GET /wp-content/uploads/2025/12/cache.php?cmd=id HTTP/1.1" 200 31 "-" "curl/7.74.0"
203.0.113.44 - - [26/Dec/2025:09:13:10 +0000] "GET /wp-content/uploads/2025/12/cache.php?cmd=uname+-a HTTP/1.1" 200 98 "-" "curl/7.74.0"

What it means: A 200 response to ?cmd=id is basically a confession. This is an interactive web shell or command runner.

Decision: Treat as confirmed remote code execution. Assume credential theft and lateral movement attempts. Rebuild the host unless you can prove isolation boundaries.

Task 6: Snapshot running processes (look for weird PHP or cron behavior)

cr0x@server:~$ ps aux --sort=-%cpu | head -10
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
www-data  21144  85.2  1.9 412300 38220 ?      R    09:14   3:12 php /var/www/example/wp-content/uploads/2025/12/cache.php
www-data  1055   4.1  0.7 225320 15400 ?      S    Dec26   0:40 php-fpm: pool www

What it means: PHP executing a file inside uploads as a long-running process is abnormal. That’s typically a web shell doing work (spam, scanning, crypto mining, outbound callbacks).

Decision: Contain immediately (block requests; take site offline), then preserve evidence. Killing the process is fine, but don’t stop there—replace the file and find how it got there.

Task 7: Check outbound connections (is it calling home?)

cr0x@server:~$ sudo ss -tpn | head
State Recv-Q Send-Q Local Address:Port  Peer Address:Port  Process
ESTAB 0      0      10.0.0.12:52418    198.51.100.77:443  users:(("php",pid=21144,fd=12))
LISTEN 0     511    0.0.0.0:80         0.0.0.0:*         users:(("nginx",pid=812,fd=6))

What it means: A PHP process making outbound TLS connections is often exfiltration, C2, or spam submission. Could also be legitimate API calls, but the PID points to the suspicious process.

Decision: Block outbound egress at the firewall for the instance if possible, at least temporarily, then proceed with rebuild/eradication.

Task 8: Verify WordPress core integrity (without trusting the web UI)

cr0x@server:~$ cd /var/www/example && sudo -u www-data wp core verify-checksums
Warning: File should not exist: wp-includes/wp-tmp.php
Warning: File should not exist: wp-admin/css/colors/coffee/coffee.php
Error: Checksum verification failed for: wp-includes/version.php, wp-settings.php

What it means: Unexpected files in core directories and checksum failures indicate tampering. This is not “just a bad plugin.” It’s deeper.

Decision: Replace WordPress core entirely from a trusted source. Do not attempt line-by-line edits unless you’re doing forensics.

Task 9: Enumerate admin users and look for sleepers

cr0x@server:~$ sudo -u www-data wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
+----+------------+----------------------+---------------------+
| ID | user_login  | user_email            | user_registered     |
+----+------------+----------------------+---------------------+
|  1 | editor-in-chief | eic@example.com  | 2021-04-12 10:22:19 |
| 42 | wp_support  | wp-support@proton.tld | 2025-12-26 09:10:03 |
+----+------------+----------------------+---------------------+

What it means: New admin accounts created at the time of compromise are common persistence. The email domain is often a clue, but don’t rely on stereotypes—attackers use normal-looking addresses too.

Decision: Disable/remove suspicious accounts after evidence is preserved. Then rotate all admin passwords and invalidate sessions.

Task 10: Dump and scan WordPress cron events for persistence

cr0x@server:~$ sudo -u www-data wp cron event list | head
+----------------------+---------------------+---------------------+----------+
| hook                 | next_run_gmt        | recurrence          | args     |
+----------------------+---------------------+---------------------+----------+
| wp_version_check     | 2025-12-27 02:10:00 | twice_daily         |          |
| wp_update_plugins    | 2025-12-27 02:12:00 | twice_daily         |          |
| wp_tmp_cache_refresh | 2025-12-26 09:20:00 | every_minute        |          |
+----------------------+---------------------+---------------------+----------+

What it means: A nonstandard hook running every minute is suspicious, especially if your site doesn’t have legitimate job scheduling requirements.

Decision: Locate who registered that hook (plugin/theme/mu-plugin) and remove it. If you can’t confidently attribute it, treat as malicious until proven otherwise.

Task 11: Identify mu-plugins and must-use persistence

cr0x@server:~$ ls -la /var/www/example/wp-content/mu-plugins
total 24
drwxr-xr-x 2 www-data www-data 4096 Dec 26 09:09 .
drwxr-xr-x 8 www-data www-data 4096 Dec 26 09:05 ..
-rw-r--r-- 1 www-data www-data 8120 Dec 26 09:09 security-update.php

What it means: Many sites don’t use mu-plugins at all. A recently created mu-plugin is a high-signal persistence location because it loads automatically.

Decision: Quarantine and inspect the file. If it’s malicious, you now have a likely persistence mechanism and a time anchor for logs.

Task 12: Search for common obfuscation patterns (fast, not perfect)

cr0x@server:~$ sudo grep -R --line-number -E 'base64_decode\(|gzinflate\(|str_rot13\(|eval\(|preg_replace\(.*/e' /var/www/example/wp-content | head
/var/www/example/wp-content/uploads/2025/12/cache.php:1:
/var/www/example/wp-content/themes/example/functions.php:402:$a=gzinflate(base64_decode($b));

What it means: These functions aren’t automatically evil, but in WordPress land they’re used disproportionately for hiding payloads. The uploads hit is almost certainly malicious.

Decision: Treat as compromise indicators. Replace the theme/plugin from a trusted copy. Don’t “just delete the one line” and call it a day.

Task 13: Verify database for injected content and rogue options

cr0x@server:~$ sudo mysql -NBe "SELECT option_name FROM wp_options WHERE option_name IN ('active_plugins','siteurl','home')"
home
siteurl
active_plugins

What it means: This confirms the option keys exist (normal). The real value check is next: do they point where you expect, and are active plugins sane?

Decision: If siteurl/home are modified, you may be dealing with redirect injection or admin takeover. If active_plugins includes unknown plugins, that’s a persistence vector.

Task 14: Check for suspicious redirects in the database (conditional SEO spam)

cr0x@server:~$ sudo mysql -NBe "SELECT ID,post_title FROM wp_posts WHERE post_content LIKE '%document.location%' LIMIT 5"
1931  Summer Sale
2077  About Us

What it means: JavaScript redirects in content fields often drive SEO spam, affiliate redirects, or exploit kits.

Decision: If found, plan a content cleanup pass (export, sanitize, re-import) and invalidate caches/CDN. Also assume the attacker had write access to the DB or admin.

Task 15: Confirm SSH access history and unexpected keys

cr0x@server:~$ sudo tail -50 /var/log/auth.log | egrep 'Accepted|Failed|session opened' | tail
Dec 26 09:08:14 server sshd[18422]: Accepted password for ubuntu from 203.0.113.44 port 53122 ssh2
Dec 26 09:08:15 server sshd[18422]: pam_unix(sshd:session): session opened for user ubuntu by (uid=0)

What it means: A successful SSH login from the same IP hitting your web shell is a very bad sign. That’s not “WordPress got hacked,” that’s “the box is owned.”

Decision: Initiate full host rebuild. Rotate all secrets that ever touched this host. Review cloud audit logs for key changes and snapshots.

Task 16: Confirm backups are not quietly compromised (spot-check a backup)

cr0x@server:~$ sudo tar -tf /backups/example/wp-files-2025-12-20.tar.gz | egrep 'wp-content/uploads/.*\.php|mu-plugins|wp-includes/wp-tmp' | head
var/www/example/wp-content/uploads/2025/12/cache.php
var/www/example/wp-content/mu-plugins/security-update.php

What it means: If your backup contains the same malicious artifacts, restoring it will resurrect the compromise. Backups preserve truth, not virtue.

Decision: Find the last known-good backup. If you can’t, you must rebuild from clean sources and migrate only validated content.

Common entry points and how to prove them

You don’t get to claim “plugin vulnerability” because it feels plausible. You prove it, or you treat the system as compromised in multiple ways.

1) Stolen admin credentials

How it happens: password reuse, phishing, credential stuffing, leaked browser session, or an infected admin laptop.

How to prove it:

  • Look for successful logins and admin actions around the first compromise time in access logs.
  • Check for new admin users created (Task 9).
  • Check WordPress sessions and application passwords if used.

What you do about it: rotate all admin creds, force logout, enable MFA, and review who has admin when they shouldn’t.

2) Vulnerable plugin/theme, or supply-chain compromise

How it happens: outdated plugin with a known exploit, or a plugin update pulled from a compromised source.

How to prove it:

  • Compare plugin files against known-good versions.
  • Check timestamps: a plugin directory modified right before compromise is suspicious.
  • Inspect logs for requests to plugin-specific endpoints before the first malicious file appears.

What you do about it: remove/replace the plugin; pin to trusted sources; reduce plugin count like you mean it.

3) Writable filesystem + RCE chain

How it happens: once an attacker gets code execution, they write persistence into WordPress files, uploads, or mu-plugins.

How to prove it: suspicious PHP in uploads (Task 2/5/6/12), checksum mismatches (Task 8), unexpected mu-plugins (Task 11).

What you do about it: lock down file permissions; disable PHP execution in writable directories; move to immutable deploys if possible.

4) Compromised hosting account / SSH

How it happens: weak SSH password, leaked key, reused credentials, stolen cloud API token.

How to prove it: auth logs show unexpected logins (Task 15), new keys in ~/.ssh/authorized_keys, or unexpected sudo usage.

What you do about it: rebuild host, rotate secrets, add MFA to cloud console, lock SSH, review IAM.

Phase 4: eradication (actually remove the attacker)

Eradication is where people get reckless. They delete the web shell they saw, feel heroic, and move on. Meanwhile the attacker keeps access through a mu-plugin, a scheduled cron hook, a rogue admin, and a database injector. You didn’t eradicate anything; you performed interpretive dance on a live system.

Preferred approach: rebuild on a clean host

If you have any hint of host-level compromise, rebuild. That means a new VM/container, fresh OS image, patched packages, minimal services, clean SSH keys, least-privilege DB access, and a restore that you control.

Yes, it’s more work than “cleaning.” It’s also faster than playing whack-a-mole for two weeks.

When you must clean in place (last resort)

Sometimes reality bites: no spare capacity, no infra automation, no clean backup, business won’t allow downtime. If you clean in place, do it with discipline:

  • Replace WordPress core with a fresh copy (don’t patch individual core files).
  • Replace every plugin and theme from a trusted source, or remove it.
  • Delete unknown PHP files in uploads/cache/temp directories after evidence capture.
  • Reset all WordPress salts/keys and all user passwords (at minimum admins).
  • Rotate database credentials and ensure the DB user is least-privilege.
  • Disable XML-RPC if not needed; enforce MFA for admins; lock down wp-admin by IP if feasible.

Two areas people forget: the database and the scheduler

Attackers love persistence that survives file replacement. That’s usually: (1) WordPress options, (2) scheduled tasks, (3) admin users, (4) injected post content, (5) server cronjobs.

Phase 5: recovery and safe restore

Recovery is not “site loads.” Recovery is “site loads, and we can defend that claim.” A proper restore answers: what we restored, why we trust it, what we changed, and how we’ll detect a relapse.

Restore strategy that doesn’t re-infect you

  1. Stand up a clean environment (new VM/container, patched OS).
  2. Install WordPress core from trusted source (same version as your clean backup, or updated if you can validate compatibility).
  3. Restore database from the last known-good snapshot, then inspect for injected content/options.
  4. Restore uploads cautiously. Uploads are where backdoors hide. Copy media only; block PHP execution; scan for .php, .phtml, .phar, and unexpected “images” that contain PHP.
  5. Re-add plugins and themes from trusted sources, not from the old filesystem.

Verify before you open the doors

Verification is layered:

  • Core checksum verification passes.
  • No PHP in uploads (and server blocks it anyway).
  • No unexpected admin users.
  • Logs show normal traffic patterns after reopening.
  • Outbound connections are expected and minimal.

Short joke #2: “We’ll just put it back online and monitor closely” is the incident-response equivalent of “I’ll start eating healthy on Monday.”

Phase 6: hardening that survives contact with humans

Hardening isn’t a list of best practices you paste into a ticket and ignore. It’s the minimum set of guardrails that make the next incident smaller.

File permissions and deployment model

  • Make WordPress core read-only for the web user. Updates should be a deployment action, not a runtime side effect.
  • Uploads should be writable, but not executable. Enforce at the web server and, if possible, at the filesystem mount options.
  • Prefer immutable deploys (build artifacts, deploy, don’t edit in prod). If that’s too big a leap, at least restrict write permissions.

Credential hygiene (the unsexy kind)

  • Enable MFA for WordPress admins and hosting provider accounts.
  • Rotate WordPress salts/keys after an incident; invalidate sessions.
  • Use unique passwords and a manager; stop emailing credentials around.
  • Disable unused accounts and remove vendor “temporary admin” users.

Least privilege for database access

Your WordPress DB user usually does not need global privileges. It needs access to its own database, and that’s it. If an attacker gets DB creds, limit the blast radius.

Logging that helps instead of just existing

  • Keep web logs long enough to cover your detection window. If it took you 10 days to notice, 2 days of logs is performance art.
  • Centralize logs off-host if you can. Attackers delete local logs when they’re tidy.
  • Alert on weird spikes: login attempts, xmlrpc hits, POSTs to uploads, sudden admin creation.

Backups: design them like you’ll need them

Backups should be immutable (or at least access-controlled), tested, and separated from the compromised environment. The best backup is the one the attacker can’t encrypt or delete.

Checklists / step-by-step plan

These are written for people who have to do this under pressure. Print them if you’re old-school. Copy them into your incident channel if you’re modern.

Checklist A: first hour (containment + preservation)

  1. Open an incident channel. Assign an incident lead. One person decides.
  2. Contain: maintenance page / WAF block / restrict wp-admin by IP.
  3. Block outbound egress from the host if possible (temporary).
  4. Preserve: copy logs off-host (web, auth, PHP), capture process list and network connections.
  5. Snapshot: filesystem snapshot and DB dump (read-only storage if possible).
  6. Confirm scope: is it one site, one host, or multiple tenants?

Checklist B: same day (triage + eradication plan)

  1. Run core checksums and scan for suspicious files/functions.
  2. Enumerate admin users, cron events, mu-plugins.
  3. Decide: rebuild host vs clean in place (default to rebuild if host compromise suspected).
  4. Identify last known-good backup (validate it isn’t contaminated).
  5. Rotate credentials: hosting, SSH keys, DB users, WordPress admins, API keys used by plugins.
  6. Plan communications: who needs to know (legal, security, customers) based on data exposure risk.

Checklist C: recovery and validation (before reopening)

  1. Bring up clean environment with patched OS and minimal services.
  2. Install clean WordPress core; install plugins/themes from trusted sources only.
  3. Restore DB and uploads carefully; enforce “no PHP in uploads” at web server.
  4. Verify: checksums pass; no rogue admins; no suspicious cron hooks; logs look normal.
  5. Reopen gradually if possible; watch logs and outbound connections.
  6. Post-incident: document root cause (or best-supported hypothesis), add monitoring, schedule patching.

Common mistakes: symptom → root cause → fix

This is the part where we stop pretending everyone has perfect process. These are predictable failure modes.

1) Symptom: “We deleted the malicious file, but it came back.”

Root cause: Persistence via mu-plugin, cron event, rogue admin user, or host-level compromise rewriting files.

Fix: Check mu-plugins (Task 11), cron events (Task 10), admin users (Task 9). If the host shows suspicious SSH or processes (Task 15/6), rebuild.

2) Symptom: “The homepage is fine, but Google shows spam pages.”

Root cause: Conditional SEO spam served to bots or injected into posts/options; sometimes only visible with certain user agents.

Fix: Query DB for injected scripts (Task 14), inspect theme functions.php, and check for cloaking logic. Clear caches after cleanup.

3) Symptom: “Users report random redirects.”

Root cause: Injected JavaScript in content, compromised plugin injecting headers, or modified .htaccess/nginx rules.

Fix: Diff web server config, inspect DB content, and replace plugins/themes. Validate home/siteurl options (Task 13).

4) Symptom: “wp-admin is slow and CPU is pegged.”

Root cause: Malicious cron tasks, spam sending, brute force, or crypto-mining via PHP.

Fix: Inspect processes (Task 6), connections (Task 7), and logs for spikes (Task 4). Contain and rebuild if needed.

5) Symptom: “We restored from backup and the hack returned immediately.”

Root cause: Backup was already compromised, or credentials remained compromised and attacker re-entered.

Fix: Validate backups (Task 16), rotate all secrets, enforce MFA, and close the initial entry point before restore.

6) Symptom: “Security plugin says clean, but browsers still warn.”

Root cause: Scanner missed obfuscation, or malicious content is served conditionally, or cached malicious assets remain.

Fix: Manual verification: checksum core (Task 8), scan suspicious patterns (Task 12), inspect logs, purge caches, and re-check on a clean client.

Three corporate mini-stories from the trenches

Mini-story 1: the incident caused by a wrong assumption

At a mid-sized company with a “marketing runs WordPress” setup, the team assumed the compromise was limited to WordPress because the first symptom was SEO spam. The site was behind a CDN, so they flipped a maintenance page and told leadership, “We’ll restore from backup.”

They restored the files and database onto the same VM. The spam disappeared. For about a day. Then the redirects came back, but only for mobile user agents and only in certain countries. The team blamed caching and spent hours purging layers that didn’t need purging.

The clue was boring: outbound connections from PHP to a handful of IPs on port 443, persistent even when the site was in maintenance mode. A quick process list showed a PHP process running from a file inside uploads. That should have been impossible—except their Nginx config didn’t block PHP execution in uploads and their PHP-FPM config routed “try_files” in a way that still executed it.

The wrong assumption was: “If we restore WordPress, we’re clean.” They hadn’t proven the host was clean, and they hadn’t rotated secrets. The attacker still had an admin session and also had a web shell. They were playing checkers against someone playing “I can come back whenever I want.”

They rebuilt the VM, restored from a validated backup, and forced password resets for privileged users. The incident ended. The postmortem action item that mattered most was cultural: stop treating WordPress as separate from “real infrastructure.” It runs on a server. Servers get owned.

Mini-story 2: the optimization that backfired

A larger org decided to “optimize deployments” by allowing WordPress to auto-update plugins and themes directly in production. The rationale was reasonable: fewer tickets, faster security patches, less toil. The implementation was the problem: they made the entire WordPress tree writable by the web user so updates wouldn’t fail.

Six months later, a vulnerable plugin endpoint gave an attacker a foothold. Because the filesystem was writable, the attacker didn’t have to be subtle. They modified core files, dropped a mu-plugin, and planted a backdoor in a theme file that looked like analytics glue code. The payload was tiny and cleverly conditional.

Detection was delayed because the site mostly behaved. The attacker only served spam content to bots and used the server to send bursts of email through a compromised PHP mailer. The first real alarm was deliverability: their transactional emails started landing in spam because the IP reputation cratered.

When they responded, cleanup kept failing because auto-update behavior reintroduced plugin files and masked drift. The system continuously changed, so the team couldn’t tell which changes were attacker-driven and which were “helpful automation.”

The fix was not “never update.” The fix was to update through controlled deployments and keep the runtime mostly read-only. They still got fast patches, but the attacker lost the ability to rewrite the application at will.

Mini-story 3: the boring but correct practice that saved the day

A company with multiple WordPress properties did something deeply unsexy: centralized logging and immutable backups. Not perfect, not fancy, just consistent. Web logs shipped off-host, database backups were write-once for a retention window, and restores were practiced quarterly because someone in ops was stubborn in the best way.

When one site started redirecting, they contained it quickly at the CDN. Then they pulled logs from the centralized store and spotted a narrow time window: a burst of POST requests to a plugin endpoint, followed by a new admin user creation, followed by requests to a PHP file in uploads. Clean, linear, ugly.

The team didn’t argue about whether it was “really compromised.” They had evidence. They rebuilt the host using an existing automation pipeline. They restored from a backup taken before the first exploit request. They rotated secrets and disabled XML-RPC because the site didn’t need it. Total downtime was measured in hours, not days.

Later, leadership asked the usual question: “How did you move so fast?” The answer was not heroics. It was the boring practice of having logs that survived the attacker and backups that couldn’t be quietly overwritten.

It wasn’t glamorous. It was correct. Which is the highest compliment in operations.

FAQ

1) Should I take the site offline immediately?

If the site is actively serving malware, redirecting users, or leaking data: yes. Contain at the edge if possible so you don’t destroy evidence on the host.

2) Can I just reinstall WordPress core and be done?

Reinstalling core helps, but it’s rarely sufficient. Compromises often include rogue admins, cron persistence, modified plugins/themes, and database injections. Verify all layers.

3) Do security plugins fix a hacked site?

They can detect and sometimes remove known patterns. They can also give false confidence. Use them as an input, not as a verdict. You still need logs, checksums, and permissions hardening.

4) How do I know if the server itself is compromised?

Indicators include unexpected SSH logins, unknown processes, suspicious outbound connections, new system users, or tampering outside the WordPress directory. If you suspect it, rebuild.

5) What’s the single most common persistence mechanism in WordPress compromises?

Rogue admin users and PHP backdoors in writable directories (uploads, cache, mu-plugins). Attackers like persistence that survives theme changes.

6) Are backups safe to restore?

Only if you validate them. Spot-check for suspicious artifacts and confirm the backup predates the compromise. Also rotate credentials; otherwise you can get re-compromised immediately.

7) Should I rotate database credentials and WordPress salts?

Yes. After a compromise, assume credentials and cookies may be stolen. Rotate DB credentials, regenerate WordPress salts, and force re-authentication for users—at least admins.

8) Why do hacks “only show up” for Google or certain countries?

Cloaking. Attackers serve clean content to you and malicious content to bots or specific user agents to avoid detection. This is why you use logs and integrity checks, not just a browser test.

9) If I rebuild the host, do I still need forensics?

Some, yes. You rebuild to get clean. You do forensics to prevent recurrence and to assess exposure. Minimum: identify entry point, time window, and affected data.

10) What’s the fastest safe path for a small business with no SRE team?

Contain (maintenance page), get a clean host (managed WordPress or a new VM), restore from the last known-good backup, replace plugins/themes from trusted sources, rotate credentials, and add MFA.

Conclusion: what to do next week, not just today

Today, your job is containment, evidence preservation, and a clean recovery path. Be decisive: if the host looks owned, rebuild. If backups look infected, stop pretending you have a restore plan and start building a clean one.

Next week, do the work that prevents the sequel:

  • Make WordPress core read-only at runtime; block PHP execution in uploads.
  • Reduce plugins/themes; keep a patch cadence you can actually sustain.
  • Centralize logs off-host; keep them long enough to match detection reality.
  • Test restores; store backups immutably; document “last known-good” criteria.
  • Enforce MFA and least privilege everywhere: WordPress, hosting, SSH, database.

If you do it right, the next “WordPress hacked” moment becomes a contained annoyance, not a week-long crisis with a side of reputational damage. That’s the bar.

← Previous
Debian 13: Nginx suddenly returns 403/404 — permissions vs config, how to tell instantly
Next →
ZFS ARC sizing: When Too Much Cache Slows Down Everything Else

Leave a comment