LOADING

加载过慢请开启缓存 浏览器默认开启

从 88 次构建失败到全层解密:小米 17 OrangeFox Recovery 移植全记录

设备:小米 17(pudding / sm8750 / Android 17 / HyperOS 4.0.26),1220×2656 屏幕,屏下挖孔
目标:移植 OrangeFox Recovery,并让它在不依赖任何 DE 分区副本的前提下解出全部文件名
结果:DE/CE 全层明文、真实 spblob 直读、SELinux 拒绝日志降 87%、启动页停留大幅缩短,并搭好 CI
代价:113 次成功构建、约 20 次刷机、40+ 次重启、35 小时


一、现象:三个”看起来像玄学”的问题

接手时的情况很典型:recovery 能启动、能解密 metadata、能建 dm 设备、用户 PIN 也能被接受(twrp.user.0.decrypt=1),但文件名字全是 AlBUYQAAAAAPK-y2PupsHUKcvt6LIq0d 这样的密文

更麻烦的是三个互相纠缠的现象:

  1. /data/system_de/0(DE 层)文件名密文
  2. /data/media/0/sdcard(CE 层)文件名密文
  3. 解锁后重启,连已经明文的目录都会退回密文

正常 fstab、正常密钥、正常解锁,但名字就是不解密——这类问题最容易被归为”玄学”,而玄学的解法只有一个:把每一个假设都做成可复现的实验,然后逐个杀掉

二、先做减法:逐项排除的九个假设

排查的第一阶段是”逐个证伪”,每一项都有实测证据:

# 假设 验证方式 结果
1 fstab 的 fileencryption 串错误 对比 A16/A17 的 ParseOptions 已修正,非根因
2 inlinecrypt / gc_merge 挂载选项 /proc/mounts 实测 已修正,非根因
3 metadata_encryption= 丢失 dm 设备建立成功 已修正,非根因
4 spblob 取自陈旧副本 自加 [SP] 诊断判定 已修正,非根因
5 CE key 取自副本 descriptor 逐一对比 已改,非根因
6 /data 未挂载时装键 加挂载检查补丁 /data 已挂载,非根因
7 hw_wrapped 未生效 [POL] hw_wrapped=1 诊断 已生效,非根因
8 fs_mgr 覆盖 use_hw_wrapped_key 移植 A17 语义 已修正,非根因
9 fstab 与参考树不一致 逐字采用参考树配置 仍然失败

第 9 项特别有价值:本地有一份同机型参考树(README 声称”data decryption 稳定”),把它的 fstab 逐字照搬后依然全密文——这条实测直接告诉我们:参考树的声明不可信,fstab 不是根因

三、三个真正的根因

根因 1:缺失的编译期开关 —— TW_USE_FSCRYPT_POLICY := 2

转折点来自一份公开可用的同平台设备树YuKongA/twrp_device_xiaomi_sm8750_thales,109★)。它的 BoardConfig 里有三个我们缺的开关:

TW_INCLUDE_CRYPTO_FBE := true
TW_INCLUDE_FBE_METADATA_DECRYPT := true
TW_USE_FSCRYPT_POLICY := 2

其中最关键的是 TW_USE_FSCRYPT_POLICY := 2

原理:设备上所有目录的策略实测都是 v2:

[POL] /data/system_de/0 -> v2 desc=0a1d1454047afe13 contents=1 filenames=4 flags=0xa

fscrypt_policy 结构体在 v1 与 v2 下长度和字段都不同。缺这个开关时编译出的是 v1 结构体,于是所有策略读写都在用错误的格式解释内存——密钥装了、描述符看着也对,但内核按错误结构理解,结果永远对不上。

// system/vold/fscrypt_policy.cpp 里的开关
#ifdef USE_FSCRYPT_POLICY_V1
    return fscrypt_policy_v1_to_bytes(policy);   // ← 我们一直在用这个
#else
    return fscrypt_policy_v2_to_bytes(policy);
#endif

改完之后,DE 层立刻明文

顺带一提:TW_INCLUDE_FBE_METADATA_DECRYPT 缺失会让 partitionmanager.cpp 里整段 metadata 解密逻辑被 #ifdef 掉;BUILD_BROKEN_DUP_RULES 则是为了解决新开关引入的 se_omapi.rc 重复拷贝。

根因 2:内核密钥绑在”挂载实例”上

DE 层通了、/data/misc 也明文了,但 /data/media/0 依旧密文。这次的关键线索来自用户的一句话:

“我记得有一次也是正常解密的,只是需要手动挂载 storage。”

这句话把方向从”密钥错”转到了”时机错“。

顺着查下去,问题在解锁流程的顺序:

解锁: 解出密钥 → 安装到内核 ✓
      → Setup_Data_Media() 会 **卸载 + 重挂 /data** ✗
      → 内核密钥绑定在 superblock 上, 随旧挂载实例一起失效
      → 文件名退回密文

Android 系统与官方 recovery 不踩这个坑,是因为它们在正确时机安装、之后只挂载一次

修复方式很直接:每次重挂之后把密钥重新装回去

[0121] fscrypt_reinit_de_keys_after_remount()   // 重装 DE 密钥
[0125] fscrypt_reinstall_ce_key()              // 重装 CE 密钥 ← /data/media/0 的关键

两者都在 partitionmanager.cpp 的 CE 解锁段落里、Setup_Data_Media() 与重挂之后调用。改完实测:

[0125] CE key saved for post-remount reinstall (user=0 size=263)
[DE] [0121] reinit result: systemwide=1 de_keys=1
[0125] reinstall CE key after remount: OK
/data/media/0 → AIEdgeGallery-中文版-1.0.19-release.apk / Android / DCIM / …  (28 项明文)

根因 3:keystore2 启动竞态(以及我自己埋的雷)

有一次开机 /data 挂不上,日志里是:

[CE] step: Keystore() ctor FAILED (keystore2 不可用)
I:Failed to mount '/data' (Invalid argument)

原因是 metadata 解密跑在了 keystore2 注册之前。我加了等待逻辑 [0126],但第一版写错了

// ❌ 错误版本
for (int i = 0; i < timeout_sec * 5; i++) {
    Keystore probe;        // 构造函数内部有 300×100ms = 30 秒的有界轮询!
    if (probe) return true;
    usleep(200000);
}
// timeout_sec=20 ⇒ 100 次迭代 × 30.2s ≈ 50 分钟/调用点

而且服务名也写错了(android.system.keystore2 并不存在,正确的是 android.system.keystore2.IKeystoreService/default),导致探测永远失败。

正确版本:先用 checkService() 做廉价预判(服务确实存在才构造),再用 steady_clock 做墙钟兜底:

static const char* const kKeystoreService =
        "android.system.keystore2.IKeystoreService/default";
auto start = std::chrono::steady_clock::now();
for (;;) {
    bool present = false;
    sp<IServiceManager> sm = defaultServiceManager();
    if (sm != nullptr)
        present = (sm->checkService(String16(kKeystoreService)) != nullptr);
    if (present) {
        Keystore probe;
        if (probe) return true;
    }
    if (std::chrono::steady_clock::now() - start >=
        std::chrono::seconds(timeout_sec)) break;
    usleep(200000);
}

实测 [0126] keystore ready after 2600 ms —— 也证明这 2.6 秒等待是真实需要的。

四、性能:把 1759 条 SELinux 拒绝降到 225

启动页停留很久,用 dmesg 带时间戳一量,抓到了真凶:

SELinux 拒绝: t=66.40s → t=78.52s  共 12.1 秒, 1759 条
avc: denied { getattr } for comm="recovery"
    path="/data/user_de/0/cn.wps.moffice_eng.xiaomi.lite/cache/..."

原因有两层:

第一层TWExclude::Get_Folder_Size() 统计 /data 已用空间时,先 lstat 再判排除。而被排除的路径每次 lstat 都被 SELinux 拒绝并产生一条 audit 日志。把判定前移即可(check_skip_dirs() 是纯字符串比较,不依赖 stat):

FullPath = Path + "/" + de->d_name;
if (check_skip_dirs(FullPath))   // ← 提前到这里
    continue;
if (lstat(FullPath.c_str(), &st)) { ... }

第二层:更多拒绝来自”访问本来就被禁止的路径”。这里的正确做法不是跳过(跳过会少算真实数据),而是 **dontaudit**——保留拒绝、只免除审计开销:

/* 用基类属性而非逐个枚举类型:
   逐一枚举会撞上 recovery 精简策略集里不存在的类型(实测 checkin_data_file 即如此) */
dontaudit recovery data_file_type:dir  { search getattr read open };
dontaudit recovery data_file_type:file { getattr read open map };
dontaudit recovery property_type:file  { getattr read open map };

踩坑记录:我一度往跳过列表里加了 /data/user/,结果”备份大小”从 154354MB 掉到 64335MB——少算了 85.5GB 真实用户数据。后来查清 /data/user/0 是独立挂载点(/dev/block/dm-18),属于备份主体,绝不能跳。这个错误如果没被发现,会让备份前的空间检查误判。

结果

项目 优化前 优化后
SELinux 拒绝日志 1759 条 225 条 (-87%)
IHealth 服务轮询 68 次 2 次 (-97%)
keystore 等待上限 最坏 50 分钟 10 秒

其中 IHealth 轮询来自 TWRP 的电池后台线程(twrp.cpp:555-613while(true) + sleep(1s)),改用 TW_USE_LEGACY_BATTERY_SERVICES := true 走 sysfs 读取即可。

五、UI:1220×2656 挖孔屏的适配

5.1 非等比缩放的真相

主题是按 1080×1920(16:9)设计的,而屏幕是 1220×2656(约 1:2.18)。日志里给出了精确数值:

I:Scaling theme width 1.129630x and height 1.383333x
        scale_w = 1220/1080 = 1.1296   (横向 +13%)
        scale_h = 2656/1920 = 1.3833   (纵向 +38%)

两边不等 ⇒ 严重变形。翻 pages.cpp:924-950 才看明白坑在哪:

if (resize_attr) {                                    // 只有存在 resizing 属性时
    height = atoi(height_res_attr->value());          // 才用 XML 声明的 height
} else {
    DataManager::GetValue("screen_original_h", height);   // 否则用真实屏幕高度!
}

主题的 <resolution width="1080" height="1920" /> 没有 resizing,于是宽度用声明值、高度用真实屏幕值——两边各算各的。修正后:

<resolution width="1080" height="2351" resizing="1" />
<!-- 1080 * 2656 / 1220 = 2351 ⇒ scale_w == scale_h == 1.1297 ⇒ 不再变形 -->

5.2 分列时钟:一个 atoi 引发的”完全消失”

挖孔在屏幕正中,居中时钟必然被遮。方案是把时间拆成两半、贴在孔左右:

<text style="text_status">
    <placement x="%center_x%-78" y="%status_info_y%" placement="5"/>
    <text>%tw_time_hh%</text>
</text>

结果两个字符完全看不见。原因堪称经典:

x = "540-78"        // gui_parse_text 把 %center_x% 替换成 540
atoi("540-78") = 540    // atoi 遇到 '-' 就停止解析
⇒ hh 和 mm 都渲染在正中心 540 ⇒ 叠在一起 + 正好压在挖孔下

placement 属性不支持算术式,必须用预计算的纯数字变量:

<variable name="clock_hh_x" value="462"/>   <!-- 540-78 -->
<variable name="clock_mm_x" value="618"/>   <!-- 540+78 -->

同时新增两个动态变量(data.cppGetMagicValue):

else if (varName == "tw_time_hh") {
    /* 小时制规则与 tw_time 完全一致(遵循 tw_military_time 设置) */
    sprintf(tmp, "%d", current->tm_hour % 12 == 0 ? 12 : current->tm_hour % 12);
}

5.3 其他 UI 修复

问题 根因 修复
解密界面导航栏偏上 data.cpp:810 用编译期宏 OF_SCREEN_H(默认1920) 覆盖主题 screen_h OF_SCREEN_H := 2351
电量/温度被 R 角裁切 OF_STATUS_INDENT_* 默认仅 20px := 64
封面图底部露出橙色条 图片 1080×1920 铺不满屏幕,露出填充色 生成 1220×2656 适配版

六、顺带修好的”启动图定制定不了”

用户改了启动图颜色、点了”应用”、提示更新成功,重启后纹丝不动。日志给出了现场:

I:operation_start: 'WLFW'
- Unpacking boot/recovery image - block device=          ← 空的!
cp: /tmp/orangefox/ramdisk/twres/splash.xml: No such file or directory
I:operation_start: 'WLFX'
- Repacking boot/recovery image ...
- Flashing repacked image ...                            ← 看起来"成功"

twrp-functions.cpp:3203 的分支选择是:

#if (defined(AB_OTA_UPDATER) || defined(FOX_AB_DEVICE)) && !defined(OF_AB_DEVICE_WITH_RECOVERY_PARTITION)
    tmpstr = Boot->Actual_Block_Device;
#else
    TWPartition *Recovery = PartitionManager.Find_Partition_By_Path("/recovery");
    tmpstr = Recovery->Actual_Block_Device;
#endif

两个问题叠加:

  1. 我先前从参考树抄了 AB_OTA_UPDATER := true,让代码误判为”无 recovery 分区”,去找 /boot——而 本机 fstab 里连 /boot/recovery 都没声明Find_Partition_By_Path() 双双返回 NULL。
  2. 结果 tmpstr 为空,解包必然失败,后续全在操作不存在的目录,定制被静默丢弃。

修复:给三份 fstab 补上 /recovery/boot 条目,并加 OF_AB_DEVICE_WITH_RECOVERY_PARTITION := 1 走正确分支。实测解包成功:

magiskboot unpack /dev/block/bootdevice/by-name/recovery_a
→ RAMDISK_FMT [lz4_legacy]
→ ramdisk.cpio 142 MB 解出 ✓(内含 twres/splash.xml 与 Splash/*.png)

七、把工程放进 CI

最后把这套东西做成了可复现的工程,推到 Gogs 与 GitHub,并写了 Actions 工作流。

关键认知修正:我一开始认为”GitHub Actions 跑不了 AOSP 构建”,这是错的。别人能跑,靠的是三点:

  1. 最小 manifest:OrangeFox 官方就有 twrp-default.xml(仅 36 个项目,而完整 AOSP 的 default.xml989 个)
  2. 浅克隆repo init --depth=1
  3. 清理 runner 预装工具链:删掉 .NET/SDK/Haskell 等,腾出约 30GB

于是工作流设计为:多设备矩阵 → 清磁盘 → 浅克隆最小 manifest → 从上游拉设备树 → 叠加本仓库的改动 → 应用 5 个补丁 → lunch → m recoveryimage → 上传产物。

仓库本身只有 1.9MB(只存改动,不存 97GB 源码树),并做了防上游删除处理:把 manifest 与设备树都 fork 到自己账号

首次运行的教训libncurses5 / lib32ncurses5-dev 在 Ubuntu 24.04 已被移除,改用 libncurses-dev 等等价包,并加了失败回退到最小依赖集合。

八、复盘:这次真正学到的东西

1. “参考”必须实测,不能信声明。 同机型参考树 README 写着”data decryption 稳定”,逐字照搬后依然全密文。它的价值在于排除了 fstab 这个方向,而不在于提供答案。

2. 编译期开关的破坏力被严重低估。 fscrypt_policy 的 v1/v2 结构体差异不会报任何错——编译通过、密钥装上、描述符匹配,但就是解不开。这类问题只能靠”对照可用参考的差异”来定位。

3. 用户的模糊记忆可能是最强线索。 “有一次能用,只是要手动挂载 storage”这句话,直接把方向从密钥正确性转到了挂载时机,而后者才是真正的根因。

4. 不要用”跳过”来优化。 我为了让日志安静,把 /data/user/ 加进跳过列表,结果少算 85.5GB。性能优化的前提是语义正确,跳过与 dontaudit 的区别就在这里:前者改变行为,后者只改变可观测性。

5. 属性里不能写算术式(TWRP 主题)。 atoi("540-78") == 540,这个坑让我多花了两轮构建,也贡献了本次最”隐蔽”的一个 bug。

6. 自己写的等待逻辑要审两遍。 我加的那个”最多等 20 秒”实际上能等 50 分钟——因为被等待对象的构造函数内部还有一层 30 秒轮询。当你要给某个调用加超时,先确认那个调用本身耗时多少。


附:最终状态

DE   /data/system_de/0 · /data/misc          → 明文
CE   /data/media/0 · /sdcard · /data/system_ce/0 → 明文
     真实 spblob(原始位置, 不依赖 DE 副本)     → [SP] src=REAL
性能 SELinux 拒绝 -87% / IHealth 轮询 -97% / keystore 等待上限 50min→10s
UI   等比缩放 · R角避让 · 分列时钟避开挖孔 · 封面图适配 · 中文默认

工程已开源(只含改动集,1.9MB):
https://github.com/serein-213/xiaomi17-ofox-pudding

113 次构建、35 小时、40 多次重启——换来的最大体会是:这类”玄学”问题从来不是玄学,只是证据还不够细。把每个假设都做成能跑的实验,答案会自己浮出来。