Security

How I cleaned up a six-week WordPress compromise in one morning - and why the hardest part was admitting whose password it was

The hosting provider flagged malware. By the time I got into the server, the attacker had been on the site for six weeks, had dropped sixty malicious files across five attack waves, and the latest wave had landed inside the previous hour. The site's homepage was openly publishing the PHP source code of a webshell to every visitor. Google was indexing it.

A Monday morning message from a client's hosting provider: malware or SEO spam injected on the site, please investigate. The client wanted to know if anything needed to be done.

Yes, something needed to be done. This is the timeline of how I cleaned it up that morning, what the forensics revealed about how the attacker got in, and the uncomfortable conclusion about whose credential was actually compromised. If you run or manage WordPress sites, the technical playbook below is the one I'd give a colleague to follow next time.

The site

A UK home-services brand running WordPress on Cloudways. Typical agency-built stack: Genesis parent theme with a Business Pro child, Beaver Builder for layouts, WooCommerce for a small shop, WP Rocket for caching, Wordfence installed but inactive, Cloudflare in front. About forty active plugins, eighteen admin accounts - a number that should have raised flags long before this incident - and a custom post type with roughly 1,200 location landing pages driving most of the organic traffic.

The hosting provider's alert was unspecific. It said malware had been detected but not where. So step one was to establish scope before touching anything.

The first symptom

I loaded the homepage in a browser. At the very top of the page, before the site's own HTML started, the response contained this:

{'id': 128254, 'code': 'YzctdY13

That is not debug output. That is the source code of a PHP webshell, written as a Python-style dictionary, being printed to every visitor's browser. The function names are hex-encoded to evade keyword-based scanners: \x73h\x65l\x6C_\x65\x78ec decodes to shell_exec, s\x79s\x74\x65m to system. The shell accepts a hex-encoded command via POST, XORs it with byte 37 to undo a basic obfuscation layer, and executes it via whichever of system, shell_exec, exec, passthru, or popen is available.

The site wasn't just compromised - it was loudly broadcasting that fact to every search crawler that hit it.

What I found on the server

SSH into the application directory. The plugin folder gave the first scope estimate. Alongside legitimate plugins were thirty fake plugin folders with names like analytics-1779501007, backup_1779559645, security_1779559583. The numeric suffixes are Unix timestamps. They group into five clusters, each a separate attack wave:

  • Wave 1 - 19 May at 22:16 to 22:19 UTC
  • Wave 2 - 22 May at 11:14 UTC
  • Wave 3 - 22 May at 14:49 to 14:52 UTC
  • Wave 4 - 23 May at 01:47 to 01:50 UTC
  • Wave 5 - 23 May at 18:04 to 18:08 UTC (roughly two hours before the hosting alert)

The themes folder had around twenty fake theme directories with valid style.css headers so they'd show up in wp theme list as legitimate inactive themes. Inside each malicious plugin and theme were files with deliberately misspelled names - lndex.php, archlve.php (lowercase L instead of I) - so signature scanners looking for index.php or archive.php wouldn't catch them. Standard commodity malware tradecraft.

The most significant find was at wp-content/mu-plugins/custom_file_5_1779461428.php. The mu-plugins directory (must-use plugins) is loaded by WordPress on every request before anything else, regardless of plugin activation status. This file was the raw text of the webshell dictionary - no <?php opening tag, just literal text. Because it lived in mu-plugins, WordPress loaded it on every page, which echoed the text at the top of every HTML response. The attacker had stored their payload as a string instead of executable code, apparently by mistake, and the result was the most visible compromise indicator possible.

There was also a hidden world-writable directory at the webroot - .tmb, permissions drwxrwxrwx, creation date 7 April. Empty, but writable by anyone. This is the staging directory attackers create on day one as a future drop point. Its mtime gave the initial intrusion date: six weeks before anyone noticed.

A separate malicious file lived inside wp-includes/widgets/ - a WordPress core directory. The attacker could write there, which meant they had filesystem-level access, not just admin-level access through the WordPress UI.

The cleanup, in the order it actually has to happen

The temptation when you find a compromised site is to immediately delete the malicious files. Don't. If you delete before revoking the attacker's access, they'll re-drop the files within minutes. The sequence has to be:

  1. Revoke every admin session
  2. Reset every admin password
  3. Take a quarantine backup of the artifacts
  4. Delete the malicious files
  5. Scan the database for webshell signatures
  6. Flush every cache layer
  7. Verify the visible symptom is gone

Steps 1 and 2 kick the attacker out before touching the artifacts in step 4. Via WP-CLI:

# Revoke every administrator session
wp user list --role=administrator --field=ID \
  | while read id; do wp user session destroy $id --all; done

# Reset every admin password
ADMIN_IDS=$(wp user list --role=administrator --field=ID | tr '\n' ' ')
wp user reset-password $ADMIN_IDS --skip-email --show-password

The new passwords printed to terminal. I relayed them to each admin via phone and direct message - not email, because the email accounts themselves could be compromised. Each admin was instructed to log in once with the temporary password, change it immediately to one of their own, and enrol 2FA on the same login.

Session revocation confirmed 8 active sessions destroyed across 20 admin accounts. One belonged to the client's staff; seven belonged to my own agency admin account. More on that shortly.

Step 3 - quarantine backup to /tmp before deleting anything:

QUARANTINE=/tmp/quarantine_$(date +%Y%m%d_%H%M%S)
mkdir -p $QUARANTINE
cd /home/master/applications/[app]/public_html

find . \( -name '*1779228*' -o -name '*1779229*' -o -name '*1779461*' \
       -o -name '*1779500*' -o -name '*1779559*' \) \
       -exec cp -r --parents {} $QUARANTINE/ \;
find . \( -name 'lndex.php' -o -name 'archlve.php' \) \
       -exec cp --parents {} $QUARANTINE/ \;

tar czf ${QUARANTINE}.tgz $QUARANTINE && rm -rf $QUARANTINE

Step 4 - deletion in three batches, with an HTTP 200 check on the homepage between each to catch anything that broke immediately:

# Batch 1: timestamped artifacts
find . \( -name '*1779228*' -o -name '*1779229*' \
       -o -name '*1779461*' -o -name '*1779500*' \
       -o -name '*1779559*' \) -exec rm -rf {} +

# Batch 2: misspelled backdoors
find . \( -name 'lndex.php' -o -name 'archlve.php' \) -delete

# Batch 3: specific known artifacts
rm -f wp-content/uploads/front_page_template_*.php
rm -f wp-content/cache/bottom_*.php
rm -f wp-includes/widgets/custom_file_*.php
rm -rf wp-content/plugins/wp-security-helper
rm -rf .tmb

Three files survived the first sweep - they lived in directories owned by the web server user, and my SSH user only had group read-execute access. The solution was a one-shot cleanup hook added to functions.php that ran as the web server user on the next page load:

add_action( 'init', function () {
    $targets = array(
        ABSPATH . 'wp-content/wflogs/category.template.1779500877.php',
        ABSPATH . 'wp-content/wp-rocket-config/widget-area-1779500932.php',
        ABSPATH . 'wp-content/wp-rocket-config/custom.file.2.1779500988.php',
    );
    foreach ( $targets as $t ) {
        if ( file_exists( $t ) ) { @unlink( $t ); }
    }
}, 1 );

Hit the homepage once to trigger it, then removed the hook. The three permission-protected files were gone.

Step 5 - database scan for webshell signatures across all tables:

wp db query "SELECT option_name FROM wp_options
  WHERE option_value LIKE '%YzctdY13%'
     OR option_value LIKE '%event_dispatcher%'
     OR option_value LIKE '%url_postfix%';"

Zero results. The webshell was purely file-based.

Step 6 - cache flush. This is where I lost an hour by not thinking carefully enough about every cache layer a Cloudways + Cloudflare site has:

  • WordPress object cache (Redis)
  • WP Rocket page cache (static HTML on disk)
  • PHP OPcache (compiled bytecode)
  • Cloudways Varnish (server-level HTTP cache on port 8080)
  • Cloudflare edge cache (global CDN)

The first three cleared via WP-CLI. Varnish required an HTTP PURGE request to the local instance:

curl -X PURGE http://127.0.0.1:8080/ -H 'Host: www.example.com'

Cloudflare needed a panel-level cache purge. Until that happened, every visitor was still seeing the cached dirty homepage even though the origin was clean. The site looked compromised for an extra fifteen minutes because I'd forgotten Cloudflare was caching it.

A clean origin is not a clean site if Cloudflare or Varnish are caching the dirty version. Always purge every cache layer top to bottom, then verify with curl -H "Cache-Control: no-cache" from outside - not from your local browser, which has its own cache.

Finding the entry point

Cloudways access logs showed only one external IP making requests to /wp-admin/ in the 5-minute window of wave 1:

22:16:17  GET  /wp-login.php
22:16:19  POST /wp-login.php  → 302 (success)
22:16:20  GET  /wp-admin/
22:16:49  POST /wp-admin/update.php?action=upload-plugin
22:17:21  POST /wp-admin/update.php?action=upload-theme
22:18:07  GET  /wp-admin/plugins.php?action=activate&plugin=wp-file-manager/...
22:18:32  GET  admin-ajax.php?action=mk_file_folder_manager&cmd=mkfile&name=custom_file_5_...&target=wp-includes/widgets

The whole attack took two and a half minutes from login to file drop. The attacker logged in with valid credentials (no brute-force pattern - a single successful POST), uploaded a plugin and theme, activated the already-installed wp-file-manager plugin, and used its API to write a malicious PHP file into wp-includes/widgets/.

The attacker IP was a US East Coast VPS at a commercial hosting provider. Real admins almost never log in from VPS infrastructure; that profile is near-certainty for an attacker using rented infrastructure.

But the access log doesn't contain the username - that's in the POST body of /wp-login.php, which isn't logged.

The audit log we didn't know we had

Before doing more invasive forensics, I checked the database for any audit-log plugin tables. WP Activity Log had been installed at some point and then deactivated - but deactivating a plugin doesn't drop its database tables. The wp_wsal_occurrences table contained 5,178 events going back past every attack window.

Querying it for the 19 May window:

SELECT FROM_UNIXTIME(created_on) AS ts, alert_id, username, client_ip
FROM wp_wsal_occurrences
WHERE created_on BETWEEN 1779207000 AND 1779210800
ORDER BY created_on;
2026-05-19 16:57:59  1000  agency_admin  91.245.236.146  login
2026-05-19 16:58:02  4015  agency_admin  91.245.236.146  modified
2026-05-19 16:58:33  1011  agency_admin  91.245.236.146  denied
2026-05-19 16:58:50  5002  agency_admin  91.245.236.146  deactivated
2026-05-19 16:59:02  5002  agency_admin  91.245.236.146  deactivated
2026-05-19 16:59:15  5002  agency_admin  91.245.236.146  deactivated

The agency's shared admin account had logged in from a Newark, New Jersey VPS at 16:57 UTC and within 90 seconds had deactivated Cloudflare, Wordfence, and the audit log plugin itself. Logging stopped at 16:59:15 because the attacker turned it off. Five hours later, from a different US East Coast VPS, the wave 1 file drops began.

The compromised credential was not the client's. It was my agency's own shared admin account.

The unwelcome conclusion

The systemic vulnerability was not in WordPress, not in any plugin's code, not in the hosting setup. It was in how credentials were managed.

A single shared admin account, used by multiple team members, with the password stored in browser saved logins. One leaked credential - sixty backdoor files, six weeks of unauthorised access, one notifiable data breach.

The attacker didn't need to find a WordPress vulnerability. They didn't need to brute-force anything. They needed to compromise one machine somewhere in the team and pull browser-saved credentials. That's what commodity info-stealers (Lumma, Redline, Vidar) are built to do - they sell on Telegram for around fifty dollars a month. The attacker did the rest with off-the-shelf tooling.

A Malwarebytes scan came back clean, but info-stealers are specifically designed to evade consumer antivirus. They run for under a minute, exfiltrate browser-saved credentials and session cookies, then often delete themselves. A clean scan is not exoneration.

The lessons that would have prevented this

None of these are exotic. They're basic hygiene.

  1. One admin account per person, per client site. No shared accounts. If someone leaves, you revoke their account - not change everyone's password.
  2. 2FA enforced on every admin account, on every site. This is the single highest-impact control. With 2FA in place, a stolen password isn't enough to log in. The wave 1 attack would have failed at the login step.
  3. Disable or remove plugins that allow arbitrary file writes from the admin UI. This site had wp-file-manager installed (deactivated). The attacker activated it and used it as the pivot from "has admin login" to "can write PHP files anywhere".
  4. Add define('DISALLOW_FILE_MODS', true) to wp-config.php. Blocks plugin and theme installs from WP admin entirely. Updates go through SSH or WP-CLI, which is how they should go anyway.
  5. Wordfence (free) with real-time file change detection enabled. It emails within minutes when a new PHP file appears in the plugins or themes directory. That alone would have caught wave 1 on 19 May, three days before the visible damage.
  6. Restrict wp-admin to office or VPN IPs via Cloudflare firewall rules. Removes the entire "attacker has a valid credential but can't reach the login page" risk class.
  7. Off-host backups. Cloudways takes its own backups, but they live in the same vendor account. If the vendor account is compromised, so are the backups. Push daily backups off-host to S3, Google Drive, or Dropbox.
  8. Quarterly admin user audits. This site had 18 admin accounts - some with duplicate names, some belonging to people who had left, some from agencies that finished work years ago. Each is a potential entry point. Most sites need three admin accounts.

If you do nothing else from this list: enforce 2FA and enable Wordfence file change detection on every WordPress site you manage, today. Those two controls together would have stopped this specific attack at step one.

One thing AI tooling got wrong during the incident

I used Claude Code throughout the cleanup - running SSH commands, parsing logs, decoding the webshell hex. It was useful for the diagnostic queries and for not skipping safety steps (backup before delete, verify between batches).

It was less good at one thing: recognising early that the webshell visible on the homepage might be a cached artifact rather than a live broadcast. I spent an hour chasing the file that was printing the dict before realising the file had already been removed at the origin and Cloudflare was serving the poisoned version from edge cache. A more experienced incident responder would have verified the origin directly first.

The lesson for AI-assisted incident response: always validate by hitting the origin directly with --resolve to bypass DNS and CDN, before chasing the application code:

curl -s --resolve example.com:443:[origin-ip] https://example.com/ | head -5

AI tools amplify the speed of whoever is driving them. They don't replace the institutional knowledge that comes from having handled the same incident five times before. And they don't replace the boring credential hygiene that prevents the incident from happening in the first place.

Filip Rastovic
Filip Rastovic
Shopify Developer & CRO Specialist · Stargazer Studio

Tired of managing WordPress security?

Shopify eliminates the plugin maintenance, the hosting exposure, and the attack surface that made this incident possible. Book a call and I'll scope a migration.

Book a free call More articles
Filip Rastovic
Book a Call Get started today