This commit is contained in:
root
2026-04-19 00:21:08 +00:00
parent 9c653108c3
commit c20c40159a
3 changed files with 1996 additions and 0 deletions
Executable
+434
View File
@@ -0,0 +1,434 @@
#!/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