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.
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.
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.
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.
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.
Create a new directory and initialize the project:
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;
}