OWASP MAS - III
Solving the OWASP Uncrackable L3. Covers native library integrity checks, reversing JNI code with Ghidra, a flawed ptrace-based anti-debug trick, a sneaky native anti-Frida constructor, and hooking a non-exported function to extract and decode the final flag.
Android UncrackableL3
Note
This crackme is the sequel of the UncrackableL1 and UncrackableL2 crackmes. This writeup is way more complete, and references some parts of the two previous ones. Feel free to check them!
Also, if some parts are unclear, you can contact me and help me improve this article!
Statement: The crackme from hell! A secret string is hidden somewhere in this app. Find a way to extract it.
This crackme is from the OWASP MAS crackmes.
Resources
- https://developer.android.com/jetpack/androidx/releases/versionedparcelable?hl=en
- https://developer.android.com/jetpack/androidx
- https://docs.oracle.com/javase/8/docs/api/java/util/zip/ZipEntry.html#getCrc--
- https://docs.oracle.com/en/java/javase/21/docs/specs/jni/types.html
- https://docs.oracle.com/en/java/javase/24/docs/specs/jni/functions.html#getprimitivetypearrayelements-routines
- https://docs.google.com/spreadsheets/d/1yqjFaY7mqyVIDs5jNjGLT-G8pUaRATzHWGFUgpdJRq8
- https://github.com/extremecoders-re/ghidra-jni
- https://developer.android.com/ndk/guides/jni-tips
TODO: Add some more resources.
I. First analysis
We get a classic APK file, as usual.
| |
Installing the APK:
| |
II. MainActivity class overview
This time, unlike the two previous crackmes, the methods and attributes names are not “obfuscated” (their names correspond to what they actually do).
| |
We can see the declaration of two native functions:
private native long baz(): Used in theverifyLibs()method to get a checksum value.private native void init(byte[] bArr): Used in theonCreate()method, taking thexorkeyclass attribute as its parameter.
The showDialog() method is the equivalent of the MainActivity.a() method in the previous crackmes: When called, it displays the string passed as a parameter and then forces the user to exit the app with a non-cancellable pop-up.
The verifyLibs() method performs integrity checks, which we will dive into shortly.
And obviously, we can find the usual methods such as onCreate() (the app entry point) and verify() whose role is to check if the user input is correct.
And don’t forget, at the end of the class we can see the libfoo library being loaded.
III. App initialization
Let’s first look at the entry point of the Android application: the onCreate() method!
| |
First, it calls verifyLibs(), so let’s understand how it works!
1. verifyLibs() protection
This method checks for the libraries and classes.dex integrity, so it detects if any of them has been tampered.
The class attribute tampered is initialized to 0, but can be set to a different value (31337) in this method if any of the library or classes.dex has been tampered.
| |
It instantiates a HashMap using the architecture version of the lib as the key, and the checksum as the value. The checksums are hardcoded in the Resources/resources.arsc/res/value/strings.xml so we can extract them and see what is expected:
| |
Knowing this, the map will look like this:
| |
CRC
A Cyclic Redundancy Check is a smart, math-based checksum used to detect accidental data errors in digital networks and storage devices. Standard checksums just add bytes together, so they miss errors if bits swap places (e.g., 1+2=3 and 2+1=3). CRC is highly position-sensitive.
It first checks for the libraries’ checksums (only for the four architectures we can see above).
Then, it checks for the
classes.dexchecksum. The computed checksum ofclasses.dexmust be equal to the value thebaz()native function returns.
Looking at its code after decompiling libfoo.so, it simply returns 0x18110e3 which is equal to 25235683.
| |
You may wonder how to get the code of a native function like baz(). We have to extract the native library from the APK, and then decompile it with tools like Ghidra, IDA Pro, etc.
IV. Reversing a native library
Java Native Interface
This part will be a bit harsh, but I really recommend following it along, as it explains key principles for the JNI API and also how to set up Ghidra to enhance the reverse experience.
As we’re running our APK on an x86 32-bit emulated device, we must extract the x86 version of the library.
We can do so with this command:
| |
Once we have extracted the library, let’s open it with Ghidra and run the auto-analysis.
As we just saw, the code of the baz() function is quite simple since it only returns a long integer. But what about more advanced functions that actually use the JNI in their code?
For instance, let’s dive into the init() function that is called right after verifyLibs() in the onCreate() method.
1. init(xorkey) native function
When init() is called, it takes the bytes of the MainActivity.xorkey attribute as a parameter:
| |
We can easily find its value using JShell:
| |
JShell
In order to execute short Java expressions/instructions without having to create classes or methods, we can use the
jshellcommand! An online version exists too!
2. Setting up Ghidra for the JNI
If we compare the Java method signature with the C function signature generated by Ghidra, something feels off:
- Java:
init(byte[] bArr)-> 1 parameter. - C:
init(int *param_1, undefined4 param_2, undefined4 param_3)-> 3 parameters.
| |
This is due to the JNI calling convention. When the JVM calls a native C/C++ function, it silently passes two implicit arguments at the very beginning:
JNIEnv *env(hereparam_1): A pointer to the JNI environment, which is basically an API allowing C code to interact with Java (read strings, extract arrays, etc.).jobject thiz(hereparam_2): The reference to the calling Java object (the equivalent ofthisin Java).
Therefore, param_3 is our actual explicit argument: the byte[] array containing our xorkey.
Knowing this, let’s look at this ugly line of code:
| |
Since param_1 is our JNIEnv * pointer, this line is actually making an API call to the Java environment. In C, the JNIEnv structure is just a massive list of pointers to hundreds of JNI functions. The compiler just tells the processor: “Take the base address of the JNIEnv pointer (param_1), add an offset of 0x2e0 bytes, and execute the function located there”.
Since we are reversing a 32-bit architecture, pointers are 4 bytes long. If we divide the offset 0x2e0 (736 in decimal) by 4, we get 184. This means the code is calling the 184th function of the JNI structure. If we look at this JNI cheatsheet, the 184th function is GetByteArrayElements.
Computing these offsets manually is annoying and can lead to inattention errors. Luckily, we can tell Ghidra to do this work for us by providing it with the official jni.h header file.
We can download this file from the extremecoders-re/ghidra-jni repo.
The README.md of the repository explains very well how to set it up (it’s really easy), so it’s not really relevant to explain it here.
After setting it up, we can parse the program using this new source.
The last step (congrats if you made it here!) is to change the data type of param_1 to JNIEnv * so Ghidra’s decompiler will reveal the actual JNI function names!
As you can see, it perfectly worked and now the code changed a bit (types such as jbyte, jbyteArray, etc.), making it easier to read with the right function name!
| |
3. Native-side initialization
What init() does is:
- Calls a really particular anti-debugging function we’ll dissect shortly.
| |
- Gets a pointer (
__src) to thexorkeystring in the native world. The Oracle documentation allows us to understand how theGetByteArrayElements()call works.
| |
Get<PrimitiveType>ArrayElementsYou may be wondering: Why would we need to call
GetByteArrayElementsto get a pointer to thexorkeystring, when thexorkeyparameter already seems to be one?In the JNI, a
jbyteArray(the type of thexorkeyparameter in the C code) is not a raw memory pointer. It acts as an opaque reference, like a “ticket” or an identifier managed by the JVM.Because the Java Garbage Collector constantly moves objects around to optimize memory, native code cannot safely access them directly.
GetByteArrayElementsessentially hands this ticket to the JVM and asks for a locked, raw C pointer (jbyte *) in return, ensuring the integrity of the object. You can read more about this here and here
- After retrieving a raw C pointer to the
xorkeybytes, it copies the 24 bytes of the key into the library’s static memory (DAT_0001601c).
| |
- Calls the JNI API
ReleaseByteArrayElements()function. Its documentation is also here. It is mandatory to call this function to tell the JVM that the native code is done with the pointer, allowing the Garbage Collector to safely free the memory copy.
| |
- Increments a specific memory space (
DAT_00016038) by1, which is sort of a flag that will be later checked by the program.
| |
4. FUN_00013250: Anti-debug protection… or not
Now that we understand the init(xorkey) code, we need to understand the “protection” it uses before copying the xorkey in memory. The code looks a bit confusing at first:
This is the function code after renaming it and its variables. In order to be able to fully understand the code, I recommend paying attention to the comments I put as well as reading the man pages for these syscalls:
ptracewaitpid
| |
PTRACE_ATTACHflawThe core idea behind this anti-debug trick relies on a strict Linux rule: a process can only have one tracer attached to it at a time. The child process tries to occupy this “debugger slot” to prevent us from debugging the app. If we are already debugging the application with an external tool, the child’s
ptrace(PTRACE_ATTACH)call will fail and return -1.However! If we look closely at the
if (is_pid_already_traced == 0)condition, we can spot a huge flaw. If the attachment fails, the child process does absolutely nothing! Because of this missing fallback, the protection bypasses itself and allows us to trace the parent process without even being blocked/making the program crash…
V. Java-side anti-root/anti-debug protections
Getting back to the decompiled Java code of the onCreate() function, we can see the same protections as the two previous crackmes:
| |
We can ignore the AsyncTask logic here. Debug.isDebuggerConnected() specifically looks for an active Java Debug Wire Protocol connection. Because the only debugging we’ll use relies on Frida, which injects directly into the process memory rather than attaching via JDWP, it completely bypasses this check.
Then, the final checks are made:
| |
If we look at the RootDetection and IntegrityCheck classes, we can see their methods are identical to the ones used in the previous two crackmes. We can easily bypass them using Frida hooking. Since we have already covered this technique, we won’t detail it again here. Feel free to check out this section of my Uncrackable1 writeup if you need a refresher!
If neither the libraries nor the classes.dex have been tampered with, the tampered attribute should keep its initial 0 value.
VI. Finding the secret
1. User input verification
If we look at the very end of the onCreate() method, right after the security checks, the application instantiates a CodeCheck object:
| |
We need to look at the verify() method in the MainActivity to understand how the CodeCheck object is used. This method is triggered when the user submits their input:
| |
The logic is pretty straightforward: the app passes the user input to the check_code() method of the CodeCheck object we saw earlier. If it returns true, it’s the right input.
Let’s inspect the CodeCheck class:
| |
As expected, the method used to check the user input calls a native function that we’ll have to reverse, passing it the bytes of the user input as the parameter!
Back in Ghidra, the bar() pseudo-code is straightforward.
However, let’s not forget to change the param_1 type to JNIEnv * so the JNI API calls are easier to read. We can also rename the variables correctly so we understand what their role is.
After a bit of reading and renaming, we get this new pseudo-code. Here’s the commented version I recommend paying attention to:
| |
Since we already know the xorkey value, we could retrieve the final flag by figuring out the value of encrypted_flag after generate_encrypted_flag() modifies it.
However, this function is over a thousand lines long, so statically reversing it isn’t really the way…
Instead, we will hook this native function to print the encrypted_flag bytes right after it finishes executing.
2. _INIT_0: A real sneaky anti-debug native protection
Looking at the bar() pseudo-code we just detailed above, we can see the DAT_00016038 static variable is expected to be 2. However, we’ve only seen it being incremented by 1 once (in the init(xorkey) native function code). Since this variable is stored in the .bss section, its initial value is 0. So how could it potentially reach 2?
This is due to a specific C function identified as _INIT_0.
C Constructor Functions
When writing a C program, we can use the
__attribute__((constructor))flag to tell the compiler that a function should be executed automatically before the main program starts. In this context of an Android native shared library, these constructors are triggered at the exact moment when the library is loaded into memory (when Java callsSystem.loadLibrary()).This is what it looks like:
1 2 3 4 5 6 7 8 9 10 11#include <stdio.h> // This function runs automatically when the library is being loaded __attribute__((constructor)) void early_execution() { printf("1. I run first, before any function, and without being explicitly called !\n"); } // This function runs only when explicitly called, after the library's been loaded void normal_function() { printf("2. I run later...\n"); }
Let’s break down this _INIT_0 constructor pseudo-code. Here’s the commented and renamed version:
| |
There are two important things to see here:
- This creates a background thread running the
anti_frida_function(this is how I renamed it) and stores the created thread ID in thethread_idvariable. - Increments by 1 the
DAT_00016038static variable. We’ve seen it already being incremented by 1 once in theinit(xorkey)native function. This meansDAT_00016038reaches 2 because of the_INIT_0function and theinit(xorkey)function!
anti_frida_function
Now, let’s understand the anti_frida_function behavior in order to be able to bypass it. Here’s the commented pseudo-code.
| |
So, what this function does is continuously reading the /proc/self/maps to find if frida or xposed are injected into the app process.
Memory mapping & Frida Injection
As
/proc/self/mapsis the virtual file that contains the memory map of the currently running process, it displays all the memory zones allocated to the application, including the executable code, the heap, the stack, and all the loaded shared libraries (.sofiles).When we use Frida, the
frida-serverdoesn’t monitor the app from the outside. In order to intercept and hook functions, it injects a shared library (thefrida-agent) directly into the application’s memory space.Because of this, the path of the Frida library is automatically added to the
/proc/self/mapsfile. This is why theanti_frida_functioncan detect frida or xposed being used!
The goodbye() function at the end raises a SIGABRT (abort) to crash the current process. There’s an _exit(0) call following it, in case we hook the raise() function.
| |
VII. Final exploit
Here is the plan for our final exploit:
First, we need to bypass ALL the protections (both native-side and Java-side). Next, we will hook the native generate_encrypted_flag function to extract the encrypted_flag value, and finally, we’ll XOR it with our known xorkey.
1. Bypassing the native-side protections
As we saw in the _INIT_0 part, during the library linking process, the _INIT_0 constructor function is executed, launching a background thread that searches for the frida agent in the app process memory map.
If we try to start the app with Frida (-f), we can see the SIGABRT is raised and causing the app to crash.
There are multiple ways to bypass this, here are three:
- Erasing any trace of the “frida” string in the frida-server binary (as discussed in this Frida-core issue #310).
- Hooking the
strstrfunction in a way that it always returns a null pointer, so theanti_frida_functionwill think the “frida” string is never found in the/proc/self/mapsfile. - Hooking the
anti_frida_functionwe identified. Obviously, this is how we renamed it, so we would actually need to compute its real address from the library base address + the function offset.
The first option is quite tedious to set up as it could break the agent’s dynamic library code.
The second option is much more viable as it requires us to only hook a libc.so function, which is way easier.
The third option would work fine, but it’s less cool than hooking a libc.so function :D
Here is how the shared library loading order works on Android:
- The app starts ->
libc.sois already in memory and just needs to be mapped to the app process. - As the app initializes, it loads the
libfoo.solibrary throughSystem.loadLibrary("foo"). libfoo.sois loaded into memory and the first function to execute is_INIT_0.
This means we can easily hook a libc.so function before the _INIT_0 even executes, so we can neutralize the anti_frida_function right from the start by launching the application with Frida in spawn mode (-f).
Hooking a native function: strstr
The Frida Interceptor documentation is useful to help us write the native function hook script.
| |
What this script does is:
- Retrieves
libc.soand thestrstrfunction. - Hooks the
strstrfunction by first retrieving the needle (the string being searched for). If the string matches “frida” or “xposed”, it replaces the return value with a null pointer. - Consequently, the
anti_frida_functionbelievesstrstrfound neitherfridanorxposedin the reading of/proc/self/maps.
Let’s launch the script to see if the SIGABRT is still raised.
It’s not anymore! The pop-up that appears concerns the Java-side anti-root protections, which we are about to bypass right now.
2. Bypassing the Java-side protections
As covered earlier in this article, the Java-side protections are:
verifyLibs(): Responsible for checking thelibfoo.sointegrity for different architectures, as well as theclasses.dexintegrity. -> Since we did not modify any of the APK files, this will not be triggered.RootDetection.checkRoot1(),RootDetection.checkRoot2(),RootDetection.checkRoot3(). -> As these three functions rely on the same fallback (showDialogmethod) to force the user to exit, we can directly hook theshowDialogmethod. This way, even if those root detections are triggered, it won’t prevent us from accessing the app.IntegrityCheck.isDebuggable(getApplicationContext()): As explained earlier, this does not affect us.
Knowing this, we can append this simple code to our exploit script so the “Rooting or tampering detected.” pop-up disappear and lets us submit our input.
| |
Let’s launch the app again, and see if the pop-up disappears and if we can submit our input.
Success, we can now submit our input. The last step is coming!
3. Retrieving the encoded flag
Now that all the protections are bypassed, let’s get back to this portion of the bar() code where the encoded flag is generated, before getting xored with the key to be compared to the user input:
| |
Hooking a non-exported function: generate_encrypted_flag
Hooking a non-exported function will generally follow this scheme:
- Find the library base address:
const libfoo = Process.findModuleByName("libfoo.so").base; - Find the function: Either via its name (if it’s exported) or via its offset:
const func_to_hook = libfoo.base.add(0x0ff53t); - Intercept it:
Interceptor.attach(func_to_hook, { onEnter: ..., onLeave: ... }) - Play with memory: Read the arguments, and use Frida’s API Memory functions such as
Memory.readByteArray,Memory.readUtf8String, etc.
Before writing the script, we can simply get the function offset via Ghidra.
Its offset seems to be 0x00010fa0. But we have to be careful before writing our script!
The Ghidra Image Base Trap
If we use
0x00010fa0as our offset, our Frida script will crash with anaccess violationerror. Why? Because by default, when analyzing 32-bit ELF files (like Android.solibraries), Ghidra adds an artificial Image Base of0x10000to simulate how it will be mapped in memory.Tip: You can force Ghidra to display the raw offsets by going to Window > Memory Map, clicking the “house” icon (Set Image Base), and changing the value to
00000000.
The real offset we need to add to our base_address is actually 0x00010fa0 - 0x10000 = 0xfa0.
This is the hook script we can use. I put this directly in a function for the reasons below the script code. I also directly added the flag decoding part so the script is an all-in-one!
| |
Frida spawn mode
As you know, we used the
-foption to tell Frida to spawn the Android app. This allowed us to hook thelibc.sostrstrfunction beforelibfoo.soloads and triggers the_INIT_0function. Thereforelibfoo.sois not yet loaded in memory at the moment our frida script executes.Because of this, we can’t directly retrieve the
libfoo.solibrary like this:
1const libfoo = Process.findModuleByName("libfoo.so");Doing so would produce this error at runtime:
1 2 3Spawned `owasp.mstg.uncrackable3`. Resuming main thread! Error: Could not find libfoo.so at <anonymous> (/agent/index.js:13201)To ensure the library is loaded before hooking its function, we need to hook the android
dlopen()native function! This is a neat trick: it allows us to detect exactly whenandroid_dlopen_ext("libfoo.so")is called, let it load the library normally, and apply our native hook right after.
This is how we do it, as we would hook any other native function!
| |
You can find the final exploit script here. Let’s run it, and complete this crackme !
Flag :
| |
VIII. Conclusion
And that’s a wrap for UncrackableL3! Let’s quickly recap the road we just walked through.
On the Java side, verifyLibs() used CRC checksums (both for the native libraries and classes.dex) to detect any tampering, while the classic anti-root and anti-debug checks made a comeback from the previous two crackmes, so a really easy Frida hook on the MainActivity.showDialog() method was enough to shut it down.
The real challenge was on the native side. We had to get comfortable with the JNI calling convention to make sense of Ghidra’s decompiled output, and once the jni.h header was loaded, functions like GetByteArrayElements stopped looking like cryptic pointer arithmetic. Along the way, we found a genuinely interesting (if not flawed) anti-debug trick abusing ptrace(PTRACE_ATTACH), and a much sneakier one: a _INIT_0 constructor silently spawning a thread that scans /proc/self/maps for Frida or Xposed the moment libfoo.so gets loaded.
Bypassing that last one required hooking android_dlopen_ext itself, to make sure libfoo.so was fully loaded before attaching our own hook on generate_encrypted_flag, and from there, XOR-ing the recovered bytes with our known xorkey gave us the flag.
Thanks for reading, if you have any question/remark, feel free to contact me :)









