摘要
2024年5月1日,微软公开其最新研究,其发现Android系统提供的ContentProvider能力,如果App对其使用不当,会导致App产生严重的安全漏洞。同时,微软在小米文件管理器和WPS Office上都发现存在该漏洞(目前这些App的最新版本都已修复,小米文件管理器为V1-210593版本,WPS Office为17.0.0版本)。 因此我们需要也检查一下自己的Android App,是否也存在类似潜在安全风险,及时进行漏洞修复。
省流版
如果你不想看后面关于漏洞的前因后果内容,可以直接按照下面步骤处理,即可修复漏洞
- 检查你的App是否有提供外部共享文件给自己的功能,比如小米文件管理器提供的文件复制分享功能。如果有,继续下面步骤2
- 检查你的App在处理外部共享文件传递的URI时,是否直接采用了外部的文件名保存到App内部。如果有,继续下面步骤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,以覆盖客户端应用程序特定于应用程序的存储中的文件。
如果攻击者可以覆盖应用程序的文件,则可能导致恶意代码执行(通过覆盖应用程序的代码),或允许以其他方式修改应用程序的行为(例如,通过覆盖应用程序的共享首选项或其他配置文件)。
简单说,由于恶意程序控制共享文件的名称和内容,因此通过盲目信任此输入,受害者可能会将该共享文件覆盖其私有数据空间中的关键文件,这可能会导致严重后果。
漏洞修复
方案一:不信任外部输入(推荐方案)
在使用文件系统调用时,最好在将收到的文件写入存储时生成唯一的文件名,从而在没有用户输入的情况下工作。
换言之:当客户端应用程序将接收到的文件写入存储时,它应该忽略服务器应用程序提供的文件名,而是使用其内部生成的唯一标识符作为文件名。
此示例基于上面访问请求的文件中的示例代码继续构建:
// 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