设备:小米平板(pudding,HyperOS 4 / Android 17),KernelSU + Zygisk Next + LSPosed
现象:0.24 → 0.25 小版本增量更新,下载完成、解压到 1% 即报「系统更新失败」
结果:根因定位到 update_engine 原生层的源哈希校验,绕过方案验证通过;顺带修复了 HyperCeiler「移除 OTA 校验」在 HyperOS 4 上的元数据消失问题
一、现象与第一现场
「移除 OTA 校验」模块开着,增量元数据正常出现、增量包下载完成,然后解压刚到 1% 就弹窗失败。logcat 里 Updater2: onInstallFailed -20,-20 对应 update_engine 的 kDownloadStateInitializationError。
往上翻 update_engine 自己的日志,真正的错误在这里:
E/update_engine: [ERROR:partition_writer.cc(427)] The hash of the source data
on disk for this operation doesn't match the expected value.
E/update_engine: Expected: sha256|hex = EB15A507F867…874936
E/update_engine: Calculated: sha256|hex = 3A3FF0F07258…96A773
E/update_engine: Operation source (offset:size) in blocks: 0:512
W/update_engine: Source hash mismatch on partition init_boot @ /dev/block/by-name/init_boot_a
E/update_engine: Failed to perform SOURCE_COPY operation 245, partition "init_boot"
(verified_source_fd.cc 里还有一条「Unable to open ECC source partition」是噪音——ECC 是 ChromiumOS 的机制,Android 上永远打不开,fallback 后照常读源,不是死因。)
二、第一个坑:blocks 的单位
0:512 的 512 是 payload 的块数,块大小是 4096 字节,不是 512。所以被校验的区域是 512 × 4096 = 2 MB,不是 256 KB。一开始按 256 KB 校验设备分区和镜像,怎么都对不上号;换成 2 MB 后全部命中:
# 设备 a 槽(被 KSU 改过)
head -c 2097152 /dev/block/by-name/init_boot_a | sha256sum
# 3a3ff0f0… ← 与 update_engine 的 Calculated 完全一致
# 官方 0.24 完整包 payload 提取的 init_boot.img
head -c 2097152 init_boot.img | sha256sum
# eb15a507… ← 与 Expected 完全一致
三、死结的完整链条
三层原因叠加:
1. A/B 增量包对「没变的分区」用 SOURCE_COPY 空复制,且强制验源
增量包(delta payload)对每个分区逐块决策:变了 → 带补丁数据;没变 → SOURCE_COPY 空复制(从旧分区原样复制,省体积)。关键是空复制同样要校验源哈希——update_engine 必须确认「源就是打包时的基线」才肯复制,这是写镜像的正确性保证,native 层硬编码,没有开关。
2. KernelSU 的补丁正好在 init_boot 的前 2 MB
KSU 的内核补丁打在 init_boot,被校验区域(前 2 MB)恰好是补丁区。源哈希 = 官方基线的假设被打破,EB15A5…(官方)vs 3A3FF0…(KSU 版),校验失败,整个 payload 处理终止。
3. HyperOS 4 早期版本每次小增量都带着 init_boot
逐块对比官方 0.24 与 0.25 的 init_boot:
block0 (0-2MB): 相同
block1 (2-4MB): 不同 ← 唯一变化(ramdisk 内某文件改了)
block2 (4-6MB): 相同
block3 (6-8MB): 相同
只要分区有一丁点变化,整分区进包;没变的块就用空复制表达。大版本刚出的时候 init/ramdisk 还在频繁改动,所以这段时间的每个小增量都会带上 init_boot,每次都会触发这个死结。
这同时解释了「以前为什么行」:HyperOS 3 末期 init 已经稳定,小增量只动 system/vendor,根本不碰 init_boot——增量包里没有这个分区的操作,就没有源哈希校验,KSU 改着也无所谓。以前「只用模块关掉 OTA 校验就行」成立;现在不是 hook 变弱了,是包开始带 init_boot 了。
四、为什么 Java hook 救不了
「拦截校验、伪造哈希、把 SOURCE_COPY 改成 REPLACE」三条路全部走不通:
- 校验在 update_engine 进程里——init 直接拉起的独立 native 服务,不是 zygote 派生,LSPosed/Zygisk 连注入通道都没有,更谈不上 hook;
- **改 payload 里的
src_sha256**——payload 有整包签名(内置公钥验证),改一个字节签名就翻车; - 手改操作类型——REPLACE 需要嵌入官方数据并重算整包哈希链,等于重打包增量包,还得过签名,工程上不现实。
五、解法:把「源」变回官方
既然死结是「源 ≠ 官方基线」,就把源恢复成官方。全程两分钟:
# 1. 官方 init_boot 来源:完整包 payload 提取(Payload Dumper),或 KernelSU Manager 的「还原原厂镜像」
dd if=/data/local/tmp/init_boot_official_024.img of=/dev/block/by-name/init_boot_a
# 2. 校验是否命中基线
head -c 2097152 /dev/block/by-name/init_boot_a | sha256sum # 应为 eb15a507…
# 3. 系统更新走增量 → 全程通过 → 装 KSU 到未激活槽(KernelSU Manager 一键)→ 重启
关键时机:update_engine 是边下载边应用的(流式处理,失败发生在下载开始后 3 秒),所以「还原原厂镜像」必须在点击下载之前完成,不能下载到一半再还原。
更新完成后新槽是官方系统,root 随 init_boot 一起没了,用 KernelSU Manager「安装到另一个槽位」把 KSU patch 到新槽,重启后 root 与 LSPosed 模块自动恢复。
六、顺带修了 HyperCeiler 的「移除 OTA 校验」
排查过程中发现该功能在 HyperOS 4 上有独立的 bug:support_ota_validate 这个标志位被 OTA 服务类在 <clinit>(静态初始化)里读取,用来决定增量更新的 URL/模式;旧实现全局强制返回 false,静态初始化拿到错误值,导致增量元数据直接消失。
修复方式:hook 时检查调用栈,栈里存在 <clinit> 就放行原值(保住静态初始化),离开 <clinit> 之后才返回 false(只关校验)。DexKit 定位不到时回退到 FeatureParser.hasFeature 的旧 hook。
提交:
fix(updater): support_ota_validate 静态初始化期间放行原值(HyperCeiler)
七、规律与预言
- 判定方法:直接试。增量过了 = 这次包没碰 init_boot,白赚;卡了 = 走两分钟流程,必过。
- 趋势:HyperOS 4 进入稳定期(init_boot 不再随小版本变化)后,增量包不再带 init_boot,又会回到「只用模块、不用刷回」的状态。现在的阵痛期是新大版本的特性,不是永久的。
- 本质:这不是校验开关问题,是「打包策略(空复制也验源)+ root 位置(补丁在 init_boot)」的物理矛盾。只要 KSU 还在 init_boot 上、ROM 还在改 init,这个流程就是标准操作。