/
opt
/
dedrads
/
/opt/dedrads
mkdir
upload
Name
Size
Mode
Actions
account_review/
-
0555
rm
check_software_mods/
-
0555
rm
cms_tools/
-
0555
rm
etc/
-
0555
rm
extras/
-
0555
rm
mailparse/
-
0555
rm
mysql/
-
0555
rm
nlp_scripts/
-
0555
rm
oldrads/
-
0555
rm
perl/
-
0555
rm
provision/
-
0555
rm
python/
-
0555
rm
suspended/
-
0555
rm
__pycache__/
-
0555
rm
account-review
799
0555
edit
dl
rm
allfw
6259
0555
edit
dl
rm
alp.py
16553
0555
edit
dl
rm
autossl_sync
6267
0555
edit
dl
rm
check_apache
9205
0555
edit
dl
rm
check_bandwidth
4546
0555
edit
dl
rm
check_boxtrapper
1684
0555
edit
dl
rm
check_cpu
12212
0555
edit
dl
rm
check_crons
1827
0555
edit
dl
rm
check_dcpumon
1973
0555
edit
dl
rm
check_dns
1827
0555
edit
dl
rm
check_domlogs
61
0555
edit
dl
rm
check_exim
3731
0555
edit
dl
rm
check_hacks
22604
0555
edit
dl
rm
check_imap
5249
0555
edit
dl
rm
check_io
6635
0555
edit
dl
rm
check_max_children
16341
0555
edit
dl
rm
check_mem
1660
0555
edit
dl
rm
check_misc
1510
0555
edit
dl
rm
check_pacct
5201
0555
edit
dl
rm
check_php
4344
0555
edit
dl
rm
check_pop3
2295
0555
edit
dl
rm
check_prov.py
4714
0555
edit
dl
rm
check_server
696
0555
edit
dl
rm
check_size
490
0555
edit
dl
rm
check_software
9849
0555
edit
dl
rm
check_spamd
2426
0555
edit
dl
rm
check_traffic
1933
0555
edit
dl
rm
check_user
1887
0555
edit
dl
rm
clean_exim.py
6390
0555
edit
dl
rm
clusterfix
5343
0555
edit
dl
rm
cmspass.py
3970
0555
edit
dl
rm
cms_check.py
9825
0555
edit
dl
rm
cms_counter.py
36734
0555
edit
dl
rm
cms_creds
713
0555
edit
dl
rm
cms_dumpdb
4485
0555
edit
dl
rm
cms_pw
1416
0555
edit
dl
rm
cpanel-api
7040
0555
edit
dl
rm
cpanel_postgres_manager.py
29004
0555
edit
dl
rm
cpmerge
4631
0555
edit
dl
rm
cpumon
2195
0555
edit
dl
rm
dcpumon.pl
1239
0555
edit
dl
rm
dedcheck
18127
0555
edit
dl
rm
dns-sync
14386
0555
edit
dl
rm
docroot.py
4425
0555
edit
dl
rm
du-tree
7450
0555
edit
dl
rm
envinfo.py
6633
0555
edit
dl
rm
exclude_rbl.py
5249
0555
edit
dl
rm
extract-vhost
2691
0555
edit
dl
rm
filescan
3566
0555
edit
dl
rm
find_warez
1460
0555
edit
dl
rm
first_setup.py
19120
0555
edit
dl
rm
fixwpcron.py
4822
0555
edit
dl
rm
fix_dns_cluster
353
0555
edit
dl
rm
goaccess
1217720
0555
edit
dl
rm
hostsfilemods
4467
0555
edit
dl
rm
imap_io
2323
0555
edit
dl
rm
innodb_converter.py
9343
0555
edit
dl
rm
killall911
530
0555
edit
dl
rm
killdns
941
0555
edit
dl
rm
lastcommcache.sh
1374
0555
edit
dl
rm
legal_lock_down.sh
7450
0555
edit
dl
rm
lil-cpanel
52002
0555
edit
dl
rm
listacct
4575
0555
edit
dl
rm
mailscan
4544
0555
edit
dl
rm
mail_sources.py
7407
0555
edit
dl
rm
megaclisas-status
38664
0555
edit
dl
rm
migrate2central.sh
14418
0555
edit
dl
rm
modify-account
40631
0555
edit
dl
rm
modsec_disable.py
11461
0555
edit
dl
rm
monarxctl
8912
0555
edit
dl
rm
msp.pl
27714
0555
edit
dl
rm
mysql_dstat
474
0555
edit
dl
rm
mysql_selector.py
73102
0555
edit
dl
rm
nlp
4582
0555
edit
dl
rm
procscrape
1863
0555
edit
dl
rm
quarantine
11083
0555
edit
dl
rm
radsfunctions.sh
863
0555
edit
dl
rm
recent-cp
16148
0555
edit
dl
rm
remote_dump
18347
0555
edit
dl
rm
reset_cpanel
3205
0555
edit
dl
rm
server-load
2192
0555
edit
dl
rm
show-conns
20105
0555
edit
dl
rm
show-conns-adv.py
54667
0555
edit
dl
rm
sparta.py
382498
0555
edit
dl
rm
sqltop
27129
0555
edit
dl
rm
telcheck
2411
0555
edit
dl
rm
temprootreset
186
0555
edit
dl
rm
unsuspend_user
1307
0555
edit
dl
rm
unsusprunner.sh
1840
0555
edit
dl
rm
updatednsadmin
526
0555
edit
dl
rm
update_spf
14972
0555
edit
dl
rm
upgrade-check
9216
0555
edit
dl
rm
vhost_data.py
6221
0555
edit
dl
rm
wp-xray.sh
85816
0555
edit
dl
rm
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.<pid> '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 <<EOF $PROGNAME $VERSION -- WordPress incident triage. Read-only. USAGE $PROGNAME [options] <docroot> 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-<timestamp> 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 - <cpuser>" 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' '<IfModule !mod_authz_core.c>' 'Deny from all' '</IfModule>' \ >"$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 "path<TAB>reason". 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:-<not found>}" # 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" "<non-literal -- not resolved>")" else rpt "$(printf '%-16s: %s' "$k" "${WPCFG[$k]:-<missing>}")" 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="<non-literal -- not resolved>" 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="<found>" else PWSTATE="<defined but EMPTY>"; finding med "DB_PASSWORD is defined as an empty string in wp-config.php."; fi else PWSTATE="<not found>" 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":{"<version>":{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 '<?php' -e '<?=' {} + 2>/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|<script|document\\\\.write' LIMIT 25;") if [[ -n ${SUSP_OPT:-} ]]; then rpt "" rpt "Options containing code-like content:" printf '%s\n' "$SUSP_OPT" | while IFS= read -r l; do rpt " $l"; done finding high "Options table contains code-like values (eval/base64_decode/<script>). 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 ""
Save
cmd:
run