A few days ago, a WordPress site I manage was defaced with the message "Hacked by CoupDeGrace".
On my own site I wrote about the incident from a less technical and more personal angle: Hacked by CoupDeGrace: forensic analysis of a WordPress site hacked with wp2shell.
This article is the technical version. It focuses on the evidence, the attack sequence, the persistence mechanisms, the log analysis and the checks used to decide whether the environment was clean.
The customer name, customer-owned domains, exact server paths and live access secrets have been removed. Malicious filenames, timestamps, counts and attacker infrastructure are kept when they are useful as indicators of compromise. Any token that could still provide access to a backdoor is redacted.
All timestamps are CEST.
Executive summary
The primary WordPress site was compromised on 19 July 2026 at 07:53:57 through the unauthenticated wp2shell remote code execution chain:
-
CVE-2026-63030: REST API batch-route confusion -
CVE-2026-60137: SQL injection throughauthor__not_in - Target endpoint:
batch/v1 - Vulnerable RCE-chain versions: WordPress
6.9.0to6.9.4, and7.0.0to7.0.1 - Patched RCE-chain versions:
6.9.5and7.0.2
WordPress 6.8.x was affected by the SQL injection issue, but not by the complete batch-route RCE chain. The fix for that branch was released in 6.8.6.
This was not a single intrusion followed by a defacement. The site had been colonised by at least two separate operator clusters. They used different persistence systems, deployed different payloads and removed each other's administrator accounts and files.
The compromise was still active on 29 July at 13:14, eight minutes after the WordPress core had been updated to 7.0.2.
Updating closed the vulnerable entry point. It did not remove the attackers.
Confirmed impact:
- Remote command execution as the web-server user
- Exfiltration of email addresses from WordPress and WooCommerce tables
- Database credentials exposed in plain text inside access logs
- Deployment of a bulk-mail module with several delivery fallbacks
- Use of the host as a spam relay
- Persistence in the system user's crontab
- Persistence in
wp_options - Persistence in
/tmp - Creation of a backdoor administrator
- Removal of other WordPress sessions
- Wordfence disabled by renaming its directories
- Attempts to locate and write into other virtual hosts on the same server
Scope and limitations
The material available for the analysis was:
- A full copy of the primary site's
public_html, around 1.9 GB - Access and error logs for five customer-owned domains on the same hosting environment
- No database dump
- No live shell on the original server during the offline review
The primary site had both filesystem and log evidence. The other four sites had log evidence only.
That distinction matters. A site with no post-exploitation requests in its access log cannot automatically be declared clean when its filesystem and database have not been examined.
Initial access
The relevant requests were POST requests to either of these routes:
/?rest_route=/batch/v1
/wp-json/batch/v1
A large number returned:
207 Multi-Status
A 207 response is a strong exposure signal because it shows that the batch endpoint exists, is reachable and processed the request. It is not enough by itself to prove that a persistent compromise was completed.
In this incident, persistent compromise was confirmed by the requests that immediately followed: command execution through newly written PHP files, mailer deployment, directory creation, plugin activation, administrator actions and crontab changes.
The first confirmed command execution occurred on 19 July at 07:53:57 through:
wp-content/plugins/media-optimization-core-1fd578/
The attacker's first actions were typical environment reconnaissance:
id
uname -a
hostname
pwd
The logs also showed checks for local mail-delivery capabilities, creation of directories under uploads and mu-plugins, deployment of a mailer and installation of a crontab entry.
Timeline
| Date and time | Event |
|---|---|
| 18 Jul 18:08 | First probes against the batch endpoint on the primary site |
| 19 Jul 07:53:57 |
First confirmed compromise. Commands executed through media-optimization-core-1fd578.php. The operator checked mail capabilities, deployed a mailer, created directories and added system-cron persistence |
| 19 Jul 12:42 | Database credentials extracted from wp-config.php; email addresses exfiltrated through mysql and wp-cli
|
| 19 Jul 16:25 to 17:12 |
class-wp-taxonomy-bd9d4f.php deployed |
| 22 Jul 10:50 | Scans using the User-Agent wp2shell-rce/1.0
|
| 24 Jul 03:50 to 06:22 |
6,849 requests to the batch endpoint from 152.44.36.152 in 2 hours and 32 minutes |
| 24 Jul 03:55 |
sgio-wp2shell-13f838eba615 activated through wp-admin with a valid nonce, indicating use of an administrator session |
| 24 Jul 03:55:48 | First request to /ganteng.html; the defacement was public |
| 24 Jul 15:05 | A second operator activated wp2p_fe13d6d7, which wrote 0x.txt containing chinafans
|
| 26 Jul 09:12 to 10:12 |
_xp_, _px_ and _vx_ modules deployed. Explicit timestomping performed with touch -r
|
| 26 Jul 10:12 | Malicious .htaccess added to force PHP execution in the plugin directory |
| 27 Jul 00:52:38 |
Wordfence disabled. The wordfence and wflogs directories were renamed to disabled or backup names |
| 27 Jul 00:52 | Main payload downloaded from an attacker-controlled, defanged domain |
| 27 Jul 17:32 | First invocation of feature=healspread, used for propagation |
| 27 Jul 21:57 | Search for class-wp-rest-compat-85abe7.php across the server and write tests against common virtual-host roots |
| 28 Jul 10:57 | The 96 KB class-wp-compat_* shells were redeployed |
| 29 Jul 12:17 | Last observed request to the vulnerable batch endpoint |
| 29 Jul 13:06 | WordPress core updated to 7.0.2
|
| 29 Jul 13:14 | _px_83ac137d.php rewritten eight minutes after the update |
The last line is the one I would keep in mind during cleanup. The patch worked, but existing persistence continued to run.
Separating the two operator clusters
I use "operator cluster" here in a narrow sense. This is not attribution to a person or organisation. It means that the malware, account patterns and cleanup logic let us separate two sets of activity.
The strongest evidence came from the _vx_ verifier and _xp_ eviction modules. Their code contained explicit lists for "ours" and "rivals".
Cluster A: wpsvc / CoupDeGrace
The account identifier recovered from the ou= parameter was:
wpsvc_9b9b2ba2be34
The associated email used an attacker-controlled internal-looking domain:
wpsvc_9b9b2ba2be34@wordpress-svc.internal
Artifacts associated with this cluster:
wp-content/plugins/media-optimization-core-1fd578/
wp-content/mu-plugins/wp-db-optimizer-ce974c.php
wp-content/uploads/class-wp-cache-41d7f8.php
wp-content/cdg.txt
wp-content/plugins/cdg.txt
wp-content/themes/cdg.txt
wp-content/uploads/cdg.txt
ganteng.html
cdg.html
The _xp_ module acted as an eviction component. It removed administrators whose login matched patterns associated with other operators:
wp2_
w2s_
wp_svc_
wordpress_[hex12]
bob_[hex12]
Nx_[hex12]
[hex12]
wpadmin[A-Z]{2,}
yun_\d+
support
admin001
adminlin
seomanager
upgrades
super
kacak
It also removed accounts using email domains such as:
wp2shell.invalid
wp2shell.local
wordpress-svc.internal
nx.invalid
bobresearchlabs.com
shellcode.lol
local.host
The same component deleted rival must-use plugins and rival plugin directories.
This is why the filesystem represented only the last stable state of the compromise. Some files visible in the logs had already been deleted by one of the operators.
Cluster B: database-backed must-use plugins
The second cluster deployed 15 must-use plugins using plausible WordPress-style names:
wp-asset-loader-*
wp-cache-handler-*
wp-core-update-*
wp-cron-helper-*
wp-health-check-*
wp-mail-handler-*
wp-rest-optimizer-*
These names appeared in Cluster A's explicit rival list.
This second persistence design was more careful:
- The active payload lived outside the webroot in
/tmp/php<random>. - A backup copy was expected under
uploads/class-wp-<type>_<hash>.php. - If both files were missing, the must-use plugin rebuilt the binary from a Base64 blob stored in
wp_options. - It parsed database credentials directly from
wp-config.phpand connected with PDO. - It scheduled the WordPress cron event
wp_cache_health_07ab0b44. - It hid itself from the Must-Use plugin list with
show_advanced_plugins.
The relevant database option was:
_site_transient_health_07ab0b44
This is a useful IOC because deleting the PHP files did not remove it.
The attacker also patched the entry point
One must-use plugin did not appear in either cluster's ownership list:
wp-rest-hardening-18b216a0.php
Its purpose was to block /batch/v1 for everyone except requests carrying a secret header:
X-WP-Site-Token: <redacted-token>
The logic was equivalent to:
add_action('rest_api_init', function () {
if (is_user_logged_in()) {
return;
}
$uri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($uri, '/batch/v1') === false) {
return;
}
$provided = $_SERVER['HTTP_X_WP_SITE_TOKEN'] ?? '';
$token = '<redacted-token>';
if ($provided !== '' && hash_equals($token, $provided)) {
return;
}
status_header(403);
echo json_encode([
'code' => 'rest_forbidden',
'message' => 'Sorry, you are not allowed to do that.',
]);
exit;
}, 0);
The operator closed the vulnerable route after gaining access, while keeping a private bypass.
This creates a bad diagnostic trap. A 403 response from the endpoint cannot be accepted as proof that the site is patched. In this case, the 403 was produced by malware running inside WordPress.
The endpoint response has to be interpreted together with the core version, the loaded must-use plugins and the filesystem.
Malicious file inventory
All confirmed files were isolated before analysis.
| Path | Size | Observed mtime | Function |
|---|---|---|---|
wp-content/plugins/media-optimization-core-1fd578/media-optimization-core-1fd578.php |
1,299 B | 19 Jul 07:53 | Primary backdoor. Accepted a token and a command parameter. The live token is redacted |
.../_px_83ac137d.php |
3,755 B | 29 Jul 13:14 | Persistence orchestrator and administrator creation |
.../class-wp-compat_eca18b00.php |
96,546 B | 28 Jul 10:57 | Main shell, obfuscated with chr(N^K) expressions |
.../class-wp-compat_1e9d64c0.php |
96,546 B | 28 Jul 10:57 | Identical clone, MD5 beginning 8b33c290
|
.../class-wp-rest-compat-85abe7.php |
96,546 B | 19 Jun 10:50 | Identical clone with a falsified mtime matching the core installation date |
.../.htaccess |
126 B | 26 Jul 10:12 | Forced PHP execution inside the plugin directory |
wp-content/uploads/class-wp-cache-41d7f8.php |
3,755 B | Not reliable | Copy of the _px_ dropper, MD5 beginning 736d4ef3
|
wp-content/mu-plugins/wp-db-optimizer-ce974c.php |
365 B | 26 Feb 14:10 | Cluster A guardian. Recreated the dropper from the uploads copy and vice versa |
wp-content/mu-plugins/wp-{asset-loader,cache-handler,core-update,cron-helper,health-check,mail-handler,rest-optimizer}-*.php |
About 1,832 B each | 26 Feb 14:10 | 15 Cluster B guardians with database-backed recovery |
wp-content/mu-plugins/wp-rest-hardening-18b216a0.php |
663 B | 26 Feb 14:10 | Blocked /batch/v1 unless a secret header was present |
wp-content/plugins/sgio-wp2shell-13f838eba615/ |
2,771 B | 24 Jul 03:54 | Exploit plugin. Exposed a tokenised REST route whose exact route ID is redacted |
wp-content/plugins/wp2p_fe13d6d7/ |
111 B | 24 Jul 15:05 | Wrote 0x.txt containing chinafans
|
wp-content/{,plugins/,themes/,uploads/}cdg.txt |
12 B | 24 Jul 03:55 |
CoupDeGrace markers |
Timestomping
The mtime 26 Feb 2026 14:10 on the 17 malicious must-use plugins was false.
It matched the mtime of:
wp-includes/plugin.php
The dropper used logic equivalent to:
@touch(
$mu_path,
filemtime(ABSPATH . 'wp-includes/plugin.php')
);
The larger shell used an explicit shell command:
touch -r ../../../../wp-includes/version.php <malicious-file>
Sorting the directory by date would not have found these files.
Files present in logs but missing from disk
The following artifacts appeared in requests or command output but were not present in the final filesystem copy:
mta-19310704.php
class-wp-taxonomy-bd9d4f.php
_xp_83ac137d.php
_vx_83ac137d.php
class-wp-session-compat-e77b1f.php
class-wp-http-compat-c51985.php
class-wp-cron-handler-0ba61a.php
class-wp-cron-handler-f49b0f.php
class-wp-feed-generator-08dcff.php
class-wp-feed-generator-3e7d3e.php
class-wp-rewrite-core-000e42.php
class-wp-rewrite-core-8d1aca.php
class-wp-rewrite-core-51d39c.php
class-wp-block-renderer-dfcfe8.php
class-wp-locale-helper-e26698.php
class-wp-rest-compat-86f744.php
uploads/class-wp-cache-2582cf10.php
sgio-wp2shell-bd3f8adc83d3/hantu.php
wp2p_fe13d6d7/9df6586309fb.php
wp2shell_e44ffa62/
Temporary payload paths observed:
/tmp/php7RvENA
/tmp/phppllPkX
/tmp/phpSlkrQv
/tmp/phpK2GRXS
/tmp/phpsHZLfT
/tmp/phpahxTC5
/tmp/phpmK5Ucl
/tmp/phps3vkg9
/tmp/phpAfFsOW
/tmp/phpuoQExw
/tmp/phpG2YQai
/tmp/phppSdonl
/tmp/phpu9B21m
/tmp/phpe92ZoW
/tmp/phps8XYvU
/tmp/phpWhWw2C
Missing from the final copy does not mean irrelevant. In a multi-operator compromise, deleted files can be evidence of operator conflict, previous persistence or an incomplete cleanup.
Persistence outside the site filesystem
This was the main reason a normal "delete the suspicious plugin and update WordPress" cleanup would fail.
1. System-user crontab
A cron entry ran every five minutes and restored a shell if it was missing:
*/5 * * * * test -f <site-root>/class-wp-taxonomy-bd9d4f.php || cp <site-root>/uploads/class-wp-cache-2582cf10.php <site-root>/class-wp-taxonomy-bd9d4f.php
Checks required:
crontab -l
ls -la /etc/cron.d/
ls -la /var/spool/cron/
2. Database option
The option:
_site_transient_health_07ab0b44
contained a Base64-encoded payload.
The malware could reconstruct a deleted binary by reading that option through PDO.
3. WordPress cron
The scheduled hook was:
wp_cache_health_07ab0b44
Useful checks:
wp cron event list | grep 'wp_cache_health_07ab0b44'
wp option get '_site_transient_health_07ab0b44'
These commands should be run only after preserving the database evidence.
4. /tmp
There were 16 paths matching /tmp/php<random>, with payloads around 46,976 bytes or larger.
A narrow first pass:
find /tmp -maxdepth 1 -type f -name 'php*' -size +40k -ls
A filename and size filter is only a lead. It is not a verdict.
5. Backdoor administrator
The database was not available, so the administrator account could not be verified directly.
The review should include:
wp user list --fields=ID,user_login,user_email,roles,user_registered
Search for:
wpsvc_9b9b2ba2be34
Also search for the login and email patterns listed in the operator-cluster section.
Database and mail impact
The logs showed commands that read database credentials from wp-config.php and then used both mysql and wp-cli to export email addresses.
The targeted data included:
wp_userswp_comments- WooCommerce tables containing customer email addresses
The database username and password were then passed through a query string during attacker activity.
That creates a second exposure: web access logs may contain the credentials in plain text. Cleaning the site without protecting, rotating and restricting those logs leaves another copy of the secret behind.
The mail module was:
mta-19310704.php
Its fallback chain was:
sendmail- Direct SMTP socket to
localhost:25 - PHP
mail()
The server was being used as a spam relay. Incident handling therefore had to include mail queues, outbound-mail logs, provider reputation and any credentials associated with SMTP delivery.
The email-address export may constitute a personal-data breach. The legal and notification assessment depends on jurisdiction, role and actual data involved, so it should be handled with the relevant privacy or legal contact rather than guessed from the filesystem alone.
Log analysis across five sites
Customer-owned domains are replaced with neutral labels.
| Site | IOC hits |
207 responses |
Source IPs | Post-exploitation requests | Observed window |
|---|---|---|---|---|---|
| Primary site | 7,348 | 7,011 | 71 | 225 | 18 Jul 18:08 to 29 Jul 12:17 |
| Sibling A | 186 | 76 | 71 | 0 | 18 Jul to 29 Jul 03:38 |
| Sibling B | 94 | 13 | 24 | 0 | 18 Jul 22:19 to 25 Jul 21:15 |
| Sibling C | 102 | 9 | 29 | 0 | 19 Jul 10:06 to 29 Jul 05:29 |
| Sibling D | 65 | 15 | 23 | 0 | 19 Jul 16:19 to 29 Jul 11:04 |
Only the primary site showed clear post-exploitation traffic:
- Requests to shell paths
- Parameters such as
?px=,?ou=and?sp= - Plugin activation through
wp-admin - Requests to the defacement
- Interactive command activity
The other four sites showed traffic to the batch endpoint, but no requests to known shell paths and no plugin-activation sequence.
That is not enough to declare them clean.
All four returned 207 at least once. Their response sizes also followed the same progression as the primary site:
Baseline: about 6.35 KB
Second stage: baseline + about 463 bytes
Later stage: baseline + about 610 to 620 bytes
The consistent size changes suggest that the chain progressed through at least two stages on all five sites.
Without their filesystems and databases, the correct conclusion is:
Exposed and potentially exploited, with no confirmed post-exploitation in the available logs.
Those sites still required patching, filesystem review, database checks and YARA scanning.
Cross-vhost activity
Several source IPs attacked more than one customer-owned domain in the same hosting environment:
45.138.16.164
51.161.51.84
179.43.145.34
209.99.186.3
216.126.227.x
104.194.146.x
172.81.132.79
156.0.200.13x
A filesystem spread attempt was documented from the primary site on 27 July at 21:57.
The operator used:
feature=healspread
Then searched for a known shell:
find / -name 'class-wp-rest-compat-85abe7.php'
Write tests targeted common hosting paths:
/var/www/html/
/home/*/public_html/
/srv/www/
/var/www/
The generic paths failed except for the real site path. There was no log evidence that another virtual host was successfully written.
That is a limited statement. It means "not observed", not "impossible".
Main source IPs
The main IPs observed against the primary site were:
152.44.36.152 6,849 requests
173.231.200.13 122 requests, most interactive activity
92.189.77.250
172.235.128.108
216.170.192.140
34.171.141.44 first confirmed compromise
34.172.185.49
162.193.207.171
34.136.134.112
34.171.169.105
156.0.200.134
195.206.105.12
Several addresses belonged to large cloud providers or low-cost VPS ranges.
Blocking them can reduce noise, but it is not remediation. The exploit traffic came from many addresses, and the persistence no longer depended on the original source IP.
User-Agent indicators
High-fidelity strings that did not appear in legitimate traffic:
wp2shell
wp2shell-rce/1.0
wp2shell-check/1.0
wp2shell-checker
Mozilla/5.0 (compatible; wp2shell-check/1.0)
Mozilla/5.0 (compatible; SiteHealth/1.0)
Medium-fidelity strings:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Mozilla/5.0
python-requests/2.34.2
Python-urllib/3.13
The first Windows string is suspicious because it stops after AppleWebKit/537.36 and lacks the normal browser suffix.
Medium-fidelity indicators should be combined with route, method, response code, source and request timing. Used alone, they will produce false positives.
Filesystem coverage
The full primary-site tree was scanned recursively with YARA.
Directories reviewed explicitly included:
<primary-site>/ site root
<primary-site>/.well-known/
<primary-site>/.wp-cli/
<primary-site>/cgi-bin/
<primary-site>/wp-admin/ including includes/
<primary-site>/wp-includes/ including rest-api/endpoints/
<primary-site>/wp-content/
<primary-site>/wp-content/blogs.dir/
<primary-site>/wp-content/cache/
<primary-site>/wp-content/languages/
<primary-site>/wp-content/litespeed/
<primary-site>/wp-content/maintenance/
<primary-site>/wp-content/mu-plugins/ 17 confirmed malicious files out of 19
<primary-site>/wp-content/plugins/ 31 subdirectories reviewed
<primary-site>/wp-content/themes/
<primary-site>/wp-content/updraft/
<primary-site>/wp-content/upgrade/
<primary-site>/wp-content/upgrade-temp-backup/
<primary-site>/wp-content/uploads/ complete tree, 72 PHP files
<primary-site>/wp-content/wflogs/
<primary-site>/wp-content/wflogs.bak_1785106358/
<logs>/sibling-a/
<logs>/sibling-b/
<logs>/primary/
<logs>/sibling-c/
<logs>/sibling-d/
Negative findings and false positives
Recording what was checked and found clean was useful. Otherwise every later reviewer has to repeat the same work.
Themes
No theme PHP file had been modified in July 2026.
The only malicious theme artifact was:
wp-content/themes/cdg.txt
Uploads
There were 72 PHP files under wp-content/uploads/.
Of those:
- 71 were legitimate WPML Twig cache files under
uploads/cache/wpml/twig/ - 1 was malicious:
class-wp-cache-41d7f8.php
A rule saying "every PHP file in uploads is malware" would have produced 71 false positives here.
.htaccess
Seven .htaccess files were present:
- Six were legitimate, belonging to LiteSpeed, Wordfence, UpdraftPlus or WooCommerce
- One was malicious and forced PHP execution in the attacker's plugin directory
Drop-ins and host automation
These were legitimate:
wp-content/object-cache.php
wp-content/maintenance.php
wp-content/mu-plugins/automation-by-installatron.php
Hidden-file patterns
No remaining files matched the attacker's previously used hidden-name styles:
.tmp_*
.??????????.php
The logs indicated that some were removed before the filesystem was copied.
Core and legitimate plugins
No webshell was confirmed inside WordPress core or legitimate plugin files.
YARA hits in these locations were reviewed as false positives:
wp-admin/includes/file.php
litespeed-cache
updraftplus/central/modules/core.php
woocommerce
wordpress-seo
wp-includes/rest-api*
The rules matched legitimate combinations such as:
register_rest_route
permission_callback
__return_true
base64_decode
$_POST
Every hit had to be opened and reviewed. A scanner result was treated as a queue, not as a verdict.
Practical detection workflow
These are the first checks I would run on another WordPress site suspected of the same compromise.
1. Record the running version
wp core version
grep "\$wp_version" wp-includes/version.php
2. Inspect must-use plugins
ls -la wp-content/mu-plugins/
find wp-content/mu-plugins/ -maxdepth 1 -type f -name '*.php' -print
Look for plausible core-style names followed by hexadecimal suffixes.
Do not trust the modification time.
3. Inspect PHP files in uploads
find wp-content/uploads/ -type f -name '*.php' -print
Review context before deleting anything. Cache plugins and translation plugins can create legitimate PHP files in specific subdirectories.
4. Inspect unexpected plugins
find wp-content/plugins/ -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort
wp plugin list
Compare with deployment records or a known-good inventory.
5. Search for specific artifacts
find wp-content/ -type f \
\( -name 'cdg.txt' \
-o -name 'ganteng.html' \
-o -name 'class-wp-cache-*.php' \
-o -name 'class-wp-compat_*.php' \
-o -name 'wp-rest-hardening-*.php' \) -print
6. Search logs
grep -R 'batch/v1' /path/to/logs/
grep -R 'wp2shell' /path/to/logs/
grep -R -E '(\?px=|\?ou=|\?sp=)' /path/to/logs/
grep -R 'plugins.php?action=activate' /path/to/logs/
Also inspect 207, 403 and unusual response-size clusters.
7. Check persistence outside WordPress files
crontab -l
ls -la /etc/cron.d/
ls -la /var/spool/cron/
find /tmp -maxdepth 1 -type f -name 'php*' -ls
Then review WordPress cron and the suspicious option:
wp cron event list
wp option get '_site_transient_health_07ab0b44'
8. Review users and sessions
wp user list --fields=ID,user_login,user_email,roles,user_registered
Review recent administrators, unexpected email domains and accounts matching the known patterns.
After evidence collection, invalidate all sessions and rotate WordPress salts.
9. Run YARA
The case-specific ruleset contained 11 rules and was executed recursively:
yara -r -f rules/wp2shell.yar /path/to/public_html
A useful ruleset should cover more than exact hashes. In this incident, filenames, marker strings, option names, cron-hook names, obfuscation style and recovery logic were more durable than one file hash.
Cleanup order
The order matters because WordPress requests can load must-use plugins, and must-use plugins can recreate deleted files.
1. Contain the site
- Remove it from public traffic or place it behind a controlled maintenance response
- Preserve the filesystem, database and logs
- Record hashes and timestamps before modifying evidence
- Stop normal visitors, health checks and cron-triggering requests from executing the compromised WordPress stack
2. Stop persistence outside the webroot
Check and remove confirmed malicious entries from:
- System-user crontab
/etc/cron.d//var/spool/cron//tmpwp_options- WordPress cron
- Unexpected administrator accounts
Do this before deleting the visible shells.
3. Rotate secrets
At minimum:
- Database password
- Hosting control-panel credentials
- SSH or SFTP credentials
- WordPress administrator passwords
- WordPress salts
- SMTP credentials
- API keys stored in
wp-config.php, plugin settings or environment files
Also invalidate active WordPress sessions.
Because the database password appeared in access logs, protect or rotate access to the logs themselves.
4. Rebuild from known-good sources
A clean rebuild is safer than deleting files one by one:
- Replace WordPress core with a verified patched release
- Reinstall plugins from trusted packages
- Reinstall themes from trusted packages
- Restore only reviewed uploads
- Recreate configuration files manually where practical
- Compare with a known-good inventory
- Remove every unknown must-use plugin
Do not copy the old wp-content directory back as a single unit.
5. Patch every related site
The four sibling sites returned 207 and showed multi-stage response-size changes.
Even without confirmed post-exploitation, they required:
- A patched WordPress version
- Filesystem scan
- Database-option review
- User review
- Cron review
- Log review
- Secret rotation based on shared hosting and credential reuse
6. Review data and mail impact
- Preserve evidence of email-address extraction
- Review WooCommerce customer-data exposure
- Check outbound mail queues and SMTP logs
- Check blocklists and provider abuse notices
- Start the appropriate privacy and legal assessment
Validation after cleanup
A clean home page proves very little.
I used active checks.
Guardian test
- Record the hash and location of every malicious shell.
- Remove the confirmed persistence mechanism.
- Delete the shell.
- Trigger controlled requests to the site several times.
- Run WordPress cron manually in the controlled environment.
- Wait past the previous five-minute crontab interval.
- Check whether the file, database option, cron event or
/tmppayload returns.
The test should be done in an isolated or maintenance environment, with logs enabled.
Endpoint validation
Confirm that:
- The WordPress core version is patched
- No unknown must-use plugin intercepts
/batch/v1 - The secret-header bypass is gone
- The response comes from the patched core or an approved security control
- No attacker-created route remains registered
A status code alone is not enough.
Integrity validation
- Compare WordPress core checksums
- Reinstall and compare plugins
- Reinstall and compare themes
- Review every PHP file under uploads
- Review every file under
mu-plugins - Re-run YARA
- Re-run IOC searches across logs
- Review recent administrator creation
- Confirm crontab,
/tmp, database option and WP-Cron persistence are absent
Monitoring
For the following days, alert on:
batch/v1
wp2shell
CoupDeGrace
cdg.txt
ganteng.html
wp_cache_health_07ab0b44
_site_transient_health_07ab0b44
class-wp-cache-
class-wp-compat_
wp-rest-hardening-
Also alert on unexpected plugin activation, new administrator creation and PHP execution from uploads.
What this incident changed in my review process
A few checks moved permanently to the top of my WordPress incident-response list:
- Inspect
mu-pluginsbefore the normal plugin list. - Inspect system cron before deleting files.
- Search
wp_optionsfor encoded payloads and suspicious transient names. - Treat mtimes as attacker-controlled metadata.
- Review access logs for secrets passed through query strings.
- Do not use a
403response as proof that a vulnerable route is safe. - Do not declare sibling sites clean from logs alone.
- Treat an update as containment of the vulnerability, not removal of the compromise.
The defacement was the least important artifact in the whole incident. It was just the point at which the attackers became visible.

Top comments (0)