Skip to content

Instantly share code, notes, and snippets.

@tralamazza
Created August 21, 2026 01:29
Show Gist options
  • Select an option

  • Save tralamazza/64f3bfefdb30350c7f2184c4067c82d4 to your computer and use it in GitHub Desktop.

Select an option

Save tralamazza/64f3bfefdb30350c7f2184c4067c82d4 to your computer and use it in GitHub Desktop.
#!/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