Challenges of Pentesting Android Applications (Part 1)
In this post, I’m going to discuss how to crack Android apps, the challenges of bypassing security algorithms, and everything related to pentesting and reverse engineering Android applications.
About three years ago, I published a 10-part video course on YouTube titled “Cracking Android Applications.” However, it didn’t gain as much attention as I had hoped — likely because the course was in Persian.
Overall, I received positive feedback on that course (everyone who watched it seemed satisfied 😁). However, the questions I got were usually one of these:
- How can I hack this game? 😒
- How much do you charge to add coins to my account? 😐😒
- There was a third group of people whose questions clearly showed they had watched the course with the goal of learning the concepts, and their questions were aimed at gaining deeper knowledge (not cracking games 😶😍).
Naturally, the reason behind this post — and the planned series of 10–11 posts — is to address the needs of the third group, along with the following reasons:
- Compared to topics like web security, there’s significantly less content published about subjects such as pentesting and overall mobile app security.
- Most so-called advanced courses in this field barely go beyond an intermediate level.
- And finally, many apps have recently started adding numerous security mechanisms to their code in the hope of improving security. However, in practice, these measures haven’t been very effective! It’s like taking an old, worn-out car and trying to make it safer. No matter what you do — whether you add airbags or reinforce the bumper — it won’t help much if the car’s overall structure is inherently insecure.
That’s exactly the issue here: security algorithms can only work effectively when they are implemented within a proper structure. There have been many cases where, after a pentest, we’ve advised organizations to completely overhaul the architecture of their project. Yet, due to the high cost of redevelopment, they’ve stuck with the same old structure and simply added more security algorithms! 😐
If you’re an Android developer or work in the field of security, this series of posts might interest you. The goal of these posts is to improve the situation I’ve outlined above — at least to some extent.
Prerequisites
In this series of posts (starting with this one), it’s assumed that you’re already familiar with the basics of Android programming and security. I’ll still explain topics as much as possible without making the posts too lengthy, but here are the key prerequisites you’ll need:
- Familiarity with Android Programming
You should have experience with native Android development, not cross-platform frameworks like Flutter, React Native, and the like. (Not that cross-platform tools aren’t great — they are! They significantly speed up development, enforce many security mechanisms by default after compilation, and come with numerous other benefits. However, when it comes to pentesting and reverse engineering Android apps, you need to understand the core structure of Android apps. This level of understanding only comes from developing natively for the OS, such as using Java or Kotlin for Android.) - Understanding the Basics of Hooking in Android
Hooking is typically done using tools like Frida. If you’re unfamiliar with Frida or the concept of hooking, don’t worry! I’ll include links to a few short and concise tutorials later in this post to help you get started. - Basic Knowledge of Assembly
Don’t be intimidated by the name — it’s not as scary as it sounds. You don’t need to start learning assembly from scratch. Even grasping about 20% of it is enough for a start. There are plenty of cheat sheets available, and you don’t need to memorize anything. Just decompile a few applications, keep a cheat sheet handy, and use an AI chatbot (my current recommendations are ChatGPT and Claude). Before long, you’ll see that assembly isn’t as complicated as it initially seems, and you can work with it comfortably.
Keep in mind that the architecture of most Android and iOS devices today is ARM, which has a slightly different syntax compared to x86 (ARM is easier). However, if you’re interested in learning assembly purely out of personal curiosity, I recommend starting with x86. It will give you a more comprehensive understanding of processors, and once you’re familiar with x86, ARM will feel like child’s play.
Let’s get to the main point…
As you know, one of the challenges we face in the process of analyzing, reverse engineering, and pentesting Android applications is the presence of security algorithms. These algorithms are specifically designed to make our job (as reverse engineers or pentesters) more difficult. One of the main challenges we face is bypassing these algorithms.
If you’re not familiar with these algorithms, the post below might be helpful for you:
Of course, the post below is from 2–3 years ago and needs some editing, but it’s still good for getting a general understanding.
For example, let’s say we want to capture the network traffic of an app, but we can’t, or we manage to capture the traffic but it’s being sent/received in an encrypted form, along with other similar limitations. So, what do we do? There are two general approaches:
- One option is to patch the app. This means we decompile the app, remove the part of the code responsible for encrypting the traffic, and then recompile it (this is similar to the course I mentioned at the beginning of the post, though that course mainly focused on Java code, with just a brief mention of Native Code. In this series of posts, we’ll also be working with Native Code).
- In the second approach, we can hook the part of the code that’s creating limitations for us (in pentesting or reverse engineering). This is usually done with Frida. If you’re not familiar with Frida and hooking, the links below might be helpful for you:
Android Pentesting With Frida
Mobile App Testing With Frida
In simple terms, during the hooking process, instead of directly modifying the app’s file (which is what we would do in patching or cracking), we modify the part of the code we want to change in real-time, directly from memory. For example, imagine there’s a function that checks whether the user has entered the correct password. By default, this function should only allow login if the password is correct. By hooking, we change this function (at runtime) so that it allows login regardless of the password entered.
So, hooking is somewhat similar to patching, with the key difference being that the change happens at runtime and nothing is altered in the app’s file.
If you’re wondering whether patching or hooking is better, it depends on the project and the behavior we want to bypass. Often, we handle part of the task with patching and another part with hooking. However, overall, in reverse engineering and pentesting, hooking is usually used more frequently.
By the way, I should mention that for hooking Android apps, other tools can also be used, such as:
- Using Objection (which uses Frida under the hood). It comes with a set of ready-made scripts that make the hooking process much easier, but it doesn’t always work perfectly.
- Using r2frida (which, as the name suggests, uses Frida). It combines Frida with Radare2, and it’s a very good tool. We’ll go over this later.
- Using Xposed Framework (it’s not a bad tool, it used to be widely used, but it’s not on the same level as Frida. It has some limitations, such as issues with newer Android versions, restrictions when working with Native Code, and it only works on rooted devices, etc.).
So far, we’ve discussed that one of the methods to bypass security mechanisms is to hook the part of the code that’s causing the limitation. Currently, Frida is the best tool used for this purpose.
In fact, Frida first attaches to the process we want (the app we want to hook) using ptrace. Then, it injects its gadget, which for Android is a binary file called libfrida-gadget.so, into the process. The final step is that it executes the script we provided using V8.
The way Frida is used usually falls into one of these three scenarios:
- In the first scenario, the app uses common algorithms, libraries, and methods to implement security mechanisms. In this case, we can bypass these mechanisms using Frida’s public scripts, most of which can be found at https://codeshare.frida.re.
- In the second scenario, the app has modified these common algorithms and methods to some extent (usually, instead of directly importing public libraries, they use modified versions of their source code). In this case, we need to be more familiar with Frida and how to work with it, so that we can make minor changes to the public scripts and perform the bypass.
In this case, the algorithm used is usually the same public algorithm, only the class and function names have been changed. Therefore, the changes needed to the public scripts for the bypass are usually very minor.
The third scenario is when the mechanism used by the app has been implemented in a custom way. In this case, bypassing using ready-made scripts (even with modifications) is not possible, and we need to have a custom bypass script tailored to that specific algorithm.
In this post, and in the continuation of this series, we will focus on the third scenario. (Overall, neither in this post nor in the subsequent ones, we will be using any pre-made scripts.)
You might wonder why we don’t always use the third method to ensure we can bypass any algorithm. The answer is that it’s not really necessary. When the app uses public libraries and methods that can be easily bypassed with a public script, why waste time writing a custom script? So, everything has its place, and we do it when necessary.
Types of Hooking in Android Apps:
When we talk about hooking Android apps, we are usually dealing with one of these two cases:
- Java Methods
- Native Methods
The first one is obvious, and the second refers to code that we want to hook, which is written in C or C++.
Now that we’re talking about Native Methods, keep in mind that most of the ready-made Frida scripts are written for Java Methods. If the app or the part we want to hook is implemented in C or C++, those scripts won’t work.
You might wonder, who still develops Android apps with C or C++ these days!?
- When you develop an Android app with Flutter, for example, the code is ultimately compiled into a .so file.
- Most games are developed with C or C++.
- There are many apps that have used C and C++ for parts of the code that require higher performance.
- Some apps use C to complicate the reverse engineering process. For example, right now, some apps — especially those related to internal payments — have their network traffic encryption implemented in C++.
Another question that might come to your mind is, why is reverse engineering C and C++ harder than languages like Java and Kotlin?
As you know, many languages like Java, Kotlin, and C# are compiled into an intermediate language (IL) and then translated into machine code during runtime. However, languages like C and C++ are directly compiled into machine code (01010110011). One of the reasons why performance is higher in lower-level languages is that when you write code in assembly, it’s much faster than even C. You might find this link interesting:
94x Performance Increase in FFMpeg Using Assembly CodeSo, what does this difference have to do with the reverse engineering process? When an app is developed in a language like Java, the output is in an intermediate language, which makes decompiling and converting it back to higher-level code relatively easy. But in contrast, in languages like C and C++, since the output is compiled directly into machine code (binary) right from the start, to reverse it back to higher-level code, we first need to use a disassembler to convert it to assembly, and then use a decompiler to convert it as much as possible into higher-level code (similar to C). This is what makes reverse engineering binary files more complex.
Let’s put what we’ve discussed into practice:
For this post and probably the next 3–4 posts, I’ll be using the MASTG samples. The great thing about these samples is that they’re specifically designed for teaching and understanding security concepts. They’re also commonly used in most books and tutorials related to Android security, so we’ll start with these for now.
- First, download the app file from the link below:
2- Install and run the app on an emulator.
(I’ll prepare a dedicated post later about properly setting up an emulator for analyzing Android malware. It will cover topics like selecting the right emulator, isolating the emulator, analysis tools, simulating internet, snapshots, and more. But for now, in this example, what we’re working on isn’t malware. All you need to do is install and run it on whichever emulator you’re most comfortable with.)
3- As you can see, the app has root detection enabled and won’t run on a rooted device. Let’s bypass this part for now:
4- Decompile the app using JADX and search for part of the text from the Alert Dialog in the extracted code (with the goal of identifying the section of the code responsible for checking whether the device is rooted):
The app we’re working on has very little source code, and you can easily find everything just by looking at it, so there’s no real need to search through the code. Overall, many of the explanations and methods mentioned in these posts might not even be necessary for this sample. However, the goal is to familiarize ourselves with the approach to tackling tasks in real-world scenarios.
Depending on the language and framework used to develop an app, you need to use a specific decompiler. For instance, if the app was developed with Flutter, JADX wouldn’t be able to decompile the main part (the libapp.so file).
To find out which decompilers can be used for a particular language or framework, you only need to do a quick search. (Of course, in this series of posts, we’ll get familiar with two or three of the best ones…)
So, as you can see, we searched for part of the Alert Dialog text and found an if
statement. If the condition evaluates to true
, it sends the value Root detected to the method a
:
Here is function a, which is responsible for building and showing the Alert Dialog. So, if the above if condition (red box) is true, the value Root Detected is passed to this function to be displayed to the user as an Alert Dialog along with the following message:
5- Now we need to find out which if statement was true and caused this Alert Dialog to be triggered.
As you know, in JADX, if we click on the call of any function, we will reach the function itself.
This way, we can reach the functions used inside the if statement:
All three of the above methods are used to detect if the device is rooted:
- The first method looks for a file named “su” (a file related to root management tools).
- The second method checks
Build.TAGS
(most devices that use Custom ROMs or are produced for testing and development purposes have theirBuild.TAGS
set to "test-keys"). - The third method searches for specific paths and files that are typically found on rooted devices.
Since in the condition we have (the red box above), these three methods are combined with OR, even if one of these methods returns true, the device will be detected as rooted.
6- So, the only change we need to make is to ensure that the return value of all three methods is always false. In the video course I referenced at the beginning of the post, similar examples were covered throughout the course. The only difference there was that we patched the application (we modified the source code and built the modified version), but here, we want to make the necessary changes at runtime (from memory) without modifying the application file.
Now, how can we write a Frida script to make these changes? It’s much easier than you might think.
Before we start, I should mention that you can find complete documentation for working with Frida in the following two links: (I recommend reading through them once to familiarize yourself with its features).
I have provided the GitHub link to the source code we are working with at the end of the post so you can use it more easily.
7- The script we need for hooking and changing the return value is just a few lines of code as shown below:
Line 1: According to Frida documentation, we define our script inside this function so that it gets delivered to the Java Virtual Machine (JVM) at the right time. Line 3 specifies the class we want to hook, which we got from here (the red box below):
PackageName.ClassName
In line 6, we specify the method we want to hook (green box number 1). Line 10 logs the value that method ‘a’ currently returns (without the hook) (this isn’t necessary, but we want to see what the original return value was). Lines 14 and 15 return the value ‘false’ (meaning we want the method ‘a’ to always return false under any conditions).
Now we need to do the same script for methods ‘b’ and ‘c’ (green boxes number 2 and 3), so it would look like this:
You might wonder if this script can be written much shorter and cleaner! That’s correct.
The final script could be a refactored version like this:
8- Now, let’s run the script and see what the result is:
If you’re not familiar with how to run Frida scripts, it’s explained in this link.
Well, as you can see, methods b and c returned false without any changes from our side, but method a returned true, which triggered the root detection condition and prevented the app from running (the Alert Dialog was displayed). However, we hooked the methods and set the return value of all three methods to false, so now the root detection algorithm has been bypassed.
Before we continue, for more practice, you can bring the app into Debug Mode and then bypass the Debug Mode Detection, meaning this part:
One of the methods to bypass it is exactly the same way we bypassed its Root Detection.
Let’s hook another part…
After bypassing the Root Detection and the main page of the app appears, there is an Edit Text with a button called “Verify.” When we click on Verify, the following message is displayed, indicating that the value we entered is incorrect (although it’s not possible to predict the function from the UI alone, but we’ll proceed with this assumption here). So, let’s find the correct value.
1- Similar to what we did in the first part, we first need to find the part of the code that triggers the Alert Dialog. So, we search for part of its text in the source code and reach this section:
As it’s clear, if the condition (orange box) is true, the green box will be displayed, and if false, the red box will appear as an Alert Dialog to the user. So far, we don’t know exactly what this condition is checking, but we assume that it compares the value entered in the EditText with the correct value.
2- Now we need to check the condition (orange box) that triggered this Alert Dialog, which leads us to:
3- Here, the keyword native
is used before the method bar
. When a method is defined with the native
keyword, it means that the method is implemented in a language other than Java (usually C or C++). The native
keyword is used via JNI (Java Native Interface) to communicate between Java and native methods.
So now the path ahead is clear:
- First, we need to find the C or C++ file that implements the
bar
method. - Then, we decompile the file.
- Finally, we hook the part of the code that needs to be modified.
Keep in mind that, unlike the previous method, we are now dealing with a binary file, which requires a different decompiling and hooking approach.
4- Let’s find the file.
The app we are working with has only one binary file, so it’s clear that the bar
function is implemented in this file. However, many times apps use multiple binary files. In such cases, to identify which file the code we want to hook is implemented in, we just need to search for the following in the source code (binary files are usually loaded into Java in one of these ways).
System.loadLibrary()
System.load()
ClassLoader
5- As it is clear, this app has loaded its Native file using the first method:
One thing to note is that it says to load
foo
, but we should be looking for thelibfoo.so
file. Why? The reason is that the JVM adds prefixes and extensions based on the operating system. For example, if it were Windows, it would look forfoo.dll
, and if it were macOS, it would look forlibfoo.dylib
. Now, since we're working on Android (which is based on Linux), the JVM addslib
at the beginning and.so
at the end of the entered value. So, it becomeslibfoo.so
.
6- Now, let’s find the libfoo.so
file in the file structure and then decompile it:
As you can see, in the lib folder, there is a binary file for each architecture. Now, which one should we work with? We need to check the architecture of the device or emulator we’re working on. Currently, most Android devices are ARM64, but if you’re working on an emulator, they are mostly x86 or x86_64 (of course, if you’re working with Frida, you definitely know the architecture of the device where Frida is running, as you should have downloaded the Frida Server based on the architecture).
To check the architecture of an Android device, one way is to run the following code using adb:
adb shell getprop ro.product.cpu.abi
I’m currently using an emulator on Nox Player with Android version 7 and x86 architecture. However, since it supports ARM translation, I can also use armeabi-v7a. Which one is better? If the application you are working on has both ARM and x86 architectures, it’s better to choose ARM (assuming NEON and Thumb mode are not used, ARM is generally easier than x86).
But here, we proceeded with x86, so if you encounter an application that only has x86, don’t worry — it’s not something complicated.
7- For decompiling, I used Radare2, but you can use any decompiler that you’re most comfortable with (I recommend Radare2, Ghidra, and JEB).
The functions we have are as follows:
As it’s clear, one of the functions it has is the same bar
function we were looking for, which is located at 0x00000f60. Let’s go see what’s inside it:
8- So how do we know which part we need to modify? Is it necessary to start analyzing the code from the first line? Most of the time, this is not necessary at all. We just need to consider how the code we want to hook is implemented and look for the function related to that algorithm. What does this mean?
We have an Edit Text that takes input from the user, and depending on whether the input is correct or incorrect, it shows the result in an Alert Dialog. So, how can we assume the algorithm works? There are two values: one is the value entered by the user, and the other is the correct value. It’s likely that the code compares these two values, and if they match, it returns a specific value. Now, based on this assumption, we just need to find a function that performs this comparison. As shown here (orange box below), the strncmp
function is used, which compares two strings.
Let’s take a look at the documentation for strncmp
:
int strncmp ( const char * str1, const char * str2, size_t num );
- The first argument (str1): The first string that we want to compare with the second string.
- The second argument (str2): The second string that is compared with the first string.
- The third argument (num): The number of characters to compare.For example, if we set
num
to 5, it will compare the first 5 characters of both strings. The result of the function is as follows:
- If the strings are identical, it returns 0. (Most string comparison functions in C return zero when the values are equal.)
- If the first string is smaller than the second, it returns a negative value.
- If the first string is larger than the second, it returns a positive value.Also, according to the documentation, the comparison is done based on ASCII, so it is case-sensitive.
9- Now we need to determine which part and what from strncmp
we should hook.
If the part we're working with was, for example, the login page, usually the only thing that matters is being able to log in. In that case, we could set the return value of strncmp
to 0 (zero). But here, the goal is to find the correct value (not just create a state that says the correct value has been entered; we want the actual correct value).
What should we do? It’s simple: we can log the argument corresponding to the correct value (the constant value) when strncmp
is called. Now, in strncmp
, is the first argument the dynamically entered value from the user, or the second argument? According to the documentation for this function, when the goal is to compare two equal strings (with a return value of 0), the order of the first and second arguments doesn't matter. However, according to the code, the eax
register is populated with the command lea eax, [s2]
before being passed as a parameter to the function, meaning it is filled with a constant value (the address of the s2
variable). Therefore, it's highly likely that eax
holds the constant value (the correct value) that is hardcoded in the code.
Of course, here we can log all three arguments, because eventually one of them will be the constant value. The explanation I provided was to emphasize the importance of analyzing the assembly.
The script we need looks like this:
I have included the GitHub link for the code we worked on in the project at the end of the post.
Before explaining the script, we need to get familiar with the function android_dlopen_ext
. In Linux, the dlopen
function is used to load dynamic libraries (libraries that are added at runtime). This function has been modified in Android with optimizations and security improvements, and is now referred to as android_dlopen_ext
. Essentially, this function manages the libraries and binary files that an Android application loads.
In line 1 here, we instructed the script to find the android_dlopen_ext
function. The null
we used means that we are asking it to search for this function among all the loaded libraries.
Of course, instead of
null
, we could specify the name of the library that implements this function. However, since the name of this library changes across different versions of Android, it's better to usenull
so that it searches through all the loaded libraries.
In line 3, we defined a boolean variable to detect whenever libfoo
is loaded.
In lines 5 to 21, we attached to android_dlopen_ext
. Here, we have two functions: OnEnter
and OnLeave
. As soon as android_dlopen_ext
is executed, OnEnter
is called, and then when android_dlopen_ext
returns a value, OnLeave
is called.
In fact, every time
android_dlopen_ext
is called, it loads a library (at this point,OnEnter
is called), and after the function completes and returns a value,OnLeave
is called.
Now, in line 7, we tell the script to take the input argument of android_dlopen_ext
and if it equals libfoo
, set the variable libfoo_loaded
to true. (In fact, this loop will repeat until all libraries are loaded, and one of them will be libfoo
, which we are looking for.)
Another question that might come up is why we captured the input argument of
android_dlopen_ext
inOnEnter
? Yes, because functions are called inOnEnter
, and it's only inOnEnter
that we can access the arguments. If we had access to the arguments inOnLeave
, there would be no need to definelibfoo_loaded
.
In line 12, we instructed the script to retrieve the base address of libfoo
if it had been called. (We can't access it until it's called, so before that, we check that libfoo_loaded
is true
.)
In line 23, we defined a function and told it to add the offset corresponding to strncmp
, which is 0x00000ffb
, to the base address of libfoo
(from the previous step), so we can attach to it. The offset address for strncmp
was obtained from here:
In line 25, we instructed the script to capture the value of eax
(the function argument, which is defined as a constant in the code) when strncmp
is called and display it in the log.
Finally, in line 18, we call the function we defined in line 23.
Now, let's go ahead and run the script.
Note that the first and second scripts must be run simultaneously (both in one file), so that the first script bypasses the Root Detection and the second script logs the correct input value.
10- After running the script, by default, no logs will be displayed. Why? Because the strncmp
function is not called by default; it is only called when it needs to compare a value. Therefore, we need to input a value and click "Verify" to trigger the call to strncmp
.
But after entering a value and clicking "Verify", still no logs are registered. Why!?
Let's take another look at the code...
As shown (orange box number 1), if the value in the eax
register (the value we input) is not equal to 0x17 (which equals 23), the ZF (Zero Flag) will be 0. When the ZF is 0, the EIP will jump to the new address, 0x1007 (skipping strncmp
). Therefore, to prevent the jump, the length of the value we input must be equal to 23.
In orange box number 2, if the values are equal and
strncmp
returns zero, the ZF will be 1, and the EIP will change to address 0x101e (which is the goal we want).
11- So, to call strncmp, the value we enter needs to be exactly 23 characters long.
12- The output of the script after clicking Verify will be as follows:
13- Finally, we test the value that we logged as the correct value:
We have reached the end of the first part…
This post ended up being much longer than I expected, but on the other hand, if we split it into two parts, the topic would remain incomplete.
The plan is for this post to have about 11–12 more sections. The goal of the first part (this post) was to provide an initial introduction to the concepts of hooking.
Throughout the process we worked through, I tried to answer the questions that might arise for you. There are a few more topics that might be worth mentioning:
The method we used to complete the steps in this post was aimed at learning the concepts, otherwise, we could have reached the answer much faster. For example:
- In the second part, the string we obtained through hooking could have easily been found through static analysis.
- Or in the Root Detection section, it could have been bypassed with a single line.
And another question you might have is:
Is writing a script for Frida really that simple?
If you take a look at the most popular Frida scripts on Frida CodeShare, after reading this post, you’ll probably understand 70–80% of their code. But the reality is, no, hooking is not always this easy. It actually depends on how the security mechanism we want to hook is implemented. For example:
- Sometimes Anti-Tampering is used.
- Sometimes classes and functions are created at runtime.
- A lot of times, the source code is heavily packed or obfuscated.
- Reflection techniques might have been used.
- Often, Frida detection algorithms are implemented.
- And many other challenges that can complicate the Pentest process.
The good news is, don’t worry. As I mentioned, this post is going to continue for another 11–12 sections, and we will cover many of the challenges related to Reverse Engineering and Hooking (of which only a part is related to hooking).
The source code of the script we worked on:
I hope this post has been helpful for you. I’ll try to prepare the next parts sooner.
By the way, if you’d like, you can connect with me on LinkedIn:
Stay happy and successful… ❤️