Information
UnCrackable App for Android Level 2
This app holds a secret inside. Can you find it?
- Objective: A secret string is hidden somewhere in this app. Find a way to extract it.
- Author: Bernhard Mueller.
- Maintained by the OWASP MSTG leaders.
Installation
This app is compatible with Android 4.4 and up.
1
$ adb install UnCrackable-Level2.apk
Requirements
- A rooted android device or emulator (with adb)
- Frida for dynamic instrumentation
- Java decompiler: JADX, JD-GUI, etc..
Analyzing the logic
When we open the app, we are greeted with a message telling us we can’t use the app on a rooted device again.

In my previous post I have detailed how to analyze the root checking part of the application, in level 2, root checks are mostly similar to level 1, they only changed the class name from c to b.
Using Frida to bypass the root check
The script used to bypass root checking:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Java.perform(function(){
console.log("start hooking");
const rootCheck = Java.use("sg.vantagepoint.a.b");
rootCheck.a.implementation = function(){
console.log("function a");
return false;
}
rootCheck.b.implementation = function(){
console.log("function b");
return false;
}
rootCheck.c.implementation = function(){
console.log("function c");
return false;
}
});
The secret
This time, the function a in class CodeCheck is still responsible for verifying the secret, but there’s a twist:

It uses a native function (bar). So what is a native function/method?
A native method is a Java method where the implementation is written in another language. The method is declared in Java code for use, but the actual code that runs is in another castle. Native methods are declared with the
nativekeyword like so:
1 private native String getDeviceName();
As for why people use native methods, refer to this great post by Baeldung.
Oftentimes, native methods’ implementations are stored in native libraries embedded within the apk. We can find these libraries at the lib/ directory. Unlike Java whose code get compiled into Java bytecode that can run in any machine with a Java VM, native libraries are compiled to assembly and often need platform-specific binaries to run, that’s why the lib/ folder often looks like this:

There’s a libfoo.so file in arm64-v8a, armeabi-v7a, x86 and x86_64 directories. These are called architecture, I won’t get into how they are different, just know that binaries compiled for one architecture is not necessarily able to be executed in an environment with another architecture.
If you are running the app on a physical device, chances are the device will have arm architecture, however if you’re using an emulator (Genymotion), the device is likely x86 based. You can find out which architecture you’re using by using an app or running this command:
1
$ adb shell getprop ro.product.cpu.abi
Getting back to the app, the app uses the native bar function to verify the secret, passing our string to it. From here on we can’t use JADX to analyze the app anymore. Because there’s only one native library (foo), it’s safe to say that the bar function is in this library.
Analyzing the native library
Here we’ll use Ghidra, a Software Reverse Engineering (SRE) toolkit developed by the NSA to poke around the binary, because I’m running Genymotion, I’ll choose the x86 libfoo.so binary to analyze. Just open Ghidra, create a new Project and import (File -> Import) the library:

Double click the library and let Ghidra analyze the binary, you should get this screen with details about the binary:


Because the bar function is accessible from outside the binary, it must be exported, we can find the list of exported function in the “Symbol Tree” -> “Exports” menu.

What’s with the name, I hear you thinking. The Java application communicates with the native library through the Java Native Interface (JNI), which requires:
- The native function name must be in the format: “Java_[class_name]_[function_name]”
- The native function’s first 2 arguments must be:
JNIEnv* env,jobject thisObject. The first argument is a pointer to access methods provided by the JNI environment, the second argument is thethisobject that the function is attached to.
So the method bar in class sg.vantagepoint.uncrackable2.CodeCheck corresponds to the native function Java_sg_vantagepoint_uncrackable2_CodeCheck_bar, and the first argument of the native Java function (byte[] bArr, see the function definition above) is the third argument of the native function.
The bar function
Here is the function Ghidra gave us:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
undefined4
Java_sg_vantagepoint_uncrackable2_CodeCheck_bar(int *param_1, undefined4 param_2, undefined4 param_3)
{
char *__s1;
int iVar1;
undefined4 uVar2;
int in_GS_OFFSET;
undefined4 local_30;
undefined4 local_2c;
undefined4 local_28;
undefined4 local_24;
undefined2 local_20;
undefined4 local_1e;
undefined2 local_1a;
int local_18;
local_18 = *(int *)(in_GS_OFFSET + 0x14);
if (DAT_00014008 == '\x01') {
local_30 = 0x6e616854;
local_2c = 0x6620736b;
local_28 = 0x6120726f;
local_24 = 0x74206c6c;
local_20 = 0x6568;
local_1e = 0x73696620;
local_1a = 0x68;
__s1 = (char *)(**(code **)(*param_1 + 0x2e0))(param_1,param_3,0);
iVar1 = (**(code **)(*param_1 + 0x2ac))(param_1,param_3);
if (iVar1 == 0x17) {
iVar1 = strncmp(__s1,(char *)&local_30,0x17);
if (iVar1 == 0) {
uVar2 = 1;
goto LAB_00011009;
}
}
}
uVar2 = 0;
LAB_00011009:
if (*(int *)(in_GS_OFFSET + 0x14) == local_18) {
return uVar2;
}
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
Since we know that the first and second argument is JNIEnv and jobject, the third argument is the byte[] array that get passed in Java. We can rename the arguments by right-clicking the name then choose “Rename Variable”. What we get is the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
undefined4
Java_sg_vantagepoint_uncrackable2_CodeCheck_bar(int *JNIEnv, undefined4 this_object, char *bArr)
{
char *__s1;
int iVar1;
undefined4 uVar2;
int in_GS_OFFSET;
undefined4 local_30;
undefined4 local_2c;
undefined4 local_28;
undefined4 local_24;
undefined2 local_20;
undefined4 local_1e;
undefined2 local_1a;
int local_18;
local_18 = *(int *)(in_GS_OFFSET + 0x14);
if (DAT_00014008 == '\x01') {
local_30 = 0x6e616854;
local_2c = 0x6620736b;
local_28 = 0x6120726f;
local_24 = 0x74206c6c;
local_20 = 0x6568;
local_1e = 0x73696620;
local_1a = 0x68;
__s1 = (char *)(**(code **)(*JNIEnv + 0x2e0))(JNIEnv,bArr,0);
iVar1 = (**(code **)(*JNIEnv + 0x2ac))(JNIEnv,bArr);
if (iVar1 == 0x17) {
iVar1 = strncmp(__s1,(char *)&local_30,0x17);
if (iVar1 == 0) {
uVar2 = 1;
goto LAB_00011009;
}
}
}
uVar2 = 0;
LAB_00011009:
if (*(int *)(in_GS_OFFSET + 0x14) == local_18) {
return uVar2;
}
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
We see our bArr used in line 28, 29, all with JNIEnv. bArr is passed as an argument to the function at the address JNIEnv + 0x2e0 and JNIEnv + 0x2ac. One can find which function it is in this Spreadsheet containing common JNIEnv offset and the function name.
After looking up, we know that line 28 is calling jbyte* (*GetByteArrayElements)(JNIEnv*, jbyteArray, jboolean*); and line 29 is calling jsize (*GetArrayLength)(JNIEnv*, jarray);. So then we know that the variable __s1 stores our byte array, and iVar1 stores the length of the byte array. One more rename:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
undefined4
Java_sg_vantagepoint_uncrackable2_CodeCheck_bar(int *JNIEnv,undefined4 this_object,char *bArr)
{
char *bArrNative;
int bArrLength;
...
bArrNative = (char *)(**(code **)(*JNIEnv + 0x2e0))(JNIEnv,bArr,0);
bArrLength = (**(code **)(*JNIEnv + 0x2ac))(JNIEnv,bArr);
if (bArrLength == 0x17) {
bArrLength = strncmp(bArrNative,(char *)&local_30,0x17);
if (bArrLength == 0) {
uVar1 = 1;
goto LAB_00011009;
}
}
}
...
We know that bArrLength has to be equal to 17 to continue the function, therefore the secret must also be 0x17 (23) bytes (or character) long. Then, the secret is compared (with strncmp) to 0x17 (23) bytes starting from the address of local_30, and if it matches, strncmp returns 0, which will make uVar1=1 and the bar function returns 1 (it returns uVar1).
We can solve the challenge in two ways: static analysis and dynamic instrumentation with Frida.
First way: static analysis
Looking at the decompiled code, we see that from line 21 to 27, the function assign hex values to a number of local variables (local_30, local_2c and so on). A quick look at the disassembly function information:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
**************************************************************
* FUNCTION *
**************************************************************
undefined __cdecl Java_sg_vantagepoint_uncrackable2_Code
undefined AL:1 <RETURN>
undefined4 Stack[0x4]:4 JNIEnv
undefined4 Stack[0x8]:4 this_object
char * Stack[0xc]:4 bArr
undefined4 EAX:4 bArrNative
undefined4 EAX:4 bArrLength
undefined1 Stack[-0x10]:1 local_10
undefined4 Stack[-0x18]:4 local_18
undefined2 Stack[-0x1a]:2 local_1a
undefined4 Stack[-0x1e]:4 local_1e
undefined2 Stack[-0x20]:2 local_20
undefined4 Stack[-0x24]:4 local_24
undefined4 Stack[-0x28]:4 local_28
undefined4 Stack[-0x2c]:4 local_2c
undefined4 Stack[-0x30]:4 local_30
The local variables’ names are appended by a hex number, and this is not randomly chosen. They represent the relative position of the variable to the base pointer (which store the same address as the stack pointer), that means:
local_30starts from 0x30 (48) bytes below the stack pointer, and is 4 bytes long.local_2cstarts from 0x2c (44) bytes below the stack pointer, and is 4 bytes long.local_28starts from 0x28 (40) bytes below the stack pointer, and is 4 bytes long.local_24starts from 0x24 (36) bytes below the stack pointer, and is 4 bytes long.local_20starts from 0x20 (32) bytes below the stack pointer, and is 2 bytes long.local_1estarts from 0x1e (30) bytes below the stack pointer, and is 4 bytes long.local_1astarts from 0x1a (26) bytes below the stack pointer, and is 2 bytes long.local_18starts from 0x18 (26) bytes below the stack pointer, and is 2 bytes long.local_10starts from 0x10 (26) bytes below the stack pointer, and is 2 bytes long.
We know that the strncmp will read 23 bytes starting from the address of local_30, so it will read the values of the following variables:
- After reading
local_30: 4 bytes - After reading
local_2c: 8 bytes - After reading
local_28: 12 bytes - After reading
local_24: 16 bytes - After reading
local_20: 18 bytes - After reading
local_1e: 22 bytes - After reading
local_1a: 24 bytes
We can get the values for these variables from the decompiled code:
local_30=0x6e616854local_2c=0x6620736blocal_28=0x6120726flocal_24=0x74206c6clocal_20=0x6568local_1e=0x73696620local_1a=0x68
Because we can only enter human-readable text in the app, these must be human-readable text as well. We can see the ASCII value with Ghidra by right-clicking the literal hex values in the disassembly and Convert -> Char sequence:

Doing this for all of the variables gave us this:
local_30= “nahT”local_2c= “f sk”local_28= “a ro”local_24= “t ll”local_20= “eh”local_1e= “sif”local_1a= “h”
Because the binaries’ endian-ness is Little-endian, when reading memory, each memory part will be read in reverse, that means the strncmp is reading the following sequence: “Thanks for all the fish”. This is the password we’re looking for.
Second way: dynamic analysis with Frida
We know that the function strncmp is responsible for checking the secret and the secret is 17 bytes long. So we can use Frida to hook into the strncmp function to see the value of all the arguments passed to it. Frida’s Interceptor API is well suited for this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Interceptor.attach(Module.findExportByName("libc.so", "strncmp"), {
onEnter: function(args) {
var str1ptr = args[0];
var str2ptr = args[1];
var length = args[2].toInt32();
if (length == 23 && new NativePointer(str1ptr).readUtf8String(23) == "this string has 23 char"){
var str2 = new NativePointer(str2ptr).readUtf8String(23);
console.log(str2);
}
}
});
Java.perform(function(){
console.log("Bypassing root check");
const rootCheck = Java.use("sg.vantagepoint.a.b");
rootCheck.a.implementation = function(){
console.log("function a");
return false;
}
rootCheck.b.implementation = function(){
console.log("function b");
return false;
}
rootCheck.c.implementation = function(){
console.log("function c");
return false;
}
});
Run the above script with Frida:
1
$ frida -U -f owasp.mstg.uncrackable2 --no-pause -l level2.js
From this script we also get “Thanks for all the fish”.
Third way: be rich and use IDA Pro.
Let’s just say I have access to someone with a copy of IDA Pro. When reversing the armeabi-v7a native library binary, IDA recognizes that the function is trying to copy a string into the local memory region and automatically generate the pseudocode as a call to strcpy.

This “strategy” is only for the rich and the ones who sail the high seas (which is very *ILLEGAL I might add).
See you later with the Uncrackable Level 3 writeup. Have a good day!