针对Android App“Dirty stream”安全漏洞的解决方案,国内大厂已中招

2,479 阅读6分钟

摘要

2024年5月1日,微软公开其最新研究,其发现Android系统提供的ContentProvider能力,如果App对其使用不当,会导致App产生严重的安全漏洞。同时,微软在小米文件管理器和WPS Office上都发现存在该漏洞(目前这些App的最新版本都已修复,小米文件管理器为V1-210593版本,WPS Office为17.0.0版本)。 因此我们需要也检查一下自己的Android App,是否也存在类似潜在安全风险,及时进行漏洞修复。

省流版

如果你不想看后面关于漏洞的前因后果内容,可以直接按照下面步骤处理,即可修复漏洞

  1. 检查你的App是否有提供外部共享文件给自己的功能,比如小米文件管理器提供的文件复制分享功能。如果有,继续下面步骤2
  2. 检查你的App在处理外部共享文件传递的URI时,是否直接采用了外部的文件名保存到App内部。如果有,继续下面步骤3
  3. 不再相信外部指定的共享文件名,生成自己的内部临时文件名将文件保存到App内部。修复完毕。

漏洞背景

首先我们一起了解下Android请求分享文件功能,“Dirty stream”漏洞就利用了该功能的实现机制。

Android请求分享文件

当应用想要访问其他应用共享的文件时,发出请求的应用(客户端)通常会向共享文件的应用(服务器)发送请求。在大多数情况下,请求会在服务器应用中启动一个 Activity,以显示可共享的文件。用户选择一个文件后,服务器应用会将文件的内容 URI 返回给客户端应用。

发送文件请求

如需从服务器应用请求文件,客户端应用需要使用 Intent(包含 ACTION_PICK 等操作以及客户端应用可以处理的 MIME 类型)调用 startActivityForResult

例如,以下代码段演示了如何将 Intent 发送到服务器应用,以启动共享文件中所述的 Activity

class MainActivity : Activity() {
    private lateinit var requestFileIntent: Intent
    private lateinit var inputPFD: ParcelFileDescriptor
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        requestFileIntent = Intent(Intent.ACTION_PICK).apply {
            type = "image/jpg"
        }
        ...
    }
    ...
    private fun requestFile() {
        /**
         * When the user requests a file, send an Intent to the
         * server app.
         * files.
         */
        startActivityForResult(requestFileIntent, 0)
        ...
    }
    ...
}

访问请求的文件

服务器应用通过 Intent 将文件的内容 URI 发回给客户端应用。此 Intent 会在其 onActivityResult() 替换中传递给客户端应用。客户端应用获得文件的内容 URI 后,即可通过获取其 FileDescriptor 来访问该文件。

由于客户端应用只会收到内容 URI 数据,因此在此过程中可确保文件安全性。由于此 URI 不包含目录路径,因此客户端应用无法在服务器应用中发现和打开任何其他文件。只有客户端应用可以访问该文件,且只能获取服务器应用授予的权限。这些权限是临时的,因此当客户端应用的任务堆栈完成后,将无法再从服务器应用外部访问该文件。

下一个代码段演示了客户端应用如何处理从服务器应用发送的 Intent,以及客户端应用如何使用内容 URI 获取 FileDescriptor

/*
 * When the Activity of the app that hosts files sets a result and calls
 * finish(), this method is invoked. The returned Intent contains the
 * content URI of a selected file. The result code indicates if the
 * selection worked or not.
 */
public override fun onActivityResult(requestCode: Int, resultCode: Int, returnIntent: Intent) {
    // If the selection didn't work
    if (resultCode != Activity.RESULT_OK) {
        // Exit without doing anything else
        return
    }
    // Get the file's content URI from the incoming Intent
    returnIntent.data?.also { returnUri ->
        /*
         * Try to open the file for "read" access using the
         * returned URI. If the file isn't found, write to the
         * error log and return.
         */
        inputPFD = try {
            /*
             * Get the content resolver instance for this context, and use it
             * to get a ParcelFileDescriptor for the file.
             */
            contentResolver.openFileDescriptor(returnUri, "r")
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
            Log.e("MainActivity", "File not found.")
            return
        }

        // Get a regular file descriptor for the file
        val fd = inputPFD.fileDescriptor
        ...
    }
}

openFileDescriptor() 方法会返回文件的 ParcelFileDescriptor。客户端应用从此对象获取 FileDescriptor 对象,然后可以使用该对象读取文件。

漏洞原因

不正确地信任 ContentProvider 提供的文件名

FileProvider 是 ContentProvider 的子类,旨在为应用程序(“服务器应用程序”)提供一种安全方法,以便与另一个应用程序(“客户端应用程序”)共享文件。但是,如果客户端应用程序未正确处理服务器应用程序提供的文件名,则攻击者控制的服务器应用程序可能能够实现自己的恶意 FileProvider,以覆盖客户端应用程序特定于应用程序的存储中的文件。

如果攻击者可以覆盖应用程序的文件,则可能导致恶意代码执行(通过覆盖应用程序的代码),或允许以其他方式修改应用程序的行为(例如,通过覆盖应用程序的共享首选项或其他配置文件)。

image.png

简单说,由于恶意程序控制共享文件的名称和内容,因此通过盲目信任此输入,受害者可能会将该共享文件覆盖其私有数据空间中的关键文件,这可能会导致严重后果。

漏洞修复

方案一:不信任外部输入(推荐方案)

在使用文件系统调用时,最好在将收到的文件写入存储时生成唯一的文件名,从而在没有用户输入的情况下工作。

换言之:当客户端应用程序将接收到的文件写入存储时,它应该忽略服务器应用程序提供的文件名,而是使用其内部生成的唯一标识符作为文件名。

此示例基于上面访问请求的文件中的示例代码继续构建:

// Code in
// https://developer.android.com/training/secure-file-sharing/request-file#OpenFile
// used to obtain file descriptor (fd)

try {
    val inputStream = FileInputStream(fd)
    val tempFile = File.createTempFile("temp", null, cacheDir)
    val outputStream = FileOutputStream(tempFile)
    val buf = ByteArray(1024)
    var len: Int
    len = inputStream.read(buf)
    while (len > 0) {
        if (len != -1) {
            outputStream.write(buf, 0, len)
            len = inputStream.read(buf)
        }
    }
    inputStream.close()
    outputStream.close()
} catch (e: IOException) {
    e.printStackTrace()
    Log.e("MainActivity", "File copy error.")
    return
}

方案二:清理提供的文件名(不推荐)

将收到的文件写入存储时,清理提供的文件名。

与前面的缓解措施相比,此缓解措施不太可取,因为处理所有潜在情况可能具有挑战性。尽管如此:如果生成唯一的文件名不切实际,客户端应用程序应清理提供的文件名。消毒包括:

  • 清理文件名中的路径遍历字符
  • 执行规范化以确认没有路径遍历

此示例代码基于有关检索文件信息的指南构建:

protected fun sanitizeFilename(displayName: String): String {
    val badCharacters = arrayOf("..", "/")
    val segments = displayName.split("/")
    var fileName = segments[segments.size - 1]
    for (suspString in badCharacters) {
        fileName = fileName.replace(suspString, "_")
    }
    return fileName
}

val displayName = returnCursor.getString(nameIndex)
val fileName = sanitizeFilename(displayName)
val filePath = File(context.filesDir, fileName).path

// saferOpenFile defined in Android developer documentation
val outputFile = saferOpenFile(filePath, context.filesDir.canonicalPath)

// fd obtained using Requesting a shared file from Android developer
// documentation

val inputStream = FileInputStream(fd)

// Copy the contents of the file to the new file
try {
    val outputStream = FileOutputStream(outputFile)
    val buffer = ByteArray(1024)
    var length: Int
    while (inputStream.read(buffer).also { length = it } > 0) {
        outputStream.write(buffer, 0, length)
    }
} catch (e: IOException) {
    // Handle exception
}

参考文章

1.微软披露漏洞细节,可以参考微软官网文章 # “Dirty stream” attack: Discovering and mitigating a common vulnerability pattern in Android apps

2.谷歌提供解决方案,可以参考谷歌开发者网站文章 Improperly trusting ContentProvider-provided filename

3.谷歌关于请求分享的文件的教程,可以参考谷歌开发者网站文章Requesting a shared file

4.谷歌关于分享文件的教程,可以参考谷歌开发者网站文章Sharing a file