How to inject and run custom code inside a target Node.js process
Today, I would like to show you how to execute custom JS code in another Node.js process and to get a proper result. These are relatively simple code examples, which demonstrate basic ideas of native code calls with the help of Frida framework.
Frida by itself already has an example of code injection for Node.js, but it seems that it is a bit outdated and could work only with a certain Node.js version – GUIDE. It uses V8 embed code to execute a JS string. I have updated the code to Node.js v8.16.0 x64, but I will not elaborate on injection details here. You can easily find out more via the following link, so instead, let’s go to the injected code.
At first, let’s see, which Frida code we will use (you can get more details here).
- Module.findExportByName – it finds C exported function by its name. There is one little caveat – so-called “mangled names”. In short, when we are trying to investigate C++ compiled code, we could see a lot of names like ?GetCurrent@Isolate@v8@@SAPEAV12@XZ. First of our points is to find all required function names in a disassembler like IDA or OllyDbg and to get the memory address of it.
- NativeFunction(POINTER, RETURN_VALUE, ARGUMENTS) – this function is used to define in-js bindings for a native function. Here, we need to describe correct arguments count and return values to call these functions later.
- NativeCallback – a code that will be executed after a native function call.
- WeakRef.bind is used to monitor specified pointer and the callback when it gets garbage-collected or on Frida detach.
- Memory.alloc(N) – allocates N bytes in memory and returns pointer for this memory region.
Concepts and Functions Used
Here, I want to define some concepts that I am going to use:
- Isolate represents an isolated instance of the V8 engine. V8 isolates have completely separate states. Objects from one isolate cannot be used in another isolate. When V8 is initialized, a default isolate is implicitly created and entered. The embedder can create additional isolates and use them in parallel in multiple threads. An isolate can be entered by at most one thread at any given point in time. The Locker/Unlocker API must be used to synchronize. In short, it is a kind of sandbox, which contains its own states.
- Context stands for a sandboxed execution context with its own set of built-in objects and functions.
- HandleScope is a stack-allocated class, which governs a number of local handles. After a handle scope has been created, all local handles will be allocated within that handle scope until either the handle scope is deleted or another handle scope is created. If there is already a handle scope and a new one is created, all allocations will take place in the new handle scope until it is deleted. After that, new handles will again be allocated in the original handle scope. After the handle scope of a local handle has been deleted, the garbage collector will no longer track the object stored in the handle and may deallocate it. The behavior of accessing a handle for which the handle scope has been deleted is undefined.
- String is a JS string value.
- uv_async_init – initializes handle, specifies the callback that will be executed inside the event loop;
- uv_default_loop – takes default event loop;
- uv_async_send – calls the callback;
- uv_unref – destroys the object created;
- uv_close – requests handle to be closed.
We will also use some V8 functions (formatted as real method name --> name of binded js function). In particular:
- V8::Isolate::GetCurrent(v8_Isolate_GetCurrent) – takes the instance of a current Isolate.
- V8::Isolate::GetCurrentContext(v8_Isolate_GetCurrentContext) – gets context from the current Isolate.
- V8::Context::Enter(v8_Context_Enter) – enters this context. After entering a context, all code compiled and run is compiled and run in this context. If another context is already entered, this old context is saved so it can be restored when the new context is exited.
- V8::HandleScope::Init(v8_HandleScope_init) – initializes HandleScope.
- V8::String::NewFromUtf8(v8_String_NewFromUtf8) – gets JS string from const char *.
- V8::Script::Compile(v8_Script_Compile) – compiles the specified script (bound to current context).
- V8::Script::Run(v8_Script_Run) – runs the script returning the resulting value. If the script is context-independent (created using ::New), it will be run in the currently entered context. If it is context-specific (created using ::Compile), it will be run in the context in which it was compiled.
- v8::Value::Int32Value(v8_Value_Int32Value) – gets C int from JS value. Value has a lot of functions for different types.
So, let’s check the code now:
createFunc is a helper that is used to create JS bind with a specified signature to reuse later.
All of the calls like const uv_default_loop = createFunc('uv_default_loop', 'pointer', ) are required to define JS bindings to native functions.
scriptToExecute is our injected code.
We define uv async handler, then bind processPending to the default event loop, then “wake up” that event loop for a callback call.
Inside the callback, we receive an Isolate instance, initialize new HandleScope, and take current context.
Then, we convert the JS string to V8 string, compile, and run it. After all, we just extract C int value from the script execution result.
The Use of Node.js Inject
That is a relatively simple example of how we can inject and run custom code inside a target Node.js process. It is not just about hacking. Instead, this concept allows us to write some kind of run-time plugins for Node.js, which will help to improve something specific, something that we cannot change without recompiling.