Created
August 21, 2026 01:29
-
-
Save tralamazza/64f3bfefdb30350c7f2184c4067c82d4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # Detector for the crates.io supply-chain attack of 2026-08-20 (arrayref / proc-macro1). | |
| # | |
| # The payload is a build-time dependency: compiling a project that resolved to an affected | |
| # version is enough to be infected. This script is READ-ONLY -- it reports and exits with a | |
| # status code, it never removes or modifies anything. | |
| # | |
| # Exit codes: 0 clean, 1 suspect (manual review), 2 confirmed, 3 script/environment error. | |
| # | |
| # Written for bash 3.2 and portable across macOS and Linux: no associative arrays, no | |
| # mapfile, no ${v,,}, and only BSD/GNU-compatible find, grep, awk and ps usage. | |
| set -uo pipefail | |
| VERSION="1.0.0" | |
| # -------------------------------------------------------------------------------------- | |
| # Indicators of compromise -- single source of truth. | |
| # Sources: blog.rust-lang.org/2026/08/20/supply-chain-attack-on-arrayref/, | |
| # aikido.dev, safedep.io, rustsec/advisory-db#3161 | |
| # -------------------------------------------------------------------------------------- | |
| # Legitimate crates with a single poisoned release. "name-version" form. | |
| POISONED_RELEASES="arrayref-0.3.10 | |
| append-only-vec-0.1.9 | |
| internment-0.8.7" | |
| # Crates that are malicious in their entirety (typosquats / attacker-authored). | |
| MALICIOUS_CRATES="proc-macro1 | |
| proc-macro-en | |
| aovine | |
| arone | |
| aronenao | |
| tinymember" | |
| # SHA256 of the malicious .crate archives, "hash description" per line. | |
| CRATE_HASHES="25ad700976873c76af785cb99b33c48db7df8b81f21d1e9e06b3676b9a9373ae arrayref 0.3.10 | |
| 61198155da51b838772eecf5bfaac6cbc4dcc388dccc56658fc28a8e831b34d4 proc-macro1 1.0.107 | |
| b5c1b5b0763a8809a644a8f92224653f0aca623a98eecc714d27f74b80fbe436 proc-macro1 1.0.106" | |
| # SHA256 of the stage-2 infostealer binaries. | |
| STAGE2_HASHES="408ef22050ffc5a67e005802809026b29f297a8019f8fda91a2afa8e877ba434 stage-2 Linux x86_64 | |
| 74d3447e7cf99c99ea01a16332ec27432dfb0f491e10e67cd118065a60483306 stage-2 macOS aarch64" | |
| # Test hook: extra "hash description" lines, appended to both tables. The fixture suite uses it to | |
| # exercise the hash-verified code paths without shipping a copy of the malware. Unset in real runs. | |
| if [ -n "${IOC_HASH_EXTRA:-}" ]; then | |
| CRATE_HASHES="$CRATE_HASHES | |
| $IOC_HASH_EXTRA" | |
| STAGE2_HASHES="$STAGE2_HASHES | |
| $IOC_HASH_EXTRA" | |
| fi | |
| # Test hook: override the syslog files the --logs fallback greps. The fixture suite points this at | |
| # a sandbox file (the real /var/log paths are not writable from a fixture) to exercise the hit | |
| # path of the fallback. Unset in real runs. | |
| SYSLOG_FILES="${SYSLOG_FILES:-/var/log/syslog /var/log/messages /var/log/auth.log}" | |
| C2_IP="23.254.165.112" # port 9089 = payload delivery, 443 = command and control | |
| DROPPER_NAME="rust-setup" # /tmp/rust-setup on Unix | |
| STAGE2_GLOB="rust-crate_0.*" # _0.1.0 linux-x64, _0.2.0 win-x64, _0.3.0 mac-x64, _0.4.0 mac-arm64 | |
| # Exposure window, UTC. Per the Rust Blog timeline the malicious versions were live for: | |
| # arrayref 0.3.10 published 07:15:00Z deleted 08:41:40Z (86m40s) | |
| # internment 0.8.7 published 07:34:07Z deleted 09:04:11Z (90m04s) | |
| # append-only-vec 0.1.9 published 07:37:49Z deleted 09:25:24Z (107m35s) | |
| # Both bounds are padded past those extremes on purpose: this is an mtime heuristic and a | |
| # tight end bound would have silently excluded the last 24 seconds of the append-only-vec window. | |
| WINDOW_START="2026-08-20 07:11:00 UTC" | |
| WINDOW_END="2026-08-20 09:26:00 UTC" | |
| # -------------------------------------------------------------------------------------- | |
| # Options | |
| # -------------------------------------------------------------------------------------- | |
| DEEP=0 | |
| DO_LOGS=0 | |
| JSON=0 | |
| SCAN_ROOT="" | |
| usage() { | |
| cat <<EOF | |
| detect-proc-macro1-attack.sh $VERSION | |
| Detects local traces of the 2026-08-20 crates.io supply-chain attack | |
| (arrayref 0.3.10 / append-only-vec 0.1.9 / internment 0.8.7 -> proc-macro1 build-time payload). | |
| Usage: $0 [options] | |
| --deep Scan all of / for Cargo.lock and build artifacts instead of just \$HOME. | |
| Slower; run under sudo for full coverage of other users and system dirs. | |
| --logs Query the last 48h of system logs for egress to $C2_IP and dropper | |
| execution (macOS unified log, Linux journald/syslog). | |
| Off by default: can take minutes and may need sudo. | |
| --root DIR Override the filesystem scan root (default: \$HOME). Mainly for testing. | |
| --json Emit findings as JSON instead of the human-readable report. | |
| -h, --help This message. | |
| Exit codes: | |
| 0 clean no indicator found | |
| 1 suspect something needs manual review | |
| 2 confirmed malicious artifact present or payload executed | |
| 3 error the script could not run its checks | |
| Honors \$CARGO_HOME (default ~/.cargo). | |
| Note: the live-connection, process and service checks only see the invoking user's own | |
| processes and service domain. Run the whole script under sudo for machine-wide coverage. | |
| EOF | |
| } | |
| while [ $# -gt 0 ]; do | |
| case "$1" in | |
| --deep) DEEP=1 ;; | |
| --logs) DO_LOGS=1 ;; | |
| --json) JSON=1 ;; | |
| --root) shift; [ $# -gt 0 ] || { echo "--root needs an argument" >&2; exit 3; }; SCAN_ROOT="$1" ;; | |
| --root=*) SCAN_ROOT="${1#--root=}" ;; | |
| -h|--help) usage; exit 0 ;; | |
| *) echo "unknown option: $1" >&2; usage >&2; exit 3 ;; | |
| esac | |
| shift | |
| done | |
| # -------------------------------------------------------------------------------------- | |
| # Environment | |
| # -------------------------------------------------------------------------------------- | |
| CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" | |
| [ -n "$SCAN_ROOT" ] || SCAN_ROOT="${HOME:-}" | |
| OS="$(uname -s)" | |
| if [ -z "$SCAN_ROOT" ]; then | |
| echo "error: neither --root nor \$HOME is set; nothing to scan" >&2 | |
| exit 3 | |
| fi | |
| # Pin find(1): a shell alias or a bfs/fd replacement on $PATH does not accept -newermt or | |
| # -mindepth the same way, and a silently misbehaving find would turn a real hit into a clean run. | |
| if [ -x /usr/bin/find ]; then | |
| FIND=/usr/bin/find | |
| elif command -v find >/dev/null 2>&1; then | |
| FIND="$(command -v find)" | |
| else | |
| echo "error: find(1) not found" >&2 | |
| exit 3 | |
| fi | |
| if ! "$FIND" /tmp -maxdepth 0 -newermt "$WINDOW_START" >/dev/null 2>&1; then | |
| echo "error: $FIND does not support -newermt; install GNU findutils or BSD find" >&2 | |
| exit 3 | |
| fi | |
| # Fail loudly and early if we cannot hash: several checks distinguish "name match" from | |
| # "hash-verified match", and silently skipping that would understate a real infection. | |
| # sha256sum first: it is native on Linux (where shasum is a Perl script or absent). The | |
| # openssl fallback prints "SHA2-256(path)= hash", so its hash is the LAST field, not $1. | |
| # HASH_BATCH/HASH_ARGV: sha256sum and shasum both print "hash path" and take many files per | |
| # invocation, so the registry-cache sweep hashes the whole cache with one (or a few, when | |
| # ARG_MAX splits it) -exec ... {} + call. openssl has a different output shape, so it keeps | |
| # the per-file path. | |
| HASH_BATCH=0 | |
| HASH_ARGV=() | |
| if command -v sha256sum >/dev/null 2>&1; then | |
| sha256_of() { sha256sum "$1" 2>/dev/null | awk '{print $1}'; } | |
| HASH_BATCH=1 | |
| HASH_ARGV=(sha256sum) | |
| elif command -v shasum >/dev/null 2>&1; then | |
| sha256_of() { shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'; } | |
| HASH_BATCH=1 | |
| HASH_ARGV=(shasum -a 256) | |
| elif command -v openssl >/dev/null 2>&1; then | |
| sha256_of() { openssl dgst -sha256 "$1" 2>/dev/null | awk '{print $NF}'; } | |
| else | |
| echo "error: neither sha256sum, shasum nor openssl found; cannot verify hashes" >&2 | |
| exit 3 | |
| fi | |
| if [ -t 1 ] && [ "$JSON" -eq 0 ]; then | |
| C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_GRN=$'\033[32m' | |
| C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_OFF=$'\033[0m' | |
| else | |
| C_RED=""; C_YEL=""; C_GRN=""; C_DIM=""; C_BOLD=""; C_OFF="" | |
| fi | |
| # -------------------------------------------------------------------------------------- | |
| # Finding accumulation -- every check funnels through here so that the printed lines, | |
| # the summary and the exit code cannot drift apart. | |
| # -------------------------------------------------------------------------------------- | |
| N_CONFIRMED=0 | |
| N_SUSPECT=0 | |
| N_INFO=0 | |
| N_SKIP=0 | |
| JSON_ITEMS="" | |
| CONFIRMED_LIST="" | |
| SUSPECT_LIST="" | |
| json_escape() { | |
| # Escape backslash, quote and control characters for JSON string context. Newlines have to be | |
| # handled too: a raw one inside a JSON string is invalid, and it would also split a finding | |
| # across several lines of JSON_ITEMS, which the awk in report() then emits as bogus entries. | |
| # CR is folded into a newline; the remaining C0 controls are dropped rather than escaped, | |
| # since no check produces them and \uXXXX is not worth the sed gymnastics. | |
| printf '%s' "$1" \ | |
| | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' \ | |
| | tr '\r' '\n' \ | |
| | tr -d '\000-\010\013\014\016-\037' \ | |
| | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }' | |
| } | |
| finding() { | |
| local level="$1" check="$2" detail="$3" | |
| local tag color | |
| case "$level" in | |
| CONFIRMED) tag="[BAD ]"; color="$C_RED"; N_CONFIRMED=$((N_CONFIRMED + 1)) | |
| CONFIRMED_LIST="${CONFIRMED_LIST}${check}: ${detail} | |
| " ;; | |
| SUSPECT) tag="[SUSP]"; color="$C_YEL"; N_SUSPECT=$((N_SUSPECT + 1)) | |
| SUSPECT_LIST="${SUSPECT_LIST}${check}: ${detail} | |
| " ;; | |
| INFO) tag="[INFO]"; color="$C_DIM"; N_INFO=$((N_INFO + 1)) ;; | |
| SKIP) tag="[SKIP]"; color="$C_DIM"; N_SKIP=$((N_SKIP + 1)) ;; | |
| OK) tag="[ OK ]"; color="$C_GRN" ;; | |
| *) tag="[????]"; color="" ;; | |
| esac | |
| if [ "$JSON" -eq 1 ]; then | |
| JSON_ITEMS="${JSON_ITEMS}{\"level\":\"$level\",\"check\":\"$(json_escape "$check")\",\"detail\":\"$(json_escape "$detail")\"} | |
| " | |
| else | |
| printf '%s%s%s %-28s %s\n' "$color" "$tag" "$C_OFF" "$check" "$detail" | |
| fi | |
| } | |
| section() { | |
| [ "$JSON" -eq 1 ] && return 0 | |
| printf '\n%s%s%s\n' "$C_BOLD" "$1" "$C_OFF" | |
| } | |
| # lsof, ps, launchctl and systemctl only see the invoking user's own processes and service | |
| # domain. An all-clear from them is therefore scoped, and saying so is better than implying | |
| # full coverage. | |
| priv_scope_note() { | |
| [ "$(id -u)" -eq 0 ] && return 0 | |
| printf '%s' " -- for this user only, re-run under sudo to cover all users" | |
| return 0 | |
| } | |
| # Compare a file against a hash table ("hash rest-of-line" per line). | |
| # Prints the description on match, empty string otherwise. | |
| hash_lookup() { | |
| local file="$1" table="$2" got line | |
| got="$(sha256_of "$file")" | |
| [ -n "$got" ] || return 0 | |
| printf '%s\n' "$table" | while IFS= read -r line; do | |
| [ -n "$line" ] || continue | |
| case "$line" in | |
| "$got "*) printf '%s' "${line#* }" ;; | |
| esac | |
| done | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # A. Cargo registry cache -- the official Rust Security Response WG check. | |
| # -------------------------------------------------------------------------------------- | |
| # Prints one cargo home per line. Callers must read it line by line: `for ch in $(cargo_homes)` | |
| # word-splits on spaces and would tear "/Users/some user/.cargo" into two bogus paths. | |
| cargo_homes() { | |
| printf '%s\n' "$CARGO_HOME" | |
| if [ "$DEEP" -eq 1 ]; then | |
| local d | |
| for d in /Users/*/.cargo /home/*/.cargo /opt/*/.cargo /root/.cargo /var/root/.cargo; do | |
| [ -d "$d" ] && [ "$d" != "$CARGO_HOME" ] && printf '%s\n' "$d" | |
| done | |
| fi | |
| return 0 | |
| } | |
| check_registry_cache() { | |
| section "A. Cargo registry cache (~/.cargo/registry/cache)" | |
| local ch dir found=0 f desc hash batch row i | |
| local -a seen | |
| seen=() | |
| while IFS= read -r ch; do | |
| [ -n "$ch" ] || continue | |
| dir="$ch/registry/cache" | |
| if [ ! -d "$dir" ]; then | |
| finding INFO "registry-cache" "no cargo registry cache at $dir -- nothing to check (set CARGO_HOME if your cargo home is elsewhere)" | |
| continue | |
| fi | |
| # Official WG command, extended with the two other poisoned releases. | |
| while IFS= read -r f; do | |
| [ -n "$f" ] || continue | |
| found=1 | |
| seen+=("$f") | |
| desc="$(hash_lookup "$f" "$CRATE_HASHES")" | |
| if [ -n "$desc" ]; then | |
| finding CONFIRMED "registry-cache" "$f (SHA256 matches known malicious $desc)" | |
| else | |
| finding CONFIRMED "registry-cache" "$f (filename match; hash does NOT match a published sample -- still treat as malicious)" | |
| fi | |
| done <<EOF | |
| $("$FIND" "$dir" -type f \( \ | |
| -name 'append-only-vec-0.1.9.crate' -o \ | |
| -name 'arrayref-0.3.10.crate' -o \ | |
| -name 'internment-0.8.7.crate' -o \ | |
| -name 'proc-macro1-*.crate' -o \ | |
| -name 'proc-macro-en-*.crate' -o \ | |
| -name 'aovine-*.crate' -o \ | |
| -name 'arone-*.crate' -o \ | |
| -name 'aronenao-*.crate' -o \ | |
| -name 'tinymember-*.crate' \ | |
| \) -print 2>/dev/null) | |
| EOF | |
| done <<EOF | |
| $(cargo_homes) | |
| EOF | |
| # Belt and braces: the archives may have been renamed. The name-match pass above keeps | |
| # `seen`, so a file it already reported is not reported a second time here -- and by | |
| # construction every path that reaches this branch did NOT name-match, which is what | |
| # makes "(renamed archive)" accurate. | |
| while IFS= read -r ch; do | |
| [ -n "$ch" ] || continue | |
| dir="$ch/registry/cache" | |
| [ -d "$dir" ] || continue | |
| if [ "$HASH_BATCH" -eq 1 ]; then | |
| # One (or a few, past ARG_MAX) hasher call(s) for the whole cache: per-file | |
| # fork+exec was the dominant cost of this check. Errors go to stderr and the | |
| # offending file simply misses the grep below, same as the per-file path. | |
| batch="$("$FIND" "$dir" -type f -name '*.crate' -size -2048k \ | |
| -exec "${HASH_ARGV[@]}" {} + 2>/dev/null)" | |
| # The row loop must stay in this shell (heredoc, not pipeline): finding() bumps | |
| # the global counters, and a pipeline would run it in a subshell. sed strips the | |
| # hash prefix so paths containing spaces survive, and covers the single- vs | |
| # double-space output difference between shasum and sha256sum. | |
| while IFS= read -r row; do | |
| [ -n "$row" ] || continue | |
| hash="${row%% *}" | |
| desc="${row#* }" | |
| while IFS= read -r f; do | |
| [ -n "$f" ] || continue | |
| for (( i=0; i < ${#seen[@]}; i++ )); do | |
| [ "${seen[$i]}" = "$f" ] && continue 2 | |
| done | |
| found=1 | |
| finding CONFIRMED "registry-cache-hash" "$f is malicious $desc (renamed archive)" | |
| done <<EOF | |
| $(printf '%s\n' "$batch" | sed -n "s/^${hash}[[:space:]]*//p") | |
| EOF | |
| done <<EOF | |
| $(printf '%s\n' "$CRATE_HASHES") | |
| EOF | |
| else | |
| # openssl fallback: different output shape, keep the per-file path. | |
| while IFS= read -r f; do | |
| [ -n "$f" ] || continue | |
| for (( i=0; i < ${#seen[@]}; i++ )); do | |
| [ "${seen[$i]}" = "$f" ] && continue 2 | |
| done | |
| desc="$(hash_lookup "$f" "$CRATE_HASHES")" | |
| if [ -n "$desc" ]; then | |
| found=1 | |
| finding CONFIRMED "registry-cache-hash" "$f is malicious $desc (renamed archive)" | |
| fi | |
| done <<EOF | |
| $("$FIND" "$dir" -type f -name '*.crate' -size -2048k -print 2>/dev/null) | |
| EOF | |
| fi | |
| done <<EOF | |
| $(cargo_homes) | |
| EOF | |
| [ "$found" -eq 0 ] && finding OK "registry-cache" "no malicious .crate archives cached" | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # B. Extracted sources -- cargo got as far as unpacking the archive. | |
| # -------------------------------------------------------------------------------------- | |
| check_extracted_sources() { | |
| section "B. Extracted crate sources (~/.cargo/registry/src)" | |
| local ch dir found=0 d name | |
| while IFS= read -r ch; do | |
| [ -n "$ch" ] || continue | |
| dir="$ch/registry/src" | |
| if [ ! -d "$dir" ]; then | |
| finding INFO "registry-src" "no cargo registry src at $dir -- nothing to check (set CARGO_HOME if your cargo home is elsewhere)" | |
| continue | |
| fi | |
| # -maxdepth 2: the layout is src/<registry-hash>/<crate>-<version>/. | |
| # Exact names for the poisoned releases so that arrayref-0.3.9 (clean, and present | |
| # on many machines) is never matched by a prefix. | |
| while IFS= read -r d; do | |
| [ -n "$d" ] || continue | |
| found=1 | |
| name="$(basename "$d")" | |
| if [ -f "$d/build.rs" ]; then | |
| finding CONFIRMED "registry-src" "$d (unpacked, has build.rs -- payload carrier)" | |
| else | |
| finding CONFIRMED "registry-src" "$d (unpacked malicious crate $name)" | |
| fi | |
| done <<EOF | |
| $("$FIND" "$dir" -mindepth 2 -maxdepth 2 -type d \( \ | |
| -name 'arrayref-0.3.10' -o \ | |
| -name 'append-only-vec-0.1.9' -o \ | |
| -name 'internment-0.8.7' -o \ | |
| -name 'proc-macro1-*' -o \ | |
| -name 'proc-macro-en-*' -o \ | |
| -name 'aovine-*' -o \ | |
| -name 'arone-*' -o \ | |
| -name 'aronenao-*' -o \ | |
| -name 'tinymember-*' \ | |
| \) -print 2>/dev/null) | |
| EOF | |
| done <<EOF | |
| $(cargo_homes) | |
| EOF | |
| [ "$found" -eq 0 ] && finding OK "registry-src" "no malicious crate sources unpacked" | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # C. Registry index cache -- metadata was resolved, download not implied. | |
| # -------------------------------------------------------------------------------------- | |
| check_index_cache() { | |
| section "C. Cargo registry index cache" | |
| local ch dir found=0 crate f | |
| while IFS= read -r ch; do | |
| [ -n "$ch" ] || continue | |
| dir="$ch/registry/index" | |
| [ -d "$dir" ] || continue | |
| for crate in $MALICIOUS_CRATES; do | |
| while IFS= read -r f; do | |
| [ -n "$f" ] || continue | |
| found=1 | |
| finding SUSPECT "registry-index" "$f -- cargo resolved metadata for '$crate' (download not proven)" | |
| done <<EOF | |
| $("$FIND" "$dir" -type f -name "$crate" -path '*/.cache/*' -print 2>/dev/null) | |
| EOF | |
| done | |
| done <<EOF | |
| $(cargo_homes) | |
| EOF | |
| [ "$found" -eq 0 ] && finding OK "registry-index" "no index entries for the typosquat crates" | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # D/E. Filesystem hunt: Cargo.lock, build-script output dirs, vendored sources. | |
| # One find pass over the scan root. | |
| # -------------------------------------------------------------------------------------- | |
| FS_HUNT_OUTPUT="" | |
| run_fs_hunt() { | |
| local root="$1" | |
| # target/ is deliberately NOT pruned: build-script output dirs under target/*/build/ | |
| # are the proof that the payload executed. | |
| # The -path prunes are anchored to /, so they only match when the root IS / (--deep): | |
| # walking /proc, /sys, /dev or /run is a waste of time and error spam on Linux, and | |
| # /System/Volumes double-traverses the same files on macOS. With any other root the | |
| # paths never equal those absolute names, so this is a no-op for the default scan. | |
| "$FIND" "$root" \ | |
| \( -path /proc -o -path /sys -o -path /dev -o -path /run -o -path /var/run \ | |
| -o -path /snap -o -path /System/Volumes -o -path /.vol \) -prune -o \ | |
| \( -name .git -o -name node_modules -o -name .venv -o -name Caches -o -name .Trash \) -prune -o \ | |
| \( \ | |
| -type f -name 'Cargo.lock' -print -o \ | |
| -type f -name '.cargo-checksum.json' -print -o \ | |
| -type d -name 'arrayref-*' -path '*/build/*' -print -o \ | |
| -type d -name 'proc-macro1-*' -print -o \ | |
| -type d -name 'proc-macro-en-*' -print -o \ | |
| -type d -name 'append-only-vec-*' -path '*/build/*' -print -o \ | |
| -type d -name 'internment-*' -path '*/build/*' -print -o \ | |
| -type d -name 'aovine-*' -print -o \ | |
| -type d -name 'arone-*' -print -o \ | |
| -type d -name 'aronenao-*' -print -o \ | |
| -type d -name 'tinymember-*' -print \ | |
| \) 2>/dev/null | |
| } | |
| # Is this directory an actual unpacked crate source tree, i.e. "<name>-<semver>/" containing a | |
| # Cargo.toml? Without this, a bare name-prefix match flags any unrelated directory that happens | |
| # to start with "proc-macro1-" or "arone-" -- including this repo's own checkout. | |
| is_crate_source_dir() { | |
| local dir="$1" base | |
| base="$(basename "$dir")" | |
| printf '%s' "$base" | grep -qE -- '-[0-9]+\.[0-9]+(\.[0-9]+)?([-+][0-9A-Za-z.-]+)?$' || return 1 | |
| [ -f "$dir/Cargo.toml" ] || return 1 | |
| return 0 | |
| } | |
| # Does a Cargo.lock pin a malicious package? Prints a description on hit. | |
| lock_verdict() { | |
| local lock="$1" crate rel name ver | |
| for crate in $MALICIOUS_CRATES; do | |
| if grep -q "^name = \"$crate\"\$" "$lock" 2>/dev/null; then | |
| printf '%s' "pins typosquat '$crate'" | |
| return 0 | |
| fi | |
| done | |
| for rel in $POISONED_RELEASES; do | |
| ver="${rel##*-}" | |
| name="${rel%-*}" | |
| # Lockfile entries are: name = "x"\nversion = "y". Match the pair, not either alone. | |
| if grep -A2 "^name = \"$name\"\$" "$lock" 2>/dev/null | grep -q "^version = \"$ver\"\$"; then | |
| printf '%s' "pins malicious $name $ver" | |
| return 0 | |
| fi | |
| done | |
| return 0 | |
| } | |
| # Is this vendor directory a copy of a malicious crate? Prints a description on hit. | |
| # | |
| # `cargo vendor` names the directory after the crate alone -- vendor/arrayref/ -- and only appends | |
| # the version when two versions of the same crate are vendored. So for the poisoned releases the | |
| # directory name carries no version at all, and the version has to come out of the vendored | |
| # Cargo.toml; matching on the name alone would flag every vendored arrayref 0.3.9 as well. | |
| vendor_verdict() { | |
| local vdir="$1" vname crate rel name ver got | |
| vname="$(basename "$vdir")" | |
| # Typosquats are malicious at every version, so the bare name is enough. | |
| for crate in $MALICIOUS_CRATES; do | |
| if [ "$vname" = "$crate" ] || [ "${vname%-*}" = "$crate" ]; then | |
| printf '%s' "vendored copy of typosquat '$crate'" | |
| return 0 | |
| fi | |
| done | |
| for rel in $POISONED_RELEASES; do | |
| ver="${rel##*-}" | |
| name="${rel%-*}" | |
| if [ "$vname" = "$rel" ]; then | |
| printf '%s' "vendored copy of malicious $name $ver" | |
| return 0 | |
| fi | |
| [ "$vname" = "$name" ] || continue | |
| # Scope to the [package] section: a [dependencies.foo] table with its own version line | |
| # would fool a plain grep -m1 into reporting the dependency's version instead. BEGIN | |
| # sets in_pkg so a headerless manifest (package keys at the TOML root) still reads. | |
| got="$(awk 'BEGIN { in_pkg=1 } | |
| /^\[package\]/ { in_pkg=1; next } | |
| /^\[/ { in_pkg=0 } | |
| in_pkg && /^version = "/ { sub(/^version = "/, ""); sub(/".*$/, ""); print; exit }' \ | |
| "$vdir/Cargo.toml" 2>/dev/null)" | |
| if [ -z "$got" ]; then | |
| printf '%s' "vendored copy of $name with no readable version in Cargo.toml -- check it by hand ($ver is malicious)" | |
| elif [ "$got" = "$ver" ]; then | |
| printf '%s' "vendored copy of malicious $name $ver" | |
| fi | |
| return 0 | |
| done | |
| return 0 | |
| } | |
| check_filesystem() { | |
| local root="$1" | |
| section "D/E. Project scan under $root" | |
| if [ ! -d "$root" ]; then | |
| finding SKIP "filesystem" "$root is not a directory" | |
| return 0 | |
| fi | |
| [ "$JSON" -eq 0 ] && printf '%s(this is the slow part; %s)%s\n' "$C_DIM" \ | |
| "$([ "$DEEP" -eq 1 ] && echo 'deep scan -- run under sudo for full coverage' || echo 'use --deep for all volumes')" "$C_OFF" | |
| FS_HUNT_OUTPUT="$(run_fs_hunt "$root")" | |
| local path base found_lock=0 found_build=0 found_vendor=0 verdict | |
| while IFS= read -r path; do | |
| [ -n "$path" ] || continue | |
| # Parameter expansion instead of basename/dirname(1): this loop runs once per hunt | |
| # hit and the fork cost is pure overhead. Exact for find output (no trailing | |
| # slashes); the dirname form yields "" where dirname(1) would yield "/" for a bare | |
| # "/x", which find never prints and which changes no verdict here either way. | |
| base="${path##*/}" | |
| case "$base" in | |
| Cargo.lock) | |
| verdict="$(lock_verdict "$path")" | |
| if [ -n "$verdict" ]; then | |
| found_lock=1 | |
| finding SUSPECT "cargo-lock" "$path $verdict -- will re-infect on next build; fix or delete this lockfile" | |
| fi | |
| ;; | |
| .cargo-checksum.json) | |
| # vendored copy: .../vendor/<crate>/.cargo-checksum.json | |
| local vdir | |
| case "$path" in | |
| */*) vdir="${path%/*}" ;; | |
| *) vdir="." ;; | |
| esac | |
| case "$vdir" in | |
| # Registry checkouts carry this file too, and check B already reports those | |
| # by name -- reporting them here again as a "vendored copy" is both a | |
| # duplicate and a mislabel. | |
| */registry/src/*) ;; | |
| *) | |
| verdict="$(vendor_verdict "$vdir")" | |
| if [ -n "$verdict" ]; then | |
| found_vendor=1 | |
| finding SUSPECT "vendored-source" "$vdir -- $verdict" | |
| fi | |
| ;; | |
| esac | |
| ;; | |
| *) | |
| # Directory hit. Under */build/ it is build-script output = proof of execution. | |
| case "$path" in | |
| */build/*) | |
| # arrayref-<hash> under build/ only proves a build; confirm it is a bad version | |
| # by cross-referencing the sibling Cargo.lock is not possible here, so report | |
| # the typosquat dirs as confirmed and the legit-crate dirs as suspect. | |
| case "$base" in | |
| proc-macro1-*|proc-macro-en-*|aovine-*|arone-*|aronenao-*|tinymember-*) | |
| found_build=1 | |
| finding CONFIRMED "build-artifact" "$path -- build script of a malicious crate RAN here" | |
| ;; | |
| *) | |
| found_build=1 | |
| finding SUSPECT "build-artifact" "$path -- a build of this crate happened; check the project's Cargo.lock version" | |
| ;; | |
| esac | |
| ;; | |
| # The registry checkout is check B's territory; the scan root usually contains | |
| # ~/.cargo, and reporting the same directory under two check names inflates | |
| # the confirmed count without adding evidence. | |
| */registry/src/*) ;; | |
| *) | |
| case "$base" in | |
| proc-macro1-*|proc-macro-en-*|aovine-*|arone-*|aronenao-*|tinymember-*) | |
| if is_crate_source_dir "$path"; then | |
| found_build=1 | |
| finding CONFIRMED "malicious-source-dir" "$path -- unpacked source tree of a malicious crate" | |
| fi | |
| ;; | |
| esac | |
| ;; | |
| esac | |
| ;; | |
| esac | |
| done <<EOF | |
| $FS_HUNT_OUTPUT | |
| EOF | |
| [ "$found_lock" -eq 0 ] && finding OK "cargo-lock" "no lockfile pins a malicious crate" | |
| [ "$found_build" -eq 0 ] && finding OK "build-artifact" "no build-script output from a malicious crate" | |
| [ "$found_vendor" -eq 0 ] && finding OK "vendored-source" "no vendored copies of a malicious crate" | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # F. Stage-2 dropper artifacts. | |
| # -------------------------------------------------------------------------------------- | |
| # Appends $1 to DROPPER_ROOTS unless it does not exist, is already listed, or sits inside a | |
| # directory that is already listed -- overlapping find roots report the same file twice. | |
| # The ${arr[@]+"${arr[@]}"} form is deliberate: the quoted @ keeps each element whole (a root | |
| # like "/Users/some user" survives intact), and the + alternative keeps the expansion from | |
| # tripping `set -u` when the array is empty -- DROPPER_ROOTS is empty on the first call. | |
| DROPPER_ROOTS=() | |
| add_dropper_root() { | |
| local cand="$1" existing | |
| local -a kept | |
| [ "$cand" = "/" ] || cand="${cand%/}" | |
| [ -n "$cand" ] && [ -d "$cand" ] || return 0 | |
| kept=() | |
| for existing in ${DROPPER_ROOTS[@]+"${DROPPER_ROOTS[@]}"}; do | |
| case "$cand" in | |
| "$existing"|"$existing"/*) return 0 ;; # already covered by a broader root | |
| esac | |
| case "$existing" in | |
| "$cand"/*) continue ;; # subsumed by the new, broader root | |
| esac | |
| kept+=("$existing") | |
| done | |
| kept+=("$cand") | |
| DROPPER_ROOTS=(${kept[@]+"${kept[@]}"}) | |
| return 0 | |
| } | |
| # Prints the deduped list of temp dirs the dropper could have landed in, one per line. | |
| # TMPDIR is normally unset on Linux and defaults to /tmp, which would otherwise report the | |
| # same dropper twice and inflate the confirmed count; /dev/shm and the XDG runtime dir are | |
| # common Linux dropper spots. Standalone so the fixture suite can unit-test the dedupe. | |
| dropper_temp_dirs() { | |
| local d p seen | |
| local -a temps | |
| temps=() | |
| for d in /tmp "${TMPDIR:-/tmp}" /var/tmp /dev/shm \ | |
| "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" "/run/user/$(id -u)"; do | |
| d="${d%/}" | |
| [ -d "$d" ] || continue | |
| seen=0 | |
| for p in ${temps[@]+"${temps[@]}"}; do | |
| [ "$p" = "$d" ] && seen=1 | |
| done | |
| [ "$seen" -eq 0 ] && { temps+=("$d"); printf '%s\n' "$d"; } | |
| done | |
| return 0 | |
| } | |
| check_dropper() { | |
| section "F. Stage-2 dropper artifacts" | |
| local found=0 p f desc d swept=0 | |
| while IFS= read -r d; do | |
| [ -n "$d" ] || continue | |
| p="$d/$DROPPER_NAME" | |
| if [ -e "$p" ]; then | |
| found=1 | |
| desc="$(hash_lookup "$p" "$STAGE2_HASHES")" | |
| if [ -n "$desc" ]; then | |
| finding CONFIRMED "dropper" "$p exists and SHA256 matches known $desc" | |
| else | |
| # Not downgraded to SUSPECT on a mismatch: hashes were published for only two of | |
| # the four stage-2 builds (linux-x64, mac-arm64), so a real mac-x64 or win-x64 | |
| # infection is expected to miss the table. | |
| finding CONFIRMED "dropper" "$p exists (path match; hash matches neither published stage-2 sample, but only linux-x64 and mac-arm64 hashes exist -- inspect it, do not run it)" | |
| fi | |
| fi | |
| # Windows-side names, harmless to check on a shared volume. | |
| for f in "$d/$DROPPER_NAME.ps1" "$d/$DROPPER_NAME-launch.vbs"; do | |
| [ -e "$f" ] && { found=1; finding CONFIRMED "dropper" "$f exists (Windows-stage artifact)"; } | |
| done | |
| done <<EOF | |
| $(dropper_temp_dirs) | |
| EOF | |
| # Stage-2 binary sweep. --deep adds the other users' homes, so that this check keeps up with | |
| # checks A-E instead of staying pinned to $HOME. | |
| DROPPER_ROOTS=() | |
| while IFS= read -r d; do | |
| [ -n "$d" ] || continue | |
| add_dropper_root "$d" | |
| done <<EOF | |
| $(dropper_temp_dirs) | |
| EOF | |
| if [ "$DEEP" -eq 1 ]; then | |
| for d in /Users /home /root /opt /var/root; do | |
| add_dropper_root "$d" | |
| done | |
| fi | |
| add_dropper_root "$SCAN_ROOT" | |
| if [ "${#DROPPER_ROOTS[@]}" -eq 0 ]; then | |
| finding SKIP "stage2-binary" "no readable directory to sweep for $STAGE2_GLOB" | |
| else | |
| swept=1 | |
| while IFS= read -r f; do | |
| [ -n "$f" ] || continue | |
| found=1 | |
| desc="$(hash_lookup "$f" "$STAGE2_HASHES")" | |
| if [ -n "$desc" ]; then | |
| finding CONFIRMED "stage2-binary" "$f SHA256 matches known $desc" | |
| else | |
| finding CONFIRMED "stage2-binary" "$f matches the stage-2 naming scheme (hash unknown)" | |
| fi | |
| done <<EOF | |
| $("$FIND" "${DROPPER_ROOTS[@]}" -maxdepth 4 -type f -name "$STAGE2_GLOB" -print 2>/dev/null) | |
| EOF | |
| fi | |
| if [ "$found" -eq 0 ]; then | |
| if [ "$swept" -eq 1 ]; then | |
| finding OK "dropper" "no /tmp/$DROPPER_NAME or $STAGE2_GLOB artifacts" | |
| else | |
| finding OK "dropper" "no $DROPPER_NAME in the temp dirs (the $STAGE2_GLOB sweep was skipped)" | |
| fi | |
| fi | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # G. Persistence -- launchd (macOS), systemd and cron/autostart (Linux). | |
| # Capability dispatch, not OS gating: every group runs on every OS and reports itself as | |
| # not applicable ([INFO]) when its directories and tools are both absent, so one code path | |
| # serves both platforms and the fixture suite stays OS-independent. [SKIP] stays reserved | |
| # for things that exist but cannot be checked. Only the macOS persistence mechanism of this payload | |
| # is publicly documented (LaunchAgent, RunAtLoad, /bin/zsh -c); the Linux checks therefore | |
| # search for the known IOC strings plus generic shape heuristics, which the OK wording | |
| # says plainly rather than implying equal coverage. | |
| # -------------------------------------------------------------------------------------- | |
| ioc_grep() { | |
| grep -qE "$C2_IP|$DROPPER_NAME|rust-crate_0\." "$1" 2>/dev/null | |
| } | |
| check_persistence() { | |
| section "G. Persistence (launchd / systemd / cron-autostart)" | |
| local dir plist xml labels unit f | |
| local lfound=0 sfound=0 cfound=0 launchd_ran=0 systemd_ran=0 cron_ran=0 | |
| # ---- launchd ---- | |
| for dir in "$HOME/Library/LaunchAgents" /Library/LaunchAgents /Library/LaunchDaemons; do | |
| [ -d "$dir" ] || continue | |
| launchd_ran=1 | |
| while IFS= read -r plist; do | |
| [ -n "$plist" ] || continue | |
| xml="$(plutil -convert xml1 -o - "$plist" 2>/dev/null)" | |
| [ -n "$xml" ] || xml="$(cat "$plist" 2>/dev/null)" | |
| if printf '%s' "$xml" | grep -qE "$C2_IP|$DROPPER_NAME|rust-crate_0\."; then | |
| lfound=1 | |
| finding CONFIRMED "persistence" "$plist references a known IOC" | |
| continue | |
| fi | |
| # Heuristic, deliberately SUSPECT-only: RunAtLoad + /bin/zsh -c + a long base64 blob | |
| # is the shape of this payload's LaunchAgent, but also of some legitimate installers. | |
| if printf '%s' "$xml" | grep -q 'RunAtLoad' \ | |
| && printf '%s' "$xml" | grep -qE '/bin/(zsh|bash|sh)' \ | |
| && printf '%s' "$xml" | grep -qE '[A-Za-z0-9+/=]{120,}'; then | |
| lfound=1 | |
| finding SUSPECT "persistence" "$plist: RunAtLoad + shell -c + long base64 blob -- review by hand" | |
| fi | |
| done <<EOF | |
| $("$FIND" "$dir" -maxdepth 1 -type f -name '*.plist' -print 2>/dev/null) | |
| EOF | |
| # Anything installed during the exposure window deserves a look regardless of content. | |
| while IFS= read -r plist; do | |
| [ -n "$plist" ] || continue | |
| lfound=1 | |
| finding SUSPECT "persistence" "$plist was created/modified during the attack window ($WINDOW_START - $WINDOW_END)" | |
| done <<EOF | |
| $("$FIND" "$dir" -maxdepth 1 -type f -newermt "$WINDOW_START" ! -newermt "$WINDOW_END" -print 2>/dev/null) | |
| EOF | |
| done | |
| # tr: several matching jobs would otherwise put raw newlines into the detail, which breaks | |
| # --json output and the one-finding-per-line report. | |
| if command -v launchctl >/dev/null 2>&1; then | |
| launchd_ran=1 | |
| labels="$(launchctl list 2>/dev/null | grep -iE 'rust-?(setup|crate)' | tr '\n' ';')" | |
| if [ -n "$labels" ]; then | |
| lfound=1 | |
| finding CONFIRMED "persistence" "launchctl has a loaded job matching the payload: $labels" | |
| fi | |
| fi | |
| if [ "$launchd_ran" -eq 0 ]; then | |
| finding INFO "persistence" "no launchd directories and no launchctl -- not applicable on this system" | |
| elif [ "$lfound" -eq 0 ]; then | |
| finding OK "persistence" "no suspicious launch agents or daemons; launchctl clean$(priv_scope_note)" | |
| fi | |
| # ---- systemd ---- | |
| for dir in "$HOME/.config/systemd/user" "$HOME/.local/share/systemd/user" \ | |
| /etc/systemd/system /etc/systemd/user /usr/lib/systemd/system /lib/systemd/system; do | |
| [ -d "$dir" ] || continue | |
| systemd_ran=1 | |
| while IFS= read -r unit; do | |
| [ -n "$unit" ] || continue | |
| if ioc_grep "$unit"; then | |
| sfound=1 | |
| finding CONFIRMED "persistence-systemd" "$unit references a known IOC" | |
| continue | |
| fi | |
| # Same shape heuristic as launchd, deliberately SUSPECT-only: ExecStart + shell + blob. | |
| if grep -q 'ExecStart=' "$unit" 2>/dev/null \ | |
| && grep -qE '/bin/(zsh|bash|sh)' "$unit" 2>/dev/null \ | |
| && grep -qE '[A-Za-z0-9+/=]{120,}' "$unit" 2>/dev/null; then | |
| sfound=1 | |
| finding SUSPECT "persistence-systemd" "$unit: ExecStart + shell + long base64 blob -- review by hand" | |
| fi | |
| done <<EOF | |
| $("$FIND" "$dir" -maxdepth 1 -type f \( -name '*.service' -o -name '*.timer' \) -print 2>/dev/null) | |
| EOF | |
| while IFS= read -r unit; do | |
| [ -n "$unit" ] || continue | |
| sfound=1 | |
| finding SUSPECT "persistence-systemd" "$unit was created/modified during the attack window ($WINDOW_START - $WINDOW_END)" | |
| done <<EOF | |
| $("$FIND" "$dir" -maxdepth 1 -type f \( -name '*.service' -o -name '*.timer' \) \ | |
| -newermt "$WINDOW_START" ! -newermt "$WINDOW_END" -print 2>/dev/null) | |
| EOF | |
| done | |
| if command -v systemctl >/dev/null 2>&1; then | |
| systemd_ran=1 | |
| labels="$(systemctl list-units --all 2>/dev/null | grep -iE 'rust-?(setup|crate)' | tr '\n' ';')" | |
| if [ -n "$labels" ]; then | |
| sfound=1 | |
| finding CONFIRMED "persistence-systemd" "systemctl shows a loaded unit matching the payload: $labels" | |
| fi | |
| labels="$(systemctl --user list-units --all 2>/dev/null | grep -iE 'rust-?(setup|crate)' | tr '\n' ';')" | |
| if [ -n "$labels" ]; then | |
| sfound=1 | |
| finding CONFIRMED "persistence-systemd" "systemctl --user shows a loaded unit matching the payload: $labels" | |
| fi | |
| fi | |
| if [ "$systemd_ran" -eq 0 ]; then | |
| finding INFO "persistence-systemd" "no systemd unit directories and no systemctl -- not applicable on this system" | |
| elif [ "$sfound" -eq 0 ]; then | |
| finding OK "persistence-systemd" "no IOCs in systemd units -- note: the Linux persistence mechanism of this payload is not publicly documented, so this check matches IOC strings and generic shapes only" | |
| fi | |
| # ---- cron / autostart / init ---- | |
| for f in /etc/crontab /etc/rc.local; do | |
| [ -f "$f" ] || continue | |
| cron_ran=1 | |
| if ioc_grep "$f"; then | |
| cfound=1 | |
| finding CONFIRMED "persistence-cron" "$f references a known IOC" | |
| fi | |
| done | |
| for dir in /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly \ | |
| /var/spool/cron/crontabs /var/spool/cron /var/spool/at \ | |
| "$HOME/.config/autostart" /etc/xdg/autostart /etc/init.d; do | |
| [ -d "$dir" ] || continue | |
| cron_ran=1 | |
| while IFS= read -r f; do | |
| [ -n "$f" ] || continue | |
| if ioc_grep "$f"; then | |
| cfound=1 | |
| finding CONFIRMED "persistence-cron" "$f references a known IOC" | |
| fi | |
| done <<EOF | |
| $("$FIND" "$dir" -maxdepth 1 -type f -print 2>/dev/null) | |
| EOF | |
| done | |
| if [ -f /etc/ld.so.preload ]; then | |
| cron_ran=1 | |
| if ioc_grep /etc/ld.so.preload; then | |
| cfound=1 | |
| finding CONFIRMED "persistence-cron" "/etc/ld.so.preload references a known IOC" | |
| elif [ -s /etc/ld.so.preload ]; then | |
| finding INFO "persistence-cron" "/etc/ld.so.preload is non-empty -- it is normally absent; review by hand" | |
| fi | |
| fi | |
| if [ "$cron_ran" -eq 0 ]; then | |
| finding INFO "persistence-cron" "no cron, autostart or init directories -- not applicable on this system" | |
| elif [ "$cfound" -eq 0 ]; then | |
| finding OK "persistence-cron" "no IOC strings in cron, autostart or init files" | |
| fi | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # H. Live network connections and running processes. | |
| # -------------------------------------------------------------------------------------- | |
| # Filters lines that already matched the IOC pattern but are this script's own | |
| # infrastructure, not the payload: the grep processes doing the filtering, any line from | |
| # another instance of this script, and a concurrent second instance's `log show` / `log | |
| # stream` whose --logs predicate argv contains these very IOC strings. argv[0] is whatever | |
| # the invoking shell put there: bare `log show` when found via PATH, `/usr/bin/log show` | |
| # when invoked by path -- so the pattern allows an optional path prefix. | |
| ps_self_filter() { | |
| grep -v grep \ | |
| | grep -vF 'detect-proc-macro1-attack' \ | |
| | grep -vE '^ *[0-9]+ +([^ ]*/)?log (show|stream) ' | |
| } | |
| # Prints the little-endian hex form /proc/net/tcp{,6} uses for the C2 address | |
| # (23.254.165.112 -> 70A5FE17, octets reversed), so the /proc tier needs no hardcoded value. | |
| c2_le_hex() { | |
| printf '%02X%02X%02X%02X' \ | |
| "$(printf '%s' "$C2_IP" | cut -d. -f4)" \ | |
| "$(printf '%s' "$C2_IP" | cut -d. -f3)" \ | |
| "$(printf '%s' "$C2_IP" | cut -d. -f2)" \ | |
| "$(printf '%s' "$C2_IP" | cut -d. -f1)" | |
| } | |
| # Prints the ERE used to spot the C2 address in /proc/net/tcp{,6} rows. The address field is | |
| # the address in hex, bytes reversed; in tcp6 an IPv4-mapped row stores the v4 address as the | |
| # last word, preceded by 0x0000FFFF in host (little-endian) order -- "FFFF0000" -- and 80 zero | |
| # bits, so the pattern allows an optional "0{16}FFFF0{4}" prefix. Standalone so the fixture | |
| # suite can unit-test it against synthetic rows. | |
| c2_socket_pattern() { | |
| printf '(^| )(0{16}FFFF0{4})?%s:[0-9A-F]{4} ' "$(c2_le_hex)" | |
| } | |
| check_live() { | |
| section "H. Live connections and processes" | |
| local found=0 out rc c2_ran=0 | |
| # First tool that exists wins; all four are user-scoped without root. A tool that exists | |
| # but cannot be queried reports [SKIP] -- an implied all-clear would understate a live C2. | |
| # The tool's exit status is captured BEFORE the output is filtered: under pipefail the | |
| # trailing grep's "no match" exit 1 would otherwise mask a failing tool entirely. | |
| if command -v lsof >/dev/null 2>&1; then | |
| # One invocation with stderr folded in: diagnostics start with "lsof:" and the | |
| # table never does. macOS lsof exits 1 on a clean no-match (Linux exits 0), so | |
| # exit 1 only means failure when a diagnostic is present. | |
| out="$(lsof -nP -i "@$C2_IP" 2>&1)" | |
| rc=$? | |
| if [ "$rc" -ge 2 ] || { [ "$rc" -eq 1 ] && printf '%s' "$out" | grep -q '^lsof:'; }; then | |
| finding SKIP "c2-connection" "lsof could not be queried (exit $rc)" | |
| else | |
| c2_ran=1 | |
| out="$(printf '%s' "$out" | grep -v '^COMMAND')" | |
| if [ -n "$out" ]; then | |
| found=1 | |
| finding CONFIRMED "c2-connection" "live socket to $C2_IP: $(printf '%s' "$out" | head -3 | tr '\n' ';')" | |
| fi | |
| fi | |
| elif command -v ss >/dev/null 2>&1; then | |
| out="$(ss -tunap 2>/dev/null)" | |
| rc=$? | |
| if [ "$rc" -ne 0 ]; then | |
| finding SKIP "c2-connection" "ss could not be queried (exit $rc)" | |
| else | |
| c2_ran=1 | |
| out="$(printf '%s' "$out" | grep "$C2_IP")" | |
| if [ -n "$out" ]; then | |
| found=1 | |
| finding CONFIRMED "c2-connection" "ss shows $C2_IP: $(printf '%s' "$out" | head -3 | tr '\n' ';')" | |
| fi | |
| fi | |
| elif command -v netstat >/dev/null 2>&1; then | |
| out="$(netstat -an 2>/dev/null)" | |
| rc=$? | |
| if [ "$rc" -ne 0 ]; then | |
| finding SKIP "c2-connection" "netstat could not be queried (exit $rc)" | |
| else | |
| c2_ran=1 | |
| out="$(printf '%s' "$out" | grep "$C2_IP")" | |
| if [ -n "$out" ]; then | |
| found=1 | |
| finding CONFIRMED "c2-connection" "netstat shows $C2_IP: $(printf '%s' "$out" | head -3 | tr '\n' ';')" | |
| fi | |
| fi | |
| elif [ -r /proc/net/tcp ] || [ -r /proc/net/tcp6 ]; then | |
| c2_ran=1 | |
| out="$(grep -hE "$(c2_socket_pattern)" /proc/net/tcp /proc/net/tcp6 2>/dev/null | head -3)" | |
| if [ -n "$out" ]; then | |
| found=1 | |
| finding CONFIRMED "c2-connection" "/proc/net shows a socket for $C2_IP: $(printf '%s' "$out" | tr '\n' ';')" | |
| fi | |
| else | |
| finding SKIP "c2-connection" "no lsof, ss or netstat, and no readable /proc/net/tcp -- cannot check live connections" | |
| fi | |
| # shellcheck disable=SC2009 # we want the full argv in the report, not just the pid | |
| out="$(ps axo pid,args 2>/dev/null \ | |
| | grep -E "$DROPPER_NAME|rust-crate_0\." \ | |
| | ps_self_filter)" | |
| if [ -n "$out" ]; then | |
| found=1 | |
| finding CONFIRMED "malicious-process" "$(printf '%s' "$out" | head -5 | tr '\n' ';')" | |
| fi | |
| if [ "$found" -eq 0 ]; then | |
| if [ "$c2_ran" -eq 1 ]; then | |
| finding OK "live-activity" "no connections to $C2_IP, no payload processes running$(priv_scope_note)" | |
| else | |
| finding OK "live-activity" "no payload processes running (connection check skipped)$(priv_scope_note)" | |
| fi | |
| fi | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # I. Static IOC string sweep in startup files. | |
| # -------------------------------------------------------------------------------------- | |
| check_static_strings() { | |
| section "I. Startup files and cron" | |
| local found=0 f out | |
| for f in "$HOME/.zshrc" "$HOME/.zshenv" "$HOME/.zprofile" "$HOME/.zlogin" \ | |
| "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$HOME/.bash_login" \ | |
| "$HOME/.xprofile" "$HOME/.xinitrc" "$HOME/.config/fish/config.fish" \ | |
| /etc/profile /etc/bash.bashrc /etc/zsh/zshrc; do | |
| [ -f "$f" ] || continue | |
| if grep -qE "$C2_IP|$DROPPER_NAME|rust-crate_0\." "$f" 2>/dev/null; then | |
| found=1 | |
| finding CONFIRMED "startup-file" "$f references a known IOC" | |
| fi | |
| done | |
| if [ -d /etc/profile.d ]; then | |
| while IFS= read -r f; do | |
| [ -n "$f" ] || continue | |
| if grep -qE "$C2_IP|$DROPPER_NAME|rust-crate_0\." "$f" 2>/dev/null; then | |
| found=1 | |
| finding CONFIRMED "startup-file" "$f references a known IOC" | |
| fi | |
| done <<EOF | |
| $("$FIND" /etc/profile.d -maxdepth 1 -type f -print 2>/dev/null) | |
| EOF | |
| fi | |
| if [ -d "$HOME/.config" ]; then | |
| while IFS= read -r f; do | |
| [ -n "$f" ] || continue | |
| found=1 | |
| finding CONFIRMED "config-file" "$f references $C2_IP" | |
| done <<EOF | |
| $(grep -rlF "$C2_IP" "$HOME/.config" 2>/dev/null) | |
| EOF | |
| fi | |
| out="$(crontab -l 2>/dev/null | grep -E "$C2_IP|$DROPPER_NAME|rust-crate_0\.")" | |
| if [ -n "$out" ]; then | |
| found=1 | |
| finding CONFIRMED "crontab" "$(printf '%s' "$out" | tr '\n' ';')" | |
| fi | |
| [ "$found" -eq 0 ] && finding OK "startup-files" "no IOC strings in shell startup files or cron" | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # J. Exposure-window heuristic -- was cargo even used while the crates were live? | |
| # -------------------------------------------------------------------------------------- | |
| check_window() { | |
| section "J. Exposure window heuristic (informational)" | |
| local ch hits total=0 | |
| while IFS= read -r ch; do | |
| [ -n "$ch" ] || continue | |
| [ -d "$ch/registry" ] || continue | |
| hits="$("$FIND" "$ch/registry/cache" "$ch/registry/src" -maxdepth 3 \ | |
| -newermt "$WINDOW_START" ! -newermt "$WINDOW_END" -print 2>/dev/null | wc -l | tr -d ' ')" | |
| if [ "${hits:-0}" -gt 0 ]; then | |
| total=$((total + hits)) | |
| finding INFO "attack-window" "$ch: $hits registry entries touched during $WINDOW_START - $WINDOW_END" | |
| fi | |
| done <<EOF | |
| $(cargo_homes) | |
| EOF | |
| if [ "$total" -eq 0 ]; then | |
| finding INFO "attack-window" "no cargo registry activity in the window -- weak evidence only, mtimes are mutable and mirrors can serve the crates later" | |
| else | |
| finding INFO "attack-window" "cargo was active during the window; treat checks A-E as the deciding evidence" | |
| fi | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # K. Which credential stores would have been in scope. | |
| # -------------------------------------------------------------------------------------- | |
| check_credential_scope() { | |
| [ "$N_CONFIRMED" -gt 0 ] || return 0 | |
| section "K. Credential stores in scope (stage-2 targets Chromium logins + wallet extensions)" | |
| local base p | |
| base="$HOME/Library/Application Support" | |
| for p in "$base/Google/Chrome" "$base/BraveSoftware/Brave-Browser" "$base/Microsoft Edge" \ | |
| "$HOME/.config/google-chrome" "$HOME/.config/chromium" \ | |
| "$HOME/.config/BraveSoftware/Brave-Browser" "$HOME/.config/microsoft-edge" \ | |
| "$HOME/.var/app" "$HOME/snap"; do | |
| [ -d "$p" ] && finding INFO "credential-scope" "$p present -- treat every password saved here as stolen" | |
| done | |
| for p in "$HOME/.ssh" "$HOME/.aws" "$HOME/.config/gh" "$CARGO_HOME/credentials.toml" \ | |
| "$HOME/.npmrc" "$HOME/.gnupg" "$HOME/.local/share/keyrings" \ | |
| "$HOME/.docker/config.json" "$HOME/.kube/config" "$HOME/.netrc" \ | |
| "$HOME/.config/gcloud"; do | |
| [ -e "$p" ] && finding INFO "credential-scope" "$p present -- rotate these credentials" | |
| done | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # Optional: log backends -- macOS unified log, Linux journald with a syslog-file fallback. | |
| # -------------------------------------------------------------------------------------- | |
| check_logs() { | |
| [ "$DO_LOGS" -eq 1 ] || return 0 | |
| section "L. System logs (--logs)" | |
| [ "$JSON" -eq 0 ] && printf '%squerying the last 48h of system logs; this can take minutes%s\n' "$C_DIM" "$C_OFF" | |
| # out must be initialized: bash >= 4.4 leaves a value-less `local` unset, and set -u | |
| # turns the first "${out}" read in the syslog fallback into a fatal "unbound variable" | |
| # (bash 3.2 masked this by defaulting to empty). | |
| local raw rc out="" hit backend=0 journalctl_failed=0 f | |
| if [ "$OS" = "Darwin" ] && command -v log >/dev/null 2>&1; then | |
| # process != "log" is load-bearing: log(1) logs its own argv, which contains these very | |
| # IOC strings, so without it this check always reports a hit on itself. | |
| local pred | |
| pred="(eventMessage CONTAINS \"$C2_IP\"" | |
| pred="$pred OR eventMessage CONTAINS \"$DROPPER_NAME\"" | |
| pred="$pred OR eventMessage CONTAINS \"rust-crate_0.\")" | |
| pred="$pred AND process != \"log\" AND process != \"logd\"" | |
| raw="$(log show --last 48h --style compact --predicate "$pred" 2>&1)" | |
| rc=$? | |
| if [ "$rc" -ne 0 ]; then | |
| finding SKIP "unified-log" "log query failed (try sudo): $(printf '%s' "$raw" | head -1)" | |
| return 0 | |
| fi | |
| backend=1 | |
| out="$(printf '%s\n' "$raw" \ | |
| | grep -vE '^(Filtering|Skipping|Timestamp)' \ | |
| | grep -vF 'detect-proc-macro1-attack' \ | |
| | head -20)" | |
| else | |
| # Linux path: journald first, then the classic syslog files when journalctl is | |
| # missing OR fails -- a failed journalctl usually means restricted journal access | |
| # while /var/log/syslog can still be readable. | |
| if command -v journalctl >/dev/null 2>&1; then | |
| raw="$(journalctl --since '48 hours ago' --no-pager 2>/dev/null)" | |
| rc=$? | |
| if [ "$rc" -eq 0 ]; then | |
| backend=1 | |
| out="$(printf '%s\n' "$raw" \ | |
| | grep -E "$C2_IP|$DROPPER_NAME|rust-crate_0\." \ | |
| | grep -vF 'detect-proc-macro1-attack' \ | |
| | head -20)" | |
| else | |
| journalctl_failed=1 | |
| fi | |
| fi | |
| if [ "$backend" -eq 0 ]; then | |
| for f in $SYSLOG_FILES; do | |
| [ -r "$f" ] || continue | |
| backend=1 | |
| hit="$(grep -hE "$C2_IP|$DROPPER_NAME|rust-crate_0\." "$f" 2>/dev/null \ | |
| | grep -vF 'detect-proc-macro1-attack')" | |
| [ -n "$hit" ] && out="${out}${out:+ | |
| }${hit}" | |
| done | |
| out="$(printf '%s' "$out" | head -20)" | |
| fi | |
| fi | |
| if [ "$backend" -eq 0 ]; then | |
| if [ "$journalctl_failed" -eq 1 ]; then | |
| finding SKIP "unified-log" "journalctl query failed and no readable /var/log/{syslog,messages,auth.log} -- try sudo" | |
| else | |
| finding SKIP "unified-log" "no log backend: log(1) on macOS, journalctl or /var/log/{syslog,messages,auth.log} on Linux" | |
| fi | |
| elif [ -n "$out" ]; then | |
| finding CONFIRMED "unified-log" "$(printf '%s' "$out" | head -5 | tr '\n' ';')" | |
| else | |
| finding OK "unified-log" "no log entries mentioning $C2_IP or $DROPPER_NAME in the last 48h" | |
| fi | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # Report | |
| # -------------------------------------------------------------------------------------- | |
| remediation() { | |
| cat <<EOF | |
| ${C_BOLD}What to do now${C_OFF} | |
| Assume every secret reachable from this machine is stolen. Rotate from a DIFFERENT machine: | |
| 1. Passwords saved in Chrome/Brave/Edge, starting with email, banking and cloud accounts. | |
| 2. Crypto wallets held in browser extensions -- move funds, do not just change the password. | |
| 3. crates.io, GitHub, npm and other package-registry tokens. | |
| 4. SSH keys (~/.ssh), GPG keys, and any cloud CLI credentials (~/.aws, ~/.config/gh). | |
| Then, on this machine: | |
| 5. Remove /tmp/$DROPPER_NAME and any $STAGE2_GLOB binary, and remove the persistence entry | |
| flagged above: on macOS launchctl bootout gui/\$UID <label>; on Linux | |
| systemctl --user disable --now <unit> (or delete the cron / autostart entry). | |
| 6. Delete the malicious entries from ~/.cargo/registry/{cache,src} and run 'cargo update' | |
| (or edit the lockfiles) so no project resolves to arrayref 0.3.10, append-only-vec 0.1.9, | |
| internment 0.8.7 or anything named proc-macro1. | |
| 7. Block egress to $C2_IP and review outbound traffic since 2026-08-20. | |
| A full OS reinstall is the only way to be certain -- stage 2 supports arbitrary shell commands. | |
| EOF | |
| } | |
| report() { | |
| if [ "$JSON" -eq 1 ]; then | |
| local verdict="clean" | |
| [ "$N_SUSPECT" -gt 0 ] && verdict="suspect" | |
| [ "$N_CONFIRMED" -gt 0 ] && verdict="confirmed" | |
| printf '{\n "tool": "detect-proc-macro1-attack",\n "version": "%s",\n' "$VERSION" | |
| printf ' "verdict": "%s",\n' "$verdict" | |
| printf ' "confirmed": %d,\n "suspect": %d,\n "info": %d,\n "skipped": %d,\n' \ | |
| "$N_CONFIRMED" "$N_SUSPECT" "$N_INFO" "$N_SKIP" | |
| printf ' "findings": [\n' | |
| printf '%s' "$JSON_ITEMS" | awk 'NF { if (n++) printf ",\n"; printf " %s", $0 } END { if (n) printf "\n" }' | |
| printf ' ]\n}\n' | |
| return 0 | |
| fi | |
| printf '\n%s========================================================%s\n' "$C_BOLD" "$C_OFF" | |
| if [ "$N_CONFIRMED" -gt 0 ]; then | |
| printf '%sVERDICT: COMPROMISED -- %d confirmed indicator(s)%s\n' "$C_RED$C_BOLD" "$N_CONFIRMED" "$C_OFF" | |
| printf '%s' "$CONFIRMED_LIST" | sed 's/^/ - /' | |
| [ "$N_SUSPECT" -gt 0 ] && { printf '\n%d item(s) also need review:\n' "$N_SUSPECT"; printf '%s' "$SUSPECT_LIST" | sed 's/^/ - /'; } | |
| remediation | |
| elif [ "$N_SUSPECT" -gt 0 ]; then | |
| printf '%sVERDICT: NEEDS REVIEW -- %d suspect indicator(s), none confirmed%s\n' "$C_YEL$C_BOLD" "$N_SUSPECT" "$C_OFF" | |
| printf '%s' "$SUSPECT_LIST" | sed 's/^/ - /' | |
| printf '\nNo malicious artifact was found in the cargo cache or on disk, so the payload most\n' | |
| printf 'likely never ran. Fix the items above before your next build -- a lockfile pinning a\n' | |
| printf 'malicious version will fetch and execute it the moment you compile.\n' | |
| else | |
| printf '%sVERDICT: CLEAN -- no indicators found%s\n' "$C_GRN$C_BOLD" "$C_OFF" | |
| printf 'No malicious crate archives, sources, build artifacts, dropper files, persistence\n' | |
| printf 'entries or C2 traffic were detected.\n' | |
| fi | |
| [ "$N_SKIP" -gt 0 ] && printf '\n%s%d check(s) were skipped -- a skipped check is not a passed check.%s\n' "$C_DIM" "$N_SKIP" "$C_OFF" | |
| printf '%s========================================================%s\n' "$C_BOLD" "$C_OFF" | |
| return 0 | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # main | |
| # -------------------------------------------------------------------------------------- | |
| if [ "$JSON" -eq 0 ]; then | |
| printf '%sdetect-proc-macro1-attack %s (%s)%s\n' "$C_BOLD" "$VERSION" "$OS" "$C_OFF" | |
| printf 'crates.io supply-chain attack of 2026-08-20 (arrayref / append-only-vec / internment -> proc-macro1)\n' | |
| printf '%sCARGO_HOME=%s scan root=%s deep=%s logs=%s%s\n' "$C_DIM" "$CARGO_HOME" "$SCAN_ROOT" "$DEEP" "$DO_LOGS" "$C_OFF" | |
| fi | |
| check_registry_cache | |
| check_extracted_sources | |
| check_index_cache | |
| if [ "$DEEP" -eq 1 ]; then | |
| check_filesystem "/" | |
| else | |
| check_filesystem "$SCAN_ROOT" | |
| fi | |
| check_dropper | |
| check_persistence | |
| check_live | |
| check_static_strings | |
| check_window | |
| check_logs | |
| check_credential_scope | |
| report | |
| if [ "$N_CONFIRMED" -gt 0 ]; then | |
| exit 2 | |
| elif [ "$N_SUSPECT" -gt 0 ]; then | |
| exit 1 | |
| fi | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment