It’s this time of the year again and I have the chance to play the CTF held by Hackerone. It has a lot of mobile hacking challenge, and at the same time, I wanted to dive into this. What a good time to learn!
Update 1: I totally forgot the fifth part of the flag in the first challenge while I was writing this. That’s what it’s like rushing a post :)
Mobile Challenge 1
Let’s open up the apk in jadx. The first part of the flag is revealed in the MainActivity: flag{so_much
1
2
3
4
5
...
void doSomething() {
Log.d("Part 1", "The first part of your flag is: \"flag{so_much\"");
}
}
The fourth part is represented in a function in the same package
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
package com.hackerone.mobile.challenge1;
public class FourthPart {
String eight() {
return "w";
}
String five() {
return "_";
}
String four() {
return "h";
}
String one() {
return "m";
}
String seven() {
return "o";
}
String six() {
return "w";
}
String three() {
return "c";
}
String two() {
return "u";
}
}
To get the fourth part, we only need to rearrange the characters in the order of the function name and we get much_wow
The third part is found in the strings.xml
file in the values folder: analysis_
1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<resources>
...
<string name="app_name">Challenge 1</string>
<string name="part_3">part 3: analysis_</string>
<string name="search_menu_title">Search</string>
<string name="status_bar_notification_info_overflow">999+</string>
</resources>
Opening up the native library in a disassembler or just simply run strings
on the file, we can see part two of the flag: _static_
I tried putting all these together but it was not the flag. I then noticed some weird functions in the native lib.
Grabbing the characters in the order of the functions on the left yeild _and_cool}
. That’s probably the last part of the flag.
Putting all of these together, we get flag{so_much_static_analysis_much_wow_and_cool}
Mobile Challenge 2
Install the app and open it, we are presented with an interface like a lock
Try to enter a password, and we can see some log from the app in logcat:
1
2
3
06-30 22:57:14.849 2994 2994 D PinLock : Pin complete: 121111
06-30 22:57:14.849 2994 2994 D TEST : 00000000B93BFEBB00000000000000001CA70C341CA70C341CA70C34A59CF28F
06-30 22:57:14.849 2994 2994 D PROBLEM : Unable to decrypt text
Let’s open the apk again in jadx and investigate. Note the onComplete
method, it is called when the pin is finished:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void onComplete(String str) {
String str2 = MainActivity.this.TAG;
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("Pin complete: ");
stringBuilder.append(str);
Log.d(str2, stringBuilder.toString());
str = MainActivity.this.getKey(str);
Log.d("TEST", MainActivity.bytesToHex(str));
try {
Log.d("DECRYPTED", new String(new SecretBox(str).decrypt("aabbccddeeffgghhaabbccdd".getBytes(), MainActivity.this.cipherText), StandardCharsets.UTF_8));
} catch (RuntimeException e) {
Log.d("PROBLEM", "Unable to decrypt text");
e.printStackTrace();
}
}
So the app work as following:
- Once the user finishes entering a pin, the
onComplete
method is called on the pin - The pin string goes through the function
getKey
in the native lib - The derived key is they used to decrypt the ciphertext with the nonce of
aabbccddeeffgghhaabbccdd
. The encryption is carried out using libsodium
There are 2 approaches:
- Reverse engineering the encrpytion algorithm and solve for the password
- Bruteforce ;) The key is only 6-digit long so that means 1M passwords in total.
I then patched the smali code perform a bruteforce on all possible keys when the onEmpty
event is triggered. Patched smali file MainActivity$1.smali
.method public onEmpty()V
.locals 1
invoke-virtual {p0}, Lcom/hackerone/mobile/challenge2/MainActivity$1;->hack()V
return-void
.end method
.method public hack()V
.locals 8
.prologue
.line 15
const/4 v0, 0x0
move v1, v0
:goto_2
const v0, 0xf4240
if-ge v1, v0, :cond_2f
.line 16
invoke-static {v1}, Ljava/lang/Integer;->toString(I)Ljava/lang/String;
move-result-object v0
.line 17
:goto_b
invoke-virtual {v0}, Ljava/lang/String;->length()I
move-result v2
const/4 v3, 0x6
if-ge v2, v3, :cond_26
.line 18
new-instance v2, Ljava/lang/StringBuilder;
invoke-direct {v2}, Ljava/lang/StringBuilder;-><init>()V
const-string v3, "0"
invoke-virtual {v2, v3}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v2
invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v0
invoke-virtual {v0}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
move-result-object v0
goto :goto_b
.line 20
:cond_26
const-string v4, "TRYING"
invoke-static {v4, v0}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
iget-object v4, p0, Lcom/hackerone/mobile/challenge2/MainActivity$1;->this$0:Lcom/hackerone/mobile/challenge2/MainActivity;
invoke-virtual {v4, v0}, Lcom/hackerone/mobile/challenge2/MainActivity;->getKey(Ljava/lang/String;)[B
move-result-object v0
new-instance v4, Lorg/libsodium/jni/crypto/SecretBox;
invoke-direct {v4, v0}, Lorg/libsodium/jni/crypto/SecretBox;-><init>([B)V
const-string v5, "aabbccddeeffgghhaabbccdd"
invoke-virtual {v5}, Ljava/lang/String;->getBytes()[B
move-result-object v5
:try_start_0
iget-object v6, p0, Lcom/hackerone/mobile/challenge2/MainActivity$1;->this$0:Lcom/hackerone/mobile/challenge2/MainActivity;
invoke-static {v6}, Lcom/hackerone/mobile/challenge2/MainActivity;->access$000(Lcom/hackerone/mobile/challenge2/MainActivity;)[B
move-result-object v6
invoke-virtual {v4, v5, v6}, Lorg/libsodium/jni/crypto/SecretBox;->decrypt([B[B)[B
move-result-object v6
.line 44
new-instance v5, Ljava/lang/String;
sget-object v4, Ljava/nio/charset/StandardCharsets;->UTF_8:Ljava/nio/charset/Charset;
invoke-direct {v5, v6, v4}, Ljava/lang/String;-><init>([BLjava/nio/charset/Charset;)V
const-string v6, "DECRYPTED"
.line 46
invoke-static {v6, v5}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
:try_end_0
.catch Ljava/lang/RuntimeException; {:try_start_0 .. :try_end_0} :catch_0
goto :cond_2f
:catch_0
move-exception v7
const-string v5, "PROBLEM"
const-string v4, "Unable to decrypt text"
.line 48
invoke-static {v5, v4}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
.line 49
invoke-virtual {v7}, Ljava/lang/RuntimeException;->printStackTrace()V
.line 15
add-int/lit8 v0, v1, 0x1
move v1, v0
goto :goto_2
.line 22
:cond_2f
return-void
.end method
The decompiled code after patching looks like 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
class C03091 implements PinLockListener {
public void onEmpty() {
hack();
}
public void hack() {
int i = 0;
while (i < 1000000) {
String num = Integer.toString(i);
while (num.length() < 6) {
num = "0" + num;
}
Log.d("TRYING", num);
try {
Log.d("DECRYPTED", new String(new SecretBox(MainActivity.this.getKey(num)).decrypt("aabbccddeeffgghhaabbccdd".getBytes(), MainActivity.this.cipherText), StandardCharsets.UTF_8));
return;
} catch (RuntimeException e) {
Log.d("PROBLEM", "Unable to decrypt text");
e.printStackTrace();
i++;
}
}
}
...
I then ran the patched APK and waiting for the bruteforce to finished, not noticed that there is a rate limit on the native getKey
function. It was only performing about 50 tries every 10 seconds. I decided to investigate the native library to find out the rate limit.
I did not really understand all the functions in the library, and decided to replace numerical values where it appeared to see if the rate limit is changed. After some trials and errors, I finally figured out that the rate limit was carried out in the Java_com_hackerone_mobile_challenge2_MainActivity_getKey
function.
Notice the numerical value in the cmp
operation, which is 51. That explains the rate I observed before in the log. I did attempt to patch the timing but did not succeed. Patching this did change the rate limit.
Note that there are more bytes occupied by the 2 instructions (the cmp
and the jump right after that) after patching than before patching. The bytes changed from 83 F8 33 72 64
to 3D 37 13 37 13 72 62
. Please also note that I am patching the x86 library while solving this challenge.
After running for a while, the bruteforce will stop when it finds the correct pin:
1
2
3
06-30 23:58:42.117 5134 5134 W System.err: at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)
06-30 23:58:42.117 5134 5134 D TRYING : 918264
06-30 23:58:42.117 5134 5134 D DECRYPTED: flag{wow_yall_called_a_lot_of_func$}
The correct pin is 918264
and the flag is flag{wow_yall_called_a_lot_of_func$}
Mobile Challenge 3
In this challenge, we are given a base.odex file and a boot.oat file. Basically, odex stands for optimized-dex, which is byte codes optimized for a specific device. To be able to obtain the dex file, we need to deodex the file. A tool that I found doing this job is baksmali. I ran the tool on the files but it was not successful. It was missing references to some methods.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
➜ chal3 git:(master) ✗ java -Xmx512m -jar baksmali.jar x base.odex -c boot.oat -o test_out
org.jf.dexlib2.analysis.AnalysisException: Could not resolve the method in class Landroid/support/v7/widget/MenuPopupWindow$MenuDropDownListView; at index 1053
at org.jf.dexlib2.analysis.MethodAnalyzer.analyzeInvokeVirtualQuick(MethodAnalyzer.java:1824)
at org.jf.dexlib2.analysis.MethodAnalyzer.analyzeInstruction(MethodAnalyzer.java:1040)
at org.jf.dexlib2.analysis.MethodAnalyzer.analyze(MethodAnalyzer.java:201)
at org.jf.dexlib2.analysis.MethodAnalyzer.<init>(MethodAnalyzer.java:131)
at org.jf.baksmali.Adaptors.MethodDefinition.addAnalyzedInstructionMethodItems(MethodDefinition.java:464)
at org.jf.baksmali.Adaptors.MethodDefinition.getMethodItems(MethodDefinition.java:371)
at org.jf.baksmali.Adaptors.MethodDefinition.writeTo(MethodDefinition.java:238)
at org.jf.baksmali.Adaptors.ClassDefinition.writeVirtualMethods(ClassDefinition.java:326)
at org.jf.baksmali.Adaptors.ClassDefinition.writeTo(ClassDefinition.java:112)
at org.jf.baksmali.Baksmali.disassembleClass(Baksmali.java:152)
at org.jf.baksmali.Baksmali.access$000(Baksmali.java:46)
at org.jf.baksmali.Baksmali$1.call(Baksmali.java:76)
at org.jf.baksmali.Baksmali$1.call(Baksmali.java:74)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
opcode: invoke-virtual-quick
code address: 5
method: Landroid/support/v7/widget/MenuPopupWindow;->createDropDownListView(Landroid/content/Context;Z)Landroid/support/v7/widget/DropDownListView;
After many trials and errors, and realized that the methods are from the android SDK, I copied the android
folder from one of the previous challenges and use it with baksmali to deodex the file, hoping that it would work, and it did. I was able to pull out the main logic from the application and that was really fortunate.
1
2
3
4
5
mkdir framework
mv android framework # This is the android folder when you use apktool to unpack the apk from previous challenges
java -jar smali.jar ass -o framework.dex framework
java -Xmx512m -jar baksmali.jar x -c framework.dex base.odex -o chal3
java -jar ../smali.jar ass chal3 -o chal3.dex
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
package com.hackerone.mobile.challenge3;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.text.Editable;
import android.text.TextWatcher;
import android.widget.EditText;
public class MainActivity extends AppCompatActivity {
private static char[] key = new char[]{'t', 'h', 'i', 's', '_', 'i', 's', '_', 'a', '_', 'k', '3', 'y'};
private EditText editText;
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView((int) C0225R.layout.activity_main);
final EditText editText = (EditText) findViewById(C0225R.id.editText);
editText.addTextChangedListener(new TextWatcher() {
public void afterTextChanged(Editable editable) {
}
public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) {
}
public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) {
MainActivity.checkFlag(editText.getText().toString());
}
});
}
public static byte[] hexStringToByteArray(String str) {
int length = str.length();
byte[] bArr = new byte[(length / 2)];
for (int i = 0; i < length; i += 2) {
bArr[i / 2] = (byte) ((Character.digit(str.charAt(i), 16) << 4) + Character.digit(str.charAt(i + 1), 16));
}
return bArr;
}
public static boolean checkFlag(String str) {
if (str.length() == 0) {
return false;
}
String str2 = "flag{";
if ((str.length() > str2.length() && !str.substring(0, str2.length()).equals(str2)) || str.charAt(str.length() - 1) != '}') {
return false;
}
String encryptDecrypt = encryptDecrypt(key, hexStringToByteArray(new StringBuilder("kO13t41Oc1b2z4F5F1b2BO33c2d1c61OzOdOtO").reverse().toString().replace("O", "0").replace("t", "7").replace("B", "8").replace("z", "a").replace("F", "f").replace("k", "e")));
if (str.length() <= str.length() || str.substring(str2.length(), str.length() - 1).equals(encryptDecrypt) != null) {
return true;
}
return false;
}
private static String encryptDecrypt(char[] cArr, byte[] bArr) {
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < bArr.length; i++) {
stringBuilder.append((char) (bArr[i] ^ cArr[i % cArr.length]));
}
return stringBuilder.toString();
}
}
The logic is pretty straightforward. I wrote a python script to solve the challenge.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def main():
key = ['t', 'h', 'i', 's', '_', 'i', 's', '_', 'a', '_', 'k', '3', 'y']
key = ''.join(key)
print "key = {}".format(key)
s = "kO13t41Oc1b2z4F5F1b2BO33c2d1c61OzOdOtO"
s = s[::-1]
s = s.replace("O", "0").replace("t", "7").replace("B", "8").replace("z", "a").replace("F", "f").replace("k", "e")
print "s = {}".format(s)
s = s.decode('hex')
print "hex decoded s = {}".format(s)
key = (key * 10)[:len(s)]
print ''.join([chr(ord(x[0]) ^ ord(x[1])) for x in zip(s, key)])
if __name__ == '__main__':
main()
The flag is flag{secr3t_littl3_th4ng}
.
Mobile Challenge 4
This is probably my favorite one because I learned so much more in this challenge, from writing my first android app to learning about vulnerabilities that an application can introduce.
At first, I didn’t know where to start because I have not tried to pwn an apk application before. Looking at tools to scan for vulnerabilities in APKs, I found QARK, a pretty neat tool from LinkedIn that can be used to quickly scan for common vulnerabilities in an android application, using mostly static analysis.
At the same time, I read the setup instruction and kinda had a sense of what I have to do:
- The flag file is at
/data/local/tmp/challenge4
- The owner of the file is root, the owner group is the same group as the vulnerable application.
Since we have to exploit the application, my guess was that we need to somehow use the vulnerable app’s permission to read the flag file. When the scan from QARK finish, I skimmed through the report and noticed this
1
2
3
4
INFO - Be careful with use of Check permission function
App maybe vulnerable to Privilege escalation or Confused Deputy Attack. This function can grant access to malicious application, lacking the appropriate permission, by assuming your applications permissions. This means a malicious application, without appropriate permissions, can bypass its permission check by using your applicationpermission to get access to otherwise denied resources. Use - checkCallingPermission instead.
Filepath: /home/me/Desktop/challenge4_release/classes_dex2jar/android/support/v4/app/NotificationCompatSideChannelService.java
Reference: https://developer.android.com/reference/android/content/Context.html#checkCallingOrSelfPermission(java.lang.String)
And also 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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
==>EXPORTED ACTIVITIES:
0: com.hackerone.mobile.challenge4.MenuActivity
INFO - Checking for extras in this file: com.hackerone.mobile.challenge4.MenuActivity from this entry point: onCreate
ERROR - Could not create a tree to find extras in : /home/me/Desktop/challenge4_release/classes_dex2jar/com/hackerone/mobile/challenge4/MenuActivity.java
INFO - Attempting fall-back method to determine extras
INFO - Checking for extras in this file: com.hackerone.mobile.challenge4.MenuActivity from this entry point: onStart
ERROR - Could not create a tree to find extras in : /home/me/Desktop/challenge4_release/classes_dex2jar/com/hackerone/mobile/challenge4/MenuActivity.java
INFO - Attempting fall-back method to determine extras
adb shell am start -a "android.intent.action.MAIN" -n "com.hackerone.mobile.challenge4/com.hackerone.mobile.challenge4.MenuActivity"
==>EXPORTED RECEIVERS:
0: com.hackerone.mobile.challenge4.MazeMover
INFO - Checking for extras in this file: com.hackerone.mobile.challenge4.MazeMover from this entry point: onReceive
INFO - Possible Extra: localObject of unknown type
INFO - Possible Extra: "cereal" of type: Serializable
INFO - Extra: localObject is not a simple type, or could not be determined. You'll need to append the parameter which corresponds with the correct data type, followed by a key and value, both in quotes.
Example: adb shell am broadcast -a "com.hackerone.mobile.challenge4.broadcast.MAZE_MOVER" --es "YOURKEYHERE" "YOURVALUEHERE"
Here are your options for different data types:
[-e|--es <EXTRA_KEY> <EXTRA_STRING_VALUE> ...]
[--esn <EXTRA_KEY> ...]
[--ez <EXTRA_KEY> <EXTRA_BOOLEAN_VALUE> ...]
[--ei <EXTRA_KEY> <EXTRA_INT_VALUE> ...]
[--el <EXTRA_KEY> <EXTRA_LONG_VALUE> ...]
[--ef <EXTRA_KEY> <EXTRA_FLOAT_VALUE> ...]
[--eu <EXTRA_KEY> <EXTRA_URI_VALUE> ...]
[--ecn <EXTRA_KEY> <EXTRA_COMPONENT_NAME_VALUE>]
[--eia <EXTRA_KEY> <EXTRA_INT_VALUE>[,<EXTRA_INT_VALUE...]]
[--ela <EXTRA_KEY> <EXTRA_LONG_VALUE>[,<EXTRA_LONG_VALUE...]]
[--efa <EXTRA_KEY> <EXTRA_FLOAT_VALUE>[,<EXTRA_FLOAT_VALUE...]]
[--esa <EXTRA_KEY> <EXTRA_STRING_VALUE>[,<EXTRA_STRING_VALUE...]]
INFO - Extra: "cereal" is not a simple type, or could not be determined. You'll need to append the parameter which corresponds with the correct data type, followed by a key and value, both in quotes.
Example: adb shell am broadcast -a "com.hackerone.mobile.challenge4.broadcast.MAZE_MOVER" --es "YOURKEYHERE" "YOURVALUEHERE"
Here are your options for different data types:
[-e|--es <EXTRA_KEY> <EXTRA_STRING_VALUE> ...]
[--esn <EXTRA_KEY> ...]
[--ez <EXTRA_KEY> <EXTRA_BOOLEAN_VALUE> ...]
[--ei <EXTRA_KEY> <EXTRA_INT_VALUE> ...]
[--el <EXTRA_KEY> <EXTRA_LONG_VALUE> ...]
[--ef <EXTRA_KEY> <EXTRA_FLOAT_VALUE> ...]
[--eu <EXTRA_KEY> <EXTRA_URI_VALUE> ...]
[--ecn <EXTRA_KEY> <EXTRA_COMPONENT_NAME_VALUE>]
[--eia <EXTRA_KEY> <EXTRA_INT_VALUE>[,<EXTRA_INT_VALUE...]]
[--ela <EXTRA_KEY> <EXTRA_LONG_VALUE>[,<EXTRA_L..m,mmONG_VALUE...]]
[--efa <EXTRA_KEY> <EXTRA_FLOAT_VALUE>[,<EXTRA_FLOAT_VALUE...]]
[--esa <EXTRA_KEY> <EXTRA_STRING_VALUE>[,<EXTRA_STRING_VALUE...]]
I did not know what this was at that moment. However, it seemed interesting because of the keywords like cereal
and also serializable objects, which I heard of vulnerabilities related to this but in PHP. Let’s open the app in jadx to investigate the sauce!
I opened up the class MazeMover
in the report and the code was quite interesting:
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
public class MazeMover {
public static void onReceive(Context context, Intent intent) {
if (MainActivity.getMazeView() == null) {
Log.i("MazeMover", "Not currently trying to solve the maze");
return;
}
GameManager gameManager = MainActivity.getMazeView().getGameManager();
Bundle extras = intent.getExtras();
if (extras != null) {
if (intent.hasExtra("get_maze")) {
intent = new Intent();
intent.putExtra("walls", gameManager.getMaze().getWalls());
Serializable arrayList = new ArrayList();
arrayList.add(Integer.valueOf(gameManager.getPlayer().getX()));
arrayList.add(Integer.valueOf(gameManager.getPlayer().getY()));
arrayList.add(Integer.valueOf(gameManager.getExit().getX()));
arrayList.add(Integer.valueOf(gameManager.getExit().getY()));
intent.putExtra("positions", arrayList);
intent.setAction("com.hackerone.mobile.challenge4.broadcast.MAZE_MOVER");
context.sendBroadcast(intent);
} else if (intent.hasExtra("move")) {
intent = extras.getChar("move");
int i = -1;
int i2 = 0;
switch (intent) {
case 104:
i2 = -1;
i = 0;
break;
case 106:
i = 1;
break;
case 107:
break;
case 108:
i = 0;
i2 = 1;
break;
default:
i = 0;
break;
}
intent = new Point(i2, i);
Intent intent2 = new Intent();
if (gameManager.movePlayer(intent) != null) {
intent2.putExtra("move_result", "good");
} else {
intent2.putExtra("move_result", "bad");
}
intent2.setAction("com.hackerone.mobile.challenge4.broadcast.MAZE_MOVER");
context.sendBroadcast(intent2);
} else if (intent.hasExtra("cereal")) {
((GameState) intent.getSerializableExtra("cereal")).initialize(context);
}
}
}
}
I would like to quickly explain some of the concepts that I’ve learned during the proccess of understanding this piece of code. There may be incorrect details because this is my first time attempting to do mobile hacking. I would appreciate any comments that would correct me.
Firstly, the way android applications are able to interact with each other is by broadcasting and listening for messages. Such messages are called Intents. Each intent needs to indicate an action that it wants to carry out, and there may be parameters to this actions. These parameters are called extras. Extras can be of many types, from String, Integer to Serializable objects.
Secondly, an app by declaring a broadcast receiver is able to listen to intents, and by declaring the intent filters, it can determine if the receiver will be processing the intent or not, based on the action in the intent.
Going back to the piece of code above, what it does is defining an event handler when an intent is received.
- If an intent with an extra key
get_maze
is received, broadcast an intent with the information of the current maze view including the positions of the player and the exit, the walls positions - If an intent with an extra key
move
is received, get the move value as a character and attempt to move the player accordingly, then broadcast the result of the move - If an intent with an extra key
cereal
is received, get the serializable object in the value, cast it toGameState
type and then call theinitialize
method on the current context
After understanding intents and stuffs, I found out another broadcast receiver was declared in the MenuActivity to start the game.
With the knowledge of all the broadcast receivers, we know that we can interact with the game play and also execute codes in the applicatioin by passing a serializable object to the intent. The code execution, however, is really limited, but I was determined that this is the way to get the flag and decided to investigate the code paths leading to and from the cereal
intent extra.
Let’s investigate the code of the GameState
class:
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
package com.hackerone.mobile.challenge4;
import android.content.Context;
import android.util.Log;
import java.io.Serializable;
public class GameState implements Serializable {
private static final long serialVersionUID = 1;
public String cleanupTag;
private Context context;
public int levelsCompleted;
public int playerX;
public int playerY;
public long seed;
public StateController stateController;
public GameState(int i, int i2, long j, int i3) {
this.playerX = i;
this.playerY = i2;
this.seed = j;
this.levelsCompleted = i3;
}
public GameState(String str, StateController stateController) {
this.cleanupTag = str;
this.stateController = stateController;
}
public void initialize(Context context) {
this.context = context;
GameState gameState = (GameState) this.stateController.load(context);
if (gameState != null) {
this.playerX = gameState.playerX;
this.playerY = gameState.playerY;
this.seed = gameState.seed;
this.levelsCompleted = gameState.levelsCompleted;
}
}
public void finalize() {
Log.d("GameState", "Called finalize on GameState");
if (GameManager.levelsCompleted > 2 && this.context != null) {
this.stateController.save(this.context, this);
}
}
}
There are several interesting details here:
- Most of the fields are not interesting, except for the
stateController
one. - The
stateController
is used to call its method in the initialize function - There is a finialize method, in which the
stateController
is being used again after some conditions are satisfied:- More than 2 levels are completed
- The current context is not null
- There are 2 constructors of this class. In one of them, we can initialize the
stateController
field.
I smell some more code execution
Keep investigating other classes, I found out that there are 2 subclasses extending the StateController
class: StateLoader
and BroadcastAnnouncer
. At this point, I kinda have an idea in mind to test out the code execution by initialize a GameState instance with a StateController, in which the StateController is also initialized with some field that ressembles a file location. However, there are several differences between the 2 subclasses.
- In the
load
method,StateLoader
uses thelocation
field in the super class to open a file, and read an object from the file. TheBroadcastAnnouncer
also opens a file but reads in strings from that file. The string from the file is used to make HTTP requests in other methods in the class. - The
save
method inStateLoader
write objects to file and it makes HTTP requests mentioned before inBroadcastAnnoucner
. The destination of the HTTP requests and the file location are all user controllable.
At this point, putting all we have together, we can plan out an exploit:
- Instantiate a BroadcastAnnouncer with the flag file location and a destination to a web server that we control. The reason for this is quite clear because the data type that
BroadcastAnnouncer
deals with is the same as it is in the flag file, which is string.StateLoader
, however, deals with serializable objects. - Instantiate a GameState with the BroadcastAnnouncer above.
- Send an intent to the vulnerable app with the
cereal
extra and theGameState
object. - Somehow trigger the save method in the StateController.
At the last step, going back to the finalize
method in GameState
, we can see that the save
method is called here, but only after satisfying some conditions. But when is the finalize
method being called?
I at that point did not notice the finalize method. I crafted the exploit, play some levels in the game and trigger the exploit. It was working inconsistently but it yeilded the flag! Later on, I did some search and figured out that the finalize
method is called by the Garbage Collector of JVM, when there is no reference to the instance anymore. This is quite interesting, but that’s all I know. My guess for why it works is that when we sent the object in the intent, it was instantiated but not being used anywhere else in the code and got garbage collected eventually, triggering the finalize
method.
Now the only problem is that we have to solve more than 2 levels to trigger the code path to the save
method. We can interact with the game, start it, move the player, get the maze state. A simple algorithm that would solve the maze is Depth-First Search.
The source code of my exploit app can be found here. I would like to explain some of the stuffs that I did in the exploit.
While I was playing with broadcasting intents, from the adb shell or from an APK, I encounter this warning BroadcastQueue: Background execution not allowed: receiving Intent...
, which did not call the onReceive
handler and the code did not executed accordingly. This is due to the background process limitation starting from Android O. I found a blog post that describe the workaround here
Basically, to work around this, I both had to register a receiver in my MainActivity and define the targetSdkVersion
of 25. I am still not sure if that totally solves the problem or not because broadcasting intents still doesn’t work sometimes, but it was a lot better than before.
This problem and also the asynchronous broadcasts is why I had to delay the broadcasts sending from my application. It’s kinda a workaround so that codes can run synchronously as intended. If you have any questions about my code, please feel free to leave a comments or hit me up on any of my social networks. I am willing to discuss the matters with you. The code at the time of writing this blog is still messy, I will try to clean it up asap!
After tweaking the exploit several times, I sent the APK to @breadchris. It was night time at my place so I went to bed. The next day, my exploit was run and I couldn’t be happier when I saw the flag in my web server log.
pwned! The flag is flag{my_favorite_cereal_and_mazes}
.
Thanks for a great CTF @Hackerone!