400电话
微信咨询
加入我们
咨询时间: 9:30-18:30
400-998-3555
感谢您访问本站!我们检测到您当前使用的是 IE 浏览器。由于微软已正式终止对 IE 浏览器的技术支持,本站现已不再兼容该浏览器,可能会导致页面显示异常或功能无法使用。为了获得最佳体验,建议您:使用 Chrome、Edge、Firefox 等现代浏览器访问。
x

极速扩散!TeamPcp组织利用高阶蠕虫大规模入侵开发者生态

  • 作者:火绒安全

  • 发布时间:2026-06-25

  • 阅读量:1106

一、概述

近期,火绒安全工程师在监测网络安全过程中发现了异常情况。经过溯源与技术分析,确认了这是一起TeamPcp组织使用Shai-Hulud蠕虫病毒针对供应链的投毒,下方结合供应链厂商公开提供的信息进行事件还原与样本分析。

TeamPcp组织利用三个已知漏洞的链式攻击,通过GitHub平台缓存投放Mini Shai-Hulud供应链蠕虫病毒,窃取内存中的OIDC凭证。该组织利用所窃取的OIDC凭证发布新版本供其他用户下载,导致蠕虫病毒迅速扩散,在6分钟内于42个tanstack相关的npm包中发布了84个恶意版本。

该蠕虫病毒对上游供应链tanstack发起攻击,对下游所有使用该开源工具的用户构成危害。其通过窃取用户凭证(包括GitHub凭证、加密钱包、SSH密钥等)及各类基础设备信息,并感染用户自身仓库,进而实现交叉大规模扩散。

目前,火绒安全产品已经实现对该行为的拦截与查杀。

查杀图.png

查杀图


二、流程图

流程图.jpg



三、样本分析

该蠕虫病毒在包中名称为router_init.js、大小2341681(2.23MB),经过高度混淆。通过5种C2信道通信,针对30+ CI/CD平台、100+凭证路径的系统性窃取、npm供应链+Github双重投毒实现自我传播、RSA-2048 + AES-256-GCM强加密、rm -rf的自毁机制、俄语地区的规避操作,实现感染vscode与claude的配置文件以实现持久化。

混淆的核心是javascript-obfuscator(开源工具)叠加了beautify自定义流密码形成双层保护。该工具使用了9种混淆,String Array编码、多级解码器别名、自防护、控制流扁平化,所有变量、函数、类名都不可逆混淆,注入死代码。在通过脚本去除String Array编码之后,通过ai修复大量的变量、函数、类的名称。

去混淆前后对比.png

去混淆前后对比


2.1主函数执行链

执行初始化->凭证窃取->数据加密与发送->感染与注入

2.1主函数执行链.png


2.2执行初始化

创建文件单实例锁tmp.ts018051808.lock,避免同时多个运行。


创建单实例锁.png

创建单实例锁


对俄语地区进行地区规避,这也是判断github攻击账户并非真实攻击者的依据之一。

俄语地区规避.png

俄语地区规避


注册SIGINT与SIGTERM信号处理器,阻止被攻击者使用Ctrl+C或kill终止命令进程。


防用户终止进程.png

防用户终止进程


识别当前所处环境是否在CI/CD流水线中,通过字符串判断检测了31种环境。为后续访问凭证以及npm传播做准备工作。

CD环境检测.png

CI/CD环境检测


Github仓库枚举,枚举用户所有关联仓库(owner,collaborator,organization_member),并且只关注2025-09-01后活跃的仓库,检测是否有push权限。具有push权限的提取OIDC凭证,如果权限足够还会在受害者账号下创建新仓库,仓库名称随机词语-随机词语-3位数的随机数,外加”Shai-Hulud Here We Go Again”的仓库描述。

连接Github Api.png

连接Github Api


遍历所有子仓库.png

遍历所有子仓库


创建仓库名使用的随机词表如下,名称总共有16x16x1000=256000种组合。


仓库名称词表.png

仓库名称词表


创建子仓库,相关配置信息.png

创建子仓库,相关配置信息



自毁机制

解密出字符串IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner,该字符串后续写入github仓库的commit message如果token被撤销将触发rm -rf ~/命令,将删除受害者的所有文件。其检测Token是否存在的脚本是通过11种payload解密得到。

标记字符串.png

标记字符串


标记字符串写入github commit message.png

标记字符串写入github commit message

删库命令.png

删库命令


检测token的脚本,与eval调用rm -rf命令。并且即使token未被撤销,24小时后也会执行删除${HOME}/.config/gh-token-monitor/started_at的指令,用作清理痕迹。

Token检测脚本.png

Token检测脚本


2.3凭证窃取

凭证窃取内容较多,本篇文章只介绍一种,其余均以表格形式给出。凭证获取范围:服务商、加密钱包、VPN、通讯工具、各类开发者凭证、SSH相关密钥、其他本地基础信息。

AWS凭证窃取使用5种方法:环境变量直接获取;Web Identity Token文件获取;ECS容器元数据;EC2实例元数据;本地配置文件。在窃取成功后会通过凭证窃取AWS Secrets Manager中存储的所有机密。并且在所有扫描中都会同时使用正则匹配github与npm的token。

正则匹配token github:gh[op]_[A-Za-z0-9]{36}

正则匹配token npm:npm_[A-Za-z0-9]{36,}

环境变量窃取法.png

环境变量窃取法


Web Identity Token文件.png

Web Identity Token文件


ECS容器元数据.png

ECS容器元数据

EC2实例元数据.png

EC2实例元数据

本地文件配置.png本地文件配置.png

本地文件配置


在获取凭证后,检查secretsmanager:ListSecrets和secretsmanager:GetSecretValue权限,直接窃取AWS Secrets Manager中存储的所有机密。

检测权限获取AWS机密文件.png

检测权限获取AWS机密文件


该脚本通过11种payload其中之一解密得到Runner.Worker内存dump获取凭证,直接在内存种匹配对应规则获取凭证。

Runner.Worker内存dump.png

Runner.Worker内存dump

扫描凭证过程中的正则匹配token.png

扫描凭证过程中的正则匹配token


凭证与敏感文件的扫描表格

路径

目标

~/.aws/config, ~/.aws/credentials

AWS(服务商)

~/.azure/accessTokens.json, ~/.azure/msal_token_cache.*

Microsoft Azure(服务商)

~/.config/gcloud/access_tokens.db, credentials.db, application_default_credentials.json

Google Cloud(服务商)

~/.terraform.d/credentials.tfrc.json

HashiCorp Terraform Cloud(服务商)

~/.bitcoin/wallet.dat

Bitcoin Core(加密货币)

~/.dogecoin/wallet.dat

Dogecoin Core(加密货币)

~/.litecoin/wallet.dat

Litecoin Core(加密货币)

~/.ethereum/keystore/*

Ethereum(加密货币)

~/.zcash/wallet.dat

Zcash(加密货币)

~/.monero/*

Monero(加密货币)

~/.dash/wallet.dat

Dash(加密货币)

~/.electrum/wallets/*

Electrum (BTC)(加密货币)

~/.electrum-ltc/wallets/*

Electrum-LTC(加密货币)

~/.config/Exodus/exodus.wallet/*

Exodus 多币种钱包(加密货币)

~/.config/Ledger Live/*

Ledger Live 硬件钱包(加密货币)

~/.ssh/id_rsa

SSH RSA 私钥 (SSH)

~/.ssh/id_ecdsa

SSH ECDSA 私钥(SSH)

~/.ssh/id_ed25519

SSH Ed25519 私钥(SSH)

~/.ssh/id_dsa

SSH DSA 私钥(SSH)

~/.ssh/id_*

所有 SSH 私钥(SSH)

~/.ssh/authorized_keys

SSH 授权密钥(SSH)

~/.ssh/config

SSH 配置(SSH)

~/.ssh/known_hosts

SSH 已知主机(SSH)

/etc/ssh/ssh_host_*_key

系统 SSH 主机密钥(SSH)

~/.remmina/*

Remmina 远程桌面配置(SSH)

~/.config/remmina/*

Remmina 配置(SSH)

.git/config, ~/.gitconfig, .git-credentials, ~/.git-credentials

Git 凭证(开发者凭证)

.npmrc, ~/.npmrc

npm 注册表认证 token(开发者凭证)

~/.docker/config.json, /root/.docker/config.json

Docker Registry 凭证(开发者凭证)

~/.pypirc

PyPI 认证(开发者凭证)

~/.kube/config

Kubernetes kubectl 配置(开发者凭证)

~/.config/helm/*

Helm 仓库凭证(开发者凭证)

/etc/rancher/k3s/k3s.yaml

Rancher K3s 集群配置(开发者凭证)

/var/run/secrets/kubernetes.io/serviceaccount/token

K8s Service Account Token(开发者凭证)

~/.config/Slack/Cookies

Slack Cookies(通讯工具)

~/.config/discord/Local Storage/leveldb/*

Discord 本地存储(通讯工具)

~/.config/Element/Local Storage/*

Element (Matrix) 本地存储(通讯工具)

~/.config/Signal/*

Signal 桌面端(通讯工具)

~/.local/share/TelegramDesktop/tdata/*

Telegram Desktop(通讯工具)

~/.config/telegram-desktop/*

Telegram Desktop (Linux)(通讯工具)

~/.cert/nm-openvpn/*

NetworkManager OpenVPN 证书(VPN)

%APPDATA%\NordVPN\NordVPN.exe.Config

NordVPN Windows(VPN)

%APPDATA%\OpenVPN Connect\profiles\*

OpenVPN Connect(VPN)

%PROGRAMDATA%\OpenVPN\config\*

OpenVPN 系统级配置(VPN)

%APPDATA%\ProtonVPN\user.config

ProtonVPN(VPN)

%APPDATA%\CyberGhost\CG6\CyberGhost.dat

CyberGhost VPN(VPN)

%APPDATA%\Private Internet Access\*.conf

PIA VPN(VPN)

%APPDATA%\Windscribe\Windscribe\*

Windscribe VPN(VPN)

C:\Program Files\OpenVPN\config\*.ovpn

OpenVPN Windows(VPN)

%APPDATA%\EarthVPN\OpenVPN\config\*.ovpn

EarthVPN(VPN)

/etc/openvpn/*

OpenVPN (Linux)(VPN)

~/.bash_history, ~/.zsh_history, ~/.history

Shell 历史命令(本地信息)

~/.mysql_history, ~/.psql_history

数据库历史(本地信息)

~/.python_history, ~/.node_repl_history

解释器历史(本地信息)

~/.lesshst

less 命令历史(本地信息)

~/.viminfo

Vim 信息文件(本地信息)

**/.env, .env, **/.env.local, **/.env.production

环境变量文件(本地信息)

**/config/database.yml

Rails 数据库配置(本地信息)

**/wp-config.php

WordPress 配置(本地信息)

~/.config/filezilla/recentservers.xml, sitemanager.xml

FileZilla FTP 凭证(本地信息)

~/.claude.json, ~/.claude/mcp.json

Claude AI 配置(本地信息)

~/.kiro/settings/mcp.json

Kiro MCP 设置(本地信息)

~/.config/atomic/Local Storage/leveldb/*

Atomic 钱包(本地信息)

~/.netrc

网络自动登录凭证(本地信息)

~/.purple/accounts.xml

Pidgin/finch 聊天账户(本地信息)

~/.config/weechat/irc.conf

WeeChat IRC 配置(本地信息)

~/.kde4/share/apps/kwallet/*.kwl, ~/.kde/share/apps/kwallet/*.kwl, ~/.config/kwalletd/*.kwl

KDE 钱包(本地信息)

~/.local/share/keyrings/*.keyring, ~/.local/share/keyrings/login.keyring

GNOME 密钥环(本地信息)

~/.pki/nssdb/*

NSS 证书数据库(本地信息)

**/settings.p

ROS 设置文件(本地信息)

~/.local/share/recently-used.xbel

最近使用文件列表(本地信息)


2.4数据加密与发送

2.4.1网络初始化

C2通讯总共有5种方式:主C2、备用域名、Session协议C2、Github外传push、Github外传deployment。

其中主C2与Session是在进程运行时直接触发两者并行触发,备用域名是在主C2失败时触发。Github外传是根据执行仓库push与deployment两者操作时分别触发。

2.4.1网络初始化表格.png

主C2在主函数入口时以及初始化解密,git-tanstack.com:443/router.

主C2.png

主C2


备用域名在主C2域名不可达时使用。获取方式是通过字符串thebeautifulmarchoftime在github上搜索相关commit信息,并通过配置的RSA文件进行校验通过才使用(目前github上相关痕迹已被删除)。

备用域名获取.png

备用域名获取


备用域名解密RSA公钥通过11种payload解密获得

备用域名解密RSA公钥通过11种payload解密获得.png


攻击者SessionID:05f9e609d79eed391015e11380dee4b5c9ead0b6e2e7f0134e6e51767a87323026 在主函数执行链时初始化。脚本中初始化了3个Seed节点,seed1.getsession.org,seed2.getsession.org,seed2.getsession.org

Session的节点之一与TLS证书.png

Session的节点之一与TLS证书


该脚本还通过将恶意Github Actions Workflow注入受害者仓库利用 GitHub Actions Runner 进程独立外传 Secrets。这与主 C2 通道完全解耦,不受主进程存活与否、C2 域名是否可达的影响。

注入的脚本是通过AES-256-GCM + 自定义beautify函数双层解密。

恶意Github Workflow脚本解密.png

恶意Github Workflow脚本解密


注入给Action deployment的脚本如下,非常简洁,将仓库中的文件转换为json格式发送给hxxps://api.masscan.cloud/v2/upload

Action deployment恶意脚本.png

Action deployment恶意脚本


注入给Action push脚本,将文件转换为json格式之后上传到actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f,后续攻击者进行下载。

Action push恶意脚本.png

Action push恶意脚本


2.4.2数据加密与发送

数据外传格式,随机生成的12字节IV与32字节AES密钥,AES-GCM加密数据之后,通过RSA公钥加密AES的IV与密钥,之后分别base64加密组成密文。

{
"envelope": "base64(iv + AES-GCM-ciphertext + authTag)",
"key": "base64(RSA-OAEP-encrypted-AES-key)"
}

该RSA通过11种payload内置的payload解密获得。

RSA公钥解密获得.png

RSA公钥解密获得


数据加密封装类.png

数据加密封装类


2.5感染与注入

感染的流程:

1. 解压tarball: 解压npm包到临时目录_tmp_{timestamp}_{random8bytes}

2. 将自身router_init.js注入到指定目录: 在package.json中写入"preinstall": "node setup.mjs"

3. 写入恶意 payload: 将selfReplicatingPayload_h7(AES-256-GCM 加密) 解密后写入setup.mjs

4. 递增版本号: 使篡改后的包版本号 +1

5. 重新打包: 生成package-updated.tgz

感染npm流程.png

感染npm流程


selfReplicatingPayload_h7脚本是11种payload其中之一解密得到的一个完整的自我复制脚本,运行流程:

1. 判断bun是否已经安装(恶意框架)

2. 如果有退出,如果没有判断系统版本win使用bun.exe其他使用bun,从hxxps://github.com/oven-sh/bun/releases/download/bun-v1.3.13/下载对应的版本。

3. 解压+给权限+执行(对应着本样本)

4. rm -rf清理临时目录

selfReplicatingPayload_h7脚本.png

selfReplicatingPayload_h7脚本


VSCode与Claude持久化与自动任务的脚本都是通过11种payload解密得到。持久化通过VSCode的配置文件创建SessionStart钩子实现持久化,写入位置.vscode/settings.json。通过Tasks自动运行,写入位置.claude/settings.local.json

VSCode持久化.png

VSCode持久化


Claude自动任务.png

Claude自动任务


伪装的41个tanstack生态包名

伪装的tanstack包名.png

伪装的tanstack包名



四、总结说明

综上所述,供应链投毒攻击通过迂回手段、利用终端用户系统漏洞实施入侵,污染软件开发、分发、运行的上游供应链环节,使恶意代码依附于正规软件或组件,顺势侵入众多下游业务系统。

火绒安全在此提醒广大用户:及时核查tanstack相关依赖包版本;慎用破解版、不明来源安装包,引入组件前核查漏洞补丁;实时开启安全防护软件,规范凭证权限管理,严防供应链投毒风险入侵。

除了使用火绒安全软件进行查杀,用户还可手动处置,可通过扫描tanstack/*文件夹路径下的文件是否带有以下字符串来确认。

"optionalDependencies": {
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}



五、IOC信息

Md5:833fd59ebe66a4449982c6d18db656b4

C2:git-tanstack.com/router:443

Session Seed 节点 1 :seed1.getsession.org

Session Seed 节点 2 :seed2.getsession.org

Session Seed 节点 3 :seed3.getsession.org

Session 文件存储 :filev2.getsession.org

Github Workflow Action push外联:api.masscan.cloud

SessionID:05f9e609d79eed391015e11380dee4b5c9ead0b6e2e7f0134e6e51767a87323026



六、附录

1、名词解释

GitHub :是全球最大的代码托管平台。

OIDC凭证:网络身份认证中使用的核心令牌,类似于身份证的作用。

tanstack :是一套用于构建现代Web应用的开源开发工具库集合广泛应用于 React、Vue、Solid 等前端生态。

TeamPcp:黑客组织名称

Mini Shai-Hulud:供应链蠕虫病毒Shai-Hulud的升级版,由TeamPcp组织开发并且于2026年5月在暗网BreachForums开源。

Npm:是Node.js运行时环境默认集成的软件包管理工具。

CI/CD: 一种自动化的软件开发与交付流程。该环境下权限高,可访问各类凭证,可修改npm发布流程实现供应链传播。


2、相关调研信息

域名调研

域名调研.png

证书调研

证书调研.png

域名注册后约1.5小时内完成SSL证书签发,攻击流程高度自动化。


通过对router_init.js代码中解密字符串确认其属于Mini Shai-Hulud家族,被TeamPcp组织使用。


恶意仓库创建时的字符串.png

恶意仓库创建时的字符串


TeamPcp在攻击中使用的账户(github.com/zblgg与github.com/voicproducoes)(经过对该账号的所有行为分析,账号属于被TeamPcp组织盗用)


3、11种payload概括描述

11种payload概括描述.png


解密脚本AES-256-GCM + beautify解密。该脚本密钥0c0e873033875f1bc471eda37e3b9d0f9b89bd41a4bbb4f86746caa2176c40aa


import hashlib
import struct
import base64
import gzip
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

# ============================================================
# beautifyCipher_x1 — custom substitution cipher
# ============================================================

BEAUTIFY_MASTER_KEY_HEX = "0c0e873033875f1bc471eda37e3b9d0f9b89bd41a4bbb4f86746caa2176c40aa"
PBKDF2_SALT = "svksjrhjkcejg"
PBKDF2_ITERATIONS = 0x30D40  # 200000
PBKDF2_KEYLEN = 0x20         # 32 bytes
PBKDF2_HASH = "sha256"


class Sha256CtrPrng:
    """SHA256-based CTR_DRBG-style PRNG (mirrors sha256CtrPrng_k1)"""

    def __init__(self, key: bytes):
        self.key = key
        self.counter = 0
        self.buf = b""
        self.offset = 0

    def refill(self) -> None:
        h = hashlib.sha256()
        h.update(self.key)
        h.update(struct.pack(">Q", self.counter))
        self.counter += 1
        self.buf = h.digest()
        self.offset = 0

    def next_byte(self) -> int:
        if self.offset >= len(self.buf):
            self.refill()
        b = self.buf[self.offset]
        self.offset += 1
        return b

    def next_u32(self) -> int:
        return (
            (self.next_byte() << 24)
            | (self.next_byte() << 16)
            | (self.next_byte() << 8)
            | self.next_byte()
        )


def fisher_yates_shuffle(prng: Sha256CtrPrng) -> bytes:
    """Fisher-Yates shuffle producing a 256-byte permutation (mirrors fisherYatesShuffle_aN)"""
    arr = list(range(256))
    for i in range(255, 0, -1):
        limit = 0xFFFFFFFF - (0xFFFFFFFF % (i + 1))
        while True:
            r = prng.next_u32()
            if r <= limit:
                break
        j = r % (i + 1)
        arr[i], arr[j] = arr[j], arr[i]
    return bytes(arr)


def _derive_master_key() -> bytes:
    """PBKDF2 key derivation (mirrors beautifyCipher_x1 constructor)"""
    # NB: JS passes the hex string AS-IS (UTF-8) to pbkdf2Sync, NOT the decoded bytes
    password = BEAUTIFY_MASTER_KEY_HEX.encode()
    return hashlib.pbkdf2_hmac(
        PBKDF2_HASH, password, PBKDF2_SALT.encode(), PBKDF2_ITERATIONS, dklen=PBKDF2_KEYLEN
    )


# Derived once at module load (mirrors beautifyCipherSingleton_UI)
_beautify_master_key = _derive_master_key()


def beautify_decode(encoded_input: str) -> str:
    """
    Decrypt a beautify-encoded string (mirrors beautifyCipher_x1.decode / NI / globalThis.beautify)

    Format: base64(nonce[12] || ciphertext[n])
    Each ciphertext byte is decoded via a position-dependent inverse permutation.
    """
    data = base64.b64decode(encoded_input)
    nonce = data[:12]
    ciphertext = data[12:]

    # seed = SHA256(masterKey || nonce)
    h = hashlib.sha256()
    h.update(_beautify_master_key)
    h.update(nonce)
    seed = h.digest()

    output = bytearray(len(ciphertext))
    for i in range(len(ciphertext)):
        # per-position key: SHA256(seed || i.toString())
        h_i = hashlib.sha256()
        h_i.update(seed)
        h_i.update(str(i).encode())
        key_i = h_i.digest()

        # Build permutation from PRNG
        prng = Sha256CtrPrng(key_i)
        permutation = fisher_yates_shuffle(prng)

        # Build inverse permutation
        inverse_perm = [0] * 256
        for j in range(256):
            inverse_perm[permutation[j]] = j

        output[i] = inverse_perm[ciphertext[i]]

    return output.decode("utf-8")


# ============================================================
# aes256gcmDecrypt_w8 — AES-256-GCM + gzip
# ============================================================


def aes256gcm_decrypt(key_hex: str, payload_b64: str) -> str:
    """
    Decrypt an AES-256-GCM-encrypted, gzip-compressed payload.
    (mirrors aes256gcmDecrypt_w8)

    Format: base64(iv[12] || authTag[16] || ciphertext[n])
    """
    key = bytes.fromhex(key_hex)
    full_data = base64.b64decode(payload_b64)

    iv = full_data[0:12]          # bytes 0x0 - 0xc  (12 bytes)
    auth_tag = full_data[12:28]   # bytes 0xc - 0x1c (16 bytes)
    ciphertext = full_data[28:]   # bytes 0x1c onward

    aesgcm = AESGCM(key)
    # cryptography's AESGCM.decrypt expects (nonce, ciphertext+tag, aad)
    plaintext = aesgcm.decrypt(iv, ciphertext + auth_tag, None)

    decompressed = gzip.decompress(plaintext)
    return decompressed.decode("utf-8")


# ============================================================
# Combined convenience — mirrors the usage pattern in the JS
# ============================================================


def decrypt_config_blob(beautify_blob: str, payload_b64: str) -> str:
    """Full two-stage decryption as used throughout the script."""
    key_hex = beautify_decode(beautify_blob)
    return aes256gcm_decrypt(key_hex, payload_b64)



安全无忧,一键开启

全面提升您的系统防护