At 08:30 UTC on 14 July 2026, @asyncapi/specs@6.11.2 went live on npm. This is AsyncAPI’s official package of JSON schemas, used by parsers and developer tooling to validate AsyncAPI documents. It recorded 2,662,072 downloads in the seven days ending 13 July. The package was already in widespread use.

The previous release, 6.11.1, was published on 30 January 2026 and is the last version I found without the loader. Version 6.11.2 added 27 lines to the top of its previously boring index.js. Import the package and those lines start a detached Node process, download 8.24 MB of encrypted JavaScript from IPFS, save it as sync.js, and run it out of sight.

I expected a downloader with a commodity stealer behind it. The static decryptor produced Miasma, a three-megabyte command-and-control framework. This build has an encrypted beacon, persistence, a remote shell, and signed payload replacement. It also carries dormant propagation code for npm, PyPI, Cargo, and Git repositories.

The source contains simulation labels. The published build still contacts public infrastructure, installs persistence, and accepts shell commands. I classify it as a backdoor based on that behaviour.

All payload handling in this analysis was static. I did not execute attacker-controlled JavaScript, beacon to the C2, or retrieve any operator data.

The 27-line trigger

There is no preinstall, install, or postinstall hook in the package. The malicious code sits in the normal module entry point, before the legitimate AsyncAPI schema exports. Any application that loads the library fires it.

The process launch is short:

spawn('node', ['-e', DOWNLOADER], {
  detached: true,
  stdio: 'ignore',
  windowsHide: true
}).unref();

The child gets no terminal, hides its window on Windows, and is detached from the importing process. unref() lets the parent exit without waiting for it. A successful import looks like a successful import.

Once its string table is resolved, the embedded downloader fetches this exact IPFS object:

hxxps://ipfs[.]io/ipfs/Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf

It writes the response to a directory that looks just plausible enough to ignore during a rushed triage:

Platform Dropped payload
Windows %LOCALAPPDATA%\NodeJS\sync.js
macOS ~/Library/Application Support/NodeJS/sync.js
Linux ~/.local/share/NodeJS/sync.js
Fallback ~/.config/NodeJS/sync.js

Then it starts node sync.js as another detached, silent process. The downloader does not verify the gateway response itself before passing the file to Node.

The full path from library import to backdoor looks like this:

Application imports @asyncapi/specs
  -> index.js calls injected main()
     -> detached node -e <downloader>
        -> GET exact IPFS CID
        -> write NodeJS/sync.js
        -> detached node sync.js
           -> decrypt campaign configuration
           -> decrypt and evaluate Miasma
           -> install persistence
           -> beacon about every 30 seconds
           -> accept shell, file, timing, and update commands

The first stage is simple enough to spot in a version diff. A schema package suddenly imports filesystem, HTTPS, operating-system, and child-process modules. It passes a long program to node -e and downloads a literal IPFS CID. Loading the dependency now starts a second payload.

Decrypting sync.js without running it

The downloaded sync.js is a self-decrypting wrapper. The operational code is hidden behind two AES-256-GCM envelopes and a ROT-94 transform:

  1. An encrypted campaign configuration.
  2. An encrypted 3,088,921-byte source bundle.
  3. A printable-character rotation applied to the source before packaging.

The keys are not secret. Both are derived from hardcoded strings with HKDF-SHA256:

Campaign configuration
  key material: rt-vault-master-key-32b-aaaaaaaa
  salt:         empty
  info:         rt-baked-key

Source bundle
  key material: rt-file-key-material-v1
  salt:         empty
  info:         rt-file-key

Each blob uses [12-byte IV][ciphertext][16-byte GCM tag]. The wrapper derives the keys, decrypts the source, reverses ROT-94, and hands the result to eval().

I reproduced those operations in an extractor that writes the plaintext to disk and never evaluates it:

node analysis/decrypt_asyncapi_stage2.js stage2_specs.js stage2_specs.plain.js
sha256sum stage2_specs.js stage2_specs.plain.js

The expected hashes are:

e9544a648d8fbaccd01b8477cf68471d48b89bf93eeacbdf5bba20fd296ff7b5  stage2_specs.js
f873941d1907a97dc6c718fdecf59fd7d91f3f8212da2f7e5314b878b88bdc0b  stage2_specs.plain.js

Validation of both GCM tags authenticated the recovered plaintext against the packaged blobs.

Most of the 8.24 MB wrapper is base64-encoded ciphertext, configuration, and parameters for building later mutated payloads. The decrypted file is an esbuild-style bundle with 617 vendored modules and a large prefix of mutation junk. Raw size is a poor measure of how much original malicious code it contains.

What this build actually does

The bundle mixes modules disabled by configuration with command handlers that are reachable as soon as the backdoor starts.

The baked configuration for the specs sample enables its beacon loop and autostart persistence. Its remote-shell, file-command, beacon-interval, and payload-replacement handlers can all receive commands from the C2. Automatic credential reconnaissance and ecosystem propagation are disabled.

Capability State in this sample Practical effect
Persistent beacon Enabled Contacts C2 and drains encrypted commands
Persistence Enabled Attempts platform-specific autostart
Remote shell Registered Runs attacker-supplied commands
File commands Registered Operates beneath /sim-fs
Payload replacement Registered Downloads and starts a replacement payload
Credential reconnaissance Disabled Harvester is present but not called automatically
npm, PyPI, and Cargo propagation Disabled Worm engines are present but not called
Anti-analysis checks Disabled VM, EDR, and locale checks are present but dormant

One configuration field says actualPersist: false, and nearby comments lean heavily on simulation language. The runtime reads toggles.persist, which is true, so bootWorm() attempts persistence regardless of that field or the comments.

The shell handler accepts an executable, arguments, working directory, and a timeout of up to 120 seconds. Its blacklist has one entry: killall. The dedicated file commands are confined to /sim-fs. The remote shell can read or write files elsewhere on the host, so that path restriction applies only to the file-command API.

Command and control

The primary C2 is cleartext HTTP at 85[.]137[.]53[.]71, with separate ports for beacon traffic, uploads, and proxy control:

C2 and command API:  hxxp://85[.]137[.]53[.]71:8080
Upload server:       hxxp://85[.]137[.]53[.]71:8081
Proxy control:       hxxp://85[.]137[.]53[.]71:8091
Beacon path:         /api/v1/beacon

The destination, port, path, and timing remain visible on the network. The beacon and command contents are encrypted inside the cleartext HTTP requests. Each victim creates a random secp256k1 key pair and stores it in a platform-specific camouflage path:

Platform Node identity file
Linux ~/.cache/mesa_shader_cache/gl_cache.bin
macOS ~/Library/Application Support/com.apple.spotlight/index-v2.cache
Windows %HOME%\AppData\Roaming\Microsoft\CryptnetUrlCache\Content\msrt.dat

The node ID is the first 16 hex characters of SHA-256(public_key). Miasma combines the victim private key with a baked attacker public key using secp256k1 ECDH, then derives a 32-byte channel key through HKDF-SHA256. Beacon and command envelopes use AES-256-GCM. The victim also signs the encrypted beacon envelope.

A proxy between the victim and C2 can verify the victim’s signature and its operator-signed certificate chain without decrypting the beacon. The C2 derives the same channel key and can read the contents.

The default beacon interval is 30 seconds with 20 per cent jitter. After three failed HTTP beacons, the node starts trying its Nostr and peer-to-peer fallback channels. Retry backoff doubles from two seconds and caps at 60. The server can change the delay, and command ID 12 installs a manual override.

If the hardcoded HTTP server stops answering, the node can fall back to Nostr and peer discovery. Every two minutes it also checks Nostr and an Ethereum mainnet contract for a new C2 address or payload. The framework includes IPFS retrieval, libp2p, BitTorrent DHT bootstrap, and mDNS discovery.

The configured contract at 0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710 had deployed bytecode and two post-deployment transactions. One published the same C2, upload server, relays, and DHT endpoints found in the decrypted configuration. The other published a replacement payload CID. Both writes happened more than five hours before the first poisoned package was released.

The timing confirms that the on-chain update channels were in use before the npm releases.

The staging contract came first

The deployer used a staging contract before creating the final campaign contract. Its wallet, 0x92d4C5413e4F7B258a114964101F9e1C6d64C6Ba, had nine visible transactions when I checked it. An Etherscan-labelled FixedFloat address funded it with 0.00891044 ETH.

The wallet then used that ETH to test and deploy the discovery infrastructure:

  1. It deployed a staging contract at 0x1969AB05D67b67FDCaa26240f738CCb077e1Cd84.
  2. It published placeholder endpoints, c2-proxy:8080 and upload:8081.
  3. Four hours later, it replaced them with 85[.]137[.]53[.]71:8080 and 85[.]137[.]53[.]71:8081.
  4. It republished the same live record one minute later.
  5. On 14 July, it deployed the final campaign contract.
  6. It published the live address record and generator-family update CID QmQtjE6NCaMGbcWvczwASpXTakZYrE6AmjnCnCdp8wHAo1.
  7. It swept the remaining 0.007881 ETH to 0xB56fbe164775665720C5d5cde675d9832360F7a8.

The first contract did not have the payload-update function found in the final contract. That code change suggests the blockchain discovery mechanism was still under active development between deployments. By the time the poisoned packages were published, the placeholder endpoints had been replaced with live infrastructure and the update path had been added.

Both contracts contain Solidity metadata CIDs in their bytecode:

Final contract:   QmWyGK2m8zwB7ic64TVzjMkbVAFrB5vMTHjuR6jpRsdVEV
Staging contract: QmZyHWYyEb5AxypYcv2WEyCve9urr7u8sTtLNEitSU6C7u
Compiler:         Solidity 0.8.36

I could not retrieve either metadata object through the IPFS gateways I tested. The bytecode metadata trailer still identifies the compiler version. A fuller comparison needs the missing objects.

The funding trail has limits. FixedFloat may hold transaction records that make the transfer useful through legal process. Its label does not identify a person or country. The sweep recipient has many unrelated deposits, including one from a Binance-labelled address, and may be a service deposit address. Treating it as operator-controlled would go beyond the evidence.

The sequence supports deliberate preparation. Roughly a day before the poisoned releases, the operator tested the discovery contract and replaced its placeholders with the campaign’s live C2. The final contract added payload updates before the packages were published.

Persistence and payload replacement

Miasma attempts persistence with the current Node executable and the dropped sync.js path:

Platform Persistence
macOS Appends a marked nohup block to .zshrc, .bashrc, or .bash_profile
Linux Writes and enables ~/.config/systemd/user/miasma-monitor.service
Windows Adds the HKCU Run value miasma-monitor

The Linux service is probably broken. Its ExecStart uses shell syntax without invoking a shell, and it is enabled without being started. macOS waits for a later shell launch. Windows waits for the next login.

The first sync.js process is already detached and running. These defects affect whether it returns after a reboot or login; they do not stop the initial execution.

A C2 command or newer update record from Nostr or Ethereum can provide another CID. The updater writes the new file under a random camouflage name, atomically renames it, starts it with the current Node runtime, closes the old C2 and peer connections, and exits.

The recon: false toggle disables automatic credential harvesting in this build. The operator can still use the remote shell or replace the payload without another npm release.

Dormant credential theft and propagation

The decrypted bundle contains harvesters for cloud credentials, package registries, source control, browser data, wallets, messaging clients, and developer tooling. It can fetch gitleaks and hack-browser-data. Separate engines can poison and publish npm packages, Python projects, and Cargo crates using stolen credentials.

The recovered specs configuration does not enable those propagation paths. The modules establish capability only; they are not evidence that the actions occurred on an infected host.

The enabled remote shell, persistence, and signed payload updates are sufficient to classify this build as a backdoor.

Before beaconing, the framework verifies a two-certificate chain against the baked attacker public key. I validated both signatures statically. The attacker root authorises a child key, and that child signs a blanket certificate used by later payload generations. The chain authenticates operator-issued generations. It is not a victim-side safety control.

The two injection paths

Three releases from the AsyncAPI generator monorepo also carried a wrapper variant and shared a second-stage CID:

Package Poisoned version Published UTC
@asyncapi/generator-helpers 1.1.1 07:10:42
@asyncapi/generator-components 0.7.1 07:10:44
@asyncapi/generator 3.3.1 07:10:48
@asyncapi/specs 6.11.2 08:30:09

All four were published through npm Trusted Publishing from GitHub Actions and carry SLSA provenance. Those attestations identify the workflow and source commit used for each release.

The generator source commit directly inserts the loader into four files and bumps the related package versions. The four additions are easy to miss among widespread line-ending changes and lockfile churn.

The attested specs commit changes one blank line in package.json. Its index.js is clean. GitHub Actions reported successful Linux, macOS, and Windows jobs, followed by a successful npm publish job. The resulting tarball contains a loader that is absent from the attested source commit.

The provenance points to two injection paths:

The current evidence does not identify the exact specs injection step. Blaming a mutable action, dependency, token, or runner would be guessing.

SLSA provenance binds each package to a workflow and source commit. It does not attest that the dependencies, actions, generated output, or publish step were clean. Here, it separates the loaders committed to the generator repository from the later injection in the specs release path. Neither package artefact is trustworthy.

Detection and response

Start with the exact package versions and process chain. Do not rely on finding a process called miasma. The executable is Node and the first payload is called sync.js.

legitimate-node-process
  -> node -e <long argument>
     -> node .../NodeJS/sync.js

High-signal host artefacts include:

%LOCALAPPDATA%\NodeJS\sync.js
~/Library/Application Support/NodeJS/sync.js
~/.local/share/NodeJS/sync.js
~/.config/NodeJS/sync.js
~/.config/systemd/user/miasma-monitor.service
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\miasma-monitor
### Node Auto-Update Script ###
~/.miasma/run/node.lock

On the network, hunt for the exact IPFS CID followed by connections to 85[.]137[.]53[.]71 on ports 8080, 8081, or 8091. A user-level Node process posting to /api/v1/beacon around every 30 seconds, with roughly 20 per cent variance, is worth immediate attention.

Do not block IPFS, public Nostr relays, Ethereum RPC, or public DHT routers based on this incident. They are shared services. Hunt for the campaign’s exact CID, contract, attacker key, on-chain C2 record, and surrounding process behaviour.

This YARA rule targets multiple independent strings in the recovered encrypted wrapper:

rule NPM_AsyncAPI_Specs_Miasma_Stage2_20260714 {
  meta:
    description = "Miasma stage-2 wrapper from @asyncapi/specs@6.11.2"
    date = "2026-07-14"
    sha256 = "e9544a648d8fbaccd01b8477cf68471d48b89bf93eeacbdf5bba20fd296ff7b5"
  strings:
    $k1 = "rt-file-key" ascii
    $k2 = "rt-baked-key" ascii
    $k3 = "rt-string-vault:" ascii
    $k4 = "__RT_BAKED__" ascii
    $k5 = "__RT_LOADER_PARAMS__" ascii
    $gcm = "aes-256-gcm" ascii
  condition:
    filesize > 4MB and filesize < 12MB and
    $gcm and 4 of ($k*)
}

If a host installed or loaded one of the affected releases, isolate it before cleaning. Preserve sync.js, process trees, network telemetry, user systemd units, shell profiles, Run keys, and CI logs. Kill the detached Node process after collection, remove persistence, and restore a known-clean package version.

Rotate credentials available to the affected user or build worker. This configuration disables the automatic harvester. The remote shell can still read credential files and environment variables directly. Rebuild CI workers from trusted images where practical. Deleting node_modules is not incident response.

At the final registry check on 14 July 2026, all four compromised versions still held their packages’ latest dist-tag. An unpinned install would still receive the compromised release.

Public evidence

Indicators of Compromise TLP:CLEAR HIGH
IP
85[.]137[.]53[.]71
SHA256
e9544a648d8fbaccd01b8477cf68471d48b89bf93eeacbdf5bba20fd296ff7b5f873941d1907a97dc6c718fdecf59fd7d91f3f8212da2f7e5314b878b88bdc0b