一次车机概率性 SELinux unlabeled 故障:用 ext4 原理把它挖到底
Android 车机中
/mnt/cert_package/pkiagent目录概率性 变为u:object_r:unlabeled:s0的完整排查过程,包括对故障分区镜像的逐 inode 解析与时间线还原。
0. 故障现象:一条概率性的 AVC 拒绝
某车型 IVI 车机(Android 系统,分区 cert_package 位于 /dev/block/mmcblk0p6,挂载在 /mnt/cert_package,容量仅 1MB),现场偶发报错。抓到日志时能看到典型的 SELinux 拒绝:
ini
avc: denied { search } for comm="diagApp" name="pkiagent" dev="mmcblk0p6" ino=15
scontext=u:r:remote_diagnostic:s0 tcontext=u:object_r:unlabeled:s0
tclass=dir permissive=0
业务侧的表象是远程诊断应用(diagApp)反复打印:
javascript
E [oem_sslctx_config]: cannot open cert directory: /mnt/cert_package/pkiagent/crts
三个信息值得立刻圈出来:
- tcontext 是
u:object_r:unlabeled:s0------目标对象没有 SELinux 标签; - ino=15、dev=mmcblk0p6------内核直接告诉了我们"出问题的对象"在哪个设备、哪个 inode;
- permissive=0------enforcing 模式,拒绝是实打实的,不是审计噪音。
而 file_contexts 里明明写得清清楚楚:
bash
/mnt/cert_package(/.*)? u:object_r:mnt_cert_package:s0
/mnt/cert_package/pkiagent(/.*)? u:object_r:pkiagent_root_file:s0
/mnt/cert_package/vin(/.*)? u:object_r:vin_data_file:s0
/mnt/cert_package/pkiagent/crts(/.*)? u:object_r:pkiagent_crts_file:s0
/mnt/cert_package/pkiagent/pre_install(/.*)? u:object_r:pkiagent_pre_install_file:s0
定义都在,为什么运行时却是 unlabeled?而且还是概率性的? 这是本文要回答的核心问题。要讲清楚它,得先从 ext4 的磁盘结构说起------因为答案就藏在磁盘上的字节里。
1. 理论底座(一):ext4 的磁盘布局
1.1 从创建一个 ext4 说起
在 Linux 上创建并观察一个 ext4 文件系统只需要三条命令:
bash
dd if=/dev/zero of=./ext4_image.img bs=1M count=1024 # 造一个 1GB 的"磁盘"
mkfs.ext4 ext4_image.img # 格式化为 ext4
mount -o loop ext4_image.img /mnt/ext4_test # loop 设备挂载
dumpe2fs ext4_image.img # 查看文件系统元信息
dumpe2fs 的输出(节选)就是整个文件系统的"身份证":
yaml
Inode count: 65536
Block count: 262144
Block size: 4096
Blocks per group: 32768
Inodes per group: 8192
Inode size: 256
Journal inode: 8
Journal size: 32M
1.2 总体布局:块 → 块组
ext4 把分区切成固定大小的块(block) (通常 4096 字节),再把块组织成块组(block group) 。每个块组内部依次包含:
scss
+-------------+-------------------+--------------+--------------+-------------+----------+
| Superblock | Group Descriptors | Block Bitmap | Inode Bitmap | Inode Table | 用户数据 |
| (超级块) | (组描述符表) | (块位图) | (inode位图) | (inode表) | |
+-------------+-------------------+--------------+--------------+-------------+----------+
几个设计要点:
- 超级块 固定位于分区偏移 1024 字节处(对 4K 块来说在第 0 号块内),magic number
0xEF53(小端字节序53 ef)写在超级块偏移 0x38 处; - 由于
sparse_super特性,超级块与组描述符的备份只散布在部分块组(如 Group 1、3、5、7),主超级块损坏时可用于恢复; - 块位图 / inode 位图按位记录每个块、每个 inode 的占用状态;
- 一个组描述符块里装的是所有块组的描述符,不只是自己的。
1.3 超级块:文件系统的"户口本"
超级块记录块大小、块/组/inode 计数、UUID、最后挂载点、各种时间戳,以及最重要的------三个 32 位的特性标志字:
| 字段 | 超级块内偏移 | 作用 |
|---|---|---|
s_feature_compat |
0x5C | 兼容特性(内核不认识也能挂载) |
s_feature_incompat |
0x60 | 不兼容特性(内核必须支持才能挂载) |
s_feature_ro_compat |
0x64 | 只读兼容特性 |
每个特性占一个 bit,例如:
- compat 里
0x8= ext_attr(支持扩展属性)、0x20= dir_index、0x4= has_journal(有日志!) - incompat 里
0x2= filetype、0x40= extents(extent 映射) - ro_compat 里
0x1= sparse_super、0x2= large_file、0x8= huge_file、0x10= uninit_bg、0x20= dir_nlink、0x40= extra_isize
dumpe2fs/tune2fs -l 输出里的 Filesystem features: 那一行,就是这三个字逐位翻译成人话。
1.4 Inode:文件的"实体"
每个文件/目录对应一个 inode,记录权限、uid/gid、三个时间戳(atime/mtime/ctime)、大小,以及 60 字节的 i_block 区域。i_block 的解释由 i_flags 决定:
- 传统模式:直接块指针 + 一/二/三级间接块;
- extent 模式 (
i_flags & 0x80000,现代 ext4 默认):存放一棵 extent 树,每个 extent 形如"逻辑块 0 起、长度 0x196 个块、映射到物理块 0x84b5 起",大文件的映射既紧凑又减少碎片。
给定 inode 号,定位它的公式是:
css
inode 所在偏移 = 组描述符表里查到的该组 inode table 起始块 × 块大小 + (inode号-1) × inode大小
另外 0~10 号 inode 是保留的:2 号永远是根目录,8 号被 journal 占用。
1.5 目录项:树是怎么串起来的
ext4 中"目录"本质上也是一个文件(inode 的 mode 标记为目录),其数据块内容是一串连续的目录项:
scss
+----------------+--------+-----------+------------+----------------+
| inode 号 (4B) | rec_len| name_len | file_type | 文件名(不满补齐)|
| | (2B) | (1B) | (1B) | |
+----------------+--------+-----------+------------+----------------+
于是"按路径找文件"的过程就是:从 2 号 inode(根)读出目录项 → 找到下一级名字对应的 inode 号 → 读该 inode → 再往下走。删除文件则相反:释放数据块(块位图置闲)→ 释放 inode(inode 位图置闲)→ 摘除目录项。
创建文件的完整步骤(后面讲崩溃一致性时的主角):
- 查 inode 位图,找一个空闲 inode 并标记占用;
- 查块位图,分配数据块并标记占用;
- 更新 inode 表中的元数据(权限、时间、块指针......);
- 在父目录的数据块里追加目录项。
2. 理论底座(二):崩溃一致性------为什么"没有 journal"很危险
2.1 问题:四个步骤没法原子完成
磁盘以扇区为最小写入单位,上面"创建文件的四个步骤"是四次独立的落盘,中间任何时刻断电,磁盘上就会留下"半新半旧"的自相矛盾状态:
- 第 1 步完成后断电:inode 位图标了占用,但没有对应文件 → inode 永久泄漏;
- 第 4 步前断电:数据块、inode 都好了,但目录里没有它 → 文件"不存在";
- 更隐蔽的:元数据在 page cache 里延迟批量落盘,崩溃窗口比想象的大得多。
这就是崩溃一致性(crash consistency) 问题:文件系统在崩溃、断电后,必须仍能恢复到一个"合法、可继续操作"的状态。
业界有四种主流解法,各有取舍,没有万能方案:
| 方案 | 核心思想 | 代表 | 代价 |
|---|---|---|---|
| 日志 Journal | 先把变更顺序写进日志区,提交后再应用到最终位置 | ext3/ext4、XFS | 双写,IO 放大;日志区易磨损 |
| 写时复制 COW | 修改写到新块,完成后才切换指针 | Btrfs、ZFS | 写放大、元数据负担重 |
| Soft Updates | 精确约束元数据写入顺序(如先写目录项再写 inode) | FreeBSD UFS | 实现复杂、内存开销大 |
| 日志结构 LFS | 所有更新追加到连续日志,后台整理 | F2FS、jffs2 | 随机读退化、碎片化 |
2.2 ext4 的日志:JBD2 与三种模式
ext4 的日志由 JBD2 子系统实现,遵循"先记日志、再执行、后清理":
scss
事务开始 → 收集日志 → 提交日志(写commit标记) → 应用事务(写入最终位置) → 清理日志
崩溃后的恢复规则非常简单:挂载时扫描日志区,未提交的事务直接丢弃,已提交的事务重放。相比 fsck 全盘扫描比对位图,恢复时间从分钟级降到毫秒级。
按"往日志里记什么",ext4 有三种模式:
| 模式 | 记录内容 | 特点 |
|---|---|---|
| writeback | 仅元数据 | 性能最高;崩溃后可能"新元数据指向旧数据" |
| ordered(默认) | 元数据入日志;数据不入日志但保证先于元数据落盘 | 性能与一致性的平衡点 |
| journal | 数据 + 元数据全入日志 | 一致性最强;所有数据写两遍 |
反过来说:如果格式化时没开 journal(compat 特性字里没有 0x4),这块 ext4 就退化成了"ext2 + extents + xattr" ------一致性完全指望下次开机 fsck,而 fsck 的修复是"以元数据自洽为目标"的外科手术,过程中丢掉部分元数据(比如 xattr)是合法的处置手段。记住这一点,后面全靠它。
3. SELinux 标签到底存在哪
Android 上一个问题是"标签(context)从哪来"。答案分两层,两层经常被混为一谈,而本案例恰恰败在混淆上:
第一层:磁盘上。 SELinux 标签以扩展属性(xattr)的形式挂在每个 inode 上,名字固定是 security.selinux,值形如 u:object_r:pkiagent_root_file:s0。内核在进程访问对象时实时读取这个 xattr;如果 inode 上没有这个 xattr,内核一律报告 u:object_r:unlabeled:s0。
xattr 的存放位置和 inode 大小有关:现代 ext4 的 inode 是 256 字节,放得下就内联在 inode 里(ibody);如果 inode 只有 128 字节(ext2 时代规格),所有 xattr 都放到独立的外部 xattr 块里,inode 用 i_file_acl 字段指向它。这个字段清零 = 标签 evaporate。
第二层:策略里。 file_contexts 定义的"路径 → 标签"映射,只在两种时刻生效 :制作镜像时(make_ext4fs/mke2fs 带 contexts)和执行 restorecon/restorecon_recursive 时。它不是"持续生效的规则",运行时内核压根不看 file_contexts。
两层的推论:
- file_contexts 写得再全,只要磁盘上的 xattr 丢了、又没有 restorecon 兜底,对象就是 unlabeled;
- 新建的文件按 SELinux 规则从父目录继承/转换 标签------包括把
unlabeled原样传给子文件(表现为子文件的 xattr 值字面上就是u:object_r:unlabeled:s0,这是 unlabeled 父目录的"传染性"); - 判断"标签问题是出在挂载还是出在个别文件"有个简单判据:同一挂载下,别的对象有正确标签而个别对象 unlabeled ⇒ 一定是磁盘 xattr 缺失,跟挂载方式(context= 之类)无关。
4. 实战:把 1MB 的故障分区拆开看
4.1 手里的材料
cert_package_dump.bin:故障分区完整 dump(1MB);- 5 份 logcat(共约 100MB),每份里混着多个启动会话(这是车机日志的常态:RTC 未同步时时间戳会跳到 2000-01-01 或 2022-01-01,必须按会话切开看)。
4.2 第一步:file 和十六进制看气质
ini
$ file cert_package_dump.bin
Linux rev 1.0 ext2 filesystem data (mounted or unclean), UUID=5b13xxxx-xxxx-...
注意两点:file 认为它是 ext2 (file(1) 区分 ext2/ext3/ext4 的主要依据就是 has_journal 位);状态是 mounted or unclean。
直接读超级块(偏移 1024):
ini
s_feature_compat = 0x28 → ext_attr(0x8) + dir_index(0x20) ;has_journal(0x4) = 0 ❌
s_feature_incompat = 0x42 → filetype(0x2) + extents(0x40)
s_feature_ro_compat = 0x7b → sparse_super+large_file+huge_file+uninit_bg+dir_nlink+extra_isize
s_journal_inum = 0 (有日志的 ext4 这里是 8)
s_journal_dev/uuid = 全零
8 号 inode = mode/links/size 全 0(从未使用)
其他参数:块大小 4096、共 256 块、128 个 inode、单块组、inode 大小 128 字节 、last mounted /mnt/cert_package。
结论扑面而来:这是一颗"无日志 + 128 字节 inode"的 ext4------AOSP make_ext4fs 生成静态分区镜像的典型形态。它意味着:所有 SELinux 标签都住在外部 xattr 块里;一致性没有日志保护。
4.3 第二步:写个脚本,从 2 号 inode 走遍整棵树
没有现成的 Linux 环境,就用 Node.js 手写一个迷你 ext4 解析器(80 行核心逻辑,文件保存在 ext4_inspect.js):读超级块 → 找组描述符 → 定位 inode 表 → 沿目录项递归下钻 → 对每个 inode 解析 i_file_acl 指向的 xattr 块,取出 security.selinux。
输出是整个分区最诚实的画像:
| 路径 | ino | on-disk 标签 | i_file_acl |
|---|---|---|---|
/ |
2 | mnt_cert_package |
14 |
/lost+found |
11 | mnt_cert_package |
13 |
/misc、/misc/keystore、sqlite 文件 |
12,13,18,20 | mnt_cert_package |
15... |
/evs、/evs/res/xcb、xcb_license |
23,24,25,33 | mnt_cert_package |
15... |
/pkiagent/crts + 全部证书文件 |
19,27,28,34~37 | pkiagent_crts_file |
18... |
/pkiagent/pre_install |
21 | pkiagent_pre_install_file |
22 |
/vin(空目录,0777) |
14 | 无 xattr → unlabeled | 0 |
/pkiagent |
15 | 无 xattr → unlabeled | 0 |
/pkiagent/pki_config.json |
32 | xattr 值 = u:object_r:unlabeled:s0(显式!) |
62 |
/lost+found/#16、#17(空目录,0777) |
16,17 | 无 xattr → unlabeled | 0 |
三个关键观察:
- 同分区其他一切都有正确标签 ,唯独
vin、pkiagent两个目录的i_file_acl=0。结合第 3 节的判据:挂载没问题,是这两个 inode 的磁盘 xattr 没了(或从未有过) 。 pki_config.json是运行期在 unlabeled 目录下创建的,它的 xattr 被内核显式写成 unlabeled------完美复现"父目录无标签会传染子文件"的机制。- 出问题的两个目录和 lost+found 里两个孤儿目录特征完全一致:空/浅目录、mode 0777、无 xattr。
顺带一个法证级细节:/pkiagent 的 ctime 停在 2022-01-01 而 mtime 是 2026-09-19------ctime < mtime 在正常内核行为下不可能发生 ,说明这些 inode 的元数据要么由恢复工具按原时间戳重写过,要么经历了系统时钟跳变。总之:这台设备的时间戳不能按字面相信。
4.4 第三步:日志重建时间线
把 5 份 logcat 按会话拆开(2000-01-01 / 08-06 / 09-05 / 09-20 四段),对齐每个会话里"cert 访问成功/失败 + AVC denial"的组合:
| 会话(本地时间) | 标签状态 | 证据 |
|---|---|---|
| 2000-01-01 08:00(RTC 重置 = 此前有硬掉电) | 正常 | 59 次 found final matching cert、0 失败、无任何 mmcblk0p6 的 AVC |
| 08-06 20:43 开机 | vin+pkiagent 已 unlabeled | 22 条 denial(ino=14/15);根目录 /mnt/cert_package(ino=2)tcontext 正常是 mnt_cert_package------同挂载、同时刻,个别对象坏 |
| 08-06 22:27:28 | 分区被重新 mkfs + 重灌 | tune2fs 的 Filesystem created: Aug 6 22:27:28 2026;而镜像里 inode 时间戳停在 2022-01-01 → 内容是从金片/母本镜像按原时间戳恢复的 |
| 09-05 18:18/18:21 | 持续失败 | diagApp 反复被拒;同会话有权限的域(pkiagent_app 走 crts)能成功------不是标签恢复 |
| 09-19 14:04 | 持续 | pki_config.json 写入 |
| 09-20 09:13 / 14:26 两次开机 | 持续 | denial 照旧,permissive=0 |
| 09-28(dump 时刻) | 持续 | ino 14/15 无 xattr;fs state "not clean" |
4.5 第四步:设备端 tune2fs 复核
在 DUT 上用 tune2fs -l /dev/block/by-name/cert_package 复核(没有 dumpe2fs 时的替代):
yaml
Filesystem features: ext_attr dir_index filetype extent sparse_super large_file
huge_file uninit_bg dir_nlink extra_isize ← 没有 has_journal
Filesystem state: not clean
Filesystem created: Thu Aug 6 22:27:28 2026
Last mount time: Sat Jan 1 08:00:04 2022 ← 挂载时系统时钟还是默认 epoch
Mount count: 1
Inode size: 128
与离线解析完全互相印证。
5. 根因:三层叠加
把所有证据叠起来,故障是三层因素共同作用的结果:
第一层(直接原因):磁盘上 i_file_acl=0。 /vin、/pkiagent 的 inode 没有 security.selinux xattr,内核报 unlabeled。file_contexts 定义得再好也没用------它只在 restorecon/制镜像时生效,运行时内核只认磁盘 xattr。
第二层(标签是怎么没的):两条路径都成立,且都指向"没有日志"。
- 路径 A(旧文件系统,8/6 20:43 之前):无日志 ext4 在车机硬掉电(日志里 RTC 跳回 2000-01-01 的会话就是掉电现场)后,元数据多步写不原子,e2fsck 修复时把损坏/引用异常的 xattr 块清掉(
i_file_acl置零)------修复以元数据自洽为目标,丢 xattr 是合法处置。哪个 inode 中招是随机的,这就是"概率性"的来源之一; - 路径 B(现行文件系统,8/6 22:27 之后):分区被重新格式化并从金片镜像恢复,而金片/恢复流程本身就没给
vin、pkiagent写标签(其余目录全部有正确标签),重刷等于把问题原样刷了回去。
第三层(为什么永远不自愈):缺兜底。 这台设备的启动流程里没有任何针对 /mnt/cert_package 的 restorecon_recursive。标签一旦丢失,就带着 unlabeled 一直跑,每次开机重复同样的 denial;运行期在 unlabeled 目录下新建的文件还会被显式写成 unlabeled,问题自我延续。
至于"file_contexts 明明有定义"的疑问,一句话回答:定义只是"翻译词典",得有人(restorecon)去翻它;没人翻,磁盘上缺的那一页就永远是空的。
6. 修复与预防
按"止血 → 根治 → 加固"三层:
-
存量止血(现场已坏设备,root 执行一次即可):
bashrestorecon -R /mnt/cert_package # 或最小化处理: chcon u:object_r:mnt_cert_package:s0 /mnt/cert_package/pkiagent /mnt/cert_package/vin注意把
/vin一起修------它和 pkiagent 是同一故障(carservice_app 的 denial)。 -
根治自愈(init rc,分区挂载后):
bashrestorecon_recursive /mnt/cert_package每靴执行,无论标签是被 fsck 清的还是镜像漏刷的,开机即修复。file_contexts 已经是对的,缺的只是这一行。
-
切断源头:排查重灌/镜像的生成脚本,给所有目录写标签;同时给分区补日志:
bash# 需先卸载分区 tune2fs -O has_journal /dev/block/by-name/cert_package1MB 分区开日志的代价可以忽略,却能把"掉电 → fsck → 丢 xattr"整条链掐断。
-
监控 :enforcing 模式下 denial 本身就是最好的探测器,可对
tcontext=unlabeled的 audit 做聚合告警。
7. 方法论沉淀:这类问题怎么查
复盘整个过程,可复用的套路有五条:
- AVC 日志是最好的路标 :
name=、ino=、dev=三个字段把问题对象精确定位到"哪块盘哪个 inode",比任何猜测都硬; - "同挂载下个别对象 unlabeled" 判据:邻居有标签而它没有 ⇒ 一定是磁盘 xattr 缺失,不要在挂载选项上浪费时间;
- 拿 dump 离线解析 ,别依赖设备上有没有工具:超级块在 1024 偏移、magic
0xEF53、三个特性字 +i_file_acl就能回答 90% 的问题。没有 e2fsprogs 时,dd skip=1116 count=1 | od -tx1读出28而不是2c,就足以证明没有 journal; - 车机日志必须按会话切开读:RTC 未同步时时间戳会在 2000/2022/2026 之间跳,且单个文件里混着多次开机的 buffer;inode 时间戳出现 ctime < mtime 这种"不可能状态",本身就是时钟跳变或元数据被工具重写的证据;
- file_contexts ≠ 运行时标签:验证"定义有没有被应用",看的不是 contexts 文件,而是磁盘 xattr 和"有没有东西定期去 apply 它"。
8. 结语
这个案例的"戏剧性"在于:表象是一次概率性的 SELinux 拒绝,往下一层是磁盘上消失的 60 字节 xattr,再往下一层是"无日志 ext4 在反复硬掉电 + fsck 修复"和"镜像本身漏标"两条合流的伏笔,而让它从偶发变成永久故障的最后一击,是启动流程里缺席的那一行 restorecon_recursive。
ext4 的超级块、位图、inode、extent、目录项这些结构,平时藏在 dumpe2fs 的输出和内核源码里,显得抽象;但当它以 1MB 分区的形态躺在你面前、每个字节都能和一条 AVC 日志对上号时,"文件系统"这门课就突然具体起来了。崩溃一致性那篇文章里说得很对------四种方案各有局限,没有万无一失;对工程实践而言,真正万无一失的是假设坏事情一定会发生,然后给系统留一条自愈的路。