Ectoplasm npm Install Hooks That Steal AWS Credentials

Ectoplasm: npm install hooks that harvest AWS credentials behind a container-only trigger

TL;DR

A cluster of five npm packages, published across two account handles, shipped a postinstall hook that reads cloud credentials from the host and ships them off-box. The packages carry ghost-and-pirate names — coral-wraith, ecto-corsair-whisper-6f3b9, ecto-corsair-flag-x9m4, ecto-rust-read-f3a9c1, ecto-nightly-spirit — and serialize whatever they collect into a fake ecto_module: YAML manifest before transmitting it. We track the cluster as Ectoplasm.

The payload only runs when it detects a specific environment: a host whose name is a 12-character hex string and a working directory under /app/node_modules — the shape of a containerized build or CI worker. When that gate passes, the hook queries the AWS instance metadata service (IMDSv2) for IAM role credentials, enumerates AWS Secrets Manager across three regions, dumps environment variables, reads files under /app, and scrapes for capture-the-flag strings. It then exfiltrates the result two ways: a beacon to a webhook.site collector and a manifest PUT to a raw-IP endpoint, with a list of localhost-first fallbacks.

The later package descriptions read "CTF payload for verdaccio supply-chain testing." We report that self-description as an observable fact. The behavior itself — live egress to a public IP, real IMDS credential reads, real Secrets Manager calls — is what it is regardless of the label, and is the reason these versions were classified malicious.

One name in the cluster, coral-wraith, did not stop at a single release. It republished in rapid succession through dozens of versions in a matter of hours — 1.0.0 climbing to 6.0.0 — and an earlier run of the same name had used inflated 9999.0.x version numbers, the classic shape of a dependency-confusion attempt. Across that churn the payload visibly matured: from a one-shot host-enumeration beacon into a full AWS credential pivot wrapped in environment checks that keep it quiet outside its intended target.

Packages 5 names; coral-wraith alone republished across dozens of versions
Ecosystem npm
Install vector postinstall lifecycle script
Primary target AWS IAM role credentials + Secrets Manager secret values, environment variables, /app files
Exfil webhook.site beacon + raw-IP C2 PUT
Trigger gate 12-hex hostname + /app/node_modules cwd, plus an environment check that suppresses the payload outside that context
Severity high — cloud credential and managed-secret disclosure from containerized build and runtime environments

Attack anatomy

Every package in the cluster is built the same way: a near-empty index.js (module.exports = {}), a one-line package.json script — “postinstall”: “node postinstall.js” — and the payload in postinstall.js. Installing the package is enough to run the hook; no import or call is required.

The targeting gate. Before doing anything, the ecto-family payload checks its surroundings:

function isAppWorker():
  host = os.hostname()
  if host does NOT match /^[0-9a-f]{12}$/  -> exit
  if cwd does NOT contain "/app/node_modules" -> exit
  if cwd contains "/tmp/npm-safe"            -> exit
  otherwise -> proceed

A 12-hex hostname is the default shape Docker assigns to a container, and /app/node_modules is a conventional in-container install path. The third clause bails out if the path looks like a sandboxed extraction directory. The net effect is that the payload stays dormant on a developer laptop or an analysis sandbox and only activates inside a containerized build or runtime worker — the kind of environment most likely to carry live cloud credentials. The earliest package in the cluster, coral-wraith, has no such gate and runs its (simpler) collection unconditionally.

Collection. When the gate passes, the hook shells out through execFileSync(“/bin/sh”, [“-c”, …]) and runs a single compound command that, in order:

1. PUT /latest/api/token to 169.254.169.254          (IMDSv2 token request)
2. GET .../iam/security-credentials/                  (IAM role name)
3. GET .../iam/security-credentials/<role>            (temporary credentials)
4. dump env | sort                                    (environment variables)
5. list /app (excl. node_modules) + cat first 15      (application files)
6. aws secretsmanager list-secrets                    (us-east-1, eu-west-1, eu-central-1)
7. scrape readable files for HTB{...}                 (capture-the-flag strings)

Steps 1–3 are a textbook IMDSv2 retrieval: request a session token, then attach it as the X-aws-ec2-metadata-token header to pull the instance’s IAM role and that role’s temporary access keys. The choice to implement IMDSv2 rather than the simpler unauthenticated IMDSv1 GET is worth noting — it means the payload works even on instances configured to require token-based metadata access, which is the AWS-recommended hardening. The credentials returned by step 3 are short-lived AccessKeyId/SecretAccessKey/Token triples scoped to the instance’s role; whatever that role can do, the holder of those keys can do for the credential’s lifetime.

Steps 4–6 widen the take. The env dump captures anything the build or runtime process inherited — in practice this is where registry tokens, database connection strings, and API keys most often live. The /app file walk reads up to fifteen application files outside node_modules, which can surface configuration, .env files, or source. Step 6 calls aws secretsmanager list-secrets in three regions; the credentials pulled in steps 1–3 are exactly what authenticates those calls, so the IMDS read and the Secrets Manager enumeration chain together into a single escalation: instance role → managed-secret inventory. Step 7 is a nod to the capture-the-flag framing — when an HTB{…} flag is found it is sent on its own, otherwise the raw collected blob is chunked into four pieces and sent.

Later versions in the cluster carry the escalation further. Rather than stopping at an inventory, they parse the IMDS response, export the temporary keys as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN environment variables, confirm the identity with aws sts get-caller-identity, and then loop over every secret returned by list-secrets calling aws secretsmanager get-secret-value on each — retrieving the secret contents, not just their names. The same versions also read process-substituted flag binaries (/readflag and friends) and attempt a cargo run against any Rust project found under /app, widening the harvest beyond cloud credentials into whatever the build environment exposes.

These later versions also gate themselves more aggressively. In addition to the 12-hex-hostname and /app/node_modules checks, the payload inspects the active package-registry configuration and the working-directory path and exits silently when they indicate an analysis or mirror context rather than a live target. The combined effect is a payload that does nothing observable in most inspection environments and runs its full collection only where it judges itself to be on a genuine containerized host.

Exfiltration. The collected data leaves the host on two channels. First, a beacon POST to a fixed webhook.site collector, carrying the hostname, numeric UID, working directory, and up to 120 KB of collected data. Second, the data is folded into a fake YAML “module manifest” and PUT to /api/modules/<sink-marker> on a target server:

ecto_module:
  name: "<flag-or-chunk-0>"
  version: "1.0.0"
  power_level: "<chunk-1>"
  ship_deck: "<chunk-2>"
  cargo_hold: "<chunk-3>"

The manifest field names (power_level, ship_deck, cargo_hold) are decoration — the stolen data rides inside the string values, which is why a network monitor sees what looks like a benign package-registry manifest upload rather than an obvious data dump. The beacon channel carries more: the POST body to webhook.site includes the hostname, numeric UID, working directory, and up to 120 KB of the collected blob, so even a single successful beacon delivers the full take. webhook.site is a free request-inspection service; using it as a collector means the operator never has to stand up receiving infrastructure of their own for that channel, and the recorded requests persist in the service’s bin.

The manifest PUT walks a fallback list that begins with several 127.0.0.1/localhost ports and then falls through to three public addresses in the `154.57.164.0/24` range, stopping at the first endpoint that answers with a 2xx status. The localhost-first ordering is consistent with the “verdaccio testing” self-description (a local registry on loopback), but the public-IP fallbacks mean the data leaves the host whenever loopback is not listening — which is to say, on any machine that is not the author’s own test rig.

Timeline

The cluster shows incremental capability growth rather than a single drop. We order it by observed behavior, not by publish wall-clock:

Stage Packages / Versions Behavior
Early run coral-wraith 9999.0.x Inflated version numbers consistent with a dependency-confusion attempt; install-time enumeration and exfil
Seed coral-wraith 1.0.0 postinstall collects id/env/flag files; single PUT to 154[.]57[.]164[.]71:30782, marker ECT-472839
Rapid iteration coral-wraith 1.0.1 → 6.0.0 Dozens of releases in hours; payload gains the isAppWorker() gate, IMDSv2 credential pull, the full get-secret-value pivot, the registry/path environment check, and dual sink markers
Parallel names ecto-corsair-whisper-6f3b9 1.0.14–1.0.18 Same gated payload; webhook.site beacon and multi-endpoint fallback list
Variants ecto-rust-read-f3a9c1 1.0.1–1.0.2 Adds extra sink markers ECT-987654, ECT-654321, ECT-839201
Variants ecto-corsair-flag-x9m4 1.0.0, ecto-nightly-spirit 1.1.0 Same gated payload, same C2 and beacon

The defining trait of the cluster is its publishing cadence: rather than one package and one version, the same name is republished over and over in quick succession, each release a small variation on the last, alongside a handful of differently-named siblings carrying the same payload. Within the whisper family the code split into two close fingerprints — one set tripping two critical detections, another three (an additional file-read sink) — but both resolve to the same payload; the difference is code drift, not a behavioral fork. Versions of whisper beyond the analyzed range (up to at least 1.0.25 at the time of writing) were observed live on the registry, and the coral-wraith name continued to climb its own version ladder over the same window.

Indicators of compromise

All indicators below were extracted from the package source on disk. Network indicators are defanged.

Network

Indicator Role
hxxp://154[.]57[.]164[.]71:30782 C2 PUT target (coral-wraith)
hxxp://154[.]57[.]164[.]80:30543 C2 PUT fallback (ecto-*)
hxxp://154[.]57[.]164[.]82:31250 C2 PUT fallback (ecto-*)
hxxp://154[.]57[.]164[.]71:31289 C2 PUT fallback (ecto-*)
hxxps://webhook[.]site/602a4c72-7033-4e28-92ea-dc66e59206e5 Beacon collector
169[.]254[.]169[.]254/latest/... IMDSv2 credential read (target-side, AWS metadata)

Behavioral/file

Indicator Role
"postinstall": "node postinstall.js" Install vector
ecto_module: YAML with power_level / ship_deck / cargo_hold keys Exfil manifest schema
Sink markers ECT-472839, ECT-987654, ECT-654321, ECT-839201 C2 path segment /api/modules/<marker>
isAppWorker() gate: host /^[0-9a-f]{12}$/, cwd contains /app/node_modules Activation condition
aws secretsmanager list-secrets over us-east-1, eu-west-1, eu-central-1 Secrets enumeration
HTB{...} regex scrape Capture-the-flag harvest

File hashes (sha256, captured at analysis time)

File sha256
coral-wraith/postinstall.js ce5ff035cfdfed1d0015446424b352c27b66bcb77e9fdb0a51e4245199146824
ecto-corsair-whisper-6f3b9 1.0.18/postinstall.js b58432acba376aa6976f0490d9a1c04257ccdbc856d8390260c50322d63e31c3

Attribution & observed behavior

The five package names were published under two npm account handles, but they share enough infrastructure to treat them as one cluster: the same ecto_module manifest schema, the same ECT-472839 primary sink marker, the same webhook.site collector ID, and C2 endpoints in the same 154.57.164.0/24` block. The seed package (`coral-wraith, simpler and ungated) and the gated ecto-family thus read as iterations on one toolkit rather than independent efforts.

The packages describe themselves, in later versions, as “CTF payload for verdaccio supply-chain testing.” We surface that label as an observable fact and do not restate it as a finding about purpose. What the code does is unambiguous and independent of how it is labeled: it reads IAM role credentials from the instance metadata service, enumerates managed secrets across three AWS regions, and transmits the results to a public IP and a third-party webhook collector. A genuinely loopback-only test harness would not need the public-IP fallback list, the IMDS credential reads, or the cross-region Secrets Manager calls. Because the egress and credential reach are real, the gated versions were classified malicious.

The container-only gate is the most operationally notable trait. It is both an evasion measure — staying silent on laptops and in analysis sandboxes — and a targeting measure, firing only where a real IAM role and live secrets are most likely to be present. Analysts running these packages in a generic sandbox would observe nothing; the behavior only manifests under a Docker-style hostname and an in-container install path.

The exposure here is cloud-credential and secret disclosure inside build and runtime containers. An IAM role credential pulled from IMDS carries whatever permissions that role holds; secretsmanager:ListSecrets (and any follow-on GetSecretValue) widens that to stored application secrets. Environment-variable dumps frequently carry registry tokens, database URLs, and API keys. In a CI or container context — exactly what the gate selects for — a single transitive install of one of these packages is enough to leak that material.

Ectoplasm fits a pattern we continue to see: install-time payloads that reach for cloud metadata and managed secrets rather than local files, and that gate themselves to fire only in high-value environments. Two defensive observations follow.

  • The shape is detectable. An npm/PyPI install hook whose call graph reaches both a cloud secrets API (aws secretsmanager, gcloud secrets, az keyvault) or the IMDS address and a network egress sink is a narrow, high-signal pattern — it almost never occurs in a legitimate lifecycle script. Static flow analysis can flag it without depending on any specific domain or IP.
  • Environment hardening blunts it. Enforcing IMDSv2 with a hop limit of 1 prevents container workloads from reaching instance metadata; scoping IAM roles to least privilege limits the blast radius of any credential that does leak; and running installs with –ignore-scripts in CI removes the install-hook vector entirely for packages that do not need it.

For defenders, the practical checks are: alert on outbound connections from build/CI containers to non-allowlisted public IPs during npm install; watch for IMDS access originating from package lifecycle scripts; and treat any install hook that shells out to a cloud CLI as suspect until proven otherwise.

Two further notes specific to this cluster. First, because activation is gated to containerized environments, a package that appears inert when vetted on a workstation can still be live in production — vetting needs to reproduce the container hostname and path conditions, or read the source directly, rather than relying on “I installed it and nothing happened.” Second, the use of a public request-inspection service as a beacon collector means some of the exfiltrated data may be recoverable for incident response: an organization that finds one of these packages in its dependency tree can reason about what a successful beacon would have contained from the payload’s collection logic, and should rotate any IAM role credentials, registry tokens, and managed secrets that were reachable from the affected build or runtime environment. Credential rotation, not package removal, is the operative remediation once an install has run.

sca-tools-software-composition-analysis-tools
Prioritize, remediate, and secure your software risks
7-day free trial
No credit card required

Secure your Software Development and Delivery

with Xygeni Product Suite