node-ipc is a Node.js inter-process communication library with 822,000 weekly downloads. On May 14, 2026, three versions of it were simultaneously published to the npm registry by a compromised maintainer account. Each carried an identical 80 KB obfuscated payload designed to harvest cloud credentials, SSH keys, CI/CD secrets, and AI tooling configurations — then exfiltrate everything silently over DNS. The malicious tarballs were live for approximately two hours before detection and removal.
No npm system was breached to make this happen. The entire entry path ran through a domain registration.
This article explains the mechanism: how the attacker obtained publish rights, how the payload avoids standard detection, and how the exfiltration bypasses corporate DNS infrastructure entirely.
What node-ipc is, and why the download count matters
node-ipc is a Node.js inter-process communication library. It handles the plumbing between processes that need to talk to each other: Unix sockets, TCP, UDP, and Windows named pipes, all behind a single require. It is not a framework anyone consciously chooses — it is the kind of dependency that arrives transitively, buried several levels deep in a project's node_modules tree because something else pulled it in.
That is precisely what makes the download figure significant. The 822,000 weekly installs are not 822,000 developers who thought about node-ipc last week. They are mostly automated: CI/CD runners, npm install in Docker build steps, lockfile refreshes triggered by a dependabot PR. Any project whose dependency graph includes node-ipc without a pinned exact version will pull whatever npm considers current on the next install. On May 14, 2026, for approximately two hours, what npm considered current was a credential-stealing payload.
The attacker understood npm's semver resolution mechanics well enough to maximise that window. Three versions were published simultaneously: 9.1.6, 9.2.3, and 12.0.1. The 12.0.1 tarball was tagged latest. Together, those three entries captured every common pinning strategy short of an exact version lock: ^9, ~9.1, ~9.2, ^12, and ~12.0 all resolved to a compromised build. Versions 11.1.0 and other lines were left clean, consistent with a blast-radius maximization strategy rather than a blanket replacement.
The 9.x manipulation runs deeper than version numbers alone. The legitimate node-ipc 9.x branch never contained a node-ipc.cjs bundle file — that module format did not exist in the package's architecture at the time. The attacker did not inject code into an existing file for those versions. They copied the modern 12.x package infrastructure wholesale, stamped synthetic 9.1.6 and 9.2.3 version numbers onto it, and published a fabricated package structure. Old dependency trees pinned to ^9 received a build that bore no structural resemblance to any prior node-ipc release in that version line.
The access vector: one expired domain, one password reset
No npm system was breached. No CI/CD pipeline was touched. The original maintainer's machine was not compromised. The attacker's entire entry path ran through a domain registration.
node-ipc has twelve listed npm maintainers. One of them, the account atiertant, had publish rights on the package but no prior publish history — it had never been responsible for a single release. Its recovery email address was hosted on atlantis-software.net. That domain expired on January 10, 2025. It was re-registered through Namecheap on May 7, 2026 — one week before the attack.
Once the domain was under attacker control, the path to npm publish rights was a standard account recovery flow: configure mail delivery on the new registration, navigate to npm's password reset page, enter the email address, receive the reset link, set a new password. npm's authentication infrastructure worked exactly as designed. The vulnerability was not in npm's systems — it was in the assumption that a maintainer's recovery email address remains under that maintainer's control indefinitely.
Whether the atiertant account was subject to npm's mandatory 2FA requirement for high-impact packages is not confirmed in public reporting. npm introduced the requirement for maintainers of the most-downloaded packages, but grandfathering and enforcement gaps mean legacy accounts do not always comply. If 2FA was absent or recoverable via the same email flow, that would explain how a password reset alone was sufficient. That question has not been answered publicly, and the gap — whether a missing 2FA requirement or a recovery flow that bypasses it — is the structural issue regardless of which specific condition applied here.
Seven days after re-registering the domain, the attacker published all three malicious versions within 56 seconds of each other. The tight timestamp spread, combined with the identical payload across all three, points to automated tooling: a local build was prepared in advance, three package.json contexts were configured, and a script published them in rapid succession.
The payload: what it does and how it avoids detection
The malicious code is not in a postinstall script. Scanning for suspicious preinstall, install, or postinstall entries in package.json — a standard first step in supply chain audits — would find nothing.
The payload is an obfuscated Immediately Invoked Function Expression (IIFE) appended to the very end of node-ipc.cjs, after the final module.exports line. The ESM entrypoint, node-ipc.js, is untouched. This means the payload reaches only developers and build systems using require('node-ipc') — the CommonJS path. Pure ESM consumers are not directly exposed.
When require('node-ipc') resolves to the compromised node-ipc.cjs, the appended IIFE runs as part of module evaluation. It defers its main execution to the next phase of the event loop using setImmediate(), ensuring the host application completes its initialization sequence without lagging or throwing a block. The payload then forks a detached child process, setting the environment variable __ntw=1 to prevent re-execution on subsequent requires.
Before harvesting anything, the payload checks the context of its execution environment. It hashes the top-level entry filename — safely checking the require.main execution context via sha256(path.basename(require.main.filename)) — against an obfuscated table of hardcoded signatures. This is a targeted execution gate: the payload only proceeds if it is running inside specific developer toolchains or build environments the attacker intended to harvest. If the calling application does not match a target signature, the payload halts silently. Sandbox environments running isolated script evaluations produce nothing.
The harvest itself targets over 90 credential categories: cloud provider credentials across AWS, Azure, GCP, OCI, and DigitalOcean; SSH private keys; Kubernetes configuration files; GitHub CLI and GitLab tokens; .npmrc and .env files; Terraform state; shell history; database passwords. Notably, the target list includes AI coding tool configurations — .claude.json and .kiro/settings/mcp.json — reflecting how broadly developer tooling credentials have become high-value targets. The malware skips files larger than 4 MiB and avoids .git and node_modules directories, reducing both noise and execution time on the host.
Exfiltration: why DNS, and how the routing works
Most credential-stealing malware exfiltrates over HTTPS. DNS traffic is different — it is rarely blocked at the firewall level, rarely inspected by endpoint security tools scanning for C2 traffic, and on a developer machine running npm install, a burst of DNS queries is structurally indistinguishable from ordinary dependency resolution.
The payload compresses the harvested files into a gzip tarball written to <tmp>/nt-<pid>/<machineHex>.tar.gz. The machineHex value is not just a filename component — it is part of the encryption keying. The hardcoded string qZ8pL3vNxR9wKmTyHbVcFgDsJaEoUi functions as a master key; the actual encryption stream is dynamic, derived via SHA-256 by mixing that master key with the target machine's unique machineHex identifier. Each exfiltrated archive is effectively keyed to the specific host it came from.
The encrypted archive is then chunked and encoded into DNS TXT subdomain labels carrying three prefixes: xh., xd., and xf.. A 500 KB compressed archive generates approximately 29,400 queries.
Those queries do not pass through the public recursive DNS hierarchy. The payload uses a two-phase routing strategy. In the first phase, it resolves the lookalike domain sh[.]azurestaticprovider[.]net — constructed to resemble Microsoft's legitimate Azure Static Web Apps infrastructure — via a standard public resolver such as 8.8.8.8 or 1.1.1.1. This returns the raw C2 IP address. In the second phase, the payload calls resolver.setServers([C2_IP]) on a Node.js dns.Resolver instance, reconfiguring its own resolver to point exclusively at that IP.
All 29,400 subsequent TXT queries for subdomains of bt[.]node[.]js are fired directly to the attacker's machine, which acts as a rogue nameserver. Because these requests completely bypass corporate and public recursive resolvers, the domain bt[.]node[.]js leaves no trace in traditional upstream DNS logs. Security teams relying on DNS query logs from their resolvers will not see it.
Phase 1 — one-time IP resolution
payload → public resolver (8.8.8.8)
→ resolves sh[.]azurestaticprovider[.]net
→ returns C2 IP (37.16.75.69)
Phase 2 — direct-to-C2 data exfiltration
resolver.setServers([37.16.75.69])
payload → rogue nameserver at C2 IP
→ 29,400× DNS TXT queries
→ xh./xd./xf. labels under bt[.]node[.]js
→ XOR-encrypted, machineHex-keyed chunks
Detection and remediation
Check lockfiles immediately. Audit package-lock.json or yarn.lock for any of the three affected versions: [email protected], [email protected], or [email protected]. The malicious tarballs were live for approximately two hours on May 14. Any install or lockfile refresh that ran in that window should be treated as potentially compromised. Clean versions are 9.1.5 and 12.0.0, both published by the original author riaevangelist.
Rotate everything the environment could see. If an affected version was installed, assume the full harvest list is compromised: AWS, Azure, and GCP credentials; SSH private keys; GitHub and GitLab tokens; Kubernetes configs; .npmrc and .env files; database passwords; shell history. Rotate all of them.
Hunt direct-to-IP DNS traffic, not domain names. Because the exfiltration bypasses recursive resolvers, searching DNS logs for bt[.]node[.]js will return nothing on most enterprise setups. The correct hunt is for anomalous outbound UDP/53 traffic pointing directly to the C2 IP 37.16.75.69 instead of an internal forwarder. That direct-to-external-IP DNS connection is the indicator. Block that IP at the perimeter and alert on any future direct-to-IP DNS queries from developer workstations or CI/CD runners.
The structural issue behind the access vector. The atiertant account held publish rights on a package with 822,000 weekly downloads despite having no publish history and a recovery email on a domain that had been expired for over a year. Twelve maintainers are listed on node-ipc — a number that reflects accumulation over time, not active governance. The same pattern exists across a significant portion of the npm ecosystem: dormant co-maintainer accounts with unchanged publish rights, recovery emails on domains that have since lapsed or changed hands. Any package that has accreted maintainers over years, changed ownership, or gone through periods of inactivity is a candidate for the same vector. Auditing maintainer lists and verifying that recovery email domains are still under the right control is not a response to this specific incident — it is the hygiene the incident makes visible.
*Sources:
- StepSecurity advisory (May 14, 2026);
- Socket threat research blog;