开头:Android 安全问题的关键,远不止一个"root"
在 Android 开发和安全分析里,你一定见过类似的现象:明明拿着 root 权限,执行某个系统调用还是被扔回一个 "Operation not permitted";又或者某个 native 服务在低版本上跑得好好的,一升级到高版本就直接启动失败。这类问题背后,十有八九都指向同一个话题——Android 系统的安全机制:Linux 传统权限、Capability(能力模型)、还有 SEAndroid(SELinux 的 Android 定制版)之间复杂而层层叠加的关系。
这篇文章就用我多年折腾系统安全、应用兼容性的实操经验,把Linux 能力模型(Capability)和 SELinux 的区别讲透。我会从它们各自的设计初衷、原理、在 Android 中的实际用法,到开发中最容易踩的坑,逐一拆开,帮你彻底理清这两套安全体系谁管什么、怎么管、出了问题怎么查。如果你做过系统开发、定制 ROM、安全研究,或者只是被某次诡异的权限问题卡住过,这篇文章应该都能给你一些方向。
1. 先从根上理解:Linux 传统的权限体系扛不住 Android
1.1 DAC 模式:老祖宗的设计,够用但不抗细
传统 Linux 的权限体系核心是 DAC,自主访问控制。通俗地说,系统默认相信文件的属主有权决定谁能访问自己的文件。每个文件和进程都挂着 UID 和 GID,内核在进程发起 open、read、write 这类操作时,直接拿进程的 uid/gid 和文件的 uid/gid、权限位做比较,通过就放行,不通过就拒绝。
这套机制简单、高效,几十年跑下来非常稳定。但它的缺陷在 Android 的场景下被放大了——DAC 判断的核心是"你是谁"。只要你的 uid 是 root(uid 0),你在传统 DAC 规则下几乎是万能的,可以读写任何文件、杀死任何进程、修改任何网络配置。这种"非黑即白"的授权模型,对于一个手机上运行着大量第三方 APP 的场景来说,明显太粗糙了。
Android 的 app 虽然是沙箱隔离的,每个应用有自己的 uid,但系统服务、底层 native 进程数量庞大,这些进程需要大量系统级权限。如果只靠"root 或不 root"来区分,那么任何一个被攻破的 root 进程都等于整个设备沦陷。更麻烦的是,许多操作并不需要完整 root 权限,比如一个进程只需要绑定低端口(小于 1024),按理说给它一个"能绑定低端口"的能力就够了,给它完整 root 身份反而是安全灾难。
1.2 Android 面对的麻烦:每个普通 APP 都是一等公民
Android 和传统 Linux 服务器的一个巨大差别在于,服务器的进程绝大多数是可信的、由管理员统一部署的,而 Android 上跑着的是来自全球开发者、质量参差不齐、甚至可能被恶意控制的应用。系统需要同时做到:普通 APP 能通过框架层拿到各种功能,但又不能直接碰内核和敏感系统资源;系统进程拥有较高权限,但也不能任意妄为到连 SELinux 策略都能无视。
这种需求催生了 Android 在权限模型上的"层层设卡"。最底层,是 Linux 内核的 DAC + Capability;在它之上,Android 又加了一层基于 SELinux 的 MAC(强制访问控制)。后面的文章里你会看到,这两层不是重复建设,而是各管一段、彼此咬合。
1.3 为什么说 Capability 和 SELinux 是不同维度
很多人混淆 Capability 和 SELinux,是因为它们都出现在权限报错里。但两者的"管辖维度"完全不同。Capability 回答的问题是"进程有没有资格执行某类特权操作",它是对传统 DAC 的细化——把 root 这一把万能钥匙拆成了几十把独立的小钥匙,每把钥匙对应一个特权操作类型。而 SELinux 回答的问题是"一个已经被授权的进程,能否对另一个具体的文件、socket、进程执行某个具体动作",它根本不看 uid,只看标签和策略规则。
打个生活化的比方。Capability 相当于给你发了一张"会议室门禁卡",卡上注明了你可以进哪些楼层、哪些房间。而 SELinux 相当于每个房间门口的保安,他不管你是谁、也不管你手上有什么卡,只看他的名单上允不允许你这个身份的人进这个房间。就算你手里有一张万能门禁卡(root + 所有 capability),保安那关不过,你还是进不去。
这就是为什么在 Android 上,即使你用 root 身份操作,依然会遇到大量被拒绝的情况,很多都是从 SELinux 层面拦下来的。
2. Linux Capability:把 root 的"神权"拆成一张张"有限授权卡"
2.1 Capability 的设计初衷:最小权限原则
Capability 机制在 Linux 内核 2.2 版本被引入,核心动机就是为了让进程可以获得"部分特权",而不是要么 root 要么普通用户。执行特权操作不再强制要求 uid 为 0,而是检查进程是否拥有对应的 capability 位。这一改动真正落实了最小权限原则:每个进程只要恰好拥有完成工作所需的那几个能力,就够了。
常见的 Capability 包括:
- CAP_NET_ADMIN:允许执行网络管理操作,比如配置接口、修改路由表
- CAP_NET_BIND_SERVICE:允许绑定小于 1024 的端口
- CAP_DAC_OVERRIDE:允许绕过文件读、写、执行权限检查
- CAP_KILL:允许向任意进程发送信号
- CAP_SYS_ADMIN:大杂烩能力,涵盖挂载、命名空间操作等众多系统管理能力
- CAP_SYS_BOOT:允许重启系统
在内核实现上,每个进程的 task_struct 里都保存着三组 capability 位图:有效集(effective)、允许集(permitted)、可继承集(inheritable)。内核在具体判断时看的是有效集,它才是当前生效的权限标记。当进程尝试一个特权操作,比如 mount() 系统调用,内核会先检查当前进程的有效集里有没有 CAP_SYS_ADMIN,有就放行,没有就直接返回 EPERM,与 uid 无关。
2.2 内核到底怎么查这几个 bit 位
说到内核源码,cap_capable() 是能力检查的核心函数。它接收两个参数:目标进程的 cred 结构体和要检查的 capability 编号。检查逻辑很直接,就是从 effective cap 的位图里去确认对应的 bit 是否为 1。如果想要深入一点,可以关注这三个文件:include/linux/capability.h 定义了所有能力位的编号,kernel/capability.c 实现用户态与内核态的交互逻辑,security/commoncap.c 则包含文件执行时的能力继承计算。
在 Android 上,除了内核直接做 capability 检查,还叠加了一层"能力边界集"(bounding set)的概念。bounding set 是系统级的最大能力上限,可以理解为系统设的一道总闸门。即便一个进程的 effective 集里含有某个能力,如果该能力不在 bounding set 里,进程也无法长期持有。Android 对很多敏感能力做了收敛,就算你拿到了一个 root shell,也未必能把所有 capability 加回来。
2.3 Capability 在实际项目中的应用姿势
在实际项目里,我见过不少因为 capability 问题导致的经典故障。举个例子,某模块需要把 SOC 内部某个安全寄存器的访问映射到用户态,一个普通进程即使 fd 已经通过 ioctl 拿到了,后续的 mmap 操作依然会被拒,原因往往就是 CAP_SYS_RAWIO 不在有效集里。这类能力的缺失在 logcat 里通常不会给特别明确的提示,很多时候只有一个 Premission denied 或 Operation not permitted,需要你结合源码去对号入座。
调试 capability 最常见的工具是 capsh。在 root shell 下执行capsh --print,能看到当前 shell 的 capability 位、bounding set 等信息。如果想给某个可执行文件单独设置能力位,可以用setcap cap_net_bind_service=ep /path/to/binary,这种方式叫文件能力。文件能力在 Android 上用的不算多,因为系统分区通常是只读的,而且 SELinux 会把文件能力与安全标签一起校验,改动很容易引发新的拒绝。更常用的操作方式是在 init.rc 的 service 声明里配置 capabilities,比如:
service myservice /system/bin/mydaemon class main user root group root system capabilities CAP_NET_ADMIN CAP_NET_BIND_SERVICE seclabel u:r:my_domain:s0这样,进程启动时就会被赋予指定的能力位,同时贴上为它准备的 SELinux 标签。从这一段你也能看出,Android 的权限模型中,capability 和 seclabel 往往是成对出现的——这正好引出了 SELinux 的部分。
3. SELinux 的 Android 版本:SEAndroid 到底管得有多宽
3.1 从"你有资格"到"你的行为需获准"
SELinux 是 Linux 内核中的强制访问控制(MAC)子系统。所谓强制,意思是系统安全策略不由进程所有者决定,而是由全局统一的安全策略来强制规定。Android 从 4.3 开始集成 SELinux,并在 5.0 之后默认进入 enforcing 模式,从此,所有进程的任何敏感动作,都要经过 SELinux 策略的裁决。这套 Android 定制版通常被称为 SEAndroid。
区别于 DAC 和 capability 只管"你有没有这个资格",SELinux 管的是"你到底能不能对某个具体对象做某个具体动作"。它的判断基于三个要素:主体(subject)、客体(object)和动作(operation)。主体是发起操作的进程域(domain),客体是文件、socket、进程、属性等被打上安全标签的对象,动作就是 read、write、create 等等。
3.2 label、domain 和 policy,三个核心概念
SELinux 的世界里,每个对象都挂着一个安全标签,格式类似u:r:untrusted_app:s0。这个字符串分解开是:用户(user)、角色(role)、类型(type)、敏感度(sensitivity)。在绝大多数和 Android 安全相关的场景中,真正决定放行还是拒绝的关键就是第三个字段——type,也就是类型。
进程的 type 通常叫 domain。比如普通 APP 的 domain 是 u:r:untrusted_app:s0,系统服务可能是 u:r:system_server:s0,自定义 native 服务可能是 u:r:my_service:s0。文件的 type 则各不相同,比如 /system/bin/ 下的可执行文件一般被标记为 u:object_r:system_file:s0,而 /data/data/ 下的应用私有目录会被标记为 app_data_file 之类的类型。
所有放行规则都写在 policy 里,核心内容就是"允许哪个 domain 对哪个 type 执行哪些操作",格式大致是:
allow my_domain system_file:file { read execute };翻译过来就是:允许 my_domain 这个进程域对 system_file 这个类型的普通文件执行读和执行操作。SELinux 默认策略是"没有明确允许就是拒绝"。也就是说,任何一条规则没有被明确写进 allow 语句,相关操作就会被拒绝。
3.3 在 Android 上实操:查状态、看日志、改策略
在 Android 设备上调试 SELinux,最常用的几个命令:
adb shell getenforce:查看当前 SELinux 状态,返回 Enforcing 或 Permissiveadb shell setenforce 0:切换为 Permissive 模式,此时 SELinux 不阻止操作,只记录拒绝日志adb shell dmesg | grep avc:在内核日志里过滤 AVC 拒绝记录adb shell logcat -b events -d | grep avc:从 events 缓冲区读取 SELinux 拒绝信息
一条典型的 AVC 拒绝日志长这样:
avc: denied { read } for pid=1234 comm="mydaemon" name="config.cfg" dev="mmcblk0p25" ino=12345 scontext=u:r:my_domain:s0 tcontext=u:object_r:vendor_configs_file:s0 tclass=file permissive=0拆开看:denied 后面的大括号里是具体被拒绝的操作;scontext 是主体,也就是发起操作的进程域;tcontext 是客体标签;tclass 指明对象是文件、目录、socket 还是其他类型;permissive=0 表示当前是 Enforcing 模式所以操作被拒绝了。拿到这条日志,就能针对性地在 sepolicy 中补一条 allow 规则。
修改 sepolicy 通常要改源码里的 .te 文件。新增规则后重新编译 boot 镜像或 vendor 镜像刷入。如果是调试阶段,可以让相关 service 在以 permissive 模式启动的同时仍保留标签,这样可以分步排除"到底是可执行文件标签问题,还是运行时动作被拒"。
我记得某一次为了给一个自定义 daemon 添加网络绑定和属性写入的权限,反复在 vendor 的 sepolicy 目录下调整my_domain.te里的规则,每次修改后都需要完整编译 vendor 镜像。整个过程最耗时的其实不是改规则本身,而是排查日志里大量看起来毫无规律的 AVC 消息,等理清楚每个拒绝对应的文件和动作之后,策略的补全就顺理成章了。
4. 正面硬刚:Capability 和 SELinux 的区别与协同
4.1 一张表说明白核心差异
不少人在写技术总结时习惯把 Capability 和 SELinux 看作两种并列的替代方案,但它们根本不是同一层的东西。我用一个表格理清最关键的差异:
| 对比维度 | Linux Capability | SELinux / SEAndroid |
|---|---|---|
| 安全模型 | 对传统 DAC 的细粒度扩展 | MAC,强制访问控制 |
| 核心判断依据 | 进程是否拥有某种特权能力位 | 主体对客体的类型和操作是否匹配策略 |
| 是否看 uid | 是,但 root 不再万能,需配合能力位 | 否,只看标签(type) |
| 粒度 | 粗粒度能力类别,如网络管理、文件权限绕过 | 可精确到文件路径类型、端口、属性、操作动作 |
| root 是否可绕过 | root 在无限制情况下可获取全部能力 | 不能,Enforcing 下 root 也受策略约束 |
| 决策顺序 | 在内核权限检查早期阶段执行 | 在内核 LSM 钩子阶段执行,通常在 DAC 之后 |
这个表格里最值得记住的一条结论是:Capability 判断的是"你有没有这张门禁卡",SELinux 判断的是"保安名单上有没有你"。两者可以同时生效,并且默认情况下都是先过 Capability 检查,再过 SELinux 检查,只有两道关都通过,操作才被允许。
4.2 为什么 Android 两个都要
如果 Capability 已经可以把权限细化到几十个能力位,为什么还需要 SELinux?问题的关键在于 Capability 依然无法解决"同一个能力被滥用"的问题。假设一个进程持有 CAP_DAC_OVERRIDE,它对所有文件都有读写的特权,SELinux 却可以在策略上只让它读某个特定目录下的配置文件,其他一律拒绝。这种"即使你有通行证,也过不了门禁"的控制能力,是 Capability 单独做不到的。
还有一个深层次问题:Capability 在 fork 和 exec 时容易出现继承扩散,一个进程获得的能力可能通过运行子进程传递给下一个程序,能力边界往往控制得不够死。SELinux 的 domain 转移机制则严格得多,从 init 启动一个服务时,进程的 domain 是通过 init.rc 里的 seclabel 指定的,子进程默认留在同一个 domain 里,除非显式配置 type_transition 规则。这意味着即使某个二进制被攻破,攻击者能够使用的权限范围也被死死锁在它的 domain 里。
所以,Android 的架构策略是"DAC + Capability + SELinux"三层叠加。DAC 和 Capability 负责完成大部分基础的文件权限、特权操作校验,SELinux 在此基础上再做一次更细粒度的访问控制,以牺牲部分便利性为代价,换来了更强的纵深防御。
4.3 开发中常遇到的"叠加拦路虎"
正因为两层检查串联,实际开发中常常出现"看起来权限都给够了,但操作还是失败"的诡异问题。比如我在某台设备上调试一个网络工具,明明用 root 身份运行,也有 CAP_NET_ADMIN,去修改 iptables 规则却反复失败。最后发现是 SELinux 的策略里没有允许 system domain 之外的进程对 netfilter 配置进行操作,调整了对应 .te 规则才解决。
这类问题的排查顺序很关键:先用getenforce确认模式,再用dmesg | grep avc看有没有 AVC 拒绝。如果 AVC 日志一片空白,说明问题很可能出在更前面的 capability 检查;如果 AVC 拒绝刷屏,那就集中精力改 policy。分清这两条线,能在调试中少走大量弯路。
5. 常见问题与排查技巧实录
5.1 avc denied:SELinux 拒绝的经典现场
最常见的 SELinux 故障,就是某个服务、某个应用在执行特定操作时被 AVC 拒绝。我遇到过一个案例:某系统应用需要读 /proc/XXX 下的一个节点来获取传感器校准信息,在老版本 Android 上没问题,升级新版本后日志里满屏的 avc denied。排查后发现,新版本的 sepolicy 对该 proc 节点的 type 划分更细了,应用所在 domain 没有被授予 read 权限,补一条 allow 规则即可。
排查时可以按四个步骤来:
- 复现问题,抓日志,用 dmesg 或 logcat 找到目标进程的 avc denied
- 从 scontext 里确认主体的 domain,从 tcontext 确认客体的 type
- 查找当前策略中是否已有相关 allow 规则,如果没有,在对应的 .te 文件中补充
- 重编镜像烧录验证,或临时用 permissive 模式跑通流程后再精确补策略
补规则时不要图省事直接 allow 一个大范围 domain 访问所有文件类型,最好精确匹配实际需要的 type 和操作 class。权限给宽了对系统整体安全是削弱,也会给后续的 CTS/VTS 兼容性测试埋雷。
5.2 capability 不足导致的操作失败
区别于 avc denied 的明显报错,capability 不足的问题隐蔽得多。比如一个 native 进程尝试调用 reboot() 重启系统,如果没有 CAP_SYS_BOOT,返回值是 EPERM。但很多业务层代码并不会严格检查系统调用的返回值,只会"感觉"功能没生效,导致排查时无从下手。
定位这类问题,可以用 strace 跟一下系统调用,观察哪些调用返回 EPERM,再对照目标进程的能力集逐一确认。比如:
strace -f -e trace=reboot,mount,setuid,setgid /system/bin/mydaemon在输出里如果看到reboot( ... ) = -1 EPERM (Operation not permitted),基本可以锁定方向:要么给该 service 在 init.rc 中补充 capabilities,要么改写代码降低对特权能力的依赖。
5.3 调试 SELinux 的几个实操心得
这几年调 SELinux 策略,我总结出几条经验,对刚接触的人特别有用:
- 调试阶段尽量在 userdebug 或 eng 版本上进行,这样可以方便地切换 permissive/enforcing,也不用每次修改都重刷整包
- 大量 AVC 拒绝被记录到内核环形缓冲区时,可能因为其他日志太多被冲掉。建议适当放宽 log level,或提前用 dmesg -w 在复现前开始持续抓取
- sepolicy 文件在 Android 11 之后分 system 和 vendor 两部分,各自的 .te 文件归属不同分区,修改前先确认自己改的是哪个分区的策略
- 不要只看一条 AVC 就断言问题,有时候一个操作会引发序列性的多条 AVC,逐条梳理,理清哪一条才是导致业务失败的关键
在某个虚拟项目的实际操作中,我遇到过一种很有意思的局面:服务在 enforcing 模式下无论如何都启动不起来,切到 permissive 却完全正常。当时纠结了很久,后来发现问题是 init.rc 里把 seclabel 写错了,导致服务进程被分配到一个错误 domain,而该 domain 对 init 服务的通信接口没有权限。修正 seclabel 后,一条 allow 规则都不需要补就解决了。这个坑提醒我,遇到 SELinux 问题时,先检查进程的 scontext 是否符合预期,再怀疑策略缺失。
结尾:一点个人体会
我最初接触 Android 安全时,也曾想当然地认为 SELinux 只是"更严格的权限管理",后来才慢慢理解它和 Capability 之间其实是两道防线的关系,手动梳理 selinux 策略的过程,本身就是一次很好的系统安全概念复习。
最后分享一个调试技巧:如果遇到能力或策略相关的问题,先别急着改代码。把getenforce、dmesg | grep avc、strace这三个工具从简单到复杂依次用一遍,八成以上的问题都能定位清楚。权限问题从来不是"加上就能用"这么简单,理解它背后的设计逻辑,比背几个命令更重要。