#!/usr/bin/env perl use strict; use warnings; use File::Find qw(find); use Digest::SHA; use Getopt::Long qw(GetOptions); =head1 NAME scanWordPressBackdoors - heuristically flag PHP files that look like WordPress backdoors/webshells =head1 SYNOPSIS # Standalone: walk the whole tree itself perl scanWordPressBackdoors [options] /storage/backup/nfs/jenny/www/clients # List mode: score a pre-filtered file list fed on STDIN # (see scanWordPressBackdoors.sh, which builds this with find+grep) find ... -print0 | perl scanWordPressBackdoors [options] - [display_root] perl scanWordPressBackdoors --help =head1 DESCRIPTION Recurses (or, in list mode, accepts a pre-built list of) F<.php>/F<.phtml>/F<.phps> files under a backup tree laid out as F and assigns each one a heuristic "suspiciousness" score based on: =over 4 =item * dangerous execution primitives (C, C, C, ...) =item * string obfuscation: hex (C<\xNN>) and octal (C<\NNN>) escapes, decimal via chained C calls, C, C =item * base64: literal-looking strings, C calls, and dynamically-built C<"base"."64_decode"> calls =item * C-based control-flow obfuscation =item * location red flags: a C<.php> file under C or C (should never be executable there), or a suspicious marker inside C =item * filename red flags: C/C/C/etc in the name, a double extension like F, or a file sitting directly in the WordPress docroot that isn't one of the real core filenames - especially one whose name starts with C but isn't an exact match (e.g. F planted next to the real F), a common typosquat disguise =back Every heuristic is weighted higher when the file also sits under a C, C, or C path, or directly in the docroot itself (detected via a C/C/ C sibling) - i.e. is plausibly part of an actual WordPress install - to keep noise down on backups that also contain non-WordPress PHP code. Files scoring at or above C<$THRESH_SCORE> are reported, grouped by C. This is a heuristic scanner, not a signature/AV engine: it will have both false positives (a legitimate plugin using C or C) and false negatives (a bespoke payload that avoids every listed primitive). Treat its output as a triage list to review by hand, not a verdict. A repeat false positive (e.g. a bundled library like phpseclib or PHPMailer that legitimately trips several heuristics, and shows up identically across many client sites) can be permanently suppressed once reviewed - see C<--whitelist=FILE> under ARGUMENTS. =head1 ARGUMENTS =over 4 =item C<--whitelist=FILE> Optional. C lists SHA-256 hashes (one per line, C<#> comments and blank lines ignored) of files that have been manually reviewed and confirmed OK. Only files that already scored at or above C<$THRESH_SCORE> are hashed and checked against this list - matching is on the file's full content, not its name or path, so a compromised file can't evade detection by reusing a previously-whitelisted filename, and a single entry suppresses the same legitimate file everywhere it's bundled identically across client sites. Suppressed files are counted in a C<(N file(s) suppressed by whitelist match)> report footer rather than vanishing silently. Not active unless passed explicitly - there is no default whitelist file. Each line just needs a bare 64-hex-character SHA-256 hash somewhere on it, so any of these common recipes work as-is: sha256sum knownGoodFile.php >> whitelist # GNU coreutils (Linux) sha256 -q knownGoodFile.php >> whitelist # FreeBSD base sha256 knownGoodFile.php >> whitelist # FreeBSD base, verbose form If C is given but can't be opened, the script dies immediately rather than silently running without the whitelist. =item C<--max-bytes=N> Bytes read from the head and (for larger files) tail of each file for scoring. Default C<512000>. See TUNING KNOBS. =item C<--threshold=N> Alert score a file must reach or exceed to be reported. Default C<7>. See TUNING KNOBS. =item C<--max-per-site=N> Maximum number of suspicious files listed per site in the report (sites with more are truncated, but C<[suspicious files: N]> still shows the true total). Default C<12>. =item C<--help> Print usage/options and exit C<0>. =item C Top-level directory to scan (e.g. the F directory containing every F tree). Standalone mode: the script does its own C walk, pruning F<.git>, F<.svn>, F, and F directories before descending into them. =item C<-> Switches to list mode: file paths are read NUL-delimited from STDIN instead of walking a directory. Intended to be fed by C, which prefilters with C+C first so this script's full multi-regex scoring only runs against real candidates. An optional second argument sets the "Scan root:" text shown in the report header (purely cosmetic). =back =head1 TUNING KNOBS C<$MAX_BYTES>, C<$THRESH_SCORE>, and C<$PRINT_TOP_FILES_PER_SITE> are settable on the command line via C<--max-bytes>, C<--threshold>, and C<--max-per-site> respectively (see ARGUMENTS); their defaults live near the top of the file if you'd rather change them permanently. The weighted pattern lists themselves (C<@DANGEROUS>/C<@STAGING>/C<@BAD_NAME>/etc.) are code-only knobs - see their per-hit score weights throughout C. =head1 EXIT STATUS Always exits C<0> on a completed scan (whether or not anything suspicious was found); dies with a usage message if invoked without a valid root directory or STDIN list mode marker, or if C<--whitelist=FILE> is given but the file can't be read. =head1 SEE ALSO F - find+grep wrapper that builds a candidate file list for list mode. F<../testing/> - real-world backdoor samples used to regression-test detection. =cut # ---- Knobs (defaults; all overridable via the CLI flags below) ---- my $MAX_BYTES = 512_000; # read at most this many bytes from head and tail each my $THRESH_SCORE = 7; # alert threshold (heuristic) my $PRINT_TOP_FILES_PER_SITE = 12; my $WHITELIST_FILE; my $SHOW_HELP = 0; my $USAGE = "Usage: $0 [options] /full/path/to/clients\n" . " find ... -print0 | $0 [options] - [display_root]\n"; my $HELP = $USAGE . <<"HELP"; Options: --whitelist=FILE suppress files whose SHA-256 hash appears in FILE --max-bytes=N bytes read from head/tail of each file (default: $MAX_BYTES) --threshold=N alert score threshold (default: $THRESH_SCORE) --max-per-site=N suspicious files shown per site in the report (default: $PRINT_TOP_FILES_PER_SITE) --help show this help and exit HELP GetOptions( 'whitelist=s' => \$WHITELIST_FILE, 'max-bytes=i' => \$MAX_BYTES, 'threshold=i' => \$THRESH_SCORE, 'max-per-site=i' => \$PRINT_TOP_FILES_PER_SITE, 'help' => \$SHOW_HELP, ) or die $USAGE; if ($SHOW_HELP) { print $HELP; exit 0; } my $ROOT = shift(@ARGV) // ''; die $USAGE unless $ROOT; my $LIST_MODE = ($ROOT eq '-'); my $DISPLAY_ROOT = $LIST_MODE ? (shift(@ARGV) // '(file list via STDIN)') : $ROOT; die $USAGE unless $LIST_MODE || -d $ROOT; # ---- Content-hash whitelist ---- # Files that already scored as suspicious are hashed (full file content, # SHA-256) and suppressed from the report if their hash is listed here. # Matching by content - not filename/path - means a compromised file can't # evade this by reusing a previously-whitelisted name, and a single entry # suppresses the same legitimate file everywhere it's bundled identically # across client sites (e.g. a shared vendored library). my %WHITELIST_HASHES; if (defined $WHITELIST_FILE) { open(my $wfh, '<', $WHITELIST_FILE) or die "Cannot read whitelist file '$WHITELIST_FILE': $!\n"; while (my $line = <$wfh>) { $line =~ s/#.*$//; # Accept whatever a real sha256 tool actually prints: GNU # "sha256sum file" ("hash file"), FreeBSD "sha256 -q file" (bare hash), # or FreeBSD "sha256 file" ("SHA256 (file) = hash") - just pull the hex # token out from wherever it lands on the line. if ($line =~ /\b([0-9A-Fa-f]{64})\b/) { $WHITELIST_HASHES{lc($1)} = 1; } } close($wfh); } # Paths that strongly suggest WordPress context (used to “gate” extra scoring) my @WP_PATH_MARKERS = ( qr!/wp-content/!i, qr!/wp-admin/!i, qr!/wp-includes/!i, ); # Locations where a .php file should basically never exist in a clean install. # A high-confidence backdoor drop point regardless of file content. my @SENSITIVE_DIR_MARKERS = ( qr!/wp-content/uploads/!i, qr!/wp-content/cache/!i, ); # Common execution / loader primitives my @DANGEROUS = ( qr/\b(eval|assert|create_function)\b/i, qr/\b(shell_exec|system|exec|passthru|proc_open|popen)\s*\(/i, qr/\b(curl_exec|curl_init)\s*\(/i, qr/\b(stream_get_contents|file_get_contents)\s*\(/i, qr/\b(fopen)\s*\(/i, qr/\b(unserialize)\s*\(/i, qr/\b(gzinflate|gzuncompress)\s*\(/i, ); # Remote decode / staging primitives (lower weight unless in WP context) my @STAGING = ( qr/\b(base64_decode)\s*\(/i, qr/\b(gzinflate|gzuncompress)\s*\(/i, qr/\b(str_rot13)\s*\(/i, qr/\b(pack\s*\()/i, ); # String obfuscation & control-flow markers my $RE_HEX = qr/\\x[0-9A-Fa-f]{2}/; my $RE_OCT = qr/\\[0-7]{1,3}/; my $RE_GOTO = qr/\bgoto\s+\w+/i; # Decimal / hex encoding via function calls rather than string escapes my $RE_DEC_CHR = qr/\bchr\s*\(\s*\d{1,3}\s*\)/i; my $RE_HEX2BIN = qr/\bhex2bin\s*\(/i; my $RE_PACK_HEX = qr/\bpack\s*\(\s*['"]H\*['"]/i; my $RE_PACK_DEC = qr/\bpack\s*\(\s*['"]C\*['"]/i; # Filename heuristics (optional, low weight) my @BAD_NAME = ( qr/\b(shell|webshell|backdoor|wso|cmd|xsh|kso|c99)\b/i, qr/\b(\.phar|\.phps|\.phtml)\b/i, qr/\.(?:jpe?g|png|gif|bmp|zip|txt|pdf)\.(?:php\d?|phtml|phps)$/i, # double-extension webshell ); # The complete set of legitimate top-level WordPress core files. A file # sitting directly in the docroot (identified below by a wp-config.php / # wp-load.php / wp-settings.php sibling) whose name isn't in this list is # itself a red flag - and one starting with "wp-" but not in this list is a # typosquat of a real core filename (e.g. wp-conffq.php next to the real # wp-config.php), a common disguise for a dropped backdoor. my %WP_ROOT_FILES = map { ($_ => 1) } qw( 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 ); my @WP_DOCROOT_MARKERS = qw(wp-config.php wp-load.php wp-settings.php); # ---- Base64 helpers ---- # Base64-looking string literals inside PHP code. # Matches quoted strings consisting of base64 alphabet; padding may appear at end. my $RE_B64_STR = qr/["'][A-Za-z0-9+\/]{24,}={0,2}["']/; # Direct base64_decode usage my $RE_B64_CALL = qr/\bbase64_decode\s*\(/i; # Dynamic base64_decode construction like "base" . "64_decode" my $RE_BUILD_BASE64_DECODE = qr/["']base["']\s*\.\s*["']64_decode["']/x; sub read_file_sample { my ($path) = @_; my $size = -s $path; return undef unless defined $size; open(my $fh, '<', $path) or return undef; binmode($fh); my $buf = ''; if ($size <= $MAX_BYTES * 2) { read($fh, $buf, $size); } else { # Sample both ends: obfuscated payloads are as often appended to the # tail of a large legitimate file as they are prepended to the head. my $head = ''; my $tail = ''; read($fh, $head, $MAX_BYTES); seek($fh, -$MAX_BYTES, 2); read($fh, $tail, $MAX_BYTES); $buf = $head . "\n" . $tail; } close($fh); return ($buf, $size); } sub compute_full_file_hash { my ($path, $text, $file_size) = @_; if (defined $file_size && $file_size <= $MAX_BYTES * 2) { # read_file_sample() already slurped the whole file at this size, so # $text IS the full content - hash it in-memory instead of re-reading. return Digest::SHA::sha256_hex($text); } my $sha = Digest::SHA->new(256); my $ok = eval { $sha->addfile($path, 'b'); 1 }; return undef unless $ok; # e.g. file vanished/perm race since the scan started return $sha->hexdigest; } sub count_regex_hits { my ($text, $re) = @_; my $c = 0; while ($text =~ /$re/g) { $c++ } return $c; } sub count_b64_literals { my ($text) = @_; return count_regex_hits($text, $RE_B64_STR); } sub extract_site { # Root-relative path must contain: clients/client#/web#/... # We group by the first occurrence of those segments. my ($rel) = @_; $rel =~ s!\\!/!g; $rel =~ s!^\./!!; # Break into parts my @p = split m{/}, $rel; my $client; my $web; for (my $i=0; $i<@p; $i++) { if (!defined $client && $p[$i] =~ /^client(\d+)$/i) { my $client_num = $1; # look ahead for web# for (my $j=$i+1; $j<@p; $j++) { if ($p[$j] =~ /^web(\d+)$/i) { $client = $client_num; $web = $1; last; } } } last if defined $client && defined $web; } return (undef, undef) unless defined $client && defined $web; return ("client$client/web$web", $client, $web); } # ---- Scan ---- my %hits_by_site; # site => array of suspicious files my %seen; my $skipped_no_site = 0; my $suppressed_by_whitelist = 0; sub process_file { my ($path) = @_; return if $seen{$path}++; return unless $path =~ /\.(?:php|phtml|phps)$/i; return if $path =~ m!/\.(?:git|svn)/!; return if $path =~ m!/node_modules/!; return if $path =~ m!/vendor/!; my $rel = $path; $rel =~ s!^\Q$ROOT\E/?!! unless $LIST_MODE; $rel =~ s!^\./!!; my ($site, $client, $web) = extract_site($rel); if (!defined $site) { $skipped_no_site++; return; } my ($text, $file_size) = read_file_sample($path); return unless defined $text; # WP-context gating: did we see likely WP directories or wp-config? my $in_wp_context = 0; for my $m (@WP_PATH_MARKERS) { if ($path =~ $m || $rel =~ $m) { $in_wp_context = 1; last; } } my $is_wp_config = ($path =~ m!/wp-config\.php$!i) ? 1 : 0; # A file with no wp-content/wp-admin/wp-includes in its path can still be # sitting right in the WordPress docroot itself (index.php, wp-login.php, # wp-config.php, and disguised lookalikes all live there). Detect that by # checking for a wp-config.php/wp-load.php/wp-settings.php sibling. my ($dir) = $path =~ m!^(.*)/[^/]+$!; my $in_wp_docroot = 0; if (defined $dir) { for my $marker (@WP_DOCROOT_MARKERS) { if (-e "$dir/$marker") { $in_wp_docroot = 1; last; } } } $in_wp_context ||= $in_wp_docroot; my $in_sensitive_dir = 0; for my $m (@SENSITIVE_DIR_MARKERS) { if ($path =~ $m) { $in_sensitive_dir = 1; last; } } my $score = 0; my @match_names; # Dangerous execution primitives - every distinct primitive found adds # weight, so a file combining eval()+shell_exec()+curl_exec() scores # higher than one using only eval(). my %danger_seen; for my $re (@DANGEROUS) { if ($text =~ /$re/) { my $tag = lc($1 // 'danger'); next if $danger_seen{$tag}++; $score += $in_wp_context ? 3 : 1; push @match_names, "danger:$tag"; } } # Staging/obfuscation my $hex_c = count_regex_hits($text, $RE_HEX); if ($hex_c >= 40) { $score += $in_wp_context ? 6 : 3; push @match_names, "hex($hex_c)"; } elsif ($hex_c >= 15) { $score += $in_wp_context ? 4 : 2; push @match_names, "hex($hex_c)"; } my $oct_c = count_regex_hits($text, $RE_OCT); if ($oct_c >= 25) { $score += $in_wp_context ? 3 : 1; push @match_names, "oct($oct_c)"; } elsif ($oct_c >= 10) { $score += $in_wp_context ? 2 : 1; push @match_names, "oct($oct_c)"; } # Decimal encoding via chained chr() calls, e.g. chr(101).chr(118).chr(97).chr(108) my $dec_c = count_regex_hits($text, $RE_DEC_CHR); if ($dec_c >= 30) { $score += $in_wp_context ? 6 : 3; push @match_names, "dec_chr($dec_c)"; } elsif ($dec_c >= 10) { $score += $in_wp_context ? 4 : 2; push @match_names, "dec_chr($dec_c)"; } if ($text =~ /$RE_GOTO/) { $score += $in_wp_context ? 2 : 1; push @match_names, "goto"; } # Base64 / inflate / rot13 / pack etc (lighter unless in WP context) for my $re (@STAGING) { if ($text =~ /$re/) { $score += $in_wp_context ? 1 : 0; push @match_names, "staging"; } } # Hex/decimal encoding via explicit conversion functions rather than # string escapes (hex2bin('...'), pack('H*', ...), pack('C*', ...)) if ($text =~ /$RE_HEX2BIN/) { $score += $in_wp_context ? 3 : 1; push @match_names, "hex2bin"; } if ($text =~ /$RE_PACK_HEX/) { $score += $in_wp_context ? 3 : 1; push @match_names, "pack_hex"; } if ($text =~ /$RE_PACK_DEC/) { $score += $in_wp_context ? 3 : 1; push @match_names, "pack_dec"; } # Base64 literals / calls my $b64_lits = count_b64_literals($text); if ($b64_lits >= 1) { $score += $in_wp_context ? 3 : 1; push @match_names, "b64_str($b64_lits)"; } if ($text =~ /$RE_B64_CALL/) { $score += $in_wp_context ? 3 : 1; push @match_names, "b64_call"; } if ($text =~ /$RE_BUILD_BASE64_DECODE/) { $score += $in_wp_context ? 3 : 1; push @match_names, "b64_built"; } # Filename heuristics (very low) my ($fname) = $path =~ m!/([^/]+)$!; if ($fname) { for my $re (@BAD_NAME) { if ($fname =~ $re) { $score += 1; push @match_names, "name($fname)"; last; } } } # A file living directly in the WP docroot that isn't one of the known # core filenames is unusual on its own; one that also starts with "wp-" # is very likely deliberately typosquatting a real core file (e.g. # wp-conffq.php planted next to the real wp-config.php) to blend in. if ($in_wp_docroot && $fname && !$WP_ROOT_FILES{lc($fname)}) { if ($fname =~ /^wp-/i) { $score += 6; push @match_names, "wp_core_lookalike($fname)"; } else { $score += 2; push @match_names, "unexpected_in_docroot($fname)"; } } # A .php file sitting in uploads/ or cache/ is high signal on its own. if ($in_sensitive_dir) { $score += 5; push @match_names, "in_uploads_or_cache"; } # Special-case wp-config.php: even a single suspicious loader marker elsewhere here is high signal. $score += 4 if ($is_wp_config && $in_wp_context); if ($score >= $THRESH_SCORE) { if (%WHITELIST_HASHES) { my $file_hash = compute_full_file_hash($path, $text, $file_size); if (defined $file_hash && $WHITELIST_HASHES{$file_hash}) { $suppressed_by_whitelist++; return; } } push @{ $hits_by_site{$site} }, { path => $path, score => $score, matches => \@match_names, }; } } if ($LIST_MODE) { local $/ = "\0"; while (my $path = ) { chomp $path; next if $path eq ''; next if -d $path; process_file($path); } } else { find( { no_chdir => 1, # Prune directories that can never contain a site's own code, before # File::Find descends into them (a huge win when vendor/ or # node_modules/ hold thousands of files on an NFS-mounted backup). preprocess => sub { return grep { !( $_ eq '.git' || $_ eq '.svn' || $_ eq 'node_modules' || $_ eq 'vendor' ) } @_; }, wanted => sub { my $path = $File::Find::name; return if -d $path; process_file($path); } }, $ROOT ); } my @sites = sort { (scalar(@{ $hits_by_site{$b} || [] })) <=> (scalar(@{ $hits_by_site{$a} || [] })) } keys %hits_by_site; print "Scan root: $DISPLAY_ROOT\n"; if (!@sites) { print "No obvious suspicious sites found with current heuristics.\n"; print "($skipped_no_site file(s) skipped: no client#/web# path segment found)\n" if $skipped_no_site; print "($suppressed_by_whitelist file(s) suppressed by whitelist match)\n" if $suppressed_by_whitelist; exit 0; } print "Suspicious sites (grouped by clients/client#/web#):\n"; for my $site (reverse sort { (scalar(@{ $hits_by_site{$a} || [] })) <=> (scalar(@{ $hits_by_site{$b} || [] })) } keys %hits_by_site) { my $count = scalar @{ $hits_by_site{$site} }; print "\n$site [suspicious files: $count]\n"; my $i = 0; # Show highest-scoring first my @items = sort { $b->{score} <=> $a->{score} } @{ $hits_by_site{$site} }; for my $item (@items) { last if $i++ >= $PRINT_TOP_FILES_PER_SITE; my $m = join(",", @{ $item->{matches} || [] }); print " - score=$item->{score} $item->{path}\n"; print " matches: $m\n" if $m ne ''; } } print "\n($skipped_no_site file(s) skipped: no client#/web# path segment found)\n" if $skipped_no_site; print "($suppressed_by_whitelist file(s) suppressed by whitelist match)\n" if $suppressed_by_whitelist; print "\nDone.\n";