Get a Demo
Under Attack?
Security Feed - Threat

No npm Token Required: Inside the AsyncAPI Supply Chain Attack

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:

PackageMalicious VersionSafe Pin
@asyncapi/specs6.11.2, 6.11.2-alpha.16.11.1
@asyncapi/generator3.3.13.3.0
@asyncapi/generator-helpers1.1.11.1.0
@asyncapi/generator-components0.7.10.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:57Attacker’s operational wallet funded — infrastructure staging begins
2026-07-13 00:11-04:06Four zero-value Ethereum contract interactions — prior campaign activity
2026-07-14 01:15-01:16Ethereum C2 contract initialized with two test writes
2026-07-14 02:03IPFS payload CID written into Ethereum contract — C2 armed
2026-07-14 06:58Malicious 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:0411 commits to asyncapi/spec-json-schemas alpha branch
2026-07-14 08:06@asyncapi/[email protected] published
2026-07-14 08:28Final 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.

ezgif.com-video-to-gif-converter

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:

image-4

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:

image-5

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:

image-6

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:

image-8

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

image-9

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:

image-10

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):

PortPurposeResponse
:8080C2 command server{"error":"NOT_FOUND"}
:8081Exfil / data upload{"error":"UNAUTHORIZED"}
:8091Proxy 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)DirectionCounterpartyValueSignificance
Jul 12 23:57INFunding wallet0.0089 ETHWallet funded
Jul 13 00:11-04:06OUT x4Unknown EOA0 ETHPrior activity
Jul 14 01:15-01:16OUT x2C2 contract0 ETHContract initialization
Jul 14 02:03OUTC2 contract0 ETHIPFS C2 command written
Jul 14 03:18OUTUnknown wallet0.0079 ETHPayment to unknown

The operational wallet (0x92d4c5413e4f7b258a) was funded from a second wallet (0x4e5b2e1dc63f6b91cb6cd759936495434c7e972f).

We checked the funding wallet. It holds approximately $9.3 million:

AssetValue
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:

image-11

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:

image-12

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

image-13

keyDerivationMethod: "none" means no password derivation step. The key comes from the Keychain directly.

Speed of compromise by OS:

OSResultTime to drain
macOSFull decrypt — no cracking neededMinutes
WindowsDPAPI + Local State (runs in user context)Near-immediate
LinuxRequires MetaMask password crackHours 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:

image-14

Why it didn’t propagate

The RAT has a boot gate in its config validation (internal reference LLD 00 §5.2):

image-15

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:

image-16

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:

image-17
image-18

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):

image-19

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

image-20

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:

image-21

Installed via systemctl --user enable with all output suppressed.

Blast Radius

Direct exposure:

PackageWeekly downloads
@asyncapi/specs2,741,713
@asyncapi/generator126,468
@asyncapi/generator-components46,135
@asyncapi/generator-helpers44,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

PackageMalicious versionSHA1 tarball
@asyncapi/generator3.3.193d8cffab1171a115228808e526d9bd7fe935e4e
@asyncapi/generator-helpers1.1.1not captured (packages yanked before retrieval)
@asyncapi/generator-components0.7.1not captured (packages yanked before retrieval)
@asyncapi/specs6.11.29b65b9f1bb27cef7b8a2b0327a47a539b8e473fb
@asyncapi/specs6.11.2-alpha.146a236cc9f140deb62bbcfb055e7865d5b23ad81

GitHub

TypeValue
Malicious commit (generator)3eab3ec9304aa26081358330491d3cfeb55cc245
Malicious commit (specs)61a930fca7241763e306e31abe4557221e84a76c
Attacker GitHub logininvalid-email-address (ID: 148100)

Network

TypeValue
C2 IP85.137.53.71 (NL, AS43641 SOLLUTIUM EU)
C2 serverhttp://85.137.53.71:8080
Exfil serverhttp://85.137.53.71:8081
Proxy mgmthttp://85.137.53.71:8091
Ethereum C2 contract0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710
Attacker wallet0x92d4c5413e4f7b258a114964101f9e1c6d64c6ba
Funding wallet0x4e5b2e1dc63f6b91cb6cd759936495434c7e972f
Nostr relaywss://relay.damus.io
Nostr relaywss://relay.nostr.com/

IPFS

PurposeCID
Stage-2 payload (generator packages)QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9
Stage-2 payload (specs)Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf
Ethereum C2 commandQmQtjE6NCaMGbcWvczuASpXTakZYrE6AmjnCnCdp8wHAo1

Host

TypeValue
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 labelsrt-file-key-material-v1, rt-baked-key, rt-string-vault:3
RAT master keyrt-vault-master-key-32b-aaaaaaaa
Campaign labelmiasma-train-p1
Framework versionM-RED-TEAM v6.4
Auth headerX-Miasma-Spawn-Chain

Recommendations

Pin immediately and do not install against latest:

image-22

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

image-24

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.

Contents

Further Reading

AI bottlenecks

AI Proves the Real Bottleneck Was Never Finding Vulnerabilities

Let this sink in: finding security vulnerabilities was never the bottleneck, vulnerability prioritization was. AI-scale discovery is about to make that impossible to ignore, and the teams that see it coming will pull ahead of the rest. Here's what kept me up this week. Anthropic ran a frontier model against some of the world's most…
SBOM for VM

Upwind gives you SBOM coverage across every cloud workload

Software supply chain risk doesn't stop at the container boundary. Most organizations still run a meaningful share of production workloads on virtual machines across legacy services, data pipelines, and infrastructure that was never containerized. The Upwind Platform creates SBOMs at runtime, delivering greater accuracy than build-time tools by continuously monitoring your live environment. Format adds…
Superhuman AI

Security AI Needs an Honest Scoreboard: What It’s Superhuman At, and Where It Comes Up Short

If you follow AI at all, you know the leaderboards. Every few weeks a model takes the top spot, and we all check where our favorite landed. But a leaderboard only tells you who's ahead, and it stays quiet about where any of those models still come up short. Which, conveniently, is the part that…