Despite we have a lot of electron apps are packages in ASAR format and we can easily unpack them to tamper the JavaScript inside of them, we still have to face a problem that some apps have "Protections" that we cannot tamper with them easily. Most of them are packed with a binary module which has a ".node" extension.
What is a binary module?
Take a look at Node-API, which provides the functionality to the module for communicating with NodeJS. Lets say it is a tunnel to connect the binary code to the JavaScript engine. When the require() function is called, NodeJS will call process.dlopen() to load that module into the process address space. The *.node extension in lib/internal/modules/cjs/loader.js looks like this:
// Native extension for .nodeModule._extensions['.node'] =function(module, filename) {if (manifest) {constcontent=fs.readFileSync(filename);constmoduleURL=pathToFileURL(filename);manifest.assertIntegrity(moduleURL, content); }// Be aware this doesn't use `content`returnprocess.dlopen(module,path.toNamespacedPath(filename));}
Then let's examine an actual sample module from this article. This sample provides a native implementation of a square() function:
In the code above, we declared the module init and a function. And inside of the init function we created an export to the square function. Using the wrapper NodeJS provided, we can use NAPI to control all things on JavaScript sides. But how does the module loaded?
Instead of downloading all the symbols, the binary call _register_square itself to load the module by using the constructor attribute. On Windows, it is more simple, just iterating over the export directory and call the register functions, then the module is loaded.
Practical Reversing with an encryption module
The module we will be reversed is packed by this project. So in this example, all of the js file is encrypted and could not be loaded without the binary module. Our goal is to reverse the module and extract the original data the javascript file contains.
Firstly, let's focus on the export directory. Only one export can be found as we can see. After digging into the struct we found its Initalizer. So let's call it "main" module.
Right after the module struct we can identify its Init function, in which it called another big function. Let's focus on the big function because the init function is just a wrapper of it.
Digging into the function hooking process
Inside of the function we can see a lot of stuff about anti-debugging, main module detection and other stuff, as it is harder to bypass since it's a binary module compared to a simple script. However, we can debug it using x64dbg to bypass the whole process. In this case, we can dig deeper into the binary.
We can see that it run two scripts in JS scope, one is to find the entry module, the other is to make a require function.
Then it uses napi_get_named_propertynapi_create_functionnapi_define_properties to detour the original _compile function to its own C++ version.
Inside myCompile function, it compare each module too see if it is come from app.asar. If not, we would decrypt the string buffer using some key in the binary module. In this case, sub_180004606 is the decryption routine.
Decrypt the JS module
Analyzing the decryption routine, we can see it used a constant key and a dynamic IV value to decrypt the data. And to generate the iv value, a random generator has been implemented.
The random generator's pseudo code is looked like this.
Apparently it is easier to tamper with the Javascript module because of its "free designing". However, it is easy to detect if a script file had been tampered. So instead of tampering with the script, let's focus on the binary module - the extractor.
Locating the hook address
Let's focus on the napi_run_script_wrapper function. Usually, any function can be hooked and we just pick an easy and familiar one.
So, browsing with its assembly, the position we selected to hook is this one, as it contained 7 bytes and only takes one instruction without modifying any stack variable and EFLAGS.
The shellcode
The goal we want to achieve is to execute our javascript before the main module is loaded. In this case, the script we inject is as follows, which hooked the JSON.parse function to our own for modifying the public key it returns. However, this method cannot use require() and if we want to use it, we need a very large shellcode which contains huge block of API calls to get the main module from process.mainModule and make our require function.
if (typeof _json_parse_orig =="undefined"){ _json_parse_orig =JSON.parse;JSON.parse=function(text, reviver){if (text.indexOf('-----BEGIN PUBLIC KEY-----') !=-1){ return ['-----BEGIN PUBLIC KEY-----', 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuZHZL7PmWJSHRLD2rbKr', 'k740QD4tnw6vaN4xgYqtwSwPxgKyM1fl/JzrBvMgxibuJxuzu4FUOmnfvwb2eBDN', 'skCuWkTSKjK1LWI1SWA/N0VzEIWu78JLc538nyA3j4XlJnC06jyWfQM0eixBLIDE', 'ZJolL9aTzAQvVtrIBzcCyZKRVBo4eRnWoEtI/tWgxqqIKENrYydWcfQS+wPSzpK3', 'dixFFnmh+B981ygI8ZhTycBoVS0Z2Ny7K49i25Dv0hDNvDbaDwhdjvzs/8jg4wsq', 'DHDObWLuEh4cUTe2f30Tdykichmi0WRojioE1bQxvThE2oR2v2BHdWus0EH9y9hw', '5wIDAQAB', '-----END PUBLIC KEY-----'];
}return_json_parse_orig(text, reviver); }}
And our shellcode need to be constructed with two API function calls, napi_create_string_utf8 and napi_run_script. Each function call needs to obey the x64 calling convention. Take notes that the volatile register value needs to be reversed, and if we are coding large function shellcode we may not use the Detour hook method but to use Trampoline method instead to protect the register and stack values.
EXTERN napi_create_string_utf8, napi_run_script, jmp_back
jscode: db `"undefined"==typeof _json_parse_orig&&(_json_parse_orig=JSON.parse,JSON.parse=function(B,I){return-1!=B.indexOf("-----BEGIN PUBLIC KEY-----")?["-----BEGIN PUBLIC KEY-----","MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuZHZL7PmWJSHRLD2rbKr","k740QD4tnw6vaN4xgYqtwSwPxgKyM1fl/JzrBvMgxibuJxuzu4FUOmnfvwb2eBDN","skCuWkTSKjK1LWI1SWA/N0VzEIWu78JLc538nyA3j4XlJnC06jyWfQM0eixBLIDE","ZJolL9aTzAQvVtrIBzcCyZKRVBo4eRnWoEtI/tWgxqqIKENrYydWcfQS+wPSzpK3","dixFFnmh+B981ygI8ZhTycBoVS0Z2Ny7K49i25Dv0hDNvDbaDwhdjvzs/8jg4wsq","DHDObWLuEh4cUTe2f30Tdykichmi0WRojioE1bQxvThE2oR2v2BHdWus0EH9y9hw","5wIDAQAB","-----END PUBLIC KEY-----"]:_json_parse_orig(B,I)})`,0x3B, 0x0
lenofcode: equ $-jscode-1 ; In NAPI the string length needs to be completely correct, or the API will fail and further calls with such APIs will populate errors.
shellcode:
push rcx
push rdx
push rsi
push r8
push r9
push rax
sub rsp, 0x20
mov rcx, qword [rcx]
mov rcx, rsi
lea rdx, [rel jscode] ; To make further address-fixing more easier using reletive addressing
mov r8, lenofcode
mov r9, rsp
call [rel napi_create_string_utf8]
mov rcx, rsi
mov rdx, qword [rsp]
lea r8, [rsp+0x8]
call [rel napi_run_script]
add rsp, 0x20
pop rax
pop r9
pop r8
pop rsi
pop rdx
pop rcx
mov r8, 0FFFFFFFFFFFFFFFFh ; And don't forget the instruction(s) we overwritten.
db 0xE9, 00, 00, 00, 00 ; relative jump
Fixing the shellcode and Patch DLL
To patch the shellcode into the dll file, we need to add a section (Well, currently I have not written a guide for this but I will in the future. Ping me if I forgot to add the hyperlink xD) into its PE structure.
Then we search the pattern of its function to locate the precise entry of it. And retrieve the IAT location of each function call using the relative_call_to_absoluteroutine. Finally we need to fix the call the jmp location for each function call in our shellcode. We need to get the location of each relative call and jmp instruction to fix.
P.S. Don't confuse with RVA, FOA and the file pointer.