/opt/dedrads
NameSizeModeActions
account_review/-0555rm
check_software_mods/-0555rm
cms_tools/-0555rm
etc/-0555rm
extras/-0555rm
mailparse/-0555rm
mysql/-0555rm
nlp_scripts/-0555rm
oldrads/-0555rm
perl/-0555rm
provision/-0555rm
python/-0555rm
suspended/-0555rm
__pycache__/-0555rm
account-review7990555editdlrm
allfw62590555editdlrm
alp.py165530555editdlrm
autossl_sync62670555editdlrm
check_apache92050555editdlrm
check_bandwidth45460555editdlrm
check_boxtrapper16840555editdlrm
check_cpu122120555editdlrm
check_crons18270555editdlrm
check_dcpumon19730555editdlrm
check_dns18270555editdlrm
check_domlogs610555editdlrm
check_exim37310555editdlrm
check_hacks226040555editdlrm
check_imap52490555editdlrm
check_io66350555editdlrm
check_max_children163410555editdlrm
check_mem16600555editdlrm
check_misc15100555editdlrm
check_pacct52010555editdlrm
check_php43440555editdlrm
check_pop322950555editdlrm
check_prov.py47140555editdlrm
check_server6960555editdlrm
check_size4900555editdlrm
check_software98490555editdlrm
check_spamd24260555editdlrm
check_traffic19330555editdlrm
check_user18870555editdlrm
clean_exim.py63900555editdlrm
clusterfix53430555editdlrm
cmspass.py39700555editdlrm
cms_check.py98250555editdlrm
cms_counter.py367340555editdlrm
cms_creds7130555editdlrm
cms_dumpdb44850555editdlrm
cms_pw14160555editdlrm
cpanel-api70400555editdlrm
cpanel_postgres_manager.py290040555editdlrm
cpmerge46310555editdlrm
cpumon21950555editdlrm
dcpumon.pl12390555editdlrm
dedcheck181270555editdlrm
dns-sync143860555editdlrm
docroot.py44250555editdlrm
du-tree74500555editdlrm
envinfo.py66330555editdlrm
exclude_rbl.py52490555editdlrm
extract-vhost26910555editdlrm
filescan35660555editdlrm
find_warez14600555editdlrm
first_setup.py191200555editdlrm
fixwpcron.py48220555editdlrm
fix_dns_cluster3530555editdlrm
goaccess12177200555editdlrm
hostsfilemods44670555editdlrm
imap_io23230555editdlrm
innodb_converter.py93430555editdlrm
killall9115300555editdlrm
killdns9410555editdlrm
lastcommcache.sh13740555editdlrm
legal_lock_down.sh74500555editdlrm
lil-cpanel520020555editdlrm
listacct45750555editdlrm
mailscan45440555editdlrm
mail_sources.py74070555editdlrm
megaclisas-status386640555editdlrm
migrate2central.sh144180555editdlrm
modify-account406310555editdlrm
modsec_disable.py114610555editdlrm
monarxctl89120555editdlrm
msp.pl277140555editdlrm
mysql_dstat4740555editdlrm
mysql_selector.py731020555editdlrm
nlp45820555editdlrm
procscrape18630555editdlrm
quarantine110830555editdlrm
radsfunctions.sh8630555editdlrm
recent-cp161480555editdlrm
remote_dump183470555editdlrm
reset_cpanel32050555editdlrm
server-load21920555editdlrm
show-conns201050555editdlrm
show-conns-adv.py546670555editdlrm
sparta.py3824980555editdlrm
sqltop271290555editdlrm
telcheck24110555editdlrm
temprootreset1860555editdlrm
unsuspend_user13070555editdlrm
unsusprunner.sh18400555editdlrm
updatednsadmin5260555editdlrm
update_spf149720555editdlrm
upgrade-check92160555editdlrm
vhost_data.py62210555editdlrm
wp-xray.sh858160555editdlrm
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|/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|). Database-resident injection survives any filesystem work at all -- reinstalling every file on the server changes nothing here. Clean these by hand." fi CRON_HOOKS=$(db_query "SELECT option_value FROM \`${P}options\` WHERE option_name='cron';" \ | grep -oE '"[a-z0-9_]{3,60}"' | tr -d '"' | sort -u | head -40 || true) if [[ -n ${CRON_HOOKS:-} ]]; then rpt "" rpt "Scheduled cron hooks:" printf '%s\n' "$CRON_HOOKS" | while IFS= read -r l; do rpt " $l"; done fi ACTIVE_PLUGINS=$(db_query "SELECT option_value FROM \`${P}options\` WHERE option_name='active_plugins';" \ | grep -oE '"[^"]+\.php"' | tr -d '"' | sed 's|/.*||' | sort -u || true) # Written whenever the option was read, empty or not. No file means the # database was never reached. : >"$WORKDIR/active-plugins.txt" if [[ -n ${ACTIVE_PLUGINS:-} ]]; then printf '%s\n' "$ACTIVE_PLUGINS" >"$WORKDIR/active-plugins.txt" rpt "" rpt "Active plugins (from DB): $(tr '\n' ' ' <<<"$ACTIVE_PLUGINS")" fi else warn "Could not connect to the database with the credentials from wp-config.php." rpt "Not audited: could not connect to '${WPCFG[DB_NAME]}' on '${DBHOST_RAW}' with" rpt "the credentials in wp-config.php. Nothing below was read. Rogue accounts," rpt "injected options and cron hooks are all invisible to this run." finding med "Database credentials from wp-config.php did not authenticate. The DB may be down, moved, or the config may be stale." fi fi elif [[ $SKIP_DB -eq 1 ]]; then hdr "Database audit"; rpt "Skipped (--no-db)." else hdr "Database audit"; rpt "Skipped (mysql client or DB credentials unavailable)." fi # --------------------------------------------------------------------------- # 8. Files outside the docroot that still execute for this site # # Collected while parsing wp-config.php and the ini files. They are listed # together because they are the ones a docroot reinstall silently leaves behind. # --------------------------------------------------------------------------- hdr "Persistence outside the docroot" if [[ ${#PARENT_PERSISTENCE[@]} -eq 0 ]]; then rpt "None found above $DOCROOT." else rpt "These paths sit outside the docroot and are not covered by anything you do" rpt "inside it. Replacing the docroot leaves every one of them in place." rpt "" PP_UNREADABLE=0 for entry in "${PARENT_PERSISTENCE[@]}"; do f=${entry%%$'\t'*} why=${entry#*$'\t'} if [[ -e $f ]]; then if [[ -d $f ]]; then state="directory, $(find "$f" -xdev -type f 2>/dev/null | wc -l | tr -d ' ') file(s)" else state="$(human "$(stat -c '%s' -- "$f" 2>/dev/null || echo 0)"), mtime $(stat -c '%y' -- "$f" 2>/dev/null | cut -d. -f1)" fi else # Either the attacker has not dropped it yet, or -- far more often -- it # exists but this user cannot stat it. Both mean "go look as someone who can". state="NOT PRESENT or not readable as $(id -un)" PP_UNREADABLE=1 fi rpt " $f" rpt " $why" rpt " [$state]" done finding high "${#PARENT_PERSISTENCE[@]} path(s) outside the docroot are loaded by this site: listed under 'Persistence outside the docroot'. These are the files a docroot reinstall does not touch and a core checksum scan never sees. Read every one before calling the site clean." [[ $PP_UNREADABLE -eq 1 ]] && \ finding med "At least one path referenced above could not be stat'd as $(id -un). A directive pointing at a file you cannot read is still executing for the site -- check it as root or as the owning user rather than assuming it is absent." fi # --------------------------------------------------------------------------- # 9. Findings summary # --------------------------------------------------------------------------- hdr "FINDINGS" n=0 if [[ ${#FINDING_HIGH[@]} -gt 0 ]]; then rpt "" rpt "${C_RED}HIGH${C_RESET}" for f in "${FINDING_HIGH[@]}"; do n=$((n+1)); rpt " $n. $f"; done fi if [[ ${#FINDING_MED[@]} -gt 0 ]]; then rpt "" rpt "${C_YEL}MEDIUM${C_RESET}" for f in "${FINDING_MED[@]}"; do n=$((n+1)); rpt " $n. $f"; done fi if [[ ${#FINDING_LOW[@]} -gt 0 ]]; then rpt "" rpt "${C_DIM}LOW / HOUSEKEEPING${C_RESET}" for f in "${FINDING_LOW[@]}"; do n=$((n+1)); rpt " $n. $f"; done fi [[ $n -eq 0 ]] && rpt " Nothing flagged. That is not the same as clean." # --------------------------------------------------------------------------- # 10. Wrap up # --------------------------------------------------------------------------- hdr "REPORT COMPLETE" rpt "Nothing was modified. The docroot and the database are exactly as they were." rpt "" rpt "Report : $REPORT_FILE" rpt "Log : $LOG_FILE" rpt "Workdir : $WORKDIR (mode 700 -- it lists live credentials paths and" rpt " malware locations; do not paste it into a public ticket)" rpt " Each list file there exists if that check ran. An empty one means" rpt " the check ran and found nothing; a missing one means it did not run." rpt "" rpt "WHAT THIS REPORT DOES NOT TELL YOU:" rpt " 1. The entry point. Nothing here identifies how they got in. Start the" rpt " log review at the mtime of the earliest modified file listed above." rpt " Without that answer, a cleaned site is reinfected within days." rpt " 2. Whether the code is clean. The pattern scan catches careless malware" rpt " only. Findings about files that should not exist at all -- drop-ins," rpt " mu-plugins, executables in uploads -- are worth more than any scan." rpt " 3. Anything about a nested installation listed above, beyond the pattern" rpt " scan. Run this script against each of them." rpt " 4. Anything about injected post or postmeta content, which is not" rpt " scanned at all and survives every filesystem repair." rpt "" rpt "Everything above is a report. Acting on it -- rotating credentials," rpt "replacing code, removing accounts -- is a separate, deliberate decision." ok "Report written to $REPORT_FILE" exit 0 rpt ""