LOADING

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

HyperOS 4 增量 OTA 卡死排查:update_engine 源哈希校验 vs KernelSU

2026/9/5 0 技术 5.6k 字 · 约 20 分钟 Android 逆向 HyperOS KernelSU

设备:小米平板(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」三条路全部走不通:

  1. 校验在 update_engine 进程里——init 直接拉起的独立 native 服务,不是 zygote 派生,LSPosed/Zygisk 连注入通道都没有,更谈不上 hook;
  2. **改 payload 里的 src_sha256**——payload 有整包签名(内置公钥验证),改一个字节签名就翻车;
  3. 手改操作类型——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,这个流程就是标准操作。

附件