ContentProvider 路径穿越风险分析

28 阅读3分钟

一、场景介绍

ContentProvider 是 Android 系统中用于跨应用数据共享的核心组件。

当应用需要对外共享文件时,通常会通过 ContentProvider 或 FileProvider 提供文件访问能力。

在 ContentProvider 中,系统定义了一个用于文件访问的标准接口:

public ParcelFileDescriptor openFile(
        Uri uri,
        String mode,
        CancellationSignal signal
)

如果 应用导出的 ContentProvider 或 FileProvider 重写(override)了 openFile 方法, 并且 未对外部传入的 Uri 路径进行严格校验,则可能导致路径穿越(Path Traversal)漏洞,进而造成应用私有文件泄露

二、风险边界说明(重要)

✅ 安全情况(无风险)

  • 自定义的 ContentProvider / FileProvider 未重写 openFile() 方法*直接使用系统原生实现 👉 系统原生 ContentProvider / FileProvider 的 openFile 实现本身是安全的

此情况下不存在路径穿越风险。


❌ 高风险情况

  • 自定义 ContentProvider / FileProvider,且重写了 openFile() 方法,且实现方式不当(未校验路径合法性)

三、风险分析与典型漏洞案例

案例 1:直接使用****uri.getPath()拼接文件路径(最常见)

public class InsecureFileProvider extends ContentProvider {

    @Override
    public ParcelFileDescriptor openFile(
            @NonNull Uri uri,
            @NonNull String mode
    ) throws FileNotFoundException {

        // ❌ 问题 1:未校验调用方权限
        // ❌ 问题 2:直接使用 uri.getPath() 构造 File
        File file = new File(uri.getPath());

        int fileMode = ParcelFileDescriptor.MODE_READ_ONLY;
        if ("w".equals(mode) || "wt".equals(mode)) {
            fileMode = ParcelFileDescriptor.MODE_READ_WRITE
                    | ParcelFileDescriptor.MODE_CREATE
                    | ParcelFileDescriptor.MODE_TRUNCATE;
        }

        return ParcelFileDescriptor.open(file, fileMode);
    }
}

攻击者可构造如下恶意 URI:content://com.demo.provider/../../data/data/com.demo.app/shared_prefs/token.xml 由于 uri.getPath() 返回的是未经校验的路径字符串,最终可能导致读取应用私有目录下的敏感文件。

案例 2:拼接“根目录 + 相对路径”,但未做 canonical 校验

public class InsecureRootFileProvider extends ContentProvider {

    private File baseDir;

    @Override
    public boolean onCreate() {
        // baseDir = /data/user/0/com.demo.app/files/export
        baseDir = new File(getContext().getFilesDir(), "export");
        return true;
    }

    @Override
    public ParcelFileDescriptor openFile(
            @NonNull Uri uri,
            @NonNull String mode
    ) throws FileNotFoundException {

        // ❌ 直接使用 getLastPathSegment,未做 canonicalPath 校验
        String relativePath = uri.getLastPathSegment();
        File target = new File(baseDir, relativePath);

        return ParcelFileDescriptor.open(
                target,
                ParcelFileDescriptor.MODE_READ_ONLY
        );
    }
}

这里面, getLastPathSegment()存在安全陷阱。

getLastPathSegment() 的内部处理逻辑是:

  1. 先按 / 分段(此时尚未 URL Decode)****
  2. 对每个分段做 URL 解码****
  3. 返回最后一个分段作为文件名

如果攻击者将 / 编码为 %2F,就可以绕过分段逻辑


攻击构造示例

1)攻击者构造整体编码路径 %2F..%2F..%2Fdata%2Fdata%2Fcom.demo.app%2Fshared_prefs%2Ftoken.xml

2)getPathSegments()处理过程

  • 原始 path:%2F..%2F..%2Fdata%2Fdata%2Fcom.demo.app%2Fshared_prefs%2Ftoken.xml
  • 按 / 分段:因为没有真实的 /,只得到 一个 segment
  • URL Decode 后/../../data/data/com.demo.app/shared_prefs/token.xml

3)最终路径拼接

File target = new File(
    "/data/user/0/com.demo.app/files/export",
    "/../../data/data/com.demo.app/shared_prefs/token.xml"
);

Java File 语义说明

  • 如果第二个参数 / 开头 → 被视为绝对路径****
  • 前面的 baseDir 会被完全忽略****
  • 即使不是 / 开头,../ 也会在 canonical 化过程中跳出 baseDir

最终导致访问:/data/data/com.demo.app/shared_prefs/token.xml,应用私有文件泄露

五、安全建议(强烈建议)

1️⃣ 权限层面

  • 尽量对 ContentProvider / FileProvider 设置访问权限
  • 避免导出为任意应用可访问
  • 优先使用 signature 或显式授权

2️⃣openFile()实现层面(核心)

无论 Uri 如何构造,最终访问文件的 canonicalPath 必须受控

安全原则:

实际访问路径(canonicalPath)必须位于允许的根目录(baseDir)之下****


案例 1 的安全写法

File baseDir = new File(getContext().getFilesDir(), "export");

File baseDir = new File(getContext().getFilesDir(), "export");

String path = uri.getPath();
if (path.startsWith("/")) {
    path = path.substring(1);
}

File target = new File(baseDir, path);

// 核心校验:canonicalPath 必须在 baseDir 内
String base = baseDir.getCanonicalPath();
String real = target.getCanonicalPath();
if (!real.startsWith(base + File.separator)) {
    throw new SecurityException("Path traversal detected");
}

return ParcelFileDescriptor.open(
        target,
        ParcelFileDescriptor.MODE_READ_ONLY
);

案例 2 的安全写法

File baseDir = new File(getContext().getFilesDir(), "export");

String path = uri.getLastPathSegment();
if (path == null) {
    throw new FileNotFoundException();
}

File target = new File(baseDir, path);

// 核心校验:canonicalPath 必须在 baseDir 内
String base = baseDir.getCanonicalPath();
String real = target.getCanonicalPath();
if (!real.startsWith(base + File.separator)) {
    throw new SecurityException("Path traversal detected");
}

return ParcelFileDescriptor.open(
        target,
        ParcelFileDescriptor.MODE_READ_ONLY
);

六、风险排查建议

  1. 在 AndroidManifest.xml 中定位自定义 provider

  2. 找到对应的实现类

  3. 检查是否重写了 openFile() 方法

    • ❌ 未重写:不存在路径穿越风险****
    • ⚠️ 已重写:重点审计路径拼接与 canonical 校验逻辑
  4. 对照本文风险分析逐项核查


七、参考资料