Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions.
291 lines
11 KiB
PHP
291 lines
11 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_Malware_Scanner {
|
|
|
|
const MAX_FILE_BYTES = 2 * MB_IN_BYTES;
|
|
|
|
protected static function heuristics() {
|
|
return array(
|
|
array( 'id' => 'eval-dynamic-decode', 'weight' => 40,
|
|
'pattern' => '/\beval\s*\(\s*(base64_decode|gzinflate|gzuncompress|str_rot13|convert_uudecode)\s*\(/i',
|
|
'label' => 'eval() wrapping a decode/decompress function -- the single most common webshell obfuscation pattern' ),
|
|
array( 'id' => 'superglobal-into-exec', 'weight' => 40,
|
|
'pattern' => '/\b(eval|assert|system|exec|shell_exec|passthru|proc_open)\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)/i',
|
|
'label' => 'A request superglobal ($_POST/$_GET/etc.) passed directly into a code-execution function' ),
|
|
array( 'id' => 'assert-as-eval', 'weight' => 25,
|
|
'pattern' => '/\bassert\s*\(\s*[\'"]/i',
|
|
'label' => 'assert() called with a string argument -- a legacy PHP trick equivalent to eval()' ),
|
|
array( 'id' => 'dangerous-function-present', 'weight' => 15,
|
|
'pattern' => '/\b(system|exec|shell_exec|passthru|proc_open|popen)\s*\(/i',
|
|
'label' => 'A shell-execution function is present in the file' ),
|
|
array( 'id' => 'variable-variable-obfuscation', 'weight' => 15,
|
|
'pattern' => '/\$\{\s*[\'"]\w+[\'"]\s*\}|\$\$\w+/',
|
|
'label' => 'Variable-variable syntax -- a common obfuscation technique to hide function/variable names from simple text search' ),
|
|
array( 'id' => 'large-base64-blob', 'weight' => 20,
|
|
'pattern' => '/[A-Za-z0-9+\/]{800,}={0,2}/',
|
|
'label' => 'A very large base64-like blob is embedded in the file' ),
|
|
array( 'id' => 'create-function', 'weight' => 15,
|
|
'pattern' => '/\bcreate_function\s*\(/i',
|
|
'label' => 'create_function() -- deprecated since PHP 7.2, rarely used except to obscure dynamically-generated code' ),
|
|
array( 'id' => 'file-write-from-request', 'weight' => 20,
|
|
'pattern' => '/\b(file_put_contents|fwrite)\s*\([^,]+,\s*\$_(POST|GET|REQUEST)/i',
|
|
'label' => 'Writes request data directly to a file -- consistent with a dropper/uploader shell' ),
|
|
);
|
|
}
|
|
|
|
const THRESHOLD_HIGH = 55;
|
|
const THRESHOLD_MEDIUM = 30;
|
|
|
|
const QUARANTINE_SUFFIX = '.argus-quarantined';
|
|
const HTACCESS_MARKER = 'ARGUS WordPress Defence -- deny PHP execution (ADR-0053 §9.1)';
|
|
const RESTORED_TRUST_OPTION = 'argus_wpd_malware_restored_trust';
|
|
|
|
const SCAN_WATERMARK_OPTION = 'argus_wpd_malware_watermark';
|
|
|
|
const LOCKDOWN_STATUS_OPTION = 'argus_wpd_uploads_lockdown_status';
|
|
|
|
public static function mark_restored_trusted( $rel_path, $hash ) {
|
|
$trust = get_option( self::RESTORED_TRUST_OPTION, array() );
|
|
if ( ! is_array( $trust ) ) {
|
|
$trust = array();
|
|
}
|
|
$trust[ $rel_path ] = $hash;
|
|
update_option( self::RESTORED_TRUST_OPTION, $trust, false );
|
|
}
|
|
|
|
protected static function is_restored_trusted( $rel_path, $hash ) {
|
|
$trust = get_option( self::RESTORED_TRUST_OPTION, array() );
|
|
return is_array( $trust ) && isset( $trust[ $rel_path ] ) && hash_equals( $trust[ $rel_path ], $hash );
|
|
}
|
|
|
|
public static function incremental_scan() {
|
|
self::ensure_uploads_lockdown();
|
|
self::scan_uploads_for_php();
|
|
self::scan_recently_changed();
|
|
self::rescan_restored_trust();
|
|
}
|
|
|
|
public static function ensure_uploads_lockdown() {
|
|
$uploads = wp_get_upload_dir();
|
|
$base = $uploads['basedir'] ?? null;
|
|
if ( ! $base || ! is_dir( $base ) || ! is_writable( $base ) ) {
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'not_writable', false );
|
|
return false;
|
|
}
|
|
|
|
$path = trailingslashit( $base ) . '.htaccess';
|
|
|
|
$rule = "# " . self::HTACCESS_MARKER . "\n"
|
|
. "<IfModule mod_php.c>\n\tphp_flag engine off\n</IfModule>\n"
|
|
. "<IfModule mod_php7.c>\n\tphp_flag engine off\n</IfModule>\n"
|
|
. "<FilesMatch \"\\.ph(p[3457]?|t|tml)\">\n\tRequire all denied\n</FilesMatch>\n";
|
|
|
|
$existing = file_exists( $path ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
$healthy = is_string( $existing )
|
|
&& false !== strpos( $existing, self::HTACCESS_MARKER )
|
|
&& false !== strpos( $existing, '\\.ph(p[3457]?|t|tml)"' );
|
|
|
|
if ( ! $healthy ) {
|
|
|
|
$preserved = is_string( $existing ) ? $existing : '';
|
|
$preserved = (string) preg_replace(
|
|
'/# ' . preg_quote( self::HTACCESS_MARKER, '/' ) . '.*?<\/FilesMatch>\n?/s',
|
|
'',
|
|
$preserved
|
|
);
|
|
|
|
$written = file_put_contents( $path, $rule . $preserved ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( false === $written ) {
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_write', false );
|
|
return false;
|
|
}
|
|
}
|
|
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'active', false );
|
|
return true;
|
|
}
|
|
|
|
public static function lockdown_status() {
|
|
return get_option( self::LOCKDOWN_STATUS_OPTION, 'unknown' );
|
|
}
|
|
|
|
protected static function scan_uploads_for_php() {
|
|
$uploads = wp_get_upload_dir();
|
|
$base = $uploads['basedir'] ?? null;
|
|
if ( ! $base || ! is_dir( $base ) ) {
|
|
return;
|
|
}
|
|
|
|
$data_dir = wp_normalize_path( trailingslashit( $base ) . 'argus-wpd-data' );
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ),
|
|
RecursiveIteratorIterator::LEAVES_ONLY
|
|
);
|
|
|
|
foreach ( $iterator as $file ) {
|
|
if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
|
|
continue;
|
|
}
|
|
$path = $file->getPathname();
|
|
|
|
if ( 0 === strpos( wp_normalize_path( $path ), $data_dir ) ) {
|
|
continue;
|
|
}
|
|
|
|
$rel = str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $path ) );
|
|
|
|
if ( self::is_restored_trusted( $rel, hash_file( 'sha256', $path ) ) ) {
|
|
continue;
|
|
}
|
|
|
|
$size = filesize( $path );
|
|
$mtime = gmdate( 'Y-m-d H:i:s', filemtime( $path ) );
|
|
|
|
$result = Argus_Quarantine::quarantine_file(
|
|
$path,
|
|
$rel,
|
|
array(
|
|
'engine' => 'Argus_Malware_Scanner',
|
|
'rule' => 'php-in-uploads',
|
|
'type' => 'signature',
|
|
'severity' => 'critical',
|
|
)
|
|
);
|
|
$neutralized = false !== $result;
|
|
|
|
$finding_id = Argus_Findings::record(
|
|
'malware',
|
|
'critical',
|
|
array(
|
|
'what_happened' => 'A PHP file was found inside the uploads directory: ' . $rel,
|
|
'why_it_matters' => 'WordPress does not execute PHP inside wp-content/uploads by design -- a legitimate install should never have PHP files there.',
|
|
'what_argus_found' => sprintf( 'File: %s, size %s bytes, last modified %s.', $rel, $size, $mtime ),
|
|
'when_it_happened' => current_time( 'mysql' ),
|
|
'why_suspicious' => 'This is one of the most common webshell placement techniques -- uploads directories are usually writable by the web application, unlike wp-admin/wp-includes.',
|
|
'what_could_be_affected' => 'A PHP file here can be executed directly by visiting its URL, potentially giving an attacker full code execution on your server.',
|
|
'what_should_you_do' => $neutralized
|
|
? 'This file has been quarantined -- moved to a protected, non-web-accessible store. Review it on the Quarantine page and decide whether to restore or permanently delete it.'
|
|
: 'ARGUS detected this file but could not quarantine it (the uploads directory is not writable by PHP on this host, or it is a symlink ARGUS refuses to move automatically). Remove it manually and investigate how it got there.',
|
|
),
|
|
array(
|
|
'quarantine_id' => $neutralized ? $result['id'] : null,
|
|
'file_path' => $rel,
|
|
'file_mtime' => $mtime,
|
|
'neutralized' => $neutralized,
|
|
),
|
|
Argus_Findings::STATUS_OPEN
|
|
);
|
|
|
|
if ( $neutralized ) {
|
|
global $wpdb;
|
|
$wpdb->update(
|
|
Argus_DB::table( 'quarantine' ),
|
|
array( 'finding_id' => $finding_id ),
|
|
array( 'id' => $result['id'] ),
|
|
array( '%d' ),
|
|
array( '%d' )
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected static function scan_recently_changed() {
|
|
$since = (int) get_option( self::SCAN_WATERMARK_OPTION, time() - HOUR_IN_SECONDS );
|
|
|
|
foreach ( array( WP_PLUGIN_DIR, get_theme_root(), ABSPATH . 'wp-admin', ABSPATH . WPINC ) as $dir ) {
|
|
if ( ! is_dir( $dir ) ) {
|
|
continue;
|
|
}
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
|
|
RecursiveIteratorIterator::LEAVES_ONLY
|
|
);
|
|
foreach ( $iterator as $file ) {
|
|
if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
|
|
continue;
|
|
}
|
|
if ( $file->getMTime() < $since ) {
|
|
continue;
|
|
}
|
|
self::scan_file( $file->getPathname() );
|
|
}
|
|
}
|
|
|
|
update_option( self::SCAN_WATERMARK_OPTION, time(), false );
|
|
}
|
|
|
|
protected static function rescan_restored_trust() {
|
|
$trust = get_option( self::RESTORED_TRUST_OPTION, array() );
|
|
if ( ! is_array( $trust ) || empty( $trust ) ) {
|
|
return;
|
|
}
|
|
|
|
foreach ( $trust as $rel_path => $known_hash ) {
|
|
$abs = ABSPATH . ltrim( $rel_path, '/' );
|
|
if ( ! is_readable( $abs ) ) {
|
|
continue;
|
|
}
|
|
if ( hash_file( 'sha256', $abs ) === $known_hash ) {
|
|
continue;
|
|
}
|
|
self::scan_file( $abs );
|
|
}
|
|
}
|
|
|
|
public static function score_content( $content ) {
|
|
$score = 0;
|
|
$matched = array();
|
|
|
|
foreach ( self::heuristics() as $rule ) {
|
|
if ( preg_match( $rule['pattern'], $content ) ) {
|
|
$score += $rule['weight'];
|
|
$matched[] = $rule;
|
|
}
|
|
}
|
|
|
|
return array( $score, $matched );
|
|
}
|
|
|
|
public static function scan_file( $path ) {
|
|
if ( ! is_readable( $path ) || filesize( $path ) > self::MAX_FILE_BYTES ) {
|
|
return;
|
|
}
|
|
|
|
$content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( false === $content ) {
|
|
return;
|
|
}
|
|
|
|
list( $score, $matched ) = self::score_content( $content );
|
|
|
|
if ( $score < self::THRESHOLD_MEDIUM ) {
|
|
return;
|
|
}
|
|
|
|
$severity = $score >= self::THRESHOLD_HIGH ? 'critical' : 'medium';
|
|
$rel = str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $path ) );
|
|
$labels = wp_list_pluck( $matched, 'label' );
|
|
|
|
Argus_Findings::record(
|
|
'malware',
|
|
$severity,
|
|
array(
|
|
'what_happened' => sprintf( 'Suspicious PHP file detected: %s (heuristic score %d)', $rel, $score ),
|
|
'why_it_matters' => 'This file contains patterns strongly associated with webshells or backdoors -- code designed to give an attacker persistent, hidden access to your server.',
|
|
'what_argus_found' => implode( '; ', $labels ),
|
|
'when_it_happened' => current_time( 'mysql' ),
|
|
'why_suspicious' => 'These are static-analysis heuristics, not a claim that this file is definitely malicious -- some are also occasionally present in legitimate advanced plugins. ARGUS shows you exactly what matched so you can judge it yourself.',
|
|
'what_could_be_affected' => 'If this file is genuinely malicious, an attacker could have code execution on your server through it.',
|
|
'what_should_you_do' => 'Review the file manually. If you don\'t recognize it or can\'t explain why it exists, remove it and investigate how it got there (check recent file changes, plugin/theme sources, and user activity).',
|
|
),
|
|
array( 'file_path' => $rel, 'score' => $score, 'matched_rules' => wp_list_pluck( $matched, 'id' ), 'file_mtime' => gmdate( 'Y-m-d H:i:s', filemtime( $path ) ) )
|
|
);
|
|
}
|
|
}
|