#!/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