TL; DR
Un grupo de 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 Ectoplasma.
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 Administrador de AWS Secretos 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 Secretos 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 escalando a 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.
| Paquetes | 5 names; coral-wraith alone republished across dozens of versions |
| Ecosistema | npm |
| Install vector | postinstall lifecycle script |
| Objetivo principal | AWS IAM role credentials + Secretos Manager Secreto values, environment variables, /app archivos |
| exfilar | 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 |
| Gravedad | high — cloud credential and managed-Secreto 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.
Colección. 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 Secretosmanager list-Secretos (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 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 ID de clave de acceso/SecretoAccessKey/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 / aplicación file walk reads hasta fifteen application files outside nodo_módulos, which can surface configuration, .env files, or source. Step 6 calls aws Secretosmanager list-Secretos in three regions; the credentials pulled in steps 1–3 are exactly what authenticates those calls, so the IMDS read and the Secretos Manager enumeration chain together into a single escalation: instance role → managed-Secreto 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 / CLAVE DE ACCESO SECRETA DE AWS / AWS_SESSION_TOKEN environment variables, confirm the identity with aws sts obtener-identidad-de-llamada, and then loop over every Secreto returned by list-Secretos llamar aws Secretosmanager get-Secreto-value on each — retrieving the Secreto contents, not just their names. The same versions also read process-substituted flag binaries (/readflag and friends) and attempt a recorrido de carga against any Rust project found under / aplicación, 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.
exfiltración. The collected data leaves the host on two channels. First, a beacon PUBLICAR a un fijo webhook.sitio collector, carrying the hostname, numeric UID, working directory, and hasta 120 KB of collected data. Second, the data is folded into a fake YAML “module manifest” and PUT a /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 PUBLICAR cuerpo a webhook.sitio includes the hostname, numeric UID, working directory, and hasta 120 KB of the collected blob, so even a single successful beacon delivers the full take. webhook.sitio 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.
El manifiesto 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.
Cronograma
The cluster shows incremental capability growth rather than a single drop. We order it by observed behavior, not by publish wall-clock:
| Fase | Packages / Versions | Comportamiento |
|---|---|---|
| Early run | coral-wraith 9999.0.x |
Inflated version numbers consistent with a dependency-confusion attempt; install-time enumeration and exfil |
| Semilla | coral-wraith 1.0.0 |
postinstall collects id/env/flag files; single PUT to 154[.]57[.]164[.]71:30782, marker ECT-472839 |
| Iteración rápida | coral-wraith 1.0.1 → 6.0.0 |
Dozens of releases in hours; payload gains the isAppWorker() gate, IMDSv2 credential pull, the full get-Secreto-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 |
| variantes | ecto-rust-read-f3a9c1 1.0.1–1.0.2 |
Adds extra sink markers ECT-987654, ECT-654321, ECT-839201 |
| variantes | 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 susurro 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 susurro beyond the analyzed range (hasta 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.
Indicadores de compromiso
All indicators below were extracted from the package source on disk. Network indicators are defanged.
Network
| Indicador | Rol |
|---|---|
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
| Indicador | Rol |
|---|---|
"postinstall": "node postinstall.js" |
Install vector |
ecto_module: YAML with power_level / ship_deck / cargo_hold claves |
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 |
Condición de activación |
aws Secretosmanager list-Secretos más del us-east-1, eu-west-1, eu-central-1 |
Secretos enumeration |
HTB{...} regex scrape |
Capture-the-flag harvest |
File hashes (sha256, captured at analysis time)
| Archivo | 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.sitio 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 Secretos 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 Secretos 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 Secretos 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.
Impact, trends & guidance for defenders
The exposure here is cloud-credential and Secreto disclosure inside build and runtime containers. An IAM role credential pulled from IMDS carries whatever permissions that role holds; Secretosmanager:ListSecretos (and any follow-on GetSecretoValue) widens that to stored application Secretos. 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 Secretos 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 Secretos API (aws Secretosmanager, gcloud Secretos, 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 –ignorar-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 instalar; 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 Secretos that were reachable from the affected build or runtime environment. Rotación de credenciales, not package removal, is the operative remediation once an install has run.




