contagious interview fleet

An Undocumented OtterCookie-Lineage Build and Its Invisible C2 Fleet

How one stage-3 payload led to nine OtterCookie-related hosts, three server fingerprints, and a Contagious Interview infrastructure fleet sitting at zero detections.

Author: Yury Sergeev, RST Cloud

Published: 15 August 2026


Summary

A client shared a single stage-3 payload from a DPRK-nexus Contagious Interview delivery chain. Deobfuscating it yielded more than a capability list: it exposed the structure of the operator’s command-and-control tier, and that structure turned out to be fingerprintable.

Working outward from one IP address, we identified nine hosts across three autonomous systems, two distinct infrastructure generations, and two malicious npm packages feeding the same backend. Along the way we recovered three HTTP fingerprints that identify this operator’s servers directly, none of which appear in prior public reporting.

Key findings:

  • The stage-3 payload is a Node.js RAT and infostealer of the OtterCookie/OtterCandy lineage, carrying capabilities documented for no published version of either family — including a Windows DPAPI / Chrome App-Bound Encryption bypass, SSH private-key harvesting with SSH lateral movement, automatic cloud-credential theft, and a separately delivered keylogger.
  • The C2 is a three-tier Express deployment — panel on :5000, payload download on :5056, bulk exfiltration on :3011 — and each tier advertises an application-specific CORS header set that works as a durable fingerprint surviving IP rotation.
  • Exfiltration infrastructure is deliberately hosted on different providers from the panels. Six panel and loader hosts sit in AS397423 (Tier.Net); both exfiltration hosts sit elsewhere.
  • The operator migrated from a single-port :1244 BeaverTail-style pattern to the multi-tier Express design, and that migration coincided with a sharp drop in public reporting coverage.
  • The stage-0 and stage-1 loader chain matches a documented toolkit described by ENKI WhiteHat six months earlier, down to filenames and obfuscation scheme.
  • Every live Generation 2 host scores 0/91 on VirusTotal, and every file hash in this article is absent from VirusTotal entirely. Only the two retired Generation 1 hosts carry any detection at all.

All indicators are defanged below. Detection content is in Hunting and detection.


The lead

A client shared a set of artifacts describing a Contagious Interview delivery chain, along with the stage-3 URL. No compromise or impact was identified: the material was passed to us for analysis of what the payload would have done and where it pointed, not as a response to a successful intrusion. Nothing in this article should be read as describing harm to that organisation.

The chain as reported:

token_mac.sh                 (stage 0 — shell loader, fake interview lure)  → ~/.task/parser.js         (stage 1 — dropper; stages a private Node.js runtime)  → ~/.vs_cache/main.js       (stage 2 — in-memory loader, new Function over remote JS)  → hxxps://btwknrll.s[.]gy/843111567577a5dc   (stage 3 delivery)  → stage-3 payload           (infostealer + Socket.IO RAT + persistence)

Our starting artifact was the HTTP response body retrieved with curl -L against that short link — a JSON document of roughly 273 KB. Its SHA-256 is 0747ae321ecddc3936d53fe3ead743218732d6f39e31f3728f1977f6ae8b0c62.

The Switchy short link resolves straight to the C2 with a single hop:

GET https://btwknrll.s[.]gy/843111567577a5dc  → 301 → http://66.235.168[.]14:5000/session/843111567577a5dc

Two things are worth noting. The redirect target preserves the short-link token verbatim as the /session/<token> path component, so the token is an operator-side campaign or victim identifier, not a URL-shortener artifact. And the redirect drops from HTTPS to plaintext HTTP directly to a bare IP — a transition that is trivially detectable at a proxy and is one of the cleanest network-level opportunities in the whole chain.

The JSON is a counterfeit VS Code extension manifest impersonating Microsoft’s real Python extension:

"name": "vscode-python-envs","displayName": "Python Environments","publisher": "ms-python","version": "1.30.0","repository": "https://github.com/microsoft/vscode-python-environments.git"

Every field is plausible filler. The payload rides in one non-standard key — sessions — holding 250,704 characters of obfuscated JavaScript (SHA-256 fd3061f4f0e1c1cd92070659c1623a62809e0552223491bd2a016989f1e56bdd).


Deobfuscation

The payload uses stock obfuscator.io protection in three layers.

Layer 1 — string array. 3,687 entries, each encoded with a custom Base64 alphabet that places lowercase first:

abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=

Decoded output is then percent-decoded as UTF-8.

Layer 2 — rotation shuffler. A self-checking IIFE rotates the array with push(shift()) until an arithmetic checksum over seven decoded entries equals 0x60798. The accessor indexes at idx - 0x1ca. Because decoding is index-independent, the rotation must be solved before any string maps to the right position. Brute-forcing the checksum gave rotation = 315.

Layer 3 — dictionary and proxy indirection. Every constant and operator hides behind per-scope objects — M0['SEmem'] resolves to a URL, M0['Yjzby'](a,b) to a+b — with roughly 57 chained aliases of the decoder itself (Fz = a0C, then gi = Fz), plus switch-based control-flow flattening.

We wrote two Python passes rather than executing any attacker code. The first extracts the array, brute-forces the rotation, decodes all strings and resolves decoder aliases transitively. The second performs constant propagation across the dictionary objects, rewriting proxy calls back into native operators. Together they resolved 3,687 strings and 2,534 proxy references, producing readable source.

A practical note for anyone reproducing this: the decoded payload trips endpoint AV immediately. We kept it in memory throughout and never wrote it to disk.


What the payload does

Internal version string 1.2.2; hardcoded client ID 102.

On execution it enforces a single instance via a PID lockfile, opens a Socket.IO channel to the C2, registers the victim with system information and IP geolocation, and receives a storageKey tagging all subsequent uploads. It then begins collecting immediately, without operator interaction.

Credential theft

Automatic on every connect: ~/.config/gcloud~/.aws~/.ssh~/.azure — Google Cloud, AWS, SSH and Azure credentials, uploaded in that order.

Browsers. Chrome, Brave, Edge, Opera and Yandex profile roots, scanning Default plus Profile 1 through Profile 100. On Windows it reads Local State, extracts os_crypt.encrypted_key, strips the 5-byte DPAPI prefix, unprotects it via @primno/dpapi under CurrentUser scope, and AES-256-GCM decrypts each blob (IV bytes 3–15, tag last 16). It writes a helper script to disk and executes it under its own Node runtime to do this — an App-Bound Encryption bypass. On macOS it takes ~/Library/Keychains/login.keychain-db.

Shell history. ~/.bash_history~/.zsh_history~/.local/share/fish/fish_history, and on Windows the PowerShell PSReadLine\ConsoleHost_history.txt.

SSH key harvesting. History files are parsed for ssh invocations; a regex extracts -i and --identity arguments, resolves ~, and uploads each referenced private key.

Wallets

67 hardcoded browser wallet extension IDs — each extension’s Local Extension Settings directory is copied. Desktop wallets: Exodus, Guarda, Electrum, atomic, plus ~/Desktop/exodus-exports.

Filesystem sweep

Globs include *.env**.pem*.ppk*.p12*.pfx*.secret*metamask**bitcoin**solana**secret phrase**private key*, plus documents and source. Files are ranked, with operator-supplied keywords highest, then .env, config files, JSON, and — notably — screenshots, identified by .png files named or foldered screenshotscreencapturesnip.

On Linux the stealer enumerates /mnt/<drive>/Users/<user>/AppData/..., reaching out of WSL into the Windows host to take browser profiles from there.

Remote access

Socket.IO events from operator to victim: execread_filediruploadenvimppat. The exec handler accepts {instruction, cwd, sshPassword, sshKeyPath, adminUsername, adminId} — with SSH parameters set it runs commands against a remote host and reports via an ssh_status event. Built-in lateral movement using harvested keys.

Operator commands use an ss_ prefix: ss_delss_updss_force_updss_key_startss_key_stopss_key_statusss_stopss_pausess_resumess_cancelss_upfss_backup_pausess_backup_resumess_backup_status.

Follow-on payloads

/download-win-key returns JavaScript, stripped of BOM and shebang, written to ~/.vs_cache/.<pid>_<base36-timestamp>.js and spawned under the private Node runtime with --win-key-agent and environment AGENT_LABELSERVER_URLCLIENT_IDKEYBOARD_AGENT_QUIET=1autoStartCapture=true — a keylogger.

/download-app returns a binary, saved, chmod 0755, executed. On macOS the loader first runs xattr -d com.apple.quarantine, falling back to xattr -cr — an explicit Gatekeeper bypass.

One blended payload, not a two-stage chain

Readers familiar with this ecosystem will know the classic pattern: BeaverTail steals browser data, stages a Python distribution from /pdown, then hands off to InvisibleFerret, a Python backdoor beaconing on ports 1244/1245. It is reasonable to ask whether the RAT described above is InvisibleFerret arriving by another route.

It is not. The stealer and the RAT here are one Node.js codebase — the same file, the same Socket.IO connection, the same client_id and storageKey, running in one process. The exec/dir/read_file handlers sit alongside the credential-theft routines and share module-level state; there is no second-stage handoff. Searching the deobfuscated source returns zero references to Python, /pdown, AnyDesk, or ports 1224/1244/1245.

Architecturally this is a consolidation: two roles collapsed into one process, the Python runtime dependency dropped, and one C2 channel instead of two.

One caveat, stated plainly. We did not retrieve the /download-app binary. The loader treats it as a compiled executable — chmod 0755, quarantine stripped, spawned — and Trend Micro has documented Cython-compiled InvisibleFerret in Void Dokkaebi activity. A compiled InvisibleFerret is precisely the shape of payload that loader expects. So InvisibleFerret cannot be ruled out one stage further downstream, even though it is definitively not the in-process RAT. Retrieving that binary, and the keylogger agent from /download-win-key, are the main outstanding gaps in this analysis.

Persistence

The masquerade name varies by platform: Microsoft Realtime Agreegator on Windows (the misspelling is the attacker’s), AppleAccountSync on macOS, unixkernalloader on Linux.

  • Windows — HKCU\...\CurrentVersion\Run and ...\Explorer\StartupApproved\Run, with value names NvidiaDriverUpdateVScode.Update.Publisher and the masquerade name; Startup-folder .vbs and .cmd; launched via wscript.exe "~\.vs_cache\main.vbs".
  • macOS — ~/Library/LaunchAgents/com.AppleAccountSync.plistRunAtLoadStandardOutPath=/dev/null.
  • Linux — ~/.config/autostart/unixkernalloader.desktop.

Uninstall (--uninstall or ss_del) removes these, then drops %TEMP%\_vs_rm_<pid>_<timestamp>.vbs which deletes the payload and finally itself.

One operator artifact worth recording: log timestamps are generated with a hardcoded UTC+9 offset, with the literal string tz=UTC+9 in the code.


The infrastructure fingerprint

The payload’s own network code was the pivot. It references three services on distinct ports, and querying each with the RST Enrichment API showed all three advertise application-defined CORS headers — values set by the operator’s own code, not framework defaults.

TierPortRole
Panel / C25000Registration, tasking and Socket.IO channel
Payload download5056Follow-on binary and keylogger agent
Bulk exfiltration3011Resumable mass file collection
Admin console3000 / 80Operator-facing client management

Each tier advertises its own set of application-defined CORS headers, and the console carries a distinctive title and meta description. Those signatures are set out in Hunting and detection.

The exfiltration tier validated the static analysis neatly: the header set its CORS preflight advertises is exactly the set we recovered from the deobfuscated code, and HEAD is permitted — matching the /check deduplication request the payload makes before uploading a file.

That service is a resumable mass-collection endpoint. It exposes /check (HEAD, keyed on a Base64 file path, to skip already-stolen files), /status?machineId=/upload, and /complete.

One caution before using these. All these servers return the stock Express 404 body (Cannot GET /, SHA-256 52c1e7a2c36be28c42455fe1572d7d7918c3180cad99a2b82daa2a38a7e7bb23). That hash is not a usable indicator — every bare Express deployment on the internet returns it, and it is identical across the panel and download tiers, which are different applications. The CORS values are the discriminator.

A note on what is actually new here. The :5000 port and the /client path are not novel: Silent Push documented shared BeaverTail/InvisibleFerret infrastructure on port 5000 including /client/<id>/uploads and /pdown in April 2025. What we did not find in any prior reporting is the three-tier separation, the CORS fingerprints of each tier, and the :5056 and :3011 services themselves. The port-and-path convention is inherited; the service architecture and its fingerprints are the new part.


Enumerating the fleet

Pivoting on the panel signature across Netlas, Shodan and Censys, then verifying every candidate independently with the RST Enrichment API, produced nine hosts.

This table is a snapshot, not a census. Every cell records what a single active probe returned on 14 August 2026, from RST infrastructure:

  •  — the tier signature was returned live and matched. This is a positive identification.
  •  — no response to our probe at that moment. It does not establish that the service is absent. A host may be down, firewalled against scanner ranges, reachable only by tasked victims, or simply not listening on that port that day. Read every ✕ as “not observed”, never as “not there”.
  • historical — not live for us, but recorded in archived scan data.

The distinction matters in both directions. 91.202.5[.]124:3011 was initially recorded as ✕ and turned out to be a live exfiltration listener on a re-probe — which is how we found the X-Backup-Date variant at all. Conversely, a ✕ that later becomes ✅ is a normal outcome for infrastructure that rotates.

Every host was probed on every tier port. There are no untested cells.

HostAdmin UI :3000/:80Panel :5000Download :5056Exfil :3011Gen 1 :1244ASN
66.235.168[.]14 :3000AS397423
165.140.86[.]58AS397423
38.92.47[.]164AS397423
45.43.11[.]224AS397423
91.202.5[.]124 :80AS43641
5.175.213[.]199AS206996
45.59.160[.]215:3000 recorded openhistoricalAS397423
165.140.86[.]190AS397423
147.189.172[.]163AS206996
  • AS397423 — TIER-NET, Tier.Net Technologies LLC (US) — 6 hosts
  • AS43641 — Sollutium-NL, SOLLUTIUM EU Sp z.o.o. (PL/NL) — 1 host
  • AS206996 — ZAP-Hosting GmbH (DE) — 2 hosts, geolocating to Herndon VA and Los Angeles CA

45.59.160[.]215 no longer answered when we scanned, but a Netlas observation from 24 July 2026 recorded it serving the identical panel — same expose-headers, same port. Threat-feed data also records :3000 open on that host, which the finding below identifies as the admin tier. 165.140.86[.]190 and 147.189.172[.]163 answered on nothing; both are carried here on the strength of third-party reporting rather than our own observation.

147.189.172[.]163 sits outside the tier model, and it is worth being precise about why. The stage-3 payload references exactly two addresses — 66.235.168[.]14 and 5.175.213[.]199 — and this is not one of them. It reaches us instead from the @sqlite-labs/createsql indicator set, which covers the whole npm chain rather than stage 3 alone, and its /api/service/<md5> scheme on port 80 matches none of the four tier fingerprints. The most consistent reading is that it is an earlier-stage check-in endpoint on the npm delivery path, generationally aligned with the August activity and sharing AS206996 with the Generation 2 exfiltration host, but not part of the stage-3 command-and-control fleet. We could not probe it to confirm.

A fourth tier: the operator’s admin console

The console carries a distinctive HTML code, both of which internet-wide scan platforms index.

contagious interview admin console

Pivoting on that pair surfaces the operator-facing side of this toolkit — a React single-page application for managing infected clients, live on two hosts:

HostPortBundleServer
66.235.168[.]14:3000main.f5827c3b.js / main.ca5c44f2.cssnginx/1.30.4
91.202.5[.]124:80main.13da17b1.js / main.a0eed94c.css(no server header)

The markup is otherwise identical — same title and meta description, Bootstrap 5.3.0 from jsDelivr, Font Awesome 6.4.0 from cdnjs, and Socket.IO client 4.5.4 loaded from cdn.socket.io, matching the protocol the implant uses. The differing content-hash bundle names show these are two separate builds of the same application.

Three observations follow.

The operators gate their own console carefully. The landing page is a “Device Unlock” screen requiring a device serial and a XXXX-XXXX-XXXX-XXXX-XXXX API key, with the notice “Open this app via the Electron desktop client to unlock.”

The operator console presents a Device Unlock gate requiring a device serial and an API key, and states that the application must be opened through an Electron desktop client.

The operator-facing login gate. No credentials or client data are exposed — the device serial field is empty and the API key field shows only a format placeholder. We did not attempt authentication.

Panel access is bound to specific operator devices and fronted by a dedicated Electron application — noticeably more disciplined operational security than the victim-facing infrastructure, which leaves RPC and WSDAPI exposed to the internet. It also implies the operators distribute a purpose-built desktop client among themselves, which is a further piece of tooling not previously described.

The deployment timestamp lines up with the npm wave. The 66.235.168[.]14 console reports Last-Modified: Mon, 10 Aug 2026 19:14:16 GMT — inside the same three-hour window on 10 August in which @sqlite-labs/createsql was published and revised six times (16:56–19:52). Console build and package publication are the same operational push.

It explains a previously unattributed port. Threat-feed data recorded :3000 open on 45.59.160[.]215 alongside the Gen 1 loader port :1244, with no explanation at the time. It is the admin console, which means that host carried operator tooling as well as victim-facing infrastructure.

The console port is not fixed — :3000 on one host, :80 on another — so hunt on the title and body strings rather than the port.

Delivery and collection are hosted apart

Six hosts — every panel, download and legacy loader node — sit in AS397423, spread across unrelated netblocks (38.9245.4345.5966.235165.140), consistent with VPS instances bought individually over time. Within 165.140.86.0/24 the operator holds at least two addresses.

Sorting the fleet by function rather than by tier makes the pattern sharper:

FunctionProviderHosts
Panels, payload downloads, Gen 1 loadersAS397423 Tier.Netall six
Bulk exfiltration :3011AS43641 Sollutium · AS206996 ZAP-Hosting91.202.5[.]1245.175.213[.]199
Beacon / check-in (/api/service/<md5>)AS206996 ZAP-Hosting147.189.172[.]163

Not one AS397423 host in this fleet receives victim data, and not one data-receiving host sits on AS397423. The split holds across all nine. ZAP-Hosting carrying two collection nodes rather than one makes this look like a standing arrangement rather than incidental provider choice.

The reading is that delivery and control infrastructure is kept separate from collection infrastructure, so that an abuse complaint or takedown against the panel provider does not cost the operator their stolen data — and the reverse holds too.

The practical consequence for defenders and for takedown work is that panel-focused enumeration will systematically miss the collection infrastructure. Our own investigation is the worked example: the panel signature sweep returned five hosts and not one of the collection nodes. 5.175.213[.]199 was only known because it is hardcoded in the payload, and 147.189.172[.]163 only from third-party package reporting. Neither would ever surface from a panel hunt. The two estates have to be enumerated separately, and referrals sent to all three providers in parallel.

A newer build is already deployed

The exfiltration listener on 91.202.5[.]124 advertises one header the other does not: X-Backup-Date. That string appears nowhere in the stage-3 payload we analysed. The server accepts a field this client version never sends — indicating a build newer than 1.2.2 already in circulation, most plausibly tied to the dated ss_backup_* sweep. Traffic carrying that header implies a version not yet sampled.

Host profile

Prompted by TCP 135 and 5357 on one host, we swept the fleet on 5357. Four hosts — 165.140.86[.]5838.92.47[.]16445.43.11[.]224 and 91.202.5[.]124 — returned a byte-identical WSDAPI 503 with Server: Microsoft-HTTPAPI/2.0The C2 fleet is largely Windows Server with default networking exposed, which is unusual for C2 and consistent with the toolkit’s heavily Windows-focused feature set.

To be explicit: ports 135 and 5357 are not indicators. They are default Windows services present on millions of unrelated hosts. This is host profiling only.


Two generations

A second npm package tied the fleet to an earlier configuration and clarified its history.

Generation 1 (~June 2026)Generation 2 (~August 2026)
Delivery@safehttp/strict-uri-encode@sqlite-labs/createsql
C2 patternsingle port 1244Express tiers 5000 / 5056 / 3011
Hosts45.59.160[.]215165.140.86[.]190the six Generation 2 hosts
Statusboth darklive at time of writing

45.59.160[.]215 spans both — the Generation 1 loader C2 on :1244, and the Generation 2 panel on :5000. It is the bridge host, and it is also the only member of the fleet carrying a threat-feed tag. The :1244 pattern is long-documented and well detected; the Generation 2 Express panels are not. The architectural migration appears to have reset the operator’s exposure.

Port 1244 is a documented BeaverTail C2 port, used alongside 1224 and 1245, and the well-known /pdown endpoint hosts the Python distribution needed to run InvisibleFerret (Unit 42Datadog Security Labs).

Detection coverage tracks the generation split exactly

We checked every host against VirusTotal. The result is the clearest evidence that the architectural migration reset the operator’s exposure:

HostGenerationVirusTotal
66.235.168[.]14Gen 20 / 91
5.175.213[.]199Gen 20 / 91
91.202.5[.]124Gen 20 / 91
165.140.86[.]58Gen 20 / 91
38.92.47[.]164Gen 20 / 91
45.43.11[.]224Gen 20 / 91
147.189.172[.]1630 / 91
45.59.160[.]215Gen 13 / 91
165.140.86[.]190Gen 12 / 91
bdmkaoyijqmqa6bg.public.blob.vercel-storage[.]comdelivery1 / 91

Every live Generation 2 host is at zero detections. The only hosts with any detection at all are the two Generation 1 nodes, and both of those are now dark. Even there, coverage is 2–3 vendors out of 91.

Every file hash listed in this article is likewise absent from VirusTotal entirely — not merely undetected, but unknown.

This is the practical case for fingerprint-based hunting over indicator matching. An organisation relying on IP reputation or hash blocklists would have had no signal whatsoever against the live infrastructure in this campaign. The CORS signatures in the next section do not depend on anyone having seen the sample first.


The npm delivery vector

Two malicious npm packages, catalogued by OpenSourceMalware, feed the same infrastructure.

@sqlite-labs/createsql

Published by npm user viktorkovpan, 897 downloads, all versions malicious. The registry preserves a revealing timeline:

VersionPublished (UTC)
1.0.02026-08-10 16:56:46
1.0.1 → 1.0.52026-08-10 17:37 – 19:52
0.0.1-security2026-08-10 20:29:45 — npm takedown

Six versions in under three hours, removed 37 minutes after the last push — live tuning of the payload, followed by a fast takedown.

Its indicator set overlaps the chain we analysed almost exactly: the same Vercel blob delivery host, the same short-link token, the same C2 endpoints, and — decisively — the same stage-2 SHA-256482230569e6d03f29f6f8b77b39185e0a71b7c256e0e10630e23c192433c7c10. A byte-identical stage 2 shared between a public npm package and a separately sourced sample set is a code-level link, not merely shared infrastructure.

It also revealed the delivery path behind the short link: hxxp://66.235.168[.]14:5000/session/843111567577a5dc, which explains why the payload field is called sessions.

@safehttp/strict-uri-encode

Reported June 2026. A typosquat of the widely used strict-uri-encode utility, a transitive dependency of query-string, republished under a fake @safehttp scope. Its published description notes persistence hidden under a fake .vscode directory — the direct ancestor of this sample’s ~/.vs_cache.

Because it typosquats a genuine transitive dependency, it can land in a lockfile without anyone deliberately installing it. Audit resolved lockfiles, not just direct dependencies.

The viktorkovpan account and both packages have since been removed; enumerating any further packages from that account is no longer possible from public registry data.


The loader chain is a documented toolkit

Searching for the stage-0 filename token_mac.sh returned nothing — but it surfaced tokenlinux.sh in ENKI WhiteHat’s February 2026 report on Contagious Interview abusing VS Code via GitHub. That report describes the same chain, one platform over.

ENKI, Feb 2026This sample, Aug 2026
tokenlinux.sh / token.cmdtoken_mac.sh — same token<platform> convention
Stage 0 stages a private Node.js runtime, adds it to PATHIdentical
Fetches parser.js + package.json, runs under that runtimeIdentical filenames
obfuscator.io: string array, runtime shuffleoffset-based accessExactly the scheme above
Anti-tamper: dummy code errors if reformattedSame protection class
hxxp://<server>:1244/s/6df937fe9011 returns C2 + campaign ID/session/843111567577a5dc — same /s/<hex> convention
Payload into a home .vscode directory~/.vs_cache~/.task
C2 66.235.175[.]11766.235.11[.]117 on :124466.235.168[.]14 — same 66.235.0.0/16
17 wallet extension IDs16 of those 17 appear in this sample’s 67

The stage-0/stage-1 chain is therefore not bespoke — it is the operation’s standard loader, documented six months earlier, with an extension list that has been inherited and expanded rather than rewritten. The 66.235.0.0/16 continuity is a notable thread linking the two.

ENKI’s delivery vector differs: .vscode/tasks.json with runOn: folderOpen inside trojanised GitHub repositories, executing automatically when a developer opens the folder. Organisations scoping an incident of this type should treat “did a developer open an untrusted repository in VS Code” as a live question, alongside npm and fake-interview vectors.


Attribution

We assess this activity as belonging to the Contagious Interview activity set, DPRK-nexus, with high confidence — and as using a Node.js RAT of the OtterCookie/OtterCandy lineage that post-dates any publicly documented version of either family.

Where this sits in the documented lineage

Cisco Talos records the OtterCookie version progression as: v1 remote command execution; v2 clipboard theft; v3 file theft and upload; v4 virtual-environment detection, anti-debugging and anti-logging; v5 keylogging and screenshot capture.

Our sample carries capabilities documented for none of those versions — the DPAPI and App-Bound Encryption bypass, SSH key harvesting and SSH lateral movement, automatic cloud-credential collection, and the dated ss_backup_* sweep. It also carries an internal version string, 1.2.2, where the public v1–v5 labels are analyst-assigned rather than embedded.

We are deliberately not assigning this a version number. The v1–v5 sequence reflects sustained version-tracking by NTT Security and Cisco Talos across many samples; declaring a “v6” from a single build would imply a continuity of observation we do not have, and would compound the taxonomy problem discussed below. What we can say with confidence is narrower and more useful: this build post-dates everything publicly documented for either family, in both capability and infrastructure.

It is also not StoatWaffle, the newest WaterPlum family, documented by NTT Security in March 2026 and attributed to WaterPlum Team 8 (also called Moralis / Modilus), which reportedly began using it around December 2025. StoatWaffle is also Node.js and also arrives via .vscode/tasks.json, but its C2 is HTTP polling against /api/hsocketNext/api/hsocketResult/api/errorMessage/api/handleErrors/upload and /uploadsecond — an entirely different endpoint set, with no Socket.IO, no ss_ command namespace and no client_id field.

One cross-family detail is worth recording: StoatWaffle reads Windows user-profile data from WSL using wslpath, and our sample reaches the Windows host from WSL by enumerating /mnt/<drive>/Users/<user>/AppData. Same objective, different implementation — a WaterPlum tradecraft trait appearing across separate families.

The family call is a lineage call

The sample carries three implementation details documented for OtterCandy by NTT Security Japan: the ss_del command semantics, the client_id field introduced in v2, and self-forking on SIGINT via process.on. We verified the last directly in the deobfuscated code:

const {fork} = require('child_process');function respawn(){ fork(path.join(__dirname,'express.js'), [], {...}).unref(); }process.on('SIGINT', function(){ respawn(); });

But it diverges from OtterCandy on three counts: 67 wallet extensions against a documented 7Contagious Interview delivery rather than ClickFake Interview, with none of ClickFake’s GolangGhost or FrostyFerret toolset present; and self-implemented persistence rather than delegation to a preceding loader.

Critically, OtterCandy is itself a merge of OtterCookie and RATatouille code. Details documented only in OtterCandy reporting may well originate in the OtterCookie half of that shared codebase. “Documented only in OtterCandy” is not “present only in OtterCandy” — so a clean family assignment is not supported in either direction. This is a lineage call.

That convergence is itself well documented: Cisco Talos has reported BeaverTail and OtterCookie merging via a shared JavaScript module.

Actor naming

On actor we assess WaterPlum / Famous Chollima / PurpleBravo — interchangeable aliases for one intrusion set — at moderate confidence. We do not carry forward any sub-cluster assignment.

NTT’s reporting describes WaterPlum as operating multiple clusters or teams: Cluster B, commonly called the BlockNovas cluster, associated with ClickFake Interview activity; and Team 8 (Moralis / Modilus), which previously used OtterCookie in Contagious Interview activity before moving to StoatWaffle. We do not have evidence sufficient to place this sample in either, and the public mapping between these designations is incomplete. CrowdStrike’s taxonomy, by contrast, associates FAMOUS CHOLLIMA with NICKEL TAPESTRY, Tenacious Pungsan, Void Dokkaebi, Wagemole, PurpleBravo, PurpleDelta and Contagious Interview together — a reminder that these labels sit at different levels of abstraction across vendors.

A note on “Lazarus”

Several signals in this investigation carry a Lazarus label — a community feed tag, a package tag, and an antivirus family name. None of them evidences Lazarus proper.

“Lazarus Group” originally denoted a specific cluster and is now widely used as a catch-all for any DPRK activity. Contagious Interview / Famous Chollima is generally assessed as distinct from Lazarus proper, as are Kimsuky, Andariel and BlueNoroff. The community feed tag in question, for example, derives from a ZoomEye dork for the BeaverTail :1244/pdown pattern — the evidence supports Contagious Interview, while the label asserts something broader.

This matters practically. Reading three umbrella-labelled signals as three independent confirmations of Lazarus double-counts a generic term and inflates apparent corroboration. It also implies a link to destructive and financial-institution operations that this activity does not support. “Contagious Interview (DPRK)” is both more accurate and more useful to a defender.

What we could not establish

Nothing here ties the cluster through an operator handle, unique build artifact, confirmed victim set, or proven infrastructure ownership. Shared or rented tooling remains a live alternative: malware-family overlap is not a unique attribution key. State sponsorship of this specific activity is not independently established.


Hunting and detection

Network fingerprints

The highest-value content, in priority order. These are application-defined and survive IP rotation.

Panel tier:

Access-Control-Expose-Headers: Last-Modified,X-File-Modified-At,X-File-Mtime-Ms

Download tier:

Access-Control-Expose-Headers: X-App-Last-Modified,X-Lan-Last-Modified

Exfiltration tier — alert on the header triple, which is highly distinctive:

X-Upload-ID + X-Is-Priority + X-Overwrite

Admin console — the highest-signal pivot in this set. The console’s HTML title and meta description are both distinctive and both indexed by scan platforms; the port varies (:3000 and :80 observed), so match on body content rather than port.

The structural pattern is the part that survives. Every literal above is cheap for the operator to change — Access-Control-* headers are meaningful only to browsers, so the Node implant does not depend on them at all, and they can be dropped tomorrow at no functional cost. What is harder to re-architect is the shape: three Express services on adjacent ports advertising application-specific expose-headers, a client-management console on a separate port, and collection hosted on a different ASN from delivery. Hunt the shape as well as the strings.

Watch for X-Backup-Date specifically: its presence indicates a client build newer than the one analysed here.

Path sets. Generation 2: /register/file-upload/content-upload/session/<hex>/download-app/download-win-key, and /check + /status?machineId= + /upload + /complete. Generation 1: /pdown/uploads/client/<digits>/<3 digits> on port 1244.

Do not alert on the Express 404 body hash.

Host indicators

Directories ~/.task and ~/.vs_cache~/.vs_cache/log.logmain.vbsbackup.pause, and transient .<pid>_<base36>.js%TEMP%\_vs_rm_<pid>_<ts>.vbs~/Library/LaunchAgents/com.AppleAccountSync.plist~/.config/autostart/unixkernalloader.desktop; registry Run values NvidiaDriverUpdateVScode.Update.PublisherMicrosoft Realtime Agreegator.

Supply chain

Audit resolved lockfiles for @sqlite-labs/createsql and @safehttp/strict-uri-encode, and any package published by viktorkovpan. Treat .vscode/tasks.json with runOn: folderOpen in untrusted repositories as an execution primitive.

If you find this

Treat every credential reachable from the host as compromised, in this order: cloud credentials (GCP, AWS, Azure) and any tokens they minted; all SSH private keys, plus an audit of authorized_keys on hosts those keys reach — SSH pivoting is a built-in feature, so the blast radius likely extends beyond the initial host; browser-stored passwords across all profiles; macOS login keychain; crypto wallet seed phrases, which should be assumed lost.

Given the keylogger and arbitrary follow-on binary execution, host rebuild is safer than artifact cleanup.


Indicators of compromise

All defanged.

Infrastructure

IndicatorRole
66.235.168[.]14:5000Panel / C2 — /register/file-upload/content-upload/client/session/<hex>
66.235.168[.]14:5056Payload download — /download-app/download-win-key
66.235.168[.]14:3000Operator admin console
91.202.5[.]124:80Operator admin console
5.175.213[.]199:3011Bulk exfiltration
91.202.5[.]124Panel :5000 + exfiltration :3011 (variant build)
165.140.86[.]58Panel + download
38.92.47[.]164Panel + download
45.43.11[.]224Panel + download
45.59.160[.]215Generation 1 :1244; Generation 2 panel (historical)
165.140.86[.]190Generation 1 :1244
147.189.172[.]163Earlier-stage check-in on the npm delivery path — /api/service/<md5>; not referenced by stage 3
bdmkaoyijqmqa6bg.public.blob.vercel-storage[.]comStage-1 delivery
hxxps://btwknrll.s[.]gy/843111567577a5dcStage-3 short link

Files

SHA-256Artifact
e171ff04f6dac7c6c9e810be6ee7795bde765ac61f70f663b45e02772351d882stage 0 token_mac.sh
d49cd526ac7fe6cfec4a390abc64ea0bc4ee2ad7db898b4ff8f50e0f13285eaestage 1 parser.js
482230569e6d03f29f6f8b77b39185e0a71b7c256e0e10630e23c192433c7c10stage 2 main.js — also in @sqlite-labs/createsql
0747ae321ecddc3936d53fe3ead743218732d6f39e31f3728f1977f6ae8b0c62stage-3 JSON (response body from the short link)
fd3061f4f0e1c1cd92070659c1623a62809e0552223491bd2a016989f1e56bddstage-3 payload (sessions field)
50301b6842eee311ee64848c5a8914d63cff54f42550f67a458a36bd57f63218npm package artifact
73aa93886f05b18b87a259cb390be64ec8883261d993f3b7abd03c14e66859d7npm package artifact
b986f228e6ac9f285c333e8aa496816770f732e10ff538a231e853b00a0b65cbnpm package artifact
0a5859bce803dde954c3c2d4841cd6c0342925e371dad15661cd4acc690abe47npm package artifact
230b1b68e643fd7fe5b4c39fce10100c59cd8d579107a67df63a095849286808npm package artifact
20aa80f25243fcadd72a3a3b6361566b59bbd7f71d2c9bd387897c54269b3be1npm package artifact

None of the hashes above is present in VirusTotal.

Packages and accounts

@sqlite-labs/createsql (npm, all versions) · @safehttp/strict-uri-encode (npm, all versions) · npm user viktorkovpan


Methodology and tooling

  • Custom Python deobfuscation and extraction scripts — string-array recovery, checksum-driven rotation solving, transitive alias resolution and dictionary constant propagation, all in memory.
  • RST Report Hub and RST CTI Assistant — prior-reporting research and corroboration.
  • RST Enrichment API — active scanning of candidate hosts and indicator lookups. All scanning was performed by RST infrastructure, not from client or analyst networks.
  • RST Threat Library — actor and campaign object retrieval for naming.
  • NetlasShodan and Censys — internet-wide scan data for fleet enumeration and historical observations.
  • OpenSourceMalware — npm package intelligence.
  • npm public registry API — package version history and takedown forensics.
  • VirusTotal —detection-coverage baselining for every host and file hash. This produced the finding that the entire live fleet sits at zero detections.

All indicators were checked against VirusTotal; results are in Detection coverage. Hashes are published as SHA-256 throughout.


References

All links verified 15 August 2026. Each entry notes what it was checked against and what it contributed.

OtterCookie

OtterCandy, StoatWaffle and WaterPlum

BeaverTail and InvisibleFerret

Delivery vectors — VS Code, GitHub, SVG, JSON storage

Malicious npm packages

ClickFake Interview

Actor naming, taxonomy and DPRK IT workers

Other

  • Maltrail apt_lazarus.txt trail list — https://github.com/stamparm/maltrail/blob/master/trails/static/malware/apt_lazarus.txt → Origin of the lazarus_group tag on one Generation 1 host. Tracing it back showed the tag derives from a :1244/pdown BeaverTail fingerprint — sound methodology, but a label broader than its own evidence. This is the worked example behind our note on umbrella labels.
  • OpenSourceMalware — package and indicator records for @sqlite-labs/createsql and @safehttp/strict-uri-encode — https://opensourcemalware.com/ → Source of the npm delivery vector, the viktorkovpan publisher, the second-generation indicator set, and the stage-2 hash match that tied the public package to our sample at code level.

RST Cloud delivers curated, agent-ready threat intelligence, indicator enrichment and reporting to security teams worldwide. Newly identified indicators from this research have been added to the RST Threat Feed.