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.
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
.
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).
The .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).
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
becomes1d 08 11 13
0x1549170f
becomes0f 17 49 15
0x1903000d
becomes0d 00 03 19
0x15131d5a
becomes5a 1d 13 15
0x5a0e08
or0x005a0e08
becomes08 0e 5a 00
0x14130817
becomes17 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:
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:
Result
After all that, we deserve a nice message:
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!