Skip to content
← Blog

Attack post-mortem

Case study: replaying node-ipc@10.1.1 through the Veln gate

10 min read

In March 2022, the maintainer of node-ipc — a popular npm package used by Vue CLI and tens of thousands of indirect dependents — published a release that ran a quick check at install time: what country is this computer in? If the answer was Russia or Belarus, the package walked the filesystem and overwrote files with a heart-emoji character. On every other computer, the package behaved normally.

This is a textbook example of an environment-conditional payload: code that behaves benignly during testing on Western developer machines and turns destructive against a narrow geographic target population. It's also one of the harder attack classes to catch, because the malicious behavior doesn't run during package development, code review, or CI — only on the targets.

The Veln gate has a check specifically for this pattern, internally nicknamed the "LOL" signal (Logic Of Locality). This case study walks through how it — and four other checks — catch node-ipc@10.1.1.

What this case study is doing

Veln sits between your computer and the npm registry. When you run npm install some-package, Veln intercepts the request and runs more than twenty small checks on the package before letting it through. Each check either fires or it doesn't; each fired check deducts a little bit of trust from a starting score of 1.00. If the score drops below 0.30 (the default block threshold), the install fails.

A "case study" replays a known historical attack against the current agent. We log what fired, what didn't, and what the final verdict was — no fixture tuning, no special configuration.

The attack, in one paragraph

On March 7, 2022, Brandon Nozaki Miller (npm publisher RIAEvangelist) published node-ipc@10.1.1 containing a new file, ssl-geospec.js, that ran on the module's normal require path (not as an install script). The script issued an HTTP request to api.ipgeolocation.io to determine the host's country, and if the country code matched RU or BY, it recursively walked filesystem paths and replaced file contents with a heart-emoji character (). Versions 10.1.1 through 10.1.3 contained progressively more aggressive variations. The attack was tracked as GHSA-97m3-w2cp-4xx6 and CVE-2022-23812. Vue CLI included node-ipc as a transitive dependency, putting an estimated 10M+ weekly downloads at risk through that path alone.

The maintainer described the publication as protest-ware against the invasion of Ukraine. The technical mechanics — what the gate sees — are identical to a malicious destructive attack regardless of motivation, and the agent scores them the same.

The replay setup

We point a local agent at a fixture mirror of the registry response for node-ipc@10.1.1 plus the ssl-geospec.js payload as it shipped. The fixture preserves the exact tarball, the exact package.json, and the transitive-dependency tree at publish time.

The agent runs in enforce mode with gate_protection = "medium". No fixture tuning.

Check-by-check walkthrough

1. Does the code make a decision based on where the computer is, and then do something risky? — Yes.

In plain English: the agent reads through the package's source code looking for a specific pattern: code that checks one thing about the environment (the host's IP address, the country, the username, the operating system), and then uses that check to gate a risky operation. Legitimate code rarely does this — feature toggles and locale handling exist, but they usually don't put filesystem writes or network exfil behind an environment check. When the agent sees an environment check leading directly to file-write operations, it deducts trust.

This is the LOL signal. It exists because environment-conditional behavior is the defining shape of a payload that hides from testing. Code that always runs is easy to review. Code that only runs in certain places is, almost by definition, hiding something.

What the agent saw in node-ipc@10.1.1: the file ssl-geospec.js does exactly this — makes an HTTP call to a geolocation API, reads the country code from the response, and only then walks the filesystem with destructive intent. The conditional gate is the giveaway.

How much trust this costs: −0.20 out of 1.00. This is intentionally moderate because legitimate "act differently in different regions" code does exist (compliance switches, GDPR notices). The signal carries weight when it co-occurs with other risk signals — which is exactly what happens on this package.

Signal name in logs: static.source_env_gated_risky.

2. Does the code generate other code on the fly to hide what it's doing? — Yes.

In plain English: legitimate JavaScript almost never builds code from strings at runtime and executes it. The pattern Function(someString) or eval(someString) exists for a few real reasons (template engines, plugin systems), but in modern npm packages it's overwhelmingly used to hide intent: encode the dangerous payload as a string so it doesn't look dangerous to grep-based code review, then run it at the last moment.

The agent reads the source and flags this pattern when it sees a transformed string being executed as code.

What the agent saw in node-ipc@10.1.1: ssl-geospec.js doesn't call its destructive operations directly. It contains a large base64-encoded blob, applies a chain of transforms (reverse the string, base64-decode again), then passes the result to Function(...) and calls it. Three layers of indirection between the visible code and the actual payload.

How much trust this costs: −0.30 out of 1.00.

Signal name in logs: static.source_dynamic_eval.

3. Did the file list inside the tarball change in suspicious ways? — Yes.

In plain English: every npm package, when you download it, is a compressed archive containing a set of files. The agent computes a fingerprint of which files exist and what each file's content hash is, and compares against the previous version. If a patch-level bump suddenly adds a new file containing the payload, that's the kind of structural change that warrants attention.

What the agent saw in node-ipc@10.1.1: the file ssl-geospec.js is new — it didn't exist in v10.1.0. New files in a patch release aren't automatically malicious, but they raise the prior on every other check.

How much trust this costs: −0.10 out of 1.00.

Signal name in logs: artifact.drift_unverified.

4. Did the package add new dependencies that don't have a track record? — Yes.

In plain English: the agent looks at how the package's dependencies changed between versions. Brand-new packages — registered hours ago, no version history, no community usage — added as dependencies of a popular library are suspicious. This is a common pattern for sneaking a payload through the dependency tree.

What the agent saw in node-ipc@10.1.1: the package added peacenotwar as a new dependency (a freshly-registered package by the same maintainer, containing protest text files). peacenotwar was less than 24 hours old when node-ipc@10.1.1 shipped. The "brand-new transitive added to a popular package" sub-pattern fires.

How much trust this costs: −0.10 out of 1.00.

Signal name in logs: provenance.transitive_dep_delta.

5. Does the source code look more like opaque data than like readable code? — Yes.

In plain English: real source code is mostly low-entropy — repetitive keywords, structured indentation, English-like identifiers. Obfuscated or packed code looks more like random noise: high entropy per byte, no recognizable structure. The agent measures the ratio of high-entropy regions to total source size and flags files where most of the source looks like opaque data.

What the agent saw in node-ipc@10.1.1: ssl-geospec.js contains a ~14KB base64-encoded blob wrapped by ~30 lines of transform-and-execute code. About 78% of the file is high-entropy — well above the default 40% threshold. The agent doesn't need to decode the blob to flag the shape.

How much trust this costs: −0.15 out of 1.00.

Signal name in logs: static.source_obfuscation_score.

6. Is the version brand-new? — Yes, less than a day old.

In plain English: the agent enforces a "cooling window" by default. When a brand-new version of a package shows up on the registry, the install is held for 24 hours instead of going through immediately. By the time a package is 24 hours old, if anything is seriously wrong, the npm community usually notices. The cooling window buys time for upstream disclosure.

While cooling is in effect, the install returns a "retry later" response (HTTP 503 with a Retry-After hint) rather than a hard block.

What the agent saw in node-ipc@10.1.1: the version was about 4 hours old at the moment of evaluation. Well under the 24-hour threshold.

Effect on trust: the cooling window doesn't deduct trust — it short-circuits the install with a 503 response. In practice it's what catches the attack live, before the score-based checks even run.

Signal name in logs: policy.cooling.min_version_age.

Checks that didn't fire (being honest)

Several checks you might expect to catch this attack are silent. Being explicit:

  • Publisher-change is silent. The npm account is identical between v10.1.0 and v10.1.1 — same maintainer, same publisher fingerprint. This is intentional self-publication, not a hijack.
  • Dormant-revival is silent. node-ipc was actively maintained, with multiple releases per month leading up to the incident.
  • License-change is silent. License stayed MIT.
  • Install-script checks are silent. The payload is in the main module's require path, not in a preinstall or postinstall script. The install-script checks correctly score this package as zero — they're not the right detectors for this attack class.

The lesson: provenance checks don't catch maintainer-rogue attacks (the maintainer is publishing intentionally), and install-script checks don't catch payloads embedded in normal-execution code. The detection comes from the source-pattern checks and the cooling window.

The score and verdict, in plain English

The cooling window fires first and short-circuits the install. To show what the score-based path produces (and what would happen if a defender opted out of cooling with --allow-fresh), we start at 1.00 (full trust) and pull trust away for each fired check:

| Check | Trust deduction | |---|---| | Environment check gates risky behavior (LOL) | −0.20 | | Source generates and executes code on the fly | −0.30 | | File-tree fingerprint shifted (new file) | −0.10 | | Newly-registered dependency added | −0.10 | | Source looks more like opaque data than code | −0.15 | | Total deduction | −0.85 |

Final trust score: 0.15.

The block threshold at gate_protection = "medium" is 0.30. The score is below it.

Verdict: BLOCK. Combined with the cooling-window HOLD, two independent layers of defense apply. Even with cooling disabled, the source-pattern checks alone produce a block.

What a user would have seen, live

At 09:00 UTC on March 8, 2022 (had Veln been live then), a developer running npm install node-ipc@10.1.1 or any package that transitively pulls it (Vue CLI was the high-blast-radius vector) would have seen:

npm ERR! 503 Service Unavailable - GET https://gate.veln.sh/node-ipc/-/node-ipc-10.1.1.tgz
npm ERR! gate: code=hold_pending_review
npm ERR! gate: signals fired:
npm ERR!   policy.cooling.min_version_age: version age 4 hours; below cooling window threshold of 24h
npm ERR! gate: retry after 86400 seconds (24h cooling window)

After the 24-hour cooling window — by which time the attack had been publicly disclosed and threat researchers were watching the package — a retry would produce:

npm ERR! 403 Forbidden - GET https://gate.veln.sh/node-ipc/-/node-ipc-10.1.1.tgz
npm ERR! gate: code=policy_blocked
npm ERR! gate: signals fired:
npm ERR!   static.source_env_gated_risky: environment check gates filesystem-write branch; conditional payload
npm ERR!   static.source_dynamic_eval: source contains Function() applied to decoded string; obfuscated code-execution path
npm ERR!   artifact.drift_unverified: file tree fingerprint changed from previous version; new file added (ssl-geospec.js)
npm ERR!   provenance.transitive_dep_delta: new transitive dependency added (peacenotwar, age 17h)
npm ERR!   static.source_obfuscation_score: high-entropy source ratio 0.78 exceeds threshold 0.40
npm ERR! gate: trust score 0.15 below block threshold 0.30

Either way, the payload doesn't execute. The geolocation check never runs. The filesystem is never walked.

What this tells us about defense

Three observations:

  1. The LOL check is the architectural payload for environment-conditional attacks. Without it, the gate would still block this package on the strength of the obfuscation and dynamic-eval checks — but those checks fire on a broader class of legitimate code (lazy loaders, plugin systems, packed minifiers). The LOL check is what differentiates "obfuscation for performance" from "obfuscation to hide destructive behavior gated on environment."

  2. Cooling windows handle the timing, source checks handle the content. The cooling window blocks installation during the high-risk first-24-hour window after publish. The source-pattern checks block it permanently after that. The two layers protect against different scenarios: cooling catches "the attack window before disclosure"; source checks catch "the attack remains undisclosed indefinitely."

  3. Vue CLI users got protected for free. node-ipc was a transitive dependency of Vue CLI. Developers running npm install against an unrelated Vue project would have pulled node-ipc through the dep tree. The gate scores transitive packages with the same trust-signal pipeline as direct ones. A check firing on a transitive blocks the parent install — the right blast-radius isolation for protest-ware delivered through popular transitives.

What the comparison to other replays reveals

The event-stream replay fires publisher-change and dormant-revival — provenance checks that catch maintainer handoff attacks. The ua-parser-js replay fires install-script-critical/exfil/ioc — install-script checks that catch account-compromise install-script attacks. This node-ipc replay fires environment-gated-risky and dynamic-eval — source-pattern checks that catch maintainer-rogue embedded-payload attacks.

Three attacks, three signal subsets, three different defensive narratives. The shared check across all three is file-tree drift — the universal "something materially changed in the tarball" detector.

Reproducing this

The fixture, agent version, and CLI invocation are publishable on request. The verdict is deterministic — same inputs, same score, same checks.

What this means for you

If you're running veln safe npm install today, the source-pattern checks in this case study are evaluated against every npm package you pull, transitive or direct. Environment-conditional payloads — including IP-gated, time-bomb, and feature-flag-gated malware — fire the LOL check plus the supporting checks named above.

The setup:

veln onboarding
veln wrapper on

That's the same configuration that produces the verdict above on the same fixture.

References