极速扩散!TeamPcp组织利用高阶蠕虫大规模入侵开发者生态
-
作者:火绒安全
-
发布时间:2026-06-25
-
阅读量:1106
一、概述
近期,火绒安全工程师在监测网络安全过程中发现了异常情况。经过溯源与技术分析,确认了这是一起TeamPcp组织使用Shai-Hulud蠕虫病毒针对供应链的投毒,下方结合供应链厂商公开提供的信息进行事件还原与样本分析。
TeamPcp组织利用三个已知漏洞的链式攻击,通过GitHub平台缓存投放Mini Shai-Hulud供应链蠕虫病毒,窃取内存中的OIDC凭证。该组织利用所窃取的OIDC凭证发布新版本供其他用户下载,导致蠕虫病毒迅速扩散,在6分钟内于42个tanstack相关的npm包中发布了84个恶意版本。
该蠕虫病毒对上游供应链tanstack发起攻击,对下游所有使用该开源工具的用户构成危害。其通过窃取用户凭证(包括GitHub凭证、加密钱包、SSH密钥等)及各类基础设备信息,并感染用户自身仓库,进而实现交叉大规模扩散。
目前,火绒安全产品已经实现对该行为的拦截与查杀。

查杀图
二、流程图

三、样本分析
该蠕虫病毒在包中名称为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修复大量的变量、函数、类的名称。

去混淆前后对比
2.1主函数执行链
执行初始化->凭证窃取->数据加密与发送->感染与注入

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

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

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

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

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

连接Github Api

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

仓库名称词表

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

标记字符串

标记字符串写入github commit message

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

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

环境变量窃取法

Web Identity Token文件

ECS容器元数据

EC2实例元数据

![]()
本地文件配置
在获取凭证后,检查secretsmanager:ListSecrets和secretsmanager:GetSecretValue权限,直接窃取AWS Secrets Manager中存储的所有机密。

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

Runner.Worker内存dump

扫描凭证过程中的正则匹配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两者操作时分别触发。

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

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

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

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

Session的节点之一与TLS证书
该脚本还通过将恶意Github Actions Workflow注入受害者仓库利用 GitHub Actions Runner 进程独立外传 Secrets。这与主 C2 通道完全解耦,不受主进程存活与否、C2 域名是否可达的影响。
注入的脚本是通过AES-256-GCM + 自定义beautify函数双层解密。

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

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

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公钥解密获得

数据加密封装类
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流程
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脚本
VSCode与Claude持久化与自动任务的脚本都是通过11种payload解密得到。持久化通过VSCode的配置文件创建SessionStart钩子实现持久化,写入位置.vscode/settings.json。通过Tasks自动运行,写入位置.claude/settings.local.json

VSCode持久化

Claude自动任务
伪装的41个tanstack生态包名

伪装的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、相关调研信息
域名调研

证书调研

域名注册后约1.5小时内完成SSL证书签发,攻击流程高度自动化。
通过对router_init.js代码中解密字符串确认其属于Mini Shai-Hulud家族,被TeamPcp组织使用。

恶意仓库创建时的字符串
TeamPcp在攻击中使用的账户(github.com/zblgg与github.com/voicproducoes)(经过对该账号的所有行为分析,账号属于被TeamPcp组织盗用)
3、11种payload概括描述

解密脚本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)