| Server IP : 103.175.220.74 / Your IP : 216.73.216.151 Web Server : nginx/1.18.0 System : Linux p-floo-wj1-db 5.4.0-216-generic #236-Ubuntu SMP Fri Apr 11 19:53:21 UTC 2025 x86_64 User : deploy ( 1003) PHP Version : 7.4.3-4ubuntu2.29 Disable Function : pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare, MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /home/deploy/wp2shell-scan/ |
Upload File : |
#!/usr/bin/env bash
# =============================================================================
# wp2shell-scan — detect & clean up wp2shell (CVE-2026-63030) compromise
# across one or many WordPress sites.
#
# wp2shell is the pre-auth RCE fixed in WordPress 7.0.2 / 6.9.5 / 6.8.6
# (2026-07-17). Post-exploitation it typically leaves:
# * a rogue administrator user_login=wpsvc_<hex>, email @wordpress-svc.internal
# (or @wordpress-noreply.net / *.internal)
# * a webshell disguised as a plugin wp-content/plugins/<name>-<6hex>/<same>.php
# (tiny PHP file, fake "Author: WordPress.org Community" header, ?c=<cmd>&t=<token>)
#
# Modes:
# (default) scan — READ ONLY. Report compromise. Never changes anything.
# --clean — Quarantine + remove backdoors (rogue admins, webshells),
# rotate wp-config salts. Requires --yes (or prompts).
#
# Bulk: point it at a base dir (--base) to auto-discover every WP install,
# or pass --path for a single site. Repeatable.
#
# Requires: bash, find, grep, php-readable wp-config, and a `mysql`/`mariadb`
# client. WP-CLI is used when present (nicer user deletion) but is
# NOT required. Webshell detection is pure filesystem — no DB needed.
#
# License: MIT. Use at your own risk; SNAPSHOT the site before --clean.
# =============================================================================
set -uo pipefail
VERSION="1.0.2"
MODE="scan"; FORMAT="text"; ASSUME_YES=0; ROTATE_SALTS=1
SINCE="2026-07-16 00:00:00"
QUAR="${WP2SHELL_QUARANTINE:-./wp2shell-quarantine-$(date +%Y%m%d-%H%M%S 2>/dev/null || echo run)}"
declare -a BASES=() ROOTS=() SQLFILES=()
c_red=$'\033[31m'; c_yel=$'\033[33m'; c_grn=$'\033[32m'; c_dim=$'\033[2m'; c_off=$'\033[0m'
[ -t 1 ] || { c_red=; c_yel=; c_grn=; c_dim=; c_off=; }
usage(){ cat <<USG
wp2shell-scan v$VERSION — detect & clean wp2shell (CVE-2026-63030) compromise.
wp2shell-scan.sh [--base DIR]... [--path WP_ROOT]... [options]
Discovery (pick one or more; repeatable):
--base DIR Auto-discover every WordPress install under DIR (finds wp-load.php).
--path DIR A single WordPress root (contains wp-load.php / wp-config.php).
(none) Tries common layouts: /var/www/*, /home/*/public_html, /home/*/web/*/public_html, /srv/www/*
Actions:
(default) scan — read-only report.
--clean Quarantine & remove backdoors, then rotate salts. Needs --yes.
--no-rotate With --clean: do NOT rotate wp-config salts.
--yes Non-interactive; proceed with --clean without prompting.
Backups/snapshots:
--sql FILE Scan a database dump (.sql or .sql.gz) for the attacker's admin accounts,
WITHOUT restoring it. Use this to vet a backup/snapshot/template BEFORE you
restore — a backup taken while compromised will re-infect the site. Repeatable.
Output:
--json Machine-readable JSON to stdout.
--since 'Y-M-D H:M:S' Admin-registration window start (default $SINCE).
--quarantine DIR Where removed artifacts are backed up (default ./wp2shell-quarantine-*).
Exit: 0 clean, 1 compromise found (scan) / cleaned (clean), 2 usage/error.
USG
}
while [ $# -gt 0 ]; do case "$1" in
--base) BASES+=("$2"); shift 2;;
--path) ROOTS+=("$2"); shift 2;;
--clean) MODE="clean"; shift;;
--no-rotate) ROTATE_SALTS=0; shift;;
--yes|-y) ASSUME_YES=1; shift;;
--json) FORMAT="json"; shift;;
--since) SINCE="$2"; shift 2;;
--quarantine) QUAR="$2"; shift 2;;
--sql) SQLFILES+=("$2"); shift 2;;
-h|--help) usage; exit 0;;
*) echo "unknown arg: $1" >&2; usage; exit 2;;
esac; done
log(){ [ "$FORMAT" = json ] || echo "$@" >&2; }
# ---- discovery -------------------------------------------------------------
discover(){
local d
for d in "${ROOTS[@]:-}"; do [ -n "$d" ] && [ -f "$d/wp-load.php" ] && echo "$d"; done
for d in "${BASES[@]:-}"; do [ -n "$d" ] && find "$d" -maxdepth 6 -name wp-load.php -not -path '*/wp-content/*' 2>/dev/null | sed 's#/wp-load.php$##'; done
if [ ${#ROOTS[@]} -eq 0 ] && [ ${#BASES[@]} -eq 0 ]; then
for d in /var/www/* /var/www/*/htdocs /home/*/public_html /home/*/web/*/public_html /srv/www/*; do
[ -f "$d/wp-load.php" ] && echo "$d"
done 2>/dev/null
fi
}
# ---- wp-config helpers -----------------------------------------------------
cfg_get(){ grep -m1 -E "define\(\s*['\"]$1['\"]" "$2" 2>/dev/null | sed -E "s/.*define\(\s*['\"]$1['\"]\s*,\s*['\"]([^'\"]*)['\"].*/\1/"; }
cfg_prefix(){ grep -m1 -E '\$table_prefix' "$1" 2>/dev/null | sed -E "s/.*=\s*['\"]([^'\"]*)['\"].*/\1/"; }
# Run a SELECT and print raw rows (tab separated). Uses site DB creds from wp-config.
db_query(){ # $1=wproot $2=sql
local cfg="$1/wp-config.php" dbn dbu dbp dbh
dbn=$(cfg_get DB_NAME "$cfg"); dbu=$(cfg_get DB_USER "$cfg"); dbp=$(cfg_get DB_PASSWORD "$cfg"); dbh=$(cfg_get DB_HOST "$cfg")
[ -n "$dbn" ] || return 1
local host="${dbh:-localhost}" sock="" port=""
case "$host" in *:*) port="--port=${host##*:}"; host="${host%%:*}";; esac
# As root on a hosting box, socket auth often works with no creds; try creds first, then socket.
mysql --skip-column-names --batch -h "$host" $port -u "$dbu" -p"$dbp" "$dbn" -e "$2" 2>/dev/null \
|| mysql --skip-column-names --batch "$dbn" -e "$2" 2>/dev/null
}
# ---- detection: backdoor admins -------------------------------------------
find_backdoor_admins(){ # $1=wproot -> rows: ID<TAB>login<TAB>email<TAB>registered
local pfx; pfx=$(cfg_prefix "$1/wp-config.php"); [ -n "$pfx" ] || pfx="wp_"
db_query "$1" "SELECT u.ID,u.user_login,u.user_email,u.user_registered
FROM \`${pfx}users\` u JOIN \`${pfx}usermeta\` m ON u.ID=m.user_id
WHERE m.meta_key='${pfx}capabilities' AND m.meta_value LIKE '%administrator%'
AND ( u.user_login LIKE 'wpsvc\\_%'
OR u.user_login LIKE 'wp2\\_%'
OR u.user_login LIKE 'w2s\\_%'
OR u.user_email LIKE '%wp2shell%'
OR u.user_email LIKE '%shellcode%'
OR u.user_email LIKE '%wordpress-svc%'
OR u.user_email LIKE '%wordpress-noreply%'
OR u.user_email LIKE '%@x.lol' );"
# NOTE: attacker admins appear under several variants (wpsvc_/wp2_/w2s_ usernames;
# @wp2shell.*, @shellcode.*, @wordpress-svc.internal, @wordpress-noreply.net, @x.lol emails).
# Deliberately NOT matching @system.local / *.internal broadly — @system.local is a common
# LEGIT placeholder admin-email pattern on managed hosts; matching it would delete real admins.
}
# ---- detection: webshells (filesystem, no DB) ------------------------------
find_webshells(){ # $1=wproot -> one path per line
local wc="$1/wp-content"
[ -d "$wc" ] || return 0
# (a) plugin folders named like a real plugin but ending in -<6 hex>
find "$wc/plugins" -maxdepth 1 -type d -regextype posix-extended -regex '.*-[0-9a-f]{6}$' 2>/dev/null \
| while read -r d; do find "$d" -maxdepth 1 -name '*.php' 2>/dev/null; done
# (b) small PHP taking a command via $_GET + a token/exec sink. Scoped to the dirs a
# webshell actually lands in (plugins/mu-plugins/uploads) so it stays fast on sites
# with a large wp-content (grepping ALL of wp-content can take minutes).
for sub in plugins mu-plugins uploads; do
[ -d "$wc/$sub" ] || continue
find "$wc/$sub" -type f -name '*.php' -size -8k 2>/dev/null | while read -r f; do
grep -qE "\\\$_(GET|POST|REQUEST)\[['\"]c['\"]\]" "$f" 2>/dev/null \
&& grep -qE "hash_equals\(|(system|shell_exec|passthru|proc_open|popen|exec|eval|assert)\s*\(" "$f" 2>/dev/null \
&& echo "$f"
done
done
}
# ---- cleanup ---------------------------------------------------------------
quarantine(){ mkdir -p "$QUAR" 2>/dev/null; }
clean_admin(){ # $1=wproot $2=userID $3=login
local pfx; pfx=$(cfg_prefix "$1/wp-config.php"); [ -n "$pfx" ] || pfx="wp_"
quarantine
db_query "$1" "SELECT * FROM \`${pfx}users\` WHERE ID=$2; SELECT * FROM \`${pfx}usermeta\` WHERE user_id=$2;" \
> "$QUAR/admin_${3}_$(basename "$1").sql.txt" 2>/dev/null
if command -v wp >/dev/null 2>&1; then
local owner; owner=$(stat -c '%U' "$1" 2>/dev/null)
sudo -u "${owner:-root}" -- wp --path="$1" user delete "$2" --yes --network >/dev/null 2>&1 \
|| sudo -u "${owner:-root}" -- wp --path="$1" user delete "$2" --yes >/dev/null 2>&1 \
|| db_query "$1" "DELETE FROM \`${pfx}users\` WHERE ID=$2; DELETE FROM \`${pfx}usermeta\` WHERE user_id=$2;"
else
db_query "$1" "DELETE FROM \`${pfx}users\` WHERE ID=$2; DELETE FROM \`${pfx}usermeta\` WHERE user_id=$2;"
fi
}
clean_webshell(){ # $1=wproot $2=phpfile
quarantine
local dir; dir=$(dirname "$2")
# if it's an isolated disguised-plugin dir, quarantine the whole dir; else just the file
if echo "$dir" | grep -qE '/plugins/[^/]+-[0-9a-f]{6}$'; then
mv "$dir" "$QUAR/" 2>/dev/null || rm -rf "$dir"
else
mkdir -p "$QUAR/files" 2>/dev/null; mv "$2" "$QUAR/files/" 2>/dev/null || rm -f "$2"
fi
}
rotate_salts(){ # $1=wproot
local cfg="$1/wp-config.php"; [ -w "$cfg" ] || return 1
if command -v wp >/dev/null 2>&1; then
local owner; owner=$(stat -c '%U' "$1" 2>/dev/null)
sudo -u "${owner:-root}" -- wp --path="$1" config shuffle-salts >/dev/null 2>&1 && return 0
fi
local k rnd; cp -a "$cfg" "$QUAR/wp-config.$(basename "$1").bak" 2>/dev/null
for k in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; do
rnd=$(head -c 64 /dev/urandom | base64 | tr -d '\n/+=' | head -c 64)
if grep -qE "define\(\s*['\"]$k['\"]" "$cfg"; then
sed -i -E "s|(define\(\s*['\"]$k['\"]\s*,\s*['\"]).*(['\"]\s*\)\s*;)|\1${rnd}\2|" "$cfg"
fi
done
}
# ---- scan a DB dump / backup / template snapshot ------------------------------
SQL_SIG="wpsvc_[0-9a-f]+|wp2_[0-9a-f]+|w2s_[0-9a-f]+|[A-Za-z0-9_.-]+@(wordpress-svc|wordpress-noreply|wp2shell|shellcode)[A-Za-z0-9.-]*"
scan_sql(){ # $1=file -> 0 clean, 1 poisoned
local f="$1" hits reader=cat
[ -f "$f" ] || { echo " ${c_yel}[skip]${c_off} $f (not found)"; return 0; }
case "$f" in *.gz) reader="gzip -dc";; esac
hits=$($reader "$f" 2>/dev/null | grep -oaE "$SQL_SIG" | sort -u)
if [ -n "$hits" ]; then
echo "${c_red}[POISONED BACKUP]${c_off} $f"
printf ' %s\n' $hits
return 1
fi
echo "${c_grn}[clean]${c_off} ${c_dim}$f${c_off}"; return 0
}
# ---- per-site processing ---------------------------------------------------
TOTAL=0; CLEAN=0; SUSP=0; COMP=0; CLEANED=0
JSON_ROWS=()
process(){ # $1=wproot
local root="$1" name status="clean"; name=$(basename "$(dirname "$root")")/$(basename "$root")
[ -f "$root/wp-config.php" ] || name=$(basename "$root")
TOTAL=$((TOTAL+1))
local admins webshells; admins=$(find_backdoor_admins "$root"); webshells=$(find_webshells "$root" | sort -u)
local nA=0 nW=0; [ -n "$admins" ] && nA=$(printf '%s\n' "$admins" | grep -c .); [ -n "$webshells" ] && nW=$(printf '%s\n' "$webshells" | grep -c .)
if [ "$nA" -gt 0 ] || [ "$nW" -gt 0 ]; then status="COMPROMISED"; COMP=$((COMP+1)); fi
if [ "$FORMAT" = json ]; then
local aj wj; aj=$(printf '%s\n' "$admins" | awk 'NF{printf "{\"id\":\"%s\",\"login\":\"%s\",\"email\":\"%s\",\"registered\":\"%s %s\"},",$1,$2,$3,$4,$5}' | sed 's/,$//')
wj=$(printf '%s\n' "$webshells" | awk 'NF{printf "\"%s\",",$0}' | sed 's/,$//')
JSON_ROWS+=("{\"site\":\"$root\",\"status\":\"$status\",\"backdoor_admins\":[${aj}],\"webshells\":[${wj}]}")
else
if [ "$status" = COMPROMISED ]; then
echo "${c_red}[COMPROMISED]${c_off} $root — ${nA} backdoor admin(s), ${nW} webshell(s)"
printf '%s\n' "$admins" | awk 'NF{print " admin: ID="$1" "$2" <"$3"> ("$4" "$5")"}'
printf '%s\n' "$webshells" | awk 'NF{print " webshell: "$0}'
else
echo "${c_grn}[clean]${c_off} ${c_dim}$root${c_off}"
CLEAN=$((CLEAN+1))
fi
fi
if [ "$MODE" = clean ] && [ "$status" = COMPROMISED ]; then
printf '%s\n' "$admins" | while IFS=$'\t' read -r id login email reg; do [ -n "$id" ] && clean_admin "$root" "$id" "$login"; done
printf '%s\n' "$webshells" | while read -r f; do [ -n "$f" ] && clean_webshell "$root" "$f"; done
[ "$ROTATE_SALTS" = 1 ] && rotate_salts "$root"
CLEANED=$((CLEANED+1))
log " ${c_yel}cleaned${c_off} $root (artifacts quarantined in $QUAR)"
fi
}
# ---- main ------------------------------------------------------------------
if [ ${#SQLFILES[@]} -gt 0 ]; then
log "wp2shell-scan v$VERSION — scanning ${#SQLFILES[@]} database dump(s)"
bad=0
for f in "${SQLFILES[@]}"; do scan_sql "$f" || bad=$((bad+1)); done
echo "----------------------------------------------------------------"
echo "dumps scanned=${#SQLFILES[@]} poisoned=$bad"
[ "$bad" -gt 0 ] && { echo "${c_yel}A poisoned backup will RE-INFECT any site you restore it to. Do not restore it; take a fresh backup after cleaning the live site.${c_off}"; exit 1; }
exit 0
fi
mapfile -t SITES < <(discover | awk 'NF' | sort -u)
[ ${#SITES[@]} -gt 0 ] || { log "No WordPress installs found. Use --path or --base."; exit 2; }
if [ "$MODE" = clean ] && [ "$ASSUME_YES" != 1 ]; then
log "${c_yel}--clean will DELETE rogue admins + webshells and rotate salts on ${#SITES[@]} site(s).${c_off}"
log "SNAPSHOT first. Continue? type: yes"
read -r ans; [ "$ans" = yes ] || { log "aborted."; exit 2; }
fi
log "wp2shell-scan v$VERSION — mode=$MODE, ${#SITES[@]} site(s), since='$SINCE'"
for s in "${SITES[@]}"; do process "$s"; done
if [ "$FORMAT" = json ]; then
printf '{"version":"%s","mode":"%s","scanned":%s,"compromised":%s,"cleaned":%s,"sites":[%s]}\n' \
"$VERSION" "$MODE" "$TOTAL" "$COMP" "$CLEANED" "$(IFS=,; echo "${JSON_ROWS[*]:-}")"
else
echo "----------------------------------------------------------------"
echo "scanned=$TOTAL ${c_grn}clean=$CLEAN${c_off} ${c_red}compromised=$COMP${c_off} cleaned=$CLEANED"
[ "$COMP" -gt 0 ] && [ "$MODE" = scan ] && echo "Re-run with ${c_yel}--clean --yes${c_off} to remediate (snapshot first)."
[ "$CLEANED" -gt 0 ] && { echo "Quarantined artifacts: $QUAR | Next: update WP core to 7.0.2/6.9.5/6.8.6 + reset admin passwords."; echo "${c_yel}IMPORTANT: cleaning the live site does NOT clean your BACKUPS, SNAPSHOTS or STAGING TEMPLATES.${c_off}"; echo "A backup taken while compromised still contains the backdoor and will re-infect on restore."; echo "Vet them with: $0 --sql /path/to/backup.sql then take a FRESH backup now that the site is clean."; }
fi
[ "$COMP" -gt 0 ] && exit 1 || exit 0