7. 应用沙箱(二):文件系统沙箱、数据目录隔离、外部存储访问控制

好,我们接着聊沙箱。上一章我们把沙箱的进程隔离讲透了,这一章咱们聚焦在文件系统层面。说白了,就是App在手机上能读写哪些文件,不能碰哪些文件。这个事儿,我当年刚接触Android时也踩过坑,以为权限搞定了就万事大吉,后来才发现——文件系统沙箱才是真正把App关进笼子的那道铁门。

7.1 文件系统沙箱:每个App的独立小世界

Android系统为每个应用分配一个独立的Linux用户ID(UID),并在/data/data/(Android 10以下)或/data/user/0/(Android 10+)下创建专属目录。这个目录,只有该UID的进程能访问。

你想想看,这意味着什么?App A想读App B的数据库?门儿都没有。哪怕App B自己不小心把数据库权限设成0666,Linux内核也会直接拒绝——因为UID不对。

核心机制: 文件系统沙箱 = UID隔离 + 目录权限控制 + 内核强制访问检查

我记得有一次做安全审计,发现某个预装应用把日志文件写到了/sdcard/下,还抱怨说“我自己的日志为什么别的App也能读”。这就是典型的没搞懂沙箱边界——内部存储是私有的,外部存储才是共享的。

7.2 数据目录隔离:内部存储的权限模型

每个应用安装时,系统会执行以下操作:

  1. 分配一个唯一的UID
  2. /data/data/<package_name>创建目录
  3. 设置目录所有者为此UID,权限为rwx------(即0700
  4. 挂载一个专用的命名空间(namespace),隔离/proc/sys等敏感路径

来看一个实际的目录结构:

/data/data/com.example.app/
├── databases/          # 数据库文件
├── files/              # 普通文件存储
├── shared_prefs/       # SharedPreferences
├── cache/              # 缓存文件
├── code_cache/         # 代码缓存
└── lib/                # native库(只读)

每个子目录的权限也很有讲究:

目录 默认权限 说明
databases/ rwx------ 只有本应用可读写
files/ rwx------ 同上,但可通过MODE_WORLD_READABLE放开(不推荐)
cache/ rwx------ 系统可随时清理
lib/ r-x------ 只读,防止篡改native库
注意: Android 10(API 29)开始,MODE_WORLD_READABLEMODE_WORLD_WRITEABLE已经被废弃。如果你还在用这两个flag,赶紧改掉——它们在新版本上会直接抛异常。

我曾经在一个项目中看到,某团队为了“方便”把数据库文件设成了MODE_WORLD_READABLE,结果被另一个恶意App把用户聊天记录全拖走了。嗯,这种坑我建议你千万别踩。

7.3 外部存储访问控制:从混乱到规范

外部存储(也就是/sdcard/storage/emulated/0)一直是Android安全的老大难问题。早期版本基本是“谁都能读”,后来才一步步收紧。

7.3.1 传统模式(Android 10之前)

应用只要声明READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE权限,就能读写整个外部存储。说白了,就是一把钥匙开所有门。

这带来的问题很明显:

  • App可以扫描你的照片、下载文件、音乐库
  • 恶意App可以偷偷读取其他App的缓存数据
  • 用户隐私几乎不设防

7.3.2 分区存储(Android 10+)

Google终于出手了。分区存储(Scoped Storage)把外部存储分成了几个区域:

/storage/emulated/0/
├── Android/
│   ├── data/          # 每个App的私有外部目录
│   │   └── com.example.app/
│   └── obb/           # 扩展包文件
├── DCIM/              # 照片(需特殊权限)
├── Download/          # 下载(需特殊权限)
├── Pictures/          # 图片(需特殊权限)
└── ...                # 其他公共目录

关键变化有三点:

  1. 私有目录Android/data/<package>只有本应用能访问,其他App不能读
  2. 公共目录:访问DCIMDownload等需要用户明确授权,且只能通过MediaStore API
  3. 直接路径访问:不再允许通过/sdcard/DCIM/xxx.jpg直接读写,必须用Content URI
我的建议: 如果你的应用需要访问公共目录,尽早迁移到MediaStore API。我去年帮一个客户迁移,他们之前用File API直接写路径,Android 10一上线就崩了。迁移其实不复杂,但拖到最后一刻会很被动。

7.3.3 Android 11的进一步收紧

Android 11又加了一道锁:即使你声明了权限,也不能直接访问其他应用的Android/data/目录。系统会直接返回空结果。

来看一个代码对比:

// ❌ 旧方式(Android 10以下可用)
File file = new File("/sdcard/DCIM/photo.jpg");
FileInputStream fis = new FileInputStream(file);

// ✅ 新方式(Android 10+推荐)
ContentResolver resolver = getContentResolver();
Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
Cursor cursor = resolver.query(uri, null, null, null, null);
// 通过cursor获取具体文件的Uri

说实话,这个改动让很多开发者骂娘。但从安全角度看,这是正确的——用户的数据,凭什么让App随便翻?

7.4 沙箱逃逸的常见手法与防御

讲完了规则,咱们聊聊怎么绕过规则。我见过几种典型的沙箱逃逸方式:

逃逸方式 原理 防御措施
符号链接攻击 在公共目录创建指向私有数据的符号链接 系统在Android 10+已禁止跨UID符号链接
ptrace注入 通过调试接口读写其他进程内存 Android 10+默认禁用ptrace系统调用
文件描述符传递 通过Binder传递FD绕过权限检查 SELinux策略限制FD传递范围
Zygote注入 在应用启动前修改其文件访问权限 SELinux + 文件系统挂载选项双重保护
避坑指南: 我曾经在分析一个恶意样本时发现,它通过/proc/<pid>/fd/读取其他App打开的文件描述符。这个路径在Android 11上已经被SELinux严格限制了,但如果你还在用Android 9或更低版本,这个漏洞依然存在。升级系统版本是最直接的防御手段。

7.5 文件系统沙箱的完整架构

来,我画了一张图,把整个文件系统沙箱的架构串起来:

文件系统沙箱架构图 App A (UID=10001) App B (UID=10002) App C (UID=10003) /data/data/com.app.a/ 权限: rwx------ 仅UID=10001可访问 /data/data/com.app.b/ 权限: rwx------ 仅UID=10002可访问 /data/data/com.app.c/ 权限: rwx------ 仅UID=10003可访问 ❌ 被阻止 外部存储 /storage/emulated/0/ Android/data/ DCIM/ Download/ Pictures/ 访问公共目录需用户授权 + MediaStore API 内部存储(私有) 外部存储(共享)

从这张图可以看得很清楚:内部存储是每个App的独立小房间,外部存储是公共客厅。小房间只有主人能进,客厅虽然大家都能进,但每个柜子(目录)也有自己的锁。

7.6 总结几个关键点

  • UID是沙箱的根基——没有UID隔离,文件权限就是空谈
  • 内部存储默认完全隔离——除非你主动用MODE_WORLD_READABLE作死
  • 外部存储正在走向“分区化”——Android 10是分水岭,Android 11是进一步收紧
  • SELinux是最后一道防线——即使UID和权限都对了,SELinux还能再拦一道

嗯,文件系统沙箱这块儿,说白了就是“你的数据放你家,我的数据放我家,公共区域大家守规矩”。理解了这一点,后面讲SELinux策略时你会觉得更顺。


公众号:蓝海资料掘金营,微信deep3321