From 9b92d26fa0091b08f769983626b149fc3f8e9105 Mon Sep 17 00:00:00 2001 From: CptNope <100234377+CptNope@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:56:39 -0500 Subject: [PATCH] 1.7.0: fewer false alarms, new PHP2Shell and wp2shell indicators False alarms on clean sites came from newly installed plugins and host files, and from two signatures that matched ordinary code: - PHP in an uploads/cache folder named exactly after an installed plugin or theme names its owner: older files low, recent or request-handling files medium (not high). Code signatures still apply; generic names never count. - Packages left half-unpacked in wp-content/upgrade/ (WordPress's real layout only) are one medium finding per folder, not high per file. - Must-use files that only require an installed plugin, and drop-ins that load one, are low; more host and tool names are known. - Firewall bootstraps (Wordfence, AIOS, MalCare) are trusted only at the site root while their plugin is installed; auto_prepend_file into host vendor folders or an installed plugin is low; session files, temp folders and ".." paths stay high. - A method named exec()/system() is not a shell call; file_put_contents() with form input is medium unless the file name comes from the request, a PHP file is written, or decoded input is written. - CI's false-positive test also covers ~55 wordpress.org plugins and 7 themes as shipped. An independent adversarial review found ways to hide a webshell behind the first draft (attacker-chosen folder names, fake upgrade folders, session files as "system" paths, firewall names anywhere, comment-only loaders); all are closed and tested. New indicators: PHP2Shell honeypot markers, shell_<8 hex>.php, session IDs and addresses (Oct 22-24); wp2shell persistence (fake plugin folders, w2s_/wpsvc_ admins, _site_transient_health_ incl. multisite, WP_Core_Integrity and wp_compat_ markers); a reminder for WordPress's Oct 28 security release (7.1.4 and backports). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ejUFSkKi2KuJi647biNmu --- .github/workflows/ci.yml | 20 ++ CHANGELOG.md | 20 ++ README.md | 3 +- includes/class-admin.php | 4 +- includes/class-scanner.php | 328 +++++++++++++++++++++++++++++--- includes/signatures.php | 60 ++++-- jat-shell-scanner.php | 4 +- languages/jat-shell-scanner.pot | 4 +- readme.txt | 12 +- tests/false-positives.php | 11 ++ tests/integration.sh | 102 +++++++++- 11 files changed, 524 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 673070c..c1f4cfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,26 @@ jobs: for r in WordPress/WordPress Yoast/wordpress-seo elementor/elementor takayukister/contact-form-7 woocommerce/woocommerce Automattic/wp-super-cache Automattic/jetpack; do git clone --quiet --depth 1 "https://github.com/$r" "$(basename "$r")" done + + - name: Fetch the wordpress.org zips sites actually install + # What a freshly installed plugin or theme puts on a site: security, backup, cache, + # snippet, SEO, form, management and migration tools write the files most often mistaken for malware. + run: | + mkdir -p corpus/wporg && cd corpus/wporg + plugins="akismet wordfence all-in-one-wp-security-and-firewall better-wp-security sucuri-scanner malcare-security really-simple-ssl + updraftplus all-in-one-wp-migration duplicator wpvivid-backuprestore backwpup wp-staging + litespeed-cache w3-total-cache wp-fastest-cache wp-optimize autoptimize hummingbird-performance jetpack-boost wp-cloudflare-page-cache + code-snippets insert-headers-and-footers wp-file-manager wp-all-import redirection + seo-by-rank-math all-in-one-seo-pack google-site-kit google-analytics-for-wordpress + wpforms-lite ninja-forms formidable essential-addons-for-elementor-lite wp-mail-smtp + worker mainwp-child health-check query-monitor classic-editor advanced-custom-fields loco-translate + wp-smushit ewww-image-optimizer regenerate-thumbnails duplicate-post wp-crontrol user-role-editor + limit-login-attempts-reloaded two-factor mailpoet the-events-calendar bbpress buddypress" + themes="astra kadence generatepress oceanwp hello-elementor blocksy neve" + { for p in $plugins; do echo "plugin/$p.latest-stable.zip"; done; for t in $themes; do echo "theme/$t.zip"; done; } | + xargs -P 8 -I{} sh -c 'f=$(echo "{}" | tr / _); curl -fsSL --retry 3 -o "$f" "https://downloads.wordpress.org/{}" || echo "::warning::Couldn'"'"'t download {}"' + for z in *.zip; do unzip -q -o "$z" -d "${z%.zip}" && rm "$z"; done + echo "Plugins and themes: $(ls | wc -l), PHP files: $(find . -name '*.php' | wc -l)" - name: Run the code signatures over them run: php tests/false-positives.php corpus diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a7bd23..7619212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.7.0] — 2026-11-05 + +### Fewer false alarms +Newly installed plugins and some hosts put files where the scanner looked for backdoors, and a few code signatures matched ordinary plugin code. A false "Suspicious" or "Compromised" verdict on a clean site was the result. Now: + +- **Plugin data folders.** PHP in a folder under uploads or cache named exactly after an installed plugin or theme (`uploads/wpallimport/` for WP All Import, for example) says which plugin it probably belongs to. Older files there are low. Files changed since July 17, 2026, or that handle web requests, are medium ("needs review") instead of high, because an attacker could name a folder after a plugin too. Code signatures still apply to every file. Generic names (cache, files, backups…) never count as a plugin's folder. +- **Interrupted updates.** Plugin, theme and core packages left half-unpacked in `wp-content/upgrade/` are reported once per folder (medium, "delete it if no update is running") instead of once per PHP file as high. This applies only to the layout WordPress actually uses: `upgrade/wordpress-/wordpress/…`, or `upgrade/[.]//…` for an installed plugin or theme. +- **Must-use files and drop-ins** that load an installed plugin are recognised (low). A must-use file counts only if it does nothing but `require` that plugin's files; one that also logs people in or handles requests is still medium. More known names: ManageWP (`0-worker.php`), Health Check, WP Migrate, WP Staging, WP Cerber, SpinupWP; Jetpack Boost, Super Page Cache, Endurance, APCu, Hyper Cache, Simple Cache, FlyingPress, Cachify and NitroPack drop-ins. +- **Code loaded before every page.** The firewalls of Wordfence, All In One WP Security (`aios-bootstrap.php`) and MalCare are trusted only at the site root and while that plugin is installed. The same file name anywhere else stays high, and is low at the root if the plugin is gone. `auto_prepend_file` into a vendor folder under `/usr/local`, `/opt`, `/usr/share` or `/var/imunify360` (host security agents) or into an installed plugin's folder is low. Session files, temp folders, paths with `..` and the site's own data folders stay high. (Before this release, any path merely containing `imunify`, `malcare` or `wordfence-waf.php` was trusted.) +- **Site root:** `wordfence-waf.php`, `aios-bootstrap.php` and `malcare-waf.php` are known files while their plugin is installed, and GoDaddy's `gd-config.php` when GoDaddy's must-use plugin is present. +- **Code signatures:** a method *named* `exec()` or `system()` is no longer read as running shell commands. `file_put_contents()` with form input (saving custom CSS, imports) is medium. It stays high when the file name comes from the request, a PHP file is written, or decoded (base64, gzip, URL-encoded…) request input is written. Trade-off: a dropper that writes request input to a PHP file named through a variable is now medium. +- CI's false-alarm test now also runs over the zips of about 55 of the most-installed wordpress.org plugins and 7 themes. Across 42,658 PHP files it found one more false alarm (WPForms Lite), from a code comment. +- **Comments are ignored.** A signature that matches only inside a comment ("fields that require $_POST data") no longer counts, since comments never run. The re-check only happens when something matched, so scans aren't slower. Markers attackers deliberately put in comments, like `WP_Core_Integrity`, still count. +- An independent adversarial review of these changes found ways an attacker could have hidden a webshell behind them (folder names chosen to look like a plugin, fake update folders, session files as "system" files, a firewall file name anywhere). All are closed and covered by integration tests. + +### New indicators +- **PHP2Shell** (honeypot captures October 22–24, [0x4D31](https://gist.github.com/0x4D31/6b1e7b5fb7a10219af187be91335c96f); Defused/Prophet Security): probe and exploit markers in logs (`PHP2SHELL_CORE_…`, `PHP2SHELL_CHAIN_OK`, `PHP2SHELL_CHECK`, the p2s_v2 "alive" probe, `shellflow_canary`, `recon0xdeadbeef`, `p2s-rider`, fixed session IDs and multipart boundaries, `/pwn-`), webshell names `shell_<8 hex>.php`, response markers in code, session files, and 26 more addresses. +- **wp2shell persistence** ([Dosdediez incident report](https://github.com/Dosdediez/wordpress-db-backdoor-iocs)): fake plugin folders `advanced-cache-handler-`, `database-repair-assistant-`, `wp2shell-` (critical); `w2s_` / `wpsvc_` admin accounts; the `_site_transient_health_` option; `WP_Core_Integrity` markers in code; `wp_compat_` blocks in `.htaccess`; `class-wp-cache-.php` restore copies. On multisite, the network table is checked for the option too. +- **WordPress's October 28, 2026 security release** ([7.1.4](https://wordpress.org/support/wordpress-version/version-7-1-4/) and backports 7.0.8, 6.9.11, 6.8.12…): sites below it get a medium reminder. It fixes 22 issues, including a race condition during core updates that could allow remote code execution. + ## [1.6.0] — 2026-10-28 ### JAT Shell Scanner diff --git a/README.md b/README.md index 3015414..c3fcebc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ### [Install from WordPress.org](https://wordpress.org/plugins/jat-shell-scanner/) · [⬇️ Download the zip](https://github.com/CptNope/JAT-Shell-Scanner/releases/latest/download/jat-shell-scanner.zip) -**Current version: 1.6.0** (October 28, 2026) · [What's new](#whats-new) · [Full changelog](CHANGELOG.md) · [Wiki](https://github.com/CptNope/JAT-Shell-Scanner/wiki) +**Current version: 1.7.0** (November 5, 2026) · [What's new](#whats-new) · [Full changelog](CHANGELOG.md) · [Wiki](https://github.com/CptNope/JAT-Shell-Scanner/wiki) Free · scans only read · optional one-click fixes you can undo · self-hosted fleet dashboard · no account · nothing sent to us @@ -33,6 +33,7 @@ By [Jeremy Anderson](https://jeremyanderson.tech/) · [Plugin page and docs](htt | Version | Highlights | |---|---| +| **1.7.0** | **Far fewer false alarms**: files newly installed plugins keep in uploads or cache, half-unpacked updates in `wp-content/upgrade`, plugin-made must-use files and drop-ins, and security firewalls or host tools loaded before every page are recognised. Ordinary code (a method named `exec()`, saving custom CSS) no longer matches webshell signatures. New PHP2Shell honeypot indicators and wp2shell persistence indicators; a reminder for WordPress's October 28 security release | | **1.6.0** | Renamed **JAT Shell Scanner** on WordPress.org (by Jeremy Anderson Tech). Quarantine moves into the plugin's own folder in uploads, blocked from the web; folders are found through WordPress's functions, so moved content, plugin and theme folders are scanned correctly | | **1.5.1** | Latest PHP2Shell intel (October 22–24): the Potassium/Mirai botnet's second stage, servers and download names, exploit-kit webshell names (`wp2s.php`, `axiom_*.php`…) and markers (`P2S_OK`, `NxSP…`), poisoned session files, Apache worker aborts, 34 more attacker addresses, and advice for the unpatched **CVE-2026-115560** | | **1.5.0** | Looks for backdoors in the **database** (code in settings, injected scripts and hidden links in posts, scheduled tasks that carry code), checks **plugin files against wordpress.org's checksums**, and flags unexplained **drop-ins** | diff --git a/includes/class-admin.php b/includes/class-admin.php index fc7172e..2214a11 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -259,9 +259,9 @@ private static function flash() { 'cleared' => array( 'success', __( 'The list is empty. Sites reappear (and register again) with their next report.', 'jat-shell-scanner' ) ), 'rescan' => array( 'success', __( 'Scan requested. Sites pick this up within an hour (sites on version 1.2 or older can’t; scan those yourself).', 'jat-shell-scanner' ) ), ); - $done = isset( $_GET['done'] ) ? sanitize_key( wp_unslash( $_GET['done'] ) ) : ''; + $done = isset( $_GET['done'] ) ? sanitize_key( wp_unslash( $_GET['done'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Which notice to show after a redirect; changes nothing. $err = ''; - if ( ! empty( $_GET['err'] ) ) { + if ( ! empty( $_GET['err'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Shows this user's own stored error after a redirect; changes nothing. $err = (string) get_transient( 'wp2ss_err_' . get_current_user_id() ); delete_transient( 'wp2ss_err_' . get_current_user_id() ); } diff --git a/includes/class-scanner.php b/includes/class-scanner.php index e7499f6..7cb70e4 100644 --- a/includes/class-scanner.php +++ b/includes/class-scanner.php @@ -126,6 +126,7 @@ public static function start( $args = array(), $source = 'manual', $force = fals 'frames' => array(), 'followed' => array(), 'themes' => array(), + 'staging' => array(), 'core' => null, 'plug' => null, 'logs' => null, @@ -403,18 +404,32 @@ public static function is_installatron_worker( $code, $keys ) { * @return array|null [ severity, description, advice ] for the most severe match, or null. */ public static function match_code( $code ) { + $hit = self::match_code_raw( $code, $code ); + // Comments never run: a match only inside one ("fields that require $_POST data") doesn't count, + // except for signatures that look for markers attackers put in comments. Checked only when + // something matched, so ordinary files cost nothing extra. + return $hit ? self::match_code_raw( self::strip_comments( $code ), $code ) : null; + } + + /** + * match_code() on the given source; signatures flagged 'comments' are matched against $original. + * + * @param string $code Source to check. + * @param string $original The untouched source. + */ + private static function match_code_raw( $code, $original ) { $sig = self::signatures(); $advice = 'Open the file to confirm. If it’s malicious: keep a copy as evidence, remove it, then change passwords, keys and salts.'; $best = null; foreach ( $sig['code'] as $s ) { - if ( ( ! $best || self::sev_rank( $s[0] ) > self::sev_rank( $best[0] ) ) && preg_match( $s[1], $code ) ) { + if ( ( ! $best || self::sev_rank( $s[0] ) > self::sev_rank( $best[0] ) ) && preg_match( $s[1], isset( $s[3] ) && 'comments' === $s[3] ? $original : $code ) ) { $best = array( $s[0], $s[2], $advice ); } } if ( ( ! $best || 'critical' !== $best[0] ) && false !== stripos( $code, 'register_rest_route' ) && false !== stripos( $code, 'permission_callback' ) - && preg_match( '/(?:$])(passthru|shell_exec|system|exec|proc_open|popen)\s*\([^;]{0,160}(get_param|base64_decode|\$_(POST|GET|REQUEST)|\$request\s*\[)/i', $code ) ) { + && preg_match( '/(?:$])(?guarded( array( $this, 'walk_files' ), 'file scan', true ) ) { $this->flush_theme_changes(); + $this->flush_staging(); $this->next_phase(); } break; @@ -952,6 +968,7 @@ private function truthy( $v ) { /** 2. WordPress version against the wp2shell ranges. */ private function check_version() { + $this->check_wp_security(); $v = get_bloginfo( 'version' ); foreach ( $this->sig['vulnerable'] as $r ) { if ( version_compare( $v, $r[0], '>=' ) && version_compare( $v, $r[1], '<' ) ) { @@ -962,6 +979,16 @@ private function check_version() { $this->add( 'info', 'version', "WordPress $v is not affected by wp2shell.", 'If the site ran WordPress 6.8–7.0.1 before updating, check the other findings for signs of a break-in.', '', 'wp-status' ); } + /** 2b. WordPress's October 28, 2026 security release (7.1.4 and backports). */ + private function check_wp_security() { + $v = get_bloginfo( 'version' ); + $branch = implode( '.', array_slice( explode( '.', preg_replace( '/-.*$/', '', $v ) ), 0, 2 ) ); + $fixed = isset( $this->sig['wp_security'][ $branch ] ) ? $this->sig['wp_security'][ $branch ] : ''; + if ( '' !== $fixed && version_compare( $v, $fixed, '<' ) ) { + $this->add( 'medium', 'version', "WordPress $v is missing the October 28, 2026 security release ($fixed).", "Update WordPress to $fixed or later. It fixes 22 security issues, including a race condition during core updates that could allow remote code execution. Sites with automatic minor updates get it by themselves.", '', 'wp-security' ); + } + } + /** 3. Installed plugins. */ private function check_plugins() { if ( ! function_exists( 'get_plugins' ) ) { @@ -981,6 +1008,9 @@ private function check_plugins() { } $name = isset( $data['Name'] ) ? trim( $data['Name'] ) : ''; + if ( preg_match( $this->sig['wp2shell_plugin_slugs'], $slug ) ) { + $this->add( 'critical', 'plugin', "Plugin folder named like a wp2shell backdoor: $slug", 'Fake plugins with these names (advanced-cache-handler-, database-repair-assistant-, wp2shell- plus random letters) were left behind after wp2shell break-ins. Keep a copy as evidence, then delete it and check for new admins.', "$dir/$slug" ); + } if ( preg_match( '/^wp2shell_[0-9a-f]{4,}$/i', $slug ) || preg_match( '/(^|\/)wp2shell_[0-9a-f]{4,}\.php$/i', $file ) ) { $this->add( 'critical', 'plugin', "Plugin matches the wp2shell exploit's naming: $file", 'This is the webshell the public exploit uploads. Keep a copy as evidence, then delete it.', "$dir/$file" ); } @@ -1018,7 +1048,9 @@ private function check_plugins() { if ( '.' === $entry[0] || isset( $listed[ $entry ] ) || ! is_dir( "$dir/$entry" ) || wp_normalize_path( "$dir/$entry" ) === $self ) { continue; } - if ( glob( "$dir/$entry/*.php" ) ) { + if ( preg_match( $this->sig['wp2shell_plugin_slugs'], $entry ) ) { + $this->add( 'critical', 'plugin', "Plugin folder named like a wp2shell backdoor: $entry", 'Fake plugins with these names were left behind after wp2shell break-ins. Keep a copy as evidence, then delete it and check for new admins.', "$dir/$entry" ); + } elseif ( glob( "$dir/$entry/*.php" ) ) { $this->add( 'medium', 'plugin', "Folder in /plugins with PHP but no plugin header: $entry", 'It doesn’t appear on the Plugins screen. Inspect it.', "$dir/$entry" ); } } @@ -1035,13 +1067,15 @@ private function check_mu_plugins() { if ( ! $m || $m < $this->sig['disclosure_ts'] ) { continue; } - $b = basename( $f ); - $host = (bool) preg_match( $this->sig['host_mu_plugins'], $b ); + $b = basename( $f ); + $host = (bool) preg_match( $this->sig['host_mu_plugins'], $b ); + $code = (string) @file_get_contents( $f, false, null, 0, 65536 ); // phpcs:ignore + $owner = $host || ! self::is_loader( $code ) ? '' : self::code_owner( $code ); // Only files that do nothing but load an installed plugin. $this->add( - $host ? 'low' : 'medium', + $host || '' !== $owner ? 'low' : 'medium', 'mu-plugin', - 'Must-use plugin added or changed ' . gmdate( 'Y-m-d', $m ) . ': ' . $b . ( $host ? ' (looks like your host’s)' : '' ), - $host ? 'Hosts update their own must-use plugins regularly. Ignore this if the name matches your host.' : 'Must-use plugins run on every page and don’t appear in the normal plugin list. Confirm it’s yours or your host’s.', + 'Must-use plugin added or changed ' . gmdate( 'Y-m-d', $m ) . ': ' . $b . ( $host ? ' (looks like your host’s or a well-known tool’s)' : ( '' !== $owner ? " (loads $owner)" : '' ) ), + $host ? 'Hosts and some management and migration tools update their own must-use plugins regularly. Ignore this if you use them.' : ( '' !== $owner ? "It loads files from $owner, which you have installed — some plugins add a small must-use file like this. Ignore this if you use $owner." : 'Must-use plugins run on every page and don’t appear in the normal plugin list. Confirm it’s yours or your host’s.' ), wp_normalize_path( $f ) ); } @@ -1080,6 +1114,9 @@ private function check_admins() { foreach ( $this->admin_ids() as $id ) { $u = get_userdata( $id ); + if ( $u && preg_match( $this->sig['admin_login_iocs'], $u->user_login ) ) { + $this->add( 'high', 'user', sprintf( 'Administrator "%s" (%s) is named like the accounts wp2shell attackers create (w2s_, wpsvc_).', $u->user_login, $u->user_email ), 'If you didn’t create it, remove it, reset every admin password and look for the other findings.', 'user-ioc:' . $u->ID ); + } if ( ! $u || $u->user_registered < $since ) { continue; } @@ -1197,6 +1234,11 @@ private function check_dropins() { if ( preg_match( $this->sig['dropin_known'], $code ) ) { continue; // Cache and database plugins rewrite their drop-ins all the time. } + $owner = self::match_code( $code ) || self::is_risky_php( $code ) ? '' : self::code_owner( $code ); + if ( '' !== $owner ) { + $this->add( 'low', 'file', sprintf( 'Drop-in %s was added or changed on %s; it loads %s’s files.', $name, gmdate( 'Y-m-d', $m ), $owner ), "Probably written by $owner (cache and database plugins rewrite their drop-ins). Ignore this if you use it.", $f, '', sha1( $code ) ); + continue; + } $this->add( 'medium', 'file', @@ -1269,6 +1311,15 @@ private function match_stored( $text ) { */ private function check_options() { global $wpdb; + if ( is_multisite() ) { + // On a network, site transients live in the network table. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- One indexed lookup by meta key prefix; scans must see the live table. + foreach ( (array) $wpdb->get_col( $wpdb->prepare( 'SELECT meta_key FROM %i WHERE meta_key LIKE %s LIMIT 20', $wpdb->sitemeta, $wpdb->esc_like( '_site_transient_health_' ) . '%' ) ) as $name ) { + if ( preg_match( $this->sig['option_iocs'], (string) $name ) ) { + $this->add( 'high', 'database', sprintf( 'Network setting "%s" is named like the one a wp2shell backdoor stores (_site_transient_health_ plus random letters).', self::clean_text( $name ) ), 'WordPress’s own Site Health doesn’t use this name. Keep a copy of the value as evidence, then delete it and look for the file that reads it.', 'sitemeta:' . self::clean_text( $name ) ); + } + } + } foreach ( $this->site_tables() as $t ) { $table = $t[0] . 'options'; list( $match, $values ) = $this->like_any( 'option_value', $this->sig['db_needles'] ); @@ -1277,6 +1328,12 @@ private function check_options() { // $match is only "option_value LIKE %s OR …" placeholders, built by like_any(); the table and $values fill them all. // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM %i WHERE $match AND option_name <> 'cron' AND option_name NOT LIKE %s AND option_name NOT LIKE %s AND option_name NOT LIKE %s LIMIT 200", array_merge( array( $table ), $values ) ), ARRAY_N ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- One indexed lookup by option name prefix; scans must see the live table. + foreach ( (array) $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s LIMIT 20', $table, $wpdb->esc_like( '_site_transient_health_' ) . '%' ) ) as $name ) { + if ( preg_match( $this->sig['option_iocs'], (string) $name ) ) { + $this->add( 'high', 'database', sprintf( 'Setting "%s"%s is named like the one a wp2shell backdoor stores (_site_transient_health_ plus random letters).', self::clean_text( $name ), $t[1] ), 'WordPress’s own Site Health doesn’t use this name. Keep a copy of the value as evidence, then delete it and look for the file that reads it.', "option:{$t[0]}" . self::clean_text( $name ) ); + } + } foreach ( (array) $rows as $row ) { $value = substr( (string) $row[1], 0, self::MAX_READ ); $hit = $this->match_stored( $value ); @@ -1402,11 +1459,17 @@ private function check_root_files() { $cfg = array( "$abs/.user.ini", "$abs/.htaccess", "$abs/php.ini", "$content/.user.ini", "$content/.htaccess", "$content/uploads/.user.ini", "$content/uploads/.htaccess" ); foreach ( array_unique( $cfg ) as $f ) { $txt = @is_file( $f ) ? @file_get_contents( $f, false, null, 0, 262144 ) : false; // phpcs:ignore + if ( $txt && preg_match( $this->sig['htaccess_iocs'], $txt, $ioc ) ) { + $this->add( 'high', 'file', sprintf( '%s has a block marked "%s", like the ones a wp2shell backdoor adds.', basename( $f ), self::clean_text( $ioc[0] ) ), 'Keep a copy as evidence, remove that block, and look for the file it points to.', wp_normalize_path( $f ) ); + } if ( ! $txt || ! preg_match_all( $this->sig['auto_prepend'], $txt, $m, PREG_SET_ORDER ) ) { continue; } foreach ( $m as $hit ) { - if ( ! preg_match( $this->sig['auto_prepend_allow'], $hit[2] ) ) { + $level = $this->prepend_level( $hit[2] ); + if ( 'low' === $level ) { + $this->add( 'low', 'file', sprintf( '%s makes PHP run "%s" before every page (%s).', basename( $f ), self::clean_text( $hit[2] ), $hit[1] ), 'It points into a system folder only your host can write to, so it’s probably your host’s security or monitoring tool. Ask them if you’re unsure.', wp_normalize_path( $f ) ); + } elseif ( 'high' === $level ) { $this->add( 'high', 'file', sprintf( '%s makes PHP run "%s" before every page (%s).', basename( $f ), self::clean_text( $hit[2] ), $hit[1] ), 'A favourite way to hide a backdoor. Unless a security plugin you use set this up, remove the line and inspect the file it points to.', wp_normalize_path( $f ) ); } } @@ -1701,8 +1764,12 @@ private function check_file( $path, $rel, $name ) { if ( $ht && '.htaccess' === $name && preg_match( $this->sig['htaccess_php'], $ht ) ) { $this->add( 'high', 'file', "Upload-folder .htaccess makes PHP executable: $show", 'Attackers add this so uploaded PHP will run. Remove those lines.', $path ); } - if ( $ht && preg_match( $this->sig['auto_prepend'], $ht, $m ) && ! preg_match( $this->sig['auto_prepend_allow'], $m[2] ) ) { - $this->add( 'high', 'file', sprintf( '%s makes PHP run "%s" before every page: %s', $name, self::clean_text( $m[2] ), $show ), 'A favourite way to hide a backdoor. Remove the line and inspect the file it points to.', $path ); + if ( $ht && preg_match( $this->sig['auto_prepend'], $ht, $m ) && '' !== $this->prepend_level( $m[2] ) ) { + if ( 'low' === $this->prepend_level( $m[2] ) ) { + $this->add( 'low', 'file', sprintf( '%s makes PHP run "%s" before every page: %s', $name, self::clean_text( $m[2] ), $show ), 'It points into a system folder only your host can write to, so it’s probably your host’s security or monitoring tool. Ask them if you’re unsure.', $path ); + } else { + $this->add( 'high', 'file', sprintf( '%s makes PHP run "%s" before every page: %s', $name, self::clean_text( $m[2] ), $show ), 'A favourite way to hide a backdoor. Remove the line and inspect the file it points to.', $path ); + } } } @@ -1753,17 +1820,42 @@ private function check_file( $path, $rel, $name ) { if ( ( $data || $in_cr ) && ! ( $whole && self::is_empty_php( $code ) ) && ! preg_match( $this->sig['data_allow'], $sub ) && ! ( $in_cr && in_array( $name, $this->sig['content_root_allow'], true ) ) ) { $guarded = $whole && ( self::is_guarded_php( $code ) || self::is_inert_data( $code ) ); $recent = $mtime >= $this->sig['disclosure_ts']; - $sev = $guarded ? 'low' : ( $recent ? 'high' : 'medium' ); - $where = $in_cr ? 'directly in wp-content' : 'in a data folder'; - $this->add( - $sev, - 'file', - "PHP file $where" . ( $recent ? ' (changed ' . gmdate( 'Y-m-d', $mtime ) . ')' : '' ) . ": $show", - 'cache' === $top ? 'Attackers have written webshells into wp-content/cache/. Open it and confirm what it is.' : 'PHP doesn’t normally live here. Open it and confirm what it is.', - $path, - '', - $sha1 - ); + $owner = $data && count( $parts ) > 3 ? self::folder_owner( $parts[2] ) : ''; + $staging = 'upgrade' === $top && count( $parts ) > 3 ? $this->staging_owner( $parts ) : ''; + if ( '' !== $staging ) { + // An update or install WordPress was unpacking (wp-content/upgrade//…): counted, reported once per folder. + if ( ! isset( $this->s['staging'][ $parts[2] ] ) ) { + $this->s['staging'][ $parts[2] ] = array( 'n' => 0, 'owner' => $staging ); + } + $this->s['staging'][ $parts[2] ]['n']++; + } elseif ( '' !== $owner ) { + // A folder named after an installed plugin or theme (wp-content/uploads/wpallimport/…): its own files. + // Code signatures below still apply to it; only plain, request-handling code stays worth a look. + $risky = ! $guarded && ( $recent || self::is_risky_php( $code ) ); + $this->add( + $risky ? 'medium' : 'low', + 'file', + sprintf( 'PHP file in %s’s folder in %s: %s', $owner, $top, $show ), + $risky + ? sprintf( 'Probably made by %s, which keeps files here. But it changed recently or handles web requests, and attackers can name a folder after a plugin too: open it and confirm it’s %s’s.', $owner, $owner ) + : sprintf( 'Probably made by %s, which keeps files here. If you no longer use it, you can delete the folder.', $owner ), + $path, + '', + $sha1 + ); + } else { + $sev = $guarded ? 'low' : ( $recent ? 'high' : 'medium' ); + $where = $in_cr ? 'directly in wp-content' : 'in a data folder'; + $this->add( + $sev, + 'file', + "PHP file $where" . ( $recent ? ' (changed ' . gmdate( 'Y-m-d', $mtime ) . ')' : '' ) . ": $show", + 'cache' === $top ? 'Attackers have written webshells into wp-content/cache/. Open it and confirm what it is.' : 'PHP doesn’t normally live here. Open it and confirm what it is.', + $path, + '', + $sha1 + ); + } } if ( '.' === $name[0] ) { @@ -1789,6 +1881,181 @@ private function check_file( $path, $rel, $name ) { } } + /** + * Installed plugins and themes, by a normalised key (folder name and text + * domain, letters and digits only): key => name. Used to recognise the data + * folders and loader files they create. + * + * @return array[] [ 'keys' => [ key => name ], 'slugs' => [ folder => name ] ]. + */ + private static function installed() { + static $cache = null; + if ( null !== $cache ) { + return $cache; + } + $cache = array( 'keys' => array(), 'slugs' => array() ); + if ( ! function_exists( 'get_plugins' ) ) { + WP2SS_Paths::admin_include( 'plugin.php' ); + } + $add = function ( $folder, $domain, $name ) use ( &$cache ) { + $name = '' !== trim( (string) $name ) ? trim( (string) $name ) : $folder; + if ( '' !== $folder && '.' !== $folder ) { + $cache['slugs'][ strtolower( $folder ) ] = $name; + } + foreach ( array( $folder, $domain ) as $k ) { + $k = preg_replace( '/[^a-z0-9]/', '', strtolower( (string) $k ) ); + if ( strlen( $k ) >= 3 ) { + $cache['keys'][ $k ] = $name; + } + } + }; + $self = basename( WP2SS_DIR ); + foreach ( function_exists( 'get_plugins' ) ? get_plugins() : array() as $file => $d ) { + if ( dirname( $file ) !== $self ) { + $add( dirname( $file ), isset( $d['TextDomain'] ) ? $d['TextDomain'] : '', isset( $d['Name'] ) ? $d['Name'] : '' ); + } + } + foreach ( function_exists( 'wp_get_themes' ) ? wp_get_themes() : array() as $slug => $t ) { + $add( (string) $slug, (string) $t->get( 'TextDomain' ), (string) $t->get( 'Name' ) ); + } + return $cache; + } + + /** + * The installed plugin or theme a data folder is named after ("wpallimport" + * for WP All Import, "elementor" for Elementor Pro…), or ''. + */ + private static function folder_owner( $folder ) { + $k = preg_replace( '/[^a-z0-9]/', '', strtolower( (string) $folder ) ); + // Generic names never identify a plugin, however a plugin's name begins. + $generic = array( 'cache', 'caches', 'files', 'file', 'backup', 'backups', 'temp', 'tmp', 'images', 'image', 'assets', 'media', 'uploads', 'upload', 'data', 'logs', 'log', 'fonts', 'plugins', 'plugin', 'themes', 'theme', 'scripts', 'styles', 'wordpress', 'upgrade', 'export', 'exports', 'import', 'imports', 'private', 'public', 'custom' ); + if ( strlen( $k ) < 3 || preg_match( '/^\d+$/', $k ) || in_array( $k, $generic, true ) ) { + return ''; + } + $inst = self::installed(); + if ( isset( $inst['keys'][ $k ] ) ) { + return $inst['keys'][ $k ]; + } + // "elementor-pro-data" for an "elementor-pro" plugin: only for long, specific slugs. + $folder = strtolower( (string) $folder ); + foreach ( $inst['slugs'] as $slug => $name ) { + if ( strlen( $slug ) >= 8 && preg_match( '/^' . preg_quote( $slug, '/' ) . '[-_]/', $folder ) ) { + return $name; + } + } + return ''; + } + + /** + * What WordPress was unpacking into wp-content/upgrade//: "WordPress" + * for a core update, or the installed plugin or theme the package is named + * after ("akismet.5.3/akismet/…"). '' when it doesn't look like an update. + */ + private function staging_owner( $parts ) { + if ( ! isset( $parts[4] ) ) { + return ''; + } + // Core: upgrade/wordpress-[-…]/wordpress/… + if ( 'wordpress' === $parts[3] && preg_match( '/^wordpress-\d+\.\d+(\.\d+)*(-[\w-]+)?$/i', $parts[2] ) ) { + return 'WordPress'; + } + // A plugin or theme: upgrade/[.…]//…, for one that's installed. + $slug = strtolower( $parts[3] ); + $slugs = self::installed()['slugs']; + if ( isset( $slugs[ $slug ] ) && preg_match( '/^' . preg_quote( $slug, '/' ) . '([.-]\d[\w.-]*)?$/i', $parts[2] ) ) { + return $slugs[ $slug ]; + } + return ''; + } + + /** The installed plugin whose files this code loads (a must-use loader or a cache drop-in), or ''. */ + private static function code_owner( $code ) { + if ( ! preg_match_all( '#\b(?:require|include)(?:_once)?\b[^;]*?(?:plugins/|WP_PLUGIN_DIR\s*\.\s*[\'"]/)([a-z0-9][a-z0-9_.-]*)/#i', self::strip_comments( $code ), $m ) ) { + return ''; + } + $slugs = self::installed()['slugs']; + foreach ( $m[1] as $slug ) { + if ( isset( $slugs[ strtolower( $slug ) ] ) ) { + return $slugs[ strtolower( $slug ) ]; + } + } + return ''; + } + + /** Code that takes web-request input and runs, writes or decodes something. */ + private static function is_risky_php( $code ) { + if ( ! is_string( $code ) ) { + return false; + } + $code = self::strip_comments( $code ); + // Logging someone in or creating accounts is never a data file's business. + if ( preg_match( '/\b(wp_set_auth_cookie|wp_set_current_user|wp_create_user|wp_insert_user|set_role|add_cap)\s*\(/i', $code ) ) { + return true; + } + $input = preg_match( '/\$_(GET|POST|REQUEST|COOKIE|FILES|SERVER)\b|\bgetallheaders\s*\(|php:\/\/input/i', $code ); + $acts = preg_match( '/\b(eval|assert|system|exec|passthru|shell_exec|proc_open|popen|create_function|call_user_func(_array)?|array_map|usort|move_uploaded_file|file_put_contents|fwrite|copy|base64_decode|gzinflate|str_rot13|strrev|hex2bin)\s*\(|\$\w+\s*\(|\b(include|require)(_once)?\s*\(?\s*\$/i', $code ); + return $input && $acts; + } + + /** A file a security plugin or host keeps in the site root: only while that plugin (or host) is there. */ + private function known_root_file( $name ) { + if ( isset( $this->sig['prepend_firewalls'][ $name ] ) ) { + return isset( self::installed()['slugs'][ $this->sig['prepend_firewalls'][ $name ] ] ); + } + return 'gd-config.php' === $name && '' !== WP2SS_Paths::mu_plugins() && is_file( WP2SS_Paths::mu_plugins() . '/gd-system-plugin.php' ); + } + + /** PHP source without comments (for checks that comments could fool). */ + private static function strip_comments( $code ) { + $code = (string) $code; + if ( ! function_exists( 'token_get_all' ) ) { + return (string) preg_replace( '#/\*.*?\*/|//[^\n]*|\#[^\n]*#s', '', $code ); + } + $out = ''; + foreach ( @token_get_all( $code ) as $t ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors + $out .= is_array( $t ) ? ( in_array( $t[0], array( T_COMMENT, T_DOC_COMMENT ), true ) ? ' ' : $t[1] ) : $t; + } + return $out; + } + + /** + * A must-use file that only loads files: after its comments, nothing but + * require/include lines and "stop unless WordPress loaded me" guards. + */ + private static function is_loader( $code ) { + $c = trim( (string) preg_replace( '/^\s*<\?php|\?>\s*$/', '', self::strip_comments( $code ) ) ); + $stmt = '(?:(?:require|include)(?:_once)?\b[^;]{1,300};|if\s*\(\s*!\s*defined\s*\([^)]*\)\s*\)\s*\{?\s*(?:exit|die|return)\b[^;]*;\s*\}?|defined\s*\([^)]*\)\s*(?:\|\||or)\s*(?:exit|die)\b[^;]*;)'; + return '' !== $c && strlen( $c ) < 2000 && (bool) preg_match( '/^(?:\s*' . $stmt . ')+\s*$/i', $c ); + } + + /** + * How serious an auto_prepend_file / auto_append_file target is: '' for a + * known firewall, 'low' for a system folder only the host can write to, + * 'high' for anything else (the site's own folders, temp folders). + */ + private function prepend_level( $target ) { + $t = wp_normalize_path( trim( (string) $target ) ); + $in_site = '' !== $t && ( 0 === strpos( $t, dirname( WP2SS_Paths::core() ) . '/' ) || 0 === strpos( $t, WP2SS_Paths::content() . '/' ) ); + if ( '' === $t || '/' !== $t[0] || false !== strpos( $t, '..' ) || preg_match( '#/sess(ions?)?(/|_)#i', $t ) || ( ! $in_site && preg_match( '#^/(tmp|var/tmp|dev/shm|run)/#', $t ) ) ) { + return 'high'; // Relative, climbing out, a session file or a temp folder. + } + $name = basename( $t ); + $fw = isset( $this->sig['prepend_firewalls'][ $name ] ) ? $this->sig['prepend_firewalls'][ $name ] : ''; + if ( '' !== $fw ) { + $slugs = self::installed()['slugs']; + $at_root = in_array( dirname( $t ), array( WP2SS_Paths::core(), dirname( WP2SS_Paths::core() ) ), true ); + if ( $at_root ) { + return isset( $slugs[ $fw ] ) ? '' : 'low'; // That security plugin's firewall, where it puts it (low if the plugin is gone). + } + } + // Inside an installed plugin (NinjaFirewall and others load their firewall from their own folder). + $pl = WP2SS_Paths::plugins() . '/'; + if ( 0 === strpos( $t, $pl ) && isset( self::installed()['slugs'][ strtolower( (string) strtok( substr( $t, strlen( $pl ) ), '/' ) ) ] ) ) { + return 'low'; + } + return preg_match( $this->sig['auto_prepend_system'], $t ) ? 'low' : 'high'; + } + private function read_edges( $path, $size ) { $fh = @fopen( $path, 'rb' ); // phpcs:ignore if ( ! $fh ) { @@ -1801,6 +2068,21 @@ private function read_edges( $path, $size ) { return $head . "\n" . $tail; } + /** One finding per update left half-unpacked in wp-content/upgrade/, instead of one per file. */ + private function flush_staging() { + foreach ( isset( $this->s['staging'] ) ? $this->s['staging'] : array() as $folder => $st ) { + $this->add( + 'medium', + 'file', + sprintf( '%d PHP file(s) left over from an unfinished %s update or install: wp-content/upgrade/%s', $st['n'], $st['owner'], self::clean_text( $folder ) ), + 'WordPress unpacks updates here and deletes them when it’s done; an interrupted update leaves them behind. If no update is running right now, delete this folder. (Any of its files that look like a webshell are reported separately.)', + WP2SS_Paths::content() . '/upgrade/' . $folder, + 'staging:' . md5( $folder ) + ); + } + $this->s['staging'] = array(); + } + private function flush_theme_changes() { foreach ( $this->s['themes'] as $theme => $t ) { $this->add( 'low', 'file', sprintf( 'Theme "%s": %d PHP file(s) changed since July 17, 2026.', self::clean_text( $theme ), $t['n'] ), 'Normal after a theme update or your own edits. Otherwise inspect: ' . implode( ', ', $t['files'] ) . ( $t['n'] > 5 ? ' …' : '' ), WP2SS_Paths::theme( $theme ), 'theme-recent' ); @@ -1892,7 +2174,7 @@ private function check_core() { $entries = @scandir( $abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors foreach ( $entries ? $entries : array() as $b ) { - if ( '.' !== $b[0] && is_file( $abs . $b ) && preg_match( $this->sig['php_ext'], $b ) && ! isset( $sums[ $b ] ) && ! in_array( $b, $this->sig['root_allow'], true ) ) { + if ( '.' !== $b[0] && is_file( $abs . $b ) && preg_match( $this->sig['php_ext'], $b ) && ! isset( $sums[ $b ] ) && ! in_array( $b, $this->sig['root_allow'], true ) && ! $this->known_root_file( $b ) ) { $head = (string) @file_get_contents( $abs . $b, false, null, 0, self::MAX_READ ); // phpcs:ignore if ( self::is_inert_data( $head ) || self::is_installatron_worker( $head, $this->sig['installatron_keys'] ) ) { continue; // Reported (or explained) by the site-root check. diff --git a/includes/signatures.php b/includes/signatures.php index af5e419..3066c32 100644 --- a/includes/signatures.php +++ b/includes/signatures.php @@ -72,12 +72,14 @@ array( 'segfault', 'high', '/exited on signal (11|6)\b|\bSIGSEGV\b|\bSIGABRT\b|segfault at [0-9a-f]+ .*php|zend_mm_heap corrupted|exit signal (Segmentation fault|Abort(ed)?) \((11|6)\)/i', 'PHP worker crashes (segmentation faults, aborts, heap corruption)' ), ), + // Probe and exploit markers from honeypot captures of October 22–24 (0x4D31): PHP2SHELL_CORE_/CHAIN_OK/CHECK, + // the p2s_v2 "alive" probe, shellflow_canary, recon0xdeadbeef, fixed session IDs and boundaries; p2s-rider and /pwn- (Defused, Prophet). // Exploit tools and payloads reported for PHP2Shell, matched in any log line: scanner user agents // (ELLIO, Hunt.io), test canaries and payload markers (Wordfence, 2026-10-22), second-stage downloads // and the Potassium (Mirai) botnet's hosts (Arctic Wolf, Hunt.io, 2026-10-22), the session file the // published session-poisoning chain includes, and a PHPSESSID cookie carrying percent-encoded bytes // (leaked memory, Wiz) where cookies are logged. - 'php2shell_log' => '/php2shell-(scan|verify|109618)|JACKALPOC|\bP2S_OK\b|\bSHL_OK\b|\bNxSP\d+|ADV618BOUNDARY|p2s0adf525faa|sess_p2sopchain|92\.118\.112\.100|45\.115\.26\.26|\/tmp\/nfs\b|\/nfs-setup\b|\/mountpoint\b|\/cCmono\b|botlesscucks\.st|godofthunder\.st|gettingheadfromduckysmom\.st|maste\.mangsud\.org|PHPSESSID=[^;"\s]*%[0-9A-Fa-f]{2}[^;"\s]*%[0-9A-Fa-f]{2}/i', + 'php2shell_log' => '/php2shell-(scan|verify|109618)|JACKALPOC|\bP2S_OK\b|\bSHL_OK\b|\bNxSP\d+|ADV618BOUNDARY|p2s0adf525faa|sess_p2sopchain|92\.118\.112\.100|45\.115\.26\.26|\/tmp\/nfs\b|\/nfs-setup\b|\/mountpoint\b|\/cCmono\b|botlesscucks\.st|godofthunder\.st|gettingheadfromduckysmom\.st|maste\.mangsud\.org|PHPSESSID=[^;"\s]*%[0-9A-Fa-f]{2}[^;"\s]*%[0-9A-Fa-f]{2}|p2s-rider|PHP2SHELL_(CORE_|CHAIN_OK|CHECK)|p2s_v2_(?:alive)|shellflow_canary|recon0xdeadbeef|attacksessionid0001|p2sca35689de5a64ce4|Php2Shell22fe580e9|\/pwn-\d{1,3}(\.\d{1,3}){3}\b/i', // Addresses seen attempting PHP2Shell. Many are anonymisation services or rented servers: investigate, don't just block. 'php2shell_ips' => array( @@ -85,6 +87,10 @@ '103.216.220.92', '31.223.184.166', '37.19.200.5', '134.209.159.64', '103.114.163.188', '95.217.98.37', // Hunt.io (2026-10-21/22): scanners, the mass-exploitation server, operators with custom tooling, public-PoC reuse. '45.198.224.169', '85.11.167.243', '206.188.197.105', '102.210.149.166', '122.10.110.72', '122.10.17.49', '58.221.60.160', '41.223.30.7', '157.230.117.70', '104.219.251.31', '107.150.31.151', '217.160.132.82', + // Honeypot captures, October 22–24 (0x4D31): crash/OOM sources, the core/serializer wave and a canary. A hunting list, not proof. + '178.249.214.6', '89.167.27.27', '103.151.173.212', '45.14.71.20', '91.107.150.150', '192.42.116.21', '172.86.85.171', '138.199.22.106', '106.192.102.115', '190.47.59.9', + '27.100.38.10', '46.144.20.186', '152.59.222.169', '185.177.72.45', '84.32.50.51', '144.31.90.233', '46.166.168.18', '31.185.105.123', '185.252.177.175', '193.178.226.197', + '130.78.219.18', '144.31.90.126', '88.216.36.31', '146.255.189.4', '80.47.18.86', '46.250.228.73', // Arctic Wolf (2026-10-22): Potassium botnet hosts. '45.115.26.26', '5.175.189.76', // Wordfence (2026-10-22): the 20 busiest sources of blocked PHP2Shell attempts. @@ -101,14 +107,16 @@ 'php2shell_tmp_names' => '/^(nfs(\..*)?|nfs-setup|mountpoint|ccmono)$/i', // Session files: the session-poisoning chain's fixed IDs and markers (Arctic Wolf, Wordfence, SOCRadar). - 'php2shell_sess_names' => '/^sess_(p2sopchain|fprint)/i', - 'php2shell_sess_data' => '/\bNxSP\d+|\bP2S_OK\b|\bSHL_OK\b|MARKER_[0-9a-f]{8}\.txt/i', + 'php2shell_sess_names' => '/^sess_(p2sopchain|fprint|p2sca35689de5a64ce4|attacksessionid0001)/i', + 'php2shell_sess_data' => '/\bNxSP\d+|\bP2S_OK\b|\bSHL_OK\b|MARKER_[0-9a-f]{8}\.txt|PHP2SHELL_CORE_/i', // PHP file names reported as PHP2Shell drops: [ severity, regex on the file name, description ]. 'php2shell_names' => array( array( 'high', '/^(wp2s|php2shell|php-to-shell[\w-]*|autologin_fd)\.php$/i', 'Named like a webshell PHP2Shell attackers drop (Wordfence, Hunt.io)' ), array( 'high', '/^axiom_\w*\.php$/i', 'Named like a webshell PHP2Shell attackers drop (axiom_*.php, Wordfence)' ), array( 'medium', '/^tebaran_\w*\.php$/i', 'Named like the mass-deployment scripts on a PHP2Shell attacker server (Hunt.io)' ), + array( 'high', '/^shell_[0-9a-f]{8}\.(php\d?|phtml|phar)$/i', 'Named like the webshells PHP2Shell exploit attempts tried to write (shell_<8 hex>, honeypot captures)' ), + array( 'high', '/^class-wp-cache-(?=[0-9a-f]*\d)[0-9a-f]{6,}\.php$/i', 'Named like the hidden copy a wp2shell backdoor restores itself from (class-wp-cache-.php, Dosdediez incident report)' ), ), // Thresholds (count since php2shell_ts) at which an error-log pattern escalates to high. @@ -147,6 +155,13 @@ array( '6.8', '6.8.6', 'high', 'the wp2shell SQL injection (CVE-2026-60137)', '6.8.6' ), ), + // WordPress's October 28, 2026 security release (7.1.4 and backports): 22 fixes, including a race condition + // during core updates that could lead to remote code execution. Branch => first fixed version. + 'wp_security' => array( + '7.1' => '7.1.4', '7.0' => '7.0.8', '6.9' => '6.9.11', '6.8' => '6.8.12', '6.7' => '6.7.11', + '6.6' => '6.6.11', '6.5' => '6.5.14', '6.4' => '6.4.14', '6.3' => '6.3.14', '6.2' => '6.2.15', + ), + // Published webshell SHA1 hashes. 'sha1' => array( '2a1410d8e2a8337ac2171cedea8c0fdc47c647a0' => 'wp2shell webshell (Wiz)', @@ -166,6 +181,13 @@ '172.235.128.52', ), + // Persistence left after wp2shell break-ins (Dosdediez incident report, June–August 2026): fake plugin folders, + // admin accounts, an option, and marked .htaccess blocks. + 'wp2shell_plugin_slugs' => '/^(advanced-cache-handler|database-repair-assistant|wp2shell)-[0-9a-f]{4,}$/i', + 'admin_login_iocs' => '/^(w2s|wpsvc)_/i', + 'option_iocs' => '/^_site_transient_health_[0-9a-f]{6,}$/i', + 'htaccess_iocs' => '/\bwp_compat_[0-9a-f]{6,}\b/i', + // wp2shell exploit-tool user agents. 'user_agents' => '/\b(rez)?wp2shell\b|cve-2026-63030/i', @@ -176,13 +198,16 @@ /* Webshells in general (what either attack leaves behind) */ /* ------------------------------------------------------------------ */ - // Code signatures: [ severity, regex, description ]. The most severe match per file is reported. + // Code signatures: [ severity, regex, description, optional 'comments' ]. The most severe match per file is reported. + // Matches only inside comments don't count (comments never run), unless the signature is flagged 'comments'. 'code' => array( array( 'critical', '/\b(botlesscucks|godofthunder|gettingheadfromduckysmom)\.st\b|maste\.mangsud\.org|92\.118\.112\.100\/(nfs|mountpoint)|45\.115\.26\.26:8000/i', 'Contacts infrastructure of the botnet behind PHP2Shell attacks (Potassium / Mirai)' ), array( 'high', '/\becho\s*\(?\s*[\'"](P2S_OK|SHL_OK)[\'"]/', 'Prints the success marker PHP2Shell exploit kits check for' ), + array( 'high', '/==SHELL(?:USER)==|==PASS(?:WD)==|PHP2SHELL_(?:CHAIN)_OK|p2s_v2_(?:alive)/', 'Prints the markers PHP2Shell exploit kits look for in the response' ), + array( 'high', '/\/\*\s*WP_Core_Integ(?:rity)\s+[0-9a-f]{4,}\s*\*\//', 'Code wrapped in the WP_Core_Integrity markers a wp2shell backdoor adds to wp-config.php (Dosdediez incident report)', 'comments' ), array( 'critical', '/eval\s*\(\s*\$_(POST|GET|REQUEST|COOKIE|SERVER)\s*\[[^\]]+\]\s*\?\?\s*[\'"]http_response_code\(\s*404\s*\)/i', 'Webshell one-liner that hides behind a 404 (seen with wp2shell)' ), array( 'critical', '/\b(eval|assert)\s*\(\s*(stripslashes\s*\(\s*)?\$_(POST|GET|REQUEST|COOKIE)\b/i', 'Executes code straight from a web request' ), - array( 'critical', '/(?:$])(system|passthru|shell_exec|exec|popen|proc_open|pcntl_exec)\s*\(\s*(base64_decode\s*\(\s*)?\$(_GET|_POST|_REQUEST|_COOKIE|_SERVER|request|req|r)\b/i', 'Runs shell commands taken from a web request' ), + array( 'critical', '/(?:$])(? '/^\s*(?:php_value\s+)?(auto_prepend_file|auto_append_file)\s*[= ]\s*["\']?([^"\'\s]+)/im', - 'auto_prepend_allow' => '#(wordfence-waf\.php|/wp-content/plugins/[^/]+/|/wflogs/|ninjafirewall|malcare|bitninja|imunify|/usr/share/)#i', + // Security plugins whose firewall loads a file from the site root before every page: file => plugin folder. + // Trusted only at the site root and only while that plugin is installed. + 'prepend_firewalls' => array( + 'wordfence-waf.php' => 'wordfence', + 'aios-bootstrap.php' => 'all-in-one-wp-security-and-firewall', + 'malcare-waf.php' => 'malcare-security', + ), + + // auto_prepend_file pointing into a vendor's folder under system directories a site can't write to: set by the host + // (BitNinja, Imunify360, monitoring agents). Session and temp folders never count, even under these. + 'auto_prepend_system' => '#^/(usr/local|opt|usr/share|var/imunify360)/[\w.-]+/#', - // Must-use plugins that hosts install and update themselves. - 'host_mu_plugins' => '/^(mu-plugin|wpengine-common|wpe-|slt-force-strong|force-strong-passwords|kinsta-mu-plugins|gd-system-plugin|sso|endurance-|bluehost|wpcomsh|wpcom|pantheon|object-cache|redis-cache|batcache|wp-cache-memcached|siteground|sg-|hostinger|flywheel|wpaas|pressable|nexcess|cloudways|breeze|godaddy|onecom|dreamhost|ionos|liquidweb)[\w.-]*\.php$/i', + // Must-use plugins that hosts (and a few well-known plugins: ManageWP, Health Check, WP Migrate, WP Staging, WP Cerber) install and update themselves. + 'host_mu_plugins' => '/^(mu-plugin|wpengine-common|wpe-|slt-force-strong|force-strong-passwords|kinsta-mu-plugins|gd-system-plugin|sso|endurance-|bluehost|wpcomsh|wpcom|pantheon|object-cache|redis-cache|batcache|wp-cache-memcached|siteground|sg-|hostinger|flywheel|wpaas|pressable|nexcess|cloudways|breeze|godaddy|onecom|dreamhost|ionos|liquidweb|spinupwp|0-worker|health-check-troubleshooting-mode|wp-migrate-db(-pro)?-compatibility|wp-staging-optimizer|wpstg-optimizer|aaa-wp-cerber)[\w.-]*\.php$/i', // Non-core PHP commonly found in the site root. - 'root_allow' => array( 'wordfence-waf.php', 'wp-config.php', 'wp-cli.php', 'local-config.php', 'wp-config-local.php' ), + 'root_allow' => array( 'wp-config.php', 'wp-cli.php', 'local-config.php', 'wp-config-local.php' ), /* ------------------------------------------------------------------ */ /* The database (where backdoors hide once files are cleaned) */ @@ -252,7 +290,7 @@ 'cron_random_hook' => '/^(?=[a-z0-9]*\d)(?=[a-z0-9]*[a-z])[a-z0-9]{16,}$/i', // Drop-ins (advanced-cache.php, object-cache.php, db.php…) written by well-known plugins and hosts. - 'dropin_known' => '/\b(WP Rocket|W3 Total Cache|W3TC|LiteSpeed|WP Super Cache|WP-Cache|Redis Object Cache|Object Cache Pro|Memcached|Docket Cache|Query Monitor|HyperDB|LudicrousDB|SQLite (?:Database )?Integration|WP Fastest Cache|Breeze|SiteGround|Cache Enabler|Comet Cache|Swift Performance|WP-Optimize|Powered Cache|Hummingbird|WP Engine|Kinsta|Pantheon|Pressable|Batcache|Atomic)\b/i', + 'dropin_known' => '/\b(WP Rocket|W3 Total Cache|W3TC|LiteSpeed|WP Super Cache|WP-Cache|Redis Object Cache|Object Cache Pro|Memcached|Docket Cache|Query Monitor|HyperDB|LudicrousDB|SQLite (?:Database )?Integration|WP Fastest Cache|Breeze|SiteGround|Cache Enabler|Comet Cache|Swift Performance|WP-Optimize|Powered Cache|Hummingbird|WP Engine|Kinsta|Pantheon|Pressable|Batcache|Atomic|Jetpack Boost|Super Page Cache|Endurance|APCu|Hyper Cache|Simple Cache|FlyingPress|Cachify|NitroPack)\b/i', // Capabilities that only administrators should have. 'admin_caps' => array( 'install_plugins', 'edit_plugins', 'activate_plugins', 'edit_users', 'promote_users', 'create_users', 'unfiltered_upload', 'edit_files', 'update_core' ), diff --git a/jat-shell-scanner.php b/jat-shell-scanner.php index 0fa89d3..4a33900 100644 --- a/jat-shell-scanner.php +++ b/jat-shell-scanner.php @@ -3,7 +3,7 @@ * Plugin Name: JAT Shell Scanner * Plugin URI: https://jeremyanderson.tech/project/jat-shell-scanner/ * Description: Checks whether your site is exposed to — or was broken into through — PHP2Shell (CVE-2026-109618 / CVE-2026-109619, critical bugs in PHP itself) and wp2shell (CVE-2026-63030 / CVE-2026-60137, a WordPress core bug). Looks at your PHP and WordPress versions, webshells, rogue admins, changed files and server logs. Scans only read; one-click fixes (quarantine, removing admin rights) are optional and can be undone. Includes a self-hosted fleet dashboard for many sites, with alerts. - * Version: 1.6.0 + * Version: 1.7.0 * Requires at least: 6.2 * Requires PHP: 7.0 * Author: Jeremy Anderson Tech @@ -26,7 +26,7 @@ return; // Another copy is already loaded (e.g. installed twice under different folder names). } -define( 'WP2SS_VERSION', '1.6.0' ); +define( 'WP2SS_VERSION', '1.7.0' ); define( 'WP2SS_FILE', __FILE__ ); define( 'WP2SS_DIR', plugin_dir_path( __FILE__ ) ); define( 'WP2SS_URL', plugin_dir_url( __FILE__ ) ); diff --git a/languages/jat-shell-scanner.pot b/languages/jat-shell-scanner.pot index 5ee9cb5..8644449 100644 --- a/languages/jat-shell-scanner.pot +++ b/languages/jat-shell-scanner.pot @@ -2,14 +2,14 @@ # This file is distributed under the MIT. msgid "" msgstr "" -"Project-Id-Version: JAT Shell Scanner 1.6.0\n" +"Project-Id-Version: JAT Shell Scanner 1.7.0\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/jat-shell-scanner\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-10-28T18:14:47+00:00\n" +"POT-Creation-Date: 2026-11-05T04:33:32+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: jat-shell-scanner\n" diff --git a/readme.txt b/readme.txt index 628c46e..2c9d5bd 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: security, malware, scanner, webshell, vulnerability Requires at least: 6.2 Tested up to: 7.1 Requires PHP: 7.0 -Stable tag: 1.6.0 +Stable tag: 1.7.0 License: MIT License URI: https://opensource.org/licenses/MIT @@ -106,6 +106,13 @@ Use the [support forum](https://wordpress.org/support/plugin/jat-shell-scanner/) == Changelog == += 1.7.0 = +* Far fewer false alarms from newly installed plugins and hosts. These are now recognised instead of reported as a break-in: PHP a plugin keeps in its own uploads or cache folder, updates left half-unpacked in wp-content/upgrade, must-use files and drop-ins that only load an installed plugin, security firewalls (Wordfence, All In One WP Security, MalCare) at the site root, and host security tools that load before every page. Anything still worth a look is "needs review", not "suspicious". +* Ordinary plugin code no longer matches webshell signatures: a method named exec() or system(), or saving custom CSS from a form. +* New PHP2Shell indicators from honeypot captures (October 22–24): probe markers, webshell names (shell_<8 letters>.php), session IDs and attacker addresses. +* New wp2shell persistence indicators from an incident report: fake plugin folders, w2s_/wpsvc_ admin accounts, a hidden option, and marked wp-config.php and .htaccess blocks. +* Reminds sites to install WordPress's October 28, 2026 security release (7.1.4, 7.0.8, 6.9.11…), which fixes a race condition during core updates that could allow remote code execution. + = 1.6.0 = * Renamed to JAT Shell Scanner (by Jeremy Anderson Tech). * Quarantine now lives in the plugin's own folder in uploads (uploads/jat-shell-scanner/) instead of wp-content. Files quarantined by earlier GitHub builds can still be restored or deleted. @@ -135,6 +142,9 @@ The full history is in [CHANGELOG.md](https://github.com/CptNope/JAT-Shell-Scann == Upgrade Notice == += 1.7.0 = +Fewer false alarms from newly installed plugins and host files; new PHP2Shell and wp2shell indicators. + = 1.6.0 = New name (JAT Shell Scanner); quarantine moves into the uploads folder; better support for sites with moved folders. diff --git a/tests/false-positives.php b/tests/false-positives.php index f0fa2fb..d65ca5b 100644 --- a/tests/false-positives.php +++ b/tests/false-positives.php @@ -31,6 +31,17 @@ function apply_filters( $hook, $value ) { return $value; } $serious = WP2SS_Scanner::sev_rank( $hit[0] ) >= 4; $serious ? $fail++ : $medium++; printf( "%s %-8s %s :: %s\n", $serious ? 'FAIL' : 'note', $hit[0], $hit[1], $f->getPathname() ); + if ( $serious ) { + // Show the line(s) that matched, so a false positive can be fixed without the file at hand. + foreach ( preg_split( '/\R/', (string) file_get_contents( $f->getPathname() ) ) as $n => $line ) { + foreach ( $sig['code'] as $s ) { + if ( preg_match( $s[1], $line ) ) { + printf( " line %d: %s\n", $n + 1, substr( trim( $line ), 0, 200 ) ); + break; + } + } + } + } } } printf( "\n%d PHP files checked: %d high/critical false positives, %d medium notes.\n", $files, $fail, $medium ); diff --git a/tests/integration.sh b/tests/integration.sh index ad12cdd..c174879 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -56,7 +56,7 @@ set_php 8.3.36 # A patched PHP for the baseline tests. section "1. Clean site" R=$(scan) has "$R" '.verdict' "scan returns a verdict" -if echo "$R" | jq -e '[.findings[] | select(.type=="version" and .severity!="info")] | length > 0' >/dev/null; then +if echo "$R" | jq -e '[.findings[] | select(.type=="version" and (.severity=="critical" or .severity=="high"))] | length > 0' >/dev/null; then has "$R" '.verdict == "VULNERABLE"' "unpatched clean site is VULNERABLE (not COMPROMISED)" $WP php2shell scan --skip-core --format=summary >/dev/null 2>&1; CODE=$? [ "$CODE" = "1" ] && ok "exit code 1 when vulnerable" || bad "exit code was $CODE, expected 1" @@ -85,7 +85,7 @@ hasnt "$R" '[.findings[] | select((.severity=="critical" or .severity=="high") a hasnt "$R" "$(msg 'upgrade-temp-backup')" "WordPress's update backup folder is not flagged" hasnt "$R" "$(msg 'object-cache.php')" "object-cache.php drop-in is not flagged" hasnt "$R" "$(msg 'htaccess')" "Wordfence's protective uploads .htaccess is not flagged" -hasnt "$R" "$(msg 'before every page')" "Wordfence's auto_prepend_file is not flagged" +hasnt "$R" "$(sev high 'before every page')" "Wordfence's auto_prepend_file at the site root is not an alarm" rm -rf "$C/plugins/performance-lab" "$C/upgrade-temp-backup" "$C/object-cache.php" "$C/uploads/.htaccess" "$SITE/.user.ini" section "3. wp2shell indicators" @@ -731,6 +731,104 @@ $WP post delete "$P_OK" "$P_SPAM" "$P_FRAME" --force >/dev/null $WP eval 'foreach ( array( "wp2ss_t_code", "wp2ss_t_fetch", "a8f3k2m9x7q1w5e6r4" ) as $h ) { wp_clear_scheduled_hook( $h, array( "/dev/null rm -rf "$C/advanced-cache.php" "$C/plugins/wp2ss-t-org" "$C/mu-plugins/wp2ss-t-checksums.php" "$C/wp2ss-t-sums.json" +section "17b. Newly installed plugins and host files" +# A plugin that keeps PHP in its own uploads folder (like WP All Import's uploads/wpallimport/functions.php). +mkdir -p "$C/plugins/wp-all-import" "$C/uploads/wpallimport" "$C/uploads/zzrandom" "$C/plugins/zz-normal" +printf ' "$C/plugins/wp-all-import/plugin.php" +printf ' "$C/uploads/wpallimport/functions.php" +touch -d '2024-01-01' "$C/uploads/wpallimport/functions.php" +printf ' "$C/uploads/wpallimport/fresh.php" +printf ' "$C/uploads/wpallimport/handler.php" +printf " "$C/uploads/wpallimport/shell.php" +printf ' "$C/uploads/zzrandom/x.php" +# Ordinary plugin code that merely looks like webshell code. +printf ' "$C/plugins/zz-normal/zz-normal.php" +# Host and security-plugin firewalls, plugin-made must-use files and drop-ins. +mkdir -p "$C/plugins/all-in-one-wp-security-and-firewall" +printf ' "$C/plugins/all-in-one-wp-security-and-firewall/wp-security.php" +printf 'auto_prepend_file = "%s/aios-bootstrap.php"\n' "$SITE" > "$SITE/.user.ini" +mkdir -p "$C/uploads/zzfw" && printf 'auto_prepend_file = "%s/uploads/aios-bootstrap.php"\n' "$C" > "$C/uploads/.user.ini" +printf 'auto_prepend_file = /usr/local/hostguard/protect.php\nauto_append_file = /var/lib/php/sessions/sess_p0wn\n' > "$C/.user.ini" +printf 'auto_prepend_file = /usr/local/../../home/u/s.php\n' > "$SITE/php.ini" +mkdir -p "$C/mu-plugins" +printf ' "$C/mu-plugins/zz-loader.php" +printf ' "$C/mu-plugins/0-worker.php" +printf ' "$C/mu-plugins/zz-compat.php" +mkdir -p "$C/plugins/user-switching" "$C/uploads/users" "$C/upgrade/wordpress-1" +printf ' "$C/plugins/user-switching/user-switching.php" +SHELL_EVASIVE=' "$C/uploads/users/s.php" +printf '%s\n' "$SHELL_EVASIVE" > "$C/upgrade/wordpress-1/s.php" +printf ' "$C/advanced-cache.php" +R=$(scan) +has "$R" "$(sev low 'PHP file in WP All Import.s folder in uploads: .*wpallimport/functions.php')" "older PHP in an installed plugin's own uploads folder is only low" +has "$R" "$(sev medium 'PHP file in WP All Import.s folder in uploads: .*wpallimport/fresh.php')" "...recent PHP there is medium (worth a look, not an alarm)" +has "$R" "$(sev medium 'PHP file in WP All Import.s folder in uploads: .*wpallimport/handler.php')" "...but request-handling code there is still worth a look (medium)" +has "$R" "$(sev critical 'Executes code straight from a web request: .*wpallimport/shell.php')" "...and a real webshell there is still critical" +has "$R" "$(sev high 'PHP file in a data folder.*uploads/zzrandom/x.php')" "PHP in an unknown uploads folder is still high" +hasnt "$R" '[.findings[] | select((.location // "" | test("zz-normal")) and (.severity == "critical" or .severity == "high"))] | length > 0' "a method named exec() or system() isn't mistaken for a webshell" +has "$R" "$(sev medium 'Writes web-request input to a file.*zz-normal.php')" "saving custom CSS from a form is medium, not high" +hasnt "$R" "$(msg "run .$SITE/aios-bootstrap.php.")" "All In One WP Security's firewall (aios-bootstrap.php at the site root) isn't flagged" +has "$R" "$(sev low 'makes PHP run ./usr/local/hostguard/protect.php')" "auto_prepend_file into a system folder (the host's) is only low" +has "$R" "$(sev high 'user.ini makes PHP run .*uploads/aios-bootstrap.php')" "...but a firewall file name anywhere else is still high" +has "$R" "$(sev high 'makes PHP run ./var/lib/php/sessions/sess_p0wn')" "...a PHP session file is never a host's system file (session poisoning)" +has "$R" "$(sev high 'php.ini makes PHP run ./usr/local/\\.\\./')" "...nor a path that climbs out with .." +has "$R" '[.findings[] | select(.type=="mu-plugin" and .severity=="low" and (.message|test("zz-loader.php.*loads WP All Import")))] | length > 0' "a must-use loader for an installed plugin is low" +has "$R" '[.findings[] | select(.type=="mu-plugin" and .severity=="low" and (.message|test("0-worker")))] | length > 0' "ManageWP's 0-worker.php must-use file is low" +has "$R" "$(sev low 'Drop-in advanced-cache.php .*loads WP All Import')" "a drop-in that loads an installed plugin's files is only a low note" +has "$R" '[.findings[] | select(.type=="mu-plugin" and .severity=="medium" and (.message|test("zz-compat")))] | length > 0' "a must-use file that logs people in isn't excused by mentioning a plugin" +has "$R" "$(sev high 'PHP file in a data folder.*uploads/users/s.php')" "a folder named 'users' isn't claimed by User Switching" +has "$R" "$(sev high 'PHP file in a data folder.*upgrade/wordpress-1/s.php')" "upgrade/wordpress-1/ without WordPress's layout isn't an update leftover" +# Updates WordPress was unpacking when it was interrupted: one low finding per folder, not one per file. +mkdir -p "$C/upgrade/wp-all-import.3.7.1/wp-all-import" "$C/upgrade/wordpress-7.1.4-new-bundled/wordpress/wp-admin" +printf ' "$C/upgrade/wp-all-import.3.7.1/wp-all-import/x.php" +printf ' "$C/upgrade/wordpress-7.1.4-new-bundled/wordpress/wp-admin/a.php" +printf ' "$C/upgrade/wordpress-7.1.4-new-bundled/wordpress/wp-admin/b.php" +# A generic folder name isn't claimed by a plugin whose name starts the same way. +mkdir -p "$C/plugins/cache-enabler" "$C/uploads/cache" +printf ' "$C/plugins/cache-enabler/cache-enabler.php" +printf ' "$C/uploads/cache/x.php" +R=$(scan) +has "$R" "$(sev medium '^1 PHP file.s. left over from an unfinished WP All Import update or install')" "a plugin update left in wp-content/upgrade is one finding (medium), not one per file" +has "$R" "$(sev medium '^2 PHP file.s. left over from an unfinished WordPress update or install')" "a core update left in wp-content/upgrade is one finding (medium)" +hasnt "$R" "$(msg 'PHP file in a data folder.*upgrade/wordpress-7.1.4')" "...with no per-file alarms" +has "$R" "$(sev high 'PHP file in a data folder.*uploads/cache/x.php')" "uploads/cache/ isn't treated as Cache Enabler's folder" +rm -rf "$C/upgrade/wp-all-import.3.7.1" "$C/upgrade/wordpress-7.1.4-new-bundled" "$C/plugins/cache-enabler" "$C/uploads/cache" + +# Indicators from November research: wp2shell persistence (Dosdediez) and PHP2Shell honeypot captures (0x4D31). +mkdir -p "$C/plugins/database-repair-assistant-1a2b3c4d" "$C/uploads/2026/10" +printf ' "$C/plugins/database-repair-assistant-1a2b3c4d/plugin.php" +printf ' "$C/plugins/zz-normal/inc.php" +printf ' "$C/uploads/2026/10/shell_059f4c5d.php" +printf '# BEGIN wp_compat_9f8e7d6c\nRewriteRule ^x$ - [L]\n# END wp_compat_9f8e7d6c\n' >> "$SITE/.htaccess" +$WP user create w2s_ops w2s@example.com --role=administrator --quiet >/dev/null 2>&1 +$WP option update _site_transient_health_5e6f7a8b1c 'x' >/dev/null 2>&1 +P2S=$(mktemp) +echo '84.32.50.51 - - [23/Oct/2026:10:00:00 +0000] "POST /?c=echo%20p2s_v2_alive HTTP/1.1" 200 10 "-" "Mozilla/5.0"' >> "$P2S" +R=$(scan --log="$P2S") +has "$R" "$(sev critical 'Plugin folder named like a wp2shell backdoor: database-repair-assistant-1a2b3c4d')" "fake database-repair-assistant- plugin" +has "$R" "$(sev high 'WP_Core_Integrity markers.*inc.php')" "WP_Core_Integrity marker in code" +has "$R" "$(sev high 'shell_<8 hex>.*shell_059f4c5d.php')" "shell_<8 hex>.php webshell name" +has "$R" "$(sev high 'htaccess has a block marked .wp_compat_9f8e7d6c.')" "wp_compat_ block in .htaccess" +has "$R" "$(sev high 'Administrator .w2s_ops. .* named like the accounts wp2shell attackers create')" "w2s_ admin account" +has "$R" "$(sev high 'Setting ._site_transient_health_5e6f7a8b1c. .*named like the one a wp2shell backdoor stores')" "_site_transient_health_ option" +has "$R" "$(sev high 'log lines show PHP2Shell exploit tools')" "p2s_v2_alive probe in the access log" +$WP user delete "$($WP user get w2s_ops --field=ID 2>/dev/null)" --yes --quiet >/dev/null 2>&1 +$WP option delete _site_transient_health_5e6f7a8b1c >/dev/null 2>&1 +sed -i '/wp_compat_9f8e7d6c/d;/^RewriteRule \^x\$ - \[L\]$/d' "$SITE/.htaccess" +rm -rf "$C/plugins/database-repair-assistant-1a2b3c4d" "$C/uploads/2026/10/shell_059f4c5d.php" "$P2S" + +# WordPress's October 28, 2026 security release. +printf " "$C/mu-plugins/wp2ss-test-version.php" +R=$(scan) +has "$R" "$(sev medium 'WordPress 7.1.3 is missing the October 28, 2026 security release .7.1.4.')" "WordPress 7.1.3 is reminded of the 7.1.4 security release (medium)" +hasnt "$R" '[.findings[] | select(.type=="version" and (.severity=="critical" or .severity=="high"))] | length > 0' "...which alone doesn't read as vulnerable (only wp2shell does)" +printf " "$C/mu-plugins/wp2ss-test-version.php" +R=$(scan) +hasnt "$R" "$(msg 'missing the October 28, 2026 security release')" "WordPress 7.1.4 has the security release" +rm -f "$C/mu-plugins/wp2ss-test-version.php" +rm -rf "$C/plugins/wp-all-import" "$C/uploads/wpallimport" "$C/uploads/zzrandom" "$C/plugins/zz-normal" "$C/mu-plugins/zz-loader.php" "$C/mu-plugins/0-worker.php" "$C/mu-plugins/zz-compat.php" "$C/advanced-cache.php" "$SITE/.user.ini" "$SITE/php.ini" "$C/.user.ini" "$C/zz.css" "$C/uploads/.user.ini" "$C/uploads/zzfw" "$C/plugins/all-in-one-wp-security-and-firewall" "$C/plugins/user-switching" "$C/uploads/users" "$C/upgrade/wordpress-1" + section "18. Cleanup → clean again" rm -rf "$C/plugins/wp2shell_6a5566a6" "$C/plugins/cmsmap" "$C/upgrade/wp2shell_79b06a80" \ "$C/cache/x.php" "$C/uploads/2026/07/img.php" "$C/uploads/.htaccess" "$C/themes/.hidden.php" \