Defeating Android Root Detection with Smali Patching

Binary patching Android applications to bypass security mechanisms.

The Mobile Security Guys
The Startup
10 min readJun 16, 2020

--

Root detection in Android apps has always been a cat and mouse game. Developers come up with new checks, or a new library comes out; the attackers then bypass these checks or hide root from the filesystem. Despite all the work that goes into coming up with new techniques for either side, root detection remains one of the first hurdles of a defence in depth solution for mobile applications; and as security researchers, one we see all the time.

Rooting

This post isn’t meant to educate about rooting or the act of obtaining root, but it is useful to understand the concepts surrounding it. To root a device means to gain access to the super user account on the operating system, in Android, which is based on Linux, this user is called ‘root’.

Rooting is the act of opening up the system, to break the operating system “chains”.

Photo by John Salvino on Unsplash

Being the super user, this account can do anything, view, edit, modify anything; but in the modern age when smartphones likely have more personal data than our other devices, being able to view any file is not always a good thing from a privacy perspective. To break out of the operating system ‘chains’ the device requires certain modifications, either through vendor approved unlocking, or device exploitation.

This is where root detection comes in. Developers add techniques into their app code to check whether this root user is accessible, whether the device has been modified, or if its possible to view files that are usually restricted and then take certain actions based on the result of these checks.

However, the downfall to this is that most of these checks are done badly, perhaps the strings are hardcoded, the result relies on a single Boolean return value, or incorrect logic means the checks don’t even run at all. By identifying and manipulating these flaws in the logic, we can tamper with this detection allowing apps to run as expected on our modified devices.

By leveraging development flaws, we can bypass these checks and allow apps to run on modified devices

We’re going to take a look at two examples of flawed implementations as well as explaining the smali patches involved to avoid having root detected.

Return Values

Its understandable that you need a return value to return the result of the checks, however this value can become a single point of failure if it’s badly implemented. Nesting if statements and then returning a single Boolean return makes for a very quick patch to easily bypass a multitude of different protections. However they’re not always simple, is it return void, return false, return the value of an object; and this is where understanding the syntax and having knowledge of smali really helps.

In this first example we’ll look at these return statements in an app that is unobfuscated and uses a very common root detection method, checking for the presence of root utilities with hardcoded names. The lack of code obfuscation on class and method names make finding the root detection code easy.

Java

In this first root detection mechanism, it is clear what the app attempts to achieve and what the root detection does.

Two specific root checks are implemented:

  • checkRootMethod1() searches for strings representing files that maybe present on a rooted device, such as “Superuser.apk”, “sbin/su” etc
  • checkRootMethod2() attempts to execute the command “su” by means of the Java getRuntime() method.
  • These 2 methods are called inside other methods above, which are named testIsRooted() and isRooted()
Decompiled Java code for the root detection methods.

What is important to notice here is that these methods do not change any part of the underlying app structure, they are called and merely return the values based on the results.
We have two options for this patch to achieve the same outcome.

Return values for the detection methods.
  • Both checkRootMethod1() and checkRootMethod2() return a Boolean. We could amend this to only return False.
  • Patch the calling methods, isRooted() to return False and testIsRooted() void before it calls the real methods.

Smali

Due to the low level that smali works at, it is usually best to modify at the highest point of the calling functions causing the least changes to the codebase. Making changes to specific methods may require more complex patches.

Let us now look at the checkRootMethod1() to get an idea of some of the syntax used.

It is generally better to perform the most ‘lazy’ approach, causing minimal changes where possible.

The smali for the first root detection method is shown.

  • Method code can be identified with the key word .method, and return type character; in this instance a Boolean (Z).
  • We see the way the code creates String objects using local variables, v0…v8. Then creates a new String array with this range. {v0 .. v8}, [Ljava/lang/String;
  • The array length is calculated, initialisation of other variables and a check, if v3 > v1, cond_1
  • On line 67–71, we create a new File class, signified with L prefix; and check if the hardcoded file paths exists.
  • If the path exists, put the value 0x1 (True) into the v0 local variable, line 77.
  • We then return the value of v0 to the calling method.

It is worth showing both return calls here; only if the file path exists will line 77–79 be executed as it follows the flow of code.

If no path exists, if v4 == 0, line 75, branch the code to cond_0, add 0x1 to v3 and follow the goto_0 back to line 62.

###line 62
:goto_0
if-ge v3, v1, :cond_1

The variable v1 (paths left in array - 0) is now less than v3 (0x1) and thus the code jumps to cond_1, whereby return v2 is run. v2 is 0 (False) as previously initialised on line 57 with 0x0.

Note: const/4 [r] [v] puts the numerical value (v) into the specific register (r) as we have seen. The /4 stands for 4 bits and thus the value (v) has a maximum value of 0x15, 0b1111.
const/8 (255) and const/16 (65535) can also be used.

Patching

Now we have covered some of the syntax and opcodes used, let us now consider the actual methods that we are going to patch. Despite the complexities involved in learning this new language, the patches in this first example are very straight forward.
The first part of the patch involves bypassing the method which calls the actual root detection code.

We can see by the (V) type that this method returns void, this is also evident in the return-void statement on line 184.
Simply moving this statement to before the root check methods are called, from line 184 to line 179 bypasses this function completely.

Original smali code for root detection.
Modified smali code with moved return-void statement.

We now need to target the second method, isRooted(), as seen in the method call, this returns a Boolean (Z).
This time rather than moving lines, we can add in our own code to perform the desired behaviour. We want to ensure we return 0x0 or False before the actual detection is called.

In this instance, we can manually write some new code to put a 0 into a local variable, and instantly return this before any other code is run. This can be seen on lines 143–145 in the second image.

const/4 v0, 0x0
return v0
### original code continues
Original smali for the isRooted()
Modified code to return False in v0

Before we move onto the next example we should point out, in case it wasn’t already obvious. None of the original smali code has been modified or overwritten, we have simply moved or added statements to alter the way the application flows.

Code Modification

When we say code modification we don’t mean we plan to write swathes of complex smali ourself, we simply modify the code to suit our requirements; remember, the lazy approach is the best one.

Read, interpret and modify… The lazy approach is best.

In this next example the application is heavily obfuscated, it involves numerous checks both in Java and native C and doesn’t return just a single Boolean value at the end; however the methodology is generally the same, read, interpret and modify.

When dealing with obfuscated apps, usually the very first steps consist of identifying where the root check is performed, where the root check is implemented, and where it is actually called from. Usually we have 2 options to accomplish this:

  1. We could search for strings such as “/su” and root package manager apps like “magisk” and then follow the flow. Generally quite time consuming and could lead to rabbit holes.
  2. A more general approach, consists of running the app and then tracing back any error messages that are shown when the detection check fails. For root detection this is generally on launch.

When we say “tracing back”, it relies on an associated positive or negative outcome to a specific check. For example, in a positive outcome the app would continue loading and the user would be able to use the app as normal. A negative outcome we may expect an error message, either by displaying the user a pop-up window or a toast, then the app closing. If we are lucky and this error message is visible, we can trace back this pop-up by searching for the string displayed; usually this links to a resource having a similar name defined in a xml file with an associated ID. Using the ID tag we can search for this in the smali code to identify where it is used and the workflow to obtain these actions.

Java

Let us now examine this second example with the obfuscated app implementing a more sophisticated root check. After having decompiled the app, we search for the string “su” and “magisk” and we manage to find both in a large method named a() which is located in the obfuscated class: m.f.a.c.i

Java method code incorrectly decompiled, showing smali rather than readable Java.

Despite the fact the method within this class file failed to decompile correctly we can still ascertain that the method returns a String and takes 2 Strings as input arguments. We also notice that although unclear at current, the String ‘su’ and ‘magisk’ have been hardcoded; this can also be seen when we examine the smali, however there seem to be numerous returns from multiple methods and return values of different values so we have to take a different approach compared to the previous example.

Smali

Despite not fully understanding the code from the Java point of view we can make some assumptions based on the smali opcodes.

  • Already noted are the hardcoded strings in const-string vx, “str”
  • Use of invoke-virtual, signifies that the method call is not private, static or final. They are generally used for native library loading.
  • The {} from invoke-virtual specify the arguments that are given to the method.
  • Based on the names where the actual checkForRoot() method is called, we know it is definitely using a native library — RootBeerNative

Based on the above code snippets, and the bad decompilation of the Java, it is not totally understandable what the code does at this point. However we have found these hardcoded strings which relate to rooting tools or binaries so can guess that the code is searching for these at some stage.

What if we just modify these strings to something else? Something that doesn’t exist and so they will always return False?

The modification of these two strings was the only necessary adjustments to defeat this detection and use the app as normal. You could be fooled into thinking “that was easy”, however, to get to the stage to find that it was these two strings that were being used was the biggest effort, tracing back, reading over the smali to understand the methods and return values all takes time.

Just by changing two string values the root detection was defeated, but its not always that simple.

Modification to the hardcoded strings, su to AA and magisk to BB defeated the protections.

In this post we have covered two different ways to defeat root detection by patching the smali code. We looked at modifying the return values to manipulate the behaviour without modifying the original code; and we looked at a method whereby through small changes we can trick the app into looking for a file that we know doesn’t exist and therefore making the check fail.

Root detection remains the first step of a defence in depth measure but it needs to be implemented correctly. It needs to have multiple steps and multiple different checks in order to avoid a single point of failure. Using server side verification with tools such as Google SafetyNet Attestation API¹ is a good way to move checks away from the client; however the cat is always chasing the mouse, and even SafetyNet can be bypassed with certain tools.

--

--

The Mobile Security Guys
The Startup

A diverse collection of posts on mobile and API-based security and testing techniques, contributed by a group of mobile professionals in our spare time.