#!/usr/bin/env python3 """Scan web directories for potential webshell files. Uses multiple detection methods: suspicious function calls, obfuscation patterns, entropy analysis, and file anomaly detection. """ from __future__ import annotations import argparse import hashlib import json import math import os import re import sys from collections import Counter from datetime import datetime from pathlib import Path # Suspicious patterns by language PHP_SUSPICIOUS = [ (r'\beval\s*\(', "eval() - code execution", "critical"), (r'\bsystem\s*\(', "system() - command execution", "critical"), (r'\bexec\s*\(', "exec() - command execution", "critical"), (r'\bpassthru\s*\(', "passthru() - command execution", "critical"), (r'\bshell_exec\s*\(', "shell_exec() - command execution", "critical"), (r'\bpopen\s*\(', "popen() - process open", "high"), (r'\bproc_open\s*\(', "proc_open() - process open", "high"), (r'\bpcntl_exec\s*\(', "pcntl_exec() - process execution", "critical"), (r'\bassert\s*\(', "assert() - code execution", "high"), (r'\bpreg_replace\s*\([^)]*["\'].*e["\']', "preg_replace /e modifier", "critical"), (r'\bbase64_decode\s*\(', "base64_decode()", "medium"), (r'\bgzinflate\s*\(', "gzinflate() - decompression", "medium"), (r'\bgzuncompress\s*\(', "gzuncompress()", "medium"), (r'\bstr_rot13\s*\(', "str_rot13() - obfuscation", "medium"), (r'\$_(?:GET|POST|REQUEST|COOKIE)\s*\[', "Direct superglobal use", "medium"), (r'\bcreate_function\s*\(', "create_function()", "high"), (r'\bcall_user_func\s*\(', "call_user_func()", "medium"), (r'\bfile_put_contents\s*\(', "file_put_contents() - file write", "medium"), (r'\bmove_uploaded_file\s*\(', "move_uploaded_file()", "medium"), (r'chr\s*\(\s*\d+\s*\)', "chr() character construction", "low"), ] ASPX_SUSPICIOUS = [ (r'Process\.Start', "Process.Start - command execution", "critical"), (r'cmd\.exe', "cmd.exe reference", "critical"), (r'powershell', "PowerShell reference", "high"), (r'Runtime\.GetRuntime', "Runtime execution", "critical"), (r'Server\.Execute', "Server.Execute", "high"), (r'Response\.Write.*Request', "Request reflection", "medium"), ] JSP_SUSPICIOUS = [ (r'Runtime\.getRuntime\(\)\.exec', "Runtime.exec - command execution", "critical"), (r'ProcessBuilder', "ProcessBuilder - process creation", "critical"), (r'<%.*Runtime.*%>', "Scriptlet with Runtime", "high"), ] def calculate_entropy(data) -> dict: """Calculate Shannon entropy of data.""" if not data: return 0.0 counter = Counter(data) length = len(data) return -sum( (count / length) * math.log2(count / length) for count in counter.values() ) def detect_obfuscation_layers(content) -> dict: """Count nested obfuscation layers.""" layers = 0 obfuscation_functions = [ 'base64_decode', 'gzinflate', 'gzuncompress', 'str_rot13', 'rawurldecode', 'convert_uudecode', 'gzdecode' ] for func in obfuscation_functions: layers += content.lower().count(func) return layers def scan_file(filepath) -> list: """Scan a single file for webshell indicators.""" path = Path(filepath) findings = [] try: content = path.read_text(encoding="utf-8", errors="ignore") raw_data = path.read_bytes() except Exception as e: return [{"type": "error", "description": str(e)}] ext = path.suffix.lower() # Select patterns based on extension patterns = [] if ext in (".php", ".php3", ".php4", ".php5", ".phtml", ".inc"): patterns = PHP_SUSPICIOUS elif ext in (".aspx", ".asp", ".ashx", ".asmx"): patterns = ASPX_SUSPICIOUS elif ext in (".jsp", ".jspx"): patterns = JSP_SUSPICIOUS else: patterns = PHP_SUSPICIOUS + ASPX_SUSPICIOUS + JSP_SUSPICIOUS # Check patterns for pattern, description, severity in patterns: matches = re.findall(pattern, content, re.IGNORECASE) if matches: findings.append({ "type": "suspicious_pattern", "severity": severity, "description": description, "match_count": len(matches), }) # Entropy analysis entropy = calculate_entropy(raw_data) if entropy > 5.5 and len(raw_data) > 500: findings.append({ "type": "high_entropy", "severity": "medium", "description": f"High entropy ({entropy:.2f}) suggests encoded/encrypted content", "entropy": round(entropy, 2), }) # Obfuscation layer detection obf_layers = detect_obfuscation_layers(content) if obf_layers >= 2: findings.append({ "type": "obfuscation", "severity": "high", "description": f"Multiple obfuscation layers detected ({obf_layers})", "layers": obf_layers, }) # Long single-line files (common in obfuscated webshells) lines = content.split("\n") if len(lines) <= 3 and len(content) > 1000: findings.append({ "type": "single_line", "severity": "medium", "description": f"File content on {len(lines)} lines with {len(content)} chars (obfuscation indicator)", }) # Very small files with dangerous functions if len(content) < 200 and any(f["severity"] == "critical" for f in findings): findings.append({ "type": "minimal_webshell", "severity": "critical", "description": "Very small file with critical functions (likely simple webshell)", }) return findings def scan_directory(directory, extensions=None) -> dict: """Scan a directory recursively for webshells.""" if extensions is None: extensions = {".php", ".php3", ".php4", ".php5", ".phtml", ".asp", ".aspx", ".ashx", ".jsp", ".jspx", ".py", ".cgi", ".pl", ".inc"} results = [] dir_path = Path(directory) for filepath in dir_path.rglob("*"): if not filepath.is_file(): continue if filepath.suffix.lower() not in extensions: continue findings = scan_file(filepath) if findings: # Calculate risk score severity_scores = {"critical": 40, "high": 20, "medium": 10, "low": 5} risk_score = sum( severity_scores.get(f.get("severity", "low"), 0) for f in findings ) stat = filepath.stat() results.append({ "file": str(filepath), "size": stat.st_size, "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), "sha256": hashlib.sha256(filepath.read_bytes()).hexdigest(), "risk_score": min(risk_score, 100), "findings": findings, }) # Sort by risk score descending results.sort(key=lambda x: x["risk_score"], reverse=True) return results def main() -> None: parser = argparse.ArgumentParser( description="Scan web directories for potential webshells" ) parser.add_argument("--input", "--path", "-p", required=True, help="Directory to scan") parser.add_argument("--output", "-o", help="Output file (JSON)") parser.add_argument( "--extensions", "-e", help="Comma-separated file extensions to check (default: php,aspx,jsp,asp,py)" ) parser.add_argument("--format", choices=["json", "text"], default="text") parser.add_argument( "--min-risk", type=int, default=10, help="Minimum risk score to report (default: 10)" ) args = parser.parse_args() extensions = None if args.extensions: extensions = {"." + e.strip().lstrip(".") for e in args.extensions.split(",")} results = scan_directory(args.path, extensions) results = [r for r in results if r["risk_score"] >= args.min_risk] report = { "tool": "webshell_detector", "timestamp": datetime.now().isoformat(), "scan_path": args.path, "files_flagged": len(results), "critical_files": sum(1 for r in results if r["risk_score"] >= 60), "results": results, } if args.format == "json": output = json.dumps(report, indent=2) else: output = f"=== Webshell Detection Scan ===\n" output += f"Path: {args.path}\n" output += f"Flagged: {report['files_flagged']} files (Critical: {report['critical_files']})\n\n" for r in results: marker = "!!!" if r["risk_score"] >= 60 else (" ! " if r["risk_score"] >= 30 else " ") output += f"[{marker}] Risk {r['risk_score']:3d} | {r['file']}\n" for f in r["findings"]: output += f" [{f.get('severity', 'info').upper()}] {f['description']}\n" output += "\n" if args.output: Path(args.output).write_text(output) print(f"[+] Report saved to {args.output}") else: print(output) if __name__ == "__main__": main()