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
native
keyword 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 thethis
object 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_30
starts from 0x30 (48) bytes below the stack pointer, and is 4 bytes long.local_2c
starts from 0x2c (44) bytes below the stack pointer, and is 4 bytes long.local_28
starts from 0x28 (40) bytes below the stack pointer, and is 4 bytes long.local_24
starts from 0x24 (36) bytes below the stack pointer, and is 4 bytes long.local_20
starts from 0x20 (32) bytes below the stack pointer, and is 2 bytes long.local_1e
starts from 0x1e (30) bytes below the stack pointer, and is 4 bytes long.local_1a
starts from 0x1a (26) bytes below the stack pointer, and is 2 bytes long.local_18
starts from 0x18 (26) bytes below the stack pointer, and is 2 bytes long.local_10
starts 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
=0x6e616854
local_2c
=0x6620736b
local_28
=0x6120726f
local_24
=0x74206c6c
local_20
=0x6568
local_1e
=0x73696620
local_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!