Edit: /opt/dedrads/wp-xray.sh (85816B)
#!/usr/bin/bash
#
# wp-xray.sh -- WordPress incident triage. Read-only.
#
# It reports. It changes nothing. There is no second mode, no --execute, no
# repair path. The docroot and the database are read and never written.
#
# Design rules that are not negotiable, and why:
#
# 1. It never executes site PHP. A compromised wp-config.php, drop-in, or
# mu-plugin runs on any WP bootstrap -- including wp-cli's. So this script
# reads wp-config.php with PHP's tokenizer (lexes, does not execute),
# queries the database with the mysql client directly, and verifies core
# against wordpress.org's published checksum manifest by hashing files
# itself (modified, missing, and unexpected executables in
# wp-admin/wp-includes and at the docroot root). wp-cli is never invoked.
#
# 2. Only literal wp-config.php values are read. getenv(), concatenation and
# other non-literals are reported as non-literal, never guessed. Every
# non-standard directive is reported for review.
#
# 3. Everything the script writes goes into the workdir: the report, the log
# and the supporting file lists. The workdir is mode 700 and must not be
# web-reachable -- the report carries database names, admin emails and
# malware paths.
#
# 4. Expensive walks are bounded. Cache dirs and pathological inode counts
# would otherwise make a live ticket unworkable.
#
# Run as the site user. Reading as that user is what shows you the site as the
# web application sees it, and it keeps the report files out of root's hands.
#
set -Eeuo pipefail
VERSION="2.2.0"
PROGNAME=${0##*/}
# Absolute path to this script, so the re-run commands printed for nested
# installations can be pasted as-is from a ticket into any shell. Falls back to
# the bare name if $0 is unresolvable (piped from stdin, exotic launcher).
SELF=$(realpath -e -- "$0" 2>/dev/null) || SELF=$PROGNAME
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
DOCROOT=""
SKIP_DB=0
SKIP_CORE_VERIFY=0
WORKDIR_BASE=""
SCAN_MAX_BYTES=$((3 * 1024 * 1024)) # skip grep on files larger than this
# Archive/backup artifacts. They waste quota, and a .sql dump under the docroot
# is publicly readable on most hosts.
JUNK_PATTERNS=(
'*.sql' '*.sql.gz' '*.sql.zip' '*.sql.bz2' '*.dump'
'*.zip' '*.tar' '*.tar.gz' '*.tgz' '*.gz' '*.bz2' '*.7z' '*.rar'
'*.wpress' '*.wpstg' '*.bak' '*.old' '*.orig' '*.save' '*.swp'
'*.log' '*error_log' 'error_log' '*.tmp'
# Old PHP/WordPress core dumps: multi-gig files named core or core.
'core' 'core.[0-9]*'
)
# wp-content directory basenames that are disposable and often dominate size
# and inodes (cache plugins, optimizers, Wordfence logs).
CACHE_DIR_NAMES=(
cache et-cache litespeed wflogs boost-cache
)
# Full-tree walks (sort every file by size, pattern scan) become unusable past this.
FILE_COUNT_WARN=250000
DIR_COUNT_WARN=100000
# Anything the web server may be persuaded to execute out of uploads/.
EXEC_EXTENSIONS=(
'php' 'php2' 'php3' 'php4' 'php5' 'php6' 'php7' 'php8' 'phps' 'pht'
'phtml' 'phar' 'inc' 'shtml' 'shtm' 'cgi' 'pl' 'py' 'sh' 'suspected'
)
# Drop-ins execute on every request and are not covered by core checksums.
DROPINS=(
advanced-cache.php db.php object-cache.php sunrise.php maintenance.php
db-error.php install.php php-error.php fatal-error-handler.php
blog-deleted.php blog-inactive.php blog-suspended.php
)
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
if [[ -t 1 ]]; then
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_BLU=$'\033[36m'
else
C_RESET=""; C_BOLD=""; C_DIM=""; C_RED=""; C_GRN=""; C_YEL=""; C_BLU=""
fi
REPORT_FILE="" # set once the workdir exists
# Strip CSI/ANSI colour codes for the ticket-friendly report file.
strip_ansi() { sed $'s/\033\\[[0-9;]*[[:alpha:]]//g'; }
# rpt: coloured (or plain) on the terminal; always plain in report.txt
rpt() {
printf '%s\n' "$*"
if [[ -n $REPORT_FILE ]]; then
printf '%s\n' "$*" | strip_ansi >>"$REPORT_FILE" || true
fi
}
hdr() { rpt ""; rpt "${C_BOLD}== $* ==${C_RESET}"; }
info() { printf '%s\n' "${C_BLU}--${C_RESET} $*" >&2; }
ok() { printf '%s\n' "${C_GRN}ok${C_RESET} $*" >&2; }
warn() { printf '%s\n' "${C_YEL}!!${C_RESET} $*" >&2; }
die() { printf '%s\n' "${C_RED}xx${C_RESET} $*" >&2; exit 1; }
# Findings are accumulated and re-printed as a ranked summary at the end.
FINDING_HIGH=(); FINDING_MED=(); FINDING_LOW=()
finding() {
case $1 in
high) FINDING_HIGH+=("$2") ;;
med) FINDING_MED+=("$2") ;;
*) FINDING_LOW+=("$2") ;;
esac
}
usage() {
cat <
The docroot and the database are read, never written. No site PHP is executed.
All output goes to a report in the workdir.
OPTIONS
--no-db Skip the database audit.
--no-core-verify Skip core checksum verification (faster on huge sites).
--workdir=PATH Where to put the report and the supporting file lists.
Default: \$HOME/wp-xray-
Must not be web-reachable: the report contains database
names, administrator emails and malware paths.
--scan-max=SIZE Skip malware grep on files larger than this. Accepts a byte
count or a K/M/G suffix. Default 3M.
-h, --help This.
EXAMPLES
$PROGNAME ~/public_html
$PROGNAME --no-db --workdir=/home/user/xray ~/public_html/blog
Run as the site user, never as root.
EOF
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
# parse_size 3145728 | 3M | 512K | 1G -> bytes on stdout, non-zero on garbage.
#
# This exists because find's -size takes its own suffixes. A bare "10M" reaching
# `-size -10Mc` makes find error out, the scan is wrapped in `|| true`, and the
# report then says "0 suspicious files" on a site full of shells. A tech reads
# that as clean. Refusing the input up front is the only safe behaviour.
parse_size() {
local v=$1 n u
[[ $v =~ ^([0-9]+)([KkMmGg]?)[Bb]?$ ]] || return 1
n=${BASH_REMATCH[1]}; u=${BASH_REMATCH[2]}
case $u in
K|k) n=$((n * 1024)) ;;
M|m) n=$((n * 1024 * 1024)) ;;
G|g) n=$((n * 1024 * 1024 * 1024)) ;;
esac
[[ $n -gt 0 ]] || return 1
printf '%s' "$n"
}
while [[ $# -gt 0 ]]; do
case $1 in
--no-db) SKIP_DB=1 ;;
--no-core-verify) SKIP_CORE_VERIFY=1 ;;
--workdir=*) WORKDIR_BASE=${1#*=} ;;
--scan-max=*) SCAN_MAX_BYTES=$(parse_size "${1#*=}") \
|| die "--scan-max needs a positive byte count, optionally suffixed K, M or G (got '${1#*=}')." ;;
-h|--help) usage; exit 0 ;;
--) shift; DOCROOT=${1:-}; break ;;
-*) die "Unknown option: $1 (try --help)" ;;
*) [[ -n $DOCROOT ]] && die "Only one docroot may be given."
DOCROOT=$1 ;;
esac
shift
done
[[ -n $DOCROOT ]] || { usage; exit 1; }
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
have() { command -v "$1" >/dev/null 2>&1; }
# http_get URL OUTFILE
http_get() {
if have curl; then
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 900 -o "$2" -- "$1"
else
wget -q --tries=3 --timeout=30 -O "$2" -- "$1"
fi
}
# http_body URL -> stdout, empty on failure
http_body() {
if have curl; then
curl -fsSL --retry 2 --connect-timeout 15 --max-time 120 -- "$1" 2>/dev/null || true
else
wget -q --tries=2 --timeout=30 -O - -- "$1" 2>/dev/null || true
fi
}
# strip_prefix PREFIX [FIELD] -- remove PREFIX from the start of tab-field FIELD
# (default 1) of each line on stdin, matched literally.
#
# sed would treat the path as a regular expression, so a docroot containing
# ".", "[" or "|" -- /home/u/public_html/site[1]/ is not hypothetical -- either
# fails to strip or strips the wrong thing. awk's index() has no such problem.
strip_prefix() {
awk -F'\t' -v p="$1" -v fn="${2:-1}" 'BEGIN{OFS="\t"; n=length(p)}
{ if (index($fn, p) == 1) $fn = substr($fn, n+1); print }'
}
human() { # bytes -> human
local b=${1:-0}
awk -v b="$b" 'BEGIN{
split("B KB MB GB TB",u," "); i=1;
while (b>=1024 && i<5) { b/=1024; i++ }
printf (i==1 ? "%d %s" : "%.1f %s"), b, u[i]
}'
}
# json_get JSONFILE PHP-EXPR-PATH -- our own PHP, not the site's.
json_get() {
php -d error_reporting=0 -r '
$j = json_decode(file_get_contents($argv[1]), true);
if ($j === null) { exit(1); }
$path = $argv[2] === "" ? [] : explode("/", $argv[2]);
foreach ($path as $k) {
if (is_array($j) && array_key_exists($k, $j)) { $j = $j[$k]; }
else { exit(1); }
}
if (is_array($j)) { foreach ($j as $k=>$v) { if (!is_array($v)) echo $k, "\t", $v, "\n"; } }
else { echo $j, "\n"; }
' "$1" "${2:-}" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
[[ ${EUID:-$(id -u)} -ne 0 ]] || die "Refusing to run as root. su to the site user first: su -s /bin/bash - "
for bin in find stat du df awk sed grep sort head md5sum; do
have "$bin" || die "Missing required command: $bin"
done
have php || die "Missing php CLI. Needed to parse wp-config.php safely and to read JSON."
have curl || have wget || die "Need curl or wget."
HAVE_MYSQL=0; have mysql && HAVE_MYSQL=1
DOCROOT=${DOCROOT/#\~/$HOME}
DOCROOT=$(realpath -e -- "$DOCROOT" 2>/dev/null) || die "Path does not exist: $DOCROOT"
[[ -d $DOCROOT ]] || die "Not a directory: $DOCROOT"
[[ $DOCROOT != "/" ]] || die "Refusing to operate on /"
[[ $DOCROOT != "$HOME" ]] || die "Refusing to operate on \$HOME itself ($HOME)"
[[ ${#DOCROOT} -gt 6 ]] || die "Suspiciously short path, refusing: $DOCROOT"
[[ -f $DOCROOT/wp-includes/version.php ]] \
|| die "Not a WordPress docroot (no wp-includes/version.php): $DOCROOT"
DOCROOT_UID=$(stat -c '%u' -- "$DOCROOT")
# Reading as another user is allowed -- the run is read-only -- but anything the
# site user can see and you cannot is silently missing from the report.
[[ $DOCROOT_UID -eq ${EUID:-$(id -u)} ]] \
|| warn "You do not own $DOCROOT (owned by uid $DOCROOT_UID). Unreadable files will be missing from the report. Run as that user for a complete picture."
PARENT=$(dirname -- "$DOCROOT")
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
# Default under $HOME so the workdir is not a sibling of a nested docroot
# sitting inside public_html (the report must not be web-served).
WORKDIR=${WORKDIR_BASE:-$HOME/wp-xray-$STAMP}
mkdir -p -- "$WORKDIR" || die "Could not create workdir: $WORKDIR"
WORKDIR=$(realpath -e -- "$WORKDIR" 2>/dev/null) || die "Workdir path could not be resolved: ${WORKDIR_BASE:-$HOME/wp-xray-$STAMP}"
chmod 700 -- "$WORKDIR"
# Refuse paths that are or sit under the live docroot.
case $WORKDIR in
"$DOCROOT"|"$DOCROOT"/*)
die "Workdir must not be inside the docroot (refusing $WORKDIR). Use --workdir under \$HOME."
;;
esac
# Nested-docroot case: parent is public_html (etc.) and a sibling workdir would be
# web-served. Also refuse any path with those names as a directory segment.
PARENT_BASE=$(basename -- "$PARENT")
case $PARENT_BASE in
public_html|httpdocs|www|htdocs)
case $WORKDIR in
"$PARENT"|"$PARENT"/*)
die "Workdir $WORKDIR sits under web root $PARENT. Use the default under \$HOME, or --workdir outside $PARENT_BASE."
;;
esac
;;
esac
case /$WORKDIR/ in
*/public_html/*|*/httpdocs/*|*/htdocs/*)
die "Workdir looks web-reachable ($WORKDIR). Choose a path under \$HOME outside public_html/httpdocs/htdocs."
;;
esac
# Belt and braces if the path is somehow still web-served.
printf '%s\n' 'Require all denied' '' 'Deny from all' '' \
>"$WORKDIR/.htaccess" 2>/dev/null || true
printf '%s\n' 'deny all;' >"$WORKDIR/.nginx.deny" 2>/dev/null || true
# Drop-in nginx snippet: include this from a server block if the tree is ever mapped.
cat >"$WORKDIR/nginx-deny.conf" 2>/dev/null <<'NGX' || true
# Deny HTTP access to this wp-xray workdir (report, database and malware detail).
location ~ /wp-xray-[^/]+ {
deny all;
return 403;
}
NGX
: >"$WORKDIR/index.html" 2>/dev/null || true
REPORT_FILE="$WORKDIR/report.txt"
: >"$REPORT_FILE"
LOG_FILE="$WORKDIR/run.log"
exec 2> >(tee -a "$LOG_FILE" >&2)
TMPDIR_LOCAL="$WORKDIR/tmp"
mkdir -p -- "$TMPDIR_LOCAL"
# Files outside the docroot that execute for this site anyway. They are called
# out separately because every "reinstall the docroot" instinct misses them.
#
# Entries are "pathreason". A referenced path is recorded whether or not it
# exists right now: an auto_prepend_file pointing at a file the site user cannot
# read, or one the attacker has not dropped yet, is still a live directive, and
# it is the directive -- not the file -- that survives a docroot replacement.
PARENT_PERSISTENCE=()
note_parent_persistence() {
local f=$1 reason=${2:-referenced by site configuration}
[[ -n $f ]] || return 0
case $f in
"$DOCROOT"|"$DOCROOT"/*) return 0 ;; # inside the docroot; reported elsewhere
/*) ;;
*) return 0 ;; # relative/unresolvable -- nothing to point at
esac
local x
for x in ${PARENT_PERSISTENCE[@]+"${PARENT_PERSISTENCE[@]}"}; do
[[ ${x%%$'\t'*} == "$f" ]] && return 0
done
PARENT_PERSISTENCE+=("$f"$'\t'"$reason")
}
# ---------------------------------------------------------------------------
# Exit handling
#
# Nothing outside the workdir is ever written, so there is nothing to roll back.
# ---------------------------------------------------------------------------
cleanup() {
local rc=$?
set +e
[[ $rc -ne 0 ]] && warn "Failed (exit $rc). The docroot and the database were not modified."
rm -rf -- "$TMPDIR_LOCAL" 2>/dev/null
printf '%s\n' "Report: $REPORT_FILE" >&2
printf '%s\n' "Log: $LOG_FILE" >&2
exit $rc
}
trap cleanup EXIT
trap 'warn "Interrupted."; exit 130' INT TERM
# ---------------------------------------------------------------------------
# 1. Inventory
# ---------------------------------------------------------------------------
hdr "wp-xray $VERSION -- $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
rpt "Docroot : $DOCROOT"
rpt "Owner : $(stat -c '%U:%G' -- "$DOCROOT") (uid $DOCROOT_UID)"
rpt "Workdir : $WORKDIR"
rpt "Host : $(hostname 2>/dev/null || echo unknown)"
# Installed WP version -- read statically, no bootstrap.
INSTALLED_VER=$(sed -n "s/^[[:space:]]*\$wp_version[[:space:]]*=[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" \
-- "$DOCROOT/wp-includes/version.php" 2>/dev/null | head -1)
[[ -n $INSTALLED_VER ]] || INSTALLED_VER="unknown"
rpt "WP version : $INSTALLED_VER"
# wp-config.php may legitimately live one level above the docroot.
WPCONFIG=""
if [[ -f $DOCROOT/wp-config.php ]]; then WPCONFIG="$DOCROOT/wp-config.php"
elif [[ -f $PARENT/wp-config.php ]]; then WPCONFIG="$PARENT/wp-config.php"
warn "wp-config.php found ABOVE the docroot: $WPCONFIG"
fi
[[ -n $WPCONFIG ]] || warn "No wp-config.php found. The database audit will be skipped."
rpt "wp-config : ${WPCONFIG:-}"
# A config above the docroot is outside every "replace the docroot" instinct, and
# it is a live credential store either way.
if [[ -f $PARENT/wp-config.php ]]; then
case $PARENT/wp-config.php in
"$DOCROOT"|"$DOCROOT"/*) ;;
*)
note_parent_persistence "$PARENT/wp-config.php" "wp-config.php above the docroot -- holds live database credentials"
if [[ ${WPCONFIG:-} != "$PARENT/wp-config.php" ]]; then
warn "Additional wp-config.php above the docroot: $PARENT/wp-config.php"
finding high "A wp-config.php exists above the docroot at $PARENT/wp-config.php in addition to the in-docroot config. WordPress prefers the in-docroot file, so this one is unused by the site but still holds database credentials -- and it sits outside anything a docroot replacement would touch. Read it."
fi
;;
esac
fi
if [[ -n ${WPCONFIG:-} ]]; then
case $WPCONFIG in
"$DOCROOT"|"$DOCROOT"/*) ;;
*) note_parent_persistence "$WPCONFIG" "the wp-config.php this site actually loads" ;;
esac
fi
# ---------------------------------------------------------------------------
# 2. Disk, quota, and the big-file problem
# ---------------------------------------------------------------------------
hdr "Disk usage and quota"
info "Sizing the docroot (this is the slow part on large sites)..."
SIZE_BYTES=$(du -sb --one-file-system -- "$DOCROOT" 2>/dev/null | awk '{print $1}' || true)
: "${SIZE_BYTES:=0}"
FILE_COUNT=$(find "$DOCROOT" -xdev -type f 2>/dev/null | wc -l || true)
DIR_COUNT=$(find "$DOCROOT" -xdev -type d 2>/dev/null | wc -l || true)
# wc -l pads with spaces on some systems
FILE_COUNT=${FILE_COUNT// /}
DIR_COUNT=${DIR_COUNT// /}
: "${FILE_COUNT:=0}"
: "${DIR_COUNT:=0}"
rpt "Docroot size : $(human "$SIZE_BYTES")"
rpt "Files / dirs : $FILE_COUNT / $DIR_COUNT"
PATHOLOGICAL_TREE=0
if [[ $FILE_COUNT -gt $FILE_COUNT_WARN || $DIR_COUNT -gt $DIR_COUNT_WARN ]]; then
PATHOLOGICAL_TREE=1
warn "Pathological file/dir count ($FILE_COUNT files, $DIR_COUNT dirs). Deep sorts and scans will be bounded; cache dirs are likely the cause."
finding med "Docroot has $FILE_COUNT files / $DIR_COUNT directories. Counts this high are almost always a cache-plugin inode bomb (or similar). See the cache directory sizes below -- clearing them is usually the whole fix for an inode complaint."
fi
# cPanel/CWP quota. Not reported as a routine line -- a support rep has the
# account's disk usage on screen already. It is read only so the near-full
# warning below can fire, which is the part they cannot see from the panel:
# a quota this close to the limit breaks uploads, updates and mail quietly.
QUOTA_FREE_BYTES=""
if have quota; then
# quota -w output in KB: fs blocks quota limit ...
QLINE=$(quota -w 2>/dev/null | awk 'NR>2 && $1 ~ /\// {print; exit}' || true)
if [[ -n ${QLINE:-} ]]; then
QUSED=$(awk '{gsub(/\*/,"",$2); print $2}' <<<"$QLINE")
QLIMIT=$(awk '{print $4}' <<<"$QLINE")
if [[ ${QLIMIT:-0} =~ ^[0-9]+$ && ${QLIMIT:-0} -gt 0 ]]; then
QUOTA_FREE_BYTES=$(( (QLIMIT - QUSED) * 1024 ))
fi
fi
fi
# Reclaimable junk: backup archives, dumps, logs, core dumps.
info "Locating backup artifacts, core dumps, and oversized files..."
JUNK_FIND=(); for p in "${JUNK_PATTERNS[@]}"; do JUNK_FIND+=( -o -iname "$p" ); done
JUNK_LIST="$TMPDIR_LOCAL/junk.txt"
find "$DOCROOT" -xdev -type f \( "${JUNK_FIND[@]:1}" \) -printf '%s\t%p\n' 2>/dev/null \
| sort -rn >"$JUNK_LIST" || true
JUNK_BYTES=$(awk -F'\t' '{s+=$1} END{print s+0}' "$JUNK_LIST")
JUNK_N=$(wc -l <"$JUNK_LIST"); JUNK_N=${JUNK_N// /}
rpt "Backup/log/core-dump artifacts: $JUNK_N files, $(human "$JUNK_BYTES")"
# The walk always runs, so the list always exists -- empty means none were found.
strip_prefix "$DOCROOT/" 2 <"$JUNK_LIST" >"$WORKDIR/backup-artifacts.txt" 2>/dev/null || true
if [[ $JUNK_N -gt 0 ]]; then
rpt " largest:"
head -10 "$JUNK_LIST" | while IFS=$'\t' read -r sz path; do
rpt " $(human "$sz") ${path#"$DOCROOT"/}"
done
[[ $JUNK_BYTES -gt $((200*1024*1024)) ]] && \
finding low "$(human "$JUNK_BYTES") of backup archives/dumps/logs/core dumps in the docroot. A .sql or .wpress file under the docroot is downloadable by anyone who guesses the name. Full list: $WORKDIR/backup-artifacts.txt"
fi
# Cache / optimizer / log dirs: disposable, often most of the bulk and inodes.
# Only top-level under wp-content (plus uploads/cache) -- a recursive -name cache
# would double-count children inside wp-content/cache and flag tiny plugin caches.
CACHE_BYTES=0
CACHE_FILES=0
CACHE_FOUND=()
declare -A CACHE_SZ=() CACHE_FC=()
WPC_EARLY="$DOCROOT/wp-content"
UPLOADS_EARLY="$WPC_EARLY/uploads"
if [[ -d $WPC_EARLY ]]; then
for n in "${CACHE_DIR_NAMES[@]}"; do
[[ -d $WPC_EARLY/$n ]] && CACHE_FOUND+=("wp-content/$n")
done
[[ -d $UPLOADS_EARLY/cache ]] && CACHE_FOUND+=("wp-content/uploads/cache")
fi
rpt ""
if [[ ${#CACHE_FOUND[@]} -eq 0 ]]; then
rpt "Cache/optimizer dirs: none of ${CACHE_DIR_NAMES[*]} / uploads/cache"
else
for rel in "${CACHE_FOUND[@]}"; do
cdir="$DOCROOT/$rel"
csz=$(du -sb --one-file-system -- "$cdir" 2>/dev/null | awk '{print $1}' || true)
: "${csz:=0}"
cfc=$(find "$cdir" -xdev -type f 2>/dev/null | wc -l || true); cfc=${cfc// /}
: "${cfc:=0}"
CACHE_SZ[$rel]=$csz
CACHE_FC[$rel]=$cfc
CACHE_BYTES=$((CACHE_BYTES + csz))
CACHE_FILES=$((CACHE_FILES + cfc))
done
rpt "Cache/optimizer dirs: ${#CACHE_FOUND[@]} path(s), $(human "$CACHE_BYTES"), ~$CACHE_FILES files"
for rel in "${CACHE_FOUND[@]}"; do
rpt " $rel ($(human "${CACHE_SZ[$rel]}"), ${CACHE_FC[$rel]} files)"
done
[[ $CACHE_FILES -gt 50000 || $CACHE_BYTES -gt $((500*1024*1024)) ]] && \
finding med "$(human "$CACHE_BYTES") / ${CACHE_FILES} files in cache-like directories (${CACHE_FOUND[*]}). Regenerated content, safe to clear, and usually the entire answer to a quota or inode complaint. They also dominate the scan time of this report."
fi
# Largest files -- bound the walk when the tree is an inode bomb.
rpt ""
rpt "Largest files in docroot:"
if [[ $PATHOLOGICAL_TREE -eq 1 ]]; then
rpt " (bounded: only files larger than 50 MB -- full sort skipped on pathological trees)"
find "$DOCROOT" -xdev -type f -size +50M -printf '%s\t%p\n' 2>/dev/null | sort -rn | head -15 \
| while IFS=$'\t' read -r sz path; do rpt " $(human "$sz") ${path#"$DOCROOT"/}"; done || true
else
find "$DOCROOT" -xdev -type f -printf '%s\t%p\n' 2>/dev/null | sort -rn | head -10 \
| while IFS=$'\t' read -r sz path; do rpt " $(human "$sz") ${path#"$DOCROOT"/}"; done || true
fi
rpt ""
rpt "Largest directories:"
du -xb --max-depth=2 -- "$DOCROOT" 2>/dev/null | sort -rn | sed -n '2,11p' \
| while read -r sz path; do rpt " $(human "$sz") ${path#"$DOCROOT"/}"; done || true
# How much of the bulk is media the site actually needs, and how much is not.
UPLOADS_BYTES=0
if [[ -d $UPLOADS_EARLY ]]; then
UPLOADS_BYTES=$(du -sb --one-file-system -- "$UPLOADS_EARLY" 2>/dev/null | awk '{print $1}' || true)
: "${UPLOADS_BYTES:=0}"
fi
UPLOAD_JUNK_BYTES=$(awk -F'\t' -v u="$UPLOADS_EARLY/" '
index($2, u) == 1 { s += $1 }
END { print s+0 }
' "$JUNK_LIST")
# Cache under uploads (e.g. uploads/cache) is counted inside UPLOADS_BYTES.
UPLOAD_CACHE_BYTES=0
for rel in ${CACHE_FOUND[@]+"${CACHE_FOUND[@]}"}; do
case $rel in
wp-content/uploads/*) UPLOAD_CACHE_BYTES=$((UPLOAD_CACHE_BYTES + ${CACHE_SZ[$rel]:-0})) ;;
esac
done
RECLAIMABLE_BYTES=$(( JUNK_BYTES + CACHE_BYTES ))
rpt ""
rpt "Uploads size : $(human "$UPLOADS_BYTES") (junk under uploads: $(human "$UPLOAD_JUNK_BYTES"); cache under uploads: $(human "$UPLOAD_CACHE_BYTES"))"
rpt "Disposable bulk : $(human "$RECLAIMABLE_BYTES") (backup artifacts + cache dirs)"
if [[ -n $QUOTA_FREE_BYTES && $QUOTA_FREE_BYTES -lt $((500*1024*1024)) ]]; then
warn "Account quota is nearly full ($(human "$QUOTA_FREE_BYTES") free)."
finding med "Account quota has only $(human "$QUOTA_FREE_BYTES") free. $(human "$RECLAIMABLE_BYTES") of that is backup artifacts and cache directories listed above. A full quota breaks uploads, updates and mail before it breaks anything obvious."
fi
# ---------------------------------------------------------------------------
# 3. wp-config.php -- parsed with the tokenizer, never executed
# ---------------------------------------------------------------------------
hdr "wp-config.php"
declare -A WPCFG=()
# Kept in the workdir, not the temp dir: the non-standard directives are the part
# of the config a tech reads line by line, and the report truncates nothing here.
CFG_EXTRA="$WORKDIR/wpconfig-nonstandard.txt"
: >"$CFG_EXTRA"
parse_wpconfig() {
# token_get_all() lexes PHP source into tokens. It does not run it. This is
# the only safe way to read credentials out of a file that may be trojaned.
php -d error_reporting=0 -r '
$src = @file_get_contents($argv[1]);
if ($src === false) exit(1);
$t = @token_get_all($src);
if (!$t) exit(1);
// Backslashes here pass through bash single quotes AND the PHP lexer, so
// literal escape sequences are far too easy to get wrong by one level.
// chr() removes all ambiguity: 92 = backslash, 39 = single quote.
function unq($s) {
$bs = chr(92); $sq = chr(39);
$q = $s[0];
$inner = substr($s, 1, -1);
// Single-quoted PHP strings only recognise \\ and \" as escapes.
if ($q === $sq) return str_replace([$bs.$bs, $bs.$sq], [$bs, $sq], $inner);
return stripcslashes($inner);
}
// Flatten to a list of [id, text], dropping whitespace and comments.
$f = [];
foreach ($t as $tok) {
if (is_array($tok)) {
if (in_array($tok[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) continue;
$f[] = [$tok[0], $tok[1]];
} else {
$f[] = [-1, $tok];
}
}
$n = count($f);
for ($i = 0; $i < $n; $i++) {
// define("NAME", "value") -- only accept a single literal value token
// immediately closed by ")". Concatenation, getenv(), variables, etc. are
// reported as non-literal rather than guessed at.
if ($f[$i][0] === T_STRING && strtolower($f[$i][1]) === "define"
&& isset($f[$i+4]) && $f[$i+1][1] === "(" && $f[$i+3][1] === ","
&& $f[$i+2][0] === T_CONSTANT_ENCAPSED_STRING) {
$name = unq($f[$i+2][1]);
$vtok = $f[$i+4];
$closed = isset($f[$i+5]) && $f[$i+5][1] === ")";
if (!$closed) {
echo "C\t", $name, "\t\n";
} elseif ($vtok[0] === T_CONSTANT_ENCAPSED_STRING) {
echo "D\t", $name, "\t", base64_encode(unq($vtok[1])), "\n";
} elseif ($vtok[0] === T_LNUMBER || $vtok[0] === T_DNUMBER) {
echo "D\t", $name, "\t", base64_encode($vtok[1]), "\n";
} elseif ($vtok[0] === T_STRING
&& in_array(strtolower($vtok[1]), ["true","false","null"], true)) {
echo "D\t", $name, "\t", base64_encode($vtok[1]), "\n";
} else {
echo "C\t", $name, "\t\n";
}
}
// $table_prefix = "wp_"
if ($f[$i][0] === T_VARIABLE && $f[$i][1] === "\$table_prefix"
&& isset($f[$i+2]) && $f[$i+1][1] === "=") {
if ($f[$i+2][0] === T_CONSTANT_ENCAPSED_STRING
&& isset($f[$i+3]) && $f[$i+3][1] === ";") {
echo "P\ttable_prefix\t", base64_encode(unq($f[$i+2][1])), "\n";
} else {
echo "C\ttable_prefix\t\n";
}
}
// Anything that executes at include time is a red flag in a config file.
if ($f[$i][0] === T_STRING
&& in_array(strtolower($f[$i][1]), ["eval","assert","base64_decode","gzinflate",
"gzuncompress","str_rot13","create_function","system","shell_exec","passthru",
"proc_open","popen","file_get_contents","curl_exec","fsockopen","preg_replace"], true)) {
echo "S\t", strtolower($f[$i][1]), "\t\n";
}
if (in_array($f[$i][0], [T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE], true)) {
// Reassemble the argument until ";" so we report what is actually being
// pulled in, and so the stock "require_once ABSPATH . \x27wp-settings.php\x27"
// does not show up as a finding on every single site.
$arg = "";
for ($j = $i + 1; $j < $n && $f[$j][1] !== ";"; $j++) { $arg .= $f[$j][1]; }
$arg = trim($arg, "() ");
$norm = preg_replace("/\s+/", "", $arg);
if ($norm === "ABSPATH.\x27wp-settings.php\x27" || $norm === "ABSPATH.\"wp-settings.php\"") continue;
echo "S\tinclude:", $arg, "\t\n";
}
}
' "$1" 2>/dev/null
}
CFG_SUSPECT=()
CFG_COMPLEX=()
if [[ -n $WPCONFIG ]]; then
while IFS=$'\t' read -r kind key val; do
[[ -z ${kind:-} ]] && continue
case $kind in
D|P)
decoded=$(printf '%s' "$val" | base64 -d 2>/dev/null || true)
WPCFG[$key]=$decoded
;;
C) CFG_COMPLEX+=("$key") ;;
S) CFG_SUSPECT+=("$key")
# An include of a literal absolute path is the classic wp-config
# backdoor: one line in the config, payload parked in a dotfile
# somewhere the customer never looks. Record what it points at.
case $key in
include:*)
inc=${key#include:}
inc=${inc%\"}; inc=${inc#\"}; inc=${inc%\'}; inc=${inc#\'}
note_parent_persistence "$inc" "included by wp-config.php on every request"
;;
esac
;;
esac
done < <(parse_wpconfig "$WPCONFIG")
if [[ ${#WPCFG[@]} -eq 0 && ${#CFG_COMPLEX[@]} -eq 0 ]]; then
warn "Could not tokenize wp-config.php -- it may be obfuscated or truncated."
finding high "wp-config.php could not be parsed by the PHP tokenizer. Inspect it by hand before doing anything else."
fi
# ABSPATH is defined as __DIR__ . '/' by stock WordPress itself, so it is
# non-literal on every healthy site on earth. Reporting it as HIGH put a
# false positive at the top of every single report, which is the fastest way
# to teach a tech that finding #1 is noise worth skipping.
EXPECTED_NONLITERAL=" ABSPATH "
if [[ ${#CFG_COMPLEX[@]} -gt 0 ]]; then
uniq_complex=$(printf '%s\n' "${CFG_COMPLEX[@]}" | sort -u | tr '\n' ' ')
real_complex=""
for k in $uniq_complex; do
[[ $EXPECTED_NONLITERAL == *" $k "* ]] && continue
real_complex+="$k "
done
rpt ""
rpt "Non-literal defines (value not resolved): $uniq_complex"
[[ -n $real_complex && $real_complex != "$uniq_complex" ]] && \
rpt " (ABSPATH is non-literal in stock WordPress and is expected here)"
if [[ -n $real_complex ]]; then
finding high "wp-config.php has non-literal value(s) for: $real_complex. This script lexes the file rather than running it, so getenv()/concatenation/variables are reported, never evaluated. Read those lines yourself -- the database audit below is skipped if DB credentials are among them."
fi
fi
for k in DB_NAME DB_USER DB_HOST table_prefix; do
if [[ ${#CFG_COMPLEX[@]} -gt 0 ]] && printf '%s\n' "${CFG_COMPLEX[@]}" | grep -qxF "$k"; then
rpt "$(printf '%-16s: %s' "$k" "")"
else
rpt "$(printf '%-16s: %s' "$k" "${WPCFG[$k]:-}")"
fi
done
# Distinguish "define exists but is an empty string" from "no define at all";
# the first is a legitimate (if alarming) config, the second means the parse failed.
if [[ ${#CFG_COMPLEX[@]} -gt 0 ]] && printf '%s\n' "${CFG_COMPLEX[@]}" | grep -qxF "DB_PASSWORD"; then
PWSTATE=""
elif [[ ${WPCFG[DB_PASSWORD]+set} == set ]]; then
# Length is not printed. report.txt gets pasted into tickets, and the
# character count of a live database password is free reconnaissance for
# anyone reading the thread later.
if [[ -n ${WPCFG[DB_PASSWORD]} ]]; then PWSTATE=""
else PWSTATE=""; finding med "DB_PASSWORD is defined as an empty string in wp-config.php."; fi
else
PWSTATE=""
fi
rpt "$(printf '%-16s: %s' "DB_PASSWORD" "$PWSTATE")"
# Everything that is NOT a stock define gets surfaced for a human.
STOCK="DB_NAME DB_USER DB_PASSWORD DB_HOST DB_CHARSET DB_COLLATE table_prefix \
AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY AUTH_SALT SECURE_AUTH_SALT \
LOGGED_IN_SALT NONCE_SALT WP_DEBUG ABSPATH WP_DEBUG_LOG WP_DEBUG_DISPLAY"
rpt ""
rpt "Non-standard directives in wp-config.php (review each):"
FOUND_EXTRA=0
for k in "${!WPCFG[@]}"; do
[[ " $STOCK " == *" $k "* ]] && continue
FOUND_EXTRA=1
rpt " $k = ${WPCFG[$k]}"
printf '%s=%s\n' "$k" "${WPCFG[$k]}" >>"$CFG_EXTRA"
case $k in
WP_CONTENT_DIR|WP_CONTENT_URL|WP_PLUGIN_DIR|WP_PLUGIN_URL|WPMU_PLUGIN_DIR)
finding high "wp-config.php redefines $k = ${WPCFG[$k]} -- plugins, themes or uploads may load from outside the docroot. Nothing in this report covers that path: scan it separately."
case $k in
WP_CONTENT_DIR|WP_PLUGIN_DIR|WPMU_PLUGIN_DIR)
note_parent_persistence "${WPCFG[$k]}" "$k override -- site loads plugin/theme code from here" ;;
esac ;;
DISALLOW_FILE_MODS|DISALLOW_FILE_EDIT)
: ;;
*) : ;;
esac
done
[[ $FOUND_EXTRA -eq 0 ]] && rpt " (none)"
[[ $FOUND_EXTRA -eq 1 ]] && rpt " (also written to $CFG_EXTRA)"
if [[ ${#CFG_SUSPECT[@]} -gt 0 ]]; then
uniq_susp=$(printf '%s\n' "${CFG_SUSPECT[@]}" | sort -u | tr '\n' ' ')
rpt ""
rpt "Code-execution constructs in wp-config.php: $uniq_susp"
finding high "wp-config.php contains executable constructs ($uniq_susp). This file runs on every request and on every wp-cli invocation."
fi
# Anything after the "stop editing" marker is a classic injection point.
if [[ -f $WPCONFIG ]]; then
# Note: no "--" here. mawk (Debian/Ubuntu default awk) does not accept it,
# and the path is absolute from realpath so it cannot look like an option.
TAIL_AFTER=$(awk '/stop editing|Happy publishing|Happy blogging/{found=1; next} found' "$WPCONFIG" \
| grep -vE "^\s*($|//|#|/\*|\*)" | grep -vE "ABSPATH|wp-settings\.php|define\(\s*'ABSPATH'" | head -20 || true)
if [[ -n ${TAIL_AFTER:-} ]]; then
rpt ""
rpt "Code after the 'stop editing' marker:"
printf '%s\n' "$TAIL_AFTER" | while IFS= read -r l; do rpt " $l"; done
finding high "wp-config.php has code appended after the 'stop editing' marker -- a very common injection location."
fi
fi
fi
# Workdir invariant: a list file exists iff the step that fills it ran. Empty
# here means "parsed the config, found nothing non-stock". No file at all means
# there was no wp-config.php to parse, which is a different answer.
[[ -n $WPCONFIG ]] || rm -f -- "$CFG_EXTRA"
# ---------------------------------------------------------------------------
# 4. Server config: .htaccess, php.ini, .user.ini
# ---------------------------------------------------------------------------
hdr "Server configuration files"
# The value of an auto_prepend_file/auto_append_file directive, unquoted. This
# is the payload path -- the directive is worthless to an attacker without it,
# and it is almost always outside the docroot, where nothing else here looks.
prepend_targets() {
sed -nE 's/^[^#;]*(auto_prepend_file|auto_append_file)[[:space:]]*=[[:space:]]*//Ip' -- "$1" 2>/dev/null \
| sed -E 's/^"([^"]*)".*/\1/; s/^'"'"'([^'"'"']*)'"'"'.*/\1/; s/[[:space:]]*$//' \
| grep -v '^$' || true
}
scan_ini() {
local f=$1
[[ -f $f ]] || return 0
rpt " $f"
local hits t
hits=$(grep -inE 'auto_prepend_file|auto_append_file|include_path|extension\s*=|open_basedir|disable_functions' -- "$f" 2>/dev/null || true)
if [[ -n $hits ]]; then
printf '%s\n' "$hits" | while IFS= read -r l; do rpt " $l"; done
if grep -qiE 'auto_prepend_file|auto_append_file' -- "$f" 2>/dev/null; then
finding high "$f sets auto_prepend_file/auto_append_file -- this executes arbitrary PHP on every request without touching any WordPress file. Read the file it points at."
# An ini above the docroot is outside everything else this report walks.
note_parent_persistence "$f" "sets auto_prepend_file/auto_append_file"
while IFS= read -r t; do
[[ -n $t ]] || continue
note_parent_persistence "$t" "auto_prepend/append payload loaded by ${f#"$DOCROOT"/}"
done < <(prepend_targets "$f")
fi
fi
}
for ini in "$DOCROOT/php.ini" "$DOCROOT/.user.ini" "$PARENT/php.ini" "$PARENT/.user.ini"; do
scan_ini "$ini"
done
# Nested .user.ini anywhere in the tree is almost never legitimate.
while IFS= read -r f; do
[[ $f == "$DOCROOT/.user.ini" ]] && continue
rpt " nested: $f"
finding high "Nested .user.ini at $f -- PHP-FPM reads these per-directory; a common persistence trick."
scan_ini "$f"
done < <(find "$DOCROOT" -xdev -name '.user.ini' -type f 2>/dev/null)
HTACCESS_COUNT=$(find "$DOCROOT" -xdev -name '.htaccess' -type f 2>/dev/null | wc -l || true)
rpt ""
rpt ".htaccess files in tree: $HTACCESS_COUNT"
if [[ -f $DOCROOT/.htaccess ]]; then
# Everything outside the standard WordPress block is worth a look.
# Whitespace in the stock block varies between WordPress versions and hosts,
# so match loosely on the shape of each stock line rather than exact text.
NONSTD=$(grep -vnE '^[[:space:]]*(#|$)' -- "$DOCROOT/.htaccess" \
| grep -viE 'BEGIN WordPress|END WordPress|RewriteEngine|RewriteBase|RewriteRule[[:space:]]+\^index\\?\.php\$|RewriteCond[[:space:]]+%\{REQUEST_FILENAME\}[[:space:]]+!-[fd]|RewriteRule[[:space:]]+\.[[:space:]]+/?index\.php|RewriteRule[[:space:]]+\.\*[[:space:]]+-[[:space:]]+\[E=HTTP_AUTHORIZATION|?IfModule' \
|| true)
if [[ -n ${NONSTD:-} ]]; then
rpt "Non-standard directives in docroot .htaccess:"
printf '%s\n' "$NONSTD" | head -40 | while IFS= read -r l; do rpt " $l"; done || true
finding med "Docroot .htaccess contains directives beyond the stock WordPress rewrite block. The non-standard lines are listed above with their line numbers."
fi
if grep -qiE 'php_value\s+auto_prepend|AddHandler|AddType.*php|SetHandler|ErrorDocument\s+40[0-9]\s+http' -- "$DOCROOT/.htaccess" 2>/dev/null; then
finding high "Docroot .htaccess changes PHP handlers or auto_prepend, or redirects error pages offsite."
fi
fi
# Every .htaccess in the tree, not just the docroot one and uploads. Apache reads
# them per-directory, so a handler override three levels down works exactly as
# well as one at the root -- and sits in a directory no reinstall checklist names.
# Only handler/prepend tricks are reported here; hardening rules (Deny from all,
# the block plugins ship) are silent, or this fires on every healthy site.
HTA_PHP_RE='php_value[[:space:]]+auto_(prepend|append)|php_admin_value[[:space:]]+auto_(prepend|append)|Add(Handler|Type)[[:space:]]+[^[:space:]]*php[^[:space:]]*[[:space:]]|SetHandler[[:space:]]+[^[:space:]]*php|php_flag[[:space:]]+engine[[:space:]]+on'
while IFS= read -r f; do
[[ -n $f ]] || continue
rel=${f#"$DOCROOT"/}
in_uploads=0
case $rel in wp-content/uploads/*|wp-content/uploads) in_uploads=1 ;; esac
[[ $f == "$DOCROOT/.htaccess" ]] && continue # reported above
if [[ $in_uploads -eq 1 ]]; then
rpt " uploads .htaccess: $rel"
fi
if grep -qiE "$HTA_PHP_RE" -- "$f" 2>/dev/null; then
if [[ $in_uploads -eq 1 ]]; then
finding high "$rel re-enables PHP execution inside uploads."
else
rpt " nested .htaccess with a PHP handler/prepend directive: $rel"
grep -inE "$HTA_PHP_RE" -- "$f" 2>/dev/null | head -10 \
| while IFS= read -r l; do rpt " $l"; done || true
finding high "$rel maps files to the PHP handler or sets auto_prepend. Apache applies it to that directory and everything under it, so it grants code execution without any WordPress file being touched."
fi
while IFS= read -r t; do
[[ -n $t ]] || continue
note_parent_persistence "$t" "auto_prepend/append payload loaded by $rel"
done < <(sed -nE 's/^[^#]*php(_admin)?_value[[:space:]]+auto_(prepend|append)_file[[:space:]]+//Ip' -- "$f" 2>/dev/null \
| sed -E 's/^"([^"]*)".*/\1/; s/^'"'"'([^'"'"']*)'"'"'.*/\1/; s/[[:space:]]*$//' | grep -v '^$' || true)
fi
done < <(find "$DOCROOT" -xdev -name '.htaccess' -type f 2>/dev/null || true)
# The docroot .htaccess is handled above, but its prepend target still counts.
if [[ -f $DOCROOT/.htaccess ]]; then
while IFS= read -r t; do
[[ -n $t ]] || continue
note_parent_persistence "$t" "auto_prepend/append payload loaded by the docroot .htaccess"
done < <(sed -nE 's/^[^#]*php(_admin)?_value[[:space:]]+auto_(prepend|append)_file[[:space:]]+//Ip' -- "$DOCROOT/.htaccess" 2>/dev/null \
| sed -E 's/^"([^"]*)".*/\1/; s/^'"'"'([^'"'"']*)'"'"'.*/\1/; s/[[:space:]]*$//' | grep -v '^$' || true)
fi
# ---------------------------------------------------------------------------
# 5. Core integrity -- verified against wordpress.org, statically
# ---------------------------------------------------------------------------
CORE_MODIFIED=(); CORE_MISSING=(); CORE_EXTRA=()
CORE_CHECKSUMS_OK=0
CORE_SKIP_REASON=""
if [[ $SKIP_CORE_VERIFY -eq 0 && $INSTALLED_VER != "unknown" ]]; then
hdr "Core integrity (WordPress $INSTALLED_VER)"
# Locale affects which files wordpress.org lists. Prefer the packaged locale
# from version.php, then WPLANG from wp-config, then en_US.
WP_LOCALE="en_US"
_lp=$(sed -n "s/^[[:space:]]*\$wp_local_package[[:space:]]*=[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" \
-- "$DOCROOT/wp-includes/version.php" 2>/dev/null | head -1)
[[ -n ${_lp:-} ]] && WP_LOCALE=$_lp
if [[ -n ${WPCFG[WPLANG]:-} ]]; then
WP_LOCALE=${WPCFG[WPLANG]}
fi
if [[ ! $WP_LOCALE =~ ^[A-Za-z]{2,3}([_-][A-Za-z0-9]+)*$ ]]; then
warn "Unrecognised locale '$WP_LOCALE'; using en_US for checksums."
WP_LOCALE="en_US"
fi
rpt "Checksum locale: $WP_LOCALE"
info "Fetching checksum manifest from wordpress.org (locale=$WP_LOCALE)..."
CK_JSON="$TMPDIR_LOCAL/checksums.json"
if http_get "https://api.wordpress.org/core/checksums/1.0/?version=${INSTALLED_VER}&locale=${WP_LOCALE}" "$CK_JSON" 2>/dev/null; then
CK_LIST="$TMPDIR_LOCAL/checksums.txt"
# The API has shipped two shapes over the years: {"checksums":{file:md5}} and
# {"checksums":{"":{file:md5}}}. Accept either.
json_get "$CK_JSON" "checksums/$INSTALLED_VER" >"$CK_LIST" || true
[[ -s $CK_LIST ]] || json_get "$CK_JSON" "checksums" >"$CK_LIST" || true
# If a non-en_US locale returned nothing useful, retry en_US once.
if [[ ! -s $CK_LIST && $WP_LOCALE != "en_US" ]]; then
warn "No checksums for locale $WP_LOCALE; retrying en_US."
if http_get "https://api.wordpress.org/core/checksums/1.0/?version=${INSTALLED_VER}&locale=en_US" "$CK_JSON" 2>/dev/null; then
json_get "$CK_JSON" "checksums/$INSTALLED_VER" >"$CK_LIST" || true
[[ -s $CK_LIST ]] || json_get "$CK_JSON" "checksums" >"$CK_LIST" || true
WP_LOCALE="en_US"
fi
fi
if [[ -s $CK_LIST ]]; then
CORE_CHECKSUMS_OK=1
info "Hashing core files (this compares the HACKED tree, which is the check that matters)..."
MOD_F="$TMPDIR_LOCAL/core-modified.txt"; : >"$MOD_F"
MISS_F="$TMPDIR_LOCAL/core-missing.txt"; : >"$MISS_F"
while IFS=$'\t' read -r relpath md5; do
[[ -z ${relpath:-} ]] && continue
# wp-content is user data; wordpress.org ships stock themes we do not care about here.
[[ $relpath == wp-content/* ]] && continue
f="$DOCROOT/$relpath"
if [[ ! -f $f ]]; then
printf '%s\n' "$relpath" >>"$MISS_F"
continue
fi
actual=$(md5sum -- "$f" 2>/dev/null | awk '{print $1}')
[[ $actual == "$md5" ]] || printf '%s\n' "$relpath" >>"$MOD_F"
done <"$CK_LIST"
mapfile -t CORE_MODIFIED <"$MOD_F" || true
mapfile -t CORE_MISSING <"$MISS_F" || true
rpt "Modified core files: ${#CORE_MODIFIED[@]}"
if [[ ${#CORE_MODIFIED[@]} -gt 0 ]]; then
printf '%s\n' "${CORE_MODIFIED[@]}" | head -30 | while IFS= read -r l; do rpt " $l"; done || true
finding high "${#CORE_MODIFIED[@]} WordPress core files do not match wordpress.org checksums for $INSTALLED_VER ($WP_LOCALE)."
fi
rpt "Missing core files: ${#CORE_MISSING[@]}"
if [[ ${#CORE_MISSING[@]} -gt 0 ]]; then
printf '%s\n' "${CORE_MISSING[@]}" | head -30 | while IFS= read -r l; do rpt " $l"; done || true
finding high "${#CORE_MISSING[@]} WordPress core files from the $INSTALLED_VER manifest are absent from the docroot."
fi
# Files present in wp-admin/wp-includes that upstream does not ship, plus
# executable-looking files sitting in the docroot root (classic webshell drop).
EX_F="$TMPDIR_LOCAL/core-extra.txt"; : >"$EX_F"
KNOWN="$TMPDIR_LOCAL/known.txt"
awk -F'\t' '{print $1}' "$CK_LIST" | sort >"$KNOWN"
for d in wp-admin wp-includes; do
[[ -d $DOCROOT/$d ]] || continue
find "$DOCROOT/$d" -xdev -type f -printf '%P\n' 2>/dev/null \
| sed "s|^|$d/|" | sort > "$TMPDIR_LOCAL/present-$d.txt"
comm -23 "$TMPDIR_LOCAL/present-$d.txt" "$KNOWN" >>"$EX_F" || true
done
# Docroot-root only (maxdepth 1). favicon.ico / google*.html are noise;
# executable extensions and PHP-ish names are the signal.
ROOT_EXEC_FIND=()
for e in "${EXEC_EXTENSIONS[@]}"; do ROOT_EXEC_FIND+=( -o -iname "*.$e" ); done
find "$DOCROOT" -xdev -maxdepth 1 -type f \( "${ROOT_EXEC_FIND[@]:1}" -o -iname '*.php*' \) \
-printf '%f\n' 2>/dev/null | sort >"$TMPDIR_LOCAL/present-root-exec.txt" || true
# wp-config.php is expected and is not in the upstream checksum manifest.
if [[ -s $TMPDIR_LOCAL/present-root-exec.txt ]]; then
grep -vxF 'wp-config.php' "$TMPDIR_LOCAL/present-root-exec.txt" \
| comm -23 - "$KNOWN" >>"$EX_F" || true
fi
mapfile -t CORE_EXTRA <"$EX_F" || true
rpt "Unexpected files (wp-admin/wp-includes/docroot-root executables): ${#CORE_EXTRA[@]}"
if [[ ${#CORE_EXTRA[@]} -gt 0 ]]; then
printf '%s\n' "${CORE_EXTRA[@]}" | head -30 | while IFS= read -r l; do rpt " $l"; done || true
finding high "${#CORE_EXTRA[@]} unexpected file(s) in wp-admin, wp-includes, or the docroot root that upstream WordPress does not ship. Root-level drops (radio.php, wp-tmp.php, …) are a common blind spot for core-only checks."
fi
else
warn "Checksum manifest for $INSTALLED_VER unavailable (version may be too old)."
CORE_SKIP_REASON="wordpress.org publishes no checksum manifest for $INSTALLED_VER (the version is too old, or version.php has been altered)"
fi
else
warn "Could not reach api.wordpress.org for checksums."
CORE_SKIP_REASON="api.wordpress.org could not be reached to fetch the $INSTALLED_VER manifest"
fi
fi
# When checksums did not run, still surface executable drops at the docroot root —
# these are invisible to the wp-content pattern scan.
if [[ $CORE_CHECKSUMS_OK -eq 0 ]]; then
# Silence here is not a clean bill of health, and on an incident ticket that
# distinction is the whole ballgame. Say plainly that core was never checked.
if [[ $SKIP_CORE_VERIFY -eq 1 ]]; then
CORE_SKIP_REASON=${CORE_SKIP_REASON:-"--no-core-verify was passed"}
elif [[ $INSTALLED_VER == "unknown" ]]; then
CORE_SKIP_REASON=${CORE_SKIP_REASON:-"the WordPress version could not be read from wp-includes/version.php"}
fi
finding med "WordPress core was NOT verified against wordpress.org: ${CORE_SKIP_REASON:-checksum verification did not run}. Modified, missing and planted files under wp-admin/ and wp-includes/ are therefore unknown for this run -- the absence of core findings above means nothing. Re-run with core verification once possible."
hdr "Docroot-root executable files"
STOCK_ROOT_PHP="index.php wp-activate.php wp-blog-header.php wp-comments-post.php wp-config-sample.php wp-cron.php wp-links-opml.php wp-load.php wp-login.php wp-mail.php wp-settings.php wp-signup.php wp-trackback.php xmlrpc.php"
ROOT_SUSPECT=()
while IFS= read -r rf; do
[[ -n $rf ]] || continue
base=$rf
# wp-config.php is expected; handled elsewhere.
[[ $base == "wp-config.php" ]] && continue
[[ " $STOCK_ROOT_PHP " == *" $base "* ]] && continue
ROOT_SUSPECT+=("$base")
done < <(find "$DOCROOT" -xdev -maxdepth 1 -type f \( -iname '*.php' -o -iname '*.php*' -o -iname '*.phtml' -o -iname '*.phar' -o -iname '*.cgi' -o -iname '*.pl' \) -printf '%f\n' 2>/dev/null | sort -u || true)
if [[ ${#ROOT_SUSPECT[@]} -gt 0 ]]; then
rpt "Unexpected executable-looking files in docroot root: ${#ROOT_SUSPECT[@]}"
printf '%s\n' "${ROOT_SUSPECT[@]}" | head -30 | while IFS= read -r l; do rpt " $l"; done || true
finding high "${#ROOT_SUSPECT[@]} unexpected executable-looking file(s) in the docroot root (${ROOT_SUSPECT[*]}). Core checksum verify was skipped or unavailable; these would not appear in a wp-content scan."
else
rpt "None beyond the stock WordPress root PHP set."
fi
fi
# ---------------------------------------------------------------------------
# 6. wp-content: mu-plugins, drop-ins, plugins, themes, uploads
# ---------------------------------------------------------------------------
WPC="$DOCROOT/wp-content"
[[ -d $WPC ]] || warn "No wp-content directory at $WPC"
# ---------------------------------------------------------------------------
# 5b. Other WordPress installations inside the docroot
#
# On cPanel/CWP these are routine: addon and subdomain docroots live under
# public_html, and abandoned "blog/", "old/", "staging/" copies accumulate for
# years. An unmaintained nested install is one of the most common entry points:
# the main site gets cleaned, the forgotten WordPress 4.9 in /blog keeps its
# vulnerable plugin, and the site is reinfected within days.
#
# This report covers the target docroot. Each nested install needs its own run.
#
# A WordPress tree under wp-content/uploads is a separate matter: there is no
# legitimate reason for one to be there.
# ---------------------------------------------------------------------------
hdr "Other WordPress installations inside this docroot"
NESTED_PATHS=() # relative to $DOCROOT
while IFS= read -r vf; do
[[ -n $vf ]] || continue
ndir=${vf%/wp-includes/version.php}
[[ $ndir == "$DOCROOT" ]] && continue # the target install itself
NESTED_PATHS+=("${ndir#"$DOCROOT"/}")
done < <(find "$DOCROOT" -xdev -type f -path '*/wp-includes/version.php' 2>/dev/null | sort || true)
if [[ ${#NESTED_PATHS[@]} -eq 0 ]]; then
rpt "None found. This docroot contains a single WordPress installation."
else
rpt "Found ${#NESTED_PATHS[@]} additional WordPress installation(s) below the target docroot."
rpt "Only the pattern scan below covers them. Run this script against each one."
rpt ""
for np in "${NESTED_PATHS[@]}"; do
nfull="$DOCROOT/$np"
nver=$(sed -n "s/^[[:space:]]*\$wp_version[[:space:]]*=[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" \
-- "$nfull/wp-includes/version.php" 2>/dev/null | head -1)
: "${nver:=unknown}"
nsize=$(du -sb --one-file-system -- "$nfull" 2>/dev/null | awk '{print $1}' || true)
nfiles=$(find "$nfull" -xdev -type f 2>/dev/null | wc -l || true)
ncfg="no"; [[ -f $nfull/wp-config.php ]] && ncfg="yes"
nmtime=$(stat -c '%y' -- "$nfull" 2>/dev/null | cut -d. -f1)
# Full path, not the docroot-relative one. This is the line a rep copies to
# start the next run, and a relative path makes them reconstruct it by hand.
rpt " $nfull"
rpt " version $nver | $(human "${nsize:-0}") | ${nfiles:-0} files | own wp-config: $ncfg | mtime $nmtime"
# Its own wp-config means its own database, which this run never reads.
if [[ $ncfg == "yes" ]]; then
ndb=$(sed -n "s/.*define([[:space:]]*['\"]DB_NAME['\"][[:space:]]*,[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" \
-- "$nfull/wp-config.php" 2>/dev/null | head -1)
[[ -n ${ndb:-} ]] && rpt " database: $ndb (not audited by this run)"
fi
if [[ $np == wp-content/uploads/* || $np == */uploads/* ]]; then
finding high "A WordPress installation exists inside uploads at $np (version $nver). There is no legitimate reason for one to be there -- it is either an attacker's staged copy or an old migration dump left web-accessible."
else
# Anything on a branch older than the current one is unmaintained by definition.
nmajor=${nver%%.*}
if [[ $nver != "unknown" && $nmajor =~ ^[0-9]+$ && $nmajor -lt 6 ]]; then
finding high "Nested WordPress installation at $np is version $nver -- long unmaintained and a prime candidate for the original entry point. Cleaning the parent site will not help if this is how they got in."
else
finding med "Nested WordPress installation at $np (version $nver) is outside this report except for the pattern scan. If it is an addon or subdomain docroot, run this script against it too."
fi
fi
done
rpt ""
rpt " Entry-point note: whatever you do to this docroot does nothing for the"
rpt " installs above. Run this script against each of them separately."
rpt ""
rpt " To do that, as this user ($(id -un)), copy and run:"
rpt ""
for np in "${NESTED_PATHS[@]}"; do
# A tree under uploads is not a site to triage -- it is a find, and running
# a second report on it wastes the rep's time. Say what to do instead.
if [[ $np == wp-content/uploads/* || $np == */uploads/* ]]; then
rpt " # $np -- inside uploads. Do not triage this as a site;"
rpt " # it should not exist. Read it, then remove it."
continue
fi
rpt " $SELF $(printf '%q' "$DOCROOT/$np")"
done
fi
hdr "Must-use plugins"
MUDIR="$WPC/mu-plugins"
MU_FILES=()
if [[ -d $MUDIR ]]; then
mapfile -t MU_FILES < <(find "$MUDIR" -xdev -maxdepth 2 -type f -name '*.php' -printf '%P\n' 2>/dev/null | sort) || true
rpt "mu-plugins directory present: ${#MU_FILES[@]} PHP file(s)"
for f in ${MU_FILES[@]+"${MU_FILES[@]}"}; do
rpt " $f ($(human "$(stat -c '%s' -- "$MUDIR/$f")"), mtime $(stat -c '%y' -- "$MUDIR/$f" | cut -d. -f1))"
done
if [[ ${#MU_FILES[@]} -gt 0 ]]; then
finding high "${#MU_FILES[@]} must-use plugin file(s) present. mu-plugins load unconditionally, cannot be deactivated from wp-admin, and are not listed on the plugins screen. Read every one. A site that never asked for an mu-plugin should not have one."
fi
else
rpt "No mu-plugins directory."
fi
hdr "Drop-ins"
DROPINS_FOUND=()
for d in "${DROPINS[@]}"; do
if [[ -f $WPC/$d ]]; then
DROPINS_FOUND+=("$d")
rpt " $d ($(human "$(stat -c '%s' -- "$WPC/$d")"), mtime $(stat -c '%y' -- "$WPC/$d" | cut -d. -f1))"
fi
done
if [[ ${#DROPINS_FOUND[@]} -gt 0 ]]; then
finding high "Drop-in(s) present: ${DROPINS_FOUND[*]}. These execute on every request, are not covered by core checksums, and are not shown as plugins. Match each one against an installed caching or object-cache plugin; a drop-in with no plugin behind it is a backdoor."
else
rpt " (none)"
fi
# --- Plugin and theme inventory. Directory names are .org slugs for repo items.
declare -a PLUGIN_SLUGS=() THEME_SLUGS=()
if [[ -d $WPC/plugins ]]; then
mapfile -t PLUGIN_SLUGS < <(find "$WPC/plugins" -xdev -mindepth 1 -maxdepth 1 -type d -printf '%P\n' 2>/dev/null | sort) || true
fi
if [[ -d $WPC/themes ]]; then
mapfile -t THEME_SLUGS < <(find "$WPC/themes" -xdev -mindepth 1 -maxdepth 1 -type d -printf '%P\n' 2>/dev/null | sort) || true
fi
# Loose PHP files sitting directly in plugins/ (not in a subdirectory) are
# either single-file plugins or dropped shells.
LOOSE_PLUGIN_FILES=()
if [[ -d $WPC/plugins ]]; then
mapfile -t LOOSE_PLUGIN_FILES < <(find "$WPC/plugins" -xdev -mindepth 1 -maxdepth 1 -type f -name '*.php' -printf '%P\n' 2>/dev/null | sort) || true
fi
# Ask the .org API which of these actually exist upstream. Repo membership is
# the dividing line for everything downstream: a .org slug can be diffed against
# an authoritative copy, anything else can only be read by a human.
info "Checking ${#PLUGIN_SLUGS[@]} plugin(s) and ${#THEME_SLUGS[@]} theme(s) against the wordpress.org repository..."
REPO_PLUGINS=(); ORPHAN_PLUGINS=(); UNKNOWN_PLUGINS=()
REPO_THEMES=(); ORPHAN_THEMES=(); UNKNOWN_THEMES=()
# "Not in the repository" and "could not ask the repository" are different
# answers, and conflating them is how a network blip turns a healthy site into a
# report claiming all forty plugins are planted. Distinguish them at the HTTP
# layer: 200 means the API answered, 404 means it answered "no such slug",
# anything else means we never got an answer and must say so.
#
# org_fetch URL OUTFILE -> 0 = answered 200, 1 = answered 404, 2 = no answer
org_fetch() {
local url=$1 out=$2 code rc
: >"$out"
if have curl; then
code=$(curl -sS -o "$out" -w '%{http_code}' --retry 2 --retry-delay 1 \
--connect-timeout 10 --max-time 60 -- "$url" 2>/dev/null) || code="000"
else
rc=0; wget -q --tries=2 --timeout=30 -O "$out" -- "$url" 2>/dev/null || rc=$?
case $rc in
0) code=200 ;;
8) code=404 ;; # wget: "server issued an error response"
*) code=000 ;;
esac
fi
case $code in
200) return 0 ;;
404) return 1 ;;
*) return 2 ;;
esac
}
for slug in ${PLUGIN_SLUGS[@]+"${PLUGIN_SLUGS[@]}"}; do
[[ $slug =~ ^[A-Za-z0-9._-]+$ ]] || { ORPHAN_PLUGINS+=("$slug"); continue; }
rc=0; org_fetch "https://api.wordpress.org/plugins/info/1.0/${slug}.json" "$TMPDIR_LOCAL/p.json" || rc=$?
case $rc in
0) dl=$(json_get "$TMPDIR_LOCAL/p.json" "download_link" | head -1)
if [[ -n ${dl:-} && $dl == https://* ]]; then REPO_PLUGINS+=("$slug")
else ORPHAN_PLUGINS+=("$slug"); fi ;;
1) ORPHAN_PLUGINS+=("$slug") ;;
*) UNKNOWN_PLUGINS+=("$slug") ;;
esac
done
for slug in ${THEME_SLUGS[@]+"${THEME_SLUGS[@]}"}; do
[[ $slug =~ ^[A-Za-z0-9._-]+$ ]] || { ORPHAN_THEMES+=("$slug"); continue; }
rc=0; org_fetch "https://api.wordpress.org/themes/info/1.1/?action=theme_information&request%5Bslug%5D=${slug}" "$TMPDIR_LOCAL/t.json" || rc=$?
case $rc in
0) dl=$(json_get "$TMPDIR_LOCAL/t.json" "download_link" | head -1)
if [[ -n ${dl:-} && $dl == https://* ]]; then REPO_THEMES+=("$slug")
else ORPHAN_THEMES+=("$slug"); fi ;;
1) ORPHAN_THEMES+=("$slug") ;;
*) UNKNOWN_THEMES+=("$slug") ;;
esac
done
ORG_API_DEGRADED=0
if [[ ${#UNKNOWN_PLUGINS[@]} -gt 0 || ${#UNKNOWN_THEMES[@]} -gt 0 ]]; then
ORG_API_DEGRADED=1
warn "api.wordpress.org did not answer for ${#UNKNOWN_PLUGINS[@]} plugin(s) and ${#UNKNOWN_THEMES[@]} theme(s). They are NOT classified below."
fi
hdr "Plugins (${#PLUGIN_SLUGS[@]})"
rpt "In the wordpress.org repository (${#REPO_PLUGINS[@]}):"
for s in ${REPO_PLUGINS[@]+"${REPO_PLUGINS[@]}"}; do rpt " $s"; done
rpt ""
rpt "NOT in the .org repository -- premium, custom, renamed or planted (${#ORPHAN_PLUGINS[@]}):"
for s in ${ORPHAN_PLUGINS[@]+"${ORPHAN_PLUGINS[@]}"}; do
n=$(find "$WPC/plugins/$s" -xdev -type f 2>/dev/null | wc -l)
sz=$(du -sb -- "$WPC/plugins/$s" 2>/dev/null | awk '{print $1}')
rpt " $s ($n files, $(human "${sz:-0}"))"
done
[[ ${#ORPHAN_PLUGINS[@]} -eq 0 ]] && rpt " (none)"
if [[ ${#UNKNOWN_PLUGINS[@]} -gt 0 ]]; then
rpt ""
rpt "COULD NOT BE CHECKED -- api.wordpress.org did not answer (${#UNKNOWN_PLUGINS[@]}):"
for s in "${UNKNOWN_PLUGINS[@]}"; do rpt " $s"; done
rpt " These are unclassified, NOT suspicious. Re-run when the API is reachable."
fi
if [[ ${#LOOSE_PLUGIN_FILES[@]} -gt 0 ]]; then
rpt ""
rpt "Loose PHP files directly in plugins/ (${#LOOSE_PLUGIN_FILES[@]}):"
# index.php and hello.php ship with WordPress itself. Flagging them would put
# a HIGH on every healthy site and teach techs to skim past this finding.
UNEXPECTED_LOOSE=()
for f in "${LOOSE_PLUGIN_FILES[@]}"; do
if [[ $f == "index.php" || $f == "hello.php" ]]; then
rpt " $f (ships with WordPress)"
else
rpt " $f"
UNEXPECTED_LOOSE+=("$f")
fi
done
[[ ${#UNEXPECTED_LOOSE[@]} -gt 0 ]] && \
finding high "Loose PHP file(s) directly in wp-content/plugins/ that WordPress does not ship: ${UNEXPECTED_LOOSE[*]}. Single-file plugins are legitimate, but this is also where droppers land."
fi
[[ ${#ORPHAN_PLUGINS[@]} -gt 0 ]] && \
finding med "${#ORPHAN_PLUGINS[@]} plugin director(ies) are not in the .org repo, so there is no authoritative copy to diff them against. Premium and bespoke code lives here, and so does a shell in a directory named to look like a plugin. Check each against a vendor original."
hdr "Themes (${#THEME_SLUGS[@]})"
rpt "In the wordpress.org repository (${#REPO_THEMES[@]}):"
for s in ${REPO_THEMES[@]+"${REPO_THEMES[@]}"}; do rpt " $s"; done
rpt ""
rpt "NOT in the .org repository (${#ORPHAN_THEMES[@]}):"
for s in ${ORPHAN_THEMES[@]+"${ORPHAN_THEMES[@]}"}; do
n=$(find "$WPC/themes/$s" -xdev -type f 2>/dev/null | wc -l)
rpt " $s ($n files)"
done
[[ ${#ORPHAN_THEMES[@]} -eq 0 ]] && rpt " (none)"
if [[ ${#UNKNOWN_THEMES[@]} -gt 0 ]]; then
rpt ""
rpt "COULD NOT BE CHECKED -- api.wordpress.org did not answer (${#UNKNOWN_THEMES[@]}):"
for s in "${UNKNOWN_THEMES[@]}"; do rpt " $s"; done
rpt " These are unclassified, NOT suspicious. Re-run when the API is reachable."
fi
[[ ${#ORPHAN_THEMES[@]} -gt 0 ]] && \
finding med "${#ORPHAN_THEMES[@]} theme director(ies) are not in the .org repo. Child themes and bespoke builds are normal here; so is a trojaned copy of a stock theme renamed to look bespoke."
if [[ $ORG_API_DEGRADED -eq 1 ]]; then
finding med "api.wordpress.org could not be reached for ${#UNKNOWN_PLUGINS[@]} plugin(s) and ${#UNKNOWN_THEMES[@]} theme(s), so this run cannot say whether they are stock. Treat the repo/non-repo split above as incomplete, and treat the pattern-scan review list as over-long: hits inside those unclassified directories may well be ordinary plugin code. Re-run once outbound HTTPS to api.wordpress.org works."
fi
# --- Uploads: anything executable
hdr "Executable files in uploads"
UPLOADS="$WPC/uploads"
UPLOAD_EXEC="$TMPDIR_LOCAL/uploads-exec.txt"; : >"$UPLOAD_EXEC"
if [[ -d $UPLOADS ]]; then
EXT_FIND=(); for e in "${EXEC_EXTENSIONS[@]}"; do EXT_FIND+=( -o -iname "*.$e" ); done
find "$UPLOADS" -xdev -type f \( "${EXT_FIND[@]:1}" \) -printf '%P\n' 2>/dev/null | sort >>"$UPLOAD_EXEC" || true
# Files with an innocent extension but PHP inside -- polyglot images, mostly.
# -a, not -I: these files ARE binary, which is the whole point. -I would skip
# exactly the files we are looking for.
find "$UPLOADS" -xdev -type f -size -2M \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' \
-o -iname '*.ico' -o -iname '*.svg' -o -iname '*.webp' -o -iname '*.txt' \) \
-exec grep -la -e '/dev/null \
| strip_prefix "$UPLOADS/" >>"$UPLOAD_EXEC" || true
sort -u -o "$UPLOAD_EXEC" "$UPLOAD_EXEC"
UP_N=$(wc -l <"$UPLOAD_EXEC")
rpt "Executable / PHP-bearing files under uploads: $UP_N"
head -50 "$UPLOAD_EXEC" | while IFS= read -r l; do rpt " $l"; done
[[ $UP_N -gt 50 ]] && rpt " ... and $((UP_N - 50)) more (full list: $WORKDIR/uploads-executable.txt)"
cp -- "$UPLOAD_EXEC" "$WORKDIR/uploads-executable.txt" 2>/dev/null || true
[[ $UP_N -gt 0 ]] && \
finding high "$UP_N executable or PHP-bearing file(s) in wp-content/uploads. Uploads should contain no executable code at all, so every one of these is either a webshell or a plugin doing something it should not. Full list: $WORKDIR/uploads-executable.txt"
else
rpt "No uploads directory."
fi
# --- Symlinks
hdr "Symlinks"
SYM_N=0
while IFS= read -r l; do
tgt=$(readlink -f -- "$l" 2>/dev/null || readlink -- "$l")
SYM_N=$((SYM_N+1))
rpt " ${l#"$DOCROOT"/} -> ${tgt:-?}"
if [[ -n ${tgt:-} && $tgt != "$DOCROOT"/* ]]; then
finding high "Symlink ${l#"$DOCROOT"/} points outside the docroot, to ${tgt}. Content is served from a path nothing in this report walked, and it survives any docroot replacement."
note_parent_persistence "$tgt" "symlink target of ${l#"$DOCROOT"/} -- served as site content"
fi
done < <(find "$DOCROOT" -xdev -type l 2>/dev/null | head -100)
[[ $SYM_N -eq 0 ]] && rpt " (none)"
# --- Bounded malware grep
hdr "Suspicious code patterns in wp-content"
declare -a PAT_HIGH=(
'eval[[:space:]]*\('
'assert[[:space:]]*\([[:space:]]*\$'
'create_function[[:space:]]*\('
'preg_replace[[:space:]]*\([[:space:]]*["'"'"'].*/[a-z]*e[a-z]*["'"'"']'
'\$_(POST|GET|REQUEST|COOKIE|SERVER)[[:space:]]*\[[^]]*\][[:space:]]*\('
'base64_decode[[:space:]]*\([[:space:]]*\$'
'(gzinflate|gzuncompress|str_rot13)[[:space:]]*\([[:space:]]*(base64_decode|\$)'
'auto_prepend_file|auto_append_file'
'(FilesMan|WSOsetcookie|b374k|c99shell|r57shell|IndoXploit|AnonymousFox|wp-vcd|wsoFooter)'
'(shell_exec|passthru|proc_open|popen|pcntl_exec)[[:space:]]*\('
)
declare -a PAT_MED=(
'base64_decode[[:space:]]*\('
'file_put_contents[[:space:]]*\([[:space:]]*\$'
'move_uploaded_file[[:space:]]*\('
'(\\x[0-9a-fA-F]{2}){8,}'
'[A-Za-z0-9+/]{300,}={0,2}'
'error_reporting[[:space:]]*\([[:space:]]*0[[:space:]]*\)'
'@?ini_set[[:space:]]*\([[:space:]]*["'"'"']display_errors'
)
grep_pat() { # grep_pat "regex" -> file list
# Scan wp-content plus any nested WordPress installations. Without the nested
# roots, a webshell in an abandoned /blog install is invisible here -- and that
# abandoned install is a likely entry point, so it is exactly what we want to see.
# Prune known cache/log dirs -- they dominate inodes and never hold the signal.
local prune=( \( )
local n first=1
for n in "${CACHE_DIR_NAMES[@]}"; do
[[ $first -eq 1 ]] || prune+=( -o )
prune+=( -name "$n" )
first=0
done
prune+=( \) -type d -prune -o )
find "$WPC" ${SCAN_EXTRA_ROOTS[@]+"${SCAN_EXTRA_ROOTS[@]}"} \
-xdev "${prune[@]}" \
-type f -size -"${SCAN_MAX_BYTES}c" \
\( -iname '*.php' -o -iname '*.phtml' -o -iname '*.inc' -o -iname '*.js' \
-o -iname '.htaccess' -o -iname '*.ini' \) \
-exec grep -lIE "$1" {} + 2>/dev/null || true
}
SCAN_EXTRA_ROOTS=()
for np in ${NESTED_PATHS[@]+"${NESTED_PATHS[@]}"}; do
# Skip nested trees already inside wp-content -- find would walk them twice.
[[ $np == wp-content/* ]] && continue
SCAN_EXTRA_ROOTS+=("$DOCROOT/$np")
done
HIGH_HITS="$TMPDIR_LOCAL/hits-high.txt"; : >"$HIGH_HITS"
MED_HITS="$TMPDIR_LOCAL/hits-med.txt"; : >"$MED_HITS"
info "Scanning wp-content${SCAN_EXTRA_ROOTS[0]+ and ${#SCAN_EXTRA_ROOTS[@]} nested install(s)} for suspicious patterns (files under $(human "$SCAN_MAX_BYTES"); cache dirs pruned)..."
if [[ $PATHOLOGICAL_TREE -eq 1 ]]; then
warn "Pathological tree: pattern scan still runs but skips ${CACHE_DIR_NAMES[*]} directories."
fi
for p in "${PAT_HIGH[@]}"; do grep_pat "$p" >>"$HIGH_HITS"; done
for p in "${PAT_MED[@]}"; do grep_pat "$p" >>"$MED_HITS"; done
sort -u -o "$HIGH_HITS" "$HIGH_HITS"; sort -u -o "$MED_HITS" "$MED_HITS"
if comm -23 "$MED_HITS" "$HIGH_HITS" >"$TMPDIR_LOCAL/hits-med2.txt" 2>/dev/null; then
mv -- "$TMPDIR_LOCAL/hits-med2.txt" "$MED_HITS"
fi
HH=$(wc -l <"$HIGH_HITS"); MH=$(wc -l <"$MED_HITS")
# Split the hits by whether an authoritative copy of the file exists. Legitimate
# plugins do use base64_decode and friends -- Yoast SEO's crypto code trips
# these patterns on a completely healthy site. A hit inside a .org-repo plugin
# or theme can be settled by diffing against the .org zip, so it is not where a
# tech should start. What deserves eyes first is everything else: mu-plugins,
# drop-ins, uploads, non-repo plugins and themes.
DIFFABLE_RE=""
for s in ${REPO_PLUGINS[@]+"${REPO_PLUGINS[@]}"}; do DIFFABLE_RE+="|wp-content/plugins/$s/"; done
for s in ${REPO_THEMES[@]+"${REPO_THEMES[@]}"}; do DIFFABLE_RE+="|wp-content/themes/$s/"; done
DIFFABLE_RE=${DIFFABLE_RE#|}
HITS_NEEDING_EYES="$TMPDIR_LOCAL/hits-eyes.txt"; : >"$HITS_NEEDING_EYES"
HITS_DIFFABLE="$TMPDIR_LOCAL/hits-diffable.txt"; : >"$HITS_DIFFABLE"
if [[ -n $DIFFABLE_RE ]]; then
grep -E "$DIFFABLE_RE" "$HIGH_HITS" >"$HITS_DIFFABLE" 2>/dev/null || true
grep -vE "$DIFFABLE_RE" "$HIGH_HITS" >"$HITS_NEEDING_EYES" 2>/dev/null || true
else
cp -- "$HIGH_HITS" "$HITS_NEEDING_EYES" 2>/dev/null || true
fi
HE=$(wc -l <"$HITS_NEEDING_EYES"); HR=$(wc -l <"$HITS_DIFFABLE")
rpt "High-confidence pattern hits : $HH file(s)"
rpt ""
rpt " (a) In .org plugins/themes -- diff against the .org zip to settle: $HR"
rpt " Usually benign -- legitimate plugins do use base64_decode and crypto."
strip_prefix "$DOCROOT/" <"$HITS_DIFFABLE" | head -10 | while IFS= read -r l; do rpt " $l"; done || true
[[ $HR -gt 10 ]] && rpt " ... and $((HR-10)) more"
rpt ""
rpt " (b) NEEDS REVIEW -- no authoritative copy exists to compare against: $HE"
[[ $ORG_API_DEGRADED -eq 1 ]] && \
rpt " NOTE: api.wordpress.org was unreachable for some slugs, so this list is"
[[ $ORG_API_DEGRADED -eq 1 ]] && \
rpt " longer than it should be -- unclassified plugins land here by default."
strip_prefix "$DOCROOT/" <"$HITS_NEEDING_EYES" | head -40 | while IFS= read -r l; do rpt " $l"; done || true
[[ $HE -gt 40 ]] && rpt " ... and $((HE-40)) more"
rpt ""
rpt "Lower-confidence hits : $MH file(s) (full list in workdir)"
cp -- "$HIGH_HITS" "$WORKDIR/scan-high-confidence.txt" 2>/dev/null || true
cp -- "$MED_HITS" "$WORKDIR/scan-low-confidence.txt" 2>/dev/null || true
cp -- "$HITS_NEEDING_EYES" "$WORKDIR/scan-needs-review.txt" 2>/dev/null || true
[[ $HE -gt 0 ]] && finding high "$HE file(s) match high-confidence webshell/obfuscation patterns in code with no authoritative copy to compare against (mu-plugins, drop-ins, uploads, non-repo plugins/themes). These are the ones to actually read. Full list: $WORKDIR/scan-needs-review.txt"
[[ $HE -eq 0 && $HH -gt 0 ]] && finding low "All $HH pattern hits are inside .org-repo plugins/themes, where a diff against the .org zip settles the question. Most likely benign."
rpt ""
rpt "These patterns catch careless malware. A competent implant matches none of"
rpt "them, so a clean scan here is not evidence of a clean site. Read the findings"
rpt "above it -- an unexplained drop-in beats any regex."
# --- Recently modified PHP, a useful timeline signal
hdr "Recently modified PHP files (last 30 days)"
# Scan from the docroot only. Passing both $DOCROOT and $WPC listed every
# wp-content file twice, since -printf %P is relative to each start point.
# Prune cache dirs so an inode bomb does not bury the timeline signal.
_recent_prune=( \( )
_rf=1
for _n in "${CACHE_DIR_NAMES[@]}"; do
[[ $_rf -eq 1 ]] || _recent_prune+=( -o )
_recent_prune+=( -name "$_n" )
_rf=0
done
_recent_prune+=( \) -type d -prune -o )
find "$DOCROOT" -xdev "${_recent_prune[@]}" \
-type f -name '*.php' -mtime -30 -printf '%TY-%Tm-%Td %TH:%TM\t%p\n' 2>/dev/null \
| sort -r | head -25 | strip_prefix "$DOCROOT/" 2 \
| while IFS= read -r l; do rpt " $l"; done || true
rpt ""
rpt "Oldest modification among flagged files is your best lead for the log review."
# ---------------------------------------------------------------------------
# 7. Database audit -- direct SQL, no PHP bootstrap
# ---------------------------------------------------------------------------
DB_DEFAULTS="$TMPDIR_LOCAL/my.cnf"
TABLE_PREFIX=""
# Every statement passed to db_query below is a SELECT or a SHOW. That is the
# invariant that makes this section read-only; keep it that way.
db_query() { mysql --defaults-extra-file="$DB_DEFAULTS" -N -B -D "${WPCFG[DB_NAME]}" -e "$1" 2>/dev/null || true; }
if [[ $SKIP_DB -eq 0 && $HAVE_MYSQL -eq 1 && -n ${WPCFG[DB_NAME]:-} ]]; then
hdr "Database audit"
TABLE_PREFIX=${WPCFG[table_prefix]:-wp_}
# This goes straight into SQL identifiers -- validate hard.
if [[ ! $TABLE_PREFIX =~ ^[A-Za-z0-9_]+$ ]]; then
warn "Refusing to use table prefix '$TABLE_PREFIX' -- not [A-Za-z0-9_]."
rpt "Not audited: the table prefix '$TABLE_PREFIX' is not [A-Za-z0-9_], and it"
rpt "would go straight into SQL identifiers. Nothing below was read."
finding high "Table prefix in wp-config.php is not alphanumeric: '$TABLE_PREFIX'. Investigate by hand."
TABLE_PREFIX=""
fi
if [[ -n $TABLE_PREFIX ]]; then
# WordPress DB_HOST forms: host, host:port, host:/socket, :/socket, [::1], [::1]:port.
DBHOST_RAW=${WPCFG[DB_HOST]:-localhost}
DBHOST=$DBHOST_RAW
DBPORT=""
DBSOCKET=""
if [[ $DBHOST_RAW == :/* ]]; then
# Socket-only (:/path/to.sock)
DBHOST="localhost"
DBSOCKET=${DBHOST_RAW#:}
elif [[ $DBHOST_RAW =~ ^\[([^\]]+)\]:([0-9]+)$ ]]; then
DBHOST=${BASH_REMATCH[1]}
DBPORT=${BASH_REMATCH[2]}
elif [[ $DBHOST_RAW =~ ^\[([^\]]+)\]$ ]]; then
DBHOST=${BASH_REMATCH[1]}
elif [[ $DBHOST_RAW =~ ^[^:]+:/.+ ]]; then
# host:/path/to.sock
DBHOST=${DBHOST_RAW%%:*}
DBSOCKET=${DBHOST_RAW#*:}
elif [[ $DBHOST_RAW =~ ^([^:]+):([0-9]+)$ ]]; then
DBHOST=${BASH_REMATCH[1]}
DBPORT=${BASH_REMATCH[2]}
fi
# else: bare hostname, or unbracketed IPv6 like ::1 — leave as-is for the client.
umask 077
{
printf '[client]\n'
printf 'user=%s\n' "${WPCFG[DB_USER]:-}"
# Quote the password so # and leading/trailing spaces survive option-file parsing.
pw=${WPCFG[DB_PASSWORD]:-}
pw=${pw//\\/\\\\}
pw=${pw//\"/\\\"}
printf 'password="%s"\n' "$pw"
printf 'host=%s\n' "$DBHOST"
[[ -n $DBPORT && $DBPORT =~ ^[0-9]+$ ]] && printf 'port=%s\n' "$DBPORT"
[[ -n $DBSOCKET ]] && printf 'socket=%s\n' "$DBSOCKET"
} >"$DB_DEFAULTS"
chmod 600 -- "$DB_DEFAULTS"
if [[ -n $(db_query "SELECT 1;") ]]; then
P=$TABLE_PREFIX
ok "Database connection established (no PHP executed)."
# Multisite changes the capability meta key per blog; flag rather than guess.
IS_MULTISITE=0
[[ -n $(db_query "SHOW TABLES LIKE '${P}blogs';") ]] && IS_MULTISITE=1
if [[ $IS_MULTISITE -eq 1 ]]; then
rpt "MULTISITE detected -- per-blog capability keys are not fully audited below."
finding med "This is a multisite network. Audit super-admins (${P}sitemeta / site_admins) and every sub-site's ${P}N_capabilities meta separately."
fi
rpt ""
rpt "Administrators:"
rpt "$(printf '%-6s %-24s %-34s %-20s' ID login email registered)"
while IFS=$'\t' read -r id login email reg; do
[[ -z ${id:-} ]] && continue
rpt "$(printf '%-6s %-24s %-34s %-20s' "$id" "$login" "$email" "$reg")"
done < <(db_query "SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM \`${P}users\` u
JOIN \`${P}usermeta\` m ON m.user_id = u.ID
AND m.meta_key = '${P}capabilities'
WHERE m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;")
ADMIN_N=$(db_query "SELECT COUNT(*) FROM \`${P}users\` u
JOIN \`${P}usermeta\` m ON m.user_id=u.ID AND m.meta_key='${P}capabilities'
WHERE m.meta_value LIKE '%administrator%';")
rpt ""
rpt "Administrator count: ${ADMIN_N:-?}"
# Admins created in the last 90 days are the ones worth asking about.
RECENT=$(db_query "SELECT COUNT(*) FROM \`${P}users\` u
JOIN \`${P}usermeta\` m ON m.user_id=u.ID AND m.meta_key='${P}capabilities'
WHERE m.meta_value LIKE '%administrator%'
AND u.user_registered > DATE_SUB(NOW(), INTERVAL 90 DAY);")
if [[ ${RECENT:-0} -gt 0 ]]; then
finding high "${RECENT} administrator account(s) were created in the last 90 days. Confirm every one with the customer. This script reports accounts and never touches them."
fi
# Users holding admin caps without the role string is a rarer trick.
# An account with user_level>=10 but no "administrator" capability string is
# admin-privileged yet invisible to `wp user list --role=administrator`,
# so any password rotation driven off that list misses it.
SNEAKY=$(db_query "SELECT CONCAT(u.ID, '|', u.user_login) FROM \`${P}users\` u
JOIN \`${P}usermeta\` m ON m.user_id=u.ID
WHERE m.meta_key='${P}user_level' AND m.meta_value >= 10
AND u.ID NOT IN (SELECT user_id FROM \`${P}usermeta\`
WHERE meta_key='${P}capabilities'
AND meta_value LIKE '%administrator%');")
if [[ -n ${SNEAKY:-} ]]; then
rpt ""
rpt "Users with user_level>=10 but no administrator role:"
while IFS='|' read -r sid slogin; do
[[ -n ${sid:-} ]] || continue
rpt " id=$sid $slogin"
done <<<"$SNEAKY"
finding high "Account(s) carry user_level>=10 without the administrator role string -- a way to stay off the Users screen filter. Reset these by ID; a rotation driven off 'wp user list --role=administrator' skips them."
fi
rpt ""
rpt "Key options:"
while IFS=$'\t' read -r k v; do
[[ -z ${k:-} ]] && continue
rpt "$(printf ' %-22s %s' "$k" "$v")"
done < <(db_query "SELECT option_name, LEFT(option_value,120) FROM \`${P}options\`
WHERE option_name IN ('siteurl','home','admin_email','users_can_register',
'default_role','template','stylesheet','blog_public');")
DEF_ROLE=$(db_query "SELECT option_value FROM \`${P}options\` WHERE option_name='default_role';")
UCR=$(db_query "SELECT option_value FROM \`${P}options\` WHERE option_name='users_can_register';")
[[ ${UCR:-0} == "1" && ${DEF_ROLE:-} == "administrator" ]] && \
finding high "Open registration is enabled AND the default role is administrator. Anyone can register as an admin. Fix immediately."
[[ ${DEF_ROLE:-} == "administrator" ]] && \
finding high "default_role is set to 'administrator'."
rpt ""
rpt "Largest autoloaded options (loaded on every page request):"
while IFS=$'\t' read -r n l; do
[[ -z ${n:-} ]] && continue
rpt "$(printf ' %-46s %s' "$n" "$(human "${l:-0}")")"
done < <(db_query "SELECT option_name, LENGTH(option_value) len FROM \`${P}options\`
WHERE autoload NOT IN ('no','off') ORDER BY len DESC LIMIT 12;")
AUTOLOAD_TOTAL=$(db_query "SELECT SUM(LENGTH(option_value)) FROM \`${P}options\` WHERE autoload NOT IN ('no','off');")
rpt " total autoloaded: $(human "${AUTOLOAD_TOTAL:-0}")"
[[ ${AUTOLOAD_TOTAL:-0} -gt $((3*1024*1024)) ]] && \
finding med "$(human "${AUTOLOAD_TOTAL}") of autoloaded options -- this is loaded and unserialized on every single request and is a common cause of a site feeling slow independent of the compromise."
SUSP_OPT=$(db_query "SELECT option_name FROM \`${P}options\`
WHERE option_value REGEXP 'eval\\\\(|base64_decode|gzinflate|