← News
/PLAYBOOK · 2026-07-30

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

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.