6. 应用沙箱(一):沙箱设计原理、UID隔离机制、进程沙箱化

好,咱们今天聊聊应用沙箱。说实话,这是Android安全体系里最基础、也最容易被忽视的一环。很多人一上来就研究SELinux策略怎么写,结果连沙箱隔离的基本原理都没搞透——嗯,我当年也犯过这个错。

沙箱是什么?说白了,就是给每个App画个圈。圈里你随便折腾,但想出圈?门儿都没有。这个设计思路其实很朴素:默认不信任任何应用。你想想看,一个从应用商店下载的计算器App,凭什么能读取你的通讯录?

沙箱设计原理:从零信任开始

Android的沙箱设计,核心就一句话:每个应用运行在独立的进程中,拥有独立的身份标识。这个身份标识就是UID(User ID)。

我记得刚接触Android安全时,有个问题困扰了我很久:为什么Linux系统里普通用户UID从1000开始,而Android的应用UID从10000开始?后来翻了源码才明白——Android把Linux的"用户"概念重新定义了。在Android眼里,每个应用就是一个"用户"

核心设计原则:

  • 进程隔离:每个App运行在独立的Linux进程中
  • UID隔离:每个App分配唯一的UID
  • 权限最小化:默认只拥有自身文件目录的访问权限
  • 资源隔离:内存、文件系统、网络等资源相互隔离

这里有个关键点:Android的沙箱不是"附加"的安全机制,而是操作系统级别的强制隔离。它利用的是Linux内核本身就有的进程隔离能力。说白了,Android只是把Linux的用户管理机制拿来,做了一层巧妙的映射。

UID隔离机制:每个App都是"独立用户"

咱们来看看UID到底是怎么分配的。我直接贴一段关键代码,这是PackageManagerService里处理UID分配的逻辑:

// 来自 frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java
private int allocateUid(PackageSetting pkgSetting) {
    // 应用UID从10000开始
    final int uid;
    if (pkgSetting.isSharedUser()) {
        // 共享UID的情况,比如系统应用
        uid = pkgSetting.getSharedUser().userId;
    } else {
        // 普通应用,分配独立UID
        uid = pkgSetting.getAppId() + Process.FIRST_APPLICATION_UID;
    }
    return uid;
}

看到没?FIRST_APPLICATION_UID的值就是10000。这意味着你手机上第一个安装的App,UID是10000;第二个是10001,依此类推。

那系统应用呢?它们的UID范围是0到9999。比如:

UID范围 用途 示例
0 root用户 init进程、内核线程
1000 system用户 系统服务
1001-9999 系统应用 电话、短信、设置
10000+ 第三方应用 微信、支付宝、抖音

避坑指南:我曾经在分析一个系统应用权限问题时,死活找不到问题原因。后来发现那个应用的UID是10000——它被错误地当成了普通应用来安装。记住:系统应用的UID必须在10000以下,否则很多系统级权限会失效。

UID隔离带来的直接效果是什么?两个App之间默认无法互相访问数据。你微信想读支付宝的数据库?Linux内核直接拒绝,因为UID不同,文件权限不匹配。

进程沙箱化:从fork到隔离

好,现在咱们聊聊进程是怎么被"沙箱化"的。这个过程,我建议你重点关注Zygote进程——它是所有应用进程的父进程。

当系统要启动一个App时,流程是这样的:

  1. Zygote进程收到启动请求
  2. Zygote fork自身,创建子进程
  3. 子进程立即设置UID/GID为目标的App UID
  4. 子进程设置进程的supplementary groups
  5. 子进程设置SELinux上下文
  6. 子进程加载App的代码并执行

关键在第3步。我贴一段Zygote进程设置UID的代码:

// 来自 frameworks/base/core/java/com/android/internal/os/Zygote.java
private static void setUidGid(int uid, int gid, int[] supplementaryGroups) {
    try {
        // 设置进程的UID和GID
        Os.setregid(gid, gid);
        Os.setreuid(uid, uid);
        
        // 设置附加组
        if (supplementaryGroups != null && supplementaryGroups.length > 0) {
            Os.setgroups(supplementaryGroups);
        }
    } catch (ErrnoException ex) {
        throw new RuntimeException("Failed to set UID/GID", ex);
    }
}

这里有个细节:setreuidsetregid调用之后,进程的UID就永久固定了。子进程没有权限再改回root或system。这就是权限降级——Zygote以root身份fork,但子进程立即降级为普通应用权限。

注意:这里有个安全陷阱。Zygote fork时,子进程会继承父进程的文件描述符。如果Zygote打开了一个系统敏感文件,子进程理论上也能访问。所以Android在fork之后、设置UID之前,会主动关闭所有不必要的文件描述符。这个细节,我在做漏洞分析时遇到过不止一次。

沙箱的边界:什么能做什么不能做

咱们用一张图来总结沙箱的隔离边界。我画了个示意图,帮你理清思路:

Android 应用沙箱隔离边界 App A 沙箱 UID: 10048 进程: com.example.appA 数据目录: /data/data/com.example.appA/ 权限: 仅自身文件 + 已授权权限 App B 沙箱 UID: 10049 进程: com.example.appB 数据目录: /data/data/com.example.appB/ 权限: 仅自身文件 + 已授权权限 隔离 无法互访 Linux 内核强制隔离:不同 UID 的进程无法访问对方的文件、内存和 IPC 资源

从图上可以看得很清楚:App A和App B各自有独立的进程、独立的数据目录、独立的UID。它们之间有一道"墙"——这道墙就是Linux内核的UID隔离机制。

那App之间怎么通信呢?只能通过Android系统提供的IPC机制:Binder、ContentProvider、BroadcastReceiver等。这些通道都受权限系统管控,不是你想发消息就能发的。

沙箱的局限性

说实话,沙箱不是万能的。我遇到过几个典型的绕过场景:

  • 共享UID:如果两个App用同一个签名,可以声明sharedUserId,它们就共享同一个UID。这意味着它们可以互相访问数据。嗯,这算是有意为之的"开后门"。
  • 内核漏洞:沙箱依赖Linux内核的隔离能力。如果内核有漏洞(比如脏牛漏洞),攻击者可以直接提权,绕过所有沙箱限制。
  • 文件权限配置错误:有些App把数据文件设成了world-readable,这就等于自己拆了沙箱的墙。

我的建议:做安全审计时,第一件事就是检查App的数据目录权限。用adb shell ls -l /data/data/包名/看一眼,如果发现drwxrwxrwx或者-rw-rw-rw-,基本可以判定这个App有安全问题了。

小结

应用沙箱是Android安全的第一道防线。它利用Linux内核的UID隔离机制,让每个App运行在独立的"小房间"里。这个设计虽然简单,但非常有效——你想想看,如果没有沙箱,一个恶意App就能随意读取所有其他App的数据,那后果不堪设想。

我个人觉得,理解沙箱原理是学习Android安全的基础。你只有知道"墙"在哪里,才能理解为什么需要权限系统、为什么需要SELinux来做更细粒度的控制。这些内容,咱们后面会一步步展开。


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