WordPress Malware Suspicion: Signs, Verification, and a Cleanup Plan

Was this helpful?

Something feels off. The site is slow in a new, suspicious way. Google Search Console is sending stern emails. Customers report weird redirects, but only on mobile, and only sometimes. Your marketing team says, “It’s probably caching.” Your gut says, “It’s probably crime.”

This is the production-systems version of a kitchen smell: you don’t know what’s burning, but you know it’s your problem. Here’s how to diagnose, verify, contain, clean, and keep WordPress from getting re-owned the moment you go back to sleep.

What WordPress malware actually looks like in 2025

Most WordPress compromises aren’t cinematic. No skull ASCII art. No “you’ve been hacked” homepage. The best malware is boring: it wants to keep making money while you keep paying the hosting bill.

Common real-world outcomes

  • SEO spam: thousands of spam pages generated (often in the database), targeting pharmaceuticals, gambling, or crypto keywords.
  • Conditional redirects: only certain user agents, referrers, countries, or first-time visitors get redirected.
  • Credential theft: fake wp-login.php overlays, injected JavaScript, or admin user creation.
  • Webshell persistence: tiny PHP backdoors hiding in writable directories (uploads, cache, tmp) so the attacker can come back.
  • Infrastructure piggybacking: your server becomes part of spam, scanning, or phishing infrastructure.

Where it hides (because it’s not always in “plugins”)

Attackers optimize for places you won’t look and places your automation won’t touch:

  • wp-content/uploads/ with fake “.php.jpg” or “.phtml” files, or PHP hidden in innocuous directories.
  • mu-plugins (must-use plugins) because they load automatically and are easy to ignore.
  • wp-includes and wp-admin with modified core files that survive “plugin cleanup” efforts.
  • Database: injected options, cron events, widgets, posts, or “siteurl/home” changes.
  • .htaccess rewrite rules to redirect visitors or run PHP where it should not run.
  • Scheduled tasks: wp-cron, system cron, or queue workers that re-inject the payload after you “clean” it.

One short joke, as a palate cleanser: If your WordPress install is “admin/admin” somewhere, the malware didn’t hack you. It was invited and offered coffee.

Interesting facts and a little history (useful, not trivia)

  1. WordPress powers a large chunk of the public web, which makes it a stable “ecosystem” for attackers: one exploit chain can scale.
  2. “Timthumb” (early 2010s) was a famous image resizing script widely bundled into themes; it turned into a long-running compromise vector because copies persisted everywhere.
  3. XML-RPC attacks exploded when bots used it for credential stuffing and amplification; many sites still expose it unnecessarily.
  4. wp-cron is traffic-driven: on low-traffic sites it runs unpredictably, which can hide scheduled reinfection in long intervals.
  5. Supply-chain compromise is not hypothetical: nulled themes/plugins and “free premium” bundles have been a malware distribution channel for years.
  6. Mass scanners don’t need your brand: they scan for file fingerprints and vulnerable plugin versions, not your business model.
  7. Search engines became incident responders: blacklisting, Safe Browsing warnings, and SEO spam detection often alert you before your monitoring does.
  8. Many “malware” incidents start as config drift: one forgotten staging instance with weak creds becomes the stepping stone to production.

Fast diagnosis playbook (check first/second/third)

When you suspect malware, your goal is not “scan everything and hope.” Your goal is to quickly answer three questions:

  1. Is the site actively harming users or the business right now? (redirects, phishing, spam pages)
  2. Is the compromise persistent? (backdoors, cron reinfection, writable execution paths)
  3. What’s the most reliable restoration path? (known-good backup vs surgical cleanup)

First: confirm symptoms from the outside

  • Fetch the homepage with different user agents and no cookies.
  • Check if the site serves different HTML to “Googlebot” vs normal browsers.
  • Search for unexpected indexed pages (spam titles) via your analytics/search console.

Second: check server state for “obvious persistence”

  • Recently modified PHP files under wp-content (especially uploads, cache, mu-plugins).
  • Unexpected admin users in WordPress.
  • Crons (system and wp-cron) running PHP scripts you didn’t schedule.

Third: pick the containment strategy

  • If you have a clean, recent backup, your fastest path is usually restore + patch + credential rotation + postmortem.
  • If you don’t trust backups, plan for a rebuild: fresh WordPress core, reinstall plugins/themes from trusted sources, migrate only content you validate.

Optimization note: don’t start by “deleting random files until it stops.” That’s not incident response; that’s interpretive dance with production.

Containment: stop the bleeding without destroying evidence

Containment is about minimizing harm while preserving enough information to understand what happened. You can’t prevent every repeat, but you can prevent the easy repeat.

Containment priorities

  1. Protect users: stop redirects/phishing. Consider maintenance mode or a temporary static page.
  2. Protect secrets: assume WordPress admin creds, DB creds, and salts might be exposed.
  3. Preserve evidence: snapshot filesystem and database before you “clean.” You’ll want it later.
  4. Block obvious exfil and reinfection: WAF rules, disable PHP execution in uploads, restrict wp-admin access if feasible.

One reliability quote (paraphrased idea)

Paraphrased idea — Gene Kranz (mission operations): “Be tough and competent.” In security incidents, competence is the only thing that scales at 3 a.m.

Verification tasks with commands (what output means and what you decide)

Below are practical tasks you can run on a typical Linux host. I’m assuming you have shell access and you know where the WordPress docroot lives (example: /var/www/html). Adjust paths, user, and service names for your environment.

Each task includes: command(s), example output, what it means, and the decision you make.

Task 1: Identify the WordPress root and current release

cr0x@server:~$ cd /var/www/html && grep -R "wp_version" -n wp-includes/version.php | head
16:$wp_version = '6.6.2';

What it means: Confirms you’re in the right tree and shows the WordPress version.

Decision: If this is behind current security releases, plan to upgrade during cleanup. If the file itself looks modified beyond version line changes, treat core integrity as compromised.

Task 2: Look for recently modified PHP files (fast “what changed” view)

cr0x@server:~$ cd /var/www/html && find . -type f -name "*.php" -mtime -7 -printf "%TY-%Tm-%Td %TT %p\n" | sort | tail
2025-12-26 21:44:10.0000000000 ./wp-content/uploads/2025/12/cache.php
2025-12-26 21:44:12.0000000000 ./wp-content/mu-plugins/loader.php
2025-12-27 00:02:51.0000000000 ./wp-includes/class-wp.php

What it means: Malware often lands in writable directories (uploads) and sometimes edits core files for stealth.

Decision: Any PHP in uploads is suspicious by default. Core file modifications demand immediate integrity verification (Task 6).

Task 3: Search for common obfuscation patterns in PHP

cr0x@server:~$ cd /var/www/html && grep -R --line-number --binary-files=without-match -E "base64_decode\(|gzinflate\(|str_rot13\(|eval\(|assert\(" wp-content | head
wp-content/uploads/2025/12/cache.php:3:eval(base64_decode($payload));
wp-content/mu-plugins/loader.php:41:$x = gzinflate(base64_decode($y));

What it means: These functions aren’t always malicious, but “eval(base64_decode())” is basically a red flag on fire.

Decision: Quarantine the flagged files (copy aside, then remove from runtime) and continue searching. Don’t just “clean the line”; find the reinfection vector.

Task 4: Check for PHP execution inside uploads (it should be “no”)

cr0x@server:~$ ls -la /var/www/html/wp-content/uploads | head
total 32
drwxr-xr-x  8 www-data www-data 4096 Dec 27 00:03 .
drwxr-xr-x 12 www-data www-data 4096 Dec 26 19:10 ..
drwxr-xr-x  3 www-data www-data 4096 Dec 26 18:00 2025
-rw-r--r--  1 www-data www-data  182 Dec 26 21:44 index.php

What it means: Many installs have an index.php here to prevent directory listing. That’s fine.

Decision: If you see any .php beyond the expected index.php (or worse, a cluster of them), you likely have webshells. Move to Task 11 and Task 12.

Task 5: Compare core files against known-good checksums (wp-cli)

cr0x@server:~$ cd /var/www/html && wp core verify-checksums
Warning: File doesn't verify against checksum: wp-includes/class-wp.php
Success: WordPress installation verifies against checksums.

What it means: One or more core files differ from the official distribution for that version.

Decision: Replace core with a clean copy (Task 15 in the cleanup plan) and treat this as a confirmed compromise, not a “maybe.”

Task 6: Enumerate plugins and themes (find high-risk surface area)

cr0x@server:~$ cd /var/www/html && wp plugin list --status=active
+------------------+----------+-----------+---------+
| name             | status   | update    | version |
+------------------+----------+-----------+---------+
| elementor        | active   | available | 3.24.1  |
| contact-form-7   | active   | none      | 5.9.8   |
| some-custom      | active   | none      | 1.2.0   |
+------------------+----------+-----------+---------+

What it means: Active plugins are your attack surface. “Update available” is not a suggestion; it’s a risk register entry.

Decision: Disable anything you don’t absolutely need during containment. Pay special attention to abandoned or “custom” plugins with unknown provenance.

Task 7: Check for unexpected WordPress admin users

cr0x@server:~$ cd /var/www/html && wp user list --role=administrator
+----+------------+---------------------+------------------------+-------------+
| ID | user_login | user_email          | user_registered        | display_name|
+----+------------+---------------------+------------------------+-------------+
|  1 | admin      | admin@example.com   | 2021-03-12 09:14:20    | Admin       |
| 42 | support2   | support2@evil.tld   | 2025-12-26 21:43:58    | support2    |
+----+------------+---------------------+------------------------+-------------+

What it means: New admin user with suspicious email domain and recent registration time: classic persistence.

Decision: Disable/delete the user after you capture evidence (export list, note timestamps), then rotate all passwords and session tokens.

Task 8: Inspect wp_options for injected siteurl/home changes or autoload bloat

cr0x@server:~$ cd /var/www/html && wp option get siteurl && wp option get home
https://example.com
https://example.com

What it means: Confirms you’re not being redirected via poisoned options.

Decision: If these show unexpected domains or protocols, you have a database-level compromise or misconfiguration. Fix it, then search for who changed it (Task 10 log review, Task 9 DB search).

Task 9: Search the database for common malware indicators (wp-cli db query)

cr0x@server:~$ cd /var/www/html && wp db query "SELECT option_name FROM wp_options WHERE option_value LIKE '%base64_decode%' LIMIT 10;"
+----------------------+
| option_name          |
+----------------------+
| widget_text          |
| some_cache_option    |
+----------------------+

What it means: Malware frequently hides in widgets, options, or serialized blobs.

Decision: Dump and inspect those option values. If confirmed malicious, remove and then identify how it got inserted (vuln plugin, stolen creds, file upload).

Task 10: Check WordPress cron for suspicious events

cr0x@server:~$ cd /var/www/html && wp cron event list | head
+---------------------+---------------------+---------------------+------------+
| hook                | next_run_gmt        | recurrence          | args       |
+---------------------+---------------------+---------------------+------------+
| wp_version_check    | 2025-12-27 01:00:00 | twicedaily          |            |
| wp_update_plugins   | 2025-12-27 01:05:00 | twicedaily          |            |
| cache_reinject      | 2025-12-27 00:10:00 | hourly              |            |
+---------------------+---------------------+---------------------+------------+

What it means: A hook like cache_reinject on an hourly schedule is not standard.

Decision: Find where that hook is registered (grep plugins/themes/mu-plugins), remove the code, and delete the cron event. Expect reinfection attempts.

Task 11: Review system cron for suspicious PHP execution

cr0x@server:~$ sudo crontab -l
no crontab for root
cr0x@server:~$ sudo ls -la /etc/cron.d
total 20
drwxr-xr-x  2 root root 4096 Dec 27 00:05 .
drwxr-xr-x 90 root root 4096 Dec 26 18:10 ..
-rw-r--r--  1 root root  201 Dec 26 21:45 wp-cache-refresh
cr0x@server:~$ sudo cat /etc/cron.d/wp-cache-refresh
*/15 * * * * www-data php /var/www/html/wp-content/uploads/2025/12/cache.php >/dev/null 2>&1

What it means: This is a persistence mechanism outside WordPress.

Decision: Remove the cron entry, quarantine the referenced file, and investigate how the attacker got write access to /etc/cron.d (that’s bigger than WordPress).

Task 12: Find webshell-like files by name patterns and permissions

cr0x@server:~$ cd /var/www/html && find wp-content -type f \( -name "*.phtml" -o -name "*.php5" -o -name "*.php7" -o -name "*.phar" \) -print | head
wp-content/uploads/2025/12/shell.phtml
wp-content/cache/tmp/updater.php5

What it means: Alternate PHP extensions are frequently used to bypass naive filters.

Decision: Treat these as malicious until proven otherwise. Remove from runtime and add server rules to prevent execution in writable directories.

Task 13: Check outbound connections from the web server user (quick exfil check)

cr0x@server:~$ sudo ss -tpn | head
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
ESTAB   0       0        127.0.0.1:43218      127.0.0.1:3306     users:(("php-fpm8.2",pid=1123,fd=10))
ESTAB   0       0        10.0.0.10:41012      203.0.113.44:443   users:(("php-fpm8.2",pid=1131,fd=12))

What it means: PHP-FPM talking to an external IP on 443 can be normal (APIs) or suspicious (C2).

Decision: If you don’t recognize the external endpoint, block egress temporarily, identify which PHP code is initiating connections (enable PHP-FPM slowlog/strace in extreme cases), and proceed assuming compromise.

Task 14: Inspect web access logs for suspicious POSTs and unexpected endpoints

cr0x@server:~$ sudo awk '$6 ~ /POST/ {print $1, $4, $7, $9}' /var/log/nginx/access.log | tail
198.51.100.77 [26/Dec/2025:21:43:55 /wp-admin/admin-ajax.php 200
198.51.100.77 [26/Dec/2025:21:43:57 /wp-json/wp/v2/users 401
203.0.113.90 [26/Dec/2025:21:44:01 /wp-content/uploads/2025/12/cache.php 200

What it means: A client requesting a PHP file in uploads is almost never normal user behavior.

Decision: Add immediate server rule to block PHP execution in uploads, then trace back around those timestamps for the initial entry point.

Task 15: Check filesystem ownership and “writable core” conditions

cr0x@server:~$ cd /var/www/html && find . -maxdepth 2 -type d -perm -0002 -printf "%m %u:%g %p\n" | head
777 www-data:www-data ./wp-content/cache
777 www-data:www-data ./wp-content/uploads

What it means: World-writable directories are a classic reinfection accelerant. Some cache directories may be writable, but 777 is “anyone can write here, including future regret.”

Decision: Reduce permissions to the minimum needed and ensure only the correct user/group can write. Prefer group-writable with a controlled group, not world-writable.

Task 16: Check for unexpected PHP directives or auto-prepended malware

cr0x@server:~$ php -i | grep -E "auto_prepend_file|auto_append_file"
auto_prepend_file => /var/www/html/wp-content/uploads/2025/12/prepend.php => /var/www/html/wp-content/uploads/2025/12/prepend.php
auto_append_file => no value => no value

What it means: auto_prepend_file is a powerful way to inject code into every PHP request. If you didn’t set it, that’s a system-level compromise or hostile config change.

Decision: Remove/override the directive (php.ini, pool config, .user.ini), quarantine the referenced file, and widen scope: this is beyond WordPress.

Checklists / step-by-step cleanup plan

This is the plan I’d want on my desk if I were on-call for a revenue site. It assumes you’re trying to be fast without being sloppy. Choose the “restore” track if you have a clean backup. Choose the “rebuild” track if you don’t.

Phase 0: Decide your strategy (restore vs rebuild)

  • Restore if you have a verified clean backup from before the compromise window, and your environment is otherwise trustworthy.
  • Rebuild if you can’t trust backups, can’t bound the compromise window, or see system-level persistence (cron in /etc/cron.d, php.ini injections, suspicious root activity).

Phase 1: Evidence snapshot (do this before you “fix”)

Take snapshots so you can answer “how did this happen?” later. That’s not bureaucracy; it’s how you prevent reruns.

Task 17: Snapshot the filesystem (tarball with metadata)

cr0x@server:~$ sudo tar --xattrs --acls -czf /root/wp-compromise-$(date +%F).tgz /var/www/html
tar: Removing leading `/' from member names

What it means: You captured a point-in-time copy including ACLs/xattrs (useful for forensic comparisons).

Decision: Store it off-host if possible. If the host is compromised, local evidence is fragile.

Task 18: Snapshot the database (consistent dump)

cr0x@server:~$ cd /var/www/html && wp db export /root/wp-db-$(date +%F).sql
Success: Exported to '/root/wp-db-2025-12-27.sql'.

What it means: You have a DB dump you can search offline and restore if needed.

Decision: Keep it. Even if you rebuild, you’ll need content. Just don’t import malware along with it.

Phase 2: Contain and stabilize service

Task 19: Put the site into controlled maintenance mode

cr0x@server:~$ cd /var/www/html && wp maintenance-mode activate
Success: Activated maintenance mode.

What it means: Reduces user exposure while you work.

Decision: If the site is actively redirecting/phishing, keep maintenance mode on until you’re confident.

Task 20: Disable all plugins (fast way to stop many payloads)

cr0x@server:~$ cd /var/www/html && wp plugin deactivate --all
Success: Deactivated 3 of 3 plugins.

What it means: You’ve removed a lot of execution paths quickly.

Decision: If redirects/spam stop, you likely had a plugin/theme injection. Still verify core and database; attackers don’t do “single-layer” when they can help it.

Phase 3: Remove obvious persistence

Task 21: Delete unknown admin users and reset passwords

cr0x@server:~$ cd /var/www/html && wp user delete 42 --reassign=1
Success: Deleted user 42.
Success: Reassigned posts to user 1.

What it means: You removed a persistence account without orphaning content.

Decision: Immediately reset passwords for all admin/editor accounts and rotate credentials outside WP (DB, SFTP/SSH, hosting panel).

Task 22: Remove suspicious cron hooks

cr0x@server:~$ cd /var/www/html && wp cron event delete cache_reinject
Success: Deleted the cron event.

What it means: You removed a reinfection trigger.

Decision: If the hook returns after a few minutes/hours, you still have the code that re-adds it. Keep hunting.

Task 23: Quarantine malicious files (move, don’t just rm, initially)

cr0x@server:~$ sudo mkdir -p /root/quarantine && sudo mv /var/www/html/wp-content/uploads/2025/12/cache.php /root/quarantine/

What it means: You removed the file from the webroot while preserving it for analysis.

Decision: If service stabilizes, good. If malware persists, there are more files or DB injections.

Phase 4: Restore integrity (core, plugins, themes)

This is where “surgical cleanup” often fails. If you keep potentially modified code, you keep the attacker’s handiwork. Replace what can be replaced.

Task 24: Reinstall WordPress core (in place, without touching content)

cr0x@server:~$ cd /var/www/html && wp core download --force
Downloading WordPress 6.6.2 (en_US)...
Success: WordPress downloaded.

What it means: Core files are reset to the official distribution for your version.

Decision: Re-run checksum verification. If checksums still fail, you have filesystem tampering, mixed versions, or something rewriting files.

Task 25: Reinstall plugins from trusted sources

cr0x@server:~$ cd /var/www/html && wp plugin install elementor --force
Installing Elementor (3.24.2)
Downloading installation package from https://downloads.wordpress.org/plugin/elementor.3.24.2.zip...
Unpacking the package...
Installing the plugin...
Success: Installed 1 of 1 plugins.

What it means: You replaced plugin code with a clean vendor copy.

Decision: If a plugin is not from a trustworthy channel (custom zip from “somewhere”), treat it as untrusted. Replace it with audited code or remove it.

Task 26: Reinstall themes (yes, even if “it’s customized”)

cr0x@server:~$ cd /var/www/html && wp theme list
+----------------+----------+-----------+---------+
| name           | status   | update    | version |
+----------------+----------+-----------+---------+
| twentytwentyfour | active | none      | 1.2     |
| mytheme        | inactive | none      | 2.1     |
+----------------+----------+-----------+---------+

What it means: You can see what’s in play.

Decision: If you have a custom theme, rebuild it from source control, not from the server. If you don’t have source control, you just learned why you need it.

Phase 5: Database cleanup (carefully; it’s easy to break content)

Task 27: Inspect and clean infected widget/text options

cr0x@server:~$ cd /var/www/html && wp option get widget_text | head
a:1:{i:2;a:2:{s:5:"title";s:0:"";s:4:"text";s:312:"<script>/* ... */</script>";}}

What it means: Serialized data containing script tags can be legitimate, but if it’s obfuscated or unfamiliar, it’s likely injected.

Decision: Replace the option with a known-good value (from backup) or delete the specific widget instance via the UI after you remove the payload.

Task 28: Search posts for hidden injected spam links

cr0x@server:~$ cd /var/www/html && wp db query "SELECT ID, post_title FROM wp_posts WHERE post_content LIKE '%display:none%' LIMIT 5;"
+------+---------------------------+
| ID   | post_title                |
+------+---------------------------+
| 881  | Summer sale               |
| 905  | About us                  |
+------+---------------------------+

What it means: Hidden spam links often use CSS tricks like display:none or off-screen positioning.

Decision: Review affected posts, clean content, and audit who/what edited them (user accounts, REST API abuse, compromised editor creds).

Phase 6: Fix the reinfection vector (the part everyone rushes)

If you don’t know how the attacker got in, you didn’t finish. You just took a break.

Task 29: Check for vulnerable or abandoned plugins/themes (basic inventory signal)

cr0x@server:~$ cd /var/www/html && wp plugin list --fields=name,version,update,author,status
+--------------+---------+-----------+--------------------+--------+
| name         | version | update    | author             | status |
+--------------+---------+-----------+--------------------+--------+
| some-custom  | 1.2.0   | none      | Unknown            | active |
| elementor    | 3.24.1  | available | Elementor.com      | active |
+--------------+---------+-----------+--------------------+--------+

What it means: “Unknown” authorship and no update path are risk markers.

Decision: Remove or replace unknown provenance code. If the business insists on keeping it, isolate it and treat it like a third-party service, not a plugin.

Task 30: Confirm no PHP execution in uploads via server config (Nginx example check)

cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -n "uploads" | head
214:    location ~* /wp-content/uploads/.*\.php$ { return 403; }

What it means: Requests to PHP files under uploads are blocked.

Decision: If you can’t implement this, you’re relying on “nobody will try” as a security strategy. That’s not a strategy.

Task 31: Rotate WordPress security salts (invalidate sessions)

cr0x@server:~$ cd /var/www/html && sudo sed -n '1,120p' wp-config.php | grep -E "AUTH_KEY|SECURE_AUTH_KEY|LOGGED_IN_KEY|NONCE_KEY" | head -n 4
define('AUTH_KEY',         'oldvalue...');
define('SECURE_AUTH_KEY',  'oldvalue...');
define('LOGGED_IN_KEY',    'oldvalue...');
define('NONCE_KEY',        'oldvalue...');

What it means: Keys exist and can be rotated.

Decision: Replace all salts/keys with new random values (do it via controlled change). This logs out attackers holding cookies.

Task 32: Re-check integrity and re-enable plugins one by one

cr0x@server:~$ cd /var/www/html && wp core verify-checksums
Success: WordPress installation verifies against checksums.

What it means: Core matches known-good checksums now.

Decision: Re-enable plugins one at a time while monitoring logs. The plugin that reintroduces weird behavior is your prime suspect.

Task 33: Turn maintenance mode off and validate externally

cr0x@server:~$ cd /var/www/html && wp maintenance-mode deactivate
Success: Deactivated maintenance mode.

What it means: Site is back for users.

Decision: Validate using curl with different user agents (see next section) and watch logs for recurrence.

Three corporate mini-stories from the malware trenches

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

A mid-sized company ran WordPress for a product marketing site. They had an external security vendor who “scanned weekly” and a managed host that “handled patching.” Everyone assumed the other party owned the problem. That assumption was the vulnerability.

The first symptom was subtle: the paid search team complained conversion rates dropped on mobile, but desktop looked fine. The site loaded. The uptime monitor stayed green. The marketing team blamed campaign quality. The SRE team blamed the CDN.

When someone finally reproduced the issue with a clean mobile browser, they got redirected to a sketchy “update your browser” page. It only happened for first-time visitors from certain regions. The payload lived in a single JavaScript blob injected into a theme file and conditionally executed based on user agent and referrer. The weekly scan didn’t catch it because the scanner was desktop-only and didn’t emulate the referrer chain.

Containment was fast: maintenance mode, block suspicious domains at the egress firewall, and force a clean theme reinstall. The real fix was organizational: they wrote down ownership. Who patches. Who verifies. Who gets paged. The assumption died, and so did the incident class.

Mini-story 2: The “optimization” that backfired

Another org had a performance push. Someone noticed file writes on every request and decided to make wp-content broadly writable so caching plugins could “do their job.” It was a quick change, it improved time-to-first-byte, and it shipped without a security review because it was “just permissions.”

Months later, a plugin vulnerability allowed arbitrary file upload. On a correctly configured system, that’s still bad, but you typically need a second misconfiguration to execute uploaded code. Here, the second misconfiguration had been installed as an optimization.

The attacker uploaded a small webshell into uploads, executed it, dropped a cron job, and started reinfecting core files after every cleanup attempt. The team kept deleting the webshells and wondering why they reappeared. It wasn’t magic; it was a cron they forgot to check because they were focused on WordPress-level artifacts.

The cleanup ended up being a full rebuild. The performance gain was re-achieved later with correct caching and narrowly scoped write permissions. The lesson: performance hacks that change security boundaries are not performance hacks. They are architecture changes.

Mini-story 3: The boring practice that saved the day

A regulated business ran WordPress behind a reverse proxy and had an almost offensively boring deployment practice: immutable builds, weekly patch window, and file integrity alerts. Developers couldn’t SSH into production to “hotfix.” They complained. Leadership ignored them. This is why leadership exists.

One afternoon, file integrity monitoring flagged a new PHP file under uploads and a change in a core file. The alert didn’t say “malware.” It said “this file changed.” That was enough. They flipped the site into maintenance mode and rolled back to the last known-good immutable image.

The incident response then had time. They compared the compromised snapshot to the clean image, found the initial entry point (a plugin vulnerability), and confirmed it in the logs. They rotated secrets, patched, and redeployed. The business impact was mostly a short maintenance window and an unpleasant postmortem meeting.

It wasn’t heroic. It was process. Boring is good. Boring means you can sleep.

Common mistakes: symptom → root cause → fix

These are the repeat offenders. If you recognize your situation here, good: you can skip some dead ends.

1) “Redirects only happen on mobile”

Symptom: Desktop looks clean, mobile gets redirected to spam/scam pages.

Root cause: Conditional JavaScript injection based on user-agent and sometimes referrer. Often in theme header/footer, mu-plugins, or injected widget text.

Fix: Fetch HTML with multiple user agents, disable plugins/themes to isolate, search for obfuscation patterns, and reinstall theme from trusted source.

2) “We cleaned the files but spam pages keep showing up in Google”

Symptom: Google indexes spam URLs even after filesystem cleanup.

Root cause: Spam content stored in the database (posts, options) or dynamically generated via rewrite rules.

Fix: DB searches for spam keywords and injected HTML, review rewrite rules/.htaccess, clear caches, and request reindex after cleanup.

3) “Malware comes back after every cleanup”

Symptom: You delete suspicious files; they reappear later.

Root cause: Persistence: wp-cron hook, system cron, compromised admin user, or auto_prepend_file injection. Sometimes a second backdoor you didn’t find.

Fix: Enumerate cron (WordPress + system), rotate salts and credentials, block PHP execution in uploads, and consider full rebuild if persistence is outside WP.

4) “Nothing looks wrong, but hosting says we’re sending spam”

Symptom: Provider reports outbound abuse; your site seems normal.

Root cause: Webshell or PHP mailer scripts running quietly, or the server is compromised beyond WordPress.

Fix: Check outbound connections, mail logs, process list, and filesystem for mailers; restrict egress and SMTP; rebuild host if root compromise suspected.

5) “We updated WordPress core; problem persists”

Symptom: Core updated, but redirects/injections remain.

Root cause: Malware in plugins/themes/mu-plugins, database options/widgets, or server-level prepend/cron.

Fix: Verify checksums, reinstall plugins/themes, scan DB, and review server config for prepend/append directives.

6) “Security plugin says we’re clean, but users still complain”

Symptom: Plugin scanner reports no issues; real users see issues.

Root cause: Scanner blind spots: conditional payloads, external JS loaded at runtime, or malware outside WP tree.

Fix: Trust behavior and logs over “green checkmarks.” Reproduce with curl; inspect responses; audit server configuration and filesystem changes.

Hardening after cleanup (so you don’t do this again next month)

Cleaning is a one-time cost. Hardening is how you stop paying it repeatedly. Be opinionated here. WordPress is fine; sloppy operations are not.

File and execution boundaries

  • Block PHP execution in uploads (and other writable dirs like cache). This breaks a whole class of attacks.
  • Least privilege permissions: avoid 777; make only what must be writable writable.
  • Immutable deployments if you can: build artifacts elsewhere, deploy clean. Production should not be a craft table.

Credential and session discipline

  • Rotate WordPress salts/keys after any incident.
  • Use unique admin accounts; no shared “admin” login for a team.
  • Enforce MFA where possible (and yes, for the hosting panel too).
  • Rotate DB credentials and ensure they’re not reused elsewhere.

Attack surface reduction

  • Delete unused plugins/themes. Disabled is not the same as removed.
  • Avoid “nulled” plugins/themes. The price is paid later, with interest.
  • Restrict wp-admin and wp-login by IP/VPN when feasible.
  • Disable XML-RPC if you don’t need it; otherwise rate-limit.

Observability that actually helps

  • Keep access logs and error logs long enough to investigate (days to weeks, not hours).
  • Alert on unexpected file changes in the webroot.
  • Watch for spikes in 200s on odd endpoints (uploads PHP hits, admin-ajax floods).
  • Track outbound egress destinations from the web tier if your environment supports it.

Second short joke, because we’ve earned it: A WAF is not a security program. It’s a raincoat; it helps, but you still shouldn’t stand in traffic.

FAQ

1) How do I know it’s malware and not just a bad plugin update?

Look for intent: obfuscation patterns, unknown admin users, PHP in uploads, cron reinfection, external callbacks to unfamiliar IPs/domains. Bad updates break features; malware usually adds hidden behavior.

2) Should I run a WordPress security scanner plugin?

Sure, but don’t let it become your only instrument. Scanners miss conditional payloads and server-level persistence. Use them as a signal, not a verdict.

3) If I restore from backup, am I done?

No. Restore removes symptoms if the backup is clean, but it doesn’t fix the entry point. Patch vulnerable plugins/themes, rotate credentials, and block PHP execution in writable directories.

4) What if wp-admin is inaccessible but the site still serves pages?

That can be a plugin fatal error, a WAF rule, or an attacker protecting their access by breaking yours. Check PHP error logs, disable plugins via filesystem rename, and verify admin users via database or wp-cli.

5) Is deleting the infected file enough?

Rarely. If there’s one webshell, there’s often a loader elsewhere and a reinfection mechanism (cron, injected option, mu-plugin). Delete is step one, not the finish line.

6) Do I have to rotate all passwords?

Yes. Assume credential exposure. Rotate WordPress accounts, database credentials, SFTP/SSH keys, and hosting panel accounts. Also rotate API keys stored in wp-config.php or environment variables.

7) How do I check if Google is still seeing spam or redirects?

Fetch pages like a bot would: no cookies, clean user agent tests, and verify the HTML matches what you expect. Then clean up spam pages and request review/reindex through your search tooling.

8) When should I rebuild the whole server instead of cleaning?

If you find system cron persistence, php.ini prepend injections, unexpected root changes, unknown SSH keys, or anything suggesting privilege escalation. At that point, WordPress cleanup is a distraction.

9) What’s the single most effective hardening change?

Blocking PHP execution in uploads (and any writable directory) is extremely high leverage. It doesn’t solve everything, but it cuts off a common “upload → execute → persist” path.

10) Can I keep my custom theme/plugin after an incident?

Only if you can rebuild it from a trusted source (source control, CI artifact) and you can audit changes. “The copy on the server” is not a trusted source after compromise.

Conclusion: next steps you can actually do today

If you’re in the middle of a suspected WordPress compromise, here’s the pragmatic order of operations:

  1. Reproduce externally with clean requests and multiple user agents. Confirm impact.
  2. Snapshot filesystem and database before touching anything.
  3. Contain: maintenance mode, disable plugins, block PHP in uploads, restrict admin surface.
  4. Verify: checksum core, hunt for PHP in uploads/mu-plugins, check admin users, check cron (WP + system), scan DB for injected payloads.
  5. Restore integrity: reinstall core/plugins/themes from trusted sources; clean DB carefully.
  6. Close the entry point: patch or remove the vulnerable component; rotate credentials; improve permissions and logging.
  7. Monitor for recurrence for days, not minutes. Reinfection is a patience game; win by being more boring than the attacker.

Do it once, do it cleanly, and leave behind guardrails. The goal isn’t to become a malware archaeologist. The goal is to run a site that doesn’t become someone else’s side hustle.

← Previous
ZFS L2ARC on NVMe: When It’s Worth It (and When ARC Is Enough)
Next →
MariaDB vs Percona Server: Drop-in Replacement or Compatibility Trap?

Leave a comment