Executive Summary
Upwind identified a critical supply chain compromise across five npm packages in the @asyncapi scope, published on July 14, 2026 via two separate branch compromises in two GitHub repositories.
The attacker never touched an npm token. They abused each project’s own CI pipeline through GitHub Actions OIDC to publish the malicious packages. The maintainers are victims.
The malicious releases contain a three-stage dropper injected directly into source files. The payload fires on require() — not at install time — and silently downloads an 8.25 MB AES-256-GCM encrypted RAT from IPFS.
We decoded the RAT offline using a hardcoded key embedded in the payload itself. The RAT’s command infrastructure spans three independent censorship-resistant channels: HTTP, an Ethereum smart contract, and Nostr relays.
We identified capabilities including:
– Browser credential theft (Chrome, Brave, Firefox, Edge)
– SSH key exfiltration
– npm, GitHub, and AWS credential theft
– macOS Keychain extraction – used to bypass MetaMask encryption without cracking
– Crypto wallet file theft (MetaMask, Exodus, Electrum, Phantom)
– Full worm propagation framework across npm, PyPI, RubyGems, and Cargo (disabled in this build)
– Ethereum smart contract dead-drop C2 (censorship-resistant by design)
– mDNS local network peer discovery for libp2p C2 mesh
Affected packages and safe pins:
| Package | Malicious Version | Safe Pin |
|---|---|---|
@asyncapi/specs | 6.11.2, 6.11.2-alpha.1 | 6.11.1 |
@asyncapi/generator | 3.3.1 | 3.3.0 |
@asyncapi/generator-helpers | 1.1.1 | 1.1.0 |
@asyncapi/generator-components | 0.7.1 | 0.7.0 |
Full technical disclosure: asyncapi/generator#2184 and asyncapi/spec-json-schemas#656.
How This Attack Is Different
Most npm supply chain attacks follow a predictable pattern: steal an npm token, publish a malicious version, wait for installs.
This one is different in four ways.
No npm token was stolen. The attacker pushed malicious commits to non-default branches and let each project’s own release pipeline publish the packages via GitHub Actions OIDC. The CI did the work.
No install hook. There is no postinstall or preinstall script. The payload fires the moment an infected file is loaded via require() during normal use — not at install time. A package.json audit shows nothing.
No native binary. The payload is pure JavaScript with a hardcoded decryption key. We decoded the entire RAT offline without executing it.
The C2 cannot be taken down with a domain seizure. The command infrastructure uses an Ethereum smart contract, Nostr relays, and IPFS — each channel independently censorship-resistant. Killing the HTTP server changes nothing.
Timeline
| Time (UTC) | Event |
|---|---|
| 2026-07-12 23:57 | Attacker’s operational wallet funded — infrastructure staging begins |
| 2026-07-13 00:11-04:06 | Four zero-value Ethereum contract interactions — prior campaign activity |
| 2026-07-14 01:15-01:16 | Ethereum C2 contract initialized with two test writes |
| 2026-07-14 02:03 | IPFS payload CID written into Ethereum contract — C2 armed |
| 2026-07-14 06:58 | Malicious commit pushed to asyncapi/generator next branch |
| 2026-07-14 07:10:42 | @asyncapi/[email protected] published |
| 2026-07-14 07:10:44 | @asyncapi/[email protected] published |
| 2026-07-14 07:10:48 | @asyncapi/[email protected] published |
| 2026-07-14 07:51-08:04 | 11 commits to asyncapi/spec-json-schemas alpha branch |
| 2026-07-14 08:06 | @asyncapi/[email protected] published |
| 2026-07-14 08:28 | Final blank-line trigger commit on spec-json-schemas |
| 2026-07-14 08:30 | @asyncapi/[email protected] promoted to latest — 2.74M weekly downloads exposed |
| The Ethereum C2 contract was armed 5 hours before the first package was published. The attacker staged infrastructure two days before the attack. |
Two Repos, One Attacker
The attack hit two separate GitHub repositories within the same hour.
asyncapi/generator — the next branch. Commit 3eab3ec9304aa26081358330491d3cfeb55cc245, authored by "Your Name" / [email protected], was pushed at 06:58 UTC. That is the default git identity when no user has configured git config user.name. It is not any AsyncAPI contributor.
asyncapi/spec-json-schemas — the alpha branch. Eleven commits were pushed between 07:51 and 08:28 UTC by the same GitHub account, user ID 148100, login invalid-email-address.
We confirmed both compromises share the same attacker fingerprint: identical GitHub user ID, identical unconfigured git identity, identical payload structure.
The two repos use distinct GitHub Actions OIDC configurations (oidc:72d01259 for generator, oidc:ff1fd238 for spec-json-schemas). The attacker accessed both independently. Rotating only the generator repo tokens is not sufficient.
We confirmed the next branch on asyncapi/generator had no branch protection rules configured — the branch protection API returned no rules. The alpha branch on asyncapi/spec-json-schemas accepted pushes from the same account with no rejection, consistent with no protection. A single token with contents: write scope was sufficient to push directly with no review on either branch.
The asyncapi/generator release workflow (release-with-changesets.yml) uses an org-level PAT (secrets.GH_TOKEN) to create release commits, and requests an OIDC token for the npm publish itself. The attacker did not need an npm token. Once the commit landed, the legitimate CI pipeline published the package automatically.
The Probe Commits
The spec-json-schemas compromise shows the attacker’s methodology clearly.
Before inserting the payload, the attacker made six consecutive version bump commits: 6.12.0, 6.13.0, 6.13.1, 6.13.2, 6.13.3, 6.13.4. All six were pushed within 13 minutes. None of them published to npm.
The attacker was mapping the release workflow’s trigger conditions. The workflow only fires when commits match specific conventional commit prefixes (fix:, feat:, etc.) and specific version formats. The plain version bumps didn’t match.
The plain version bumps did not trigger a publish. The payload was inserted after the probes, in commit 61a930fca724, using an alpha semver format that did trigger publishing.
After publishing the alpha, the attacker made a final commit that added a single blank line to package.json. This was the trigger to promote 6.11.2-alpha.1 to latest. It published at 08:30 UTC.
This is not opportunistic. The attacker understood the release pipeline before touching it.
The Injection
The two repos use different injection techniques, but both achieve the same result: the payload fires on require() with no install hook.
asyncapi/generator — hidden in whitespace
The injection lands on line 69 of lib/templates/config/validator.js, which was blank in the previous version. The payload is a single JavaScript line padded with approximately 1,000 leading spaces, placing the actual code far off-screen to the right.

The line is 16,288 bytes. The clean version of the same file is 8,602 bytes.
asyncapi/specs — prepended at the top
The injection in @asyncapi/specs takes a different approach. Twenty-six lines are prepended to the top of index.js using readable TypeScript-style import syntax:

The imports look legitimate. The inner stage-2 script is still obfuscated.
Why no install hook matters
There is no postinstall, preinstall, or prepare script in any of the malicious package.json files. Scanners that only check install hooks see nothing suspicious.
The payload fires when the infected file is loaded. In @asyncapi/generator, the main entry point generator.js has a top-level require:

This runs on every import of the generator. In @asyncapi/specs, the malicious code is prepended directly to index.js — the package’s main entry point — so it fires on any require('@asyncapi/specs') call, regardless of what the caller actually uses.
The commit messages on the generator repo were crafted to match the release workflow condition:

The fix: prefix is one of the allowed prefixes in the release workflow trigger. The CI saw a valid conventional commit and fired.
Three-Stage Payload
Stage 1 – the obfuscated IIFE
The injected code uses a _0x2d89 string array with rotation constant 0x32b8b, a pattern consistent with the javascript-obfuscator tool. This is what the raw injection looks like before decoding:

After decoding the string array and rotating to the correct checksum, stage 1 resolves to:

The -e flag passes the entire stage-2 script as an inline string. The process runs detached, with all output suppressed and the window hidden. .unref() ensures the parent process does not wait for it. Nothing is written to disk at this stage.
Stage 2 – the IPFS downloader
We decoded the stage-2 script from the obfuscated string array. It:
1. Selects a per-OS hidden directory:
Linux: ~/.local/share/NodeJS/
macOS: ~/Library/Application Support/NodeJS/
Windows: %LOCALAPPDATA%\NodeJS\
2. Downloads sync.js from IPFS
3. Spawns node sync.js with the same stealth flags
4. Calls process.exit(0) — the parent exits cleanly
The drop directory is named NodeJS, sitting alongside legitimate Node.js application data directories on each platform.
Stage 3 — the RAT
sync.js is 8.25 MB. It is self-decrypting using AES-256-GCM with a hardcoded master key:

Key derivation uses HKDF-SHA256 with three labeled outputs: rt-file-key, rt-baked-key, and rt-string-vault:3. Because all inputs are baked into the payload, we decrypted it entirely offline without executing a single line.
The decrypted source is 83,696 lines of JavaScript. The RAT’s hidden persistence directory is ~/.miasma/.
C2 Infrastructure Nobody Can Take Down
The RAT uses three independent command channels. Each one is chosen specifically because it cannot be disabled through conventional takedown procedures.
HTTP — three live endpoints
The primary HTTP infrastructure runs on 85.137.53.71 (NL, AS43641 SOLLUTIUM EU):
| Port | Purpose | Response |
|---|---|---|
:8080 | C2 command server | {"error":"NOT_FOUND"} |
:8081 | Exfil / data upload | {"error":"UNAUTHORIZED"} |
:8091 | Proxy management | {"error":"UNAUTHORIZED"} |
All three return structured JSON, confirming they are real authenticated REST API endpoints. The 401 responses on ports 8081 and 8091 mean the server is operational and enforcing authentication. Victim machines present a valid auth token baked into the RAT config.
The exfil server uses an asymmetric identity scheme. The auth header is X-Miasma-Spawn-Chain, a base64url-encoded signed credential derived from the worm’s key hierarchy (LLD 02 §3.1 in the RAT’s internal design docs). Victim machines self-mint 24-hour credentials.
Ethereum — on-chain dead-drop
The contract at 0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710 on Ethereum mainnet is a purpose-built C2 dead-drop. It is not a token. It has 7,890 bytes of custom bytecode with no standard ERC-20 selectors.
Its publisher() function returns the attacker’s operational wallet: 0x92d4c5413e4f7b258a114964101f9e1c6d64c6ba.
At 02:03 UTC on the day of the attack — five hours before the first package was published — the attacker wrote an IPFS CID into the contract as a C2 command. Infected machines call the contract to receive the current payload URL. The operator can update it at any time with a new CID.
This makes the payload delivery address permanently available on the blockchain. There is no server to seize.
Nostr — decentralized relay C2
The RAT uses two public Nostr relays for C2 messaging:
– wss://relay.damus.io
– wss://relay.nostr.com/
Nostr is a decentralized protocol with no central authority. Neither relay can be ordered to stop serving messages from a specific key. This channel provides a fallback C2 path that requires no infrastructure the attacker controls.
Ethereum Forensics
We reconstructed the attacker’s complete on-chain activity from the operational wallet.
| Time (UTC) | Direction | Counterparty | Value | Significance |
|---|---|---|---|---|
| Jul 12 23:57 | IN | Funding wallet | 0.0089 ETH | Wallet funded |
| Jul 13 00:11-04:06 | OUT x4 | Unknown EOA | 0 ETH | Prior activity |
| Jul 14 01:15-01:16 | OUT x2 | C2 contract | 0 ETH | Contract initialization |
| Jul 14 02:03 | OUT | C2 contract | 0 ETH | IPFS C2 command written |
| Jul 14 03:18 | OUT | Unknown wallet | 0.0079 ETH | Payment to unknown |
The operational wallet (0x92d4c5413e4f7b258a) was funded from a second wallet (0x4e5b2e1dc63f6b91cb6cd759936495434c7e972f).
We checked the funding wallet. It holds approximately $9.3 million:
| Asset | Value |
|---|---|
| ETH (1,106 ETH) | ~$1.97M |
| USDT | ~$3.75M |
| USDC | ~$1.87M |
| TUSD, USDP, LINK, BAT | ~$90K |
The high-frequency transaction pattern on this wallet — many small inputs and outputs cycling through the same addresses within minutes — is consistent with a mixer or OTC trading operation. The wallet was not created for this attack.
The operational wallet received only 0.0089 ETH — roughly $25. Just enough gas to write the C2 command and initialize the contract. The attacker kept the operational wallet isolated from their larger holdings.
What it Steals
The RAT is a collector. It does not drain wallets directly on the victim’s machine. It gathers credential files and uploads them to the exfil server. From the decrypted RAT source, the upload endpoint is:

With uploadServer set to http://85.137.53.71:8081 in the baked config. Wallet draining happens on the attacker’s side after they receive and process the stolen data.
Credential targets (confirmed from decrypted RAT source):
– Browser: Login Data, Cookies, Local State (Chrome, Brave, Firefox, Edge)
– SSH: id_rsa, id_ed25519, config, known_hosts
– Tokens: .npmrc, GITHUB_TOKEN, NPM_TOKEN
– Cloud: ~/.aws/credentials, ~/.oci/key.pem
– Dev tools: ~/.config/gh/, ~/.gitconfig
– macOS Keychain
– Crypto wallets: MetaMask, Exodus, Electrum, Phantom, Ethereum keystore files
The macOS Keychain bypass
MetaMask stores an encrypted vault in Chrome’s local extension storage. On Linux, cracking it requires knowing the user’s MetaMask password.
On macOS and Windows, it does not.
On macOS, Chrome stores its storage encryption key in the macOS Keychain under the entry Chrome Safe Storage. The RAT steals both the MetaMask LevelDB files and the macOS Keychain. With both, the vault decrypts without any password:

This is the MetaMask collector config from the decrypted RAT source:

keyDerivationMethod: "none" means no password derivation step. The key comes from the Keychain directly.
Speed of compromise by OS:
| OS | Result | Time to drain |
|---|---|---|
| macOS | Full decrypt — no cracking needed | Minutes |
| Windows | DPAPI + Local State (runs in user context) | Near-immediate |
| Linux | Requires MetaMask password crack | Hours to never |
Exodus (seed.seco) is weakly encrypted regardless of platform. It is known to crack in seconds with commodity tooling.
The Worm That Didn’t Fire
The RAT contains a complete worm propagation framework. We confirmed this from the decrypted source.
Propagation modules present in the code:
– packages/core/dist/propagate/phantom-gyp.js — the named worm propagation engine
– packages/core/dist/propagate/runtime-injection.js — runtime code injection into running Node processes
– packages/core/dist/propagate/build-toolchain.js — build pipeline infection
– packages/core/dist/propagate/vector.js — base propagation vector
The hidden directory ~/.miasma/ contains subdirectories for each propagation target:

Why it didn’t propagate
The RAT has a boot gate in its config validation (internal reference LLD 00 §5.2):

The baked config in this build has safeMode: true. The RAT refuses to run if this flag is not set.
All four propagation toggles are also explicitly disabled:

The campaign label in the baked config is miasma-train-p1. The baked config also has "poisonAI": false and "deadman": false — additional capability toggles that were off in this build. The -train-p1 suffix, combined with the safeMode boot gate, disabled propagation, and the disabled capability flags, is consistent with a controlled test deployment rather than a full live campaign.
mDNS local network discovery
The RAT also advertises itself on the local network using mDNS, under the service name _miasma._tcp. This allows infected machines to discover each other and form a libp2p peer-to-peer mesh, creating a local C2 network that requires no external infrastructure.
Self-Attribution: M-RED-TEAM v6.4
We found four references to M-RED-TEAM v6.4 in the decrypted RAT source:


This is the attacker’s own framework version label. It appears alongside an internal design document system referenced throughout the RAT code (LLD 00 through LLD 05), covering boot gating, auth schemes, beacon encryption, and C2 communication protocols.
The campaign self-identifies in three places we confirmed from our own decryption:
1. Baked config JSON (decrypted from the AES-256-GCM layer):

2. RAT persistence directory: ~/.miasma/ — from the decrypted source:

3. Worm propagation module: phantom-gyp.js
The systemd persistence unit, when enabled, installs as miasma-${unit}.service. The actual service template from the decrypted RAT:

Installed via systemctl --user enable with all output suppressed.
Blast Radius
Direct exposure:
| Package | Weekly downloads |
|---|---|
@asyncapi/specs | 2,741,713 |
@asyncapi/generator | 126,468 |
@asyncapi/generator-components | 46,135 |
@asyncapi/generator-helpers | 44,987 |
| Total | ~3.0M |
@asyncapi/specs is not a developer tool. It is a JSON schema library that any package handling AsyncAPI specifications may depend on transitively.
The Spectral transitive chain
@stoplight/spectral-cli is the industry-standard OpenAPI/AsyncAPI linting tool, with 1.24M weekly downloads. It depends on @stoplight/spectral-rulesets, which depends on @asyncapi/specs: ^6.8.0. That range resolves to 6.11.2.
Any project using Spectral for API schema linting in CI was exposed transitively — even if the project has no direct @asyncapi dependency.
We observed this real-world impact directly. The maykinmedia/open-afval project disabled their entire OAS linting CI workflow and referenced our disclosure issue (asyncapi/spec-json-schemas#656) as the reason.
Indicators of Compromise
npm packages
| Package | Malicious version | SHA1 tarball |
|---|---|---|
@asyncapi/generator | 3.3.1 | 93d8cffab1171a115228808e526d9bd7fe935e4e |
@asyncapi/generator-helpers | 1.1.1 | not captured (packages yanked before retrieval) |
@asyncapi/generator-components | 0.7.1 | not captured (packages yanked before retrieval) |
@asyncapi/specs | 6.11.2 | 9b65b9f1bb27cef7b8a2b0327a47a539b8e473fb |
@asyncapi/specs | 6.11.2-alpha.1 | 46a236cc9f140deb62bbcfb055e7865d5b23ad81 |
GitHub
| Type | Value |
|---|---|
| Malicious commit (generator) | 3eab3ec9304aa26081358330491d3cfeb55cc245 |
| Malicious commit (specs) | 61a930fca7241763e306e31abe4557221e84a76c |
| Attacker GitHub login | invalid-email-address (ID: 148100) |
Network
| Type | Value |
|---|---|
| C2 IP | 85.137.53.71 (NL, AS43641 SOLLUTIUM EU) |
| C2 server | http://85.137.53.71:8080 |
| Exfil server | http://85.137.53.71:8081 |
| Proxy mgmt | http://85.137.53.71:8091 |
| Ethereum C2 contract | 0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710 |
| Attacker wallet | 0x92d4c5413e4f7b258a114964101f9e1c6d64c6ba |
| Funding wallet | 0x4e5b2e1dc63f6b91cb6cd759936495434c7e972f |
| Nostr relay | wss://relay.damus.io |
| Nostr relay | wss://relay.nostr.com/ |
IPFS
| Purpose | CID |
|---|---|
| Stage-2 payload (generator packages) | QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9 |
| Stage-2 payload (specs) | Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf |
| Ethereum C2 command | QmQtjE6NCaMGbcWvczuASpXTakZYrE6AmjnCnCdp8wHAo1 |
Host
| Type | Value |
|---|---|
| Drop file (Linux) | ~/.local/share/NodeJS/sync.js |
| Drop file (macOS) | ~/Library/Application Support/NodeJS/sync.js |
| Drop file (Windows) | %LOCALAPPDATA%\NodeJS\sync.js |
| RAT persistence dir | ~/.miasma/ |
| Obfuscator token | _0x2d89, rotation constant 0x32b8b |
| HKDF labels | rt-file-key-material-v1, rt-baked-key, rt-string-vault:3 |
| RAT master key | rt-vault-master-key-32b-aaaaaaaa |
| Campaign label | miasma-train-p1 |
| Framework version | M-RED-TEAM v6.4 |
| Auth header | X-Miasma-Spawn-Chain |
Recommendations
Pin immediately and do not install against latest:

Check your lockfile for the resolved @asyncapi/specs version. If you use Spectral, your lockfile may resolve @asyncapi/specs transitively. Run:

If 6.11.2 or 6.11.2-alpha.1 appears, proceed with the checks below.
Check for the drop file on any host or CI runner that installed and used any affected package after the compromise windows (generator: 07:10 UTC / specs: 08:30 UTC on 2026-07-14):
– Linux: ~/.local/share/NodeJS/sync.js
– macOS: ~/Library/Application Support/NodeJS/sync.js
– Windows: %LOCALAPPDATA%\NodeJS\sync.js
If found, treat the host as compromised:
– Rotate: npm token, GitHub token, SSH keys, AWS credentials
– Treat all browser saved passwords as compromised
– If MetaMask or Exodus was on the machine: treat crypto holdings as potentially stolen
– Revoke any CI/CD secrets the machine had access to
Block outbound traffic to 85.137.53.71 (all ports) at your firewall.
For AsyncAPI maintainers:
– Rotate tokens for both asyncapi/generator and asyncapi/spec-json-schemas independently — they are separate compromise
– Add branch protection to all non-default branches used in release workflows
– Audit pull_request_target workflows for untrusted code execution paths
How Upwind Can Help
This attack demonstrates why runtime visibility matters alongside static package scanning.
The malicious behavior in this campaign occurred entirely at runtime: subprocess spawning with stealth flags, IPFS payload download, credential file collection, and encrypted HTTP exfiltration. A scanner that checks package.json scripts sees nothing. The payload fires on require(), not on install.
Upwind helps organizations detect suspicious subprocess execution, outbound connections to attacker infrastructure, credential file access, and unusual process behavior in CI/CD runners and cloud workloads. By correlating runtime execution with network activity and credential access patterns, Upwind helps security teams determine whether a malicious dependency was merely present or actually executed and what credentials may have been exposed.



