DEV Community

Cover image for Forensic analysis of a wp2shell WordPress compromise: two operators and four persistence locations
Rafael Cosquiere
Rafael Cosquiere

Posted on

Forensic analysis of a wp2shell WordPress compromise: two operators and four persistence locations

A few days ago, a WordPress site I manage was defaced with the message "Hacked by CoupDeGrace".

Hacked by CoupDeGrace - ganteng.html

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 through author__not_in
  • Target endpoint: batch/v1
  • Vulnerable RCE-chain versions: WordPress 6.9.0 to 6.9.4, and 7.0.0 to 7.0.1
  • Patched RCE-chain versions: 6.9.5 and 7.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
Enter fullscreen mode Exit fullscreen mode

A large number returned:

207 Multi-Status
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

The attacker's first actions were typical environment reconnaissance:

id
uname -a
hostname
pwd
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The associated email used an attacker-controlled internal-looking domain:

wpsvc_9b9b2ba2be34@wordpress-svc.internal
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It also removed accounts using email domains such as:

wp2shell.invalid
wp2shell.local
wordpress-svc.internal
nx.invalid
bobresearchlabs.com
shellcode.lol
local.host
Enter fullscreen mode Exit fullscreen mode

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-*
Enter fullscreen mode Exit fullscreen mode

These names appeared in Cluster A's explicit rival list.

This second persistence design was more careful:

  1. The active payload lived outside the webroot in /tmp/php<random>.
  2. A backup copy was expected under uploads/class-wp-<type>_<hash>.php.
  3. If both files were missing, the must-use plugin rebuilt the binary from a Base64 blob stored in wp_options.
  4. It parsed database credentials directly from wp-config.php and connected with PDO.
  5. It scheduled the WordPress cron event wp_cache_health_07ab0b44.
  6. It hid itself from the Must-Use plugin list with show_advanced_plugins.

The relevant database option was:

_site_transient_health_07ab0b44
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Its purpose was to block /batch/v1 for everyone except requests carrying a secret header:

X-WP-Site-Token: <redacted-token>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The dropper used logic equivalent to:

@touch(
    $mu_path,
    filemtime(ABSPATH . 'wp-includes/plugin.php')
);
Enter fullscreen mode Exit fullscreen mode

The larger shell used an explicit shell command:

touch -r ../../../../wp-includes/version.php <malicious-file>
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Checks required:

crontab -l
ls -la /etc/cron.d/
ls -la /var/spool/cron/
Enter fullscreen mode Exit fullscreen mode

2. Database option

The option:

_site_transient_health_07ab0b44
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Useful checks:

wp cron event list | grep 'wp_cache_health_07ab0b44'
wp option get '_site_transient_health_07ab0b44'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Search for:

wpsvc_9b9b2ba2be34
Enter fullscreen mode Exit fullscreen mode

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_users
  • wp_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
Enter fullscreen mode Exit fullscreen mode

Its fallback chain was:

  1. sendmail
  2. Direct SMTP socket to localhost:25
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A filesystem spread attempt was documented from the primary site on 27 July at 21:57.

The operator used:

feature=healspread
Enter fullscreen mode Exit fullscreen mode

Then searched for a known shell:

find / -name 'class-wp-rest-compat-85abe7.php'
Enter fullscreen mode Exit fullscreen mode

Write tests targeted common hosting paths:

/var/www/html/
/home/*/public_html/
/srv/www/
/var/www/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Hidden-file patterns

No remaining files matched the attacker's previously used hidden-name styles:

.tmp_*
.??????????.php
Enter fullscreen mode Exit fullscreen mode

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*
Enter fullscreen mode Exit fullscreen mode

The rules matched legitimate combinations such as:

register_rest_route
permission_callback
__return_true
base64_decode
$_POST
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2. Inspect must-use plugins

ls -la wp-content/mu-plugins/
find wp-content/mu-plugins/ -maxdepth 1 -type f -name '*.php' -print
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then review WordPress cron and the suspicious option:

wp cron event list
wp option get '_site_transient_health_07ab0b44'
Enter fullscreen mode Exit fullscreen mode

8. Review users and sessions

wp user list --fields=ID,user_login,user_email,roles,user_registered
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
  • /tmp
  • wp_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

  1. Record the hash and location of every malicious shell.
  2. Remove the confirmed persistence mechanism.
  3. Delete the shell.
  4. Trigger controlled requests to the site several times.
  5. Run WordPress cron manually in the controlled environment.
  6. Wait past the previous five-minute crontab interval.
  7. Check whether the file, database option, cron event or /tmp payload 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-
Enter fullscreen mode Exit fullscreen mode

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:

  1. Inspect mu-plugins before the normal plugin list.
  2. Inspect system cron before deleting files.
  3. Search wp_options for encoded payloads and suspicious transient names.
  4. Treat mtimes as attacker-controlled metadata.
  5. Review access logs for secrets passed through query strings.
  6. Do not use a 403 response as proof that a vulnerable route is safe.
  7. Do not declare sibling sites clean from logs alone.
  8. 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.

References

Top comments (0)