WordPress Malware Detection and Cleanup Using WP-CLI

WordPress malware infections compromise site security, steal sensitive data, and damage reputation. WP-CLI provides powerful command-line tools for detecting, analyzing, and removing malware efficiently, often faster and more thoroughly than manual cleanup or GUI-based security plugins.

Understanding WordPress Malware

Malware manifests in various forms: backdoors granting unauthorized access, spam injection contaminating content, redirect scripts hijacking visitors, cryptominers consuming server resources, and data exfiltration code stealing information. Understanding these patterns helps identify and remove infections effectively.

Common infection vectors include vulnerable plugins or themes, weak passwords, compromised hosting environments, and outdated WordPress core. WP-CLI enables systematic scanning and cleanup across all potential infection points.

Initial Assessment and Backup

Before attempting cleanup, assess the infection scope and create comprehensive backups. This ensures you can recover if cleanup causes issues.

#!/bin/bash
# pre-cleanup-assessment.sh - Initial malware assessment

WP_PATH="/var/www/html"
BACKUP_DIR="/backups/malware-cleanup"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)

mkdir -p "$BACKUP_DIR"

echo "=== Pre-Cleanup Assessment ==="

# Create complete backup
echo "Creating backup..."
wp --path="$WP_PATH" db export "$BACKUP_DIR/database-$TIMESTAMP.sql"
tar -czf "$BACKUP_DIR/files-$TIMESTAMP.tar.gz" -C "$(dirname $WP_PATH)" "$(basename $WP_PATH)"

# Get WordPress version
WP_VERSION=$(wp --path="$WP_PATH" core version)
echo "WordPress Version: $WP_VERSION"

# Check for core file modifications
echo "Checking core file integrity..."
wp --path="$WP_PATH" core verify-checksums

# List all users (especially admins)
echo "Admin users:"
wp --path="$WP_PATH" user list --role=administrator --fields=ID,user_login,user_email

# Check for suspicious users
echo "Recently created users:"
wp --path="$WP_PATH" user list --format=json | \
    jq -r '.[] | select(.roles | contains(["administrator"])) | "\(.ID)\t\(.user_login)\t\(.user_registered)"'

# List active plugins
echo "Active plugins:"
wp --path="$WP_PATH" plugin list --status=active --fields=name,version,update

# Check for suspicious scheduled tasks
echo "Scheduled cron events:"
wp --path="$WP_PATH" cron event list --format=table

echo "Assessment completed. Backup saved to: $BACKUP_DIR"

Core File Integrity Verification

WordPress core files rarely need modification. Any changes often indicate compromise.

# Verify core file checksums
wp core verify-checksums

# Get detailed output of modified files
wp core verify-checksums --format=json | jq -r '.[] | select(.status == "modified") | .file'

# Reinstall WordPress core (preserves wp-content and wp-config.php)
wp core download --force --skip-content

# Verify specific file
wp core verify-checksums wp-admin/index.php

Automated core file restoration:

#!/bin/bash
# restore-core-files.sh - Restore modified core files

WP_PATH="/var/www/html"

echo "Checking for modified core files..."

# Get list of modified files
MODIFIED_FILES=$(wp --path="$WP_PATH" core verify-checksums --format=csv | grep -v "^file,status$" | grep "should_not_exist\|not_found\|modified" | cut -d',' -f1)

if [ -z "$MODIFIED_FILES" ]; then
    echo "No modified core files found"
    exit 0
fi

echo "Modified files found:"
echo "$MODIFIED_FILES"

# Backup modified files before restoration
BACKUP_DIR="/backups/core-files-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"

echo "$MODIFIED_FILES" | while read -r file; do
    if [ -f "$WP_PATH/$file" ]; then
        BACKUP_PATH="$BACKUP_DIR/$(dirname $file)"
        mkdir -p "$BACKUP_PATH"
        cp "$WP_PATH/$file" "$BACKUP_PATH/"
        echo "Backed up: $file"
    fi
done

# Reinstall core
echo "Reinstalling WordPress core..."
wp --path="$WP_PATH" core download --force --skip-content

# Verify restoration
if wp --path="$WP_PATH" core verify-checksums; then
    echo "Core files successfully restored"
else
    echo "Warning: Some core files still show modifications"
fi

Database Scanning and Cleanup

Malware often injects malicious code into the database, particularly in posts, options, and user metadata.

# Search database for common malware patterns
wp db query "SELECT * FROM wp_posts WHERE post_content LIKE '%base64_decode%' OR post_content LIKE '%eval(%' OR post_content LIKE '%gzinflate%'"

# Search options table
wp db query "SELECT * FROM wp_options WHERE option_value LIKE '%eval(%' OR option_value LIKE '%base64_decode%'"

# Find suspicious JavaScript injections
wp db query "SELECT post_id, post_title FROM wp_posts WHERE post_content LIKE '%<script%' AND post_type='post'"

# Check for malicious admin users
wp db query "SELECT * FROM wp_users WHERE user_login LIKE '%admin%' OR user_login REGEXP '^[0-9]+$'"

# Find posts with suspicious meta data
wp db query "SELECT meta_id, post_id, meta_key, meta_value FROM wp_postmeta WHERE meta_value LIKE '%<script%' OR meta_value LIKE '%eval(%'"

Comprehensive database scanning script:

#!/bin/bash
# scan-database.sh - Scan database for malware patterns

WP_PATH="/var/www/html"
REPORT_FILE="/var/log/malware-scan-$(date +%Y%m%d-%H%M%S).txt"

echo "=== WordPress Database Malware Scan ===" | tee "$REPORT_FILE"
echo "Date: $(date)" | tee -a "$REPORT_FILE"
echo "" | tee -a "$REPORT_FILE"

# Malware patterns to search for
PATTERNS=(
    "base64_decode"
    "eval("
    "gzinflate"
    "str_rot13"
    "preg_replace.*\/e"
    "assert("
    "stripslashes"
    "iframe src"
    "script src=.*http"
    "document.write"
)

# Tables to scan
TABLES=(
    "wp_posts"
    "wp_options"
    "wp_postmeta"
    "wp_usermeta"
    "wp_comments"
)

for pattern in "${PATTERNS[@]}"; do
    echo "Searching for pattern: $pattern" | tee -a "$REPORT_FILE"

    for table in "${TABLES[@]}"; do
        COUNT=$(wp --path="$WP_PATH" db query "SELECT COUNT(*) as count FROM $table" --skip-column-names 2>/dev/null)

        if [ "$COUNT" -gt 0 ]; then
            # Get column names for table
            COLUMNS=$(wp --path="$WP_PATH" db query "SHOW COLUMNS FROM $table" --skip-column-names | awk '{print $1}' | grep -E 'content|value|text')

            for column in $COLUMNS; do
                MATCHES=$(wp --path="$WP_PATH" db query "
                    SELECT COUNT(*) FROM $table
                    WHERE $column LIKE '%$pattern%'
                " --skip-column-names 2>/dev/null || echo "0")

                if [ "$MATCHES" -gt 0 ]; then
                    echo "  Found $MATCHES matches in $table.$column" | tee -a "$REPORT_FILE"

                    # Get sample matches
                    wp --path="$WP_PATH" db query "
                        SELECT * FROM $table
                        WHERE $column LIKE '%$pattern%'
                        LIMIT 3
                    " >> "$REPORT_FILE"
                fi
            done
        fi
    done
    echo "" | tee -a "$REPORT_FILE"
done

echo "Scan completed. Report saved to: $REPORT_FILE"

Plugin and Theme Scanning

Compromised or nulled plugins/themes are common infection sources.

# List all plugins
wp plugin list --fields=name,status,version,update

# Check for plugins from suspicious sources
wp plugin list --status=active --format=json | jq -r '.[] | select(.update == "available") | .name'

# Deactivate all plugins (for diagnosis)
wp plugin deactivate --all

# Reactivate plugins one by one
wp plugin activate plugin-name

# Delete inactive plugins
wp plugin delete $(wp plugin list --status=inactive --field=name)

# Check theme integrity
wp theme list --fields=name,status,version,update

# Switch to default theme
wp theme activate twentytwentyfour

# Delete unused themes
wp theme delete $(wp theme list --status=inactive --field=name)

Scan for malicious code in plugins and themes:

#!/bin/bash
# scan-plugins-themes.sh - Scan for malware in plugins and themes

WP_PATH="/var/www/html"
SCAN_LOG="/var/log/plugin-theme-scan-$(date +%Y%m%d).txt"

echo "=== Scanning Plugins and Themes for Malware ===" | tee "$SCAN_LOG"

# Malicious code patterns
PATTERNS=(
    "eval\s*\("
    "base64_decode"
    "gzinflate"
    "str_rot13"
    "system\s*\("
    "exec\s*\("
    "shell_exec"
    "passthru"
    "preg_replace.*\/e"
    "\$_GET\[.*\]\(.*\)"
    "\$_POST\[.*\]\(.*\)"
)

# Scan plugins
echo "Scanning plugins..." | tee -a "$SCAN_LOG"
for plugin_dir in "$WP_PATH/wp-content/plugins/"*/; do
    PLUGIN_NAME=$(basename "$plugin_dir")
    echo "Checking: $PLUGIN_NAME" | tee -a "$SCAN_LOG"

    for pattern in "${PATTERNS[@]}"; do
        MATCHES=$(grep -r -i -E "$pattern" "$plugin_dir" 2>/dev/null | wc -l)

        if [ "$MATCHES" -gt 0 ]; then
            echo "  WARNING: Found $MATCHES matches for '$pattern'" | tee -a "$SCAN_LOG"
            grep -r -i -n -E "$pattern" "$plugin_dir" | head -5 >> "$SCAN_LOG"
        fi
    done
done

# Scan themes
echo "Scanning themes..." | tee -a "$SCAN_LOG"
for theme_dir in "$WP_PATH/wp-content/themes/"*/; do
    THEME_NAME=$(basename "$theme_dir")
    echo "Checking: $THEME_NAME" | tee -a "$SCAN_LOG"

    for pattern in "${PATTERNS[@]}"; do
        MATCHES=$(grep -r -i -E "$pattern" "$theme_dir" 2>/dev/null | wc -l)

        if [ "$MATCHES" -gt 0 ]; then
            echo "  WARNING: Found $MATCHES matches for '$pattern'" | tee -a "$SCAN_LOG"
            grep -r -i -n -E "$pattern" "$theme_dir" | head -5 >> "$SCAN_LOG"
        fi
    done
done

echo "Scan completed. Results in: $SCAN_LOG"

Removing Malicious Users and Content

Delete unauthorized admin accounts and clean infected content.

# List all administrators
wp user list --role=administrator --format=table

# Delete suspicious user
wp user delete 999 --yes --reassign=1

# Remove spam posts
wp post delete $(wp post list --post_status=spam --format=ids) --force

# Clean spam comments
wp comment delete $(wp comment list --status=spam --format=ids) --force

# Remove posts with malicious content
wp db query "DELETE FROM wp_posts WHERE post_content LIKE '%malicious-pattern%'"

# Clean options table
wp option delete suspicious_option_name

# Remove malicious scheduled tasks
wp cron event delete suspicious_hook

Automated malicious user cleanup:

#!/bin/bash
# cleanup-users.sh - Remove suspicious WordPress users

WP_PATH="/var/www/html"

echo "Scanning for suspicious users..."

# Get all admin users
ADMIN_USERS=$(wp --path="$WP_PATH" user list --role=administrator --format=json)

# Check for users with suspicious characteristics
echo "$ADMIN_USERS" | jq -r '.[] | select(
    .user_login | test("^[0-9]+$|admin[0-9]+|wp[-_]admin|support[0-9]+")
) | .ID' | while read -r user_id; do
    USER_LOGIN=$(wp --path="$WP_PATH" user get $user_id --field=user_login)
    USER_EMAIL=$(wp --path="$WP_PATH" user get $user_id --field=user_email)
    USER_REGISTERED=$(wp --path="$WP_PATH" user get $user_id --field=user_registered)

    echo "Suspicious user found:"
    echo "  ID: $user_id"
    echo "  Login: $USER_LOGIN"
    echo "  Email: $USER_EMAIL"
    echo "  Registered: $USER_REGISTERED"

    # Uncomment to actually delete
    # wp --path="$WP_PATH" user delete $user_id --yes --reassign=1
    # echo "  Deleted"
done

# Check for users with no posts and recently created
echo "Checking for recently created users with no activity..."
wp --path="$WP_PATH" user list --format=json | jq -r '.[] |
    select(.roles | contains(["administrator"])) |
    "\(.ID)\t\(.user_login)\t\(.user_registered)"'

File System Cleanup

Search for and remove malicious files in the WordPress installation.

#!/bin/bash
# filesystem-scan.sh - Scan for suspicious files

WP_PATH="/var/www/html"
SCAN_LOG="/var/log/filesystem-scan-$(date +%Y%m%d).txt"

echo "=== File System Malware Scan ===" | tee "$SCAN_LOG"

# Find recently modified files (last 7 days)
echo "Recently modified files:" | tee -a "$SCAN_LOG"
find "$WP_PATH" -type f -mtime -7 -not -path "*/cache/*" -not -path "*/uploads/*" | tee -a "$SCAN_LOG"

# Find PHP files with suspicious names
echo "Suspicious PHP files:" | tee -a "$SCAN_LOG"
find "$WP_PATH" -type f \( \
    -name "*.php.*" -o \
    -name "*.suspected" -o \
    -name "*.bak.php" -o \
    -name "*eval*.php" -o \
    -name "*base64*.php" -o \
    -iname "wp-config.php.bak" \
\) | tee -a "$SCAN_LOG"

# Find files with suspicious permissions (world-writable)
echo "World-writable files:" | tee -a "$SCAN_LOG"
find "$WP_PATH" -type f -perm -0002 | tee -a "$SCAN_LOG"

# Find hidden PHP files
echo "Hidden PHP files:" | tee -a "$SCAN_LOG"
find "$WP_PATH" -type f -name ".*\.php" | tee -a "$SCAN_LOG"

# Find files owned by wrong user (should be www-data or similar)
echo "Files with incorrect ownership:" | tee -a "$SCAN_LOG"
find "$WP_PATH" -type f ! -user www-data ! -user root | head -20 | tee -a "$SCAN_LOG"

# Search for base64 encoded strings in PHP files
echo "Files containing base64 encoding:" | tee -a "$SCAN_LOG"
grep -r -l "base64_decode" "$WP_PATH"/*.php "$WP_PATH"/wp-includes/*.php "$WP_PATH"/wp-admin/*.php 2>/dev/null | tee -a "$SCAN_LOG"

echo "Scan completed: $SCAN_LOG"

Security Hardening Post-Cleanup

After malware removal, implement security measures to prevent reinfection.

#!/bin/bash
# security-hardening.sh - Harden WordPress after cleanup

WP_PATH="/var/www/html"

echo "Applying security hardening..."

# Update all components
wp --path="$WP_PATH" core update
wp --path="$WP_PATH" plugin update --all
wp --path="$WP_PATH" theme update --all

# Set secure file permissions
find "$WP_PATH" -type d -exec chmod 755 {} \;
find "$WP_PATH" -type f -exec chmod 644 {} \;
chmod 600 "$WP_PATH/wp-config.php"

# Disable file editing
wp --path="$WP_PATH" config set DISALLOW_FILE_EDIT true --raw --type=constant

# Force SSL for admin
wp --path="$WP_PATH" config set FORCE_SSL_ADMIN true --raw --type=constant

# Set security keys
wp --path="$WP_PATH" config shuffle-salts

# Remove inactive plugins and themes
wp --path="$WP_PATH" plugin delete $(wp --path="$WP_PATH" plugin list --status=inactive --field=name)
wp --path="$WP_PATH" theme delete $(wp --path="$WP_PATH" theme list --status=inactive --field=name)

# Disable XML-RPC if not needed
cat >> "$WP_PATH/wp-config.php" << 'EOF'
add_filter('xmlrpc_enabled', '__return_false');
EOF

# Install and configure security plugin
wp --path="$WP_PATH" plugin install wordfence --activate
wp --path="$WP_PATH" option update wordfence_scan_enabled 1

# Change all user passwords
echo "Resetting all user passwords..."
for user_id in $(wp --path="$WP_PATH" user list --field=ID); do
    NEW_PASS=$(openssl rand -base64 16)
    wp --path="$WP_PATH" user update $user_id --user_pass="$NEW_PASS"
    echo "User ID $user_id password reset"
done

# Clean and optimize database
wp --path="$WP_PATH" db optimize

echo "Security hardening completed"

Monitoring and Prevention

Set up monitoring to detect future compromises early.

#!/bin/bash
# monitor-integrity.sh - Monitor WordPress for changes

WP_PATH="/var/www/html"
HASH_FILE="/var/backups/wp-hashes.txt"
ALERT_EMAIL="admin@example.com"

# Create hash database if it doesn't exist
if [ ! -f "$HASH_FILE" ]; then
    echo "Creating initial hash database..."
    find "$WP_PATH" -type f ! -path "*/uploads/*" ! -path "*/cache/*" -exec md5sum {} + > "$HASH_FILE"
    echo "Hash database created"
    exit 0
fi

# Check for changes
TEMP_HASH="/tmp/wp-hashes-temp.txt"
find "$WP_PATH" -type f ! -path "*/uploads/*" ! -path "*/cache/*" -exec md5sum {} + > "$TEMP_HASH"

# Compare hashes
CHANGES=$(diff "$HASH_FILE" "$TEMP_HASH" | grep "^[<>]" | wc -l)

if [ "$CHANGES" -gt 0 ]; then
    echo "WARNING: $CHANGES files have changed"

    # Send alert
    diff "$HASH_FILE" "$TEMP_HASH" | mail -s "WordPress File Changes Detected" "$ALERT_EMAIL"

    # Update hash database
    mv "$TEMP_HASH" "$HASH_FILE"
else
    echo "No changes detected"
    rm "$TEMP_HASH"
fi