Files
argus-wp-defence/includes/class-argus-malware-scanner.php
T
root df0f2fccb8 ARGUS WordPress Defence 1.0.0 — first production release
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.
2026-08-09 13:40:16 +00:00

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 ) ) )
);
}
}