Created
June 27, 2026 15:45
-
-
Save mmastrac/3d70d09e5b0dabd6004b701ad7bdcba0 to your computer and use it in GitHub Desktop.
PinPinRAT C2 implant IoC checker
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 | |
| # check_ioc.sh — PinPinRAT C2 implant IoC checker | |
| # Read-only: prints findings, changes nothing. | |
| # Run with: bash check_ioc.sh | |
| # Requires bash (not sh). Works on macOS and Linux. | |
| set -euo pipefail | |
| RED='\033[0;31m'; YEL='\033[0;33m'; GRN='\033[0;32m'; BLD='\033[1m'; RST='\033[0m' | |
| # HITS is written from subshells via a temp file so counts survive pipe boundaries. | |
| HITFILE="$(mktemp)" | |
| trap 'rm -f "$HITFILE"' EXIT | |
| hit() { | |
| echo -e " ${RED}[HIT]${RST} $*" | |
| echo "x" >> "$HITFILE" # one byte per hit; counted in summary | |
| } | |
| info() { echo -e " ${YEL}[INFO]${RST} $*"; } | |
| ok() { echo -e " ${GRN}[OK]${RST} $*"; } | |
| sep() { echo -e "\n${BLD}── $* ──${RST}"; } | |
| OS="$(uname -s)" # Darwin or Linux | |
| TMPD="${TMPDIR:-/tmp}" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "1 · Filesystem — runtime artifact directories" | |
| CACHE_DIRS=() | |
| if [ "$OS" = "Darwin" ]; then | |
| CACHE_ROOT="$HOME/Library/Caches/runtime-cache" | |
| if [ -d "$CACHE_ROOT" ]; then | |
| info "runtime-cache dir exists: $CACHE_ROOT" | |
| while IFS= read -r d; do | |
| CACHE_DIRS+=("$d") | |
| hit "Suspect cache dir: $d" | |
| done < <(find "$CACHE_ROOT" -maxdepth 1 -type d -name '.cache-*' 2>/dev/null) | |
| else | |
| ok "~/Library/Caches/runtime-cache not found" | |
| fi | |
| fi | |
| while IFS= read -r d; do | |
| CACHE_DIRS+=("$d") | |
| hit "Suspect tmpdir cache dir: $d" | |
| done < <(find "$TMPD" -maxdepth 1 -type d -name '.cache-*' 2>/dev/null) | |
| [ ${#CACHE_DIRS[@]} -eq 0 ] && ok "No .cache-* dirs found under expected locations" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "2 · Filesystem — payload and mutex scripts" | |
| for base in "${CACHE_DIRS[@]+"${CACHE_DIRS[@]}"}"; do | |
| for f in payload.js mutex.js; do | |
| fp="$base/$f" | |
| if [ -f "$fp" ]; then | |
| hit "Implant file: $fp ($(wc -c < "$fp" | tr -d ' ') bytes)" | |
| fi | |
| done | |
| done | |
| # Belt-and-suspenders: wider search under HOME in case paths differ | |
| while IFS= read -r f; do | |
| hit "Implant file (HOME search): $f" | |
| done < <(find "$HOME" -maxdepth 6 -type f \ | |
| \( -name 'payload.js' -o -name 'mutex.js' \) \ | |
| -path '*/.cache-*' 2>/dev/null) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "3 · Filesystem — poisoned typescript.js in any node_modules" | |
| info "Searching HOME, /tmp, /var — may take a few seconds on large disks" | |
| _check_ts() { | |
| local f="$1" | |
| if grep -qF '12ff4b51' "$f" 2>/dev/null || \ | |
| grep -qF 'ticket-harbor-tsc-shim-anchor' "$f" 2>/dev/null; then | |
| hit "Patched typescript file: $f" | |
| fi | |
| } | |
| while IFS= read -r f; do _check_ts "$f"; done < <( | |
| find "$HOME" /tmp 2>/dev/null \ | |
| -maxdepth 10 -type f -name 'typescript.js' \ | |
| -path '*/typescript/lib/*' 2>/dev/null | |
| ) | |
| # /var searched separately; skipped on macOS where it rarely contains npm installs | |
| if [ "$OS" = "Linux" ]; then | |
| while IFS= read -r f; do _check_ts "$f"; done < <( | |
| find /var 2>/dev/null \ | |
| -maxdepth 10 -type f -name 'typescript.js' \ | |
| -path '*/typescript/lib/*' 2>/dev/null | |
| ) | |
| fi | |
| # Also check current working directory tree | |
| while IFS= read -r f; do _check_ts "$f"; done < <( | |
| find . -maxdepth 8 -type f -name 'typescript.js' \ | |
| -path '*/typescript/lib/*' 2>/dev/null | |
| ) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "4 · Persistence — crontab" | |
| if command -v crontab &>/dev/null; then | |
| CRON="$(crontab -l 2>/dev/null || true)" | |
| if [ -n "$CRON" ]; then | |
| while IFS= read -r line; do | |
| case "$line" in | |
| *'.cache-'*|*'NODT_PAYLOAD'*|*'mutex.js'*) | |
| hit "Crontab entry: $line" ;; | |
| esac | |
| done <<< "$CRON" | |
| echo "$CRON" | grep -qE '\.cache-|NODT_PAYLOAD|mutex\.js' || \ | |
| ok "No suspect crontab entries" | |
| else | |
| ok "No crontab installed" | |
| fi | |
| else | |
| info "crontab not available" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "5 · Persistence — Windows scheduled task (via schtasks if available)" | |
| if command -v schtasks.exe &>/dev/null 2>&1; then | |
| if schtasks.exe /query /tn "PinpinWrappedJs" &>/dev/null 2>&1; then | |
| hit "Scheduled task exists: PinpinWrappedJs" | |
| else | |
| ok "Scheduled task PinpinWrappedJs not found" | |
| fi | |
| else | |
| info "schtasks not available (not Windows or not in PATH)" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "6 · Running processes — Node masquerading as WebKit (macOS)" | |
| if [ "$OS" = "Darwin" ]; then | |
| FOUND_MASQ=0 | |
| while IFS= read -r line; do | |
| # Legitimate WebKit helper processes always have --type= in their args. | |
| # A Node process using this argv0 as a disguise will not. | |
| if ! echo "$line" | grep -q -- '--type='; then | |
| hit "Suspect Node masquerade (no --type= flag): $line" | |
| FOUND_MASQ=1 | |
| else | |
| info "Legitimate WebKit process: $(echo "$line" | awk '{print $1, $2}')" | |
| fi | |
| done < <(ps -axo pid,comm,args 2>/dev/null | \ | |
| grep 'com\.apple\.WebKit\.Networking' | grep -v grep || true) | |
| [ "$FOUND_MASQ" -eq 0 ] && ok "No suspect WebKit masquerades found" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "7 · Running processes — NODT_PAYLOAD env var in any process" | |
| if [ "$OS" = "Linux" ] && [ -d /proc ]; then | |
| # nullglob prevents iterating the literal glob string if /proc is empty | |
| shopt -s nullglob | |
| FOUND_ENV=0 | |
| for pid_dir in /proc/[0-9]*/environ; do | |
| pid="${pid_dir//[^0-9]/}" | |
| if grep -qz 'NODT_PAYLOAD' "$pid_dir" 2>/dev/null; then | |
| COMM="$(cat "/proc/$pid/comm" 2>/dev/null || echo '?')" | |
| hit "Process $pid ($COMM) has NODT_PAYLOAD env var" | |
| FOUND_ENV=1 | |
| fi | |
| done | |
| shopt -u nullglob | |
| [ "$FOUND_ENV" -eq 0 ] && ok "No process has NODT_PAYLOAD env var" | |
| elif [ "$OS" = "Darwin" ]; then | |
| # ps -axE prints each process with its full environment on the same line. | |
| # Only our own user's processes are visible without sudo. | |
| FOUND_ENV=0 | |
| while IFS= read -r line; do | |
| hit "Process with NODT_PAYLOAD in environment: $(echo "$line" | awk '{print $1, $2}')" | |
| FOUND_ENV=1 | |
| done < <(ps -axE 2>/dev/null | grep 'NODT_PAYLOAD' | grep -v grep || true) | |
| [ "$FOUND_ENV" -eq 0 ] && ok "No process has NODT_PAYLOAD env var" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "8 · Running processes — Node processes with files open in .cache- dirs" | |
| if command -v lsof &>/dev/null; then | |
| info "Running lsof — may take a few seconds on a busy machine" | |
| FOUND_LSOF=0 | |
| while IFS= read -r line; do | |
| hit "lsof: node has file open in suspect dir: $line" | |
| FOUND_LSOF=1 | |
| done < <(lsof -n -c node 2>/dev/null | grep '\.cache-' || true) | |
| [ "$FOUND_LSOF" -eq 0 ] && ok "No node process has files open in .cache- dirs" | |
| else | |
| info "lsof not available — skipping open-file check" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "9 · Network — active connections to C2 (89.124.107.161)" | |
| C2_IP="89.124.107.161" | |
| FOUND_NET=0 | |
| if command -v lsof &>/dev/null; then | |
| while IFS= read -r line; do | |
| hit "lsof: active connection to C2: $line" | |
| FOUND_NET=1 | |
| done < <(lsof -n -i "TCP@${C2_IP}:80" 2>/dev/null | grep -v '^COMMAND' || true) | |
| fi | |
| if command -v netstat &>/dev/null; then | |
| while IFS= read -r line; do | |
| hit "netstat: connection involving C2 IP: $line" | |
| FOUND_NET=1 | |
| done < <(netstat -an 2>/dev/null | grep "$C2_IP" || true) | |
| fi | |
| if command -v ss &>/dev/null; then | |
| while IFS= read -r line; do | |
| hit "ss: connection involving C2 IP: $line" | |
| FOUND_NET=1 | |
| done < <(ss -tn 2>/dev/null | grep "$C2_IP" || true) | |
| fi | |
| [ "$FOUND_NET" -eq 0 ] && ok "No active connections to C2 IP detected" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "10 · Network — system log references to C2 IP (macOS, last 24h)" | |
| if [ "$OS" = "Darwin" ] && command -v log &>/dev/null; then | |
| info "Scanning unified system log — this can take 30-60 seconds, please wait" | |
| COUNT="$(log show --last 24h \ | |
| --predicate "eventMessage CONTAINS '89.124.107.161'" \ | |
| 2>/dev/null | grep -c '89.124.107.161' || true)" | |
| if [ "${COUNT:-0}" -gt 0 ]; then | |
| hit "System log: $COUNT entries referencing C2 IP in the last 24h" | |
| else | |
| ok "No C2 IP references in system log (last 24h)" | |
| fi | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| sep "Summary" | |
| HITS="$(wc -l < "$HITFILE" | tr -d ' ')" | |
| if [ "${HITS:-0}" -gt 0 ]; then | |
| echo -e "\n${RED}${BLD}${HITS} indicator(s) found. See [HIT] lines above.${RST}" | |
| echo -e "${YEL}Do not remove anything — preserve for forensics first.${RST}" | |
| echo -e "Refer to the readout PDF for remediation steps.\n" | |
| else | |
| echo -e "\n${GRN}${BLD}No IoCs found on this machine.${RST}\n" | |
| fi | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment