WebShell Detection and Removal: A Practical Linux Incident Response Guide
WebShells are the backdoor attackers most often deploy after taking server control. This article covers Linux WebShell hunting, signature detection, and a complete Python intrusion-detection script.
Scope
WebShells are the most common backdoors attackers deploy after compromising a web server. They typically exist as PHP/JSP/ASP/ASPX/Python scripts; the attacker remotely executes system commands via a browser or CLI. This article is for Linux server operators doing rapid triage and cleanup during post-compromise incident response.
Prerequisites
- Linux server (CentOS 7+ / Ubuntu 20.04+)
- Python 3.6+
- root or sudo
- Basic Linux command-line skill
How It Works
WebShells fall into three complexity classes: small shells (single file, single function such as upload or command execution), full-featured shells (multi-function, with auth and stealth), and one-liner Trojans (minimal payload used with a client tool). Shared traits: dangerous function calls (eval(), system(), shell_exec()), encoding obfuscation (Base64/Hex), anomalous file timestamps, recent modification.
Procedure
Step 1: Manual Triage
At the start of IR, run these quick checks:
# 1. Find PHP files modified in the last 7 days
find /var/www -name "*.php" -mtime -7 -type f | head -50
# 2. Find PHP files containing eval, system, and other dangerous functions
grep -rn "eval(" /var/www --include="*.php" 2>/dev/null | head -20
grep -rn "system(" /var/www --include="*.php" 2>/dev/null | head -20
grep -rn "shell_exec(" /var/www --include="*.php" 2>/dev/null | head -20
grep -rn "exec(" /var/www --include="*.php" 2>/dev/null | head -20
grep -rn "popen(" /var/www --include="*.php" 2>/dev/null | head -20
grep -rn "assert(" /var/www --include="*.php" 2>/dev/null | head -20
grep -rn "base64_decode(" /var/www --include="*.php" 2>/dev/null | head -20
# 3. Find hidden files with non-standard extensions
find /var/www -name "*.php5" -o -name "*.phtml" -o \
-name "*.php7" -o -name "*.shtml" 2>/dev/null
# 4. Find anomalous SUID files
find / -perm -4000 -type f 2>/dev/null
# 5. Inspect suspicious processes
ps aux | grep -E "(bash|sh|perl|python)" | grep -v grep
Step 2: Python WebShell Scanner
The following Python script automates WebShell scanning with a three-way verdict: static signature match + entropy + file timestamp analysis.
#!/usr/bin/env python3
"""
WebShell scanner v1.0
Usage: python3 webshell_scanner.py /var/www
"""
import os, sys, re, math, collections
# High-risk function signatures
SUSPICIOUS_FUNCTIONS = {
'php': [
r'\beval\s*\(', r'\bsystem\s*\(', r'\bshell_exec\s*\(',
r'\bexec\s*\(', r'\bpopen\s*\(', r'\bpassthru\s*\(',
r'\bassert\s*\(', r'\bcreate_function\s*\(',
r'\bbase64_decode\s*\(', r'\bgzinflate\s*\(',
r'\beval\(\s*\$(GET|POST|REQUEST|SERVER)',
],
'jsp': [
r'Runtime\.getRuntime\(\)', r'ProcessBuilder',
],
'asp': [
r'Eval\s*\(', r'Execute\s*\(', r'WScript\.Shell',
],
'py': [
r'\bexec\s*\(', r'\beval\s*\(', r'\b__import__\s*\(',
r'os\.system', r'subprocess\.',
],
}
def file_entropy(data):
"""Compute file Shannon entropy; high entropy may indicate obfuscated/encrypted code"""
if not data:
return 0
counter = collections.Counter(data)
total = len(data)
entropy = -sum((count/total) * math.log2(count/total)
for count in counter.values())
return entropy
def scan_file(filepath, ext):
"""Scan a single file"""
try:
with open(filepath, 'rb') as f:
raw = f.read()
except (IOError, PermissionError):
return None, [], 0
if raw[:100].count(b'\x00') > 10:
return None, [], 0
if len(raw) > 5 * 1024 * 1024:
return None, [], 0
text = raw.decode('utf-8', errors='ignore')
matches = []
funcs = SUSPICIOUS_FUNCTIONS.get(ext, [])
for pattern in funcs:
if re.search(pattern, text, re.IGNORECASE):
matches.append(pattern)
if not matches:
return None, [], 0
entropy = file_entropy(raw)
if len(matches) >= 3 or entropy > 6.0:
level = "high"
elif len(matches) >= 2:
level = "suspicious"
else:
level = "low"
return level, matches, entropy
def scan_directory(root_dir):
"""Recursively scan a directory"""
results = []
web_exts = ['php', 'jsp', 'asp', 'aspx', 'py', 'phtml']
for dirpath, _, filenames in os.walk(root_dir):
for fn in filenames:
ext = fn.rsplit('.', 1)[-1].lower() if '.' in fn else ''
if ext not in web_exts:
continue
fullpath = os.path.join(dirpath, fn)
try:
st = os.stat(fullpath)
except OSError:
continue
level, matches, entropy = scan_file(fullpath, ext)
if level:
results.append({
'path': fullpath, 'level': level,
'matches': [m[:40] for m in matches],
'entropy': round(entropy, 2),
'size': st.st_size, 'mtime': st.st_mtime,
})
return results
def print_report(results):
if not results:
print("No WebShell files found")
return
print("\n" + "=" * 70)
print(" WebShell scan report -- {} suspicious files found".format(len(results)))
print("=" * 70)
for r in sorted(results, key=lambda x: {'high':0,'suspicious':1,'low':2}[x['level']]):
from datetime import datetime
mtime_str = datetime.fromtimestamp(r['mtime']).strftime('%Y-%m-%d %H:%M')
print("\n{}".format(r['path']))
print(" risk: {} size: {} mtime: {} entropy: {}".format(
r['level'], r['size'], mtime_str, r['entropy']))
for m in r['matches']:
print(" - " + m)
if __name__ == '__main__':
if len(sys.argv) ")
sys.exit(1)
target = sys.argv[1]
if not os.path.isdir(target):
print("Error: directory does not exist: " + target)
sys.exit(1)
print("Scanning: " + target + "...")
results = scan_directory(target)
print_report(results)
Step 3: File Integrity Check
#!/bin/bash
# Integrity-check WordPress core files
WP_DIR="/var/www/html"
# Compute local file hashes
find "$WP_DIR" -type f -name "*.php" -not -path "*/wp-content/*" | \
while read f; do
md5sum "$f"
done > /tmp/local_hashes.txt
# Compare with official hashes (WordPress 6.x example)
# Download the official hash file first
# sha1sum -c wordpress-6.x.sha1 2>/dev/null | grep -v "OK$"
Step 4: Deep Analysis of Suspect Files
# 1. Inspect file head and tail
head -5 suspicious.php
tail -5 suspicious.php
# 2. Strip comments to view live code
grep -v "^\s*$" suspicious.php | grep -v "^\s*//" | \
grep -v "^\s*#" | grep -v "^\s*/\*"
# 3. Base64-decode suspicious strings
grep -oP "base64_decode\(['\"]\K[^'\"]+" suspicious.php | \
base64 -d 2>/dev/null
Step 5: Removal and Hardening
# 1. Back up suspect files (forensics)
cp suspicious.php /root/forensics/suspicious_$(date +%Y%m%d).php
# 2. Delete the WebShell
rm -f suspicious.php
# 3. Find and fix the intrusion vector
tail -500 /var/log/auth.log | grep "Accepted"
# 4. Rotate all passwords (DB, SSH, FTP, WP admin)
# 5. Update all software to latest versions
apt update && apt upgrade -y # Ubuntu/Debian
yum update -y # CentOS/RHEL
Verification
# 1. Run the scanner
python3 webshell_scanner.py /var/www
# 2. Verify the script detects a known WebShell
echo '' > /tmp/test_webshell.php
python3 webshell_scanner.py /tmp
# Should emit a high-risk warning
# 3. Clean up the test file
rm -f /tmp/test_webshell.php
FAQ
Q1: Which WebShells can signature scanning miss?
Advanced WebShells use multi-layer encoding (repeated Base64 + GZip + XOR), dynamic function calls ($func($_POST['cmd'])), or PHP 7/8 features to evade static detection. Countering that requires a dynamic execution sandbox (e.g. Cuckoo Sandbox) or behavior-based EDR.
Q2: After removal, how do you prevent re-compromise?
After cleanup you must close the entry point, or re-compromise can occur within 24 hours. Priority order: (1) update all web apps and plugins; (2) remove unused themes and plugins; (3) disable the file editor (define('DISALLOW_FILE_EDIT', true) in WordPress); (4) separate web directory owner from the runtime user (chown -R root:www-data /var/www and 644/755 perms); (5) deploy WAF rules (e.g. ModSecurity) to block file-upload flaws and RFI.
Summary
WebShell hunting is a core IR task; manual search does not scale—use an automated scanner. The three-way strategy (signatures + entropy + timeline) covers most common WebShells. Best defense remains prevention: least privilege, timely patches, file-integrity monitoring, and a WAF.