v1
This commit is contained in:
@@ -0,0 +1,910 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
###############################################################################
|
||||||
|
# perfctl / perfcc Malware Cleanup Script
|
||||||
|
# Targets: LD_PRELOAD rootkit (libgcwrap.so), cryptominer, proxyjacking,
|
||||||
|
# SSH backdoors, SUID backdoors, trojanized tools, cron persistence
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ROOTFS=/path/to/mounted/rootfs ./clean.sh
|
||||||
|
#
|
||||||
|
# Cold-disk only. Intended for container rootfs scanning — OpenVZ 7
|
||||||
|
# (e.g. /vz/root/<CTID>) or Incus (e.g. /var/lib/incus/storage-pools/
|
||||||
|
# <pool>/containers/<name>/rootfs), or any extracted rootfs tarball.
|
||||||
|
#
|
||||||
|
# The script is non-destructive to user data (MySQL, nginx configs, node apps,
|
||||||
|
# /home, /var/www, /var/backups). It only targets known perfctl IoCs.
|
||||||
|
###############################################################################
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${ROOTFS:-}" ]; then
|
||||||
|
echo "ERROR: ROOTFS is not set. Refusing to run against live '/'." >&2
|
||||||
|
echo "Usage: ROOTFS=/mnt/ct-cleanup $0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
R="$ROOTFS"
|
||||||
|
LOG="/var/log/perfctl-cleanup-${ROOTFS//\//_}-$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
FORENSICS_DIR="${FORENSICS_DIR:-/root/forensics-$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
FOUND=0
|
||||||
|
REMOVED=0
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# ---------- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; }
|
||||||
|
warn() { echo "[$(date '+%H:%M:%S')] WARNING: $*" | tee -a "$LOG"; }
|
||||||
|
ok() { echo "[$(date '+%H:%M:%S')] OK: $*" | tee -a "$LOG"; }
|
||||||
|
fail() { echo "[$(date '+%H:%M:%S')] FAIL: $*" | tee -a "$LOG"; ERRORS=$((ERRORS+1)); }
|
||||||
|
|
||||||
|
remove_file() {
|
||||||
|
local f="$R$1"
|
||||||
|
[ -e "$f" ] || [ -L "$f" ] || return 0
|
||||||
|
FOUND=$((FOUND+1))
|
||||||
|
log " removing: $f"
|
||||||
|
chattr -i "$f" 2>/dev/null || true
|
||||||
|
rm -f "$f" && REMOVED=$((REMOVED+1)) || fail "could not remove $f"
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_dir() {
|
||||||
|
local d="$R$1"
|
||||||
|
[ -d "$d" ] || return 0
|
||||||
|
FOUND=$((FOUND+1))
|
||||||
|
log " removing dir: $d"
|
||||||
|
chattr -R -i "$d" 2>/dev/null || true
|
||||||
|
rm -rf "$d" && REMOVED=$((REMOVED+1)) || fail "could not remove $d"
|
||||||
|
}
|
||||||
|
|
||||||
|
save_forensic() {
|
||||||
|
local f="$R$1"
|
||||||
|
[ -e "$f" ] || return 0
|
||||||
|
mkdir -p "$FORENSICS_DIR"
|
||||||
|
local base
|
||||||
|
base=$(echo "$1" | tr '/' '_')
|
||||||
|
cp -a "$f" "$FORENSICS_DIR/$base" 2>/dev/null && \
|
||||||
|
log " saved forensic copy: $FORENSICS_DIR/$base" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
echo "You are about to run cleanup for '${R:-/}' system."
|
||||||
|
read -r -p "Confirm /y/n to proceed: " _confirm
|
||||||
|
case "$_confirm" in
|
||||||
|
y|Y|yes|YES) ;;
|
||||||
|
*) echo "Aborted."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
log "=========================================="
|
||||||
|
log "perfctl cleanup started"
|
||||||
|
log "ROOTFS prefix: '${R:-/}'"
|
||||||
|
log "Forensics dir: $FORENSICS_DIR"
|
||||||
|
log "=========================================="
|
||||||
|
|
||||||
|
# ---------- 0. strip immutable flags ----------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 0: Strip immutable flags ==="
|
||||||
|
for d in etc lib usr/lib bin usr/bin sbin usr/sbin tmp root var/spool; do
|
||||||
|
[ -d "$R/$d" ] && chattr -R -i "$R/$d" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
ok "immutable flags cleared"
|
||||||
|
|
||||||
|
# ---------- 1. save forensic samples ----------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 1: Save forensic samples ==="
|
||||||
|
for f in \
|
||||||
|
/etc/ld.so.preload \
|
||||||
|
/lib/libgcwrap.so \
|
||||||
|
/bin/perfcc \
|
||||||
|
/bin/wizlmsh \
|
||||||
|
/tmp/.perf.c/pctl \
|
||||||
|
; do
|
||||||
|
save_forensic "$f"
|
||||||
|
done
|
||||||
|
if [ -d "$R/tmp/.xdiag" ]; then
|
||||||
|
mkdir -p "$FORENSICS_DIR"
|
||||||
|
tar czf "$FORENSICS_DIR/_tmp_.xdiag.tgz" -C "$R" tmp/.xdiag 2>/dev/null && \
|
||||||
|
log " saved forensic copy: $FORENSICS_DIR/_tmp_.xdiag.tgz" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 2. remove rootkit + payload files --------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 2: Remove rootkit, payload, and dropper files ==="
|
||||||
|
|
||||||
|
# ld.so.preload (the rootkit injector)
|
||||||
|
remove_file /etc/ld.so.preload
|
||||||
|
remove_file /etc/ld.so.preload_
|
||||||
|
remove_file /etc/ld.so.preload.bak
|
||||||
|
|
||||||
|
# userland rootkit shared objects
|
||||||
|
for so in libgcwrap.so libfsnldev.so libpprocps.so; do
|
||||||
|
remove_file "/lib/$so"
|
||||||
|
remove_file "/usr/lib/$so"
|
||||||
|
done
|
||||||
|
|
||||||
|
# main malware installer
|
||||||
|
remove_file /bin/perfcc
|
||||||
|
remove_file /usr/bin/perfcc
|
||||||
|
|
||||||
|
# SUID backdoor
|
||||||
|
remove_file /bin/wizlmsh
|
||||||
|
remove_file /usr/bin/wizlmsh
|
||||||
|
|
||||||
|
# other known dropper/miner binaries
|
||||||
|
remove_file /bin/kkbush
|
||||||
|
remove_file /usr/bin/kkbush
|
||||||
|
remove_file /tmp/.apid
|
||||||
|
remove_file /tmp/gd.sh
|
||||||
|
remove_file /tmp/ccrl
|
||||||
|
remove_file /tmp/kubeupd
|
||||||
|
remove_file /tmp/javax64
|
||||||
|
remove_file /tmp/wttwe
|
||||||
|
remove_file /dev/shm/libfsnldev.so
|
||||||
|
|
||||||
|
# ---------- 3. remove malware directories ------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 3: Remove malware directories ==="
|
||||||
|
|
||||||
|
remove_dir /tmp/.xdiag
|
||||||
|
remove_dir /tmp/.perf.c
|
||||||
|
remove_dir /tmp/.dmesg
|
||||||
|
remove_dir /bin/wbin
|
||||||
|
remove_dir /bin/.local
|
||||||
|
remove_dir /bin/.atmp
|
||||||
|
remove_dir /root/.config/cron
|
||||||
|
remove_dir /root/.config/traffmonetizer
|
||||||
|
|
||||||
|
# ---------- 4. find and remove hidden miner binaries -------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 4: Scan for hidden miner binaries ==="
|
||||||
|
|
||||||
|
# statically-linked unpackaged ELFs in system dirs (miner signature)
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -z "$f" ] && continue
|
||||||
|
if file "$f" 2>/dev/null | grep -q "statically linked"; then
|
||||||
|
if ! dpkg -S "$f" >/dev/null 2>&1; then
|
||||||
|
log " SUSPECT STATIC BINARY: $f ($(stat -c%s "$f" 2>/dev/null) bytes)"
|
||||||
|
save_forensic "${f#$R}"
|
||||||
|
FOUND=$((FOUND+1))
|
||||||
|
chattr -i "$f" 2>/dev/null || true
|
||||||
|
rm -f "$f" && REMOVED=$((REMOVED+1)) && log " removed: $f" || fail "could not remove $f"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(find "$R/usr/bin" "$R/usr/sbin" "$R/bin" "$R/sbin" -xdev -type f 2>/dev/null)
|
||||||
|
|
||||||
|
# ---------- 5. remove proxyjacking container rootfs --------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 5: Remove proxyjacking leftovers ==="
|
||||||
|
|
||||||
|
# known proxyjacking rootfs paths (attacker renames to hide)
|
||||||
|
remove_dir /var/-nonrpk
|
||||||
|
remove_dir /var/.r.rpk
|
||||||
|
|
||||||
|
# Docker/containerd proxyjacking remnants
|
||||||
|
for d in /bin/wbin /tmp/kubeupd; do
|
||||||
|
remove_dir "$d"
|
||||||
|
done
|
||||||
|
|
||||||
|
# scan /var/ and /dev/shm/ for ANY hidden files/dirs the attacker may have stashed
|
||||||
|
log " scanning /var/ for hidden entries..."
|
||||||
|
KNOWN_VAR_HIDDEN=".updated|.placeholder|.gitkeep|.gitignore|.prettierrc|.prettierignore|.dockerignore|.env"
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
[ -z "$entry" ] && continue
|
||||||
|
base=$(basename "$entry")
|
||||||
|
# skip known-safe dotfiles
|
||||||
|
if echo "$base" | grep -qE "^($KNOWN_VAR_HIDDEN)$"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# auto-remove known perfctl proxyjacking dirs
|
||||||
|
case "$base" in
|
||||||
|
.r.rpk|.rpk|.-nonrpk|.nonrpk|.proxy*|.bitping*|.earnfm*|.speedshare*|.repocket*)
|
||||||
|
log " removing known proxyjacking dir: $entry"
|
||||||
|
chattr -R -i "$entry" 2>/dev/null || true
|
||||||
|
rm -rf "$entry" && REMOVED=$((REMOVED+1)) || fail "could not remove $entry"
|
||||||
|
FOUND=$((FOUND+1))
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warn "UNKNOWN HIDDEN ENTRY in /var/: $entry (review manually)"
|
||||||
|
ls -la "$entry" 2>/dev/null | tee -a "$LOG"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(find "$R/var" -maxdepth 2 -name '.*' -not -name '.' -not -name '..' \
|
||||||
|
-not -path '*/lib/*' -not -path '*/cache/*' -not -path '*/log/*' \
|
||||||
|
-not -path '*/spool/*' -not -path '*/backups/*' -not -path '*/www/*' \
|
||||||
|
2>/dev/null)
|
||||||
|
|
||||||
|
log " scanning /dev/shm/ for hidden entries..."
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
[ -z "$entry" ] && continue
|
||||||
|
base=$(basename "$entry")
|
||||||
|
case "$base" in
|
||||||
|
.ICE-unix|.X11-unix|.XIM-unix|.font-unix) continue ;; # standard X11
|
||||||
|
esac
|
||||||
|
warn "HIDDEN ENTRY in /dev/shm/: $entry (review manually)"
|
||||||
|
ls -la "$entry" 2>/dev/null | tee -a "$LOG"
|
||||||
|
# auto-remove if it matches known malware patterns
|
||||||
|
if file "$entry" 2>/dev/null | grep -qE 'ELF|executable|shared object'; then
|
||||||
|
log " removing suspicious binary: $entry"
|
||||||
|
save_forensic "${entry#$R}"
|
||||||
|
rm -rf "$entry" && REMOVED=$((REMOVED+1)) || fail "could not remove $entry"
|
||||||
|
FOUND=$((FOUND+1))
|
||||||
|
fi
|
||||||
|
done < <(find "$R/dev/shm" -maxdepth 2 -name '.*' -not -name '.' -not -name '..' 2>/dev/null)
|
||||||
|
|
||||||
|
# ---------- 6. clean trojanized tools ---------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 6: Remove trojanized tool wrappers ==="
|
||||||
|
|
||||||
|
# perfctl drops SHC-compiled wrappers for top, ldd, lsof, crontab
|
||||||
|
for f in top ldd lsof crontab htop strace mount; do
|
||||||
|
for d in /bin/.local/bin /bin/.atmp; do
|
||||||
|
remove_file "$d/$f"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- 7. clean .profile / shell RC hooks -------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 7: Clean shell RC hooks ==="
|
||||||
|
|
||||||
|
for rc in \
|
||||||
|
/root/.profile \
|
||||||
|
/root/.bashrc \
|
||||||
|
/root/.bash_profile \
|
||||||
|
/etc/profile \
|
||||||
|
/etc/bash.bashrc \
|
||||||
|
; do
|
||||||
|
f="$R$rc"
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
if grep -qE 'perfcc|FPROF|AAZHDE|perfctl|libgcwrap|\.perf\.c|\.xdiag' "$f" 2>/dev/null; then
|
||||||
|
log " cleaning $rc"
|
||||||
|
sed -i.bak '/perfcc\|FPROF\|AAZHDE\|perfctl\|libgcwrap\|\.perf\.c\|\.xdiag/d' "$f"
|
||||||
|
rm -f "${f}.bak" 2>/dev/null
|
||||||
|
FOUND=$((FOUND+1)); REMOVED=$((REMOVED+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- 8. neuter 'news' / system-account SSH backdoor -------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 8: Remove SSH backdoors on system accounts ==="
|
||||||
|
|
||||||
|
# trailing-space nologin binary (copy of dash used as backdoor shell)
|
||||||
|
find "$R/usr/sbin" "$R/sbin" -maxdepth 1 -name 'nologin *' -print 2>/dev/null | while read -r f; do
|
||||||
|
log " removing backdoor binary: $f"
|
||||||
|
rm -f "$f" && REMOVED=$((REMOVED+1)) || fail "could not remove $f"
|
||||||
|
FOUND=$((FOUND+1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# trailing-space entry in /etc/shells
|
||||||
|
if [ -f "$R/etc/shells" ]; then
|
||||||
|
if grep -qE '^/usr/sbin/nologin +$|^/sbin/nologin +$' "$R/etc/shells" 2>/dev/null; then
|
||||||
|
log " removing trailing-space nologin from /etc/shells"
|
||||||
|
sed -i -E '/^\/usr\/sbin\/nologin +$/d; /^\/sbin\/nologin +$/d' "$R/etc/shells"
|
||||||
|
FOUND=$((FOUND+1)); REMOVED=$((REMOVED+1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# trailing space in /etc/passwd news line
|
||||||
|
if [ -f "$R/etc/passwd" ]; then
|
||||||
|
if grep -qP '^news:.*nologin +$' "$R/etc/passwd" 2>/dev/null; then
|
||||||
|
log " fixing news account trailing space in /etc/passwd"
|
||||||
|
sed -i -E 's|(^news:.*:/usr/sbin/nologin) +$|\1|' "$R/etc/passwd"
|
||||||
|
FOUND=$((FOUND+1)); REMOVED=$((REMOVED+1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# remove attacker authorized_keys from system accounts
|
||||||
|
SYS_USERS="news bin daemon sys lp mail games uucp proxy www-data backup list irc gnats nobody systemd-network"
|
||||||
|
for u in $SYS_USERS; do
|
||||||
|
home=$(awk -F: -v u="$u" '$1==u{print $6}' "$R/etc/passwd" 2>/dev/null)
|
||||||
|
[ -n "$home" ] && [ -d "$R$home/.ssh" ] && {
|
||||||
|
log " removing .ssh from system account: $u ($home)"
|
||||||
|
save_forensic "$home/.ssh/authorized_keys"
|
||||||
|
rm -rf "$R$home/.ssh"
|
||||||
|
FOUND=$((FOUND+1)); REMOVED=$((REMOVED+1))
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- 9. clean cron persistence ----------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 9: Clean cron persistence ==="
|
||||||
|
|
||||||
|
# named cron files
|
||||||
|
for f in \
|
||||||
|
/etc/cron.d/perfclean \
|
||||||
|
/etc/cron.hourly/perfclean \
|
||||||
|
/etc/cron.daily/perfclean \
|
||||||
|
/etc/cron.weekly/perfclean \
|
||||||
|
/etc/cron.monthly/perfclean \
|
||||||
|
; do
|
||||||
|
remove_file "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# clean root crontab entries
|
||||||
|
if [ -f "$R/var/spool/cron/crontabs/root" ]; then
|
||||||
|
if grep -qE 'perfcc|perfctl|libgcwrap|AAZHDE|FPROF|\.xdiag|\.perf\.c' "$R/var/spool/cron/crontabs/root" 2>/dev/null; then
|
||||||
|
log " cleaning root crontab"
|
||||||
|
chattr -i "$R/var/spool/cron/crontabs/root" 2>/dev/null || true
|
||||||
|
sed -i '/perfcc\|perfctl\|libgcwrap\|AAZHDE\|FPROF\|\.xdiag\|\.perf\.c/d' "$R/var/spool/cron/crontabs/root"
|
||||||
|
FOUND=$((FOUND+1)); REMOVED=$((REMOVED+1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# remove system-user crontabs (shouldn't exist)
|
||||||
|
if [ -d "$R/var/spool/cron/crontabs" ]; then
|
||||||
|
for ct in "$R"/var/spool/cron/crontabs/*; do
|
||||||
|
[ -f "$ct" ] || continue
|
||||||
|
base=$(basename "$ct")
|
||||||
|
[ "$base" = "root" ] && continue
|
||||||
|
log " removing system-user crontab: $base"
|
||||||
|
chattr -i "$ct" 2>/dev/null || true
|
||||||
|
rm -f "$ct"
|
||||||
|
FOUND=$((FOUND+1)); REMOVED=$((REMOVED+1))
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 10. remove systemd persistence -----------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 10: Remove systemd persistence ==="
|
||||||
|
|
||||||
|
SUSP_PATTERN='perfcc\|perfctl\|libgcwrap\|kubeupd\|kkbush\|AAZHDE\|ExecStart=/tmp'
|
||||||
|
for sdir in "$R/etc/systemd" "$R/usr/lib/systemd" "$R/lib/systemd"; do
|
||||||
|
[ -d "$sdir" ] || continue
|
||||||
|
grep -rl "$SUSP_PATTERN" "$sdir" 2>/dev/null | while read -r u; do
|
||||||
|
log " removing systemd unit: $u"
|
||||||
|
base=$(basename "$u")
|
||||||
|
find "$R/etc/systemd" -name "$base" -lname '*' -delete 2>/dev/null
|
||||||
|
rm -f "$u"
|
||||||
|
FOUND=$((FOUND+1)); REMOVED=$((REMOVED+1))
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- 11. full filesystem IoC scan -------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 11: Full filesystem IoC scan ==="
|
||||||
|
|
||||||
|
REMAINING=$(find "$R/" -xdev \( \
|
||||||
|
-name 'libgcwrap*' -o -name 'perfcc*' -o -name 'perfctl*' -o -name 'perfclean*' \
|
||||||
|
-o -name 'libfsnldev*' -o -name 'libpprocps*' -o -name 'wizlmsh*' \
|
||||||
|
-o -name 'javax64*' -o -name '.xdiag' -o -name '.perf.c' \
|
||||||
|
-o -name 'kkbush*' -o -name 'kubeupd*' -o -name 'ld.so.preload*' \
|
||||||
|
-o -name 'zipgrepjournalctl*' \
|
||||||
|
\) 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$REMAINING" ]; then
|
||||||
|
warn "IoC files still found after cleanup:"
|
||||||
|
echo "$REMAINING" | while read -r f; do
|
||||||
|
warn " $f"
|
||||||
|
chattr -i "$f" 2>/dev/null || true
|
||||||
|
rm -rf "$f" 2>/dev/null && log " force-removed: $f" || fail " STUCK: $f"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
ok "no IoC files remaining on disk"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 12. grep sweep for residual strings ------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 12: Grep sweep for residual rootkit strings ==="
|
||||||
|
|
||||||
|
GREP_HITS=$(grep -rlE 'libgcwrap|perfcc|perfctl|AAZHDE|FPROF|\.xdiag|\.perf\.c|wizlmsh|zipgrepjournalctl' \
|
||||||
|
"$R/etc" "$R/root" "$R/var/spool" 2>/dev/null | \
|
||||||
|
grep -v '\.log$\|\.tgz$\|forensics' || true)
|
||||||
|
|
||||||
|
if [ -n "$GREP_HITS" ]; then
|
||||||
|
warn "files still containing rootkit strings (review manually):"
|
||||||
|
echo "$GREP_HITS" | tee -a "$LOG"
|
||||||
|
else
|
||||||
|
ok "no residual rootkit strings in config/cron/home"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 13. PAM / NSS hijack check --------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 13: PAM / NSS hijack check ==="
|
||||||
|
|
||||||
|
# PAM modules: flag any .so in security/ that isn't a pam_<name>.so from
|
||||||
|
# libpam-modules* / libpam-runtime
|
||||||
|
for secdir in "$R/lib/security" "$R"/lib/*/security "$R/usr/lib/security" "$R"/usr/lib/*/security; do
|
||||||
|
[ -d "$secdir" ] || continue
|
||||||
|
for so in "$secdir"/*.so; do
|
||||||
|
[ -f "$so" ] || continue
|
||||||
|
base=$(basename "$so")
|
||||||
|
case "$base" in
|
||||||
|
pam_*.so) ;;
|
||||||
|
*)
|
||||||
|
warn "non-standard file in PAM security dir: $so"
|
||||||
|
save_forensic "${so#$R}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# pam.d configs referencing absolute paths (modules are normally bare names)
|
||||||
|
if [ -d "$R/etc/pam.d" ]; then
|
||||||
|
PAM_ABS_HITS=$(grep -rEn '^[^#]*\s/(tmp|opt|usr/local|home|var/tmp)/' "$R/etc/pam.d/" 2>/dev/null || true)
|
||||||
|
if [ -n "$PAM_ABS_HITS" ]; then
|
||||||
|
warn "pam.d references suspicious absolute paths:"
|
||||||
|
echo "$PAM_ABS_HITS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NSS modules: list libnss_*.so not matching a known package provider
|
||||||
|
for libdir in "$R"/lib/*/ "$R"/usr/lib/*/ "$R/lib" "$R/usr/lib"; do
|
||||||
|
[ -d "$libdir" ] || continue
|
||||||
|
for so in "$libdir"/libnss_*.so*; do
|
||||||
|
[ -f "$so" ] || continue
|
||||||
|
base=$(basename "$so")
|
||||||
|
case "$base" in
|
||||||
|
libnss_compat.*|libnss_dns.*|libnss_files.*|libnss_hesiod.*|libnss_nis.*|libnss_nisplus.*|libnss_db.*|libnss_mdns*|libnss_myhostname.*|libnss_resolve.*|libnss_systemd.*|libnss_mymachines.*|libnss_sss.*|libnss_ldap.*|libnss_winbind.*|libnss_wins.*) ;;
|
||||||
|
*)
|
||||||
|
warn "non-standard NSS module: $so"
|
||||||
|
save_forensic "${so#$R}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
done
|
||||||
|
ok "PAM/NSS check complete"
|
||||||
|
|
||||||
|
# ---------- 14. SSH server configuration tampering --------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 14: SSH server configuration tampering ==="
|
||||||
|
|
||||||
|
SSHD_FILES=""
|
||||||
|
[ -f "$R/etc/ssh/sshd_config" ] && SSHD_FILES="$R/etc/ssh/sshd_config"
|
||||||
|
if [ -d "$R/etc/ssh/sshd_config.d" ]; then
|
||||||
|
for f in "$R"/etc/ssh/sshd_config.d/*.conf; do
|
||||||
|
[ -f "$f" ] && SSHD_FILES="$SSHD_FILES $f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$SSHD_FILES" ]; then
|
||||||
|
# dangerous directives
|
||||||
|
SSH_HITS=$(grep -HnE '^\s*(PermitRootLogin\s+yes|PasswordAuthentication\s+yes|PermitEmptyPasswords\s+yes|ForceCommand|AuthorizedKeysCommand|AuthorizedKeysFile\s+(/tmp|/opt|/home|/var/tmp)|Subsystem\s+.*(/tmp|/opt|/dev/shm))' $SSHD_FILES 2>/dev/null || true)
|
||||||
|
if [ -n "$SSH_HITS" ]; then
|
||||||
|
warn "sshd_config contains suspicious directives:"
|
||||||
|
echo "$SSH_HITS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# top-level Match blocks (can override per-user policy)
|
||||||
|
MATCH_HITS=$(grep -HnE '^\s*Match\s+' $SSHD_FILES 2>/dev/null || true)
|
||||||
|
if [ -n "$MATCH_HITS" ]; then
|
||||||
|
log " sshd_config Match blocks (review):"
|
||||||
|
echo "$MATCH_HITS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# trailing-space paths in sshd_config (same backdoor trick as nologin )
|
||||||
|
TS_HITS=$(grep -HnE ' +$' $SSHD_FILES 2>/dev/null || true)
|
||||||
|
if [ -n "$TS_HITS" ]; then
|
||||||
|
warn "sshd_config lines with trailing whitespace (possible backdoor):"
|
||||||
|
echo "$TS_HITS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
ok "sshd_config check complete"
|
||||||
|
|
||||||
|
# ---------- 15. sudoers tampering -------------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 15: sudoers tampering ==="
|
||||||
|
|
||||||
|
SUDO_FILES=""
|
||||||
|
[ -f "$R/etc/sudoers" ] && SUDO_FILES="$R/etc/sudoers"
|
||||||
|
if [ -d "$R/etc/sudoers.d" ]; then
|
||||||
|
for f in "$R"/etc/sudoers.d/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
case "$(basename "$f")" in
|
||||||
|
README|*.dpkg-*|*.ucf-*) continue ;;
|
||||||
|
esac
|
||||||
|
SUDO_FILES="$SUDO_FILES $f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$SUDO_FILES" ]; then
|
||||||
|
NOPASSWD_HITS=$(grep -HnE '^[^#]*NOPASSWD:' $SUDO_FILES 2>/dev/null || true)
|
||||||
|
if [ -n "$NOPASSWD_HITS" ]; then
|
||||||
|
warn "sudoers NOPASSWD rules (review):"
|
||||||
|
echo "$NOPASSWD_HITS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DANGEROUS_HITS=$(grep -HnE '^[^#]*(Defaults\s+!authenticate|Defaults\s+targetpw|runas_default)' $SUDO_FILES 2>/dev/null || true)
|
||||||
|
if [ -n "$DANGEROUS_HITS" ]; then
|
||||||
|
warn "sudoers dangerous defaults:"
|
||||||
|
echo "$DANGEROUS_HITS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# include directives pointing outside sudoers.d
|
||||||
|
INC_HITS=$(grep -HnE '^\s*[#@]include(dir)?\s+' $SUDO_FILES 2>/dev/null | grep -vE '/etc/sudoers\.d' || true)
|
||||||
|
if [ -n "$INC_HITS" ]; then
|
||||||
|
warn "sudoers include directives outside /etc/sudoers.d:"
|
||||||
|
echo "$INC_HITS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
ok "sudoers check complete"
|
||||||
|
|
||||||
|
# ---------- 16. account hardening audit -------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 16: Account hardening audit ==="
|
||||||
|
|
||||||
|
if [ -f "$R/etc/passwd" ]; then
|
||||||
|
# UID 0 accounts (only root should exist)
|
||||||
|
UID0=$(awk -F: '$3==0 && $1!="root" {print}' "$R/etc/passwd" 2>/dev/null || true)
|
||||||
|
if [ -n "$UID0" ]; then
|
||||||
|
warn "non-root UID 0 accounts:"
|
||||||
|
echo "$UID0" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# accounts with real login shells (excluding system defaults)
|
||||||
|
LOGIN_SHELLS=$(awk -F: '$7 ~ /(bash|zsh|ksh|fish|sh)$/ && $1!="root" {print}' "$R/etc/passwd" 2>/dev/null || true)
|
||||||
|
if [ -n "$LOGIN_SHELLS" ]; then
|
||||||
|
log " accounts with real login shells (review):"
|
||||||
|
echo "$LOGIN_SHELLS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$R/etc/shadow" ]; then
|
||||||
|
# empty-password accounts
|
||||||
|
EMPTY_PW=$(awk -F: '$2=="" {print $1}' "$R/etc/shadow" 2>/dev/null || true)
|
||||||
|
if [ -n "$EMPTY_PW" ]; then
|
||||||
|
warn "accounts with empty password fields in /etc/shadow:"
|
||||||
|
echo "$EMPTY_PW" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# weak/unusual hash prefixes (strong: $6$/$y$/$7$)
|
||||||
|
WEAK_PW=$(awk -F: '$2 ~ /^\$[135]\$/ {print $1":"substr($2,1,3)}' "$R/etc/shadow" 2>/dev/null || true)
|
||||||
|
if [ -n "$WEAK_PW" ]; then
|
||||||
|
warn "accounts with weak password hashes (MD5/SHA1/Blowfish):"
|
||||||
|
echo "$WEAK_PW" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$R/etc/group" ]; then
|
||||||
|
for grp in sudo wheel adm docker lxd incus-admin; do
|
||||||
|
members=$(awk -F: -v g="$grp" '$1==g {print $4}' "$R/etc/group" 2>/dev/null)
|
||||||
|
if [ -n "$members" ]; then
|
||||||
|
log " group $grp members: $members"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# authorized_keys for ALL users (Phase 8 only handled system users)
|
||||||
|
if [ -f "$R/etc/passwd" ]; then
|
||||||
|
while IFS=: read -r user _ uid _ _ home _; do
|
||||||
|
[ "$uid" -ge 1000 ] || [ "$user" = "root" ] || continue
|
||||||
|
[ -n "$home" ] && [ -d "$R$home/.ssh" ] || continue
|
||||||
|
for akf in authorized_keys authorized_keys2; do
|
||||||
|
ak="$R$home/.ssh/$akf"
|
||||||
|
[ -f "$ak" ] || continue
|
||||||
|
keycount=$(grep -cE '^(ssh-|ecdsa-|sk-)' "$ak" 2>/dev/null || echo 0)
|
||||||
|
log " $user ($home/.ssh/$akf): $keycount keys"
|
||||||
|
# flag forced commands and restrictive/exfiltration options
|
||||||
|
forced=$(grep -En '^(command=|no-pty|no-agent-forwarding|permitopen=|ProxyCommand)' "$ak" 2>/dev/null || true)
|
||||||
|
if [ -n "$forced" ]; then
|
||||||
|
warn " $user has authorized_keys with forced commands/options:"
|
||||||
|
echo "$forced" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# ~/.ssh/config ProxyCommand is suspicious on a server account
|
||||||
|
if [ -f "$R$home/.ssh/config" ]; then
|
||||||
|
proxy=$(grep -HnE '^\s*(ProxyCommand|LocalCommand|PermitLocalCommand)' "$R$home/.ssh/config" 2>/dev/null || true)
|
||||||
|
if [ -n "$proxy" ]; then
|
||||||
|
warn "$user ~/.ssh/config contains proxy/local-command directives:"
|
||||||
|
echo "$proxy" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < "$R/etc/passwd"
|
||||||
|
fi
|
||||||
|
ok "account audit complete"
|
||||||
|
|
||||||
|
# ---------- 17. other persistence vectors -----------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 17: Other persistence vectors ==="
|
||||||
|
|
||||||
|
PERSIST_TARGETS=""
|
||||||
|
[ -f "$R/etc/rc.local" ] && PERSIST_TARGETS="$PERSIST_TARGETS $R/etc/rc.local"
|
||||||
|
for f in \
|
||||||
|
"$R"/etc/profile.d/*.sh \
|
||||||
|
"$R"/etc/xdg/autostart/*.desktop \
|
||||||
|
"$R"/etc/systemd/system/*.timer \
|
||||||
|
"$R"/usr/lib/systemd/system/*.timer \
|
||||||
|
"$R"/lib/systemd/system/*.timer \
|
||||||
|
"$R"/etc/systemd/system/*.service.d/*.conf \
|
||||||
|
"$R"/usr/share/dbus-1/system-services/*.service \
|
||||||
|
"$R"/etc/polkit-1/rules.d/*.rules \
|
||||||
|
"$R"/etc/cron.allow \
|
||||||
|
"$R"/etc/cron.deny \
|
||||||
|
; do
|
||||||
|
[ -f "$f" ] && PERSIST_TARGETS="$PERSIST_TARGETS $f"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$PERSIST_TARGETS" ]; then
|
||||||
|
# auto-remove any file matching SUSP_PATTERN (BRE — matches Phase 10 convention)
|
||||||
|
for f in $PERSIST_TARGETS; do
|
||||||
|
if grep -q "$SUSP_PATTERN" "$f" 2>/dev/null; then
|
||||||
|
log " removing persistence file matching IoC pattern: $f"
|
||||||
|
save_forensic "${f#$R}"
|
||||||
|
chattr -i "$f" 2>/dev/null || true
|
||||||
|
rm -f "$f" && REMOVED=$((REMOVED+1)) || fail "could not remove $f"
|
||||||
|
FOUND=$((FOUND+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# list timers (they're easy to miss in Phase 10's content-grep sweep)
|
||||||
|
for t in "$R"/etc/systemd/system/*.timer "$R"/usr/lib/systemd/system/*.timer "$R"/lib/systemd/system/*.timer; do
|
||||||
|
[ -f "$t" ] || continue
|
||||||
|
log " systemd timer present: ${t#$R}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# flag any non-shipped init.d script
|
||||||
|
if [ -d "$R/etc/init.d" ]; then
|
||||||
|
for f in "$R"/etc/init.d/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
base=$(basename "$f")
|
||||||
|
case "$base" in
|
||||||
|
README|rc|rcS|skeleton|hwclock.sh|single|sendsigs|umountfs|umountnfs.sh|umountroot|checkfs.sh|checkroot.sh|checkroot-bootclean.sh|mountkernfs.sh|mountdevsubfs.sh|mountall.sh|mountall-bootclean.sh|mountnfs.sh|mountnfs-bootclean.sh|procps|hostname.sh|networking|ssh|cron|rsyslog|dbus|mysql|nginx|apache2|postfix|postgresql|redis-server|memcached|php*-fpm) continue ;;
|
||||||
|
esac
|
||||||
|
log " unexpected init.d script (review): $f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
ok "persistence vector scan complete"
|
||||||
|
|
||||||
|
# ---------- 18. file capability audit ---------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 18: File capability audit ==="
|
||||||
|
|
||||||
|
if command -v getcap >/dev/null 2>&1; then
|
||||||
|
CAP_HITS=$(getcap -r "$R/usr" "$R/bin" "$R/sbin" 2>/dev/null || true)
|
||||||
|
if [ -n "$CAP_HITS" ]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
f="${line%% =*}"
|
||||||
|
base=$(basename "$f")
|
||||||
|
case "$base" in
|
||||||
|
ping|ping6|mtr-packet|arping|traceroute6.iputils|clockdiff|systemd-detect-virt|suexec) continue ;;
|
||||||
|
esac
|
||||||
|
warn "file capability (review): $line"
|
||||||
|
# auto-flag catastrophic caps
|
||||||
|
if echo "$line" | grep -qE 'cap_sys_admin|cap_dac_read_search|cap_dac_override|cap_setuid|cap_setgid|cap_sys_module|cap_sys_ptrace'; then
|
||||||
|
warn " ^ catastrophic capability — likely backdoor"
|
||||||
|
fi
|
||||||
|
done <<< "$CAP_HITS"
|
||||||
|
fi
|
||||||
|
ok "capability audit complete"
|
||||||
|
else
|
||||||
|
log " getcap not installed on host — skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 19. network-level IoC (config files only) -----------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 19: Network-level IoC ==="
|
||||||
|
|
||||||
|
# /etc/hosts: anything non-loopback and non-self
|
||||||
|
if [ -f "$R/etc/hosts" ]; then
|
||||||
|
HOSTS_ODD=$(grep -vE '^\s*#|^\s*$|^\s*(127\.|::1|::ffff:127\.|fe80:|ff[0-9a-f]{2}:)' "$R/etc/hosts" 2>/dev/null || true)
|
||||||
|
if [ -n "$HOSTS_ODD" ]; then
|
||||||
|
log " /etc/hosts non-loopback entries (review):"
|
||||||
|
echo "$HOSTS_ODD" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# nameservers outside known-safe set
|
||||||
|
NS_FILES=""
|
||||||
|
[ -f "$R/etc/resolv.conf" ] && NS_FILES="$R/etc/resolv.conf"
|
||||||
|
[ -f "$R/etc/systemd/resolved.conf" ] && NS_FILES="$NS_FILES $R/etc/systemd/resolved.conf"
|
||||||
|
if [ -d "$R/etc/systemd/resolved.conf.d" ]; then
|
||||||
|
for f in "$R"/etc/systemd/resolved.conf.d/*.conf; do
|
||||||
|
[ -f "$f" ] && NS_FILES="$NS_FILES $f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -n "$NS_FILES" ]; then
|
||||||
|
NS_HITS=$(grep -HnE '^\s*(nameserver|DNS=)' $NS_FILES 2>/dev/null | \
|
||||||
|
grep -vE '(1\.1\.1\.1|1\.0\.0\.1|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9|149\.112\.112\.112|208\.67\.222\.222|208\.67\.220\.220|127\.0\.0\.[0-9]+|::1|fe80:|169\.254\.|192\.168\.|10\.|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[01]\.)' || true)
|
||||||
|
if [ -n "$NS_HITS" ]; then
|
||||||
|
warn "non-standard DNS nameservers configured:"
|
||||||
|
echo "$NS_HITS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# C2/pool string sweep across network-relevant config
|
||||||
|
NET_CONFIG_DIRS=""
|
||||||
|
for d in "$R/etc/hosts" "$R/etc/hosts.allow" "$R/etc/hosts.deny" \
|
||||||
|
"$R/etc/NetworkManager" "$R/etc/systemd/network" \
|
||||||
|
"$R/etc/iptables" "$R/etc/nftables.conf" \
|
||||||
|
"$R/etc/network" "$R/etc/netplan"; do
|
||||||
|
[ -e "$d" ] && NET_CONFIG_DIRS="$NET_CONFIG_DIRS $d"
|
||||||
|
done
|
||||||
|
if [ -n "$NET_CONFIG_DIRS" ]; then
|
||||||
|
C2_HITS=$(grep -rlEi 'solscan|xmrpool|nanopool|monerohash|supportxmr|minexmr|moneroocean|c3pool|hashvault|:(3333|5555|7777|14444|14433|9000)\b' $NET_CONFIG_DIRS 2>/dev/null || true)
|
||||||
|
if [ -n "$C2_HITS" ]; then
|
||||||
|
warn "network config files containing mining-pool / C2 strings:"
|
||||||
|
echo "$C2_HITS" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
ok "network IoC scan complete"
|
||||||
|
|
||||||
|
# ---------- 20. anti-forensic / timestamp anomalies -------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 20: Anti-forensic anomalies ==="
|
||||||
|
|
||||||
|
# files with mtime > 1h in the future (clock-skew tolerant)
|
||||||
|
FUTURE_BOUNDARY=$(date -u -d '+1 hour' '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -u -v+1H '+%Y-%m-%d %H:%M:%S' 2>/dev/null)
|
||||||
|
FUTURE_FILES=""
|
||||||
|
[ -n "$FUTURE_BOUNDARY" ] && FUTURE_FILES=$(find "$R/etc" "$R/usr/bin" "$R/usr/sbin" "$R/bin" "$R/sbin" \
|
||||||
|
-xdev -newermt "$FUTURE_BOUNDARY" 2>/dev/null | head -50 || true)
|
||||||
|
if [ -n "$FUTURE_FILES" ]; then
|
||||||
|
warn "files with future mtime (anti-forensic timestamp tampering):"
|
||||||
|
echo "$FUTURE_FILES" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# history clearing
|
||||||
|
for h in "$R/root/.bash_history" "$R/root/.zsh_history"; do
|
||||||
|
[ -e "$h" ] || continue
|
||||||
|
if [ -L "$h" ]; then
|
||||||
|
target=$(readlink "$h")
|
||||||
|
case "$target" in
|
||||||
|
/dev/null|/dev/zero) warn "shell history symlinked to $target: $h" ;;
|
||||||
|
esac
|
||||||
|
elif [ ! -s "$h" ]; then
|
||||||
|
warn "shell history file is empty: $h"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -d "$R/home" ]; then
|
||||||
|
for userhome in "$R"/home/*/; do
|
||||||
|
[ -d "$userhome" ] || continue
|
||||||
|
for h in "$userhome.bash_history" "$userhome.zsh_history"; do
|
||||||
|
[ -e "$h" ] || continue
|
||||||
|
if [ -L "$h" ]; then
|
||||||
|
target=$(readlink "$h")
|
||||||
|
case "$target" in
|
||||||
|
/dev/null|/dev/zero) warn "shell history symlinked to $target: $h" ;;
|
||||||
|
esac
|
||||||
|
elif [ ! -s "$h" ]; then
|
||||||
|
warn "shell history file is empty: $h"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# unusual xattrs on system binaries
|
||||||
|
if command -v getfattr >/dev/null 2>&1; then
|
||||||
|
XATTR_HITS=$(getfattr -R -m '^user\.' --absolute-names "$R/etc" "$R/usr/bin" "$R/bin" 2>/dev/null | grep -B1 '^user\.' || true)
|
||||||
|
if [ -n "$XATTR_HITS" ]; then
|
||||||
|
warn "system files with user.* xattrs (review — can hide data):"
|
||||||
|
echo "$XATTR_HITS" | head -40 | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
ok "anti-forensic scan complete"
|
||||||
|
|
||||||
|
# ---------- 21. Docker-in-container artifacts -------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 21: Docker-in-container artifacts ==="
|
||||||
|
|
||||||
|
if [ -d "$R/var/lib/docker" ] || [ -d "$R/var/lib/containerd" ]; then
|
||||||
|
warn "container runtime dirs present inside this container — unusual"
|
||||||
|
|
||||||
|
if [ -d "$R/var/lib/docker/containers" ]; then
|
||||||
|
for cfg in "$R"/var/lib/docker/containers/*/config.v2.json; do
|
||||||
|
[ -f "$cfg" ] || continue
|
||||||
|
img=$(grep -oE '"Image":"[^"]+"' "$cfg" 2>/dev/null | head -1)
|
||||||
|
log " docker container config: $cfg image=$img"
|
||||||
|
if echo "$img" | grep -qiE 'xmrig|bitping|repocket|traffmonetiz|peer2profit|earnapp|honeygain|packetstream|mining|miner'; then
|
||||||
|
warn " ^ known proxyjacking/mining image"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$R/etc/docker/daemon.json" ]; then
|
||||||
|
if grep -qE '"hosts"\s*:\s*\[[^]]*tcp://' "$R/etc/docker/daemon.json" 2>/dev/null; then
|
||||||
|
warn "docker daemon.json exposes TCP socket:"
|
||||||
|
grep -nE '"hosts"' "$R/etc/docker/daemon.json" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log " no container runtime artifacts present"
|
||||||
|
fi
|
||||||
|
ok "container-runtime scan complete"
|
||||||
|
|
||||||
|
# ---------- 22. optional third-party scanners (gated by env) ----------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 22: Optional third-party scanners ==="
|
||||||
|
|
||||||
|
if [ "${RUN_CHKROOTKIT:-0}" = "1" ] && command -v chkrootkit >/dev/null 2>&1; then
|
||||||
|
log " running chkrootkit against $R (may take several minutes)..."
|
||||||
|
chkrootkit -r "$R" 2>&1 | tee -a "$LOG" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${RUN_RKHUNTER:-0}" = "1" ] && command -v rkhunter >/dev/null 2>&1; then
|
||||||
|
log " running rkhunter against $R (may take several minutes)..."
|
||||||
|
rkhunter --rootdir "$R" --check --sk --nocolors 2>&1 | tee -a "$LOG" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${YARA_RULES:-}" ] && [ -f "$YARA_RULES" ] && command -v yara >/dev/null 2>&1; then
|
||||||
|
log " running yara with rules $YARA_RULES..."
|
||||||
|
yara -r "$YARA_RULES" "$R/usr" "$R/bin" "$R/lib" "$R/tmp" "$R/opt" 2>&1 | tee -a "$LOG" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${RUN_CLAMAV:-0}" = "1" ] && command -v clamscan >/dev/null 2>&1; then
|
||||||
|
log " running clamscan against $R (slow)..."
|
||||||
|
clamscan -ri "$R" --exclude-dir='^'"$R"'/(proc|sys|dev)' 2>&1 | tee -a "$LOG" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${RUN_CHKROOTKIT:-0}" != "1" ] && [ "${RUN_RKHUNTER:-0}" != "1" ] \
|
||||||
|
&& [ -z "${YARA_RULES:-}" ] && [ "${RUN_CLAMAV:-0}" != "1" ]; then
|
||||||
|
log " no third-party scanners requested (set RUN_CHKROOTKIT=1, RUN_RKHUNTER=1,"
|
||||||
|
log " YARA_RULES=/path/rules.yar, or RUN_CLAMAV=1 to enable)"
|
||||||
|
fi
|
||||||
|
ok "third-party scanner phase complete"
|
||||||
|
|
||||||
|
# ---------- 23. debsums package verification (cold-disk) --------------------
|
||||||
|
|
||||||
|
if command -v debsums >/dev/null 2>&1; then
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 23: Package integrity verification (debsums) ==="
|
||||||
|
DEBSUMS_MISMATCHES=$(debsums --root="$R" -c 2>&1 || true)
|
||||||
|
if [ -n "$DEBSUMS_MISMATCHES" ]; then
|
||||||
|
warn "debsums found mismatched files:"
|
||||||
|
echo "$DEBSUMS_MISMATCHES" | tee -a "$LOG"
|
||||||
|
else
|
||||||
|
ok "all package files match expected checksums"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 23: debsums not installed on host — skipping ==="
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 24. SUID/SGID audit (cold-disk) ---------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== PHASE 24: SUID/SGID binary audit ==="
|
||||||
|
|
||||||
|
find "$R" -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null | while read -r f; do
|
||||||
|
case "$(basename "$f")" in
|
||||||
|
mount.cifs|mount.nfs*|unix_chkpwd|su|sudo|mount|umount|passwd|chsh|chfn|gpasswd|newgrp|pkexec|ping|ping6|fusermount|fusermount3|ssh-keysign|at|expiry|crontab|chage) ;;
|
||||||
|
*) warn "SUID/SGID binary (review): $f ($(ls -la "$f" 2>/dev/null))" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
ok "SUID/SGID audit complete"
|
||||||
|
|
||||||
|
# ---------- summary ----------------------------------------------------------
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=========================================="
|
||||||
|
log "CLEANUP COMPLETE"
|
||||||
|
log " IoC items found: $FOUND"
|
||||||
|
log " IoC items removed: $REMOVED"
|
||||||
|
log " Errors: $ERRORS"
|
||||||
|
log " Log saved to: $LOG"
|
||||||
|
log " Forensics saved: $FORENSICS_DIR"
|
||||||
|
log "=========================================="
|
||||||
|
|
||||||
|
if [ "$ERRORS" -gt 0 ]; then
|
||||||
|
warn "some items could not be removed — review log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "RECOMMENDED NEXT STEPS:"
|
||||||
|
log " 1. Rotate ALL credentials for this container (SSH keys, DB passwords,"
|
||||||
|
log " API tokens) — the rootkit's TTY keylogger + PAM hook captured everything"
|
||||||
|
log " 2. Check $FORENSICS_DIR for saved malware samples"
|
||||||
|
log " 3. Submit libgcwrap.so to VirusTotal for attribution"
|
||||||
|
log " 4. Re-run this script against the same rootfs to verify cleanup"
|
||||||
|
log " 5. Before starting the container again, confirm no persistence remains"
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -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
|
||||||
+652
@@ -0,0 +1,652 @@
|
|||||||
|
/*
|
||||||
|
* perfctl.yar — YARA rules for perfctl/perfcc Linux userland rootkit family
|
||||||
|
* and adjacent threats commonly seen on the same compromised hosts
|
||||||
|
* (cryptominers, proxyjacking agents, LD_PRELOAD rootkits, SSH backdoors).
|
||||||
|
*
|
||||||
|
* Usage with clean.sh:
|
||||||
|
* YARA_RULES=/srv/perfctl.yar ROOTFS=/vz/root/101 ./clean.sh
|
||||||
|
*
|
||||||
|
* Or standalone:
|
||||||
|
* yara -r /srv/perfctl.yar /mnt/target-rootfs/
|
||||||
|
*
|
||||||
|
* SOURCES:
|
||||||
|
* - Aqua Nautilus, "perfctl: A Stealthy Malware Targeting Millions of Linux
|
||||||
|
* Servers" (October 2024) — full IoC table: file paths, hashes, IPs,
|
||||||
|
* domains, env vars. https://www.aquasec.com/blog/perfctl-a-stealthy-
|
||||||
|
* malware-targeting-millions-of-linux-servers/
|
||||||
|
* - Florian Roth / Nextron Systems, signature-base, XMRig + cryptominer
|
||||||
|
* rules. https://github.com/Neo23x0/signature-base — DRL 1.1 licensed,
|
||||||
|
* attribution retained in meta.
|
||||||
|
* - Yara-Rules community repo (MALW_XMRIG_Miner).
|
||||||
|
* https://github.com/Yara-Rules/rules
|
||||||
|
* - bleepingcomputer / thehackernews / darkreading perfctl writeups
|
||||||
|
* (Oct 2024) for independent IoC confirmation.
|
||||||
|
* - Remaining rules (proxyjacking agents, PAM stealer, LKM names,
|
||||||
|
* perfctl-specific binary rules) written for this file based on the
|
||||||
|
* published IoCs — no equivalent public rules exist as of April 2026.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "hash"
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// perfctl / perfcc rootkit family — specific rules
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
rule perfctl_known_hashes
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "File matches a known perfctl/perfcc MD5 from Aqua IoC table"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "perfctl"
|
||||||
|
severity = "critical"
|
||||||
|
reference = "https://www.aquasec.com/blog/perfctl-a-stealthy-malware-targeting-millions-of-linux-servers/"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
// main malware
|
||||||
|
hash.md5(0, filesize) == "656e22c65bf7c04d87b5afbe52b8d800" or
|
||||||
|
// cryptominer
|
||||||
|
hash.md5(0, filesize) == "6e7230dbe35df5b46dcd08975a0cc87f" or
|
||||||
|
// rootkit (libgcwrap.so)
|
||||||
|
hash.md5(0, filesize) == "835a9a6908409a67e51bce69f80dd58a" or
|
||||||
|
// trojanized ldd
|
||||||
|
hash.md5(0, filesize) == "cf265a3a3dd068d0aa0c70248cd6325d" or
|
||||||
|
// trojanized top
|
||||||
|
hash.md5(0, filesize) == "da006a0b9b51d56fa3f9690cf204b99f" or
|
||||||
|
// wizlmsh persistence binary
|
||||||
|
hash.md5(0, filesize) == "ba120e9c7f8896d9148ad37f02b0e3cb"
|
||||||
|
}
|
||||||
|
|
||||||
|
rule perfctl_libgcwrap_rootkit
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "perfctl userland rootkit — LD_PRELOAD shared object"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "perfctl"
|
||||||
|
severity = "critical"
|
||||||
|
reference = "https://www.aquasec.com/blog/perfctl-a-stealthy-malware-targeting-millions-of-linux-servers/"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$soname = "libgcwrap.so"
|
||||||
|
// operator-bypass env vars (from Aqua IoC table)
|
||||||
|
$env1 = "AAZHDE"
|
||||||
|
$env2 = "FPROF"
|
||||||
|
$env3 = "A2ZNODE"
|
||||||
|
$env4 = "VEI"
|
||||||
|
$env5 = "ABWTRX"
|
||||||
|
$hide1 = "perfctl"
|
||||||
|
$hide2 = "perfcc"
|
||||||
|
$hook1 = "readdir"
|
||||||
|
$hook2 = "readdir64"
|
||||||
|
$hook3 = "__xstat"
|
||||||
|
$hook4 = "__lxstat"
|
||||||
|
$hook5 = "getdents"
|
||||||
|
$hook6 = "getdents64"
|
||||||
|
$preload = "/etc/ld.so.preload"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
uint32(0) == 0x464C457F and
|
||||||
|
filesize < 5MB and
|
||||||
|
(
|
||||||
|
$soname or
|
||||||
|
( 1 of ($env*) and 3 of ($hook*) ) or
|
||||||
|
( any of ($hide*) and $preload )
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule perfctl_perfcc_installer
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "perfctl main installer / dropper binary (perfcc)"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "perfctl"
|
||||||
|
severity = "critical"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$n1 = "perfcc"
|
||||||
|
$n2 = "perfctl"
|
||||||
|
$n3 = "libgcwrap"
|
||||||
|
$d1 = "/tmp/.xdiag"
|
||||||
|
$d2 = "/tmp/.perf.c"
|
||||||
|
$d3 = "/tmp/.dmesg"
|
||||||
|
$d4 = "/tmp/smpr"
|
||||||
|
$d5 = "/root/.config/cron/perfcc"
|
||||||
|
$p1 = "ld.so.preload"
|
||||||
|
$p2 = "AAZHDE"
|
||||||
|
$p3 = "FPROF"
|
||||||
|
$p4 = "A2ZNODE"
|
||||||
|
$p5 = "ABWTRX"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
uint32(0) == 0x464C457F and
|
||||||
|
filesize < 50MB and
|
||||||
|
( 2 of ($n*) or (1 of ($n*) and any of ($d*)) or (any of ($p*) and any of ($d*)) )
|
||||||
|
}
|
||||||
|
|
||||||
|
rule perfctl_wizlmsh_suid_backdoor
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "perfctl SUID backdoor (wizlmsh / kkbush / zipgrepjournalctl)"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "perfctl"
|
||||||
|
severity = "critical"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$n1 = "wizlmsh"
|
||||||
|
$n2 = "kkbush"
|
||||||
|
$n3 = "zipgrepjournalctl"
|
||||||
|
$s1 = "setuid"
|
||||||
|
$s2 = "execve"
|
||||||
|
$s3 = "/bin/sh"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
uint32(0) == 0x464C457F and
|
||||||
|
filesize < 5MB and
|
||||||
|
any of ($n*) and 2 of ($s*)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule perfctl_dropper_paths_in_any_file
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "File of any type references known perfctl dropper paths"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "perfctl"
|
||||||
|
severity = "high"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$p1 = "/tmp/.xdiag"
|
||||||
|
$p2 = "/tmp/.perf.c"
|
||||||
|
$p3 = "/tmp/.apid"
|
||||||
|
$p4 = "/tmp/gd.sh"
|
||||||
|
$p5 = "/tmp/ccrl"
|
||||||
|
$p6 = "/tmp/javax64"
|
||||||
|
$p7 = "/tmp/kubeupd"
|
||||||
|
$p8 = "/tmp/wttwe"
|
||||||
|
$p9 = "/tmp/smpr"
|
||||||
|
$p10 = "/dev/shm/libfsnldev.so"
|
||||||
|
$p11 = "libfsnldev.so"
|
||||||
|
$p12 = "libfsnkdev.so" // variant spelling seen in Aqua IoC
|
||||||
|
$p13 = "libpprocps.so"
|
||||||
|
$p14 = "/root/.config/cron/perfcc"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 20MB and 2 of them
|
||||||
|
}
|
||||||
|
|
||||||
|
rule perfctl_cron_persistence
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Cron job referencing perfctl/perfcc persistence"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "perfctl"
|
||||||
|
severity = "high"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$c1 = "perfcc"
|
||||||
|
$c2 = "perfctl"
|
||||||
|
$c3 = "perfclean"
|
||||||
|
$c4 = "libgcwrap"
|
||||||
|
$c5 = "AAZHDE"
|
||||||
|
$c6 = "FPROF"
|
||||||
|
$c7 = "A2ZNODE"
|
||||||
|
$c8 = "ABWTRX"
|
||||||
|
$cron1 = "* * * * *"
|
||||||
|
$cron2 = "@reboot"
|
||||||
|
$cron3 = "@hourly"
|
||||||
|
$cron4 = "@daily"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 100KB and
|
||||||
|
1 of ($cron*) and 1 of ($c*)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule perfctl_known_filenames
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "File matches a known perfctl IoC filename (catch-all)"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "perfctl"
|
||||||
|
severity = "critical"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$n1 = "libgcwrap.so"
|
||||||
|
$n2 = "libfsnldev.so"
|
||||||
|
$n3 = "libfsnkdev.so"
|
||||||
|
$n4 = "libpprocps.so"
|
||||||
|
$n5 = "wizlmsh"
|
||||||
|
$n6 = "kkbush"
|
||||||
|
$n7 = "kubeupd"
|
||||||
|
$n8 = "perfcc"
|
||||||
|
$n9 = "perfctl"
|
||||||
|
$n10 = "perfclean"
|
||||||
|
$n11 = "zipgrepjournalctl"
|
||||||
|
$n12 = "javax64"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 100MB and any of them
|
||||||
|
}
|
||||||
|
|
||||||
|
rule perfctl_network_indicators
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Binary or script references published perfctl IPs / C2 domains"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "perfctl"
|
||||||
|
severity = "high"
|
||||||
|
reference = "Aqua Nautilus IoC table (Oct 2024)"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
// C2 / download servers
|
||||||
|
$ip1 = "211.234.111.116"
|
||||||
|
$ip2 = "46.101.139.173"
|
||||||
|
$ip3 = "104.183.100.189"
|
||||||
|
$ip4 = "198.211.126.180"
|
||||||
|
// Tor relay nodes the malware connects to
|
||||||
|
$ip5 = "80.67.172.162"
|
||||||
|
$ip6 = "176.10.107.180"
|
||||||
|
$ip7 = "78.47.18.110"
|
||||||
|
$ip8 = "95.217.109.36"
|
||||||
|
$ip9 = "145.239.41.102"
|
||||||
|
// proxyjacking SaaS domains the malware installs clients for
|
||||||
|
$d1 = "bitping.com"
|
||||||
|
$d2 = "earn.fm"
|
||||||
|
$d3 = "speedshare.app"
|
||||||
|
$d4 = "repocket.com"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
// require ELF or shebang — rule is IoC-aware, not a plain grep
|
||||||
|
( uint32(0) == 0x464C457F or uint16(0) == 0x2123 ) and
|
||||||
|
filesize < 10MB and any of them
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// XMRig / cryptominer — Neo23x0 signature-base (DRL 1.1, Florian Roth)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
rule XMRIG_Monero_Miner : HIGHVOL
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Detects Monero mining software (XMRig)"
|
||||||
|
license = "Detection Rule License 1.1 https://github.com/Neo23x0/signature-base/blob/master/LICENSE"
|
||||||
|
author = "Florian Roth (Nextron Systems)"
|
||||||
|
reference = "https://github.com/xmrig/xmrig/releases"
|
||||||
|
date = "2018-01-04"
|
||||||
|
modified = "2022-11-10"
|
||||||
|
hash1 = "5c13a274adb9590249546495446bb6be5f2a08f9dcd2fc8a2049d9dc471135c0"
|
||||||
|
hash2 = "08b55f9b7dafc53dfc43f7f70cdd7048d231767745b76dc4474370fb323d7ae7"
|
||||||
|
hash3 = "f3f2703a7959183b010d808521b531559650f6f347a5830e47f8e3831b10bad5"
|
||||||
|
hash4 = "0972ea3a41655968f063c91a6dbd31788b20e64ff272b27961d12c681e40b2d2"
|
||||||
|
id = "71bf1b9c-c806-5737-83a9-d6013872b11d"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$s1 = "'h' hashrate, 'p' pause, 'r' resume" fullword ascii
|
||||||
|
$s2 = "--cpu-affinity" ascii
|
||||||
|
$s3 = "set process affinity to CPU core(s), mask 0x3 for cores 0 and 1" ascii
|
||||||
|
$s4 = "password for mining server" fullword ascii
|
||||||
|
$s5 = "XMRig/%s libuv/%s%s" fullword ascii
|
||||||
|
|
||||||
|
condition:
|
||||||
|
( uint16(0) == 0x5a4d or uint16(0) == 0x457f ) and filesize < 10MB and 2 of them
|
||||||
|
}
|
||||||
|
|
||||||
|
rule XMRIG_Monero_Miner_Config
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "XMRig config.json file"
|
||||||
|
license = "Detection Rule License 1.1"
|
||||||
|
author = "Florian Roth (Nextron Systems)"
|
||||||
|
reference = "https://github.com/xmrig/xmrig/releases"
|
||||||
|
date = "2018-01-04"
|
||||||
|
id = "374efe7f-9ef2-5974-8e24-f749183ab2d0"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$s2 = "\"cpu-affinity\": null, // set process affinity to CPU core(s), mask \"0x3\" for cores 0 and 1" fullword ascii
|
||||||
|
$s5 = "\"nicehash\": false // enable nicehash/xmrig-proxy support" fullword ascii
|
||||||
|
$s8 = "\"algo\": \"cryptonight\", // cryptonight (default) or cryptonight-lite" fullword ascii
|
||||||
|
|
||||||
|
condition:
|
||||||
|
( uint16(0) == 0x0a7b or uint16(0) == 0x0d7b ) and filesize < 5KB and 1 of them
|
||||||
|
}
|
||||||
|
|
||||||
|
rule PUA_LNX_XMRIG_CryptoMiner
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Detects XMRIG CryptoMiner (Linux ELF)"
|
||||||
|
license = "Detection Rule License 1.1"
|
||||||
|
author = "Florian Roth (Nextron Systems)"
|
||||||
|
reference = "Internal Research"
|
||||||
|
date = "2018-06-28"
|
||||||
|
modified = "2023-01-06"
|
||||||
|
hash1 = "10a72f9882fc0ca141e39277222a8d33aab7f7a4b524c109506a407cd10d738c"
|
||||||
|
id = "bbdeff2e-68cc-5bbe-b843-3cba9c8c7ea8"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$x1 = "number of hash blocks to process at a time (don't set or 0 enables automatic selection o" fullword ascii
|
||||||
|
$s2 = "'h' hashrate, 'p' pause, 'r' resume, 'q' shutdown" fullword ascii
|
||||||
|
$s3 = "* THREADS: %d, %s, aes=%d, hf=%zu, %sdonate=%d%%" fullword ascii
|
||||||
|
$s4 = ".nicehash.com" ascii
|
||||||
|
|
||||||
|
condition:
|
||||||
|
uint16(0) == 0x457f and filesize < 8000KB and ( 1 of ($x*) or 2 of them )
|
||||||
|
}
|
||||||
|
|
||||||
|
rule SUSP_XMRIG_String
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Suspicious XMRIG string in executable"
|
||||||
|
license = "Detection Rule License 1.1"
|
||||||
|
author = "Florian Roth (Nextron Systems)"
|
||||||
|
date = "2018-12-28"
|
||||||
|
modified = "2026-04-18 (clean.sh — require ELF/PE magic to reduce FP)"
|
||||||
|
id = "8c6f3e6e-df2a-51b7-81b8-21cd33b3c603"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$x1 = "xmrig.exe" fullword ascii
|
||||||
|
$x2 = "XMRig" fullword ascii
|
||||||
|
|
||||||
|
condition:
|
||||||
|
// only flag executables, not arbitrary text files
|
||||||
|
( uint16(0) == 0x457f or uint16(0) == 0x5a4d ) and
|
||||||
|
filesize < 10MB and 1 of them
|
||||||
|
}
|
||||||
|
|
||||||
|
rule CoinMiner_Strings : SCRIPT HIGHVOL
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Detects mining pool protocol strings in executable"
|
||||||
|
license = "Detection Rule License 1.1"
|
||||||
|
author = "Florian Roth (Nextron Systems)"
|
||||||
|
reference = "https://minergate.com/faq/what-pool-address"
|
||||||
|
date = "2018-01-04"
|
||||||
|
modified = "2021-10-26"
|
||||||
|
id = "ac045f83-5f32-57a9-8011-99a2658a0e05"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$sa1 = "stratum+tcp://" ascii
|
||||||
|
$sa2 = "stratum+udp://" ascii
|
||||||
|
$sa3 = "stratum+ssl://" ascii
|
||||||
|
$sb1 = "\"normalHashing\": true,"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 3000KB and 1 of them
|
||||||
|
}
|
||||||
|
|
||||||
|
rule PUA_CryptoMiner_cpuminer
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "cpuminer-style crypto miner"
|
||||||
|
license = "Detection Rule License 1.1"
|
||||||
|
author = "Florian Roth (Nextron Systems)"
|
||||||
|
date = "2019-01-31"
|
||||||
|
hash1 = "ede858683267c61e710e367993f5e589fcb4b4b57b09d023a67ea63084c54a05"
|
||||||
|
id = "aebfdce9-c2dd-5f24-aa25-071e1a961239"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$s1 = "Stratum notify: invalid Merkle branch" fullword ascii
|
||||||
|
$s2 = "-t, --threads=N number of miner threads (default: number of processors)" fullword ascii
|
||||||
|
$s3 = "User-Agent: cpuminer/" ascii
|
||||||
|
$s4 = "hash > target (false positive)" fullword ascii
|
||||||
|
$s5 = "thread %d: %lu hashes, %s khash/s" fullword ascii
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 5000KB and 1 of them
|
||||||
|
}
|
||||||
|
|
||||||
|
rule PUA_Crypto_Mining_CommandLine_Indicators : SCRIPT
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Command-line flags used by crypto miners"
|
||||||
|
license = "Detection Rule License 1.1"
|
||||||
|
author = "Florian Roth (Nextron Systems)"
|
||||||
|
reference = "https://www.poolwatch.io/coin/monero"
|
||||||
|
date = "2021-10-24"
|
||||||
|
id = "afe5a63a-08c3-5cb7-b4b1-b996068124b7"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$s01 = " --cpu-priority="
|
||||||
|
$s02 = "--donate-level=0"
|
||||||
|
$s03 = " -o pool."
|
||||||
|
$s04 = " -o stratum+tcp://"
|
||||||
|
$s05 = " --nicehash"
|
||||||
|
$s06 = " --algo=rx/0 "
|
||||||
|
// base64'd versions
|
||||||
|
$se1 = "LS1kb25hdGUtbGV2ZWw9"
|
||||||
|
$se2 = "0tZG9uYXRlLWxldmVsP"
|
||||||
|
$se3 = "tLWRvbmF0ZS1sZXZlbD"
|
||||||
|
$se4 = "c3RyYXR1bSt0Y3A6Ly"
|
||||||
|
$se5 = "N0cmF0dW0rdGNwOi8v"
|
||||||
|
$se6 = "zdHJhdHVtK3RjcDovL"
|
||||||
|
$se7 = "c3RyYXR1bSt1ZHA6Ly"
|
||||||
|
$se8 = "N0cmF0dW0rdWRwOi8v"
|
||||||
|
$se9 = "zdHJhdHVtK3VkcDovL"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 5000KB and 1 of them
|
||||||
|
}
|
||||||
|
|
||||||
|
rule linux_miner_pool_urls
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Binary or config references known Monero mining pools"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "cryptominer"
|
||||||
|
severity = "medium"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$p1 = "pool.supportxmr.com"
|
||||||
|
$p2 = "xmr.nanopool.org"
|
||||||
|
$p3 = "xmr.2miners.com"
|
||||||
|
$p4 = "xmrpool.eu"
|
||||||
|
$p5 = "monerohash.com"
|
||||||
|
$p6 = "monero.crypto-pool.fr"
|
||||||
|
$p7 = "minexmr.com"
|
||||||
|
$p8 = "moneroocean.stream"
|
||||||
|
$p9 = "pool.hashvault.pro"
|
||||||
|
$p10 = "c3pool.com"
|
||||||
|
$p11 = "solscan.io"
|
||||||
|
$p12 = "pool.minexmr.com"
|
||||||
|
$p13 = "gulf.moneroocean.stream"
|
||||||
|
$p14 = "auto.c3pool.org"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 50MB and any of them
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Proxyjacking agents (residential-proxy SaaS abused for bandwidth theft)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
rule linux_proxyjacking_agents
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Proxyjacking agent binary or install script (requires ELF or shell-script magic)"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "proxyjacking"
|
||||||
|
severity = "high"
|
||||||
|
reference = "Aqua Nautilus perfctl report + Akamai/Sysdig proxyjacking writeups"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$a1 = "peer2profit" nocase
|
||||||
|
$a2 = "traffmonetizer" nocase
|
||||||
|
$a3 = "repocket" nocase
|
||||||
|
$a4 = "earnapp" nocase
|
||||||
|
$a5 = "honeygain" nocase
|
||||||
|
$a6 = "packetstream" nocase
|
||||||
|
$a7 = "bitping" nocase
|
||||||
|
$a8 = "speedshare" nocase
|
||||||
|
$a9 = "earnfm" nocase
|
||||||
|
$a10 = "earn.fm" nocase
|
||||||
|
$a11 = "IPRoyal" nocase
|
||||||
|
$a12 = "mysterium" nocase
|
||||||
|
$a13 = "urnetwork" nocase
|
||||||
|
$a14 = "proxyrack" nocase
|
||||||
|
$a15 = "pawns.app" nocase
|
||||||
|
$a16 = "traffmonetizer.com"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
// require ELF binary or shell/python shebang — reject plain text
|
||||||
|
( uint32(0) == 0x464C457F or uint16(0) == 0x2123 ) and
|
||||||
|
filesize < 100MB and any of them
|
||||||
|
}
|
||||||
|
|
||||||
|
rule linux_proxyjacking_container_images
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Docker/container config referencing proxyjacking images"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "proxyjacking"
|
||||||
|
severity = "high"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$i1 = "xmrig/xmrig" nocase
|
||||||
|
$i2 = "minerd" nocase
|
||||||
|
$i3 = "bitpinger" nocase
|
||||||
|
$i4 = "peer2profit/p2pclient" nocase
|
||||||
|
$i5 = "repocket/repocket" nocase
|
||||||
|
$i6 = "traffmonetizer" nocase
|
||||||
|
$i7 = "packetstream/psclient" nocase
|
||||||
|
$i8 = "honeygain/honeygain" nocase
|
||||||
|
$i9 = "iproyal/pawns" nocase
|
||||||
|
$cfg1 = "\"Image\":"
|
||||||
|
$cfg2 = "config.v2.json"
|
||||||
|
$cfg3 = "\"Cmd\":"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 10MB and any of ($i*) and any of ($cfg*)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Generic LD_PRELOAD rootkit indicators (beyond perfctl-specific)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
rule linux_ldpreload_rootkit_generic
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Generic LD_PRELOAD userland rootkit traits (tight condition to avoid libc FP)"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "rootkit.ldpreload"
|
||||||
|
severity = "high"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$h1 = "readdir"
|
||||||
|
$h2 = "readdir64"
|
||||||
|
$h3 = "getdents"
|
||||||
|
$h4 = "getdents64"
|
||||||
|
$h5 = "__xstat"
|
||||||
|
$h6 = "__lxstat"
|
||||||
|
$d1 = "RTLD_NEXT"
|
||||||
|
$d2 = "dlsym"
|
||||||
|
$p1 = "/etc/ld.so.preload"
|
||||||
|
// distinctive rootkit config-string markers that do NOT appear in libc
|
||||||
|
$c1 = "HIDE_PROC" fullword
|
||||||
|
$c2 = "HIDE_FILE" fullword
|
||||||
|
$c3 = "MAGIC_STRING" fullword
|
||||||
|
$c4 = "hide_pid" fullword
|
||||||
|
$c5 = "hide_port" fullword
|
||||||
|
$c6 = "HIDE_TCP" fullword
|
||||||
|
$c7 = "HIDE_UDP" fullword
|
||||||
|
|
||||||
|
condition:
|
||||||
|
uint32(0) == 0x464C457F and
|
||||||
|
filesize < 5MB and
|
||||||
|
// require at least one distinctive rootkit marker — libc has the hook
|
||||||
|
// names and dlsym but not these config keywords
|
||||||
|
( 2 of ($h*) and any of ($d*) and any of ($c*) ) or
|
||||||
|
( 2 of ($h*) and $p1 and any of ($c*) )
|
||||||
|
}
|
||||||
|
|
||||||
|
rule linux_ldpreload_config_file
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "ld.so.preload file pointing at known rootkit (tight — matches only known malicious SO names)"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "rootkit.ldpreload"
|
||||||
|
severity = "critical"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$s1 = "libgcwrap"
|
||||||
|
$s2 = "libfsnldev"
|
||||||
|
$s3 = "libfsnkdev"
|
||||||
|
$s4 = "libpprocps"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 1KB and any of them
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SSH backdoors and credential stealers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
rule linux_ssh_backdoor_authorized_keys_in_binary
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Binary contains embedded SSH public key (credential implant)"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "backdoor.ssh"
|
||||||
|
severity = "high"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$k1 = "ssh-rsa AAAA"
|
||||||
|
$k2 = "ssh-ed25519 AAAA"
|
||||||
|
$k3 = "ecdsa-sha2-nistp256 AAAA"
|
||||||
|
$path1 = ".ssh/authorized_keys"
|
||||||
|
$path2 = "/root/.ssh"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
uint32(0) == 0x464C457F and
|
||||||
|
filesize < 20MB and
|
||||||
|
any of ($k*) and any of ($path*)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule linux_pam_credential_stealer
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "PAM module that logs plaintext credentials to disk"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "backdoor.pam"
|
||||||
|
severity = "critical"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$sym1 = "pam_sm_authenticate"
|
||||||
|
$sym2 = "pam_get_item"
|
||||||
|
$sym3 = "PAM_AUTHTOK"
|
||||||
|
$w1 = "fopen"
|
||||||
|
$w2 = "fprintf"
|
||||||
|
$w3 = "/tmp/"
|
||||||
|
$w4 = "/var/tmp/"
|
||||||
|
$w5 = "/dev/shm/"
|
||||||
|
$d1 = ".passwd"
|
||||||
|
$d2 = ".creds"
|
||||||
|
$d3 = ".log"
|
||||||
|
$d4 = "passwords.txt"
|
||||||
|
|
||||||
|
condition:
|
||||||
|
uint32(0) == 0x464C457F and
|
||||||
|
filesize < 2MB and
|
||||||
|
2 of ($sym*) and any of ($w*) and any of ($d*)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// LKM rootkit name match (cold-disk: filename-content only)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
rule linux_lkm_rootkit_known_names
|
||||||
|
{
|
||||||
|
meta:
|
||||||
|
description = "Known Linux LKM rootkit by filename or symbol"
|
||||||
|
author = "clean.sh incident response"
|
||||||
|
family = "rootkit.lkm"
|
||||||
|
severity = "critical"
|
||||||
|
|
||||||
|
strings:
|
||||||
|
$n1 = "diamorphine"
|
||||||
|
$n2 = "reptile_module" nocase
|
||||||
|
$n3 = "suterusu" nocase
|
||||||
|
$n4 = "nuk3gh0st" nocase
|
||||||
|
$n5 = "kbeast" nocase
|
||||||
|
$n6 = "module_hide"
|
||||||
|
$n7 = "module_hidden"
|
||||||
|
$n8 = "adore-ng" nocase
|
||||||
|
|
||||||
|
condition:
|
||||||
|
filesize < 10MB and any of them
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user