The Node.js loader that locks its own strings to the folder it lives in
A Node.js loader disguised as a dev tool: signed node.exe, extensionless script, folder-keyed string cipher, and an in-memory payload that never touches disk.
The views and opinions expressed in this post are my own and do not represent those of my employer. This is a personal blog where I share research and things I’m learning.
TL;DR
A Node.js first-stage loader masquerading as a scheduled task. It runs a legitimately signed
node.exeagainst an extensionless JavaScript file calleddiagrams. The standout trick: every string in the loader is encrypted with the install folder name as the key - move the file to analyse it and every string fails to decrypt. It fingerprints the host usingMachineGuid, then beacons over HTTPS tosvc[.]featuresettings[.]comand executes whatever JavaScript the operator pushes back - entirely in memory vianew Function(), nothing written to disk.If this is your fleet, do these first:
- Block
node.exeexecution from user-writable paths via Application Control (ML1) and alert on Event ID 4688 fornode.exelaunching fromAppData- Hunt Event ID 4688 for
node.exespawningreg.exe QUERY ...\Cryptography /v MachineGuid- this is the behavioural tell that fired the original detection- Alert on Event ID 4698 for scheduled tasks whose action runs a script runtime against a user-profile file with no extension
Full IOCs and detection rules are at the bottom.
A scheduled task, a copy of node.exe, and a file called diagrams
A scheduled task landed in my lap with a slightly odd shape. It wasn’t running a script, or a PE, or anything with an extension at all. It was running a private copy of node.exe against a file literally named diagrams - no extension, just diagrams - sitting in the user’s AppData\Local\Programs folder.
That’s the kind of thing that’s either completely boring (some dev tool’s build artefact) or quietly nasty. This one was the second kind. diagrams turned out to be ~290 KB of obfuscated JavaScript: a first-stage loader that fingerprints the host, phones home over HTTPS, and runs whatever JavaScript the server sends back - entirely in memory, never touching disk.
The detail I enjoyed most: the malware encrypts its own strings with a key derived from the name of the folder it’s installed in. Copy the file somewhere else to analyse it, and every string fails to decrypt. It’s a cheap, clever anti-analysis trick, and it’s the thread I want to pull on first.
This is a clean example of signed-binary abuse - if you maintain a LOLBins-aware detection programme, this pattern belongs in it. Let’s dig in.
Defender quick reference
| Field | Details |
|---|---|
| Activity type | Loader / persistence / remote code execution capability |
| Primary artifacts | Scheduled task named after install folder; node.exe (signed, bundled) in AppData\Local\Programs; extensionless loader diagrams; C2 svc[.]featuresettings[.]com |
| Verdict | Malicious |
| Confidence | High - obfuscated JS loader, folder-keyed cipher, HTTPS C2, in-memory new Function() execution |
| Key logs | Windows Security 4688 (process creation), 4698 (task created) |
| ATT&CK | T1053.005, T1059.007, T1059.003, T1027, T1140, T1012, T1082, T1071.001, T1573.001, T1105, T1620 |
| First defender actions | Hunt 4688 for node.exe from AppData; check 4698 for tasks invoking a script runtime against a user-profile file; look for extensionless files beside bundled node.exe installs |
| Detection opportunities | YARA rule below; Event IDs 4688 and 4698 as described in the defenders section |
| False-positive notes | Legitimate Electron/Node apps install under AppData\Local\Programs but do not ship extensionless scripts or spawn reg.exe for MachineGuid |
The attack at a glance
- Execution - a Scheduled Task runs a bundled, legitimately signed
node.exeagainst the extensionless loaderdiagrams, in a minimised window. - Defence evasion - the loader is double-obfuscated: a public JS obfuscator on the outside, then a custom per-string cipher keyed to the install folder.
- Discovery - it reads the machine’s
MachineGuidfrom the registry plus the OS version, and builds a per-host identity. - Command and control - it beacons to a hardcoded HTTPS endpoint with an encrypted JSON envelope.
- Objective - the server’s reply is JavaScript, executed in-memory via
new Function(...). The real payload is delivered at run time and gated by the operator.
How it works
Stage 1 - Execution via a signed Node runtime
The task action is a classic “hide in plain sight” launcher:
1
2
3
cmd /C start "" /min ^
"C:\Users\<redacted>\AppData\Local\Programs\filterByCategory\node\node.exe" ^
"C:\Users\<redacted>\AppData\Local\Programs\filterByCategory\diagrams"
start "" /min runs it minimised so nothing flashes up. The node.exe here is a genuine OpenJS Foundation-signed Node.js build - not trojanised. That matters for defenders: signature-based allow-listing that trusts “anything signed” waves this straight through. The only malicious file on disk is diagrams.
Stage 2 - Strings keyed to the install folder
Peel off the outer obfuscator (I used an AST deobfuscator for this; it’s just a text transform, the payload never runs) and you find a webpack bundle. Most of the modules are decoy npm libraries. The interesting one is a string decryptor. Every literal - URLs, registry paths, protocol field names - is stored encrypted and unpacked on demand by a function whose key is this:
1
2
// the decryption key is the parent folder name of the working directory
const key = process.cwd().split("\\").at(-2); // -> "filterByCategory"
The cipher itself is a tidy little homebrew: base64url-decode, XOR against a keystream from a seeded xorshift32 PRNG (the seed is an FNV-1a hash of key + callIndex), then verify a prepended Adler-32 checksum. Get the folder name wrong and the checksum fails, so it throws rather than handing you plaintext. It’s a relocation guard - analyse the file outside its install path and it clams up. Genuinely neat, and trivial to defeat once you spot it: you just feed the real folder name back in and reimplement the function.
Stage 3 - Fingerprint and beacon
With strings recovered, the host-profiling is plain. The loader reads the registry MachineGuid as a stable per-host ID, grabs the OS version, mints a random session UUID, and wraps it all in a JSON envelope:
1
2
{ Event: "app", MachineId: <HKLM\...\Cryptography\MachineGuid>,
SessionId: <randomUUID>, Version: "0.2.1", OSVersion: <os.release()> }
It reads MachineGuid through a bundled winreg library, which under the hood shells out to reg.exe - so on the endpoint you actually see node.exe spawning:
1
cmd /d /s /c "reg.exe QUERY HKLM\Software\Microsoft\Cryptography /v MachineGuid"
That spawn is the single best behavioural tell, and it’s what the original detection fired on.
Stage 4 - Encrypted C2 and in-memory execution
The envelope is encrypted with a 16-byte random IV, XOR’d, base64’d, and POSTed as text/plain to a hardcoded host. The reply is decrypted the same way - and then comes the part worth the price of admission:
1
2
3
// the decrypted server reply is treated as JavaScript and run in memory
new Function("exports", "require", "module", "__filename", "__dirname",
"//# sourceURL=./temp.js\n" + remoteCode)(...);
No second-stage file is ever written. The capability - whatever the operator wants to run - arrives as text and executes inside the existing node.exe. When I replayed the beacon (fetching, never executing) the server returned an empty task list, { "pl": [] }. I even tried it with the real victim MachineGuid: same empty reply. The tasking is operator-push and was only live transiently, so the actual delivered payload isn’t recoverable from the C2 now - only from host memory or logs.
Techniques observed (MITRE ATT&CK)
The following techniques have been mapped to MITRE ATT&CK for future reference.
| Tactic | Technique | ATT&CK ID | What it did here |
|---|---|---|---|
| Persistence | Scheduled Task | T1053.005 | Task runs node.exe against the loader |
| Execution | Command/Scripting Interpreter: JavaScript | T1059.007 | Signed node.exe runs the obfuscated loader |
| Execution | Command/Scripting Interpreter: Windows Command Shell | T1059.003 | cmd /c launcher and reg.exe wrappers |
| Defence Evasion | Obfuscated Files or Information | T1027 | Public JS obfuscator + custom folder-keyed cipher |
| Defence Evasion | Deobfuscate/Decode at runtime | T1140 | Strings decrypted with the install-folder key |
| Discovery | Query Registry | T1012 | Reads MachineGuid as a host ID |
| Discovery | System Information Discovery | T1082 | os.release() OS version |
| Command and Control | Application Layer Protocol: Web | T1071.001 | HTTPS POST beacon |
| Command and Control | Encrypted Channel: Symmetric Crypto | T1573.001 | IV-prefixed XOR over base64 |
| Command and Control | Ingress Tool Transfer | T1105 | Second-stage JS pulled from C2 |
| Execution | Reflective Code Loading | T1620 | new Function() runs fetched JS in memory |
Why this matters
Strip away the clever bits and this is a foothold with a remote-code pipe. The operator can push arbitrary JavaScript into a long-lived node.exe process whenever they choose, profile the host first, and decide per-victim what to deliver - an info-stealer, a downloader, a deeper implant. Because the second stage lives only in memory, there’s very little on disk to find after the fact, and because the runtime is a legitimately signed Node binary, naive allow-listing and a lot of AV won’t blink.
The behaviour overlaps with public reporting on a Node.js botnet (“Tsundere”), which others have tentatively linked to wider state-aligned activity - but this sample uses a plain hardcoded HTTPS C2 rather than the blockchain-based trick those write-ups describe, so I’d treat it as a relative or a variant, not a confirmed match. I’m not putting a flag on it. The technical facts stand on their own, and they’re enough to defend against.
What defenders can do
| Technique (ATT&CK) | What to do | Essential Eight | What to hunt for |
|---|---|---|---|
| Execution via Node (T1059.007) | Block node.exe execution from user-writable paths | Application Control (ML1) | 4688: node.exe from AppData\Local\Programs |
| Scheduled Task (T1053.005) | Restrict who can create tasks; baseline autoruns | Application Control; Restrict Administrative Privileges | 4698: new task whose action is a script runtime |
| Obfuscation / runtime decode (T1027 / T1140) | Stop the interpreter running at all - the decoder needs node.exe | Application Control | No on-disk tell; rely on execution and spawn behaviour |
| Discovery (T1012 / T1082) | Treat node.exe spawning reg.exe/WMI hardware queries as anomalous | No clean E8 home | 4688: node.exe parent spawning reg.exe/wmic for MachineGuid, GPU, volume serial |
| HTTPS C2 (T1071.001 / T1573.001) | Default-deny egress; proxy + DNS reputation | No clean E8 home - network architecture | Periodic same-size POSTs to a recently-seen domain |
| Ingress + reflective exec (T1105 / T1620) | Block the loader from executing upstream; this stage never hits disk | Application Control (upstream only) | node.exe making outbound then spawning children |
Execution via a signed Node runtime (T1059.007)
This is the load-bearing control. Application Control at Maturity Level One states that “application control restricts the execution of executables, software libraries, scripts, installers, compiled HTML, HTML applications and control panel applets to an organisation-approved set”, and that it is “applied to user profiles and temporary folders” (Essential Eight Maturity Model, November 2023). A bundled node.exe dropped into AppData\Local\Programs is exactly an unapproved executable in a user profile path - so a real allow-list bites here even though the binary is validly signed. The trap is publisher rules that trust the OpenJS signature blanket-wide; scope by approved path or hash, not just signature. See Implementing Application Control (November 2023). If prevention fails, hunt Event ID 4688 for node.exe launching from a user-writable directory.
Scheduled Task persistence (T1053.005)
Application Control still blocks the payload the task points at, and tightening who can register tasks - covered in Restricting Administrative Privileges (November 2023) - shrinks the door. The clean detection is Event ID 4698 on task creation: flag any task whose action invokes a script runtime (node.exe, wscript, powershell) against a file in a user profile, especially with a benign-sounding name.
Obfuscation and runtime decode (T1027 / T1140)
There’s nothing to scan for on disk here - the strings only exist decrypted in memory. The honest answer is that Application Control bites one step earlier: the decoder can’t run if node.exe was never allowed to execute the loader. Don’t chase the obfuscation; deny the interpreter.
Host discovery (T1012 / T1082)
No clean Essential Eight home for “reading MachineGuid” - it’s a normal API. But the pattern is gold: a Node process shelling out to reg.exe for MachineGuid, or to WMI for the GPU, or cmd /c vol for the volume serial, is almost never legitimate. Build the hunt on Event ID 4688 with node.exe as the parent and those hardware-enumeration commands as the children. This is precisely what caught it.
Encrypted HTTPS C2 (T1071.001 / T1573.001)
No Essential Eight home here - this is network architecture. Default-deny egress, force traffic through a proxy with category and reputation filtering, and apply DNS reputation. Hunt for periodic, identical-size POSTs to a domain your estate has only just started resolving, especially one fronted by a CDN with a server banner that doesn’t match a real web stack.
Ingress and reflective execution (T1105 / T1620)
Because the second stage runs via new Function() and never lands on disk, file-based controls miss it entirely - the only durable answer is upstream: stop the loader executing in the first place (back to Application Control). For detection, the behavioural shape is a node.exe that makes an outbound connection and then spawns child processes.
Hunting and detection summary
- 4688 -
node.exeexecuting fromAppData\Local\Programsor other user-writable paths. - 4688 -
node.exeas parent ofreg.exe QUERY ...\Cryptography /v MachineGuid,wmic/PowerShell GPU queries (Win32_VideoController), orcmd /c vol. - 4698 - new Scheduled Task whose action runs a script runtime against a user-profile file, particularly with an innocuous name.
- A bundled
node.exe(validly signed) living under a per-app folder inAppData\Local\Programs, beside an extensionless file. - An
olog.txtfile written next to a node script (this loader tees its console output there - a handy on-disk capture if you can grab it). - Proxy/DNS: periodic equal-size
text/plainPOSTs to a recently-resolved, CDN-fronted domain.
Indicators of Compromise
Victim-identifying details (hostnames, usernames, account IDs, internal IPs, the host’s MachineGuid) have been removed and shown as <redacted>. The indicators below are malware and infrastructure artefacts. Note the install-folder name (filterByCategory) doubles as the string-decryption key and may differ per victim.
| Type | Indicator | Notes |
|---|---|---|
| SHA256 | 06eb3b6859cb1e6fb3e1e442e28ca28f84135925bc0c5b06d8615d1547fa6a6d | The loader (diagrams) |
| Domain | svc[.]featuresettings[.]com | Hardcoded HTTPS C2 |
| IP | 104[.]21[.]60[.]55, 172[.]67[.]192[.]118 | CDN front (origin hidden) |
| HTTP header | x-powered-by: Lite.NET/17.5 | C2 server banner |
| Path | ...\AppData\Local\Programs\<redacted>\node\node.exe | Bundled signed Node runtime (not malware itself) |
| Path | ...\AppData\Local\Programs\<redacted>\diagrams | The malicious loader |
| File | olog.txt (loader’s working dir) | Console-output log written by the loader |
| Task | Scheduled Task named after the install folder | Persistence; runs node.exe against the loader |
| Registry | HKLM\Software\Microsoft\Cryptography\MachineGuid | Read as host ID (read, not written) |
| Protocol | JSON fields Event/MachineId/SessionId/Version/OSVersion; reply {"pl":[]} | Beacon schema; implant version 0.2.1 |
Detection rules
A starting YARA stub - match on the C2 plus the folder-key idiom, or on the decrypted protocol shape:
rule Node_FolderKeyed_Loader
{
meta:
description = "Node.js loader: install-folder-keyed string cipher + HTTPS C2 + Function() RCE"
author = "Luke Wilkinson"
date = "2026-06-10"
strings:
$c2 = "svc.featuresettings.com" ascii
$cwd_key = ".split(\"\\\\\").at(-2)" ascii
$env1 = "MachineId" ascii
$env2 = "SessionId" ascii
$env3 = "OSVersion" ascii
$log = "olog.txt" ascii
condition:
$c2 or ($cwd_key and 2 of ($env*)) or (all of ($env*) and $log)
}
Closing
If you take one thing away, make it this: the only file that mattered here was an extensionless blob run by a legitimate signed interpreter, and the payload that counted never touched the disk. Signature trust and file scanning both shrug at that - but a humble Application Control rule that won’t run node.exe out of a user’s AppData folder stops the whole chain dead, and a 4688 hunt for node.exe -> reg.exe MachineGuid catches it if it slips through.
I had a soft spot for the folder-name cipher - it’s the sort of small, smug trick that’s fun to unpick and falls apart the moment you understand it. Pull things apart, write down what you learn, and the next weird scheduled task is a little less weird. Stay curious.
On methodology: the investigation is mine. The reverse engineering and analysis assembly were carried out with AI workflows (Claude, primarily). I reviewed every finding. Errors are mine - ping me on X or Instagram if you spot something off.
References
- MITRE ATT&CK: T1053.005, T1059.007, T1027, T1620, T1071.001, T1012
- ASD/ACSC: Essential Eight Maturity Model (November 2023); Implementing Application Control (November 2023); Restricting Administrative Privileges (November 2023)
- Public research on the “Tsundere” Node.js botnet (referenced for behavioural comparison only; attribution here is left open).