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).
Here, I want to define some concepts that I am going to use:
Next, I describe the functions of libuv that we will use – a lib that allows using an async, event-driven style of programming:
We will also use some V8 functions (formatted as real method name --> name of binded js function). In particular:
So, let’s check the code now:
try {
const createFunc = (name, retval, args) => {
const _ptr = Module.findExportByName(null, name);
return new NativeFunction(_ptr, retval, args);
}
const uv_default_loop = createFunc('uv_default_loop', 'pointer', []);
const uv_async_init = createFunc('uv_async_init', 'int', ['pointer', 'pointer', 'pointer']);
const uv_async_send = createFunc('uv_async_send', 'int', ['pointer']);
const uv_unref = createFunc('uv_unref', 'void', ['pointer']);
const v8_Isolate_GetCurrent = createFunc('?GetCurrent@Isolate@v8@@SAPEAV12@XZ', 'pointer', []);
const v8_Isolate_GetCurrentContext = createFunc('?GetCurrentContext@Isolate@v8@@QEAA?AV?$Local@VContext@v8@@@2@XZ', 'pointer', ['pointer', 'pointer']);
const v8_Context_Enter = createFunc('?Enter@Context@v8@@QEAAXXZ', 'pointer', ['pointer']);
const v8_HandleScope_init = createFunc('??0HandleScope@v8@@QEAA@PEAVIsolate@1@@Z', 'void', ['pointer', 'pointer']);
const v8_String_NewFromUtf8 = createFunc('?NewFromUtf8@String@v8@@SA?AV?$MaybeLocal@VString@v8@@@2@PEAVIsolate@2@PEBDW4NewStringType@2@H@Z', 'pointer', ['pointer', 'pointer', 'pointer', 'int', 'int']);
const v8_Script_Compile = createFunc('?Compile@ScriptCompiler@v8@@SA?AV?$MaybeLocal@VScript@v8@@@2@V?$Local@VContext@v8@@@2@PEAVSource@12@W4CompileOptions@12@@Z', 'pointer', ['pointer', 'pointer', 'pointer', 'pointer']);
const v8_Script_Run = createFunc('?Run@Script@v8@@QEAA?AV?$Local@VValue@v8@@@2@XZ', 'pointer', ['pointer', 'pointer']);
const v8_Value_Int32Value = createFunc('?Int32Value@Value@v8@@QEBAHXZ', 'int64', ['pointer']);
const scriptToExecute = `((a, b)=>{
console.log("Hello from Frida", a, b);
return a+b;
})(5, 17)`;
const processPending = new NativeCallback(function () {
const isolate = v8_Isolate_GetCurrent();
const scope = Memory.alloc(128);
v8_HandleScope_init(scope, isolate);
const opts = Memory.alloc(128);
const context = v8_Isolate_GetCurrentContext(isolate, opts);
const item = scriptToExecute;
const unkMem = Memory.alloc(128);
const source = v8_String_NewFromUtf8(unkMem, isolate, Memory.allocUtf8String(item), 0, -1);
const script = v8_Script_Compile(context, Memory.readPointer(context), source, NULL);
const result = v8_Script_Run(Memory.readPointer(context), context);
const intResult = v8_Value_Int32Value(Memory.readPointer(result));
console.log('Result', intResult);
}, 'void', ['pointer']);
const onClose = new NativeCallback(function () { }, 'void', ['pointer']);
const async = Memory.alloc(24);
uv_async_init(uv_default_loop(), async, processPending);
uv_async_send(async);
uv_unref(async);
}
catch (ex) {
console.log("Injected code execution error", ex);
}
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.
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.
Thanks to Anton Trofimov for helping with the article and sharing his software development expertise.