435 lines
17 KiB
Bash
Executable File
435 lines
17 KiB
Bash
Executable File
#!/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
|