'/\b(eval|assert)\s*\(\s*(stripslashes\s*\(\s*)?\$_(POST|GET|REQUEST|COOKIE|SERVER)\b/i', 'Runs system commands sent in a request' => '/\b(system|shell_exec|passthru|exec|popen|proc_open)\s*\(\s*(base64_decode\s*\(\s*)?\$_(POST|GET|REQUEST|COOKIE)\b/i', 'Calls a function named in a request' => '/\$_(POST|GET|REQUEST|COOKIE)\s*\[\s*[\'"][^\'"]{1,30}[\'"]\s*\]\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)/i', 'Known web shell' => '/\b(FilesMan|WSO\s+\d|c99shell|r57shell|b374k|IndoXploit|AnonymousFox|Alfa\s*Shell|wso_version)\b/', 'preg_replace /e code execution' => '/preg_replace\s*\(\s*[\'"]([^\'"\\\\]|\\\\.)*\/[a-z]*e[a-z]*[\'"]\s*,\s*\$_(POST|GET|REQUEST|COOKIE)/i', 'Decodes and runs a hidden payload' => '/\beval\s*\(\s*(gzinflate|gzuncompress|gzdecode|str_rot13|base64_decode)\s*\(\s*(gzinflate|gzuncompress|str_rot13|base64_decode|strrev)?\s*\(?\s*[\'"][A-Za-z0-9+\/=]{400,}/i', ); } /** Disguised code: often malware, sometimes a commercial plugin's licence check. */ private static function obfuscation_patterns() { return array( 'Runs decoded code' => '/\beval\s*\(\s*(gzinflate|gzuncompress|gzdecode|str_rot13|base64_decode|hex2bin)\s*\(/i', 'Long chain of chr() calls' => '/(chr\s*\(\s*\d+\s*\)\s*\.\s*){40,}/i', 'Long hex-escaped string' => '/(\\\\x[0-9a-f]{2}){120,}/i', 'Function built from strings' => '/\$[a-z_]\w*\s*=\s*[\'"](as|ba|cr|ev|gz|st)[\'"]\s*\.\s*[\'"][a-z_]+[\'"]\s*\.\s*[\'"][a-z_]+[\'"]\s*;\s*\$[a-z_]\w*\s*\(/i', ); } public static function register_routes() { register_rest_route( Gralus_Rest::NS, '/security/deep', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'report' ), 'permission_callback' => array( 'Gralus_Connection', 'verify_request' ) ) ); } public static function report() { if ( function_exists( 'set_time_limit' ) ) { set_time_limit( self::TIME_BUDGET + 30 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- the scan reads many files; some hosts stop PHP at 30 s. } $start = microtime( true ); $files = self::scan_files( $start ); return array( 'files' => $files, 'database' => self::scan_database(), 'redirects' => self::scan_redirects(), 'seconds' => round( microtime( true ) - $start, 1 ), ); } /** * Redirects to other websites set up on the server (.htaccess) or in the Redirection plugin. * A rule that only fires for search engines, visitors from Google or phones is the classic hack. */ private static function scan_redirects() { $out = array( 'htaccess' => array(), 'plugin' => array() ); $own = strtolower( preg_replace( '/^www\./', '', (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ); $files = array( ABSPATH . '.htaccess', trailingslashit( dirname( ABSPATH ) ) . '.htaccess', WP_CONTENT_DIR . '/.htaccess' ); $up = wp_get_upload_dir(); if ( ! empty( $up['basedir'] ) ) { $files[] = $up['basedir'] . '/.htaccess'; } foreach ( array_unique( $files ) as $file ) { if ( ! is_readable( $file ) ) { continue; } $lines = (array) file( $file, FILE_IGNORE_NEW_LINES ); $conds = array(); foreach ( $lines as $line ) { $t = trim( $line ); if ( '' === $t || '#' === $t[0] ) { continue; } if ( preg_match( '/^RewriteCond\s+%\{(HTTP_REFERER|HTTP_USER_AGENT)\}\s+(\S+)/i', $t, $m ) ) { $conds[] = $m[1] . ' ' . $m[2]; continue; } $target = null; if ( preg_match( '/^RewriteRule\s+\S+\s+(https?:\/\/[^\s\[]+)/i', $t, $m ) || preg_match( '/^Redirect(Match|Permanent|Temp)?\s+(\d{3}\s+)?\S+\s+(https?:\/\/\S+)/i', $t, $m ) ) { $target = end( $m ); } if ( $target ) { $host = strtolower( preg_replace( '/^www\./', '', (string) wp_parse_url( $target, PHP_URL_HOST ) ) ); if ( $host && $host !== $own && substr( $host, -strlen( '.' . $own ) ) !== '.' . $own ) { $targeted = (bool) preg_grep( '/google|bing|yahoo|yandex|baidu|bot|crawl|spider|android|iphone|mobile/i', $conds ); $out['htaccess'][] = array( 'file' => str_replace( wp_normalize_path( dirname( ABSPATH ) ), '', wp_normalize_path( $file ) ), 'to' => $host, 'rule' => substr( $t, 0, 160 ), 'conditions' => array_slice( $conds, 0, 4 ), 'suspicious' => $targeted, ); } } if ( preg_match( '/^RewriteRule/i', $t ) ) { $conds = array(); // conditions belong to the next rule only } if ( count( $out['htaccess'] ) >= self::LIST_MAX ) { break 2; } } } // Redirection plugin (most popular redirect plugin): redirects that leave the site. global $wpdb; $table = $wpdb->prefix . 'redirection_items'; // The Redirection plugin's own table; it has no API for this. Read-only, fixed SQL. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is $wpdb->prefix plus a fixed name. $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; $rows = $exists ? $wpdb->get_results( "SELECT url, action_data FROM {$table} WHERE status = 'enabled' AND action_data LIKE 'http%' LIMIT 200" ) : array(); // phpcs:enable if ( $exists ) { foreach ( (array) $rows as $r ) { $host = strtolower( preg_replace( '/^www\./', '', (string) wp_parse_url( (string) $r->action_data, PHP_URL_HOST ) ) ); if ( $host && $host !== $own && count( $out['plugin'] ) < self::LIST_MAX ) { $out['plugin'][] = array( 'from' => (string) $r->url, 'to' => $host ); } } } return $out; } /** Theme, plugin and must-use plugin PHP files. Gralus's own files are skipped. */ private static function scan_files( $start ) { $out = array( 'scanned' => 0, 'skipped' => 0, 'timedOut' => false, 'backdoors' => array(), 'obfuscated' => array() ); $roots = array_filter( array( WP_CONTENT_DIR . '/themes', WP_PLUGIN_DIR, defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '' ) ); $self = wp_normalize_path( dirname( GRALUS_FILE ) ); $back = self::backdoor_patterns(); $obf = self::obfuscation_patterns(); foreach ( $roots as $root ) { if ( ! is_dir( $root ) ) { continue; } $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ) ); foreach ( $it as $f ) { if ( ! $f->isFile() || ! preg_match( '/\.(php|phtml|php\d|inc)$/i', $f->getFilename() ) ) { continue; } $path = wp_normalize_path( $f->getPathname() ); if ( 0 === strpos( $path, $self ) ) { continue; } if ( $out['scanned'] >= self::MAX_FILES || ( microtime( true ) - $start ) > self::TIME_BUDGET ) { $out['timedOut'] = true; break 2; } if ( $f->getSize() > self::MAX_BYTES ) { ++$out['skipped']; continue; } if ( ! is_readable( $path ) ) { continue; } $code = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local file to scan it. if ( false === $code ) { continue; } ++$out['scanned']; $rel = ltrim( str_replace( wp_normalize_path( WP_CONTENT_DIR ), 'wp-content', $path ), '/' ); foreach ( $back as $reason => $re ) { if ( preg_match( $re, $code ) ) { if ( count( $out['backdoors'] ) < self::LIST_MAX ) { $out['backdoors'][] = array( 'path' => $rel, 'reason' => $reason ); } continue 2; } } foreach ( $obf as $reason => $re ) { if ( preg_match( $re, $code ) ) { if ( count( $out['obfuscated'] ) < self::LIST_MAX ) { $out['obfuscated'][] = array( 'path' => $rel, 'reason' => $reason ); } break; } } } } return $out; } /** Posts and settings with injected scripts or hidden spam, and a site address that points elsewhere. */ private static function scan_database() { global $wpdb; $out = array( 'posts' => array(), 'options' => array(), 'address' => null, 'postsChecked' => 0, 'optionsChecked' => 0 ); $bad = '/eval\s*\(\s*(atob|unescape|String\.fromCharCode)|document\.write\s*\(\s*unescape|String\.fromCharCode\s*\((\s*\d+\s*,){30,}|]+(width|height)\s*=\s*["\']?[01]["\'\s>]|]+src\s*=\s*["\']?https?:\/\/[^"\'>]*\.(top|xyz|tk|ml|ga|cf|gq|icu|cyou|buzz|rest)\//i'; $spam = '/(viagra|cialis|online\s+casino|slot\s+gacor|judi\s+online|payday\s+loan|replica\s+(watches|handbags)|escort\s+service)/i'; $hide = '/<(div|span|p)[^>]*style\s*=\s*["\'][^"\']*(display\s*:\s*none|visibility\s*:\s*hidden|left\s*:\s*-\d{3,}px)[^"\']*["\'][^>]*>[\s\S]{0,2000}?get_results( "SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE post_type NOT IN ('revision','attachment','nav_menu_item','customize_changeset','oembed_cache') AND (post_content LIKE '%get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type NOT IN ('revision','attachment','nav_menu_item','customize_changeset','oembed_cache')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- read-only, fixed SQL on core tables. foreach ( (array) $rows as $r ) { $reason = null; if ( preg_match( $bad, $r->post_content ) ) { $reason = 'Injected script or hidden frame'; } elseif ( preg_match( $hide, $r->post_content, $m ) && preg_match( $spam, $m[0] ) ) { $reason = 'Hidden spam links'; } if ( $reason && count( $out['posts'] ) < self::LIST_MAX ) { $out['posts'][] = array( 'id' => (int) $r->ID, 'title' => wp_strip_all_tags( (string) $r->post_title ), 'reason' => $reason ); } } // Settings (skipping caches): injected scripts and disguised code. $opts = $wpdb->get_results( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name NOT LIKE '\\_transient\\_%' AND option_name NOT LIKE '\\_site\\_transient\\_%' AND (option_value LIKE '%get_var( "SELECT COUNT(*) FROM {$wpdb->options}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- read-only, fixed SQL on core tables. foreach ( (array) $opts as $o ) { if ( preg_match( $bad, $o->option_value ) && count( $out['options'] ) < self::LIST_MAX ) { $out['options'][] = array( 'name' => (string) $o->option_name, 'reason' => 'Injected script or hidden frame' ); } } // The site's own address: attackers change it to send every visitor elsewhere. $host = wp_parse_url( (string) get_option( 'home' ), PHP_URL_HOST ); $site = wp_parse_url( (string) get_option( 'siteurl' ), PHP_URL_HOST ); $norm = function ( $h ) { return strtolower( preg_replace( '/^www\./', '', (string) $h ) ); }; if ( $host && $site && $norm( $host ) !== $norm( $site ) ) { $out['address'] = sprintf( 'Home address is %s but the WordPress address is %s', $host, $site ); } return $out; } }