Skip to content

Instantly share code, notes, and snippets.

@joe-desimone
Last active April 16, 2026 05:01
Show Gist options
  • Select an option

  • Save joe-desimone/36061dabd2bc2513705e0d083a9673e7 to your computer and use it in GitHub Desktop.

Select an option

Save joe-desimone/36061dabd2bc2513705e0d083a9673e7 to your computer and use it in GitHub Desktop.

Supply-Chain Compromise of axios npm Package

Date: 2026-03-31 Severity: Critical Status: Active compromise Discovered by: Elastic Security researchers


Summary

An npm maintainer account (jasonsaayman) on the widely used axios HTTP client package has been compromised. The attacker published two malicious versions of axios and a new typosquat package, plain-crypto-js, which serves as the payload delivery vehicle. At the time of discovery, both the latest and legacy dist-tags pointed to compromised versions, meaning default npm install axios resolved to a backdoored package.

The malicious dependency plain-crypto-js contains an obfuscated postinstall script that downloads and executes platform-specific stage-2 payloads from an external C2 server on macOS, Windows, and Linux. The payload self-deletes and overwrites its own package.json to evade post-incident forensic detection.

Who Is Affected

Any system that ran npm install (or equivalent) resolving axios@1.14.1 or axios@0.30.4 after 2026-03-31T00:21:58Z may have executed the stage-2 payload. This includes:

  • Developer workstations
  • CI/CD pipelines
  • Production deployments pulling fresh installs
  • Any project with axios as a direct or transitive dependency without a lockfile pinning to a prior safe version

Affected Packages

Package Version Status
axios 1.14.1 Malicious — tagged latest at time of discovery
axios 0.30.4 Malicious — tagged legacy at time of discovery
plain-crypto-js 4.2.0, 4.2.1 Malicious — payload delivery vehicle (postinstall backdoor)

Safe Versions

Package Version Notes
axios 1.14.0 Last legitimate 1.x release — published via GitHub Actions OIDC with SLSA provenance
axios 0.30.3 Last legitimate 0.30.x release

Recommended Actions for Security Teams

  1. Check your lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml) for axios@1.14.1, axios@0.30.4, or any version of plain-crypto-js. If present, treat the system as potentially compromised.
  2. Pin to safe versions: Ensure axios resolves to 1.14.0 or earlier known-good versions.
  3. Hunt for IOCs (see Indicators of Compromise below) across developer machines and CI/CD infrastructure.
  4. Rotate credentials on any system where the compromised package was installed — the stage-2 payload may have exfiltrated secrets, tokens, or keys.
  5. Block the C2 domain sfrclak.com at your network perimeter.

Indicators of Compromise

Network

Type Value
C2 server http://sfrclak.com:8000/
Campaign ID 6202033
Full C2 URL http://sfrclak.com:8000/6202033

Filesystem — macOS

Indicator Path
Stage-2 binary (disguised as Apple daemon) /Library/Caches/com.apple.act.mond

Filesystem — Windows

Indicator Path
Renamed PowerShell copy %PROGRAMDATA%\wt.exe
Transient VBScript loader %TEMP%\6202033.vbs
Transient PowerShell payload %TEMP%\6202033.ps1

Filesystem — Linux

Indicator Path
Stage-2 Python script /tmp/ld.py

npm Account

Indicator Value
Compromised account jasonsaayman
Attacker email ifstap@proton.me
Legitimate email (prior to compromise) jasonsaayman@gmail.com

Evidence of Account Compromise

The jasonsaayman maintainer email changed from jasonsaayman@gmail.com (present on all prior legitimate releases) to ifstap@proton.me on the malicious versions.

axios@1.14.0 (legitimate):

  • Published via GitHub Actions OIDC trusted publisher with SLSA provenance attestations
  • Dependencies: form-data, proxy-from-env, follow-redirects

axios@1.14.1 (compromised):

  • Published via direct CLI publish — no OIDC, no provenance attestations
  • Only change from 1.14.0: addition of plain-crypto-js@^4.2.1 to dependencies

axios@0.30.4 (compromised):

  • Published ~39 minutes after 1.14.1
  • Only change from the legitimate 0.30.3: version bump and addition of plain-crypto-js@^4.2.1 to dependencies (all other files are byte-identical to 0.30.3)

Technical Analysis of the Payload

Delivery Chain

  1. Installing axios@1.14.1 or axios@0.30.4 pulls plain-crypto-js@^4.2.1 as a dependency
  2. plain-crypto-js declares "postinstall": "node setup.js" in its package.json — this executes automatically on npm install
  3. setup.js is an obfuscated multi-platform backdoor installer

Obfuscation

The setup.js file uses a two-layer encoding scheme:

  • Layer 1: String reversal + base64 decoding
  • Layer 2: XOR cipher using the key "OrDeR_7077" with a position-dependent index (7 * i² % 10)

All critical strings (module names, URLs, shell commands) are stored in an encoded array stq[] and decoded at runtime.

Platform-Specific Behavior

macOS (darwin):

  • Writes an AppleScript to $TMPDIR/<campaign_id>
  • The AppleScript downloads a binary from the C2 via curl to /Library/Caches/com.apple.act.mond
  • Disguises the binary as an Apple system daemon
  • Sets permissions (chmod 770) and executes in background via /bin/zsh
  • Runs through osascript for process-tree evasion

Windows (win32):

  • Locates PowerShell via where powershell
  • Copies it to %PROGRAMDATA%\wt.exe (masquerading as Windows Terminal)
  • Writes a VBScript (.vbs) to %TEMP% that:
    • Downloads a .ps1 payload from the C2 via curl
    • Executes it hidden: wt.exe -w hidden -ep bypass -file <payload>.ps1
    • Deletes the .ps1 after execution
  • Runs the VBScript silently via cscript //nologo, then deletes it

Linux (fallback):

  • Downloads a Python script to /tmp/ld.py via curl
  • Executes python3 /tmp/ld.py in background via nohup

Anti-Forensics

The payload performs cleanup to hinder incident response:

  • Self-deletion: setup.js removes itself (fs.unlink(__filename))
  • package.json swap: A clean package.md (version 4.2.0, no postinstall hook) is renamed to package.json, overwriting the malicious version. Post-incident inspection of node_modules/plain-crypto-js/package.json will show no trace of the postinstall trigger.

Decoded String Table

stq[0]:  "child_process"
stq[1]:  "os"
stq[2]:  "fs"
stq[3]:  "http://sfrclak.com:8000/"
stq[5]:  "win32"
stq[6]:  "darwin"
stq[7]:  VBScript dropper (Windows)
stq[8]:  "cscript \"LOCAL_PATH\" //nologo && del \"LOCAL_PATH\" /f"
stq[9]:  AppleScript dropper (macOS)
stq[10]: "nohup osascript \"LOCAL_PATH\" > /dev/null 2>&1 &"
stq[12]: curl + python3 dropper (Linux)
stq[13]: "package.json"
stq[14]: "package.md"
stq[15]: ".exe"
stq[16]: ".ps1"
stq[17]: ".vbs"

Impact

axios is one of the most depended-upon packages in the npm ecosystem, with approximately 80 million weekly downloads. The compromised 1.14.1 was tagged as latest, and 0.30.4 was tagged as legacy. Any environment performing a fresh npm install of axios without a lockfile pinning a specific prior version would have pulled the backdoored release.

Timeline (UTC)

Time Event
2026-02-18T17:19:20Z axios@0.30.3 published legitimately by jasonsaayman@gmail.com
2026-03-27T19:01:40Z axios@1.14.0 published legitimately via GitHub Actions OIDC
2026-03-31 plain-crypto-js@4.2.0 and 4.2.1 published (brand-new package, attacker-controlled)
2026-03-31T00:21:58Z axios@1.14.1 published by compromised account; becomes latest
2026-03-31T01:00:57Z axios@0.30.4 published by compromised account; tagged legacy
2026-03-31 Discovered by Elastic Security researchers via automated supply-chain monitoring

Dist-Tags at Time of Discovery

latest:      1.14.1       (COMPROMISED)
legacy:      0.30.4       (COMPROMISED)
old-version: 0.30.0       (legitimate)
next:        1.7.0-beta.2 (legitimate)

References

@strix-security
Copy link
Copy Markdown

Thanks for your awesome work on this -- we are looking into it as well and the payload package was published by nrwise@proton.me, so there are at least 2 accounts involved here. Will share more as our agent digs deeper

@luiyongsheng
Copy link
Copy Markdown

luiyongsheng commented Mar 31, 2026

[UPDATE] False positive fix

Quick scanner to check whether your device is compromised

#!/usr/bin/env bash
# ============================================================================
# Axios Supply-Chain Compromise Scanner
# Date: 2026-03-31
# Based on: https://gist.github.com/joe-desimone/36061dabd2bc2913705e0d083a9673e7
#
# Scans for:
#   1. Compromised axios versions (1.14.1, 0.30.4) in lockfiles & node_modules
#   2. Malicious package "plain-crypto-js" anywhere on disk
#   3. Platform-specific stage-2 payload IOCs (filesystem)
#   4. Active C2 connections to sfrclak.com
#   5. Global npm/yarn/pnpm packages
#   6. npm cache contamination
#
# Usage:
#   chmod +x scan_axios_compromise.sh
#   ./scan_axios_compromise.sh                    # scan common paths
#   ./scan_axios_compromise.sh /path/to/projects  # scan specific directory
# ============================================================================

set -euo pipefail

RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m' # No Color

FOUND_ISSUES=0

banner() {
  echo ""
  echo -e "${BOLD}========================================${NC}"
  echo -e "${BOLD}  Axios Compromise Scanner (2026-03-31)${NC}"
  echo -e "${BOLD}========================================${NC}"
  echo ""
}

section() {
  echo ""
  echo -e "${CYAN}[*] $1${NC}"
  echo -e "${CYAN}$(printf '%.0s-' {1..50})${NC}"
}

found() {
  echo -e "${RED}[!!!] FOUND: $1${NC}"
  FOUND_ISSUES=$((FOUND_ISSUES + 1))
}

warn() {
  echo -e "${YELLOW}[!] WARNING: $1${NC}"
}

safe() {
  echo -e "${GREEN}[✓] $1${NC}"
}

info() {
  echo -e "    $1"
}

# Determine scan root
SCAN_ROOT="${1:-$HOME}"
OS="$(uname -s)"

banner
echo "Platform detected: $OS"
echo "Scan root:         $SCAN_ROOT"
echo "Date:              $(date -u '+%Y-%m-%dT%H:%M:%SZ')"

# ============================================================================
# 1. FILESYSTEM IOCs — Stage-2 Payloads
# ============================================================================
section "Checking for stage-2 payload IOCs on disk"

# macOS
if [[ "$OS" == "Darwin" ]]; then
  if [[ -f "/Library/Caches/com.apple.act.mond" ]]; then
    found "macOS stage-2 binary at /Library/Caches/com.apple.act.mond"
    ls -la "/Library/Caches/com.apple.act.mond" 2>/dev/null || true
  else
    safe "No macOS stage-2 binary found"
  fi
fi

# Linux
if [[ "$OS" == "Linux" ]]; then
  if [[ -f "/tmp/ld.py" ]]; then
    found "Linux stage-2 payload at /tmp/ld.py"
    ls -la "/tmp/ld.py" 2>/dev/null || true
    echo "    First 5 lines:"
    head -5 "/tmp/ld.py" 2>/dev/null | sed 's/^/    /' || true
  else
    safe "No Linux stage-2 payload (/tmp/ld.py) found"
  fi
fi

# Windows (WSL / Git Bash / MSYS)
if [[ -d "/mnt/c" ]] || [[ "$OS" == "MINGW"* ]] || [[ "$OS" == "MSYS"* ]]; then
  PROGRAMDATA="${PROGRAMDATA:-/mnt/c/ProgramData}"
  TEMP_DIR="${TEMP:-${LOCALAPPDATA:-/mnt/c/Users/$USER/AppData/Local}/Temp}"

  for f in "$PROGRAMDATA/wt.exe" "$TEMP_DIR/6202033.vbs" "$TEMP_DIR/6202033.ps1"; do
    if [[ -f "$f" ]]; then
      found "Windows IOC found: $f"
    fi
  done
  safe "Windows IOC check complete"
fi

# ============================================================================
# 2. NETWORK — Active C2 Connections
# ============================================================================
section "Checking for active connections to C2 (sfrclak.com)"

C2_FOUND=0
if command -v ss &>/dev/null; then
  if ss -tunap 2>/dev/null | grep -qi "sfrclak"; then
    found "Active connection to sfrclak.com detected!"
    ss -tunap 2>/dev/null | grep -i "sfrclak" | sed 's/^/    /'
    C2_FOUND=1
  fi
elif command -v netstat &>/dev/null; then
  if netstat -an 2>/dev/null | grep -qi "sfrclak"; then
    found "Active connection to sfrclak.com detected!"
    netstat -an 2>/dev/null | grep -i "sfrclak" | sed 's/^/    /'
    C2_FOUND=1
  fi
elif command -v lsof &>/dev/null; then
  if lsof -i -n 2>/dev/null | grep -qi "sfrclak"; then
    found "Active connection to sfrclak.com detected!"
    lsof -i -n 2>/dev/null | grep -i "sfrclak" | sed 's/^/    /'
    C2_FOUND=1
  fi
fi

if [[ $C2_FOUND -eq 0 ]]; then
  safe "No active C2 connections detected"
fi

# Also check DNS cache / hosts if possible
if command -v dig &>/dev/null; then
  C2_IP=$(dig +short sfrclak.com 2>/dev/null || true)
  if [[ -n "$C2_IP" ]]; then
    info "C2 domain sfrclak.com resolves to: $C2_IP"
    info "Check firewall/DNS logs for connections to this IP"
  fi
fi

# ============================================================================
# 3. GLOBAL NPM / YARN / PNPM PACKAGES
# ============================================================================
section "Checking globally installed npm packages"

check_global_package() {
  local pkg="$1"
  local manager="$2"
  local list_output="$3"

  if echo "$list_output" | grep -q "$pkg"; then
    found "$pkg found in global $manager packages!"
    echo "$list_output" | grep "$pkg" | sed 's/^/    /'
  fi
}

# npm global
if command -v npm &>/dev/null; then
  info "Scanning npm global packages..."
  NPM_GLOBAL=$(npm list -g --depth=0 2>/dev/null || true)
  check_global_package "axios@1.14.1" "npm" "$NPM_GLOBAL"
  check_global_package "axios@0.30.4" "npm" "$NPM_GLOBAL"
  check_global_package "plain-crypto-js" "npm" "$NPM_GLOBAL"

  # Also check all global with deep dependencies
  NPM_GLOBAL_DEEP=$(npm list -g --all 2>/dev/null || true)
  if echo "$NPM_GLOBAL_DEEP" | grep -q "plain-crypto-js"; then
    found "plain-crypto-js found as a transitive global npm dependency!"
    echo "$NPM_GLOBAL_DEEP" | grep "plain-crypto-js" | sed 's/^/    /'
  fi

  # Check npm global root for the actual files
  NPM_GLOBAL_ROOT=$(npm root -g 2>/dev/null || true)
  if [[ -n "$NPM_GLOBAL_ROOT" ]]; then
    if [[ -d "$NPM_GLOBAL_ROOT/plain-crypto-js" ]]; then
      found "plain-crypto-js directory exists at $NPM_GLOBAL_ROOT/plain-crypto-js"
    fi
    if [[ -d "$NPM_GLOBAL_ROOT/axios" ]]; then
      GLOBAL_AXIOS_VER=$(node -p "require('$NPM_GLOBAL_ROOT/axios/package.json').version" 2>/dev/null || echo "unknown")
      if [[ "$GLOBAL_AXIOS_VER" == "1.14.1" || "$GLOBAL_AXIOS_VER" == "0.30.4" ]]; then
        found "Compromised global axios version: $GLOBAL_AXIOS_VER"
      else
        safe "Global axios version $GLOBAL_AXIOS_VER (not compromised)"
      fi
    fi
  fi
else
  warn "npm not found, skipping npm global scan"
fi

# yarn global
if command -v yarn &>/dev/null; then
  info "Scanning yarn global packages..."
  YARN_GLOBAL=$(yarn global list 2>/dev/null || true)
  check_global_package "axios@1.14.1" "yarn" "$YARN_GLOBAL"
  check_global_package "axios@0.30.4" "yarn" "$YARN_GLOBAL"
  check_global_package "plain-crypto-js" "yarn" "$YARN_GLOBAL"
fi

# pnpm global
if command -v pnpm &>/dev/null; then
  info "Scanning pnpm global packages..."
  PNPM_GLOBAL=$(pnpm list -g 2>/dev/null || true)
  check_global_package "axios@1.14.1" "pnpm" "$PNPM_GLOBAL"
  check_global_package "axios@0.30.4" "pnpm" "$PNPM_GLOBAL"
  check_global_package "plain-crypto-js" "pnpm" "$PNPM_GLOBAL"
fi

# bun global
if command -v bun &>/dev/null; then
  info "Scanning bun global packages..."
  BUN_GLOBAL_ROOT="$HOME/.bun/install/global/node_modules"
  if [[ -d "$BUN_GLOBAL_ROOT/plain-crypto-js" ]]; then
    found "plain-crypto-js found in bun global packages!"
  fi
  if [[ -d "$BUN_GLOBAL_ROOT/axios" ]]; then
    BUN_AXIOS_VER=$(node -p "require('$BUN_GLOBAL_ROOT/axios/package.json').version" 2>/dev/null || echo "unknown")
    if [[ "$BUN_AXIOS_VER" == "1.14.1" || "$BUN_AXIOS_VER" == "0.30.4" ]]; then
      found "Compromised bun global axios version: $BUN_AXIOS_VER"
    fi
  fi
fi

# ============================================================================
# 4. NPM CACHE
# ============================================================================
section "Checking npm cache for compromised packages"

if command -v npm &>/dev/null; then
  NPM_CACHE_DIR=$(npm config get cache 2>/dev/null || echo "$HOME/.npm")

  # Check _cacache for plain-crypto-js entries
  if [[ -d "$NPM_CACHE_DIR/_cacache" ]]; then
    CACHE_HITS=$(find "$NPM_CACHE_DIR/_cacache" -name "*.json" -exec grep -l "plain-crypto-js" {} \; 2>/dev/null | head -20 || true)
    if [[ -n "$CACHE_HITS" ]]; then
      warn "plain-crypto-js found in npm cache (may indicate past install):"
      echo "$CACHE_HITS" | sed 's/^/    /'
      info "Run: npm cache clean --force"
    else
      safe "npm cache clean of plain-crypto-js"
    fi

    CACHE_AXIOS=$(find "$NPM_CACHE_DIR/_cacache" -name "*.json" -exec grep -l '"axios","version":"1.14.1"\|"axios","version":"0.30.4"' {} \; 2>/dev/null | head -20 || true)
    if [[ -n "$CACHE_AXIOS" ]]; then
      warn "Compromised axios version found in npm cache:"
      echo "$CACHE_AXIOS" | sed 's/^/    /'
    fi
  fi
fi

# ============================================================================
# 5. PROJECT LOCKFILES — Comprehensive Scan
# ============================================================================
section "Scanning lockfiles under $SCAN_ROOT (this may take a moment...)"

LOCKFILE_COUNT=0

scan_lockfile() {
  local lockfile="$1"
  local project_dir
  project_dir=$(dirname "$lockfile")
  local hit=0

  # Check for compromised axios versions
  # axios@X.Y.Z covers yarn.lock and pnpm-lock.yaml
  # -B5 context check covers package-lock.json (version is on a separate line from the package name)
  if grep -qE 'axios@1\.14\.1' "$lockfile" 2>/dev/null \
     || grep -B5 '"1\.14\.1"' "$lockfile" 2>/dev/null | grep -q '"axios'; then
    found "axios@1.14.1 in $lockfile"
    hit=1
  fi
  if grep -qE 'axios@0\.30\.4' "$lockfile" 2>/dev/null \
     || grep -B5 '"0\.30\.4"' "$lockfile" 2>/dev/null | grep -q '"axios'; then
    found "axios@0.30.4 in $lockfile"
    hit=1
  fi

  # Check for plain-crypto-js (should NEVER appear in any legit project)
  if grep -q "plain-crypto-js" "$lockfile" 2>/dev/null; then
    found "plain-crypto-js in $lockfile — THIS PACKAGE IS MALICIOUS"
    hit=1
  fi

  if [[ $hit -eq 0 ]]; then
    LOCKFILE_COUNT=$((LOCKFILE_COUNT + 1))
  fi
}

# Find all lockfiles (limit depth to avoid extremely deep traversals)
while IFS= read -r -d '' lockfile; do
  scan_lockfile "$lockfile"
done < <(find "$SCAN_ROOT" \
  -maxdepth 8 \
  -name "node_modules" -prune -o \
  -name ".git" -prune -o \
  \( -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" -o -name "bun.lockb" \) \
  -print0 2>/dev/null || true)

# For bun.lockb (binary format), try bun to inspect
if command -v bun &>/dev/null; then
  while IFS= read -r -d '' bunlock; do
    BUN_TEXT=$(cd "$(dirname "$bunlock")" && bun bun.lockb 2>/dev/null || true)
    if echo "$BUN_TEXT" | grep -q "plain-crypto-js"; then
      found "plain-crypto-js in $bunlock"
    fi
    if echo "$BUN_TEXT" | grep -qE "axios@1\.14\.1|axios@0\.30\.4"; then
      found "Compromised axios version in $bunlock"
    fi
  done < <(find "$SCAN_ROOT" -maxdepth 8 -name "bun.lockb" -print0 2>/dev/null || true)
fi

safe "$LOCKFILE_COUNT lockfiles scanned clean"

# ============================================================================
# 6. NODE_MODULES — Direct Inspection
# ============================================================================
section "Scanning node_modules directories for compromised packages"

NM_SCANNED=0

while IFS= read -r -d '' nm_dir; do
  NM_SCANNED=$((NM_SCANNED + 1))

  # Check for plain-crypto-js (should never exist)
  if [[ -d "$nm_dir/plain-crypto-js" ]]; then
    found "plain-crypto-js installed at $nm_dir/plain-crypto-js"

    # Check if setup.js still exists (it self-deletes, but worth checking)
    if [[ -f "$nm_dir/plain-crypto-js/setup.js" ]]; then
      found "setup.js payload STILL PRESENT at $nm_dir/plain-crypto-js/setup.js"
    fi

    # Check if package.json was swapped (anti-forensics check)
    if [[ -f "$nm_dir/plain-crypto-js/package.json" ]]; then
      if grep -q "postinstall" "$nm_dir/plain-crypto-js/package.json" 2>/dev/null; then
        found "package.json still contains postinstall hook (payload not yet cleaned up)"
      else
        warn "package.json has NO postinstall — likely swapped by anti-forensics (package.md → package.json)"
        warn "This means the payload ALREADY EXECUTED on this system"
      fi
    fi
  fi

  # Check axios version
  if [[ -f "$nm_dir/axios/package.json" ]]; then
    AXIOS_VER=$(node -p "try{require('$nm_dir/axios/package.json').version}catch(e){'parse-error'}" 2>/dev/null || true)
    if [[ "$AXIOS_VER" == "1.14.1" || "$AXIOS_VER" == "0.30.4" ]]; then
      found "Compromised axios@$AXIOS_VER at $nm_dir/axios/"

      # Check if it has plain-crypto-js as dependency
      if node -e "const p=require('$nm_dir/axios/package.json');process.exit(p.dependencies&&p.dependencies['plain-crypto-js']?0:1)" 2>/dev/null; then
        found "This axios has plain-crypto-js as a dependency — CONFIRMED COMPROMISED"
      fi
    fi
  fi
done < <(find "$SCAN_ROOT" -maxdepth 7 -type d -name "node_modules" -print0 2>/dev/null || true)

safe "$NM_SCANNED node_modules directories scanned"

# ============================================================================
# 7. RUNNING PROCESSES — Check for suspicious payloads
# ============================================================================
section "Checking running processes for known payload indicators"

PROC_HIT=0

if command -v ps &>/dev/null; then
  PS_OUTPUT=$(ps aux 2>/dev/null || true)

  for pattern in "com.apple.act.mond" "ld.py" "6202033" "sfrclak" "wt.exe.*hidden"; do
    if echo "$PS_OUTPUT" | grep -v "grep" | grep -qi "$pattern"; then
      found "Suspicious process matching '$pattern':"
      echo "$PS_OUTPUT" | grep -i "$pattern" | grep -v "grep" | sed 's/^/    /'
      PROC_HIT=1
    fi
  done
fi

if [[ $PROC_HIT -eq 0 ]]; then
  safe "No suspicious processes detected"
fi

# ============================================================================
# 8. SHELL HISTORY — Check if npm install ran recently (informational)
# ============================================================================
section "Checking shell history for recent npm installs (informational)"

for histfile in "$HOME/.bash_history" "$HOME/.zsh_history" "$HOME/.local/share/fish/fish_history"; do
  if [[ -f "$histfile" ]]; then
    RECENT_INSTALLS=$(grep -i "npm install\|npm i \|yarn add\|pnpm add\|bun add\|bun install" "$histfile" 2>/dev/null | tail -20 || true)
    if [[ -n "$RECENT_INSTALLS" ]]; then
      info "Recent install commands from $(basename "$histfile"):"
      echo "$RECENT_INSTALLS" | tail -10 | sed 's/^/    /'
    fi
  fi
done

# ============================================================================
# SUMMARY
# ============================================================================
echo ""
echo -e "${BOLD}========================================${NC}"
echo -e "${BOLD}  SCAN COMPLETE${NC}"
echo -e "${BOLD}========================================${NC}"
echo ""

if [[ $FOUND_ISSUES -gt 0 ]]; then
  echo -e "${RED}${BOLD}⚠️  $FOUND_ISSUES ISSUE(S) FOUND — YOUR SYSTEM MAY BE COMPROMISED${NC}"
  echo ""
  echo -e "${YELLOW}Immediate actions:${NC}"
  echo "  1. Disconnect from the network if stage-2 IOCs were found"
  echo "  2. Remove compromised packages: npm uninstall axios && npm install axios@1.14.0"
  echo "  3. Delete plain-crypto-js from all node_modules"
  echo "  4. Remove stage-2 payloads:"
  echo "     • macOS: sudo rm -f /Library/Caches/com.apple.act.mond"
  echo "     • Linux: rm -f /tmp/ld.py"
  echo "     • Windows: del %PROGRAMDATA%\\wt.exe, %TEMP%\\6202033.*"
  echo "  5. Clean npm cache: npm cache clean --force"
  echo "  6. ROTATE ALL CREDENTIALS — tokens, API keys, SSH keys, passwords"
  echo "  7. Block sfrclak.com at your DNS/firewall"
  echo "  8. Check CI/CD pipelines for the same compromise"
  echo ""
else
  echo -e "${GREEN}${BOLD}✅ No indicators of compromise found.${NC}"
  echo ""
  echo "Preventive recommendations:"
  echo "  • Pin axios to 1.14.0 in your lockfiles"
  echo "  • Run: npm audit"
  echo "  • Consider enabling npm's --ignore-scripts for untrusted installs"
  echo "  • Block sfrclak.com at your DNS/firewall as a precaution"
fi

echo ""
echo "Scanner finished at $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
image

@ChristianWitts
Copy link
Copy Markdown

@luiyongsheng your scripts regex produces false positives FYI, with the "version": "1\.14\.1" part.

@eschoeller
Copy link
Copy Markdown

eschoeller commented Mar 31, 2026

Here's a patch to the issue @ChristianWitts reported

--- a/scan_axios_compromise.sh
+++ b/scan_axios_compromise.sh
@@ -265,6 +265,113 @@
 
 LOCKFILE_COUNT=0
 
+package_lock_has_axios_version() {
+  local lockfile="$1"
+  local bad_version="$2"
+
+  command -v node &>/dev/null || return 1
+
+  node - "$lockfile" "$bad_version" >/dev/null 2>&1 <<'NODE'
+const fs = require('fs');
+
+const [lockfile, badVersion] = process.argv.slice(2);
+let data;
+
+try {
+  data = JSON.parse(fs.readFileSync(lockfile, 'utf8'));
+} catch (error) {
+  process.exit(1);
+}
+
+function hasBadDependencyTree(deps) {
+  if (!deps || typeof deps !== 'object') {
+    return false;
+  }
+
+  for (const [name, meta] of Object.entries(deps)) {
+    if (name === 'axios' && meta && typeof meta === 'object' && meta.version === badVersion) {
+      return true;
+    }
+    if (meta && hasBadDependencyTree(meta.dependencies)) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
+if (data.packages && typeof data.packages === 'object') {
+  for (const [pkgPath, meta] of Object.entries(data.packages)) {
+    if (/(^|\/)node_modules\/axios$/.test(pkgPath) && meta && meta.version === badVersion) {
+      process.exit(0);
+    }
+  }
+}
+
+if (hasBadDependencyTree(data.dependencies)) {
+  process.exit(0);
+}
+
+process.exit(1);
+NODE
+}
+
+yarn_lock_has_axios_version() {
+  local lockfile="$1"
+  local bad_version="$2"
+
+  awk -v bad_version="$bad_version" '
+    BEGIN {
+      in_axios = 0;
+      found = 0;
+    }
+    /^[^[:space:]].*:[[:space:]]*$/ {
+      in_axios = ($0 ~ /(^|, )[\"\047]?axios@/);
+      next;
+    }
+    in_axios && /^[[:space:]]+version([[:space:]]|:)/ {
+      line = $0;
+      sub(/^[[:space:]]+version[[:space:]]*:?[[:space:]]*"?/, "", line);
+      sub(/"?[[:space:]]*$/, "", line);
+      if (line == bad_version) {
+        found = 1;
+        exit;
+      }
+    }
+    END {
+      exit(found ? 0 : 1);
+    }
+  ' "$lockfile" >/dev/null 2>&1
+}
+
+pnpm_lock_has_axios_version() {
+  local lockfile="$1"
+  local bad_version="$2"
+  local bad_regex="${bad_version//./\\.}"
+
+  grep -qE "^[[:space:]]*['\"]?(/axios/${bad_regex}|axios@${bad_regex}(\\([^'\"]+\\))?)['\"]?:" "$lockfile" 2>/dev/null
+}
+
+lockfile_has_axios_version() {
+  local lockfile="$1"
+  local bad_version="$2"
+
+  case "$(basename "$lockfile")" in
+    package-lock.json)
+      package_lock_has_axios_version "$lockfile" "$bad_version"
+      ;;
+    yarn.lock)
+      yarn_lock_has_axios_version "$lockfile" "$bad_version"
+      ;;
+    pnpm-lock.yaml)
+      pnpm_lock_has_axios_version "$lockfile" "$bad_version"
+      ;;
+    *)
+      return 1
+      ;;
+  esac
+}
+
 scan_lockfile() {
   local lockfile="$1"
   local project_dir
@@ -272,11 +379,11 @@
   local hit=0
 
   # Check for compromised axios versions
-  if grep -qE '"axios".*"1\.14\.1"|axios@1\.14\.1|"version": "1\.14\.1"' "$lockfile" 2>/dev/null; then
+  if lockfile_has_axios_version "$lockfile" "1.14.1"; then
     found "axios@1.14.1 in $lockfile"
     hit=1
   fi
-  if grep -qE '"axios".*"0\.30\.4"|axios@0\.30\.4|"version": "0\.30\.4"' "$lockfile" 2>/dev/null; then
+  if lockfile_has_axios_version "$lockfile" "0.30.4"; then
     found "axios@0.30.4 in $lockfile"
     hit=1
   fi

@eschoeller
Copy link
Copy Markdown

Also, replace node -p/node -e path interpolation with argv-based package.json parsing helpers to eliminate path-driven JS injection in the scanner

--- a/scan_axios_compromise.sh
+++ b/scan_axios_compromise.sh
@@ -60,6 +60,49 @@
   echo -e "    $1"
 }
 
+package_json_version() {
+  local package_json="$1"
+  local fallback="${2:-unknown}"
+
+  if ! command -v node &>/dev/null; then
+    printf '%s\n' "$fallback"
+    return 0
+  fi
+
+  node - "$package_json" "$fallback" 2>/dev/null <<'NODE'
+const fs = require('fs');
+
+const [packageJson, fallback] = process.argv.slice(2);
+
+try {
+  const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
+  process.stdout.write(typeof pkg.version === 'string' ? pkg.version : fallback);
+} catch (error) {
+  process.stdout.write(fallback);
+}
+NODE
+}
+
+package_json_has_dependency() {
+  local package_json="$1"
+  local dependency="$2"
+
+  command -v node &>/dev/null || return 1
+
+  node - "$package_json" "$dependency" >/dev/null 2>&1 <<'NODE'
+const fs = require('fs');
+
+const [packageJson, dependency] = process.argv.slice(2);
+
+try {
+  const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
+  process.exit(pkg.dependencies && Object.prototype.hasOwnProperty.call(pkg.dependencies, dependency) ? 0 : 1);
+} catch (error) {
+  process.exit(1);
+}
+NODE
+}
+
 # Determine scan root
 SCAN_ROOT="${1:-$HOME}"
 OS="$(uname -s)"
@@ -186,7 +229,7 @@
       found "plain-crypto-js directory exists at $NPM_GLOBAL_ROOT/plain-crypto-js"
     fi
     if [[ -d "$NPM_GLOBAL_ROOT/axios" ]]; then
-      GLOBAL_AXIOS_VER=$(node -p "require('$NPM_GLOBAL_ROOT/axios/package.json').version" 2>/dev/null || echo "unknown")
+      GLOBAL_AXIOS_VER=$(package_json_version "$NPM_GLOBAL_ROOT/axios/package.json" "unknown")
       if [[ "$GLOBAL_AXIOS_VER" == "1.14.1" || "$GLOBAL_AXIOS_VER" == "0.30.4" ]]; then
         found "Compromised global axios version: $GLOBAL_AXIOS_VER"
       else
@@ -224,7 +267,7 @@
     found "plain-crypto-js found in bun global packages!"
   fi
   if [[ -d "$BUN_GLOBAL_ROOT/axios" ]]; then
-    BUN_AXIOS_VER=$(node -p "require('$BUN_GLOBAL_ROOT/axios/package.json').version" 2>/dev/null || echo "unknown")
+    BUN_AXIOS_VER=$(package_json_version "$BUN_GLOBAL_ROOT/axios/package.json" "unknown")
     if [[ "$BUN_AXIOS_VER" == "1.14.1" || "$BUN_AXIOS_VER" == "0.30.4" ]]; then
       found "Compromised bun global axios version: $BUN_AXIOS_VER"
     fi
@@ -456,12 +499,12 @@
 
   # Check axios version
   if [[ -f "$nm_dir/axios/package.json" ]]; then
-    AXIOS_VER=$(node -p "try{require('$nm_dir/axios/package.json').version}catch(e){'parse-error'}" 2>/dev/null || true)
+    AXIOS_VER=$(package_json_version "$nm_dir/axios/package.json" "parse-error")
     if [[ "$AXIOS_VER" == "1.14.1" || "$AXIOS_VER" == "0.30.4" ]]; then
       found "Compromised axios@$AXIOS_VER at $nm_dir/axios/"
 
       # Check if it has plain-crypto-js as dependency
-      if node -e "const p=require('$nm_dir/axios/package.json');process.exit(p.dependencies&&p.dependencies['plain-crypto-js']?0:1)" 2>/dev/null; then
+      if package_json_has_dependency "$nm_dir/axios/package.json" "plain-crypto-js"; then
         found "This axios has plain-crypto-js as a dependency — CONFIRMED COMPROMISED"
       fi
     fi

@morph13nd
Copy link
Copy Markdown

For anyone watching and wanting a copy of the latest scanner with IOCs, please feel free to use mine:
https://github.com/morph13nd/axios-compromise-scanner/blob/main/axios_check.sh

Added 3 iOCs and other detection methods for macOS, and patched several other bugs.

@abhagsain
Copy link
Copy Markdown

abhagsain commented Mar 31, 2026

Thanks for the script but my co-founder says it gave false positive results.

image

@luiyongsheng
Copy link
Copy Markdown

cuz bro just literally copied my code above

I have patched the false positive issue :D

@abhagsain

@morph13nd
Copy link
Copy Markdown

morph13nd commented Mar 31, 2026

cuz bro just literally copied my code above

I have patched the false positive issue :D

@abhagsain

My apologies. I added credits to you @luiyongsheng for the original copy of the script. Removed calls to the infected domains to not set off internal security apparatuses for an production environment, and the false positive. Axios should already be pinned in search and it would not hit something e.g. like "lodash@1.14.1".

@HenkPoley
Copy link
Copy Markdown

Analysis of the Mach-O binary that it would drop: https://gist.github.com/joe-desimone/f9b205b6a5c2a826987e27b6ecc84c05

@kumarayushkumar
Copy link
Copy Markdown

Screenshot 2026-03-31 at 14 29 23

@PierrunoYT
Copy link
Copy Markdown

Can someone try this? https://github.com/PierrunoYT/axios-scanner

I made it with AI so idk no if there are any issues.

@morph13nd
Copy link
Copy Markdown

what bout this

#!/bin/bash

# ============================
# Supply Chain Attack Scanner
# Detects malicious Axios/npm packages
# ============================

echo "Starting supply chain scan for Axios npm compromise..."

# 1️⃣ Check npm global packages for malicious versions
echo -e "\n[+] Checking global npm packages..."
npm list -g --depth=0 2>/dev/null | grep -E "axios@1\.14\.1|axios@0\.30\.4|plain-crypto-js@4\.2\.1" || echo "No suspicious global packages found."

# 2️⃣ Check local project node_modules
echo -e "\n[+] Searching local node_modules for malicious packages..."
find . -type d -name "axios@1.14.1" -o -name "axios@0.30.4" -o -name "plain-crypto-js@4.2.1" 2>/dev/null || echo "No suspicious local packages found."

# 3️⃣ Look for postinstall scripts in node_modules
echo -e "\n[+] Checking for suspicious postinstall scripts..."
grep -R "\"postinstall\"" node_modules 2>/dev/null | grep -E "axios|plain-crypto-js" && echo "Potential malicious postinstall scripts found." || echo "No suspicious postinstall scripts detected."

# 4️⃣ Search for known malware drop files
echo -e "\n[+] Searching for known malware files..."
SUSPICIOUS_FILES=(
  "/Library/Caches/com.apple.act.mond"
  "/tmp/ld.py"
  "$TMPDIR/6202033.vbs"
  "$TMPDIR/6202033.ps1"
)
for file in "${SUSPICIOUS_FILES[@]}"; do
  if [ -e "$file" ]; then
    echo "[!] Found suspicious file: $file"
  else
    echo "[+] File not found: $file"
  fi
done

# 5️⃣ Search npm lockfiles for malicious versions
echo -e "\n[+] Searching package-lock.json and yarn.lock..."
grep -R "axios@1.14.1" package-lock.json yarn.lock 2>/dev/null
grep -R "axios@0.30.4" package-lock.json yarn.lock 2>/dev/null
grep -R "plain-crypto-js@4.2.1" package-lock.json yarn.lock 2>/dev/null

echo -e "\nScan complete."

Not good enough, please see: https://github.com/morph13nd/axios-compromise-scanner/blob/main/axios_check.sh
I just updated it with new IOCs found overnight.

Screenshot 2026-03-31 at 14 29 23

Very nice! Check again, I added new IOCs overnight. Good luck everyone. https://github.com/morph13nd/axios-compromise-scanner/blob/main/axios_check.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment