Posts OWASP Android Uncrackable Level 2 Writeup
Post
Cancel

OWASP Android Uncrackable Level 2 Writeup

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:

`CodeCheck` class

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:

`lib/` folder

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:

Ghidra import file

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

libfoo.so binary info

Ghidra libfoo.so file

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.

libfoo.so exports

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:

  1. The native function name must be in the format: “Java_[class_name]_[function_name]”
  2. 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 the this 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.

`bar` function in IDA Pro

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!

Further Reading

  1. Frida github page
  2. Hackaday’s Introduction to Reverse Engineering with Ghidra
  3. Guide to JNI by Baeldung
  4. JNI Functions
  5. Common JNI Offset list
  6. AndroidAppRE by maddiestone - Reversing Native Libraries
This post is licensed under CC BY 4.0 by the author.