diff --git a/clean.sh b/clean.sh new file mode 100755 index 0000000..d830188 --- /dev/null +++ b/clean.sh @@ -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/) or Incus (e.g. /var/lib/incus/storage-pools/ +# /containers//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_.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 diff --git a/detect.sh b/detect.sh new file mode 100755 index 0000000..cb495db --- /dev/null +++ b/detect.sh @@ -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 diff --git a/perfctl.yar b/perfctl.yar new file mode 100644 index 0000000..16c47ec --- /dev/null +++ b/perfctl.yar @@ -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 +}