Post

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 Node.js loader that locks its own strings to the folder it lives in

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.exe against an extensionless JavaScript file called diagrams. 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 using MachineGuid, then beacons over HTTPS to svc[.]featuresettings[.]com and executes whatever JavaScript the operator pushes back - entirely in memory via new Function(), nothing written to disk.

If this is your fleet, do these first:

  • Block node.exe execution from user-writable paths via Application Control (ML1) and alert on Event ID 4688 for node.exe launching from AppData
  • Hunt Event ID 4688 for node.exe spawning reg.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

FieldDetails
Activity typeLoader / persistence / remote code execution capability
Primary artifactsScheduled task named after install folder; node.exe (signed, bundled) in AppData\Local\Programs; extensionless loader diagrams; C2 svc[.]featuresettings[.]com
VerdictMalicious
ConfidenceHigh - obfuscated JS loader, folder-keyed cipher, HTTPS C2, in-memory new Function() execution
Key logsWindows Security 4688 (process creation), 4698 (task created)
ATT&CKT1053.005, T1059.007, T1059.003, T1027, T1140, T1012, T1082, T1071.001, T1573.001, T1105, T1620
First defender actionsHunt 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 opportunitiesYARA rule below; Event IDs 4688 and 4698 as described in the defenders section
False-positive notesLegitimate 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

  1. Execution - a Scheduled Task runs a bundled, legitimately signed node.exe against the extensionless loader diagrams, in a minimised window.
  2. 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.
  3. Discovery - it reads the machine’s MachineGuid from the registry plus the OS version, and builds a per-host identity.
  4. Command and control - it beacons to a hardcoded HTTPS endpoint with an encrypted JSON envelope.
  5. 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.

TacticTechniqueATT&CK IDWhat it did here
PersistenceScheduled TaskT1053.005Task runs node.exe against the loader
ExecutionCommand/Scripting Interpreter: JavaScriptT1059.007Signed node.exe runs the obfuscated loader
ExecutionCommand/Scripting Interpreter: Windows Command ShellT1059.003cmd /c launcher and reg.exe wrappers
Defence EvasionObfuscated Files or InformationT1027Public JS obfuscator + custom folder-keyed cipher
Defence EvasionDeobfuscate/Decode at runtimeT1140Strings decrypted with the install-folder key
DiscoveryQuery RegistryT1012Reads MachineGuid as a host ID
DiscoverySystem Information DiscoveryT1082os.release() OS version
Command and ControlApplication Layer Protocol: WebT1071.001HTTPS POST beacon
Command and ControlEncrypted Channel: Symmetric CryptoT1573.001IV-prefixed XOR over base64
Command and ControlIngress Tool TransferT1105Second-stage JS pulled from C2
ExecutionReflective Code LoadingT1620new 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 doEssential EightWhat to hunt for
Execution via Node (T1059.007)Block node.exe execution from user-writable pathsApplication Control (ML1)4688: node.exe from AppData\Local\Programs
Scheduled Task (T1053.005)Restrict who can create tasks; baseline autorunsApplication Control; Restrict Administrative Privileges4698: new task whose action is a script runtime
Obfuscation / runtime decode (T1027 / T1140)Stop the interpreter running at all - the decoder needs node.exeApplication ControlNo on-disk tell; rely on execution and spawn behaviour
Discovery (T1012 / T1082)Treat node.exe spawning reg.exe/WMI hardware queries as anomalousNo clean E8 home4688: node.exe parent spawning reg.exe/wmic for MachineGuid, GPU, volume serial
HTTPS C2 (T1071.001 / T1573.001)Default-deny egress; proxy + DNS reputationNo clean E8 home - network architecturePeriodic same-size POSTs to a recently-seen domain
Ingress + reflective exec (T1105 / T1620)Block the loader from executing upstream; this stage never hits diskApplication 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.exe executing from AppData\Local\Programs or other user-writable paths.
  • 4688 - node.exe as parent of reg.exe QUERY ...\Cryptography /v MachineGuid, wmic/PowerShell GPU queries (Win32_VideoController), or cmd /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 in AppData\Local\Programs, beside an extensionless file.
  • An olog.txt file 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/plain POSTs 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.

TypeIndicatorNotes
SHA25606eb3b6859cb1e6fb3e1e442e28ca28f84135925bc0c5b06d8615d1547fa6a6dThe loader (diagrams)
Domainsvc[.]featuresettings[.]comHardcoded HTTPS C2
IP104[.]21[.]60[.]55, 172[.]67[.]192[.]118CDN front (origin hidden)
HTTP headerx-powered-by: Lite.NET/17.5C2 server banner
Path...\AppData\Local\Programs\<redacted>\node\node.exeBundled signed Node runtime (not malware itself)
Path...\AppData\Local\Programs\<redacted>\diagramsThe malicious loader
Fileolog.txt (loader’s working dir)Console-output log written by the loader
TaskScheduled Task named after the install folderPersistence; runs node.exe against the loader
RegistryHKLM\Software\Microsoft\Cryptography\MachineGuidRead as host ID (read, not written)
ProtocolJSON 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

This post is licensed under CC BY 4.0 by the author.