# Axios npm Supply Chain Attack

On 31st March 2026, two malicious versions of the axios HTTP client library were published to the npm registry: `axios@1.14.1` and `axios@0.30.4`. Axios has in excess of 83 million weekly downloads, making it one of the most widely used packages in the JavaScript ecosystem.

This article covers the attack, what it does, how to determine whether your environment is affected, and how to scan container images at scale, including images stored as tar archives.

***

### The Attack

The malicious versions were published using the compromised npm credentials of the lead axios maintainer. The attacker changed the account email to an anonymous ProtonMail address and published both versions directly via the npm CLI, bypassing the project's normal GitHub Actions pipeline entirely.

Both versions inject a new dependency `plain-crypto-js@4.2.1` which is not referenced anywhere in the legitimate axios source. Its sole purpose is to execute a `postinstall` script that acts as a cross-platform RAT dropper, targeting macOS, Windows, and Linux. After execution, the malware deletes itself and replaces its own `package.json` with a clean copy to hinder detection efforts and forensic analysis.

A clean decoy version (`plain-crypto-js@4.2.0`) was published approximately 18 hours prior to avoid triggering "brand-new package" alerts from security scanners. Both malicious axios versions were published within 39 minutes of each other.

Socket's automated detection flagged the malicious package within six minutes of publication. Both versions have since been removed from the npm registry.

***

### Does This Affect Browser/CDN Usage?

The attack relies entirely on npm's `postinstall` lifecycle hook which is a Node.js-specific mechanism that executes shell commands on the installing machine. If you are loading axios via a CDN such as unpkg, none of this executes. The browser fetches the JavaScript bundle directly so the  lifecycle scripts never run.

Based on current analysis, the malicious code resided within the `plain-crypto-js` dependency, not within the axios bundle itself. End users loading axios via `https://unpkg.com/axios@1.14.1/dist/axios.min.js` are not at risk from this specific attack.

The target was developer machines and CI/CD pipelines and not end users.

***

### Indicators of Compromise

| Indicator                                                   | Significance                                      |
| ----------------------------------------------------------- | ------------------------------------------------- |
| `axios@1.14.1` or `axios@0.30.4` in `node_modules`          | Vulnerable version installed                      |
| `plain-crypto-js` directory present anywhere on disk        | Strong IOC - no legitimate use case               |
| `package-lock.json` resolving axios to `1.14.1` or `0.30.4` | Malicious version was pulled during `npm install` |
| Outbound connections to `sfrclak[.]com`                     | C2 contact - assume active compromise             |

The removal of the package from the npm registry means `npm audit` and most vulnerability scanners will not flag this. The CVE has not yet landed in databases such as Trivy's at time of writing. Manual inspection is currently the most reliable detection method.

***

### Immediate Remediation

Pin axios to a known safe version:

```bash
npm install axios@1.14.0
```

If `plain-crypto-js` is found on any system, treat it as fully compromised. Rotate all secrets, API keys, and credentials accessible from that environment. Review outbound network connections for contact with `sfrclak[.]com`.

CI/CD pipelines that ran during the window the packages were live (approximately 23:59–02:00 UTC, 31st March) warrant particular attention, as any secrets present in those environments may have been exfiltrated.

***

### Scanning Docker Images

Given the npm registry removal, tooling which relies on a vulnerability database may not detect this. The following approaches work regardless of database state.

#### Locally Running Images (via Docker Socket)

The following script iterates all locally available Docker images, scanning each for the vulnerable axios versions and the `plain-crypto-js` IOC.

```bash
#!/bin/bash
VULN_AXIOS=("1.14.1" "0.30.4")
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'

for image in $(docker images --format "{{.Repository}}:{{.Tag}}" | grep -v "<none>"); do
  echo -e "\n🔍 Scanning: $image"

  axios_ver=$(docker run --rm --entrypoint="" "$image" \
    sh -c 'find / -path /proc -prune -o -path /sys -prune -o \
    -name "package.json" -print 2>/dev/null | \
    xargs grep -l "\"name\": \"axios\"" 2>/dev/null | \
    xargs grep -h "\"version\"" 2>/dev/null | \
    grep -o "[0-9]\+\.[0-9]\+\.[0-9]\+" | head -1' 2>/dev/null)

  crypto=$(docker run --rm --entrypoint="" "$image" \
    sh -c 'find / -path /proc -prune -o -type d \
    -name "plain-crypto-js" -print 2>/dev/null' 2>/dev/null)

  if [ -n "$crypto" ]; then
    echo -e "${RED}🚨 CRITICAL: plain-crypto-js found in $image${NC}"
  elif [ -n "$axios_ver" ]; then
    for vuln in "${VULN_AXIOS[@]}"; do
      if [ "$axios_ver" = "$vuln" ]; then
        echo -e "${RED}🚨 VULNERABLE: axios@$axios_ver found in $image${NC}"
        break
      fi
    done
    [[ ! " ${VULN_AXIOS[@]} " =~ " ${axios_ver} " ]] && \
      echo -e "${GREEN}✅ OK: axios@$axios_ver${NC}"
  else
    echo -e "${GREEN}✅ No axios found${NC}"
  fi
done
```

#### Scanning Image Tar Archives (OCI Format)

Images exported via `docker save` or pulled from a registry in OCI format follow this structure:

```
images.tar
└── blobs/sha256/
    ├── <hash>   JSON (manifest or config)
    ├── <hash>   POSIX tar archive (layer)
    └── <hash>   POSIX tar archive (layer)
```

Each layer blob is itself a tar archive containing the filesystem for that layer. The script below handles this two-level structure which extracts the outer tar, identify layer blobs using `file`, then streams `package.json` contents directly from each layer without secondary extraction to disk.

```bash
#!/bin/bash
# Usage: ./scan_axios.sh images.tar

TAR_FILE="${1}"
VULN_AXIOS=("1.14.1" "0.30.4")
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

if [ -z "$TAR_FILE" ] || [ ! -f "$TAR_FILE" ]; then
  echo "Usage: $0 <images.tar>"
  exit 1
fi

echo "============================================================"
echo " Axios Supply Chain Scanner"
echo " Target: $TAR_FILE"
echo " $(date -u)"
echo "============================================================"

TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT

echo "Extracting $TAR_FILE..."
tar -xf "$TAR_FILE" -C "$TMPDIR" 2>/dev/null

BLOB_DIR="$TMPDIR/blobs/sha256"

if [ ! -d "$BLOB_DIR" ]; then
  echo "❌ No blobs/sha256 directory found — is this an OCI format tar?"
  exit 1
fi

for blob in "$BLOB_DIR"/*; do
  # Skip JSON blobs (manifests and configs)
  file "$blob" | grep -q "JSON" && continue
  file "$blob" | grep -q "tar archive" || continue

  echo -e "\n🔍 Layer: $(basename $blob | cut -c1-12)..."

  # Check for axios own package.json
  axios_path=$(tar -tf "$blob" 2>/dev/null | grep "node_modules/axios/package\.json$" | head -1)

  if [ -n "$axios_path" ]; then
    version=$(tar -xOf "$blob" "$axios_path" 2>/dev/null | python3 -c "
import json, sys
try:
    d = json.load(sys.stdin)
    if d.get('name') == 'axios':
        print(d.get('version','unknown'))
except: pass
")
    if [ -n "$version" ]; then
      vuln_match=false
      for vuln in "${VULN_AXIOS[@]}"; do
        if [ "$version" = "$vuln" ]; then
          echo -e "  ${RED}🚨 VULNERABLE: axios@$version${NC}"
          vuln_match=true
          break
        fi
      done
      [ "$vuln_match" = false ] && echo -e "  ${GREEN}✅ OK: axios@$version${NC}"
    fi
  fi

  # Check parent package.json files for axios as a declared dependency
  while IFS= read -r pkgjson; do
    echo "$pkgjson" | grep -q "node_modules/axios/package\.json" && continue
    result=$(tar -xOf "$blob" "$pkgjson" 2>/dev/null | python3 -c "
import json, sys
try:
    d = json.load(sys.stdin)
    deps = {}
    deps.update(d.get('dependencies', {}))
    deps.update(d.get('devDependencies', {}))
    if 'axios' in deps:
        print(deps['axios'])
except: pass
")
    [ -n "$result" ] && echo -e "  ${YELLOW}⚠️  package.json declares axios: $result ($pkgjson)${NC}"
  done < <(tar -tf "$blob" 2>/dev/null | grep "package\.json$")

  # Check package-lock.json for resolved version
  while IFS= read -r lockfile; do
    result=$(tar -xOf "$blob" "$lockfile" 2>/dev/null | python3 -c "
import json, sys
try:
    d = json.load(sys.stdin)
    packages = d.get('packages', {})
    for name, info in packages.items():
        if name.endswith('/axios') or name == 'axios':
            print(info.get('version',''))
    deps = d.get('dependencies', {})
    if 'axios' in deps:
        print(deps['axios'].get('version',''))
except: pass
")
    if [ -n "$result" ]; then
      for vuln in "${VULN_AXIOS[@]}"; do
        if [ "$result" = "$vuln" ]; then
          echo -e "  ${RED}🚨 VULNERABLE: package-lock.json resolved axios@$result${NC}"
          break
        fi
      done
    fi
  done < <(tar -tf "$blob" 2>/dev/null | grep "package-lock\.json$")

  # plain-crypto-js — strongest IOC
  crypto=$(tar -tf "$blob" 2>/dev/null | grep "plain-crypto-js" | head -1)
  if [ -n "$crypto" ]; then
    echo -e "  ${RED}🚨 CRITICAL: plain-crypto-js found — assume compromised${NC}"
    echo "     → $crypto"
  fi

done

echo -e "\n============================================================"
echo "Scan complete."
```

Run it against any OCI image tar:

```bash
chmod +x scan_axios.sh
./scan_axios.sh images.tar
```

### Using Trivy

Trivy can scan image tars directly without requiring Docker to be running, which avoids socket permission issues:

```bash
trivy image --input images.tar
```

As noted, the CVE is unlikely to be in Trivy's database yet. To inspect package versions regardless of database state, use the JSON output:

```bash
trivy image --input images.tar --format json | python3 -c "
import json, sys
VULN = {'1.14.1', '0.30.4'}
data = json.load(sys.stdin)
for result in data.get('Results', []):
    for pkg in result.get('Packages', []):
        if pkg.get('Name','').lower() == 'axios':
            version = pkg.get('Version','unknown')
            flag = '🚨 VULNERABLE' if version in VULN else '✅ OK'
            print(f'{flag}: axios@{version}')
"
```

For use in a pipeline where you need an exit code:

```bash
trivy image --format json $IMAGE_NAME | python3 -c "
import json, sys
VULN = {'1.14.1', '0.30.4'}
data = json.load(sys.stdin)
hits = [
    f\"{pkg['Name']}@{pkg['Version']}\"
    for r in data.get('Results',[])
    for pkg in r.get('Packages',[])
    if pkg.get('Name','').lower() == 'axios' and pkg.get('Version') in VULN
]
if hits:
    print('VULNERABLE:', ', '.join(hits))
    sys.exit(1)
print('OK')
sys.exit(0)
"
```

Trivy also supports generating a full SBOM, which provides a complete package inventory independent of any vulnerability database:

```bash
trivy image --input images.tar --format cyclonedx --output sbom.json
grep -i axios sbom.json
```

***

### Output from Trivy Script

```
============================================================
 Axios Supply Chain Scanner
 Target: image1.tar
 Tue Mar 31 09:34:14 AM UTC 2026
============================================================
Extracting image1.tar...

🔍 Layer: 4436891c77b5...
🔍 Layer: 466f2827c5d1...
🔍 Layer: 52caa5bd1d9d...
🔍 Layer: 5906df279b58...
🔍 Layer: 989e799e6349...
🔍 Layer: bddea6620312...
  ✅ OK: axios@1.14.0
  ⚠️  package.json declares axios: ^1.14.1 (app/package.json)
  🚨 VULNERABLE: package-lock.json resolved axios@1.14.1
```

### Output from Trivy (scanning Docker socket directly)

```
$ trivy image axios:vuln1 --format cyclonedx --output sbom.json
2026-03-31T09:38:21Z    INFO    "--format cyclonedx" disables security scanning. Specify "--scanners vuln" explicitly if you want to include vulnerabilities in the "cyclonedx" report.
2026-03-31T09:38:23Z    INFO    Detected OS     family="alpine" version="3.23.3"
2026-03-31T09:38:23Z    INFO    Number of language-specific files       num=1
$ cat sbom.json | grep axios
      "name": "axios:vuln1",
          "value": "axios:vuln1"
          "value": "axios:vuln1"
      "bom-ref": "pkg:npm/axios@1.14.1",
      "name": "axios",
      "purl": "pkg:npm/axios@1.14.1",
          "value": "app/node_modules/axios/package.json"
          "value": "axios@1.14.1"
        "pkg:npm/axios@1.14.1",
      "ref": "pkg:npm/axios@1.14.1",
```

### Summary

The npm registry removal creates a false sense of safety. Standard tooling will not flag this. If your environment runs Node.js-based containers, or your CI/CD pipeline installs npm packages, manual inspection using the methods above is currently the most reliable approach.

The presence of `plain-crypto-js` anywhere on disk, in any layer of any image, should be treated as a confirmed compromise regardless of the axios version reported.

If you need a test image to validate your detection logic, feel free to reach out (contact details on contact page).

## References / IOCs / Further Reading

<https://www.wiz.io/blog/axios-npm-compromised-in-supply-chain-attack>\
<https://www.aikido.dev/blog/axios-npm-compromised-maintainer-hijacked-rat>\
<https://thehackernews.com/2026/03/axios-supply-chain-attack-pushes-cross.html>\
<https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan>
