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.
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:
init
, which is called inside theMainActivity.onCreate
methodbaz
, which is called by theverifyLibs
methodbar
, which is called by theCodeCheck.check_code
method
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:
DAT_0001601c
becomesINIT_STRING
DAT_00016038
becomesINIT_FLAG
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.
- It fills 24 bytes of
local_40
with zeros (local_40 = ZEXT816(0)
) - It checks if
INIT_FLAG
is2
- calls
FUN_00010fa0()
to compute the stringlocal_40
- uses XORs
INIT_STRING
withlocal_40
to compute the flag, and then compares this with the stringbar
got as a parameter
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
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.
-
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. ↩︎