regex. */ private const MALWARE_SIGNATURES = [ 'eval_decoded' => '/\b(?:eval|assert)\s*\(\s*(?:base64_decode|gzinflate|gzuncompress|str_rot13|strrev|rawurldecode)\s*\(/i', 'decode_chain' => '/\b(?:gzinflate|gzuncompress|str_rot13)\s*\(\s*base64_decode\s*\(/i', 'eval_superglobal' => '/\b(?:eval|assert)\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b/i', 'exec_superglobal' => '/\b(?:system|exec|passthru|shell_exec|popen|proc_open)\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)\b/i', 'preg_replace_e' => '/\bpreg_replace\s*\(\s*([\'"]).*?\/[a-z]*e[a-z]*\1/i', 'create_function' => '/\bcreate_function\s*\(\s*[\'"][^\'"]*[\'"]\s*,\s*(?:base64_decode|gzinflate|str_rot13)/i', 'known_webshell' => '/\b(?:c99shell|r57shell|FilesMan|b374k|phpspy|WSOshell|JFolder|by\s+orb)\b/i', 'gif_php_polyglot' => '/GIF89a\s*<\?php/i', 'callback_decoded' => '/\b(?:call_user_func|call_user_func_array)\s*\(\s*(?:base64_decode|gzinflate|str_rot13)\s*\(/i', ]; private const READ_BYTES = 524288; // read up to 512 KB per file private const MAX_FILE_SIZE = 3145728; // skip files > 3 MB private const MAX_FILES = 40000; // hard cap to bound scan time public function run(): CheckResult { try { $suspicious = []; $scanned = 0; $selfDir = untrailingslashit(wp_normalize_path(SENTINELO_PLUGIN_DIR)); // 1) plugins / themes / mu-plugins FIRST: this is where real webshells // hide and it isn't covered by any other check, so it must never be // starved of the shared MAX_FILES budget by a media-heavy uploads tree. $codeRoots = array_filter([ WP_PLUGIN_DIR, get_theme_root(), WPMU_PLUGIN_DIR, ], 'is_dir'); foreach (array_unique($codeRoots) as $root) { $this->scanCodeRoot($root, $selfDir, $suspicious, $scanned); } // 2) uploads/: executables + double-extensions + content signatures. $uploadsDir = wp_upload_dir(null, false)['basedir'] ?? ''; if (is_dir($uploadsDir)) { $this->scanUploads($uploadsDir, $suspicious, $scanned); } $count = count($suspicious); $score = $count === 0 ? 15 : 0; $status = $count === 0 ? 'pass' : 'fail'; return new CheckResult( 'suspicious_files', $status, $score, 15, 'critical', array_slice($suspicious, 0, 100), $count === 0 ? "Aucun fichier suspect détecté ({$scanned} fichiers analysés)" : sprintf('%d fichier(s) suspect(s) détecté(s) sur %d analysés', $count, $scanned) ); } catch (\Throwable $e) { return new CheckResult( 'suspicious_files', 'skip', 0, 15, 'info', ['error' => $e->getMessage()], 'Vérification ignorée (exception interne) : ' . $e->getMessage() ); } } private function scanUploads(string $uploadsDir, array &$suspicious, int &$scanned): void { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($uploadsDir, \RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (!$file->isFile() || $scanned >= self::MAX_FILES) { continue; } // The agent's own logs and encrypted archives (uploads/sentinelo/ in the // wordpress.org edition): nothing to analyse there. if (sentinelo_is_agent_storage($file->getPathname())) { continue; } $scanned++; $fileName = $file->getFilename(); $extension = strtolower($file->getExtension()); $relativePath = str_replace(ABSPATH, '', wp_normalize_path($file->getPathname())); if ($fileName === '.htaccess' || $fileName === 'index.php') { continue; } // Executable files have no business living in uploads/. // // Un fichier de CONFIGURATION protege par une garde fait exception, et // il faut la nommer : plusieurs extensions repandues ecrivent leur // configuration dans `uploads/` sous la forme `.ini.php`, ouverte // par `; isGuardedDataFile($file->getPathname())) { continue; } $suspicious[] = ['file' => $relativePath, 'reason' => 'Exécutable dans uploads/ : .' . $extension]; continue; } // image.jpg.php style disguises. if (preg_match('/\.(jpg|jpeg|png|gif|pdf|doc|docx|zip)\.(php|phtml|php5|phar)$/i', $fileName)) { $suspicious[] = ['file' => $relativePath, 'reason' => 'Double extension suspecte']; continue; } if (($extension === 'php' || $extension === 'phtml') && ($hit = $this->matchSignatures($file->getPathname())) !== null) { $suspicious[] = ['file' => $relativePath, 'reason' => 'Signature malware : ' . $hit]; } } } private function scanCodeRoot(string $root, string $selfDir, array &$suspicious, int &$scanned): void { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($root, \RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (!$file->isFile() || $scanned >= self::MAX_FILES) { continue; } $extension = strtolower($file->getExtension()); if ($extension !== 'php' && $extension !== 'phtml') { continue; } $normalized = wp_normalize_path($file->getPathname()); // Skip our own plugin, vendored deps, caches and VCS — all expected to // contain encode/eval-ish tokens or be noise. if ( str_starts_with($normalized, $selfDir) || preg_match('#/(?:vendor|node_modules|\.git|cache|tests?)/#i', $normalized) ) { continue; } $scanned++; if (($hit = $this->matchSignatures($normalized)) !== null) { $suspicious[] = [ 'file' => str_replace(ABSPATH, '', $normalized), 'reason' => 'Signature malware : ' . $hit, ]; } } } /** * Un fichier de donnees rendu inexecutable par une garde en tete. * * Le motif : le fichier ouvre par `guardPrefix($head))); return !str_contains($rest, ')?/i', $head, $m) === 1 ? $m[0] : ''; } /** Returns the matched signature label, or null. */ private function matchSignatures(string $path): ?string { if (filesize($path) > self::MAX_FILE_SIZE) { return null; } $handle = @fopen($path, 'rb'); if ($handle === false) { return null; } $content = (string) fread($handle, self::READ_BYTES); fclose($handle); foreach (self::MALWARE_SIGNATURES as $label => $regex) { if (preg_match($regex, $content)) { return $label; } } return null; } }