Frida Dynamic instrumentation to bypass Root detections Part II

Musyoka Ian
14 min readOct 13, 2024

--

During the Last article we discussed and demonstrated the change of and Android application behavior by hooking the application while it’s in a running state and modifying it’s behavior by hooking into different Java classes and methods. Based on the previous articles we had noticed that the Java methods were being called from the shared library called libnative — lib.so. In this Walkthrough we are going to bypass the same in app protection checks just from assembly layer by hooking into the shared library rather than from the Java Later. Hooking into native code with Frida on Android refers to the process of intercepting and modifying calls to functions in native libraries (typically written in C or C++ and compiled into .so files) while an application is running. This is particularly useful for reverse engineering, debugging, and security analysis of Android applications.

The below documentation can be really useful especially to beginners who are trying to learn

Just to give a bit of recap when we opened the application four rdetection mechanisms were firing up and the application notified us that the application was rooted

Reversing the application we noted that the checks were done by the Java code by calling some function from the native library

The next step is decompiling the shared library and the program I’ll use is hopper

You can use any other disassembler like ghidra is doesn’t matter. On decompiling the shared library we get the functions which we need to hook into and even get to see the assembly code.

My interest immediately did not go to analyze the function but see the functions that have been imported from libc.so

Looking at the hopper decomplication we get a few functions namely

fopens, srtcmp, strstr, access etc

This functions are imported and they can be found from libc.so library. My main focus was to first of all hook into this functions and see the arguments and output.

The first snippet of the code i wrote was as you can see below


const colors = {
colorize: (str, cc) => `\x1b${cc}${str}\x1b[0m`,
red: str => colors.colorize(str, '[31m'),
green: str => colors.colorize(str, '[32m'),
yellow: str => colors.colorize(str, '[33m'),
blue: str => colors.colorize(str, '[34m'),
cyan: str => colors.colorize(str, '[36m'),
white: str => colors.colorize(str, '[37m'),
};


var linker64 = Process.findModuleByName("linker64").enumerateSymbols();

var dl_open_adddress = null;
var call_constructor_address = null;
var libnative_address = null;

for(var x = 0; x < linker64.length; x++){
if((JSON.stringify(linker64[x])).indexOf("do_dlopen") >= 0){
console.log(colors.green("\t[+] do_dlopen called by the application"))
console.log(colors.yellow("\t[*] Charcteristics of do_dlopen: " + JSON.stringify(linker64[x])));
console.log(colors.yellow("\t[*] Memory adress of do_dlopen: " + colors.green(linker64[x].address)));
dl_open_adddress = linker64[x].address;
} else if ((JSON.stringify(linker64[x])).indexOf("call_constructor") >= 0){
console.log(colors.green("\t[+] call_constructor called by the application"))
console.log(colors.yellow("\t[*] Charcteristics of call_constructor: " + JSON.stringify(linker64[x])));
console.log(colors.yellow("\t[*] Memory adress of call_constructor: " + colors.green(linker64[x].address)));
call_constructor_address = linker64[x].address;
}
}

var libnative_loaded = 0
Interceptor.attach(dl_open_adddress, {
onEnter: function(args){
if((args[0].readCString()).indexOf("libnative-lib.so") >= 0){
console.log(colors.green("[+] libnative-lib.so starting to load..."));
//var libnative_loaded = 0;
Interceptor.attach(call_constructor_address, {
onEnter: function(args1){
if(libnative_loaded == 0){
libnative_address = Process.findModuleByName("libnative-lib.so").base;
console.log(colors.green("[+] Base address of libnative-lib.so: "+JSON.stringify(libnative_address)));

}
libnative_loaded = 1;


},
onLeave: function(retval1){
//do ntohing
}
});
}
},
onLeave: function(retval){
//do something
}
})

The provided code snippet is a Frida script designed to hook into specific functions of an Android application’s native library, particularly focusing on `do_dlopen` and `call_constructor`. It begins by defining a utility object, `colors`, which is used for colorizing console output in the terminal. The `colorize` method formats a string with the specified ANSI color code, while specific methods like `red`, `green`, and `yellow` create colorized versions of strings for ease of readability. This setup aids in visually distinguishing various log messages, enhancing the script’s usability during debugging or analysis.

The next part of the code retrieves information about the loaded symbols from the `linker64` module using `Process.findModuleByName(“linker64”).enumerateSymbols()`. The script initializes three variables — `dl_open_address`, `call_constructor_address`, and `libnative_address` — to store the memory addresses of the functions of interest. A loop iterates through the symbols returned by the enumeration, checking for the presence of the strings “do_dlopen” and “call_constructor” within each symbol. When found, the script logs relevant information, including the function characteristics and memory addresses, and assigns these addresses to the respective variables for later use.

Following the symbol enumeration, the script sets up an interceptor on the `dl_open_address`, which corresponds to the `do_dlopen` function. The `onEnter` callback is triggered when this function is called. It checks if the first argument, which is expected to be a string, contains the substring “libnative-lib.so”, indicating that the specific native library is being loaded. If it is, the script logs a message indicating the loading process has started. Additionally, the script attaches another interceptor to `call_constructor_address`, which corresponds to the `call_constructor` function. Inside this interceptor, it checks if the library has already been loaded (using the `libnative_loaded` flag). If not, it retrieves the base address of `libnative-lib.so` and logs this address. The `libnative_loaded` variable is then set to `1`, ensuring the library’s address is only retrieved once during the execution of the hook.

Overall, this script effectively monitors the loading of a specific native library within an Android application, providing insights into the library’s behavior and interactions during runtime. By using Frida’s interception capabilities, the script allows for real-time analysis and potential modification of the native code’s execution flow, which is particularly valuable for security researchers and developers debugging their applications.

Now that we know that libnative-lib.so has been successfully loaded, We call call our imported function i write the following code that intercepts the functions whenever they are called

I’ll incoporate the snippet to the code i had already write earlier on

function hook_lib(){
Interceptor.attach(Module.getExportByName(null, "fopen"), {
onEnter: function(args){
if((args[0].readCString()).indexOf("proc") >= 0){
console.log(colors.yellow("\t[*] Binary called fopen with argument: " + args[0].readCString() ));

}

},
onLeave: function(retval){
//console.log(colors.yellow("\t[*] fopen closing: " ));
}
})


Interceptor.attach(Module.getExportByName(null, "strcpy"), {
onEnter: function(args){
console.log(colors.yellow("\t[*] Binary called strcpy with argument: " + args[1].readCString() ));
},
onLeave: function(retval){
//console.log(colors.yellow("\t[*] fopen closing: " ));
}
})

Interceptor.attach(Module.getExportByName(null, "access"), {
onEnter: function(args){
console.log(colors.yellow("\t[*] Binary called access with argument: " + args[0].readCString() ));
},
onLeave: function(retval){
//console.log(colors.yellow("\t[*] fopen closing: " ));
}
})

}

The provided code defines a function hook_lib that uses Frida to intercept calls to three specific native functions: fopen, strcpy, and access. These functions are commonly used in C/C++ programs for file operations, string manipulation, and checking file access permissions, respectively. The primary purpose of this hooking mechanism is to log the arguments passed to these functions, enabling monitoring and analysis of the application's behavior at runtime.

Within the hook_lib function, the first interception is set up for fopen, which opens a file. The Interceptor.attach method binds to the function and specifies an onEnter callback that executes when fopen is called. The callback checks if the first argument (the filename) contains the substring "proc," which often indicates that the application is trying to access the /proc filesystem, a virtual filesystem in Linux that provides information about running processes. If this condition is met, it logs the argument using a colorized console output, helping the developer or researcher identify when the application interacts with sensitive parts of the filesystem.

The second interception targets strcpy, a function that copies a string from one location to another. The onEnter callback logs the second argument (the destination string) when strcpy is called. This is useful for understanding what strings are being manipulated by the application, especially if they relate to sensitive data or potential vulnerabilities like buffer overflows.

Finally, the script hooks into the access function, which checks the accessibility of a file. Similar to the previous hooks, the onEnter callback logs the first argument (the file path being checked). This can provide insights into the application's permission checks and its interactions with the filesystem.

Up to now the only thing we have done is monitor the application behaviour. The next part is now change the arguments being called by the functions dynamically. From the code i have shared above i modified the it as seen below

function hook_lib(){
Interceptor.attach(Module.getExportByName(null, "fopen"), {
onEnter: function(args){
if((args[0].readCString()).indexOf("proc") >= 0){
console.log(colors.yellow("\t[*] Binary called fopen with argument: " + args[0].readCString() ));
var path2 = "/bypassing protections";
var mem_address2 = Memory.alloc(path2.length);
args[0] = mem_address2.writeUtf8String(path2);

}

},
onLeave: function(retval){
//console.log(colors.yellow("\t[*] fopen closing: " ));
}
})


Interceptor.attach(Module.getExportByName(null, "strcpy"), {
onEnter: function(args){
console.log(colors.yellow("\t[*] Binary called strcpy with argument: " + args[1].readCString() ));
var path1 = "/bypassing protections";
var mem_address1 = Memory.alloc(path1.length);
args[1] = mem_address1.writeUtf8String(path1);
},
onLeave: function(retval){
//console.log(colors.yellow("\t[*] fopen closing: " ));
}
})

Interceptor.attach(Module.getExportByName(null, "access"), {
onEnter: function(args){
console.log(colors.yellow("\t[*] Binary called access with argument: " + args[0].readCString() ));
var path = "/bypassing protections";
var mem_address = Memory.alloc(path.length);
args[0] = mem_address.writeUtf8String(path);

},
onLeave: function(retval){
//console.log(colors.yellow("\t[*] fopen closing: " ));
}
})

}

Hooking fopen

The first part of the code attaches an interceptor to the fopen function, which is responsible for opening files. Inside the onEnter callback, the script checks whether the first argument (the file path) contains the substring "proc." This check is crucial because accessing files in the /proc directory often pertains to sensitive system information or process details. If the condition is met, the script logs this event, providing visibility into the application's behavior. Subsequently, the code defines a new file path (/bypassing protections) and allocates memory to hold this string. By overwriting the original argument (args[0]) with a pointer to the newly allocated memory, the script effectively redirects any file opening attempts to the specified path. This manipulation can help bypass security mechanisms that restrict access to certain files.

Hooking strcpy

The second interceptor targets the strcpy function, which copies a string from one location to another. The onEnter callback logs the second argument, which represents the destination where the string is being copied. This logging provides insights into how the application manages string data. Similar to the fopen hook, the script allocates memory for the new path (/bypassing protections) and writes this string to the allocated memory. By modifying the argument (args[1]), the script ensures that any string copying operations will now reference this new path. This can be strategically useful for altering the behavior of the application by controlling where data is written, potentially circumventing any restrictions on specific paths.

Hooking access

The third interceptor attaches to the access function, which checks the existence of a file and the permissions of the calling process to access it. In the onEnter callback, the script logs the first argument (the file path being checked) to track what files the application is attempting to access. To maintain control over the access checks, the code allocates memory for the new path and writes the string /bypassing protections to this allocated memory. This modification of the original argument (args[0]) ensures that any checks for file access will now point to the specified path, which can be critical for bypassing security measures designed to protect sensitive files.

After running the application again while hooking and injecting the script created we can see that two detection mechanisms have been bypassed successfully and only two are left

Two functions are still returning that the application is rooted namely

  1. isSuExists
  2. isFoundDangerousProps

Remember that this true methods return Boolean values. The easiest way is to hook at the return values and change the true values to false. I write the below snippet code that does this automatically

var su_exists_address = ptr(0x3768);
var hook_su_exists_address = libnative_address.add(su_exists_address);
Interceptor.attach(hook_su_exists_address, {
onEnter: function() {
//console.warn("*************************" + Instruction.parse(this.context.x0));
this.context.x0 = ptr(0x0);
},
onLeave: function(retval) {
//console.warn("********************" + Instruction.parse(this.context.x0));
//return ptr(0x0);
}
});
var dangerous_props_address = ptr(0x35b4);
var hook_dangerous_props_address = libnative_address.add(dangerous_props_address);
Interceptor.attach(hook_dangerous_props_address, {
onEnter: function() {
//console.warn("*************************" + Instruction.parse(this.context.x0));
this.context.x0 = ptr(0x0);
},
onLeave: function(retval) {
//console.warn("********************" + Instruction.parse(this.context.x0));
//return ptr(0x0);
}
});

Remember that the base address of libnative-lib.so had already been leaked previously the other two addresses 0x3768 and 0x35b4 are the return offsets for the two functions

Hooking su_exists

The first part of the code initializes a pointer to the address of the su_exists function using ptr(0x3768). This address is relative and indicates a specific location within the library. The hook_su_exists_address variable is then calculated by adding this relative address to libnative_address, which represents the base address of the native library. This approach is common in dynamic instrumentation, as it allows for accurate targeting of functions regardless of their actual memory locations during runtime.

An interceptor is then attached to hook_su_exists_address using Interceptor.attach. The onEnter callback is defined to execute when the su_exists function is called. Inside this callback, the code modifies the value of this.context.x0, setting it to ptr(0x0). In ARM architecture, x0 typically holds the first argument passed to a function. By setting x0 to zero, the script effectively alters the behavior of the su_exists function, which likely checks for the presence of superuser access. This manipulation can prevent the function from returning information about superuser privileges, thereby bypassing any security checks related to root access.

Hooking dangerous_props

The second part of the code follows a similar structure to the first. It initializes a pointer to the address of a function related to checking for dangerous properties (dangerous_props) using ptr(0x35b4). As before, hook_dangerous_props_address is calculated by adding this relative address to libnative_address, ensuring the interceptor targets the correct function in memory.

The code attaches an interceptor to hook_dangerous_props_address with an onEnter callback that, like the first function, sets this.context.x0 to ptr(0x0). This manipulation is again aimed at altering the function's behavior by neutralizing any checks or responses related to dangerous properties, which could include security features designed to protect the application from exploitation.

Combining all the code we get a fully bypass by hooking to the native library

And we are done the bypass works effectively. Below is the combined code for both Java and native hooking

/**
* Aritcles
* https://github.com/interference-security/frida-scripts
* https://learnfrida.info/advanced_usage/
* https://node-security.com/posts/frida-for-ios/
* https://frida.re/docs/javascript-api/
* https://8ksec.io/advanced-frida-mobile/
* https://deviltux.thedev.id/notes/ios-frida-scripting/
* https://armconverter.com/
*
*
*/

const colors = {
colorize: (str, cc) => `\x1b${cc}${str}\x1b[0m`,
red: str => colors.colorize(str, '[31m'),
green: str => colors.colorize(str, '[32m'),
yellow: str => colors.colorize(str, '[33m'),
blue: str => colors.colorize(str, '[34m'),
cyan: str => colors.colorize(str, '[36m'),
white: str => colors.colorize(str, '[37m'),
};


var callbacks = {
onMatch: function(args){
if(args.indexOf("frida") >= 0){
console.log(colors.cyan("[*] Class Loaded: " + args));
var method = Java.use(args);
var all__methods = method.class.getMethods();
for(var i = 0; i <= all__methods.length; i++){
//console.log(colors.blue("\t[**] Method assosiated: " + all__methods[i]));
}
}
},
onComplete: function(retval){
//do nothing
}
}


Java.perform(function(){
try{
Java.enumerateLoadedClasses(callbacks);
}catch(e){
console.log(colors.red("\t[-] An error occured: " + e))
}
});

Java.perform(function(){
try{
var bypass1 = Java.use("com.detectfrida.util.RootDetectorHelper");
bypass1['isFoundWrongPathPermission'].overload().implementation = function(){
console.log(colors.yellow("\t[+] isFoundWrongPathPermission called"));
var ret = this.isFoundWrongPathPermission();
console.log(colors.green("\t[+] Return value for isFoundWrongPathPermission: " + JSON.stringify(ret) ));
return ret
}
}catch(e){
console.log(colors.red("\t[-] An error occured: " + e));
}
});

Java.perform(function(){
try{
var bypass1 = Java.use("com.detectfrida.util.RootDetectorHelper");
bypass1['isSuExists'].overload().implementation = function(){
console.log(colors.yellow("\t[+] isSuExists called"));
var ret = this.isSuExists();
console.log(colors.green("\t[+] Return value for isSuExists: " + JSON.stringify(ret) ));
return ret
}
}catch(e){
console.log(colors.red("\t[-] An error occured: " + e));
}
});

Java.perform(function(){
try{
var bypass1 = Java.use("com.detectfrida.util.RootDetectorHelper");
bypass1['isFoundSuBinary'].overload().implementation = function(){
console.log(colors.yellow("\t[+] isFoundSuBinary called"));
var ret = this.isFoundSuBinary();
console.log(colors.green("\t[+] Return value for isFoundSuBinary: " + JSON.stringify(ret) ));
return ret
}
}catch(e){
console.log(colors.red("\t[-] An error occured: " + e));
}
});

Java.perform(function(){
try{
var bypass1 = Java.use("com.detectfrida.util.RootDetectorHelper");
bypass1['isFoundDangerousProps'].overload().implementation = function(){
console.log(colors.yellow("\n\t[+] isFoundDangerousProps called"));
var ret = this.isFoundDangerousProps();
console.log(colors.green("\t[+] Return value for isFoundDangerousProps: " + JSON.stringify(ret) ));
return ret
}
}catch(e){
console.log(colors.red("\t[-] An error occured: " + e));
}
});




var linker64 = Process.findModuleByName("linker64").enumerateSymbols();

var dl_open_adddress = null;
var call_constructor_address = null;
var libnative_address = null;

for(var x = 0; x < linker64.length; x++){
if((JSON.stringify(linker64[x])).indexOf("do_dlopen") >= 0){
console.log(colors.green("\t[+] do_dlopen called by the application"))
console.log(colors.yellow("\t[*] Charcteristics of do_dlopen: " + JSON.stringify(linker64[x])));
console.log(colors.yellow("\t[*] Memory adress of do_dlopen: " + colors.green(linker64[x].address)));
dl_open_adddress = linker64[x].address;
} else if ((JSON.stringify(linker64[x])).indexOf("call_constructor") >= 0){
console.log(colors.green("\t[+] call_constructor called by the application"))
console.log(colors.yellow("\t[*] Charcteristics of call_constructor: " + JSON.stringify(linker64[x])));
console.log(colors.yellow("\t[*] Memory adress of call_constructor: " + colors.green(linker64[x].address)));
call_constructor_address = linker64[x].address;
}
}

var libnative_loaded = 0
Interceptor.attach(dl_open_adddress, {
onEnter: function(args){
if((args[0].readCString()).indexOf("libnative-lib.so") >= 0){
console.log(colors.green("[+] libnative-lib.so starting to load..."));
//var libnative_loaded = 0;
Interceptor.attach(call_constructor_address, {
onEnter: function(args1){
if(libnative_loaded == 0){
libnative_address = Process.findModuleByName("libnative-lib.so").base;
console.log(colors.green("[+] Base address of libnative-lib.so: "+JSON.stringify(libnative_address)));
hook_lib();
/**
* Trying to bypass superuser apk
*/
var su_exists_address = ptr(0x3768);
var hook_su_exists_address = libnative_address.add(su_exists_address);
Interceptor.attach(hook_su_exists_address, {
onEnter: function(){
//console.warn("*************************" + Instruction.parse(this.context.x0));
this.context.x0 = ptr(0x0);
},
onLeave: function(retval){
//console.warn("********************" + Instruction.parse(this.context.x0));
//return ptr(0x0);
}
});
var dangerous_props_address = ptr(0x35b4);
var hook_dangerous_props_address = libnative_address.add(dangerous_props_address);
Interceptor.attach(hook_dangerous_props_address, {
onEnter: function(){
//console.warn("*************************" + Instruction.parse(this.context.x0));
this.context.x0 = ptr(0x0);
},
onLeave: function(retval){
//console.warn("********************" + Instruction.parse(this.context.x0));
//return ptr(0x0);
}
});


}
libnative_loaded = 1;


},
onLeave: function(retval1){
//do ntohing
}
});
}
},
onLeave: function(retval){
//do something
}
})


function hook_lib(){
Interceptor.attach(Module.getExportByName(null, "fopen"), {
onEnter: function(args){
if((args[0].readCString()).indexOf("proc") >= 0){
console.log(colors.yellow("\t[*] Binary called fopen with argument: " + args[0].readCString() ));
var path2 = "/bypassing protections";
var mem_address2 = Memory.alloc(path2.length);
args[0] = mem_address2.writeUtf8String(path2);

}

},
onLeave: function(retval){
//console.log(colors.yellow("\t[*] fopen closing: " ));
}
})


Interceptor.attach(Module.getExportByName(null, "strcpy"), {
onEnter: function(args){
console.log(colors.yellow("\t[*] Binary called strcpy with argument: " + args[1].readCString() ));
var path1 = "/bypassing protections";
var mem_address1 = Memory.alloc(path1.length);
args[1] = mem_address1.writeUtf8String(path1);
},
onLeave: function(retval){
//console.log(colors.yellow("\t[*] fopen closing: " ));
}
})

Interceptor.attach(Module.getExportByName(null, "access"), {
onEnter: function(args){
console.log(colors.yellow("\t[*] Binary called access with argument: " + args[0].readCString() ));
var path = "/bypassing protections";
var mem_address = Memory.alloc(path.length);
args[0] = mem_address.writeUtf8String(path);

},
onLeave: function(retval){
//console.log(colors.yellow("\t[*] fopen closing: " ));
}
})

}

The provided code snippets demonstrate the use of Frida to intercept and manipulate calls to native functions in an Android application, specifically targeting security-related checks such as `su_exists` and `dangerous_props`. By hooking into these functions, the script alters their behavior by setting their first argument to zero, effectively bypassing checks for superuser access and dangerous properties. This allows for real-time monitoring and modification of application behavior, which is particularly useful for security researchers and developers looking to analyze or alter how the application interacts with sensitive system features. The use of memory allocation and pointer manipulation showcases Frida’s powerful capabilities for dynamic instrumentation in native code environments.

I hope you enjoyed the walkthrough and if so clap for me down below and follow me so that you won’t miss any upcoming walkthroughs. I plan on doing a youtube video for it seen the whole dynamic instrumentation seems a bit advanced. I’ll update once the video is ready but it’ll take some time because of work. But until next time, goodbye

--

--