OWASP MSTG Android UnCrackable Level 3

published on April 2, 2021

This is my write up for the OWASP MSTG Android UnCrackable Level 3 CrackMe

First of all, let’s try runnig the app on the Android Emulator. As soon as the app starts, it shows a dialog, warning that the device is rooted.

Warning dialog

Once we tap the “OK” button, the app gets closed.

To see what exactly happens, let’s decompile the APK with jadx

Decompiled Java code

Looking at the code for the MainActivity.onCreate method, we find the following:

public void onCreate(Bundle bundle) {
    verifyLibs();
    init(xorkey.getBytes());
    new AsyncTask<Void, String, String>() {
        /* class sg.vantagepoint.uncrackable3.MainActivity.AnonymousClass2 */

        /* access modifiers changed from: protected */
        public String doInBackground(Void... voidArr) {
            while (!Debug.isDebuggerConnected()) {
                SystemClock.sleep(100);
            }
            return null;
        }

        /* access modifiers changed from: protected */
        public void onPostExecute(String str) {
            MainActivity.this.showDialog("Debugger detected!");
            System.exit(0);
        }
    }.execute(null, null, null);
    if (RootDetection.checkRoot1() || RootDetection.checkRoot2() || RootDetection.checkRoot3() || IntegrityCheck.isDebuggable(getApplicationContext()) || tampered != 0) {
        showDialog("Rooting or tampering detected.");
    }
    this.check = new CodeCheck();
    super.onCreate(bundle);
    setContentView(R.layout.activity_main);
}

As we can see, it does a series of checks to verify the integrity of the app (with the verifyLibs method), and to check if there is a debugger attached to the process or if the device is rooted. In case it detects any tampering, it calls showDialog, which has the following code:

private void showDialog(String str) {
    AlertDialog create = new AlertDialog.Builder(this).create();
    create.setTitle(str);
    create.setMessage("This is unacceptable. The app is now going to exit.");
    create.setButton(-3, "OK", new DialogInterface.OnClickListener() {
        /* class sg.vantagepoint.uncrackable3.MainActivity.AnonymousClass1 */

        public void onClick(DialogInterface dialogInterface, int i) {
            System.exit(0);
        }
    });
    create.setCancelable(false);
    create.show();
}

Here we see that System.exit gets called to close the app. So to bypass all the previous checks, we can just use Frida and redefine this method. The Frida script is the following:

// frida -U -f "owasp.mstg.uncrackable3" -l script.js --no-pause
Java.perform(function() {
    const System = Java.use("java.lang.System");
    System.exit.implementation = function(_) {
        console.log("bypassing System.exit");
    }
});

Running it, this time the app crashes, and Frida prints a stack trace.

backtrace:
    #00 pc 00000ac4  [vdso:b62ab000] (__kernel_vsyscall+16)
    #01 pc 000ad298  /system/bin/linker (__dl_syscall+40)
    #02 pc 00027d6d  /system/bin/linker (__dl__ZL13resend_signalP7siginfob+105)
    #03 pc 00027c4c  /system/bin/linker (__dl__ZL24debuggerd_signal_handleriP7siginfoPv+1263)
    #04 pc 001af30b  /data/local/tmp/re.frida.server/frida-agent-32.so
    #05 pc 00000ddf  /system/lib/libc.so (offset 0x1e000)
    #06 pc 00000ac3  [vdso:b62ab000] (__kernel_vsyscall+15)
    #07 pc 00000b3c  /system/lib/libc.so (offset 0x75000)
    #08 pc 00003021  /data/app/owasp.mstg.uncrackable3--Gjd-I1BwzgsJBY4fmdb7g==/lib/x86/libfoo.so (_Z7goodbyev+33)
    #09 pc 00003179  /data/app/owasp.mstg.uncrackable3--Gjd-I1BwzgsJBY4fmdb7g==/lib/x86/libfoo.so
    #10 pc 000449b5  /system/lib/libc.so (offset 0x2c000)
    #11 pc 0000050b  /system/lib/libc.so (offset 0x20000)
    #12 pc 00000da6  /system/lib/libc.so (offset 0x1e000)
    #13 pc 002570da  /system/lib/libart.so (offset 0x313000)
***

From this stack trace, we can see that the native library libfoo.so gets called, so maybe there are some additional checks done in native code. In order to investigate further, we can use Ghidra to disassemble the native library.

Decompiled native code

Since we are running the application in the Android Emulator, which uses the x86 architecture, we can go on and disassemble that version of the lib.

From the Java code, we see that there are 3 JNI functions:

Of these methods, init will probably do some initialization of the native lib, and bar will probably be the method that checks if the string we insert in the textbox is the flag. Still, since the names could be intentionally misleading, we can take a look at their code usign Ghidra decompiler

baz


undefined8 Java_sg_vantagepoint_uncrackable3_MainActivity_baz(void)

{
  return 0x18110e3;
}

It just returns a constant, so the System.exit bypass should also bypass any check related to this function

init

void Java_sg_vantagepoint_uncrackable3_MainActivity_init
               (JNIEnv *param_1,jobject param_2,jbyteArray param_3)

{
  jbyte *__src;
  
  FUN_00013250();
  __src = (*(*param_1)->GetByteArrayElements)(param_1,param_3,(jboolean *)0x0);
  strncpy((char *)&DAT_0001601c,__src,0x18);
  (*(*param_1)->ReleaseByteArrayElements)(param_1,param_3,__src,2);
  DAT_00016038 = DAT_00016038 + 1;
  return;
}

It calls FUN_00013250(), and then just copies an input string to DAT_0001601c, then increments DAT_00016038. Both are global variables, since they are located in the .bss segment, so we can rename them to make it easier to recognise them if we run across them in other functions:

So far, init just does what its name implies, and it does not perform any integrity/tampering check; let’s take a look inside FUN_00013250() to see if that function does anything interesting.

void FUN_00013250(void)

{
  __pid_t _Var1;
  long lVar2;
  __pid_t _Var3;
  int in_GS_OFFSET;
  pthread_t pStack32;
  uint local_1c;
  int local_18;
  
  local_18 = *(int *)(in_GS_OFFSET + 0x14);
  _Var1 = fork();
  if (_Var1 == 0) {
    _Var1 = getppid();
    lVar2 = ptrace(PTRACE_ATTACH,_Var1,0,0);
    if (lVar2 == 0) {
      waitpid(_Var1,(int *)&local_1c,0);
      while( true ) {
        ptrace(PTRACE_CONT,_Var1,0,0);
        _Var3 = waitpid(_Var1,(int *)&local_1c,0);
        if (_Var3 == 0) break;
        if ((local_1c & 0x7f) != 0x7f) {
                    /* WARNING: Subroutine does not return */
          _exit(0);
        }
      }
    }
  }
  else {
    pthread_create(&pStack32,(pthread_attr_t *)0x0,FUN_00013220,(void *)0x0);
  }
  if (*(int *)(in_GS_OFFSET + 0x14) == local_18) {
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

At first glance, it may seem that we found something interesting, since the function forks the process and calls ptrace from the child on the parent, to prevent other processes to ptrace. However, if the call to ptrace fails, it simply calls _exit(0), so if we hook Frida first we should not have any problem.1

bar


bool Java_sg_vantagepoint_uncrackable3_CodeCheck_bar
               (JNIEnv *param_1,jobject param_2,jbyteArray param_3)

{
  bool bVar1;
  jbyte *pjVar2;
  jsize jVar3;
  uint uVar4;
  char *pcVar5;
  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 (INIT_FLAG == 2) {
    FUN_00010fa0(local_40);
    pjVar2 = (*(*param_1)->GetByteArrayElements)(param_1,param_3,(jboolean *)0x0);
    jVar3 = (*(*param_1)->GetArrayLength)(param_1,(jarray)param_3);
    if (jVar3 == 0x18) {
      uVar4 = 0;
      pcVar5 = INIT_STRING;
      do {
        if (pjVar2[uVar4] != (jbyte)(*pcVar5 ^ local_40[uVar4])) goto LAB_00013456;
        uVar4 = uVar4 + 1;
        pcVar5 = (char *)((byte *)pcVar5 + 1);
      } while (uVar4 < 0x18);
      bVar1 = true;
      if (uVar4 == 0x18) goto LAB_00013458;
    }
  }
LAB_00013456:
  bVar1 = false;
LAB_00013458:
  if (*(int *)(in_GS_OFFSET + 0x14) == local_18) {
    return bVar1;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

This is the function that compares our string to the flag.

if we look at the FUN_00010fa0() code, we see that it has a bunch of inlined calls to some RNG function, to obfuscate the code, and then fills its parameter with a 24-byte key, which is constant. We can rename this function get_key().

In the functions we analized so far, no integrity or tampering check was performed. Additionally, from the stack-trace that Frida printed, we know that there should be a call to an exported function named _Z7goodbye, which is the one that raises the signal that crashes the app. If we look at the Exports in Ghidra, we find the goodbye() function, but none of the JNI functions call it, so there must be another function that somehow gets called and in turn calls it.

.init_array

For ELF shared libraries, there is a section called .init_array which contains an array of function addresses. These functions are called in order to initialize the library. For libfoo.so, this section contains exactly one address; we can take a look at the code:


void FUN_00013180(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_00013080,(void *)0x0);
  INIT_STRING._4_4_ = 0;
  INIT_STRING._0_4_ = 0;
  INIT_STRING._12_4_ = 0;
  INIT_STRING._8_4_ = 0;
  DAT_00016034 = 0;
  INIT_STRING._20_4_ = 0;
  INIT_STRING._16_4_ = 0;
  INIT_FLAG = INIT_FLAG + 1;
  if (*(int *)(in_GS_OFFSET + 0x14) == local_20) {
    INIT_STRING._0_4_ = 0;
    INIT_STRING._4_4_ = 0;
    INIT_STRING._8_4_ = 0;
    INIT_STRING._12_4_ = 0;
    INIT_STRING._16_4_ = 0;
    INIT_STRING._20_4_ = 0;
    DAT_00016034 = 0;
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

It just creates a new thread and fills INIT_STRING with zeros. Let’s see what the new thread does:


void FUN_00013080(void)
{
  FILE *__stream;
  char *pcVar1;
  FILE *pFVar2;
  int in_GS_OFFSET;
  int iVar3;
  undefined *puStack564;
  char *local_224;
  char *local_220;
  char *local_21c;
  char *local_218;
  char local_214 [516];
  
  puStack564 = (undefined *)0x13094;
  local_220 = "r";
  local_224 = "/proc/self/maps";
  __stream = fopen("/proc/self/maps","r");
  if (__stream != (FILE *)0x0) {
    local_218 = "frida";
    local_21c = "xposed";
    do {
      pcVar1 = fgets(local_214,0x200,__stream);
      if (pcVar1 == (char *)0x0) {
        fclose(__stream);
        usleep(500);
        __stream = fopen(local_224,local_220);
        pFVar2 = __stream;
      }
      else {
        pcVar1 = strstr(local_214,local_218);
        if (pcVar1 != (char *)0x0) break;
        pFVar2 = (FILE *)strstr(local_214,local_21c);
      }
    } while (pFVar2 == (FILE *)0x0);
  }
  __android_log_print();
  puStack564 = (undefined *)0x1317a;
  goodbye();
  iVar3 = *(int *)(in_GS_OFFSET + 0x14);
  puStack564 = &stack0xfffffffc;
  pthread_create((pthread_t *)&stack0xfffffdac,(pthread_attr_t *)0x0,FUN_00013080,(void *)0x0);
  INIT_STRING._4_4_ = 0;
  INIT_STRING._0_4_ = 0;
  INIT_STRING._12_4_ = 0;
  INIT_STRING._8_4_ = 0;
  DAT_00016034 = 0;
  INIT_STRING._20_4_ = 0;
  INIT_STRING._16_4_ = 0;
  INIT_FLAG = INIT_FLAG + 1;
  if (*(int *)(in_GS_OFFSET + 0x14) == iVar3) {
    INIT_STRING._0_4_ = 0;
    INIT_STRING._4_4_ = 0;
    INIT_STRING._8_4_ = 0;
    INIT_STRING._12_4_ = 0;
    INIT_STRING._16_4_ = 0;
    INIT_STRING._20_4_ = 0;
    DAT_00016034 = 0;
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

Bingo! the new thread opens /proc/self/maps and tries to detect the usage of Frida or Xposed; it actually performs the check at 500 microseconds intervals. Once it finds that either Frida or Xposed have access to the process, it exits the loop and calls goodbye(), which is exactly the function that causes the app to crash.

Bypassing the native check

Now that we know what happens, we can bypass the check using Frida Interceptor

Java.perform(function() {
    const System = Java.use("java.lang.System");
    System.exit.implementation = function(_) {
        console.log("bypassing System.exit");
    }

    let maps_fp = 0;

    Interceptor.attach(Module.getExportByName('libc.so', 'fopen'), {
        onEnter(args) {
            const name = args[0].readCString();
            if(name == "/proc/self/maps") {
                this.maps = true;
            }
        },
        onLeave(retval) {
            if(this.maps) {
                maps_fp = retval.toInt32();
            }
        }
    });

    Interceptor.attach(Module.getExportByName('libc.so', 'fgets'), {
        onEnter(args) {
            const fp = args[2].toInt32();
            if(maps_fp == fp) {
                this.maps = true;
            }
        },
        onLeave(retval) {
            if (this.maps) {
                retval.replace(0);
            }
        }
    });
});

We intercept calls to fopen(), and when it’s called to open /proc/self/maps we save the returned FILE *; then we intercept calls to fgets(), which is used by the initialization function to check for Frida/Xposed, and when its called on the FILE *' we saved, we return 0, so that the thread reamins inside the loop.

After this, we are able to launch the app and interact with it, so we can now think about getting the flag.

Intercepting the key and decoding the flag

To get the key, we would like to intercept the call to get_key(), reading the value of the key once the function returns. Since it’s an internal function and it’s not exported, to hook it with Frida we need to find it’s address. We can hover over the address of the function in Ghidra

function address

Once we know the address, we can hook the function and retrieve the key, then use it with the string passef to init() to get the flag.

Java.perform(function() {
    const System = Java.use("java.lang.System");
    System.exit.implementation = function(_) {
        console.log("bypassing System.exit");
    }

    let maps_fp = 0;

    Interceptor.attach(Module.getExportByName('libc.so', 'fopen'), {
        onEnter(args) {
            const name = args[0].readCString();
            if(name == "/proc/self/maps") {
                this.maps = true;
            }
        },
        onLeave(retval) {
            if(this.maps) {
                maps_fp = retval.toInt32();
            }
        }
    });

    Interceptor.attach(Module.getExportByName('libc.so', 'fgets'), {
        onEnter(args) {
            const fp = args[2].toInt32();
            if(maps_fp == fp) {
                this.maps = true;
            }
        },
        onLeave(retval) {
            if (this.maps) {
                retval.replace(0);
            }
        }
    });

    setTimeout(() => {
        Interceptor.attach(Module.findBaseAddress('libfoo.so').add(0x0fa0), {
            onEnter(args) {
                this.arg = ptr(args[0].toString());
            },
            onLeave(_) {
                const str = this.arg.readByteArray(24)
                const buf = new Uint8Array(str);
                const key = str2arr("pizzapizzapizzapizzapizz");
                for(let i = 0; i < key.length; ++i) {
                    buf[i] = buf[i]^key[i];
                }
                console.log(`flag: ${arr2str(buf)}`);
            }
        });
    }, 1000);
});

function str2arr(str) {
    const map = Array.prototype.map;
    return new Uint8Array(map.call(str, x => x.charCodeAt(0)));
}

function arr2str(arr) {
    let str = "";
    for(const el of arr) {
        str += String.fromCharCode(el);
    }
    return str;
}

And we are done. If we just tap the “VERIFY” button the flag gets printed in the console.


  1. After investingating some more, it looks like the call to ptrace from the forked process actually succeds, but if we use Frida to spawn the process (as we usually do) and not to attach to it, everything works fine. I’m not completely sure about what happens, but we can still just ignore the function. ↩︎