#!/bin/bash # Watches the downloads tree and IMMEDIATELY DELETES Windows executables dressed up as video # files (hash and metadata logged first). Runs as a systemd service. # # Detection is by EXTENSION *and* CONTENT (magic bytes): the same payload has arrived with its # .exe dropped. Deletion is IRREVERSIBLE, so the content rule is deliberately narrow # (STRONG_MAGIC / WEAK_MAGIC) and a file is held OPEN and re-verified immediately before unlink. set -u _here="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=lib/paths.sh . "$_here/lib/paths.sh" WATCH_DIR="$MEDIA_STACK_ROOT/data/torrents" # ReadWritePaths in ops/systemd/malware-guard.service must cover this path. LOG="$VAR_LOG/malware-guard.log" # Full rescan interval: inotify can drop events on queue overflow, and a populated directory # moved in whole generates no event for its children. RESCAN_SECS=900 # Never legitimate in a media pipeline. Keep lowercased. BAD_EXTS='exe|scr|bat|vbs|pif|lnk|cmd|com|msi|ps1|hta|wsf|jar' # file(1) descriptions that justify irreversible deletion, anchored at the start (for a regular # file it never contains the pathname). DO NOT WIDEN WITHOUT REPRODUCING THE FALSE POSITIVES. # Rejected: # * a bare "for MS Windows" substring: matches metadata (a gzip named "Subtitles for MS # Windows.srt", Windows in EXIF); ^PE32 already catches the real payload. # * "COM executable": weak one-byte heuristic; text beginning 0xb8 (EUC-KR note, CP437 NFO) matches. # * "DOS/MBR boot sector": a zero-filled file with 55 aa at offset 510 matches. # # "MS-DOS executable" is DEMOTED to WEAK: "MZ" at offset 0 can occur by accident (a QuickTime # `free` atom sized 0x4d5a0000). A weak hit never deletes alone: ffprobe must ALSO find no stream. STRONG_MAGIC='^(PE32|ELF [0-9]|Mach-O)' WEAK_MAGIC='^MS-DOS executable' # Safe for a line-oriented log (a newline in a filename could forge a record). qpath() { printf '%s' "$1" | tr -c '[:print:]' '?' } # True when ffprobe finds no stream in the OPEN descriptor; only corroborates a WEAK_MAGIC hit. not_media() { local out out=$(ffprobe -v error -show_entries stream=index -of csv=p=0 "/proc/self/fd/$1" 2>/dev/null) [[ -z "${out//[[:space:]]/}" ]] } log() { printf '[%s] %s\n' "$(date -u +%FT%TZ)" "$*" | tee -a "$LOG" } # Classify and, if warranted, destroy ONE path: the single decision point for the sweep and the # live watch. The descriptor is held from classification through unlink; before rm the file is # re-verified both ways: the inode must still be the one classified (replace-by-rename) and # re-classifying the descriptor must say the same (in-place rewrite). Bash cannot unlink by # descriptor, so a window of one syscall survives. check_and_destroy() { local f="$1" base ext lc_ext desc why="" fd sha size ino_open ino_now recheck [[ -f "$f" ]] || return 0 base="${f##*/}" ext="${base##*.}" lc_ext=$(printf '%s' "$ext" | tr '[:upper:]' '[:lower:]') exec {fd}<"$f" 2>/dev/null || return 0 ino_open=$(stat -L -c%i "/proc/self/fd/$fd" 2>/dev/null) || { exec {fd}<&-; return 0; } # Extension only if the BASENAME has a dot (a dotted parent dir must not supply one). if [[ "$base" == *.* && "|$BAD_EXTS|" == *"|$lc_ext|"* ]]; then why="extension:$lc_ext" else # -L dereferences /proc/self/fd/N; classifying the descriptor, not the path, judges exactly # the bytes we destroy. desc=$(file -bL -- "/proc/self/fd/$fd" 2>/dev/null) if [[ -n "$desc" && "$desc" =~ $STRONG_MAGIC ]]; then why="content:$desc" elif [[ -n "$desc" && "$desc" =~ $WEAK_MAGIC ]] && not_media "$fd"; then why="content-weak+nomedia:$desc" fi fi if [[ -z "$why" ]]; then exec {fd}<&- return 0 fi sha=$(sha256sum < "/proc/self/fd/$fd" | cut -d' ' -f1) size=$(stat -L -c%s "/proc/self/fd/$fd" 2>/dev/null) # Forensic record BEFORE the final check and the unlink. log "DESTROYING reason=$why sha256=$sha size=$size src='$(qpath "$f")'" # Content re-check FIRST: anything slow between the pathname check and rm lets a replacement # arriving in that gap be what gets deleted. recheck=$(file -bL -- "/proc/self/fd/$fd" 2>/dev/null) if [[ "$why" == content* && "$recheck" != "$desc" ]]; then exec {fd}<&- log "ABORTED not deleted: content changed during inspection (now '${recheck:-unreadable}') src='$(qpath "$f")'" return 0 fi # A weak hit rests on BOTH conditions, so both must still hold (an incomplete download that # gains decodable streams still reports "MS-DOS executable"). if [[ "$why" == content-weak* ]] && ! not_media "$fd"; then exec {fd}<&- log "ABORTED not deleted: became decodable media during inspection src='$(qpath "$f")'" return 0 fi ino_now=$(stat -c%i -- "$f" 2>/dev/null) exec {fd}<&- if [[ "$ino_open" != "${ino_now:-}" ]]; then log "ABORTED not deleted: path re-pointed during inspection (inode $ino_open -> ${ino_now:-gone}) src='$(qpath "$f")'" return 0 fi rm -f -- "$f" || { log "FAILED to delete: $(qpath "$f")" return 1 } } sweep() { while IFS= read -r -d '' f; do check_and_destroy "$f" done < <(find "$WATCH_DIR" -type f -print0 2>/dev/null) } ROOT_INO=$(stat -c%i -- "$WATCH_DIR" 2>/dev/null) \ || { log "FATAL: watch root $WATCH_DIR does not exist"; exit 1; } log "malware-guard starting, watching $WATCH_DIR (root inode $ROOT_INO)" # The watcher must be RUNNING before the sweep, or a file landing in an already-enumerated # directory is missed. (1) bash blocks opening a FIFO's write end until a reader exists, so the # read end is opened on a descriptor FIRST. (2) -q is omitted so we wait for "Watches # established." rather than sleeping. EVENTS=$(mktemp -u -t malware-guard.XXXXXX.fifo) WSTATUS=$(mktemp -t malware-guard.XXXXXX.status) mkfifo -m 600 "$EVENTS" || { log "FATAL: cannot create event FIFO"; exit 1; } exec {EV}<>"$EVENTS" # NUL-delimited (%w%f%0 + --no-newline): a newline in a filename would split one path in two. inotifywait -m -r --format '%w%f%0' --no-newline \ -e close_write -e moved_to \ "$WATCH_DIR" >&$EV 2>"$WSTATUS" & WATCHER=$! cleanup() { kill "$WATCHER" 2>/dev/null; rm -f "$EVENTS" "$WSTATUS"; } trap cleanup EXIT INT TERM for _ in $(seq 1 600); do grep -q "Watches established" "$WSTATUS" 2>/dev/null && break kill -0 "$WATCHER" 2>/dev/null || { log "FATAL: watcher died: $(head -2 "$WSTATUS")"; exit 1; } sleep 0.5 done grep -q "Watches established" "$WSTATUS" 2>/dev/null \ || log "WARNING: proceeding without watcher readiness confirmation" sweep last_sweep=$SECONDS while :; do if IFS= read -r -d '' -t 60 -u "$EV" f; then if [[ -d "$f" ]]; then # A directory moved in whole generates no events for existing children; walk it. while IFS= read -r -d '' g; do check_and_destroy "$g" done < <(find "$f" -type f -print0 2>/dev/null) else check_and_destroy "$f" fi else # >128 is the read timeout; anything else means the stream ended. (( $? > 128 )) || break # EOF is NOT a death signal (we hold the FIFO's write end open via <>); without this poll the # service would stay "active" but degraded to periodic sweeps only. if ! kill -0 "$WATCHER" 2>/dev/null; then log "watcher pid $WATCHER is gone; exiting so systemd restarts a live watch" exit 1 fi # A live process is NOT proof of a live watch: deleting the watched root drops inotifywait's # watch count to zero while it keeps running. Restart if the root's inode changed or vanished. root_now=$(stat -c%i -- "$WATCH_DIR" 2>/dev/null) if [[ "$root_now" != "$ROOT_INO" ]]; then log "watch root changed (inode $ROOT_INO -> ${root_now:-gone}); exiting to rebuild watches" exit 1 fi fi if (( SECONDS - last_sweep >= RESCAN_SECS )); then sweep last_sweep=$SECONDS fi done log "event stream ended (watcher pid $WATCHER); exiting for restart" exit 1