#!/bin/bash ############################################################################### # detect.sh — cold-disk malware detection and family-classification scanner # # Read-only. Scans a mounted container rootfs (OpenVZ 7 / Incus / extracted # tarball) and classifies findings by known family. Runs YARA # (/srv/perfctl.yar), chkrootkit, rkhunter, debsums, and static-IoC checks. # # Usage: # ROOTFS=/vz/root/101 ./detect.sh # ROOTFS=/var/lib/incus/storage-pools/default/containers/c1/rootfs ./detect.sh # # Opt-in extras (slow): # RUN_CLAMAV=1 ROOTFS=... ./detect.sh # RUN_RKHUNTER=0 ROOTFS=... ./detect.sh # skip rkhunter # # Exit codes: # 0 = no evidence of known malware families # 1 = suspicious (review recommended) # 2 = confirmed infection (see verdict) ############################################################################### set -uo pipefail if [ -z "${ROOTFS:-}" ]; then echo "ERROR: ROOTFS is not set." >&2 echo "Usage: ROOTFS=/path/to/mounted/rootfs $0" >&2 exit 2 fi if [ ! -d "$ROOTFS" ]; then echo "ERROR: ROOTFS='$ROOTFS' is not a directory" >&2 exit 2 fi R="$ROOTFS" YARA_RULES="${YARA_RULES:-/srv/perfctl.yar}" LOG="/var/log/malware-detect-${ROOTFS//\//_}-$(date +%Y%m%d-%H%M%S).log" # opt-in / opt-out scanner controls RUN_YARA="${RUN_YARA:-1}" RUN_CHKROOTKIT="${RUN_CHKROOTKIT:-1}" RUN_RKHUNTER="${RUN_RKHUNTER:-1}" RUN_DEBSUMS="${RUN_DEBSUMS:-1}" RUN_CLAMAV="${RUN_CLAMAV:-0}" FAMILIES="perfctl xmrig proxyjacking ldpreload_rootkit lkm_rootkit ssh_backdoor generic_compromise" declare -A FAMILY_SCORE declare -A FAMILY_EVIDENCE for f in $FAMILIES; do FAMILY_SCORE[$f]=0 FAMILY_EVIDENCE[$f]="" done # ---------- helpers --------------------------------------------------------- log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; } warn() { echo "[$(date '+%H:%M:%S')] WARN: $*" | tee -a "$LOG"; } ok() { echo "[$(date '+%H:%M:%S')] OK: $*" | tee -a "$LOG"; } hit() { echo "[$(date '+%H:%M:%S')] HIT: $*" | tee -a "$LOG"; } evidence() { local family="$1" weight="$2" detail="$3" FAMILY_SCORE[$family]=$((FAMILY_SCORE[$family] + weight)) FAMILY_EVIDENCE[$family]="${FAMILY_EVIDENCE[$family]} [+${weight}] ${detail}"$'\n' hit "[$family +$weight] $detail" } need() { command -v "$1" >/dev/null 2>&1; } # ---------- preflight ------------------------------------------------------- log "==========================================" log "malware detect (cold-disk) started" log "ROOTFS: $R" log "Log: $LOG" log "YARA rules: $YARA_RULES" log "==========================================" # ---------- PHASE 1 — static IoC file presence ------------------------------ log "" log "=== PHASE 1: Static IoC file presence ===" for f in \ "$R/etc/ld.so.preload" \ "$R/lib/libgcwrap.so" "$R/usr/lib/libgcwrap.so" \ "$R/lib/libfsnldev.so" "$R/usr/lib/libfsnldev.so" \ "$R/lib/libfsnkdev.so" "$R/usr/lib/libfsnkdev.so" \ "$R/lib/libpprocps.so" "$R/usr/lib/libpprocps.so" \ "$R/dev/shm/libfsnldev.so" \ "$R/bin/perfcc" "$R/usr/bin/perfcc" \ "$R/bin/wizlmsh" "$R/usr/bin/wizlmsh" \ "$R/bin/kkbush" "$R/usr/bin/kkbush" \ "$R/tmp/.apid" "$R/tmp/gd.sh" "$R/tmp/ccrl" \ "$R/tmp/kubeupd" "$R/tmp/javax64" "$R/tmp/wttwe" "$R/tmp/smpr" \ "$R/root/.config/cron/perfcc" \ ; do if [ -e "$f" ] || [ -L "$f" ]; then evidence perfctl 10 "file present: $f" fi done for d in "$R/tmp/.xdiag" "$R/tmp/.perf.c" "$R/tmp/.dmesg" \ "$R/bin/wbin" "$R/bin/.local" "$R/bin/.atmp" \ "$R/root/.config/cron" "$R/root/.config/traffmonetizer" \ "$R/var/-nonrpk" "$R/var/.r.rpk"; do if [ -d "$d" ]; then case "$d" in */traffmonetizer) evidence proxyjacking 8 "dir present: $d" ;; */-nonrpk|*/.r.rpk) evidence proxyjacking 8 "dir present: $d" ;; *) evidence perfctl 8 "dir present: $d" ;; esac fi done if [ -f "$R/etc/ld.so.preload" ] && [ -s "$R/etc/ld.so.preload" ]; then preload_content=$(cat "$R/etc/ld.so.preload" 2>/dev/null) log " ld.so.preload content: $preload_content" if echo "$preload_content" | grep -qE 'libgcwrap|libfsnldev|libfsnkdev|libpprocps'; then evidence perfctl 15 "ld.so.preload points at known perfctl rootkit" elif echo "$preload_content" | grep -qE '^/(tmp|dev/shm|var/tmp|home|opt)/'; then evidence ldpreload_rootkit 10 "ld.so.preload points at non-system path: $preload_content" fi fi ok "static IoC scan done" # ---------- PHASE 2 — YARA scan --------------------------------------------- log "" log "=== PHASE 2: YARA scan ===" if [ "$RUN_YARA" = "1" ] && need yara && [ -f "$YARA_RULES" ]; then YARA_EXCLUDE="${YARA_EXCLUDE:-\.claude/|\.cache/|\.mozilla/|var/log/|var/backups/|/root/forensics|/share/rkhunter|/share/chkrootkit|/share/clamav}" YARA_OUT=$(mktemp) YARA_RAW=$(mktemp) for p in etc bin sbin usr/bin usr/sbin lib usr/lib lib64 \ tmp var/tmp dev/shm opt root home var/spool/cron; do target="$R/$p" [ -e "$target" ] || continue timeout 120 yara -r -w -N "$YARA_RULES" "$target" 2>/dev/null >> "$YARA_RAW" || true done grep -vE "$YARA_EXCLUDE" "$YARA_RAW" > "$YARA_OUT" 2>/dev/null || true rm -f "$YARA_RAW" YARA_HITS=$(grep -E '^[A-Za-z_][A-Za-z0-9_]*[[:space:]]+/' "$YARA_OUT" || true) if [ -n "$YARA_HITS" ]; then log " yara hits:" echo "$YARA_HITS" | tee -a "$LOG" log "" while IFS= read -r line; do rule=$(echo "$line" | awk '{print $1}') file=$(echo "$line" | cut -d' ' -f2-) case "$rule" in perfctl_known_hashes|perfctl_libgcwrap_rootkit|perfctl_perfcc_installer|perfctl_wizlmsh_suid_backdoor) evidence perfctl 20 "yara $rule on $file" ;; perfctl_known_filenames|perfctl_dropper_paths_in_any_file|perfctl_cron_persistence|perfctl_network_indicators) evidence perfctl 12 "yara $rule on $file" ;; XMRIG_Monero_Miner|PUA_LNX_XMRIG_CryptoMiner|XMRIG_Monero_Miner_Config) evidence xmrig 15 "yara $rule on $file" ;; SUSP_XMRIG_String|CoinMiner_Strings|PUA_CryptoMiner_cpuminer|PUA_Crypto_Mining_CommandLine_Indicators|linux_miner_pool_urls) evidence xmrig 8 "yara $rule on $file" ;; linux_proxyjacking_agents|linux_proxyjacking_container_images) evidence proxyjacking 10 "yara $rule on $file" ;; linux_ldpreload_rootkit_generic|linux_ldpreload_config_file) evidence ldpreload_rootkit 12 "yara $rule on $file" ;; linux_ssh_backdoor_authorized_keys_in_binary|linux_pam_credential_stealer) evidence ssh_backdoor 12 "yara $rule on $file" ;; linux_lkm_rootkit_known_names) evidence lkm_rootkit 15 "yara $rule on $file" ;; *) evidence generic_compromise 5 "yara $rule on $file" ;; esac done <<< "$YARA_HITS" else ok "no yara hits" fi rm -f "$YARA_OUT" else log " skipped (yara not installed, disabled, or rules file missing)" fi # ---------- PHASE 3 — chkrootkit (cold-disk with -r) ------------------------ if [ "$RUN_CHKROOTKIT" = "1" ]; then log "" log "=== PHASE 3: chkrootkit ===" if need chkrootkit; then CHK_OUT=$(mktemp) timeout 300 chkrootkit -r "$R" > "$CHK_OUT" 2>&1 || true # match only real hits — "INFECTED" is chkrootkit's positive-finding keyword. # Skip descriptive "Searching for suspicious..." progress lines. INFECTED=$(grep -E '\bINFECTED\b' "$CHK_OUT" 2>/dev/null | head -40 || true) if [ -n "$INFECTED" ]; then warn "chkrootkit findings:" echo "$INFECTED" | tee -a "$LOG" hits=$(echo "$INFECTED" | wc -l) if echo "$INFECTED" | grep -qiE 'suckit|adore|kbeast|suterusu|diamorphine|reptile|lrk'; then evidence lkm_rootkit 25 "chkrootkit named rootkit" fi evidence generic_compromise "$((hits > 10 ? 10 : hits))" "chkrootkit: $hits INFECTED lines" else ok "chkrootkit: no findings" fi rm -f "$CHK_OUT" else log " chkrootkit not installed on host — skipping (apt install chkrootkit)" fi fi # ---------- PHASE 4 — rkhunter (cold-disk with --rootdir) ------------------- if [ "$RUN_RKHUNTER" = "1" ]; then log "" log "=== PHASE 4: rkhunter ===" if need rkhunter; then RK_OUT=$(mktemp) timeout 600 rkhunter --rootdir "$R" --check --sk --nocolors > "$RK_OUT" 2>&1 || true # Score only rkhunter lines that BOTH name a rootkit AND carry a # positive-finding status ([ Warning ]/[ Found ]/[ Suspect ]/[ Infected ]). # rkhunter emits "Diamorphine LKM [ Not found ]" on every run — matching # the name alone would false-positive against that progress output. RK_NAMED=$(grep -iE '(suckit|adore|kbeast|suterusu|diamorphine|reptile|xzres|maliroot).*\[ *(Warning|Found|Suspect|Infected) *\]' "$RK_OUT" 2>/dev/null | head -10 || true) if [ -n "$RK_NAMED" ]; then warn "rkhunter named a rootkit with positive status:" echo "$RK_NAMED" | tee -a "$LOG" evidence lkm_rootkit 25 "rkhunter named rootkit" fi # Log warning count for analyst context but do not score RK_WARN_COUNT=$(grep -cE '\[ *Warning *\]' "$RK_OUT" 2>/dev/null || echo 0) log " rkhunter: $RK_WARN_COUNT warning lines (not scored — review $RK_OUT-style output manually)" if [ "$RK_WARN_COUNT" -gt 0 ]; then grep -E '\[ *Warning *\]' "$RK_OUT" 2>/dev/null | head -20 | tee -a "$LOG" > /dev/null fi rm -f "$RK_OUT" else log " rkhunter not installed on host — skipping (apt install rkhunter)" fi fi # ---------- PHASE 5 — debsums (cold-disk with --root) ----------------------- if [ "$RUN_DEBSUMS" = "1" ]; then log "" log "=== PHASE 5: debsums ===" if need debsums; then DS_OUT=$(mktemp) timeout 300 debsums --root="$R" -c > "$DS_OUT" 2>&1 || true if [ -s "$DS_OUT" ]; then warn "debsums package-integrity mismatches (trojanized binaries likely):" head -30 "$DS_OUT" | tee -a "$LOG" hits=$(wc -l < "$DS_OUT") if grep -qE '/(top|ldd|lsof|crontab|htop|ps|strace)$' "$DS_OUT"; then evidence perfctl 15 "debsums: classic userland tools trojanized (top/ldd/lsof/ps)" fi evidence generic_compromise "$((hits > 10 ? 10 : hits))" "debsums: $hits package-file mismatches" else ok "debsums: all package files match" fi rm -f "$DS_OUT" else log " debsums not installed on host — skipping (apt install debsums)" fi fi # ---------- PHASE 6 — persistence string-sweep ------------------------------ log "" log "=== PHASE 6: Persistence string-sweep ===" SUSP_PATTERN='perfcc\|perfctl\|libgcwrap\|libfsnldev\|libfsnkdev\|libpprocps\|kubeupd\|kkbush\|wizlmsh\|AAZHDE\|FPROF\|A2ZNODE\|ABWTRX\|\.xdiag\|\.perf\.c' for tgt in \ "$R/root/.bashrc" "$R/root/.bash_profile" "$R/root/.profile" \ "$R/etc/profile" "$R/etc/bash.bashrc" \ "$R/etc/rc.local" "$R/etc/crontab" \ "$R/var/spool/cron/crontabs/root" \ ; do [ -f "$tgt" ] || continue if grep -qE "$SUSP_PATTERN" "$tgt" 2>/dev/null; then evidence perfctl 12 "IoC string in ${tgt#$R}" fi done for crond in "$R/etc/cron.d" "$R/etc/cron.hourly" "$R/etc/cron.daily" \ "$R/etc/cron.weekly" "$R/etc/cron.monthly" \ "$R/etc/systemd/system" "$R/usr/lib/systemd/system" "$R/lib/systemd/system"; do [ -d "$crond" ] || continue HITS=$(grep -rlE "$SUSP_PATTERN" "$crond" 2>/dev/null | head -10 || true) if [ -n "$HITS" ]; then while IFS= read -r h; do evidence perfctl 10 "IoC string in ${h#$R}" done <<< "$HITS" fi done # hidden SSH keys in system accounts if [ -f "$R/etc/passwd" ]; then for u in news bin daemon sys lp mail games uucp proxy www-data backup list irc gnats nobody systemd-network; do home=$(awk -F: -v u="$u" '$1==u{print $6}' "$R/etc/passwd" 2>/dev/null) [ -n "$home" ] && [ -f "$R$home/.ssh/authorized_keys" ] && { keys=$(grep -cE '^(ssh-|ecdsa-)' "$R$home/.ssh/authorized_keys" 2>/dev/null || echo 0) evidence ssh_backdoor 15 "system account $u has $keys authorized_keys entries" } done fi # trailing-space nologin backdoor if [ -f "$R/etc/shells" ]; then if grep -qE '^/usr/sbin/nologin +$|^/sbin/nologin +$' "$R/etc/shells" 2>/dev/null; then evidence ssh_backdoor 18 "trailing-space nologin backdoor in /etc/shells" fi fi ok "persistence sweep done" # ---------- PHASE 7 — ClamAV (opt-in, slow) --------------------------------- if [ "$RUN_CLAMAV" = "1" ]; then log "" log "=== PHASE 7: ClamAV ===" if need clamscan; then CAV_OUT=$(mktemp) timeout 1800 clamscan -ri "$R" \ --exclude-dir='^'"$R"'/(proc|sys|dev)' \ > "$CAV_OUT" 2>&1 || true INFECTED=$(grep 'FOUND$' "$CAV_OUT" 2>/dev/null | head -40 || true) if [ -n "$INFECTED" ]; then warn "ClamAV FOUND:" echo "$INFECTED" | tee -a "$LOG" hits=$(echo "$INFECTED" | wc -l) if echo "$INFECTED" | grep -qiE 'xmrig|coinminer|cryptominer'; then evidence xmrig 10 "ClamAV miner signature" fi if echo "$INFECTED" | grep -qiE 'rootkit|backdoor'; then evidence generic_compromise 10 "ClamAV rootkit/backdoor signature" fi evidence generic_compromise "$((hits > 10 ? 10 : hits))" "ClamAV $hits detections" else ok "ClamAV: no detections" fi rm -f "$CAV_OUT" else log " clamscan not installed on host — skipping (apt install clamav)" fi fi # ---------- VERDICT --------------------------------------------------------- log "" log "==========================================" log "SCORECARD" log "==========================================" TOTAL=0 BEST_FAMILY="" BEST_SCORE=0 for f in $FAMILIES; do s=${FAMILY_SCORE[$f]} printf " %-22s %d\n" "$f" "$s" | tee -a "$LOG" TOTAL=$((TOTAL + s)) if [ "$s" -gt "$BEST_SCORE" ]; then BEST_SCORE=$s BEST_FAMILY=$f fi done log "" log "==========================================" log "VERDICT" log "==========================================" if [ "$TOTAL" -eq 0 ]; then log "NO EVIDENCE OF KNOWN MALWARE FAMILIES DETECTED." log " This does NOT prove the rootfs is clean — bespoke/novel malware" log " will not match these signatures." exit 0 elif [ "$BEST_SCORE" -ge 30 ]; then log "CONFIRMED INFECTION — primary family: $BEST_FAMILY (score=$BEST_SCORE)" log "" log "Top family evidence:" echo -n "${FAMILY_EVIDENCE[$BEST_FAMILY]}" | tee -a "$LOG" log "" log "Secondary families with non-zero score:" for f in $FAMILIES; do [ "$f" = "$BEST_FAMILY" ] && continue s=${FAMILY_SCORE[$f]} [ "$s" -eq 0 ] && continue log " $f ($s):" echo -n "${FAMILY_EVIDENCE[$f]}" | tee -a "$LOG" done log "" case "$BEST_FAMILY" in perfctl) log "RECOMMENDED ACTION:" log " - run: ROOTFS=$R /srv/clean.sh" log " - rotate ALL credentials for the workload in this container" log " - review forensic samples after clean.sh" ;; xmrig) log "RECOMMENDED ACTION:" log " - run: ROOTFS=$R /srv/clean.sh (covers dropper paths + persistence)" log " - check for associated rootkit (miners rarely run alone)" ;; proxyjacking) log "RECOMMENDED ACTION:" log " - run: ROOTFS=$R /srv/clean.sh (removes known proxyjacking dirs)" log " - audit /etc/systemd/system and cron for the persistence vector" ;; ldpreload_rootkit|lkm_rootkit) log "RECOMMENDED ACTION:" log " - run: ROOTFS=$R /srv/clean.sh" log " - inspect /root/forensics-* for samples; submit to VirusTotal" ;; ssh_backdoor) log "RECOMMENDED ACTION:" log " - inspect /etc/shells, system-account .ssh/ dirs" log " - rotate keys; audit recent SSH logins from retained journal" ;; esac exit 2 else log "SUSPICIOUS — low-confidence signals (score=$TOTAL, top=$BEST_FAMILY @$BEST_SCORE)" log "" for f in $FAMILIES; do s=${FAMILY_SCORE[$f]} [ "$s" -eq 0 ] && continue log " $f ($s):" echo -n "${FAMILY_EVIDENCE[$f]}" | tee -a "$LOG" done log "" log "Recommend manual review of flagged items and/or running with RUN_CLAMAV=1" exit 1 fi