Jul 17, 2026BTA Team10 min read

A Draft PR Was Enough: How a Pwn Request Turned AsyncAPI's Own Release Pipeline Into a Miasma RAT Drop

The strangest thing about the AsyncAPI npm compromise on July 14 is what a defender would find if they diffed a package.json from before the attack against one from during it. Nothing suspicious. No preinstall hook, no postinstall hook, no unfamiliar dependency. The tarballs got published from the real asyncapi/generator repository, through the real asyncapi-bot identity, each one carrying a valid SLSA provenance attestation, as Microsoft's incident write-up documents. A supply-chain policy that said "install packages with verified provenance from a known upstream repository" would have said yes.

We at BlueTeamAutomation kept coming back to that detail. The signal every recent hardening guide told a blue team to trust as the safe green light was the exact signal the attacker was leaning on to get code into three million weekly downloads. Everything about the publish looked authentic because everything about the publish was authentic. What was not authentic was the commit that triggered it, and the chain from that unauthorized commit to a running RAT on a build runner (five malicious versions across four packages, per the maintainer advisory and BleepingComputer's summary) is worth understanding in detail.

How a pull request reached protected branches

The initial access point was a workflow the AsyncAPI team wrote for a very ordinary reason: they wanted to give pull requests a Netlify preview. That workflow, manual-netlify-preview.yml, used pull_request_target, one of two GitHub Actions triggers that runs in the security context of the base repository rather than the fork. Anyone with a GitHub account can open a pull request against asyncapi/generator, but only pull_request_target lets that PR's workflow read secrets from the base repository. The trigger works safely as long as the workflow refuses to check out the PR's own code.

The AsyncAPI workflow checked out the PR's code anyway. Once GitHub Actions ran actions/checkout against the PR head, the runner cloned attacker-controlled files into the workspace with base-repository credentials available and the checkout token persisted in the repo's local git config. That is the "pwn request" pattern watchTowr and StepSecurity have been describing for years, and it turns any scripted step into arbitrary attacker code running with the workflow's full privilege. Microsoft pins the malicious commit to hash 47be388 in PR #2155 at 05:08:58 UTC, which told the workflow to fetch a rentry paste and evaluate it. What that eval needed was one file: the asyncapi-bot personal access token actions/checkout had left in .git/config. Once the PAT walked out of the runner, none of the protected-branch rules mattered, because the PAT had permission to push directly.

Trusted publishing signed a stolen commit

AsyncAPI publishes to npm through trusted publishing, so the PAT never had to touch npm directly. Instead, the attacker used it to push a poisoned commit onto a release branch, then let the normal release workflow do its job. That workflow authenticated to npm via GitHub Actions OIDC, npm saw a token signed by GitHub for the asyncapi/generator repository on a trusted branch, produced a SLSA provenance attestation pointing back at the workflow run, and pushed the tarball. From npm's viewpoint the release was as legitimate as any other; from the attacker's, the trust chain from GitHub Actions to npm turned a stolen bot PAT into a signed release under an identity nobody would question.

Five versions went out in about four hours: @asyncapi/generator 3.3.1, @asyncapi/generator-components 0.7.1, and @asyncapi/generator-helpers 1.1.1 near 07:10 UTC, then @asyncapi/specs 6.11.2-alpha.1 and 6.11.2 at 08:06 and 08:30 UTC with byte-identical payloads so any lockfile pinned to either the prerelease or the stable channel resolved to the compromise. npm unpublished all five by 11:18 UTC, but because npm mirrors cache aggressively, some proxies served the trojanized versions later.

The payload waits for require(), not npm install

Blue teams learned to distrust npm lifecycle scripts long ago. npm ci --ignore-scripts is the boilerplate defense, and StepSecurity Harden-Runner blocks unexpected preinstall network activity by default. This payload sidestepped both by declaring no lifecycle hooks at all; the malicious behavior lived inside code paths Node.js evaluates on require() and import, so scanners hunting for preinstall or postinstall shell had nothing to see.

The injection points sit inside files a downstream consumer will obviously exercise. Microsoft documents them as index.js in @asyncapi/specs, lib/templates/config/validator.js in @asyncapi/generator, src/utils.js in @asyncapi/generator-helpers, and lib/utils/ErrorHandling.js in @asyncapi/generator-components. When an application says require('@asyncapi/specs'), Node executes the loader stanza the attacker planted, and the loader immediately spawns a detached Node process with stdio unbound so nothing ties it to the parent.

That detached child then fetches an 8.2 MB sync.js from IPFS (CID Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf), decrypts it with a hard-coded HKDF-SHA256 key derived from rt-vault-master-key-32b-aaaaaaaa, unwraps AES-256-GCM and a rotation labelled ROT-94de, and evaluates the result. The runtime that lands is the Miasma RAT the researchers tracked as M-RED-TEAM v6.4, miasma-train-p1, with remote command execution, file transfer, and network proxying live at publication and further modules for browser credential theft, environment-variable sweeps across more than one hundred secret-shaped names, and worm propagation compiled in but held back by a runtime flag. The staged payload lives in a folder literally named NodeJS under per-user roaming or cache paths on all three operating systems, persistence writes an HKCU Run key miasma-monitor on Windows, a miasma-monitor.service systemd user unit on Linux, and shell RC appends on macOS, and the runtime lock sits at ~/.config/.miasma/run/node.lock.

Detection: process trees, IPFS pulls, and mDNS

The pattern that survives every stealth choice above is the process tree. A node process that spawns a detached second node with stdio ignored and then dials 85.137.53.71 on ports 8080, 8081, or 8091 is the artifact your EDR and network telemetry can key on. An IPFS gateway request carrying either of the CIDs Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf or QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9, from a host whose whole job is not IPFS work, is the same drop. The Miasma runtime also broadcasts an mDNS record for _miasma._tcp to discover peers on the local segment, which is unique enough to hunt for on its own, and any writes to HKCU\Software\Microsoft\Windows\CurrentVersion\Run\miasma-monitor, creations of miasma-monitor.service under ~/.config/systemd/user/, or file drops in a per-user folder literally named NodeJS should page a human.

In ATT&CK terms the chain reads as T1195.002 for the supply-chain compromise, T1078 and T1550.001 for the stolen PAT and the trusted-publishing token chain, T1059.007 for the Node interpreter, T1105 for the IPFS staging pull, T1547.001 / T1543.002 / T1546.004 for the three persistence variants, T1071.001 and T1090 for the C2 and proxy, and T1552.001 for the credential sweep the payload can flip on. A BAS run that exercises the detached child plus IPFS pull plus HKCU Run write will tell you which of your controls fire before the next window opens.

What to check on your fleet right now

If any workstation, developer laptop, or CI runner in your environment ran npm install or npm ci that resolved these four packages between 07:10 and 11:18 UTC on July 14, treat that host as compromised until you have verified otherwise. Diff every package-lock.json and pnpm-lock.yaml against the tainted versions, search endpoint telemetry for the file paths, registry keys, systemd units, and shell RC modifications named earlier, and pivot on network telemetry to 85.137.53.71 ports 8080/8081/8091 and IPFS gateway paths containing the two CIDs. On a hit, assume every secret readable from that machine is attacker property (npm tokens, GitHub PATs, cloud credentials, SSH keys, browser sessions) and rotate. Pin the AsyncAPI packages to versions predating July 14 or to the maintainer's post-remediation release, refresh lockfiles, and rerun installs against a clean cache.

Then take the harder step. Audit every workflow that uses pull_request_target, and confirm none of them run actions/checkout against github.event.pull_request.head or use expressions built from PR content. StepSecurity Harden-Runner or a similar egress-monitoring action will flag the outbound calls that both the credential exfiltration step and the stage-two payload rely on. Constrain trusted publishing to a workflow that only runs on tagged releases from a protected branch, and gate OIDC-based npm publishes behind a manual approval so a stolen bot PAT cannot walk a signed tarball onto the registry unaccompanied. Verifying the fix held means one more BAS run against the emulated chain, not a look at the workflow YAML and a checkbox.

This is the loop BlueTeamAutomation was built to run continuously, coupling BAS emulation of the pwn-request-to-trusted-publish chain and the module-load Miasma stage with EDR correlation, SIEM stitching, SOAR-driven token rotation, and audit-ready compliance evidence on every run. Detection and the proof it works stay continuous, so the next four-hour window is one your fleet has already rehearsed for.

See BASzy emulate this chain against your controls

Continuous BAS validation, EDR and SIEM correlation, and SOAR-driven response on hardware you control. Prove the patch actually held, on every run.

Explore BASzy →