Posts OWASP Android Uncrackable Level 3 Writeup
Post
Cancel

OWASP Android Uncrackable Level 3 Writeup

Information

UnCrackable App for Android Level 3

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-Level3.apk

Requirements

  • A rooted android device or emulator (with adb)
  • Frida for dynamic instrumentation
  • Java decompiler: JADX, JD-GUI, etc..
  • Ghidra SRE Toolkit

Using Frida to bypass the root check

When we open the app, we got a message that tells us we can’t use the app on a rooted device. The logic is almost exactly the same as level 1 and 2, and I won’t bother with it here.

Uncrackable 3 welcome screen

The script used to bypass root checking:

1
2
3
4
5
6
7
8
9
10
11
12
Java.perform(function(){
  const root = Java.use("sg.vantagepoint.util.RootDetection");
	root.checkRoot1.implementation = function(){
		return false;
	}
	root.checkRoot2.implementation = function(){
		return false;
	}
	root.checkRoot3.implementation = function(){
		return false;
	}
});

Using Ghidra to analyze the native library

Uncrackable level 3 has new tricks up it’s sleeves, even though we have the Frida script to bypass the Java-based root check, there’s still something preventing us from actually hooking the application with Frida. Whenever Frida is used, this error spits out:

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
PS D:\temp\uncrackable> frida -U -f owasp.mstg.uncrackable3 --no-pause -l .\level3.js
Spawned `owasp.mstg.uncrackable3`. Resuming main thread!
[Samsung::owasp.mstg.uncrackable3]-> Loading dynamic library => foo
Process crashed: Trace/BPT trap

***
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'Android/vbox86p/vbox86p:9/PI/138:userdebug/test-keys'
Revision: '0'
ABI: 'x86'
pid: 2873, tid: 2899, name: tg.uncrackable3  >>> owasp.mstg.uncrackable3 <<<
signal 6 (SIGABRT), code -6 (SI_TKILL), fault addr --------
    eax 00000000  ebx 00000b39  ecx 00000b53  edx 00000006
    edi 00000006  esi 00000b39
    ebp c94d76a8  esp c94d765c  eip eab39bb9

backtrace:
    #00 pc 00000bb9  [vdso:eab39000] (__kernel_vsyscall+9)
    #01 pc 00078c5c  /system/lib/libc.so (offset 0x78000) (tgkill+28)
    #02 pc 0002e3f4  /system/lib/libc.so (offset 0x2e000) (raise+68)
    #03 pc 00003021  /data/app/owasp.mstg.uncrackable3-vLOEe5EaSoenqwDiYCP6RA==/lib/x86/libfoo.so (offset 0x3000) (goodbye()+33)
    #04 pc 00003179  /data/app/owasp.mstg.uncrackable3-vLOEe5EaSoenqwDiYCP6RA==/lib/x86/libfoo.so (offset 0x3000)
    #05 pc 0008f275  /system/lib/libc.so (offset 0x78000) (__pthread_start(void*)+53)
    #06 pc 0002480b  /system/lib/libc.so (offset 0x24000) (__start_thread+75)
***

Looking closer at the backtrace, we see that the trace shows us libfoo’s goodbye() function is called after a thread is started (pthread_start). It’s time to bring out the big guns (Ghidra) and load libfoo into it. Remember to adjust the Image Base value to 0x00000000.

Image base value

ELF files have a section called .init_array that store instructions to run when the program is called. You can find the .init_array section in the “Program Trees” menu (usually at the top left).

"Program Trees" menu

The .init_array section: .init_array section

Here, the function _INIT_0 is executed at startup:

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
void _INIT_0(void)

{
  int in_GS_OFFSET;
  pthread_t local_24;
  int local_20;
  
  local_20 = *(int *)(in_GS_OFFSET + 0x14);
  pthread_create(&local_24,(pthread_attr_t *)0x0,FUN_00003080,(void *)0x0);
  DAT_00006020 = 0;
  DAT_0000601c = 0;
  DAT_00006028 = 0;
  DAT_00006024 = 0;
  DAT_00006034 = 0;
  DAT_00006030 = 0;
  DAT_0000602c = 0;
  DAT_00006038 = DAT_00006038 + 1;
  if (*(int *)(in_GS_OFFSET + 0x14) == local_20) {
    DAT_0000601c = 0;
    DAT_00006020 = 0;
    DAT_00006024 = 0;
    DAT_00006028 = 0;
    DAT_0000602c = 0;
    DAT_00006030 = 0;
    DAT_00006034 = 0;
    return;
  }
  __stack_chk_fail();
}

This function create a new thread to execute a function named FUN_00003080. Upon further examination, it’s evident that this function is somehow checking for the existence of Frida in memory. Before we move on, I want to highlight another way we can arrive to the same fucntion: you can search for the string frida using Ghidra’s Search menu -> For String. Then navigate to the XREFS (cross references, basically where the value is used).

Search for string frida in the binary

"frida" string location with xrefs

Checking for “frida” and “xposed”

The function FUN_00003080:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
void FUN_00003080(void)

{
  FILE *__stream;
  char *pcVar1;
  FILE *pFVar2;
  int in_GS_OFFSET;
  pthread_t pStack596;
  int iStack592;
  undefined4 uStack580;
  char local_214 [516];
  
  uStack580 = 0x30b9;
  __stream = fopen("/proc/self/maps","r");
  if (__stream != (FILE *)0x0) {
    do {
      uStack580 = 0x30ef;
      pcVar1 = fgets(local_214,0x200,__stream);
      if (pcVar1 == (char *)0x0) {
        uStack580 = 0x3129;
        fclose(__stream);
        uStack580 = 0x3136;
        usleep(500);
        uStack580 = 0x3146;
        __stream = fopen("/proc/self/maps","r");
        pFVar2 = __stream;
      }
      else {
        uStack580 = 0x3103;
        pcVar1 = strstr(local_214,"frida");
        if (pcVar1 != (char *)0x0) break;
        uStack580 = 0x3117;
        pFVar2 = (FILE *)strstr(local_214,"xposed");
      }
    } while (pFVar2 == (FILE *)0x0);
  }
  uStack580 = 0x3172;
  __android_log_print();
  goodbye();
  iStack592 = *(int *)(in_GS_OFFSET + 0x14);
  pthread_create(&pStack596,(pthread_attr_t *)0x0,FUN_00003080,(void *)0x0);
  DAT_00006020 = 0;
  DAT_0000601c = 0;
  DAT_00006028 = 0;
  DAT_00006024 = 0;
  DAT_00006034 = 0;
  DAT_00006030 = 0;
  DAT_0000602c = 0;
  DAT_00006038 = DAT_00006038 + 1;
  if (*(int *)(in_GS_OFFSET + 0x14) == iStack592) {
    DAT_0000601c = 0;
    DAT_00006020 = 0;
    DAT_00006024 = 0;
    DAT_00006028 = 0;
    DAT_0000602c = 0;
    DAT_00006030 = 0;
    DAT_00006034 = 0;
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

The function can be summarized as follows:

  • Open /proc/self/maps in read mode.
  • Read 0x200 (512) bytes from the file and store in a variable.
  • If the variable is not empty, check if the string variable contains “frida” or “exposed” with strstr.
  • If the string constains “frida” or “exposed”, say goodbye() and crash the app.

So why would the application use /proc/self/maps to check if the application is hooked? There’s a good explaination in this StackOverflow thread. In summary: /proc/self/maps is a file containing the currently mapped memory regions and their access permissions of the current application. When you hook an application with frida, by default, the file will look like this:

1
2
3
4
5
6
# cat /proc/4588/maps
....
ccd40000-ce3b3000 r-xp 00000000 08:13 651750                             /data/local/tmp/re.frida.server/frida-agent-32.so
ce3b3000-ce3ff000 r--p 01672000 08:13 651750                             /data/local/tmp/re.frida.server/frida-agent-32.so
ce3ff000-ce40d000 rw-p 016be000 08:13 651750                             /data/local/tmp/re.frida.server/frida-agent-32.so
....

Quoting OWASP MSTG:

Frida uses ptrace to hijack a thread of a running process. This thread is used to allocate a chunk of memory and populate it with a mini-bootstrapper. The bootstrapper starts a fresh thread, connects to the Frida debugging server that’s running on the device, and loads a shared library that contains the Frida agent (frida-agent.so).

So the app did not do anything fancy, it just checks the memory maps to see if there’s anything named “frida” in it, we could modify Frida to not use the string “frida” in the library name, but I won’t get into that.

For other ways to check for hooks, refer to this blog: Detect Frida for Android by Darvin

Because the app used strstr with plaintext “frida” string, we can use Frida to hook directly to the libc’s function and change it’s behaviour, bypassing anti-frida with Frida, isn’t that ironic. Utilizing the Interceptor API, we can make strstr return 0 when the string to be checked is equal to “frida” or “xposed”. Code snippet from Project: anti-frida-bypass on Frida CodeShare

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Interceptor.attach(Module.findExportByName("libc.so", "strstr"), {

    onEnter: function(args) {

        this.haystack = args[0];
        this.needle = args[1];
        this.frida = Boolean(0);

        haystack = Memory.readUtf8String(this.haystack);
        needle = Memory.readUtf8String(this.needle);

        if (haystack.indexOf("frida") !== -1 || haystack.indexOf("xposed") !== -1) {
            this.frida = Boolean(1);
        }
    },

    onLeave: function(retval) {

        if (this.frida) {
            retval.replace(0);
        }
        return retval;
    }
});

The bar function

The app still use the bar function to verify the secret, but of course the implementation is different. This time, the app uses XOR “encryption” to hide the secret, as evident by the initialize code in Java

1
2
3
4
5
6
7
8
9
10
11
public class MainActivity{
  ...
  private static final String xorkey = "pizzapizzapizzapizzapizz";
  ...
  public void onCreate(Bundle bundle) {
          verifyLibs();
          init(xorkey.getBytes());
  ...
  }

}

init() is a native function, a quick look (with JNIEnv functions and variables renamed correctly):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Java_sg_vantagepoint_uncrackable3_MainActivity_init
               (JNIEnv *JNIEnv,undefined4 param_2,jbyteArray xor_key)

{
  jbyte *xor_key_bytes;
  
  FUN_00003250();
  xor_key_bytes = (*(*JNIEnv)->GetByteArrayElements)(JNIEnv, xor_key, (jboolean *)0x0);

  strncpy((char *)&DAT_0000601c, xor_key_bytes, 0x18);
  
  (*(*JNIEnv)->ReleaseByteArrayElements)(JNIEnv,xor_key,xor_key_bytes,2);
  DAT_00006038 = DAT_00006038 + 1;
  return;
}

We can see the 24-bytes XOR key is copied into a variable called DAT_0000601c.

The bar function uses this variable:

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
46
47
48
undefined4
Java_sg_vantagepoint_uncrackable3_CodeCheck_bar
          (JNIEnv *JNIEnv,_jobject param_2,jbyteArray secret_to_check)

{
  jbyte *secret_to_check_bytes;
  jsize secret_to_check_length;
  uint i;
  undefined4 result;
  undefined4 *xor_key_bytes;
  int in_GS_OFFSET;
  undefined local_40 [16];
  undefined4 local_30;
  undefined4 local_2c;
  undefined local_28;
  int local_18;
  
  local_18 = *(int *)(in_GS_OFFSET + 0x14);
  local_40 = ZEXT816(0);
  local_2c = 0;
  local_30 = 0;
  local_28 = 0;
  if (DAT_00006038 == 2) {
    FUN_00000fa0(local_40);
    secret_to_check_bytes =
         (*(*JNIEnv)->GetByteArrayElements)(JNIEnv,secret_to_check,(jboolean *)0x0);
    secret_to_check_length = (*(*JNIEnv)->GetArrayLength)(JNIEnv,secret_to_check);
    if (secret_to_check_length == 0x18) {
      i = 0;
      xor_key_bytes = &DAT_0000601c;
      do {
        if (secret_to_check_bytes[i] != (*(byte *)xor_key_bytes ^ local_40[i])) goto LAB_00003456;
        i = i + 1;
        xor_key_bytes = (undefined4 *)((int)xor_key_bytes + 1);
      } while (i < 24);
      result = CONCAT31((int3)(i >> 8),1);
      if (i == 24) goto LAB_00003458;
    }
  }
LAB_00003456:
  result = 0;
LAB_00003458:
  if (*(int *)(in_GS_OFFSET + 0x14) == local_18) {
    return result;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

The main logic is in line 23 - 39. In line 32, the string we entered (secret_to_check_bytes) is compared to the xorkey (pizzapizzapizzapizzapizz) xored with a local variable (local_40) byte by byte.

If we want to find the right string to put in the text box, we have to find the value of local_40, we only see the variable exactly once more in the function as an argument when calling FUN_00000fa0. I’ll rename these to other_key and get_other_key.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
get_other_key(other_key);
secret_to_check_bytes =
      (*(*JNIEnv)->GetByteArrayElements)(JNIEnv,secret_to_check,(jboolean *)0x0);
secret_to_check_length = (*(*JNIEnv)->GetArrayLength)(JNIEnv,secret_to_check);
if (secret_to_check_length == 24) {
  i = 0;
  xor_key_bytes = &DAT_0000601c;
  do {
    if (secret_to_check_bytes[i] != (*(byte *)xor_key_bytes ^ other_key[i])) goto LAB_00003456;
    i = i + 1;
    xor_key_bytes = (undefined4 *)((int)xor_key_bytes + 1);
  } while (i < 24);
  result = CONCAT31((int3)(i >> 8),1);
  if (i == 24) goto LAB_00003458;
}

If you’re wondering why we pass other_key to a function called get_other_key, I’ve got you covered. In C you can pass a pointer to a function, and the function can access or modify the memory address the pointer is pointing to. We must somehow get the value of other_key after the function has finished or do some static analysis. I’ll cover both of these.

get_other_key analysis

Static analysis

The function is very very long (~1500 lines of pseudocode). But we should focus on the function parameter, since it’s what we need to find.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void get_other_key(undefined4 *other_key)
{
  ...1500 lines of code...
  if (_1_sub_doit__opaque_list1_1 != (uint *)0x0) {
    other_key[1] = 0;
    *other_key = 0;
    other_key[3] = 0;
    other_key[2] = 0;
    *(undefined *)(other_key + 6) = 0;
    *other_key = 0x1311081d;
    other_key[1] = 0x1549170f;
    other_key[2] = 0x1903000d;
    other_key[3] = 0x15131d5a;
    other_key[4] = 0x5a0e08;
    other_key[5] = 0x14130817;
  }
  return;
}

We can see the function assign the value to other_key directly. At the time of doing the challenge, I didn’t know why there’s so much code above, I’ll get into that later.

Like the previous challenge, the binary is in Little Endian, so we just have to reverse the byte order from each DWORD and concatenating the bytes together:

  • 0x1311081d becomes 1d 08 11 13
  • 0x1549170f becomes 0f 17 49 15
  • 0x1903000d becomes 0d 00 03 19
  • 0x15131d5a becomes 5a 1d 13 15
  • 0x5a0e08 or 0x005a0e08 becomes 08 0e 5a 00
  • 0x14130817 becomes 17 08 13 14

From the bar function, we know that the loop run 24 times, and the xorkey (pizzapizzapizzapizzapizz) is also 24 bytes long, so it make sense that the secret we have to find is also 24 bytes, and the other_key we have is also exactly 24 bytes long.

1
1d 08 11 13 0f 17 49 15 0d 00 03 19 5a 1d 13 15 08 0e 5a 00 17 08 13 14

The xorkey (pizzapizzapizzapizzapizz) in hex (ASCII):

1
70 69 7a 7a 61 70 69 7a 7a 61 70 69 7a 7a 61 70 69 7a 7a 61 70 69 7a 7a

We only have to xor these together to get the key to the level now. The following python script can be used.

1
2
3
4
pizza = bytes.fromhex("70 69 7a 7a 61 70 69 7a 7a 61 70 69 7a 7a 61 70 69 7a 7a 61 70 69 7a 7a")
other_key = bytes.fromhex("1d 08 11 13 0f 17 49 15 0d 00 03 19 5a 1d 13 15 08 0e 5a 00 17 08 13 14")
secret = bytes(a ^ b for (a, b) in zip(pizza, other_key))
print(secret)

We got this beautiful sentence:

1
2
 $ python xor.py
making owasp great again

Dynamic analysis

Dynamic analysis seems overkill when the code directly assign values to the key. But it won’t always be this easy, the source code of the application does not assign values directly like this, it goes through a bunch of functions like so:

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
46
47
48
49
50
tmp = __builtin_object_size((void *)out, 0);
__builtin___memset_chk((void *)out, 0, 25UL, tmp);
tmp___0 = sub_0_4();
*(out + 0) = (char )tmp___0;
tmp___1 = sub_1_4();
*(out + 1) = (char )tmp___1;
tmp___2 = sub_2_4();
*(out + 2) = (char )tmp___2;
tmp___3 = sub_3_4();
*(out + 3) = (char )tmp___3;
tmp___4 = sub_4_4();
*(out + 4) = (char )tmp___4;
tmp___5 = sub_5_4();
*(out + 5) = (char )tmp___5;
tmp___6 = sub_6_4();
*(out + 6) = (char )tmp___6;
tmp___7 = sub_7_4();
*(out + 7) = (char )tmp___7;
tmp___8 = sub_8_4();
*(out + 8) = (char )tmp___8;
tmp___9 = sub_9_4();
*(out + 9) = (char )tmp___9;
tmp___10 = sub_10_4();
*(out + 10) = (char )tmp___10;
tmp___11 = sub_11_4();
*(out + 11) = (char )tmp___11;
tmp___12 = sub_12_4();
*(out + 12) = (char )tmp___12;
tmp___13 = sub_13_4();
*(out + 13) = (char )tmp___13;
tmp___14 = sub_14_4();
*(out + 14) = (char )tmp___14;
tmp___15 = sub_15_4();
*(out + 15) = (char )tmp___15;
tmp___16 = sub_16_4();
*(out + 16) = (char )tmp___16;
tmp___17 = sub_17_4();
*(out + 17) = (char )tmp___17;
tmp___18 = sub_18_4();
*(out + 18) = (char )tmp___18;
tmp___19 = sub_19_4();
*(out + 19) = (char )tmp___19;
tmp___20 = sub_20_4();
*(out + 20) = (char )tmp___20;
tmp___21 = sub_21_4();
*(out + 21) = (char )tmp___21;
tmp___22 = sub_22_4();
*(out + 22) = (char )tmp___22;
tmp___23 = sub_23_4();
*(out + 23) = (char )tmp___23;

You can take a look yourself at line 2804 - 2853 here. Whatever compiler they used to make the binary recognizes that the function calls are static and can be reduced to literal values, and made appropriate changes.

Let’s assume the function is so complicated and static analysis is very time-consuming and tedious, we then can retrieve the value through dynamic instrumenting with Frida’s Interceptor API.

We can hook the function call to get_other_key, but not through Module.getExportByName() because this function is not exported. Instead, we have to find the base memory address of the library, find the offset of the function and hook directly to that memory address. Something like this:

1
2
3
4
5
6
7
8
9
10
11
Interceptor.attach(Module.findBaseAddress("libfoo.so").add('0x00000fa0'),{
  onEnter: function(args){
    console.log("getting other_key value");
    this.other_key_address = args[0];
  },
  onLeave: function(retval){
    var other_key = new NativePointer(this.other_key_address);
    var arr = other_key.readByteArray(24);
    console.log(arr);
  }
});

In the above script, we intercept the function to get it’s first argument’s address, we then save the address to a variable. When the function finish, we take the previously saved address to read 24 bytes starting from that address. The function offset 0x00000fa0 can be found at the first instruction of the function in the Disassembly window, just make sure you subtract or change the Image Base address:

get_other_key offset

If we try to run the above hook, we will get an error, this is due to the fact that the library is loaded after the script is executed. You can either wait a bit (10-20s) or hook directly to the System.loadLibrary() function using this snippet from a Github issue.

  • The first way:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    setTimeout(function(){
      console.log("Hooking get_other_key");
    Interceptor.attach(Module.findBaseAddress("libfoo.so").add('0x00000fa0'),{
      onEnter: function(args){
        console.log("getting other_key value");
        this.other_key_address = args[0];
      },
      onLeave: function(retval){
        var other_key = new NativePointer(this.other_key_address);
        var arr = other_key.readByteArray(24);
        console.log(arr);
      }
    });
    }, 20000);
    
  • The second way:
    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
    
    Java.perform(function() {
      const System = Java.use("java.lang.System");
    const Runtime = Java.use("java.lang.Runtime");
    const SystemLoad_2 = System.loadLibrary.overload("java.lang.String");
    const VMStack = Java.use("dalvik.system.VMStack");
    
    SystemLoad_2.implementation = function(library) {
        console.log("Loading dynamic library => " + library);
        try {
          const loaded =     Runtime.getRuntime().loadLibrary0( VMStack.getCallingClassLoader(), library);
          if(library.includes("foo")) {
            //function that gets the xored value 
            Interceptor.attach(Module.findBaseAddress("libfoo.so").add('0x00000fa0'),{
              onEnter: function(args){
                console.log("getting other_key value");
                this.other_key_address = args[0];
              },
              onLeave: function(retval){
                var other_key = new NativePointer(this.other_key_address);
                var arr = other_key.readByteArray(24);
                console.log(arr);
              }
            });
          }
          return loaded;
        } catch(ex) {
          console.log(ex);
          console.log(ex.stack);
        }
    };
    });
    

    They should produce the same output like so:

Hooking with frida to get other_key

Result

After all that, we deserve a nice message:

Success

There’s level 4 and another challenge, but I think I’ll save it for another time. Message me if you have any questions. See you in another post!

Further Reading

  1. Frida github page
  2. Understanding /proc/self/maps
  3. Detect Frida for Android
  4. AndroidAppRE by maddiestone - Reversing Native Libraries
This post is licensed under CC BY 4.0 by the author.